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', 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.

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: {
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() },
},
})

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>

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