-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| /** | ||
| * 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: entry.id, or derived from the module spec. | ||
| * Bare package specifiers keep their full path ('@scope/pkg/plugin.js' -> | ||
| * 'scope-pkg-plugin') so same-named files in different packages don't | ||
| * collide; file paths use the basename, because a machine-specific | ||
| * directory prefix must not name the plugin's data dir (the id — and with | ||
| * it pluginDir — would change whenever the deployment moves). The loader | ||
| * additionally rejects duplicate ids, so any residual collision fails the | ||
| * boot instead of silently sharing storage. | ||
| */ | ||
| export function pluginId(spec) { | ||
| const module = String(spec.module); | ||
| const raw = typeof spec.id === 'string' && spec.id | ||
| ? spec.id | ||
| : (module.startsWith('.') || path.isAbsolute(module) | ||
| ? path.basename(module) | ||
| : 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); | ||
| const seenIds = new Set(); | ||
| 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); | ||
| if (seenIds.has(id)) { | ||
| throw new Error(`plugins: duplicate id '${id}' — set entry.id to keep the plugins' data dirs apart`); | ||
| } | ||
| seenIds.add(id); | ||
|
|
||
| // 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`); | ||
| } | ||
|
|
||
| // Any provided prefix must validate — a falsy one ('', 0) silently | ||
| // skipping the appPaths exemption would mount the app behind WAC. | ||
| // Omit the property entirely for a plugin with no mount prefix. | ||
| const prefix = normalizePrefix(spec.prefix); | ||
| if (spec.prefix !== undefined && !prefix) { | ||
| throw new Error(`plugin ${id}: invalid prefix ${JSON.stringify(spec.prefix)} (must start with '/'; omit for none)`); | ||
| } | ||
| if (prefix) ctx.appPaths.push(prefix); // WAC exemption under the mount (#582) | ||
|
|
||
| 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. | ||
| const socket = connection.socket ?? connection; | ||
| // A plugin bug here must not become an unhandled rejection that | ||
| // takes the host down: log it and close the one affected socket. | ||
| try { | ||
| Promise.resolve(handler(socket, request)).catch((err) => { | ||
| log.error(`plugin ${id}: ws handler failed: ${err.message}`); | ||
| socket.terminate?.(); | ||
| }); | ||
| } catch (err) { | ||
| log.error(`plugin ${id}: ws handler failed: ${err.message}`); | ||
| socket.terminate?.(); | ||
| } | ||
| }); | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| 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}` : ''}`); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
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)