---
title: Your own Durable Object
description: 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.
icon: puzzle
---

`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:

```ts src/worker.ts
import { DurableObject } from 'cloudflare:workers';
import { attachDocument, closedSocket, 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 match = /^\/rooms\/([^/]+)$/.exec(new URL(request.url).pathname);
    if (!match) return new Response('not found', { status: 404 });
    let documentId: string; // the provider percent-encodes the room id
    try {
      documentId = decodeURIComponent(match[1]);
    } catch {
      return closedSocket(4400, 'invalid document id');
    }
    return routeDocumentSocket(request, env.NOTES, documentId, authorize);
  }
} satisfies ExportedHandler<Env>;
```

That is the whole server. `routeDocumentSocket` works with any namespace whose objects host a document, and [authorization](/docs/server/authorization) is unchanged.

:::note
This `attachDocument` comes from `edytor/cloudflare` and hosts a document in a Durable Object. The `attachDocument` exported by `edytor` wraps an engine document in the browser or Node. They are different functions.
:::

## What `attachDocument(this, options)` does

- **Storage.** The document lives in the object's SQLite storage, in two tables named `edytor_rows` and `edytor_replicas`, beside your own tables. Every edit is stored there before it is acknowledged, whatever your hooks do. `tablePrefix` changes 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`, and `alarm` when you pass `onSave`.
- **Return value.** An `AttachedDocument` with the same handlers, plus the methods `transact`, `read`, `compact`, `dropWaitingDeletes`, `reset`, `records` and `owns(ws)`, and the fields `facade`, `doc`, `failure`, `refusals`, `refusalCounts`, `presence` and `origin` (see [the room](/docs/server/room#diagnostics)).
- **RPC.** Durable Object RPC reaches only your class's own methods, not the returned document's. Forward the ones you call from a Worker or an admin route, for example `dropWaitingDeletes() { return this.document.dropWaitingDeletes(); }`.

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:

```ts
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](#load). |
| `onSave` | | Receives `{ value, update, replicas }` after edits. See [Save](#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](#block-roles-on-the-server). |

`DocumentRoom` reads the same settings from `vars` (`EDYTOR_SAVE_AFTER`, `EDYTOR_COMPACT_AFTER`, …; see [the room](/docs/server/room#settings)) 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: `onLoad` is 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 asks `onLoad` again.
- **A refused payload** (another generation or schema, undecodable bytes, any other shape) sets `failure` and refuses every socket with `1008` (`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](/docs/server/authorization#restoring-from-an-older-snapshot).

## Save

`onSave({ value, update, replicas })` runs `saveAfter` ms after the first edit it has not saved (a stored [waiting delete](/docs/server/room#storage) counts), 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](/docs/server/authorization#replicas). |
| `value` | The document as JSON, for search, previews or exports. |

:::warning
Load from `update`, not from `value`. JSON starts a new document without the shared history: if the room's storage were ever reset and seeded from JSON, a client still holding the old history offline would merge both and could duplicate content.
:::

## 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.

**R2**

```ts
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.

**KV**

```ts
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.

**D1**

```ts
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](/docs/reference/document-api) in one transaction, stores the edit and broadcasts it to every connected socket, like a client's edit. It returns what `fn` returns. It is one transaction, not a rollback, like every client-side `transact`: if `fn` throws, the writes it made before the throw are kept, stored and broadcast, and the error is rethrown. Validate before writing, or build one plan with `facade.prepare.*` and apply it: a refused plan writes nothing. If storing the edit fails, the storage error is thrown instead and nothing is kept: the room rebuilds the document from its rows, which replaces `doc` and `facade`, and subscriptions made on the old ones (`facade.onChange`, `doc.on`) end. Read `facade` from the room each time rather than keeping it. A `transact` called inside `fn` joins the outer one. `fn` runs synchronously: writes after an `await` in it are not part of the transaction. Write through `transact`: a write straight through `facade` is stored and broadcast too, but when its append fails nothing is thrown and that write is dropped when the room next serves or reads the document, or when you next read `facade` or `doc` while the room is idle (not inside a transaction, a client's frame or their change events), so a later write through a freshly read `facade` or `doc` is stored. A later write through a `facade` or `doc` you kept from before the failure is dropped with it. A `facade.onChange` subscriber or a `doc.on('afterAllTransactions')` listener may read the room. A `transact` there, whether the change is a client's or comes from the room's own `transact`, or inside a `facade.transact` or `doc.transact` you opened, throws without writing: the engine would apply its write only after the change being handled, too late for the room to store it before `transact` returns. Only a `transact` called inside `fn` itself joins. After a write straight through `facade` (outside `transact` and a client's frame), a `doc.on('afterAllTransactions')` listener runs once that write is done: a `transact` there is a change of its own and is stored. Defer it with `queueMicrotask(() => this.transact(...))`: it then runs once the change is handled, and throws the storage error if storing fails. When the append of the change a subscriber sees failed, a read there does not hide it: a client's change is dropped and its sender's socket closed (`1011`, so the client resends it) once the subscriber returns, and the room's own `transact` throws the storage error. Expose it through your own RPC methods:

```ts
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, islands or islands of lines (`lines`, the code block), 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:

```ts
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:

```ts src/room.ts check
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`, `dropWaitingDeletes` and `reset` are methods of the room itself, so RPC reaches them without forwarding.
