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
8 changes: 6 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,12 @@ Each entry:
`activate(api)` receives: `api.fastify` (register routes here),
`api.prefix`, `api.config`, `api.log`, `api.auth.getAgent(request)`
(identity, as above), `api.storage.pluginDir()` (a private server-side
directory under the data root, never served over HTTP), and
`api.ws.route(path, (socket, request) => {})` for WebSocket endpoints —
directory under the data root, never served over HTTP),
`api.serverInfo()` → `{ baseUrl, protocol, host, port, listening }` (the
server's own origin, for minting absolute URLs and loopback calls — call
it lazily, e.g. per request: with `port: 0` the real port exists only once
the server is listening, and an explicit `idpIssuer` wins as `baseUrl`),
and `api.ws.route(path, (socket, request) => {})` for WebSocket endpoints —
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.
Expand Down
27 changes: 27 additions & 0 deletions src/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* api.log server logger
* api.auth.getAgent(req) -> agent id string | null (#584)
* api.storage.pluginDir() -> private server-side data dir for this plugin
* api.serverInfo() -> { baseUrl, protocol, host, port, listening } (#601)
* api.ws.route(path, (socket, request) => {}) (#588)
*
* The entry's `prefix` is added to appPaths automatically (#582), so the
Expand Down Expand Up @@ -152,6 +153,32 @@ export async function loadPlugins(fastify, entries, ctx) {
return dir;
},
},
// The server's own origin (#601) — for minting absolute URLs and
// loopback calls, so plugins stop repeating baseUrl in config where
// a wrong value fails quietly. A function, not a snapshot: with
// port 0 the real port exists only once the server is listening,
// so call it lazily (per request, or in an onListen hook via
// api.fastify) rather than caching the result during activate.
// An explicit idpIssuer is the deployment's canonical public origin
// and wins over the host:port derivation, mirroring the pod-seeding
// logic in server.js.
serverInfo() {
const o = ctx.origin ?? {};
const addr = fastify.server?.listening ? fastify.server.address() : null;
const live = addr && typeof addr === 'object' ? addr : null;
// Once listening, the live bind wins over configured values —
// listen() may be called with a different host/port than
// createServer() was given (tests do exactly this).
const port = live?.port ?? o.port ?? null;
const rawHost = live?.address ?? o.host;
// Unspecified binds aren't callable addresses; report the
// loopback name instead. IPv6 literals need brackets in URLs.
const host = !rawHost || rawHost === '0.0.0.0' || rawHost === '::' ? 'localhost' : rawHost;
const protocol = o.ssl ? 'https' : 'http';
const urlHost = host.includes(':') ? `[${host}]` : host;
const baseUrl = o.baseUrl || `${protocol}://${urlHost}:${port}`;
return { baseUrl, protocol, host, port, listening: !!live };
},
// 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
Expand Down
8 changes: 8 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,14 @@ export function createServer(options = {}) {
appPaths,
root: options.root || process.env.DATA_ROOT || './data',
log: fastify.log,
// api.serverInfo inputs (#601). ?? keeps an explicit port 0 —
// "resolved at listen" — instead of masking it with the default.
origin: {
ssl: !!options.ssl,
host: options.host,
port: options.port ?? defaults.port,
baseUrl: idpIssuer?.replace(/\/$/, '') || null,
},
});
});
}
Expand Down
110 changes: 110 additions & 0 deletions test/plugin-serverinfo.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* api.serverInfo (#601) — a plugin can learn the server's own origin
* instead of being told it via config (where a wrong value fails
* quietly). Lazy by design: with port 0 the real port exists only once
* the server is listening, so serverInfo() is a call, not a snapshot.
*/

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-serverinfo';
const FIXTURE_DIR = path.join(os.tmpdir(), 'jss-serverinfo-fixture');

// Captures serverInfo at activate time and serves the live value per
// request — the two moments the seam has to be honest about.
const FIXTURE = `
export async function activate(api) {
const atActivate = api.serverInfo();
api.fastify.get('/info-app/now', async () => ({
atActivate,
now: api.serverInfo(),
}));
}
`;

let server;
let baseUrl;
let originalDataRoot;

async function start(extraOptions = {}, listenHost) {
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: 'info', module: path.join(FIXTURE_DIR, 'plugin.js'), prefix: '/info-app' },
],
...extraOptions,
});
// Bind to the host under test — serverInfo must reflect the real bind,
// not just the configured value.
await server.listen({ port: 0, host: listenHost ?? extraOptions.host ?? '127.0.0.1' });
baseUrl = `http://127.0.0.1:${server.server.address().port}`;
}

describe('api.serverInfo (#601)', () => {
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('resolves the real port once listening, even when booted with port 0', async () => {
await start({ port: 0, host: '127.0.0.1' });
const boundPort = server.server.address().port;
const res = await fetch(`${baseUrl}/info-app/now`);
assert.strictEqual(res.status, 200);
const { atActivate, now } = await res.json();
assert.strictEqual(now.listening, true);
assert.strictEqual(now.port, boundPort);
assert.strictEqual(now.baseUrl, `http://127.0.0.1:${boundPort}`);
assert.strictEqual(now.protocol, 'http');
// At activate time the server wasn't listening yet; the configured
// port 0 is reported as-is rather than masked by a default.
assert.strictEqual(atActivate.listening, false);
assert.strictEqual(atActivate.port, 0);
});

it('0.0.0.0 binds report localhost as the callable host', async () => {
await start({ port: 0, host: '0.0.0.0' });
const boundPort = server.server.address().port;
const res = await fetch(`http://127.0.0.1:${boundPort}/info-app/now`);
const { now } = await res.json();
assert.strictEqual(now.host, 'localhost');
assert.strictEqual(now.baseUrl, `http://localhost:${boundPort}`);
});

it('the live bind wins over the configured host once listening', async () => {
// Configured 0.0.0.0 but actually bound to 127.0.0.1 — the live
// address is the one a caller can use, so it must win.
await start({ host: '0.0.0.0' }, '127.0.0.1');
const res = await fetch(`${baseUrl}/info-app/now`);
const { now } = await res.json();
assert.strictEqual(now.host, '127.0.0.1');
assert.strictEqual(now.baseUrl, `http://127.0.0.1:${server.server.address().port}`);
});

it('an explicit idpIssuer wins as the canonical public baseUrl', async () => {
await start({ port: 0, host: '127.0.0.1', idpIssuer: 'https://pods.example/' });
const res = await fetch(`${baseUrl}/info-app/now`);
const { now } = await res.json();
assert.strictEqual(now.baseUrl, 'https://pods.example');
// The live bind details stay available alongside the override.
assert.strictEqual(now.port, server.server.address().port);
});
});