Skip to content

Commit e789e27

Browse files
feat(plugins): api.plugins — the loaded-plugin roster (#610)
A plugin whose job is describing the deployment (a status dashboard, an admin console) can't enumerate its co-loaded siblings — the loader holds the entry list but exposes none of it. So the operator hand-feeds such a plugin a duplicate of the createServer plugins array, and the two silently drift (an added plugin never appears; a removed one keeps being probed and the default '<500 = alive' rule reports it healthy). api.plugins -> [{ id, prefix, module }], read-only. A frozen boot-time snapshot computed BEFORE any activate() runs, so it is complete regardless of load order (the reporting plugin sees siblings that activate after it). Includes the plugin itself; consumers filter. It reuses the loader's own pluginId/normalizePrefix, so on any successful boot every id/prefix matches what the load loop derives; the roster computation is lenient and never throws — the loop still owns validation. Distinct from the apps-as-pod-resources vision (#463/#464) and the marketplace (#184/#194/#200): this is the minimal loader-introspection primitive those could build on, not an install/registry surface. Three tests: load-order-independent completeness, includes-self, frozen at both levels. Full suite 1038/1038.
1 parent 9dadbc9 commit e789e27

3 files changed

Lines changed: 147 additions & 0 deletions

File tree

docs/configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@ directory under the data root, never served over HTTP),
429429
server's own origin, for minting absolute URLs and loopback calls — call
430430
it lazily, e.g. per request: with `port: 0` the real port exists only once
431431
the server is listening, and an explicit `idpIssuer` wins as `baseUrl`),
432+
`api.plugins``[{ id, prefix, module }]` for every loaded entry (a
433+
read-only, frozen boot-time snapshot, so a plugin can enumerate its
434+
co-loaded siblings instead of being handed a copy of the operator's
435+
plugins array — it includes the plugin itself, so consumers filter),
432436
and `api.ws.route(path, (socket, request) => {})` for WebSocket endpoints —
433437
routed through the same upgrade path as the built-in realtime features, so
434438
plugins never attach their own `'upgrade'` listener. Return

