diff --git a/.changeset/typescript-rpc-validators.md b/.changeset/typescript-rpc-validators.md new file mode 100644 index 0000000..9f56e30 --- /dev/null +++ b/.changeset/typescript-rpc-validators.md @@ -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(...)` call sites bind validators to the original `RpcStub`, preserving `RpcPromise` pipelining, disposal, and `StubBase` behavior. diff --git a/.gitignore b/.gitignore index b267ee2..92814fa 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts new file mode 100644 index 0000000..e46a9ad --- /dev/null +++ b/__tests__/typecheck-package.test.ts @@ -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); + 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); + } + }); +}); diff --git a/__tests__/typecheck-types.test.ts b/__tests__/typecheck-types.test.ts new file mode 100644 index 0000000..c015f92 --- /dev/null +++ b/__tests__/typecheck-types.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Per-kind validator coverage. Every TypeSpec kind capnweb claims to support +// has at least one happy-path test (accepts a valid value) and one +// rejection (catches a wrong shape). When this file grows a new kind, add +// it here too — codegen support without a test is a hole. + +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { generateForPackage, resetTypecheckPackage } from "../src/typecheck/generate.js"; + +type Case = { + /** Display name in the test report. */ + name: string; + /** File-scope code that goes before the class declaration (interfaces, type aliases). */ + preamble?: string; + /** Imports beyond `RpcTarget`. Comma-separated names. */ + extraImports?: string; + /** Method declaration body — e.g. `method(value: string): void {}`. */ + fixture: string; + /** Value the validator should accept without throwing. Omit to skip the happy-path check. */ + ok?: unknown; + /** Values the validator must reject; substring is matched against the thrown message. */ + bad: { value: unknown; match: RegExp }[]; + /** If true, assert on the return validator instead of args. Used for void returns. */ + testReturn?: { ok?: unknown; bad?: { value: unknown; match: RegExp }[] }; + /** + * Use a local fake capnweb module instead of the real one. Needed for + * types whose real definitions involve constraints TypeScript resolves to + * `any` in a minimal fixture (e.g. RpcStub). Codegen identifies these + * by symbol name, so a trivial alias is enough to exercise the kind. + */ + fakeCapnweb?: boolean; +}; + +const CASES: Case[] = [ + { + name: "primitive: string", + fixture: `method(value: string): void {}`, + ok: "hello", + bad: [ + { value: 42, match: /expected string/ }, + { value: null, match: /expected string/ }, + ], + }, + { + name: "primitive: number", + fixture: `method(value: number): void {}`, + ok: 42, + bad: [{ value: "42", match: /expected number/ }], + }, + { + name: "primitive: boolean", + fixture: `method(value: boolean): void {}`, + ok: true, + bad: [{ value: "true", match: /expected boolean/ }], + }, + { + name: "primitive: bigint", + fixture: `method(value: bigint): void {}`, + ok: BigInt(1), + bad: [{ value: 1, match: /expected bigint/ }], + }, + { + name: "primitive: null", + fixture: `method(value: null): void {}`, + ok: null, + bad: [{ value: undefined, match: /expected null/ }], + }, + { + name: "primitive: undefined", + fixture: `method(value: undefined): void {}`, + ok: undefined, + bad: [{ value: 0, match: /expected undefined/ }], + }, + { + name: "literal: string", + fixture: `method(value: "open"): void {}`, + ok: "open", + bad: [{ value: "closed", match: /expected "open"/ }], + }, + { + name: "literal: number", + fixture: `method(value: 42): void {}`, + ok: 42, + bad: [{ value: 7, match: /expected 42/ }], + }, + { + name: "literal: boolean", + fixture: `method(value: true): void {}`, + ok: true, + bad: [{ value: false, match: /expected true/ }], + }, + { + name: "array", + fixture: `method(value: string[]): void {}`, + ok: ["a", "b"], + bad: [ + { value: "a", match: /expected string\[\]/ }, + { value: ["a", 1], match: /expected string/ }, + ], + }, + { + name: "tuple", + fixture: `method(value: [string, number]): void {}`, + ok: ["x", 1], + bad: [ + { value: ["x"], match: /expected \[string, number\]/ }, + { value: ["x", "y"], match: /expected number/ }, + ], + }, + { + name: "object", + fixture: `method(value: { id: string, age: number }): void {}`, + ok: { id: "u_1", age: 30 }, + bad: [ + { value: { id: "u_1" }, match: /expected number/ }, + { value: { id: 1, age: 30 }, match: /expected string/ }, + ], + }, + { + name: "object with optional", + fixture: `method(value: { id: string, age?: number }): void {}`, + ok: { id: "u_1" }, + bad: [{ value: { id: "u_1", age: "old" }, match: /expected number/ }], + }, + { + name: "record", + fixture: `method(value: Record): void {}`, + ok: { a: 1, b: 2 }, + bad: [{ value: { a: "1" }, match: /expected number/ }], + }, + { + name: "map", + fixture: `method(value: Map): void {}`, + ok: new Map([["a", 1]]), + bad: [{ value: new Map([["a", "1"]]), match: /expected number/ }], + }, + { + name: "set", + fixture: `method(value: Set): void {}`, + ok: new Set(["a"]), + bad: [{ value: new Set([1]), match: /expected string/ }], + }, + { + name: "union", + fixture: `method(value: string | number): void {}`, + ok: "x", + bad: [{ value: true, match: /expected number \| string/ }], + }, + { + name: "instance: Date", + fixture: `method(value: Date): void {}`, + ok: new Date(), + bad: [{ value: "2026-01-01", match: /expected Date/ }], + }, + { + name: "instance: Uint8Array", + fixture: `method(value: Uint8Array): void {}`, + ok: new Uint8Array([1, 2, 3]), + bad: [{ value: [1, 2, 3], match: /expected Uint8Array/ }], + }, + { + name: "function", + fixture: `method(value: () => void): void {}`, + ok: () => {}, + bad: [{ value: "not a function", match: /expected Function/ }], + }, + { + name: "recursive (named ref)", + preamble: `interface Tree { name: string; children: Tree[]; }`, + fixture: `method(value: Tree): void {}`, + ok: { name: "root", children: [{ name: "leaf", children: [] }] }, + bad: [ + { value: { name: "root", children: [{ name: 42, children: [] }] }, match: /expected string/ }, + ], + }, + { + name: "nested: array of object", + preamble: `interface User { id: string; }`, + fixture: `method(value: User[]): void {}`, + ok: [{ id: "a" }, { id: "b" }], + bad: [{ value: [{ id: 1 }], match: /expected string/ }], + }, + { + name: "void return", + fixture: `method(): void {}`, + bad: [], + // Return validator accepts undefined, rejects anything else. + testReturn: { + ok: undefined, + bad: [{ value: "oops", match: /expected undefined/ }], + }, + }, + { + name: "rpcTarget (subclass param)", + preamble: `export class Helper extends RpcTarget { ping(): string { return "ok"; } }`, + fixture: `method(value: Helper): void {}`, + bad: [ + { value: { not: "a target" }, match: /expected/ }, + { value: 42, match: /expected/ }, + ], + // RpcTarget identity check uses `instanceof`; a happy-path test would + // need to actually instantiate the user's class, which the test harness + // can't easily do from the generated module. The negative tests below + // cover the rejection path. + }, + { + name: "stub (RpcStub)", + fakeCapnweb: true, + extraImports: "RpcStub", + fixture: `method(value: RpcStub): void {}`, + // A stub at runtime is any object or function; the validator only + // distinguishes object/function from primitives/null. + ok: { fake: "stub" }, + bad: [ + { value: null, match: /expected RpcStub/ }, + { value: 42, match: /expected RpcStub/ }, + ], + }, + { + name: "instance: Error", + fixture: `method(value: Error): void {}`, + ok: new Error("boom"), + bad: [{ value: { message: "fake" }, match: /expected Error/ }], + }, + { + name: "instance: Blob", + fixture: `method(value: Blob): void {}`, + ok: new Blob(["hi"]), + bad: [{ value: "hi", match: /expected Blob/ }], + }, +]; + +describe("validator coverage by TypeSpec kind", () => { + let workDir: string; + + beforeAll(() => { + workDir = mkdtempSync(join(tmpdir(), "capnweb-kinds-")); + }); + + afterAll(() => { + resetTypecheckPackage(); + }); + + it.each(CASES)("$name", async (c) => { + let input = join(workDir, `case-${slug(c.name)}.ts`); + let imports = c.extraImports ? `RpcTarget, ${c.extraImports}` : "RpcTarget"; + let importPath = "capnweb"; + if (c.fakeCapnweb) { + let fakePath = join(workDir, `fake-capnweb-${slug(c.name)}.ts`); + // RpcStub is a class (not a `type` alias) so TypeScript preserves its + // identity instead of inlining the type parameter; codegen's symbol-name + // check ("RpcStub") needs that identity to detect the kind. + writeFileSync(fakePath, `export class RpcTarget {}\nexport class RpcStub { value!: T; }\n`); + importPath = `./fake-capnweb-${slug(c.name)}.js`; + } + writeFileSync(input, ` +import { ${imports} } from "${importPath}"; + +${c.preamble ?? ""} + +export class Api extends RpcTarget { + ${c.fixture} +} +`); + generateForPackage({ input }); + let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; + + expect(mod.validators?.Api?.method).toBeTruthy(); + + if (c.ok !== undefined) { + expect(() => mod.validators.Api.method.args([c.ok])).not.toThrow(); + } + for (let bad of c.bad) { + expect(() => mod.validators.Api.method.args([bad.value])).toThrow(bad.match); + } + + if (c.testReturn) { + let r = c.testReturn; + if (r.ok !== undefined || r.ok === undefined) { + expect(() => mod.validators.Api.method.returns(r.ok)).not.toThrow(); + } + for (let bad of r.bad ?? []) { + expect(() => mod.validators.Api.method.returns(bad.value)).toThrow(bad.match); + } + } + }); +}); + +function slug(name: string): string { + return name.replace(/[^A-Za-z0-9]/g, "_"); +} diff --git a/__tests__/typecheck.test.ts b/__tests__/typecheck.test.ts new file mode 100644 index 0000000..646f4d2 --- /dev/null +++ b/__tests__/typecheck.test.ts @@ -0,0 +1,756 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + collectReachableSourceFiles, + commonDir, + createProject, + extractClasses, +} from "../src/typecheck/extract.js"; +import { generate } from "../src/typecheck/generate.js"; +import { emitShadowSources } from "../src/typecheck/rewrite.js"; +import { RpcStub, RpcTarget } from "../src/index.js"; +import { RpcPayload, setRpcMethodValidators } from "../src/core.js"; + +// ===================================================================== +// Helpers + +function inspectInput(input: string) { + let project = createProject(input); + let sourceFile = project.sourceFile; + let reachableFiles = collectReachableSourceFiles(project); + let { classes, named } = extractClasses(project, reachableFiles); + return { sourceFile, reachableFiles, classes, named }; +} + +function emitShadowFor(input: string, outDir: string): void { + let { sourceFile, reachableFiles, classes } = inspectInput(input); + let root = commonDir(reachableFiles.map(file => file.fileName)); + emitShadowSources(reachableFiles, sourceFile, outDir, root, classes.map(c => c.name)); +} + +function runtimeImportFor(outDir: string): string { + let runtimeImport = relative(outDir, resolve("src/index.ts")).split(sep).join("/"); + if (!runtimeImport.startsWith(".")) runtimeImport = "./" + runtimeImport; + return runtimeImport.replace(/\.ts$/, ".js"); +} + +function writeFakeCapnweb(dir: string): void { + writeFileSync(resolve(dir, "fake-capnweb.ts"), + `export class RpcTarget {}\nexport type RpcStub = T;\n`); +} + +// ===================================================================== +// Runtime validator hook tests. These use the same hand-written validators +// the codegen ultimately wires up, but skip codegen entirely -- they prove +// the runtime hook in `core.ts` calls validators at the right points +// (deliverCall args, deliverCall return, forwarded RpcPromise resolution). + +class CheckedApi extends RpcTarget { + echo(value: string) { + return { value }; + } + + badReturn() { + return { value: 123 }; + } +} + +setRpcMethodValidators(CheckedApi, { + echo: { + args(args) { + if (args.length !== 1 || typeof args[0] !== "string") { + throw new TypeError("CheckedApi.echo expected a string"); + } + }, + returns(value) { + if (typeof value !== "object" || value === null + || typeof (value as { value?: unknown }).value !== "string") { + throw new TypeError("CheckedApi.echo returned invalid value"); + } + }, + }, + badReturn: { + args(args) { + if (args.length !== 0) { + throw new TypeError("CheckedApi.badReturn expected no arguments"); + } + }, + returns(value) { + if (typeof value !== "object" || value === null + || typeof (value as { value?: unknown }).value !== "string") { + throw new TypeError("CheckedApi.badReturn returned invalid value"); + } + }, + }, +}); + +describe("runtime type validators", () => { + it("checks RPC method arguments before invoking the method", async () => { + let stub = new RpcStub(new CheckedApi()); + await expect(stub.echo("ok")).resolves.toStrictEqual({ value: "ok" }); + await expect((stub as any).echo(123)).rejects.toThrow("CheckedApi.echo expected a string"); + }); + + it("checks RPC method return values before serializing them", async () => { + let stub = new RpcStub(new CheckedApi()); + await expect((stub as any).badReturn()).rejects.toThrow( + "CheckedApi.badReturn returned invalid value"); + }); + + it("checks forwarded RpcPromise return values when they resolve", async () => { + class BadSource extends RpcTarget { + value() { return Promise.resolve({ value: 123 } as unknown); } + user() { return Promise.resolve({ name: 123 } as unknown); } + } + class ForwardingApi extends RpcTarget { + constructor(private remote: any) { super(); } + forward() { return this.remote.value(); } + forwardName() { return this.remote.user().name; } + } + setRpcMethodValidators(ForwardingApi, { + forward: { + returns(value) { + if (typeof value !== "object" || value === null + || typeof (value as { value?: unknown }).value !== "string") { + throw new TypeError("ForwardingApi.forward returned invalid value"); + } + }, + }, + forwardName: { + returns(value) { + if (typeof value !== "string") { + throw new TypeError("ForwardingApi.forwardName returned invalid value"); + } + }, + }, + }); + + using remote: any = new RpcStub(new BadSource()); + let api = new ForwardingApi(remote); + + let payload = RpcPayload.deepCopyFrom([], undefined, null); + let result = await payload.deliverCall(api.forward, api, "forward"); + await expect(result.deliverResolve()).rejects.toThrow( + "ForwardingApi.forward returned invalid value"); + + let namePayload = RpcPayload.deepCopyFrom([], undefined, null); + let nameResult = await namePayload.deliverCall(api.forwardName, api, "forwardName"); + await expect(nameResult.deliverResolve()).rejects.toThrow( + "ForwardingApi.forwardName returned invalid value"); + }); +}); + +// ===================================================================== +// RpcTarget class discovery and preflight type rejection. These exercise +// `extractClasses` directly via `inspectInput`; no validator codegen. + +describe("RpcTarget class extraction", () => { + it("discovers classes reachable through re-exports", () => { + let root = mkdtempSync(resolve(".capnweb-reexport-")); + try { + writeFakeCapnweb(root); + writeFileSync(resolve(root, "api.ts"), ` + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + ping(value: string): string { return value; } + } + `); + let input = resolve(root, "worker.ts"); + writeFileSync(input, `export { Api } from "./api.js";`); + expect(inspectInput(input).classes.map(c => c.name)).toStrictEqual(["Api"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("honors tsconfig path aliases and aliased RpcTarget imports", () => { + let root = mkdtempSync(resolve(".capnweb-paths-")); + try { + writeFakeCapnweb(root); + mkdirSync(resolve(root, "api"), { recursive: true }); + writeFileSync(resolve(root, "tsconfig.json"), JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@api/*": ["api/*"], + "fake-capnweb": ["fake-capnweb.ts"], + }, + }, + })); + writeFileSync(resolve(root, "api", "api.ts"), ` + import { RpcTarget as BaseTarget } from "fake-capnweb"; + export class Api extends BaseTarget { + ping(value: string): string { return value; } + } + `); + let input = resolve(root, "worker.ts"); + writeFileSync(input, `export { Api } from "@api/api";`); + expect(inspectInput(input).classes.map(c => c.name)).toStrictEqual(["Api"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("discovers locally-declared and imported RpcTarget callbacks", () => { + let root = mkdtempSync(resolve(".capnweb-callback-")); + try { + writeFakeCapnweb(root); + writeFileSync(resolve(root, "callback.ts"), ` + import { RpcTarget } from "./fake-capnweb.js"; + export class ImportedCallback extends RpcTarget { + notify(message: string): void {} + } + `); + let input = resolve(root, "worker.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + import { ImportedCallback } from "./callback.js"; + export class LocalCallback extends RpcTarget { + notify(message: string): void {} + } + export class Api extends RpcTarget { + local(callback: LocalCallback): void {} + imported(callback: ImportedCallback): void {} + } + `); + expect(inspectInput(input).classes.map(c => c.name).sort()) + .toStrictEqual(["Api", "ImportedCallback", "LocalCallback"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("detects indirect RpcTarget inheritance on default-exported classes", () => { + let root = mkdtempSync(resolve(".capnweb-default-indirect-")); + try { + writeFakeCapnweb(root); + writeFileSync(resolve(root, "base.ts"), ` + import { RpcTarget } from "./fake-capnweb.js"; + export class BaseTarget extends RpcTarget {} + `); + let input = resolve(root, "worker.ts"); + writeFileSync(input, ` + import { BaseTarget } from "./base.js"; + export default class Api extends BaseTarget { + ping(value: string): void {} + } + `); + let [klass] = inspectInput(input).classes; + expect(klass.name).toBe("Api"); + expect(klass.isDefault).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects anonymous default-exported RpcTarget classes", () => { + let root = mkdtempSync(resolve(".capnweb-anonymous-default-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "worker.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + export default class extends RpcTarget { + ping(value: string): void {} + } + `); + expect(() => inspectInput(input)).toThrow( + /Anonymous RpcTarget classes are not supported/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("skips static methods when extracting the RPC surface", () => { + let root = mkdtempSync(resolve(".capnweb-static-method-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "worker.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + static helper(value: any): void {} + ping(value: string): void {} + } + `); + expect(inspectInput(input).classes[0].methods.map(m => m.name)).toStrictEqual(["ping"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects non-exported RpcTarget classes", () => { + let root = mkdtempSync(resolve(".capnweb-non-exported-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "worker.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + class InternalApi extends RpcTarget { + ping(value: string): void {} + } + export class Api extends RpcTarget { + ping(value: string): void {} + } + `); + expect(() => inspectInput(input)).toThrow( + /InternalApi: RpcTarget classes must be exported/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("preflight type rejection", () => { + it.each([ + ["any", `anyArg(value: any): void {}`, + /Api\.anyArg parameter 'value': type 'any' cannot be validated/], + ["unknown", `unknownArg(value: unknown): void {}`, + /Api\.unknownArg parameter 'value': type 'unknown' cannot be validated/], + ["any nested in an object literal", `nestedAny(value: { tags: any[] }): void {}`, + /Api\.nestedAny parameter 'value': property 'tags': type 'any' cannot be validated/], + ["any in a return type", `badReturn(): any { return 1; }`, + /Api\.badReturn return: type 'any' cannot be validated/], + ["symbol", `symbolArg(value: symbol): void {}`, + /Unsupported RPC type: symbol/], + ["bigint literal", `bigintLiteral(value: 1n): void {}`, + /bigint literal types are not supported/], + ["optional tuple element", `optionalTuple(value: [string, number?]): void {}`, + /optional and rest tuple elements are not supported/], + ])("rejects %s", (_label, body, pattern) => { + let root = mkdtempSync(resolve(".capnweb-preflight-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "api.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + ${body} + } + `); + expect(() => inspectInput(input)).toThrow(pattern); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("accepts recursive types by hoisting named validators", () => { + let root = mkdtempSync(resolve(".capnweb-recursive-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "api.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + interface Tree { name: string; children: Tree[]; } + export class Api extends RpcTarget { + tree(value: Tree): void {} + } + `); + let result = inspectInput(input); + // The Tree -> Tree[] cycle should produce a `ref` somewhere in the + // method signature, and a matching entry in the hoisted-named table. + expect(result.named.size).toBeGreaterThan(0); + let params = result.classes[0].methods[0].params; + let value = params[0].type; + // The parameter resolves to a ref into the hoisted Tree type. + expect(value.kind === "object" || value.kind === "ref").toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + "ArrayBuffer", "DataView", "RegExp", "Uint16Array", "Float32Array", + ])("rejects native not currently serialized by Cap'n Web: %s", type => { + let root = mkdtempSync(resolve(".capnweb-native-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "api.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { valueArg(value: ${type}): void {} } + `); + expect(() => inspectInput(input)).toThrow(/Unsupported RPC type/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects mixed string index signatures and named properties", () => { + let root = mkdtempSync(resolve(".capnweb-mixed-index-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "api.ts"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + interface Dict { required: string; [key: string]: string; } + export class Api extends RpcTarget { valueArg(value: Dict): void {} } + `); + expect(() => inspectInput(input)).toThrow(/both string index signatures and named properties/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +// ===================================================================== +// Shadow source emission. These exercise `emitShadowSources` directly -- +// no validator codegen. They cover the CLI's client-rewrite path +// (the Vite plugin's in-memory rewrite is covered in vite-plugin.test.ts). + +describe("shadow source emission", () => { + it("wraps typed RPC factory calls in the shadow source tree", () => { + let root = mkdtempSync(resolve(".capnweb-shadow-")); + try { + writeFakeCapnweb(root); + let input = resolve(root, "worker.ts"); + let outDir = resolve(root, ".capnweb"); + writeFileSync(input, ` + import { newHttpBatchRpcSession } from "capnweb"; + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + ping(value: string): string { return value; } + } + export const remote = newHttpBatchRpcSession("/rpc"); + `); + + emitShadowFor(input, outDir); + + let shadow = readFileSync(resolve(outDir, "source", "worker.ts"), "utf8"); + expect(shadow).toContain(`import { __capnweb_wrap_Api } from "../clients.js"`); + expect(shadow).toContain(`__capnweb_wrap_Api(newHttpBatchRpcSession("/rpc"))`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("copies relative non-TS asset imports into the shadow tree", () => { + let root = mkdtempSync(resolve(".capnweb-shadow-asset-")); + try { + writeFakeCapnweb(root); + writeFileSync(resolve(root, "config.json"), `{"prefix":"ok"}`); + let input = resolve(root, "worker.ts"); + let outDir = resolve(root, ".capnweb"); + writeFileSync(input, ` + import config from "./config.json"; + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + ping(value: string): string { return config.prefix + value; } + } + `); + + emitShadowFor(input, outDir); + + let copied = resolve(outDir, "source", "config.json"); + expect(readFileSync(copied, "utf8")).toBe(`{"prefix":"ok"}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects asset imports that would escape the shadow tree", () => { + let root = mkdtempSync(resolve(".capnweb-shadow-asset-escape-")); + try { + let sourceDir = resolve(root, "src"); + mkdirSync(sourceDir, { recursive: true }); + writeFakeCapnweb(sourceDir); + writeFileSync(resolve(root, "escape.json"), `{"prefix":"bad"}`); + let input = resolve(sourceDir, "worker.ts"); + let outDir = resolve(root, ".capnweb"); + writeFileSync(input, ` + import config from "../escape.json"; + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + ping(value: string): string { return config.prefix + value; } + } + `); + + expect(() => emitShadowFor(input, outDir)).toThrow( + /escape the generated shadow source tree/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +// ===================================================================== +// End-to-end validator codegen. ONE `generate()` call is shared by both the +// server-side validator integration tests and the client-side bound +// validator integration tests, so we only pay the `ts.createProgram` cost +// once per file. Everything before this point in the file avoids it +// entirely. + +describe("end-to-end validator codegen", () => { + let root: string; + let api: any; + let wrap: (stub: unknown) => any; + let wrapSession: (session: unknown) => any; + + let call = async (method: string, args: unknown[]) => { + let payload = RpcPayload.deepCopyFrom(args, undefined, null); + let result = await payload.deliverCall(api[method], api, method); + return result.deliverResolve(); + }; + + beforeAll(async () => { + root = mkdtempSync(resolve(".capnweb-e2e-")); + writeFakeCapnweb(root); + let input = resolve(root, "api.ts"); + let outDir = resolve(root, ".capnweb"); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + hello(name: string): { value: string } { return { value: name }; } + maybe(value?: string | null): void {} + leadingDefault(value = "default", required: string): void {} + contract(value: { + text: string; + count: number; + flag: boolean; + tags: string[]; + pair: [string, number]; + byId: Record; + map: Map; + set: Set; + maybe: string | null; + created: Date; + bytes: Uint8Array; + literal: "ok"; + }): void {} + getUser(token: string): { id: string } { return { id: token }; } + getProfile(id: string): { id: string; ok: true } { return { id, ok: true }; } + badReturn(): { value: string } { return { value: 123 } as any; } + } + `); + + let runtimeImport = runtimeImportFor(outDir); + generate({ input, outDir, runtimeImport }); + + await import(pathToFileURL(resolve(outDir, "validators.ts")).href); + let mod = await import(pathToFileURL(resolve(outDir, "worker.entry.ts")).href); + api = new mod.Api(); + + let clients = await import(pathToFileURL(resolve(outDir, "clients.ts")).href); + wrap = clients.__capnweb_wrap_Api; + wrapSession = clients.__capnweb_wrap_RpcSession_Api; + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + describe("server-side validators", () => { + it("accepts a well-typed call and returns the value", async () => { + await expect(call("hello", ["ok"])).resolves.toStrictEqual({ value: "ok" }); + }); + + it("rejects an arg of the wrong type, with the parameter name in the error", async () => { + await expect(call("hello", [123])).rejects.toThrow( + /Api\.hello: name: expected string, got number/); + }); + + it("rejects a return value of the wrong type", async () => { + await expect(call("badReturn", [])).rejects.toThrow( + /Api\.badReturn return: value: expected string, got number/); + }); + + it("accepts elided and explicit-null values for optional + nullable params", async () => { + await expect(call("maybe", [])).resolves.toBeUndefined(); + await expect(call("maybe", [null])).resolves.toBeUndefined(); + }); + + it("preserves the error path on optional + nullable mismatches", async () => { + await expect(call("maybe", [123])).rejects.toThrow( + /Api\.maybe: value: expected null \| string \| undefined, got number/); + }); + + it("counts required arity after optional leading params", async () => { + await expect(call("leadingDefault", ["only one"])).rejects.toThrow(/expected 2 argument/); + }); + + it("validates the supported Cap'n Web type contract", async () => { + await expect(call("contract", [{ + text: "ok", + count: 1, + flag: true, + tags: ["a", "b"], + pair: ["x", 2], + byId: { a: { active: true } }, + map: new Map([["a", { active: true }]]), + set: new Set(["a"]), + maybe: null, + created: new Date(0), + bytes: new Uint8Array([1, 2, 3]), + literal: "ok", + }])).resolves.toBeUndefined(); + + await expect(call("contract", [{ + text: "ok", + count: 1, + flag: true, + tags: ["a"], + pair: ["x", 2], + byId: { a: { active: "no" } }, + map: new Map([["a", { active: true }]]), + set: new Set(["a"]), + maybe: null, + created: new Date(0), + bytes: new Uint8Array(), + literal: "ok", + }])).rejects.toThrow(/value\.byId\.a\.active: expected boolean, got string/); + + await expect(call("contract", [{ + text: "ok", + count: 1, + flag: true, + tags: ["a"], + pair: ["x", 2], + byId: { a: { active: true } }, + map: new Map([["a", { active: "no" }]]), + set: new Set(["a"]), + maybe: null, + created: new Date(0), + bytes: new Uint8Array(), + literal: "ok", + }])).rejects.toThrow(/value\.map\.a\.active: expected boolean, got string/); + + await expect(call("contract", [{ + text: "ok", + count: 1, + flag: true, + tags: ["a"], + pair: ["x", 2], + byId: { a: { active: true } }, + map: new Map([[1, { active: true }]]), + set: new Set(["a"]), + maybe: null, + created: new Date(0), + bytes: new Uint8Array(), + literal: "ok", + }])).rejects.toThrow(/value\.map\.: expected string, got number/); + + await expect(call("contract", [{ + text: "ok", + count: 1, + flag: true, + tags: ["a"], + pair: ["x", 2], + byId: { a: { active: true } }, + map: new Map([["a", { active: true }]]), + set: new Set([1]), + maybe: null, + created: new Date(0), + bytes: new Uint8Array(), + literal: "ok", + }])).rejects.toThrow(/value\.set\.\[0\]: expected string, got number/); + }); + }); + + describe("client-side bound validators", () => { + it("rejects wrong return values from a remote stub", async () => { + class BadReturn extends RpcTarget { + hello(_name: string) { return Promise.resolve({ value: 123 } as unknown); } + } + let wrapped = wrap(new RpcStub(new BadReturn())); + let failure = await wrapped.hello("ok").catch((err: unknown) => err); + expect(failure).toBeInstanceOf(TypeError); + expect((failure as Error).message).toMatch( + /Api\.hello return: value: expected string, got number/); + }); + + it("rejects invalid client arguments before sending the call", async () => { + let sent = false; + class ShouldNotCall extends RpcTarget { + hello(_name: string) { + sent = true; + return Promise.resolve({ value: "sent" }); + } + } + let wrapped = wrap(new RpcStub(new ShouldNotCall())); + expect(() => wrapped.hello(123)).toThrow( + /Api\.hello: name: expected string, got number/); + expect(sent).toBe(false); + }); + + it("rejects wrong return values consumed through pipelined properties", async () => { + class BadReturn extends RpcTarget { + getUser(_token: string) { return Promise.resolve({ id: 123 } as unknown); } + getProfile(id: string) { return Promise.resolve({ id, ok: true }); } + } + let wrapped = wrap(new RpcStub(new BadReturn())); + await expect(wrapped.getProfile(wrapped.getUser("token").id)).rejects.toThrow( + /Api.getUser return: id: expected string, got number/); + }); + + it("does not apply root validators to nested pipelined method calls", () => { + class Nested extends RpcTarget { + hello(_name: string) { return Promise.resolve({ value: "root" }); } + } + let wrapped = wrap(new RpcStub(new Nested())); + expect(() => wrapped.admin.hello(123)).not.toThrow(); + }); + + it("passes through valid return values", async () => { + class GoodReturn extends RpcTarget { + hello(name: string) { return Promise.resolve({ value: name }); } + } + let wrapped = wrap(new RpcStub(new GoodReturn())); + await expect(wrapped.hello("ok")).resolves.toStrictEqual({ value: "ok" }); + }); + + it("validates the success value reached via .catch / .finally", async () => { + class BadCatch extends RpcTarget { + hello(_name: string) { return Promise.resolve({ value: 123 } as unknown); } + } + let wrapped = wrap(new RpcStub(new BadCatch())); + + let viaCatch = await wrapped.hello("ok").catch((e: unknown) => e); + expect(viaCatch).toBeInstanceOf(TypeError); + expect((viaCatch as Error).message).toMatch( + /Api\.hello return: value: expected string, got number/); + + await expect(wrapped.hello("ok").finally(() => {})).rejects.toThrow( + /Api\.hello return: value: expected string, got number/); + }); + + it("binds session-shape validators on getRemoteMain() and returns the session", async () => { + // `new RpcSession(...)` returns a session, not a stub, so the + // session wrap walks to `getRemoteMain()` once, binds validators on + // that stub, and returns the session unchanged. + class BadReturn extends RpcTarget { + hello(_name: string) { return Promise.resolve({ value: 123 } as unknown); } + } + let main = new RpcStub(new BadReturn()); + let fakeSession = { getRemoteMain: () => main, getStats: () => ({}) }; + + let wrapped = wrapSession(fakeSession); + expect(wrapped).toBe(fakeSession); + + let failure = await main.hello("ok").catch((err: unknown) => err); + expect(failure).toBeInstanceOf(TypeError); + expect((failure as Error).message).toMatch( + /Api\.hello return: value: expected string, got number/); + }); + + it("preserves RpcPromise pipelining on bound stubs", async () => { + class Pipelined extends RpcTarget { + getUser(token: string) { return Promise.resolve({ id: token }); } + getProfile(id: string) { return Promise.resolve({ id, ok: true }); } + } + let wrapped = wrap(new RpcStub(new Pipelined())); + let user = wrapped.getUser("u_1"); + let profile = wrapped.getProfile(user.id); + await expect(Promise.all([user, profile])).resolves.toStrictEqual([ + { id: "u_1" }, + { id: "u_1", ok: true }, + ]); + }); + }); +}); diff --git a/__tests__/vite-plugin.test.ts b/__tests__/vite-plugin.test.ts new file mode 100644 index 0000000..efa4002 --- /dev/null +++ b/__tests__/vite-plugin.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { emitViteRegistration } from "../src/typecheck/generate.js"; +import { transformClientCalls } from "../src/typecheck/rewrite.js"; +import capnweb from "../src/typecheck/vite.js"; + +describe("capnweb/vite plugin client rewrite", () => { + it("rewrites typed RPC factory calls in user source", () => { + let userCode = ` +import { newHttpBatchRpcSession } from "capnweb"; +import type { Api as RemoteApi } from "./api.js"; +const api = newHttpBatchRpcSession("/rpc"); +const other = newHttpBatchRpcSession("/rpc2"); +function local(newHttpBatchRpcSession: any) { + return newHttpBatchRpcSession("/local"); +} +`; + + let code = transformClientCalls(userCode, new Set(["Api"]), "./.capnweb/clients.js"); + expect(code).toContain(`__capnweb_wrap_Api(newHttpBatchRpcSession("/rpc"))`); + // Calls with no matching class spec are left alone -- TS opted out of + // typing here, so we opt out of runtime validation. + expect(code).toContain(`newHttpBatchRpcSession("/rpc2")`); + expect(code).toContain(`newHttpBatchRpcSession("/local")`); + expect(code).not.toContain(`__capnweb_wrap_Unknown`); + expect(code).toContain(`import { __capnweb_wrap_Api } from "./.capnweb/clients.js"`); + + let wrapCalls = (code.match(/__capnweb_wrap_Api\(/g) ?? []).length; + expect(wrapCalls).toBe(1); + }); + + + it("parses TSX and preserves directive prologues when rewriting", () => { + let userCode = `"use client"; +import { newHttpBatchRpcSession } from "capnweb"; +import type { Api } from "./api.js"; +const view =
; +const api = newHttpBatchRpcSession("/rpc"); +`; + + let code = transformClientCalls(userCode, new Set(["Api"]), "./clients.js", "client.tsx"); + expect(code.startsWith(`"use client"; +import { __capnweb_wrap_Api } from "./clients.js";`)).toBe(true); + expect(code).toContain(`const view =
;`); + expect(code).toContain(`__capnweb_wrap_Api(newHttpBatchRpcSession("/rpc"))`); + }); + + it("injects server validator registration into the Vite worker entry", () => { + let root = mkdtempSync(resolve(".capnweb-vite-entry-")); + try { + let input = resolve(root, "worker.ts"); + let outDir = resolve(root, ".capnweb"); + let validatorsAbs = resolve(outDir, "validators.ts"); + let registration = emitViteRegistration([ + { name: "Api", valueName: "Api", isDefault: false, sourcePath: input }, + ], input, validatorsAbs); + let code = `${registration.imports}\nexport class Api {}\n${registration.call}\n`; + expect(code).toContain(`import { registerCapnwebValidators as __capnweb_registerValidators } from "./.capnweb/validators.js";`); + expect(code).toContain(`__capnweb_registerValidators({ "Api": Api });`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + + + it("rewrites aliased and namespace-imported RPC factory calls", () => { + let userCode = ` +import * as capnweb from "capnweb"; +import { newHttpBatchRpcSession as connect } from "capnweb"; +import type { Api } from "./api.js"; +const a = connect("/rpc"); +const b = capnweb.newWebSocketRpcSession("ws://example.com/rpc"); +`; + + let code = transformClientCalls(userCode, new Set(["Api"]), "./clients.js"); + expect(code).toContain(`__capnweb_wrap_Api(connect("/rpc"))`); + expect(code).toContain(`__capnweb_wrap_Api(capnweb.newWebSocketRpcSession("ws://example.com/rpc"))`); + }); + + it("rewrites typed `new RpcSession(...)` with the session-shape wrap", () => { + // `new RpcSession(transport)` returns a session, not a stub, so the + // rewriter routes it through the session-shape wrap to bind validators + // on `session.getRemoteMain()` without changing the call-site type. + let userCode = ` +import { RpcSession } from "capnweb"; +import type { Api } from "./api.js"; +const transport = {} as any; +const session = new RpcSession(transport); +`; + + let code = transformClientCalls(userCode, new Set(["Api"]), "./clients.js"); + expect(code).toContain(`__capnweb_wrap_RpcSession_Api(new RpcSession(transport))`); + expect(code).toContain(`import { __capnweb_wrap_RpcSession_Api } from`); + // Stub-shape wrap is not referenced by this file. + expect(code).not.toContain(`__capnweb_wrap_Api(`); + }); + + it("ignores files in node_modules and the generated output", () => { + let root = mkdtempSync(resolve(".capnweb-vite-skip-")); + try { + let input = resolve(root, "worker.ts"); + let outDir = resolve(root, ".capnweb"); + writeFileSync(resolve(root, "fake-capnweb.ts"), `export class RpcTarget {}`); + writeFileSync(input, ` + import { RpcTarget } from "./fake-capnweb.js"; + export class Api extends RpcTarget { + ping(value: string): string { return value; } + } + `); + + let plugin = capnweb({ input, outDir }) as any; + plugin.configResolved({ root }); + + let userCode = ` +import { newHttpBatchRpcSession } from "capnweb"; +const api = newHttpBatchRpcSession("/rpc"); +`; + + expect(plugin.transform(userCode, resolve(root, "node_modules", "x.ts"))).toBeNull(); + expect(plugin.transform(userCode, resolve(outDir, "extra.ts"))).toBeNull(); + expect(plugin.transform(userCode, resolve(root, "client.d.ts"))).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh new file mode 100755 index 0000000..37d11fa --- /dev/null +++ b/examples/worker-react/dev-debug.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WEB_DIR="$SCRIPT_DIR/web" +MODE="${1:-app}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/capnweb-worker-react-vite.XXXXXX")" +TMP_CONFIG="$TMP_DIR/vite.config.mjs" +TMP_WRANGLER_CONFIG="$TMP_DIR/wrangler.toml" +TYPECHECK=false +if [[ "$MODE" == "--typecheck" || "$MODE" == "typecheck" ]]; then + TYPECHECK=true +elif [[ "$MODE" != "app" ]]; then + echo "Usage: $0 [--typecheck]" >&2 + exit 1 +fi +WORKER_PORT="${WORKER_PORT:-$(node - <<'NODE' +const net = require('node:net'); + +function isFree(port) { + return new Promise(resolve => { + const server = net.createServer(); + server.unref(); + server.once('error', () => resolve(false)); + server.listen(port, '127.0.0.1', () => server.close(() => resolve(true))); + }); +} + +(async () => { + if (await isFree(8787)) { + console.log(8787); + return; + } + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => console.log(address.port)); + }); +})(); +NODE +)}" +VITE_BASE="/examples/worker-react/web/" +VITE_PORT="${VITE_PORT:-$(node - <<'NODE' +const net = require('node:net'); + +function isFree(port) { + return new Promise(resolve => { + const server = net.createServer(); + server.unref(); + server.once('error', () => resolve(false)); + server.listen(port, '127.0.0.1', () => server.close(() => resolve(true))); + }); +} + +(async () => { + if (await isFree(5173)) { + console.log(5173); + return; + } + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => console.log(address.port)); + }); +})(); +NODE +)}" + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ -n "${WRANGLER_PID:-}" ]]; then + kill "$WRANGLER_PID" 2>/dev/null || true + wait "$WRANGLER_PID" 2>/dev/null || true + fi + rm -rf "$TMP_DIR" + exit "$status" +} + +trap cleanup EXIT INT TERM + +cd "$REPO_ROOT" +env NODE_OPTIONS= npm run build + +# Runtime type validation toggles via capnweb internal placeholder subpaths. +# `gen` overwrites them with real validators; `reset` puts the stubs back +# so no validation runs. The worker entry never changes either way. +cd "$SCRIPT_DIR" +if [[ "$TYPECHECK" == "true" ]]; then + echo "Generating RPC validators into capnweb internal subpaths..." + if [[ "${TYPECHECK_DEBUG:-false}" == "false" || "${TYPECHECK_DEBUG:-false}" == "0" ]]; then + env NODE_OPTIONS= node --enable-source-maps \ + "$REPO_ROOT/dist/cli.cjs" typecheck gen src/worker.ts + else + echo "Typecheck generator is paused on start; attach the debugger, then continue." + node --enable-source-maps --inspect-brk=0 \ + "$REPO_ROOT/dist/cli.cjs" typecheck gen src/worker.ts + fi +else + echo "Resetting capnweb typecheck placeholders (validators disabled)..." + env NODE_OPTIONS= node "$REPO_ROOT/dist/cli.cjs" typecheck reset +fi + +cat > "$TMP_CONFIG" < "$TMP_WRANGLER_CONFIG" </dev/null 2>&1 & +WRANGLER_PID=$! + +cd "$WEB_DIR" +echo "Open this URL in VS Code Debug: Open Link:" +echo "http://127.0.0.1:$VITE_PORT$VITE_BASE" +env NODE_OPTIONS= npx vite --host 127.0.0.1 --port "$VITE_PORT" --strictPort --config "$TMP_CONFIG" diff --git a/examples/worker-react/src/worker.ts b/examples/worker-react/src/worker.ts index 7e89ab1..6579849 100644 --- a/examples/worker-react/src/worker.ts +++ b/examples/worker-react/src/worker.ts @@ -11,12 +11,15 @@ type Env = { 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([ +type User = { id: string; name: string }; +type Profile = { id: string; bio: string }; + +const USERS = new Map([ ['cookie-123', { id: 'u_1', name: 'Ada Lovelace' }], ['cookie-456', { id: 'u_2', name: 'Alan Turing' }], ]); -const PROFILES = new Map([ +const PROFILES = new Map([ ['u_1', { id: 'u_1', bio: 'Mathematician & first programmer' }], ['u_2', { id: 'u_2', bio: 'Mathematician & CS pioneer' }], ]); @@ -29,21 +32,21 @@ const NOTIFICATIONS = new Map([ export class Api extends RpcTarget { constructor(private env: Env) { super(); } - async authenticate(sessionToken: string) { + async authenticate(sessionToken: string): Promise { 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) { + async getUserProfile(userId: string): Promise { 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) { + async getNotifications(userId: string): Promise { await sleep(Number(this.env.DELAY_NOTIFS_MS ?? 120)); return NOTIFICATIONS.get(userId) ?? []; } diff --git a/package-lock.json b/package-lock.json index 8d0f38c..de9858f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "capnweb", "version": "0.7.0", "license": "MIT", + "bin": { + "capnweb": "dist/cli.cjs" + }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", @@ -23,6 +26,14 @@ "typescript": "^5.9.3", "vitest": "^3.2.4", "ws": "^8.19.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@actions/core": { @@ -6355,6 +6366,11 @@ "engines": { "node": ">=20" } + }, + "packages/capnweb-typecheck": { + "version": "0.0.1", + "extraneous": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index f5a9b6e..e78574e 100644 --- a/package.json +++ b/package.json @@ -24,21 +24,56 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./internal/typecheck": { + "workerd": { + "types": "./dist/internal-typecheck.d.ts", + "import": "./dist/index-workers.js", + "require": "./dist/index-workers.cjs" + }, + "bun": { + "types": "./dist/internal-typecheck.d.ts", + "import": "./dist/index-bun.js", + "require": "./dist/index-bun.cjs" + }, + "types": "./dist/internal-typecheck.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./vite": { + "types": "./dist/vite.d.ts", + "import": "./dist/vite.js", + "require": "./dist/vite.cjs" + }, + "./_typecheck-validators": { + "types": "./dist/_typecheck-validators.d.ts", + "import": "./dist/_typecheck-validators.js", + "require": "./dist/_typecheck-validators.cjs" + }, + "./_typecheck-clients": { + "types": "./dist/_typecheck-clients.d.ts", + "import": "./dist/_typecheck-clients.js", + "require": "./dist/_typecheck-clients.cjs" } }, + "bin": { + "capnweb": "./dist/cli.cjs" + }, "type": "module", "publishConfig": { "access": "public" }, "scripts": { "build": "tsup", + "build:runtime": "tsup", "build:watch": "tsup --watch", "test": "vitest run", "test:bun": "bun test __tests__/bun.test.ts", "test:ci": "vitest run && bun test __tests__/bun.test.ts", "test:watch": "vitest", "test:types": "tsc -p __type-tests__/tsconfig.json --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "postinstall": "node -e \"process.exit(0)\"" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", @@ -63,5 +98,13 @@ "bugs": { "url": "https://github.com/cloudflare/capnweb/issues" }, - "homepage": "https://github.com/cloudflare/capnweb#readme" + "homepage": "https://github.com/cloudflare/capnweb#readme", + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } } diff --git a/src/_typecheck-clients.ts b/src/_typecheck-clients.ts new file mode 100644 index 0000000..d771d78 --- /dev/null +++ b/src/_typecheck-clients.ts @@ -0,0 +1,5 @@ +// Internal placeholder subpath. `capnweb typecheck gen` overwrites this with +// `__capnweb_wrap_` helpers consumed by the Vite plugin's +// client-side rewrite. Not part of capnweb's public API. + +export {}; diff --git a/src/_typecheck-validators.ts b/src/_typecheck-validators.ts new file mode 100644 index 0000000..e8882be --- /dev/null +++ b/src/_typecheck-validators.ts @@ -0,0 +1,8 @@ +// Internal placeholder subpath. The capnweb runtime imports `validators` from +// here at startup; `capnweb typecheck gen` overwrites this file in-place with +// generated validators. While the placeholder is in place runtime validation +// is a no-op. Not part of capnweb's public API — do not import directly. + +import type { RpcClassValidators } from "./core.js"; + +export const validators: Record | null = null; diff --git a/src/core.ts b/src/core.ts index fd443a6..0b21291 100644 --- a/src/core.ts +++ b/src/core.ts @@ -4,6 +4,7 @@ import type { RpcTargetBranded, __RPC_TARGET_BRAND } from "./types.js"; import { WORKERS_MODULE_SYMBOL } from "./symbols.js" +import { validators as generatedValidators } from "capnweb/_typecheck-validators"; // Polyfill Symbol.dispose for browsers that don't support it yet if (!Symbol.dispose) { @@ -37,6 +38,97 @@ export let RpcTarget = workersModule ? workersModule.RpcTarget : class {}; export type PropertyPath = (string | number)[]; +export type RpcValidationOptions = { + isRpcPlaceholder?: (value: unknown) => boolean; +}; + +export type RpcMethodValidator = { + args?: (args: unknown[], options?: RpcValidationOptions) => void; + returns?: (value: unknown) => void | Promise; + returnsPath?: (path: PropertyPath, value: unknown, options?: RpcValidationOptions) => void | Promise; +}; + +export type RpcClassValidators = Record; + +/** + * Structured detail attached to {@link RpcValidationError}. Lets a caller + * inspect *what* went wrong (which path, expected vs. actual type, original + * value) without parsing the error message. + */ +export type RpcValidationFailure = { + path: PropertyPath; + expected: string; + actual: string; + value: unknown; +}; + +/** + * Thrown by capnweb when RPC arguments or return values fail a generated + * validator. Extends {@link TypeError} so existing `instanceof TypeError` + * catches still match; adds `rpcValidation` for structured inspection. + * + * ```ts + * try { + * await stub.method(badArg); + * } catch (e) { + * if (e instanceof RpcValidationError) { + * console.log(e.rpcValidation.path, e.rpcValidation.expected); + * } + * } + * ``` + */ +export class RpcValidationError extends TypeError { + readonly rpcValidation: RpcValidationFailure; + + constructor(message: string, validation: RpcValidationFailure) { + super(message); + this.name = "RpcValidationError"; + this.rpcValidation = validation; + if (typeof (Error as { captureStackTrace?: Function }).captureStackTrace === "function") { + (Error as { captureStackTrace: Function }).captureStackTrace(this, RpcValidationError); + } + } +} + +const rpcValidators = new WeakMap(); + +export function setRpcMethodValidators(klass: Function, validators: RpcClassValidators): void { + let existing = rpcValidators.get(klass); + if (existing) { + Object.assign(existing, validators); + } else { + rpcValidators.set(klass, {...validators}); + } +} + +function getRpcValidator( + thisArg: object | undefined, methodName: string | number | undefined): RpcMethodValidator | undefined { + if (thisArg === undefined || methodName === undefined) return undefined; + + let klass = (thisArg as {constructor?: Function}).constructor; + while (typeof klass === "function") { + let registered = rpcValidators.get(klass); + if (!registered && generatedValidators) { + // First lookup for this class: fall back to validators generated by + // `capnweb typecheck gen`. Cache on the class so subsequent lookups + // hit the WeakMap fast path. + let byName = generatedValidators[klass.name]; + if (byName) { + setRpcMethodValidators(klass, byName); + registered = rpcValidators.get(klass); + } + } + let validator = registered?.[methodName]; + if (validator) return validator; + + let parentPrototype = klass.prototype ? Object.getPrototypeOf(klass.prototype) : undefined; + klass = parentPrototype?.constructor; + if (klass === Object) break; + } + + return undefined; +} + type TypeForRpc = "unsupported" | "primitive" | "object" | "function" | "array" | "date" | "bigint" | "bytes" | "blob" | "stub" | "rpc-promise" | "rpc-target" | "rpc-thenable" | "error" | "undefined" | "writable" | "readable" | "headers" | "request" | "response"; @@ -348,11 +440,70 @@ export interface RpcStub extends Disposable { [RAW_STUB]: this; } +const rpcStubValidators = new WeakMap(); +const rpcReturnValidators = new WeakMap(); + +export function setRpcStubValidators(stub: object, validators: RpcClassValidators): void { + let raw = (stub as RpcStub)[RAW_STUB]; + if (!raw || !(raw.hook instanceof StubHook)) { + throw new TypeError("Cap'n Web client validators can only be attached to RpcStub objects."); + } + rpcStubValidators.set(raw.hook, {...validators}); +} + +function getRpcStubValidator(stub: RpcStub): RpcMethodValidator | undefined { + if (!stub.pathIfPromise || stub.pathIfPromise.length !== 1) return undefined; + return rpcStubValidators.get(stub.hook)?.[stub.pathIfPromise[0]]; +} + +function setRpcPromiseReturnValidator(promise: RpcPromise, validator: RpcMethodValidator): void { + let {hook, pathIfPromise} = promise[RAW_STUB]; + if (pathIfPromise && pathIfPromise.length === 0) { + rpcReturnValidators.set(hook, validator); + } +} + +function withRpcPromiseReturnValidator( + promise: RpcPromise, validator: RpcMethodValidator): RpcPromise { + let {hook, pathIfPromise} = promise[RAW_STUB]; + if (pathIfPromise && pathIfPromise.length > 0) { + promise = new RpcPromise(hook.get(pathIfPromise), []); + } + setRpcPromiseReturnValidator(promise, validator); + return promise; +} + +function copyRpcPromiseReturnValidator(source: RpcPromise, target: RpcPromise): void { + let {hook, pathIfPromise} = source[RAW_STUB]; + let validator = rpcReturnValidators.get(hook); + if (!validator) return; + if (!pathIfPromise || pathIfPromise.length === 0) { + setRpcPromiseReturnValidator(target, validator); + } else if (validator.returnsPath) { + setRpcPromiseReturnValidator(target, { + returns: value => validator.returnsPath!(pathIfPromise, value), + }); + } +} + +function getRpcPromiseReturnValidator(promise: RpcPromise): RpcMethodValidator | undefined { + return rpcReturnValidators.get(promise[RAW_STUB].hook); +} + +function isRpcPromisePlaceholder(value: unknown): boolean { + if ((typeof value !== "object" && typeof value !== "function") || value === null) return false; + return (value as RpcStub)[RAW_STUB]?.pathIfPromise !== undefined; +} + const PROXY_HANDLERS: ProxyHandler<{raw: RpcStub}> = { apply(target: {raw: RpcStub}, thisArg: any, argumentsList: any[]) { let stub = target.raw; - return new RpcPromise(doCall(stub.hook, - stub.pathIfPromise || [], RpcPayload.fromAppParams(argumentsList)), []); + let validator = getRpcStubValidator(stub); + validator?.args?.(argumentsList, { isRpcPlaceholder: isRpcPromisePlaceholder }); + let hook = doCall(stub.hook, + stub.pathIfPromise || [], RpcPayload.fromAppParams(argumentsList)); + if (validator?.returns) rpcReturnValidators.set(hook, validator); + return new RpcPromise(hook, []); }, get(target: {raw: RpcStub}, prop: string | symbol, receiver: any) { @@ -497,11 +648,15 @@ export class RpcStub extends RpcTarget { // in Workers RPC today? (Need to check.) Alternatively, should there be an optional // parameter to specify promise vs. stub? let target = this[RAW_STUB]; + let result: RpcStub; if (target.pathIfPromise) { - return new RpcStub(target.hook.get(target.pathIfPromise)); + result = new RpcStub(target.hook.get(target.pathIfPromise)); } else { - return new RpcStub(target.hook.dup()); + result = new RpcStub(target.hook.dup()); + let validators = rpcStubValidators.get(target.hook); + if (validators) setRpcStubValidators(result, validators); } + return result; } onRpcBroken(callback: (error: any) => void) { @@ -618,15 +773,20 @@ export function unwrapStubAndPath(stub: RpcStub): {hook: StubHook, pathIfPromise // payload. This is a helper used to implement the then/catch/finally methods of RpcPromise. async function pullPromise(promise: RpcPromise): Promise { let {hook, pathIfPromise} = promise[RAW_STUB]; - if (pathIfPromise!.length > 0) { + let path = pathIfPromise ?? []; + let validator = rpcReturnValidators.get(hook); + let validate = path.length === 0 ? validator?.returns : + validator?.returnsPath ? (value: unknown) => validator.returnsPath!(path, value) : + undefined; + if (path.length > 0) { // If this isn't the root promise, we have to clone it and pull the clone. This is a little // weird in terms of disposal: There's no way for the app to dispose/cancel the promise while // waiting because it never actually got a direct disposable reference. It has to dispose // the result. - hook = hook.get(pathIfPromise!); + hook = hook.get(path); } let payload = await hook.pull(); - return payload.deliverResolve(); + return payload.deliverResolve(validate); } // ======================================================================================= @@ -990,6 +1150,7 @@ export class RpcPayload { } if (stub instanceof RpcPromise) { let promise = new RpcPromise(hook, []); + copyRpcPromiseReturnValidator(stub, promise); this.promises!.push({parent, property, promise}); return promise; } else { @@ -1147,22 +1308,42 @@ export class RpcPayload { throw new Error("property promises should have been resolved earlier"); } + let validator = RpcPayload.validatorForPromise(promise); let inner = hook.pull(); if (inner instanceof RpcPayload) { // Immediately resolved to payload. - inner.deliverTo(parent, property, promises); + let subPromises: Promise[] = []; + inner.deliverTo(parent, property, subPromises); + RpcPayload.finishDeliveredPromise(parent, property, subPromises, promises, validator); } else { // It's a promise. promises.push(inner.then(payload => { let subPromises: Promise[] = []; payload.deliverTo(parent, property, subPromises); if (subPromises.length > 0) { - return Promise.all(subPromises); + return Promise.all(subPromises).then(() => validator?.returns?.((parent as any)[property])); } + return validator?.returns?.((parent as any)[property]); })); } } + private static validatorForPromise(promise: RpcPromise): RpcMethodValidator | undefined { + return getRpcPromiseReturnValidator(promise); + } + + private static finishDeliveredPromise( + parent: object, property: string | number, subPromises: Promise[], + promises: Promise[], validator: RpcMethodValidator | undefined): void { + let validate = () => validator?.returns?.((parent as any)[property]); + if (subPromises.length > 0) { + promises.push(Promise.all(subPromises).then(validate)); + } else { + let validation = validate(); + if (validation instanceof Promise) promises.push(validation); + } + } + // Call the given function with the payload as an argument. The call is made synchronously if // possible, in order to maintain e-order. However, if any RpcPromises exist in the payload, // they are awaited and substituted before calling the function. The result of the call is @@ -1170,7 +1351,9 @@ export class RpcPayload { // // The payload is automatically disposed after the call completes. The caller should not call // dispose(). - public async deliverCall(func: Function, thisArg: object | undefined): Promise { + public async deliverCall( + func: Function, thisArg: object | undefined, + methodName?: string | number): Promise { try { let promises: Promise[] = []; this.deliverTo(this, "value", promises); @@ -1182,6 +1365,14 @@ export class RpcPayload { await Promise.all(promises); } + let validator = getRpcValidator(thisArg, methodName); + if (validator?.args) { + if (!Array.isArray(this.value)) { + throw new TypeError("RPC call arguments payload must be an array."); + } + validator.args(this.value); + } + // Call the function. let result = Function.prototype.apply.call(func, thisArg, this.value); @@ -1189,11 +1380,28 @@ export class RpcPayload { // Special case: If the function immediately returns RpcPromise, we don't want to await it, // since that will actually wait for the promise. Instead we want to construct a payload // around it directly. + // + // We deliberately do NOT run the return-value validator here. The + // `RpcPromise` is a placeholder for a remote call's eventual result; + // its declared return type is the resolved value's type (the generator + // unwraps `Promise` to `T`), not the promise object itself. + // Validation will happen wherever the underlying value originates -- + // either on another peer's `deliverCall` or, for in-process forwarding, + // when the resolved payload is actually delivered. + if (validator?.returns) result = withRpcPromiseReturnValidator(result, validator); return RpcPayload.fromAppReturn(result); } else { // In all other cases, await the result (which may or may not be a promise, but `await` // will just pass through non-promises). - return RpcPayload.fromAppReturn(await result); + let awaited = await result; + let payload = RpcPayload.fromAppReturn(awaited); + try { + await validator?.returns?.(awaited); + return payload; + } catch (err) { + payload.dispose(); + throw err; + } } } finally { this.dispose(); @@ -1205,7 +1413,7 @@ export class RpcPayload { // // The returned object will have a disposer which disposes the payload. The caller should not // separately dispose it. - public async deliverResolve(): Promise { + public async deliverResolve(validate?: (value: unknown) => void | Promise): Promise { try { let promises: Promise[] = []; this.deliverTo(this, "value", promises); @@ -1215,6 +1423,7 @@ export class RpcPayload { } let result = this.value; + await validate?.(result); // Add disposer to result. if (result instanceof Object) { @@ -1638,7 +1847,8 @@ abstract class ValueStubHook extends StubHook { if (typeof followResult.value != "function") { throw new TypeError(`'${path.join('.')}' is not a function.`); } - let promise = args.deliverCall(followResult.value, followResult.parent); + let methodName = path.length > 0 ? path[path.length - 1] : undefined; + let promise = args.deliverCall(followResult.value, followResult.parent, methodName); return new PromiseStubHook(promise.then(payload => { return new PayloadStubHook(payload); })); diff --git a/src/index.ts b/src/index.ts index 10e4b7a..103c3dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,20 @@ // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit -import { RpcTarget as RpcTargetImpl, RpcStub as RpcStubImpl, RpcPromise as RpcPromiseImpl } from "./core.js"; +import { + RpcTarget as RpcTargetImpl, + RpcStub as RpcStubImpl, + RpcPromise as RpcPromiseImpl, +} from "./core.js"; +// Pulled in so the validator registry hooks are part of the main bundle. The actual +// generator-facing surface for these functions lives at +// `capnweb/internal/typecheck`, which resolves to this same file at runtime +// (see `package.json` exports). The exports below are marked `@internal`, so +// they're stripped from `dist/index.d.ts` but remain in `dist/index.js`. +import { + __capnweb_registerRpcValidators as __capnweb_registerRpcValidatorsImpl, + __capnweb_bindClientValidator as __capnweb_bindClientValidatorImpl, +} from "./typecheck/runtime.js"; import { serialize, deserialize } from "./serialize.js"; import { RpcTransport, RpcSession as RpcSessionImpl, RpcSessionOptions } from "./rpc.js"; import { RpcTargetBranded, RpcCompatible, Stub, Stubify, __RPC_TARGET_BRAND } from "./types.js"; @@ -18,10 +31,32 @@ forceInitMap(); forceInitStreams(); // Re-export public API types. -export { serialize, deserialize, newWorkersWebSocketRpcResponse, newHttpBatchRpcResponse, - nodeHttpBatchRpcResponse }; +export { + serialize, + deserialize, + newWorkersWebSocketRpcResponse, + newHttpBatchRpcResponse, + nodeHttpBatchRpcResponse, +}; export type { RpcTransport, RpcSessionOptions, RpcCompatible }; +// Thrown by generated validators when an RPC argument or return value fails a +// type check. Public so user code can `catch (e) { if (e instanceof RpcValidationError) ... }`. +export { RpcValidationError } from "./core.js"; +export type { RpcValidationFailure } from "./core.js"; + +// Library-internal entry points. These are imported by code emitted by +// the typecheck generator / Vite plugin, never by user code. They live here so the +// validator registry stays in a single bundle (one shared WeakMap). +// +// `stripInternal` removes them from `dist/index.d.ts`, so they don't show up +// in the user-facing API surface. The accompanying `capnweb/internal/typecheck` +// subpath has its own `.d.ts` that re-exposes them for generated code. +/** @internal */ +export const __capnweb_registerRpcValidators = __capnweb_registerRpcValidatorsImpl; +/** @internal */ +export const __capnweb_bindClientValidator = __capnweb_bindClientValidatorImpl; + // Hack the type system to make RpcStub's types work nicely! /** * Represents a reference to a remote object, on which methods may be remotely invoked via RPC. diff --git a/src/internal-typecheck.ts b/src/internal-typecheck.ts new file mode 100644 index 0000000..2b616c0 --- /dev/null +++ b/src/internal-typecheck.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Library-internal entry point. This module is not part of the user-facing +// API; it is imported only by generated typecheck code and +// the Vite plugin. The corresponding subpath in `package.json` +// (`./internal/typecheck`) resolves at runtime to `dist/index.js` so the +// validator state lives in a single bundle alongside the rest of the runtime. + +export { + RpcTarget, + __capnweb_registerRpcValidators, + __capnweb_bindClientValidator, +} from "./typecheck/runtime.js"; + +export type { + RpcClassValidators, + RpcMethodValidator, + RpcValidationOptions, +} from "./typecheck/runtime.js"; diff --git a/src/typecheck/cli.ts b/src/typecheck/cli.ts new file mode 100644 index 0000000..c7c3215 --- /dev/null +++ b/src/typecheck/cli.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Build-time CLI for Cap'n Web RPC validation codegen. +// +// `capnweb typecheck gen ` extracts RpcTarget classes from +// and writes validators into capnweb's internal placeholder subpaths +// (`capnweb/_typecheck-validators` and `capnweb/_typecheck-clients`). The +// capnweb runtime auto-loads from there by class name, so user code and +// bundler entry points stay untouched. +// +// `capnweb typecheck reset` restores the placeholders to their stub state, +// which disables runtime validation until the next `gen` run. + +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { generate, generateForPackage, resetTypecheckPackage, type GenOptions } from "./generate.js"; + +function usage(exitCode = 1): never { + let out = exitCode === 0 ? console.log : console.error; + out(`Usage: + capnweb typecheck gen [--out ] + capnweb typecheck reset + +Examples: + capnweb typecheck gen src/worker.ts + capnweb typecheck gen src/worker.ts --out .capnweb # legacy directory output + capnweb typecheck reset # restore stub validators`); + process.exit(exitCode); +} + +async function main() { + let [command, subcommand, ...rest] = process.argv.slice(2); + if (!command || command === "--help" || command === "-h") usage(0); + if (command !== "typecheck") usage(); + if (!subcommand || subcommand === "--help" || subcommand === "-h") usage(0); + + if (subcommand === "reset") { + if (rest.length > 0) usage(); + resetTypecheckPackage(); + return; + } + + if (subcommand !== "gen") usage(); + if (rest.includes("--help") || rest.includes("-h")) usage(0); + + let parsed = parseGenArgs(rest); + if (parsed.outDir === undefined) { + generateForPackage({ input: parsed.input }); + } else { + generate({ input: parsed.input, outDir: parsed.outDir }); + } +} + +function parseGenArgs(args: string[]): { input: string; outDir: string | undefined } { + let input: string | undefined; + let outDir: string | undefined; + for (let i = 0; i < args.length; i++) { + let arg = args[i]; + if (arg === "--out" || arg === "-o") { + let value = args[++i]; + if (value === undefined) throw new Error(`${arg} requires a directory argument.`); + outDir = value; + } else if (arg.startsWith("--")) { + throw new Error(`Unknown option: ${arg}`); + } else if (!input) { + input = arg; + } else { + throw new Error(`Unexpected argument: ${arg}`); + } + } + + if (!input) usage(); + return { input, outDir }; +} + +if (isMain()) { + main().catch(err => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} + +function isMain(): boolean { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return false; + } +} diff --git a/src/typecheck/extract.ts b/src/typecheck/extract.ts new file mode 100644 index 0000000..6721ba8 --- /dev/null +++ b/src/typecheck/extract.ts @@ -0,0 +1,405 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { existsSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; +import * as ts from "typescript"; +import type { ExtractedClassSpec, MethodSpec, ParamSpec, TypeSpec } from "./types.js"; + +const OPAQUE_NATIVES = new Set([ + "Date", "Error", "EvalError", "RangeError", "ReferenceError", "SyntaxError", + "TypeError", "URIError", "AggregateError", + "ReadableStream", "WritableStream", "Request", "Response", "Headers", + "Blob", "Uint8Array", +]); + +const SERIALIZER_UNSUPPORTED_NATIVES = new Set([ + "ArrayBuffer", "DataView", "RegExp", "Uint8ClampedArray", "Uint16Array", + "Uint32Array", "Int8Array", "Int16Array", "Int32Array", "BigUint64Array", + "BigInt64Array", "Float32Array", "Float64Array", +]); + +export type SourceFile = ts.SourceFile; + +export type TypecheckProject = { + program: ts.Program; + checker: ts.TypeChecker; + sourceFile: ts.SourceFile; +}; + +export function createProject(inputAbs: string): TypecheckProject { + let configPath = findTsConfig(inputAbs); + let options = configPath ? readCompilerOptions(configPath) : {}; + options = { + ...options, + target: options.target ?? ts.ScriptTarget.ES2023, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + allowJs: true, + noEmit: true, + }; + let program = ts.createProgram({ rootNames: [resolve(inputAbs)], options }); + let sourceFile = program.getSourceFile(resolve(inputAbs)); + if (!sourceFile) throw new Error(`Input file does not exist: ${inputAbs}`); + return { program, checker: program.getTypeChecker(), sourceFile }; +} + +export function findTsConfig(start: string): string | undefined { + let dir = dirname(resolve(start)); + while (true) { + let candidate = join(dir, "tsconfig.json"); + if (existsSync(candidate)) return candidate; + let parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +function readCompilerOptions(configPath: string): ts.CompilerOptions { + let config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + return ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configPath)).options; +} + +export function collectReachableSourceFiles(project: TypecheckProject): ts.SourceFile[] { + let result: ts.SourceFile[] = []; + let seen = new Set(); + let options = project.program.getCompilerOptions(); + + let visit = (file: ts.SourceFile) => { + let filePath = file.fileName; + if (seen.has(filePath)) return; + if (filePath.includes(`${sep}node_modules${sep}`)) return; + if (!isTypeScriptSource(filePath)) return; + seen.add(filePath); + result.push(file); + + let visitModule = (specifier: string) => { + let resolved = ts.resolveModuleName(specifier, filePath, options, ts.sys) + .resolvedModule?.resolvedFileName; + if (!resolved) return; + let target = project.program.getSourceFile(resolved); + if (target) visit(target); + }; + + for (let statement of file.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + visitModule(statement.moduleSpecifier.text); + } else if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier)) { + visitModule(statement.moduleSpecifier.text); + } + } + }; + + visit(project.sourceFile); + return result; +} + +/** + * Per-extraction state for type lowering. Threaded through `lowerType` so + * recursive types can be detected and hoisted into named validators that + * call themselves instead of being inlined infinitely. + */ +export type LowerContext = { + /** Currently in-flight types. Value is the assigned id once recursion has been detected. */ + visiting: Map; + /** Types that have been finalized and hoisted. */ + named: Map; + /** Stable id for every type that has been hoisted; reused on subsequent encounters. */ + typeToId: Map; + /** Monotonic counter for generating unique named ids. */ + nextNamedId: { value: number }; +}; + +export function newLowerContext(): LowerContext { + return { + visiting: new Map(), + named: new Map(), + typeToId: new Map(), + nextNamedId: { value: 0 }, + }; +} + +export type ExtractedClasses = { + classes: ExtractedClassSpec[]; + named: Map; +}; + +export function extractClasses(project: TypecheckProject, sourceFiles: ts.SourceFile[]): ExtractedClasses { + let ctx = newLowerContext(); + let classes = sourceFiles.flatMap(sourceFile => sourceFile.statements.filter(ts.isClassDeclaration)) + .filter(klass => isRpcTargetExtender(project.checker, klass)) + .map((klass, index) => extractClass(project.checker, klass, index, ctx)); + return { classes, named: ctx.named }; +} + +function isRpcTargetExtender(checker: ts.TypeChecker, klass: ts.ClassDeclaration): boolean { + let visited = new Set(); + let current: ts.ClassDeclaration | undefined = klass; + while (current && !visited.has(current)) { + visited.add(current); + let heritage = current.heritageClauses?.find(h => h.token === ts.SyntaxKind.ExtendsKeyword); + let typeNode = heritage?.types[0]; + if (!typeNode) return false; + + if (typeNode.expression.getText() === "RpcTarget") return true; + let symbol = checker.getSymbolAtLocation(typeNode.expression); + let aliased = symbol && (symbol.flags & ts.SymbolFlags.Alias) + ? checker.getAliasedSymbol(symbol) : symbol; + if (aliased?.getName() === "RpcTarget") return true; + + current = checker.getTypeAtLocation(typeNode.expression).getSymbol() + ?.declarations?.find(ts.isClassDeclaration); + } + return false; +} + +function extractClass(checker: ts.TypeChecker, klass: ts.ClassDeclaration, index: number, ctx: LowerContext): ExtractedClassSpec { + let name = klass.name?.text; + if (!name) { + throw new Error("Anonymous RpcTarget classes are not supported. Give the class a name " + + "so generated validators can import and register it."); + } + if (!hasModifier(klass, ts.SyntaxKind.ExportKeyword)) { + throw new Error(`${name}: RpcTarget classes must be exported so generated validators ` + + `can import and register their constructors.`); + } + let isDefault = hasModifier(klass, ts.SyntaxKind.DefaultKeyword); + return { + name, + valueName: isDefault ? `__capnweb_default_${index}` : name, + isDefault, + sourcePath: klass.getSourceFile().fileName, + methods: extractMethods(checker, klass, name, ctx), + }; +} + +function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some(m => m.kind === kind) === true; +} + +function extractMethods( + checker: ts.TypeChecker, klass: ts.ClassDeclaration, className: string, ctx: LowerContext): MethodSpec[] { + let methods: MethodSpec[] = []; + let seen = new Set(); + for (let member of klass.members) { + if (!ts.isMethodDeclaration(member)) continue; + if (hasModifier(member, ts.SyntaxKind.PrivateKeyword) || + hasModifier(member, ts.SyntaxKind.ProtectedKeyword) || + hasModifier(member, ts.SyntaxKind.StaticKeyword)) continue; + + let methodName = propertyNameText(member.name); + if (methodName === undefined) throw new Error(`${className}: computed RPC method names are not supported.`); + if (seen.has(methodName)) throw new Error(`${className}.${methodName}: overloaded RPC methods are not supported.`); + seen.add(methodName); + + let params: ParamSpec[] = []; + for (let param of member.parameters) { + if (param.dotDotDotToken) throw new Error(`${className}.${methodName}: rest parameters are not supported.`); + if (!ts.isIdentifier(param.name)) throw new Error(`${className}.${methodName}: destructured parameters are not supported.`); + params.push({ + name: param.name.text, + optional: param.questionToken !== undefined || param.initializer !== undefined, + type: lowerType(checker, checker.getTypeAtLocation(param), + `${className}.${methodName} parameter '${param.name.text}'`, ctx), + }); + } + let signature = checker.getSignatureFromDeclaration(member); + let returns = lowerType(checker, checker.getReturnTypeOfSignature(signature!), + `${className}.${methodName} return`, ctx); + methods.push({ name: methodName, params, returns }); + } + return methods; +} + +function propertyNameText(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; + return undefined; +} + +function lowerType( + checker: ts.TypeChecker, type: ts.Type, location: string, ctx: LowerContext): TypeSpec { + // Already hoisted from an earlier encounter — emit a ref directly. + let existingId = ctx.typeToId.get(type); + if (existingId !== undefined) return { kind: "ref", id: existingId }; + + // Currently being lowered (recursion). Assign an id and emit a ref; + // the outer call will record the lowered shape in `named` below. + if (ctx.visiting.has(type)) { + let id = ctx.visiting.get(type); + if (id === undefined) { + id = makeNamedId(type, ctx); + ctx.visiting.set(type, id); + ctx.typeToId.set(type, id); + } + return { kind: "ref", id }; + } + + ctx.visiting.set(type, undefined); + try { + let spec = lowerTypeBody(checker, type, location, ctx); + let assignedId = ctx.visiting.get(type); + if (assignedId !== undefined) { + // Self-referenced during lowering: hoist the body under the id and + // return a ref so the outer site emits the same call as inner ones. + ctx.named.set(assignedId, spec); + return { kind: "ref", id: assignedId }; + } + return spec; + } finally { + ctx.visiting.delete(type); + } +} + +function makeNamedId(type: ts.Type, ctx: LowerContext): string { + let base = rawTypeName(type)?.replace(/[^A-Za-z0-9_]/g, "_") || "Anon"; + return `${base}_${ctx.nextNamedId.value++}`; +} + +/** Symbol name TypeScript prints for `type` — alias first, falling back to + * the resolved symbol. Used both for hoisted-validator ids and for + * generated function display names. */ +function rawTypeName(type: ts.Type): string | undefined { + return type.aliasSymbol?.getName() ?? type.getSymbol()?.getName(); +} + +function lowerTypeBody( + checker: ts.TypeChecker, type: ts.Type, location: string, ctx: LowerContext): TypeSpec { + let text = checker.typeToString(type); + if (type.flags & ts.TypeFlags.Any) throw new Error(`${location}: type 'any' cannot be validated; specify a concrete type.`); + if (type.flags & ts.TypeFlags.Unknown) throw new Error(`${location}: type 'unknown' cannot be validated; specify a concrete type or narrow it.`); + if (text === "symbol" || text === "unique symbol" || (type.flags & ts.TypeFlags.ESSymbolLike) !== 0) { + throw new Error(`${location}: Unsupported RPC type: symbol`); + } + if (type.flags & ts.TypeFlags.Never) return { kind: "never" }; + if (type.flags & ts.TypeFlags.String) return { kind: "primitive", name: "string" }; + if (type.flags & ts.TypeFlags.Number) return { kind: "primitive", name: "number" }; + if (type.flags & ts.TypeFlags.BigIntLiteral) { + throw new Error(`${location}: bigint literal types are not supported by capnweb typecheck yet.`); + } + if (type.flags & ts.TypeFlags.BigInt) return { kind: "primitive", name: "bigint" }; + if (type.flags & ts.TypeFlags.Boolean) return { kind: "primitive", name: "boolean" }; + if (type.flags & ts.TypeFlags.Undefined) return { kind: "primitive", name: "undefined" }; + if (type.flags & ts.TypeFlags.Null) return { kind: "primitive", name: "null" }; + if (type.flags & ts.TypeFlags.Void) return { kind: "primitive", name: "void" }; + if (type.isStringLiteral()) return { kind: "literal", value: type.value }; + if (type.isNumberLiteral()) return { kind: "literal", value: type.value }; + if (type.flags & ts.TypeFlags.BooleanLiteral) return { kind: "literal", value: text === "true" }; + if (type.isUnion()) { + let variants = type.types.map(variant => lowerType(checker, variant, location, ctx)); + return variants.length === 1 ? variants[0] : { kind: "union", variants }; + } + if (type.isIntersection()) return { kind: "object", props: objectProps(checker, type, location, ctx) }; + if (checker.isTupleType(type)) { + let tupleTarget = (type as ts.TypeReference).target as ts.TupleType | undefined; + let elementFlags = tupleTarget?.elementFlags ?? []; + if (elementFlags.some(flag => + (flag & (ts.ElementFlags.Optional | ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0)) { + throw new Error(`${location}: optional and rest tuple elements are not supported by capnweb typecheck yet.`); + } + return { + kind: "tuple", + elements: checker.getTypeArguments(type as ts.TypeReference) + .map(element => lowerType(checker, element, location, ctx)), + }; + } + if (checker.isArrayType(type)) { + let arg = checker.getTypeArguments(type as ts.TypeReference)[0]; + return { kind: "array", element: arg ? lowerType(checker, arg, location, ctx) : { kind: "any" } }; + } + if (type.getCallSignatures().length > 0) return { kind: "function" }; + + let symbol = type.aliasSymbol ?? type.getSymbol(); + let symbolName = symbol?.getName(); + let typeArgs = checker.getTypeArguments(type as ts.TypeReference); + if (symbolName === "RpcStub" || symbolName === "RpcPromise") return { kind: "stub" }; + if (symbolName === "RpcTarget") return { kind: "rpcTarget" }; + if (symbolName === "Map" || symbolName === "ReadonlyMap") { + return { + kind: "map", + key: typeArgs[0] ? lowerType(checker, typeArgs[0], location, ctx) : { kind: "any" }, + value: typeArgs[1] ? lowerType(checker, typeArgs[1], location, ctx) : { kind: "any" }, + }; + } + if (symbolName === "Set" || symbolName === "ReadonlySet") { + return { + kind: "set", + value: typeArgs[0] ? lowerType(checker, typeArgs[0], location, ctx) : { kind: "any" }, + }; + } + if (symbolName && SERIALIZER_UNSUPPORTED_NATIVES.has(symbolName)) { + throw new Error(`${location}: Unsupported RPC type: ${symbolName}`); + } + if (symbolName && OPAQUE_NATIVES.has(symbolName)) return { kind: "instance", name: symbolName }; + if (symbolName === "Promise" || symbolName === "PromiseLike") { + return typeArgs[0] ? lowerType(checker, typeArgs[0], location, ctx) : { kind: "any" }; + } + if (symbolName === "Array" || symbolName === "ReadonlyArray") { + return typeArgs[0] ? { kind: "array", element: lowerType(checker, typeArgs[0], location, ctx) } + : { kind: "array", element: { kind: "any" } }; + } + let declaration = symbol?.declarations?.find(ts.isClassDeclaration); + if (declaration && isRpcTargetExtender(checker, declaration)) return { kind: "rpcTarget" }; + let index = checker.getIndexTypeOfType(type, ts.IndexKind.String); + if (index) { + let props = checker.getPropertiesOfType(type); + if (props.length > 0) { + throw new Error(`${location}: object types with both string index signatures and named properties are not supported by capnweb typecheck yet.`); + } + return { kind: "record", value: lowerType(checker, index, location, ctx) }; + } + return { + kind: "object", + props: objectProps(checker, type, location, ctx), + displayName: pickDisplayName(type), + }; +} + +/** + * Capture the user-facing name for an object/interface type so the codegen + * can produce `__capnweb_assert_User` instead of `__capnweb_assert_0`. Skips + * TypeScript's internal `"__type"` (used for inline object literals) and the + * structural `"Object"` so the names we emit always trace back to something + * the user wrote. + */ +function pickDisplayName(type: ts.Type): string | undefined { + let raw = rawTypeName(type); + if (!raw || raw === "__type" || raw === "Object") return undefined; + if (!/^[A-Za-z_$][\w$]*$/.test(raw)) return undefined; + return raw; +} + +function objectProps( + checker: ts.TypeChecker, type: ts.Type, location: string, ctx: LowerContext) { + return checker.getPropertiesOfType(type).flatMap(prop => { + let propDecl = prop.valueDeclaration ?? prop.declarations?.[0]; + if (!propDecl) return []; + let propType = checker.getTypeOfSymbolAtLocation(prop, propDecl); + return [{ + name: prop.getName(), + optional: (prop.flags & ts.SymbolFlags.Optional) !== 0, + type: lowerType(checker, propType, `${location}: property '${prop.getName()}'`, ctx), + }]; + }); +} + +function isTypeScriptSource(path: string): boolean { + return /\.(?:ts|tsx|mts|cts)$/.test(path) && !/\.d\.(?:ts|mts|cts)$/.test(path); +} + +export function commonDir(paths: string[]): string { + if (paths.length === 0) return process.cwd(); + let parts = paths.map(path => dirname(path).split(sep)); + let first = parts[0]; + let end = first.length; + for (let other of parts.slice(1)) { + let i = 0; + while (i < end && i < other.length && first[i] === other[i]) i++; + end = i; + } + return first.slice(0, end).join(sep) || sep; +} diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts new file mode 100644 index 0000000..7ecb7cb --- /dev/null +++ b/src/typecheck/generate.ts @@ -0,0 +1,934 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import * as ts from "typescript"; +import { + collectReachableSourceFiles, + commonDir, + createProject, + extractClasses, +} from "./extract.js"; +import { emitShadowSources, wrapFunctionName, wrapSessionFunctionName } from "./rewrite.js"; +import type { ClassRegistration, ExtractedClassSpec, GenOptions, GenResult, MethodSpec, TypeSpec } from "./types.js"; + +export function generate(options: GenOptions): GenResult { + let prepared = prepare(options); + let { classes, outAbs, sourceFile, reachableFiles, root } = prepared; + + let entryAbs = emitShadowSources(reachableFiles, sourceFile, outAbs, root, classes.map(c => c.name)); + let importPath = jsImportPath(relative(outAbs, entryAbs)); + let hasDefaultExport = sourceFile.statements.some(statement => + (ts.isExportAssignment(statement) && !statement.isExportEquals) || + (ts.canHaveModifiers(statement) && + ts.getModifiers(statement)?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword))); + + emitArtifacts(prepared); + writeFileSync(join(outAbs, "worker.entry.ts"), + emitWorkerEntry(importPath, hasDefaultExport, classes, outAbs, root)); + log(outAbs, "worker.entry.ts"); + return makeGenResult(classes); +} + +/** + * Generate validators and write them to capnweb's internal placeholder + * subpaths inside the resolved install (`capnweb/_typecheck-validators`, + * `capnweb/_typecheck-clients`). Users run `capnweb typecheck gen` once and + * the capnweb runtime picks the validators up by class name on next load. + * No entry-point or import changes are required in user code. + * + * The validators file deliberately has no `capnweb` import, so the runtime + * importing it cannot create an import cycle. The clients file imports + * `capnweb/internal/typecheck` for the Vite plugin's client-side rewrites + * and is only pulled in when the frontend wraps validated stubs. + */ +export function generateForPackage(options: { input: string }): GenResult { + let { validatorsEsm, validatorsCjs, clientsEsm, clientsCjs } = resolveTypecheckTargets(); + let prepared = prepare({ + input: options.input, + outDir: dirname(validatorsEsm), + runtimeImport: "capnweb/internal/typecheck", + }); + let { classes, named, runtimeImport } = prepared; + + let specsTs = emitSpecsModule(classes, runtimeImport, named); + // Generated `clients.ts` imports `validators` from `./specs.js`; point that + // at the validators subpath instead so the two files are independent. + let clientsTs = emitClientsModule(classes, runtimeImport) + .replace(/from "\.\/specs\.js"/g, `from "capnweb/_typecheck-validators"`); + + safeWrite(validatorsEsm, tsToEsm(specsTs)); + safeWrite(validatorsCjs, tsToCjs(specsTs)); + safeWrite(clientsEsm, tsToEsm(clientsTs)); + safeWrite(clientsCjs, tsToCjs(clientsTs)); + for (let path of [validatorsEsm, validatorsCjs, clientsEsm, clientsCjs]) { + console.log(`Generated ${relative(process.cwd(), path)}`); + } + return makeGenResult(classes); +} + +/** + * Replace a file without modifying any inode the system shares with other + * paths. pnpm hardlinks installed packages from its content-addressable store, + * so a plain `writeFileSync` would truncate the shared inode and silently + * corrupt every other project on the same store. Unlinking first leaves the + * store entry intact and lets us create a fresh file at the target path. + */ +function safeWrite(path: string, content: string): void { + try { unlinkSync(path); } + catch (err) { if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err; } + writeFileSync(path, content); +} + +function tsToEsm(source: string): string { + return ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; +} + +function tsToCjs(source: string): string { + return ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; +} + +/** + * Restore the internal typecheck placeholder subpaths to their stub state. + * Equivalent to a fresh install — no validators are active. + */ +export function resetTypecheckPackage(): void { + let { validatorsEsm, validatorsCjs, clientsEsm, clientsCjs } = resolveTypecheckTargets(); + safeWrite(validatorsEsm, STUB_ESM); + safeWrite(validatorsCjs, STUB_CJS); + safeWrite(clientsEsm, STUB_CLIENTS_ESM); + safeWrite(clientsCjs, STUB_CLIENTS_CJS); +} + +const STUB_BANNER = `// Placeholder. Overwritten in-place by \`capnweb typecheck gen\`.\n` + + `// While the placeholder is in place, capnweb skips RPC type checking.\n`; + +const STUB_ESM = STUB_BANNER + `export const validators = null;\n`; + +const STUB_CJS = STUB_BANNER + + `"use strict";\n` + + `Object.defineProperty(exports, "__esModule", { value: true });\n` + + `exports.validators = null;\n`; + +const STUB_CLIENTS_ESM = STUB_BANNER + `// No client wrappers until \`gen\` runs.\n`; + +const STUB_CLIENTS_CJS = STUB_BANNER + + `"use strict";\n` + + `Object.defineProperty(exports, "__esModule", { value: true });\n`; + +type TypecheckTargets = { + validatorsEsm: string; + validatorsCjs: string; + clientsEsm: string; + clientsCjs: string; +}; + +function resolveTypecheckTargets(): TypecheckTargets { + let req = createRequire(resolve(process.cwd(), "_")); + // `req.resolve` picks the `import` or `require` condition based on calling + // context. Use it only to locate capnweb's `dist` directory, then build the + // four sibling paths explicitly so we hit both ESM and CJS regardless of + // how the CLI was invoked. This stays agnostic to the install layout + // (npm flat, pnpm `.pnpm`, yarn classic). + let resolved = req.resolve("capnweb/_typecheck-validators"); + if (isInsideYarnPnpZip(resolved)) { + throw new Error( + `capnweb resolves inside a Yarn Plug'n'Play archive (${resolved}). ` + + `\`capnweb typecheck gen\` needs to rewrite files inside the package, ` + + `which isn't possible while it's zipped.\n` + + `\n` + + `Run \`yarn unplug capnweb\` once, then re-run ` + + `\`capnweb typecheck gen\`. Yarn will keep the package unplugged on ` + + `future installs so codegen continues to work.` + ); + } + let distDir = dirname(resolved); + return { + validatorsEsm: join(distDir, "_typecheck-validators.js"), + validatorsCjs: join(distDir, "_typecheck-validators.cjs"), + clientsEsm: join(distDir, "_typecheck-clients.js"), + clientsCjs: join(distDir, "_typecheck-clients.cjs"), + }; +} + +function isInsideYarnPnpZip(path: string): boolean { + // Yarn PnP serves packages out of zip archives mounted by its loader. The + // resolved path looks like `…/cache/foo-npm-1.0.0-abc.zip/node_modules/foo/…`. + // process.versions.pnp is set when the PnP runtime is active. + if (process.versions.pnp !== undefined && /\.zip[\/\\]/.test(path)) return true; + return /[\/\\]\.yarn[\/\\]cache[\/\\][^\/\\]+\.zip[\/\\]/.test(path); +} + + +export function generateValidatorsOnly(options: GenOptions): GenResult { + let prepared = prepare(options); + emitArtifacts(prepared); + return makeGenResult(prepared.classes); +} + +type Prepared = { + classes: ExtractedClassSpec[]; + named: Map; + outAbs: string; + runtimeImport: string; + sourceFile: ReturnType["sourceFile"]; + reachableFiles: ReturnType; + root: string; +}; + +function prepare(options: GenOptions): Prepared { + let inputAbs = resolve(options.input); + let outAbs = resolve(options.outDir); + let runtimeImport = options.runtimeImport ?? "capnweb/internal/typecheck"; + if (!existsSync(inputAbs)) throw new Error(`Input file does not exist: ${options.input}`); + + let project = createProject(inputAbs); + let sourceFile = project.sourceFile; + let reachableFiles = collectReachableSourceFiles(project); + let root = commonDir(reachableFiles.map(file => file.fileName)); + let { classes, named } = extractClasses(project, reachableFiles); + if (classes.length === 0) throw new Error(`No classes extending RpcTarget found in ${options.input}`); + checkNameCollisions(classes); + let checkedRefs = new Set(); + for (let klass of classes) { + for (let method of klass.methods) { + for (let param of method.params) checkSupported(param.type, named, checkedRefs); + checkSupported(method.returns, named, checkedRefs); + } + } + for (let spec of named.values()) checkSupported(spec, named, checkedRefs); + mkdirSync(outAbs, { recursive: true }); + return { classes, named, outAbs, runtimeImport, sourceFile, reachableFiles, root }; +} + +function checkNameCollisions(classes: ExtractedClassSpec[]): void { + let seen = new Map(); + for (let klass of classes) { + let prior = seen.get(klass.name); + if (prior) { + throw new Error(`Multiple RpcTarget classes share the name '${klass.name}'. ` + + `Sources: ${prior.sourcePath}, ${klass.sourcePath}. Give one of them an explicit name.`); + } + seen.set(klass.name, klass); + } +} + +function checkSupported(type: TypeSpec, named: Map, visited: Set): void { + switch (type.kind) { + case "unsupported": throw new Error(`Unsupported RPC type: ${type.text}`); + case "array": checkSupported(type.element, named, visited); break; + case "tuple": for (let element of type.elements) checkSupported(element, named, visited); break; + case "map": checkSupported(type.key, named, visited); checkSupported(type.value, named, visited); break; + case "set": checkSupported(type.value, named, visited); break; + case "object": for (let prop of type.props) checkSupported(prop.type, named, visited); break; + case "record": checkSupported(type.value, named, visited); break; + case "union": for (let variant of type.variants) checkSupported(variant, named, visited); break; + case "ref": { + if (visited.has(type.id)) break; + visited.add(type.id); + let target = named.get(type.id); + if (target) checkSupported(target, named, visited); + break; + } + } +} + +function emitArtifacts(p: Prepared): void { + writeFileSync(join(p.outAbs, "specs.ts"), emitSpecsModule(p.classes, p.runtimeImport, p.named)); + log(p.outAbs, "specs.ts"); + writeFileSync(join(p.outAbs, "validators.ts"), emitValidatorsModule(p.runtimeImport)); + log(p.outAbs, "validators.ts"); + writeFileSync(join(p.outAbs, "clients.ts"), emitClientsModule(p.classes, p.runtimeImport)); + log(p.outAbs, "clients.ts"); +} + +function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string, named: Map = new Map()): string { + let emit: ValidatorEmit = { + lines: [], + nextId: 0, + refValueFns: new Map(), + refPathFns: new Map(), + helpers: new Set(), + valueByKey: new Map(), + pathByKey: new Map(), + // Reserve every name we emit ourselves so a user type with the same name + // can't shadow it — pickFunctionName will suffix the user type instead. + usedNames: new Set(RESERVED_HELPER_NAMES), + }; + // Reserve function names for every hoisted type before emitting any + // bodies, so mutually recursive named types can resolve each other. + for (let id of named.keys()) { + emit.refValueFns.set(id, "__capnweb_validate_named_" + safeIdent(id)); + emit.refPathFns.set(id, "__capnweb_validate_path_named_" + safeIdent(id)); + } + for (let [id, spec] of named) { + emitNamedValidator(id, spec, emit); + } + let classEntries: string[] = []; + + for (let klass of classes) { + let methodEntries = klass.methods.map(method => { + let paramValidators = method.params.map(param => emitTypeValidator(param.type, emit)); + let returnValidator = emitTypeValidator(method.returns, emit); + let returnPathValidator = emitTypePathValidator(method.returns, emit); + return emitMethodValidator(klass.name, method, paramValidators, returnValidator, returnPathValidator); + }); + classEntries.push(" " + JSON.stringify(klass.name) + ": {\n" + + methodEntries.join(",\n") + "\n }"); + } + + let lines = [ + "// Generated by capnweb typecheck gen. Do not edit.", + "import { RpcTarget as __capnweb_RpcTarget } from " + JSON.stringify(runtimeImport) + ";", + // Imported lazily through capnweb's main entry. Safe under the + // capnweb -> _typecheck-validators -> capnweb cycle because the binding + // is only read inside `__capnweb_makeValidationError` at call time. + "import { RpcValidationError as __CapnwebRpcValidationError } from \"capnweb\";", + "", + "type __CapnwebValidationOptions = { isRpcPlaceholder?: (value: unknown) => boolean };", + "type __CapnwebValidationFailure = { path: (string | number)[]; expected: string; actual: string; value: unknown };", + "", + "function __capnweb_mismatch(path: (string | number)[], expected: string, value: unknown): __CapnwebValidationFailure {", + " return { path, expected, actual: __capnweb_actualKind(value), value };", + "}", + "", + "function __capnweb_missing(path: (string | number)[], expected: string): __CapnwebValidationFailure {", + " return { path, expected, actual: \"missing\", value: undefined };", + "}", + "", + "function __capnweb_makeValidationError(prefix: string, failure: __CapnwebValidationFailure): __CapnwebRpcValidationError {", + " let where = failure.path.length > 0 ? failure.path.join(\".\") : \"value\";", + " let message = prefix + \": \" + where + \": expected \" + failure.expected + \", got \" + failure.actual;", + " return new __CapnwebRpcValidationError(message, failure);", + "}", + "", + "function __capnweb_fail(path: (string | number)[], expected: string, value: unknown): never {", + " throw __capnweb_makeValidationError(\"\", __capnweb_mismatch(path, expected, value));", + "}", + "", + "function __capnweb_failMissing(path: (string | number)[], expected: string): never {", + " throw __capnweb_makeValidationError(\"\", __capnweb_missing(path, expected));", + "}", + "", + "function __capnweb_rethrow(prefix: string, e: unknown): never {", + " if (e instanceof __CapnwebRpcValidationError) throw __capnweb_makeValidationError(prefix, e.rpcValidation);", + " throw e;", + "}", + "", + "function __capnweb_actualKind(value: unknown): string {", + " if (value === null) return \"null\";", + " if (Array.isArray(value)) return \"array\";", + " if (value instanceof Date) return \"Date\";", + " if (value instanceof RegExp) return \"RegExp\";", + " if (value instanceof Error) return \"Error\";", + " if (typeof value === \"object\") {", + " let ctor = (value as object).constructor;", + " if (ctor && ctor.name && ctor.name !== \"Object\") return ctor.name;", + " return \"object\";", + " }", + " return typeof value;", + "}", + "", + ...emitSharedHelpers(emit.helpers), + ...emit.lines, + "export const validators = {", + classEntries.join(",\n"), + "};", + "", + ]; + return lines.join("\n"); +} + +type ValidatorEmit = { + lines: string[]; + nextId: number; + // Named-type ref id -> function name. Populated before any emit calls so a + // ref encountered inside a named body resolves cleanly even before the + // referent's own body has finished emitting (mutual recursion). + refValueFns: Map; + refPathFns: Map; + /** Shared helpers actually referenced — preamble only emits these. */ + helpers: Set; + /** Structural-dedup cache: same `typeKey` → reuse the emitted function. */ + valueByKey: Map; + pathByKey: Map; + /** Function names taken in the module so `pickFunctionName` can suffix collisions. */ + usedNames: Set; +}; + +/** + * Pick a unique function name. Prefers a human-readable base when the type + * carries a display name (an interface or type-alias name from the source); + * otherwise falls back to a sequential id. The dedup map (`valueByKey` / + * `pathByKey`) ensures same-shape types reuse the SAME emitted function; + * `usedNames` only kicks in when two distinct shapes happen to share a name. + */ +function pickFunctionName(emit: ValidatorEmit, prefix: string, displayName?: string): string { + let base = displayName + ? `${prefix}_${displayName}` + : `${prefix}_${emit.nextId++}`; + let name = base; + let suffix = 1; + while (emit.usedNames.has(name)) name = `${base}_${++suffix}`; + emit.usedNames.add(name); + return name; +} + +type HelperId = + | "any" | "never" | "rpcTarget" | "stub" | "function" + | `primitive:${string}`; + +/** Every name emitted by the preamble + emitSharedHelpers. Reserved against + * user types so a user-defined `String` interface can't shadow our helpers. */ +const RESERVED_HELPER_NAMES = [ + "__capnweb_mismatch", "__capnweb_missing", "__capnweb_makeValidationError", + "__capnweb_fail", "__capnweb_failMissing", "__capnweb_rethrow", + "__capnweb_actualKind", "__capnweb_RpcTarget", "__CapnwebRpcValidationError", + "__capnweb_assert_any", "__capnweb_assert_never", "__capnweb_assert_rpcTarget", + "__capnweb_assert_stub", "__capnweb_assert_function", + "__capnweb_assert_string", "__capnweb_assert_number", "__capnweb_assert_bigint", + "__capnweb_assert_boolean", "__capnweb_assert_undefined", "__capnweb_assert_null", + "__capnweb_assert_void", +]; + +/** + * Return the name of a shared helper for a kind-only type, or undefined if + * the type carries structure (object shape, tuple elements, union variants, + * etc.) that requires emitting a dedicated function. Registers the helper + * in `emit.helpers` as a side effect so the preamble emits only what's used. + */ +function sharedValidatorFor(type: TypeSpec, emit: ValidatorEmit): string | undefined { + switch (type.kind) { + case "any": emit.helpers.add("any"); return "__capnweb_assert_any"; + case "never": emit.helpers.add("never"); return "__capnweb_assert_never"; + case "rpcTarget": emit.helpers.add("rpcTarget"); return "__capnweb_assert_rpcTarget"; + case "stub": emit.helpers.add("stub"); return "__capnweb_assert_stub"; + case "function": emit.helpers.add("function"); return "__capnweb_assert_function"; + case "primitive": { + let id: HelperId = `primitive:${type.name}`; + emit.helpers.add(id); + return "__capnweb_assert_" + type.name; + } + default: return undefined; + } +} + +/** + * Stable structural key for a TypeSpec. Used to dedupe emitted validators so + * two identical shapes (e.g. two methods that return the same `User` object) + * share one function instead of producing byte-identical copies. Union + * variants are sorted so `A | B` and `B | A` collapse. + */ +function typeKey(type: TypeSpec): string { + switch (type.kind) { + case "any": return "any"; + case "never": return "never"; + case "rpcTarget": return "rpcTarget"; + case "stub": return "stub"; + case "function": return "function"; + case "primitive": return `prim:${type.name}`; + case "literal": return `lit:${typeof type.value}:${JSON.stringify(type.value)}`; + case "instance": return `inst:${type.name}`; + case "array": return `arr<${typeKey(type.element)}>`; + case "tuple": return `tup<${type.elements.map(typeKey).join(",")}>`; + case "map": return `map<${typeKey(type.key)},${typeKey(type.value)}>`; + case "set": return `set<${typeKey(type.value)}>`; + case "record": return `rec<${typeKey(type.value)}>`; + case "object": return `obj<${type.props.map(p => `${p.name}${p.optional ? "?" : ""}:${typeKey(p.type)}`).join(",")}>`; + case "union": return `un<${type.variants.map(typeKey).sort().join("|")}>`; + case "ref": return `ref:${type.id}`; + case "unsupported": return `unsup:${type.text}`; + } +} + +function emitSharedHelpers(used: Set): string[] { + let lines: string[] = []; + let emitAssert = (name: string, check: string, expected: string) => { + lines.push( + `function ${name}(value: unknown, path: (string | number)[], options?: __CapnwebValidationOptions): void {`, + ` if (options?.isRpcPlaceholder?.(value)) return;`, + ` if (!(${check})) __capnweb_fail(path, ${expected}, value);`, + `}`, + ""); + }; + if (used.has("any")) { + lines.push( + `function __capnweb_assert_any(_value: unknown, _path: (string | number)[], _options?: __CapnwebValidationOptions): void {}`, + ""); + } + if (used.has("never")) { + lines.push( + `function __capnweb_assert_never(value: unknown, path: (string | number)[], _options?: __CapnwebValidationOptions): void {`, + ` __capnweb_fail(path, "never", value);`, + `}`, + ""); + } + if (used.has("rpcTarget")) { + emitAssert("__capnweb_assert_rpcTarget", `value instanceof __capnweb_RpcTarget`, `"RpcTarget"`); + } + if (used.has("stub")) { + emitAssert("__capnweb_assert_stub", + `value !== null && (typeof value === "object" || typeof value === "function")`, + `"RpcStub"`); + } + if (used.has("function")) { + emitAssert("__capnweb_assert_function", `typeof value === "function"`, `"Function"`); + } + for (let id of used) { + if (!id.startsWith("primitive:")) continue; + let name = id.slice("primitive:".length); + emitAssert("__capnweb_assert_" + name, primitiveCheck("value", name), + JSON.stringify(name === "void" ? "undefined" : name)); + } + return lines; +} + +function safeIdent(id: string): string { + return id.replace(/[^A-Za-z0-9_]/g, "_"); +} + +function emitNamedValidator(id: string, spec: TypeSpec, emit: ValidatorEmit): void { + let fnName = emit.refValueFns.get(id)!; + // The body delegates to the spec's own validator; for a self-referential + // type, the inner ref resolves back to this same function name. JS hoists + // function declarations so the call works even though the body still emits. + let inner = emitTypeValidator(spec, emit); + emit.lines.push( + `function ${fnName}(value: unknown, path: (string | number)[], options?: __CapnwebValidationOptions): void {`, + ` ${inner}(value, path, options);`, + `}`, + ""); + let pathName = emit.refPathFns.get(id)!; + let pathInner = emitTypePathValidator(spec, emit); + emit.lines.push( + `function ${pathName}(path: (string | number)[], value: unknown, offset = 0, options?: __CapnwebValidationOptions): void {`, + ` ${pathInner}(path, value, offset, options);`, + `}`, + ""); +} + +function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { + // Kind-only types share a single helper instead of getting a fresh function. + let shared = sharedValidatorFor(type, emit); + if (shared) return shared; + + let key = typeKey(type); + let cached = emit.valueByKey.get(key); + if (cached) return cached; + + let displayName = type.kind === "object" ? type.displayName : undefined; + let name = pickFunctionName(emit, "__capnweb_assert", displayName); + emit.valueByKey.set(key, name); + let expected = JSON.stringify(typeDescription(type)); + let lines = [ + "function " + name + "(value: unknown, path: (string | number)[], options?: __CapnwebValidationOptions): void {", + " if (options?.isRpcPlaceholder?.(value)) return;", + ]; + + switch (type.kind) { + case "any": + break; + case "never": + case "unsupported": + lines.push(" __capnweb_fail(path, " + expected + ", value);"); + break; + case "primitive": + lines.push(" if (!(" + primitiveCheck("value", type.name) + ")) __capnweb_fail(path, " + expected + ", value);"); + break; + case "literal": + lines.push(" if (value !== " + JSON.stringify(type.value) + ") __capnweb_fail(path, " + expected + ", value);"); + break; + case "array": { + let element = emitTypeValidator(type.element, emit); + lines.push( + " if (!Array.isArray(value)) __capnweb_fail(path, " + expected + ", value);", + " let arr = value as unknown[];", + " for (let i = 0; i < arr.length; i++) " + element + "(arr[i], [...path, \"[\" + i + \"]\"], options);"); + break; + } + case "tuple": { + let elements = type.elements.map(element => emitTypeValidator(element, emit)); + lines.push( + " if (!Array.isArray(value) || value.length !== " + elements.length + ") __capnweb_fail(path, " + expected + ", value);", + " let tup = value as unknown[];"); + elements.forEach((element, index) => { + lines.push(" " + element + "(tup[" + index + "], [...path, \"[" + index + "]\"], options);"); + }); + break; + } + case "map": { + let key = emitTypeValidator(type.key, emit); + let value = emitTypeValidator(type.value, emit); + lines.push( + " if (!(value instanceof Map)) __capnweb_fail(path, " + expected + ", value);", + " for (let [k, v] of value as Map) {", + " " + key + "(k, [...path, \"\"], options);", + " " + value + "(v, [...path, String(k)], options);", + " }"); + break; + } + case "set": { + let value = emitTypeValidator(type.value, emit); + lines.push( + " if (!(value instanceof Set)) __capnweb_fail(path, " + expected + ", value);", + " let i = 0;", + " for (let item of value as Set) " + value + "(item, [...path, \"[\" + i++ + \"]\"], options);"); + break; + } + case "object": + lines.push( + " if (typeof value !== \"object\" || value === null || Array.isArray(value)) __capnweb_fail(path, " + expected + ", value);", + " let record = value as Record;"); + for (let prop of type.props) { + let propValidator = emitTypeValidator(prop.type, emit); + let propName = JSON.stringify(prop.name); + let propPath = "[...path, " + propName + "]"; + let propExpected = JSON.stringify(typeDescription(prop.type)); + if (prop.optional) { + lines.push( + " if (Object.prototype.hasOwnProperty.call(record, " + propName + ") && record[" + propName + "] !== undefined)", + " " + propValidator + "(record[" + propName + "], " + propPath + ", options);"); + } else { + lines.push( + " if (!Object.prototype.hasOwnProperty.call(record, " + propName + ")) __capnweb_failMissing(" + propPath + ", " + propExpected + ");", + " " + propValidator + "(record[" + propName + "], " + propPath + ", options);"); + } + } + break; + case "record": { + let value = emitTypeValidator(type.value, emit); + lines.push( + " if (typeof value !== \"object\" || value === null || Array.isArray(value)) __capnweb_fail(path, " + expected + ", value);", + " for (let [k, v] of Object.entries(value as Record)) " + value + "(v, [...path, k], options);"); + break; + } + case "union": { + let variants = type.variants.map(variant => emitTypeValidator(variant, emit)); + lines.push(" let failures: __CapnwebValidationFailure[] = [];"); + for (let variant of variants) { + lines.push( + " try { " + variant + "(value, path, options); return; }", + " catch (e) {", + " if (e instanceof __CapnwebRpcValidationError) failures.push(e.rpcValidation);", + " else throw e;", + " }"); + } + lines.push( + " let pick = failures.find(f => f.path.length > path.length);", + " if (pick) throw __capnweb_makeValidationError(\"\", pick);", + " __capnweb_fail(path, " + expected + ", value);"); + break; + } + case "instance": + lines.push( + " let ctor = (globalThis as Record)[" + JSON.stringify(type.name) + "];", + " if (!(typeof ctor === \"function\" && value instanceof ctor)) __capnweb_fail(path, " + expected + ", value);"); + break; + case "rpcTarget": + lines.push(" if (!(value instanceof __capnweb_RpcTarget)) __capnweb_fail(path, " + expected + ", value);"); + break; + case "stub": + lines.push(" if (!(value !== null && (typeof value === \"object\" || typeof value === \"function\"))) __capnweb_fail(path, " + expected + ", value);"); + break; + case "function": + lines.push(" if (typeof value !== \"function\") __capnweb_fail(path, " + expected + ", value);"); + break; + case "ref": { + // Delegate to the hoisted named validator. Function name is reserved + // before any body emits so this resolves for forward references and + // for self-referential types alike. + let target = emit.refValueFns.get(type.id); + if (!target) throw new Error(`Internal: no named validator for ref id '${type.id}'`); + lines.push(" " + target + "(value, path, options);"); + break; + } + } + + lines.push("}", ""); + emit.lines.push(...lines); + return name; +} + +function emitTypePathValidator(type: TypeSpec, emit: ValidatorEmit): string { + let key = typeKey(type); + let cached = emit.pathByKey.get(key); + if (cached) return cached; + + let displayName = type.kind === "object" ? type.displayName : undefined; + let name = pickFunctionName(emit, "__capnweb_walk", displayName); + emit.pathByKey.set(key, name); + let fullValidator = emitTypeValidator(type, emit); + let lines = [ + "function " + name + "(path: (string | number)[], value: unknown, offset = 0, options?: __CapnwebValidationOptions): void {", + " if (offset >= path.length) { " + fullValidator + "(value, path, options); return; }", + ]; + + switch (type.kind) { + case "object": { + lines.push(" switch (path[offset]) {"); + for (let prop of type.props) { + let child = emitTypePathValidator(prop.type, emit); + lines.push(" case " + JSON.stringify(prop.name) + ":"); + if (prop.optional) { + lines.push(" if (value !== undefined) " + child + "(path, value, offset + 1, options);"); + lines.push(" return;"); + } else { + lines.push(" " + child + "(path, value, offset + 1, options); return;"); + } + } + lines.push(" }"); + break; + } + case "array": { + let child = emitTypePathValidator(type.element, emit); + lines.push(" " + child + "(path, value, offset + 1, options);"); + break; + } + case "tuple": { + let elements = type.elements.map(element => emitTypePathValidator(element, emit)); + lines.push(" switch (path[offset]) {"); + elements.forEach((element, index) => { + lines.push( + " case " + JSON.stringify(String(index)) + ":", + " case " + index + ": " + element + "(path, value, offset + 1, options); return;"); + }); + lines.push(" }"); + break; + } + case "record": { + let child = emitTypePathValidator(type.value, emit); + lines.push(" " + child + "(path, value, offset + 1, options);"); + break; + } + case "map": { + let child = emitTypePathValidator(type.value, emit); + lines.push(" " + child + "(path, value, offset + 1, options);"); + break; + } + case "union": { + let variants = type.variants.map(variant => emitTypePathValidator(variant, emit)); + lines.push(" let failures: __CapnwebValidationFailure[] = [];"); + for (let variant of variants) { + lines.push( + " try { " + variant + "(path, value, offset, options); return; }", + " catch (e) {", + " if (e instanceof __CapnwebRpcValidationError) failures.push(e.rpcValidation);", + " else throw e;", + " }"); + } + lines.push( + " let pick = failures.find(f => f.path.length > offset) ?? failures[0];", + " if (pick) throw __capnweb_makeValidationError(\"\", pick);"); + break; + } + case "ref": { + let target = emit.refPathFns.get(type.id); + if (!target) throw new Error(`Internal: no named path validator for ref id '${type.id}'`); + lines.push(" " + target + "(path, value, offset, options);"); + break; + } + } + + lines.push("}", ""); + emit.lines.push(...lines); + return name; +} + +function primitiveCheck(value: string, name: string): string { + switch (name) { + case "string": return "typeof " + value + " === \"string\""; + case "number": return "typeof " + value + " === \"number\""; + case "bigint": return "typeof " + value + " === \"bigint\""; + case "boolean": return "typeof " + value + " === \"boolean\""; + case "undefined": + case "void": return value + " === undefined"; + case "null": return value + " === null"; + default: throw new Error("Unknown primitive type: " + name); + } +} + +function emitMethodValidator( + className: string, method: MethodSpec, paramValidators: string[], + returnValidator: string, returnPathValidator: string): string { + let prefix = className + "." + method.name; + let minArgs = method.params.reduce((min, param, index) => param.optional ? min : index + 1, 0); + let expected = minArgs === method.params.length ? String(minArgs) : minArgs + "-" + method.params.length; + let prefixJson = JSON.stringify(prefix); + let returnPrefixJson = JSON.stringify(prefix + " return"); + + let paramCalls = method.params.map((param, index) => { + let path = JSON.stringify([param.name]); + let call = paramValidators[index] + "(args[" + index + "], " + path + ", options);"; + return param.optional + ? "if (args[" + index + "] !== undefined) " + call + : call; + }).join(" "); + + let lines = [ + " " + JSON.stringify(method.name) + ": {", + " args(args: unknown[], options?: __CapnwebValidationOptions): void {", + " if (args.length < " + minArgs + " || args.length > " + method.params.length + ") {", + " throw new TypeError(" + JSON.stringify(prefix + " expected " + expected + " argument(s), got ") + " + args.length);", + " }", + " try { " + paramCalls + " } catch (e) { __capnweb_rethrow(" + prefixJson + ", e); }", + " },", + " returns(value: unknown): void {", + " try { " + returnValidator + "(value, []); } catch (e) { __capnweb_rethrow(" + returnPrefixJson + ", e); }", + " },", + " returnsPath(path: (string | number)[], value: unknown): void {", + " try { " + returnPathValidator + "(path, value); } catch (e) { __capnweb_rethrow(" + returnPrefixJson + ", e); }", + " },", + " }", + ]; + return lines.join("\n"); +} + +function typeDescription(type: TypeSpec): string { + switch (type.kind) { + case "any": return "any"; + case "never": return "never"; + case "primitive": return type.name === "void" ? "undefined" : type.name; + case "literal": return typeof type.value === "string" ? JSON.stringify(type.value) : String(type.value); + case "array": return typeDescription(type.element) + "[]"; + case "tuple": return "[" + type.elements.map(typeDescription).join(", ") + "]"; + case "map": return "Map<" + typeDescription(type.key) + ", " + typeDescription(type.value) + ">"; + case "set": return "Set<" + typeDescription(type.value) + ">"; + case "object": return "{ " + type.props.map(p => p.name + (p.optional ? "?" : "") + ": " + typeDescription(p.type)).join(", ") + " }"; + case "record": return "Record"; + case "union": return type.variants.map(typeDescription).sort().join(" | "); + case "instance": return type.name; + case "rpcTarget": return "RpcTarget"; + case "stub": return "RpcStub"; + case "function": return "Function"; + case "unsupported": return type.text; + case "ref": { + // Strip the disambiguating numeric suffix added by makeNamedId; the + // human-readable base is more useful in error messages. + let match = type.id.match(/^(.*)_\d+$/); + return match ? match[1] : type.id; + } + } +} + +function emitValidatorsModule(runtimeImport: string): string { + return [ + "// Generated by capnweb typecheck gen. Do not edit.", + "import { __capnweb_registerRpcValidators } from " + JSON.stringify(runtimeImport) + ";", + "import { validators } from \"./specs.js\";", + "", + "export function registerCapnwebValidators(classes: Record): void {", + " __capnweb_registerRpcValidators(classes, validators);", + "}", + "", + ].join("\n"); +} + +function emitClientsModule(classes: ExtractedClassSpec[], runtimeImport: string): string { + let parts = [ + "// Generated by capnweb typecheck gen. Do not edit.", + "import { __capnweb_bindClientValidator } from " + JSON.stringify(runtimeImport) + ";", + "import { validators } from \"./specs.js\";", + "", + ]; + for (let klass of classes) { + let key = JSON.stringify(klass.name); + parts.push("export function " + wrapFunctionName(klass.name) + "(stub: T): T {"); + parts.push(" return __capnweb_bindClientValidator(stub as object, validators[" + key + "]) as T;"); + parts.push("}"); + parts.push(""); + parts.push("export function " + wrapSessionFunctionName(klass.name) + "(session: T): T {"); + parts.push(" let main = (session as { getRemoteMain(): object }).getRemoteMain();"); + parts.push(" __capnweb_bindClientValidator(main, validators[" + key + "]);"); + parts.push(" return session;"); + parts.push("}"); + parts.push(""); + } + return parts.join("\n"); +} + +function emitWorkerEntry( + importPath: string, hasDefault: boolean, classes: ExtractedClassSpec[], + outAbs: string, root: string): string { + let registration = emitRegistrationImportsAndCall(classes, outAbs, + sourcePath => join(outAbs, "source", relative(root, sourcePath))); + return `// Generated by capnweb typecheck gen. Do not edit.\n` + + `import { registerCapnwebValidators } from "./validators.js";\n` + + `${registration.imports.join("\n")}\n` + + `registerCapnwebValidators({ ${registration.entries.join(", ")} });\n` + + `export * from ${JSON.stringify(importPath)};\n` + + (hasDefault ? `export { default } from ${JSON.stringify(importPath)};\n` : ""); +} + +type RegistrationEmit = { imports: string[]; entries: string[] }; + +export function emitViteRegistration( + classes: ClassRegistration[], inputAbs: string, + validatorsAbs: string): { imports: string; call: string } { + let registration = emitRegistrationImportsAndCall(classes, dirname(inputAbs), + sourcePath => sourcePath, inputAbs); + let imports = `import { registerCapnwebValidators as __capnweb_registerValidators } ` + + `from ${JSON.stringify(jsImportPath(relative(dirname(inputAbs), validatorsAbs)))};\n` + + `${registration.imports.join("\n")}`; + let call = `__capnweb_registerValidators({ ${registration.entries.join(", ")} });`; + return { imports, call }; +} + +function emitRegistrationImportsAndCall( + classes: ClassRegistration[], baseDir: string, + sourcePathFor: (sourcePath: string) => string, + localSourcePath?: string): RegistrationEmit { + let imports: string[] = []; + let entries: string[] = []; + let named = new Map(); + let defaults: string[] = []; + for (let klass of classes) { + if (localSourcePath && klass.sourcePath === localSourcePath) { + entries.push(`${JSON.stringify(klass.name)}: ${klass.isDefault ? klass.name : klass.valueName}`); + continue; + } + let importPath = jsImportPath(relative(baseDir, sourcePathFor(klass.sourcePath))); + let alias = `__capnweb_${klass.valueName}`; + entries.push(`${JSON.stringify(klass.name)}: ${alias}`); + if (klass.isDefault) defaults.push(`import ${alias} from ${JSON.stringify(importPath)};`); + else { + let group = named.get(importPath) ?? []; + group.push(`${klass.name} as ${alias}`); + named.set(importPath, group); + } + } + for (let [importPath, group] of named) { + imports.push(`import { ${group.join(", ")} } from ${JSON.stringify(importPath)};`); + } + imports.push(...defaults); + return { imports, entries }; +} + +function makeGenResult(classes: ExtractedClassSpec[]): GenResult { + return { + classes: classes.map(c => c.name), + registrations: classes.map(({ name, valueName, isDefault, sourcePath }) => + ({ name, valueName, isDefault, sourcePath })), + }; +} + + +function jsImportPath(path: string): string { + let normalized = path.split(/[/\\]+/).join("/"); + let ext = extname(normalized); + if (ext) normalized = normalized.slice(0, -ext.length) + ".js"; + if (!normalized.startsWith("./") && !normalized.startsWith("../")) normalized = "./" + normalized; + return normalized; +} + +function log(outAbs: string, name: string): void { + console.log(`Generated ${relative(process.cwd(), join(outAbs, name))}`); +} + +export type { GenOptions, GenResult } from "./types.js"; diff --git a/src/typecheck/rewrite.ts b/src/typecheck/rewrite.ts new file mode 100644 index 0000000..17b3e94 --- /dev/null +++ b/src/typecheck/rewrite.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import * as fs from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import * as ts from "typescript"; +import type { SourceFile } from "./extract.js"; + +export function wrapFunctionName(className: string): string { + return `__capnweb_wrap_${className}`; +} + +export function wrapSessionFunctionName(className: string): string { + return `__capnweb_wrap_RpcSession_${className}`; +} + +const STUB_FACTORY_NAMES = new Set([ + "newHttpBatchRpcSession", + "newWebSocketRpcSession", + "newMessagePortRpcSession", +]); + +const SESSION_CONSTRUCTOR_NAME = "RpcSession"; + +export function emitShadowSources( + sourceFiles: SourceFile[], entry: SourceFile, outAbs: string, commonRoot: string, + classNames: string[]): string { + let shadowRoot = join(outAbs, "source"); + let classSet = new Set(classNames); + let entryShadow = ""; + + for (let file of sourceFiles) { + let originalPath = file.fileName; + let relativePath = relative(commonRoot, originalPath); + let shadowPath = join(shadowRoot, relativePath); + fs.mkdirSync(dirname(shadowPath), { recursive: true }); + + let clientsImport = jsImportPath(relative(dirname(shadowPath), join(outAbs, "clients.ts"))); + let transformed = transformClientCalls(file, classSet, clientsImport); + fs.writeFileSync(shadowPath, transformed); + copyRelativeAssets(file, shadowPath, shadowRoot); + + if (originalPath === entry.fileName) entryShadow = shadowPath; + } + + return entryShadow || entry.fileName; +} + +function copyRelativeAssets(file: SourceFile, shadowPath: string, shadowRoot: string): void { + let copySpecifier = (specifier: string) => { + if (!specifier.startsWith(".")) return; + if (resolveTypeScriptImport(dirname(file.fileName), specifier)) return; + + let assetSpecifier = specifier.split(/[?#]/, 1)[0]; + let sourcePath = resolve(dirname(file.fileName), assetSpecifier); + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) { + throw new Error(`Cannot copy relative asset import ${JSON.stringify(specifier)} ` + + `from ${file.fileName}.`); + } + + let destPath = resolve(dirname(shadowPath), assetSpecifier); + if (!isPathInside(shadowRoot, destPath)) { + throw new Error(`Relative asset import ${JSON.stringify(specifier)} from ` + + `${file.fileName} would escape the generated shadow source tree.`); + } + fs.mkdirSync(dirname(destPath), { recursive: true }); + fs.copyFileSync(sourcePath, destPath); + }; + + for (let statement of file.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + copySpecifier(statement.moduleSpecifier.text); + } else if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier)) { + copySpecifier(statement.moduleSpecifier.text); + } + } +} + +function resolveTypeScriptImport(baseDir: string, specifier: string): string | undefined { + let clean = specifier.split(/[?#]/, 1)[0]; + let base = resolve(baseDir, clean); + let candidates = [base]; + let ext = extname(base); + if (ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") { + candidates.push(base.slice(0, -ext.length) + ".ts"); + candidates.push(base.slice(0, -ext.length) + ".tsx"); + candidates.push(base.slice(0, -ext.length) + ".mts"); + candidates.push(base.slice(0, -ext.length) + ".cts"); + } + for (let candidate of candidates) { + if (isTypeScriptSource(candidate) && fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +function isTypeScriptSource(path: string): boolean { + return /\.(?:ts|tsx|mts|cts)$/.test(path) && !/\.d\.(?:ts|mts|cts)$/.test(path); +} + +function isPathInside(parent: string, child: string): boolean { + let rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(rel)); +} + +export function transformClientCalls( + source: string | SourceFile, knownClasses: Set, + clientsImport: string, fileName = "capnweb-rewrite-input.ts"): string { + let sourceFile = typeof source === "string" + ? ts.createSourceFile(fileName, source, ts.ScriptTarget.ES2023, true, scriptKindFor(fileName)) + : source; + let code = sourceFile.getFullText(); + let imports = collectCapnwebImports(sourceFile); + let rewrites = findClientRewrites(sourceFile, knownClasses, imports); + if (rewrites.length === 0) return code; + + let usedNames = new Set(); + for (let edit of rewrites) usedNames.add(nameForRewrite(edit)); + + rewrites.sort((a, b) => b.start - a.start); + let result = code; + for (let edit of rewrites) { + let original = result.slice(edit.start, edit.end); + result = result.slice(0, edit.start) + `${nameForRewrite(edit)}(${original})` + + result.slice(edit.end); + } + + let importStatement = `import { ${[...usedNames].sort().join(", ")} } from ${JSON.stringify(clientsImport)};\n`; + let insertAt = importInsertionPoint(sourceFile); + let prefix = result.slice(0, insertAt); + if (prefix && !prefix.endsWith("\n")) prefix += "\n"; + return prefix + importStatement + result.slice(insertAt); +} + +function scriptKindFor(fileName: string): ts.ScriptKind { + return fileName.split(/[?#]/, 1)[0].endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; +} + +function importInsertionPoint(sourceFile: SourceFile): number { + let code = sourceFile.getFullText(); + let point = 0; + if (code.startsWith("#!")) { + let newline = code.indexOf("\n"); + point = newline < 0 ? code.length : newline + 1; + } + + for (let statement of sourceFile.statements) { + if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) { + point = statement.end; + } else { + break; + } + } + return point; +} + +function nameForRewrite(edit: ClientRewrite): string { + return edit.kind === "session" ? wrapSessionFunctionName(edit.className) : + wrapFunctionName(edit.className); +} + +type ClientRewrite = { start: number; end: number; className: string; kind: "stub" | "session" }; + +type ImportedBinding = { original: string; alias: string }; + +type ImportedFactories = { + stubAliasToName: Map; + sessionAliasToName: Map; + typeAliasToName: Map; + namespaces: Set; +}; + +function collectCapnwebImports(sourceFile: SourceFile): ImportedFactories { + let stubAliasToName = new Map(); + let sessionAliasToName = new Map(); + let typeAliasToName = new Map(); + let namespaces = new Set(); + + for (let statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || + !statement.importClause) continue; + + let isCapnweb = statement.moduleSpecifier.text === "capnweb"; + let named = statement.importClause.namedBindings; + if (isCapnweb && named && ts.isNamespaceImport(named)) namespaces.add(named.name.text); + if (!named || !ts.isNamedImports(named)) continue; + + for (let spec of named.elements) { + let original = (spec.propertyName ?? spec.name).text; + let alias = spec.name.text; + typeAliasToName.set(alias, { original, alias }); + if (!isCapnweb) continue; + if (STUB_FACTORY_NAMES.has(original)) { + stubAliasToName.set(alias, { original, alias }); + } else if (original === SESSION_CONSTRUCTOR_NAME) { + sessionAliasToName.set(alias, { original, alias }); + } + } + } + + return { stubAliasToName, sessionAliasToName, typeAliasToName, namespaces }; +} + +function findClientRewrites( + sourceFile: SourceFile, knownClasses: Set, imports: ImportedFactories): ClientRewrite[] { + let edits: ClientRewrite[] = []; + let visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && callsKnownStubFactory(node.expression, imports)) { + let rewrite = rewriteForTypedExpression(node, knownClasses, imports, "stub"); + if (rewrite) edits.push(rewrite); + } else if (ts.isNewExpression(node) && callsKnownSessionConstructor(node.expression, imports)) { + let rewrite = rewriteForTypedExpression(node, knownClasses, imports, "session"); + if (rewrite) edits.push(rewrite); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return edits; +} + +function callsKnownStubFactory(expr: ts.Expression, imports: ImportedFactories): boolean { + if (ts.isIdentifier(expr)) { + return imports.stubAliasToName.has(expr.text) && !isShadowed(expr, expr.text); + } + if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) { + return imports.namespaces.has(expr.expression.text) && !isShadowed(expr.expression, expr.expression.text) && + STUB_FACTORY_NAMES.has(expr.name.text); + } + return false; +} + +function callsKnownSessionConstructor(expr: ts.Expression, imports: ImportedFactories): boolean { + if (ts.isIdentifier(expr)) { + return imports.sessionAliasToName.has(expr.text) && !isShadowed(expr, expr.text); + } + if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) { + return imports.namespaces.has(expr.expression.text) && !isShadowed(expr.expression, expr.expression.text) && + expr.name.text === SESSION_CONSTRUCTOR_NAME; + } + return false; +} + +function rewriteForTypedExpression( + node: ts.CallExpression | ts.NewExpression, knownClasses: Set, + imports: ImportedFactories, kind: "stub" | "session"): ClientRewrite | undefined { + let typeArg = node.typeArguments?.[0]; + if (!typeArg || !ts.isTypeReferenceNode(typeArg) || !ts.isIdentifier(typeArg.typeName)) return undefined; + let typeName = typeArg.typeName.text; + let importedType = imports.typeAliasToName.get(typeName); + if (importedType) typeName = importedType.original; + if (!/^[$A-Z_a-z][$\w]*$/.test(typeName) || !knownClasses.has(typeName)) return undefined; + return { start: node.getStart(), end: node.getEnd(), className: typeName, kind }; +} + +function isShadowed(node: ts.Identifier, name: string): boolean { + let current: ts.Node | undefined = node.parent; + while (current) { + if ((ts.isFunctionLike(current) || ts.isSourceFile(current)) && declaresName(current, name, node.pos)) return true; + current = current.parent; + } + return false; +} + +function declaresName(scope: ts.Node, name: string, before: number): boolean { + let found = false; + let visit = (node: ts.Node) => { + if (found || node.pos > before) return; + if (ts.isImportDeclaration(node)) return; + if (ts.isParameter(node) || ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) { + let id = "name" in node && node.name && ts.isIdentifier(node.name) ? node.name : undefined; + if (id?.text === name) found = true; + } + if (node !== scope && (ts.isFunctionLike(node) || ts.isClassLike(node))) return; + ts.forEachChild(node, visit); + }; + ts.forEachChild(scope, visit); + return found; +} + +function jsImportPath(path: string): string { + let normalized = path.split(/[/\\]+/).join("/"); + let ext = extname(normalized); + if (ext) normalized = normalized.slice(0, -ext.length) + ".js"; + if (!normalized.startsWith("./") && !normalized.startsWith("../")) normalized = "./" + normalized; + return normalized; +} diff --git a/src/typecheck/runtime.ts b/src/typecheck/runtime.ts new file mode 100644 index 0000000..2c86215 --- /dev/null +++ b/src/typecheck/runtime.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { + RpcTarget, + type RpcClassValidators, + setRpcMethodValidators, + setRpcStubValidators, +} from "../core.js"; + +export function __capnweb_registerRpcValidators( + classes: Record, validators: Record): void { + for (let [name, classValidators] of Object.entries(validators)) { + let klass = classes[name]; + if (!klass) throw new Error(`Missing class '${name}' for Cap'n Web validator.`); + setRpcMethodValidators(klass, classValidators); + } +} + +export function __capnweb_bindClientValidator( + stub: T, validators: RpcClassValidators): T { + setRpcStubValidators(stub, validators); + return stub; +} + +export { RpcTarget }; +export type { RpcClassValidators, RpcMethodValidator, RpcValidationOptions } from "../core.js"; diff --git a/src/typecheck/types.ts b/src/typecheck/types.ts new file mode 100644 index 0000000..b69dc67 --- /dev/null +++ b/src/typecheck/types.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +export type PrimitiveName = "string" | "number" | "bigint" | "boolean" | "undefined" | "null" | "void"; + +export type TypeSpec = + | { kind: "any" } + | { kind: "primitive", name: PrimitiveName } + | { kind: "literal", value: string | number | boolean } + | { kind: "array", element: TypeSpec } + | { kind: "tuple", elements: TypeSpec[] } + | { kind: "map", key: TypeSpec, value: TypeSpec } + | { kind: "set", value: TypeSpec } + | { kind: "object", props: ObjectProp[], displayName?: string } + | { kind: "record", value: TypeSpec } + | { kind: "union", variants: TypeSpec[] } + | { kind: "instance", name: string } + | { kind: "rpcTarget" } + | { kind: "stub" } + | { kind: "function" } + | { kind: "never" } + | { kind: "unsupported", text: string } + // Reference to a hoisted named validator. Used for recursive types + // (e.g. `type JsonValue = ... | JsonValue[]`) so the validator can be + // emitted once and call itself instead of being inlined infinitely. + | { kind: "ref", id: string }; + +export type ObjectProp = { name: string, optional: boolean, type: TypeSpec }; +export type ParamSpec = { name: string, optional: boolean, type: TypeSpec }; +export type MethodSpec = { name: string, params: ParamSpec[], returns: TypeSpec }; + +export type ClassRegistration = { + name: string; + valueName: string; + isDefault: boolean; + sourcePath: string; +}; + +export type ExtractedClassSpec = ClassRegistration & { methods: MethodSpec[] }; + +export type GenResult = { + classes: string[]; + registrations: ClassRegistration[]; +}; + +export type GenOptions = { + input: string; + outDir: string; + /** @internal Override the import emitted in generated modules. */ + runtimeImport?: string; +}; diff --git a/src/typecheck/vite.ts b/src/typecheck/vite.ts new file mode 100644 index 0000000..e9eee71 --- /dev/null +++ b/src/typecheck/vite.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { existsSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { emitViteRegistration, generateValidatorsOnly, type GenResult } from "./generate.js"; +import { transformClientCalls } from "./rewrite.js"; + +type CapnwebState = { + inputAbs: string; + outDirAbs: string; + validatorsAbs: string; + clientsAbs: string; + knownClasses: Set; + registrations: GenResult["registrations"]; +}; + +export type CapnwebVitePluginOptions = { + /** Worker / RPC entry file to inspect. Defaults to `src/worker.ts`. */ + input?: string; + /** Directory where generated validators are written. Defaults to `.capnweb`. */ + outDir?: string; +}; + +type ViteConfig = { root?: string }; +type ViteServer = { watcher?: { on(event: string, callback: (path: string) => void): void } }; +type ViteTransformResult = string | { code: string; map?: unknown } | null; + +type VitePlugin = { + name: string; + enforce: "pre"; + configResolved(config: ViteConfig): void; + buildStart(): void; + configureServer(server: ViteServer): void; + transform(code: string, id: string): ViteTransformResult; +}; + +export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlugin { + let root = process.cwd(); + let state: CapnwebState = { + inputAbs: "", + outDirAbs: "", + validatorsAbs: "", + clientsAbs: "", + knownClasses: new Set(), + registrations: [], + }; + let generated = false; + + let run = () => { + state.inputAbs = resolve(root, options.input ?? "src/worker.ts"); + state.outDirAbs = resolve(root, options.outDir ?? ".capnweb"); + state.validatorsAbs = resolve(state.outDirAbs, "validators.ts"); + state.clientsAbs = resolve(state.outDirAbs, "clients.ts"); + let result: GenResult = generateValidatorsOnly({ input: state.inputAbs, outDir: state.outDirAbs }); + state.knownClasses = new Set(result.classes); + state.registrations = result.registrations; + generated = true; + }; + + return { + name: "capnweb:typecheck", + enforce: "pre", + configResolved(config) { root = config.root ?? root; }, + buildStart() { run(); }, + configureServer(server) { + if (!generated) run(); + // Re-runs full codegen on TS file changes. This is simple but + // re-creates the TypeScript program each time; acceptable for typical + // project sizes but could be optimized with incremental compilation + // or reachable-file filtering for very large codebases. + let regenerate = (path: string) => { + if (!/\.(?:ts|tsx|mts|cts)$/.test(path) || /\.d\.(?:ts|mts|cts)$/.test(path)) return; + if (path === state.validatorsAbs || path === state.clientsAbs) return; + if (state.outDirAbs && (path === state.outDirAbs || path.startsWith(state.outDirAbs + sep))) return; + generated = false; + run(); + }; + server.watcher?.on("change", regenerate); + server.watcher?.on("add", regenerate); + server.watcher?.on("unlink", regenerate); + }, + transform(code, id) { + if (!id || id.startsWith("\0")) return null; + let cleanId = id.split(/[?#]/, 1)[0]; + if (cleanId.includes("node_modules")) return null; + if (cleanId.includes(`${sep}.capnweb${sep}`) || cleanId.includes("/.capnweb/")) return null; + if (!/\.(?:ts|tsx|mts|cts)$/.test(cleanId)) return null; + if (cleanId.endsWith(".d.ts") || cleanId.endsWith(".d.cts") || cleanId.endsWith(".d.mts")) return null; + if (state.knownClasses.size === 0) return null; + if (!existsSync(state.clientsAbs)) throw new Error("capnweb/vite has not generated clients.ts yet."); + + let clientsImport = jsImportPath(relative(dirname(cleanId), state.clientsAbs)); + let rewritten = transformClientCalls(code, state.knownClasses, clientsImport, cleanId); + if (cleanId === state.inputAbs) { + let registration = emitViteRegistration(state.registrations, state.inputAbs, state.validatorsAbs); + rewritten = registration.imports + `\n` + rewritten + `\n` + registration.call + `\n`; + } + return rewritten === code ? null : { code: rewritten }; + }, + }; +} + +function jsImportPath(path: string): string { + let normalized = path.split(sep).join("/"); + let ext = normalized.match(/\.(ts|tsx|mts|cts)$/); + if (ext) normalized = normalized.slice(0, -ext[0].length) + ".js"; + if (!normalized.startsWith("./") && !normalized.startsWith("../")) normalized = "./" + normalized; + return normalized; +} diff --git a/tsconfig.json b/tsconfig.json index 7676e29..69e2b29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,13 @@ "isolatedModules": true, "esModuleInterop": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "stripInternal": true, + "baseUrl": ".", + "paths": { + "capnweb/_typecheck-validators": ["./src/_typecheck-validators.ts"], + "capnweb/_typecheck-clients": ["./src/_typecheck-clients.ts"] + } }, "include": ["src/**/*.ts", "__tests__/**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/tsup.config.ts b/tsup.config.ts index 240f467..cb00daf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,23 +4,46 @@ import { defineConfig } from 'tsup' -export default defineConfig({ - entry: ['src/index.ts', 'src/index-workers.ts', 'src/index-bun.ts'], - format: ['esm', 'cjs'], - external: ['cloudflare:workers'], +const common = { + format: ['esm', 'cjs'] as const, dts: true, sourcemap: true, - clean: true, - - // ES2023 includes Explicit Resource Management. Note that the library does not actually use - // the `using` keyword, but does use `Symbol.dispose`, and automatically polyfills it if it is - // missing. - target: 'es2023', - - // Works in browsers, Node, and Cloudflare Workers - platform: 'neutral', - + target: 'es2023' as const, splitting: false, treeshake: true, - minify: false, // Keep readable for debugging -}) \ No newline at end of file + minify: false, +} + +export default defineConfig([ + { + ...common, + entry: [ + 'src/index.ts', + 'src/index-workers.ts', + 'src/index-bun.ts', + // `internal-typecheck` is built only for its `.d.ts`; the `package.json` + // exports map points the runtime path of `capnweb/internal/typecheck` at + // `dist/index.js` so the validator runtime is shared with the main bundle. + 'src/internal-typecheck.ts', + // Internal placeholder subpaths overwritten by `capnweb typecheck gen`. + // Kept as separate entries so they emit standalone files the CLI can + // safely rewrite, and so the main bundle imports them as externals. + 'src/_typecheck-validators.ts', + 'src/_typecheck-clients.ts', + ], + external: ['cloudflare:workers', 'capnweb/_typecheck-validators', 'capnweb/_typecheck-clients'], + clean: true, + // Works in browsers, Node, and Cloudflare Workers + platform: 'neutral', + }, + { + ...common, + entry: { + cli: 'src/typecheck/cli.ts', + vite: 'src/typecheck/vite.ts', + }, + external: ['typescript'], + clean: false, + platform: 'node', + }, +]) diff --git a/vitest.config.ts b/vitest.config.ts index 1e43ca4..430d2d0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,14 +2,31 @@ // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit +import { readFileSync } from 'node:fs' import { defineConfig } from 'vitest/config' +// `dist/index-workers.js` (built from src/core.ts) imports +// `capnweb/_typecheck-validators`. Node/wrangler resolves that via the +// package `exports` map; workerd's module loader doesn't run package +// resolution and looks up the bare specifier as a path relative to the +// importer (yielding `dist/capnweb/_typecheck-validators`). Provide the +// resolved path as a virtual module so the workerd test pool can load it. +const typecheckModule = (subpath: string) => ({ + type: 'ESModule' as const, + path: `./dist/capnweb/${subpath}`, + contents: readFileSync(`./dist/${subpath}.js`, 'utf8'), +}) + export default defineConfig({ esbuild: { target: 'es2022', // Transpile using syntax for browser compatibility }, test: { globalSetup: ['__tests__/test-server.ts'], + // typecheck-package and typecheck-types both write into the same + // dist/_typecheck-validators.js (the placeholder subpath). Serialize + // test files so they don't clobber each other's output mid-assert. + fileParallelism: false, projects: [ // Node.js { @@ -17,7 +34,7 @@ export default defineConfig({ name: 'node', // We throw flow-control test under Node only because it's testing straightforward // JavaScript -- no need to run it on every runtime. - include: ['__tests__/index.test.ts', '__tests__/flow-control.test.ts'], + include: ['__tests__/index.test.ts', '__tests__/flow-control.test.ts', '__tests__/typecheck.test.ts', '__tests__/typecheck-package.test.ts', '__tests__/typecheck-types.test.ts', '__tests__/vite-plugin.test.ts'], environment: 'node', }, }, @@ -52,6 +69,8 @@ export default defineConfig({ type: "ESModule", path: "./dist/index-workers.js", }, + typecheckModule('_typecheck-validators'), + typecheckModule('_typecheck-clients'), ], durableObjects: { TEST_DO: "TestDo"