Skip to content

Commit 83401a5

Browse files
feat(plugins): api.mountApp for wrapped node-style apps (#583)
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.
1 parent 7021ab3 commit 83401a5

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

docs/configuration.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,25 @@ routed through the same upgrade path as the built-in realtime features, so
430430
plugins never attach their own `'upgrade'` listener. Return
431431
`{ deactivate }` to run teardown (state saves, timers) on server close.
432432

433+
To mount an **existing node-style app** — a `(req, res)` handler, a reverse
434+
proxy, or a framework adapter — use `api.mountApp(handler, { prefix })`:
435+
436+
```js
437+
export async function activate(api) {
438+
const app = createMyNodeApp();
439+
await api.mountApp((req, res) => app.handle(req, res));
440+
}
441+
```
442+
443+
`mountApp` bundles the four things a wrapped-app plugin needs and would
444+
otherwise rediscover: the appPaths WAC exemption, a **scoped pass-through
445+
content parser** (so the wrapped app receives an unconsumed body stream
446+
instead of one Fastify already drained — the failure that hangs any
447+
body-reading app), `reply.hijack()` so Fastify releases the response, and
448+
registration on both the bare prefix and its subtree. It defaults to the
449+
entry's `prefix`; pass `{ prefix }` to mount a second app elsewhere (that
450+
prefix is WAC-exempted too).
451+
433452
A plugin that fails to import or activate fails `listen()` loudly rather
434453
than booting a server silently missing an app.
435454

