| sidebar_position | 17 |
|---|---|
| title | Plugins & Application Mounts |
| description | Extend JSS with whole applications — where the plugin system stands and how to use it today |
JSS can host entire applications beside your pod — same origin, same server, with the pod's identity system as the app's login. This page covers what works today (v0.0.219+), how to build a plugin, and the seams underneath.
:::tip The one-line version
createServer({ plugins: [{ module: 'my-app/plugin.js', prefix: '/myapp' }] })
loads an application and mounts it under a URL prefix. The app owns
everything below its prefix; the pod keeps everything else. Your WebID can
be the app's account — no separate passwords.
:::
| Piece | Status | Where |
|---|---|---|
Plugin loader (plugins option, activate(api)) |
✅ Shipped v0.0.215 | #206, #589 |
Application mount points (appPaths) |
✅ Shipped v0.0.213 | #582, #585 |
Public identity accessor (getAgent) |
✅ Shipped v0.0.214 | #584, #586 |
WebSocket routing (api.ws.route) |
✅ Shipped v0.0.215 | #588, #589 |
CLI: config-file plugins + repeatable --plugin module@prefix |
✅ Shipped v0.0.216–217 | #593, #595 |
Wrapping node apps (api.mountApp) |
✅ Shipped v0.0.217 | #583, #590 |
Server origin (api.serverInfo()) |
✅ Shipped v0.0.218 | #601, #605 |
Reserve fixed paths (api.reservePath) |
✅ Shipped v0.0.218 | #602, #607 |
Loaded-plugin roster (api.plugins) |
✅ Shipped v0.0.218 | #610, #612 |
| Reference plugins | ✅ A 33-plugin out-of-tree suite (dashboard, WebDAV/CalDAV/CardDAV, Mastodon/Bluesky/Matrix shims, S3, JMAP, …) plus Tideholm & bridge | plugins repo |
Bundled-feature migration; api.events, api.authorize seams; panes |
🔭 Next | #564, #603, #604 |
Declare your apps; the server imports, mounts, and tears them down:
import { createServer } from 'javascript-solid-server/src/server.js';
const fastify = createServer({
root: './data/pods',
idp: true,
idpIssuer: 'https://pod.example',
plugins: [
{ module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm',
config: { bots: 8 } },
{ module: './my-app/plugin.js', prefix: '/myapp' },
],
});
await fastify.listen({ port: 4443 });No config file? Mount the same apps from the CLI — the repeatable --plugin
flag takes module[@prefix] and appends to any config-file plugins
(v0.0.216–217):
jss start \
--plugin ./relay/plugin.js@/relay \
--plugin ./dashboard/plugin.js@/dashboardA plugin module exports one function:
export async function activate(api) {
// HTTP routes — the prefix is already WAC-exempt (appPaths, below):
api.fastify.all(api.prefix + '/api/*', async (request, reply) => {
const agent = await api.auth.getAgent(request); // WebID | did:nostr | null
// ...
});
// Realtime — the loader owns the upgrade; you get the ready socket:
await api.ws.route(api.prefix + '/ws', (socket, request) => {
socket.on('message', (data) => socket.send(data));
});
// Private server-side storage (never served over HTTP):
const dir = api.storage.pluginDir();
// The server's own origin — for absolute URLs and loopback calls, so you
// never repeat baseUrl in config. Call it per request (with port 0 the
// real port exists only once listening):
const { baseUrl, host, port } = api.serverInfo(); // (#601)
// Every co-loaded plugin, read-only — enumerate your siblings instead of a
// hand-copied list (this is how the dashboard plugin knows what to show):
for (const { id, prefix, module } of api.plugins) { /* … */ } // (#610)
return { deactivate() { /* save state, clear timers */ } };
}The api also carries api.config (the entry's config, verbatim), api.log
(speaks both pino and console dialects), api.reservePath(path) — claim and
WAC-exempt a fixed or /:param/-shaped route outside your prefix, for
protocols that pin absolute paths (/xrpc, /:user/did.json) — and
api.mountApp(handler, { prefix }) for wrapping a node-style app (below). A
plugin that fails to import or activate fails listen() loudly — a
server silently missing an app is worse than one that refuses to start.
Entries take an optional id that keys pluginDir. It defaults to a name
derived from module: the file's basename, or its parent directory for
the conventional <name>/plugin.js layout (relay/plugin.js → relay), so
distinct plugins get distinct ids with none set (v0.0.219+,
#596).
Set it explicitly only if two specifiers still reduce to the same name.
Plugins load only from operator config — never from pod storage (pods are user-writable; a loader that read them would be remote code execution).
Everything below this point is the machinery the loader assembles — worth knowing when you're debugging, composing by hand, or targeting a pre-0.0.215 host.
JSS already is a plugin system internally: notifications, the IdP,
ActivityPub, the Nostr relay, MCP, remoteStorage, the tunnel — each is an
encapsulated Fastify plugin toggled by a createServer flag. The plugin
effort (#206) is about opening that same power to third parties without
editing server.js: games, dashboards, CardDAV servers, custom APIs —
anything that wants to live on your pod's origin and speak to your pod's
identity.
The strategy is deliberate: ship the smallest seam, prove it with a real
consumer, bless the API afterward. Tideholm (plugin zero) forced
appPaths (v0.0.213) and getAgent (v0.0.214); bridge (plugin two, the
first realtime app) forced ws.route; the loader that assembles them
shipped in v0.0.215 with both games as its test consumers.
JSS authorizes every request against pod ACLs (WAC) in a global hook, and rejects before routes run. Built-in features escape via a hardcoded list; until v0.0.213, third-party routes couldn't. Now:
import { createServer } from 'javascript-solid-server/src/server.js';
const fastify = createServer({
root: './data/pods',
idp: true,
idpIssuer: 'https://pod.example',
appPaths: ['/myapp'], // ← the seam
});
// Register both forms — fastify wildcards don't match the bare prefix.
fastify.all('/myapp', myAppHandler);
fastify.all('/myapp/*', myAppHandler);
await fastify.listen({ port: 4443 });Rules of the road:
- Requests at or below an app path skip the WAC hook — the app owns
authentication and authorization under its prefix, exactly the deal
/storage/and/db/have always had. Everything else keeps full WAC. - Matching is per path segment:
/myappdoes not exempt/myapplication. Trailing slashes are normalized; malformed entries (no leading/, bare/, whitespace) are dropped, never widened. request.webIdis never set under an app path — the WAC hook is what populates it. Resolve identity yourself (next section).- Default off. No
appPaths, no change.
The best part: your app doesn't need accounts. JSS's token verification already handles every supported scheme uniformly — IdP Bearer tokens, Solid-OIDC DPoP, Nostr NIP-98 signatures, LWS10-CID:
import { getAgent } from 'javascript-solid-server/auth.js'; // v0.0.214+
async function myAppHandler(request, reply) {
const agent = await getAgent(request);
if (!agent) return reply.code(401).send({ error: 'sign in with your pod' });
// agent is a verified identifier — key your app's users on it
}getAgent returns the verified agent identifier: usually an HTTP(S)
WebID, but a did:nostr:... DID for NIP-98 agents without a WebID mapping —
DID agents are first-class. It covers all five credential schemes (IdP
Bearer, Solid-OIDC DPoP, Nostr NIP-98, LWS10-CID, WebID-TLS), never throws
on bad credentials, and everything under src/ stays internal — this
import is the contract.
Browser users don't send Authorization headers by themselves. The
pattern proven in Tideholm: the app's login screen POSTs pod credentials to
JSS's documented /idp/credentials
endpoint, stores the returned Bearer token, and attaches it to the app's
API calls. Tokens expire after 3600s — handle the 401 by returning to the
login screen.
If your "plugin" is an existing node-style HTTP app ((req, res) handler),
two gotchas bite: Fastify's content parsers consume request bodies
before handlers run (so a wrapped app hangs waiting for a stream that's been
drained), and Fastify wants to own the response. api.mountApp (v0.0.217,
#590)
handles both — plus the appPaths exemption and reply.hijack():
export async function activate(api) {
await api.mountApp(
(req, res) => myNodeApp.handle(req, res), // your (req, res) app
{ prefix: api.prefix }, // omit to use the entry prefix
);
}The wrapped app receives the undrained request stream and owns the
response — including gzipped bodies, which it inflates itself. Need the
caller's identity inside it? Resolve it before handing off:
req.agent = await api.auth.getAgent(request).
Under the hood it's a scoped pass-through content-type parser plus
reply.hijack(), registered on both the bare prefix and its subtree — the
pattern plugins rediscovered the hard way before the helper shipped
(#583).
Every seam above was forced by a real consumer before it shipped — that's
the house method, and it scaled. A
33-plugin out-of-tree suite
now exercises the whole api — a status dashboard that auto-discovers its
siblings via api.plugins, the WebDAV/CalDAV/CardDAV family, Mastodon /
Bluesky / Matrix / ActivityPub shims, an S3 gateway, JMAP mail, Micropub,
remoteStorage, and more, all on one server from pure config. Each plugin's
README records the seam it needed and the wall it hit — the findings that
drove serverInfo, reservePath, and api.plugins upstream. The two games
remain the original forcing consumers, live at
nostr.social/tideholm and
nostr.social/bridge:
Tideholm (plugin zero —
forced appPaths and getAgent): a multiplayer island-strategy game whose
core is zero-dependency and transport-agnostic, with a ~100-line adapter at
jss-plugin/.
- WebID → player: first authenticated request auto-provisions a game account keyed to the pod identity. Two pods, two players. No passwords.
- Same origin, both worlds:
/alice/profile/card.jsonld(LDP + WAC) and/tideholm/api/state(game auth) serve side by side. - Dual mode: the same game runs standalone (
node server.js, its own password accounts) or mounted (pod identity), switching via a tinyGET /api/metathe client reads at boot.
bridge (plugin two — forced
ws.route): a realtime card game, no build step, WebSocket tables. It
contributed the ticket pattern for WebSocket auth: the client
authenticates a normal HTTP request (/bridge/auth/nip98), getAgent
verifies it — NIP-98 signature or pod Bearer, same call — and the app mints
a one-use short-TTL ticket presented in the first socket message.
Credentials never cross the upgrade, and the host never touches the app's
socket protocol.
Both adapters export the activate(api) contract, so one server can run
both games from six lines of plugins: config — one pod account is a
Tideholm player and a bridge seat with a single sign-in.
- Migrate the bundled features onto the loader (#564) — the relay, ActivityPub, git, pay and friends become battle-tested consumers, one at a time.
- The next demanded seams —
api.events.onResourceChange(react to pod writes; seven consumers waiting) andapi.authorize(ask WAC about authority the caller doesn't drive), thenregisterMcpToolandregisterPane, with the pane store as the eventual marketplace layer. - Publish the plugin suite as a package so
--plugin @jss/plugins/dashboard@/dashboardloads by name, no clone.
The pattern for contributing a seam is established: build a real thing
against JSS, hit a wall, file the smallest issue that removes it, prove it
with your consumer. Seven seams (appPaths, getAgent, ws.route,
mountApp, serverInfo, reservePath, api.plugins) and the loader
itself each went from idea to npm this way — the door is open.