Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7d866ac
Add capnweb-typecheck for TypeScript RPC validation codegen
teamchong May 11, 2026
ec9629d
Add worker-react debug helper
teamchong May 12, 2026
d44adfa
Update worker-react debug helper for typecheck mode
teamchong May 12, 2026
ec8ef52
Remove typecheck tooling dependencies
teamchong May 13, 2026
034a437
Fold typecheck tooling into capnweb
teamchong May 13, 2026
e00863c
Use capnweb CLI for typecheck codegen
teamchong May 13, 2026
08e5518
Generate typecheck validators at build time
teamchong May 13, 2026
65773d9
Fix vite plugin jsImportPath for .tsx/.mts and add codegen comment
teamchong May 13, 2026
23c1f7e
Address typecheck PR review findings
teamchong May 13, 2026
1e0ed6a
Fix worker-react debug URL mapping
teamchong May 15, 2026
44c0c0a
fix(examples): auto-build web assets before wrangler in dev-debug.sh
teamchong May 15, 2026
2801873
feat(typecheck): auto-load validators via capnweb-typecheck placeholder
teamchong May 16, 2026
9c359fb
fix(typecheck): support pnpm and Yarn PnP package layouts
teamchong May 16, 2026
64a9ed6
refactor(typecheck): replace capnweb-typecheck package with capnweb s…
teamchong May 16, 2026
2041056
fix(build): unblock DTS generation for capnweb subpath self-references
teamchong May 16, 2026
177901a
feat(typecheck): add --strict mode to capnweb typecheck gen
teamchong May 16, 2026
5731f6d
feat(typecheck): expose RpcValidationError class for clean catch patt…
teamchong May 16, 2026
b057272
feat(typecheck): support recursive types via hoisted named validators
teamchong May 16, 2026
18c52f5
feat(typecheck): throw-on-fail validators, shared helpers, readable n…
teamchong May 17, 2026
cdcb77c
fix(test): register typecheck subpath modules with the workerd test pool
teamchong May 17, 2026
e860918
fix(typecheck): auto-unplug under Yarn Plug'n'Play via a no-op postin…
teamchong May 18, 2026
7cd1953
fix(test): keep hardlink sentinel on the same filesystem as the target
teamchong May 18, 2026
3343de6
chore(typecheck): trim narrating comments and a redundant test
teamchong May 18, 2026
d13b590
fix(example): resolve typecheck subpaths in debug vite config
teamchong May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/typescript-rpc-validators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"capnweb": minor
---

Add build-time TypeScript RPC validation codegen.

A new opt-in `capnweb typecheck gen` CLI command and `capnweb/vite` plugin generate runtime validators for `RpcTarget` methods from your TypeScript types. The `capnweb` runtime stays dependency-free; the typecheck tooling uses the project's TypeScript compiler via an optional peer.

- `capnweb typecheck gen`: `capnweb typecheck gen src/worker.ts --out .capnweb` for Wrangler-style builds.
- `capnweb/vite` plugin: transforms client modules in memory and registers server validators via the worker entry module.
- Server-side: validators are keyed by `RpcTarget` constructor and check arguments before invocation and return values before serialization.
- Client-side: typed factory and `new RpcSession<T>(...)` call sites bind validators to the original `RpcStub`, preserving `RpcPromise` pipelining, disposal, and `StubBase` behavior.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ node_modules
examples/worker-react/web/dist/
notes.txt
/dist/
packages/*/dist/
.capnweb/
examples/**/.capnweb/
examples/worker-react/wrangler.typecheck-dev.toml
170 changes: 170 additions & 0 deletions __tests__/typecheck-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the MIT license found in the LICENSE.txt file or at:
// https://opensource.org/license/mit
//
// Tests for the `generateForPackage` flow: validators are written into
// capnweb's `_typecheck-validators` subpath and the runtime auto-binds them
// by class name without an explicit registration call.

import { linkSync, mkdtempSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
generateForPackage,
resetTypecheckPackage,
} from "../src/typecheck/generate.js";

const FIXTURE = `
import { RpcTarget } from "capnweb";

export class Echo extends RpcTarget {
ping(input: string): string {
return input;
}
add(a: number, b: number): number {
return a + b;
}
}
`;

