Skip to content

Workers + React

A React app calling a Cap'n Web Worker, with a request timeline and runtime validation at the RPC boundary.

Updated View as Markdown
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 });
  },
};
/playground/worker-react/index.htmlOpen

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 8787

That 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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close