-
Notifications
You must be signed in to change notification settings - Fork 9
feat(plugins): createServer({ plugins }) — the #206 loader #589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| /** | ||
| * Plugin loader — the #206 seam, assembled from its shipped parts. | ||
| * | ||
| * createServer({ | ||
| * plugins: [ | ||
| * { module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm', | ||
| * config: { bots: 8 } }, | ||
| * { module: './my-app/plugin.js', prefix: '/myapp' }, | ||
| * ], | ||
| * }) | ||
| * | ||
| * Each entry's module is imported and its exported `activate(api)` called | ||
| * during server startup (before listen completes). The api wires the seams | ||
| * every plugin consumer so far has needed: | ||
| * | ||
| * api.fastify scoped Fastify instance to register routes on | ||
| * api.prefix the entry's mount prefix ('' when none) | ||
| * api.config the entry's config object, verbatim | ||
| * api.log server logger | ||
| * api.auth.getAgent(req) -> agent id string | null (#584) | ||
| * api.storage.pluginDir() -> private server-side data dir for this plugin | ||
| * api.ws.route(path, (socket, request) => {}) (#588) | ||
| * | ||
| * The entry's `prefix` is added to appPaths automatically (#582), so the | ||
| * plugin owns authentication and authorization under its mount — the same | ||
| * deal the bundled pseudo-plugins (idp, /db, /storage/…) already have. | ||
| * | ||
| * ws.route registers WebSocket endpoints through @fastify/websocket — the | ||
| * same single upgrade path the bundled realtime features (nostr relay, | ||
| * tunnel, notifications…) use — so plugins never attach their own 'upgrade' | ||
| * listener. That matters: node only auto-destroys stray upgrade attempts | ||
| * while the server has NO 'upgrade' listener, so a plugin attaching one | ||
| * would become responsible for every unclaimed socket on the host (#588). | ||
| * The handler receives the raw ws socket; a plugin with its own | ||
| * WebSocketServer({ noServer: true }) can feed it straight in: | ||
| * api.ws.route('/myapp/ws', (socket, req) => wss.emit('connection', socket, req)); | ||
| * | ||
| * activate() may return { deactivate() {} }; deactivate runs on server | ||
| * close (world saves, timer teardown). A plugin that fails to load fails | ||
| * the boot loudly — the operator wrote the config, and a server silently | ||
| * missing an app is worse than one that refuses to start. | ||
| */ | ||
|
|
||
| import fs from 'fs'; | ||
| import path from 'path'; | ||
| import { pathToFileURL } from 'url'; | ||
| import websocket from '@fastify/websocket'; | ||
| import { getAgent } from '../auth.js'; | ||
|
|
||
| /** | ||
| * api.log speaks both dialects: pino-style (info/warn/error/debug, what | ||
| * fastify.log is) and console-style (log/error, what plain node apps | ||
| * expect) — plugins shouldn't need to know which logger the host runs. | ||
| */ | ||
| export function makePluginLog(base) { | ||
| const call = (level) => (...args) => { | ||
| const fn = base?.[level] ?? base?.info ?? base?.log; | ||
| if (typeof fn === 'function') fn.call(base, ...args); | ||
| }; | ||
| return { log: call('info'), info: call('info'), warn: call('warn'), error: call('error'), debug: call('debug') }; | ||
| } | ||
|
|
||
| /** Same normalization appPaths applies: no trailing slash, must be '/x…'. */ | ||
| export function normalizePrefix(p) { | ||
| if (typeof p !== 'string') return ''; | ||
| const trimmed = p.trim().replace(/\/+$/, ''); | ||
| return trimmed.startsWith('/') && trimmed.length > 1 ? trimmed : ''; | ||
| } | ||
|
|
||
| /** Directory-safe plugin id: from entry.id or derived from the module spec. */ | ||
| 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; | ||
| } | ||
|
|
||
| /** | ||
| * Load and activate every plugin entry. Called from createServer inside a | ||
| * fastify.register scope, so `fastify` here is that scope; routes and hooks | ||
| * plugins add land on the running server. | ||
| * | ||
| * @param {object} fastify scoped instance the plugins register on | ||
| * @param {Array} entries options.plugins, verbatim | ||
| * @param {object} ctx { appPaths, root, log } | ||
| */ | ||
| export async function loadPlugins(fastify, entries, ctx) { | ||
| const log = makePluginLog(ctx.log); | ||
| for (const entry of entries) { | ||
| const spec = typeof entry === 'string' ? { module: entry } : entry; | ||
| if (!spec || typeof spec.module !== 'string' || !spec.module) { | ||
| throw new Error('plugins: each entry needs a module (import specifier or path)'); | ||
| } | ||
| const id = pluginId(spec); | ||
|
|
||
| // Paths resolve from the operator's cwd; bare specifiers stay package | ||
| // imports resolved from JSS's own module graph. | ||
| const href = spec.module.startsWith('.') || path.isAbsolute(spec.module) | ||
| ? pathToFileURL(path.resolve(spec.module)).href | ||
| : spec.module; | ||
| let mod; | ||
| try { | ||
| mod = await import(href); | ||
| } catch (err) { | ||
| throw new Error(`plugin ${id}: cannot import ${spec.module}: ${err.message}`); | ||
| } | ||
| const activate = mod.activate ?? mod.default; | ||
| if (typeof activate !== 'function') { | ||
| throw new Error(`plugin ${id}: module exports no activate(api) function`); | ||
| } | ||
|
|
||
| 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) | ||
|
Comment on lines
+135
to
+139
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
|
||
| const api = { | ||
| fastify, | ||
| prefix, | ||
| config: spec.config ?? {}, | ||
| log, | ||
| auth: { getAgent }, | ||
| storage: { | ||
| // Under the data root's dot-guard (like .idp): never served over LDP. | ||
| pluginDir() { | ||
| const dir = path.join(ctx.root, '.plugins', id); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| return dir; | ||
| }, | ||
| }, | ||
| ws: { | ||
| async route(wsPath, handler) { | ||
| if (typeof wsPath !== 'string' || !wsPath.startsWith('/')) { | ||
| throw new Error(`plugin ${id}: ws.route path must start with '/'`); | ||
| } | ||
| if (!fastify.websocketServer) { | ||
| await fastify.register(websocket); | ||
| } | ||
| 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); | ||
| }); | ||
|
Comment on lines
+163
to
+178
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| let result; | ||
| try { | ||
| result = await activate(api); | ||
| } catch (err) { | ||
| throw new Error(`plugin ${id}: activate() failed: ${err.message}`); | ||
| } | ||
| if (result && typeof result.deactivate === 'function') { | ||
| fastify.addHook('onClose', async () => { | ||
| try { | ||
| await result.deactivate(); | ||
| } catch (err) { | ||
| log.warn(`plugin ${id}: deactivate() failed: ${err.message}`); | ||
| } | ||
| }); | ||
| } | ||
| log.info(`plugin ${id} active${prefix ? ` at ${prefix}` : ''}`); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking | ||
| * @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false) | ||
| * @param {boolean} options.pay - Enable HTTP 402 paid /pay/* routes (default false) | ||
| * @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. | ||
|
Comment on lines
+63
to
+65
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
||
| * @param {number} options.payCost - Cost per request in satoshis (default 1) | ||
| * @param {string} options.payMempoolUrl - Mempool API base URL (default testnet4) | ||
| * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers | ||
|
|
@@ -117,6 +120,10 @@ export function createServer(options = {}) { | |
| .map((p) => p.trim().replace(/\/+$/, '')) // '/myapp/' matches like '/myapp' | ||
| .filter((p) => p.startsWith('/') && p.length > 1) | ||
| : []; | ||
| // App plugins (#206): loaded at startup, each entry's prefix joins | ||
| // appPaths. The WAC hook reads the array per request, so pushes made | ||
| // during plugin activation are honored. | ||
| const pluginEntries = Array.isArray(options.plugins) ? options.plugins : []; | ||
| // ActivityPub federation is OFF by default | ||
| const activitypubEnabled = options.activitypub ?? false; | ||
| const apUsername = options.apUsername ?? 'me'; | ||
|
|
@@ -411,6 +418,20 @@ export function createServer(options = {}) { | |
| }); | ||
| } | ||
|
|
||
| // Load app plugins (#206). Deferred into a register scope so the dynamic | ||
| // imports and async activation run during fastify's startup; a failing | ||
| // plugin fails listen() rather than leaving a half-configured server. | ||
| if (pluginEntries.length) { | ||
| fastify.register(async (instance) => { | ||
| const { loadPlugins } = await import('./plugins.js'); | ||
| await loadPlugins(instance, pluginEntries, { | ||
| appPaths, | ||
| root: options.root || process.env.DATA_ROOT || './data', | ||
| log: fastify.log, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // Register Nostr relay if enabled | ||
| if (nostrEnabled) { | ||
| fastify.register(async (instance) => { | ||
|
|
||
There was a problem hiding this comment.
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)