Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
60 changes: 58 additions & 2 deletions src/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('/')) {
Expand All @@ -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?.();
Comment on lines 226 to 228

Copy link
Copy Markdown
Contributor Author

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.

});
} 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

Copy link
Copy Markdown
Contributor Author

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.

}
});
Expand Down
165 changes: 165 additions & 0 deletions test/plugin-mountapp.test.js
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"/,
);
});
});