Rooms reference
Durable Object base classes - RecordRoom, YjsRoom, CanvasRoom, PresenceRoom, CronRoom, GameRoom, JobRoom.
The SDK ships seven Durable Object base classes. Subclass them in worker.ts, declare them in __DO_MANIFEST__, and the SDK handles WebSocket upgrades, RBAC, persistence, and broadcast.
import {
BaseRoom, RecordRoom, YjsRoom, CanvasRoom,
PresenceRoom, CronRoom, GameRoom, JobRoom,
} from 'deepspace/worker'
import type {
RecordRoomConfig, CronRoomConfig, GameRoomConfig,
CronTask, CronExecution, CanvasShape, Viewport,
PresencePeer, Player, GameInput, UserAttachment,
Job, JobContext,
} from 'deepspace/worker'
Each base class is parameterized over your Env interface so this.env.<binding> is typed inside overrides.
BaseRoom<E>#
Abstract parent of all rooms. Provides WebSocket plumbing, JWT identity parsing, and the connection lifecycle. Subclass directly only when none of the specialized rooms fit - rare in practice.
abstract class BaseRoom<E = Record<string, unknown>> {
constructor(state: DurableObjectState, env: unknown)
// Lifecycle hooks subclasses override
protected onConnect(
ws: WebSocket,
user: UserAttachment,
): UserAttachment | void | Promise<UserAttachment | void>
protected abstract onMessage(
ws: WebSocket,
user: UserAttachment,
message: { type: string; [key: string]: unknown },
): void | Promise<void>
protected onBinaryMessage?(
ws: WebSocket,
user: UserAttachment,
data: ArrayBuffer,
): void | Promise<void>
protected onDisconnect(
ws: WebSocket,
user: UserAttachment,
): void | Promise<void>
protected onRequest?(request: Request): Response | Promise<Response>
protected onAlarm?(): void | Promise<void>
}
interface UserAttachment {
userId: string
userName: string
userEmail: string
userImageUrl?: string
// Subclass-specific data is serialized alongside user info
[key: string]: unknown
}
onConnect may return an augmented UserAttachment - the returned value (or the default) is serialized on the WebSocket via state.acceptWebSocket(...) and survives DO hibernation.
RecordRoom<E>#
Primary data DO - backs every record collection your app declares.
class RecordRoom<E = Record<string, unknown>> extends BaseRoom<E> {
constructor(
state: DurableObjectState,
env: unknown,
schemas?: CollectionSchema[],
config?: RecordRoomConfig,
)
}
interface RecordRoomConfig {
/** User ID of the app owner. Automatically gets the `admin` role on connect. */
ownerUserId?: string
}
Scaffold pattern:
export class AppRecordRoom extends RecordRoom<Env> {
constructor(state: DurableObjectState, env: Env) {
super(state, env, schemas, { ownerUserId: env.OWNER_USER_ID })
}
}
YjsRoom<E>#
Per-document collaborative state (Y.Text, Y.Map, Y.Array).
class YjsRoom<E = Record<string, unknown>> extends BaseRoom<E> {
constructor(state: DurableObjectState, env: unknown)
}
Connected to via /ws/yjs/:docId. The DO persists the full Yjs update as a single binary blob in SQLite.
CanvasRoom<E>#
Collaborative canvas - shapes and viewports.
class CanvasRoom<E = Record<string, unknown>> extends BaseRoom<E> {
constructor(state: DurableObjectState, env: unknown)
}
interface CanvasShape {
id: string
type: string
x: number
y: number
width: number
height: number
rotation?: number
props: Record<string, unknown>
createdBy: string
createdAt: string
updatedAt: string
}
interface Viewport {
userId: string
x: number
y: number
width: number
height: number
zoom: number
}
Connected to via /ws/canvas/:docId.
PresenceRoom<E>#
Ephemeral peer state - cursors, typing indicators, viewports. Not persisted.
class PresenceRoom<E = Record<string, unknown>> extends BaseRoom<E> {
constructor(state: DurableObjectState, env: unknown)
}
interface PresencePeer {
userId: string
userName: string
userEmail: string
userImageUrl?: string
joinedAt: string
/** Arbitrary per-user state (cursor, typing, viewport, etc.) */
state: Record<string, unknown>
}
Connected to via /ws/presence/:scopeId.
CronRoom<E>#
Scheduled-task DO. Declare tasks in the constructor config and override onTask.
abstract class CronRoom<E = Record<string, unknown>> extends BaseRoom<E> {
constructor(state: DurableObjectState, env: unknown, config: CronRoomConfig)
protected abstract onTask(taskName: string): void | Promise<void>
}
interface CronRoomConfig {
tasks: CronTask[]
}
interface CronTask {
name: string
/** Interval in minutes - mutually exclusive with `schedule`. */
intervalMinutes?: number
/** 5-field cron expression - requires `timezone`. */
schedule?: string
/** IANA timezone string (e.g. "America/New_York"). Required with `schedule`. */
timezone?: string
/** Whether the task starts paused. */
paused?: boolean
}
interface CronExecution {
taskName: string
startedAt: string
/** Null while the task is still running. */
completedAt: string | null
success: boolean
durationMs: number
error?: string
}
Scaffold pattern:
export class AppCronRoom extends CronRoom<Env> {
constructor(state: DurableObjectState, env: Env) {
super(state, env, { tasks: cronTasks })
}
protected async onTask(name: string) {
await runCronTask(name, this.env)
}
}
Connected to via /ws/cron/:roomId (admin/monitor stream).
GameRoom<E>#
Authoritative tick-based game loop DO.
abstract class GameRoom<E = Record<string, unknown>> extends BaseRoom<E> {
constructor(state: DurableObjectState, env: unknown, config?: GameRoomConfig)
protected abstract onTick(
state: Record<string, unknown>,
inputs: GameInput[],
tick: number,
): Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>
protected onPlayerJoin(player: Player): void
protected onPlayerLeave(player: Player): void
protected onGameStart(): void
protected onGameEnd(finalState: Record<string, unknown>): void
/** Override to migrate persisted state on schema bumps. */
protected onHydrateState(stored: Record<string, unknown>): Record<string, unknown>
}
interface GameRoomConfig {
/** Ticks per second (default: 20) */
tickRate?: number
/** Minimum players to start (default: 1) */
minPlayers?: number
/** Maximum players (default: unlimited) */
maxPlayers?: number
}
interface Player {
userId: string
userName: string
ready: boolean
connectedAt: string
data: Record<string, unknown>
}
interface GameInput {
userId: string
action: string
data: Record<string, unknown>
tick: number
}
Connected to via /ws/game/:roomId.
JobRoom<E>#
Durable background-job DO. Handlers run on the DO's alarm with a ~15-minute wall budget per tick (chain longer jobs with ctx.continue(state)). Jobs are persisted in SQLite, survive isolate restarts, and broadcast every state change over WebSocket - clients see live progress without polling. For worked patterns, see the Background jobs guide.
abstract class JobRoom<
E = Record<string, unknown>,
P = unknown,
R = unknown,
> extends BaseRoom<E> {
constructor(state: DurableObjectState, env: unknown, config?: JobRoomConfig)
protected abstract onJob(job: Job<P>, ctx: JobContext): R | void | Promise<R | void>
}
interface JobRoomConfig {
/** Default retry budget for jobs that don't pass `maxAttempts` to `enqueue`. Default 1 (no auto-retry). */
defaultMaxAttempts?: number
/** How long terminal rows (succeeded / failed / canceled) are kept. Default 24h. */
retentionMs?: number
/** Default retry backoff for failed jobs. Default 1000ms. */
retryBackoffMs?: number
/** How many historical rows the DO holds in its in-memory snapshot before evicting. Default 100. */
snapshotLimit?: number
}
interface Job {
id: string
type: string
payload?: unknown
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled'
attempts: number
maxAttempts: number
/** Return value of a successful `onJob`. */
result?: unknown
/** Error message from a thrown `onJob`. */
error?: string
/** Last `ctx.progress(value)` value (0..1). Present while live. */
progress?: number
/** Last `ctx.progress(_, message)` string. */
progressMessage?: string
enqueuedAt: string
startedAt?: string | null
completedAt?: string | null
enqueuedBy?: string | null
/** State checkpoint from a previous `ctx.continue(state)` call. */
resumeFrom?: unknown
}
interface JobContext {
/** Broadcast progress (0..1) and an optional human-readable message. */
progress(value: number, message?: string): void
/** AbortSignal that fires on client cancel. Pass to `fetch(url, { signal })`. */
signal: AbortSignal
/** Checkpoint state and yield to the next alarm tick. Call this then `return`. */
continue(state: unknown, opts?: { afterMs?: number }): void
}
The onJob return value becomes job.result (must be JSON-serializable); throwing fails the job and triggers a retry if attempts < maxAttempts. There is no ctx.complete() / ctx.fail() - return or throw. Sync returns are allowed - onJob does not have to be async.
P and R narrow the job payload and result types: JobRoom<Env, MyPayload, MyResult> gives you onJob(job: Job<MyPayload>, ctx): MyResult | void | Promise<MyResult | void>. Most apps leave them as unknown and cast at the dispatch site (see the scaffold pattern below).
Scaffold pattern:
export class AppJobRoom extends JobRoom<Env> {
constructor(state: DurableObjectState, env: Env) {
super(state, env)
}
protected async onJob(job: Job, ctx: JobContext): Promise<unknown> {
return await runJob(job, ctx, this.env)
}
}
Connected to via /ws/jobs/:roomId. For the worker-side enqueue helper that lets you enqueue from HTTP routes, cron handlers, or server actions (different isolate from the DO), see enqueueJob below.
enqueueJob(namespace, roomId, type, payload?, opts?)#
Cross-isolate enqueue helper - use anywhere outside the JobRoom DO (HTTP routes, cron handlers, server actions, AI routes).
function enqueueJob(
namespace: DurableObjectNamespace,
roomId: string,
type: string,
payload?: unknown,
opts?: { maxAttempts?: number; enqueuedBy?: string },
): Promise<string> // resolves with the jobId
Pass app:${env.APP_NAME} as roomId to hit the per-app AppJobRoom. From inside an onJob handler, call this.enqueue(...) directly instead - it skips the HTTP hop.
Audio/video rooms have no SDK DO class. Use LiveKit via the
livekit/*integration endpoints instead.
The DO manifest#
import type { DOManifest, DOManifestEntry, DOBindings } from 'deepspace/worker'
| Export | Type | Description |
|---|---|---|
DOManifest |
type | DOManifestEntry[] - shape of __DO_MANIFEST__. |
DOManifestEntry |
type | { binding: string; className: string; sqlite: boolean }. |
DOBindings<typeof __DO_MANIFEST__> |
type | Derives the Env interface's DO bindings from the manifest. |
DEFAULT_DO_MANIFEST |
const | Two-entry fallback (RECORD_ROOMS + YJS_ROOMS) used when an app doesn't export __DO_MANIFEST__. |
The scaffold's pattern:
export const __DO_MANIFEST__ = [
{ binding: 'RECORD_ROOMS', className: 'AppRecordRoom', sqlite: true },
{ binding: 'YJS_ROOMS', className: 'AppYjsRoom', sqlite: true },
{ binding: 'CANVAS_ROOMS', className: 'AppCanvasRoom', sqlite: true },
{ binding: 'PRESENCE_ROOMS', className: 'AppPresenceRoom', sqlite: true },
{ binding: 'CRON_ROOMS', className: 'AppCronRoom', sqlite: true },
{ binding: 'JOB_ROOMS', className: 'AppJobRoom', sqlite: true },
] as const satisfies DOManifest
interface Env extends DOBindings<typeof __DO_MANIFEST__> {
// ...secrets and custom bindings
}
See also#
- Architecture concepts - how DOs fit into the system
- Get started → Project structure - the DO manifest in context
- Cron guide - patterns for
CronRoom