You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
awaitfastify.listen({ port });game.attachUpgrade(fastify.server);// routes <prefix>/ws, after listen
Two sharp edges found doing it:
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.
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.
Third seam from the #206 plugin-zero exercise.
appPaths(#582, shipped 0.0.213) andgetAgent(#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),getAgentverifies 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:Two sharp edges found doing it:
'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 strayws://host/elsewhereconnect 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.@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):
The loader attaches the single
'upgrade'listener, dispatches by longest matching prefix, and destroys unclaimed sockets. Plugins bring their ownws/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.