describe("generateForPackage", () => {
let workDir: string;
let inputFile: string;

beforeAll(() => {
workDir = mkdtempSync(join(tmpdir(), "capnweb-typecheck-"));
inputFile = join(workDir, "worker.ts");
writeFileSync(inputFile, FIXTURE);
});

afterAll(() => {
resetTypecheckPackage();
});

it("writes validators that load and reject wrong arg shapes", async () => {
generateForPackage({ input: inputFile });
// Cache-bust on every import so we read the file we just wrote.
let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any;

expect(Object.keys(mod.validators.Echo).sort()).toEqual(["add", "ping"]);
expect(() => mod.validators.Echo.ping.args(["hello"])).not.toThrow();
expect(() => mod.validators.Echo.ping.args([])).toThrow(/expected 1 argument/);
expect(() => mod.validators.Echo.ping.args([42])).toThrow(/expected string/);
});

it("reset restores the null-validators stub", async () => {
generateForPackage({ input: inputFile });
resetTypecheckPackage();

let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any;
expect(mod.validators).toBeNull();
});

it("validates recursive types through hoisted named validators", async () => {
let recursiveDir = mkdtempSync(join(tmpdir(), "capnweb-recursive-"));
let recursiveInput = join(recursiveDir, "worker.ts");
writeFileSync(recursiveInput, `
import { RpcTarget } from "capnweb";

interface Tree { name: string; children: Tree[]; }

export class TreeApi extends RpcTarget {
size(value: Tree): number { return 0; }
}
`);
generateForPackage({ input: recursiveInput });
let m = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any;

// Valid deeply nested tree.
let tree = { name: "root", children: [{ name: "leaf", children: [] }] };
expect(() => m.validators.TreeApi.size.args([tree])).not.toThrow();

// Invalid: bad type at a deep level. Must reach the validator via the
// hoisted named function calling itself recursively.
let bad = { name: "root", children: [{ name: 42, children: [] }] };
expect(() => m.validators.TreeApi.size.args([bad])).toThrow(/expected string/);
});

it("throws RpcValidationError with structured payload on argument failures", async () => {
generateForPackage({ input: inputFile });
let m = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any;
// Import RpcValidationError from `capnweb` (i.e., the built dist) — that's
// the same module the generated validators import from. Importing from
// `../src/index.js` would give a different class even though both
// construct errors with the same shape, and instanceof would fail.
let cw = await import("capnweb") as typeof import("../src/index.js");

try {
m.validators.Echo.ping.args([42]);
throw new Error("expected validator to throw");
} catch (err) {
expect(err).toBeInstanceOf(cw.RpcValidationError);
expect(err).toBeInstanceOf(TypeError);
let v = (err as InstanceType<typeof cw.RpcValidationError>);
expect(v.name).toBe("RpcValidationError");
expect(v.rpcValidation.expected).toBe("string");
expect(v.rpcValidation.actual).toBe("number");
expect(v.rpcValidation.path).toEqual(["input"]);
expect(v.rpcValidation.value).toBe(42);
}
});

it("disambiguates two unrelated types that share a display name", async () => {
let fixturePath = join(workDir, "name-collision.ts");
writeFileSync(fixturePath, `
import { RpcTarget } from "capnweb";

// Two distinct shapes both called User. Codegen must keep them functionally
// separate while keeping the names readable.
namespace Account { export interface User { id: string; } }
namespace Billing { export interface User { customerId: string; } }

export class Api extends RpcTarget {
accountUser(value: Account.User): void {}
billingUser(value: Billing.User): void {}
}
`);
generateForPackage({ input: fixturePath });
let m = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any;

// The Account.User validator should accept its shape and reject the
// Billing.User shape (and vice versa). If naming collided incorrectly
// (both mapped to the same function), one of these would let the wrong
// shape through.
expect(() => m.validators.Api.accountUser.args([{ id: "u_1" }])).not.toThrow();
expect(() => m.validators.Api.accountUser.args([{ customerId: "c_1" }])).toThrow(/expected string/);
expect(() => m.validators.Api.billingUser.args([{ customerId: "c_1" }])).not.toThrow();
expect(() => m.validators.Api.billingUser.args([{ id: "u_1" }])).toThrow(/expected string/);
});

// pnpm installs packages as hardlinks to a content-addressable store. A
// naive `writeFileSync` would truncate the shared inode, corrupting every
// other project that depends on the same capnweb version. The generate
// path must unlink first so the store entry stays intact.
it("does not corrupt a hardlinked sibling of the validators file", () => {
let req = createRequire(__filename);
let validatorsPath = req.resolve("capnweb/_typecheck-validators");
// Hardlinks can't cross filesystems (`EXDEV`). On GitHub Actions
// `tmpdir()` (`/tmp`) sits on a different volume from the project
// (`/__w/...`), so put the sentinel next to the file we're linking.
let sentinel = join(dirname(validatorsPath), "__hardlink-sentinel.js");

writeFileSync(validatorsPath, "export const validators = null;\n");
let beforeInode = statSync(validatorsPath).ino;
try { unlinkSync(sentinel); } catch {}
linkSync(validatorsPath, sentinel);
expect(statSync(sentinel).ino).toBe(beforeInode);

try {
generateForPackage({ input: inputFile });

expect(statSync(validatorsPath).ino).not.toBe(beforeInode);
expect(readFileSync(sentinel, "utf8")).toContain("export const validators = null");
expect(readFileSync(validatorsPath, "utf8")).toContain("Echo");
} finally {
unlinkSync(sentinel);
}
});
});
Loading
Loading