Skip to content

Add capnweb-typecheck for TypeScript RPC validation codegen#169

Draft
teamchong wants to merge 24 commits into
cloudflare:mainfrom
teamchong:typescript-rpc-validators
Draft

Add capnweb-typecheck for TypeScript RPC validation codegen#169
teamchong wants to merge 24 commits into
cloudflare:mainfrom
teamchong:typescript-rpc-validators

Conversation

@teamchong
Copy link
Copy Markdown
Collaborator

@teamchong teamchong commented May 11, 2026

Summary

Adds opt-in runtime validation for Cap'n Web RPC method arguments and return values.

Running capnweb typecheck gen src/worker.ts generates validators from RpcTarget TypeScript signatures into the capnweb-typecheck placeholder package.
The runtime auto-loads those validators by RPC class name. If validators are not generated, capnweb-typecheck exports validators = null and validation
is skipped.

How it works

  • capnweb typecheck gen extracts reachable RpcTarget classes and public RPC methods.
  • Generated validators are written into capnweb-typecheck.
  • core.ts imports validators from capnweb-typecheck.
  • On first RPC call for a class, getRpcValidator() looks up validators by constructor name and caches them in the existing WeakMap.
  • Arguments are validated before method invocation.
  • Return values are validated after resolution. Nested RpcPromise placeholders are allowed initially and validated after they resolve.
  • capnweb typecheck reset restores the null stub and disables validation.

No worker entry swap, no generated .capnweb/ entry wrapper, and no wrangler config changes are required for the default package-based flow.

Review fixes included

  • The published placeholder now exports validators = null, so examples are not accidentally loaded for users who did not run codegen.
  • Return validation handles nested RPC promises without rejecting valid pipelined returns.
  • ECMAScript private methods (#helper()) are ignored during extraction.
  • Map and Set signatures are rejected until serialization supports them.

Generated code

The generated code is plain JavaScript:

  • helper validators such as __capnweb_validate_N
  • a validators object keyed by class and method name
  • structured validation errors with path, expected type, actual type, and value

Example shape:

export const validators = {
  "Api": {
    "authenticate": {
      args(args) {
        // validate args
      },
      returns(value) {
        // validate return
      }
    }
  }
};

Example debug flow

examples/worker-react/dev-debug.sh keeps the normal src/worker.ts wrangler entry.

  • Without --typecheck, it runs capnweb typecheck reset.
  • With --typecheck, it runs capnweb typecheck gen src/worker.ts.

The script then builds the React assets and starts wrangler dev normally. The app is served by wrangler at:

http://127.0.0.1:8787/

Testing

  npm run build
  npm run test:types
  npm test
  ./examples/worker-react/dev-debug.sh
  ./examples/worker-react/dev-debug.sh --typecheck

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: d13b590

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
capnweb Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@169

commit: d13b590

@teamchong teamchong force-pushed the typescript-rpc-validators branch from c0dc243 to a762c16 Compare May 11, 2026 18:56
@teamchong teamchong changed the title Add TypeScript RPC validation codegen Add capnweb-typecheck for TypeScript RPC validation codegen May 11, 2026
@teamchong teamchong force-pushed the typescript-rpc-validators branch 4 times, most recently from e1b7d41 to 400182b Compare May 12, 2026 02:17
@teamchong teamchong force-pushed the typescript-rpc-validators branch from 400182b to 7d866ac Compare May 12, 2026 02:27
teamchong added 19 commits May 12, 2026 12:44
- 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
- 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 <dir> 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.
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.
…ubpaths

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.
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.
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.
…erns

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).
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.
…ames

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.
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.
teamchong added 4 commits May 17, 2026 22:46
…stall

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
`<pm> 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.
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`.
- 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant