Server Setup
Example server implementations:
Quick Start
Section titled “Quick Start”import { createServer } from 'node:http'import { acceptConnection, FileStorage, RoomManager } from '@woven-ecs/canvas-store-server'import { WebSocketServer } from 'ws'
const manager = new RoomManager({ idleTimeout: 60_000 })
const server = createServer((_req, res) => res.writeHead(200).end('ok'))const wss = new WebSocketServer({ server })
wss.on('connection', async (ws, req) => { let conn try { conn = await acceptConnection({ socket: ws, url: req.url ?? '', request: req, manager, authorize: async ({ roomId, token }) => { const claims = await validateToken(token) // your verifier if (claims.roomId !== roomId) throw new Error('token does not match this room') return { permissions: claims.canWrite ? 'readwrite' : 'readonly', metadata: { claims }, // optional — exposed via room.getSessionMetadata() } }, roomOptions: (roomId) => ({ createStorage: () => new FileStorage({ dir: './data', roomId }), }), }) } catch (err) { ws.close(1008, (err as Error).message) return }
ws.on('message', (data) => conn.onMessage(String(data))) ws.on('close', conn.onClose) ws.on('error', conn.onError)})
server.listen(8080)That’s the full setup. No URL parsing, no manual handleSocketConnect/onTokenRefresh plumbing — acceptConnection owns the protocol and re-runs authorize whenever the client sends a fresh token. If authorize throws on a refresh, the room closes the socket; the client’s normal reconnect flow then mints a new token and retries.
acceptConnection is runtime-agnostic
Section titled “acceptConnection is runtime-agnostic”It depends only on a WebSocketLike socket ({ send, close }) and a URL string. Adapting to Bun, Deno, or uWebSockets is a matter of forwarding events from the runtime’s WebSocket events into conn.onMessage / conn.onClose / conn.onError:
// BunBun.serve({ fetch(req, server) { if (server.upgrade(req, { data: { req } })) return return new Response('ok') }, websocket: { async open(ws) { ws.data.conn = await acceptConnection({ socket: ws, url: ws.data.req.url, request: ws.data.req, manager, authorize: async ({ roomId, token }) => { const claims = await validateToken(token) if (claims.roomId !== roomId) throw new Error('mismatch') return { permissions: claims.canWrite ? 'readwrite' : 'readonly' } }, }) }, message(ws, message) { ws.data.conn?.onMessage(String(message)) }, close(ws) { ws.data.conn?.onClose() }, },})Per-session metadata
Section titled “Per-session metadata”The optional metadata field returned from authorize is stored on the session and refreshed automatically when the client swaps tokens. Read it back with room.getSessionMetadata(sessionId) — useful for caching the verified token or claims when the server later makes outbound calls on the user’s behalf.
const meta = conn.room.getSessionMetadata(conn.sessionId)// ^? unknown — type via the generic on acceptConnection<TRequest, TMeta>Storage Backends
Section titled “Storage Backends”canvas-store-server provides some built-in storage options, but you can also implement your own by adhering to the Storage interface:
import { MemoryStorage, FileStorage } from '@woven-ecs/canvas-store-server';
// In-memory (development)new MemoryStorage();
// File-based (simple persistence)new FileStorage({ dir: './data', roomId: 'doc-123' });
// Custom (implement the Storage interface)class PostgresStorage implements Storage { async load(): Promise<RoomSnapshot | null> { // Load from database } async save(snapshot: RoomSnapshot): Promise<void> { // Save to database }}Permissions
Section titled “Permissions”Each session has a permission level: readwrite or readonly. With acceptConnection, you return permissions from authorize. Without it (low-level path), pass them to handleSocketConnect:
const sessionId = room.handleSocketConnect({ socket: ws, clientId, permissions: 'readonly', // or 'readwrite'});readwrite— client can send and receive patches.readonly— client can only receive patches.
You can change permissions dynamically:
room.setSessionPermissions(sessionId, 'readwrite');const perms = room.getSessionPermissions(sessionId);To list all connected sessions:
const sessions = room.getSessions();// [{ sessionId, clientId, permissions, metadata }, ...]Low-level API
Section titled “Low-level API”If acceptConnection doesn’t fit your setup — custom URL params, non-standard handshake, fully-custom auth flow — drop down to the lower-level building blocks. You’re then responsible for parsing the URL, calling your verifier, and wiring onTokenRefresh:
import { RoomManager, FileStorage } from '@woven-ecs/canvas-store-server'
wss.on('connection', async (ws, req) => { const url = new URL(req.url!, 'http://_') const roomId = url.searchParams.get('roomId')! const clientId = url.searchParams.get('clientId')! const token = url.searchParams.get('token')!
// Defined locally so it closes over `roomId` and is reused for refresh. const authorize = async (t: string) => { const claims = await validateToken(t) if (claims.roomId !== roomId) throw new Error('mismatch') return { permissions: claims.canWrite ? 'readwrite' : 'readonly' as const } }
let auth try { auth = await authorize(token) } catch { ws.close(1008, 'Unauthorized'); return }
const room = await manager.getOrCreateRoom(roomId, { createStorage: () => new FileStorage({ dir: './data', roomId }), onTokenRefresh: (_, info) => authorize(info.token), })
const sessionId = room.handleSocketConnect({ socket: ws, clientId, permissions: auth.permissions, })
ws.on('message', (data) => room.handleSocketMessage(sessionId, String(data))) ws.on('close', () => room.handleSocketClose(sessionId)) ws.on('error', () => room.handleSocketError(sessionId))})