The real-time hooks beyond records and messaging - presence, collaborative editing, canvas, game rooms, and helpers.
import {
// Yjs
useYjsText, useYjsField, useYjsRoom,
// Canvas
useCanvas,
// Presence
usePresence, usePresenceRoom,
// Game rooms
useGameRoom,
// Cron monitor
useCronMonitor,
// Background jobs
useJobs,
// User colors
DEFAULT_USER_COLORS, getUserColor,
// Low-level sync primitives
createEncoder, createDecoder, encodeSyncStep1, encodeSyncStep2, encodeUpdate,
handleSyncMessage, Awareness, encodeAwarenessMessage, handleAwarenessMessage,
} from 'deepspace'
Yjs hooks
useYjsText(collection, recordId, fieldName)
Collaborative plain text bound to a record field.
function useYjsText(
collection: string,
recordId: string,
fieldName: string,
): {
text: string
setText: (value: string) => void
synced: boolean
canWrite: boolean
}
useYjsField(collection, recordId, fieldName)
Lower-level Yjs binding. Returns the raw Y.Doc and Awareness so you can build any Yjs type (Y.Map, Y.Array, Y.XmlFragment, etc.) on top of it. Not generic - the hook doesn’t model the field shape.
function useYjsField(
collection: string,
recordId: string,
fieldName: string,
): {
doc: Y.Doc
awareness: Awareness
synced: boolean
canWrite: boolean
/** Increments on every local or remote Y.Doc update - useful as a render trigger. */
updateCount: number
}
Build whichever Yjs type you need off doc:
const { doc, synced } = useYjsField('documents', docId, 'tasks')
const list = useMemo(() => doc.getArray<Task>('tasks'), [doc])
useYjsRoom(docId, fieldName)
Standalone Yjs document not tied to a record. Useful for ephemeral collaboration sessions. Opens a direct WebSocket to a dedicated YjsRoom DO at /ws/yjs/:docId.
function useYjsRoom(docId: string, fieldName: string): {
doc: Y.Doc
awareness: Awareness
text: string
setText: (value: string) => void
synced: boolean
canWrite: boolean
}
text / setText are bound to the Y.Text at fieldName. Use doc directly if you need a different type.
awareness is a y-protocols Awareness instance pre-wired to the same WebSocket. Calls to awareness.setLocalState(...) or awareness.setLocalStateField('cursor' | 'selection' | 'user' | …, value) fire an MSG_AWARENESS frame to peers; remote states arrive on the awareness 'change' / 'update' events and are visible via awareness.getStates(). Pass it to an editor binding (e.g. @tiptap/extension-collaboration-cursor) or wire your own cursor/selection UI - see the collaborative editing guide for a worked example.
The /ws/yjs/:docId route is token-required and docs-aware: 401 without a verified JWT, 403 without read access. With the docs feature installed, roles resolve from documents.ownerId / editors / collaborators; without it, any authenticated caller is treated as member. See the security model for the full path.
For the raw protocol constants (MSG_AWARENESS, encodeAwarenessMessage, handleAwarenessMessage) used by this hook internally, see Low-level sync primitives below.
useCanvas(roomId)
Connects to a CanvasRoom DO.
function useCanvas(roomId: string): {
shapes: CanvasShapeClient[]
/** All connected users' viewports (including self). */
viewports: ViewportClient[]
connected: boolean
/** RBAC gate. False until the server AUTH frame lands; stays false for viewers. */
canWrite: boolean
addShape: (shape: Partial<CanvasShapeClient>) => void
moveShape: (shapeId: string, x: number, y: number) => void
resizeShape: (shapeId: string, width: number, height: number, x?: number, y?: number) => void
updateShape: (shapeId: string, props: Record<string, unknown>) => void
deleteShape: (shapeId: string) => void
setViewport: (viewport: Omit<ViewportClient, 'userId'>) => void
undo: () => void
redo: () => void
}
All mutation methods are fire-and-forget - they encode and send a typed message and return void. viewports is an array, not a Map.
Use canWrite to disable shape/draw controls for viewers - shape mutations (addShape, moveShape, resizeShape, updateShape, deleteShape, undo, redo) silently no-op when canWrite is false. setViewport is exempt and stays open for viewers (viewport broadcasts are presence-like, not stateful writes).
Each shape:
type CanvasShapeClient = {
id: string
type: string // free-form: 'rect', 'circle', 'text', etc.
x: number
y: number
width: number
height: number
rotation?: number
props: Record<string, unknown> // app-specific payload
createdBy: string
createdAt: string
updatedAt: string
}
type ViewportClient = {
userId: string
x: number
y: number
width: number
height: number
zoom: number
}
Presence hooks
usePresence(options?)
Online/offline status derived from lastSeenAt heartbeats on the users collection.
function usePresence(options?: { timeoutMs?: number }): {
users: RoomUser[]
isOnline: (userId: string) => boolean
getLastSeen: (userId: string) => string | null
}
The hook also sends a heartbeat every 60 seconds so the server refreshes the caller’s lastSeenAt. Default timeoutMs is 5 minutes.
usePresenceRoom(scopeId)
High-frequency ephemeral state (cursors, typing, viewport). Connects to a dedicated PresenceRoom DO.
function usePresenceRoom(scopeId: string): {
peers: PresencePeerClient[] // excludes self
connected: boolean
updateState: (state: object) => void // merges
}
type PresencePeerClient = {
userId: string
userName: string
userEmail: string
userImageUrl?: string
joinedAt: string
state: Record<string, unknown>
}
scopeId is any string. Common patterns: canvas:${canvasId}, thread:${channelId}, doc:${docId}.
useGameRoom(roomId)
Turn-tick or sim-tick game loop DO. Not generic - state is Record<string, unknown> (cast at the use-site).
function useGameRoom(roomId: string): {
state: Record<string, unknown>
tick: number
players: GamePlayer[]
running: boolean
connected: boolean
/** RBAC gate. False until the server AUTH frame lands; stays false for spectators. */
canWrite: boolean
sendInput: (action: string, data?: Record<string, unknown>) => void
setReady: () => void
startGame: () => void
endGame: () => void
}
type GamePlayer = {
userId: string
userName: string
ready: boolean
connectedAt: string
/** Server-managed per-player slot - always present, never undefined. */
data: Record<string, unknown>
}
All mutation methods (sendInput, setReady, startGame, endGame) are fire-and-forget - they return void - and silently no-op when canWrite is false. Use canWrite to disable lobby and input UI for spectators. The server-side GameInput event carries userId (not playerId), action, data, and the current tick.
State migration on schema bumps lives in the worker - override onHydrateState(stored) on your GameRoom subclass.
useCronMonitor(roomId)
Admin/monitor stream for the CronRoom DO. Pass app:<APP_NAME> for the app’s default cron room.
function useCronMonitor(roomId: string): {
tasks: CronTaskState[]
history: CronHistoryEntry[]
connected: boolean
/** RBAC gate. False until the server AUTH frame lands; stays false for read-only viewers. */
canWrite: boolean
trigger: (taskName: string) => void
pause: (taskName: string) => void
resume: (taskName: string) => void
}
type CronTaskState = {
name: string
/** Required. Null when the task is configured via `schedule` instead. */
intervalMinutes: number | null
/** Required. Null when the task is configured via `intervalMinutes` instead. */
schedule: string | null
/** Required. Null when the task has no explicit timezone. */
timezone: string | null
paused: boolean
lastRunAt: string | null
nextRunAt: string | null
}
type CronHistoryEntry = {
taskName: string
startedAt: string
completedAt: string | null
success: boolean
durationMs: number
error?: string
}
trigger, pause, and resume are fire-and-forget - they return void - and silently no-op when canWrite is false.
Default scaffold lets every signed-in user fire owner-billed tasks. The Cron DO authorizes trigger / pause / resume based on the role the wsRoute resolver returns. The scaffolded /ws/cron/:roomId returns role: 'member' for any signed-in connection, so by default any signed-in user gets canWrite: true. For owner-only writes, replace the scaffolded wsRoute helper with an inline handler that returns role: 'member' only when the JWT subject matches OWNER_USER_ID. Then also disable the controls client-side by canWrite (or useUser().user?.role === 'admin') so the buttons match server policy.
useJobs(roomId)
Enqueue and monitor durable background jobs on the JobRoom DO. Pass app:<APP_NAME> for the per-app job room. The hook subscribes via WebSocket, so jobs re-renders on every state change - no polling.
function useJobs(roomId: string): {
jobs: JobView[]
connected: boolean
enqueue(
type: string,
payload?: unknown,
opts?: { maxAttempts?: number },
): Promise<string> // resolves with the jobId
getJob(id: string): JobView | undefined
cancel(id: string): void
retry(id: string): void
}
type JobView = {
id: string
type: string
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled'
payload?: unknown
result?: unknown
error?: string
/** 0..1 - present while live. */
progress?: number
progressMessage?: string
attempts: number
maxAttempts: number
enqueuedAt: string
startedAt?: string
completedAt?: string
enqueuedBy?: string
}
enqueue resolves once the server acks (10 s timeout, also rejects on socket close). cancel(id) is best-effort - it always flips the DB row to canceled, and if the cancel reaches the isolate running the job, ctx.signal fires so any in-flight fetch(...) you wired with signal: ctx.signal aborts cleanly. retry(id) re-queues a failed or canceled job.
The handler runs in your AppJobRoom’s onJob(job, ctx) - see Background jobs for the worker-side pattern and the enqueueJob helper for HTTP routes / cron / actions.
The JobRoom DO does not enforce a role on enqueue / cancel / retry. Unlike useCronMonitor - whose DO gates writes off the wsRoute resolver role - any signed-in (or anonymous) connection can enqueue jobs. For jobs that spend owner credits via integrations or AI proxies, gate the button by useUser().user?.role === 'admin' or wrap the page route in (protected)/. See Background jobs for the pattern.
User colors
// 12-color palette of cursor/avatar tints
const DEFAULT_USER_COLORS: readonly string[]
// Deterministic hash: same userId → same color
function getUserColor(userId: string, palette?: string[]): string
Use for cursor dots in usePresence / usePresenceRoom, avatar fallbacks, and “who’s typing” pills.
Low-level sync primitives
For building custom hooks against a DeepSpace Yjs DO (rare):
createEncoder, createDecoder
toUint8Array, writeVarUint, writeVarUint8Array, readVarUint, readVarUint8Array
encodeSyncStep1, encodeSyncStep2, encodeUpdate
handleSyncMessage
Awareness, encodeAwarenessMessage, handleAwarenessMessage
getMessageType
MSG_SYNC, MSG_AWARENESS, MSG_SYNC_STEP1, MSG_SYNC_STEP2, MSG_SYNC_UPDATE
Most apps never use these directly. Use the higher-level hooks (useYjsText, etc.) unless you’re building a custom binding.
See also