src/plugins.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* api.storage.pluginDir() -> private server-side data dir for this plugin
2222
* api.serverInfo() -> { baseUrl, protocol, host, port, listening } (#601)
2323
* api.reservePath(path) claim + WAC-exempt a protocol-pinned path (#602)
24+
* api.plugins -> [{ id, prefix, module }] of every loaded entry (#610)
2425
* api.ws.route(path, (socket, request) => {}) (#588)
2526
*
2627
* The entry's `prefix` is added to appPaths automatically (#582), so the
@@ -162,6 +163,28 @@ export async function loadPlugins(fastify, entries, ctx) {
162163
// names — loud beats the silent-loser outcome witnessed with webfinger
163164
// vs remotestorage.
164165
const reservations = new Map();
166+
167+
// Read-only roster of every loaded entry (#610): { id, prefix, module }
168+
// for each. Computed up front, before any activate() runs, so a plugin
169+
// whose job is describing the deployment (a status dashboard, an admin
170+
// console) sees the FULL set regardless of load order — rather than being
171+
// hand-fed a duplicate of the operator's plugins array that silently
172+
// drifts. Frozen so one plugin can't mutate another's view; it includes
173+
// the plugin itself (consumers filter). Lenient here (never throws) — the
174+
// load loop below owns validation and fails the boot on a bad entry, so
175+
// on any successful boot every id/prefix matches what the loop derives.
176+
// A boot-time snapshot: there is no runtime add/remove yet.
177+
const roster = Object.freeze(entries.map((entry) => {
178+
const spec = typeof entry === 'string' ? { module: entry } : (entry ?? {});
179+
let id = null;
180+
try { if (spec.module) id = pluginId(spec); } catch { /* the loop reports it */ }
181+
return Object.freeze({
182+
id,
183+
prefix: normalizePrefix(spec.prefix),
184+
module: spec.module ? String(spec.module) : null,
185+
});
186+
}));
187+
165188
for (const entry of entries) {
166189
const spec = typeof entry === 'string' ? { module: entry } : entry;
167190
if (!spec || typeof spec.module !== 'string' || !spec.module) {
@@ -297,6 +320,12 @@ export async function loadPlugins(fastify, entries, ctx) {
297320
const baseUrl = o.baseUrl || `${protocol}://${urlHost}:${port}`;
298321
return { baseUrl, protocol, host, port, listening: !!live };
299322
},
323+
// Every loaded plugin's { id, prefix, module } (#610), read-only — so
324+
// a plugin can enumerate its co-loaded siblings instead of being
325+
// handed a copy of the operator's plugins array. Includes this
326+
// plugin; consumers filter themselves out. A frozen boot-time
327+
// snapshot (see `roster` above).
328+
plugins: roster,
300329
// Mount a node-style (req, res) handler — a wrapped HTTP app, reverse
301330
// proxy, or framework adapter — under the plugin's prefix (#583). This
302331
// bundles the four things every such plugin needs and otherwise

test/plugin-roster.test.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* api.plugins (#610) — a plugin can enumerate its co-loaded siblings
3+
* instead of being hand-fed a duplicate of the operator's plugins array
4+
* (which silently drifts). A read-only, frozen boot-time snapshot of
5+
* every entry's { id, prefix, module }, computed before any activate()
6+
* runs so it is complete regardless of load order.
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-roster';
16+
const FIXTURE_DIR = path.join(os.tmpdir(), 'jss-roster-fixture');
17+
18+
// The FIRST plugin captures api.plugins at activate time and serves it.
19+
// Because the roster is computed up front, it must already list the
20+
// siblings that activate AFTER this one — the load-order-independence
21+
// the seam exists to guarantee.
22+
const REPORTER = `
23+
export async function activate(api) {
24+
const atActivate = api.plugins;
25+
api.fastify.get('/a/roster', async () => ({
26+
plugins: atActivate,
27+
frozen: Object.isFrozen(atActivate),
28+
entryFrozen: atActivate.length > 0 ? Object.isFrozen(atActivate[0]) : null,
29+
self: atActivate.find((p) => p.id === 'roster-a') ?? null,
30+
}));
31+
}
32+
`;
33+
34+
// A sibling that registers no routes — it only needs to exist in the roster.
35+
const NOOP = `export async function activate() {}`;
36+
37+
let server;
38+
let baseUrl;
39+
let originalDataRoot;
40+
41+
async function start(plugins) {
42+
await fs.emptyDir(TEST_DATA_DIR);
43+
const { createServer } = await import('../src/server.js');
44+
server = createServer({
45+
logger: false,
46+
forceCloseConnections: true,
47+
root: TEST_DATA_DIR,
48+
plugins,
49+
});
50+
await server.listen({ port: 0, host: '127.0.0.1' });
51+
baseUrl = `http://127.0.0.1:${server.server.address().port}`;
52+
}
53+
54+
describe('api.plugins (#610)', () => {
55+
before(async () => {
56+
originalDataRoot = process.env.DATA_ROOT;
57+
await fs.emptyDir(FIXTURE_DIR);
58+
await fs.writeFile(path.join(FIXTURE_DIR, 'reporter.js'), REPORTER);
59+
await fs.writeFile(path.join(FIXTURE_DIR, 'noop.js'), NOOP);
60+
});
61+
after(async () => {
62+
await fs.remove(FIXTURE_DIR);
63+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
64+
else process.env.DATA_ROOT = originalDataRoot;
65+
});
66+
afterEach(async () => {
67+
if (server) { await server.close(); server = null; }
68+
await fs.remove(TEST_DATA_DIR);
69+
});
70+
71+
const reporter = path.join(FIXTURE_DIR, 'reporter.js');
72+
const noop = path.join(FIXTURE_DIR, 'noop.js');
73+
74+
it('lists every loaded entry — including siblings that activate later', async () => {
75+
await start([
76+
{ id: 'roster-a', module: reporter, prefix: '/a' },
77+
{ id: 'roster-b', module: noop, prefix: '/b' },
78+
{ id: 'roster-c', module: noop }, // no prefix
79+
]);
80+
const res = await fetch(`${baseUrl}/a/roster`);
81+
assert.strictEqual(res.status, 200);
82+
const { plugins } = await res.json();
83+
84+
// The reporter is entry 0 yet sees b and c (activated after it) —
85+
// the roster is complete up front, not accumulated during the loop.
86+
assert.deepStrictEqual(plugins.map((p) => p.id), ['roster-a', 'roster-b', 'roster-c']);
87+
const b = plugins.find((p) => p.id === 'roster-b');
88+
assert.strictEqual(b.prefix, '/b');
89+
assert.strictEqual(b.module, noop);
90+
// A no-prefix entry reports '' (same normalization the loader applies).
91+
assert.strictEqual(plugins.find((p) => p.id === 'roster-c').prefix, '');
92+
});
93+
94+
it('includes the plugin itself', async () => {
95+
await start([
96+
{ id: 'roster-a', module: reporter, prefix: '/a' },
97+
{ id: 'roster-b', module: noop, prefix: '/b' },
98+
]);
99+
const { self } = await (await fetch(`${baseUrl}/a/roster`)).json();
100+
assert.ok(self, 'the roster includes the reporting plugin');
101+
assert.strictEqual(self.prefix, '/a');
102+
assert.strictEqual(self.module, reporter);
103+
});
104+
105+
it('is a frozen, read-only snapshot at both levels', async () => {
106+
await start([
107+
{ id: 'roster-a', module: reporter, prefix: '/a' },
108+
{ id: 'roster-b', module: noop, prefix: '/b' },
109+
]);
110+
const { frozen, entryFrozen } = await (await fetch(`${baseUrl}/a/roster`)).json();
111+
assert.strictEqual(frozen, true, 'the array is frozen');
112+
assert.strictEqual(entryFrozen, true, 'each entry object is frozen');
113+
});
114+
});

0 commit comments

Comments
 (0)