/**
* Producer side of the `job-dashboard` example. Runs durable operations, fans
* their lifecycle out to the tenant's `Dashboard`, and hands out tracker
* handles so callers can wait for a result over Workers RPC. Browsers never
* connect here.
*/
import { DurableObject } from 'cloudflare:workers';
import {
Durability,
DurabilityFanout,
DurabilityScheduler,
type DurableCall,
} from 'durability';
import { DurabilityTracker } from 'durability/experimental';
import type { Env, JobEvent, OrderSummary } from './shared';
export class Pipeline extends DurableObject<Env> {
private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
private readonly durability = new Durability({
scheduler: this.scheduler,
handlers: {
importOrder: async ({
id,
payload,
signal,
}: DurableCall<{ orderId: string }>): Promise<OrderSummary> => {
const response = await this.env.ORDERS.fetch(
`https://orders/${payload.orderId}`,
{ headers: { 'Idempotency-Key': id }, signal }
);
if (!response.ok) {
throw new Error(`Order lookup failed with ${response.status}`);
}
return response.json();
},
},
// Completed lifecycle events carry the summary the dashboard publishes.
includeResults: true,
});
private readonly tracker = new DurabilityTracker({
sources: { jobs: this.durability },
});
// Push side. Delivery is durable: the dashboard gets the batch again after
// an eviction or a failed call, until `ingest` returns without throwing.
private readonly fanout = new DurabilityFanout<JobEvent>({
scheduler: this.scheduler,
targets: {
dashboard: {
deliver: (messages) =>
this.env.DASHBOARD.getByName(this.tenant).ingest(
messages.map((message) => message.body)
),
ackOnReturn: true,
},
},
batchDelayMs: 100,
});
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Lifecycle hooks are best-effort. If the object dies between a job
// settling and this enqueue committing, that one event is lost until the
// dashboard resyncs. Enqueue from inside the handler, keyed by `id`, when
// that gap matters.
this.durability.lifecycle((event) => {
if (event.type === 'registered') {
return this.publish(event, { status: 'pending' });
}
if (
event.type === 'attempt_settled' &&
event.outcome === 'completed' &&
event.result !== undefined
) {
return this.publish(event, {
status: 'completed',
result: event.result,
});
}
if (
(event.type === 'attempt_settled' && event.outcome === 'failed') ||
event.type === 'terminal'
) {
return this.publish(event, { status: 'failed', error: event.error });
}
return undefined;
});
}
private get tenant(): string {
return this.ctx.id.name ?? this.ctx.id.toString();
}
private async publish(
event: { id: string; generation: string; timestamp: number },
state: JobEvent['state']
): Promise<void> {
await this.fanout.enqueue([
{
id: `${event.id}:${event.generation}:${state.status}`,
body: {
id: event.id,
name: 'importOrder',
generation: event.generation,
createdAt: event.timestamp,
state,
},
},
]);
}
/** Registers the import; resolves on acceptance, not completion. */
importOrder(orderId: string) {
return this.durability.importOrder({
id: `import:${orderId}`,
payload: { orderId },
});
}
/**
* Pull side. Over Workers RPC the handle's `get` and `wait` become stubs, so
* `stub.track(id).wait({ timeoutMs })` waits in one pipelined round trip.
*/
async track(orderId: string) {
const handle = await this.tracker.get('jobs', `import:${orderId}`);
if (!handle) {
throw new Error(`Order "${orderId}" was never imported`);
}
return handle;
}
override alarm(info?: AlarmInvocationInfo) {
return this.scheduler.alarm(info);
}
}
/**
* Hub of the `job-dashboard` example. Three kinds of peers talk to it:
*
* - Browsers, over `DurabilitySocket`. `work` serves `tracker.records()` as
* the snapshot (plain JSON, no functions); every ingested change is pushed
* as a `job` event, which the socket persists and replays after reconnects.
* `importOrder` starts a job on the tenant's `Pipeline` and waits for its
* result through a tracker handle, in one pipelined RPC round trip.
* - The tenant's `Pipeline`, through fanout deliveries into `ingest`.
*
* The ingested table is itself a `TrackedSource`, so `this.tracker.get` also
* works locally: its `watch` is settled by `ingest`.
*/
import { DurableObject } from 'cloudflare:workers';
import {
DurabilityTracker,
type TrackedRecord,
type TrackedSource,
type TrackedStatus,
} from 'durability/experimental';
import { DurabilitySocket, event } from 'durability/socket';
import { z } from 'zod';
import type { Env, JobEvent, Jobs } from './shared';
type JobRow = {
id: string;
name: string;
generation: string;
created_at: number;
status: string;
state: string;
};
const toRecord = (row: JobRow): TrackedRecord<Jobs> => ({
id: row.id,
name: 'importOrder',
generation: row.generation,
createdAt: row.created_at,
state: JSON.parse(row.state),
});
const workQuery = z.object({
cursor: z.string().optional(),
status: z.enum(['pending', 'completed', 'failed']).optional(),
});
export class Dashboard extends DurableObject<Env> {
private readonly sql = this.ctx.storage.sql;
private readonly watchers = new Map<
string,
Set<(settled?: TrackedRecord<Jobs>) => void>
>();
/** The ingested table, read through the tracker's source contract. */
private readonly jobs: TrackedSource<Jobs> = {
get: async (id) => {
const row = this.readRow(id);
return row && toRecord(row);
},
list: async ({ limit, cursor, status, name }) => {
const [createdAt, afterId] =
cursor === undefined
? [-1, '']
: (JSON.parse(cursor) as [number, string]);
const rows = this.sql
.exec<JobRow>(
`SELECT * FROM jobs
WHERE (? IS NULL OR status = ?)
AND (? IS NULL OR name = ?)
AND (created_at > ? OR (created_at = ? AND id > ?))
ORDER BY created_at, id
LIMIT ?`,
status ?? null,
status ?? null,
name ?? null,
name ?? null,
createdAt,
createdAt,
afterId,
limit + 1
)
.toArray();
const page = rows.slice(0, limit);
const last = page[page.length - 1];
const records = page.map(toRecord);
return rows.length > limit && last
? { records, cursor: JSON.stringify([last.created_at, last.id]) }
: { records };
},
watch: (id, listener) => {
const listeners = this.watchers.get(id) ?? new Set();
this.watchers.set(id, listeners.add(listener));
return () => {
listeners.delete(listener);
};
},
};
private readonly tracker = new DurabilityTracker({
sources: { jobs: this.jobs },
});
readonly socket = new DurabilitySocket({
context: this.ctx,
inbound: {
importOrder: {
input: z.string().min(1),
handle: async (orderId: string) => {
const pipeline = this.env.PIPELINE.getByName(this.tenant);
await pipeline.importOrder(orderId);
return pipeline.track(orderId).wait({ timeoutMs: 30_000 });
},
},
work: {
input: workQuery,
handle: (query: {
cursor?: string | undefined;
status?: TrackedStatus | undefined;
}) => this.tracker.records(query),
},
},
outbound: { job: event<TrackedRecord<Jobs>>() },
});
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql.exec(
`CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
generation TEXT NOT NULL,
created_at INTEGER NOT NULL,
status TEXT NOT NULL,
state TEXT NOT NULL
)`
);
}
private get tenant(): string {
return this.ctx.id.name ?? this.ctx.id.toString();
}
/** Called by the pipeline's fanout. Returning without throwing acks the batch. */
ingest(events: JobEvent[]): void {
for (const job of events) {
// Deliveries can arrive out of order on retry: a late `pending` must not
// overwrite a settled row of the same generation.
this.sql.exec(
`INSERT INTO jobs (id, name, generation, created_at, status, state)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
generation = excluded.generation,
created_at = excluded.created_at,
status = excluded.status,
state = excluded.state
WHERE excluded.generation != jobs.generation OR jobs.status = 'pending'`,
job.id,
job.name,
job.generation,
job.createdAt,
job.state.status,
JSON.stringify(job.state)
);
const row = this.readRow(job.id);
if (!row) {
continue;
}
const record = toRecord(row);
this.socket.job(record);
for (const listener of this.watchers.get(job.id) ?? []) {
listener();
}
}
}
private readRow(id: string): JobRow | undefined {
return this.sql
.exec<JobRow>('SELECT * FROM jobs WHERE id = ?', id)
.toArray()[0];
}
override fetch(request: Request) {
return this.socket.fetch(request);
}
override webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
return this.socket.webSocketMessage(ws, message);
}
override webSocketClose(ws: WebSocket) {
this.socket.webSocketClose(ws);
}
}
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { trace, traceFunction, traceMethods, traceStorage } from './dev.js';
import { reportFailure, serializeError } from './errors.js';
import {
durabilitySocketMigrations,
migrateCapability,
type DurabilityMigrationResult,
} from './migrations.js';
import { deserialize, serialize } from './serialization.js';
import {
parseClientFrame,
socketMaxFrameLength,
socketProtocolErrorCode,
socketProtocolVersion,
type SocketCall,
type SocketClientFrame,
type SocketInboundHandler,
type SocketInboundMap,
type SocketOutboundMap,
type SocketOutboundPayload,
type SocketServerFrame,
type SocketInboundMetadata,
type SocketShape,
socketShape,
} from './socket-protocol.js';
/** The Durable Object state a socket needs: SQLite storage and hibernatable WebSockets. */
export type DurabilitySocketContext = Pick<
DurableObjectState,
'storage' | 'acceptWebSocket' | 'getWebSockets' | 'setWebSocketAutoResponse'
>;
export type DurabilitySocketConfig<
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound>,
> = {
context: DurabilitySocketContext;
/** Methods clients may call, keyed by the names they become on the client. */
inbound: Inbound;
/** Events the object may push, keyed by the methods they become on this instance. */
outbound: Outbound;
/**
* Captures trusted per-connection data from the upgrade request. Stored in
* the WebSocket attachment, so it survives hibernation without re-auth.
*/
metadata?: (request: Request) => Metadata | Promise<Metadata>;
/** Outbound events kept for replay to reconnecting clients. Defaults to 1000. */
retention?: number;
};
/** Thrown to the caller when an inbound payload fails its schema. */
export class SocketInputError extends Error {
readonly issues: readonly StandardSchemaV1.Issue[];
constructor(method: string, issues: readonly StandardSchemaV1.Issue[]) {
super(
`Invalid input for "${method}": ${issues.map((issue) => issue.message).join('; ')}`
);
this.name = 'SocketInputError';
this.issues = issues;
}
}
type Attachment<Metadata> = {
metadata: Metadata;
names: string[];
};
type EventRow = {
seq: number;
name: string;
payload: string;
};
declare const WebSocketPair: {
new (): { 0: WebSocket; 1: WebSocket };
};
declare const WebSocketRequestResponsePair: {
new (request: string, response: string): WebSocketRequestResponsePair;
};
const reservedOutboundNames = new Set(['status', 'resync']);
const sendFrame = (ws: WebSocket, frame: SocketServerFrame): void => {
trace('socket.send', { frame });
let text = JSON.stringify(frame);
if (text.length > socketMaxFrameLength) {
if (frame.type === 'event') {
reportFailure(
'durability.socket.event_too_large',
{ name: frame.name, seq: frame.seq },
new Error('Outbound event exceeds the frame limit')
);
return;
}
text = JSON.stringify({
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: {
name: 'SocketFrameTooLargeError',
message: `Response to "${frame.type}" exceeds ${socketMaxFrameLength} characters`,
},
} satisfies SocketServerFrame);
}
try {
ws.send(text);
} catch (error) {
reportFailure('durability.socket.send_failed', { type: frame.type }, error);
}
};
const readAttachment = <Metadata>(ws: WebSocket): Attachment<Metadata> => {
const stored: unknown = ws.deserializeAttachment();
if (typeof stored !== 'object' || stored === null) {
return { metadata: undefined as Metadata, names: [] };
}
const names = Reflect.get(stored, 'names');
return {
metadata: Reflect.get(stored, 'metadata') as Metadata,
names: Array.isArray(names)
? names.filter((name): name is string => typeof name === 'string')
: [],
};
};
class DurabilitySocketCore<
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound>,
> {
declare readonly [socketShape]: SocketShape<Inbound, Outbound>;
readonly #context: DurabilitySocketContext;
readonly #storage: DurableObjectStorage;
readonly #inbound: Map<string, SocketInboundHandler<never>>;
readonly #outboundNames: Set<string>;
readonly #metadata: DurabilitySocketConfig<
Inbound,
Outbound,
Metadata
>['metadata'];
readonly #retention: number;
constructor(config: DurabilitySocketConfig<Inbound, Outbound, Metadata>) {
const retention = config.retention ?? 1_000;
if (!Number.isSafeInteger(retention) || retention <= 0) {
throw new RangeError('retention must be a positive safe integer');
}
this.#context = config.context;
this.#storage = traceStorage(config.context.storage);
// Only own keys are callable; nothing on the prototype chain is reachable.
this.#inbound = new Map(Object.entries(config.inbound));
this.#metadata = config.metadata;
this.#retention = retention;
this.#outboundNames = new Set(Object.keys(config.outbound));
const reserved = new Set([
...Object.getOwnPropertyNames(DurabilitySocketCore.prototype),
...reservedOutboundNames,
]);
for (const name of this.#outboundNames) {
if (reserved.has(name)) {
throw new Error(`"${name}" is reserved by DurabilitySocket`);
}
Object.defineProperty(this, name, {
value: traceFunction(`DurabilitySocket.${name}`, (payload: unknown) =>
this.#publish(name, payload)
),
enumerable: true,
});
}
migrateCapability(
this.#storage,
'socket',
durabilitySocketMigrations,
undefined
);
// Keep-alive pings are answered by the runtime without waking a hibernated object.
this.#context.setWebSocketAutoResponse(
new WebSocketRequestResponsePair(
JSON.stringify({ v: socketProtocolVersion, type: 'ping' }),
JSON.stringify({ v: socketProtocolVersion, type: 'pong' })
)
);
}
/** Applies or reverts the `durability_socket_events` schema. */
static migrate(
context: Pick<DurableObjectState, 'storage'>,
target?: string | null
): DurabilityMigrationResult {
return migrateCapability(
context.storage,
'socket',
durabilitySocketMigrations,
target
);
}
/** Accepts a WebSocket upgrade; call from the Durable Object's `fetch`. */
async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return new Response('Expected a WebSocket upgrade', { status: 426 });
}
const metadata = await this.#metadata?.(request);
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
this.#context.acceptWebSocket(server);
server.serializeAttachment({
metadata,
names: [],
} satisfies Attachment<Metadata | undefined>);
return new Response(null, { status: 101, webSocket: client });
}
/** Handles one inbound frame; call from the Durable Object's `webSocketMessage`. */
async webSocketMessage(
ws: WebSocket,
message: string | ArrayBuffer
): Promise<void> {
const text =
typeof message === 'string' ? message : new TextDecoder().decode(message);
const frame = parseClientFrame(text);
if (!frame) {
trace('socket.close', {
code: socketProtocolErrorCode,
reason: 'Invalid frame',
});
ws.close(socketProtocolErrorCode, 'Invalid frame');
return;
}
if (frame.type === 'subscribe') {
this.#subscribe(ws, frame);
return;
}
await this.#call(ws, frame);
}
/**
* Call from the Durable Object's `webSocketClose`. Subscriptions live in the
* socket's attachment, so nothing needs cleaning up; the runtime requires
* the method to exist for hibernatable WebSockets.
*/
webSocketClose(_ws: WebSocket): void {}
async #call(
ws: WebSocket,
frame: Extract<SocketClientFrame, { type: 'call' }>
): Promise<void> {
const handler = this.#inbound.get(frame.method);
if (!handler) {
sendFrame(ws, {
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: {
name: 'SocketUnknownMethodError',
message: `Unknown method "${frame.method}"`,
},
});
return;
}
const call: SocketCall<never> = {
metadata: readAttachment<never>(ws).metadata,
};
try {
const output =
typeof handler === 'function'
? await handler(frame.input, call)
: await handler.handle(
(await this.#validate(
frame.method,
handler.input,
frame.input
)) as never,
call
);
sendFrame(ws, {
v: socketProtocolVersion,
type: 'result',
id: frame.id,
output,
});
} catch (error) {
sendFrame(ws, {
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: serializeError(error),
});
}
}
async #validate(
method: string,
schema: StandardSchemaV1,
input: unknown
): Promise<unknown> {
const result = await schema['~standard'].validate(input);
if (result.issues) {
throw new SocketInputError(method, result.issues);
}
return result.value;
}
#subscribe(
ws: WebSocket,
frame: Extract<SocketClientFrame, { type: 'subscribe' }>
): void {
const unknown = frame.names.find((name) => !this.#outboundNames.has(name));
if (unknown !== undefined) {
sendFrame(ws, {
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: {
name: 'SocketUnknownEventError',
message: `Unknown event "${unknown}"`,
},
});
return;
}
const attachment = readAttachment<Metadata>(ws);
ws.serializeAttachment({
...attachment,
names: frame.names,
} satisfies Attachment<Metadata>);
const head = this.#headSeq();
const replayed = this.#replay(ws, frame.names, frame.since, head);
sendFrame(ws, {
v: socketProtocolVersion,
type: 'subscribed',
id: frame.id,
seq: head,
replayed,
});
}
/**
* Replays retained events after `since` and reports whether the client is
* now caught up. A client ahead of the log or behind its oldest row must
* rebuild its state from a fresh snapshot instead.
*/
#replay(
ws: WebSocket,
names: string[],
since: number | undefined,
head: number
): boolean {
if (since === undefined || since > head) {
return false;
}
if (since === head) {
return true;
}
const oldest = this.#storage.sql
.exec<{ seq: number }>(
'SELECT MIN(seq) AS seq FROM durability_socket_events'
)
.one().seq;
if (since + 1 < oldest) {
return false;
}
const rows = this.#storage.sql
.exec<EventRow>(
'SELECT seq, name, payload FROM durability_socket_events WHERE seq > ? ORDER BY seq',
since
)
.toArray();
const wanted = new Set(names);
for (const row of rows) {
if (!wanted.has(row.name)) {
continue;
}
sendFrame(ws, {
v: socketProtocolVersion,
type: 'event',
seq: row.seq,
name: row.name,
payload: deserialize(row.payload),
});
}
return true;
}
#headSeq(): number {
const row = this.#storage.sql
.exec<{ seq: number | null }>(
'SELECT MAX(seq) AS seq FROM durability_socket_events'
)
.one();
return row.seq ?? 0;
}
#publish(name: string, payload: unknown): void {
const serialized = serialize(payload);
const sql = this.#storage.sql;
const { seq } = sql
.exec<{ seq: number }>(
'INSERT INTO durability_socket_events (name, payload, created_at) VALUES (?, ?, ?) RETURNING seq',
name,
serialized,
Date.now()
)
.one();
sql.exec(
'DELETE FROM durability_socket_events WHERE seq <= ?',
seq - this.#retention
);
const frame: SocketServerFrame = {
v: socketProtocolVersion,
type: 'event',
seq,
name,
payload,
};
for (const ws of this.#context.getWebSockets()) {
if (ws.readyState !== WebSocket.OPEN) {
continue;
}
if (!readAttachment(ws).names.includes(name)) {
continue;
}
sendFrame(ws, frame);
}
}
}
type OutboundMethods<Outbound extends SocketOutboundMap> = {
[Name in keyof Outbound]: (
payload: SocketOutboundPayload<Outbound[Name]>
) => void;
};
/**
* Typed WebSocket RPC and event push for one Durable Object, over hibernatable
* WebSockets.
*
* Every `outbound` event becomes a method on the instance that persists the
* event for replay and pushes it to subscribed clients. Clients call `inbound`
* handlers through `durability/client`.
*
* @example
* ```ts
* class TodoList extends DurableObject<Env> {
* readonly socket = new DurabilitySocket({
* context: this.ctx,
* inbound: {
* list: () => this.repo.list(),
* create: {
* input: TodoSchema,
* handle: async (todo: Todo) => {
* await this.repo.insert(todo);
* this.socket.created(todo);
* },
* },
* },
* outbound: { created: event<Todo>() },
* });
*
* fetch(request: Request) {
* return this.socket.fetch(request);
* }
*
* webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
* return this.socket.webSocketMessage(ws, message);
* }
*
* webSocketClose(ws: WebSocket) {
* this.socket.webSocketClose(ws);
* }
* }
* ```
*/
export type DurabilitySocket<
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound> =
SocketInboundMetadata<Inbound>,
> = DurabilitySocketCore<Inbound, Outbound, Metadata> &
OutboundMethods<Outbound>;
export interface DurabilitySocketConstructor {
new <
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound> =
SocketInboundMetadata<Inbound>,
>(
config: DurabilitySocketConfig<Inbound, Outbound, Metadata>
): DurabilitySocket<Inbound, Outbound, Metadata>;
migrate: typeof DurabilitySocketCore.migrate;
}
traceMethods(DurabilitySocketCore.prototype, 'DurabilitySocket');
// TypeScript cannot declare class members derived from a generic event map,
// so the constructor is typed separately; the constructor installs and
// reserves every outbound method at runtime.
export const DurabilitySocket =
DurabilitySocketCore as unknown as DurabilitySocketConstructor;
export { durabilitySocketMigrations } from './migrations.js';
export {
event,
type SocketCall,
type SocketInboundHandler,
type SocketInboundMap,
type SocketOutboundEvent,
type SocketOutboundMap,
} from './socket-protocol.js';