BLAZE: REAL-TIME DOCUMENTS ON CLOUDFLARE
Firestore feel. Cloudflare price.
TECHNICAL GUIDE 2025.01.15
Live JSON docs with WebSocket updates—no database to babysit.
THE QUICK VERSION
One Durable Object per document. Keep state in memory, push patches over WebSockets. Clients get a snapshot and live updates.
Great for notes, flags, dashboards—small JSON that should sync fast.
ARCHITECTURE
Client ↔ Worker → DocRoom (DO) ↓ Broadcast
INFRASTRUCTURE
One namespace, one Worker—Alchemy wires the bindings.
// alchemy.run.ts (Blaze) import alchemy from "alchemy"; import { Worker, DurableObjectNamespace } from "alchemy/cloudflare"; const app = await alchemy("blaze"); const docs = DurableObjectNamespace("doc", { className: "DocRoom" }); await Worker("api", { entrypoint: "./src/blaze-worker.ts", bindings: { DOC: docs } }); await app.finalize();
DURABLE OBJECT ROOM
Keeps document state and broadcasts patches to WebSocket clients.
// src/doc.ts (Durable Object room for a document) import { DurableObject } from "cloudflare:workers"; type Patch = { op: 'set' | 'merge'; value: Record<string, unknown> }; export class DocRoom extends DurableObject { private state: Record<string, unknown> = {}; private sockets = new Set<WebSocket>(); private broadcast(msg: unknown) { const text = JSON.stringify(msg); for (const ws of this.sockets) try { ws.send(text); } catch {} } async fetch(req: Request) { const url = new URL(req.url); if (url.pathname === '/ws') { const pair = new WebSocketPair(); const server = pair[1]; server.accept(); server.addEventListener('close', () => this.sockets.delete(server)); server.addEventListener('error', () => this.sockets.delete(server)); this.sockets.add(server); server.send(JSON.stringify({ type: 'snapshot', value: this.state })); return new Response(null, { status: 101, webSocket: pair[0] }); } if (url.pathname === '/patch' && req.method === 'POST') { const patch: Patch = await req.json(); if (patch.op === 'set') this.state = patch.value; if (patch.op === 'merge') this.state = { ...this.state, ...patch.value }; this.broadcast({ type: 'patch', patch }); return new Response('ok'); } if (url.pathname === '/get') return Response.json(this.state); return new Response('not found', { status: 404 }); } }
WORKER ROUTING
Thin router that forwards to the correct document room by id.
// src/blaze-worker.ts (routing) import type { worker } from "../alchemy.run"; export default { async fetch(req: Request, env: typeof worker.Env): Promise<Response> { const url = new URL(req.url); const [, , id] = url.pathname.split('/'); if (!id) return new Response('bad request', { status: 400 }); const stub = env.DOC.get(env.DOC.idFromName(id)); if (url.pathname.startsWith('/doc/') && url.pathname.endsWith('/ws')) { return stub.fetch('https://do/ws'); } if (url.pathname.startsWith('/doc/') && url.pathname.endsWith('/patch') && req.method === 'POST') { return stub.fetch('https://do/patch', { method: 'POST', body: await req.text() }); } if (url.pathname.startsWith('/doc/') && url.pathname.endsWith('/get')) { return stub.fetch('https://do/get'); } return new Response('not found', { status: 404 }); } };
CLIENT
Connect via WebSocket; send merge patches for instant updates.
// client.ts (SvelteKit or plain browser) const docId = 'readme'; const ws = new WebSocket(`${location.origin.replace(/^http/, 'ws')}/doc/${docId}/ws`); ws.onmessage = (e) => console.log('msg', JSON.parse(e.data)); // merge patch await fetch(`/doc/${docId}/patch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op: 'merge', value: { title: 'Hello' } }) });