-
Notifications
You must be signed in to change notification settings - Fork 9
feat(plugins): api.mountApp for wrapped node-style apps (#583) #590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
83401a5
feat(plugins): api.mountApp for wrapped node-style apps (#583)
melvincarvalho 6b300e4
review: validate mountApp secondary prefix; guard handler after hijack
melvincarvalho 92ffb28
review: failure guards must not throw on non-Error throws
melvincarvalho ceb9e21
review: log { err } in failure guards so stacks survive
melvincarvalho File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -152,6 +152,62 @@ export async function loadPlugins(fastify, entries, ctx) { | |
| return dir; | ||
| }, | ||
| }, | ||
| // Mount a node-style (req, res) handler — a wrapped HTTP app, reverse | ||
| // proxy, or framework adapter — under the plugin's prefix (#583). This | ||
| // bundles the four things every such plugin needs and otherwise | ||
| // rediscovers: the appPaths WAC exemption (already applied above), a | ||
| // scoped pass-through content parser so the wrapped app receives an | ||
| // unconsumed body stream, reply.hijack() so Fastify releases the | ||
| // response, and registration on both the bare prefix and its subtree. | ||
| // Without the scoped parser, Fastify drains the request stream before | ||
| // the handler runs and any body-reading app hangs forever. | ||
| async mountApp(handler, opts = {}) { | ||
| if (typeof handler !== 'function') { | ||
| throw new Error(`plugin ${id}: mountApp(handler) needs a (req, res) function`); | ||
| } | ||
| // Any provided prefix must validate — same rule as entry.prefix: a | ||
| // falsy normalization silently falling back to the entry prefix | ||
| // would mount the app (and WAC-exempt it) somewhere unexpected. | ||
| const secondary = normalizePrefix(opts.prefix); | ||
| if (opts.prefix !== undefined && !secondary) { | ||
| throw new Error(`plugin ${id}: mountApp invalid prefix ${JSON.stringify(opts.prefix)} (must start with '/'; omit to use the entry prefix)`); | ||
| } | ||
| const mountPrefix = secondary || prefix; | ||
| if (!mountPrefix) { | ||
| throw new Error(`plugin ${id}: mountApp needs a prefix (entry.prefix or opts.prefix)`); | ||
| } | ||
| if (mountPrefix !== prefix && !ctx.appPaths.includes(mountPrefix)) { | ||
| ctx.appPaths.push(mountPrefix); // exempt a secondary mount too | ||
| } | ||
| await fastify.register(async (scope) => { | ||
| scope.removeAllContentTypeParsers(); | ||
| scope.addContentTypeParser('*', (req, payload, done) => done(null, payload)); | ||
| // After hijack() Fastify sends nothing, so a handler bug must not | ||
| // hang the client or become an unhandled rejection (same contract | ||
| // as ws.route below): log, answer 500 if nothing went out yet, | ||
| // else drop the one affected socket. | ||
| const fail = (res, err) => { | ||
| log.error({ err }, `plugin ${id}: mounted app handler failed`); | ||
| if (!res.headersSent && !res.writableEnded) { | ||
| res.statusCode = 500; | ||
| res.end(); | ||
| } else { | ||
| res.destroy(); | ||
| } | ||
| }; | ||
| const wrapped = (request, reply) => { | ||
| reply.hijack(); | ||
| try { | ||
| Promise.resolve(handler(request.raw, reply.raw)) | ||
| .catch((err) => fail(reply.raw, err)); | ||
| } catch (err) { | ||
| fail(reply.raw, err); | ||
| } | ||
| }; | ||
| scope.all(mountPrefix, wrapped); | ||
| scope.all(mountPrefix + '/*', wrapped); | ||
| }); | ||
| }, | ||
| ws: { | ||
| async route(wsPath, handler) { | ||
| if (typeof wsPath !== 'string' || !wsPath.startsWith('/')) { | ||
|
|
@@ -168,11 +224,11 @@ export async function loadPlugins(fastify, entries, ctx) { | |
| // takes the host down: log it and close the one affected socket. | ||
| try { | ||
| Promise.resolve(handler(socket, request)).catch((err) => { | ||
| log.error(`plugin ${id}: ws handler failed: ${err.message}`); | ||
| log.error({ err }, `plugin ${id}: ws handler failed`); | ||
| socket.terminate?.(); | ||
| }); | ||
| } catch (err) { | ||
| log.error(`plugin ${id}: ws handler failed: ${err.message}`); | ||
| log.error({ err }, `plugin ${id}: ws handler failed`); | ||
| socket.terminate?.(); | ||
|
Comment on lines
230
to
232
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in ceb9e21 — all three guard sites now log { err } per house style; stacks survive, non-Error throws still handled (this also retired the errMessage helper). 1024/1024. |
||
| } | ||
| }); | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| /** | ||
| * api.mountApp (#583) — a plugin mounting a node-style (req, res) handler | ||
| * receives unconsumed request bodies, the exact shape the Tideholm and | ||
| * bridge adapters hand-rolled. Verifies the scoped pass-through parser lets | ||
| * a body-reading app work, host parsing stays intact outside the mount, and | ||
| * a secondary mount prefix is WAC-exempted too. | ||
| */ | ||
|
|
||
| import { describe, it, before, after, afterEach } from 'node:test'; | ||
| import assert from 'node:assert'; | ||
| import fs from 'fs-extra'; | ||
| import path from 'path'; | ||
| import os from 'os'; | ||
|
|
||
| const TEST_DATA_DIR = './test-data-mountapp'; | ||
| const FIXTURE_DIR = path.join(os.tmpdir(), 'jss-mountapp-fixture'); | ||
|
|
||
| // A plugin that mounts a plain node handler which reads the whole body and | ||
| // echoes it back — the app that hangs if Fastify drained the stream first. | ||
| const FIXTURE = ` | ||
| export async function activate(api) { | ||
| await api.mountApp((req, res) => { | ||
| let body = ''; | ||
| req.on('data', (c) => { body += c; }); | ||
| req.on('end', () => { | ||
| res.writeHead(200, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify({ echoed: body, method: req.method, url: req.url })); | ||
| }); | ||
| }); | ||
| // A second mount under a different prefix, to prove secondary exemption. | ||
| await api.mountApp((req, res) => { | ||
| res.writeHead(200, { 'content-type': 'text/plain' }); | ||
| res.end('secondary'); | ||
| }, { prefix: '/wrapped2' }); | ||
| // Handlers that fail — sync throw and async rejection — to prove a | ||
| // handler bug after hijack() answers 500 instead of hanging the client. | ||
| await api.mountApp(() => { throw new Error('sync boom'); }, { prefix: '/boom' }); | ||
| await api.mountApp(async () => { throw new Error('async boom'); }, { prefix: '/boom-async' }); | ||
| // Non-Error throw: the failure guard itself must not throw on err.message. | ||
| await api.mountApp(() => { throw 'string boom'; }, { prefix: '/boom-raw' }); | ||
| await api.mountApp(async () => Promise.reject(undefined), { prefix: '/boom-undef' }); | ||
| } | ||
| `; | ||
|
|
||
| // A plugin whose secondary mount prefix is invalid — must fail the boot. | ||
| const BAD_PREFIX_FIXTURE = ` | ||
| export async function activate(api) { | ||
| await api.mountApp((req, res) => res.end('x'), { prefix: 'chat' }); | ||
| } | ||
| `; | ||
|
|
||
| let server; | ||
| let baseUrl; | ||
| let originalDataRoot; | ||
|
|
||
| async function start() { | ||
| await fs.emptyDir(TEST_DATA_DIR); | ||
| const { createServer } = await import('../src/server.js'); | ||
| server = createServer({ | ||
| logger: false, | ||
| forceCloseConnections: true, | ||
| root: TEST_DATA_DIR, | ||
| plugins: [ | ||
| { id: 'wrapped', module: path.join(FIXTURE_DIR, 'plugin.js'), prefix: '/wrapped' }, | ||
| ], | ||
| }); | ||
| await server.listen({ port: 0, host: '127.0.0.1' }); | ||
| baseUrl = `http://127.0.0.1:${server.server.address().port}`; | ||
| } | ||
|
|
||
| describe('api.mountApp (#583)', () => { | ||
| before(async () => { | ||
| originalDataRoot = process.env.DATA_ROOT; | ||
| await fs.emptyDir(FIXTURE_DIR); | ||
| await fs.writeFile(path.join(FIXTURE_DIR, 'plugin.js'), FIXTURE); | ||
| }); | ||
| after(async () => { | ||
| await fs.remove(FIXTURE_DIR); | ||
| if (originalDataRoot === undefined) delete process.env.DATA_ROOT; | ||
| else process.env.DATA_ROOT = originalDataRoot; | ||
| }); | ||
| afterEach(async () => { | ||
| if (server) { await server.close(); server = null; } | ||
| await fs.remove(TEST_DATA_DIR); | ||
| }); | ||
|
|
||
| it('a JSON POST body round-trips through the wrapped node handler', async () => { | ||
| await start(); | ||
| const res = await fetch(`${baseUrl}/wrapped/api/thing`, { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ hello: 'world' }), | ||
| }); | ||
| assert.strictEqual(res.status, 200); | ||
| const body = await res.json(); | ||
| assert.strictEqual(body.echoed, JSON.stringify({ hello: 'world' })); | ||
| assert.strictEqual(body.method, 'POST'); | ||
| }); | ||
|
|
||
| it('serves the bare prefix and the subtree, unauthenticated (WAC-exempt)', async () => { | ||
| await start(); | ||
| const bare = await fetch(`${baseUrl}/wrapped`, { method: 'GET' }); | ||
| assert.strictEqual(bare.status, 200); | ||
| const deep = await fetch(`${baseUrl}/wrapped/a/b/c`, { method: 'GET' }); | ||
| assert.strictEqual(deep.status, 200); | ||
| }); | ||
|
|
||
| it('a secondary mount prefix is also served and WAC-exempt', async () => { | ||
| await start(); | ||
| const res = await fetch(`${baseUrl}/wrapped2/anything`); | ||
| assert.strictEqual(res.status, 200); | ||
| assert.strictEqual(await res.text(), 'secondary'); | ||
| }); | ||
|
|
||
| it('host body parsing is unaffected outside the mount (LDP PUT still WAC-guarded)', async () => { | ||
| await start(); | ||
| const res = await fetch(`${baseUrl}/somepod/private/x`, { method: 'PUT', body: 'data' }); | ||
| assert.ok([401, 403].includes(res.status), `expected WAC rejection, got ${res.status}`); | ||
| }); | ||
|
|
||
| it('a handler that throws sync answers 500 and the server survives', async () => { | ||
| await start(); | ||
| const res = await fetch(`${baseUrl}/boom`); | ||
| assert.strictEqual(res.status, 500); | ||
| // Process and server still alive: a healthy mount keeps answering. | ||
| const ok = await fetch(`${baseUrl}/wrapped2/still-up`); | ||
| assert.strictEqual(ok.status, 200); | ||
| }); | ||
|
|
||
| it('a handler that rejects async answers 500 instead of leaking the rejection', async () => { | ||
| await start(); | ||
| const res = await fetch(`${baseUrl}/boom-async`); | ||
| assert.strictEqual(res.status, 500); | ||
| const ok = await fetch(`${baseUrl}/wrapped2/still-up`); | ||
| assert.strictEqual(ok.status, 200); | ||
| }); | ||
|
|
||
| it('a handler that throws a non-Error still answers 500 (guard must not throw on err.message)', async () => { | ||
| await start(); | ||
| const raw = await fetch(`${baseUrl}/boom-raw`); | ||
| assert.strictEqual(raw.status, 500); | ||
| const undef = await fetch(`${baseUrl}/boom-undef`); | ||
| assert.strictEqual(undef.status, 500); | ||
| const ok = await fetch(`${baseUrl}/wrapped2/still-up`); | ||
| assert.strictEqual(ok.status, 200); | ||
| }); | ||
|
|
||
| it('a provided-but-invalid secondary prefix fails the boot instead of mounting at the entry prefix', async () => { | ||
| await fs.writeFile(path.join(FIXTURE_DIR, 'bad-prefix.js'), BAD_PREFIX_FIXTURE); | ||
| await fs.emptyDir(TEST_DATA_DIR); | ||
| const { createServer } = await import('../src/server.js'); | ||
| server = createServer({ | ||
| logger: false, | ||
| forceCloseConnections: true, | ||
| root: TEST_DATA_DIR, | ||
| plugins: [ | ||
| { id: 'bad', module: path.join(FIXTURE_DIR, 'bad-prefix.js'), prefix: '/ok' }, | ||
| ], | ||
| }); | ||
| await assert.rejects( | ||
| server.listen({ port: 0, host: '127.0.0.1' }), | ||
| /invalid prefix "chat"/, | ||
| ); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in ceb9e21 — all three guard sites now log { err } per house style; stacks survive, non-Error throws still handled (this also retired the errMessage helper). 1024/1024.