FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Plugin system for JSS · Issue #206 · JavaScriptSolidServer/JavaScriptSolidServer · GitHub

Repository navigation

Plugin system for JSS #206

Open
Open

Description

Summary

Develop a plugin system for JSS so that applications can register routes, middleware, and UI panes in a standardized way.

Motivation

Currently applications like bitmark-explorer register routes directly as a Fastify plugin. A formalized plugin system would allow:

  • Standardized route registration
  • UI pane discovery and loading
  • Shared middleware (auth, caching, etc.)
  • Easier development of new JSS-based applications

Activity

  1. melvincarvalho commented on Jun 11, 2026

    ContributorAuthor

    Update 2026-06-12 — jsclaw just shipped a plugin system built from an audit of openclaw's (jsclaw/jsclaw#64, landed in jsclaw/jsclaw#77, ~100-line loader + tests). Most of the design transfers to JSS directly, and the JSS-specific parts are clarifying rather than complicating.

    The reframe

    JSS already is a plugin system internally: server.js composes notifications, IDP, webrtc, terminal, tunnel, ActivityPub, remoteStorage, and MCP as Fastify plugins gated by createServer({...}) flags. What this issue actually needs is the missing outer layer: third parties extending JSS without editing server.js — discovery, manifest, policy, per-plugin config.

    What transfers verbatim (openclaw's vocabulary, now in jsclaw too)

    • Manifest + entry: jss.plugin.json { id, version, entry, description? }; entry module exports activate(api)
    • Config (same shape in openclaw and jsclaw — ecosystem consistency for free):
      { "plugins": {
          "enabled": true,
          "allow": null, "deny": [],
          "load": { "paths": ["./my-plugin"] },
          "entries": { "my-plugin": { "enabled": true, "config": { } } }
      } }
      Policy: deny > allow > per-entry enabled. Failing plugins warn and skip, never crash the server. Loaded ids print at boot.

    The api — JSS's seams (differ from jsclaw's, but rhyme)

    • api.fastify — a scoped Fastify instance. Don't reinvent route/middleware registration; Fastify encapsulation already is that story, and every in-tree JSS feature proves the pattern. (This answers the "routes + middleware" part of the issue with zero new machinery.)
    • api.registerMcpTool(name, { description, inputSchema, handler }) — plugins extend the pod's MCP surface (the TOOLS table in src/mcp/tools.js). Sleeper feature: a CardDAV plugin (Add CardDAV plugin for contact sync with phones and desktops #157) adding contacts_* tools enriches the pod for every MCP-speaking agent.
    • api.registerPane({ name, path, ... }) — pane discovery; Pane store: discover and install premium panes (like WordPress plugins) #184's pane store becomes "installable plugins that ship UI".
    • api.storage / auth-chain helpers — the superpowers external apps currently fork JSS to reach.

    One JSS-specific rule, stated loudly

    Plugins load only from operator config paths — never auto-discovered from pod storage. Pods are user-writable; a loader that touches them is RCE via file upload. (openclaw's "workspace-origin plugins default-disabled" is the same instinct; JSS needs the strict version.)

    Sequencing

    1. Loader + api.fastify + policy (small; jsclaw's src/plugins.js is a working reference at ~100 lines)
    2. Extract WebFinger and OAuth into standalone always-on plugins #164 (WebFinger/OAuth as always-on plugins) as the in-tree proving ground
    3. registerMcpTool + registerPane (Add CardDAV plugin for contact sync with phones and desktops #157 CardDAV as first real consumer)
    4. Pane store: discover and install premium panes (like WordPress plugins) #184 pane store as the marketplace layer on top

    Related: jsclaw/jsclaw#64 (audit of openclaw's system: sources, slots, hooks, install policy — the deferred long tail documented there applies here too).

  2. melvincarvalho commented on Jul 10, 2026

    ContributorAuthor

    RFC: Tideholm as plugin zero — a real third-party consumer to pin the api

    The loader design above is ready to build; what it's missing is the thing jsclaw had and openclaw before it: a real external consumer developed against the seams while they land. In-tree migrations (#564) prove the mechanics but can't keep the api honest — they were all written with access to server.js internals and it shows in what they never ask for.

    Proposal: port Tideholm — a zero-dependency island-strategy browser game (~7.4k lines, engine/HTTP cleanly split, 160 tests, bots that play by the same rules as humans) — as the first third-party plugin. A playable game is the most legible plugin a system can ship (the WordPress dynamic: nobody understands a platform from a WebFinger extraction; everybody understands jss install tideholm).

    The seams it forces that #564's migrations won't

    Seam Why in-tree features never asked
    api.auth.getAgent(request) → WebID / did:nostr Bundled features sit inside the auth chain; an app sits behind it as a consumer. Tideholm drops its own password accounts in plugin mode: your pod identity is your player — auto-provisioned on first authenticated request. This is the agentic-web demo in one sentence.
    api.storage.pluginDir(id) Private server-side state (world.json, season archives). Explicitly not pod storage — consistent with the "never load/execute from pods" rule, and game state is server-authoritative, not user data.
    deactivate() lifecycle Tideholm runs interval timers (bot tick, autosave) and must save the world on teardown. First plugin to force the shutdown contract the loader will eventually need anyway.
    entries.<id>.config passthrough { "gameSpeed": 5, "bots": 20, "freeIsles": 30 } — exercises per-plugin config with something a user actually wants to tune.

    Shape (adapter is ~150 lines; engine stays zero-dep)

    // jss.plugin.json
    { "id": "tideholm", "version": "1.0.0", "entry": "./plugin.js",
      "description": "Island empire strategy game — log in with your pod" }
    
    // plugin.js
    export async function activate(api) {
      const game = createGame({ dataDir: api.storage.pluginDir('tideholm'), ...api.config });
      await api.fastify.register(async (f) => {
        f.addHook('preHandler', async (req) => {
          req.player = game.playerFor(await api.auth.getAgent(req)); // WebID -> player
        });
        registerRoutes(f, game);   // existing route table, transport-agnostic already
      });                          // + static assets under /tideholm/
      game.start();                // bot tick + autosave intervals
      return { deactivate: () => game.stop() }; // saves world, clears timers
    }

    Standalone node server.js keeps working — the game keeps its zero-dependency core and gains a thin adapter, so it's also a living example of "app that runs alone or as a plugin".

    Sequencing (slots into #564's plan without reordering it)

    1. Loader + api.fastify + policy land as designed above
    2. Tunnel/remoteStorage prove the in-tree path (Migrate optional features to the plugin system; define the core/plugin line #564 step 2)
    3. Tideholm proves the third-party path — and it can start today as a plain Fastify plugin (the bitmark-explorer pattern), flipping to the manifest the day the loader merges

    Once landed, the follow-ups write themselves

    I'll build the adapter against whatever the loader lands with — treat this as a standing offer of a plugin-zero test consumer for every api decision above.

  3. melvincarvalho commented on Jul 10, 2026

    ContributorAuthor

    Plugin zero is running: a pod identity plays Tideholm inside JSS

    Follow-up to the RFC above — the adapter is built, and the end-to-end works today: melvincarvalho/tideholm jss-plugin/ (adapter + jss.plugin.json manifest + runnable demo.mjs).

    JSS_PATH=/path/to/JavaScriptSolidServer node jss-plugin/demo.mjs
    

    boots a real JSS (IdP on), registers a pod account over HTTP, mints a Bearer token from /idp/credentials, and then — all 12 checks green:

    • anonymous GET /tideholm/api/state → 401 from the game's own auth, not WAC
    • same request with the token → player auto-provisioned from the WebID (alice), starting island and all
    • POST /tideholm/api/build flows through the whole stack (JSS auth → adapter → game engine)
    • game client served at /tideholm/, LDP still serving alice's profile on the same origin
    • second pod (bob) → second player; the game world is keyed by WebID (player.extId)

    The adapter is ~100 lines of glue, exports both contracts from the RFC — tideholmApp() for today's plain-Fastify composition and activate(api) for the loader — and the game core stayed zero-dependency.

    Two things plugin zero forced that the loader design above doesn't cover yet

    1. Plugins need WAC-hook exemption. The global preHandler authorizes every URL against pod ACLs and rejects before plugin routes run — a plugin's POSTs die in WAC unless its prefix is on the hook's hardcoded exempt list (which is how /storage/, /db/, /mcp etc. escape today). The demo runs on this 8-line seam:

    @@ createServer options @@
    +  // Application mount points (plugin seam, #206): URL prefixes owned by
    +  // registered apps (e.g. a game mounted at /tideholm). Requests below an
    +  // app path skip the WAC hook — the app owns authentication and
    +  // authorization under its prefix, like /storage/ and /db/ already do.
    +  const appPaths = Array.isArray(options.appPaths)
    +    ? options.appPaths.filter((p) => typeof p === 'string' && p.startsWith('/') && p.length > 1)
    +    : [];
    @@ the WAC preHandler exempt condition @@
    +        appPaths.some(p => request.url === p || request.url.startsWith(p + '/') || request.url.startsWith(p + '?')) ||

    For the loader: api.fastify should carry this implicitly — a plugin's declared prefix is its jurisdiction. (appPaths also stands alone as a useful createServer option for the bitmark-explorer-style compositions that exist now. Happy to PR it.)

    2. Plugins that read raw bodies need a content-parser escape. Fastify's JSON parser consumes the request stream before the handler runs, so a plugin that proxies to its own node-style handler hangs waiting for a body that's already been eaten. The fix is the standard proxy pattern, scoped so it can't affect the host:

    await fastify.register(async (scope) => {
      scope.removeAllContentTypeParsers();
      scope.addContentTypeParser('*', (req, payload, done) => done(null, payload));
      scope.all(prefix, handler);
      scope.all(prefix + '/*', handler);
    });

    The loader's api.fastify should offer this as a mode (rawBody: true or similar) — any plugin wrapping an existing HTTP app will hit it.

    Identity turned out to need no new seam at all: getWebIdFromRequestAsync(request) from src/auth/token.js already does Bearer/DPoP/NIP-98/LWS-CID uniformly — it just needs to be blessed as public api (api.auth.getAgent), not reached into.

  4. melvincarvalho commented on Jul 10, 2026

    ContributorAuthor

    Seam scoreboard update:

    Seam Status
    appPaths (WAC-exempt app mounts) ✅ v0.0.213 — #582 / #585
    getAgent (request → agent identifier) ✅ v0.0.214 — #584 / #586. Pre-loader shape: import { getAgent } from 'javascript-solid-server/auth.js'; when the loader lands, api.auth.getAgent is this function
    Raw-body mode / api.mountApp 📋 #583 — pattern documented, helper unbuilt
    Loader (manifest, discovery, policy) 🔭 this issue + #564

    Plugin zero (Tideholm) migrates onto the public getAgent import once 0.0.214 hits npm, at which point the reference plugin uses zero internal imports — everything it touches is either the returned fastify instance, appPaths, or auth.js. The loader is now genuinely the only missing piece between "compose it yourself" and jss start with a plugins config.

  5. melvincarvalho commented on Jul 10, 2026

    ContributorAuthor

    Seam three, from the first realtime consumer (bridge, live at https://nostr.social/bridge/): WebSocket upgrade routing — #588. HTTP mounting (#582) and auth (#584) composed unchanged; upgrades are the one thing Fastify never shows a plugin, plus a destroy-unclaimed-sockets invariant someone must own. Proposal there is a ~15-line api.ws.route(prefix, handler) on the loader api.

  6. melvincarvalho commented on Jul 10, 2026

    ContributorAuthor

    The loader shipped: createServer({ plugins: [{ module, prefix, config, id }] }) — PR #589, merged as c516bb6, version 0.0.215.

    Each module's activate(api) runs at startup with the seams this exercise forced, in the order they were forced: the entry's prefix auto-joins appPaths (#582, 0.0.213), api.auth.getAgent verifies any host credential (#584, 0.0.214), api.ws.route gives realtime apps WebSocket endpoints without owning an 'upgrade' listener (#588), plus api.storage.pluginDir() and dual-dialect logging. Docs: configuration.md → App Plugins.

    Validated the plugin-zero way: both real consumers (Tideholm and bridge) compose from pure config in one server — one pod account plays both games, bridge over the loader-owned WebSocket.

    Remaining from this RFC's scope, deliberately deferred: a plugins block in the CLI config file (makes 'install an app = edit config' real for non-programmatic deployments), #583 raw-body mode, #564 migrating the bundled features onto the loader, and UI-pane discovery (still no consumer needing it).

  7. added a commit that references this issue on Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions


      Back | FazBrowse Home | New Git URL