JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 23
  • Score
    100M100P100Q98464F
  • License MIT

Relay-only WebSocket server for shareable-data-store (Hono, JWT auth, multi-store)

Package Exports

  • @rozek/sds-websocket-server

Readme

@rozek/sds-websocket-server

The relay server for the shareable-data-store (SDS) family. A Hono-based WebSocket server that:

  • authenticates clients with JWT (HS256)
  • relays CRDT patches between connected peers
  • enforces scope-based access control (read / write / admin)
  • provides a WebRTC signalling relay
  • issues short-lived access tokens via an admin API

The server is relay-only: it holds no store state between connections. Use a @rozek/sds-sidecar-* daemon alongside the relay to keep a persistent local copy of any store.

Important — sync requires an online peer: Because the relay server holds no state, a sds store sync invocation can only receive remote data if at least one other peer (e.g. a sds-sidecar-* daemon) is connected to the same relay at the same time. If the other peer is offline when sync runs, the syncing client will see an empty store.

Runs on Node.js 22.5+ using @hono/node-server.

You may install the server locally or deploy it using a Docker image (refer to DEPLOYMENT.md)


Prerequisites

requirement details
Node.js 22.5+ required. Download from nodejs.org.

There are no runtime dependencies beyond @hono/node-server.


Installation

pnpm add @rozek/sds-websocket-server

Quick start

Minimal server

import { createSDSServer } from '@rozek/sds-websocket-server'

const { start } = createSDSServer({ JWTSecret:'your-secret-at-least-32-chars', Port:3000 })
start()

Important: Always use the start() helper rather than calling @hono/node-server's serve() directly. start() also calls injectWebSocket() internally, which is required for WebSocket upgrades to work. Skipping injectWebSocket() will cause all WebSocket connections to silently fail.

With environment variables

SDS_JWT_SECRET=your-secret-at-least-32-chars \
SDS_PORT=3000 \
SDS_HOST=0.0.0.0 \
node server.js

API reference

createSDSServer

function createSDSServer (Options?:Partial<SDS_ServerOptions>):{ app:Hono, start:() => void }

Returns the configured Hono application and a convenience start() function. Pass app.fetch to @hono/node-server's serve(), or call start() to let the server bind to the configured Port and Host automatically.

SDS_ServerOptions

interface SDS_ServerOptions {
  JWTSecret: string  // HMAC-SHA256 signing secret (required, min 32 chars)
  Issuer?:   string  // JWT iss claim to validate (optional)
  Port?:     number  // TCP port (default: 3000; also read from SDS_PORT)
  Host?:     string  // bind address (default: 127.0.0.1; also read from SDS_HOST)
}

Options take priority over environment variables.

Environment variables

variable default description
SDS_JWT_SECRET (required) HMAC-SHA256 signing secret — must be at least 32 characters
SDS_ISSUER (none) expected JWT iss claim; omit to skip issuer validation
SDS_PORT 3000 TCP port the server listens on
SDS_HOST 127.0.0.1 bind address (0.0.0.0 to listen on all interfaces)

Endpoints

GET /ws/:StoreId — CRDT sync WebSocket

Clients connect here to exchange CRDT patches in real time.

Authentication: a JWT is required as the token query parameter.

wss://my-server.example.com/ws/my-store?token=<jwt>

JWT claims:

claim required description
sub yes subject (user identifier)
aud yes must match :StoreId
scope yes 'read', 'write', or 'admin'
exp recommended expiry timestamp

JWT error handling:

condition behaviour
missing, malformed, expired, or otherwise invalid token WebSocket upgrade is accepted; connection is immediately closed with code 4001 (Unauthorized)
valid token but aud does not match :StoreId WebSocket upgrade is accepted; connection is immediately closed with code 4003 (Forbidden)

The upgrade is always accepted before the close is sent because Hono's WebSocket adapter requires an onOpen handler to issue the close frame — the HTTP-level handshake completes first in every case.

Scope enforcement:

scope may receive patches may send patches/values
read
write
admin

Frames sent by read clients with types 0x01 (PATCH), 0x02 (VALUE), or 0x05 (VALUE_CHUNK) are silently dropped. Presence frames (0x04) and value requests (0x03) are allowed for all scopes.

Relay behaviour: every incoming frame is forwarded to all other clients connected to the same :StoreId. The sender does not receive its own frame.


GET /signal/:StoreId — WebRTC signalling WebSocket

Relays JSON signalling messages (SDP offers/answers, ICE candidates) between peers for WebRTC connection setup. Used by @rozek/sds-network-webrtc.

