Skip to content

Commit c516bb6

Browse files
feat(plugins): createServer({ plugins }) — the #206 loader (#589)
* feat(plugins): createServer({ plugins }) — the #206 loader 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). * review: plugins docs section, collision-resistant ids, guarded ws handlers - 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 * review: any provided prefix must validate 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.
1 parent 35d70ff commit c516bb6

4 files changed

Lines changed: 517 additions & 0 deletions

File tree

docs/configuration.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,50 @@ for the design discussion and
389389
for a complete example (a multiplayer game where pod WebIDs are the player
390390
accounts).
391391

392+
## App Plugins (plugins)
393+
394+
The plugin loader
395+
([#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206))
396+
does the appPaths wiring for you: declare the apps in config and the server
397+
imports, mounts, and tears them down itself.
398+
399+
```js
400+
const fastify = createServer({
401+
root: './data',
402+
idp: true,
403+
plugins: [
404+
{ module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm',
405+
config: { bots: 8 } },
406+
{ module: './my-app/plugin.js', prefix: '/myapp' },
407+
],
408+
});
409+
```
410+
411+
Each entry:
412+
413+
- `module` — import specifier: a package path (resolved from JSS's module
414+
graph) or a file path (`./…` or absolute, resolved from the process cwd).
415+
The module exports `activate(api)`, called during startup.
416+
- `prefix` — the app's mount point. Added to `appPaths` automatically, so
417+
the app owns authentication below it (see the section above). Must start
418+
with `/`; invalid prefixes fail startup.
419+
- `config` — passed to the plugin verbatim as `api.config`.
420+
- `id` — optional stable identifier (defaults to a name derived from
421+
`module`); names the plugin's private data dir, so set it explicitly if
422+
you load two plugins whose specifiers reduce to the same name.
423+
424+
`activate(api)` receives: `api.fastify` (register routes here),
425+
`api.prefix`, `api.config`, `api.log`, `api.auth.getAgent(request)`
426+
(identity, as above), `api.storage.pluginDir()` (a private server-side
427+
directory under the data root, never served over HTTP), and
428+
`api.ws.route(path, (socket, request) => {})` for WebSocket endpoints —
429+
routed through the same upgrade path as the built-in realtime features, so
430+
plugins never attach their own `'upgrade'` listener. Return
431+
`{ deactivate }` to run teardown (state saves, timers) on server close.
432+
433+
A plugin that fails to import or activate fails `listen()` loudly rather
434+
than booting a server silently missing an app.
435+
392436
## Storage Quotas
393437

394438
Limit storage per pod to prevent abuse and manage resources:

src/plugins.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* Plugin loader — the #206 seam, assembled from its shipped parts.
3+
*
4+
* createServer({
5+
* plugins: [
6+
* { module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm',
7+
* config: { bots: 8 } },
8+
* { module: './my-app/plugin.js', prefix: '/myapp' },
9+
* ],
10+
* })
11+
*
12+
* Each entry's module is imported and its exported `activate(api)` called
13+
* during server startup (before listen completes). The api wires the seams
14+
* every plugin consumer so far has needed:
15+
*
16+
* api.fastify scoped Fastify instance to register routes on
17+
* api.prefix the entry's mount prefix ('' when none)
18+
* api.config the entry's config object, verbatim
19+
* api.log server logger
20+
* api.auth.getAgent(req) -> agent id string | null (#584)
21+
* api.storage.pluginDir() -> private server-side data dir for this plugin
22+
* api.ws.route(path, (socket, request) => {}) (#588)
23+
*
24+
* The entry's `prefix` is added to appPaths automatically (#582), so the
25+
* plugin owns authentication and authorization under its mount — the same
26+
* deal the bundled pseudo-plugins (idp, /db, /storage/…) already have.
27+
*
28+
* ws.route registers WebSocket endpoints through @fastify/websocket — the
29+
* same single upgrade path the bundled realtime features (nostr relay,
30+
* tunnel, notifications…) use — so plugins never attach their own 'upgrade'
31+
* listener. That matters: node only auto-destroys stray upgrade attempts
32+
* while the server has NO 'upgrade' listener, so a plugin attaching one
33+
* would become responsible for every unclaimed socket on the host (#588).
34+
* The handler receives the raw ws socket; a plugin with its own
35+
* WebSocketServer({ noServer: true }) can feed it straight in:
36+
* api.ws.route('/myapp/ws', (socket, req) => wss.emit('connection', socket, req));
37+
*
38+
* activate() may return { deactivate() {} }; deactivate runs on server
39+
* close (world saves, timer teardown). A plugin that fails to load fails
40+
* the boot loudly — the operator wrote the config, and a server silently
41+
* missing an app is worse than one that refuses to start.
42+
*/
43+
44+
import fs from 'fs';
45+
import path from 'path';
46+
import { pathToFileURL } from 'url';
47+
import websocket from '@fastify/websocket';
48+
import { getAgent } from '../auth.js';
49+
50+
/**
51+
* api.log speaks both dialects: pino-style (info/warn/error/debug, what
52+
* fastify.log is) and console-style (log/error, what plain node apps
53+
* expect) — plugins shouldn't need to know which logger the host runs.
54+
*/
55+
export function makePluginLog(base) {
56+
const call = (level) => (...args) => {
57+
const fn = base?.[level] ?? base?.info ?? base?.log;
58+
if (typeof fn === 'function') fn.call(base, ...args);
59+
};
60+
return { log: call('info'), info: call('info'), warn: call('warn'), error: call('error'), debug: call('debug') };
61+
}
62+
63+
/** Same normalization appPaths applies: no trailing slash, must be '/x…'. */
64+
export function normalizePrefix(p) {
65+
if (typeof p !== 'string') return '';
66+
const trimmed = p.trim().replace(/\/+$/, '');
67+
return trimmed.startsWith('/') && trimmed.length > 1 ? trimmed : '';
68+
}
69+
70+
/**
71+
* Directory-safe plugin id: entry.id, or derived from the module spec.
72+
* Bare package specifiers keep their full path ('@scope/pkg/plugin.js' ->
73+
* 'scope-pkg-plugin') so same-named files in different packages don't
74+
* collide; file paths use the basename, because a machine-specific
75+
* directory prefix must not name the plugin's data dir (the id — and with
76+
* it pluginDir — would change whenever the deployment moves). The loader
77+
* additionally rejects duplicate ids, so any residual collision fails the
78+
* boot instead of silently sharing storage.
79+
*/
80+
export function pluginId(spec) {
81+
const module = String(spec.module);
82+
const raw = typeof spec.id === 'string' && spec.id
83+
? spec.id
84+
: (module.startsWith('.') || path.isAbsolute(module)
85+
? path.basename(module)
86+
: module
87+
).replace(/\.[cm]?js$/, '');
88+
const id = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
89+
if (!id) throw new Error(`plugins: cannot derive an id from ${JSON.stringify(spec.module)}; set entry.id`);
90+
return id;
91+
}
92+
93+
/**
94+
* Load and activate every plugin entry. Called from createServer inside a
95+
* fastify.register scope, so `fastify` here is that scope; routes and hooks
96+
* plugins add land on the running server.
97+
*
98+
* @param {object} fastify scoped instance the plugins register on
99+
* @param {Array} entries options.plugins, verbatim
100+
* @param {object} ctx { appPaths, root, log }
101+
*/
102+
export async function loadPlugins(fastify, entries, ctx) {
103+
const log = makePluginLog(ctx.log);
104+
const seenIds = new Set();
105+
for (const entry of entries) {
106+
const spec = typeof entry === 'string' ? { module: entry } : entry;
107+
if (!spec || typeof spec.module !== 'string' || !spec.module) {
108+
throw new Error('plugins: each entry needs a module (import specifier or path)');
109+
}
110+
const id = pluginId(spec);
111+
if (seenIds.has(id)) {
112+
throw new Error(`plugins: duplicate id '${id}' — set entry.id to keep the plugins' data dirs apart`);
113+
}
114+
seenIds.add(id);
115+
116+
// Paths resolve from the operator's cwd; bare specifiers stay package
117+
// imports resolved from JSS's own module graph.
118+
const href = spec.module.startsWith('.') || path.isAbsolute(spec.module)
119+
? pathToFileURL(path.resolve(spec.module)).href
120+
: spec.module;
121+
let mod;
122+
try {
123+
mod = await import(href);
124+
} catch (err) {
125+
throw new Error(`plugin ${id}: cannot import ${spec.module}: ${err.message}`);
126+
}
127+
const activate = mod.activate ?? mod.default;
128+
if (typeof activate !== 'function') {
129+
throw new Error(`plugin ${id}: module exports no activate(api) function`);
130+
}
131+
132+
// Any provided prefix must validate — a falsy one ('', 0) silently
133+
// skipping the appPaths exemption would mount the app behind WAC.
134+
// Omit the property entirely for a plugin with no mount prefix.
135+
const prefix = normalizePrefix(spec.prefix);
136+
if (spec.prefix !== undefined && !prefix) {
137+
throw new Error(`plugin ${id}: invalid prefix ${JSON.stringify(spec.prefix)} (must start with '/'; omit for none)`);
138+
}
139+
if (prefix) ctx.appPaths.push(prefix); // WAC exemption under the mount (#582)
140+
141+
const api = {
142+
fastify,
143+
prefix,
144+
config: spec.config ?? {},
145+
log,
146+
auth: { getAgent },
147+
storage: {
148+
// Under the data root's dot-guard (like .idp): never served over LDP.
149+
pluginDir() {
150+
const dir = path.join(ctx.root, '.plugins', id);
151+
fs.mkdirSync(dir, { recursive: true });
152+
return dir;
153+
},
154+
},
155+
ws: {
156+
async route(wsPath, handler) {
157+
if (typeof wsPath !== 'string' || !wsPath.startsWith('/')) {
158+
throw new Error(`plugin ${id}: ws.route path must start with '/'`);
159+
}
160+
if (!fastify.websocketServer) {
161+
await fastify.register(websocket);
162+
}
163+
fastify.get(wsPath, { websocket: true }, (connection, request) => {
164+
// @fastify/websocket v8 hands a SocketStream; the ws socket is
165+
// .socket. Later majors hand the socket directly — accept both.
166+
const socket = connection.socket ?? connection;
167+
// A plugin bug here must not become an unhandled rejection that
168+
// takes the host down: log it and close the one affected socket.
169+
try {
170+
Promise.resolve(handler(socket, request)).catch((err) => {
171+
log.error(`plugin ${id}: ws handler failed: ${err.message}`);
172+
socket.terminate?.();
173+
});
174+
} catch (err) {
175+
log.error(`plugin ${id}: ws handler failed: ${err.message}`);
176+
socket.terminate?.();
177+
}
178+
});
179+
},
180+
},
181+
};
182+
183+
let result;
184+
try {
185+
result = await activate(api);
186+
} catch (err) {
187+
throw new Error(`plugin ${id}: activate() failed: ${err.message}`);
188+
}
189+
if (result && typeof result.deactivate === 'function') {
190+
fastify.addHook('onClose', async () => {
191+
try {
192+
await result.deactivate();
193+
} catch (err) {
194+
log.warn(`plugin ${id}: deactivate() failed: ${err.message}`);
195+
}
196+
});
197+
}
198+
log.info(`plugin ${id} active${prefix ? ` at ${prefix}` : ''}`);
199+
}
200+
}

src/server.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
6060
* @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
6161
* @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false)
6262
* @param {boolean} options.pay - Enable HTTP 402 paid /pay/* routes (default false)
63+
* @param {Array} options.plugins - App plugins to load (#206): [{ module, prefix, config, id }].
64+
* Each module's activate(api) runs at startup; prefix is WAC-exempted via appPaths.
65+
* See src/plugins.js for the api surface.
6366
* @param {number} options.payCost - Cost per request in satoshis (default 1)
6467
* @param {string} options.payMempoolUrl - Mempool API base URL (default testnet4)
6568
* @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers
@@ -117,6 +120,10 @@ export function createServer(options = {}) {
117120
.map((p) => p.trim().replace(/\/+$/, '')) // '/myapp/' matches like '/myapp'
118121
.filter((p) => p.startsWith('/') && p.length > 1)
119122
: [];
123+
// App plugins (#206): loaded at startup, each entry's prefix joins
124+
// appPaths. The WAC hook reads the array per request, so pushes made
125+
// during plugin activation are honored.
126+
const pluginEntries = Array.isArray(options.plugins) ? options.plugins : [];
120127
// ActivityPub federation is OFF by default
121128
const activitypubEnabled = options.activitypub ?? false;
122129
const apUsername = options.apUsername ?? 'me';
@@ -411,6 +418,20 @@ export function createServer(options = {}) {
411418
});
412419
}
413420

421+
// Load app plugins (#206). Deferred into a register scope so the dynamic
422+
// imports and async activation run during fastify's startup; a failing
423+
// plugin fails listen() rather than leaving a half-configured server.
424+
if (pluginEntries.length) {
425+
fastify.register(async (instance) => {
426+
const { loadPlugins } = await import('./plugins.js');
427+
await loadPlugins(instance, pluginEntries, {
428+
appPaths,
429+
root: options.root || process.env.DATA_ROOT || './data',
430+
log: fastify.log,
431+
});
432+
});
433+
}
434+
414435
// Register Nostr relay if enabled
415436
if (nostrEnabled) {
416437
fastify.register(async (instance) => {

0 commit comments

Comments
 (0)