Skip to main content

Getting Started

This tutorial builds a collaborative document editor using all mesh node types. By the end, you'll have:

  • A DocumentDO that stores document content and notifies collaborators
  • A SpellCheckWorker that checks spelling via an external API
  • An EditorClient that runs in the browser and receives real-time updates

What You'll Build

Call flows:

  1. User edits → Client → Gateway → DocumentDO
  2. DocumentDO notifies → Gateway → Client (real-time update)
  3. DocumentDO spell checks → SpellCheckWorker → Gateway → Client
Diagram vs Implementation
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 checks
  • this.lmz.call() makes fire-and-forget or request/response calls
  • this.ctn<T>() builds type-safe continuations for remote methods
  • { newChain: true } starts a fresh call chain for server-initiated pushes

Patterns to Notice

PatternWhy It Matters
No async/awaitContinuations describe work; framework handles timing. Helps avoid race conditions.
No instance variablesState in storage survives hibernation/eviction. Reads are cached and cheap (writes cost 1,000x more).
Direct deliverySpellCheckWorker sends findings straight to the originating client, not back through DocumentDO (as is shown in the incomplete diagram).
No instance name for WorkersPass 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
Cost Optimization

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
  • onSubscriptionRequired handles (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 using keyword for automatic cleanup via Symbol.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

Key Rotation

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",
// ...
}
}
Service bindings

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

  1. Sign up at resend.com
  2. Verify your sending domain — Resend's dashboard walks you through adding DNS records (SPF, DKIM, DMARC)
  3. Create an API key in the Resend dashboard
  4. 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.

Customizing templates or using a different provider

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:

Production hardening

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.