Add capnweb-typecheck for TypeScript RPC validation codegen#169
Draft
teamchong wants to merge 24 commits into
Draft
Add capnweb-typecheck for TypeScript RPC validation codegen#169teamchong wants to merge 24 commits into
teamchong wants to merge 24 commits into
Conversation
🦋 Changeset detectedLatest commit: d13b590 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
Contributor
|
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. |
commit: |
c0dc243 to
a762c16
Compare
e1b7d41 to
400182b
Compare
400182b to
7d866ac
Compare
- 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.
…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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds opt-in runtime validation for Cap'n Web RPC method arguments and return values.
Running
capnweb typecheck gen src/worker.tsgenerates validators fromRpcTargetTypeScript signatures into thecapnweb-typecheckplaceholder package.The runtime auto-loads those validators by RPC class name. If validators are not generated,
capnweb-typecheckexportsvalidators = nulland validationis skipped.
How it works
capnweb typecheck genextracts reachableRpcTargetclasses and public RPC methods.capnweb-typecheck.core.tsimportsvalidatorsfromcapnweb-typecheck.getRpcValidator()looks up validators by constructor name and caches them in the existing WeakMap.RpcPromiseplaceholders are allowed initially and validated after they resolve.capnweb typecheck resetrestores 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
validators = null, so examples are not accidentally loaded for users who did not run codegen.#helper()) are ignored during extraction.MapandSetsignatures are rejected until serialization supports them.Generated code
The generated code is plain JavaScript:
__capnweb_validate_Nvalidatorsobject keyed by class and method nameExample shape:
Example debug flow
examples/worker-react/dev-debug.sh keeps the normal src/worker.ts wrangler entry.
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