Skip to content

feat(plugins): createServer({ plugins }) — the #206 loader#589

Merged
melvincarvalho merged 3 commits into
gh-pagesfrom
plugin-loader-206
Jul 10, 2026
Merged

feat(plugins): createServer({ plugins }) — the #206 loader#589
melvincarvalho merged 3 commits into
gh-pagesfrom
plugin-loader-206

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Closes the loader half of #206. The seams shipped first, each forced by a real consumer — appPaths (#582, Tideholm), getAgent (#584, Tideholm), WebSocket routing (#588, bridge) — and this PR is the ~150 lines that assemble them into config-driven app loading:

createServer({
  root, idp: true,
  plugins: [
    { module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm', config: { bots: 8 } },
    { module: 'bridge/jss-plugin/bridge-jss.js',     prefix: '/bridge' },
  ],
})

The api

Each module's activate(api) runs during startup:

surface provides
api.fastify scoped instance to register routes on
api.prefix / api.config the entry's mount point and config, verbatim
api.auth.getAgent(req) host credential verification → agent id | null (#584)
api.ws.route(path, (socket, req) => {}) WebSocket endpoints through @fastify/websocket — same upgrade path as the bundled realtime features, so plugins never attach their own 'upgrade' listener (#588's foot-gun)
api.storage.pluginDir() private server-side dir under the data root's dot-guard (the .idp precedent) — never served over LDP
api.log speaks both pino (info/warn/error) and console (log/error) dialects

The entry's prefix joins appPaths automatically (#582), so the plugin owns auth under its mount — the same deal the bundled pseudo-plugins already have. activate() may return { deactivate }, run on close. A plugin that fails to import/activate fails listen() loudly — the operator wrote the config; a server silently missing an app is worse than one that refuses to start.

Validation

  • test/plugins.test.js: 8 tests — config→route serving under a WAC-exempt prefix, prefix/config pass-through, ws.route echo over a real socket, pluginDir creation + LDP shielding, deactivate on close, sibling WAC enforcement, loud failure on missing module and invalid prefix. Full suite: 1014/1014.
  • Both real consumers composed from pure config in one server (their activate() exports were written against this contract): one pod account registers once, is auto-provisioned as a Tideholm player and mints a bridge login ticket with the same Bearer, plays bridge over the loader-owned WebSocket at /bridge/ws; sibling LDP paths keep full WAC; Tideholm's world persists under .plugins/tideholm-jss/.

Punted deliberately

Refs #206, #582, #584, #588.

Loads app plugins from config: each entry's module is imported and its
activate(api) run at startup. The api assembles the seams the plugin-zero
exercise shipped or specced — appPaths WAC exemption for the entry's
prefix (#582), auth.getAgent (#584), ws.route for WebSocket endpoints
through @fastify/websocket so plugins never own an 'upgrade' listener
(#588) — plus a private storage dir under the data root's dot-guard and
a logger that speaks both pino and console dialects.

A plugin that fails to load fails listen() loudly: the operator wrote
the config, and a server silently missing an app is worse than one that
refuses to start. activate() may return { deactivate } for teardown on
close.

Validated against both real consumers (Tideholm and bridge composed from
pure config in one server: shared pod identity across both games, live
WebSocket play, WAC intact on sibling paths).

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 the #206 plugin loader to JSS by introducing createServer({ plugins }), dynamically importing and activating configured app modules during Fastify startup, and exposing a small host-provided API surface (fastify scope, auth/getAgent, websocket routing via @fastify/websocket, and private plugin storage). This formalizes previously-shipped seams (appPaths, getAgent, WS routing) into a config-driven loader.

Changes:

  • Add plugin loading/activation during server startup via a new src/plugins.js loader and a createServer({ plugins }) option.
  • Provide plugin API helpers: prefix normalization, plugin-private storage under .plugins/, ws route registration, and logger compatibility.
  • Add end-to-end tests for loader behavior, failure modes, ws routing, and plugin storage shielding.

Reviewed changes

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

File Description
src/server.js Wires options.plugins into startup and appends plugin prefixes into appPaths to bypass WAC under plugin mounts.
src/plugins.js Implements the loader (dynamic import + activate(api)), plugin api surface (auth/storage/ws/log), and lifecycle (deactivate on close).
test/plugins.test.js Adds end-to-end tests covering plugin activation, WAC-exempt prefix routing, ws.route behavior, pluginDir behavior, and loud startup failures.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/server.js
Comment on lines +63 to +65
* @param {Array} options.plugins - App plugins to load (#206): [{ module, prefix, config, id }].
* Each module's activate(api) runs at startup; prefix is WAC-exempted via appPaths.
* See src/plugins.js for the api surface.

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.

Added an "App Plugins (plugins)" section to docs/configuration.md right after the appPaths/getAgent sections it builds on: entry shape (module/prefix/config/id), the automatic appPaths exemption, the full activate(api) surface, and the fail-loudly contract. (b334fbd)

Comment thread src/plugins.js
Comment on lines +71 to +78
export function pluginId(spec) {
const raw = typeof spec.id === 'string' && spec.id
? spec.id
: path.basename(String(spec.module)).replace(/\.[cm]?js$/, '');
const id = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
if (!id) throw new Error(`plugins: cannot derive an id from ${JSON.stringify(spec.module)}; set entry.id`);
return id;
}

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.

Done, with one deliberate split: bare package specifiers now derive from the full path (@scope1/pkg/plugin.js → scope1-pkg-plugin, no more cross-scope collisions), but file paths keep the basename — a machine-specific directory prefix must not name the data dir, or pluginDir would change whenever the deployment moves. Residual collisions are covered deterministically: duplicate ids across entries now fail the boot with a pointer to entry.id, instead of silently sharing storage. Both behaviors tested. (b334fbd)

Comment thread src/plugins.js
Comment on lines +142 to +146
fastify.get(wsPath, { websocket: true }, (connection, request) => {
// @fastify/websocket v8 hands a SocketStream; the ws socket is
// .socket. Later majors hand the socket directly — accept both.
handler(connection.socket ?? connection, request);
});

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.

Guarded: the handler call is wrapped for both sync throws and rejected promises — log via api's logger and terminate the one affected socket; the host keeps serving. Test added (throwing ws handler closes that socket, /echo still answers). (b334fbd)

…dlers

- docs/configuration.md gains an App Plugins section beside appPaths and
  getAgent: entry shape, the automatic appPaths exemption, the activate
  api, and the fail-loudly contract
- pluginId derives from the full specifier for bare package imports
  (@scope1/pkg and @scope2/pkg no longer collide) but keeps the basename
  for file paths, where a machine-specific prefix must not name the data
  dir; duplicate ids across entries now fail the boot instead of sharing
  storage
- ws.route wraps plugin handlers: a sync throw or rejected promise logs
  and terminates the one affected socket instead of surfacing as an
  unhandled rejection in the host

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 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/plugins.js
Comment on lines +132 to +136
const prefix = normalizePrefix(spec.prefix);
if (spec.prefix && !prefix) {
throw new Error(`plugin ${id}: invalid prefix ${JSON.stringify(spec.prefix)} (must start with '/')`);
}
if (prefix) ctx.appPaths.push(prefix); // WAC exemption under the mount (#582)

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.

Fixed: only an omitted prefix now means 'no mount prefix' — any provided value (including '', 0, null, '/') must normalize to a valid '/x…' or listen() fails, so a plugin can't silently mount behind WAC. The invalid-prefix test now sweeps the falsy cases too.

prefix: '' (or another falsy value) previously skipped validation and
mounted the plugin without its appPaths exemption — behind WAC, contra
the documented contract. Only an omitted prefix now means 'none';
everything else must normalize to a valid mount point.

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 4 out of 4 changed files in this pull request and generated no new comments.

@melvincarvalho
melvincarvalho merged commit c516bb6 into gh-pages Jul 10, 2026
2 checks passed
@melvincarvalho
melvincarvalho deleted the plugin-loader-206 branch July 10, 2026 22:16
melvincarvalho added a commit that referenced this pull request Jul 10, 2026
The plugin loader (#206, #589): createServer({ plugins: [{ module,
prefix, config, id }] }) imports each module and runs its activate(api)
at startup. The api assembles the seams shipped in 0.0.213/0.0.214 —
the entry's prefix joins appPaths automatically (#582), auth.getAgent
verifies any host credential (#584) — plus ws.route for WebSocket
endpoints through @fastify/websocket (#588), a private pluginDir under
the data root's dot-guard, and dual-dialect logging. Plugins that fail
to load fail listen() loudly; activate may return { deactivate } for
teardown on close. Documented in docs/configuration.md; validated by
two real consumers (Tideholm, bridge) composed from pure config.
Remaining from #206: CLI config-file plugins block, #583 raw-body
mode, #564 feature migration.
melvincarvalho added a commit that referenced this pull request Jul 11, 2026
) (#593)

loadConfig merges both keys from config.json, but the explicit
createServer allowlist in bin/jss.js omitted them — so the documented
'declare the apps in config' route (#206/#589, docs/configuration.md)
silently booted a server with no plugins, exactly the missing-app
failure mode the loader was designed to refuse. Same for appPaths
(#582).

Verified: jss start -c config.json with a plugins entry now mounts the
app (route responds, config delivered to activate); before the fix the
same config booted with a 404 on the plugin route.
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.

2 participants