import { newWorkersRpcResponse, RpcTarget } from 'capnweb';
import { validateRpc } from 'capnweb-validate';
type User = { id: string; name: string };
type Profile = { id: string; bio: string };
type Env = {
DELAY_AUTH_MS?: string;
DELAY_PROFILE_MS?: string;
DELAY_NOTIFS_MS?: string;
SIMULATED_RTT_MS?: string;
SIMULATED_RTT_JITTER_MS?: string;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const jittered = (base: number, jitter: number) => base + (jitter ? Math.random() * jitter : 0);
const USERS = new Map<string, User>([
['cookie-123', { id: 'u_1', name: 'Ada Lovelace' }],
['cookie-456', { id: 'u_2', name: 'Alan Turing' }],
]);
const PROFILES = new Map<string, Profile>([
['u_1', { id: 'u_1', bio: 'Mathematician & first programmer' }],
['u_2', { id: 'u_2', bio: 'Mathematician & CS pioneer' }],
]);
const NOTIFICATIONS = new Map<string, string[]>([
['u_1', ['Welcome to Cap\'n Web!', 'You have 2 new followers']],
['u_2', ['New feature: pipelining!', 'Security tips for your account']],
]);
@validateRpc()
export class Api extends RpcTarget {
constructor(private env: Env) { super(); }
async authenticate(sessionToken: string): Promise<User> {
await sleep(Number(this.env.DELAY_AUTH_MS ?? 80));
const user = USERS.get(sessionToken);
if (!user) throw new Error('Invalid session');
return user;
}
async getUserProfile(userId: string): Promise<Profile> {
await sleep(Number(this.env.DELAY_PROFILE_MS ?? 120));
const profile = PROFILES.get(userId);
if (!profile) throw new Error('No such user');
return profile;
}
async getNotifications(userId: string): Promise<string[]> {
await sleep(Number(this.env.DELAY_NOTIFS_MS ?? 120));
return NOTIFICATIONS.get(userId) ?? [];
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === 'OPTIONS' && url.pathname === '/api') {
// Basic CORS preflight support if testing cross-origin
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') || '*',
Vary: 'Origin',
},
});
}
if (url.pathname === '/api') {
// Simulate uplink latency (browser -> server)
const rttBase = Number(env.SIMULATED_RTT_MS ?? 0);
const rttJitter = Number(env.SIMULATED_RTT_JITTER_MS ?? 0);
if (rttBase || rttJitter) await sleep(jittered(rttBase, rttJitter));
const resp = await newWorkersRpcResponse(request, new Api(env));
// Simulate downlink latency (server -> browser)
if (rttBase || rttJitter) await sleep(jittered(rttBase, rttJitter));
// Add CORS so the example also works cross-origin
const headers = new Headers(resp.headers);
const origin = request.headers.get('Origin');
if (origin) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Vary', 'Origin');
}
return new Response(resp.body, { status: resp.status, headers });
}
// Static assets are served from client/dist by Wrangler assets config.
return new Response('Not found', { status: 404 });
},
};// Every RPC call this app makes, and the instrumentation used to time them.
// Kept out of App.tsx so the comparison can be read without the chart and the
// layout around it. Nothing here touches React or the DOM.
import { newHttpBatchRpcSession } from 'capnweb'
import { validateStub } from 'capnweb-validate'
import type { Api } from '../../../server/worker'
export type CallEvent = { label: string, start: number, end: number }
export type NetEvent = { label: string, start: number, end: number }
export type Trace = { total: number, calls: CallEvent[], network: NetEvent[] }
export type Result = {
posts: number
ms: number
user: any
profile: any
notifications: any
trace: Trace
}
/**
* A new session. `validateStub` wraps it so arguments and return values are
* checked against the server's types at the boundary -- see runValidationFailure.
*/
function connectApi() {
return validateStub<Api>(newHttpBatchRpcSession<Api>('/api'))
}
export type FetchInstrument = ReturnType<typeof createFetchInstrument>
/**
* Counts RPC POSTs and records when each one was in flight, by replacing
* `fetch` for as long as it is installed. Latency itself is simulated on the
* Worker (see `SIMULATED_RTT_MS` in wrangler.jsonc), so this only observes.
*/
export function createFetchInstrument() {
let posts = 0
let origin = 0
let events: NetEvent[] = []
const orig = globalThis.fetch
return {
install() {
;(globalThis as any).fetch = async (input: RequestInfo, init?: RequestInit) => {
const method = (init?.method) || (input instanceof Request ? input.method : 'GET')
const url = input instanceof Request ? input.url : String(input)
if (url.endsWith('/api') && method === 'POST') {
posts++
const start = performance.now() - origin
const resp = await orig(input as any, init)
const end = performance.now() - origin
events.push({ label: 'POST /api', start, end })
return resp
}
return orig(input as any, init)
}
},
uninstall() { ;(globalThis as any).fetch = orig },
get() { return posts },
reset() { posts = 0; events = [] },
setOrigin(o: number) { origin = o },
getEvents(): NetEvent[] { return events.slice() },
}
}
/**
* One session, three dependent calls, one round trip. `user` is never awaited
* before `user.id` is passed to the next two calls, so those travel as promise
* references in the same batch rather than waiting for a value to come back.
*/
export async function runPipelined(wrapFetch: FetchInstrument): Promise<Result> {
wrapFetch.reset()
const t0 = performance.now()
wrapFetch.setOrigin(t0)
const calls: CallEvent[] = []
const api = connectApi()
const userStart = 0; calls.push({ label: 'authenticate', start: userStart, end: NaN })
const user = api.authenticate('cookie-123')
user.then(() => { calls.find(c => c.label==='authenticate')!.end = performance.now() - t0 })
const profStart = performance.now() - t0; calls.push({ label: 'getUserProfile', start: profStart, end: NaN })
const profile = api.getUserProfile(user.id)
profile.then(() => { calls.find(c => c.label==='getUserProfile')!.end = performance.now() - t0 })
const notiStart = performance.now() - t0; calls.push({ label: 'getNotifications', start: notiStart, end: NaN })
const notifications = api.getNotifications(user.id)
notifications.then(() => { calls.find(c => c.label==='getNotifications')!.end = performance.now() - t0 })
const [u, p, n] = await Promise.all([user, profile, notifications])
const t1 = performance.now()
const net = wrapFetch.getEvents()
const total = t1 - t0
// Ensure any missing ends are set
calls.forEach(c => { if (!Number.isFinite(c.end)) c.end = total })
return { posts: wrapFetch.get(), ms: total, user: u, profile: p, notifications: n,
trace: { total, calls, network: net } }
}
/**
* The same three calls, each awaited before the next can be built. Three
* sessions, three round trips -- the value of `u.id` has to arrive in the
* browser before the second call can name it.
*/
export async function runSequential(wrapFetch: FetchInstrument): Promise<Result> {
wrapFetch.reset()
const t0 = performance.now()
wrapFetch.setOrigin(t0)
const calls: CallEvent[] = []
const api1 = connectApi()
const aStart = 0; calls.push({ label: 'authenticate', start: aStart, end: NaN })
const uPromise = api1.authenticate('cookie-123')
uPromise.then(() => { calls.find(c => c.label==='authenticate')!.end = performance.now() - t0 })
const u = await uPromise
const api2 = connectApi()
const pStart = performance.now() - t0; calls.push({ label: 'getUserProfile', start: pStart, end: NaN })
const pPromise = api2.getUserProfile(u.id)
pPromise.then(() => { calls.find(c => c.label==='getUserProfile')!.end = performance.now() - t0 })
const p = await pPromise
const api3 = connectApi()
const nStart = performance.now() - t0; calls.push({ label: 'getNotifications', start: nStart, end: NaN })
const nPromise = api3.getNotifications(u.id)
nPromise.then(() => { calls.find(c => c.label==='getNotifications')!.end = performance.now() - t0 })
const n = await nPromise
const t1 = performance.now()
const net = wrapFetch.getEvents()
const total = t1 - t0
calls.forEach(c => { if (!Number.isFinite(c.end)) c.end = total })
return { posts: wrapFetch.get(), ms: total, user: u, profile: p, notifications: n,
trace: { total, calls, network: net } }
}
/**
* Deliberately passes a number where the server declares a string. Returns the
* rejection message, which comes from the validation wrapper rather than from
* anything the server had to hand-write.
*/
export async function runValidationFailure(): Promise<string> {
const api = connectApi() as any
try {
await api.authenticate(12345)
return '(no error thrown, which is unexpected)'
} catch (err) {
return err instanceof Error ? err.message : String(err)
}
}import { defineConfig } from 'vite'
import path from 'node:path'
import { capnwebValidate } from '../../../packages/capnweb-validate/src/plugin.ts'
const repoRoot = path.resolve(__dirname, '../../..')
export default defineConfig({
plugins: [
capnwebValidate.vite({
cwd: __dirname,
tsconfig: 'tsconfig.json',
}),
],
// This example aliases packages to local monorepo source. External projects
// using the published packages should not need this `resolve.alias` block.
resolve: {
alias: {
'capnweb-validate/internal/core': path.resolve(repoRoot, 'packages/capnweb-validate/src/internal/core.ts'),
'capnweb-validate/internal': path.resolve(repoRoot, 'packages/capnweb-validate/src/internal/runtime.ts'),
'capnweb-validate': path.resolve(repoRoot, 'packages/capnweb-validate/src/index.ts'),
'capnweb': path.resolve(repoRoot, 'src/index.ts'),
},
},
build: {
target: 'esnext',
},
esbuild: {
target: 'esnext',
supported: {
'top-level-await': true,
},
},
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
fs: { allow: [repoRoot] },
proxy: { '/api': 'http://127.0.0.1:8787' },
},
})The same comparison as the batch + pipelining example, but from a real front end: a React app served as static assets by the same Worker that answers its RPC calls. It draws a timeline of the requests, so you can watch the sequential version wait out three round trips while the pipelined version makes one.
It also shows the two halves of runtime validation, @validateRpc() on the server and
validateStub() on the client, including what a rejected call looks like.
One Worker, both jobs
The Worker serves the built React app and the RPC endpoint. Static assets are matched first, so
fetch only ever sees /api:
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (url.pathname === '/api') {
return newWorkersRpcResponse(request, new Api(env));
}
return new Response('Not found', { status: 404 });
},
};The client points at a relative /api, so the same build works when served by the Worker and when
served by the Vite dev server, which proxies /api across:
const api = validateStub<Api>(newHttpBatchRpcSession<Api>('/api'));Typed end to end, checked at runtime
runs.ts imports the Api class from server/worker.ts as a type. That gives the client full
autocomplete and compile-time checking against the real server interface, with no schema, no
codegen step, and nothing shipped to the browser. The import disappears at build time.
Types alone stop at the network boundary though, since anything can POST to /api. That is what the
validation layer is for:
@validateRpc()on the server generates argument and return validators from the TypeScript types, and rejects malformed calls before they reach your method.validateStub()on the client checks that what came back matches what the types promised.
The Test validation failure button calls authenticate(12345) with a number where a string is
declared, so you can see the server refuse it.
Run it yourself
npm run build # the examples resolve capnweb to dist/
npx wrangler dev --cwd examples/worker-react --ip 127.0.0.1 --port 8787That serves it from a Worker, so the round trips cross the network.
For React hot reloading, run the Worker and the Vite dev server side by side. See the example’s README.
Next
- Runtime validation: the validation package in full.
- Cloudflare Workers: serving Cap’n Web from a Worker.
- RpcPromise & pipelining: the mechanism being demonstrated.