Skip to main content
Documentation

Rooms reference

Durable Object base classes - RecordRoom, YjsRoom, CanvasRoom, PresenceRoom, CronRoom, and JobRoom.

On this page

The SDK ships six specialized Durable Object base classes plus their shared BaseRoom parent. 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, JobRoom,
} from 'deepspace/worker'
import type {
  RecordRoomConfig, CronRoomConfig, JobRoomConfig,
  CronTask, CronExecution, CanvasShape, Viewport,
  PresencePeer, UserAttachment,
  Job, JobContext,
} from 'deepspace/worker'
ts

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

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

Scaffold pattern:

export class AppRecordRoom extends RecordRoom<Env> {
  constructor(state: DurableObjectState, env: Env) {
    super(state, env, schemas, { ownerUserId: env.OWNER_USER_ID })
  }
}
ts

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

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

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

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

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

Connected to via /ws/cron/:roomId (admin/monitor stream).

JobRoom<E>#

Durable background-job DO. By default, handlers run inside an alarm with a 15-minute wall-time limit. Use ctx.continue(state) to split work into resumable parts, or backgroundJobTypes to run selected handlers after the alarm returns. Job rows persist in SQLite and broadcast state changes over WebSocket. A Worker restart interrupts the handler; recovery retries it if attempts remain. 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 {
  /** Type names that run after the alarm returns. Default none; handlers must set a deadline. */
  backgroundJobTypes?: readonly string[]
  /** 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
  /** Re-check queue write access. Defaults to member/admin roles. */
  authorizeWrite?: (user: UserAttachment) => boolean | Promise<boolean>
  /** Re-check queue read access. Defaults to authorizeWrite. */
  authorizeRead?: (user: UserAttachment) => boolean | Promise<boolean>
}

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

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.

backgroundJobTypes accepts any app-defined job type name. Enqueue those jobs through the same client hook or worker helper. JobRoom runs one handler at a time and keeps short alarms active while a listed handler runs. Set the handler's deadline and pass ctx.signal through upstream calls. This does not change CPU or memory limits or preserve a live handler across a restart. See jobs longer than 15 minutes for configuration and recovery examples.

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

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
ts

Pass app:${env.DEEPSPACE_APP_ID} as roomId to hit the per-app AppJobRoom - room ids key to the immutable app id, never the app name (why). 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'
ts
ExportTypeDescription
DOManifesttypeDOManifestEntry[] - shape of __DO_MANIFEST__.
DOManifestEntrytype{ binding: string; className: string; sqlite: boolean }.
DOBindings<typeof __DO_MANIFEST__>typeDerives the Env interface's DO bindings from the manifest.
DEFAULT_DO_MANIFESTconstTwo-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
}
ts

See also#