From 83401a50aaad38c955cd57b0b5ac52e915573a8a Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 11 Jul 2026 01:47:01 +0200 Subject: [PATCH 1/4] feat(plugins): api.mountApp for wrapped node-style apps (#583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second seam #206 forced (after appPaths and getAgent): a plugin that hands requests to an existing (req, res) handler — a wrapped HTTP app, reverse proxy, or framework adapter — hangs, because Fastify's content parsers drain the request stream before the handler runs. The Tideholm and bridge adapters each hand-rolled the same scoped-parser + hijack incantation to work around it. api.mountApp(handler, { prefix }) bundles the four things every such plugin needs: the appPaths WAC exemption, a scoped pass-through content parser (unconsumed body stream), reply.hijack(), and registration on the bare prefix plus its subtree. Defaults to the entry's prefix; an explicit prefix mounts and exempts a second app. Tests: the exact Tideholm case (JSON POST round-trips through a node handler), bare + subtree serving, secondary mount, and host parsing intact outside the mount. docs/configuration.md updated. --- docs/configuration.md | 19 +++++++ src/plugins.js | 31 ++++++++++ test/plugin-mountapp.test.js | 106 +++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 test/plugin-mountapp.test.js diff --git a/docs/configuration.md b/docs/configuration.md index 8928b91..ea140c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -430,6 +430,25 @@ routed through the same upgrade path as the built-in realtime features, so plugins never attach their own `'upgrade'` listener. Return `{ deactivate }` to run teardown (state saves, timers) on server close. +To mount an **existing node-style app** — a `(req, res)` handler, a reverse +proxy, or a framework adapter — use `api.mountApp(handler, { prefix })`: + +```js +export async function activate(api) { + const app = createMyNodeApp(); + await api.mountApp((req, res) => app.handle(req, res)); +} +``` + +`mountApp` bundles the four things a wrapped-app plugin needs and would +otherwise rediscover: the appPaths WAC exemption, a **scoped pass-through +content parser** (so the wrapped app receives an unconsumed body stream +instead of one Fastify already drained — the failure that hangs any +body-reading app), `reply.hijack()` so Fastify releases the response, and +registration on both the bare prefix and its subtree. It defaults to the +entry's `prefix`; pass `{ prefix }` to mount a second app elsewhere (that +prefix is WAC-exempted too). + A plugin that fails to import or activate fails `listen()` loudly rather than booting a server silently missing an app. diff --git a/src/plugins.js b/src/plugins.js index 04e16b8..4100cdb 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -152,6 +152,37 @@ 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`); + } + const mountPrefix = normalizePrefix(opts.prefix) || 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)); + const wrapped = (request, reply) => { + reply.hijack(); + handler(request.raw, reply.raw); + }; + scope.all(mountPrefix, wrapped); + scope.all(mountPrefix + '/*', wrapped); + }); + }, ws: { async route(wsPath, handler) { if (typeof wsPath !== 'string' || !wsPath.startsWith('/')) { diff --git a/test/plugin-mountapp.test.js b/test/plugin-mountapp.test.js new file mode 100644 index 0000000..b96233f --- /dev/null +++ b/test/plugin-mountapp.test.js @@ -0,0 +1,106 @@ +/** + * 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' }); +} +`; + +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}`); + }); +}); From 6b300e4c0728581d7911bc47276c370395c430c3 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 11 Jul 2026 10:20:13 +0200 Subject: [PATCH 2/4] review: validate mountApp secondary prefix; guard handler after hijack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a provided-but-invalid opts.prefix now fails activate() (same rule as entry.prefix) instead of silently mounting — and WAC-exempting — the app at the entry prefix - the wrapped handler is guarded like ws.route: after hijack() Fastify sends nothing, so a sync throw hung the client and an unawaited async rejection could take the process down; both now log and answer 500 when nothing has gone out, else drop the one affected socket Three regression tests; full suite 1023/1023. --- src/plugins.js | 29 +++++++++++++++++++++-- test/plugin-mountapp.test.js | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/plugins.js b/src/plugins.js index 4100cdb..fccea73 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -165,7 +165,14 @@ export async function loadPlugins(fastify, entries, ctx) { if (typeof handler !== 'function') { throw new Error(`plugin ${id}: mountApp(handler) needs a (req, res) function`); } - const mountPrefix = normalizePrefix(opts.prefix) || prefix; + // 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)`); } @@ -175,9 +182,27 @@ export async function loadPlugins(fastify, entries, ctx) { 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(`plugin ${id}: mounted app handler failed: ${err.message}`); + if (!res.headersSent && !res.writableEnded) { + res.statusCode = 500; + res.end(); + } else { + res.destroy(); + } + }; const wrapped = (request, reply) => { reply.hijack(); - handler(request.raw, reply.raw); + 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); diff --git a/test/plugin-mountapp.test.js b/test/plugin-mountapp.test.js index b96233f..d8a19d3 100644 --- a/test/plugin-mountapp.test.js +++ b/test/plugin-mountapp.test.js @@ -32,6 +32,17 @@ export async function activate(api) { 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' }); +} +`; + +// 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' }); } `; @@ -103,4 +114,39 @@ describe('api.mountApp (#583)', () => { 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 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"/, + ); + }); }); From 92ffb2835c4c941514f39345d5ebc2647860390e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 11 Jul 2026 10:40:23 +0200 Subject: [PATCH 3/4] review: failure guards must not throw on non-Error throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit err.message on a thrown string or undefined made fail() itself throw inside the catch — recreating the unhandled rejection the guard exists to prevent. errMessage() normalizes whatever a plugin threw; applied to mountApp's fail() and both ws.route guards, which shipped with the same latent bug. Regression test: string throw and undefined rejection both answer 500 with the server still up. Full suite 1024/1024. --- src/plugins.js | 15 ++++++++++++--- test/plugin-mountapp.test.js | 13 +++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/plugins.js b/src/plugins.js index fccea73..cd76c37 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -60,6 +60,15 @@ export function makePluginLog(base) { return { log: call('info'), info: call('info'), warn: call('warn'), error: call('error'), debug: call('debug') }; } +/** + * A loggable message from whatever a plugin threw — handlers can throw + * non-Errors (strings, undefined), and the failure guards must never + * throw themselves while reporting one. + */ +export function errMessage(err) { + return err instanceof Error ? err.message : String(err); +} + /** Same normalization appPaths applies: no trailing slash, must be '/x…'. */ export function normalizePrefix(p) { if (typeof p !== 'string') return ''; @@ -187,7 +196,7 @@ export async function loadPlugins(fastify, entries, ctx) { // as ws.route below): log, answer 500 if nothing went out yet, // else drop the one affected socket. const fail = (res, err) => { - log.error(`plugin ${id}: mounted app handler failed: ${err.message}`); + log.error(`plugin ${id}: mounted app handler failed: ${errMessage(err)}`); if (!res.headersSent && !res.writableEnded) { res.statusCode = 500; res.end(); @@ -224,11 +233,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(`plugin ${id}: ws handler failed: ${errMessage(err)}`); socket.terminate?.(); }); } catch (err) { - log.error(`plugin ${id}: ws handler failed: ${err.message}`); + log.error(`plugin ${id}: ws handler failed: ${errMessage(err)}`); socket.terminate?.(); } }); diff --git a/test/plugin-mountapp.test.js b/test/plugin-mountapp.test.js index d8a19d3..d6e5d97 100644 --- a/test/plugin-mountapp.test.js +++ b/test/plugin-mountapp.test.js @@ -36,6 +36,9 @@ export async function activate(api) { // 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' }); } `; @@ -132,6 +135,16 @@ describe('api.mountApp (#583)', () => { 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); From ceb9e211a13a1d0ac303282e6988465ebf458b0f Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 11 Jul 2026 10:52:58 +0200 Subject: [PATCH 4/4] review: log { err } in failure guards so stacks survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit String interpolation dropped Error stack traces; { err } is the house idiom (src/server.js:1127), pino serializes it with stack and console prints it whole, and it handles non-Error throws without touching .message — which also retires the errMessage() helper from the previous round. All three guard sites (mountApp fail, both ws.route paths). Suite 1024/1024; non-Error regression tests unchanged and passing. --- src/plugins.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/plugins.js b/src/plugins.js index cd76c37..6d76a73 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -60,15 +60,6 @@ export function makePluginLog(base) { return { log: call('info'), info: call('info'), warn: call('warn'), error: call('error'), debug: call('debug') }; } -/** - * A loggable message from whatever a plugin threw — handlers can throw - * non-Errors (strings, undefined), and the failure guards must never - * throw themselves while reporting one. - */ -export function errMessage(err) { - return err instanceof Error ? err.message : String(err); -} - /** Same normalization appPaths applies: no trailing slash, must be '/x…'. */ export function normalizePrefix(p) { if (typeof p !== 'string') return ''; @@ -196,7 +187,7 @@ export async function loadPlugins(fastify, entries, ctx) { // as ws.route below): log, answer 500 if nothing went out yet, // else drop the one affected socket. const fail = (res, err) => { - log.error(`plugin ${id}: mounted app handler failed: ${errMessage(err)}`); + log.error({ err }, `plugin ${id}: mounted app handler failed`); if (!res.headersSent && !res.writableEnded) { res.statusCode = 500; res.end(); @@ -233,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: ${errMessage(err)}`); + log.error({ err }, `plugin ${id}: ws handler failed`); socket.terminate?.(); }); } catch (err) { - log.error(`plugin ${id}: ws handler failed: ${errMessage(err)}`); + log.error({ err }, `plugin ${id}: ws handler failed`); socket.terminate?.(); } });