Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
44 changes: 44 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,50 @@ for the design discussion and
for a complete example (a multiplayer game where pod WebIDs are the player
accounts).

## App Plugins (plugins)

The plugin loader
([#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206))
does the appPaths wiring for you: declare the apps in config and the server
imports, mounts, and tears them down itself.

```js
const fastify = createServer({
root: './data',
idp: true,
plugins: [
{ module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm',
config: { bots: 8 } },
{ module: './my-app/plugin.js', prefix: '/myapp' },
],
});
```

Each entry:

- `module` — import specifier: a package path (resolved from JSS's module
graph) or a file path (`./…` or absolute, resolved from the process cwd).
The module exports `activate(api)`, called during startup.
- `prefix` — the app's mount point. Added to `appPaths` automatically, so
the app owns authentication below it (see the section above). Must start
with `/`; invalid prefixes fail startup.
- `config` — passed to the plugin verbatim as `api.config`.
- `id` — optional stable identifier (defaults to a name derived from
`module`); names the plugin's private data dir, so set it explicitly if
you load two plugins whose specifiers reduce to the same name.

`activate(api)` receives: `api.fastify` (register routes here),
`api.prefix`, `api.config`, `api.log`, `api.auth.getAgent(request)`
(identity, as above), `api.storage.pluginDir()` (a private server-side
directory under the data root, never served over HTTP), and
`api.ws.route(path, (socket, request) => {})` for WebSocket endpoints —
routed through the same upgrade path as the built-in realtime features, so
plugins never attach their own `'upgrade'` listener. Return
`{ deactivate }` to run teardown (state saves, timers) on server close.

A plugin that fails to import or activate fails `listen()` loudly rather
than booting a server silently missing an app.

## Storage Quotas

Limit storage per pod to prevent abuse and manage resources:
Expand Down
200 changes: 200 additions & 0 deletions src/plugins.js
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}` : ''}`);
}
}
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