Skip to main content

LumenizeClientGateway

LumenizeClientGateway is a zero-storage Durable Object that bridges mesh nodes that are running outside of Cloudflare (browser/node.js/etc.) into the Lumenize Mesh. It extends DurableObject directly, not LumenizeDO, to avoid any storage operations.

Design Principles

Zero Storage = Zero Cost When Idle

LumenizeClientGateway uses no DO storage operations:

  • No ctx.storage.put/get/delete
  • No ctx.storage.sql
  • No ctx.storage.kv

Instead, state is derived from:

  • this.ctx.getWebSockets() — Active WebSocket connections
  • this.ctx.getAlarm() — Pending reconnection grace period
  • ws.deserializeAttachment() — Per-connection context

This zero-storage design is foundational. Clients can use whatever identifiers make sense for their application, and when those identifiers become stale — a tab closes, a session ends, a user logs out — there's no cleanup required. Abandoned Gateway instances incur no ongoing costs and leave no orphaned data.

1:1 Gateway-Client Relationship

Each LumenizeClient connects to its own Gateway instance. Using the document editing example from the Getting Started Guide:

Alice's browser (alice.tab1) → Gateway DO "alice.tab1"
Bob's browser (bob.tab1) → Gateway DO "bob.tab1"
Bob's second tab (bob.tab2) → Gateway DO "bob.tab2"

The Gateway instance name matches the client's instanceName configuration.

Transparent Proxying

Gateway doesn't interpret the calls passing through it — it simply forwards them from the caller to the callee using Workers RPC and WebSocket messages. This keeps Gateway simple and allows clients to act as true peers by defining any methods they want just like LumenizeDO and LumenizeWorker mesh nodes.

Implications

  • Cost:

    • Decreases. Charges are only incurred when actively processing messages.
    • Increases. On the other hand, the use of a Gateway increases request count charges.
  • Ephemerality: While the same client must reconnect with the same id to preserve subscriptions, resubscribing is expected frequently and there is no downside when a new tab/browser/nodejs instance/etc. connects with a different randomly generated tabId.

  • Latency: This design adds one hop inside of Cloudflare, but the primary driver for latency is the cumulative physical distance of the hops from caller to callee which would only be significantly increased in rare cases. A single hop between regions can be several hundred milliseconds, but a hop in the same data center is typically less than ten milliseconds.

  • Complexity:

    • More complex. A single client connecting to a single DO over a reconnecting WebSocket connection is simpler. Once a client needs to communicate with two or more DOs, the need to maintain multiple connections erases the simplicity advantage of the single-DO use case.
    • Simpler. Having a client address look exactly like the address of other nodes in the mesh enables the simpler true-peer mental-model. Clients are not only coded like other mesh nodes, they are addressed in the same way. The upstream vs downstream distinction disappears.

Derived Connection State

Gateway state is derived, not stored. On every message, it checks:

getWebSockets()getAlarm()StateBehavior
Has connectionAnyConnectedForward calls immediately
EmptyPendingGrace PeriodWait for reconnect (up to 5s)
EmptyNoneDisconnectedReject calls with ClientDisconnectedError

WebSocket Attachments

Attachments store per-connection context without using DO storage. When a client connects, the Gateway decodes the verified JWT and stores the identity (sub, claims, expiration) in an attachment associated with the hibernatable WebSocket.

This mechanism ensures:

  • Hibernation Safety: Attachments persist across DO hibernation. When the DO wakes up to handle a message, it can immediately access the verified identity from the active WebSocket.
  • Zero Storage Cost: No KV or SQL operations are needed to maintain connection state.

Why Not Extend LumenizeDO?

LumenizeDO stores identity in ctx.storage.kv:

  • __lmz_do_binding_name
  • __lmz_do_instance_name

For Gateway's zero-storage requirement, this is unacceptable. Gateway extends DurableObject directly and derives all state from:

  • WebSocket list
  • Alarm status
  • WebSocket attachments

Error Handling

Client Not Connected

If a mesh node attempts to call a client that is disconnected and its grace period has expired, the caller receives a ClientDisconnectedError.

Grace Period Expires

If the call arrives during the grace period, Gateway waits for the client to reconnect. If the grace period expires before reconnection, the caller receives ClientDisconnectedError.

Client Call Timeout

While other mesh nodes can take longer to respond to a call, the Gateway enforces a 30-second timeout for client responses. If the client doesn't respond in time, the connection is closed and a ClientDisconnectedError is returned to the caller.

Token Expiration

Gateway verifies token expiration on each incoming message using claims.exp from the WebSocket attachment. If the token is expired, the connection is closed with a 4401 code and 'Token expired' message.

Resubscribing

When a caller receives ClientDisconnectedError, it is expected to clean up any subscriptions associated with that client. The client will need to restore those subscriptions to restart updates — see Reconnection & Subscription Loss for the client-side pattern.

Trust Demilitarized Zone (DMZ)

The Gateway is the trust DMZ between less trusted clients and more trusted mesh. When forwarding a client's call to the mesh, the Gateway builds the call context from verified sources only (JWT claims and connection data). Even if a malicious client sends fake identity information in a call message, the Gateway ignores it and uses only the verified data from the WebSocket attachment.

This ensures that callContext.originAuth always reflects the actual authenticated user, not whatever the client claimed.

The client can tell the Gateway WHAT to call, but cannot tell the Gateway WHO they are.

Extensibility

LumenizeClientGateway provides lifecycle hooks so applications can customize connection validation, call context enrichment, and call filtering.

Lifecycle Hooks

