Getting Started
This tutorial builds a collaborative document editor using all mesh node types. By the end, you'll have:
- A
DocumentDOthat stores document content and notifies collaborators - A
SpellCheckWorkerthat checks spelling via an external API - An
EditorClientthat runs in the browser and receives real-time updates
What You'll Build
Call flows:
- User edits → Client → Gateway → DocumentDO
- DocumentDO notifies → Gateway → Client (real-time update)
- DocumentDO spell checks → SpellCheckWorker → Gateway → Client
Minor differences between the diagram and the code below
- Workspace DO — Shown in diagram but omitted from tutorial (left as exercise for the reader)
- Spell check return path — Diagram shows results flowing back through DocumentDO, but we implement a more efficient "three one-way calls" pattern where SpellCheckWorker sends results directly to the client, bypassing DocumentDO entirely.
Prerequisites
npm install @lumenize/mesh @lumenize/routing
Step 1: Define the Document DO
LumenizeDO is for stateful server-side logic. It has persistent SQL/KV storage and can receive calls from any mesh node.
import { LumenizeDO, mesh } from '@lumenize/mesh';
// import types for type checked continuation construction
import type { SpellCheckWorker } from './spell-check-worker.js';
import type { EditorClient } from './editor-client.js';
export class DocumentDO extends LumenizeDO<Env> {
// @mesh annotation with guard function. Allows only subscribers to call update()
@mesh((instance: DocumentDO) => {
const clientId = instance.lmz.callContext.callChain[0]?.instanceName;
const subscribers: Set<string> = instance.ctx.storage.kv.get('subscribers') ?? new Set();
if (!clientId || !subscribers.has(clientId)) {
throw new Error('Must be subscribed to edit');
}
})
update(content: string) {
this.ctx.storage.kv.put('content', content);
this.#broadcastContent(content);
// Trigger spell check - worker sends results directly to originator
const { callChain } = this.lmz.callContext;
const clientId = callChain[0]?.instanceName;
const documentId = this.lmz.instanceName!;
if (clientId) {
this.lmz.call(
'SPELLCHECK_WORKER',
undefined, // Workers have no instance name
this.ctn<SpellCheckWorker>().check(content, clientId, documentId)
);
}
}
@mesh() // No guard function, so any mesh node can subscribe()
subscribe(): string {
const { callChain } = this.lmz.callContext;
const clientId = callChain[0]?.instanceName;
if (clientId) {
const subscribers: Set<string> = this.ctx.storage.kv.get('subscribers') ?? new Set();
subscribers.add(clientId);
this.ctx.storage.kv.put('subscribers', subscribers);
}
return this.ctx.storage.kv.get('content') ?? '';
}
// unsubscribe() left as exercise for reader
#broadcastContent(content: string) {
const documentId = this.lmz.instanceName!;
const subscribers: Set<string> = this.ctx.storage.kv.get('subscribers') ?? new Set();
// Continuation is created once and reused — serialization has no side effects
const remote = this.ctn<EditorClient>().handleContentUpdate(documentId, content);
// Note: In production, you'd skip the originator to avoid redundant updates
for (const clientId of subscribers) {
this.lmz.call(
'LUMENIZE_CLIENT_GATEWAY',
clientId,
remote,
undefined,
{ newChain: true } // so clients won't see who initiated the update
);
}
}
}
Features demonstrated:
@mesh(guard)enforces fine-grained access control at the method level — only subscribers can edit@mesh()without a guard exposes methods with no additional checksthis.lmz.call()makes fire-and-forget or request/response callsthis.ctn<T>()builds type-safe continuations for remote methods{ newChain: true }starts a fresh call chain for server-initiated pushes
Patterns to Notice
| Pattern | Why It Matters |
|---|---|
No async/await | Continuations describe work; framework handles timing. Helps avoid race conditions. |
| No instance variables | State in storage survives hibernation/eviction. Reads are cached and cheap (writes cost 1,000x more). |
| Direct delivery | SpellCheckWorker sends findings straight to the originating client, not back through DocumentDO (as is shown in the incomplete diagram). |
| No instance name for Workers | Pass undefined as the 2nd parameter to lmz.call() |
Step 2: Define the Spell Check Worker
LumenizeWorker is for stateless server-side logic. It's perfect for proxying external APIs, compute-intensive tasks, or offloading fanout.
import { LumenizeWorker, mesh } from '@lumenize/mesh';
import type { EditorClient } from './editor-client.js';
export interface SpellFinding {
word: string;
position: number;
suggestions: string[];
}
export class SpellCheckWorker extends LumenizeWorker<Env> {
@mesh()
async check(content: string, clientId: string, documentId: string): Promise<void> {
// Mock spell checker - flags "teh" as misspelled
const findings: SpellFinding[] = [];
const words = content.split(' ');
let position = 0;
for (const word of words) {
if (word.toLowerCase() === 'teh') {
findings.push({ word, position, suggestions: ['the'] });
}
position += word.length + 1;
}
// Send results directly to the originating client (fire-and-forget)
if (findings.length > 0) {
this.lmz.call(
'LUMENIZE_CLIENT_GATEWAY',
clientId,
this.ctn<EditorClient>().handleSpellFindings(documentId, findings)
);
}
}
}
Key patterns:
- It's fine for Worker methods to be
async— they don't have the same consistency concerns as DOs - Direct delivery: Worker sends findings straight to the client that triggered the check
For long-running external calls, consider using @lumenize/fetch which utilizes a "two one-way call" pattern to avoid DO wall-clock billing while waiting for long-running (>5s) external fetches.
Step 3: Define the Browser Client
LumenizeClient runs anywhere with JavaScript and WebSocket support (browser, node.js, bun, etc.) and is a full mesh peer — it can both make and receive calls.
import { LumenizeClient, mesh, type LumenizeClientConfig } from '@lumenize/mesh';
import type { DocumentDO } from './document-do.js';
import type { SpellFinding } from './spell-check-worker.js';
// Callbacks for a single document
export interface DocumentCallbacks {
// Called when document content is updated (initial load or broadcast)
onContentUpdate?: (content: string) => void;
// Called when spell check findings are received
onSpellFindings?: (findings: SpellFinding[]) => void;
}
// Handle for an open document - allows saving content and closing
export interface DocumentHandle {
// Save new content to the document
saveContent(content: string): void;
// Close this document (unsubscribe and remove from registry)
close(): void;
}
export class EditorClient extends LumenizeClient {
// Registry of open documents by documentId
readonly #documents = new Map<string, DocumentCallbacks>();
/**
* Open a document for editing
*
* Subscribes to the document and registers callbacks for updates.
* Returns a handle for saving content and closing the document.
*/
openDocument(documentId: string, callbacks: DocumentCallbacks): DocumentHandle {
// Register callbacks
this.#documents.set(documentId, callbacks);
// Subscribe to document updates
this.#subscribe(documentId, callbacks);
// Return handle for interacting with this document
return {
saveContent: (content: string) => {
this.lmz.call(
'DOCUMENT_DO',
documentId,
this.ctn<DocumentDO>().update(content)
);
},
close: () => {
this.#documents.delete(documentId);
// Should create and use DocumentDO.unsubscribe
},
};
}
#subscribe(documentId: string, callbacks: DocumentCallbacks) {
this.lmz.call(
'DOCUMENT_DO',
documentId,
this.ctn<DocumentDO>().subscribe(),
this.ctn().handleSubscribeResult(documentId, this.ctn().$result)
);
}
// Called on every connection (except reconnects within 5s grace period)
onSubscriptionRequired = () => {
// (Re)subscribe to all open documents
for (const [documentId, callbacks] of this.#documents) {
this.#subscribe(documentId, callbacks);
}
};
// Response handler for subscribe - receives initial content or Error
// No @mesh needed - framework trusts your own continuations
handleSubscribeResult(documentId: string, result: string | Error) {
const callbacks = this.#documents.get(documentId);
if (!callbacks) return; // Document was closed
if (result instanceof Error) {
console.error(`Failed to subscribe to ${documentId}:`, result);
return;
}
callbacks.onContentUpdate?.(result);
}
// Called by DocumentDO when content changes (broadcast)
@mesh()
handleContentUpdate(documentId: string, content: string) {
this.#documents.get(documentId)?.onContentUpdate?.(content);
}
// Called directly by SpellCheckWorker — not routed back through DocumentDO.
// This "direct delivery" pattern is a key benefit of the mesh architecture:
// any node can send results to any other node without intermediate hops.
@mesh()
handleSpellFindings(documentId: string, findings: SpellFinding[]) {
this.#documents.get(documentId)?.onSpellFindings?.(findings);
}
}
Key patterns:
- Unlike for DOs, instance variables are fine
onSubscriptionRequiredhandles (re)subscription. Fires on every connection except reconnects within the 5-second grace period- The client can call any mesh node using the same
this.lmz.call()API - Results can be
Error— always check before using
Step 4: Connect from the Browser
import { EditorClient } from './editor-client.js';
// ...
// Use `using` for automatic cleanup via Symbol.dispose
using client = new EditorClient({
baseUrl: 'https://localhost',
// ...
});
// ...
const doc = client.openDocument(documentId, {
onContentUpdate: updateEditor,
onSpellFindings: showSpellingSuggestions,
});
// ...
doc.saveContent('The quick brown fox');
// ...
// Cleanup: close document handles (clients auto-disconnect via `using`)
doc.close();
Key patterns:
- Use
usingkeyword for automatic cleanup viaSymbol.dispose
Step 5: Set Up Authentication
The default auth solution for the mesh is @lumenize/auth which provides passwordless magic link login. This step sets up the keys and environment.
Generate Ed25519 Key Pairs
Run twice. Once for each pair (BLUE and GREEN):
# Generate and display private key (copy for next step)
openssl genpkey -algorithm ed25519 | tee /dev/stderr | openssl pkey -pubout
Configure Secrets and Variables
Secrets (set via wrangler secret put, each prompts interactively):
# Primary key pair (signs new tokens)
wrangler secret put JWT_PRIVATE_KEY_BLUE
wrangler secret put JWT_PUBLIC_KEY_BLUE
# Secondary key pair (can be empty initially — used for zero-downtime rotation)
wrangler secret put JWT_PRIVATE_KEY_GREEN
wrangler secret put JWT_PUBLIC_KEY_GREEN
Paste each key (including -----BEGIN/END----- lines) when prompted.
Set PRIMARY_JWT_KEY to BLUE in the dashboard or wrangler.jsonc/wrangler.toml
The BLUE/GREEN pattern allows zero-downtime key rotation. See @lumenize/auth: Key Rotation for the procedure.
Install Auth Package
npm install @lumenize/auth
Step 6: Set Up the Worker Entry Point
import { env } from 'cloudflare:workers';
import { routeDORequest } from '@lumenize/routing';
import {
LumenizeAuth,
createAuthRoutes,
createRouteDORequestAuthHooks
} from '@lumenize/auth';
import { LumenizeClientGateway } from '@lumenize/mesh';
// Re-export classes for wrangler bindings
export { LumenizeClientGateway, LumenizeAuth };
export { DocumentDO } from './document-do.js';
export { SpellCheckWorker, type SpellFinding } from './spell-check-worker.js';
export { AuthEmailSender } from './auth-email-sender.js';
// Create auth routes and hooks once at module level
const authRoutes = createAuthRoutes(env);
const authHooks = await createRouteDORequestAuthHooks(env);
// Worker entry point
export default {
async fetch(request: Request) {
// Handle auth routes
const authResponse = await authRoutes(request);
if (authResponse) {
return authResponse;
}
const response = await routeDORequest(request, env, {
prefix: 'gateway',
...authHooks,
});
if (response) {
return response;
}
return new Response('Not Found', { status: 404 });
},
};
Step 7: Configure wrangler.jsonc
{
"name": "mesh-getting-started",
"main": "index.ts",
"compatibility_date": "2025-09-12",
"durable_objects": {
"bindings": [
{
"name": "LUMENIZE_CLIENT_GATEWAY",
"class_name": "LumenizeClientGateway"
},
{
"name": "DOCUMENT_DO",
"class_name": "DocumentDO"
},
{
"name": "LUMENIZE_AUTH",
"class_name": "LumenizeAuth"
}
]
},
"services": [
{
"binding": "SPELLCHECK_WORKER",
"service": "mesh-getting-started",
"entrypoint": "SpellCheckWorker"
},
{
"binding": "AUTH_EMAIL_SENDER",
"service": "mesh-getting-started",
"entrypoint": "AuthEmailSender"
}
],
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["LumenizeClientGateway", "DocumentDO", "LumenizeAuth"]
}
],
"vars": {
"PRIMARY_JWT_KEY": "BLUE",
// ...
}
}
SpellCheckWorker and AuthEmailSender are named entrypoints in the same Worker, bound via services. The AUTH_EMAIL_SENDER binding is how the Auth DO delivers email — Step 8 below walks you through creating it.
Step 8: Set Up Email Delivery
Magic links are how your users log in. Without an email provider, the auth flow silently fails — users see "check your email" but nothing arrives. This step takes about 5 minutes.
8a. Sign up for Resend
Resend is the recommended default — it uses standard fetch (no SDK), works natively on Cloudflare Workers, and has a generous free tier (100 emails/day).
- Sign up at resend.com
- Verify your sending domain — Resend's dashboard walks you through adding DNS records (SPF, DKIM, DMARC)
- Create an API key in the Resend dashboard
- Test it: Click the "Send test email" button in the Resend dashboard to confirm email arrives in your inbox
8b. Add the API key to your environment
# Local development — add to .dev.vars
RESEND_API_KEY=re_...
# Production — set as a secret
wrangler secret put RESEND_API_KEY
8c. Create your AuthEmailSender class
Create a tiny auth-email-sender.ts alongside your other source files:
import { ResendEmailSender } from '@lumenize/auth';
export class AuthEmailSender extends ResendEmailSender {
from = 'auth@example.com';
}
Change auth@example.com to an address on your verified Resend domain. That's it — ResendEmailSender handles the API call, default HTML templates, and subject lines.
This class is already exported from your Worker entry point (Step 6) and bound via AUTH_EMAIL_SENDER in wrangler.jsonc (Step 7). No additional wiring needed.
See @lumenize/auth: Email Provider for template customization, composing with defaults, and bringing your own email provider (Postmark, SES, SendGrid, etc.).
(Optional) Step 9: Configure Turnstile and Rate Limiting
These are genuinely optional for getting started but recommended before deploying to production:
- Turnstile — Bot protection for the magic-link endpoint. See @lumenize/auth: Turnstile.
- Rate limiting — Per-subject rate limiting for authenticated routes. See @lumenize/auth: Rate Limiting.
Without Turnstile, anyone can flood your email sending quota. Without rate limiting, compromised tokens can overwhelm your DOs. Both are quick to enable when you're ready.