feat(plugins): api.plugins — the loaded-plugin roster (#610)#612
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new plugin-loader introspection seam so plugins (e.g., dashboards/admin consoles) can enumerate the full set of co-loaded plugins without maintaining a duplicated inventory list outside the loader.
Changes:
- Expose
api.pluginsas a frozen, boot-time snapshot of{ id, prefix, module }for all configured plugin entries (computed before anyactivate()runs). - Add a dedicated test suite validating completeness across load order, self-inclusion, and deep immutability.
- Document
api.pluginsalongside other plugin API seams in configuration docs.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| test/plugin-roster.test.js | Adds tests covering ordering independence, includes-self, and freezing semantics for api.plugins. |
| src/plugins.js | Computes and exposes a frozen plugin roster as api.plugins during plugin activation. |
| docs/configuration.md | Documents the new api.plugins seam and its intended snapshot semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let id = null; | ||
| try { if (spec.module) id = pluginId(spec); } catch { /* the loop reports it */ } | ||
| return Object.freeze({ | ||
| id, | ||
| prefix: normalizePrefix(spec.prefix), | ||
| module: spec.module ? String(spec.module) : null, | ||
| }); |
There was a problem hiding this comment.
Good catch — addressed in the follow-up commit. module is now surfaced only when it's a non-empty string (mirroring the load loop's own typeof spec.module === 'string' gate), and id is derived only from a valid module, so the roster never carries a coerced "[object Object]" even in the transient pre-boot-failure window. No change on a successful boot, where the loop already guarantees a clean string.
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.
8fd602c to
e789e27
Compare
Addresses the Copilot review note: String(spec.module) would coerce a
misconfigured non-string module into a misleading value ("[object
Object]") in the transient window before the load loop fails the boot.
Gate module on typeof === 'string' (mirroring the loop's own check) and
derive id only from a valid module. No change on any successful boot —
the loop already guarantees every module is a non-empty string there.
Four plugin-API seams land, all surfaced by the out-of-tree plugin
experiment (github.com/JavaScriptSolidServer/plugins), each with a
consumer proving the need:
- api.mountApp(handler, { prefix }) (#583, #590): mount a wrapped
node-style (req, res) app — a reverse proxy or framework adapter —
under the plugin's prefix. Bundles the appPaths WAC exemption, a
scoped pass-through parser that hands the app an undrained body
stream, and reply.hijack(). The raw-body-mode answer to #583.
- api.serverInfo() -> { baseUrl, protocol, host, port, listening }
(#601, #605): a plugin learns the server's own origin instead of
repeating baseUrl/loopbackUrl in config, where a wrong value fails
quietly. A function, not a snapshot — with port 0 the real port
exists only once listening; an explicit idpIssuer wins as the
canonical baseUrl.
- api.reservePath(path, opts?) (#602, #607): claim and WAC-exempt a
route outside the plugin's prefix. Literal paths ('/xrpc') own their
subtree; parameterized paths ('/:user/did.json') match an exact
shape (read-only by default) for pinned documents inside pod
namespaces. Two plugins reserving the same path fail the boot with
both names — loud beats the silent-loser outcome.
- api.plugins -> [{ id, prefix, module }] (#610, #612): a read-only,
frozen boot-time roster of every loaded entry, so a plugin
describing the deployment (a status dashboard, an admin console)
enumerates its co-loaded siblings instead of being hand-fed a
duplicate of the operator's plugins array.
All documented in docs/configuration.md App Plugins. Remaining plugin
seams tracked upstream: api.events.onResourceChange (#603),
api.authorize (#604).
Closes #610.
Why
A plugin whose whole job is describing the deployment — a status dashboard, an admin console — cannot ask what else is loaded. The loader already holds the entry list but exposes none of it, so the operator has to hand 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 the stale entry as healthy. The out-of-treedashboard/andadmin/plugins both live this today —serve.jsthere keeps a hand-maintainedINVENTORYarray beside the real one purely to work around this.What
A frozen boot-time snapshot of every loaded entry, computed before any
activate()runs — so it is complete regardless of load order (the first plugin already sees siblings that activate after it). It includes the plugin itself (consumers filter). It reuses the loader's ownpluginId/normalizePrefix, so on any successful boot everyid/prefixmatches what the load loop derives; the roster computation is lenient and never throws — the load loop still owns validation and fails the boot on a bad entry.src/plugins.jsonly — noserver.jschange needed, since the roster comes entirely from theentriesalready passed toloadPlugins.One design choice worth a look
I made
api.pluginsa property (a frozen array), not a function likeapi.serverInfo().serverInfois a function because the port genuinely changes at listen; the plugin roster is boot-time-static, so a property reads more naturally (api.plugins.map(...)) and matches the sketch in the issue. If runtime enable/disable ever lands it would want to become a live view — happy to switch it to a function for forward-consistency if you'd prefer.Scope
This is deliberately the minimal loader-introspection primitive — distinct from the apps-as-pod-resources vision (#463/#464) and the marketplace (#184/#194/#200), both of which could be built on top of it, not replaced by it.
Tests
test/plugin-roster.test.js— three cases: load-order-independent completeness, includes-self, and frozen at both levels. Full suite 1038/1038 green. No version bump (left to the usual separate bump commit).Context
Second of the plugin-API seams from the 33-plugin out-of-tree experiment, after
api.serverInfo(#601/#605). It's the phase-1 blocker for shippingdashboard/as a blessed read-only status console — which needs no auth seam (its probes are anonymous), only this.