feat(plugins): createServer({ plugins }) — the #206 loader#589
Conversation
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).
There was a problem hiding this comment.
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.jsloader and acreateServer({ 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.
| * @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. |
There was a problem hiding this comment.
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)
| 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; | ||
| } |
There was a problem hiding this comment.
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)
| 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); | ||
| }); |
There was a problem hiding this comment.
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
| 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) |
There was a problem hiding this comment.
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.
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.
) (#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.
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:The api
Each module's
activate(api)runs during startup:api.fastifyapi.prefix/api.configapi.auth.getAgent(req)api.ws.route(path, (socket, req) => {})@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().idpprecedent) — never served over LDPapi.loginfo/warn/error) and console (log/error) dialectsThe entry's
prefixjoinsappPathsautomatically (#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 failslisten()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.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
pluginsblock — natural follow-up once the option proves out; it's what makes "install an app = edit config" real.Refs #206, #582, #584, #588.