Cloudflare Workers
teleportal/cloudflare runs a full Teleportal sync server on Cloudflare Workers, backed entirely by Durable Object storage – no KV namespace, R2 bucket, or external database required.
What it demonstrates
Section titled “What it demonstrates”- Hosting the Teleportal
Serverinside a Durable Object, with WebSocket and HTTP/SSE clients sharing one instance - Every storage interface (documents, files, milestones, rate limits, key registry) implemented directly on Durable Object storage
- Handling Durable Object hibernation without dropping client state
- Deploying with
wrangler, entirely offline-testable viawrangler dev
Architecture
Section titled “Architecture”A Worker forwards all sync traffic – WebSocket upgrades and HTTP/SSE requests – to a single Durable Object instance, which hosts the Teleportal Server. Because every connection lands in the same instance, WebSocket clients and SSE/HTTP clients share one set of sessions and the in-memory PubSub, with no cross-instance coordination needed.
graph LR C1["WebSocket Client"] -->|"/api/*"| W["Worker"] C2["HTTP/SSE Client"] -->|"/api/*"| W W -->|"stub.fetch()"| DO["Durable Object<br/>Server + storage"]
Storage
Section titled “Storage”Every Teleportal storage interface has a direct implementation on Durable Object storage (ctx.storage) – there’s no adapter layer in between. Values ride structured clone, so updates, sidecars, chunks, and wrapped keys stay binary.
| Class | Interface |
|---|---|
DurableObjectDocumentStorage |
DocumentStorage |
DurableObjectFileStorage |
FileStorage |
DurableObjectTemporaryUploadStorage |
TemporaryUploadStorage |
DurableObjectMilestoneStorage |
MilestoneStorage |
DurableObjectRateLimitStorage |
RateLimitStorage |
DurableObjectKeyRegistryStorage |
KeyRegistryStorage |
A few implementation details worth knowing:
- The document pending log stores one update per key (
pending:{seq},seqzero-padded to 16 digits so lexicographiclist()order equals insertion order), so appends are O(1) writes rather than a read-append-rewrite of a single array. Sequence allocation is concurrency-safe: an in-memory counter seeded once from the persisted log tail hands out numbers with no interveningawait, so concurrent appends never collide. transaction()is an in-memory per-key mutex (KeyedMutex) – a Durable Object instance is the single writer for its own storage, so no TTL or advisory locks are needed. The mutex only serializes read-modify-write sequences on the same key across theawaitpoints where Durable Objects interleave concurrent requests.- Milestones are scoped by
documentId: both the meta doc and each content blob live undermilestone:{documentId}:…, so a milestone id never resolves or hydrates under the wrong document. - Rate-limit TTLs are stamped expiry timestamps, since Durable Object storage has no native TTL; expired state reads as absent and is deleted lazily.
- Use SQLite-backed Durable Objects (
new_sqlite_classesin the migration config) – available on the free plan, with a 2 MiB per-value limit. A document’s compacted state and a milestone snapshot are each a single value, so this bounds document size. File chunks are 1 MiB and always fit.
Durable Object Wiring
Section titled “Durable Object Wiring”import crossws from "crossws/adapters/cloudflare";import { Server } from "teleportal/server";import { getHTTPHandlers } from "teleportal/http";import { DurableObjectDocumentStorage, getDurableObjectHandlers, getDurableObjectWebsocketHooks,} from "teleportal/cloudflare";
export class TeleportalDurableObject { ctx; env; #handlers;
constructor(state: DurableObjectState, env: Env) { this.ctx = state; // crossws expects the state on `.ctx` this.env = env;
const server = new Server({ storage: async () => new DurableObjectDocumentStorage(state.storage, { keyPrefix: "document" }), });
const getContext = () => ({ userId: "someone", room: "docs" });
this.#handlers = getDurableObjectHandlers({ ws: crossws({ hooks: getDurableObjectWebsocketHooks({ server, onUpgrade: async () => ({ context: getContext() }), }), }), http: getHTTPHandlers({ server, getContext }), }); }
fetch(request: Request) { return this.#handlers.fetch(this, request); } webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) { return this.#handlers.webSocketMessage(this, ws, message); } webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) { return this.#handlers.webSocketClose(this, ws, code, reason, wasClean); } webSocketPublish(topic: string, data: unknown, opts?: unknown) { return this.#handlers.webSocketPublish(this, topic, data, opts); }}The Worker resolves the instance and forwards everything – WebSocket upgrades pass through stub.fetch unchanged:
export default { async fetch(request: Request, env: Env) { const stub = env.TELEPORTAL_DO.get(env.TELEPORTAL_DO.idFromName("teleportal")); return stub.fetch(request); },};WebSockets and Hibernation
Section titled “WebSockets and Hibernation”getDurableObjectWebsocketHooks({ server, onUpgrade }) wraps getWebsocketHandlers from teleportal/websocket-server for crossws’s Cloudflare adapter (crossws/adapters/cloudflare), which has two quirks this package accounts for:
- Dropped upgrade context: the durable upgrade path drops the context returned by the upgrade hook. The wrapper stashes it per-request and re-applies it to
peer.contextbeforeopenruns. - Hibernation wake-up: the adapter uses the WebSocket Hibernation API. When an instance is evicted and later woken by a message, the peer’s in-memory state is gone. The websocket-server hooks detect this and close the socket so the client reconnects and resyncs automatically. Open SSE connections prevent hibernation entirely.
This package never imports crossws/adapters/cloudflare itself, since that module only resolves inside workerd – instantiate it in your Worker code and pass it to getDurableObjectHandlers.
Local Development and Testing
Section titled “Local Development and Testing”wrangler dev runs on workerd (the same runtime Cloudflare uses in production) fully offline – no Cloudflare account needed, and Durable Object storage is emulated locally:
cd examples/cloudflarebun run devOpen http://localhost:8787 in two tabs and type – edits sync between tabs through the Durable Object.
Because workerd runs locally, you can write real integration tests that spawn wrangler dev as a subprocess and assert against actual sync behavior (WebSocket round-trips, SSE wire format, persistence across reconnects) instead of mocking the runtime. See examples/cloudflare/src/integration.test.ts for the pattern.
Scaling Beyond One Instance
Section titled “Scaling Beyond One Instance”idFromName("teleportal") pins the whole app to one Durable Object. To shard, derive the instance name from the room (e.g. idFromName(room)) in both the Worker and any place that mints client URLs – every client of a room must land on the same instance.
Scope and Caveats
Section titled “Scope and Caveats”- No built-in auth in the example: the example wires a static
{ userId, room }context. For real deployments, useTokenManager(teleportal/token) withtokenAuthenticatedHTTPHandleror a token check inonUpgrade–joseruns fine on workerd. See Authentication. - One Durable Object instance per app by default – see Scaling above to shard by room.
- 2 MiB value size limit on SQLite-backed Durable Object storage. A document’s compacted state and a milestone snapshot are each one value; file chunks (1 MiB) always fit.
- No
crossws/adapters/cloudflareimport insideteleportal/cloudflare– that module only resolves inside workerd, so the library exposes hooks with type-only crossws imports and expects the consumer to instantiate the adapter.
Full Example
Section titled “Full Example”See examples/cloudflare for a complete, deployable example: wrangler config, static assets, a bundled ProseMirror browser client, and all storages wired into RPC handlers.
Next Steps
Section titled “Next Steps”- Authentication - Add real token-based auth in place of the static example context
- Custom Storage - Implement additional storage backends
- Scaling - Sharding and multi-instance strategies