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
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,10 @@ directory under the data root, never served over HTTP),
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`),
`api.plugins` → `[{ id, prefix, module }]` for every loaded entry (a
read-only, frozen boot-time snapshot, so a plugin can enumerate its
co-loaded siblings instead of being handed a copy of the operator's
plugins array — it includes the plugin itself, so consumers filter),
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
Expand Down
31 changes: 31 additions & 0 deletions src/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* api.storage.pluginDir() -> private server-side data dir for this plugin
* api.serverInfo() -> { baseUrl, protocol, host, port, listening } (#601)
* api.reservePath(path) claim + WAC-exempt a protocol-pinned path (#602)
* api.plugins -> [{ id, prefix, module }] of every loaded entry (#610)
* api.ws.route(path, (socket, request) => {}) (#588)
*
* The entry's `prefix` is added to appPaths automatically (#582), so the
Expand Down Expand Up @@ -162,6 +163,30 @@ export async function loadPlugins(fastify, entries, ctx) {
// names — loud beats the silent-loser outcome witnessed with webfinger
// vs remotestorage.
const reservations = new Map();

// Read-only roster of every loaded entry (#610): { id, prefix, module }
// for each. Computed up front, before any activate() runs, so a plugin
// whose job is describing the deployment (a status dashboard, an admin
// console) sees the FULL set regardless of load order — rather than being
// hand-fed a duplicate of the operator's plugins array that silently
// drifts. Frozen so one plugin can't mutate another's view; it includes
// the plugin itself (consumers filter). Lenient here (never throws) — the
// load loop below owns validation and fails the boot on a bad entry, so
// on any successful boot every id/prefix matches what the loop derives.
// A boot-time snapshot: there is no runtime add/remove yet.
const roster = Object.freeze(entries.map((entry) => {
const spec = typeof entry === 'string' ? { module: entry } : (entry ?? {});
// Only surface a well-formed module — a non-string/empty one makes the
// load loop below fail the boot, so don't fabricate a coerced value
// (e.g. "[object Object]") for the transient pre-failure window. Mirrors
// the loop's own `typeof spec.module === 'string'` gate; id is derived
// only from a valid module.
const module = typeof spec.module === 'string' && spec.module ? spec.module : null;
let id = null;
try { if (module) id = pluginId(spec); } catch { /* the loop reports it */ }
return Object.freeze({ id, prefix: normalizePrefix(spec.prefix), module });
}));

for (const entry of entries) {
const spec = typeof entry === 'string' ? { module: entry } : entry;
if (!spec || typeof spec.module !== 'string' || !spec.module) {
Expand Down Expand Up @@ -297,6 +322,12 @@ export async function loadPlugins(fastify, entries, ctx) {
const baseUrl = o.baseUrl || `${protocol}://${urlHost}:${port}`;
return { baseUrl, protocol, host, port, listening: !!live };
},
// Every loaded plugin's { id, prefix, module } (#610), read-only — so
// a plugin can enumerate its co-loaded siblings instead of being
// handed a copy of the operator's plugins array. Includes this
// plugin; consumers filter themselves out. A frozen boot-time
// snapshot (see `roster` above).
plugins: roster,
// 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
114 changes: 114 additions & 0 deletions test/plugin-roster.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* api.plugins (#610) — a plugin can enumerate its co-loaded siblings
* instead of being hand-fed a duplicate of the operator's plugins array
* (which silently drifts). A read-only, frozen boot-time snapshot of
* every entry's { id, prefix, module }, computed before any activate()
* runs so it is complete regardless of load order.
*/

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

// The FIRST plugin captures api.plugins at activate time and serves it.
// Because the roster is computed up front, it must already list the
// siblings that activate AFTER this one — the load-order-independence
// the seam exists to guarantee.
const REPORTER = `
export async function activate(api) {
const atActivate = api.plugins;
api.fastify.get('/a/roster', async () => ({
plugins: atActivate,
frozen: Object.isFrozen(atActivate),
entryFrozen: atActivate.length > 0 ? Object.isFrozen(atActivate[0]) : null,
self: atActivate.find((p) => p.id === 'roster-a') ?? null,
}));
}
`;

// A sibling that registers no routes — it only needs to exist in the roster.
const NOOP = `export async function activate() {}`;

let server;
let baseUrl;
let originalDataRoot;

async function start(plugins) {
await fs.emptyDir(TEST_DATA_DIR);
const { createServer } = await import('../src/server.js');
server = createServer({
logger: false,
forceCloseConnections: true,
root: TEST_DATA_DIR,
plugins,
});
await server.listen({ port: 0, host: '127.0.0.1' });
baseUrl = `http://127.0.0.1:${server.server.address().port}`;
}

describe('api.plugins (#610)', () => {
before(async () => {
originalDataRoot = process.env.DATA_ROOT;
await fs.emptyDir(FIXTURE_DIR);
await fs.writeFile(path.join(FIXTURE_DIR, 'reporter.js'), REPORTER);
await fs.writeFile(path.join(FIXTURE_DIR, 'noop.js'), NOOP);
});
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);
});

const reporter = path.join(FIXTURE_DIR, 'reporter.js');
const noop = path.join(FIXTURE_DIR, 'noop.js');

it('lists every loaded entry — including siblings that activate later', async () => {
await start([
{ id: 'roster-a', module: reporter, prefix: '/a' },
{ id: 'roster-b', module: noop, prefix: '/b' },
{ id: 'roster-c', module: noop }, // no prefix
]);
const res = await fetch(`${baseUrl}/a/roster`);
assert.strictEqual(res.status, 200);
const { plugins } = await res.json();

// The reporter is entry 0 yet sees b and c (activated after it) —
// the roster is complete up front, not accumulated during the loop.
assert.deepStrictEqual(plugins.map((p) => p.id), ['roster-a', 'roster-b', 'roster-c']);
const b = plugins.find((p) => p.id === 'roster-b');
assert.strictEqual(b.prefix, '/b');
assert.strictEqual(b.module, noop);
// A no-prefix entry reports '' (same normalization the loader applies).
assert.strictEqual(plugins.find((p) => p.id === 'roster-c').prefix, '');
});

it('includes the plugin itself', async () => {
await start([
{ id: 'roster-a', module: reporter, prefix: '/a' },
{ id: 'roster-b', module: noop, prefix: '/b' },
]);
const { self } = await (await fetch(`${baseUrl}/a/roster`)).json();
assert.ok(self, 'the roster includes the reporting plugin');
assert.strictEqual(self.prefix, '/a');
assert.strictEqual(self.module, reporter);
});

it('is a frozen, read-only snapshot at both levels', async () => {
await start([
{ id: 'roster-a', module: reporter, prefix: '/a' },
{ id: 'roster-b', module: noop, prefix: '/b' },
]);
const { frozen, entryFrozen } = await (await fetch(`${baseUrl}/a/roster`)).json();
assert.strictEqual(frozen, true, 'the array is frozen');
assert.strictEqual(entryFrozen, true, 'each entry object is frozen');
});
});