Skip to content

Plugin api: WebSocket upgrade routing for realtime apps (api.ws.route) #588

Description

@melvincarvalho

Third seam from the #206 plugin-zero exercise. appPaths (#582, shipped 0.0.213) and getAgent (#584, shipped 0.0.214) were forced by Tideholm; this one comes from the first realtime consumer, bridge — an online card game now composed with JSS and live at https://nostr.social/bridge/. Full findings log: jss-plugin/NOTES.md.

What works today (no JSS changes)

The HTTP half of a WS app is just another app: appPaths + the scoped pass-through parser + reply.hijack(). Auth composes too — the client authenticates a plain HTTP request (/bridge/auth/nip98), getAgent verifies it (NIP-98 and pod Bearer both work), and the app mints a one-use short-TTL ticket presented in the first socket message. Credentials never cross the upgrade.

What has no seam

Fastify routes never see HTTP upgrades — they're a bare 'upgrade' event on the node server. So a realtime app has no sanctioned mount point; today the composer reaches around the api:

await fastify.listen({ port });
game.attachUpgrade(fastify.server);   // routes <prefix>/ws, after listen

Two sharp edges found doing it:

  1. The listener foot-gun. While a node http server has no 'upgrade' listener, it destroys stray upgrade attempts itself. The moment anyone attaches one, every unclaimed upgrade is their problem — an unhandled socket neither errors nor closes, it just hangs open forever (found empirically: a stray ws://host/elsewhere connect simply never resolved). Each plugin working around this independently means each assumes it owns the only listener — two realtime plugins on one host would fight or leak sockets.
  2. @fastify/websocket (used by the tunnel) also owns upgrade handling, so a plugin attaching its own listener coexists by luck, not contract (WebSocket upgrades fail intermittently under pm2: ERR_HTTP_SOCKET_ASSIGNED (@pm2/io httpMetrics vs @fastify/websocket) #545 is adjacent).

Proposal

A tiny loader-owned dispatcher (~15 lines):

api.ws.route(pathPrefix, (req, socket, head) => { ... })

The loader attaches the single 'upgrade' listener, dispatches by longest matching prefix, and destroys unclaimed sockets. Plugins bring their own ws/WebSocketServer({ noServer: true }) — JSS takes no dependency and does no protocol work; it only routes and enforces the destroy-unclaimed invariant.

Until the #206 loader exists, even documenting the fastify.server + attach-after-listen pattern (with edge 1 spelled out) on the plugins docs page would save the next app the debugging.

Metadata

Metadata

Assignees

No one assigned

    Labels

    pluginCould be implemented as a plugin (#206); core/plugin line defined in #564

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions