From 7d866acad2fe04f1a1ef91e621f50df8c9a1a493 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Mon, 11 May 2026 14:55:58 -0400 Subject: [PATCH 01/24] Add capnweb-typecheck for TypeScript RPC validation codegen --- .changeset/typescript-rpc-validators.md | 13 + .gitignore | 4 + .npmrc | 1 + __tests__/typecheck.test.ts | 558 +++++++++++++++ __tests__/vite-plugin.test.ts | 116 ++++ package-lock.json | 751 ++++++++++++++++++++- package.json | 21 +- packages/capnweb-typecheck/package.json | 40 ++ packages/capnweb-typecheck/src/cli.ts | 70 ++ packages/capnweb-typecheck/src/extract.ts | 316 +++++++++ packages/capnweb-typecheck/src/generate.ts | 449 ++++++++++++ packages/capnweb-typecheck/src/rewrite.ts | 330 +++++++++ packages/capnweb-typecheck/src/types.ts | 32 + packages/capnweb-typecheck/src/vite.ts | 123 ++++ packages/capnweb-typecheck/tsup.config.ts | 22 + src/core.ts | 157 ++++- src/index.ts | 46 +- src/internal-typecheck.ts | 29 + src/typecheck/runtime.ts | 209 ++++++ src/typecheck/typia-runtime.ts | 69 ++ tsconfig.json | 3 +- tsup.config.ts | 12 +- vitest.config.ts | 2 +- 23 files changed, 3336 insertions(+), 37 deletions(-) create mode 100644 .changeset/typescript-rpc-validators.md create mode 100644 .npmrc create mode 100644 __tests__/typecheck.test.ts create mode 100644 __tests__/vite-plugin.test.ts create mode 100644 packages/capnweb-typecheck/package.json create mode 100644 packages/capnweb-typecheck/src/cli.ts create mode 100644 packages/capnweb-typecheck/src/extract.ts create mode 100644 packages/capnweb-typecheck/src/generate.ts create mode 100644 packages/capnweb-typecheck/src/rewrite.ts create mode 100644 packages/capnweb-typecheck/src/types.ts create mode 100644 packages/capnweb-typecheck/src/vite.ts create mode 100644 packages/capnweb-typecheck/tsup.config.ts create mode 100644 src/internal-typecheck.ts create mode 100644 src/typecheck/runtime.ts create mode 100644 src/typecheck/typia-runtime.ts diff --git a/.changeset/typescript-rpc-validators.md b/.changeset/typescript-rpc-validators.md new file mode 100644 index 0000000..ff72267 --- /dev/null +++ b/.changeset/typescript-rpc-validators.md @@ -0,0 +1,13 @@ +--- +"capnweb": minor +"capnweb-typecheck": minor +--- + +Add build-time TypeScript RPC validation codegen. + +A new opt-in tooling package, `capnweb-typecheck`, generates runtime validators for `RpcTarget` methods from your TypeScript types. The main `capnweb` package stays dependency-free; the tooling dependencies (`ts-morph` and `typia`) live only in `capnweb-typecheck`. + +- `capnweb-typecheck` CLI: `capnweb-typecheck gen src/worker.ts --out .capnweb` for Wrangler-style builds. +- `capnweb-typecheck/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/.npmrc b/.npmrc new file mode 100644 index 0000000..ec9e05d --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=3 diff --git a/__tests__/typecheck.test.ts b/__tests__/typecheck.test.ts new file mode 100644 index 0000000..2092251 --- /dev/null +++ b/__tests__/typecheck.test.ts @@ -0,0 +1,558 @@ +// 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 "../packages/capnweb-typecheck/src/extract.js"; +import { generate } from "../packages/capnweb-typecheck/src/generate.js"; +import { emitShadowSources } from "../packages/capnweb-typecheck/src/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.addSourceFileAtPath(input); + let reachableFiles = collectReachableSourceFiles(sourceFile); + let classes = extractClasses(reachableFiles); + return { sourceFile, reachableFiles, classes }; +} + +function emitShadowFor(input: string, outDir: string): void { + let { sourceFile, reachableFiles, classes } = inspectInput(input); + let root = commonDir(reachableFiles.map(file => file.getFilePath())); + 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 codegen, no Typia. + +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("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("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/], + ])("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.each([ + "ArrayBuffer", "DataView", "Map", "RegExp", "Set", "Uint16Array", + ])("rejects serializer-unsupported native: %s", type => { + let root = mkdtempSync(resolve(".capnweb-unsupported-")); + 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 }); + } + }); +}); + +// ===================================================================== +// Shadow source emission. These exercise `emitShadowSources` directly -- +// no Typia, no `ts.createProgram`. 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 Typia 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 Typia 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 {} + 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 Typia 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/); + }); + }); + + 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("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..63b221a --- /dev/null +++ b/__tests__/vite-plugin.test.ts @@ -0,0 +1,116 @@ +// 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 "../packages/capnweb-typecheck/src/generate.js"; +import { transformClientCalls } from "../packages/capnweb-typecheck/src/rewrite.js"; +import capnweb from "../packages/capnweb-typecheck/src/vite.js"; + +describe("capnweb-typecheck/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("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/package-lock.json b/package-lock.json index 8d0f38c..bbf7780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "capnweb", "version": "0.7.0", "license": "MIT", + "workspaces": [ + "packages/capnweb-typecheck" + ], "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", @@ -1955,7 +1958,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -2817,6 +2819,12 @@ "win32" ] }, + "node_modules/@samchon/openapi": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@samchon/openapi/-/openapi-4.7.2.tgz", + "integrity": "sha512-yj6kGWHtKK85wfJXrSmWqLWjfCd98SAvCTsVDlej2s7OfXXHqA4hmgPRNrAPIQRVi00xn26qL1PChjq1MhzlRQ==", + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -2837,6 +2845,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2871,6 +2885,17 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2917,7 +2942,7 @@ "version": "25.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -3094,11 +3119,25 @@ "node": ">=6" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3141,6 +3180,12 @@ "dequal": "^2.0.3" } }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "license": "MIT" + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -3161,6 +3206,35 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -3181,6 +3255,17 @@ "node": ">=4" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -3188,6 +3273,18 @@ "dev": true, "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3201,6 +3298,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bun-types": { "version": "1.3.11", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz", @@ -3244,6 +3365,17 @@ "dev": true, "license": "MIT" }, + "node_modules/capnweb": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/capnweb/-/capnweb-0.7.0.tgz", + "integrity": "sha512-zO7tt5ch2tImacaR/oMd7e1dqi/fWU7hjZdvQMv6Yo3v9uUGA8cPIUQGvfQTu2c+NgyE/j/oDmMaUlf1PXyfJw==", + "license": "MIT", + "peer": true + }, + "node_modules/capnweb-typecheck": { + "resolved": "packages/capnweb-typecheck", + "link": true + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -3261,11 +3393,53 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "dev": true, "license": "MIT" }, "node_modules/check-error": { @@ -3317,6 +3491,72 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3327,6 +3567,19 @@ "node": ">= 6" } }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -3418,6 +3671,18 @@ "node": ">=6" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -3485,6 +3750,21 @@ "node": ">=10" } }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -3558,11 +3838,19 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -3626,6 +3914,21 @@ "reusify": "^1.0.4" } }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3762,6 +4065,15 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -3776,7 +4088,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -3789,6 +4100,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3799,6 +4130,38 @@ "node": ">= 4" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3809,6 +4172,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3822,6 +4194,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3845,6 +4226,18 @@ "node": ">=4" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -3968,6 +4361,12 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.startcase": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", @@ -3975,6 +4374,22 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4026,6 +4441,15 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/miniflare": { "version": "4.20260205.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260205.0.tgz", @@ -4079,6 +4503,21 @@ } } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -4132,6 +4571,12 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4204,6 +4649,44 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -4277,12 +4760,17 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", - "dev": true, "license": "MIT", "dependencies": { "quansync": "^0.2.7" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4548,7 +5036,6 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "dev": true, "funding": [ { "type": "individual", @@ -4631,6 +5118,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "license": "MIT", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -4678,6 +5178,20 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4712,6 +5226,34 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4768,6 +5310,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4792,11 +5343,39 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -4990,6 +5569,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -5000,11 +5588,24 @@ "node": ">=0.6.19" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5115,6 +5716,12 @@ "node": ">=0.8" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5133,7 +5740,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -5150,7 +5756,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -5168,7 +5773,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5254,13 +5858,21 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsup": { "version": "8.5.1", @@ -5370,11 +5982,22 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5384,6 +6007,36 @@ "node": ">=14.17" } }, + "node_modules/typia": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/typia/-/typia-9.7.2.tgz", + "integrity": "sha512-eLIKd0KHZtSvbsA+FYwX+Y0ZBt0BwVGz3GgODQX+6GfGL4DOzKW02LEx62oUZg6vCQX1BL5xyiPXAIdW+Hc51g==", + "license": "MIT", + "dependencies": { + "@samchon/openapi": "^4.7.1", + "@standard-schema/spec": "^1.0.0", + "commander": "^10.0.0", + "comment-json": "^4.2.3", + "inquirer": "^8.2.5", + "package-manager-detector": "^0.2.0", + "randexp": "^0.5.3" + }, + "bin": { + "typia": "lib/executable/typia.js" + }, + "peerDependencies": { + "typescript": ">=4.8.0 <5.10.0" + } + }, + "node_modules/typia/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -5408,7 +6061,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unenv": { @@ -5448,6 +6101,12 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/validate-npm-package-name": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", @@ -5688,6 +6347,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -6279,6 +6947,35 @@ "@esbuild/win32-x64": "0.27.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6355,6 +7052,24 @@ "engines": { "node": ">=20" } + }, + "packages/capnweb-typecheck": { + "version": "0.7.0", + "license": "MIT", + "dependencies": { + "ts-morph": "^28.0.0", + "typia": "^9.0.0" + }, + "bin": { + "capnweb-typecheck": "dist/cli.js" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "capnweb": "^0.7.0" + } } } } diff --git a/package.json b/package.json index f5a9b6e..d904274 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "files": [ "dist" ], + "workspaces": [ + "packages/capnweb-typecheck" + ], "exports": { ".": { "workerd": { @@ -24,6 +27,21 @@ "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" } }, "type": "module", @@ -31,7 +49,8 @@ "access": "public" }, "scripts": { - "build": "tsup", + "build": "npm run build:runtime && npm run build -w capnweb-typecheck", + "build:runtime": "tsup", "build:watch": "tsup --watch", "test": "vitest run", "test:bun": "bun test __tests__/bun.test.ts", diff --git a/packages/capnweb-typecheck/package.json b/packages/capnweb-typecheck/package.json new file mode 100644 index 0000000..f04668f --- /dev/null +++ b/packages/capnweb-typecheck/package.json @@ -0,0 +1,40 @@ +{ + "name": "capnweb-typecheck", + "version": "0.7.0", + "description": "Build-time TypeScript RPC validation tooling for Cap'n Web", + "type": "module", + "author": "Kenton Varda ", + "license": "MIT", + "files": [ + "dist" + ], + "exports": { + "./vite": { + "types": "./dist/vite.d.ts", + "import": "./dist/vite.js", + "require": "./dist/vite.cjs" + } + }, + "bin": { + "capnweb-typecheck": "./dist/cli.js" + }, + "scripts": { + "build": "tsup --config tsup.config.ts" + }, + "peerDependencies": { + "capnweb": "^0.7.0" + }, + "dependencies": { + "ts-morph": "^28.0.0", + "typia": "^9.0.0" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/capnweb", + "directory": "packages/capnweb-typecheck" + } +} diff --git a/packages/capnweb-typecheck/src/cli.ts b/packages/capnweb-typecheck/src/cli.ts new file mode 100644 index 0000000..5670b7d --- /dev/null +++ b/packages/capnweb-typecheck/src/cli.ts @@ -0,0 +1,70 @@ +#!/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. Intended for builds +// that can point a bundler at a generated entry file -- Wrangler is the +// reference target. It writes validators, client wrappers, a shadow source +// tree, and a worker entry module under the configured output directory. + +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { generate, type GenOptions } from "./generate.js"; + +function usage(exitCode = 1): never { + let out = exitCode === 0 ? console.log : console.error; + out(`Usage: + capnweb-typecheck gen [--out ] + +Example: + capnweb-typecheck gen src/worker.ts --out .capnweb`); + process.exit(exitCode); +} + +async function main() { + let [command, ...rest] = process.argv.slice(2); + if (!command || command === "--help" || command === "-h") usage(0); + if (command !== "gen") usage(); + + let options = parseGenArgs(rest); + generate(options); +} + +function parseGenArgs(args: string[]): GenOptions { + let input: string | undefined; + let outDir = ".capnweb"; + 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/packages/capnweb-typecheck/src/extract.ts b/packages/capnweb-typecheck/src/extract.ts new file mode 100644 index 0000000..850b4fe --- /dev/null +++ b/packages/capnweb-typecheck/src/extract.ts @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Class discovery for the Cap'n Web typecheck codegen. +// +// We use `ts-morph` to find every class transitively extending `RpcTarget` +// across the reachable source graph, plus the names of each method's +// parameters. We deliberately do NOT lower type information here -- that +// job is now Typia's; we just hand it the user's types via synthesized +// `typia.createValidate>()` call sites and let the transformer +// inline the validators. + +import { existsSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; +import { + ClassDeclaration, + MethodDeclaration, + ModuleKind, + ModuleResolutionKind, + Node, + ParameterDeclaration, + Project, + ScriptTarget, + ts, + Type, + type SourceFile, +} from "ts-morph"; + +// Native types Typia would silently accept but the Cap'n Web wire +// serializer can't carry. We reject them at codegen so the user gets a clear +// build-time error instead of a confusing runtime failure. +const SERIALIZER_UNSUPPORTED = new Set([ + "ArrayBuffer", "SharedArrayBuffer", "DataView", "RegExp", + "Map", "ReadonlyMap", "Set", "ReadonlySet", "WeakMap", "WeakSet", + "Uint8ClampedArray", "Uint16Array", "Uint32Array", + "Int8Array", "Int16Array", "Int32Array", + "BigUint64Array", "BigInt64Array", + "Float32Array", "Float64Array", +]); + +// Native/global types whose internal shape we deliberately don't walk during +// preflight. Validators treat these by `instanceof` identity, not by +// structural fields, so descending into `Error.cause: unknown` etc. would +// generate false positives. +const OPAQUE_NATIVES = new Set([ + "Date", "Error", "EvalError", "RangeError", "ReferenceError", "SyntaxError", + "TypeError", "URIError", "AggregateError", + "ReadableStream", "WritableStream", "Request", "Response", "Headers", + "Blob", "Uint8Array", + // Cap'n Web identity types are reference values; their declared interface + // is a proxy facade and shouldn't be walked structurally. + "RpcStub", "RpcPromise", "RpcTarget", +]); + +export type RpcMethodInfo = { + /** Method name on the `RpcTarget` subclass. */ + name: string; + /** Parameter names, in declaration order. Used to label argument-validation errors. */ + paramNames: readonly string[]; + /** Whether each parameter is optional (declared with `?` or with a default value). */ + paramOptional: readonly boolean[]; +}; + +export type RpcClassInfo = { + /** Name visible to generated code. `default` for anonymous default exports. */ + name: string; + /** Identifier we import the class as in generated modules. */ + valueName: string; + /** Whether the class was the default export of its source file. */ + isDefault: boolean; + /** Absolute path of the source file the class is declared in. */ + sourcePath: string; + methods: readonly RpcMethodInfo[]; +}; + +export function createProject(inputAbs: string): Project { + return new Project({ + tsConfigFilePath: findTsConfig(inputAbs), + skipAddingFilesFromTsConfig: true, + compilerOptions: { + target: ScriptTarget.ES2023, + module: ModuleKind.ESNext, + moduleResolution: ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + allowJs: true, + noEmit: true, + }, + }); +} + +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; + } +} + +export function extractClasses(sourceFiles: SourceFile[]): RpcClassInfo[] { + let candidates = sourceFiles.flatMap(sourceFile => sourceFile.getClasses()); + let rpcClasses = candidates.filter(isRpcTargetExtender); + return rpcClasses.map((klass, index) => extractClass(klass, index)); +} + +function isRpcTargetExtender(klass: ClassDeclaration): boolean { + let visited = new Set(); + let current: ClassDeclaration | undefined = klass; + + while (current && !visited.has(current)) { + visited.add(current); + let extendsExpr = current.getExtends(); + if (!extendsExpr) return false; + + let name = extendsExpr.getExpression().getText(); + if (name === "RpcTarget") return true; + + let symbol = extendsExpr.getExpression().getSymbol(); + if (symbol?.getAliasedSymbol()?.getName() === "RpcTarget") return true; + if (symbol?.getName() === "RpcTarget") return true; + + current = extendsExpr.getExpression().getType().getSymbol() + ?.getDeclarations() + .find(Node.isClassDeclaration); + } + + return false; +} + +function extractClass(klass: ClassDeclaration, index: number): RpcClassInfo { + let name = klass.getName(); + let isDefault = klass.isDefaultExport(); + 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 (!klass.isExported()) { + throw new Error(`${name}: RpcTarget classes must be exported so generated validators ` + + `can import and register their constructors.`); + } + + return { + name, + valueName: isDefault ? `__capnweb_default_${index}` : name, + isDefault, + sourcePath: klass.getSourceFile().getFilePath(), + methods: extractMethods(klass, name), + }; +} + +function extractMethods(klass: ClassDeclaration, className: string): RpcMethodInfo[] { + let methods: RpcMethodInfo[] = []; + let seen = new Set(); + for (let method of klass.getInstanceMethods()) { + if (method.hasModifier(ts.SyntaxKind.PrivateKeyword) || + method.hasModifier(ts.SyntaxKind.ProtectedKeyword)) continue; + + let methodName = method.getName(); + if (seen.has(methodName)) { + throw new Error(`${className}.${methodName}: overloaded RPC methods are not supported.`); + } + seen.add(methodName); + + let paramNames: string[] = []; + let paramOptional: boolean[] = []; + for (let param of method.getParameters()) { + if (param.isRestParameter()) { + throw new Error(`${className}.${methodName}: rest parameters are not supported.`); + } + preflightCheckType(param.getType(), + `${className}.${methodName} parameter '${param.getName()}'`, param, new Set()); + paramNames.push(param.getName()); + paramOptional.push(param.isOptional() || param.hasInitializer()); + } + preflightCheckType(method.getReturnType(), + `${className}.${methodName} return`, method, new Set()); + methods.push({ name: methodName, paramNames, paramOptional }); + } + return methods; +} + +// Quick gate that throws on types Typia can't or won't catch for us at its +// own transform time: bare `any`/`unknown`, `symbol`, and native types whose +// wire format the Cap'n Web serializer doesn't support. We deliberately do +// NOT walk into structurally rich types -- we only check leaf type aliases, +// classes, and obvious wrappers (arrays, tuples, unions, promises, simple +// object props). Recursive types and complex mapped/conditional types are +// left for Typia. +function preflightCheckType( + type: Type, location: string, + node: ParameterDeclaration | MethodDeclaration, visiting: Set): void { + let id = type.compilerType; + if (visiting.has(id)) return; // already walked; assume Typia handles + visiting.add(id); + try { + if (type.isAny()) { + throw new Error(`${location}: type 'any' cannot be validated; specify a concrete type.`); + } + if (type.isUnknown()) { + throw new Error(`${location}: type 'unknown' cannot be validated; specify a concrete type or narrow it.`); + } + if (type.getText() === "symbol" || type.getText() === "unique symbol" || + (type.compilerType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { + throw new Error(`${location}: Unsupported RPC type: symbol`); + } + + if (type.isUnion()) { + for (let variant of type.getUnionTypes()) { + preflightCheckType(variant, location, node, visiting); + } + return; + } + if (type.isIntersection()) { + for (let part of type.getIntersectionTypes()) { + preflightCheckType(part, location, node, visiting); + } + return; + } + if (type.isArray()) { + let element = type.getArrayElementType(); + if (element) preflightCheckType(element, location, node, visiting); + return; + } + if (type.isTuple()) { + for (let element of type.getTupleElements()) { + preflightCheckType(element, location, node, visiting); + } + return; + } + + let symbol = type.getAliasSymbol() ?? type.getSymbol(); + let symbolName = symbol?.getName(); + let typeArgs = type.getTypeArguments(); + + if (symbolName && SERIALIZER_UNSUPPORTED.has(symbolName)) { + throw new Error(`${location}: Unsupported RPC type: ${type.getText()}`); + } + if (symbolName && OPAQUE_NATIVES.has(symbolName)) { + // Validated by `instanceof` -- we deliberately don't walk fields. + return; + } + if (symbolName === "Promise" || symbolName === "PromiseLike") { + if (typeArgs[0]) preflightCheckType(typeArgs[0], location, node, visiting); + return; + } + if (symbolName === "Array" || symbolName === "ReadonlyArray") { + if (typeArgs[0]) preflightCheckType(typeArgs[0], location, node, visiting); + return; + } + + // Walk property types. Limited to types that look like plain object + // literals / interfaces -- types with a known native or class identity + // are handled by their `instanceof` check at runtime. + for (let prop of type.getProperties()) { + let propDecl = prop.getValueDeclaration() ?? prop.getDeclarations()[0]; + if (!propDecl) continue; + let propType = prop.getTypeAtLocation(propDecl); + preflightCheckType(propType, `${location}: property '${prop.getName()}'`, + node, visiting); + } + } finally { + visiting.delete(id); + } +} + +export function collectReachableSourceFiles(entry: SourceFile): SourceFile[] { + let result: SourceFile[] = []; + let seen = new Set(); + + let visit = (file: SourceFile) => { + let filePath = file.getFilePath(); + 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 = (target: SourceFile | undefined) => { + if (target && isTypeScriptSource(target.getFilePath())) visit(target); + }; + + for (let importDecl of file.getImportDeclarations()) { + visitModule(importDecl.getModuleSpecifierSourceFile()); + } + for (let exportDecl of file.getExportDeclarations()) { + if (exportDecl.getModuleSpecifierValue() !== undefined) { + visitModule(exportDecl.getModuleSpecifierSourceFile()); + } + } + }; + + visit(entry); + return result; +} + +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/packages/capnweb-typecheck/src/generate.ts b/packages/capnweb-typecheck/src/generate.ts new file mode 100644 index 0000000..1caaff6 --- /dev/null +++ b/packages/capnweb-typecheck/src/generate.ts @@ -0,0 +1,449 @@ +// 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 generator that produces Cap'n Web validation artifacts in a +// user-specified output directory. The hard work (turning each +// `RpcTarget`-method's TypeScript signature into a runtime validator) is +// delegated to Typia: we synthesize a `specs.ts` module containing +// `typia.createValidate()` call sites for each parameter / return type, +// run Typia's transformer programmatically, and rewrite the resulting JS so +// the user's app pulls in the validator helpers through +// `capnweb/internal/typecheck` rather than `typia/lib/internal/*`. + +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import * as ts from "typescript"; +import { transform as typiaTransform } from "typia/lib/transform.js"; +import { + collectReachableSourceFiles, + commonDir, + createProject, + extractClasses, + findTsConfig, + type RpcClassInfo, +} from "./extract.js"; +import { emitShadowSources, wrapFunctionName, wrapSessionFunctionName } from "./rewrite.js"; +import type { + ClassRegistration, + GenOptions, + GenResult, +} from "./types.js"; + +/** + * CLI / Wrangler path. Emits a shadow source tree alongside the validator + * artifacts and a worker entry point that re-exports the user's module after + * registering server-side validators. + */ +export function generate(options: GenOptions): GenResult { + let prepared = prepare(options); + let { classes, outAbs, sourceFile, reachableFiles, root, runtimeImport } = prepared; + + let entryAbs = emitShadowSources(reachableFiles, sourceFile, outAbs, root, + classes.map(c => c.name)); + let importPath = jsImportPath(relative(outAbs, entryAbs)); + let hasDefaultExport = sourceFile.getDefaultExportSymbol() !== undefined; + + emitArtifacts(prepared); + + writeFileSync(join(outAbs, "worker.entry.ts"), + emitWorkerEntry(importPath, hasDefaultExport, classes, outAbs, root)); + log(outAbs, "worker.entry.ts"); + + return makeGenResult(classes); +} + +/** + * Vite path. Same artifacts as CLI mode minus the shadow source tree and + * worker-entry module: the Vite plugin handles client rewrites in memory and + * injects the server validator registration into the user's worker module + * itself. + */ +export function generateValidatorsOnly(options: GenOptions): GenResult { + let prepared = prepare(options); + emitArtifacts(prepared); + return makeGenResult(prepared.classes); +} + +type Prepared = { + classes: RpcClassInfo[]; + outAbs: string; + runtimeImport: string; + sourceFile: ReturnType["addSourceFileAtPath"]>; + 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.addSourceFileAtPath(inputAbs); + let reachableFiles = collectReachableSourceFiles(sourceFile); + let root = commonDir(reachableFiles.map(file => file.getFilePath())); + let classes = extractClasses(reachableFiles); + if (classes.length === 0) { + throw new Error(`No classes extending RpcTarget found in ${options.input}`); + } + + checkNameCollisions(classes); + mkdirSync(outAbs, { recursive: true }); + + return { classes, outAbs, runtimeImport, sourceFile, reachableFiles, root }; +} + +function checkNameCollisions(classes: RpcClassInfo[]): void { + let seenNames = new Map(); + for (let klass of classes) { + let prior = seenNames.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 (e.g., name the default-exported class) ` + + `so the generated validator map has unique keys.`); + } + seenNames.set(klass.name, klass); + } + +} + +function emitArtifacts(p: Prepared): void { + emitSpecs(p); + writeFileSync(join(p.outAbs, "validators.ts"), emitValidatorsWrapper(p.runtimeImport)); + log(p.outAbs, "validators.ts"); + writeFileSync(join(p.outAbs, "clients.ts"), emitClientsWrapper(p.classes, p.runtimeImport)); + log(p.outAbs, "clients.ts"); +} + +// ===================================================================== +// specs.ts -> specs.js: synthesize typia.createValidate calls, run Typia's +// transformer programmatically, rewrite the resulting JS imports to point at +// our vendored runtime shim. The compiled specs.js is what downstream code +// (validators.ts, clients.ts) imports at runtime. + +function emitSpecs(p: Prepared): void { + let specsSrc = synthesizeSpecsSource(p); + let specsAbs = join(p.outAbs, "specs.ts"); + writeFileSync(specsAbs, specsSrc); + + let compilerOptions = readCompilerOptions(p.sourceFile.getFilePath()); + let program = ts.createProgram({ + rootNames: [specsAbs], + options: { + ...compilerOptions, + target: compilerOptions.target ?? ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, + strict: true, + esModuleInterop: true, + skipLibCheck: true, + allowImportingTsExtensions: false, + noEmit: false, + // We capture emitted files via a writeFile callback and write them to + // their final location ourselves, so we don't pass outDir here -- TS + // would compute rootDir from the source graph (which includes user + // files outside .capnweb/) and produce nested output paths. + rootDir: undefined, + outDir: undefined, + declaration: false, + emitDeclarationOnly: false, + }, + }); + + // Typia's transformer needs `extras.addDiagnostic` to report type-level + // problems (e.g. attempting to validate an `any` type). We collect those + // alongside the standard TypeScript emit diagnostics. + let typiaDiags: ts.Diagnostic[] = []; + let extras = { + addDiagnostic: (d: ts.Diagnostic) => typiaDiags.push(d), + }; + let transformer = typiaTransform(program, undefined, extras); + let emittedFiles: { name: string; text: string }[] = []; + let specsFile = program.getSourceFile(specsAbs); + if (!specsFile) { + throw new Error("capnweb-typecheck: expected generated specs.ts to be part of the TypeScript program."); + } + let emit = program.emit( + specsFile, + (fileName, text) => emittedFiles.push({ name: fileName, text }), + undefined, + false, + { before: [transformer] }, + ); + + let allDiags = [ + ...program.getOptionsDiagnostics(), + ...program.getGlobalDiagnostics(), + ...program.getSyntacticDiagnostics(specsFile), + ...program.getSemanticDiagnostics(specsFile), + ...emit.diagnostics, + ...typiaDiags, + ]; + let blockingDiags = allDiags.filter(d => d.category === ts.DiagnosticCategory.Error); + if (blockingDiags.length > 0) { + let msgs = blockingDiags.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")); + throw new Error(`capnweb-typecheck: TypeScript reported errors while ` + + `generating validators:\n ${msgs.join("\n ")}`); + } + + // The emit callback receives whatever path TS picked for each output. We + // only care about the .js for our synthesized specs file; rewrite its + // typia imports and place it at `/specs.js`. + let specsJsName = "specs.js"; + let written = false; + for (let { name, text } of emittedFiles) { + if (!name.endsWith(".js")) continue; + if (!name.endsWith(specsJsName)) continue; + text = rewriteTypiaImports(text, p.runtimeImport); + writeFileSync(join(p.outAbs, specsJsName), text); + written = true; + break; + } + if (!written) { + throw new Error("capnweb-typecheck: expected specs.js to be emitted but it wasn't. " + + `Emitted files: ${emittedFiles.map(f => f.name).join(", ")}`); + } + + // The synthesized specs.ts is only useful to feed the transformer; remove + // it so downstream tooling doesn't accidentally pull in the un-transformed + // (`typia.createValidate` call site) source. + try { unlinkSync(specsAbs); } catch {} + log(p.outAbs, "specs.js"); +} + +function synthesizeSpecsSource(p: Prepared): string { + let lines: string[] = [ + `// Generated by capnweb-typecheck gen. Do not edit.`, + `// This file is consumed by Typia's transformer at build time; the`, + `// emitted JS contains inline runtime validators.`, + `import typia from "typia";`, + ]; + + let importPaths = new Map>(); + let defaultImports: { alias: string; path: string }[] = []; + for (let klass of p.classes) { + let importPath = jsImportPath(relative(p.outAbs, klass.sourcePath)); + let typeName = klass.name; + if (klass.isDefault) { + defaultImports.push({ alias: klass.valueName, path: importPath }); + } else { + let set = importPaths.get(importPath); + if (!set) { + set = new Set(); + importPaths.set(importPath, set); + } + set.add(typeName); + } + } + for (let [path, names] of importPaths) { + lines.push(`import type { ${[...names].join(", ")} } from ${JSON.stringify(path)};`); + } + for (let { alias, path } of defaultImports) { + lines.push(`import type ${alias} from ${JSON.stringify(path)};`); + } + lines.push(``); + + // Synthesize one typia.createValidate per parameter and per return. + p.classes.forEach((klass, classIndex) => { + let typeRef = klass.isDefault ? klass.valueName : klass.name; + klass.methods.forEach((method, methodIndex) => { + let methodKey = JSON.stringify(method.name); + let validatorPrefix = `_v_${classIndex}_${methodIndex}`; + let argsTuple = `Parameters<${typeRef}[${methodKey}]>`; + let returnT = `Awaited>`; + method.paramNames.forEach((_, i) => { + lines.push(`const ${validatorPrefix}_arg${i} = ` + + `typia.createValidate<${argsTuple}[${i}]>();`); + }); + lines.push(`const ${validatorPrefix}_return = ` + + `typia.createValidate<${returnT}>();`); + }); + }); + + lines.push(``, `export const validators = {`); + p.classes.forEach((klass, classIndex) => { + lines.push(` ${JSON.stringify(klass.name)}: {`); + klass.methods.forEach((method, methodIndex) => { + let validatorPrefix = `_v_${classIndex}_${methodIndex}`; + lines.push(` ${JSON.stringify(method.name)}: {`); + lines.push(` paramNames: ${JSON.stringify(method.paramNames)},`); + lines.push(` paramOptional: ${JSON.stringify(method.paramOptional)},`); + let argRefs = method.paramNames.map((_, i) => + `${validatorPrefix}_arg${i}`).join(", "); + lines.push(` paramValidators: [${argRefs}],`); + lines.push(` returns: ${validatorPrefix}_return,`); + lines.push(` },`); + }); + lines.push(` },`); + }); + lines.push(`};`); + + return lines.join("\n") + "\n"; +} + +// Typia emits imports like: +// import * as __typia_transform__validateReport from "typia/lib/internal/_validateReport"; +// import * as __typia_transform__createStandardSchema from "typia/lib/internal/_createStandardSchema"; +// import typia from "typia"; +// We rewrite the first two to import from `capnweb/internal/typecheck` (where +// we re-export the vendored helpers), and drop the third (Typia replaces all +// call sites, so `typia` itself is dead-imported). +function rewriteTypiaImports(src: string, runtimeImport: string): string { + src = src.replace( + /import \* as (\S+) from "typia\/lib\/internal\/_(?:validateReport|createStandardSchema)(?:\.js)?";\n/g, + `import * as $1 from ${JSON.stringify(runtimeImport)};\n`); + src = src.replace(/^import typia from "typia";\n/m, ""); + return src; +} + +function readCompilerOptions(inputAbs: string): ts.CompilerOptions { + let tsconfig = findTsConfig(inputAbs); + if (!tsconfig) return {}; + let config = ts.readConfigFile(tsconfig, ts.sys.readFile); + if (config.error) { + throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + } + return ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(tsconfig)).options; +} + +// ===================================================================== +// Thin TS wrappers around specs.js. These are the modules user-side code +// imports from `.capnweb/`. + +function emitValidatorsWrapper(runtimeImport: string): string { + return `// Generated by capnweb-typecheck gen. Do not edit. +import { __capnweb_registerRpcValidators } from ${JSON.stringify(runtimeImport)}; +// @ts-ignore - specs.js is generated by Typia and ships without .d.ts +import { validators } from "./specs.js"; + +export function registerCapnwebValidators(classes: Record): void { + __capnweb_registerRpcValidators(classes, validators as Record>); +} +`; +} + +function emitClientsWrapper(classes: RpcClassInfo[], runtimeImport: string): string { + let lines: string[] = [ + `// Generated by capnweb-typecheck gen. Do not edit.`, + `import { __capnweb_bindClientValidator } from ${JSON.stringify(runtimeImport)};`, + `// @ts-ignore - specs.js is generated by Typia and ships without .d.ts`, + `import { validators } from "./specs.js";`, + ``, + ]; + for (let klass of classes) { + let stub = wrapFunctionName(klass.name); + let session = wrapSessionFunctionName(klass.name); + let key = JSON.stringify(klass.name); + lines.push(`export function ${stub}(stub: T): T {`); + lines.push(` return __capnweb_bindClientValidator(stub as object, ${key}, ` + + `(validators as any)[${key}]) as T;`); + lines.push(`}`); + lines.push(``); + lines.push(`export function ${session}(session: T): T {`); + lines.push(` let main = (session as { getRemoteMain(): object }).getRemoteMain();`); + lines.push(` __capnweb_bindClientValidator(main, ${key}, (validators as any)[${key}]);`); + lines.push(` return session;`); + lines.push(`}`); + lines.push(``); + } + return lines.join("\n"); +} + +function emitWorkerEntry( + importPath: string, hasDefault: boolean, classes: RpcClassInfo[], + 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. +import { registerCapnwebValidators } from "./validators.js"; +${registration.imports.join("\n")} +registerCapnwebValidators({ ${registration.entries.join(", ")} }); +export * from ${JSON.stringify(importPath)}; +${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 namedImports = new Map(); + let defaultImports: string[] = []; + + for (let klass of classes) { + if (localSourcePath && klass.sourcePath === localSourcePath) { + if (klass.isDefault && klass.name === "default") { + throw new Error("capnweb-typecheck/vite cannot auto-register an anonymous default-exported " + + "RpcTarget class. Give the class a name."); + } + let localValueName = klass.isDefault ? klass.name : klass.valueName; + entries.push(`${JSON.stringify(klass.name)}: ${localValueName}`); + continue; + } + + let sourcePath = sourcePathFor(klass.sourcePath); + let importPath = jsImportPath(relative(baseDir, sourcePath)); + let alias = `__capnweb_${klass.valueName}`; + entries.push(`${JSON.stringify(klass.name)}: ${alias}`); + if (klass.isDefault) { + defaultImports.push(`import ${alias} from ${JSON.stringify(importPath)};`); + } else { + let group = namedImports.get(importPath); + if (!group) { + group = []; + namedImports.set(importPath, group); + } + group.push(`${klass.name} as ${alias}`); + } + } + + for (let [importPath, group] of namedImports) { + imports.push(`import { ${group.join(", ")} } from ${JSON.stringify(importPath)};`); + } + imports.push(...defaultImports); + return { imports, entries }; +} + +function makeGenResult(classes: RpcClassInfo[]): 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/packages/capnweb-typecheck/src/rewrite.ts b/packages/capnweb-typecheck/src/rewrite.ts new file mode 100644 index 0000000..091fddb --- /dev/null +++ b/packages/capnweb-typecheck/src/rewrite.ts @@ -0,0 +1,330 @@ +// 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 { + CallExpression, + NewExpression, + Node, + Project, + ScriptTarget, + ts, + type SourceFile, +} from "ts-morph"; +/** + * Names of the wrap-factory functions the rewriter calls. Each is exported + * from the generated `clients.ts`; the rewriter chooses one per typed factory + * call in user source. Kept in sync with the matching emit in `generate.ts`. + * + * Two flavors: + * - `__capnweb_wrap_` accepts an `RpcStub` and binds return + * validators directly on it. Used for the three stub-returning helpers. + * - `__capnweb_wrap_RpcSession_` accepts an `RpcSession`, + * binds validators on `session.getRemoteMain()`, and returns the session + * unchanged so the caller still has `getRemoteMain`, `drain`, etc. + */ +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.getFilePath(); + 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.getFilePath()) { + entryShadow = shadowPath; + } + } + + return entryShadow || entry.getFilePath(); +} + +function copyRelativeAssets(file: SourceFile, shadowPath: string, shadowRoot: string): void { + let copySpecifier = (specifier: string, target: SourceFile | undefined) => { + if (!specifier.startsWith(".")) return; + if (target && isTypeScriptSource(target.getFilePath())) return; + + let assetSpecifier = specifier.split(/[?#]/, 1)[0]; + let sourcePath = target?.getFilePath() ?? resolve(dirname(file.getFilePath()), assetSpecifier); + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) { + throw new Error(`Cannot copy relative asset import ${JSON.stringify(specifier)} ` + + `from ${file.getFilePath()}.`); + } + + let destPath = resolve(dirname(shadowPath), assetSpecifier); + if (!isPathInside(shadowRoot, destPath)) { + throw new Error(`Relative asset import ${JSON.stringify(specifier)} from ` + + `${file.getFilePath()} would escape the generated shadow source tree.`); + } + fs.mkdirSync(dirname(destPath), { recursive: true }); + fs.copyFileSync(sourcePath, destPath); + }; + + for (let importDecl of file.getImportDeclarations()) { + copySpecifier(importDecl.getModuleSpecifierValue(), importDecl.getModuleSpecifierSourceFile()); + } + for (let exportDecl of file.getExportDeclarations()) { + let specifier = exportDecl.getModuleSpecifierValue(); + if (specifier !== undefined) copySpecifier(specifier, exportDecl.getModuleSpecifierSourceFile()); + } +} + +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)); +} + +/** + * Rewrite typed RPC session creations so the returned stub or session gets + * its return-value validators bound to it. + * + * Two shapes are recognized: + * - `newHttpBatchRpcSession(...)` / `newWebSocketRpcSession(...)` / + * `newMessagePortRpcSession(...)` -- the call returns an `RpcStub`; + * wrap with `__capnweb_wrap_Api(...)`, which binds validators on it. + * - `new RpcSession(transport, ...)` -- the constructor returns an + * `RpcSession` whose `getRemoteMain()` is the stub we care about; + * wrap with `__capnweb_wrap_RpcSession_Api(...)`, which calls + * `session.getRemoteMain()` once, binds validators on that stub, and + * returns the session unchanged so the caller still has `getRemoteMain`, + * `getStats`, `drain`, etc. + * + * Calls without a type argument, or with a type argument that isn't a known + * RpcTarget class, are left alone. The TypeScript caller "opted out" of + * typing, so we opt out of runtime validation in the same place. + */ +export function transformClientCalls( + source: string | SourceFile, knownClasses: Set, + clientsImport: string, fileName = "capnweb-rewrite-input.ts"): string { + let sourceFile = typeof source === "string" ? parseSource(source, fileName) : source; + let code = sourceFile.getFullText(); + let imports = collectCapnwebImports(sourceFile); + let rewrites = findClientRewrites(sourceFile, knownClasses, imports); + if (rewrites.length === 0) return code; + + // Collect the exact set of wrap names actually used so we only import what + // the file references. Separate stub-shape and session-shape so option-A + // dispatch stays statically typed. + 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 importsList = [...usedNames].sort().join(", "); + return `import { ${importsList} } from ${JSON.stringify(clientsImport)};\n` + result; +} + +function nameForRewrite(edit: ClientRewrite): string { + return edit.kind === "session" + ? wrapSessionFunctionName(edit.className) + : wrapFunctionName(edit.className); +} + +function parseSource(code: string, fileName: string): SourceFile { + let project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { target: ScriptTarget.ES2023, jsx: ts.JsxEmit.ReactJSX }, + }); + return project.createSourceFile(fileName, code); +} + +type ClientRewrite = { + start: number; + end: number; + className: string; + // "stub": typed call to a stub-returning helper, wrap is bound directly on + // the returned RpcStub. + // "session": typed `new RpcSession(...)`, wrap walks the session to its + // cached `getRemoteMain()` stub and binds there. + kind: "stub" | "session"; +}; + +type ImportedBinding = { original: string, binding: Node }; + +type ImportedFactories = { + // Alias -> original capnweb stub-returning helper name (e.g. "connect" -> "newHttpBatchRpcSession"). + stubAliasToName: Map; + // Alias -> "RpcSession" if the user imported RpcSession (possibly renamed). + sessionAliasToName: Map; + // Any imported name -> its original name. Used to resolve `import type { Api as RemoteApi }`. + typeAliasToName: Map; + // Identifiers bound via `import * as ns from "capnweb"`. + namespaces: Map; +}; + +function collectCapnwebImports(sourceFile: SourceFile): ImportedFactories { + let stubAliasToName = new Map(); + let sessionAliasToName = new Map(); + let typeAliasToName = new Map(); + let namespaces = new Map(); + + for (let importDecl of sourceFile.getImportDeclarations()) { + let isCapnweb = importDecl.getModuleSpecifierValue() === "capnweb"; + + if (isCapnweb) { + let namespaceImport = importDecl.getNamespaceImport(); + if (namespaceImport) namespaces.set(namespaceImport.getText(), namespaceImport); + } + + for (let spec of importDecl.getNamedImports()) { + let original = spec.getName(); + let alias = spec.getAliasNode()?.getText() ?? original; + let binding = { original, binding: spec }; + typeAliasToName.set(alias, binding); + if (!isCapnweb) continue; + if (STUB_FACTORY_NAMES.has(original)) { + stubAliasToName.set(alias, binding); + } else if (original === SESSION_CONSTRUCTOR_NAME) { + sessionAliasToName.set(alias, binding); + } + } + } + + return { stubAliasToName, sessionAliasToName, typeAliasToName, namespaces }; +} + +function findClientRewrites( + sourceFile: SourceFile, knownClasses: Set, imports: ImportedFactories): ClientRewrite[] { + let edits: ClientRewrite[] = []; + + for (let node of sourceFile.getDescendants()) { + if (Node.isCallExpression(node)) { + let rewrite = rewriteForCall(node, knownClasses, imports); + if (rewrite) edits.push(rewrite); + } else if (Node.isNewExpression(node)) { + let rewrite = rewriteForNew(node, knownClasses, imports); + if (rewrite) edits.push(rewrite); + } + } + + return edits; +} + +function rewriteForCall( + node: CallExpression, knownClasses: Set, imports: ImportedFactories): ClientRewrite | undefined { + if (!callsKnownStubFactory(node.getExpression(), imports)) return undefined; + return rewriteForTypedExpression(node, knownClasses, imports, "stub"); +} + +function rewriteForNew( + node: NewExpression, knownClasses: Set, imports: ImportedFactories): ClientRewrite | undefined { + if (!callsKnownSessionConstructor(node.getExpression(), imports)) return undefined; + return rewriteForTypedExpression(node, knownClasses, imports, "session"); +} + +function callsKnownStubFactory(expr: Node, imports: ImportedFactories): boolean { + if (Node.isIdentifier(expr)) { + let imported = imports.stubAliasToName.get(expr.getText()); + return imported !== undefined && symbolMatches(expr, imported.binding); + } + if (Node.isPropertyAccessExpression(expr)) { + let namespaceExpr = expr.getExpression(); + if (!Node.isIdentifier(namespaceExpr)) return false; + let namespace = namespaceExpr.getText(); + let name = expr.getName(); + let imported = imports.namespaces.get(namespace); + return imported !== undefined && symbolMatches(namespaceExpr, imported) && + STUB_FACTORY_NAMES.has(name); + } + return false; +} + +function callsKnownSessionConstructor(expr: Node, imports: ImportedFactories): boolean { + if (Node.isIdentifier(expr)) { + let imported = imports.sessionAliasToName.get(expr.getText()); + return imported !== undefined && symbolMatches(expr, imported.binding); + } + if (Node.isPropertyAccessExpression(expr)) { + let namespaceExpr = expr.getExpression(); + if (!Node.isIdentifier(namespaceExpr)) return false; + let namespace = namespaceExpr.getText(); + let name = expr.getName(); + let imported = imports.namespaces.get(namespace); + return imported !== undefined && symbolMatches(namespaceExpr, imported) && + name === SESSION_CONSTRUCTOR_NAME; + } + return false; +} + +function rewriteForTypedExpression( + node: CallExpression | NewExpression, knownClasses: Set, + imports: ImportedFactories, kind: "stub" | "session"): ClientRewrite | undefined { + let typeArg = node.getTypeArguments()[0]; + if (!typeArg || !Node.isTypeReference(typeArg)) return undefined; + + let typeNameNode = typeArg.getTypeName(); + let typeName = typeNameNode.getText(); + let importedType = imports.typeAliasToName.get(typeName); + if (importedType && Node.isIdentifier(typeNameNode) && symbolMatches(typeNameNode, importedType.binding)) { + typeName = importedType.original; + } + if (!/^[$A-Z_a-z][$\w]*$/.test(typeName)) return undefined; + if (!knownClasses.has(typeName)) return undefined; + + return { + start: node.getStart(), + end: node.getEnd(), + className: typeName, + kind, + }; +} + +function symbolMatches(use: Node, declaration: Node): boolean { + let symbol = use.getSymbol(); + return symbol?.getDeclarations().some(candidate => + candidate.getSourceFile().getFilePath() === declaration.getSourceFile().getFilePath() && + rangesOverlap(candidate, declaration)) ?? false; +} + +function rangesOverlap(a: Node, b: Node): boolean { + return a.getStart() <= b.getEnd() && b.getStart() <= a.getEnd(); +} + +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/packages/capnweb-typecheck/src/types.ts b/packages/capnweb-typecheck/src/types.ts new file mode 100644 index 0000000..9a3ee9c --- /dev/null +++ b/packages/capnweb-typecheck/src/types.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Tooling-only option / result shapes. The validator data types and helpers +// are produced by Typia at build time and registered through +// `capnweb/internal/typecheck`; this file only describes the API surface of +// the generator itself. + +export type ClassRegistration = { + name: string; + valueName: string; + isDefault: boolean; + sourcePath: string; +}; + +export type GenResult = { + classes: string[]; + registrations: ClassRegistration[]; +}; + +export type GenOptions = { + input: string; + outDir: string; + /** + * @internal Override the package import emitted in generated modules. + * Defaults to `"capnweb/internal/typecheck"`. Test infrastructure points it + * at the in-tree runtime source so generated code can run before the + * package is built. + */ + runtimeImport?: string; +}; diff --git a/packages/capnweb-typecheck/src/vite.ts b/packages/capnweb-typecheck/src/vite.ts new file mode 100644 index 0000000..1ac9b6a --- /dev/null +++ b/packages/capnweb-typecheck/src/vite.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Vite plugin entry. Runs validator generation once per build, rewrites typed +// client factory/session calls in Vite's transform hook, and injects server +// validator registration into the worker entry module. + +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(); + server.watcher?.on("change", path => { + if (!path.endsWith(".ts") && !path.endsWith(".tsx")) return; + // Skip our own generated output, otherwise writing the generated files + // re-triggers `run()` in a loop. + if (path === state.validatorsAbs || path === state.clientsAbs) return; + if (state.outDirAbs && + (path === state.outDirAbs || path.startsWith(state.outDirAbs + sep))) return; + generated = false; + run(); + }); + }, + + 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-typecheck/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("/"); + if (!normalized.startsWith("./") && !normalized.startsWith("../")) normalized = "./" + normalized; + return normalized.replace(/\.ts$/, ".js"); +} diff --git a/packages/capnweb-typecheck/tsup.config.ts b/packages/capnweb-typecheck/tsup.config.ts new file mode 100644 index 0000000..bb6f5e7 --- /dev/null +++ b/packages/capnweb-typecheck/tsup.config.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + cli: "src/cli.ts", + vite: "src/vite.ts", + }, + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + target: "es2023", + platform: "node", + splitting: false, + treeshake: true, + minify: false, + external: ["ts-morph", "typescript", "capnweb", "capnweb/internal/typecheck"], +}); diff --git a/src/core.ts b/src/core.ts index fd443a6..7f2f030 100644 --- a/src/core.ts +++ b/src/core.ts @@ -37,6 +37,45 @@ 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; +}; + +export type RpcClassValidators = Record; + +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 validator = rpcValidators.get(klass)?.[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 +387,59 @@ 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 === 0) return undefined; + let methodName = stub.pathIfPromise[stub.pathIfPromise.length - 1]; + return rpcStubValidators.get(stub.hook)?.[methodName]; +} + +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 getRpcPromiseReturnValidator(promise: RpcPromise): RpcMethodValidator | undefined { + let {hook, pathIfPromise} = promise[RAW_STUB]; + return pathIfPromise && pathIfPromise.length === 0 ? rpcReturnValidators.get(hook) : undefined; +} + +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 +584,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,6 +709,7 @@ 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]; + let validator = pathIfPromise!.length === 0 ? rpcReturnValidators.get(hook) : undefined; if (pathIfPromise!.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 @@ -626,7 +718,7 @@ async function pullPromise(promise: RpcPromise): Promise { hook = hook.get(pathIfPromise!); } let payload = await hook.pull(); - return payload.deliverResolve(); + return payload.deliverResolve(validator?.returns); } // ======================================================================================= @@ -1147,22 +1239,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 +1282,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 +1296,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 +1311,22 @@ 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; + await validator?.returns?.(awaited); + return RpcPayload.fromAppReturn(awaited); } } finally { this.dispose(); @@ -1205,7 +1338,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 +1348,7 @@ export class RpcPayload { } let result = this.value; + await validate?.(result); // Add disposer to result. if (result instanceof Object) { @@ -1638,7 +1772,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..14c3249 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,22 @@ // 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 runtime is 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, + _validateReport as _validateReportImpl, + _createStandardSchema as _createStandardSchemaImpl, +} 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 +33,35 @@ forceInitMap(); forceInitStreams(); // Re-export public API types. -export { serialize, deserialize, newWorkersWebSocketRpcResponse, newHttpBatchRpcResponse, - nodeHttpBatchRpcResponse }; +export { + serialize, + deserialize, + newWorkersWebSocketRpcResponse, + newHttpBatchRpcResponse, + nodeHttpBatchRpcResponse, +}; export type { RpcTransport, RpcSessionOptions, RpcCompatible }; +// Library-internal entry points. These are imported by code emitted by +// `capnweb-typecheck gen` / the Vite plugin, never by user code. They live here so the +// runtime 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; +/** @internal Vendored typia runtime helper. Imported by capnweb-typecheck's + * generated specs.js after the build-time import rewrite. + */ +export const _validateReport = _validateReportImpl; +/** @internal Vendored typia runtime helper. Imported by capnweb-typecheck's + * generated specs.js after the build-time import rewrite. + */ +export const _createStandardSchema = _createStandardSchemaImpl; + // 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..e7e449f --- /dev/null +++ b/src/internal-typecheck.ts @@ -0,0 +1,29 @@ +// 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 code emitted by `capnweb-typecheck gen` 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 { + // Generated-code entry points. + __capnweb_registerRpcValidators, + __capnweb_bindClientValidator, + // Vendored typia helpers. Generated validator modules import these (after + // our build-time import-rewrite step). They live here so the user's app + // resolves them through `capnweb` instead of needing `typia` at runtime. + _validateReport, + _createStandardSchema, +} from "./typecheck/runtime.js"; + +export type { + RpcMethodTypiaValidators, + RpcClassTypiaValidators, + RpcTypiaRegistry, + TypiaValidator, + TypiaValidationError, + TypiaValidationResult, +} from "./typecheck/runtime.js"; diff --git a/src/typecheck/runtime.ts b/src/typecheck/runtime.ts new file mode 100644 index 0000000..1c7b058 --- /dev/null +++ b/src/typecheck/runtime.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Runtime side of the typecheck feature. Consumes typia-generated validator +// functions emitted at build time by `capnweb-typecheck` and adapts them to +// Cap'n Web's `RpcMethodValidator` shape (throw on failure, preserve +// `RpcPromise` placeholders to keep pipelining working). +// +// Imported by the main bundle so `capnweb/internal/typecheck` can share the +// same validator state as normal `capnweb` imports. + +import { + type RpcClassValidators, + type RpcMethodValidator, + setRpcMethodValidators, + setRpcStubValidators, +} from "../core.js"; +import type { TypiaValidationError, TypiaValidationResult } from "./typia-runtime.js"; + +// Re-export the vendored typia helpers under stable names so generated code +// can resolve them through `capnweb/internal/typecheck`. The post-process +// step in `capnweb-typecheck`'s build rewrites typia's `lib/internal/*` +// imports to point here. +export { _validateReport, _createStandardSchema } from "./typia-runtime.js"; +export type { TypiaValidationError, TypiaValidationResult } from "./typia-runtime.js"; + +/** + * Validator function signature emitted by `typia.createValidate()`. We + * treat these as opaque -- typia generates the body, we only call it and + * inspect the `{success, errors}` result. + */ +export type TypiaValidator = (input: unknown) => TypiaValidationResult; + +/** + * Per-method validator set produced by the generator: one validator per + * positional parameter (for per-arg pipelining-aware checks) plus a return + * validator. `paramNames` is recorded so error messages can reference the + * parameter by name instead of by index. `paramOptional` says whether each + * positional parameter can be elided when calling the method. + */ +export type RpcMethodTypiaValidators = { + paramNames: readonly string[]; + paramOptional: readonly boolean[]; + paramValidators: readonly TypiaValidator[]; + returns: TypiaValidator; +}; + +export type RpcClassTypiaValidators = Record; +export type RpcTypiaRegistry = Record; + +/** + * Shape of `err.rpcValidation` on the `TypeError`s thrown by registered + * validators. + */ +type RpcValidationFailure = { + path: string[]; + expected: string; + actual: string; + value: unknown; +}; + +/** + * Register per-method argument and return-value validators for a set of + * `RpcTarget` classes. `classes` maps the spec class name (a string emitted + * by generated code) to the runtime constructor. `registry` is the + * typia-backed validator map produced by `capnweb-typecheck`. + */ +export function __capnweb_registerRpcValidators( + classes: Record, registry: RpcTypiaRegistry): void { + for (let className in registry) { + let klass = classes[className]; + if (!klass) { + throw new Error(`Missing class '${className}' for Cap'n Web validator.`); + } + let methodValidators: RpcClassValidators = {}; + for (let methodName in registry[className]) { + methodValidators[methodName] = wrapTypiaValidators( + `${className}.${methodName}`, registry[className][methodName]); + } + setRpcMethodValidators(klass, methodValidators); + } +} + +/** + * Bind client-side argument and return validators to an existing `RpcStub` + * and return that same stub. Generated code wraps typed factory calls with + * this helper, so the application still receives the original Cap'n Web + * proxy object and `RpcPromise` pipelining / disposal / `StubBase` methods + * keep their normal behavior. + */ +export function __capnweb_bindClientValidator( + stub: T, className: string, validators: RpcClassTypiaValidators): T { + let methodValidators: RpcClassValidators = {}; + for (let methodName in validators) { + methodValidators[methodName] = wrapTypiaValidators( + `${className}.${methodName}`, validators[methodName]); + } + setRpcStubValidators(stub, methodValidators); + return stub; +} + +function wrapTypiaValidators( + prefix: string, raw: RpcMethodTypiaValidators): RpcMethodValidator { + let maxArgs = raw.paramValidators.length; + let minArgs = raw.paramOptional.reduce((min, optional, index) => + optional ? min : index + 1, 0); + return { + args(argList, options) { + if (argList.length < minArgs || argList.length > maxArgs) { + let expected = minArgs === maxArgs ? `${maxArgs}` : `${minArgs}-${maxArgs}`; + throw new TypeError( + `${prefix} expected ${expected} argument(s), got ${argList.length}`); + } + for (let i = 0; i < argList.length; i++) { + let arg = argList[i]; + // Preserve RpcPromise pipelining: skip validation for pipelined + // placeholders. The eventual value will still be validated on the + // server when it's delivered. + if (options?.isRpcPlaceholder?.(arg)) continue; + let validator = raw.paramValidators[i]; + if (!validator) continue; + let result = validator(arg); + if (!result.success) { + throw makeValidationError(prefix, raw.paramNames[i], result.errors, false); + } + } + }, + returns(value) { + let result = raw.returns(value); + if (!result.success) { + throw makeValidationError(prefix, undefined, result.errors, true); + } + }, + }; +} + +function makeValidationError( + prefix: string, paramName: string | undefined, + errors: TypiaValidationError[], isReturn: boolean): TypeError { + let first = errors[0]; + let pathSegments = stripInputPrefix(first.path); + let actual = actualKind(first.value); + let where: string; + let publicPath: string[]; + + if (isReturn) { + publicPath = pathSegments; + where = pathSegments.length > 0 ? pathSegments.join(".") : "value"; + } else { + publicPath = paramName ? [paramName, ...pathSegments] : pathSegments; + where = publicPath.length > 0 ? publicPath.join(".") : "value"; + } + + let header = isReturn ? `${prefix} return` : prefix; + let err = new TypeError(`${header}: ${where}: expected ${first.expected}, got ${actual}`); + (err as TypeError & { rpcValidation: RpcValidationFailure }).rpcValidation = { + path: publicPath, expected: first.expected, actual, value: first.value, + }; + return err; +} + +// Typia paths look like: +// $input (the root) +// $input.user.name (nested property) +// $input[0] (numeric index) +// $input["k"] (string-literal key) +// Strip the `$input` head and split into a flat list of property/index segments. +function stripInputPrefix(path: string): string[] { + let rest = path.startsWith("$input") ? path.slice("$input".length) : path; + let out: string[] = []; + let i = 0; + while (i < rest.length) { + let c = rest[i]; + if (c === ".") { + let end = i + 1; + while (end < rest.length && rest[end] !== "." && rest[end] !== "[") end++; + out.push(rest.slice(i + 1, end)); + i = end; + } else if (c === "[") { + let close = rest.indexOf("]", i); + if (close < 0) break; + let segment = rest.slice(i + 1, close); + if (segment.startsWith("\"") && segment.endsWith("\"")) { + out.push(JSON.parse(segment)); + } else { + out.push(`[${segment}]`); + } + i = close + 1; + } else { + i++; + } + } + return out; +} + +function 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" && value !== null) { + let ctor = (value as object).constructor; + if (ctor && ctor.name && ctor.name !== "Object") return ctor.name; + return "object"; + } + return typeof value; +} diff --git a/src/typecheck/typia-runtime.ts b/src/typecheck/typia-runtime.ts new file mode 100644 index 0000000..7a099eb --- /dev/null +++ b/src/typecheck/typia-runtime.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit +// +// Vendored helpers from `typia/lib/internal/`. Typia is MIT licensed +// (Copyright 2020 Jeongho Nam ); the original +// source lives in https://github.com/samchon/typia. We vendor these two +// tiny dependency-free helpers so that generated validators -- produced at +// build time by `capnweb-typecheck` and shipped in the user's app -- do not +// pull `typia` into the runtime bundle. + +export type TypiaValidationError = { + path: string; + expected: string; + value: unknown; + description?: string; +}; + +export type TypiaValidationResult = + | { success: true; data: unknown } + | { success: false; errors: TypiaValidationError[]; data: unknown }; + +// Builds a "reporter" that the generated validator code calls for each +// observed mismatch. Mirrors typia/lib/internal/_validateReport. +export const _validateReport = (array: TypiaValidationError[]) => { + const reportable = (path: string) => { + if (array.length === 0) return true; + const last = array[array.length - 1].path; + return path.length > last.length || last.substring(0, path.length) !== path; + }; + return (exceptable: boolean, error: TypiaValidationError) => { + if (exceptable && reportable(error.path)) { + if (error.value === undefined) { + error.description ??= [ + "The value at this path is `undefined`.", + "", + `Please fill the \`${error.expected}\` typed value next time.`, + ].join("\n"); + } + array.push(error); + } + return false; + }; +}; + +// Attaches a Standard Schema (https://standardschema.dev) interface to a typia +// validator. Mirrors typia/lib/internal/_createStandardSchema with a simpler +// path representation -- generated validators only call into this when the +// host wants Standard Schema interop, and our wrapper consumes typia's native +// error format directly, so we don't need typia's full path parser here. +export const _createStandardSchema = ( + fn: (input: unknown) => TypiaValidationResult): typeof fn => { + return Object.assign(fn, { + "~standard": { + version: 1, + vendor: "capnweb", + validate: (input: unknown) => { + const result = fn(input); + if (result.success) return { value: result.data }; + return { + issues: result.errors.map(e => ({ + message: `expected ${e.expected}, got ${typeof e.value}`, + path: e.path.split(/[.\[\]"]+/).filter(Boolean), + })), + }; + }, + }, + }); +}; diff --git a/tsconfig.json b/tsconfig.json index 7676e29..346e3fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "isolatedModules": true, "esModuleInterop": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "stripInternal": true }, "include": ["src/**/*.ts", "__tests__/**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/tsup.config.ts b/tsup.config.ts index 240f467..9c9252f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,7 +5,15 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/index-workers.ts', 'src/index-bun.ts'], + 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', + ], format: ['esm', 'cjs'], external: ['cloudflare:workers'], dts: true, @@ -23,4 +31,4 @@ export default defineConfig({ splitting: false, treeshake: true, minify: false, // Keep readable for debugging -}) \ No newline at end of file +}) diff --git a/vitest.config.ts b/vitest.config.ts index 1e43ca4..f57cb39 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,7 +17,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__/vite-plugin.test.ts'], environment: 'node', }, }, From ec9629d149104d4faf67035472417ceb79aef061 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Tue, 12 May 2026 12:44:27 -0400 Subject: [PATCH 02/24] Add worker-react debug helper --- examples/worker-react/dev-debug.sh | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100755 examples/worker-react/dev-debug.sh diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh new file mode 100755 index 0000000..9c71d18 --- /dev/null +++ b/examples/worker-react/dev-debug.sh @@ -0,0 +1,89 @@ +#!/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" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/capnweb-worker-react-vite.XXXXXX")" +TMP_CONFIG="$TMP_DIR/vite.config.mjs" +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 +)}" + +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 + +cat > "$TMP_CONFIG" </dev/null 2>&1 & +WRANGLER_PID=$! + +cd "$WEB_DIR" +if [[ ! -d node_modules ]]; then + env NODE_OPTIONS= npm install +fi +echo "Open this URL in VS Code Debug: Open Link:" +echo "http://127.0.0.1:5173" +env NODE_OPTIONS= npx vite --host 127.0.0.1 --port 5173 --config "$TMP_CONFIG" From d44adfa82b4575ffef34f362439db192ac70a805 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Tue, 12 May 2026 15:07:51 -0400 Subject: [PATCH 03/24] Update worker-react debug helper for typecheck mode --- examples/worker-react/dev-debug.sh | 59 +++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index 9c71d18..32c1c26 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -4,8 +4,17 @@ 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'); @@ -45,14 +54,36 @@ cleanup() { trap cleanup EXIT INT TERM +if [[ "$TYPECHECK" == "true" ]]; then + cd "$REPO_ROOT" + env NODE_OPTIONS= npm run build + + cd "$SCRIPT_DIR" + echo "Debugging capnweb-typecheck gen for examples/worker-react/src/worker.ts" + env NODE_OPTIONS= node --enable-source-maps --inspect=0 \ + "$REPO_ROOT/packages/capnweb-typecheck/dist/cli.js" gen src/worker.ts --out .capnweb +fi + +VITE_PLUGIN_IMPORT="" +VITE_PLUGINS="" +WORKER_MAIN="$SCRIPT_DIR/src/worker.ts" +if [[ "$TYPECHECK" == "true" ]]; then + VITE_PLUGIN_IMPORT="import capnweb from '$REPO_ROOT/packages/capnweb-typecheck/dist/vite.js';" + VITE_PLUGINS="plugins: [capnweb({ input: '$SCRIPT_DIR/src/worker.ts', outDir: '$SCRIPT_DIR/.capnweb' })]," + WORKER_MAIN="$SCRIPT_DIR/.capnweb/worker.entry.ts" +fi + cat > "$TMP_CONFIG" < "$TMP_WRANGLER_CONFIG" </dev/null 2>&1 & +env NODE_OPTIONS= npx wrangler dev --config "$TMP_WRANGLER_CONFIG" --ip 127.0.0.1 --port "$WORKER_PORT" >/dev/null 2>&1 & WRANGLER_PID=$! cd "$WEB_DIR" From ec8ef525cc92af4f3fe1714b805497ad2a59241a Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Tue, 12 May 2026 23:04:25 -0400 Subject: [PATCH 04/24] Remove typecheck tooling dependencies --- .changeset/typescript-rpc-validators.md | 2 +- .npmrc | 1 - __tests__/typecheck.test.ts | 48 +- package-lock.json | 723 +-------------------- packages/capnweb-typecheck/package.json | 4 - packages/capnweb-typecheck/src/extract.ts | 389 +++++------ packages/capnweb-typecheck/src/generate.ts | 409 +++--------- packages/capnweb-typecheck/src/rewrite.ts | 286 ++++---- packages/capnweb-typecheck/src/types.ts | 29 +- packages/capnweb-typecheck/tsup.config.ts | 2 +- src/index.ts | 10 - src/internal-typecheck.ts | 18 +- src/typecheck/runtime.ts | 288 ++++---- src/typecheck/types.ts | 26 + src/typecheck/typia-runtime.ts | 69 -- 15 files changed, 625 insertions(+), 1679 deletions(-) delete mode 100644 .npmrc create mode 100644 src/typecheck/types.ts delete mode 100644 src/typecheck/typia-runtime.ts diff --git a/.changeset/typescript-rpc-validators.md b/.changeset/typescript-rpc-validators.md index ff72267..0bc9579 100644 --- a/.changeset/typescript-rpc-validators.md +++ b/.changeset/typescript-rpc-validators.md @@ -5,7 +5,7 @@ Add build-time TypeScript RPC validation codegen. -A new opt-in tooling package, `capnweb-typecheck`, generates runtime validators for `RpcTarget` methods from your TypeScript types. The main `capnweb` package stays dependency-free; the tooling dependencies (`ts-morph` and `typia`) live only in `capnweb-typecheck`. +A new opt-in tooling package, `capnweb-typecheck`, generates runtime validators for `RpcTarget` methods from your TypeScript types. The main `capnweb` package and the typecheck tooling package both stay dependency-free. - `capnweb-typecheck` CLI: `capnweb-typecheck gen src/worker.ts --out .capnweb` for Wrangler-style builds. - `capnweb-typecheck/vite` plugin: transforms client modules in memory and registers server validators via the worker entry module. diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ec9e05d..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -min-release-age=3 diff --git a/__tests__/typecheck.test.ts b/__tests__/typecheck.test.ts index 2092251..a7d5f86 100644 --- a/__tests__/typecheck.test.ts +++ b/__tests__/typecheck.test.ts @@ -23,15 +23,15 @@ import { RpcPayload, setRpcMethodValidators } from "../src/core.js"; function inspectInput(input: string) { let project = createProject(input); - let sourceFile = project.addSourceFileAtPath(input); - let reachableFiles = collectReachableSourceFiles(sourceFile); - let classes = extractClasses(reachableFiles); + let sourceFile = project.sourceFile; + let reachableFiles = collectReachableSourceFiles(project); + let classes = extractClasses(project, reachableFiles); return { sourceFile, reachableFiles, classes }; } function emitShadowFor(input: string, outDir: string): void { let { sourceFile, reachableFiles, classes } = inspectInput(input); - let root = commonDir(reachableFiles.map(file => file.getFilePath())); + let root = commonDir(reachableFiles.map(file => file.fileName)); emitShadowSources(reachableFiles, sourceFile, outDir, root, classes.map(c => c.name)); } @@ -149,7 +149,7 @@ describe("runtime type validators", () => { // ===================================================================== // RpcTarget class discovery and preflight type rejection. These exercise -// `extractClasses` directly via `inspectInput`. No codegen, no Typia. +// `extractClasses` directly via `inspectInput`; no validator codegen. describe("RpcTarget class extraction", () => { it("discovers classes reachable through re-exports", () => { @@ -170,6 +170,34 @@ describe("RpcTarget class extraction", () => { } }); + 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 { @@ -313,7 +341,7 @@ describe("preflight type rejection", () => { // ===================================================================== // Shadow source emission. These exercise `emitShadowSources` directly -- -// no Typia, no `ts.createProgram`. They cover the CLI's client-rewrite path +// 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", () => { @@ -392,13 +420,13 @@ describe("shadow source emission", () => { }); // ===================================================================== -// End-to-end Typia codegen. ONE `generate()` call is shared by both the +// 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 Typia codegen", () => { +describe("end-to-end validator codegen", () => { let root: string; let api: any; let wrap: (stub: unknown) => any; @@ -463,9 +491,9 @@ describe("end-to-end Typia codegen", () => { await expect(call("maybe", [null])).resolves.toBeUndefined(); }); - it("preserves the Typia error path on optional + nullable mismatches", async () => { + 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/); + /Api\.maybe: value: expected null \| string \| undefined, got number/); }); it("counts required arity after optional leading params", async () => { diff --git a/package-lock.json b/package-lock.json index bbf7780..846e455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1958,6 +1958,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -2819,12 +2820,6 @@ "win32" ] }, - "node_modules/@samchon/openapi": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/@samchon/openapi/-/openapi-4.7.2.tgz", - "integrity": "sha512-yj6kGWHtKK85wfJXrSmWqLWjfCd98SAvCTsVDlej2s7OfXXHqA4hmgPRNrAPIQRVi00xn26qL1PChjq1MhzlRQ==", - "license": "MIT" - }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -2845,12 +2840,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2885,17 +2874,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@ts-morph/common": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", - "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", - "license": "MIT", - "dependencies": { - "minimatch": "^10.0.1", - "path-browserify": "^1.0.1", - "tinyglobby": "^0.2.14" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2942,7 +2920,7 @@ "version": "25.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -3119,25 +3097,11 @@ "node": ">=6" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3180,12 +3144,6 @@ "dequal": "^2.0.3" } }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "license": "MIT" - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -3206,35 +3164,6 @@ "node": ">=12" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -3255,17 +3184,6 @@ "node": ">=4" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -3273,18 +3191,6 @@ "dev": true, "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3298,30 +3204,6 @@ "node": ">=8" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/bun-types": { "version": "1.3.11", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz", @@ -3393,53 +3275,11 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, "license": "MIT" }, "node_modules/check-error": { @@ -3491,72 +3331,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "license": "ISC", - "engines": { - "node": ">= 10" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/code-block-writer": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", - "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3567,19 +3341,6 @@ "node": ">= 6" } }, - "node_modules/comment-json": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", - "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", - "license": "MIT", - "dependencies": { - "array-timsort": "^1.0.3", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -3671,18 +3432,6 @@ "node": ">=6" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -3750,21 +3499,6 @@ "node": ">=10" } }, - "node_modules/drange": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", - "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -3838,19 +3572,11 @@ "@esbuild/win32-x64": "0.27.2" } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -3914,21 +3640,6 @@ "reusify": "^1.0.4" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4065,15 +3776,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -4088,6 +3790,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -4100,26 +3803,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4130,38 +3813,6 @@ "node": ">= 4" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inquirer": { - "version": "8.2.7", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", - "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", - "license": "MIT", - "dependencies": { - "@inquirer/external-editor": "^1.0.0", - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4172,15 +3823,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4194,15 +3836,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4226,18 +3859,6 @@ "node": ">=4" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -4361,12 +3982,6 @@ "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, "node_modules/lodash.startcase": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", @@ -4374,22 +3989,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4441,15 +4040,6 @@ "node": ">=8.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/miniflare": { "version": "4.20260205.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260205.0.tgz", @@ -4503,21 +4093,6 @@ } } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -4571,12 +4146,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "license": "ISC" - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4649,44 +4218,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -4760,17 +4291,12 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, "license": "MIT", "dependencies": { "quansync": "^0.2.7" } }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5036,6 +4562,7 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, "funding": [ { "type": "individual", @@ -5118,19 +4645,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randexp": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", - "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", - "license": "MIT", - "dependencies": { - "drange": "^1.0.2", - "ret": "^0.2.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -5178,20 +4692,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5226,34 +4726,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/ret": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", - "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5310,15 +4782,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5343,39 +4806,11 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -5569,15 +5004,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -5588,24 +5014,11 @@ "node": ">=0.6.19" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5716,12 +5129,6 @@ "node": ">=0.8" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5740,6 +5147,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -5756,6 +5164,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -5773,6 +5182,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5858,21 +5268,13 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/ts-morph": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", - "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", - "license": "MIT", - "dependencies": { - "@ts-morph/common": "~0.29.0", - "code-block-writer": "^13.0.3" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/tsup": { "version": "8.5.1", @@ -5982,22 +5384,11 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6007,36 +5398,6 @@ "node": ">=14.17" } }, - "node_modules/typia": { - "version": "9.7.2", - "resolved": "https://registry.npmjs.org/typia/-/typia-9.7.2.tgz", - "integrity": "sha512-eLIKd0KHZtSvbsA+FYwX+Y0ZBt0BwVGz3GgODQX+6GfGL4DOzKW02LEx62oUZg6vCQX1BL5xyiPXAIdW+Hc51g==", - "license": "MIT", - "dependencies": { - "@samchon/openapi": "^4.7.1", - "@standard-schema/spec": "^1.0.0", - "commander": "^10.0.0", - "comment-json": "^4.2.3", - "inquirer": "^8.2.5", - "package-manager-detector": "^0.2.0", - "randexp": "^0.5.3" - }, - "bin": { - "typia": "lib/executable/typia.js" - }, - "peerDependencies": { - "typescript": ">=4.8.0 <5.10.0" - } - }, - "node_modules/typia/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -6061,7 +5422,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unenv": { @@ -6101,12 +5462,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/validate-npm-package-name": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", @@ -6347,15 +5702,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -6947,35 +6293,6 @@ "@esbuild/win32-x64": "0.27.0" } }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7056,10 +6373,6 @@ "packages/capnweb-typecheck": { "version": "0.7.0", "license": "MIT", - "dependencies": { - "ts-morph": "^28.0.0", - "typia": "^9.0.0" - }, "bin": { "capnweb-typecheck": "dist/cli.js" }, diff --git a/packages/capnweb-typecheck/package.json b/packages/capnweb-typecheck/package.json index f04668f..689525b 100644 --- a/packages/capnweb-typecheck/package.json +++ b/packages/capnweb-typecheck/package.json @@ -24,10 +24,6 @@ "peerDependencies": { "capnweb": "^0.7.0" }, - "dependencies": { - "ts-morph": "^28.0.0", - "typia": "^9.0.0" - }, "devDependencies": { "tsup": "^8.5.1", "typescript": "^5.9.3" diff --git a/packages/capnweb-typecheck/src/extract.ts b/packages/capnweb-typecheck/src/extract.ts index 850b4fe..444a7c8 100644 --- a/packages/capnweb-typecheck/src/extract.ts +++ b/packages/capnweb-typecheck/src/extract.ts @@ -1,35 +1,12 @@ // Copyright (c) 2026 Cloudflare, Inc. // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit -// -// Class discovery for the Cap'n Web typecheck codegen. -// -// We use `ts-morph` to find every class transitively extending `RpcTarget` -// across the reachable source graph, plus the names of each method's -// parameters. We deliberately do NOT lower type information here -- that -// job is now Typia's; we just hand it the user's types via synthesized -// `typia.createValidate>()` call sites and let the transformer -// inline the validators. import { existsSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; -import { - ClassDeclaration, - MethodDeclaration, - ModuleKind, - ModuleResolutionKind, - Node, - ParameterDeclaration, - Project, - ScriptTarget, - ts, - Type, - type SourceFile, -} from "ts-morph"; +import * as ts from "typescript"; +import type { ClassSpec, MethodSpec, ParamSpec, TypeSpec } from "./types.js"; -// Native types Typia would silently accept but the Cap'n Web wire -// serializer can't carry. We reject them at codegen so the user gets a clear -// build-time error instead of a confusing runtime failure. const SERIALIZER_UNSUPPORTED = new Set([ "ArrayBuffer", "SharedArrayBuffer", "DataView", "RegExp", "Map", "ReadonlyMap", "Set", "ReadonlySet", "WeakMap", "WeakSet", @@ -39,55 +16,38 @@ const SERIALIZER_UNSUPPORTED = new Set([ "Float32Array", "Float64Array", ]); -// Native/global types whose internal shape we deliberately don't walk during -// preflight. Validators treat these by `instanceof` identity, not by -// structural fields, so descending into `Error.cause: unknown` etc. would -// generate false positives. const OPAQUE_NATIVES = new Set([ "Date", "Error", "EvalError", "RangeError", "ReferenceError", "SyntaxError", "TypeError", "URIError", "AggregateError", "ReadableStream", "WritableStream", "Request", "Response", "Headers", "Blob", "Uint8Array", - // Cap'n Web identity types are reference values; their declared interface - // is a proxy facade and shouldn't be walked structurally. - "RpcStub", "RpcPromise", "RpcTarget", ]); -export type RpcMethodInfo = { - /** Method name on the `RpcTarget` subclass. */ - name: string; - /** Parameter names, in declaration order. Used to label argument-validation errors. */ - paramNames: readonly string[]; - /** Whether each parameter is optional (declared with `?` or with a default value). */ - paramOptional: readonly boolean[]; -}; +export type SourceFile = ts.SourceFile; -export type RpcClassInfo = { - /** Name visible to generated code. `default` for anonymous default exports. */ - name: string; - /** Identifier we import the class as in generated modules. */ - valueName: string; - /** Whether the class was the default export of its source file. */ - isDefault: boolean; - /** Absolute path of the source file the class is declared in. */ - sourcePath: string; - methods: readonly RpcMethodInfo[]; +export type TypecheckProject = { + program: ts.Program; + checker: ts.TypeChecker; + sourceFile: ts.SourceFile; }; -export function createProject(inputAbs: string): Project { - return new Project({ - tsConfigFilePath: findTsConfig(inputAbs), - skipAddingFilesFromTsConfig: true, - compilerOptions: { - target: ScriptTarget.ES2023, - module: ModuleKind.ESNext, - moduleResolution: ModuleResolutionKind.Bundler, - strict: true, - skipLibCheck: true, - allowJs: true, - noEmit: true, - }, - }); +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 { @@ -101,205 +61,216 @@ export function findTsConfig(start: string): string | undefined { } } -export function extractClasses(sourceFiles: SourceFile[]): RpcClassInfo[] { - let candidates = sourceFiles.flatMap(sourceFile => sourceFile.getClasses()); - let rpcClasses = candidates.filter(isRpcTargetExtender); - return rpcClasses.map((klass, index) => extractClass(klass, index)); +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; } -function isRpcTargetExtender(klass: ClassDeclaration): boolean { - let visited = new Set(); - let current: ClassDeclaration | undefined = klass; +export function extractClasses(project: TypecheckProject, sourceFiles: ts.SourceFile[]): ClassSpec[] { + return sourceFiles.flatMap(sourceFile => sourceFile.statements.filter(ts.isClassDeclaration)) + .filter(klass => isRpcTargetExtender(project.checker, klass)) + .map((klass, index) => extractClass(project.checker, klass, index)); +} +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 extendsExpr = current.getExtends(); - if (!extendsExpr) return false; + let heritage = current.heritageClauses?.find(h => h.token === ts.SyntaxKind.ExtendsKeyword); + let typeNode = heritage?.types[0]; + if (!typeNode) return false; - let name = extendsExpr.getExpression().getText(); - if (name === "RpcTarget") return true; + 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; - let symbol = extendsExpr.getExpression().getSymbol(); - if (symbol?.getAliasedSymbol()?.getName() === "RpcTarget") return true; - if (symbol?.getName() === "RpcTarget") return true; - - current = extendsExpr.getExpression().getType().getSymbol() - ?.getDeclarations() - .find(Node.isClassDeclaration); + current = checker.getTypeAtLocation(typeNode.expression).getSymbol() + ?.declarations?.find(ts.isClassDeclaration); } - return false; } -function extractClass(klass: ClassDeclaration, index: number): RpcClassInfo { - let name = klass.getName(); - let isDefault = klass.isDefaultExport(); +function extractClass(checker: ts.TypeChecker, klass: ts.ClassDeclaration, index: number): ClassSpec { + 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 (!klass.isExported()) { + 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().getFilePath(), - methods: extractMethods(klass, name), + sourcePath: klass.getSourceFile().fileName, + methods: extractMethods(checker, klass, name), }; } -function extractMethods(klass: ClassDeclaration, className: string): RpcMethodInfo[] { - let methods: RpcMethodInfo[] = []; +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): MethodSpec[] { + let methods: MethodSpec[] = []; let seen = new Set(); - for (let method of klass.getInstanceMethods()) { - if (method.hasModifier(ts.SyntaxKind.PrivateKeyword) || - method.hasModifier(ts.SyntaxKind.ProtectedKeyword)) continue; + for (let member of klass.members) { + if (!ts.isMethodDeclaration(member)) continue; + if (hasModifier(member, ts.SyntaxKind.PrivateKeyword) || + hasModifier(member, ts.SyntaxKind.ProtectedKeyword)) continue; - let methodName = method.getName(); - if (seen.has(methodName)) { - throw new Error(`${className}.${methodName}: overloaded RPC methods are not supported.`); - } + 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 paramNames: string[] = []; - let paramOptional: boolean[] = []; - for (let param of method.getParameters()) { - if (param.isRestParameter()) { - throw new Error(`${className}.${methodName}: rest parameters are not supported.`); - } - preflightCheckType(param.getType(), - `${className}.${methodName} parameter '${param.getName()}'`, param, new Set()); - paramNames.push(param.getName()); - paramOptional.push(param.isOptional() || param.hasInitializer()); + 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}'`, new Set()), + }); } - preflightCheckType(method.getReturnType(), - `${className}.${methodName} return`, method, new Set()); - methods.push({ name: methodName, paramNames, paramOptional }); + let signature = checker.getSignatureFromDeclaration(member); + let returns = lowerType(checker, checker.getReturnTypeOfSignature(signature!), + `${className}.${methodName} return`, new Set()); + methods.push({ name: methodName, params, returns }); } return methods; } -// Quick gate that throws on types Typia can't or won't catch for us at its -// own transform time: bare `any`/`unknown`, `symbol`, and native types whose -// wire format the Cap'n Web serializer doesn't support. We deliberately do -// NOT walk into structurally rich types -- we only check leaf type aliases, -// classes, and obvious wrappers (arrays, tuples, unions, promises, simple -// object props). Recursive types and complex mapped/conditional types are -// left for Typia. -function preflightCheckType( - type: Type, location: string, - node: ParameterDeclaration | MethodDeclaration, visiting: Set): void { - let id = type.compilerType; - if (visiting.has(id)) return; // already walked; assume Typia handles - visiting.add(id); +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, visiting: Set): TypeSpec { + if (visiting.has(type)) return { kind: "any" }; + visiting.add(type); try { - if (type.isAny()) { - throw new Error(`${location}: type 'any' cannot be validated; specify a concrete type.`); - } - if (type.isUnknown()) { - throw new Error(`${location}: type 'unknown' cannot be validated; specify a concrete type or narrow it.`); - } - if (type.getText() === "symbol" || type.getText() === "unique symbol" || - (type.compilerType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { + 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.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()) { - for (let variant of type.getUnionTypes()) { - preflightCheckType(variant, location, node, visiting); - } - return; + let variants = type.types.map(variant => lowerType(checker, variant, location, visiting)); + return variants.length === 1 ? variants[0] : { kind: "union", variants }; } - if (type.isIntersection()) { - for (let part of type.getIntersectionTypes()) { - preflightCheckType(part, location, node, visiting); - } - return; - } - if (type.isArray()) { - let element = type.getArrayElementType(); - if (element) preflightCheckType(element, location, node, visiting); - return; + if (type.isIntersection()) return { kind: "object", props: objectProps(checker, type, location, visiting) }; + if (checker.isArrayType(type)) { + let arg = checker.getTypeArguments(type as ts.TypeReference)[0]; + return { kind: "array", element: arg ? lowerType(checker, arg, location, visiting) : { kind: "any" } }; } - if (type.isTuple()) { - for (let element of type.getTupleElements()) { - preflightCheckType(element, location, node, visiting); - } - return; + if (checker.isTupleType(type)) { + return { + kind: "tuple", + elements: checker.getTypeArguments(type as ts.TypeReference) + .map(element => lowerType(checker, element, location, visiting)), + }; } + if (type.getCallSignatures().length > 0) return { kind: "function" }; - let symbol = type.getAliasSymbol() ?? type.getSymbol(); + let symbol = type.aliasSymbol ?? type.getSymbol(); let symbolName = symbol?.getName(); - let typeArgs = type.getTypeArguments(); - - if (symbolName && SERIALIZER_UNSUPPORTED.has(symbolName)) { - throw new Error(`${location}: Unsupported RPC type: ${type.getText()}`); - } - if (symbolName && OPAQUE_NATIVES.has(symbolName)) { - // Validated by `instanceof` -- we deliberately don't walk fields. - return; - } + let typeArgs = checker.getTypeArguments(type as ts.TypeReference); + if (symbolName && SERIALIZER_UNSUPPORTED.has(symbolName)) throw new Error(`${location}: Unsupported RPC type: ${text}`); + if (symbolName === "RpcStub" || symbolName === "RpcPromise") return { kind: "stub" }; + if (symbolName === "RpcTarget") return { kind: "rpcTarget" }; + if (symbolName && OPAQUE_NATIVES.has(symbolName)) return { kind: "instance", name: symbolName }; if (symbolName === "Promise" || symbolName === "PromiseLike") { - if (typeArgs[0]) preflightCheckType(typeArgs[0], location, node, visiting); - return; + return typeArgs[0] ? lowerType(checker, typeArgs[0], location, visiting) : { kind: "any" }; } if (symbolName === "Array" || symbolName === "ReadonlyArray") { - if (typeArgs[0]) preflightCheckType(typeArgs[0], location, node, visiting); - return; - } - - // Walk property types. Limited to types that look like plain object - // literals / interfaces -- types with a known native or class identity - // are handled by their `instanceof` check at runtime. - for (let prop of type.getProperties()) { - let propDecl = prop.getValueDeclaration() ?? prop.getDeclarations()[0]; - if (!propDecl) continue; - let propType = prop.getTypeAtLocation(propDecl); - preflightCheckType(propType, `${location}: property '${prop.getName()}'`, - node, visiting); + return typeArgs[0] ? { kind: "array", element: lowerType(checker, typeArgs[0], location, visiting) } + : { 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) return { kind: "record", value: lowerType(checker, index, location, visiting) }; + return { kind: "object", props: objectProps(checker, type, location, visiting) }; } finally { - visiting.delete(id); + visiting.delete(type); } } -export function collectReachableSourceFiles(entry: SourceFile): SourceFile[] { - let result: SourceFile[] = []; - let seen = new Set(); - - let visit = (file: SourceFile) => { - let filePath = file.getFilePath(); - 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 = (target: SourceFile | undefined) => { - if (target && isTypeScriptSource(target.getFilePath())) visit(target); - }; - - for (let importDecl of file.getImportDeclarations()) { - visitModule(importDecl.getModuleSpecifierSourceFile()); - } - for (let exportDecl of file.getExportDeclarations()) { - if (exportDecl.getModuleSpecifierValue() !== undefined) { - visitModule(exportDecl.getModuleSpecifierSourceFile()); - } - } - }; - - visit(entry); - return result; +function objectProps( + checker: ts.TypeChecker, type: ts.Type, location: string, visiting: Set) { + 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()}'`, visiting), + }]; + }); } function isTypeScriptSource(path: string): boolean { - return /\.(?:ts|tsx|mts|cts)$/.test(path) && - !/\.d\.(?:ts|mts|cts)$/.test(path); + return /\.(?:ts|tsx|mts|cts)$/.test(path) && !/\.d\.(?:ts|mts|cts)$/.test(path); } export function commonDir(paths: string[]): string { diff --git a/packages/capnweb-typecheck/src/generate.ts b/packages/capnweb-typecheck/src/generate.ts index 1caaff6..4903c0e 100644 --- a/packages/capnweb-typecheck/src/generate.ts +++ b/packages/capnweb-typecheck/src/generate.ts @@ -1,64 +1,37 @@ // 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 generator that produces Cap'n Web validation artifacts in a -// user-specified output directory. The hard work (turning each -// `RpcTarget`-method's TypeScript signature into a runtime validator) is -// delegated to Typia: we synthesize a `specs.ts` module containing -// `typia.createValidate()` call sites for each parameter / return type, -// run Typia's transformer programmatically, and rewrite the resulting JS so -// the user's app pulls in the validator helpers through -// `capnweb/internal/typecheck` rather than `typia/lib/internal/*`. -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import * as ts from "typescript"; -import { transform as typiaTransform } from "typia/lib/transform.js"; import { collectReachableSourceFiles, commonDir, createProject, extractClasses, - findTsConfig, - type RpcClassInfo, } from "./extract.js"; import { emitShadowSources, wrapFunctionName, wrapSessionFunctionName } from "./rewrite.js"; -import type { - ClassRegistration, - GenOptions, - GenResult, -} from "./types.js"; +import type { ClassRegistration, ClassSpec, GenOptions, GenResult, TypeSpec } from "./types.js"; -/** - * CLI / Wrangler path. Emits a shadow source tree alongside the validator - * artifacts and a worker entry point that re-exports the user's module after - * registering server-side validators. - */ export function generate(options: GenOptions): GenResult { let prepared = prepare(options); - let { classes, outAbs, sourceFile, reachableFiles, root, runtimeImport } = prepared; + let { classes, outAbs, sourceFile, reachableFiles, root } = prepared; - let entryAbs = emitShadowSources(reachableFiles, sourceFile, outAbs, root, - classes.map(c => c.name)); + let entryAbs = emitShadowSources(reachableFiles, sourceFile, outAbs, root, classes.map(c => c.name)); let importPath = jsImportPath(relative(outAbs, entryAbs)); - let hasDefaultExport = sourceFile.getDefaultExportSymbol() !== undefined; + 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); } -/** - * Vite path. Same artifacts as CLI mode minus the shadow source tree and - * worker-entry module: the Vite plugin handles client rewrites in memory and - * injects the server validator registration into the user's worker module - * itself. - */ export function generateValidatorsOnly(options: GenOptions): GenResult { let prepared = prepare(options); emitArtifacts(prepared); @@ -66,10 +39,10 @@ export function generateValidatorsOnly(options: GenOptions): GenResult { } type Prepared = { - classes: RpcClassInfo[]; + classes: ClassSpec[]; outAbs: string; runtimeImport: string; - sourceFile: ReturnType["addSourceFileAtPath"]>; + sourceFile: ReturnType["sourceFile"]; reachableFiles: ReturnType; root: string; }; @@ -78,295 +51,107 @@ 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}`); - } + if (!existsSync(inputAbs)) throw new Error(`Input file does not exist: ${options.input}`); let project = createProject(inputAbs); - let sourceFile = project.addSourceFileAtPath(inputAbs); - let reachableFiles = collectReachableSourceFiles(sourceFile); - let root = commonDir(reachableFiles.map(file => file.getFilePath())); - let classes = extractClasses(reachableFiles); - if (classes.length === 0) { - throw new Error(`No classes extending RpcTarget found in ${options.input}`); - } - + let sourceFile = project.sourceFile; + let reachableFiles = collectReachableSourceFiles(project); + let root = commonDir(reachableFiles.map(file => file.fileName)); + let classes = extractClasses(project, reachableFiles); + if (classes.length === 0) throw new Error(`No classes extending RpcTarget found in ${options.input}`); checkNameCollisions(classes); + for (let klass of classes) { + for (let method of klass.methods) { + for (let param of method.params) checkSupported(param.type); + checkSupported(method.returns); + } + } mkdirSync(outAbs, { recursive: true }); - return { classes, outAbs, runtimeImport, sourceFile, reachableFiles, root }; } -function checkNameCollisions(classes: RpcClassInfo[]): void { - let seenNames = new Map(); +function checkNameCollisions(classes: ClassSpec[]): void { + let seen = new Map(); for (let klass of classes) { - let prior = seenNames.get(klass.name); + 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 (e.g., name the default-exported class) ` + - `so the generated validator map has unique keys.`); + `Sources: ${prior.sourcePath}, ${klass.sourcePath}. Give one of them an explicit name.`); } - seenNames.set(klass.name, klass); + seen.set(klass.name, klass); } +} +function checkSupported(type: TypeSpec): void { + switch (type.kind) { + case "unsupported": throw new Error(`Unsupported RPC type: ${type.text}`); + case "array": checkSupported(type.element); break; + case "tuple": for (let element of type.elements) checkSupported(element); break; + case "object": for (let prop of type.props) checkSupported(prop.type); break; + case "record": checkSupported(type.value); break; + case "union": for (let variant of type.variants) checkSupported(variant); break; + } } function emitArtifacts(p: Prepared): void { - emitSpecs(p); - writeFileSync(join(p.outAbs, "validators.ts"), emitValidatorsWrapper(p.runtimeImport)); + writeFileSync(join(p.outAbs, "specs.ts"), emitSpecsModule(p.classes)); + 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"), emitClientsWrapper(p.classes, p.runtimeImport)); + writeFileSync(join(p.outAbs, "clients.ts"), emitClientsModule(p.classes, p.runtimeImport)); log(p.outAbs, "clients.ts"); } -// ===================================================================== -// specs.ts -> specs.js: synthesize typia.createValidate calls, run Typia's -// transformer programmatically, rewrite the resulting JS imports to point at -// our vendored runtime shim. The compiled specs.js is what downstream code -// (validators.ts, clients.ts) imports at runtime. - -function emitSpecs(p: Prepared): void { - let specsSrc = synthesizeSpecsSource(p); - let specsAbs = join(p.outAbs, "specs.ts"); - writeFileSync(specsAbs, specsSrc); - - let compilerOptions = readCompilerOptions(p.sourceFile.getFilePath()); - let program = ts.createProgram({ - rootNames: [specsAbs], - options: { - ...compilerOptions, - target: compilerOptions.target ?? ts.ScriptTarget.ES2022, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, - strict: true, - esModuleInterop: true, - skipLibCheck: true, - allowImportingTsExtensions: false, - noEmit: false, - // We capture emitted files via a writeFile callback and write them to - // their final location ourselves, so we don't pass outDir here -- TS - // would compute rootDir from the source graph (which includes user - // files outside .capnweb/) and produce nested output paths. - rootDir: undefined, - outDir: undefined, - declaration: false, - emitDeclarationOnly: false, - }, - }); - - // Typia's transformer needs `extras.addDiagnostic` to report type-level - // problems (e.g. attempting to validate an `any` type). We collect those - // alongside the standard TypeScript emit diagnostics. - let typiaDiags: ts.Diagnostic[] = []; - let extras = { - addDiagnostic: (d: ts.Diagnostic) => typiaDiags.push(d), - }; - let transformer = typiaTransform(program, undefined, extras); - let emittedFiles: { name: string; text: string }[] = []; - let specsFile = program.getSourceFile(specsAbs); - if (!specsFile) { - throw new Error("capnweb-typecheck: expected generated specs.ts to be part of the TypeScript program."); - } - let emit = program.emit( - specsFile, - (fileName, text) => emittedFiles.push({ name: fileName, text }), - undefined, - false, - { before: [transformer] }, - ); - - let allDiags = [ - ...program.getOptionsDiagnostics(), - ...program.getGlobalDiagnostics(), - ...program.getSyntacticDiagnostics(specsFile), - ...program.getSemanticDiagnostics(specsFile), - ...emit.diagnostics, - ...typiaDiags, - ]; - let blockingDiags = allDiags.filter(d => d.category === ts.DiagnosticCategory.Error); - if (blockingDiags.length > 0) { - let msgs = blockingDiags.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")); - throw new Error(`capnweb-typecheck: TypeScript reported errors while ` + - `generating validators:\n ${msgs.join("\n ")}`); - } - - // The emit callback receives whatever path TS picked for each output. We - // only care about the .js for our synthesized specs file; rewrite its - // typia imports and place it at `/specs.js`. - let specsJsName = "specs.js"; - let written = false; - for (let { name, text } of emittedFiles) { - if (!name.endsWith(".js")) continue; - if (!name.endsWith(specsJsName)) continue; - text = rewriteTypiaImports(text, p.runtimeImport); - writeFileSync(join(p.outAbs, specsJsName), text); - written = true; - break; - } - if (!written) { - throw new Error("capnweb-typecheck: expected specs.js to be emitted but it wasn't. " + - `Emitted files: ${emittedFiles.map(f => f.name).join(", ")}`); - } - - // The synthesized specs.ts is only useful to feed the transformer; remove - // it so downstream tooling doesn't accidentally pull in the un-transformed - // (`typia.createValidate` call site) source. - try { unlinkSync(specsAbs); } catch {} - log(p.outAbs, "specs.js"); -} - -function synthesizeSpecsSource(p: Prepared): string { - let lines: string[] = [ - `// Generated by capnweb-typecheck gen. Do not edit.`, - `// This file is consumed by Typia's transformer at build time; the`, - `// emitted JS contains inline runtime validators.`, - `import typia from "typia";`, - ]; - - let importPaths = new Map>(); - let defaultImports: { alias: string; path: string }[] = []; - for (let klass of p.classes) { - let importPath = jsImportPath(relative(p.outAbs, klass.sourcePath)); - let typeName = klass.name; - if (klass.isDefault) { - defaultImports.push({ alias: klass.valueName, path: importPath }); - } else { - let set = importPaths.get(importPath); - if (!set) { - set = new Set(); - importPaths.set(importPath, set); - } - set.add(typeName); - } - } - for (let [path, names] of importPaths) { - lines.push(`import type { ${[...names].join(", ")} } from ${JSON.stringify(path)};`); - } - for (let { alias, path } of defaultImports) { - lines.push(`import type ${alias} from ${JSON.stringify(path)};`); - } - lines.push(``); - - // Synthesize one typia.createValidate per parameter and per return. - p.classes.forEach((klass, classIndex) => { - let typeRef = klass.isDefault ? klass.valueName : klass.name; - klass.methods.forEach((method, methodIndex) => { - let methodKey = JSON.stringify(method.name); - let validatorPrefix = `_v_${classIndex}_${methodIndex}`; - let argsTuple = `Parameters<${typeRef}[${methodKey}]>`; - let returnT = `Awaited>`; - method.paramNames.forEach((_, i) => { - lines.push(`const ${validatorPrefix}_arg${i} = ` + - `typia.createValidate<${argsTuple}[${i}]>();`); - }); - lines.push(`const ${validatorPrefix}_return = ` + - `typia.createValidate<${returnT}>();`); - }); - }); - - lines.push(``, `export const validators = {`); - p.classes.forEach((klass, classIndex) => { - lines.push(` ${JSON.stringify(klass.name)}: {`); - klass.methods.forEach((method, methodIndex) => { - let validatorPrefix = `_v_${classIndex}_${methodIndex}`; - lines.push(` ${JSON.stringify(method.name)}: {`); - lines.push(` paramNames: ${JSON.stringify(method.paramNames)},`); - lines.push(` paramOptional: ${JSON.stringify(method.paramOptional)},`); - let argRefs = method.paramNames.map((_, i) => - `${validatorPrefix}_arg${i}`).join(", "); - lines.push(` paramValidators: [${argRefs}],`); - lines.push(` returns: ${validatorPrefix}_return,`); - lines.push(` },`); - }); - lines.push(` },`); - }); - lines.push(`};`); - - return lines.join("\n") + "\n"; +function emitSpecsModule(classes: ClassSpec[]): string { + return `// Generated by capnweb-typecheck gen. Do not edit.\n` + + `export const validators = {\n` + + classes.map(klass => ` ${JSON.stringify(klass.name)}: ${formatSpec(klass, 2)},`).join("\n") + + `\n};\n`; } -// Typia emits imports like: -// import * as __typia_transform__validateReport from "typia/lib/internal/_validateReport"; -// import * as __typia_transform__createStandardSchema from "typia/lib/internal/_createStandardSchema"; -// import typia from "typia"; -// We rewrite the first two to import from `capnweb/internal/typecheck` (where -// we re-export the vendored helpers), and drop the third (Typia replaces all -// call sites, so `typia` itself is dead-imported). -function rewriteTypiaImports(src: string, runtimeImport: string): string { - src = src.replace( - /import \* as (\S+) from "typia\/lib\/internal\/_(?:validateReport|createStandardSchema)(?:\.js)?";\n/g, - `import * as $1 from ${JSON.stringify(runtimeImport)};\n`); - src = src.replace(/^import typia from "typia";\n/m, ""); - return src; +function emitValidatorsModule(runtimeImport: string): string { + return `// Generated by capnweb-typecheck gen. Do not edit.\n` + + `import { __capnweb_registerRpcValidators } from ${JSON.stringify(runtimeImport)};\n` + + `import { validators } from "./specs.js";\n\n` + + `export function registerCapnwebValidators(classes: Record): void {\n` + + ` __capnweb_registerRpcValidators(classes, Object.values(validators));\n` + + `}\n`; } -function readCompilerOptions(inputAbs: string): ts.CompilerOptions { - let tsconfig = findTsConfig(inputAbs); - if (!tsconfig) return {}; - let config = ts.readConfigFile(tsconfig, ts.sys.readFile); - if (config.error) { - throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); - } - return ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(tsconfig)).options; -} - -// ===================================================================== -// Thin TS wrappers around specs.js. These are the modules user-side code -// imports from `.capnweb/`. - -function emitValidatorsWrapper(runtimeImport: string): string { - return `// Generated by capnweb-typecheck gen. Do not edit. -import { __capnweb_registerRpcValidators } from ${JSON.stringify(runtimeImport)}; -// @ts-ignore - specs.js is generated by Typia and ships without .d.ts -import { validators } from "./specs.js"; - -export function registerCapnwebValidators(classes: Record): void { - __capnweb_registerRpcValidators(classes, validators as Record>); -} -`; -} - -function emitClientsWrapper(classes: RpcClassInfo[], runtimeImport: string): string { - let lines: string[] = [ +function emitClientsModule(classes: ClassSpec[], runtimeImport: string): string { + let parts = [ `// Generated by capnweb-typecheck gen. Do not edit.`, `import { __capnweb_bindClientValidator } from ${JSON.stringify(runtimeImport)};`, - `// @ts-ignore - specs.js is generated by Typia and ships without .d.ts`, `import { validators } from "./specs.js";`, ``, ]; for (let klass of classes) { - let stub = wrapFunctionName(klass.name); - let session = wrapSessionFunctionName(klass.name); let key = JSON.stringify(klass.name); - lines.push(`export function ${stub}(stub: T): T {`); - lines.push(` return __capnweb_bindClientValidator(stub as object, ${key}, ` + - `(validators as any)[${key}]) as T;`); - lines.push(`}`); - lines.push(``); - lines.push(`export function ${session}(session: T): T {`); - lines.push(` let main = (session as { getRemoteMain(): object }).getRemoteMain();`); - lines.push(` __capnweb_bindClientValidator(main, ${key}, (validators as any)[${key}]);`); - lines.push(` return session;`); - lines.push(`}`); - lines.push(``); + 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 lines.join("\n"); + return parts.join("\n"); } function emitWorkerEntry( - importPath: string, hasDefault: boolean, classes: RpcClassInfo[], + importPath: string, hasDefault: boolean, classes: ClassSpec[], 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. -import { registerCapnwebValidators } from "./validators.js"; -${registration.imports.join("\n")} -registerCapnwebValidators({ ${registration.entries.join(", ")} }); -export * from ${JSON.stringify(importPath)}; -${hasDefault ? `export { default } from ${JSON.stringify(importPath)};\n` : ""}`; + 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[] }; @@ -377,8 +162,8 @@ export function emitViteRegistration( 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")}`; + `from ${JSON.stringify(jsImportPath(relative(dirname(inputAbs), validatorsAbs)))};\n` + + `${registration.imports.join("\n")}`; let call = `__capnweb_registerValidators({ ${registration.entries.join(", ")} });`; return { imports, call }; } @@ -389,51 +174,45 @@ function emitRegistrationImportsAndCall( localSourcePath?: string): RegistrationEmit { let imports: string[] = []; let entries: string[] = []; - let namedImports = new Map(); - let defaultImports: string[] = []; - + let named = new Map(); + let defaults: string[] = []; for (let klass of classes) { if (localSourcePath && klass.sourcePath === localSourcePath) { - if (klass.isDefault && klass.name === "default") { - throw new Error("capnweb-typecheck/vite cannot auto-register an anonymous default-exported " + - "RpcTarget class. Give the class a name."); - } - let localValueName = klass.isDefault ? klass.name : klass.valueName; - entries.push(`${JSON.stringify(klass.name)}: ${localValueName}`); + entries.push(`${JSON.stringify(klass.name)}: ${klass.isDefault ? klass.name : klass.valueName}`); continue; } - - let sourcePath = sourcePathFor(klass.sourcePath); - let importPath = jsImportPath(relative(baseDir, sourcePath)); + let importPath = jsImportPath(relative(baseDir, sourcePathFor(klass.sourcePath))); let alias = `__capnweb_${klass.valueName}`; entries.push(`${JSON.stringify(klass.name)}: ${alias}`); - if (klass.isDefault) { - defaultImports.push(`import ${alias} from ${JSON.stringify(importPath)};`); - } else { - let group = namedImports.get(importPath); - if (!group) { - group = []; - namedImports.set(importPath, group); - } + 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 namedImports) { + for (let [importPath, group] of named) { imports.push(`import { ${group.join(", ")} } from ${JSON.stringify(importPath)};`); } - imports.push(...defaultImports); + imports.push(...defaults); return { imports, entries }; } -function makeGenResult(classes: RpcClassInfo[]): GenResult { +function makeGenResult(classes: ClassSpec[]): GenResult { return { classes: classes.map(c => c.name), - registrations: classes.map(({name, valueName, isDefault, sourcePath}) => + registrations: classes.map(({ name, valueName, isDefault, sourcePath }) => ({ name, valueName, isDefault, sourcePath })), }; } +const GEN_ONLY_KEYS = new Set(["valueName", "isDefault", "sourcePath"]); + +function formatSpec(value: unknown, indent: number): string { + let json = JSON.stringify(value, (key, v) => GEN_ONLY_KEYS.has(key) ? undefined : v, 2); + return json.split("\n").map((line, i) => i === 0 ? line : " ".repeat(indent) + line).join("\n"); +} + function jsImportPath(path: string): string { let normalized = path.split(/[/\\]+/).join("/"); let ext = extname(normalized); diff --git a/packages/capnweb-typecheck/src/rewrite.ts b/packages/capnweb-typecheck/src/rewrite.ts index 091fddb..8d8d4f4 100644 --- a/packages/capnweb-typecheck/src/rewrite.ts +++ b/packages/capnweb-typecheck/src/rewrite.ts @@ -4,27 +4,9 @@ import * as fs from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; -import { - CallExpression, - NewExpression, - Node, - Project, - ScriptTarget, - ts, - type SourceFile, -} from "ts-morph"; -/** - * Names of the wrap-factory functions the rewriter calls. Each is exported - * from the generated `clients.ts`; the rewriter chooses one per typed factory - * call in user source. Kept in sync with the matching emit in `generate.ts`. - * - * Two flavors: - * - `__capnweb_wrap_` accepts an `RpcStub` and binds return - * validators directly on it. Used for the three stub-returning helpers. - * - `__capnweb_wrap_RpcSession_` accepts an `RpcSession`, - * binds validators on `session.getRemoteMain()`, and returns the session - * unchanged so the caller still has `getRemoteMain`, `drain`, etc. - */ +import * as ts from "typescript"; +import type { SourceFile } from "./extract.js"; + export function wrapFunctionName(className: string): string { return `__capnweb_wrap_${className}`; } @@ -49,58 +31,72 @@ export function emitShadowSources( let entryShadow = ""; for (let file of sourceFiles) { - let originalPath = file.getFilePath(); + 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 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.getFilePath()) { - entryShadow = shadowPath; - } + if (originalPath === entry.fileName) entryShadow = shadowPath; } - return entryShadow || entry.getFilePath(); + return entryShadow || entry.fileName; } function copyRelativeAssets(file: SourceFile, shadowPath: string, shadowRoot: string): void { - let copySpecifier = (specifier: string, target: SourceFile | undefined) => { + let copySpecifier = (specifier: string) => { if (!specifier.startsWith(".")) return; - if (target && isTypeScriptSource(target.getFilePath())) return; + if (resolveTypeScriptImport(dirname(file.fileName), specifier)) return; let assetSpecifier = specifier.split(/[?#]/, 1)[0]; - let sourcePath = target?.getFilePath() ?? resolve(dirname(file.getFilePath()), assetSpecifier); + 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.getFilePath()}.`); + `from ${file.fileName}.`); } let destPath = resolve(dirname(shadowPath), assetSpecifier); if (!isPathInside(shadowRoot, destPath)) { throw new Error(`Relative asset import ${JSON.stringify(specifier)} from ` + - `${file.getFilePath()} would escape the generated shadow source tree.`); + `${file.fileName} would escape the generated shadow source tree.`); } fs.mkdirSync(dirname(destPath), { recursive: true }); fs.copyFileSync(sourcePath, destPath); }; - for (let importDecl of file.getImportDeclarations()) { - copySpecifier(importDecl.getModuleSpecifierValue(), importDecl.getModuleSpecifierSourceFile()); + 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); + } } - for (let exportDecl of file.getExportDeclarations()) { - let specifier = exportDecl.getModuleSpecifierValue(); - if (specifier !== undefined) copySpecifier(specifier, exportDecl.getModuleSpecifierSourceFile()); +} + +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); + return /\.(?:ts|tsx|mts|cts)$/.test(path) && !/\.d\.(?:ts|mts|cts)$/.test(path); } function isPathInside(parent: string, child: string): boolean { @@ -108,37 +104,17 @@ function isPathInside(parent: string, child: string): boolean { return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(rel)); } -/** - * Rewrite typed RPC session creations so the returned stub or session gets - * its return-value validators bound to it. - * - * Two shapes are recognized: - * - `newHttpBatchRpcSession(...)` / `newWebSocketRpcSession(...)` / - * `newMessagePortRpcSession(...)` -- the call returns an `RpcStub`; - * wrap with `__capnweb_wrap_Api(...)`, which binds validators on it. - * - `new RpcSession(transport, ...)` -- the constructor returns an - * `RpcSession` whose `getRemoteMain()` is the stub we care about; - * wrap with `__capnweb_wrap_RpcSession_Api(...)`, which calls - * `session.getRemoteMain()` once, binds validators on that stub, and - * returns the session unchanged so the caller still has `getRemoteMain`, - * `getStats`, `drain`, etc. - * - * Calls without a type argument, or with a type argument that isn't a known - * RpcTarget class, are left alone. The TypeScript caller "opted out" of - * typing, so we opt out of runtime validation in the same place. - */ export function transformClientCalls( source: string | SourceFile, knownClasses: Set, clientsImport: string, fileName = "capnweb-rewrite-input.ts"): string { - let sourceFile = typeof source === "string" ? parseSource(source, fileName) : source; + let sourceFile = typeof source === "string" + ? ts.createSourceFile(fileName, source, ts.ScriptTarget.ES2023, true, ts.ScriptKind.TS) + : source; let code = sourceFile.getFullText(); let imports = collectCapnwebImports(sourceFile); let rewrites = findClientRewrites(sourceFile, knownClasses, imports); if (rewrites.length === 0) return code; - // Collect the exact set of wrap names actually used so we only import what - // the file references. Separate stub-shape and session-shape so option-A - // dispatch stays statically typed. let usedNames = new Set(); for (let edit of rewrites) usedNames.add(nameForRewrite(edit)); @@ -146,77 +122,53 @@ export function transformClientCalls( 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 = result.slice(0, edit.start) + `${nameForRewrite(edit)}(${original})` + result.slice(edit.end); } - let importsList = [...usedNames].sort().join(", "); - return `import { ${importsList} } from ${JSON.stringify(clientsImport)};\n` + result; + return `import { ${[...usedNames].sort().join(", ")} } from ${JSON.stringify(clientsImport)};\n` + result; } function nameForRewrite(edit: ClientRewrite): string { - return edit.kind === "session" - ? wrapSessionFunctionName(edit.className) - : wrapFunctionName(edit.className); + return edit.kind === "session" ? wrapSessionFunctionName(edit.className) : + wrapFunctionName(edit.className); } -function parseSource(code: string, fileName: string): SourceFile { - let project = new Project({ - useInMemoryFileSystem: true, - compilerOptions: { target: ScriptTarget.ES2023, jsx: ts.JsxEmit.ReactJSX }, - }); - return project.createSourceFile(fileName, code); -} +type ClientRewrite = { start: number; end: number; className: string; kind: "stub" | "session" }; -type ClientRewrite = { - start: number; - end: number; - className: string; - // "stub": typed call to a stub-returning helper, wrap is bound directly on - // the returned RpcStub. - // "session": typed `new RpcSession(...)`, wrap walks the session to its - // cached `getRemoteMain()` stub and binds there. - kind: "stub" | "session"; -}; - -type ImportedBinding = { original: string, binding: Node }; +type ImportedBinding = { original: string; alias: string }; type ImportedFactories = { - // Alias -> original capnweb stub-returning helper name (e.g. "connect" -> "newHttpBatchRpcSession"). stubAliasToName: Map; - // Alias -> "RpcSession" if the user imported RpcSession (possibly renamed). sessionAliasToName: Map; - // Any imported name -> its original name. Used to resolve `import type { Api as RemoteApi }`. typeAliasToName: Map; - // Identifiers bound via `import * as ns from "capnweb"`. - namespaces: Map; + namespaces: Set; }; function collectCapnwebImports(sourceFile: SourceFile): ImportedFactories { let stubAliasToName = new Map(); let sessionAliasToName = new Map(); let typeAliasToName = new Map(); - let namespaces = new Map(); + let namespaces = new Set(); - for (let importDecl of sourceFile.getImportDeclarations()) { - let isCapnweb = importDecl.getModuleSpecifierValue() === "capnweb"; + for (let statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || + !statement.importClause) continue; - if (isCapnweb) { - let namespaceImport = importDecl.getNamespaceImport(); - if (namespaceImport) namespaces.set(namespaceImport.getText(), namespaceImport); - } + 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 importDecl.getNamedImports()) { - let original = spec.getName(); - let alias = spec.getAliasNode()?.getText() ?? original; - let binding = { original, binding: spec }; - typeAliasToName.set(alias, binding); + 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, binding); + stubAliasToName.set(alias, { original, alias }); } else if (original === SESSION_CONSTRUCTOR_NAME) { - sessionAliasToName.set(alias, binding); + sessionAliasToName.set(alias, { original, alias }); } } } @@ -227,98 +179,78 @@ function collectCapnwebImports(sourceFile: SourceFile): ImportedFactories { function findClientRewrites( sourceFile: SourceFile, knownClasses: Set, imports: ImportedFactories): ClientRewrite[] { let edits: ClientRewrite[] = []; - - for (let node of sourceFile.getDescendants()) { - if (Node.isCallExpression(node)) { - let rewrite = rewriteForCall(node, knownClasses, imports); + 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 (Node.isNewExpression(node)) { - let rewrite = rewriteForNew(node, knownClasses, imports); + } 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 rewriteForCall( - node: CallExpression, knownClasses: Set, imports: ImportedFactories): ClientRewrite | undefined { - if (!callsKnownStubFactory(node.getExpression(), imports)) return undefined; - return rewriteForTypedExpression(node, knownClasses, imports, "stub"); -} - -function rewriteForNew( - node: NewExpression, knownClasses: Set, imports: ImportedFactories): ClientRewrite | undefined { - if (!callsKnownSessionConstructor(node.getExpression(), imports)) return undefined; - return rewriteForTypedExpression(node, knownClasses, imports, "session"); -} - -function callsKnownStubFactory(expr: Node, imports: ImportedFactories): boolean { - if (Node.isIdentifier(expr)) { - let imported = imports.stubAliasToName.get(expr.getText()); - return imported !== undefined && symbolMatches(expr, imported.binding); +function callsKnownStubFactory(expr: ts.Expression, imports: ImportedFactories): boolean { + if (ts.isIdentifier(expr)) { + return imports.stubAliasToName.has(expr.text) && !isShadowed(expr, expr.text); } - if (Node.isPropertyAccessExpression(expr)) { - let namespaceExpr = expr.getExpression(); - if (!Node.isIdentifier(namespaceExpr)) return false; - let namespace = namespaceExpr.getText(); - let name = expr.getName(); - let imported = imports.namespaces.get(namespace); - return imported !== undefined && symbolMatches(namespaceExpr, imported) && - STUB_FACTORY_NAMES.has(name); + 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: Node, imports: ImportedFactories): boolean { - if (Node.isIdentifier(expr)) { - let imported = imports.sessionAliasToName.get(expr.getText()); - return imported !== undefined && symbolMatches(expr, imported.binding); +function callsKnownSessionConstructor(expr: ts.Expression, imports: ImportedFactories): boolean { + if (ts.isIdentifier(expr)) { + return imports.sessionAliasToName.has(expr.text) && !isShadowed(expr, expr.text); } - if (Node.isPropertyAccessExpression(expr)) { - let namespaceExpr = expr.getExpression(); - if (!Node.isIdentifier(namespaceExpr)) return false; - let namespace = namespaceExpr.getText(); - let name = expr.getName(); - let imported = imports.namespaces.get(namespace); - return imported !== undefined && symbolMatches(namespaceExpr, imported) && - name === SESSION_CONSTRUCTOR_NAME; + 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: CallExpression | NewExpression, knownClasses: Set, + node: ts.CallExpression | ts.NewExpression, knownClasses: Set, imports: ImportedFactories, kind: "stub" | "session"): ClientRewrite | undefined { - let typeArg = node.getTypeArguments()[0]; - if (!typeArg || !Node.isTypeReference(typeArg)) return undefined; - - let typeNameNode = typeArg.getTypeName(); - let typeName = typeNameNode.getText(); + 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 && Node.isIdentifier(typeNameNode) && symbolMatches(typeNameNode, importedType.binding)) { - typeName = importedType.original; - } - if (!/^[$A-Z_a-z][$\w]*$/.test(typeName)) return undefined; - if (!knownClasses.has(typeName)) return undefined; - - return { - start: node.getStart(), - end: node.getEnd(), - className: typeName, - kind, - }; + 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 symbolMatches(use: Node, declaration: Node): boolean { - let symbol = use.getSymbol(); - return symbol?.getDeclarations().some(candidate => - candidate.getSourceFile().getFilePath() === declaration.getSourceFile().getFilePath() && - rangesOverlap(candidate, declaration)) ?? false; +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 rangesOverlap(a: Node, b: Node): boolean { - return a.getStart() <= b.getEnd() && b.getStart() <= a.getEnd(); +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 { diff --git a/packages/capnweb-typecheck/src/types.ts b/packages/capnweb-typecheck/src/types.ts index 9a3ee9c..ec59e6b 100644 --- a/packages/capnweb-typecheck/src/types.ts +++ b/packages/capnweb-typecheck/src/types.ts @@ -2,10 +2,31 @@ // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit // -// Tooling-only option / result shapes. The validator data types and helpers -// are produced by Typia at build time and registered through -// `capnweb/internal/typecheck`; this file only describes the API surface of -// the generator itself. +// Tooling-only option / result shapes plus the small validator metadata model +// shared structurally with `capnweb/internal/typecheck`. + +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: "object", props: ObjectProp[] } + | { kind: "record", value: TypeSpec } + | { kind: "union", variants: TypeSpec[] } + | { kind: "instance", name: string } + | { kind: "rpcTarget" } + | { kind: "stub" } + | { kind: "function" } + | { kind: "never" } + | { kind: "unsupported", text: 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 ClassSpec = ClassRegistration & { methods: MethodSpec[] }; export type ClassRegistration = { name: string; diff --git a/packages/capnweb-typecheck/tsup.config.ts b/packages/capnweb-typecheck/tsup.config.ts index bb6f5e7..97e02e1 100644 --- a/packages/capnweb-typecheck/tsup.config.ts +++ b/packages/capnweb-typecheck/tsup.config.ts @@ -18,5 +18,5 @@ export default defineConfig({ splitting: false, treeshake: true, minify: false, - external: ["ts-morph", "typescript", "capnweb", "capnweb/internal/typecheck"], + external: ["typescript", "capnweb", "capnweb/internal/typecheck"], }); diff --git a/src/index.ts b/src/index.ts index 14c3249..5e6e62c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,8 +15,6 @@ import { import { __capnweb_registerRpcValidators as __capnweb_registerRpcValidatorsImpl, __capnweb_bindClientValidator as __capnweb_bindClientValidatorImpl, - _validateReport as _validateReportImpl, - _createStandardSchema as _createStandardSchemaImpl, } from "./typecheck/runtime.js"; import { serialize, deserialize } from "./serialize.js"; import { RpcTransport, RpcSession as RpcSessionImpl, RpcSessionOptions } from "./rpc.js"; @@ -53,14 +51,6 @@ export type { RpcTransport, RpcSessionOptions, RpcCompatible }; export const __capnweb_registerRpcValidators = __capnweb_registerRpcValidatorsImpl; /** @internal */ export const __capnweb_bindClientValidator = __capnweb_bindClientValidatorImpl; -/** @internal Vendored typia runtime helper. Imported by capnweb-typecheck's - * generated specs.js after the build-time import rewrite. - */ -export const _validateReport = _validateReportImpl; -/** @internal Vendored typia runtime helper. Imported by capnweb-typecheck's - * generated specs.js after the build-time import rewrite. - */ -export const _createStandardSchema = _createStandardSchemaImpl; // Hack the type system to make RpcStub's types work nicely! /** diff --git a/src/internal-typecheck.ts b/src/internal-typecheck.ts index e7e449f..e1b692d 100644 --- a/src/internal-typecheck.ts +++ b/src/internal-typecheck.ts @@ -9,21 +9,15 @@ // validator state lives in a single bundle alongside the rest of the runtime. export { - // Generated-code entry points. __capnweb_registerRpcValidators, __capnweb_bindClientValidator, - // Vendored typia helpers. Generated validator modules import these (after - // our build-time import-rewrite step). They live here so the user's app - // resolves them through `capnweb` instead of needing `typia` at runtime. - _validateReport, - _createStandardSchema, } from "./typecheck/runtime.js"; export type { - RpcMethodTypiaValidators, - RpcClassTypiaValidators, - RpcTypiaRegistry, - TypiaValidator, - TypiaValidationError, - TypiaValidationResult, + ClassSpec, + MethodSpec, + ObjectProp, + ParamSpec, + PrimitiveName, + TypeSpec, } from "./typecheck/runtime.js"; diff --git a/src/typecheck/runtime.ts b/src/typecheck/runtime.ts index 1c7b058..2e1f813 100644 --- a/src/typecheck/runtime.ts +++ b/src/typecheck/runtime.ts @@ -1,197 +1,161 @@ // Copyright (c) 2026 Cloudflare, Inc. // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit -// -// Runtime side of the typecheck feature. Consumes typia-generated validator -// functions emitted at build time by `capnweb-typecheck` and adapts them to -// Cap'n Web's `RpcMethodValidator` shape (throw on failure, preserve -// `RpcPromise` placeholders to keep pipelining working). -// -// Imported by the main bundle so `capnweb/internal/typecheck` can share the -// same validator state as normal `capnweb` imports. import { + RpcTarget, type RpcClassValidators, type RpcMethodValidator, + type RpcValidationOptions, setRpcMethodValidators, setRpcStubValidators, } from "../core.js"; -import type { TypiaValidationError, TypiaValidationResult } from "./typia-runtime.js"; +import type { ClassSpec, MethodSpec, TypeSpec } from "./types.js"; -// Re-export the vendored typia helpers under stable names so generated code -// can resolve them through `capnweb/internal/typecheck`. The post-process -// step in `capnweb-typecheck`'s build rewrites typia's `lib/internal/*` -// imports to point here. -export { _validateReport, _createStandardSchema } from "./typia-runtime.js"; -export type { TypiaValidationError, TypiaValidationResult } from "./typia-runtime.js"; +type RpcValidationFailure = { path: string[]; expected: string; actual: string; value: unknown }; -/** - * Validator function signature emitted by `typia.createValidate()`. We - * treat these as opaque -- typia generates the body, we only call it and - * inspect the `{success, errors}` result. - */ -export type TypiaValidator = (input: unknown) => TypiaValidationResult; - -/** - * Per-method validator set produced by the generator: one validator per - * positional parameter (for per-arg pipelining-aware checks) plus a return - * validator. `paramNames` is recorded so error messages can reference the - * parameter by name instead of by index. `paramOptional` says whether each - * positional parameter can be elided when calling the method. - */ -export type RpcMethodTypiaValidators = { - paramNames: readonly string[]; - paramOptional: readonly boolean[]; - paramValidators: readonly TypiaValidator[]; - returns: TypiaValidator; -}; - -export type RpcClassTypiaValidators = Record; -export type RpcTypiaRegistry = Record; - -/** - * Shape of `err.rpcValidation` on the `TypeError`s thrown by registered - * validators. - */ -type RpcValidationFailure = { - path: string[]; - expected: string; - actual: string; - value: unknown; -}; - -/** - * Register per-method argument and return-value validators for a set of - * `RpcTarget` classes. `classes` maps the spec class name (a string emitted - * by generated code) to the runtime constructor. `registry` is the - * typia-backed validator map produced by `capnweb-typecheck`. - */ export function __capnweb_registerRpcValidators( - classes: Record, registry: RpcTypiaRegistry): void { - for (let className in registry) { - let klass = classes[className]; - if (!klass) { - throw new Error(`Missing class '${className}' for Cap'n Web validator.`); - } - let methodValidators: RpcClassValidators = {}; - for (let methodName in registry[className]) { - methodValidators[methodName] = wrapTypiaValidators( - `${className}.${methodName}`, registry[className][methodName]); - } - setRpcMethodValidators(klass, methodValidators); + classes: Record, classSpecs: readonly ClassSpec[]): void { + for (let spec of classSpecs) { + let klass = classes[spec.name]; + if (!klass) throw new Error(`Missing class '${spec.name}' for Cap'n Web validator.`); + let validators: RpcClassValidators = {}; + for (let method of spec.methods) validators[method.name] = makeMethodValidator(spec.name, method); + setRpcMethodValidators(klass, validators); } } -/** - * Bind client-side argument and return validators to an existing `RpcStub` - * and return that same stub. Generated code wraps typed factory calls with - * this helper, so the application still receives the original Cap'n Web - * proxy object and `RpcPromise` pipelining / disposal / `StubBase` methods - * keep their normal behavior. - */ -export function __capnweb_bindClientValidator( - stub: T, className: string, validators: RpcClassTypiaValidators): T { - let methodValidators: RpcClassValidators = {}; - for (let methodName in validators) { - methodValidators[methodName] = wrapTypiaValidators( - `${className}.${methodName}`, validators[methodName]); - } - setRpcStubValidators(stub, methodValidators); +export function __capnweb_bindClientValidator(stub: T, classSpec: ClassSpec): T { + let validators: RpcClassValidators = {}; + for (let method of classSpec.methods) validators[method.name] = makeMethodValidator(classSpec.name, method); + setRpcStubValidators(stub, validators); return stub; } -function wrapTypiaValidators( - prefix: string, raw: RpcMethodTypiaValidators): RpcMethodValidator { - let maxArgs = raw.paramValidators.length; - let minArgs = raw.paramOptional.reduce((min, optional, index) => - optional ? min : index + 1, 0); +function makeMethodValidator(className: string, method: MethodSpec): RpcMethodValidator { + let prefix = `${className}.${method.name}`; + let minArgs = method.params.reduce((min, param, index) => param.optional ? min : index + 1, 0); return { - args(argList, options) { - if (argList.length < minArgs || argList.length > maxArgs) { - let expected = minArgs === maxArgs ? `${maxArgs}` : `${minArgs}-${maxArgs}`; - throw new TypeError( - `${prefix} expected ${expected} argument(s), got ${argList.length}`); + args(args, options) { + if (args.length < minArgs || args.length > method.params.length) { + let expected = minArgs === method.params.length ? `${minArgs}` : `${minArgs}-${method.params.length}`; + throw new TypeError(`${prefix} expected ${expected} argument(s), got ${args.length}`); } - for (let i = 0; i < argList.length; i++) { - let arg = argList[i]; - // Preserve RpcPromise pipelining: skip validation for pipelined - // placeholders. The eventual value will still be validated on the - // server when it's delivered. - if (options?.isRpcPlaceholder?.(arg)) continue; - let validator = raw.paramValidators[i]; - if (!validator) continue; - let result = validator(arg); - if (!result.success) { - throw makeValidationError(prefix, raw.paramNames[i], result.errors, false); - } + for (let i = 0; i < args.length; i++) { + let param = method.params[i]; + if (!param || (param.optional && args[i] === undefined)) continue; + let failure = validateTypeSpec(param.type, args[i], [param.name], options); + if (failure) throw makeValidationError(prefix, failure); } }, returns(value) { - let result = raw.returns(value); - if (!result.success) { - throw makeValidationError(prefix, undefined, result.errors, true); - } + let failure = validateTypeSpec(method.returns, value, []); + if (failure) throw makeValidationError(`${prefix} return`, failure); }, }; } -function makeValidationError( - prefix: string, paramName: string | undefined, - errors: TypiaValidationError[], isReturn: boolean): TypeError { - let first = errors[0]; - let pathSegments = stripInputPrefix(first.path); - let actual = actualKind(first.value); - let where: string; - let publicPath: string[]; - - if (isReturn) { - publicPath = pathSegments; - where = pathSegments.length > 0 ? pathSegments.join(".") : "value"; - } else { - publicPath = paramName ? [paramName, ...pathSegments] : pathSegments; - where = publicPath.length > 0 ? publicPath.join(".") : "value"; - } - - let header = isReturn ? `${prefix} return` : prefix; - let err = new TypeError(`${header}: ${where}: expected ${first.expected}, got ${actual}`); - (err as TypeError & { rpcValidation: RpcValidationFailure }).rpcValidation = { - path: publicPath, expected: first.expected, actual, value: first.value, - }; +function makeValidationError(prefix: string, failure: RpcValidationFailure): TypeError { + let where = failure.path.length > 0 ? failure.path.join(".") : "value"; + let err = new TypeError(`${prefix}: ${where}: expected ${failure.expected}, got ${failure.actual}`); + (err as TypeError & { rpcValidation: RpcValidationFailure }).rpcValidation = failure; return err; } -// Typia paths look like: -// $input (the root) -// $input.user.name (nested property) -// $input[0] (numeric index) -// $input["k"] (string-literal key) -// Strip the `$input` head and split into a flat list of property/index segments. -function stripInputPrefix(path: string): string[] { - let rest = path.startsWith("$input") ? path.slice("$input".length) : path; - let out: string[] = []; - let i = 0; - while (i < rest.length) { - let c = rest[i]; - if (c === ".") { - let end = i + 1; - while (end < rest.length && rest[end] !== "." && rest[end] !== "[") end++; - out.push(rest.slice(i + 1, end)); - i = end; - } else if (c === "[") { - let close = rest.indexOf("]", i); - if (close < 0) break; - let segment = rest.slice(i + 1, close); - if (segment.startsWith("\"") && segment.endsWith("\"")) { - out.push(JSON.parse(segment)); - } else { - out.push(`[${segment}]`); +function validateTypeSpec( + type: TypeSpec, value: unknown, path: string[], + options?: RpcValidationOptions): RpcValidationFailure | undefined { + if (options?.isRpcPlaceholder?.(value)) return undefined; + let mismatch = (): RpcValidationFailure => ({ path, expected: describeTypeSpec(type), actual: actualKind(value), value }); + switch (type.kind) { + case "any": return undefined; + case "never": return mismatch(); + case "primitive": + switch (type.name) { + case "string": return typeof value === "string" ? undefined : mismatch(); + case "number": return typeof value === "number" ? undefined : mismatch(); + case "bigint": return typeof value === "bigint" ? undefined : mismatch(); + case "boolean": return typeof value === "boolean" ? undefined : mismatch(); + case "undefined": + case "void": return value === undefined ? undefined : mismatch(); + case "null": return value === null ? undefined : mismatch(); + } + case "literal": return value === type.value ? undefined : mismatch(); + case "array": { + if (!Array.isArray(value)) return mismatch(); + for (let i = 0; i < value.length; i++) { + let failure = validateTypeSpec(type.element, value[i], [...path, `[${i}]`], options); + if (failure) return failure; } - i = close + 1; - } else { - i++; + return undefined; } + case "tuple": { + if (!Array.isArray(value) || value.length !== type.elements.length) return mismatch(); + for (let i = 0; i < type.elements.length; i++) { + let failure = validateTypeSpec(type.elements[i], value[i], [...path, `[${i}]`], options); + if (failure) return failure; + } + return undefined; + } + case "object": { + if (typeof value !== "object" || value === null || Array.isArray(value)) return mismatch(); + let record = value as Record; + for (let prop of type.props) { + if (!Object.prototype.hasOwnProperty.call(record, prop.name)) { + if (prop.optional) continue; + return { path: [...path, prop.name], expected: describeTypeSpec(prop.type), actual: "missing", value: undefined }; + } + if (record[prop.name] === undefined && prop.optional) continue; + let failure = validateTypeSpec(prop.type, record[prop.name], [...path, prop.name], options); + if (failure) return failure; + } + return undefined; + } + case "record": { + if (typeof value !== "object" || value === null || Array.isArray(value)) return mismatch(); + for (let [key, item] of Object.entries(value as Record)) { + let failure = validateTypeSpec(type.value, item, [...path, key], options); + if (failure) return failure; + } + return undefined; + } + case "union": { + let failures: RpcValidationFailure[] = []; + for (let variant of type.variants) { + let failure = validateTypeSpec(variant, value, path, options); + if (!failure) return undefined; + failures.push(failure); + } + return failures.find(failure => failure.path.length > path.length) ?? mismatch(); + } + case "instance": { + let ctor = (globalThis as Record)[type.name]; + return typeof ctor === "function" && value instanceof ctor ? undefined : mismatch(); + } + case "rpcTarget": return value instanceof RpcTarget ? undefined : mismatch(); + case "stub": return value !== null && (typeof value === "object" || typeof value === "function") ? undefined : mismatch(); + case "function": return typeof value === "function" ? undefined : mismatch(); + case "unsupported": return mismatch(); + } +} + +function describeTypeSpec(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 `${describeTypeSpec(type.element)}[]`; + case "tuple": return `[${type.elements.map(describeTypeSpec).join(", ")}]`; + case "object": return `{ ${type.props.map(p => `${p.name}${p.optional ? "?" : ""}: ${describeTypeSpec(p.type)}`).join(", ")} }`; + case "record": return `Record`; + case "union": return type.variants.map(describeTypeSpec).sort().join(" | "); + case "instance": return type.name; + case "rpcTarget": return "RpcTarget"; + case "stub": return "RpcStub"; + case "function": return "Function"; + case "unsupported": return type.text; } - return out; } function actualKind(value: unknown): string { @@ -200,10 +164,12 @@ function actualKind(value: unknown): string { if (value instanceof Date) return "Date"; if (value instanceof RegExp) return "RegExp"; if (value instanceof Error) return "Error"; - if (typeof value === "object" && value !== null) { + 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; } + +export type { ClassSpec, MethodSpec, ObjectProp, ParamSpec, PrimitiveName, TypeSpec } from "./types.js"; diff --git a/src/typecheck/types.ts b/src/typecheck/types.ts new file mode 100644 index 0000000..63503a0 --- /dev/null +++ b/src/typecheck/types.ts @@ -0,0 +1,26 @@ +// 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: "object", props: ObjectProp[] } + | { kind: "record", value: TypeSpec } + | { kind: "union", variants: TypeSpec[] } + | { kind: "instance", name: string } + | { kind: "rpcTarget" } + | { kind: "stub" } + | { kind: "function" } + | { kind: "never" } + | { kind: "unsupported", text: 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 ClassSpec = { name: string, methods: MethodSpec[] }; diff --git a/src/typecheck/typia-runtime.ts b/src/typecheck/typia-runtime.ts deleted file mode 100644 index 7a099eb..0000000 --- a/src/typecheck/typia-runtime.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the MIT license found in the LICENSE.txt file or at: -// https://opensource.org/license/mit -// -// Vendored helpers from `typia/lib/internal/`. Typia is MIT licensed -// (Copyright 2020 Jeongho Nam ); the original -// source lives in https://github.com/samchon/typia. We vendor these two -// tiny dependency-free helpers so that generated validators -- produced at -// build time by `capnweb-typecheck` and shipped in the user's app -- do not -// pull `typia` into the runtime bundle. - -export type TypiaValidationError = { - path: string; - expected: string; - value: unknown; - description?: string; -}; - -export type TypiaValidationResult = - | { success: true; data: unknown } - | { success: false; errors: TypiaValidationError[]; data: unknown }; - -// Builds a "reporter" that the generated validator code calls for each -// observed mismatch. Mirrors typia/lib/internal/_validateReport. -export const _validateReport = (array: TypiaValidationError[]) => { - const reportable = (path: string) => { - if (array.length === 0) return true; - const last = array[array.length - 1].path; - return path.length > last.length || last.substring(0, path.length) !== path; - }; - return (exceptable: boolean, error: TypiaValidationError) => { - if (exceptable && reportable(error.path)) { - if (error.value === undefined) { - error.description ??= [ - "The value at this path is `undefined`.", - "", - `Please fill the \`${error.expected}\` typed value next time.`, - ].join("\n"); - } - array.push(error); - } - return false; - }; -}; - -// Attaches a Standard Schema (https://standardschema.dev) interface to a typia -// validator. Mirrors typia/lib/internal/_createStandardSchema with a simpler -// path representation -- generated validators only call into this when the -// host wants Standard Schema interop, and our wrapper consumes typia's native -// error format directly, so we don't need typia's full path parser here. -export const _createStandardSchema = ( - fn: (input: unknown) => TypiaValidationResult): typeof fn => { - return Object.assign(fn, { - "~standard": { - version: 1, - vendor: "capnweb", - validate: (input: unknown) => { - const result = fn(input); - if (result.success) return { value: result.data }; - return { - issues: result.errors.map(e => ({ - message: `expected ${e.expected}, got ${typeof e.value}`, - path: e.path.split(/[.\[\]"]+/).filter(Boolean), - })), - }; - }, - }, - }); -}; From 034a4378ce7293302d90d26405c6ab812c776578 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Wed, 13 May 2026 10:14:45 -0400 Subject: [PATCH 05/24] Fold typecheck tooling into capnweb --- .changeset/typescript-rpc-validators.md | 5 +- __tests__/typecheck.test.ts | 107 ++++++++++++++++-- __tests__/vite-plugin.test.ts | 8 +- examples/worker-react/dev-debug.sh | 4 +- package-lock.json | 31 +---- package.json | 13 ++- packages/capnweb-typecheck/package.json | 36 ------ packages/capnweb-typecheck/src/types.ts | 53 --------- packages/capnweb-typecheck/tsup.config.ts | 22 ---- src/index.ts | 2 +- src/internal-typecheck.ts | 2 +- .../src => src/typecheck}/cli.ts | 0 .../src => src/typecheck}/extract.ts | 39 ++++--- .../src => src/typecheck}/generate.ts | 16 +-- .../src => src/typecheck}/rewrite.ts | 0 src/typecheck/runtime.ts | 21 ++++ src/typecheck/types.ts | 23 ++++ .../src => src/typecheck}/vite.ts | 31 +---- tsup.config.ts | 58 ++++++---- 19 files changed, 235 insertions(+), 236 deletions(-) delete mode 100644 packages/capnweb-typecheck/package.json delete mode 100644 packages/capnweb-typecheck/src/types.ts delete mode 100644 packages/capnweb-typecheck/tsup.config.ts rename {packages/capnweb-typecheck/src => src/typecheck}/cli.ts (100%) rename {packages/capnweb-typecheck/src => src/typecheck}/extract.ts (92%) rename {packages/capnweb-typecheck/src => src/typecheck}/generate.ts (94%) rename {packages/capnweb-typecheck/src => src/typecheck}/rewrite.ts (100%) rename {packages/capnweb-typecheck/src => src/typecheck}/vite.ts (81%) diff --git a/.changeset/typescript-rpc-validators.md b/.changeset/typescript-rpc-validators.md index 0bc9579..9387245 100644 --- a/.changeset/typescript-rpc-validators.md +++ b/.changeset/typescript-rpc-validators.md @@ -1,13 +1,12 @@ --- "capnweb": minor -"capnweb-typecheck": minor --- Add build-time TypeScript RPC validation codegen. -A new opt-in tooling package, `capnweb-typecheck`, generates runtime validators for `RpcTarget` methods from your TypeScript types. The main `capnweb` package and the typecheck tooling package both stay dependency-free. +A new opt-in `capnweb-typecheck` CLI and `capnweb/vite` plugin generate runtime validators for `RpcTarget` methods from your TypeScript types. The main `capnweb` runtime and the typecheck tooling stay dependency-free. - `capnweb-typecheck` CLI: `capnweb-typecheck gen src/worker.ts --out .capnweb` for Wrangler-style builds. -- `capnweb-typecheck/vite` plugin: transforms client modules in memory and registers server validators via the worker entry module. +- `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/__tests__/typecheck.test.ts b/__tests__/typecheck.test.ts index a7d5f86..3e0c160 100644 --- a/__tests__/typecheck.test.ts +++ b/__tests__/typecheck.test.ts @@ -12,9 +12,9 @@ import { commonDir, createProject, extractClasses, -} from "../packages/capnweb-typecheck/src/extract.js"; -import { generate } from "../packages/capnweb-typecheck/src/generate.js"; -import { emitShadowSources } from "../packages/capnweb-typecheck/src/rewrite.js"; +} 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"; @@ -319,20 +319,36 @@ describe("preflight type rejection", () => { } }); - it.each([ - "ArrayBuffer", "DataView", "Map", "RegExp", "Set", "Uint16Array", - ])("rejects serializer-unsupported native: %s", type => { - let root = mkdtempSync(resolve(".capnweb-unsupported-")); + it("rejects recursive types", () => { + 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 { - valueArg(value: ${type}): void {} + tree(value: Tree): void {} } `); - expect(() => inspectInput(input)).toThrow(/Unsupported RPC type/); + expect(() => inspectInput(input)).toThrow(/recursive types are not supported/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + "ArrayBuffer", "DataView", "RegExp", "Uint16Array", "Float32Array", + ])("accepts structured-clone native declared by RpcCompatible: %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).classes[0].methods[0].params[0].type.kind).toBe("instance"); } finally { rmSync(root, { recursive: true, force: true }); } @@ -449,6 +465,23 @@ describe("end-to-end validator codegen", () => { 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; + buffer: ArrayBuffer; + view: DataView; + pattern: RegExp; + 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; } @@ -499,6 +532,62 @@ describe("end-to-end validator codegen", () => { 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]), + buffer: new ArrayBuffer(1), + view: new DataView(new ArrayBuffer(1)), + pattern: /ok/, + 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(), + buffer: new ArrayBuffer(1), + view: new DataView(new ArrayBuffer(1)), + pattern: /ok/, + 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(), + buffer: new ArrayBuffer(1), + view: new DataView(new ArrayBuffer(1)), + pattern: /ok/, + literal: "ok", + }])).rejects.toThrow(/value\.map\.a\.active: expected boolean, got string/); + }); }); describe("client-side bound validators", () => { diff --git a/__tests__/vite-plugin.test.ts b/__tests__/vite-plugin.test.ts index 63b221a..0bf2f21 100644 --- a/__tests__/vite-plugin.test.ts +++ b/__tests__/vite-plugin.test.ts @@ -5,11 +5,11 @@ import { describe, expect, it } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; -import { emitViteRegistration } from "../packages/capnweb-typecheck/src/generate.js"; -import { transformClientCalls } from "../packages/capnweb-typecheck/src/rewrite.js"; -import capnweb from "../packages/capnweb-typecheck/src/vite.js"; +import { emitViteRegistration } from "../src/typecheck/generate.js"; +import { transformClientCalls } from "../src/typecheck/rewrite.js"; +import capnweb from "../src/typecheck/vite.js"; -describe("capnweb-typecheck/vite plugin client rewrite", () => { +describe("capnweb/vite plugin client rewrite", () => { it("rewrites typed RPC factory calls in user source", () => { let userCode = ` import { newHttpBatchRpcSession } from "capnweb"; diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index 32c1c26..c7494b3 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -61,14 +61,14 @@ if [[ "$TYPECHECK" == "true" ]]; then cd "$SCRIPT_DIR" echo "Debugging capnweb-typecheck gen for examples/worker-react/src/worker.ts" env NODE_OPTIONS= node --enable-source-maps --inspect=0 \ - "$REPO_ROOT/packages/capnweb-typecheck/dist/cli.js" gen src/worker.ts --out .capnweb + "$REPO_ROOT/dist/cli.js" gen src/worker.ts --out .capnweb fi VITE_PLUGIN_IMPORT="" VITE_PLUGINS="" WORKER_MAIN="$SCRIPT_DIR/src/worker.ts" if [[ "$TYPECHECK" == "true" ]]; then - VITE_PLUGIN_IMPORT="import capnweb from '$REPO_ROOT/packages/capnweb-typecheck/dist/vite.js';" + VITE_PLUGIN_IMPORT="import capnweb from '$REPO_ROOT/dist/vite.js';" VITE_PLUGINS="plugins: [capnweb({ input: '$SCRIPT_DIR/src/worker.ts', outDir: '$SCRIPT_DIR/.capnweb' })]," WORKER_MAIN="$SCRIPT_DIR/.capnweb/worker.entry.ts" fi diff --git a/package-lock.json b/package-lock.json index 846e455..42ea70c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,9 @@ "name": "capnweb", "version": "0.7.0", "license": "MIT", - "workspaces": [ - "packages/capnweb-typecheck" - ], + "bin": { + "capnweb-typecheck": "dist/cli.js" + }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", @@ -3247,17 +3247,6 @@ "dev": true, "license": "MIT" }, - "node_modules/capnweb": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/capnweb/-/capnweb-0.7.0.tgz", - "integrity": "sha512-zO7tt5ch2tImacaR/oMd7e1dqi/fWU7hjZdvQMv6Yo3v9uUGA8cPIUQGvfQTu2c+NgyE/j/oDmMaUlf1PXyfJw==", - "license": "MIT", - "peer": true - }, - "node_modules/capnweb-typecheck": { - "resolved": "packages/capnweb-typecheck", - "link": true - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -6369,20 +6358,6 @@ "engines": { "node": ">=20" } - }, - "packages/capnweb-typecheck": { - "version": "0.7.0", - "license": "MIT", - "bin": { - "capnweb-typecheck": "dist/cli.js" - }, - "devDependencies": { - "tsup": "^8.5.1", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "capnweb": "^0.7.0" - } } } } diff --git a/package.json b/package.json index d904274..a550686 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,6 @@ "files": [ "dist" ], - "workspaces": [ - "packages/capnweb-typecheck" - ], "exports": { ".": { "workerd": { @@ -42,14 +39,22 @@ "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" } }, + "bin": { + "capnweb-typecheck": "./dist/cli.js" + }, "type": "module", "publishConfig": { "access": "public" }, "scripts": { - "build": "npm run build:runtime && npm run build -w capnweb-typecheck", + "build": "tsup", "build:runtime": "tsup", "build:watch": "tsup --watch", "test": "vitest run", diff --git a/packages/capnweb-typecheck/package.json b/packages/capnweb-typecheck/package.json deleted file mode 100644 index 689525b..0000000 --- a/packages/capnweb-typecheck/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "capnweb-typecheck", - "version": "0.7.0", - "description": "Build-time TypeScript RPC validation tooling for Cap'n Web", - "type": "module", - "author": "Kenton Varda ", - "license": "MIT", - "files": [ - "dist" - ], - "exports": { - "./vite": { - "types": "./dist/vite.d.ts", - "import": "./dist/vite.js", - "require": "./dist/vite.cjs" - } - }, - "bin": { - "capnweb-typecheck": "./dist/cli.js" - }, - "scripts": { - "build": "tsup --config tsup.config.ts" - }, - "peerDependencies": { - "capnweb": "^0.7.0" - }, - "devDependencies": { - "tsup": "^8.5.1", - "typescript": "^5.9.3" - }, - "repository": { - "type": "git", - "url": "https://github.com/cloudflare/capnweb", - "directory": "packages/capnweb-typecheck" - } -} diff --git a/packages/capnweb-typecheck/src/types.ts b/packages/capnweb-typecheck/src/types.ts deleted file mode 100644 index ec59e6b..0000000 --- a/packages/capnweb-typecheck/src/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the MIT license found in the LICENSE.txt file or at: -// https://opensource.org/license/mit -// -// Tooling-only option / result shapes plus the small validator metadata model -// shared structurally with `capnweb/internal/typecheck`. - -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: "object", props: ObjectProp[] } - | { kind: "record", value: TypeSpec } - | { kind: "union", variants: TypeSpec[] } - | { kind: "instance", name: string } - | { kind: "rpcTarget" } - | { kind: "stub" } - | { kind: "function" } - | { kind: "never" } - | { kind: "unsupported", text: 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 ClassSpec = ClassRegistration & { methods: MethodSpec[] }; - -export type ClassRegistration = { - name: string; - valueName: string; - isDefault: boolean; - sourcePath: string; -}; - -export type GenResult = { - classes: string[]; - registrations: ClassRegistration[]; -}; - -export type GenOptions = { - input: string; - outDir: string; - /** - * @internal Override the package import emitted in generated modules. - * Defaults to `"capnweb/internal/typecheck"`. Test infrastructure points it - * at the in-tree runtime source so generated code can run before the - * package is built. - */ - runtimeImport?: string; -}; diff --git a/packages/capnweb-typecheck/tsup.config.ts b/packages/capnweb-typecheck/tsup.config.ts deleted file mode 100644 index 97e02e1..0000000 --- a/packages/capnweb-typecheck/tsup.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the MIT license found in the LICENSE.txt file or at: -// https://opensource.org/license/mit - -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { - cli: "src/cli.ts", - vite: "src/vite.ts", - }, - format: ["esm", "cjs"], - dts: true, - sourcemap: true, - clean: true, - target: "es2023", - platform: "node", - splitting: false, - treeshake: true, - minify: false, - external: ["typescript", "capnweb", "capnweb/internal/typecheck"], -}); diff --git a/src/index.ts b/src/index.ts index 5e6e62c..5360d37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ export { export type { RpcTransport, RpcSessionOptions, RpcCompatible }; // Library-internal entry points. These are imported by code emitted by -// `capnweb-typecheck gen` / the Vite plugin, never by user code. They live here so the +// the typecheck generator / Vite plugin, never by user code. They live here so the // runtime registry stays in a single bundle (one shared WeakMap). // // `stripInternal` removes them from `dist/index.d.ts`, so they don't show up diff --git a/src/internal-typecheck.ts b/src/internal-typecheck.ts index e1b692d..3f85989 100644 --- a/src/internal-typecheck.ts +++ b/src/internal-typecheck.ts @@ -3,7 +3,7 @@ // https://opensource.org/license/mit // // Library-internal entry point. This module is not part of the user-facing -// API; it is imported only by code emitted by `capnweb-typecheck gen` and +// 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. diff --git a/packages/capnweb-typecheck/src/cli.ts b/src/typecheck/cli.ts similarity index 100% rename from packages/capnweb-typecheck/src/cli.ts rename to src/typecheck/cli.ts diff --git a/packages/capnweb-typecheck/src/extract.ts b/src/typecheck/extract.ts similarity index 92% rename from packages/capnweb-typecheck/src/extract.ts rename to src/typecheck/extract.ts index 444a7c8..817c2d9 100644 --- a/packages/capnweb-typecheck/src/extract.ts +++ b/src/typecheck/extract.ts @@ -5,22 +5,17 @@ import { existsSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; import * as ts from "typescript"; -import type { ClassSpec, MethodSpec, ParamSpec, TypeSpec } from "./types.js"; - -const SERIALIZER_UNSUPPORTED = new Set([ - "ArrayBuffer", "SharedArrayBuffer", "DataView", "RegExp", - "Map", "ReadonlyMap", "Set", "ReadonlySet", "WeakMap", "WeakSet", - "Uint8ClampedArray", "Uint16Array", "Uint32Array", - "Int8Array", "Int16Array", "Int32Array", - "BigUint64Array", "BigInt64Array", - "Float32Array", "Float64Array", -]); +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", + "Blob", "ArrayBuffer", "DataView", "RegExp", + "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", + "Int8Array", "Int16Array", "Int32Array", + "BigUint64Array", "BigInt64Array", + "Float32Array", "Float64Array", ]); export type SourceFile = ts.SourceFile; @@ -102,7 +97,7 @@ export function collectReachableSourceFiles(project: TypecheckProject): ts.Sourc return result; } -export function extractClasses(project: TypecheckProject, sourceFiles: ts.SourceFile[]): ClassSpec[] { +export function extractClasses(project: TypecheckProject, sourceFiles: ts.SourceFile[]): ExtractedClassSpec[] { return sourceFiles.flatMap(sourceFile => sourceFile.statements.filter(ts.isClassDeclaration)) .filter(klass => isRpcTargetExtender(project.checker, klass)) .map((klass, index) => extractClass(project.checker, klass, index)); @@ -129,7 +124,7 @@ function isRpcTargetExtender(checker: ts.TypeChecker, klass: ts.ClassDeclaration return false; } -function extractClass(checker: ts.TypeChecker, klass: ts.ClassDeclaration, index: number): ClassSpec { +function extractClass(checker: ts.TypeChecker, klass: ts.ClassDeclaration, index: number): ExtractedClassSpec { let name = klass.name?.text; if (!name) { throw new Error("Anonymous RpcTarget classes are not supported. Give the class a name " + @@ -193,7 +188,9 @@ function propertyNameText(name: ts.PropertyName): string | undefined { function lowerType( checker: ts.TypeChecker, type: ts.Type, location: string, visiting: Set): TypeSpec { - if (visiting.has(type)) return { kind: "any" }; + if (visiting.has(type)) { + throw new Error(`${location}: recursive types are not supported by capnweb-typecheck yet.`); + } visiting.add(type); try { let text = checker.typeToString(type); @@ -234,9 +231,21 @@ function lowerType( let symbol = type.aliasSymbol ?? type.getSymbol(); let symbolName = symbol?.getName(); let typeArgs = checker.getTypeArguments(type as ts.TypeReference); - if (symbolName && SERIALIZER_UNSUPPORTED.has(symbolName)) throw new Error(`${location}: Unsupported RPC type: ${text}`); 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, visiting) : { kind: "any" }, + value: typeArgs[1] ? lowerType(checker, typeArgs[1], location, visiting) : { kind: "any" }, + }; + } + if (symbolName === "Set" || symbolName === "ReadonlySet") { + return { + kind: "set", + value: typeArgs[0] ? lowerType(checker, typeArgs[0], location, visiting) : { kind: "any" }, + }; + } if (symbolName && OPAQUE_NATIVES.has(symbolName)) return { kind: "instance", name: symbolName }; if (symbolName === "Promise" || symbolName === "PromiseLike") { return typeArgs[0] ? lowerType(checker, typeArgs[0], location, visiting) : { kind: "any" }; diff --git a/packages/capnweb-typecheck/src/generate.ts b/src/typecheck/generate.ts similarity index 94% rename from packages/capnweb-typecheck/src/generate.ts rename to src/typecheck/generate.ts index 4903c0e..459bf5e 100644 --- a/packages/capnweb-typecheck/src/generate.ts +++ b/src/typecheck/generate.ts @@ -12,7 +12,7 @@ import { extractClasses, } from "./extract.js"; import { emitShadowSources, wrapFunctionName, wrapSessionFunctionName } from "./rewrite.js"; -import type { ClassRegistration, ClassSpec, GenOptions, GenResult, TypeSpec } from "./types.js"; +import type { ClassRegistration, ExtractedClassSpec, GenOptions, GenResult, TypeSpec } from "./types.js"; export function generate(options: GenOptions): GenResult { let prepared = prepare(options); @@ -39,7 +39,7 @@ export function generateValidatorsOnly(options: GenOptions): GenResult { } type Prepared = { - classes: ClassSpec[]; + classes: ExtractedClassSpec[]; outAbs: string; runtimeImport: string; sourceFile: ReturnType["sourceFile"]; @@ -70,8 +70,8 @@ function prepare(options: GenOptions): Prepared { return { classes, outAbs, runtimeImport, sourceFile, reachableFiles, root }; } -function checkNameCollisions(classes: ClassSpec[]): void { - let seen = new Map(); +function checkNameCollisions(classes: ExtractedClassSpec[]): void { + let seen = new Map(); for (let klass of classes) { let prior = seen.get(klass.name); if (prior) { @@ -102,7 +102,7 @@ function emitArtifacts(p: Prepared): void { log(p.outAbs, "clients.ts"); } -function emitSpecsModule(classes: ClassSpec[]): string { +function emitSpecsModule(classes: ExtractedClassSpec[]): string { return `// Generated by capnweb-typecheck gen. Do not edit.\n` + `export const validators = {\n` + classes.map(klass => ` ${JSON.stringify(klass.name)}: ${formatSpec(klass, 2)},`).join("\n") + @@ -118,7 +118,7 @@ function emitValidatorsModule(runtimeImport: string): string { `}\n`; } -function emitClientsModule(classes: ClassSpec[], runtimeImport: string): string { +function emitClientsModule(classes: ExtractedClassSpec[], runtimeImport: string): string { let parts = [ `// Generated by capnweb-typecheck gen. Do not edit.`, `import { __capnweb_bindClientValidator } from ${JSON.stringify(runtimeImport)};`, @@ -142,7 +142,7 @@ function emitClientsModule(classes: ClassSpec[], runtimeImport: string): string } function emitWorkerEntry( - importPath: string, hasDefault: boolean, classes: ClassSpec[], + importPath: string, hasDefault: boolean, classes: ExtractedClassSpec[], outAbs: string, root: string): string { let registration = emitRegistrationImportsAndCall(classes, outAbs, sourcePath => join(outAbs, "source", relative(root, sourcePath))); @@ -198,7 +198,7 @@ function emitRegistrationImportsAndCall( return { imports, entries }; } -function makeGenResult(classes: ClassSpec[]): GenResult { +function makeGenResult(classes: ExtractedClassSpec[]): GenResult { return { classes: classes.map(c => c.name), registrations: classes.map(({ name, valueName, isDefault, sourcePath }) => diff --git a/packages/capnweb-typecheck/src/rewrite.ts b/src/typecheck/rewrite.ts similarity index 100% rename from packages/capnweb-typecheck/src/rewrite.ts rename to src/typecheck/rewrite.ts diff --git a/src/typecheck/runtime.ts b/src/typecheck/runtime.ts index 2e1f813..195cf36 100644 --- a/src/typecheck/runtime.ts +++ b/src/typecheck/runtime.ts @@ -97,6 +97,25 @@ function validateTypeSpec( } return undefined; } + case "map": { + if (!(value instanceof Map)) return mismatch(); + for (let [key, item] of value) { + let keyFailure = validateTypeSpec(type.key, key, [...path, ""], options); + if (keyFailure) return keyFailure; + let valueFailure = validateTypeSpec(type.value, item, [...path, String(key)], options); + if (valueFailure) return valueFailure; + } + return undefined; + } + case "set": { + if (!(value instanceof Set)) return mismatch(); + let i = 0; + for (let item of value) { + let failure = validateTypeSpec(type.value, item, [...path, `[${i++}]`], options); + if (failure) return failure; + } + return undefined; + } case "object": { if (typeof value !== "object" || value === null || Array.isArray(value)) return mismatch(); let record = value as Record; @@ -147,6 +166,8 @@ function describeTypeSpec(type: TypeSpec): string { case "literal": return typeof type.value === "string" ? JSON.stringify(type.value) : String(type.value); case "array": return `${describeTypeSpec(type.element)}[]`; case "tuple": return `[${type.elements.map(describeTypeSpec).join(", ")}]`; + case "map": return `Map<${describeTypeSpec(type.key)}, ${describeTypeSpec(type.value)}>`; + case "set": return `Set<${describeTypeSpec(type.value)}>`; case "object": return `{ ${type.props.map(p => `${p.name}${p.optional ? "?" : ""}: ${describeTypeSpec(p.type)}`).join(", ")} }`; case "record": return `Record`; case "union": return type.variants.map(describeTypeSpec).sort().join(" | "); diff --git a/src/typecheck/types.ts b/src/typecheck/types.ts index 63503a0..c21f27f 100644 --- a/src/typecheck/types.ts +++ b/src/typecheck/types.ts @@ -10,6 +10,8 @@ export type TypeSpec = | { 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[] } | { kind: "record", value: TypeSpec } | { kind: "union", variants: TypeSpec[] } @@ -24,3 +26,24 @@ 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 ClassSpec = { name: string, methods: MethodSpec[] }; + +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/packages/capnweb-typecheck/src/vite.ts b/src/typecheck/vite.ts similarity index 81% rename from packages/capnweb-typecheck/src/vite.ts rename to src/typecheck/vite.ts index 1ac9b6a..9d08946 100644 --- a/packages/capnweb-typecheck/src/vite.ts +++ b/src/typecheck/vite.ts @@ -1,10 +1,6 @@ // Copyright (c) 2026 Cloudflare, Inc. // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit -// -// Vite plugin entry. Runs validator generation once per build, rewrites typed -// client factory/session calls in Vite's transform hook, and injects server -// validator registration into the worker entry module. import { existsSync } from "node:fs"; import { dirname, relative, resolve, sep } from "node:path"; @@ -57,10 +53,7 @@ export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlu 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, - }); + let result: GenResult = generateValidatorsOnly({ input: state.inputAbs, outDir: state.outDirAbs }); state.knownClasses = new Set(result.classes); state.registrations = result.registrations; generated = true; @@ -69,29 +62,18 @@ export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlu return { name: "capnweb-typecheck", enforce: "pre", - - configResolved(config) { - root = config.root ?? root; - }, - - buildStart() { - run(); - }, - + configResolved(config) { root = config.root ?? root; }, + buildStart() { run(); }, configureServer(server) { if (!generated) run(); server.watcher?.on("change", path => { if (!path.endsWith(".ts") && !path.endsWith(".tsx")) return; - // Skip our own generated output, otherwise writing the generated files - // re-triggers `run()` in a loop. if (path === state.validatorsAbs || path === state.clientsAbs) return; - if (state.outDirAbs && - (path === state.outDirAbs || path.startsWith(state.outDirAbs + sep))) return; + if (state.outDirAbs && (path === state.outDirAbs || path.startsWith(state.outDirAbs + sep))) return; generated = false; run(); }); }, - transform(code, id) { if (!id || id.startsWith("\0")) return null; let cleanId = id.split(/[?#]/, 1)[0]; @@ -100,12 +82,9 @@ export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlu 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-typecheck/vite has not generated clients.ts yet."); - } + 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); diff --git a/tsup.config.ts b/tsup.config.ts index 9c9252f..72316e3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,31 +4,41 @@ import { defineConfig } from 'tsup' -export default defineConfig({ - 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', - ], - 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 -}) + 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', + ], + external: ['cloudflare:workers'], + 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', + }, +]) From e00863c3b112b3631c28d87123f330380b49f808 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Wed, 13 May 2026 11:23:16 -0400 Subject: [PATCH 06/24] Use capnweb CLI for typecheck codegen --- .changeset/typescript-rpc-validators.md | 4 ++-- examples/worker-react/dev-debug.sh | 4 ++-- package-lock.json | 2 +- package.json | 2 +- src/typecheck/cli.ts | 11 +++++++---- src/typecheck/extract.ts | 2 +- src/typecheck/generate.ts | 8 ++++---- src/typecheck/vite.ts | 2 +- 8 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.changeset/typescript-rpc-validators.md b/.changeset/typescript-rpc-validators.md index 9387245..e1184f4 100644 --- a/.changeset/typescript-rpc-validators.md +++ b/.changeset/typescript-rpc-validators.md @@ -4,9 +4,9 @@ Add build-time TypeScript RPC validation codegen. -A new opt-in `capnweb-typecheck` CLI and `capnweb/vite` plugin generate runtime validators for `RpcTarget` methods from your TypeScript types. The main `capnweb` runtime and the typecheck tooling stay dependency-free. +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 and typecheck tooling stay dependency-free. -- `capnweb-typecheck` CLI: `capnweb-typecheck gen src/worker.ts --out .capnweb` for Wrangler-style builds. +- `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/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index c7494b3..cf7f727 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -59,9 +59,9 @@ if [[ "$TYPECHECK" == "true" ]]; then env NODE_OPTIONS= npm run build cd "$SCRIPT_DIR" - echo "Debugging capnweb-typecheck gen for examples/worker-react/src/worker.ts" + echo "Debugging capnweb typecheck gen for examples/worker-react/src/worker.ts" env NODE_OPTIONS= node --enable-source-maps --inspect=0 \ - "$REPO_ROOT/dist/cli.js" gen src/worker.ts --out .capnweb + "$REPO_ROOT/dist/cli.js" typecheck gen src/worker.ts --out .capnweb fi VITE_PLUGIN_IMPORT="" diff --git a/package-lock.json b/package-lock.json index 42ea70c..3865722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.0", "license": "MIT", "bin": { - "capnweb-typecheck": "dist/cli.js" + "capnweb": "dist/cli.js" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", diff --git a/package.json b/package.json index a550686..3071a1c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ } }, "bin": { - "capnweb-typecheck": "./dist/cli.js" + "capnweb": "./dist/cli.js" }, "type": "module", "publishConfig": { diff --git a/src/typecheck/cli.ts b/src/typecheck/cli.ts index 5670b7d..9f84817 100644 --- a/src/typecheck/cli.ts +++ b/src/typecheck/cli.ts @@ -15,18 +15,21 @@ import { generate, 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 gen [--out ] Example: - capnweb-typecheck gen src/worker.ts --out .capnweb`); + capnweb typecheck gen src/worker.ts --out .capnweb`); process.exit(exitCode); } async function main() { - let [command, ...rest] = process.argv.slice(2); + let [command, subcommand, ...rest] = process.argv.slice(2); if (!command || command === "--help" || command === "-h") usage(0); - if (command !== "gen") usage(); + if (command !== "typecheck") usage(); + if (!subcommand || subcommand === "--help" || subcommand === "-h") usage(0); + if (subcommand !== "gen") usage(); + if (rest.includes("--help") || rest.includes("-h")) usage(0); let options = parseGenArgs(rest); generate(options); } diff --git a/src/typecheck/extract.ts b/src/typecheck/extract.ts index 817c2d9..96884ed 100644 --- a/src/typecheck/extract.ts +++ b/src/typecheck/extract.ts @@ -189,7 +189,7 @@ function propertyNameText(name: ts.PropertyName): string | undefined { function lowerType( checker: ts.TypeChecker, type: ts.Type, location: string, visiting: Set): TypeSpec { if (visiting.has(type)) { - throw new Error(`${location}: recursive types are not supported by capnweb-typecheck yet.`); + throw new Error(`${location}: recursive types are not supported by capnweb typecheck yet.`); } visiting.add(type); try { diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index 459bf5e..c2e8938 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -103,14 +103,14 @@ function emitArtifacts(p: Prepared): void { } function emitSpecsModule(classes: ExtractedClassSpec[]): string { - return `// Generated by capnweb-typecheck gen. Do not edit.\n` + + return `// Generated by capnweb typecheck gen. Do not edit.\n` + `export const validators = {\n` + classes.map(klass => ` ${JSON.stringify(klass.name)}: ${formatSpec(klass, 2)},`).join("\n") + `\n};\n`; } function emitValidatorsModule(runtimeImport: string): string { - return `// Generated by capnweb-typecheck gen. Do not edit.\n` + + return `// Generated by capnweb typecheck gen. Do not edit.\n` + `import { __capnweb_registerRpcValidators } from ${JSON.stringify(runtimeImport)};\n` + `import { validators } from "./specs.js";\n\n` + `export function registerCapnwebValidators(classes: Record): void {\n` + @@ -120,7 +120,7 @@ function emitValidatorsModule(runtimeImport: string): string { function emitClientsModule(classes: ExtractedClassSpec[], runtimeImport: string): string { let parts = [ - `// Generated by capnweb-typecheck gen. Do not edit.`, + `// Generated by capnweb typecheck gen. Do not edit.`, `import { __capnweb_bindClientValidator } from ${JSON.stringify(runtimeImport)};`, `import { validators } from "./specs.js";`, ``, @@ -146,7 +146,7 @@ function emitWorkerEntry( 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` + + 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` + diff --git a/src/typecheck/vite.ts b/src/typecheck/vite.ts index 9d08946..05dcf58 100644 --- a/src/typecheck/vite.ts +++ b/src/typecheck/vite.ts @@ -60,7 +60,7 @@ export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlu }; return { - name: "capnweb-typecheck", + name: "capnweb:typecheck", enforce: "pre", configResolved(config) { root = config.root ?? root; }, buildStart() { run(); }, From 08e5518a5d8c8c534d1890dadcd78d7e3bb3af83 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Wed, 13 May 2026 12:32:47 -0400 Subject: [PATCH 07/24] Generate typecheck validators at build time --- src/index.ts | 4 +- src/internal-typecheck.ts | 10 +- src/typecheck/generate.ts | 340 ++++++++++++++++++++++++++++++++++---- src/typecheck/runtime.ts | 186 +-------------------- src/typecheck/types.ts | 1 - 5 files changed, 322 insertions(+), 219 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5360d37..6723788 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { RpcStub as RpcStubImpl, RpcPromise as RpcPromiseImpl, } from "./core.js"; -// Pulled in so the validator runtime is part of the main bundle. The actual +// 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 @@ -42,7 +42,7 @@ export type { RpcTransport, RpcSessionOptions, RpcCompatible }; // 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 -// runtime registry stays in a single bundle (one shared WeakMap). +// 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` diff --git a/src/internal-typecheck.ts b/src/internal-typecheck.ts index 3f85989..2b616c0 100644 --- a/src/internal-typecheck.ts +++ b/src/internal-typecheck.ts @@ -9,15 +9,13 @@ // 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 { - ClassSpec, - MethodSpec, - ObjectProp, - ParamSpec, - PrimitiveName, - TypeSpec, + RpcClassValidators, + RpcMethodValidator, + RpcValidationOptions, } from "./typecheck/runtime.js"; diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index c2e8938..f1bb86b 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -12,7 +12,7 @@ import { extractClasses, } from "./extract.js"; import { emitShadowSources, wrapFunctionName, wrapSessionFunctionName } from "./rewrite.js"; -import type { ClassRegistration, ExtractedClassSpec, GenOptions, GenResult, TypeSpec } from "./types.js"; +import type { ClassRegistration, ExtractedClassSpec, GenOptions, GenResult, MethodSpec, TypeSpec } from "./types.js"; export function generate(options: GenOptions): GenResult { let prepared = prepare(options); @@ -87,6 +87,8 @@ function checkSupported(type: TypeSpec): void { case "unsupported": throw new Error(`Unsupported RPC type: ${type.text}`); case "array": checkSupported(type.element); break; case "tuple": for (let element of type.elements) checkSupported(element); break; + case "map": checkSupported(type.key); checkSupported(type.value); break; + case "set": checkSupported(type.value); break; case "object": for (let prop of type.props) checkSupported(prop.type); break; case "record": checkSupported(type.value); break; case "union": for (let variant of type.variants) checkSupported(variant); break; @@ -94,7 +96,7 @@ function checkSupported(type: TypeSpec): void { } function emitArtifacts(p: Prepared): void { - writeFileSync(join(p.outAbs, "specs.ts"), emitSpecsModule(p.classes)); + writeFileSync(join(p.outAbs, "specs.ts"), emitSpecsModule(p.classes, p.runtimeImport)); log(p.outAbs, "specs.ts"); writeFileSync(join(p.outAbs, "validators.ts"), emitValidatorsModule(p.runtimeImport)); log(p.outAbs, "validators.ts"); @@ -102,41 +104,319 @@ function emitArtifacts(p: Prepared): void { log(p.outAbs, "clients.ts"); } -function emitSpecsModule(classes: ExtractedClassSpec[]): string { - return `// Generated by capnweb typecheck gen. Do not edit.\n` + - `export const validators = {\n` + - classes.map(klass => ` ${JSON.stringify(klass.name)}: ${formatSpec(klass, 2)},`).join("\n") + - `\n};\n`; +function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string): string { + let emit: ValidatorEmit = { lines: [], nextId: 0 }; + 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); + return emitMethodValidator(klass.name, method, paramValidators, returnValidator); + }); + 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) + ";", + "", + "type __CapnwebValidationOptions = { isRpcPlaceholder?: (value: unknown) => boolean };", + "type __CapnwebValidationFailure = { path: string[]; expected: string; actual: string; value: unknown };", + "", + "function __capnweb_mismatch(path: string[], expected: string, value: unknown): __CapnwebValidationFailure {", + " return { path, expected, actual: __capnweb_actualKind(value), value };", + "}", + "", + "function __capnweb_missing(path: string[], expected: string): __CapnwebValidationFailure {", + " return { path, expected, actual: \"missing\", value: undefined };", + "}", + "", + "function __capnweb_makeValidationError(prefix: string, failure: __CapnwebValidationFailure): TypeError {", + " let where = failure.path.length > 0 ? failure.path.join(\".\") : \"value\";", + " let err = new TypeError(prefix + \": \" + where + \": expected \" + failure.expected + \", got \" + failure.actual);", + " (err as TypeError & { rpcValidation: __CapnwebValidationFailure }).rpcValidation = failure;", + " return err;", + "}", + "", + "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;", + "}", + "", + ...emit.lines, + "export const validators = {", + classEntries.join(",\n"), + "};", + "", + ]; + return lines.join("\n"); +} + +type ValidatorEmit = { lines: string[]; nextId: number }; + +function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { + let name = "__capnweb_validate_" + emit.nextId++; + let expected = JSON.stringify(typeDescription(type)); + let lines = [ + "function " + name + "(value: unknown, path: string[], options?: __CapnwebValidationOptions): __CapnwebValidationFailure | undefined {", + " if (options?.isRpcPlaceholder?.(value)) return undefined;", + ]; + + switch (type.kind) { + case "any": + lines.push(" return undefined;"); + break; + case "never": + lines.push(" return __capnweb_mismatch(path, " + expected + ", value);"); + break; + case "primitive": + lines.push(" return " + primitiveCheck("value", type.name) + " ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + break; + case "literal": + lines.push(" return value === " + JSON.stringify(type.value) + " ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + break; + case "array": { + let element = emitTypeValidator(type.element, emit); + lines.push( + " if (!Array.isArray(value)) return __capnweb_mismatch(path, " + expected + ", value);", + " for (let i = 0; i < value.length; i++) {", + " let failure = " + element + "(value[i], [...path, \"[\" + i + \"]\"], options);", + " if (failure) return failure;", + " }", + " return undefined;"); + break; + } + case "tuple": { + let elements = type.elements.map(element => emitTypeValidator(element, emit)); + lines.push( + " if (!Array.isArray(value) || value.length !== " + elements.length + ") return __capnweb_mismatch(path, " + expected + ", value);"); + elements.forEach((element, index) => { + lines.push( + " {", + " let failure = " + element + "(value[" + index + "], [...path, \"[" + index + "]\"], options);", + " if (failure) return failure;", + " }"); + }); + lines.push(" return undefined;"); + break; + } + case "map": { + let key = emitTypeValidator(type.key, emit); + let value = emitTypeValidator(type.value, emit); + lines.push( + " if (!(value instanceof Map)) return __capnweb_mismatch(path, " + expected + ", value);", + " for (let [key, item] of value) {", + " let keyFailure = " + key + "(key, [...path, \"\"], options);", + " if (keyFailure) return keyFailure;", + " let valueFailure = " + value + "(item, [...path, String(key)], options);", + " if (valueFailure) return valueFailure;", + " }", + " return undefined;"); + break; + } + case "set": { + let value = emitTypeValidator(type.value, emit); + lines.push( + " if (!(value instanceof Set)) return __capnweb_mismatch(path, " + expected + ", value);", + " let i = 0;", + " for (let item of value) {", + " let failure = " + value + "(item, [...path, \"[\" + i++ + \"]\"], options);", + " if (failure) return failure;", + " }", + " return undefined;"); + break; + } + case "object": + lines.push( + " if (typeof value !== \"object\" || value === null || Array.isArray(value)) return __capnweb_mismatch(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 + "]"; + if (prop.optional) { + lines.push( + " if (Object.prototype.hasOwnProperty.call(record, " + propName + ") && record[" + propName + "] !== undefined) {", + " let failure = " + propValidator + "(record[" + propName + "], " + propPath + ", options);", + " if (failure) return failure;", + " }"); + } else { + lines.push( + " if (!Object.prototype.hasOwnProperty.call(record, " + propName + ")) return __capnweb_missing(" + propPath + ", " + JSON.stringify(typeDescription(prop.type)) + ");", + " {", + " let failure = " + propValidator + "(record[" + propName + "], " + propPath + ", options);", + " if (failure) return failure;", + " }"); + } + } + lines.push(" return undefined;"); + break; + case "record": { + let value = emitTypeValidator(type.value, emit); + lines.push( + " if (typeof value !== \"object\" || value === null || Array.isArray(value)) return __capnweb_mismatch(path, " + expected + ", value);", + " for (let [key, item] of Object.entries(value as Record)) {", + " let failure = " + value + "(item, [...path, key], options);", + " if (failure) return failure;", + " }", + " return undefined;"); + break; + } + case "union": { + let variants = type.variants.map(variant => emitTypeValidator(variant, emit)); + lines.push(" let failures: __CapnwebValidationFailure[] = [];"); + for (let variant of variants) { + lines.push( + " {", + " let failure = " + variant + "(value, path, options);", + " if (!failure) return undefined;", + " failures.push(failure);", + " }"); + } + lines.push(" return failures.find(failure => failure.path.length > path.length) ?? __capnweb_mismatch(path, " + expected + ", value);"); + break; + } + case "instance": + lines.push( + " let ctor = (globalThis as Record)[" + JSON.stringify(type.name) + "];", + " return typeof ctor === \"function\" && value instanceof ctor ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + break; + case "rpcTarget": + lines.push(" return value instanceof __capnweb_RpcTarget ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + break; + case "stub": + lines.push(" return value !== null && (typeof value === \"object\" || typeof value === \"function\") ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + break; + case "function": + lines.push(" return typeof value === \"function\" ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + break; + case "unsupported": + lines.push(" return __capnweb_mismatch(path, " + expected + ", value);"); + 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): 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 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);", + " }", + ]; + + method.params.forEach((param, index) => { + let path = JSON.stringify([param.name]); + if (param.optional) { + lines.push( + " if (args[" + index + "] !== undefined) {", + " let failure = " + paramValidators[index] + "(args[" + index + "], " + path + ", options);", + " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix) + ", failure);", + " }"); + } else { + lines.push( + " {", + " let failure = " + paramValidators[index] + "(args[" + index + "], " + path + ", options);", + " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix) + ", failure);", + " }"); + } + }); + + lines.push( + " },", + " returns(value: unknown): void {", + " let failure = " + returnValidator + "(value, []);", + " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix + " return") + ", failure);", + " },", + " }"); + 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; + } } function emitValidatorsModule(runtimeImport: string): string { - return `// Generated by capnweb typecheck gen. Do not edit.\n` + - `import { __capnweb_registerRpcValidators } from ${JSON.stringify(runtimeImport)};\n` + - `import { validators } from "./specs.js";\n\n` + - `export function registerCapnwebValidators(classes: Record): void {\n` + - ` __capnweb_registerRpcValidators(classes, Object.values(validators));\n` + - `}\n`; + 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";`, - ``, + "// 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(``); + 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"); } @@ -206,12 +486,6 @@ function makeGenResult(classes: ExtractedClassSpec[]): GenResult { }; } -const GEN_ONLY_KEYS = new Set(["valueName", "isDefault", "sourcePath"]); - -function formatSpec(value: unknown, indent: number): string { - let json = JSON.stringify(value, (key, v) => GEN_ONLY_KEYS.has(key) ? undefined : v, 2); - return json.split("\n").map((line, i) => i === 0 ? line : " ".repeat(indent) + line).join("\n"); -} function jsImportPath(path: string): string { let normalized = path.split(/[/\\]+/).join("/"); diff --git a/src/typecheck/runtime.ts b/src/typecheck/runtime.ts index 195cf36..2c86215 100644 --- a/src/typecheck/runtime.ts +++ b/src/typecheck/runtime.ts @@ -5,192 +5,24 @@ import { RpcTarget, type RpcClassValidators, - type RpcMethodValidator, - type RpcValidationOptions, setRpcMethodValidators, setRpcStubValidators, } from "../core.js"; -import type { ClassSpec, MethodSpec, TypeSpec } from "./types.js"; - -type RpcValidationFailure = { path: string[]; expected: string; actual: string; value: unknown }; export function __capnweb_registerRpcValidators( - classes: Record, classSpecs: readonly ClassSpec[]): void { - for (let spec of classSpecs) { - let klass = classes[spec.name]; - if (!klass) throw new Error(`Missing class '${spec.name}' for Cap'n Web validator.`); - let validators: RpcClassValidators = {}; - for (let method of spec.methods) validators[method.name] = makeMethodValidator(spec.name, method); - setRpcMethodValidators(klass, validators); + 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, classSpec: ClassSpec): T { - let validators: RpcClassValidators = {}; - for (let method of classSpec.methods) validators[method.name] = makeMethodValidator(classSpec.name, method); +export function __capnweb_bindClientValidator( + stub: T, validators: RpcClassValidators): T { setRpcStubValidators(stub, validators); return stub; } -function makeMethodValidator(className: string, method: MethodSpec): RpcMethodValidator { - let prefix = `${className}.${method.name}`; - let minArgs = method.params.reduce((min, param, index) => param.optional ? min : index + 1, 0); - return { - args(args, options) { - if (args.length < minArgs || args.length > method.params.length) { - let expected = minArgs === method.params.length ? `${minArgs}` : `${minArgs}-${method.params.length}`; - throw new TypeError(`${prefix} expected ${expected} argument(s), got ${args.length}`); - } - for (let i = 0; i < args.length; i++) { - let param = method.params[i]; - if (!param || (param.optional && args[i] === undefined)) continue; - let failure = validateTypeSpec(param.type, args[i], [param.name], options); - if (failure) throw makeValidationError(prefix, failure); - } - }, - returns(value) { - let failure = validateTypeSpec(method.returns, value, []); - if (failure) throw makeValidationError(`${prefix} return`, failure); - }, - }; -} - -function makeValidationError(prefix: string, failure: RpcValidationFailure): TypeError { - let where = failure.path.length > 0 ? failure.path.join(".") : "value"; - let err = new TypeError(`${prefix}: ${where}: expected ${failure.expected}, got ${failure.actual}`); - (err as TypeError & { rpcValidation: RpcValidationFailure }).rpcValidation = failure; - return err; -} - -function validateTypeSpec( - type: TypeSpec, value: unknown, path: string[], - options?: RpcValidationOptions): RpcValidationFailure | undefined { - if (options?.isRpcPlaceholder?.(value)) return undefined; - let mismatch = (): RpcValidationFailure => ({ path, expected: describeTypeSpec(type), actual: actualKind(value), value }); - switch (type.kind) { - case "any": return undefined; - case "never": return mismatch(); - case "primitive": - switch (type.name) { - case "string": return typeof value === "string" ? undefined : mismatch(); - case "number": return typeof value === "number" ? undefined : mismatch(); - case "bigint": return typeof value === "bigint" ? undefined : mismatch(); - case "boolean": return typeof value === "boolean" ? undefined : mismatch(); - case "undefined": - case "void": return value === undefined ? undefined : mismatch(); - case "null": return value === null ? undefined : mismatch(); - } - case "literal": return value === type.value ? undefined : mismatch(); - case "array": { - if (!Array.isArray(value)) return mismatch(); - for (let i = 0; i < value.length; i++) { - let failure = validateTypeSpec(type.element, value[i], [...path, `[${i}]`], options); - if (failure) return failure; - } - return undefined; - } - case "tuple": { - if (!Array.isArray(value) || value.length !== type.elements.length) return mismatch(); - for (let i = 0; i < type.elements.length; i++) { - let failure = validateTypeSpec(type.elements[i], value[i], [...path, `[${i}]`], options); - if (failure) return failure; - } - return undefined; - } - case "map": { - if (!(value instanceof Map)) return mismatch(); - for (let [key, item] of value) { - let keyFailure = validateTypeSpec(type.key, key, [...path, ""], options); - if (keyFailure) return keyFailure; - let valueFailure = validateTypeSpec(type.value, item, [...path, String(key)], options); - if (valueFailure) return valueFailure; - } - return undefined; - } - case "set": { - if (!(value instanceof Set)) return mismatch(); - let i = 0; - for (let item of value) { - let failure = validateTypeSpec(type.value, item, [...path, `[${i++}]`], options); - if (failure) return failure; - } - return undefined; - } - case "object": { - if (typeof value !== "object" || value === null || Array.isArray(value)) return mismatch(); - let record = value as Record; - for (let prop of type.props) { - if (!Object.prototype.hasOwnProperty.call(record, prop.name)) { - if (prop.optional) continue; - return { path: [...path, prop.name], expected: describeTypeSpec(prop.type), actual: "missing", value: undefined }; - } - if (record[prop.name] === undefined && prop.optional) continue; - let failure = validateTypeSpec(prop.type, record[prop.name], [...path, prop.name], options); - if (failure) return failure; - } - return undefined; - } - case "record": { - if (typeof value !== "object" || value === null || Array.isArray(value)) return mismatch(); - for (let [key, item] of Object.entries(value as Record)) { - let failure = validateTypeSpec(type.value, item, [...path, key], options); - if (failure) return failure; - } - return undefined; - } - case "union": { - let failures: RpcValidationFailure[] = []; - for (let variant of type.variants) { - let failure = validateTypeSpec(variant, value, path, options); - if (!failure) return undefined; - failures.push(failure); - } - return failures.find(failure => failure.path.length > path.length) ?? mismatch(); - } - case "instance": { - let ctor = (globalThis as Record)[type.name]; - return typeof ctor === "function" && value instanceof ctor ? undefined : mismatch(); - } - case "rpcTarget": return value instanceof RpcTarget ? undefined : mismatch(); - case "stub": return value !== null && (typeof value === "object" || typeof value === "function") ? undefined : mismatch(); - case "function": return typeof value === "function" ? undefined : mismatch(); - case "unsupported": return mismatch(); - } -} - -function describeTypeSpec(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 `${describeTypeSpec(type.element)}[]`; - case "tuple": return `[${type.elements.map(describeTypeSpec).join(", ")}]`; - case "map": return `Map<${describeTypeSpec(type.key)}, ${describeTypeSpec(type.value)}>`; - case "set": return `Set<${describeTypeSpec(type.value)}>`; - case "object": return `{ ${type.props.map(p => `${p.name}${p.optional ? "?" : ""}: ${describeTypeSpec(p.type)}`).join(", ")} }`; - case "record": return `Record`; - case "union": return type.variants.map(describeTypeSpec).sort().join(" | "); - case "instance": return type.name; - case "rpcTarget": return "RpcTarget"; - case "stub": return "RpcStub"; - case "function": return "Function"; - case "unsupported": return type.text; - } -} - -function 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; -} - -export type { ClassSpec, MethodSpec, ObjectProp, ParamSpec, PrimitiveName, TypeSpec } from "./types.js"; +export { RpcTarget }; +export type { RpcClassValidators, RpcMethodValidator, RpcValidationOptions } from "../core.js"; diff --git a/src/typecheck/types.ts b/src/typecheck/types.ts index c21f27f..7f0d1ec 100644 --- a/src/typecheck/types.ts +++ b/src/typecheck/types.ts @@ -25,7 +25,6 @@ export type TypeSpec = 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 ClassSpec = { name: string, methods: MethodSpec[] }; export type ClassRegistration = { name: string; From 65773d9f239b0c08e29ee1811df85b8e8ff540bd Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Wed, 13 May 2026 15:29:41 -0400 Subject: [PATCH 08/24] Fix vite plugin jsImportPath for .tsx/.mts and add codegen comment --- src/typecheck/vite.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/typecheck/vite.ts b/src/typecheck/vite.ts index 05dcf58..8ae62ff 100644 --- a/src/typecheck/vite.ts +++ b/src/typecheck/vite.ts @@ -66,6 +66,10 @@ export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlu buildStart() { run(); }, configureServer(server) { if (!generated) run(); + // Re-runs full codegen on any TS file change. 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. server.watcher?.on("change", path => { if (!path.endsWith(".ts") && !path.endsWith(".tsx")) return; if (path === state.validatorsAbs || path === state.clientsAbs) return; @@ -97,6 +101,8 @@ export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlu 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.replace(/\.ts$/, ".js"); + return normalized; } From 23c1f7e025cae65046df6bd2326eb5a270fc893f Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Wed, 13 May 2026 17:20:00 -0400 Subject: [PATCH 09/24] Address typecheck PR review findings --- .changeset/typescript-rpc-validators.md | 2 +- __tests__/typecheck.test.ts | 102 ++++++++++++++++++++---- __tests__/vite-plugin.test.ts | 16 ++++ examples/worker-react/dev-debug.sh | 2 +- examples/worker-react/src/worker.ts | 13 +-- package-lock.json | 10 ++- package.json | 12 ++- src/core.ts | 45 ++++++++--- src/typecheck/extract.ts | 43 +++++++--- src/typecheck/generate.ts | 95 ++++++++++++++++++++-- src/typecheck/rewrite.ts | 30 ++++++- src/typecheck/vite.ts | 11 ++- 12 files changed, 323 insertions(+), 58 deletions(-) diff --git a/.changeset/typescript-rpc-validators.md b/.changeset/typescript-rpc-validators.md index e1184f4..9f56e30 100644 --- a/.changeset/typescript-rpc-validators.md +++ b/.changeset/typescript-rpc-validators.md @@ -4,7 +4,7 @@ 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 and typecheck tooling stay dependency-free. +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. diff --git a/__tests__/typecheck.test.ts b/__tests__/typecheck.test.ts index 3e0c160..1a59afc 100644 --- a/__tests__/typecheck.test.ts +++ b/__tests__/typecheck.test.ts @@ -268,6 +268,24 @@ describe("RpcTarget class extraction", () => { } }); + 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 { @@ -302,6 +320,10 @@ describe("preflight type rejection", () => { /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 { @@ -339,7 +361,7 @@ describe("preflight type rejection", () => { it.each([ "ArrayBuffer", "DataView", "RegExp", "Uint16Array", "Float32Array", - ])("accepts structured-clone native declared by RpcCompatible: %s", type => { + ])("rejects native not currently serialized by Cap'n Web: %s", type => { let root = mkdtempSync(resolve(".capnweb-native-")); try { writeFakeCapnweb(root); @@ -348,7 +370,23 @@ describe("preflight type rejection", () => { import { RpcTarget } from "./fake-capnweb.js"; export class Api extends RpcTarget { valueArg(value: ${type}): void {} } `); - expect(inspectInput(input).classes[0].methods[0].params[0].type.kind).toBe("instance"); + 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 }); } @@ -477,9 +515,6 @@ describe("end-to-end validator codegen", () => { maybe: string | null; created: Date; bytes: Uint8Array; - buffer: ArrayBuffer; - view: DataView; - pattern: RegExp; literal: "ok"; }): void {} getUser(token: string): { id: string } { return { id: token }; } @@ -546,9 +581,6 @@ describe("end-to-end validator codegen", () => { maybe: null, created: new Date(0), bytes: new Uint8Array([1, 2, 3]), - buffer: new ArrayBuffer(1), - view: new DataView(new ArrayBuffer(1)), - pattern: /ok/, literal: "ok", }])).resolves.toBeUndefined(); @@ -564,9 +596,6 @@ describe("end-to-end validator codegen", () => { maybe: null, created: new Date(0), bytes: new Uint8Array(), - buffer: new ArrayBuffer(1), - view: new DataView(new ArrayBuffer(1)), - pattern: /ok/, literal: "ok", }])).rejects.toThrow(/value\.byId\.a\.active: expected boolean, got string/); @@ -582,11 +611,38 @@ describe("end-to-end validator codegen", () => { maybe: null, created: new Date(0), bytes: new Uint8Array(), - buffer: new ArrayBuffer(1), - view: new DataView(new ArrayBuffer(1)), - pattern: /ok/, 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/); }); }); @@ -616,6 +672,24 @@ describe("end-to-end validator codegen", () => { 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 }); } diff --git a/__tests__/vite-plugin.test.ts b/__tests__/vite-plugin.test.ts index 0bf2f21..efa4002 100644 --- a/__tests__/vite-plugin.test.ts +++ b/__tests__/vite-plugin.test.ts @@ -34,6 +34,22 @@ function local(newHttpBatchRpcSession: any) { 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 { diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index cf7f727..0001524 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -61,7 +61,7 @@ if [[ "$TYPECHECK" == "true" ]]; then cd "$SCRIPT_DIR" echo "Debugging capnweb typecheck gen for examples/worker-react/src/worker.ts" env NODE_OPTIONS= node --enable-source-maps --inspect=0 \ - "$REPO_ROOT/dist/cli.js" typecheck gen src/worker.ts --out .capnweb + "$REPO_ROOT/dist/cli.cjs" typecheck gen src/worker.ts --out .capnweb fi VITE_PLUGIN_IMPORT="" 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 3865722..27a194e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.0", "license": "MIT", "bin": { - "capnweb": "dist/cli.js" + "capnweb": "dist/cli.cjs" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", @@ -26,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": { diff --git a/package.json b/package.json index 3071a1c..81de53d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ } }, "bin": { - "capnweb": "./dist/cli.js" + "capnweb": "./dist/cli.cjs" }, "type": "module", "publishConfig": { @@ -87,5 +87,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/core.ts b/src/core.ts index 7f2f030..137c5ac 100644 --- a/src/core.ts +++ b/src/core.ts @@ -44,6 +44,7 @@ export type RpcValidationOptions = { 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; @@ -399,9 +400,8 @@ export function setRpcStubValidators(stub: object, validators: RpcClassValidator } function getRpcStubValidator(stub: RpcStub): RpcMethodValidator | undefined { - if (!stub.pathIfPromise || stub.pathIfPromise.length === 0) return undefined; - let methodName = stub.pathIfPromise[stub.pathIfPromise.length - 1]; - return rpcStubValidators.get(stub.hook)?.[methodName]; + if (!stub.pathIfPromise || stub.pathIfPromise.length !== 1) return undefined; + return rpcStubValidators.get(stub.hook)?.[stub.pathIfPromise[0]]; } function setRpcPromiseReturnValidator(promise: RpcPromise, validator: RpcMethodValidator): void { @@ -421,9 +421,21 @@ function withRpcPromiseReturnValidator( 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 { - let {hook, pathIfPromise} = promise[RAW_STUB]; - return pathIfPromise && pathIfPromise.length === 0 ? rpcReturnValidators.get(hook) : undefined; + return rpcReturnValidators.get(promise[RAW_STUB].hook); } function isRpcPromisePlaceholder(value: unknown): boolean { @@ -709,16 +721,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]; - let validator = pathIfPromise!.length === 0 ? rpcReturnValidators.get(hook) : undefined; - 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(validator?.returns); + return payload.deliverResolve(validate); } // ======================================================================================= @@ -1082,6 +1098,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 { @@ -1325,8 +1342,14 @@ export class RpcPayload { // In all other cases, await the result (which may or may not be a promise, but `await` // will just pass through non-promises). let awaited = await result; - await validator?.returns?.(awaited); - return RpcPayload.fromAppReturn(awaited); + let payload = RpcPayload.fromAppReturn(awaited); + try { + await validator?.returns?.(awaited); + return payload; + } catch (err) { + payload.dispose(); + throw err; + } } } finally { this.dispose(); diff --git a/src/typecheck/extract.ts b/src/typecheck/extract.ts index 96884ed..8a3b1f2 100644 --- a/src/typecheck/extract.ts +++ b/src/typecheck/extract.ts @@ -11,11 +11,13 @@ const OPAQUE_NATIVES = new Set([ "Date", "Error", "EvalError", "RangeError", "ReferenceError", "SyntaxError", "TypeError", "URIError", "AggregateError", "ReadableStream", "WritableStream", "Request", "Response", "Headers", - "Blob", "ArrayBuffer", "DataView", "RegExp", - "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", - "Int8Array", "Int16Array", "Int32Array", - "BigUint64Array", "BigInt64Array", - "Float32Array", "Float64Array", + "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; @@ -155,7 +157,8 @@ function extractMethods( for (let member of klass.members) { if (!ts.isMethodDeclaration(member)) continue; if (hasModifier(member, ts.SyntaxKind.PrivateKeyword) || - hasModifier(member, ts.SyntaxKind.ProtectedKeyword)) continue; + 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.`); @@ -202,6 +205,9 @@ function lowerType( 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" }; @@ -215,17 +221,23 @@ function lowerType( return variants.length === 1 ? variants[0] : { kind: "union", variants }; } if (type.isIntersection()) return { kind: "object", props: objectProps(checker, type, location, visiting) }; - if (checker.isArrayType(type)) { - let arg = checker.getTypeArguments(type as ts.TypeReference)[0]; - return { kind: "array", element: arg ? lowerType(checker, arg, location, visiting) : { kind: "any" } }; - } 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, visiting)), }; } + if (checker.isArrayType(type)) { + let arg = checker.getTypeArguments(type as ts.TypeReference)[0]; + return { kind: "array", element: arg ? lowerType(checker, arg, location, visiting) : { kind: "any" } }; + } if (type.getCallSignatures().length > 0) return { kind: "function" }; let symbol = type.aliasSymbol ?? type.getSymbol(); @@ -246,6 +258,9 @@ function lowerType( value: typeArgs[0] ? lowerType(checker, typeArgs[0], location, visiting) : { 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, visiting) : { kind: "any" }; @@ -257,7 +272,13 @@ function lowerType( 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) return { kind: "record", value: lowerType(checker, index, location, visiting) }; + 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, visiting) }; + } return { kind: "object", props: objectProps(checker, type, location, visiting) }; } finally { visiting.delete(type); diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index f1bb86b..27f0358 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -112,7 +112,8 @@ function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string): let methodEntries = klass.methods.map(method => { let paramValidators = method.params.map(param => emitTypeValidator(param.type, emit)); let returnValidator = emitTypeValidator(method.returns, emit); - return emitMethodValidator(klass.name, method, paramValidators, returnValidator); + 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 }"); @@ -123,13 +124,13 @@ function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string): "import { RpcTarget as __capnweb_RpcTarget } from " + JSON.stringify(runtimeImport) + ";", "", "type __CapnwebValidationOptions = { isRpcPlaceholder?: (value: unknown) => boolean };", - "type __CapnwebValidationFailure = { path: string[]; expected: string; actual: string; value: unknown };", + "type __CapnwebValidationFailure = { path: (string | number)[]; expected: string; actual: string; value: unknown };", "", - "function __capnweb_mismatch(path: string[], expected: string, value: unknown): __CapnwebValidationFailure {", + "function __capnweb_mismatch(path: (string | number)[], expected: string, value: unknown): __CapnwebValidationFailure {", " return { path, expected, actual: __capnweb_actualKind(value), value };", "}", "", - "function __capnweb_missing(path: string[], expected: string): __CapnwebValidationFailure {", + "function __capnweb_missing(path: (string | number)[], expected: string): __CapnwebValidationFailure {", " return { path, expected, actual: \"missing\", value: undefined };", "}", "", @@ -169,7 +170,7 @@ function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { let name = "__capnweb_validate_" + emit.nextId++; let expected = JSON.stringify(typeDescription(type)); let lines = [ - "function " + name + "(value: unknown, path: string[], options?: __CapnwebValidationOptions): __CapnwebValidationFailure | undefined {", + "function " + name + "(value: unknown, path: (string | number)[], options?: __CapnwebValidationOptions): __CapnwebValidationFailure | undefined {", " if (options?.isRpcPlaceholder?.(value)) return undefined;", ]; @@ -311,6 +312,83 @@ function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { return name; } +function emitTypePathValidator(type: TypeSpec, emit: ValidatorEmit): string { + let name = "__capnweb_validate_path_" + emit.nextId++; + let fullValidator = emitTypeValidator(type, emit); + let lines = [ + "function " + name + "(path: (string | number)[], value: unknown, offset = 0, options?: __CapnwebValidationOptions): __CapnwebValidationFailure | undefined {", + " if (offset >= path.length) return " + fullValidator + "(value, path, options);", + ]; + + 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(" return value === undefined ? undefined : " + child + "(path, value, offset + 1, options);"); + } else { + lines.push(" return " + child + "(path, value, offset + 1, options);"); + } + } + lines.push( + " default: return undefined;", + " }"); + break; + } + case "array": { + let child = emitTypePathValidator(type.element, emit); + lines.push(" return " + 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 + ": return " + element + "(path, value, offset + 1, options);"); + }); + lines.push( + " default: return undefined;", + " }"); + break; + } + case "record": { + let child = emitTypePathValidator(type.value, emit); + lines.push(" return " + child + "(path, value, offset + 1, options);"); + break; + } + case "map": { + let child = emitTypePathValidator(type.value, emit); + lines.push(" return " + 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( + " {", + " let failure = " + variant + "(path, value, offset, options);", + " if (!failure) return undefined;", + " failures.push(failure);", + " }"); + } + lines.push(" return failures.find(failure => failure.path.length > offset) ?? failures[0];"); + break; + } + default: + lines.push(" return undefined;"); + break; + } + + lines.push("}", ""); + emit.lines.push(...lines); + return name; +} + function primitiveCheck(value: string, name: string): string { switch (name) { case "string": return "typeof " + value + " === \"string\""; @@ -325,7 +403,8 @@ function primitiveCheck(value: string, name: string): string { } function emitMethodValidator( - className: string, method: MethodSpec, paramValidators: string[], returnValidator: string): string { + 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; @@ -360,6 +439,10 @@ function emitMethodValidator( " let failure = " + returnValidator + "(value, []);", " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix + " return") + ", failure);", " },", + " returnsPath(path: (string | number)[], value: unknown): void {", + " let failure = " + returnPathValidator + "(path, value);", + " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix + " return") + ", failure);", + " },", " }"); return lines.join("\n"); } diff --git a/src/typecheck/rewrite.ts b/src/typecheck/rewrite.ts index 8d8d4f4..17b3e94 100644 --- a/src/typecheck/rewrite.ts +++ b/src/typecheck/rewrite.ts @@ -108,7 +108,7 @@ 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, ts.ScriptKind.TS) + ? ts.createSourceFile(fileName, source, ts.ScriptTarget.ES2023, true, scriptKindFor(fileName)) : source; let code = sourceFile.getFullText(); let imports = collectCapnwebImports(sourceFile); @@ -126,7 +126,33 @@ export function transformClientCalls( result.slice(edit.end); } - return `import { ${[...usedNames].sort().join(", ")} } from ${JSON.stringify(clientsImport)};\n` + result; + 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 { diff --git a/src/typecheck/vite.ts b/src/typecheck/vite.ts index 8ae62ff..e9eee71 100644 --- a/src/typecheck/vite.ts +++ b/src/typecheck/vite.ts @@ -66,17 +66,20 @@ export default function capnweb(options: CapnwebVitePluginOptions = {}): VitePlu buildStart() { run(); }, configureServer(server) { if (!generated) run(); - // Re-runs full codegen on any TS file change. This is simple but + // 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. - server.watcher?.on("change", path => { - if (!path.endsWith(".ts") && !path.endsWith(".tsx")) return; + 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; From 1e0ed6a5a6fb710a26615e89068f0e59acdffe05 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Fri, 15 May 2026 15:20:59 -0400 Subject: [PATCH 10/24] Fix worker-react debug URL mapping --- examples/worker-react/dev-debug.sh | 44 ++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index 0001524..488942e 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -40,6 +40,32 @@ function isFree(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=$? @@ -60,8 +86,14 @@ if [[ "$TYPECHECK" == "true" ]]; then cd "$SCRIPT_DIR" echo "Debugging capnweb typecheck gen for examples/worker-react/src/worker.ts" - env NODE_OPTIONS= node --enable-source-maps --inspect=0 \ - "$REPO_ROOT/dist/cli.cjs" typecheck gen src/worker.ts --out .capnweb + if [[ "${TYPECHECK_DEBUG:-true}" == "false" || "${TYPECHECK_DEBUG:-true}" == "0" ]]; then + env NODE_OPTIONS= node --enable-source-maps \ + "$REPO_ROOT/dist/cli.cjs" typecheck gen src/worker.ts --out .capnweb + 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 --out .capnweb + fi fi VITE_PLUGIN_IMPORT="" @@ -78,6 +110,7 @@ import path from 'node:path'; $VITE_PLUGIN_IMPORT export default { + base: '$VITE_BASE', $VITE_PLUGINS resolve: { alias: [ @@ -96,7 +129,8 @@ export default { }, server: { host: '127.0.0.1', - port: 5173, + port: $VITE_PORT, + strictPort: true, proxy: { '/api': 'http://127.0.0.1:$WORKER_PORT', }, @@ -134,5 +168,5 @@ if [[ ! -d node_modules ]]; then env NODE_OPTIONS= npm install fi echo "Open this URL in VS Code Debug: Open Link:" -echo "http://127.0.0.1:5173" -env NODE_OPTIONS= npx vite --host 127.0.0.1 --port 5173 --config "$TMP_CONFIG" +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" From 44c0c0ac1fbbebe9113301cb3cefdb422212fa7f Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Fri, 15 May 2026 16:32:25 -0400 Subject: [PATCH 11/24] fix(examples): auto-build web assets before wrangler in dev-debug.sh - Install web deps and build dist before starting wrangler - Wrangler requires web/dist to exist due to [assets] config - Run vite from WEB_DIR so index.html is found correctly --- examples/worker-react/dev-debug.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index 488942e..005b1ec 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -159,14 +159,19 @@ if [[ "$TYPECHECK" == "false" ]]; then env NODE_OPTIONS= npm run build fi +cd "$WEB_DIR" +if [[ ! -d node_modules ]]; then + env NODE_OPTIONS= npm install +fi +if [[ ! -d dist ]]; then + env NODE_OPTIONS= npx vite build --config "$TMP_CONFIG" +fi + cd "$SCRIPT_DIR" env NODE_OPTIONS= npx wrangler dev --config "$TMP_WRANGLER_CONFIG" --ip 127.0.0.1 --port "$WORKER_PORT" >/dev/null 2>&1 & WRANGLER_PID=$! cd "$WEB_DIR" -if [[ ! -d node_modules ]]; then - env NODE_OPTIONS= npm install -fi 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" From 2801873e20957bd0985799e405effd192e1420f6 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Fri, 15 May 2026 23:59:11 -0400 Subject: [PATCH 12/24] feat(typecheck): auto-load validators via capnweb-typecheck placeholder - Add internal capnweb-typecheck workspace package shipped as a stub (validators = null). The capnweb runtime imports from it and falls back to name-based lookup in getRpcValidator, so users never need to register validators or swap entry points. - Add capnweb typecheck gen (no --out) writing into the resolved capnweb-typecheck location via require.resolve. Old --out flow preserved. - Add capnweb typecheck reset to restore the stub (disables validation). - Update examples/worker-react/dev-debug.sh: always use src/worker.ts as the wrangler entry; --typecheck runs gen, no flag runs reset. - Cover generate/reset/load round-trip in typecheck-package.test.ts. --- __tests__/typecheck-package.test.ts | 74 +++++ examples/worker-react/dev-debug.sh | 40 +-- package-lock.json | 14 + package.json | 6 + packages/capnweb-typecheck/clients.cjs | 15 + packages/capnweb-typecheck/clients.d.ts | 3 + packages/capnweb-typecheck/clients.js | 11 + packages/capnweb-typecheck/index.cjs | 358 ++++++++++++++++++++++++ packages/capnweb-typecheck/index.d.ts | 8 + packages/capnweb-typecheck/index.js | 355 +++++++++++++++++++++++ packages/capnweb-typecheck/package.json | 36 +++ src/core.ts | 14 +- src/typecheck/cli.ts | 43 ++- src/typecheck/generate.ts | 86 ++++++ tsup.config.ts | 2 +- vitest.config.ts | 2 +- 16 files changed, 1027 insertions(+), 40 deletions(-) create mode 100644 __tests__/typecheck-package.test.ts create mode 100644 packages/capnweb-typecheck/clients.cjs create mode 100644 packages/capnweb-typecheck/clients.d.ts create mode 100644 packages/capnweb-typecheck/clients.js create mode 100644 packages/capnweb-typecheck/index.cjs create mode 100644 packages/capnweb-typecheck/index.d.ts create mode 100644 packages/capnweb-typecheck/index.js create mode 100644 packages/capnweb-typecheck/package.json diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts new file mode 100644 index 0000000..5708c88 --- /dev/null +++ b/__tests__/typecheck-package.test.ts @@ -0,0 +1,74 @@ +// 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 the +// resolved `capnweb-typecheck` placeholder and the runtime auto-binds them by +// class name without an explicit registration call. + +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"; + +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-pkg-")); + inputFile = join(workDir, "worker.ts"); + writeFileSync(inputFile, FIXTURE); + }); + + afterAll(() => { + resetTypecheckPackage(); + }); + + it("writes validators that load from capnweb-typecheck", async () => { + generateForPackage({ input: inputFile }); + + // Re-import after writing — cache-bust by appending a query string. + let mod = await import("capnweb-typecheck?nocache=" + Date.now()) as any; + expect(mod.validators).toBeTruthy(); + expect(mod.validators.Echo).toBeTruthy(); + expect(Object.keys(mod.validators.Echo).sort()).toEqual(["add", "ping"]); + }); + + it("validates args based on the source method signature", async () => { + generateForPackage({ input: inputFile }); + let mod = await import("capnweb-typecheck?nocache=" + Date.now()) as any; + + // Valid call passes. + expect(() => mod.validators.Echo.ping.args(["hello"])).not.toThrow(); + // Wrong arity throws. + expect(() => mod.validators.Echo.ping.args([])).toThrow(/expected 1 argument/); + // Wrong type throws. + 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?nocache=" + Date.now()) as any; + expect(mod.validators).toBeNull(); + }); +}); diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index 005b1ec..9cba4c0 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -80,38 +80,33 @@ cleanup() { trap cleanup EXIT INT TERM -if [[ "$TYPECHECK" == "true" ]]; then - cd "$REPO_ROOT" - env NODE_OPTIONS= npm run build +cd "$REPO_ROOT" +env NODE_OPTIONS= npm run build - cd "$SCRIPT_DIR" - echo "Debugging capnweb typecheck gen for examples/worker-react/src/worker.ts" - if [[ "${TYPECHECK_DEBUG:-true}" == "false" || "${TYPECHECK_DEBUG:-true}" == "0" ]]; then +# Runtime type validation toggles via the `capnweb-typecheck` placeholder +# package. `gen` overwrites it with real validators; `reset` puts the stub 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-typecheck..." + 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 --out .capnweb + "$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 --out .capnweb + "$REPO_ROOT/dist/cli.cjs" typecheck gen src/worker.ts fi -fi - -VITE_PLUGIN_IMPORT="" -VITE_PLUGINS="" -WORKER_MAIN="$SCRIPT_DIR/src/worker.ts" -if [[ "$TYPECHECK" == "true" ]]; then - VITE_PLUGIN_IMPORT="import capnweb from '$REPO_ROOT/dist/vite.js';" - VITE_PLUGINS="plugins: [capnweb({ input: '$SCRIPT_DIR/src/worker.ts', outDir: '$SCRIPT_DIR/.capnweb' })]," - WORKER_MAIN="$SCRIPT_DIR/.capnweb/worker.entry.ts" +else + echo "Resetting capnweb-typecheck placeholder (validators disabled)..." + env NODE_OPTIONS= node "$REPO_ROOT/dist/cli.cjs" typecheck reset fi cat > "$TMP_CONFIG" < "$TMP_WRANGLER_CONFIG" <=20" } + }, + "packages/capnweb-typecheck": { + "version": "0.0.1", + "license": "MIT" } } } diff --git a/package.json b/package.json index 81de53d..633791f 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,15 @@ "capnweb": "./dist/cli.cjs" }, "type": "module", + "workspaces": [ + "packages/*" + ], "publishConfig": { "access": "public" }, + "dependencies": { + "capnweb-typecheck": "0.0.1" + }, "scripts": { "build": "tsup", "build:runtime": "tsup", diff --git a/packages/capnweb-typecheck/clients.cjs b/packages/capnweb-typecheck/clients.cjs new file mode 100644 index 0000000..f6ed413 --- /dev/null +++ b/packages/capnweb-typecheck/clients.cjs @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.__capnweb_wrap_Api = __capnweb_wrap_Api; +exports.__capnweb_wrap_RpcSession_Api = __capnweb_wrap_RpcSession_Api; +// Generated by capnweb typecheck gen. Do not edit. +const typecheck_1 = require("capnweb/internal/typecheck"); +const capnweb_typecheck_1 = require("capnweb-typecheck"); +function __capnweb_wrap_Api(stub) { + return (0, typecheck_1.__capnweb_bindClientValidator)(stub, capnweb_typecheck_1.validators["Api"]); +} +function __capnweb_wrap_RpcSession_Api(session) { + let main = session.getRemoteMain(); + (0, typecheck_1.__capnweb_bindClientValidator)(main, capnweb_typecheck_1.validators["Api"]); + return session; +} diff --git a/packages/capnweb-typecheck/clients.d.ts b/packages/capnweb-typecheck/clients.d.ts new file mode 100644 index 0000000..f908f0b --- /dev/null +++ b/packages/capnweb-typecheck/clients.d.ts @@ -0,0 +1,3 @@ +// Client-side stub wrappers, regenerated by `capnweb typecheck gen`. When the +// placeholder is in place the module is empty. +export {}; diff --git a/packages/capnweb-typecheck/clients.js b/packages/capnweb-typecheck/clients.js new file mode 100644 index 0000000..418ad78 --- /dev/null +++ b/packages/capnweb-typecheck/clients.js @@ -0,0 +1,11 @@ +// Generated by capnweb typecheck gen. Do not edit. +import { __capnweb_bindClientValidator } from "capnweb/internal/typecheck"; +import { validators } from "capnweb-typecheck"; +export function __capnweb_wrap_Api(stub) { + return __capnweb_bindClientValidator(stub, validators["Api"]); +} +export function __capnweb_wrap_RpcSession_Api(session) { + let main = session.getRemoteMain(); + __capnweb_bindClientValidator(main, validators["Api"]); + return session; +} diff --git a/packages/capnweb-typecheck/index.cjs b/packages/capnweb-typecheck/index.cjs new file mode 100644 index 0000000..b0335f4 --- /dev/null +++ b/packages/capnweb-typecheck/index.cjs @@ -0,0 +1,358 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.validators = void 0; +function __capnweb_mismatch(path, expected, value) { + return { path, expected, actual: __capnweb_actualKind(value), value }; +} +function __capnweb_missing(path, expected) { + return { path, expected, actual: "missing", value: undefined }; +} +function __capnweb_makeValidationError(prefix, failure) { + let where = failure.path.length > 0 ? failure.path.join(".") : "value"; + let err = new TypeError(prefix + ": " + where + ": expected " + failure.expected + ", got " + failure.actual); + err.rpcValidation = failure; + return err; +} +function __capnweb_actualKind(value) { + 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.constructor; + if (ctor && ctor.name && ctor.name !== "Object") + return ctor.name; + return "object"; + } + return typeof value; +} +function __capnweb_validate_0(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_2(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_3(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_1(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, name: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_2(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "name")) + return __capnweb_missing([...path, "name"], "string"); + { + let failure = __capnweb_validate_3(record["name"], [...path, "name"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_6(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_7(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_5(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, name: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_6(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "name")) + return __capnweb_missing([...path, "name"], "string"); + { + let failure = __capnweb_validate_7(record["name"], [...path, "name"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_9(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_8(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_9(value, path, options); + return undefined; +} +function __capnweb_validate_11(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_10(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_11(value, path, options); + return undefined; +} +function __capnweb_validate_path_4(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_5(value, path, options); + switch (path[offset]) { + case "id": + return __capnweb_validate_path_8(path, value, offset + 1, options); + case "name": + return __capnweb_validate_path_10(path, value, offset + 1, options); + default: return undefined; + } +} +function __capnweb_validate_12(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_14(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_15(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_13(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, bio: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_14(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "bio")) + return __capnweb_missing([...path, "bio"], "string"); + { + let failure = __capnweb_validate_15(record["bio"], [...path, "bio"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_18(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_19(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_17(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, bio: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_18(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "bio")) + return __capnweb_missing([...path, "bio"], "string"); + { + let failure = __capnweb_validate_19(record["bio"], [...path, "bio"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_21(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_20(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_21(value, path, options); + return undefined; +} +function __capnweb_validate_23(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_22(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_23(value, path, options); + return undefined; +} +function __capnweb_validate_path_16(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_17(value, path, options); + switch (path[offset]) { + case "id": + return __capnweb_validate_path_20(path, value, offset + 1, options); + case "bio": + return __capnweb_validate_path_22(path, value, offset + 1, options); + default: return undefined; + } +} +function __capnweb_validate_24(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_26(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_25(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (!Array.isArray(value)) + return __capnweb_mismatch(path, "string[]", value); + for (let i = 0; i < value.length; i++) { + let failure = __capnweb_validate_26(value[i], [...path, "[" + i + "]"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_29(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_28(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (!Array.isArray(value)) + return __capnweb_mismatch(path, "string[]", value); + for (let i = 0; i < value.length; i++) { + let failure = __capnweb_validate_29(value[i], [...path, "[" + i + "]"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_31(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_30(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_31(value, path, options); + return undefined; +} +function __capnweb_validate_path_27(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_28(value, path, options); + return __capnweb_validate_path_30(path, value, offset + 1, options); +} +exports.validators = { + "Api": { + "authenticate": { + args(args, options) { + if (args.length < 1 || args.length > 1) { + throw new TypeError("Api.authenticate expected 1 argument(s), got " + args.length); + } + { + let failure = __capnweb_validate_0(args[0], ["sessionToken"], options); + if (failure) + throw __capnweb_makeValidationError("Api.authenticate", failure); + } + }, + returns(value) { + let failure = __capnweb_validate_1(value, []); + if (failure) + throw __capnweb_makeValidationError("Api.authenticate return", failure); + }, + returnsPath(path, value) { + let failure = __capnweb_validate_path_4(path, value); + if (failure) + throw __capnweb_makeValidationError("Api.authenticate return", failure); + }, + }, + "getUserProfile": { + args(args, options) { + if (args.length < 1 || args.length > 1) { + throw new TypeError("Api.getUserProfile expected 1 argument(s), got " + args.length); + } + { + let failure = __capnweb_validate_12(args[0], ["userId"], options); + if (failure) + throw __capnweb_makeValidationError("Api.getUserProfile", failure); + } + }, + returns(value) { + let failure = __capnweb_validate_13(value, []); + if (failure) + throw __capnweb_makeValidationError("Api.getUserProfile return", failure); + }, + returnsPath(path, value) { + let failure = __capnweb_validate_path_16(path, value); + if (failure) + throw __capnweb_makeValidationError("Api.getUserProfile return", failure); + }, + }, + "getNotifications": { + args(args, options) { + if (args.length < 1 || args.length > 1) { + throw new TypeError("Api.getNotifications expected 1 argument(s), got " + args.length); + } + { + let failure = __capnweb_validate_24(args[0], ["userId"], options); + if (failure) + throw __capnweb_makeValidationError("Api.getNotifications", failure); + } + }, + returns(value) { + let failure = __capnweb_validate_25(value, []); + if (failure) + throw __capnweb_makeValidationError("Api.getNotifications return", failure); + }, + returnsPath(path, value) { + let failure = __capnweb_validate_path_27(path, value); + if (failure) + throw __capnweb_makeValidationError("Api.getNotifications return", failure); + }, + } + } +}; diff --git a/packages/capnweb-typecheck/index.d.ts b/packages/capnweb-typecheck/index.d.ts new file mode 100644 index 0000000..2415263 --- /dev/null +++ b/packages/capnweb-typecheck/index.d.ts @@ -0,0 +1,8 @@ +// Internal placeholder. Do not import this package directly. +import type { RpcClassValidators } from "capnweb/internal/typecheck"; + +/** + * Map of RPC class name → method validators. When null, runtime validation is + * disabled. Overwritten in-place by `capnweb typecheck gen`. + */ +export declare const validators: Record | null; diff --git a/packages/capnweb-typecheck/index.js b/packages/capnweb-typecheck/index.js new file mode 100644 index 0000000..fa5523b --- /dev/null +++ b/packages/capnweb-typecheck/index.js @@ -0,0 +1,355 @@ +function __capnweb_mismatch(path, expected, value) { + return { path, expected, actual: __capnweb_actualKind(value), value }; +} +function __capnweb_missing(path, expected) { + return { path, expected, actual: "missing", value: undefined }; +} +function __capnweb_makeValidationError(prefix, failure) { + let where = failure.path.length > 0 ? failure.path.join(".") : "value"; + let err = new TypeError(prefix + ": " + where + ": expected " + failure.expected + ", got " + failure.actual); + err.rpcValidation = failure; + return err; +} +function __capnweb_actualKind(value) { + 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.constructor; + if (ctor && ctor.name && ctor.name !== "Object") + return ctor.name; + return "object"; + } + return typeof value; +} +function __capnweb_validate_0(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_2(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_3(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_1(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, name: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_2(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "name")) + return __capnweb_missing([...path, "name"], "string"); + { + let failure = __capnweb_validate_3(record["name"], [...path, "name"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_6(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_7(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_5(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, name: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_6(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "name")) + return __capnweb_missing([...path, "name"], "string"); + { + let failure = __capnweb_validate_7(record["name"], [...path, "name"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_9(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_8(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_9(value, path, options); + return undefined; +} +function __capnweb_validate_11(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_10(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_11(value, path, options); + return undefined; +} +function __capnweb_validate_path_4(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_5(value, path, options); + switch (path[offset]) { + case "id": + return __capnweb_validate_path_8(path, value, offset + 1, options); + case "name": + return __capnweb_validate_path_10(path, value, offset + 1, options); + default: return undefined; + } +} +function __capnweb_validate_12(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_14(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_15(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_13(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, bio: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_14(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "bio")) + return __capnweb_missing([...path, "bio"], "string"); + { + let failure = __capnweb_validate_15(record["bio"], [...path, "bio"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_18(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_19(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_17(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return __capnweb_mismatch(path, "{ id: string, bio: string }", value); + let record = value; + if (!Object.prototype.hasOwnProperty.call(record, "id")) + return __capnweb_missing([...path, "id"], "string"); + { + let failure = __capnweb_validate_18(record["id"], [...path, "id"], options); + if (failure) + return failure; + } + if (!Object.prototype.hasOwnProperty.call(record, "bio")) + return __capnweb_missing([...path, "bio"], "string"); + { + let failure = __capnweb_validate_19(record["bio"], [...path, "bio"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_21(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_20(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_21(value, path, options); + return undefined; +} +function __capnweb_validate_23(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_22(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_23(value, path, options); + return undefined; +} +function __capnweb_validate_path_16(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_17(value, path, options); + switch (path[offset]) { + case "id": + return __capnweb_validate_path_20(path, value, offset + 1, options); + case "bio": + return __capnweb_validate_path_22(path, value, offset + 1, options); + default: return undefined; + } +} +function __capnweb_validate_24(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_26(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_25(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (!Array.isArray(value)) + return __capnweb_mismatch(path, "string[]", value); + for (let i = 0; i < value.length; i++) { + let failure = __capnweb_validate_26(value[i], [...path, "[" + i + "]"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_29(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_28(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + if (!Array.isArray(value)) + return __capnweb_mismatch(path, "string[]", value); + for (let i = 0; i < value.length; i++) { + let failure = __capnweb_validate_29(value[i], [...path, "[" + i + "]"], options); + if (failure) + return failure; + } + return undefined; +} +function __capnweb_validate_31(value, path, options) { + if (options?.isRpcPlaceholder?.(value)) + return undefined; + return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); +} +function __capnweb_validate_path_30(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_31(value, path, options); + return undefined; +} +function __capnweb_validate_path_27(path, value, offset = 0, options) { + if (offset >= path.length) + return __capnweb_validate_28(value, path, options); + return __capnweb_validate_path_30(path, value, offset + 1, options); +} +export const validators = { + "Api": { + "authenticate": { + args(args, options) { + if (args.length < 1 || args.length > 1) { + throw new TypeError("Api.authenticate expected 1 argument(s), got " + args.length); + } + { + let failure = __capnweb_validate_0(args[0], ["sessionToken"], options); + if (failure) + throw __capnweb_makeValidationError("Api.authenticate", failure); + } + }, + returns(value) { + let failure = __capnweb_validate_1(value, []); + if (failure) + throw __capnweb_makeValidationError("Api.authenticate return", failure); + }, + returnsPath(path, value) { + let failure = __capnweb_validate_path_4(path, value); + if (failure) + throw __capnweb_makeValidationError("Api.authenticate return", failure); + }, + }, + "getUserProfile": { + args(args, options) { + if (args.length < 1 || args.length > 1) { + throw new TypeError("Api.getUserProfile expected 1 argument(s), got " + args.length); + } + { + let failure = __capnweb_validate_12(args[0], ["userId"], options); + if (failure) + throw __capnweb_makeValidationError("Api.getUserProfile", failure); + } + }, + returns(value) { + let failure = __capnweb_validate_13(value, []); + if (failure) + throw __capnweb_makeValidationError("Api.getUserProfile return", failure); + }, + returnsPath(path, value) { + let failure = __capnweb_validate_path_16(path, value); + if (failure) + throw __capnweb_makeValidationError("Api.getUserProfile return", failure); + }, + }, + "getNotifications": { + args(args, options) { + if (args.length < 1 || args.length > 1) { + throw new TypeError("Api.getNotifications expected 1 argument(s), got " + args.length); + } + { + let failure = __capnweb_validate_24(args[0], ["userId"], options); + if (failure) + throw __capnweb_makeValidationError("Api.getNotifications", failure); + } + }, + returns(value) { + let failure = __capnweb_validate_25(value, []); + if (failure) + throw __capnweb_makeValidationError("Api.getNotifications return", failure); + }, + returnsPath(path, value) { + let failure = __capnweb_validate_path_27(path, value); + if (failure) + throw __capnweb_makeValidationError("Api.getNotifications return", failure); + }, + } + } +}; diff --git a/packages/capnweb-typecheck/package.json b/packages/capnweb-typecheck/package.json new file mode 100644 index 0000000..e44469c --- /dev/null +++ b/packages/capnweb-typecheck/package.json @@ -0,0 +1,36 @@ +{ + "name": "capnweb-typecheck", + "version": "0.0.1", + "description": "Internal placeholder package for Cap'n Web runtime type validators. Do not import directly — capnweb manages this for you.", + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js", + "require": "./index.cjs" + }, + "./clients": { + "types": "./clients.d.ts", + "import": "./clients.js", + "require": "./clients.cjs" + } + }, + "files": [ + "index.js", + "index.cjs", + "index.d.ts", + "clients.js", + "clients.cjs", + "clients.d.ts" + ], + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/capnweb" + } +} diff --git a/src/core.ts b/src/core.ts index 137c5ac..3da42d1 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"; // Polyfill Symbol.dispose for browsers that don't support it yet if (!Symbol.dispose) { @@ -66,7 +67,18 @@ function getRpcValidator( let klass = (thisArg as {constructor?: Function}).constructor; while (typeof klass === "function") { - let validator = rpcValidators.get(klass)?.[methodName]; + 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; diff --git a/src/typecheck/cli.ts b/src/typecheck/cli.ts index 9f84817..403646a 100644 --- a/src/typecheck/cli.ts +++ b/src/typecheck/cli.ts @@ -3,22 +3,30 @@ // 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. Intended for builds -// that can point a bundler at a generated entry file -- Wrangler is the -// reference target. It writes validators, client wrappers, a shadow source -// tree, and a worker entry module under the configured output directory. +// Build-time CLI for Cap'n Web RPC validation codegen. +// +// `capnweb typecheck gen ` extracts RpcTarget classes from +// and writes validators into the resolved `capnweb-typecheck` placeholder +// package. The capnweb runtime auto-loads from there by class name, so user +// code and bundler entry points stay untouched. +// +// `capnweb typecheck reset` restores the placeholder to its stub state, which +// disables runtime validation until the next `gen` run. import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { generate, type GenOptions } from "./generate.js"; +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 -Example: - capnweb typecheck gen src/worker.ts --out .capnweb`); +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); } @@ -27,16 +35,27 @@ async function main() { if (!command || command === "--help" || command === "-h") usage(0); if (command !== "typecheck") usage(); if (!subcommand || subcommand === "--help" || subcommand === "-h") usage(0); - if (subcommand !== "gen") usage(); + if (subcommand === "reset") { + if (rest.length > 0) usage(); + resetTypecheckPackage(); + return; + } + + if (subcommand !== "gen") usage(); if (rest.includes("--help") || rest.includes("-h")) usage(0); - let options = parseGenArgs(rest); - generate(options); + + let parsed = parseGenArgs(rest); + if (parsed.outDir === undefined) { + generateForPackage({ input: parsed.input }); + } else { + generate({ input: parsed.input, outDir: parsed.outDir }); + } } -function parseGenArgs(args: string[]): GenOptions { +function parseGenArgs(args: string[]): { input: string; outDir: string | undefined } { let input: string | undefined; - let outDir = ".capnweb"; + let outDir: string | undefined; for (let i = 0; i < args.length; i++) { let arg = args[i]; if (arg === "--out" || arg === "-o") { diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index 27f0358..8758d2c 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -3,6 +3,7 @@ // https://opensource.org/license/mit import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, extname, join, relative, resolve } from "node:path"; import * as ts from "typescript"; import { @@ -32,6 +33,91 @@ export function generate(options: GenOptions): GenResult { return makeGenResult(classes); } +/** + * Generate validators and write them to the resolved `capnweb-typecheck` + * package location. This is the supported flow: users run + * `capnweb typecheck gen src/worker.ts` once, validators are written into the + * placeholder package, and the capnweb runtime picks them up automatically by + * class name. No entry-point or import changes are required in user code. + * + * Two files are produced inside the placeholder package directory: + * - `index.js` / `index.cjs` — exports `validators`. Pure data + helpers, + * no `capnweb` import, so loading the capnweb runtime does not create an + * import cycle. + * - `clients.js` / `clients.cjs` — exports `__capnweb_wrap_*` functions for + * the Vite plugin's client-side rewriting. Imports `capnweb` (safe: only + * pulled in when the frontend uses validated stubs). + */ +export function generateForPackage(options: { input: string }): GenResult { + let packageDir = resolveTypecheckPackageDir(); + let prepared = prepare({ input: options.input, outDir: packageDir, runtimeImport: "capnweb/internal/typecheck" }); + let { classes, runtimeImport } = prepared; + + let specsTs = emitSpecsModule(classes, runtimeImport); + // Generated `clients.ts` imports `validators` from `./specs.js`; the package + // exposes the same export from its main entry instead, so point clients at + // it via the bare package name. + let clientsTs = emitClientsModule(classes, runtimeImport) + .replace(/from "\.\/specs\.js"/g, `from "capnweb-typecheck"`); + + writeFileSync(join(packageDir, "index.js"), tsToEsm(specsTs)); + writeFileSync(join(packageDir, "index.cjs"), tsToCjs(specsTs)); + writeFileSync(join(packageDir, "clients.js"), tsToEsm(clientsTs)); + writeFileSync(join(packageDir, "clients.cjs"), tsToCjs(clientsTs)); + log(packageDir, "index.js"); + log(packageDir, "index.cjs"); + log(packageDir, "clients.js"); + log(packageDir, "clients.cjs"); + return makeGenResult(classes); +} + +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 `capnweb-typecheck` placeholder to its stub state. Equivalent to + * a fresh install — no validators are active. + */ +export function resetTypecheckPackage(): void { + let packageDir = resolveTypecheckPackageDir(); + writeFileSync(join(packageDir, "index.js"), STUB_ESM); + writeFileSync(join(packageDir, "index.cjs"), STUB_CJS); + writeFileSync(join(packageDir, "clients.js"), STUB_CLIENTS_ESM); + writeFileSync(join(packageDir, "clients.cjs"), 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`; + +function resolveTypecheckPackageDir(): string { + let req = createRequire(resolve(process.cwd(), "_")); + let indexPath = req.resolve("capnweb-typecheck"); + return dirname(indexPath); +} + + export function generateValidatorsOnly(options: GenOptions): GenResult { let prepared = prepare(options); emitArtifacts(prepared); diff --git a/tsup.config.ts b/tsup.config.ts index 72316e3..b30b2d6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -26,7 +26,7 @@ export default defineConfig([ // `dist/index.js` so the validator runtime is shared with the main bundle. 'src/internal-typecheck.ts', ], - external: ['cloudflare:workers'], + external: ['cloudflare:workers', 'capnweb-typecheck'], clean: true, // Works in browsers, Node, and Cloudflare Workers platform: 'neutral', diff --git a/vitest.config.ts b/vitest.config.ts index f57cb39..51bdd82 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,7 +17,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', '__tests__/typecheck.test.ts', '__tests__/vite-plugin.test.ts'], + include: ['__tests__/index.test.ts', '__tests__/flow-control.test.ts', '__tests__/typecheck.test.ts', '__tests__/typecheck-package.test.ts', '__tests__/vite-plugin.test.ts'], environment: 'node', }, }, From 9c359fb3793babcd2378790ca3db923c74a4f7b7 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sat, 16 May 2026 00:21:49 -0400 Subject: [PATCH 13/24] fix(typecheck): support pnpm and Yarn PnP package layouts pnpm hardlinks installed packages to a content-addressable store; writing through node_modules/capnweb-typecheck/index.js with truncate semantics would corrupt the shared inode and every other project on the same store. Switch the codegen and reset paths to unlink-then-write so the store entry keeps its content and only the project copy diverges. Yarn PnP serves packages from zip archives that cannot be modified in place. Detect via process.versions.pnp / `.yarn/cache/*.zip` segments in the resolved path and surface a clear error pointing at `yarn unplug capnweb-typecheck` instead of failing partway through write. Add a vitest case that hardlinks a sentinel to the package index and verifies generateForPackage leaves the sentinel's content intact. --- __tests__/typecheck-package.test.ts | 31 ++++++++++++++++-- src/typecheck/generate.ts | 49 +++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index 5708c88..cf14253 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -6,9 +6,10 @@ // resolved `capnweb-typecheck` placeholder and the runtime auto-binds them by // class name without an explicit registration call. -import { mkdtempSync, writeFileSync } from "node:fs"; +import { linkSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { generateForPackage, @@ -71,4 +72,30 @@ describe("generateForPackage", () => { let mod = await import("capnweb-typecheck?nocache=" + Date.now()) as any; expect(mod.validators).toBeNull(); }); + + // 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-typecheck version. The + // generate path must unlink first so the store entry stays intact. + it("does not corrupt a hardlinked sibling of capnweb-typecheck/index.js", () => { + let req = createRequire(__filename); + let pkgIndex = req.resolve("capnweb-typecheck"); + let pkgDir = dirname(pkgIndex); + let sentinelDir = mkdtempSync(join(tmpdir(), "capnweb-pnpm-")); + let sentinel = join(sentinelDir, "shared-inode.js"); + + // Put a stub in place we control, then hardlink the sentinel to it. + writeFileSync(pkgIndex, "export const validators = null;\n"); + let beforeInode = statSync(pkgIndex).ino; + linkSync(pkgIndex, sentinel); + expect(statSync(sentinel).ino).toBe(beforeInode); + + generateForPackage({ input: inputFile }); + + // After codegen, the package index lives at a fresh inode and the + // hardlinked sentinel still points at the original stub content. + expect(statSync(pkgIndex).ino).not.toBe(beforeInode); + expect(readFileSync(sentinel, "utf8")).toContain("export const validators = null"); + expect(readFileSync(pkgIndex, "utf8")).toContain("Echo"); + }); }); diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index 8758d2c..a85a1a3 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +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"; @@ -60,10 +60,10 @@ export function generateForPackage(options: { input: string }): GenResult { let clientsTs = emitClientsModule(classes, runtimeImport) .replace(/from "\.\/specs\.js"/g, `from "capnweb-typecheck"`); - writeFileSync(join(packageDir, "index.js"), tsToEsm(specsTs)); - writeFileSync(join(packageDir, "index.cjs"), tsToCjs(specsTs)); - writeFileSync(join(packageDir, "clients.js"), tsToEsm(clientsTs)); - writeFileSync(join(packageDir, "clients.cjs"), tsToCjs(clientsTs)); + safeWrite(join(packageDir, "index.js"), tsToEsm(specsTs)); + safeWrite(join(packageDir, "index.cjs"), tsToCjs(specsTs)); + safeWrite(join(packageDir, "clients.js"), tsToEsm(clientsTs)); + safeWrite(join(packageDir, "clients.cjs"), tsToCjs(clientsTs)); log(packageDir, "index.js"); log(packageDir, "index.cjs"); log(packageDir, "clients.js"); @@ -71,6 +71,19 @@ export function generateForPackage(options: { input: string }): GenResult { 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 }, @@ -89,10 +102,10 @@ function tsToCjs(source: string): string { */ export function resetTypecheckPackage(): void { let packageDir = resolveTypecheckPackageDir(); - writeFileSync(join(packageDir, "index.js"), STUB_ESM); - writeFileSync(join(packageDir, "index.cjs"), STUB_CJS); - writeFileSync(join(packageDir, "clients.js"), STUB_CLIENTS_ESM); - writeFileSync(join(packageDir, "clients.cjs"), STUB_CLIENTS_CJS); + safeWrite(join(packageDir, "index.js"), STUB_ESM); + safeWrite(join(packageDir, "index.cjs"), STUB_CJS); + safeWrite(join(packageDir, "clients.js"), STUB_CLIENTS_ESM); + safeWrite(join(packageDir, "clients.cjs"), STUB_CLIENTS_CJS); } const STUB_BANNER = `// Placeholder. Overwritten in-place by \`capnweb typecheck gen\`.\n` + @@ -114,9 +127,27 @@ const STUB_CLIENTS_CJS = STUB_BANNER + function resolveTypecheckPackageDir(): string { let req = createRequire(resolve(process.cwd(), "_")); let indexPath = req.resolve("capnweb-typecheck"); + if (isInsideYarnPnpZip(indexPath)) { + throw new Error( + `capnweb-typecheck resolves inside a Yarn Plug'n'Play archive ` + + `(${indexPath}). Writable files are required for codegen.\n` + + `\n` + + `Run \`yarn unplug capnweb-typecheck\` once, then re-run ` + + `\`capnweb typecheck gen\`. Yarn will keep the package unplugged on ` + + `future installs so codegen continues to work.` + ); + } return dirname(indexPath); } +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); From 64a9ed60db5575a94d3026ff1f344d8969c6aa11 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sat, 16 May 2026 00:46:13 -0400 Subject: [PATCH 14/24] refactor(typecheck): replace capnweb-typecheck package with capnweb subpaths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the validator placeholder out of a separate `capnweb-typecheck` workspace into two internal subpaths inside capnweb itself: `capnweb/_typecheck-validators` and `capnweb/_typecheck-clients`. The package.json `exports` map and tsup entries make the stubs available the moment capnweb is installed, and `capnweb typecheck gen` rewrites them in-place using the same `safeWrite` (unlink-then-write) pattern that keeps pnpm's content-addressable store intact. The leading underscore signals "internal — do not import directly." Users still only ever import from `capnweb`. The runtime imports validators via a package self-reference; an ambient `.d.ts` keeps the source build happy before the dist files exist. Drops the standalone `packages/capnweb-typecheck` workspace, the `capnweb-typecheck` dependency, and the `workspaces` field. Removes a supply-chain footgun (the unclaimed `capnweb-typecheck` name on npm) and shrinks the published surface to one package again. The Yarn PnP unplug hint now points at `capnweb` itself, since that's the package the user needs unplugged. --- __tests__/typecheck-package.test.ts | 41 ++- package-lock.json | 11 +- package.json | 16 +- packages/capnweb-typecheck/clients.cjs | 15 - packages/capnweb-typecheck/clients.d.ts | 3 - packages/capnweb-typecheck/clients.js | 11 - packages/capnweb-typecheck/index.cjs | 358 ------------------------ packages/capnweb-typecheck/index.d.ts | 8 - packages/capnweb-typecheck/index.js | 355 ----------------------- packages/capnweb-typecheck/package.json | 36 --- src/_typecheck-clients.ts | 5 + src/_typecheck-validators.d.ts | 9 + src/_typecheck-validators.ts | 8 + src/core.ts | 2 +- src/typecheck/cli.ts | 11 +- src/typecheck/generate.ts | 100 ++++--- tsup.config.ts | 7 +- 17 files changed, 122 insertions(+), 874 deletions(-) delete mode 100644 packages/capnweb-typecheck/clients.cjs delete mode 100644 packages/capnweb-typecheck/clients.d.ts delete mode 100644 packages/capnweb-typecheck/clients.js delete mode 100644 packages/capnweb-typecheck/index.cjs delete mode 100644 packages/capnweb-typecheck/index.d.ts delete mode 100644 packages/capnweb-typecheck/index.js delete mode 100644 packages/capnweb-typecheck/package.json create mode 100644 src/_typecheck-clients.ts create mode 100644 src/_typecheck-validators.d.ts create mode 100644 src/_typecheck-validators.ts diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index cf14253..734edcf 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -2,9 +2,9 @@ // 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 the -// resolved `capnweb-typecheck` placeholder and the runtime auto-binds them by -// class name without an explicit registration call. +// 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, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; @@ -34,7 +34,7 @@ describe("generateForPackage", () => { let inputFile: string; beforeAll(() => { - workDir = mkdtempSync(join(tmpdir(), "capnweb-pkg-")); + workDir = mkdtempSync(join(tmpdir(), "capnweb-typecheck-")); inputFile = join(workDir, "worker.ts"); writeFileSync(inputFile, FIXTURE); }); @@ -43,11 +43,11 @@ describe("generateForPackage", () => { resetTypecheckPackage(); }); - it("writes validators that load from capnweb-typecheck", async () => { + it("writes validators that load from capnweb/_typecheck-validators", async () => { generateForPackage({ input: inputFile }); // Re-import after writing — cache-bust by appending a query string. - let mod = await import("capnweb-typecheck?nocache=" + Date.now()) as any; + let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; expect(mod.validators).toBeTruthy(); expect(mod.validators.Echo).toBeTruthy(); expect(Object.keys(mod.validators.Echo).sort()).toEqual(["add", "ping"]); @@ -55,13 +55,10 @@ describe("generateForPackage", () => { it("validates args based on the source method signature", async () => { generateForPackage({ input: inputFile }); - let mod = await import("capnweb-typecheck?nocache=" + Date.now()) as any; + let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; - // Valid call passes. expect(() => mod.validators.Echo.ping.args(["hello"])).not.toThrow(); - // Wrong arity throws. expect(() => mod.validators.Echo.ping.args([])).toThrow(/expected 1 argument/); - // Wrong type throws. expect(() => mod.validators.Echo.ping.args([42])).toThrow(/expected string/); }); @@ -69,33 +66,29 @@ describe("generateForPackage", () => { generateForPackage({ input: inputFile }); resetTypecheckPackage(); - let mod = await import("capnweb-typecheck?nocache=" + Date.now()) as any; + let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; expect(mod.validators).toBeNull(); }); // 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-typecheck version. The - // generate path must unlink first so the store entry stays intact. - it("does not corrupt a hardlinked sibling of capnweb-typecheck/index.js", () => { + // 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 pkgIndex = req.resolve("capnweb-typecheck"); - let pkgDir = dirname(pkgIndex); + let validatorsPath = req.resolve("capnweb/_typecheck-validators"); let sentinelDir = mkdtempSync(join(tmpdir(), "capnweb-pnpm-")); let sentinel = join(sentinelDir, "shared-inode.js"); - // Put a stub in place we control, then hardlink the sentinel to it. - writeFileSync(pkgIndex, "export const validators = null;\n"); - let beforeInode = statSync(pkgIndex).ino; - linkSync(pkgIndex, sentinel); + writeFileSync(validatorsPath, "export const validators = null;\n"); + let beforeInode = statSync(validatorsPath).ino; + linkSync(validatorsPath, sentinel); expect(statSync(sentinel).ino).toBe(beforeInode); generateForPackage({ input: inputFile }); - // After codegen, the package index lives at a fresh inode and the - // hardlinked sentinel still points at the original stub content. - expect(statSync(pkgIndex).ino).not.toBe(beforeInode); + expect(statSync(validatorsPath).ino).not.toBe(beforeInode); expect(readFileSync(sentinel, "utf8")).toContain("export const validators = null"); - expect(readFileSync(pkgIndex, "utf8")).toContain("Echo"); + expect(readFileSync(validatorsPath, "utf8")).toContain("Echo"); }); }); diff --git a/package-lock.json b/package-lock.json index f7754d9..de9858f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,6 @@ "name": "capnweb", "version": "0.7.0", "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "capnweb-typecheck": "0.0.1" - }, "bin": { "capnweb": "dist/cli.cjs" }, @@ -3261,10 +3255,6 @@ "dev": true, "license": "MIT" }, - "node_modules/capnweb-typecheck": { - "resolved": "packages/capnweb-typecheck", - "link": true - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -6379,6 +6369,7 @@ }, "packages/capnweb-typecheck": { "version": "0.0.1", + "extraneous": true, "license": "MIT" } } diff --git a/package.json b/package.json index 633791f..4fc9ea8 100644 --- a/package.json +++ b/package.json @@ -44,21 +44,25 @@ "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", - "workspaces": [ - "packages/*" - ], "publishConfig": { "access": "public" }, - "dependencies": { - "capnweb-typecheck": "0.0.1" - }, "scripts": { "build": "tsup", "build:runtime": "tsup", diff --git a/packages/capnweb-typecheck/clients.cjs b/packages/capnweb-typecheck/clients.cjs deleted file mode 100644 index f6ed413..0000000 --- a/packages/capnweb-typecheck/clients.cjs +++ /dev/null @@ -1,15 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.__capnweb_wrap_Api = __capnweb_wrap_Api; -exports.__capnweb_wrap_RpcSession_Api = __capnweb_wrap_RpcSession_Api; -// Generated by capnweb typecheck gen. Do not edit. -const typecheck_1 = require("capnweb/internal/typecheck"); -const capnweb_typecheck_1 = require("capnweb-typecheck"); -function __capnweb_wrap_Api(stub) { - return (0, typecheck_1.__capnweb_bindClientValidator)(stub, capnweb_typecheck_1.validators["Api"]); -} -function __capnweb_wrap_RpcSession_Api(session) { - let main = session.getRemoteMain(); - (0, typecheck_1.__capnweb_bindClientValidator)(main, capnweb_typecheck_1.validators["Api"]); - return session; -} diff --git a/packages/capnweb-typecheck/clients.d.ts b/packages/capnweb-typecheck/clients.d.ts deleted file mode 100644 index f908f0b..0000000 --- a/packages/capnweb-typecheck/clients.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Client-side stub wrappers, regenerated by `capnweb typecheck gen`. When the -// placeholder is in place the module is empty. -export {}; diff --git a/packages/capnweb-typecheck/clients.js b/packages/capnweb-typecheck/clients.js deleted file mode 100644 index 418ad78..0000000 --- a/packages/capnweb-typecheck/clients.js +++ /dev/null @@ -1,11 +0,0 @@ -// Generated by capnweb typecheck gen. Do not edit. -import { __capnweb_bindClientValidator } from "capnweb/internal/typecheck"; -import { validators } from "capnweb-typecheck"; -export function __capnweb_wrap_Api(stub) { - return __capnweb_bindClientValidator(stub, validators["Api"]); -} -export function __capnweb_wrap_RpcSession_Api(session) { - let main = session.getRemoteMain(); - __capnweb_bindClientValidator(main, validators["Api"]); - return session; -} diff --git a/packages/capnweb-typecheck/index.cjs b/packages/capnweb-typecheck/index.cjs deleted file mode 100644 index b0335f4..0000000 --- a/packages/capnweb-typecheck/index.cjs +++ /dev/null @@ -1,358 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.validators = void 0; -function __capnweb_mismatch(path, expected, value) { - return { path, expected, actual: __capnweb_actualKind(value), value }; -} -function __capnweb_missing(path, expected) { - return { path, expected, actual: "missing", value: undefined }; -} -function __capnweb_makeValidationError(prefix, failure) { - let where = failure.path.length > 0 ? failure.path.join(".") : "value"; - let err = new TypeError(prefix + ": " + where + ": expected " + failure.expected + ", got " + failure.actual); - err.rpcValidation = failure; - return err; -} -function __capnweb_actualKind(value) { - 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.constructor; - if (ctor && ctor.name && ctor.name !== "Object") - return ctor.name; - return "object"; - } - return typeof value; -} -function __capnweb_validate_0(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_2(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_3(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_1(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, name: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_2(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "name")) - return __capnweb_missing([...path, "name"], "string"); - { - let failure = __capnweb_validate_3(record["name"], [...path, "name"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_6(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_7(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_5(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, name: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_6(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "name")) - return __capnweb_missing([...path, "name"], "string"); - { - let failure = __capnweb_validate_7(record["name"], [...path, "name"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_9(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_8(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_9(value, path, options); - return undefined; -} -function __capnweb_validate_11(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_10(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_11(value, path, options); - return undefined; -} -function __capnweb_validate_path_4(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_5(value, path, options); - switch (path[offset]) { - case "id": - return __capnweb_validate_path_8(path, value, offset + 1, options); - case "name": - return __capnweb_validate_path_10(path, value, offset + 1, options); - default: return undefined; - } -} -function __capnweb_validate_12(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_14(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_15(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_13(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, bio: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_14(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "bio")) - return __capnweb_missing([...path, "bio"], "string"); - { - let failure = __capnweb_validate_15(record["bio"], [...path, "bio"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_18(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_19(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_17(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, bio: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_18(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "bio")) - return __capnweb_missing([...path, "bio"], "string"); - { - let failure = __capnweb_validate_19(record["bio"], [...path, "bio"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_21(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_20(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_21(value, path, options); - return undefined; -} -function __capnweb_validate_23(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_22(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_23(value, path, options); - return undefined; -} -function __capnweb_validate_path_16(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_17(value, path, options); - switch (path[offset]) { - case "id": - return __capnweb_validate_path_20(path, value, offset + 1, options); - case "bio": - return __capnweb_validate_path_22(path, value, offset + 1, options); - default: return undefined; - } -} -function __capnweb_validate_24(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_26(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_25(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (!Array.isArray(value)) - return __capnweb_mismatch(path, "string[]", value); - for (let i = 0; i < value.length; i++) { - let failure = __capnweb_validate_26(value[i], [...path, "[" + i + "]"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_29(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_28(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (!Array.isArray(value)) - return __capnweb_mismatch(path, "string[]", value); - for (let i = 0; i < value.length; i++) { - let failure = __capnweb_validate_29(value[i], [...path, "[" + i + "]"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_31(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_30(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_31(value, path, options); - return undefined; -} -function __capnweb_validate_path_27(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_28(value, path, options); - return __capnweb_validate_path_30(path, value, offset + 1, options); -} -exports.validators = { - "Api": { - "authenticate": { - args(args, options) { - if (args.length < 1 || args.length > 1) { - throw new TypeError("Api.authenticate expected 1 argument(s), got " + args.length); - } - { - let failure = __capnweb_validate_0(args[0], ["sessionToken"], options); - if (failure) - throw __capnweb_makeValidationError("Api.authenticate", failure); - } - }, - returns(value) { - let failure = __capnweb_validate_1(value, []); - if (failure) - throw __capnweb_makeValidationError("Api.authenticate return", failure); - }, - returnsPath(path, value) { - let failure = __capnweb_validate_path_4(path, value); - if (failure) - throw __capnweb_makeValidationError("Api.authenticate return", failure); - }, - }, - "getUserProfile": { - args(args, options) { - if (args.length < 1 || args.length > 1) { - throw new TypeError("Api.getUserProfile expected 1 argument(s), got " + args.length); - } - { - let failure = __capnweb_validate_12(args[0], ["userId"], options); - if (failure) - throw __capnweb_makeValidationError("Api.getUserProfile", failure); - } - }, - returns(value) { - let failure = __capnweb_validate_13(value, []); - if (failure) - throw __capnweb_makeValidationError("Api.getUserProfile return", failure); - }, - returnsPath(path, value) { - let failure = __capnweb_validate_path_16(path, value); - if (failure) - throw __capnweb_makeValidationError("Api.getUserProfile return", failure); - }, - }, - "getNotifications": { - args(args, options) { - if (args.length < 1 || args.length > 1) { - throw new TypeError("Api.getNotifications expected 1 argument(s), got " + args.length); - } - { - let failure = __capnweb_validate_24(args[0], ["userId"], options); - if (failure) - throw __capnweb_makeValidationError("Api.getNotifications", failure); - } - }, - returns(value) { - let failure = __capnweb_validate_25(value, []); - if (failure) - throw __capnweb_makeValidationError("Api.getNotifications return", failure); - }, - returnsPath(path, value) { - let failure = __capnweb_validate_path_27(path, value); - if (failure) - throw __capnweb_makeValidationError("Api.getNotifications return", failure); - }, - } - } -}; diff --git a/packages/capnweb-typecheck/index.d.ts b/packages/capnweb-typecheck/index.d.ts deleted file mode 100644 index 2415263..0000000 --- a/packages/capnweb-typecheck/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Internal placeholder. Do not import this package directly. -import type { RpcClassValidators } from "capnweb/internal/typecheck"; - -/** - * Map of RPC class name → method validators. When null, runtime validation is - * disabled. Overwritten in-place by `capnweb typecheck gen`. - */ -export declare const validators: Record | null; diff --git a/packages/capnweb-typecheck/index.js b/packages/capnweb-typecheck/index.js deleted file mode 100644 index fa5523b..0000000 --- a/packages/capnweb-typecheck/index.js +++ /dev/null @@ -1,355 +0,0 @@ -function __capnweb_mismatch(path, expected, value) { - return { path, expected, actual: __capnweb_actualKind(value), value }; -} -function __capnweb_missing(path, expected) { - return { path, expected, actual: "missing", value: undefined }; -} -function __capnweb_makeValidationError(prefix, failure) { - let where = failure.path.length > 0 ? failure.path.join(".") : "value"; - let err = new TypeError(prefix + ": " + where + ": expected " + failure.expected + ", got " + failure.actual); - err.rpcValidation = failure; - return err; -} -function __capnweb_actualKind(value) { - 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.constructor; - if (ctor && ctor.name && ctor.name !== "Object") - return ctor.name; - return "object"; - } - return typeof value; -} -function __capnweb_validate_0(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_2(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_3(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_1(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, name: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_2(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "name")) - return __capnweb_missing([...path, "name"], "string"); - { - let failure = __capnweb_validate_3(record["name"], [...path, "name"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_6(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_7(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_5(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, name: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_6(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "name")) - return __capnweb_missing([...path, "name"], "string"); - { - let failure = __capnweb_validate_7(record["name"], [...path, "name"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_9(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_8(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_9(value, path, options); - return undefined; -} -function __capnweb_validate_11(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_10(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_11(value, path, options); - return undefined; -} -function __capnweb_validate_path_4(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_5(value, path, options); - switch (path[offset]) { - case "id": - return __capnweb_validate_path_8(path, value, offset + 1, options); - case "name": - return __capnweb_validate_path_10(path, value, offset + 1, options); - default: return undefined; - } -} -function __capnweb_validate_12(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_14(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_15(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_13(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, bio: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_14(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "bio")) - return __capnweb_missing([...path, "bio"], "string"); - { - let failure = __capnweb_validate_15(record["bio"], [...path, "bio"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_18(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_19(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_17(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (typeof value !== "object" || value === null || Array.isArray(value)) - return __capnweb_mismatch(path, "{ id: string, bio: string }", value); - let record = value; - if (!Object.prototype.hasOwnProperty.call(record, "id")) - return __capnweb_missing([...path, "id"], "string"); - { - let failure = __capnweb_validate_18(record["id"], [...path, "id"], options); - if (failure) - return failure; - } - if (!Object.prototype.hasOwnProperty.call(record, "bio")) - return __capnweb_missing([...path, "bio"], "string"); - { - let failure = __capnweb_validate_19(record["bio"], [...path, "bio"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_21(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_20(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_21(value, path, options); - return undefined; -} -function __capnweb_validate_23(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_22(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_23(value, path, options); - return undefined; -} -function __capnweb_validate_path_16(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_17(value, path, options); - switch (path[offset]) { - case "id": - return __capnweb_validate_path_20(path, value, offset + 1, options); - case "bio": - return __capnweb_validate_path_22(path, value, offset + 1, options); - default: return undefined; - } -} -function __capnweb_validate_24(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_26(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_25(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (!Array.isArray(value)) - return __capnweb_mismatch(path, "string[]", value); - for (let i = 0; i < value.length; i++) { - let failure = __capnweb_validate_26(value[i], [...path, "[" + i + "]"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_29(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_28(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - if (!Array.isArray(value)) - return __capnweb_mismatch(path, "string[]", value); - for (let i = 0; i < value.length; i++) { - let failure = __capnweb_validate_29(value[i], [...path, "[" + i + "]"], options); - if (failure) - return failure; - } - return undefined; -} -function __capnweb_validate_31(value, path, options) { - if (options?.isRpcPlaceholder?.(value)) - return undefined; - return typeof value === "string" ? undefined : __capnweb_mismatch(path, "string", value); -} -function __capnweb_validate_path_30(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_31(value, path, options); - return undefined; -} -function __capnweb_validate_path_27(path, value, offset = 0, options) { - if (offset >= path.length) - return __capnweb_validate_28(value, path, options); - return __capnweb_validate_path_30(path, value, offset + 1, options); -} -export const validators = { - "Api": { - "authenticate": { - args(args, options) { - if (args.length < 1 || args.length > 1) { - throw new TypeError("Api.authenticate expected 1 argument(s), got " + args.length); - } - { - let failure = __capnweb_validate_0(args[0], ["sessionToken"], options); - if (failure) - throw __capnweb_makeValidationError("Api.authenticate", failure); - } - }, - returns(value) { - let failure = __capnweb_validate_1(value, []); - if (failure) - throw __capnweb_makeValidationError("Api.authenticate return", failure); - }, - returnsPath(path, value) { - let failure = __capnweb_validate_path_4(path, value); - if (failure) - throw __capnweb_makeValidationError("Api.authenticate return", failure); - }, - }, - "getUserProfile": { - args(args, options) { - if (args.length < 1 || args.length > 1) { - throw new TypeError("Api.getUserProfile expected 1 argument(s), got " + args.length); - } - { - let failure = __capnweb_validate_12(args[0], ["userId"], options); - if (failure) - throw __capnweb_makeValidationError("Api.getUserProfile", failure); - } - }, - returns(value) { - let failure = __capnweb_validate_13(value, []); - if (failure) - throw __capnweb_makeValidationError("Api.getUserProfile return", failure); - }, - returnsPath(path, value) { - let failure = __capnweb_validate_path_16(path, value); - if (failure) - throw __capnweb_makeValidationError("Api.getUserProfile return", failure); - }, - }, - "getNotifications": { - args(args, options) { - if (args.length < 1 || args.length > 1) { - throw new TypeError("Api.getNotifications expected 1 argument(s), got " + args.length); - } - { - let failure = __capnweb_validate_24(args[0], ["userId"], options); - if (failure) - throw __capnweb_makeValidationError("Api.getNotifications", failure); - } - }, - returns(value) { - let failure = __capnweb_validate_25(value, []); - if (failure) - throw __capnweb_makeValidationError("Api.getNotifications return", failure); - }, - returnsPath(path, value) { - let failure = __capnweb_validate_path_27(path, value); - if (failure) - throw __capnweb_makeValidationError("Api.getNotifications return", failure); - }, - } - } -}; diff --git a/packages/capnweb-typecheck/package.json b/packages/capnweb-typecheck/package.json deleted file mode 100644 index e44469c..0000000 --- a/packages/capnweb-typecheck/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "capnweb-typecheck", - "version": "0.0.1", - "description": "Internal placeholder package for Cap'n Web runtime type validators. Do not import directly — capnweb manages this for you.", - "type": "module", - "main": "./index.js", - "types": "./index.d.ts", - "exports": { - ".": { - "types": "./index.d.ts", - "import": "./index.js", - "require": "./index.cjs" - }, - "./clients": { - "types": "./clients.d.ts", - "import": "./clients.js", - "require": "./clients.cjs" - } - }, - "files": [ - "index.js", - "index.cjs", - "index.d.ts", - "clients.js", - "clients.cjs", - "clients.d.ts" - ], - "license": "MIT", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "https://github.com/cloudflare/capnweb" - } -} 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.d.ts b/src/_typecheck-validators.d.ts new file mode 100644 index 0000000..92204a1 --- /dev/null +++ b/src/_typecheck-validators.d.ts @@ -0,0 +1,9 @@ +// Ambient declaration so the source build (tsup -> rollup DTS) can resolve +// `capnweb/_typecheck-validators` before the dist files exist. The real +// implementation is `src/_typecheck-validators.ts`; both keep their type +// signatures in sync. + +declare module "capnweb/_typecheck-validators" { + import type { RpcClassValidators } from "./core.js"; + export const validators: Record | null; +} 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 3da42d1..0227d71 100644 --- a/src/core.ts +++ b/src/core.ts @@ -4,7 +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"; +import { validators as generatedValidators } from "capnweb/_typecheck-validators"; // Polyfill Symbol.dispose for browsers that don't support it yet if (!Symbol.dispose) { diff --git a/src/typecheck/cli.ts b/src/typecheck/cli.ts index 403646a..8818e2d 100644 --- a/src/typecheck/cli.ts +++ b/src/typecheck/cli.ts @@ -6,12 +6,13 @@ // Build-time CLI for Cap'n Web RPC validation codegen. // // `capnweb typecheck gen ` extracts RpcTarget classes from -// and writes validators into the resolved `capnweb-typecheck` placeholder -// package. The capnweb runtime auto-loads from there by class name, so user -// code and bundler entry points stay untouched. +// 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 placeholder to its stub state, which -// disables runtime validation until the next `gen` run. +// `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"; diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index a85a1a3..ba2d36a 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -34,40 +34,39 @@ export function generate(options: GenOptions): GenResult { } /** - * Generate validators and write them to the resolved `capnweb-typecheck` - * package location. This is the supported flow: users run - * `capnweb typecheck gen src/worker.ts` once, validators are written into the - * placeholder package, and the capnweb runtime picks them up automatically by - * class name. No entry-point or import changes are required in user code. + * 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. * - * Two files are produced inside the placeholder package directory: - * - `index.js` / `index.cjs` — exports `validators`. Pure data + helpers, - * no `capnweb` import, so loading the capnweb runtime does not create an - * import cycle. - * - `clients.js` / `clients.cjs` — exports `__capnweb_wrap_*` functions for - * the Vite plugin's client-side rewriting. Imports `capnweb` (safe: only - * pulled in when the frontend uses validated stubs). + * 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 packageDir = resolveTypecheckPackageDir(); - let prepared = prepare({ input: options.input, outDir: packageDir, runtimeImport: "capnweb/internal/typecheck" }); + let { validatorsEsm, validatorsCjs, clientsEsm, clientsCjs } = resolveTypecheckTargets(); + let prepared = prepare({ + input: options.input, + outDir: dirname(validatorsEsm), + runtimeImport: "capnweb/internal/typecheck", + }); let { classes, runtimeImport } = prepared; let specsTs = emitSpecsModule(classes, runtimeImport); - // Generated `clients.ts` imports `validators` from `./specs.js`; the package - // exposes the same export from its main entry instead, so point clients at - // it via the bare package name. + // 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"`); - - safeWrite(join(packageDir, "index.js"), tsToEsm(specsTs)); - safeWrite(join(packageDir, "index.cjs"), tsToCjs(specsTs)); - safeWrite(join(packageDir, "clients.js"), tsToEsm(clientsTs)); - safeWrite(join(packageDir, "clients.cjs"), tsToCjs(clientsTs)); - log(packageDir, "index.js"); - log(packageDir, "index.cjs"); - log(packageDir, "clients.js"); - log(packageDir, "clients.cjs"); + .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); } @@ -97,15 +96,15 @@ function tsToCjs(source: string): string { } /** - * Restore the `capnweb-typecheck` placeholder to its stub state. Equivalent to - * a fresh install — no validators are active. + * Restore the internal typecheck placeholder subpaths to their stub state. + * Equivalent to a fresh install — no validators are active. */ export function resetTypecheckPackage(): void { - let packageDir = resolveTypecheckPackageDir(); - safeWrite(join(packageDir, "index.js"), STUB_ESM); - safeWrite(join(packageDir, "index.cjs"), STUB_CJS); - safeWrite(join(packageDir, "clients.js"), STUB_CLIENTS_ESM); - safeWrite(join(packageDir, "clients.cjs"), STUB_CLIENTS_CJS); + 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` + @@ -124,20 +123,39 @@ const STUB_CLIENTS_CJS = STUB_BANNER + `"use strict";\n` + `Object.defineProperty(exports, "__esModule", { value: true });\n`; -function resolveTypecheckPackageDir(): string { +type TypecheckTargets = { + validatorsEsm: string; + validatorsCjs: string; + clientsEsm: string; + clientsCjs: string; +}; + +function resolveTypecheckTargets(): TypecheckTargets { let req = createRequire(resolve(process.cwd(), "_")); - let indexPath = req.resolve("capnweb-typecheck"); - if (isInsideYarnPnpZip(indexPath)) { + // `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-typecheck resolves inside a Yarn Plug'n'Play archive ` + - `(${indexPath}). Writable files are required for codegen.\n` + + `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-typecheck\` once, then re-run ` + + `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.` ); } - return dirname(indexPath); + 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 { diff --git a/tsup.config.ts b/tsup.config.ts index b30b2d6..cb00daf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -25,8 +25,13 @@ export default defineConfig([ // 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'], + external: ['cloudflare:workers', 'capnweb/_typecheck-validators', 'capnweb/_typecheck-clients'], clean: true, // Works in browsers, Node, and Cloudflare Workers platform: 'neutral', From 20410564d3adfc671d921a55c4e812e201ce10c4 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sat, 16 May 2026 01:07:01 -0400 Subject: [PATCH 15/24] fix(build): unblock DTS generation for capnweb subpath self-references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the side-by-side `_typecheck-validators.d.ts` ambient with a tsconfig `paths` mapping. The companion .d.ts conflicted with tsup's DTS emit ("would overwrite input file"), which left dist/index.d.ts unbuilt on a clean install — previous runs only succeeded because old artifacts were still in dist. Routing the self-reference through `paths` keeps the source resolution local without triggering the overwrite check. Verified that @internal + stripInternal still removes `__capnweb_registerRpcValidators` and `__capnweb_bindClientValidator` from the published dist/index.d.ts; both stay accessible through capnweb/internal/typecheck for generated code. --- src/_typecheck-validators.d.ts | 9 --------- tsconfig.json | 7 ++++++- 2 files changed, 6 insertions(+), 10 deletions(-) delete mode 100644 src/_typecheck-validators.d.ts diff --git a/src/_typecheck-validators.d.ts b/src/_typecheck-validators.d.ts deleted file mode 100644 index 92204a1..0000000 --- a/src/_typecheck-validators.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Ambient declaration so the source build (tsup -> rollup DTS) can resolve -// `capnweb/_typecheck-validators` before the dist files exist. The real -// implementation is `src/_typecheck-validators.ts`; both keep their type -// signatures in sync. - -declare module "capnweb/_typecheck-validators" { - import type { RpcClassValidators } from "./core.js"; - export const validators: Record | null; -} diff --git a/tsconfig.json b/tsconfig.json index 346e3fd..69e2b29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,12 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "stripInternal": 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"] From 177901a6f9950553f77a2b81afa2518cca4e7d6d Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sat, 16 May 2026 01:10:56 -0400 Subject: [PATCH 16/24] feat(typecheck): add --strict mode to capnweb typecheck gen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In strict mode the runtime throws when an RpcTarget subclass is encountered that has no entry in the generated validators map. Catches the silent footgun where a class is renamed or added without rerunning codegen — without --strict, the lookup falls through and validation silently disappears. The flag is baked into the generated module as `export const strict = true|false`; the runtime imports it alongside `validators` and only throws when validators were generated AND strict is on AND the leaf class has no matching entry. Default remains non-strict so additive projects (multi-entry, third-party RpcTarget bases) don't break. --- __tests__/typecheck-package.test.ts | 11 +++++++++++ src/_typecheck-validators.ts | 8 ++++++++ src/core.ts | 17 +++++++++++++++-- src/typecheck/cli.ts | 16 ++++++++++------ src/typecheck/generate.ts | 15 +++++++++++---- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index 734edcf..47b902e 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -70,6 +70,17 @@ describe("generateForPackage", () => { expect(mod.validators).toBeNull(); }); + it("marks strict=true in the generated module when --strict is passed", async () => { + generateForPackage({ input: inputFile, strict: true }); + let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; + expect(mod.strict).toBe(true); + + // Default (no flag) should reset to non-strict. + generateForPackage({ input: inputFile }); + let mod2 = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; + expect(mod2.strict).toBe(false); + }); + // 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 diff --git a/src/_typecheck-validators.ts b/src/_typecheck-validators.ts index e8882be..9249e06 100644 --- a/src/_typecheck-validators.ts +++ b/src/_typecheck-validators.ts @@ -6,3 +6,11 @@ import type { RpcClassValidators } from "./core.js"; export const validators: Record | null = null; + +/** + * When true, the runtime throws on the first call into an `RpcTarget` + * subclass that has no entry in `validators` (instead of silently skipping + * validation). Set by `capnweb typecheck gen --strict` to catch missed + * regenerations after a class is renamed or a new class is added. + */ +export const strict: boolean = false; diff --git a/src/core.ts b/src/core.ts index 0227d71..5394732 100644 --- a/src/core.ts +++ b/src/core.ts @@ -4,7 +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"; +import { validators as generatedValidators, strict as generatedStrict } from "capnweb/_typecheck-validators"; // Polyfill Symbol.dispose for browsers that don't support it yet if (!Symbol.dispose) { @@ -65,7 +65,8 @@ 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; + let leafClass = (thisArg as {constructor?: Function}).constructor; + let klass = leafClass; while (typeof klass === "function") { let registered = rpcValidators.get(klass); if (!registered && generatedValidators) { @@ -86,6 +87,18 @@ function getRpcValidator( if (klass === Object) break; } + // Strict mode: validators were generated but nothing in the prototype chain + // matches. Almost always means the class was renamed or added after the last + // `capnweb typecheck gen`. Surface it loudly instead of silently skipping + // validation — exactly the bug `--strict` exists to catch. + if (generatedValidators && generatedStrict && leafClass && leafClass.name) { + throw new TypeError( + `Cap'n Web strict typecheck: no generated validators for '${leafClass.name}'. ` + + `Re-run \`capnweb typecheck gen\` against the entry that exports this class, ` + + `or remove --strict to disable this assertion.` + ); + } + return undefined; } diff --git a/src/typecheck/cli.ts b/src/typecheck/cli.ts index 8818e2d..1dbc815 100644 --- a/src/typecheck/cli.ts +++ b/src/typecheck/cli.ts @@ -21,13 +21,14 @@ import { generate, generateForPackage, resetTypecheckPackage, type GenOptions } function usage(exitCode = 1): never { let out = exitCode === 0 ? console.log : console.error; out(`Usage: - capnweb typecheck gen [--out ] + capnweb typecheck gen [--strict] [--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`); + capnweb typecheck gen src/worker.ts --strict # throw on any RpcTarget without validators + capnweb typecheck gen src/worker.ts --out .capnweb # legacy directory output + capnweb typecheck reset # restore stub validators`); process.exit(exitCode); } @@ -48,21 +49,24 @@ async function main() { let parsed = parseGenArgs(rest); if (parsed.outDir === undefined) { - generateForPackage({ input: parsed.input }); + generateForPackage({ input: parsed.input, strict: parsed.strict }); } else { generate({ input: parsed.input, outDir: parsed.outDir }); } } -function parseGenArgs(args: string[]): { input: string; outDir: string | undefined } { +function parseGenArgs(args: string[]): { input: string; outDir: string | undefined; strict: boolean } { let input: string | undefined; let outDir: string | undefined; + let strict = false; 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 === "--strict") { + strict = true; } else if (arg.startsWith("--")) { throw new Error(`Unknown option: ${arg}`); } else if (!input) { @@ -73,7 +77,7 @@ function parseGenArgs(args: string[]): { input: string; outDir: string | undefin } if (!input) usage(); - return { input, outDir }; + return { input, outDir, strict }; } if (isMain()) { diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index ba2d36a..74bc70e 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -45,7 +45,7 @@ export function generate(options: GenOptions): GenResult { * `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 { +export function generateForPackage(options: { input: string; strict?: boolean }): GenResult { let { validatorsEsm, validatorsCjs, clientsEsm, clientsCjs } = resolveTypecheckTargets(); let prepared = prepare({ input: options.input, @@ -53,8 +53,12 @@ export function generateForPackage(options: { input: string }): GenResult { runtimeImport: "capnweb/internal/typecheck", }); let { classes, runtimeImport } = prepared; + let strict = Boolean(options.strict); - let specsTs = emitSpecsModule(classes, runtimeImport); + // Append a `strict` export so the runtime can flip into throw-on-miss mode + // without a separate file or env var. + let specsTs = emitSpecsModule(classes, runtimeImport) + + `\nexport const strict = ${strict ? "true" : "false"};\n`; // 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) @@ -110,12 +114,15 @@ export function resetTypecheckPackage(): void { 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_ESM = STUB_BANNER + + `export const validators = null;\n` + + `export const strict = false;\n`; const STUB_CJS = STUB_BANNER + `"use strict";\n` + `Object.defineProperty(exports, "__esModule", { value: true });\n` + - `exports.validators = null;\n`; + `exports.validators = null;\n` + + `exports.strict = false;\n`; const STUB_CLIENTS_ESM = STUB_BANNER + `// No client wrappers until \`gen\` runs.\n`; From 5731f6d268ccfe68955820c8864bf65b347032dd Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sat, 16 May 2026 01:14:51 -0400 Subject: [PATCH 17/24] feat(typecheck): expose RpcValidationError class for clean catch patterns Replace the (err as TypeError & { rpcValidation }) cast pattern with a proper RpcValidationError class exported from capnweb. Users can now write `if (err instanceof RpcValidationError)` and pull structured detail from `err.rpcValidation` without type assertions. The class extends TypeError so existing `instanceof TypeError` catches still match. Generated validators import RpcValidationError from capnweb via a deferred reference (cycle-safe because the binding is only read inside the error factory at call time). --- __tests__/typecheck-package.test.ts | 24 +++++++++++++++++ src/core.ts | 40 +++++++++++++++++++++++++++++ src/index.ts | 5 ++++ src/typecheck/generate.ts | 9 ++++--- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index 47b902e..d380a5d 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -70,6 +70,30 @@ describe("generateForPackage", () => { expect(mod.validators).toBeNull(); }); + 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("marks strict=true in the generated module when --strict is passed", async () => { generateForPackage({ input: inputFile, strict: true }); let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; diff --git a/src/core.ts b/src/core.ts index 5394732..06ee7ce 100644 --- a/src/core.ts +++ b/src/core.ts @@ -50,6 +50,46 @@ export type RpcMethodValidator = { 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 { diff --git a/src/index.ts b/src/index.ts index 6723788..103c3dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,11 @@ export { }; 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). diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index 74bc70e..44d188e 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -264,6 +264,10 @@ function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string): 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 };", @@ -278,9 +282,8 @@ function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string): "", "function __capnweb_makeValidationError(prefix: string, failure: __CapnwebValidationFailure): TypeError {", " let where = failure.path.length > 0 ? failure.path.join(\".\") : \"value\";", - " let err = new TypeError(prefix + \": \" + where + \": expected \" + failure.expected + \", got \" + failure.actual);", - " (err as TypeError & { rpcValidation: __CapnwebValidationFailure }).rpcValidation = failure;", - " return err;", + " let message = prefix + \": \" + where + \": expected \" + failure.expected + \", got \" + failure.actual;", + " return new __CapnwebRpcValidationError(message, failure);", "}", "", "function __capnweb_actualKind(value: unknown): string {", From b0572726367110a4cca5f0e1d6aa53023ec3ec6f Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sat, 16 May 2026 02:02:06 -0400 Subject: [PATCH 18/24] feat(typecheck): support recursive types via hoisted named validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the recursion-rejection in lowerType with detection that hoists a self-referential type into a named entry. On a re-entry the lowerer emits a `{ kind: "ref", id }` and the codegen emits one shared validator function per id. JavaScript hoists function declarations, so emitted functions can call themselves and each other for mutual recursion (e.g. `type A = { b: B }`, `type B = { a: A }`). Threads named-type state through extractClasses (now returns `{ classes, named }`), Prepared, prepare/checkSupported, and emit helpers. checkSupported tracks visited ref ids to avoid infinite walks when validating cycles in the IR itself. Flips the "rejects recursive types" test to assert acceptance and adds an end-to-end runtime case that validates a Tree shape (string children, recursive children array) and catches a deeply-nested wrong type. Common patterns that previously errored out — JsonValue, TreeNode, linked lists — now produce a single hoisted validator that the rest of the generated code calls into. --- __tests__/typecheck-package.test.ts | 25 +++ __tests__/typecheck.test.ts | 15 +- src/typecheck/extract.ts | 258 +++++++++++++++++----------- src/typecheck/generate.ts | 108 ++++++++++-- src/typecheck/types.ts | 6 +- 5 files changed, 292 insertions(+), 120 deletions(-) diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index d380a5d..94297a9 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -70,6 +70,31 @@ describe("generateForPackage", () => { 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; diff --git a/__tests__/typecheck.test.ts b/__tests__/typecheck.test.ts index 1a59afc..646f4d2 100644 --- a/__tests__/typecheck.test.ts +++ b/__tests__/typecheck.test.ts @@ -25,8 +25,8 @@ function inspectInput(input: string) { let project = createProject(input); let sourceFile = project.sourceFile; let reachableFiles = collectReachableSourceFiles(project); - let classes = extractClasses(project, reachableFiles); - return { sourceFile, reachableFiles, classes }; + let { classes, named } = extractClasses(project, reachableFiles); + return { sourceFile, reachableFiles, classes, named }; } function emitShadowFor(input: string, outDir: string): void { @@ -341,7 +341,7 @@ describe("preflight type rejection", () => { } }); - it("rejects recursive types", () => { + it("accepts recursive types by hoisting named validators", () => { let root = mkdtempSync(resolve(".capnweb-recursive-")); try { writeFakeCapnweb(root); @@ -353,7 +353,14 @@ describe("preflight type rejection", () => { tree(value: Tree): void {} } `); - expect(() => inspectInput(input)).toThrow(/recursive types are not supported/); + 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 }); } diff --git a/src/typecheck/extract.ts b/src/typecheck/extract.ts index 8a3b1f2..608b74f 100644 --- a/src/typecheck/extract.ts +++ b/src/typecheck/extract.ts @@ -99,10 +99,42 @@ export function collectReachableSourceFiles(project: TypecheckProject): ts.Sourc return result; } -export function extractClasses(project: TypecheckProject, sourceFiles: ts.SourceFile[]): ExtractedClassSpec[] { - return sourceFiles.flatMap(sourceFile => sourceFile.statements.filter(ts.isClassDeclaration)) +/** + * 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)); + .map((klass, index) => extractClass(project.checker, klass, index, ctx)); + return { classes, named: ctx.named }; } function isRpcTargetExtender(checker: ts.TypeChecker, klass: ts.ClassDeclaration): boolean { @@ -126,7 +158,7 @@ function isRpcTargetExtender(checker: ts.TypeChecker, klass: ts.ClassDeclaration return false; } -function extractClass(checker: ts.TypeChecker, klass: ts.ClassDeclaration, index: number): ExtractedClassSpec { +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 " + @@ -142,7 +174,7 @@ function extractClass(checker: ts.TypeChecker, klass: ts.ClassDeclaration, index valueName: isDefault ? `__capnweb_default_${index}` : name, isDefault, sourcePath: klass.getSourceFile().fileName, - methods: extractMethods(checker, klass, name), + methods: extractMethods(checker, klass, name, ctx), }; } @@ -151,7 +183,7 @@ function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { } function extractMethods( - checker: ts.TypeChecker, klass: ts.ClassDeclaration, className: string): MethodSpec[] { + checker: ts.TypeChecker, klass: ts.ClassDeclaration, className: string, ctx: LowerContext): MethodSpec[] { let methods: MethodSpec[] = []; let seen = new Set(); for (let member of klass.members) { @@ -173,12 +205,12 @@ function extractMethods( name: param.name.text, optional: param.questionToken !== undefined || param.initializer !== undefined, type: lowerType(checker, checker.getTypeAtLocation(param), - `${className}.${methodName} parameter '${param.name.text}'`, new Set()), + `${className}.${methodName} parameter '${param.name.text}'`, ctx), }); } let signature = checker.getSignatureFromDeclaration(member); let returns = lowerType(checker, checker.getReturnTypeOfSignature(signature!), - `${className}.${methodName} return`, new Set()); + `${className}.${methodName} return`, ctx); methods.push({ name: methodName, params, returns }); } return methods; @@ -190,103 +222,135 @@ function propertyNameText(name: ts.PropertyName): string | undefined { } function lowerType( - checker: ts.TypeChecker, type: ts.Type, location: string, visiting: Set): TypeSpec { - if (visiting.has(type)) { - throw new Error(`${location}: recursive types are not supported by capnweb typecheck yet.`); + 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 }; } - visiting.add(type); + + ctx.visiting.set(type, undefined); try { - 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`); + 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 }; } - 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, visiting)); - return variants.length === 1 ? variants[0] : { kind: "union", variants }; - } - if (type.isIntersection()) return { kind: "object", props: objectProps(checker, type, location, visiting) }; - 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, visiting)), - }; - } - if (checker.isArrayType(type)) { - let arg = checker.getTypeArguments(type as ts.TypeReference)[0]; - return { kind: "array", element: arg ? lowerType(checker, arg, location, visiting) : { 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, visiting) : { kind: "any" }, - value: typeArgs[1] ? lowerType(checker, typeArgs[1], location, visiting) : { kind: "any" }, - }; - } - if (symbolName === "Set" || symbolName === "ReadonlySet") { - return { - kind: "set", - value: typeArgs[0] ? lowerType(checker, typeArgs[0], location, visiting) : { 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, visiting) : { kind: "any" }; - } - if (symbolName === "Array" || symbolName === "ReadonlyArray") { - return typeArgs[0] ? { kind: "array", element: lowerType(checker, typeArgs[0], location, visiting) } - : { kind: "array", element: { kind: "any" } }; + return spec; + } finally { + ctx.visiting.delete(type); + } +} + +function makeNamedId(type: ts.Type, ctx: LowerContext): string { + let symbol = type.aliasSymbol ?? type.getSymbol(); + let base = symbol?.getName().replace(/[^A-Za-z0-9_]/g, "_") || "Anon"; + return `${base}_${ctx.nextNamedId.value++}`; +} + +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.`); } - 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, visiting) }; + 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: "object", props: objectProps(checker, type, location, visiting) }; - } finally { - visiting.delete(type); + return { kind: "record", value: lowerType(checker, index, location, ctx) }; } + return { kind: "object", props: objectProps(checker, type, location, ctx) }; } function objectProps( - checker: ts.TypeChecker, type: ts.Type, location: string, visiting: Set) { + 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 []; @@ -294,7 +358,7 @@ function objectProps( return [{ name: prop.getName(), optional: (prop.flags & ts.SymbolFlags.Optional) !== 0, - type: lowerType(checker, propType, `${location}: property '${prop.getName()}'`, visiting), + type: lowerType(checker, propType, `${location}: property '${prop.getName()}'`, ctx), }]; }); } diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index 44d188e..9713aa5 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -52,12 +52,12 @@ export function generateForPackage(options: { input: string; strict?: boolean }) outDir: dirname(validatorsEsm), runtimeImport: "capnweb/internal/typecheck", }); - let { classes, runtimeImport } = prepared; + let { classes, named, runtimeImport } = prepared; let strict = Boolean(options.strict); // Append a `strict` export so the runtime can flip into throw-on-miss mode // without a separate file or env var. - let specsTs = emitSpecsModule(classes, runtimeImport) + + let specsTs = emitSpecsModule(classes, runtimeImport, named) + `\nexport const strict = ${strict ? "true" : "false"};\n`; // Generated `clients.ts` imports `validators` from `./specs.js`; point that // at the validators subpath instead so the two files are independent. @@ -182,6 +182,7 @@ export function generateValidatorsOnly(options: GenOptions): GenResult { type Prepared = { classes: ExtractedClassSpec[]; + named: Map; outAbs: string; runtimeImport: string; sourceFile: ReturnType["sourceFile"]; @@ -199,17 +200,19 @@ function prepare(options: GenOptions): Prepared { let sourceFile = project.sourceFile; let reachableFiles = collectReachableSourceFiles(project); let root = commonDir(reachableFiles.map(file => file.fileName)); - let classes = extractClasses(project, reachableFiles); + 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); - checkSupported(method.returns); + 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, outAbs, runtimeImport, sourceFile, reachableFiles, root }; + return { classes, named, outAbs, runtimeImport, sourceFile, reachableFiles, root }; } function checkNameCollisions(classes: ExtractedClassSpec[]): void { @@ -224,21 +227,28 @@ function checkNameCollisions(classes: ExtractedClassSpec[]): void { } } -function checkSupported(type: TypeSpec): void { +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); break; - case "tuple": for (let element of type.elements) checkSupported(element); break; - case "map": checkSupported(type.key); checkSupported(type.value); break; - case "set": checkSupported(type.value); break; - case "object": for (let prop of type.props) checkSupported(prop.type); break; - case "record": checkSupported(type.value); break; - case "union": for (let variant of type.variants) checkSupported(variant); break; + 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)); + 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"); @@ -246,8 +256,17 @@ function emitArtifacts(p: Prepared): void { log(p.outAbs, "clients.ts"); } -function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string): string { - let emit: ValidatorEmit = { lines: [], nextId: 0 }; +function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string, named: Map = new Map()): string { + let emit: ValidatorEmit = { lines: [], nextId: 0, refValueFns: new Map(), refPathFns: new Map() }; + // 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) { @@ -309,7 +328,39 @@ function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string): return lines.join("\n"); } -type ValidatorEmit = { lines: string[]; nextId: number }; +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; +}; + +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): __CapnwebValidationFailure | undefined {`, + ` return ${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): __CapnwebValidationFailure | undefined {`, + ` return ${pathInner}(path, value, offset, options);`, + `}`, + ""); +} function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { let name = "__capnweb_validate_" + emit.nextId++; @@ -450,6 +501,15 @@ function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { case "unsupported": lines.push(" return __capnweb_mismatch(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(" return " + target + "(value, path, options);"); + break; + } } lines.push("}", ""); @@ -524,6 +584,12 @@ function emitTypePathValidator(type: TypeSpec, emit: ValidatorEmit): string { lines.push(" return failures.find(failure => failure.path.length > offset) ?? failures[0];"); 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(" return " + target + "(path, value, offset, options);"); + break; + } default: lines.push(" return undefined;"); break; @@ -610,6 +676,12 @@ function typeDescription(type: TypeSpec): string { 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; + } } } diff --git a/src/typecheck/types.ts b/src/typecheck/types.ts index 7f0d1ec..bd7a4ab 100644 --- a/src/typecheck/types.ts +++ b/src/typecheck/types.ts @@ -20,7 +20,11 @@ export type TypeSpec = | { kind: "stub" } | { kind: "function" } | { kind: "never" } - | { kind: "unsupported", text: string }; + | { 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 }; From 18c52f5d423bd4195cc902294189bd1e87379706 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sun, 17 May 2026 18:06:28 -0400 Subject: [PATCH 19/24] feat(typecheck): throw-on-fail validators, shared helpers, readable names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the generated validators around throw-on-fail semantics so object/array/etc. bodies stop carrying `let failure = ...; if (failure) return failure;` plumbing around every property check. Each child call is one line; the method-level try/catch rewraps the `RpcValidationError` with the right `Api.method` prefix. Reuse a single primitive helper per kind. A typical RPC interface that takes nine string args used to emit nine identical `__capnweb_validate_*` bodies; now one `__capnweb_assert_string` is called by reference and the others collapse. Structural dedup on top: two methods that return the same `User` shape share one validator function instead of producing byte-identical copies. Pull the TypeScript alias symbol through to the codegen so emitted functions are named after the user's types — `__capnweb_assert_User`, `__capnweb_assert_Profile`, `__capnweb_walk_User` — instead of sequential ids. Collisions (two unrelated shapes that share a name) get suffixed via a `usedNames` set seeded with our infrastructure names so user types can't shadow the helpers. Support recursive types: `lowerType` detects when a `ts.Type` is being visited a second time, assigns an id, and emits a `{ kind: "ref", id }` that the codegen turns into a hoisted named validator the body calls itself. JsonValue, Tree, mutual-recursion all work via JS function hoisting. Add a per-kind coverage matrix in `__tests__/typecheck-types.test.ts` that asserts a happy-path value passes and a wrong-shape value throws for every supported TypeSpec kind (primitives + literals + array + tuple + object + record + map + set + union + instance + rpcTarget + stub + function + recursive + void return). 27 cases. Plus a name-collision test in typecheck-package to lock in the suffix behavior. `vitest.config.ts` sets `fileParallelism: false` — typecheck-package and typecheck-types both write into dist/_typecheck-validators.js, so running them in separate workers raced on that shared state. --- __tests__/typecheck-package.test.ts | 33 ++- __tests__/typecheck-types.test.ts | 297 ++++++++++++++++++++ src/_typecheck-validators.ts | 8 - src/core.ts | 17 +- src/typecheck/cli.ts | 12 +- src/typecheck/extract.ts | 30 +- src/typecheck/generate.ts | 412 +++++++++++++++++++--------- src/typecheck/types.ts | 2 +- vitest.config.ts | 6 +- 9 files changed, 639 insertions(+), 178 deletions(-) create mode 100644 __tests__/typecheck-types.test.ts diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index 94297a9..906c365 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -119,15 +119,32 @@ export class TreeApi extends RpcTarget { } }); - it("marks strict=true in the generated module when --strict is passed", async () => { - generateForPackage({ input: inputFile, strict: true }); - let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; - expect(mod.strict).toBe(true); + it("disambiguates two unrelated types that share a display name", async () => { + let fixturePath = join(workDir, "name-collision.ts"); + writeFileSync(fixturePath, ` +import { RpcTarget } from "capnweb"; - // Default (no flag) should reset to non-strict. - generateForPackage({ input: inputFile }); - let mod2 = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; - expect(mod2.strict).toBe(false); +// 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 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/src/_typecheck-validators.ts b/src/_typecheck-validators.ts index 9249e06..e8882be 100644 --- a/src/_typecheck-validators.ts +++ b/src/_typecheck-validators.ts @@ -6,11 +6,3 @@ import type { RpcClassValidators } from "./core.js"; export const validators: Record | null = null; - -/** - * When true, the runtime throws on the first call into an `RpcTarget` - * subclass that has no entry in `validators` (instead of silently skipping - * validation). Set by `capnweb typecheck gen --strict` to catch missed - * regenerations after a class is renamed or a new class is added. - */ -export const strict: boolean = false; diff --git a/src/core.ts b/src/core.ts index 06ee7ce..0b21291 100644 --- a/src/core.ts +++ b/src/core.ts @@ -4,7 +4,7 @@ import type { RpcTargetBranded, __RPC_TARGET_BRAND } from "./types.js"; import { WORKERS_MODULE_SYMBOL } from "./symbols.js" -import { validators as generatedValidators, strict as generatedStrict } from "capnweb/_typecheck-validators"; +import { validators as generatedValidators } from "capnweb/_typecheck-validators"; // Polyfill Symbol.dispose for browsers that don't support it yet if (!Symbol.dispose) { @@ -105,8 +105,7 @@ function getRpcValidator( thisArg: object | undefined, methodName: string | number | undefined): RpcMethodValidator | undefined { if (thisArg === undefined || methodName === undefined) return undefined; - let leafClass = (thisArg as {constructor?: Function}).constructor; - let klass = leafClass; + let klass = (thisArg as {constructor?: Function}).constructor; while (typeof klass === "function") { let registered = rpcValidators.get(klass); if (!registered && generatedValidators) { @@ -127,18 +126,6 @@ function getRpcValidator( if (klass === Object) break; } - // Strict mode: validators were generated but nothing in the prototype chain - // matches. Almost always means the class was renamed or added after the last - // `capnweb typecheck gen`. Surface it loudly instead of silently skipping - // validation — exactly the bug `--strict` exists to catch. - if (generatedValidators && generatedStrict && leafClass && leafClass.name) { - throw new TypeError( - `Cap'n Web strict typecheck: no generated validators for '${leafClass.name}'. ` + - `Re-run \`capnweb typecheck gen\` against the entry that exports this class, ` + - `or remove --strict to disable this assertion.` - ); - } - return undefined; } diff --git a/src/typecheck/cli.ts b/src/typecheck/cli.ts index 1dbc815..c7c3215 100644 --- a/src/typecheck/cli.ts +++ b/src/typecheck/cli.ts @@ -21,12 +21,11 @@ import { generate, generateForPackage, resetTypecheckPackage, type GenOptions } function usage(exitCode = 1): never { let out = exitCode === 0 ? console.log : console.error; out(`Usage: - capnweb typecheck gen [--strict] [--out ] + capnweb typecheck gen [--out ] capnweb typecheck reset Examples: capnweb typecheck gen src/worker.ts - capnweb typecheck gen src/worker.ts --strict # throw on any RpcTarget without validators capnweb typecheck gen src/worker.ts --out .capnweb # legacy directory output capnweb typecheck reset # restore stub validators`); process.exit(exitCode); @@ -49,24 +48,21 @@ async function main() { let parsed = parseGenArgs(rest); if (parsed.outDir === undefined) { - generateForPackage({ input: parsed.input, strict: parsed.strict }); + generateForPackage({ input: parsed.input }); } else { generate({ input: parsed.input, outDir: parsed.outDir }); } } -function parseGenArgs(args: string[]): { input: string; outDir: string | undefined; strict: boolean } { +function parseGenArgs(args: string[]): { input: string; outDir: string | undefined } { let input: string | undefined; let outDir: string | undefined; - let strict = false; 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 === "--strict") { - strict = true; } else if (arg.startsWith("--")) { throw new Error(`Unknown option: ${arg}`); } else if (!input) { @@ -77,7 +73,7 @@ function parseGenArgs(args: string[]): { input: string; outDir: string | undefin } if (!input) usage(); - return { input, outDir, strict }; + return { input, outDir }; } if (isMain()) { diff --git a/src/typecheck/extract.ts b/src/typecheck/extract.ts index 608b74f..6721ba8 100644 --- a/src/typecheck/extract.ts +++ b/src/typecheck/extract.ts @@ -256,11 +256,17 @@ function lowerType( } function makeNamedId(type: ts.Type, ctx: LowerContext): string { - let symbol = type.aliasSymbol ?? type.getSymbol(); - let base = symbol?.getName().replace(/[^A-Za-z0-9_]/g, "_") || "Anon"; + 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); @@ -346,7 +352,25 @@ function lowerTypeBody( } return { kind: "record", value: lowerType(checker, index, location, ctx) }; } - return { kind: "object", props: objectProps(checker, type, 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( diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index 9713aa5..ef9c6fd 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -45,7 +45,7 @@ export function generate(options: GenOptions): GenResult { * `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; strict?: boolean }): GenResult { +export function generateForPackage(options: { input: string }): GenResult { let { validatorsEsm, validatorsCjs, clientsEsm, clientsCjs } = resolveTypecheckTargets(); let prepared = prepare({ input: options.input, @@ -53,12 +53,8 @@ export function generateForPackage(options: { input: string; strict?: boolean }) runtimeImport: "capnweb/internal/typecheck", }); let { classes, named, runtimeImport } = prepared; - let strict = Boolean(options.strict); - // Append a `strict` export so the runtime can flip into throw-on-miss mode - // without a separate file or env var. - let specsTs = emitSpecsModule(classes, runtimeImport, named) + - `\nexport const strict = ${strict ? "true" : "false"};\n`; + 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) @@ -114,15 +110,12 @@ export function resetTypecheckPackage(): void { 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` + - `export const strict = false;\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` + - `exports.strict = false;\n`; + `exports.validators = null;\n`; const STUB_CLIENTS_ESM = STUB_BANNER + `// No client wrappers until \`gen\` runs.\n`; @@ -257,7 +250,18 @@ function emitArtifacts(p: Prepared): void { } function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string, named: Map = new Map()): string { - let emit: ValidatorEmit = { lines: [], nextId: 0, refValueFns: new Map(), refPathFns: new Map() }; + 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()) { @@ -299,12 +303,25 @@ function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string, n " return { path, expected, actual: \"missing\", value: undefined };", "}", "", - "function __capnweb_makeValidationError(prefix: string, failure: __CapnwebValidationFailure): TypeError {", + "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\";", @@ -319,6 +336,7 @@ function emitSpecsModule(classes: ExtractedClassSpec[], runtimeImport: string, n " return typeof value;", "}", "", + ...emitSharedHelpers(emit.helpers), ...emit.lines, "export const validators = {", classEntries.join(",\n"), @@ -336,8 +354,150 @@ type ValidatorEmit = { // referent's own body has finished emitting (mutual recursion). refValueFns: Map; refPathFns: Map; + // Shared helpers actually referenced during emission. Lets us emit only the + // helpers a given module needs instead of dumping every primitive. + helpers: Set; + // Structural dedup: identical type shapes share one emitted function. Two + // methods returning the same `User` object literal collapse to one + // validator instead of two byte-identical copies. + valueByKey: Map; + pathByKey: Map; + // Function names already used in the module. Lets us safely append "_2" + // when two genuinely different types share a display name (e.g. two `User` + // interfaces imported from different files). + 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[] = []; + // Helpers are assert-style: void return on success, throw a partial + // RpcValidationError on failure. The method-level wrappers (args/returns) + // catch and rewrap with the method-name prefix. + 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, "_"); } @@ -349,125 +509,116 @@ function emitNamedValidator(id: string, spec: TypeSpec, emit: ValidatorEmit): vo // 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): __CapnwebValidationFailure | undefined {`, - ` return ${inner}(value, path, options);`, + `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): __CapnwebValidationFailure | undefined {`, - ` return ${pathInner}(path, value, offset, options);`, + `function ${pathName}(path: (string | number)[], value: unknown, offset = 0, options?: __CapnwebValidationOptions): void {`, + ` ${pathInner}(path, value, offset, options);`, `}`, ""); } function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { - let name = "__capnweb_validate_" + emit.nextId++; + // Short-circuit for kind-only types: there's nothing in the shape worth + // generating a fresh function for, so reuse a shared helper. Cuts a typical + // RPC interface (which is mostly strings/numbers) down to one helper per + // primitive instead of one function per occurrence. + let shared = sharedValidatorFor(type, emit); + if (shared) return shared; + + // Structural dedup: same shape -> reuse the already-emitted function. + 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): __CapnwebValidationFailure | undefined {", - " if (options?.isRpcPlaceholder?.(value)) return undefined;", + "function " + name + "(value: unknown, path: (string | number)[], options?: __CapnwebValidationOptions): void {", + " if (options?.isRpcPlaceholder?.(value)) return;", ]; switch (type.kind) { case "any": - lines.push(" return undefined;"); break; case "never": - lines.push(" return __capnweb_mismatch(path, " + expected + ", value);"); + case "unsupported": + lines.push(" __capnweb_fail(path, " + expected + ", value);"); break; case "primitive": - lines.push(" return " + primitiveCheck("value", type.name) + " ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + lines.push(" if (!(" + primitiveCheck("value", type.name) + ")) __capnweb_fail(path, " + expected + ", value);"); break; case "literal": - lines.push(" return value === " + JSON.stringify(type.value) + " ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + 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)) return __capnweb_mismatch(path, " + expected + ", value);", - " for (let i = 0; i < value.length; i++) {", - " let failure = " + element + "(value[i], [...path, \"[\" + i + \"]\"], options);", - " if (failure) return failure;", - " }", - " return undefined;"); + " 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 + ") return __capnweb_mismatch(path, " + expected + ", value);"); + " if (!Array.isArray(value) || value.length !== " + elements.length + ") __capnweb_fail(path, " + expected + ", value);", + " let tup = value as unknown[];"); elements.forEach((element, index) => { - lines.push( - " {", - " let failure = " + element + "(value[" + index + "], [...path, \"[" + index + "]\"], options);", - " if (failure) return failure;", - " }"); + lines.push(" " + element + "(tup[" + index + "], [...path, \"[" + index + "]\"], options);"); }); - lines.push(" return undefined;"); break; } case "map": { let key = emitTypeValidator(type.key, emit); let value = emitTypeValidator(type.value, emit); lines.push( - " if (!(value instanceof Map)) return __capnweb_mismatch(path, " + expected + ", value);", - " for (let [key, item] of value) {", - " let keyFailure = " + key + "(key, [...path, \"\"], options);", - " if (keyFailure) return keyFailure;", - " let valueFailure = " + value + "(item, [...path, String(key)], options);", - " if (valueFailure) return valueFailure;", - " }", - " return undefined;"); + " 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)) return __capnweb_mismatch(path, " + expected + ", value);", + " if (!(value instanceof Set)) __capnweb_fail(path, " + expected + ", value);", " let i = 0;", - " for (let item of value) {", - " let failure = " + value + "(item, [...path, \"[\" + i++ + \"]\"], options);", - " if (failure) return failure;", - " }", - " return undefined;"); + " 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)) return __capnweb_mismatch(path, " + expected + ", value);", + " 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) {", - " let failure = " + propValidator + "(record[" + propName + "], " + propPath + ", options);", - " if (failure) return failure;", - " }"); + " 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 + ")) return __capnweb_missing(" + propPath + ", " + JSON.stringify(typeDescription(prop.type)) + ");", - " {", - " let failure = " + propValidator + "(record[" + propName + "], " + propPath + ", options);", - " if (failure) return failure;", - " }"); + " if (!Object.prototype.hasOwnProperty.call(record, " + propName + ")) __capnweb_failMissing(" + propPath + ", " + propExpected + ");", + " " + propValidator + "(record[" + propName + "], " + propPath + ", options);"); } } - lines.push(" return undefined;"); break; case "record": { let value = emitTypeValidator(type.value, emit); lines.push( - " if (typeof value !== \"object\" || value === null || Array.isArray(value)) return __capnweb_mismatch(path, " + expected + ", value);", - " for (let [key, item] of Object.entries(value as Record)) {", - " let failure = " + value + "(item, [...path, key], options);", - " if (failure) return failure;", - " }", - " return undefined;"); + " 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": { @@ -475,31 +626,31 @@ function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { lines.push(" let failures: __CapnwebValidationFailure[] = [];"); for (let variant of variants) { lines.push( - " {", - " let failure = " + variant + "(value, path, options);", - " if (!failure) return undefined;", - " failures.push(failure);", + " try { " + variant + "(value, path, options); return; }", + " catch (e) {", + " if (e instanceof __CapnwebRpcValidationError) failures.push(e.rpcValidation);", + " else throw e;", " }"); } - lines.push(" return failures.find(failure => failure.path.length > path.length) ?? __capnweb_mismatch(path, " + expected + ", value);"); + 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) + "];", - " return typeof ctor === \"function\" && value instanceof ctor ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + " if (!(typeof ctor === \"function\" && value instanceof ctor)) __capnweb_fail(path, " + expected + ", value);"); break; case "rpcTarget": - lines.push(" return value instanceof __capnweb_RpcTarget ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + lines.push(" if (!(value instanceof __capnweb_RpcTarget)) __capnweb_fail(path, " + expected + ", value);"); break; case "stub": - lines.push(" return value !== null && (typeof value === \"object\" || typeof value === \"function\") ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); + lines.push(" if (!(value !== null && (typeof value === \"object\" || typeof value === \"function\"))) __capnweb_fail(path, " + expected + ", value);"); break; case "function": - lines.push(" return typeof value === \"function\" ? undefined : __capnweb_mismatch(path, " + expected + ", value);"); - break; - case "unsupported": - lines.push(" return __capnweb_mismatch(path, " + expected + ", value);"); + lines.push(" if (typeof value !== \"function\") __capnweb_fail(path, " + expected + ", value);"); break; case "ref": { // Delegate to the hoisted named validator. Function name is reserved @@ -507,7 +658,7 @@ function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { // 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(" return " + target + "(value, path, options);"); + lines.push(" " + target + "(value, path, options);"); break; } } @@ -518,11 +669,17 @@ function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { } function emitTypePathValidator(type: TypeSpec, emit: ValidatorEmit): string { - let name = "__capnweb_validate_path_" + emit.nextId++; + 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): __CapnwebValidationFailure | undefined {", - " if (offset >= path.length) return " + fullValidator + "(value, path, options);", + "function " + name + "(path: (string | number)[], value: unknown, offset = 0, options?: __CapnwebValidationOptions): void {", + " if (offset >= path.length) { " + fullValidator + "(value, path, options); return; }", ]; switch (type.kind) { @@ -532,19 +689,18 @@ function emitTypePathValidator(type: TypeSpec, emit: ValidatorEmit): string { let child = emitTypePathValidator(prop.type, emit); lines.push(" case " + JSON.stringify(prop.name) + ":"); if (prop.optional) { - lines.push(" return value === undefined ? undefined : " + child + "(path, value, offset + 1, options);"); + lines.push(" if (value !== undefined) " + child + "(path, value, offset + 1, options);"); + lines.push(" return;"); } else { - lines.push(" return " + child + "(path, value, offset + 1, options);"); + lines.push(" " + child + "(path, value, offset + 1, options); return;"); } } - lines.push( - " default: return undefined;", - " }"); + lines.push(" }"); break; } case "array": { let child = emitTypePathValidator(type.element, emit); - lines.push(" return " + child + "(path, value, offset + 1, options);"); + lines.push(" " + child + "(path, value, offset + 1, options);"); break; } case "tuple": { @@ -553,21 +709,19 @@ function emitTypePathValidator(type: TypeSpec, emit: ValidatorEmit): string { elements.forEach((element, index) => { lines.push( " case " + JSON.stringify(String(index)) + ":", - " case " + index + ": return " + element + "(path, value, offset + 1, options);"); + " case " + index + ": " + element + "(path, value, offset + 1, options); return;"); }); - lines.push( - " default: return undefined;", - " }"); + lines.push(" }"); break; } case "record": { let child = emitTypePathValidator(type.value, emit); - lines.push(" return " + child + "(path, value, offset + 1, options);"); + lines.push(" " + child + "(path, value, offset + 1, options);"); break; } case "map": { let child = emitTypePathValidator(type.value, emit); - lines.push(" return " + child + "(path, value, offset + 1, options);"); + lines.push(" " + child + "(path, value, offset + 1, options);"); break; } case "union": { @@ -575,24 +729,23 @@ function emitTypePathValidator(type: TypeSpec, emit: ValidatorEmit): string { lines.push(" let failures: __CapnwebValidationFailure[] = [];"); for (let variant of variants) { lines.push( - " {", - " let failure = " + variant + "(path, value, offset, options);", - " if (!failure) return undefined;", - " failures.push(failure);", + " try { " + variant + "(path, value, offset, options); return; }", + " catch (e) {", + " if (e instanceof __CapnwebRpcValidationError) failures.push(e.rpcValidation);", + " else throw e;", " }"); } - lines.push(" return failures.find(failure => failure.path.length > offset) ?? failures[0];"); + 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(" return " + target + "(path, value, offset, options);"); + lines.push(" " + target + "(path, value, offset, options);"); break; } - default: - lines.push(" return undefined;"); - break; } lines.push("}", ""); @@ -619,42 +772,33 @@ function emitMethodValidator( 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); }", + " },", + " }", ]; - - method.params.forEach((param, index) => { - let path = JSON.stringify([param.name]); - if (param.optional) { - lines.push( - " if (args[" + index + "] !== undefined) {", - " let failure = " + paramValidators[index] + "(args[" + index + "], " + path + ", options);", - " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix) + ", failure);", - " }"); - } else { - lines.push( - " {", - " let failure = " + paramValidators[index] + "(args[" + index + "], " + path + ", options);", - " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix) + ", failure);", - " }"); - } - }); - - lines.push( - " },", - " returns(value: unknown): void {", - " let failure = " + returnValidator + "(value, []);", - " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix + " return") + ", failure);", - " },", - " returnsPath(path: (string | number)[], value: unknown): void {", - " let failure = " + returnPathValidator + "(path, value);", - " if (failure) throw __capnweb_makeValidationError(" + JSON.stringify(prefix + " return") + ", failure);", - " },", - " }"); return lines.join("\n"); } diff --git a/src/typecheck/types.ts b/src/typecheck/types.ts index bd7a4ab..b69dc67 100644 --- a/src/typecheck/types.ts +++ b/src/typecheck/types.ts @@ -12,7 +12,7 @@ export type TypeSpec = | { kind: "tuple", elements: TypeSpec[] } | { kind: "map", key: TypeSpec, value: TypeSpec } | { kind: "set", value: TypeSpec } - | { kind: "object", props: ObjectProp[] } + | { kind: "object", props: ObjectProp[], displayName?: string } | { kind: "record", value: TypeSpec } | { kind: "union", variants: TypeSpec[] } | { kind: "instance", name: string } diff --git a/vitest.config.ts b/vitest.config.ts index 51bdd82..d930991 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ }, 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 +21,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', '__tests__/typecheck.test.ts', '__tests__/typecheck-package.test.ts', '__tests__/vite-plugin.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', }, }, From cdcb77c1c6e6e9681f5d2a6c308a475c0380ea8b Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sun, 17 May 2026 19:32:08 -0400 Subject: [PATCH 20/24] fix(test): register typecheck subpath modules with the workerd test pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dist/index-workers.js (built from src/core.ts) imports `capnweb/_typecheck-validators`. Under Node/wrangler that resolves through the package `exports` map. The workerd test pool loads the bundle directly via miniflare's module loader, which doesn't run package resolution — it interprets the bare specifier as a path relative to the importer, looking for `dist/capnweb/_typecheck-validators`. Add the two typecheck subpaths to the test worker's `modules` array with the path workerd expects, sourcing the contents from the real generated files under `dist/`. Same fix applies for both validators and clients subpaths. CI's `test` job (workerd project) was failing with `Uncaught Error: No such module "dist/capnweb/_typecheck-validators"`; this resolves it. The node project was unaffected because Node's ESM loader honors the package exports map directly. --- vitest.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index d930991..430d2d0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,21 @@ // 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 @@ -56,6 +69,8 @@ export default defineConfig({ type: "ESModule", path: "./dist/index-workers.js", }, + typecheckModule('_typecheck-validators'), + typecheckModule('_typecheck-clients'), ], durableObjects: { TEST_DO: "TestDo" From e860918cf6223eff0856299df9e3616d148604aa Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Sun, 17 May 2026 22:46:00 -0400 Subject: [PATCH 21/24] fix(typecheck): auto-unplug under Yarn Plug'n'Play via a no-op postinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yarn PnP serves packages out of zip archives. Node 24's ESM loader, when translating capnweb's CJS CLI (`dist/cli.cjs`) inside a zip, crashes with `EBADF` before any of our code runs — so the friendly "run `yarn unplug capnweb`" error we wrote never gets a chance to fire. Confirmed by an end-to-end fixture under Yarn 4.9.4 + Node 24.15. Yarn 4 auto-unplugs any package that ships an install lifecycle script. Adding a no-op `postinstall` triggers this behavior on install, so Yarn extracts capnweb out of the zip before the CLI ever runs. The same mechanism Prisma's `@prisma/client` uses. End-to-end verified against fresh installs with npm, pnpm, and Yarn PnP (Yarn 4.9.4): each one runs `yarn add capnweb typescript`, then ` capnweb typecheck gen worker.ts`, and validators load and reject wrong types at runtime. No additional commands required. Cost for npm/pnpm users: one `node -e ''` invocation at install time (milliseconds). The runtime detection that throws on `.yarn/cache/*.zip` paths stays in place as a safety net for users on Yarn 3 / explicit no-postinstall configs. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4fc9ea8..e78574e 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,8 @@ "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", From 7cd1953485b1749c70cbc31d0990333390b371c7 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Mon, 18 May 2026 10:07:10 -0400 Subject: [PATCH 22/24] fix(test): keep hardlink sentinel on the same filesystem as the target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed with `EXDEV: cross-device link not permitted` on the hardlink test. GitHub Actions puts the project at `/__w/...` and `tmpdir()` at `/tmp`, on separate volumes — and hardlinks can't cross filesystems. Local macOS happened to keep both on one volume so the bug only surfaced under CI. Put the sentinel next to the validators file (same dist directory, guaranteed-same filesystem) and clean it up in a `finally`. --- __tests__/typecheck-package.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index 906c365..f4add93 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -6,7 +6,7 @@ // capnweb's `_typecheck-validators` subpath and the runtime auto-binds them // by class name without an explicit registration call. -import { linkSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +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"; @@ -154,18 +154,25 @@ export class Api extends RpcTarget { it("does not corrupt a hardlinked sibling of the validators file", () => { let req = createRequire(__filename); let validatorsPath = req.resolve("capnweb/_typecheck-validators"); - let sentinelDir = mkdtempSync(join(tmpdir(), "capnweb-pnpm-")); - let sentinel = join(sentinelDir, "shared-inode.js"); + // 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); - generateForPackage({ input: inputFile }); + 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"); + 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); + } }); }); From 3343de685e637a8dfa311026bfdc42691e76dfea Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Mon, 18 May 2026 10:17:23 -0400 Subject: [PATCH 23/24] chore(typecheck): trim narrating comments and a redundant test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip three verbose multi-line comments that justified design decisions (kind-only short-circuit, throw-fail helpers, the shared-helper field doc) — the design is visible in the code and the prose was overselling it. - Merge the "writes validators that load" smoke test into the "validates args" test; the second was already a strict superset (regenerated, re-imported, and asserted the same shape on top of doing the arg-validation work). --- __tests__/typecheck-package.test.ts | 14 +++----------- src/typecheck/generate.ts | 20 ++++---------------- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/__tests__/typecheck-package.test.ts b/__tests__/typecheck-package.test.ts index f4add93..e46a9ad 100644 --- a/__tests__/typecheck-package.test.ts +++ b/__tests__/typecheck-package.test.ts @@ -43,20 +43,12 @@ describe("generateForPackage", () => { resetTypecheckPackage(); }); - it("writes validators that load from capnweb/_typecheck-validators", async () => { - generateForPackage({ input: inputFile }); - - // Re-import after writing — cache-bust by appending a query string. - let mod = await import("capnweb/_typecheck-validators?nocache=" + Date.now()) as any; - expect(mod.validators).toBeTruthy(); - expect(mod.validators.Echo).toBeTruthy(); - expect(Object.keys(mod.validators.Echo).sort()).toEqual(["add", "ping"]); - }); - - it("validates args based on the source method signature", async () => { + 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/); diff --git a/src/typecheck/generate.ts b/src/typecheck/generate.ts index ef9c6fd..7ecb7cb 100644 --- a/src/typecheck/generate.ts +++ b/src/typecheck/generate.ts @@ -354,17 +354,12 @@ type ValidatorEmit = { // referent's own body has finished emitting (mutual recursion). refValueFns: Map; refPathFns: Map; - // Shared helpers actually referenced during emission. Lets us emit only the - // helpers a given module needs instead of dumping every primitive. + /** Shared helpers actually referenced — preamble only emits these. */ helpers: Set; - // Structural dedup: identical type shapes share one emitted function. Two - // methods returning the same `User` object literal collapse to one - // validator instead of two byte-identical copies. + /** Structural-dedup cache: same `typeKey` → reuse the emitted function. */ valueByKey: Map; pathByKey: Map; - // Function names already used in the module. Lets us safely append "_2" - // when two genuinely different types share a display name (e.g. two `User` - // interfaces imported from different files). + /** Function names taken in the module so `pickFunctionName` can suffix collisions. */ usedNames: Set; }; @@ -455,9 +450,6 @@ function typeKey(type: TypeSpec): string { function emitSharedHelpers(used: Set): string[] { let lines: string[] = []; - // Helpers are assert-style: void return on success, throw a partial - // RpcValidationError on failure. The method-level wrappers (args/returns) - // catch and rewrap with the method-name prefix. let emitAssert = (name: string, check: string, expected: string) => { lines.push( `function ${name}(value: unknown, path: (string | number)[], options?: __CapnwebValidationOptions): void {`, @@ -523,14 +515,10 @@ function emitNamedValidator(id: string, spec: TypeSpec, emit: ValidatorEmit): vo } function emitTypeValidator(type: TypeSpec, emit: ValidatorEmit): string { - // Short-circuit for kind-only types: there's nothing in the shape worth - // generating a fresh function for, so reuse a shared helper. Cuts a typical - // RPC interface (which is mostly strings/numbers) down to one helper per - // primitive instead of one function per occurrence. + // Kind-only types share a single helper instead of getting a fresh function. let shared = sharedValidatorFor(type, emit); if (shared) return shared; - // Structural dedup: same shape -> reuse the already-emitted function. let key = typeKey(type); let cached = emit.valueByKey.get(key); if (cached) return cached; From d13b5904dcbf1d80e7d4a095bf0fecf8f196e09e Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Wed, 20 May 2026 11:07:22 -0400 Subject: [PATCH 24/24] fix(example): resolve typecheck subpaths in debug vite config --- examples/worker-react/dev-debug.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/worker-react/dev-debug.sh b/examples/worker-react/dev-debug.sh index 9cba4c0..37d11fa 100755 --- a/examples/worker-react/dev-debug.sh +++ b/examples/worker-react/dev-debug.sh @@ -83,12 +83,12 @@ trap cleanup EXIT INT TERM cd "$REPO_ROOT" env NODE_OPTIONS= npm run build -# Runtime type validation toggles via the `capnweb-typecheck` placeholder -# package. `gen` overwrites it with real validators; `reset` puts the stub back +# 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-typecheck..." + 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 @@ -98,7 +98,7 @@ if [[ "$TYPECHECK" == "true" ]]; then "$REPO_ROOT/dist/cli.cjs" typecheck gen src/worker.ts fi else - echo "Resetting capnweb-typecheck placeholder (validators disabled)..." + echo "Resetting capnweb typecheck placeholders (validators disabled)..." env NODE_OPTIONS= node "$REPO_ROOT/dist/cli.cjs" typecheck reset fi @@ -109,6 +109,8 @@ export default { base: '$VITE_BASE', resolve: { alias: [ + { find: 'capnweb/_typecheck-validators', replacement: path.resolve('$REPO_ROOT/dist/_typecheck-validators.js') }, + { find: 'capnweb/_typecheck-clients', replacement: path.resolve('$REPO_ROOT/dist/_typecheck-clients.js') }, { find: 'capnweb/internal/typecheck', replacement: path.resolve('$REPO_ROOT/dist/index.js') }, { find: 'capnweb', replacement: path.resolve('$REPO_ROOT/dist/index.js') }, ],