Your own Durable Object
Host a document in any Durable Object with attachDocument, load it from and save it to your own storage (R2, KV, D1), and edit it on the server.
DocumentRoom is a ready-made Durable Object. When you already have one, or want your own storage and server logic, attach the document to it instead:
import { DurableObject } from 'cloudflare:workers';
import { attachDocument, routeDocumentSocket } from 'edytor/cloudflare';
export class Notes extends DurableObject<Env> {
document = attachDocument(this, {
onLoad: () => loadFromMyStore(this.ctx.id.name!), // { update, replicas }
onSave: ({ update, replicas }) => saveToMyStore(this.ctx.id.name!, { update, replicas })
});
}
export default {
fetch(request, env) {
const id = new URL(request.url).pathname.split('/').pop()!;
return routeDocumentSocket(request, env.NOTES, id, authorize);
}
} satisfies ExportedHandler<Env>;
That is the whole server. routeDocumentSocket works with any namespace whose objects host a document, and authorization is unchanged.
What attachDocument(this, options) does
- Storage. The document lives in the object’s SQLite storage, in two tables named
edytor_rowsandedytor_replicas, beside your own tables. Every edit is stored there before it is acknowledged, whatever your hooks do.tablePrefixchanges the prefix. - Sockets. The document’s sockets carry the tag
edytor(SOCKET_TAG). Other sockets of the object are yours. - Handlers. Each handler your class does not define is installed on the object:
fetch,webSocketMessage,webSocketClose,webSocketError, andalarmwhen you passonSave. - Return value. An
AttachedDocumentwith the same handlers, plustransact,read,facadeandcompact.
Call it once, in a field or the constructor.
Alongside your own handlers
A class that defines a handler keeps it and delegates to the document. The document’s socket handlers return false for sockets that are not its own:
export class Workspace extends DurableObject<Env> {
document = attachDocument(this, { onSave: (saved) => this.persist(saved) });
async fetch(request: Request) {
if (new URL(request.url).pathname.startsWith('/events')) return this.openEventStream(request);
return this.document.fetch(request); // the document's upgrade
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
if (this.document.webSocketMessage(ws, message)) return;
// …your own sockets
}
async alarm() {
await this.document.alarm(); // runs onSave
// …your own scheduled work
}
}
A Durable Object has one alarm. The document sets it saveAfter ms after an unsaved edit, which replaces a time you set earlier. An alarm already pending when the object wakes is kept: the document does not move it, so frequent wakes never push onSave back. If your class also uses alarms, re-arm your own schedule from alarm(), and call document.alarm() from it.
Options
| Option | Default | Description |
|---|---|---|
onLoad |
Returns the document for a room that stores nothing yet. See Load. | |
onSave |
Receives { value, update, replicas } after edits. See Save. |
|
saveAfter |
2000 |
ms between the first unsaved edit and onSave. |
compactAfter |
500 |
Stored updates before they are merged into one snapshot. |
maxRowBytes |
1,995,904 | Largest stored row. Can only be lowered. |
maxFrameBytes |
32 MiB | Largest frame sent whole; larger ones are chunked. Can only be lowered. |
tablePrefix |
'edytor_' |
Prefix of the document’s two tables. |
semantics |
defaultSemantics |
The block roles server edits obey. See Block roles on the server. |
DocumentRoom reads the same settings from vars (EDYTOR_SAVE_AFTER, EDYTOR_COMPACT_AFTER, …; see the room) and uses the unprefixed tables rows and replicas. It takes semantics from its semantics() method, read at first use, so it may return a field of your subclass.
Load
onLoad() runs when the object starts and stores nothing yet, before any socket is served. It may be async. Return:
| Return | Effect |
|---|---|
{ update, replicas } |
What an earlier onSave received: the v14 state and who owns which client id. Restores both. |
Uint8Array |
A bare v14 update. Restores the content but not the owners: each client id that holds content is unowned until a signed-in writer dials with it as its replica (logged as an orphan entry). |
JSONDoc |
Seeded as the document. Seeding is deterministic, so a client that seeds the same value converges with it. |
undefined / null |
An empty room; the first client seeds it. |
Nothing is stored until onLoad settles, and the result is stored in one transaction, so the room is never left half-loaded:
- Nothing returned is provisional:
onLoadis asked again at every start until something is stored. A KV read that is not yet visible is retried at the next start. - A throw refuses sockets with
1011(their providers retry), and the next dial or start asksonLoadagain. - A refused payload (another generation or schema, undecodable bytes, any other shape) sets
failureand refuses every socket with1008(refused: container). The object keeps answering.
Once anything is stored, the object restores from its own storage and does not ask again.
A saved copy can be older than the room’s last edits (it is written saveAfter ms after them). Clients that reconnect after such a restore deliver what the room lacks, including other users’ edits they received: nobody is disconnected for it, and nobody takes over another user’s client id. See restoring from an older snapshot.
Save
onSave({ value, update, replicas }) runs saveAfter ms after the first edit it has not saved, on the object’s alarm, so it does not keep the object from hibernating. If it throws, the platform retries the alarm with backoff. If the alarm fires while the room cannot read its rows (a storage fault), it reads them again first; if that fails too, the save stays due and the alarm is set again, backing off up to 5 minutes, so onSave runs once the room recovers. Without onSave, no alarm is ever set.
| Field | Content |
|---|---|
update |
The full CRDT state. Store it to load the document back. |
replicas |
Who owns each client id ({ replica, user }[]), read with update. Store it beside update and return both from onLoad, so returning clients can write again after a restore. See replicas. |
value |
The document as JSON, for search, previews or exports. |
Storage recipes
The room’s name is this.ctx.id.name: rooms are opened with getByName(documentId), and Cloudflare keeps the name for the object’s lifetime, including when an alarm wakes it.
document = attachDocument(this, {
onLoad: async () => {
const [object, owners] = await Promise.all([
this.env.DOCS.get(`${this.ctx.id.name}.bin`),
this.env.DOCS.get(`${this.ctx.id.name}.replicas.json`)
]);
if (!object) return undefined;
const update = new Uint8Array(await object.arrayBuffer());
return owners ? { update, replicas: await owners.json<ReplicaOwner[]>() } : update;
},
onSave: async ({ value, update, replicas }) => {
await this.env.DOCS.put(`${this.ctx.id.name}.bin`, update);
await this.env.DOCS.put(`${this.ctx.id.name}.replicas.json`, JSON.stringify(replicas));
await this.env.DOCS.put(`${this.ctx.id.name}.json`, JSON.stringify(value)); // optional
}
});R2 has no practical size limit: the best fit for documents. ReplicaOwner is exported by edytor/cloudflare; a copy saved before replicas existed still loads as a bare update.
document = attachDocument(this, {
onLoad: async () => {
const name = this.ctx.id.name!;
const bytes = await this.env.DOCS_KV.get(name, 'arrayBuffer');
const replicas = await this.env.DOCS_KV.get<ReplicaOwner[]>(`${name}:replicas`, 'json');
if (!bytes) return undefined;
return replicas ? { update: new Uint8Array(bytes), replicas } : new Uint8Array(bytes);
},
onSave: async ({ update, replicas }) => {
await this.env.DOCS_KV.put(this.ctx.id.name!, update);
await this.env.DOCS_KV.put(`${this.ctx.id.name}:replicas`, JSON.stringify(replicas));
}
});A KV value holds at most 25 MiB, and other locations may read an old value for a short while. onLoad only runs for a room that stores nothing, and a missing value is asked again at the next start, so that rarely matters here.
document = attachDocument(this, {
onLoad: async () => {
const row = await this.env.DB.prepare('SELECT state, replicas FROM documents WHERE id = ?')
.bind(this.ctx.id.name)
.first<{ state: ArrayBuffer; replicas: string }>();
return row ? { update: new Uint8Array(row.state), replicas: JSON.parse(row.replicas) } : undefined;
},
onSave: async ({ value, update, replicas }) => {
await this.env.DB.prepare(
'INSERT OR REPLACE INTO documents (id, state, replicas, json) VALUES (?, ?, ?, ?)'
)
.bind(this.ctx.id.name, update, JSON.stringify(replicas), JSON.stringify(value))
.run();
}
});A D1 row holds at most 2 MB; use R2 for larger documents.
Edit on the server
transact(fn) runs fn with the document’s facade in one transaction, stores the edit and broadcasts it to every connected socket, like a client’s edit. It returns what fn returns. Expose it through your own RPC methods:
import { toBlockSpec } from 'edytor/crdt/edytor';
export class Notes extends DurableObject<Env> {
document = attachDocument(this);
appendNote(text: string) {
return this.document.transact((doc) =>
doc.insertBlock(
{ parent: null, index: doc.childrenIds(null).length },
toBlockSpec({ type: 'paragraph', content: [{ text }] })
)
);
}
}
// From a Worker, a queue consumer, a cron trigger…
await env.NOTES.getByName(documentId).appendNote('Deployed v2');
read() returns the document as JSON. A room with no document yet is seeded with one empty block before transact runs.
Block roles on the server
The server has no plugins, so it takes the block roles it checks from semantics: which kinds are void or islands, which render no content, and each container’s default child. The default, defaultSemantics from edytor/crdt/edytor, holds the kinds of the bundled rich-text, code and image plugins. Server edits obey them as a view does: merging into a divider, splitting an image or moving a code line out of its code block is refused.
If your clients’ plugins add void or island kinds, or redefine bundled ones, pass the same rules:
import { defaultSemantics } from 'edytor/crdt/edytor';
document = attachDocument(this, {
semantics: { ...defaultSemantics, roles: { ...defaultSemantics.roles, embed: { void: true } } }
});
In a DocumentRoom subclass, override protected semantics() and return the config. semantics: {} checks no roles.
Subclassing DocumentRoom
The ready-made room exposes the same hooks as methods:
import { DocumentRoom, type ReplicaOwner, type SavedDocument } from 'edytor/cloudflare';
export class Room extends DocumentRoom<Env> {
protected override async onLoad() {
const name = this.ctx.id.name;
const [object, owners] = await Promise.all([
this.env.DOCS.get(`${name}.bin`),
this.env.DOCS.get(`${name}.replicas.json`)
]);
if (!object) return undefined;
const update = new Uint8Array(await object.arrayBuffer());
return owners ? { update, replicas: await owners.json<ReplicaOwner[]>() } : update;
}
protected override async onSave({ update, replicas }: SavedDocument) {
await this.env.DOCS.put(`${this.ctx.id.name}.bin`, update);
await this.env.DOCS.put(`${this.ctx.id.name}.replicas.json`, JSON.stringify(replicas));
}
}
Bind and migrate the subclass by its own class name ("class_name": "Room", "new_sqlite_classes": ["Room"]). If it defines alarm(), call await super.alarm() from it. transact, read, compact and reset are methods of the room itself.