Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions src/plugins.js
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;
}
Comment on lines +80 to +91

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)


/**
* 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

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.


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

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)

},
},
};

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}` : ''}`);
}
}
21 changes: 21 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

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)

* @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
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
Loading