HookLifecycle momentDefault behavior
onBeforeAcceptWebSocket upgradeValidates {sub}.{tabId} format; all JWT claims auto-included
onBeforeCallToMeshClient → DO call dispatchReturns baseContext unchanged
onBeforeCallToClientDO → client call forwardNo-op (allows all calls)

All hooks are synchronous — this is intentional, so the base class has careful control over concurrency.

Why the Gateway needs hooks

Inside the mesh, DOs trust each other — calls arrive over Workers RPC with a verified CallContext and every node can run onBeforeCall to enforce its own rules. But the client runs in the browser, which is untrusted territory. A malicious user can modify client-side code, forge call payloads, or replay stolen tokens.

The Gateway sits at the boundary between untrusted and trusted. It is the only enforcement point, so it guards traffic in both directions:

  • onBeforeAccept — validates who is connecting. The default confirms the JWT subject matches the instance name so a stolen token can't hijack someone else's Gateway. All JWT payload fields are auto-included in claims; the hook can return a Record to merge additional claims on top. A multi-tenant app might extend this to verify the tenant ID embedded in the instance name (e.g., {sub}.{tenantId}.{tabId}).
  • onBeforeCallToMesh — validates whether this particular user should be allowed to make this particular call. Every inbound call passes through here before reaching any Cloudflare-hosted mesh node.
  • onBeforeCallToClient — validates whether this particular user should be allowed to receive this particular call. For example, a multi-tenant app can reject calls whose context doesn't match the connected user's tenantcy.

onBeforeAccept(instanceName, sub, jwtPayload)

Called during WebSocket upgrade after the base class decodes the JWT. The instanceName parameter is the client's configured instanceName — the same value that identifies the Gateway DO instance (see 1:1 Gateway-Client Relationship). The base class auto-includes all JWT payload fields in claims before calling this hook.

onBeforeAccept(
instanceName: string,
sub: string,
jwtPayload: Record<string, unknown>
): Response | Record<string, unknown> | undefined

Return convention:

  • Response — reject the WebSocket upgrade (returned as the HTTP response)
  • Record<string, unknown> — proceed; merged on top of JWT claims ({ ...jwtPayload, ...hookResult }) and stored in the WebSocket attachment, flowing into callContext.originAuth.claims
  • undefined — proceed with JWT claims only (no additional claims)

The default implementation validates that instanceName follows {sub}.{tabId} format and returns undefined (JWT claims only).

onBeforeCallToMesh(baseContext, connectionInfo)

Called after building the base CallContext for a client-initiated call, before dispatch to the target DO. Return the (possibly enriched) context.

onBeforeCallToMesh(
baseContext: CallContext,
connectionInfo: GatewayConnectionInfo
): CallContext

GatewayConnectionInfo is the single type for both the WebSocket attachment (survives hibernation) and hook parameters. sub is a convenience field that duplicates claims.sub; token expiration is available as claims.exp.

interface GatewayConnectionInfo {
sub: string;
bindingName: string;
instanceName: string;
claims: Record<string, unknown>;
}

onBeforeCallToClient(envelope, connectionInfo)

Called before forwarding a DO-initiated call to the connected client. connectionInfo carries the connected client's verified identity and claims — use it to compare against the envelope's callContext. Throw to reject — the error is wrapped as { $error } for Workers RPC compatibility.

onBeforeCallToClient(envelope: CallEnvelope, connectionInfo: GatewayConnectionInfo): void

Example: Custom Gateway

The Gateway reads its binding name from the X-Lumenize-DO-Binding-Name routing header (set by routeDORequest), so subclasses don't need to override it — just register the binding in wrangler.jsonc.

import { LumenizeClientGateway } from '@lumenize/mesh';
import type { GatewayConnectionInfo, CallContext, CallEnvelope } from '@lumenize/mesh';

export class MyGateway extends LumenizeClientGateway {
// Custom instance name format: {sub}.{tenantId}.{tabId}
// JWT claims are auto-included; return additional claims to merge on top
override onBeforeAccept(
instanceName: string,
sub: string,
jwtPayload: Record<string, unknown>
): Response | Record<string, unknown> | undefined {
const segments = instanceName.split('.');
if (segments.length !== 3) {
return new Response('Invalid format (expected sub.tenantId.tabId)', { status: 403 });
}
if (segments[0] !== sub) {
return new Response('Identity mismatch', { status: 403 });
}
// Merge tenantId on top of auto-included JWT claims
return { tenantId: segments[1] };
}

// Stamp tenant onto every inbound call
override onBeforeCallToMesh(
baseContext: CallContext,
connectionInfo: GatewayConnectionInfo
): CallContext {
return {
...baseContext,
tenantId: connectionInfo.claims?.tenantId,
};
}

// Reject calls originating from a different tenant
override onBeforeCallToClient(
envelope: CallEnvelope,
connectionInfo: GatewayConnectionInfo
): void {
if ((envelope.callContext as any)?.tenantId !== connectionInfo.claims?.tenantId) {
throw new Error('Cross-tenant call rejected');
}
}
}

Register the subclass in wrangler.jsonc. Set gatewayBindingName on your LumenizeClient to match:

{
"durable_objects": {
"bindings": [
{ "name": "MY_GATEWAY", "class_name": "MyGateway" }
]
}
}

Relationship to Trust DMZ

The Trust DMZ is an invariant — the base class always builds callContext.originAuth from verified sources (JWT claims and connection data), never from client-supplied fields. Hooks customize what the DMZ extracts and validates, but cannot bypass the DMZ itself. The client can still tell the Gateway what to call, but cannot tell the Gateway who they are.

Wire Protocol

The Gateway and Client communicate over WebSocket using encoded messages. For complete message type definitions and serialization format details, see the Protocol Specification.