vs Cap'n Web (basics and types)
π Doc-testing β Why do these examples look like tests?
This documentation uses testable code examples to ensure accuracy and reliability:
- Guaranteed accuracy: All examples are real, working code that runs against the actual package(s)
- Guaranteed latest comparisons: Further, our release script won't allow us to release a new version of Lumenize, without prompting us to update any doc-tested comparison package (e.g. Cap'n Web)
- Always up-to-date: When a package changes, the tests fail and the docs must be updated
- Copy-paste confidence: What you see is what works - no outdated or broken examples
- Real-world patterns: Tests show complete, runnable scenarios, not just snippets
Ignore the test boilerplate (it(), describe(), etc.) - focus on the code inside.
This living documentation compares how Lumenize RPC and Cap'n Web (Cloudflare's official "last-mile" RPC solution) handle basic operations and data types.
Importsβ
import { it, expect } from 'vitest';
// @ts-expect-error - cloudflare:test module types are not consistently exported
import { SELF } from 'cloudflare:test';
import { createRpcClient, createWebSocketTransport } from '@lumenize/rpc';
import { getWebSocketShim } from '@lumenize/utils';
import { newWebSocketRpcSession } from 'capnweb';
import { LumenizeDO, CapnWebRpcTarget } from '../src/index';
Version(s)β
This test asserts the installed version(s) and our release script warns if we aren't using the latest version published to npm, so this living documentation should always be up to date.
import lumenizeRpcPackage from '../../../../packages/rpc/package.json';
import capnwebPackage from '../../../../node_modules/capnweb/package.json';
it('detects package versions', () => {
expect(lumenizeRpcPackage.version).toBe('0.17.0');
expect(capnwebPackage.version).toBe('0.1.0');
});
Creating Clientsβ
// The `WebSocketClass` injection is for vitest-workers-pool. In production,
// this would be (3 lines):
// ```ts
// const client = createRpcClient<typeof LumenizeDO>({
// transport: createWebSocketTransport('LUMENIZE', 'name')
// });
// ```
function getLumenizeClient(instanceName: string) {
return createRpcClient<typeof LumenizeDO>({
transport: createWebSocketTransport('LUMENIZE', instanceName,
{ WebSocketClass: getWebSocketShim(SELF.fetch.bind(SELF)) }
)
});
}
// Similarly, in production, this would be (2 lines):
// ```ts
// const url = `wss://test.com/capnweb/capnweb/name`;
// const client = newWebSocketRpcSession<CapnWebRpcTarget>(url);
// ```
function getCapnWebClient(instanceName: string) {
const url = `wss://test.com/capnweb/capnweb/${instanceName}`;
const ws = new (getWebSocketShim(SELF.fetch.bind(SELF)))(url);
return newWebSocketRpcSession<CapnWebRpcTarget>(ws);
}
Simple method callβ
Simple method calls are exactly the same.
it('demonstrates a simple method call', async () => {
// ==========================================================================
// Lumenize RPC
// ==========================================================================
using lumenizeClient = getLumenizeClient('method-call');
expect(await lumenizeClient.increment()).toBe(1);
// ==========================================================================
// Cap'n Web
// ==========================================================================
using capnwebClient = getCapnWebClient('method-call');
expect(await capnwebClient.increment()).toBe(1);
});
RPC Client Access to ctx and envβ
It may be possible to access properties like ctx and env via
Cap'n Web, but we couldn't find any documentation or examples showing how,
and trying the obvious approaches didn't work. If anyone knows whether and how
this can be done with Cap'n Web, please let us know and we'll immediately
update this document.
If our understanding is correct, this is the biggest usage difference between Cap'n Web and Lumenize RPC.
Lumenize RPC:
- β
Full client access:
client.ctx.storage.kv.put('key', 'value') - β
Full client access to env:
client.env.BINDING.getByName().someMethod() - β No custom methods needed for storage/state access
Cap'n Web:
- β No client access to
ctx- Must write custom methods - β No client access to
env- Must write custom methods - β οΈ Every storage operation requires a custom DO method
it('demonstrates RPC client access to ctx and env', async () => {
// ==========================================================================
// Lumenize RPC
// ==========================================================================
using lumenizeClient = getLumenizeClient('ctx-access');
// β
Lumenize RPC: Direct client access to ctx.storage!
await lumenizeClient.ctx.storage.put('direct-key', 'direct-value');
const directValue = await lumenizeClient.ctx.storage.get('direct-key');
expect(directValue).toBe('direct-value');
// β
Access to env and hopping to another instance
const anotherInstance = await lumenizeClient.env.LUMENIZE.getByName(
'another-instance'
);
expect(anotherInstance.name).toBe('another-instance');
// β
You can still call custom methods if you want
expect(await lumenizeClient.increment()).toBe(1);
// ==========================================================================
// Cap'n Web
// ==========================================================================
using capnwebClient = getCapnWebClient('ctx-access');
// β Trying to use ctx.storage will fail
const capnCtx: any = capnwebClient.ctx;
await expect(async () => {
await capnCtx.storage.put('direct-key', 'direct-value');
const directValue = await capnCtx.storage.get('direct-key');
expect(directValue).toBe('direct-value');
}).rejects.toThrow();
// β Trying to use env will also fail
const capnEnv = (capnwebClient as any).env;
await expect(async () => {
const anotherInstance = await capnEnv.CAPNWEB.getByName('another-instance');
expect(anotherInstance.name).toBe('another-instance');
}).rejects.toThrow();
// β οΈ You MUST write a custom method like increment() to access storage
expect(await capnwebClient.increment()).toBe(1);
});
Supported Typesβ
Lumenize RPC supports everything that Durable Object storage (SQLite engine) supports, plus a few additional types. The only types that Workers RPC supports that Lumenize does not are Readable/Writable Streams.
Cap'n Web's limited type support is a significant foot-gun. If that improves over time, we'll update this table.
| Type | Workers RPC | DO Storage | Cap'n Web | Lumenize | Notes |
|---|---|---|---|---|---|
| Cycles & Aliases | β ββ | β ββ | β | β | Cap'n Web throws error Cloudflare plans to remove |
| Primitives | |||||
| undefined | β | β | β | β | |
| null | β | β | β | β | |
| Special Numbers | |||||
| NaN | β | β | βββ | β | Cap'n Web returns null |
| Infinity | β | β | βββ | β | Cap'n Web returns null |
| -Infinity | β | β | βββ | β | Cap'n Web returns null |
| Built-in Types | |||||
| BigInt | β | β | β | β | |
| Date | β | β | β | β | |
| RegExp | β | β | βββ | β | |
| Map | β | β | βββ | β | |
| Set | β | β | βββ | β | |
| ArrayBuffer | β | β | βββ | β | |
| Uint8Array | β | β | β | β | |
| Errors | |||||
| Error (thrown) | β | N/A | β οΈ | β | Cap'n Web loses name and remote stack |
| Error (value) | β | β οΈ | β οΈββ | β | Cap'n Web loses name and remote stack |
| Web API Types | |||||
| Request | β | β | β | β | |
| Response | β | β | β | β | |
| Headers | β | β | βββ | β | Cap'n Web: type annotation only, no serialization |
| URL | β | β | βββ | β | |
| Streams | |||||
| ReadableStream | β | β | β | β | Cap'n Web: "may be added" |
| WritableStream | β | β | β | β | Lumenize: "just use WebSockets" |
Note, we use βββ or β οΈββ in the Cap'n Web column for certain rows because we have submitted a pull request to upgrade Cap'n Web's type support.
For comprehensive type support testing, see the behavior test suite.
Error handling (thrown)β
A significant DX concern is getting useful information from thrown Errors.
Lumenize RPC doesn't reconstitute custom Error types over the wire, but it automatically sets the name property to the custom Error type's identifier and sends the server-side stack trace for use on the client side.
Cap'n Web preserves the server-side message, but the name is lost and the stack trace shows Cap'n Web internals on the client side.
Lumenize RPC: β
Preserves name, message, and remote stack trace
Cap'n Web: β οΈ Preserves message only, loses name and remote stack
it('demonstrates error throwing', async () => {
// ==========================================================================
// Lumenize RPC
// ==========================================================================
using lumenizeClient = getLumenizeClient('error-throw');
try {
await lumenizeClient.throwError();
expect.fail('should not reach');
} catch (e: any) {
expect(e.message).toContain('Intentional error'); // β
expect(e.stack).toContain('throwError'); // β
Actual remote stack
}
// ==========================================================================
// Cap'n Web
// ==========================================================================
using capnwebClient = getCapnWebClient('error-throw');
try {
await capnwebClient.throwError();
expect.fail('should not reach');
} catch (e: any) {
expect(e.message).toContain('Intentional error'); // β
expect(e.stack).not.toContain('throwError'); // β Local RPC internals
}
});
Error as valueβ
Lumenize RPC: β
Error type (name), message, and stack all preserved
Cap'n Web: β Loses error type (name), stack shows RPC internals not origin
Both: β οΈ Loses prototype, but name can be used as a substitute for Lumenize
it('demonstrates error as value', async () => {
class CustomError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomError';
}
}
const testError = new CustomError('Test error');
// ==========================================================================
// Lumenize RPC
// ==========================================================================
using lumenizeClient = getLumenizeClient('error-value');
const lumenizeResult = await lumenizeClient.echo(testError);
expect(lumenizeResult.message).toBe('Test error'); // β
expect(lumenizeResult).toBeInstanceOf(Error); // β
expect(lumenizeResult).not.toBeInstanceOf(CustomError); // β
// β
But name is automatically set and preserved
expect(lumenizeResult.name).toBe('CustomError');
// β
Original stack preserved
expect(lumenizeResult.stack).toContain('basics-and-types.test.ts');
// ==========================================================================
// Cap'n Web
// ==========================================================================
using capnwebClient = getCapnWebClient('error-value');
// @ts-ignore Typescript thinks next line has an infinite recursion problem
const capnwebResult = await capnwebClient.echo(testError);
expect(capnwebResult.message).toBe('Test error'); // β
expect(capnwebResult).toBeInstanceOf(Error); // β
expect(capnwebResult).not.toBeInstanceOf(CustomError); // β
expect(capnwebResult.name).not.toBe('CustomError'); // β Lost CustomError name
expect(capnwebResult.stack).toContain('_Evaluator'); // β RPC internals
});
Circular references and aliasesβ
Lumenize RPC: β
Handles circular references and aliases correctly
Cap'n Web: β Throws "DataCloneError: The object could not be cloned"
Most disappointing to us at Lumenize regarding supported types is that Cap'n Web does not and will not ever support cyclic values or aliases. Our core product uses directed acyclic graphs (DAG) with heavy use of aliases. Refactoring to a nodes + edges data structure is not workable for us.
We have often considered moving our intra-Cloudflare transport to Workers RPC, but those discussions have stopped because of this statement from the Workers RPC documentation:
Workers RPC supports sending values that contain aliases and cycles. This can actually cause problems, so we actually plan to remove this feature from Workers RPC (with a compatibility flag, of course) [emphasis added].
it('demonstrates circular references', async () => {
const circular: any = { name: 'root' };
circular.self = circular;
// ==========================================================================
// Lumenize RPC
// ==========================================================================
using lumenizeClient = getLumenizeClient('circular');
const lumenizeResult = await lumenizeClient.echo(circular);
expect(lumenizeResult).toEqual(circular); // β
// ==========================================================================
// Cap'n Web
// ==========================================================================
using capnwebClient = getCapnWebClient('circular');
let capnwebThrew = false;
try {
await capnwebClient.echo(circular);
} catch (e) {
capnwebThrew = true;
}
expect(capnwebThrew).toBe(true); // β
});
Web API types (Request, Response, Headers, URL)β
The main use case for this capability is offloading external HTTP fetches
from a Durable Object (where you're billed on wall clock time) to a Worker
(where you're billed on CPU time). We have on our roadmap to release
@lumenize/proxy-fetch, a package that implements this offloading pattern.
This use case is one of the most common sources of repeated questions on the #durable-objects Discord channel.
Lumenize RPC: β
Web API types work including body content
Cap'n Web: β Cannot serialize any Web API types
it('demonstrates Web API Request support', async () => {
const testRequest = new Request('https://example.com/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: 'test payload' })
});
// ==========================================================================
// Lumenize RPC
// ==========================================================================
using lumenizeClient = getLumenizeClient('request');
const lumenizeResult = await lumenizeClient.echo(testRequest);
expect(lumenizeResult).toBeInstanceOf(Request); // β
expect(lumenizeResult.url).toBe('https://example.com/test'); // β
expect(lumenizeResult.method).toBe('POST'); // β
expect(await lumenizeResult.json()).toEqual({ data: 'test payload' }); // β
// ==========================================================================
// Cap'n Web
// ==========================================================================
using capnwebClient = getCapnWebClient('request');
let capnwebThrew = false;
try {
await capnwebClient.echo(testRequest);
} catch (e) {
capnwebThrew = true;
}
expect(capnwebThrew).toBe(true); // β
});
Standard types (primitives and built-ins)β
Lumenize RPC: β
All standard types preserved correctly
Cap'n Web: β οΈ Special numbers (NaN, Infinity, -Infinity) become null
it('demonstrates standard type support', async () => {
const bigInt = 12345678901234567890n;
// ==========================================================================
// Lumenize RPC
// ==========================================================================
using lumenizeClient = getLumenizeClient('types');
expect(await lumenizeClient.echo(undefined)).toBeUndefined(); // β
expect(await lumenizeClient.echo(null)).toBeNull(); // β
expect(Number.isNaN(await lumenizeClient.echo(NaN))).toBe(true); // β
expect(await lumenizeClient.echo(Infinity)).toBe(Infinity); // β
expect(await lumenizeClient.echo(-Infinity)).toBe(-Infinity); // β
expect(await lumenizeClient.echo(bigInt)).toBe(bigInt); // β
// ==========================================================================
// Cap'n Web
// ==========================================================================
using capnwebClient = getCapnWebClient('types');
expect(await capnwebClient.echo(undefined)).toBeUndefined(); // β
expect(await capnwebClient.echo(null)).toBeNull(); // β
expect(Number.isNaN(await capnwebClient.echo(NaN))).not.toBe(true); // β
expect(await capnwebClient.echo(Infinity)).not.toBe(Infinity); // β
expect(await capnwebClient.echo(-Infinity)).not.toBe(-Infinity); // β
expect(await capnwebClient.echo(bigInt)).toBe(bigInt); // β
});
Installationβ
npm install --save-dev vitest@3.2
npm install --save-dev @vitest/coverage-istanbul@3.2
npm install --save-dev @cloudflare/vitest-pool-workers
npm install --save-dev @lumenize/rpc
npm install --save-dev @lumenize/utils
npm install --save-dev capnweb
Configuration Filesβ
src/index.tsβ
Worker, DurableObjects and RpcTargets
import { DurableObject, RpcTarget } from 'cloudflare:workers';
import { lumenizeRpcDO } from '@lumenize/rpc';
import { routeDORequest } from '@lumenize/utils';
import { newWorkersRpcResponse } from 'capnweb';
// ============================================================================
// Lumenize RPC
// ============================================================================
class _LumenizeDO extends DurableObject {
increment(): number {
let count = (this.ctx.storage.kv.get<number>("count")) ?? 0;
this.ctx.storage.kv.put("count", ++count);
return count;
}
throwError(): never {
throw new Error('Intentional error from Lumenize DO');
}
echo(value: any): any {
return value;
}
}
export const LumenizeDO = lumenizeRpcDO(_LumenizeDO);
// ============================================================================
// Cap'n Web - A little more boilerplate (fetch and constructor, but the latter
// is only because we want to use storage and test env access)
// ============================================================================
// Per Cap'n Web docs: "Classes which are intended to be passed by reference
// and called over RPC must extend RpcTarget"
export class CapnWebRpcTarget extends RpcTarget {
// RpcTarget requires us to manually capture ctx/env in constructor
constructor(
public ctx: DurableObjectState,
public env: any
) {
super();
}
increment(): number {
let count = (this.ctx.storage.kv.get<number>("count")) ?? 0;
this.ctx.storage.kv.put("count", ++count);
return count;
}
throwError(): never {
throw new Error('Intentional error from Cap\'n Web RpcTarget');
}
echo(value: any): any {
return value;
}
fetch(request: Request): Response | Promise<Response> {
return newWorkersRpcResponse(request, this);
}
}
// ============================================================================
// Worker - Route requests to appropriate DO
// ============================================================================
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const lumenizeResponse = await routeDORequest(request, env, { prefix: '__rpc' });
if (lumenizeResponse) return lumenizeResponse;
const capnwebResponse = await routeDORequest(request, env, { prefix: 'capnweb' });
if (capnwebResponse) return capnwebResponse;
// Fallback for non-RPC requests
return new Response('Not found', { status: 404 });
},
};
wrangler.jsoncβ
{
"name": "feature-comparison",
"main": "src/index.ts",
"compatibility_date": "2025-09-12",
"durable_objects": {
"bindings": [
{
"name": "LUMENIZE",
"class_name": "LumenizeDO"
},
{
"name": "CAPNWEB",
"class_name": "CapnWebRpcTarget"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["LumenizeDO", "CapnWebRpcTarget"]
}
]
}
vitest.config.jsβ
import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersProject({
test: {
testTimeout: 2000, // 2 second global timeout
poolOptions: {
workers: {
// Must be false to use websockets. Have each test
// reference a different DO instance to avoid state sharing.
isolatedStorage: false,
// Important! use the wrangler.jsonc in ./test
wrangler: { configPath: "./wrangler.jsonc" },
},
},
// Use `vitest --run --coverage` to get test coverage report(s)
coverage: {
provider: "istanbul", // Cannot use V8
reporter: ['text', 'json', 'html'],
include: ['**/src/**'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/*.config.ts',
'**/scratch/**'
],
},
},
});
Try it outβ
To run these examples:
vitest --run
To see test coverage:
vitest --run --coverage