Skip to main content
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'
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
}

See also