src/plugins.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,37 @@ export async function loadPlugins(fastify, entries, ctx) {
152152
return dir;
153153
},
154154
},
155+
// Mount a node-style (req, res) handler — a wrapped HTTP app, reverse
156+
// proxy, or framework adapter — under the plugin's prefix (#583). This
157+
// bundles the four things every such plugin needs and otherwise
158+
// rediscovers: the appPaths WAC exemption (already applied above), a
159+
// scoped pass-through content parser so the wrapped app receives an
160+
// unconsumed body stream, reply.hijack() so Fastify releases the
161+
// response, and registration on both the bare prefix and its subtree.
162+
// Without the scoped parser, Fastify drains the request stream before
163+
// the handler runs and any body-reading app hangs forever.
164+
async mountApp(handler, opts = {}) {
165+
if (typeof handler !== 'function') {
166+
throw new Error(`plugin ${id}: mountApp(handler) needs a (req, res) function`);
167+
}
168+
const mountPrefix = normalizePrefix(opts.prefix) || prefix;
169+
if (!mountPrefix) {
170+
throw new Error(`plugin ${id}: mountApp needs a prefix (entry.prefix or opts.prefix)`);
171+
}
172+
if (mountPrefix !== prefix && !ctx.appPaths.includes(mountPrefix)) {
173+
ctx.appPaths.push(mountPrefix); // exempt a secondary mount too
174+
}
175+
await fastify.register(async (scope) => {
176+
scope.removeAllContentTypeParsers();
177+
scope.addContentTypeParser('*', (req, payload, done) => done(null, payload));
178+
const wrapped = (request, reply) => {
179+
reply.hijack();
180+
handler(request.raw, reply.raw);
181+
};
182+
scope.all(mountPrefix, wrapped);
183+
scope.all(mountPrefix + '/*', wrapped);
184+
});
185+
},
155186
ws: {
156187
async route(wsPath, handler) {
157188
if (typeof wsPath !== 'string' || !wsPath.startsWith('/')) {

test/plugin-mountapp.test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* api.mountApp (#583) — a plugin mounting a node-style (req, res) handler
3+
* receives unconsumed request bodies, the exact shape the Tideholm and
4+
* bridge adapters hand-rolled. Verifies the scoped pass-through parser lets
5+
* a body-reading app work, host parsing stays intact outside the mount, and
6+
* a secondary mount prefix is WAC-exempted too.
7+
*/
8+
9+
import { describe, it, before, after, afterEach } from 'node:test';
10+
import assert from 'node:assert';
11+
import fs from 'fs-extra';
12+
import path from 'path';
13+
import os from 'os';
14+
15+
const TEST_DATA_DIR = './test-data-mountapp';
16+
const FIXTURE_DIR = path.join(os.tmpdir(), 'jss-mountapp-fixture');
17+
18+
// A plugin that mounts a plain node handler which reads the whole body and
19+
// echoes it back — the app that hangs if Fastify drained the stream first.
20+
const FIXTURE = `
21+
export async function activate(api) {
22+
await api.mountApp((req, res) => {
23+
let body = '';
24+
req.on('data', (c) => { body += c; });
25+
req.on('end', () => {
26+
res.writeHead(200, { 'content-type': 'application/json' });
27+
res.end(JSON.stringify({ echoed: body, method: req.method, url: req.url }));
28+
});
29+
});
30+
// A second mount under a different prefix, to prove secondary exemption.
31+
await api.mountApp((req, res) => {
32+
res.writeHead(200, { 'content-type': 'text/plain' });
33+
res.end('secondary');
34+
}, { prefix: '/wrapped2' });
35+
}
36+
`;
37+
38+
let server;
39+
let baseUrl;
40+
let originalDataRoot;
41+
42+
async function start() {
43+
await fs.emptyDir(TEST_DATA_DIR);
44+
const { createServer } = await import('../src/server.js');
45+
server = createServer({
46+
logger: false,
47+
forceCloseConnections: true,
48+
root: TEST_DATA_DIR,
49+
plugins: [
50+
{ id: 'wrapped', module: path.join(FIXTURE_DIR, 'plugin.js'), prefix: '/wrapped' },
51+
],
52+
});
53+
await server.listen({ port: 0, host: '127.0.0.1' });
54+
baseUrl = `http://127.0.0.1:${server.server.address().port}`;
55+
}
56+
57+
describe('api.mountApp (#583)', () => {
58+
before(async () => {
59+
originalDataRoot = process.env.DATA_ROOT;
60+
await fs.emptyDir(FIXTURE_DIR);
61+
await fs.writeFile(path.join(FIXTURE_DIR, 'plugin.js'), FIXTURE);
62+
});
63+
after(async () => {
64+
await fs.remove(FIXTURE_DIR);
65+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
66+
else process.env.DATA_ROOT = originalDataRoot;
67+
});
68+
afterEach(async () => {
69+
if (server) { await server.close(); server = null; }
70+
await fs.remove(TEST_DATA_DIR);
71+
});
72+
73+
it('a JSON POST body round-trips through the wrapped node handler', async () => {
74+
await start();
75+
const res = await fetch(`${baseUrl}/wrapped/api/thing`, {
76+
method: 'POST',
77+
headers: { 'content-type': 'application/json' },
78+
body: JSON.stringify({ hello: 'world' }),
79+
});
80+
assert.strictEqual(res.status, 200);
81+
const body = await res.json();
82+
assert.strictEqual(body.echoed, JSON.stringify({ hello: 'world' }));
83+
assert.strictEqual(body.method, 'POST');
84+
});
85+
86+
it('serves the bare prefix and the subtree, unauthenticated (WAC-exempt)', async () => {
87+
await start();
88+
const bare = await fetch(`${baseUrl}/wrapped`, { method: 'GET' });
89+
assert.strictEqual(bare.status, 200);
90+
const deep = await fetch(`${baseUrl}/wrapped/a/b/c`, { method: 'GET' });
91+
assert.strictEqual(deep.status, 200);
92+
});
93+
94+
it('a secondary mount prefix is also served and WAC-exempt', async () => {
95+
await start();
96+
const res = await fetch(`${baseUrl}/wrapped2/anything`);
97+
assert.strictEqual(res.status, 200);
98+
assert.strictEqual(await res.text(), 'secondary');
99+
});
100+
101+
it('host body parsing is unaffected outside the mount (LDP PUT still WAC-guarded)', async () => {
102+
await start();
103+
const res = await fetch(`${baseUrl}/somepod/private/x`, { method: 'PUT', body: 'data' });
104+
assert.ok([401, 403].includes(res.status), `expected WAC rejection, got ${res.status}`);
105+
});
106+
});

0 commit comments

Comments
 (0)