Skip to content

Server Setup

Example server implementations:

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', (ws, req) => {
const conn = 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 }),
}),
})
ws.on('message', (data) => conn.onMessage(String(data)))
ws.on('close', conn.onClose)
ws.on('error', conn.onError)
conn.ready.catch((err) => ws.close(1008, (err as Error).message))
})
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.

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:

// Bun
Bun.serve({
fetch(req, server) {
if (server.upgrade(req, { data: { req } })) return
return new Response('ok')
},
websocket: {
open(ws) {
const conn = 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' }
},
})
ws.data.conn = conn
conn.ready.catch((err) => ws.close(1008, (err as Error).message))
},
message(ws, message) { ws.data.conn?.onMessage(String(message)) },
close(ws) { ws.data.conn?.onClose() },
},
})

The optional metadata field returned from authorize is stored on the session and refreshed automatically when the client swaps tokens. Read it back with conn.getMetadata(), or via room.getSessionMetadata(sessionId) once ready resolves — useful for caching the verified token or claims when the server later makes outbound calls on the user’s behalf.

const { room, sessionId } = await conn.ready
const meta = room.getSessionMetadata(sessionId)
// ^? unknown — type via the generic on acceptConnection<TRequest, TMeta>
// Or, at any point after ready, without destructuring:
const sameMeta = conn.getMetadata() // undefined until ready resolves

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
}
}

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 }, ...]

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))
})

Rooms persist on a throttled timer, so a process that exits abruptly can lose edits made since the last save. Call manager.closeAll() from your signal handlers: it’s async and, before resolving, disconnects every client and flushes each room to storage — so await it before exiting.

async function shutdown(signal: string) {
console.log(`${signal} received, flushing rooms...`)
server.close() // stop accepting new connections
await manager.closeAll() // disconnect clients, then flush every room
process.exit(0)
}
// Kubernetes sends SIGTERM on rollout/scale-down; SIGINT is Ctrl-C locally.
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))