Authentication: same JWT ?token= parameter as the sync endpoint.

wss://my-server.example.com/signal/my-store?token=<jwt>

JWT error handling: identical to the sync endpoint — invalid or mismatched token closes the connection with code 4001 or 4003 respectively.

Messages can be binary or text. The server broadcasts each message to all other peers connected to the same :StoreId — targeted delivery is not enforced by the server; clients use the message content (e.g. a to field) to decide which messages to act on.


POST /api/token — Token issuance (admin only)

Issues a new signed JWT. Requires an admin-scope Bearer token in the Authorization header.

Request:

POST /api/token
Authorization: Bearer <admin-jwt>
Content-Type: application/json

{
  "sub":   "alice",
  "scope": "write",
  "exp":   "1h"
}
field required description
sub yes subject (user identifier)
scope yes 'read', 'write', or 'admin'
exp no expiry in zeit/ms format, e.g. '1h', '7d'

The issued token automatically inherits the store ID (aud claim) from the admin token — there is no aud field in the request body. An admin token is store-scoped, so it can only issue tokens for its own store.

Response (200):

{ "token":"<signed-jwt>" }

Errors:

status body condition
400 { "error": "invalid JSON body" } request body cannot be parsed as JSON
401 { "error": "missing token" } no Authorization header
401 { "error": "invalid token" } token is malformed, expired, has wrong signature, etc.
403 { "error": "admin scope required" } token is valid but scope is not 'admin'

Internal helpers (exported for testing)

// per-store client registry
class LiveStore {
  constructor (StoreId:string)
  addClient (Client:LiveClient):void
  removeClient (Client:LiveClient):void
  isEmpty ():boolean
  broadcast (Data:Uint8Array, Sender:LiveClient):void
}

// returns true if a frame of type `MsgType` must be dropped for read-scope clients
function rejectWriteFrame (MsgType:number):boolean

// client interface expected by LiveStore
interface LiveClient {
  send:(Data:Uint8Array) => void
  scope:'read' | 'write' | 'admin'
}

Persistent sync peer

The relay server itself holds no store state. To keep a persistent local copy and fire webhooks on changes, run a @rozek/sds-sidecar-* daemon alongside the relay:

# json-joy backend
sds-sidecar-jj wss://relay.example.com my-store \
  --token "$SDS_TOKEN" \
  --on change

# Loro backend
sds-sidecar-loro wss://relay.example.com my-store \
  --token "$SDS_TOKEN" \
  --on change

# Y.js backend
sds-sidecar-yjs wss://relay.example.com my-store \
  --token "$SDS_TOKEN" \
  --on change

All clients connected to the same relay — including the sidecar — must use the same CRDT backend. Mixing backends causes silent data corruption.


Examples

Self-contained server with token issuance

import { createSDSServer } from '@rozek/sds-websocket-server'

const Secret = 'super-secret-key-at-least-32-chars!!'

const { start } = createSDSServer({ JWTSecret:Secret, Port:3000 })
start()

// clients connect to wss://host/ws/<storeId>?token=<jwt>
// admins issue tokens via POST /api/token (authenticated with an admin JWT)

Issuing a token programmatically (e.g. from your auth server)

import { SignJWT } from 'jose'

const Secret = new TextEncoder().encode('super-secret-key-at-least-32-chars!!')

const Token = await new SignJWT({ sub:'alice', aud:'my-store', scope:'write' })
  .setProtectedHeader({ alg:'HS256' })
  .setIssuedAt()
  .setExpirationTime('1h')
  .sign(Secret)

// pass this token to the client so it can connect:
// wss://host/ws/my-store?token=<Token>

Behind a reverse proxy (Caddy / nginx)

The server binds to 127.0.0.1:3000 by default. Point your reverse proxy at that address and handle TLS termination there:

# Caddyfile example
my-server.example.com {
  reverse_proxy /ws/*     localhost:3000
  reverse_proxy /signal/* localhost:3000
  reverse_proxy /api/*    localhost:3000
}

Deployment

For production use — Docker + Caddy, bare Node.js, security hardening — refer to DEPLOYMENT.md.


Wire protocol

The server is protocol-agnostic: it forwards raw binary frames without inspecting the payload (except for the one-byte type prefix used for scope enforcement). The frame format is defined by @rozek/sds-network-websocket:

byte name description
0x01 PATCH dropped for read-scope senders
0x02 VALUE dropped for read-scope senders
0x03 REQ_VALUE allowed for all scopes
0x04 PRESENCE allowed for all scopes
0x05 VALUE_CHUNK dropped for read-scope senders

License

MIT License © Andreas Rozek