Skip to content

feat(plugins): api.plugins — the loaded-plugin roster (#610)#612

Merged
melvincarvalho merged 2 commits into
gh-pagesfrom
feat/api-plugins-610
Jul 11, 2026
Merged

feat(plugins): api.plugins — the loaded-plugin roster (#610)#612
melvincarvalho merged 2 commits into
gh-pagesfrom
feat/api-plugins-610

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

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-tree dashboard/ and admin/ plugins both live this today — serve.js there keeps a hand-maintained INVENTORY array beside the real one purely to work around this.

What

api.plugins  // → [{ id, prefix, module }], read-only

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 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 load loop still owns validation and fails the boot on a bad entry.

src/plugins.js only — no server.js change needed, since the roster comes entirely from the entries already passed to loadPlugins.

One design choice worth a look

I made api.plugins a property (a frozen array), not a function like api.serverInfo(). serverInfo is 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 primitivedistinct 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 shipping dashboard/ as a blessed read-only status console — which needs no auth seam (its probes are anonymous), only this.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.plugins as a frozen, boot-time snapshot of { id, prefix, module } for all configured plugin entries (computed before any activate() runs).
  • Add a dedicated test suite validating completeness across load order, self-inclusion, and deep immutability.
  • Document api.plugins alongside 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.

Comment thread src/plugins.js Outdated
Comment on lines +120 to +126
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,
});

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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

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.
@melvincarvalho
melvincarvalho merged commit 18ee785 into gh-pages Jul 11, 2026
@melvincarvalho
melvincarvalho deleted the feat/api-plugins-610 branch July 11, 2026 14:45
melvincarvalho added a commit that referenced this pull request Jul 11, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin API seam: api.plugins — a plugin can't enumerate its co-loaded siblings

2 participants