Skip to content

Add capnweb-validate RPC validators#169

Merged
kentonv merged 44 commits into
cloudflare:mainfrom
teamchong:typescript-rpc-validators
Jun 9, 2026
Merged

Add capnweb-validate RPC validators#169
kentonv merged 44 commits into
cloudflare:mainfrom
teamchong:typescript-rpc-validators

Conversation

@teamchong

@teamchong teamchong commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Closes #174

Summary

Adds capnweb-validate, an opt-in TypeScript-to-runtime-validator transform for Cap'n Web and Workers RPC services.

Server validation is enabled with @validateRpc() on the service class. Users keep importing Cap'n Web APIs from capnweb, so the service remains usable with native Workers RPC and with Cap'n Web.

Client validation is enabled by the transform recognizing Cap'n Web session constructors by TypeScript symbol resolution. No client import change is required.

Validated paths:

  • server incoming arguments
  • server resolved return values
  • client outgoing arguments
  • client resolved return values

If @validateRpc() is left untransformed, it throws a configuration error instead of silently running without validation.

How it works

  • The transform finds @validateRpc() decorators imported from capnweb-validate.
  • It resolves the concrete RPC surface from:
    1. explicit decorator type argument
    2. a single implemented interface
    3. the class public methods
  • If a class implements multiple interfaces and no @validateRpc<T>() type argument is given, the transform emits a warning and falls back to class public methods.
  • It lowers method signatures into runtime validators.
  • It emits plain JavaScript validator objects using capnweb-validate/internal/core.
  • It rewrites the decorator to wrap constructed service instances.
  • It recognizes Cap'n Web client session calls from capnweb and injects client-side validation wrappers.
  • The CLI writes a transformed source tree for Wrangler and other flows without bundler plugin hooks.
  • The universal plugin supports Vite, Rollup, Webpack, Rspack, esbuild, and Farm.

Supported pass-by-value types include primitives, arrays, tuples, plain objects, unions, Record<string, T>, selected platform built-ins, and Promise<T> return unwrapping.

Pass-by-reference values include plain functions, RpcStub<T>, RpcPromise<T>, RpcTarget subclasses, and Workers RPC stubs.

Unsupported wire types such as Map, Set, WeakMap, WeakSet, RegExp, ArrayBuffer, and non-Uint8Array typed arrays fail at build time.

Included

  • New packages/capnweb-validate workspace package:
    • @validateRpc() class decorator
    • @skipRpcValidation() method opt-out
    • structured RpcValidationError
    • capnweb-free core runtime validators and wrappers
    • Cap'n Web-specific compatibility helpers under capnweb-validate/capnweb
    • transform implementation
    • CLI
    • universal bundler plugin adapters
  • capnweb is an optional peer dependency. The root entry and capnweb-validate/internal/core have no capnweb imports; Cap'n Web-specific helpers live under capnweb-validate/capnweb and capnweb-validate/internal/capnweb. Workers RPC-only users can install capnweb-validate alone.
  • Workers RPC WorkerEntrypoint support. Runtime-invoked methods on WorkerEntrypoint subclasses (fetch, tail, trace, scheduled, queue, test, email, connect, tailStream) bypass the validator so the Workers runtime can still invoke them; user-defined RPC methods are validated.
  • RpcValidationError extends TypeError with err.rpcValidation = { path, expected, actual, value }. path is JSON-pointer-style, for example ["Api", "authenticate", 0, "profile", "email"].
  • Root build/test wiring.
  • Transform/runtime test coverage.
  • Client session validation without changing imports from capnweb.
  • examples/worker-react validation wiring.
  • VS Code launch/tasks for the example.

Example

Server:

import { newWorkersRpcResponse, RpcTarget } from "capnweb";
import { validateRpc } from "capnweb-validate";

type User = { id: string; name: string };

@validateRpc()
class Api extends RpcTarget {
  async authenticate(sessionToken: string): Promise<User> {
    return { id: "u_1", name: "Ada Lovelace" };
  }
}

export default {
  fetch(request: Request, env: Env) {
    return newWorkersRpcResponse(request, new Api());
  },
};

Client:

import { newHttpBatchRpcSession } from "capnweb";
import type { Api } from "./worker";

const api = newHttpBatchRpcSession<Api>("/api");

await api.authenticate("cookie-123");

Wrangler flow

Wrangler does not expose a bundler plugin hook, so use the CLI for Worker source:

npx capnweb-validate build --cwd src --out .wrangler/validate

Point Wrangler's main at the generated entry under .wrangler/validate.

The React client uses the Vite plugin, not the CLI.

Testing

Package:

npm run -w capnweb-validate build
npm run -w capnweb-validate test

Repo:

npm run build
npm run test:types

Example:

cd examples/worker-react/client && npm run build
node ../../packages/capnweb-validate/dist/cli.cjs build --cwd server --out ../.wrangler/validate
npx tsc -p examples/worker-react/server/tsconfig.json --noEmit

@changeset-bot

changeset-bot Bot commented May 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 06e7be7

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

This PR includes changesets to release 1 package
Name Type
capnweb-validate 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

github-actions Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@pkg-pr-new

pkg-pr-new Bot commented May 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 06e7be7

@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 5 times, most recently from 400182b to 7d866ac Compare May 12, 2026 02:27
@teamchong teamchong changed the title Add capnweb-typecheck for TypeScript RPC validation codegen Add capnweb-typecheck RPC validators May 27, 2026
@teamchong teamchong force-pushed the typescript-rpc-validators branch 2 times, most recently from bda749b to 4ceeec5 Compare May 27, 2026 04:30
Add the capnweb-typecheck package, marker transform, plugin adapters, CLI, runtime validators, tests, and Worker React debug example.
@teamchong teamchong force-pushed the typescript-rpc-validators branch from 4ceeec5 to 7f94aff Compare May 27, 2026 13:41
@teamchong teamchong force-pushed the typescript-rpc-validators branch from 0a6e258 to 2e725b9 Compare May 27, 2026 22:26
teamchong added 12 commits May 27, 2026 18:33
capnweb-validate turns a service's TypeScript method signatures into
build-time validators injected at the four RPC boundaries (server in,
server out, client out, client resolved). No schema library ships at
runtime; the validators are generated JS that capnweb's transform emits.

This change reviews the package end to end, fixes the correctness bugs
that review and real-project dogfooding surfaced, and tightens the
generated output. Every fix has a regression test that fails without it.
337 node tests and 145 workerd tests pass.

Correctness:
- Built-in wire types declared as ambient globals (from
  @cloudflare/workers-types or a wrangler worker-configuration.d.ts) were
  missed by the lib-file gate and validated structurally instead of by
  instanceof. Gate on global scope, not declaration-file path.
- Namespace-qualified marker calls (import * as cv) were silently
  skipped: no rewrite, no validator, no error. Resolve them, and fail the
  build on an unresolvable shape.
- wrapRpcPromise hung instead of rejecting when an awaited resolved value
  failed validation in throw mode. Route the failure through the
  rejection continuation.
- The Fetcher structural fallback treated any { fetch(), connect() }
  object as a pass-through stub. Require fetch to return Promise<Response>.

Dogfooding on a clean install (server decorator, client session, real
tsc/esbuild build, runtime harness) found:
- Branded primitive (string & { __brand }) was walked as an object and
  rejected at build time. Collapse an intersection with a primitive
  constituent to that primitive.
- Record<number, V> dropped the value validator (only the string index
  was read), so any object passed. Read both string and numeric indexes.
- Non-homomorphic mapped types ({ [K in U]: T }, Record<"a"|"b", V>) have
  synthesized members with no declaration, so the property walk emitted an
  empty object that accepted anything. Read members via getTypeOfSymbol.
- Getter accessors on the RPC surface were skipped on both boundaries;
  capnweb exposes a getter as a real RPC surface. Validate them.
- User-defined Error subclasses lowered to a structural object instead of
  v.error; capnweb routes any instanceof Error through the error path.
- Optional methods (m?(): T) were dropped (a union with undefined reports
  no call signatures). Strip the optional undefined first.
- Overloaded methods were validated against the first signature only,
  rejecting valid calls to the others. Pass them through with a warning.
- File was lowered to v.blob, but capnweb matches the structured-clone
  built-ins (Blob, Date, Headers, Request, Response, streams) by exact
  prototype, so a File (Blob subclass) is rejected at the wire. Reject
  File at build time, and make those runtime brands check the exact
  prototype rather than instanceof. bytes and error stay instanceof,
  matching capnweb (it accepts Buffer for bytes and any Error subclass).

Generated output:
- Named object and union types used by more than one method are hoisted
  to a single shared validator instead of inlined at every use, with
  lazy cross-references so cyclic and order-dependent shapes are safe.
- Optional properties no longer add a redundant undefined branch when the
  property type already admits undefined.

Ergonomics:
- A generic service class needs no annotation: an unconstrained type
  parameter defaults to any with a warning, a constrained one validates
  against its constraint.
- Readable build errors for the never type, the object keyword, and
  unresolved generic parameters instead of raw TypeScript flag bitmasks.

Tests share a __tests__/helpers.ts fixture (build a tmp project, run the
real transform, capture warnings) instead of repeating the scaffolding in
each file.
A WorkerEntrypoint or DurableObject service exposes platform lifecycle
hooks (fetch, tailStream, alarm, webSocketMessage, ...) that the runtime
invokes directly and that are not part of the user's RPC surface. The
previous code skipped them with a hardcoded name list gated on a
targetKind that did not fire when the RPC interface extended
WorkerEntrypoint, and the list never covered DurableObject. Result:

- Build error on `class X extends WorkerEntrypoint implements XIface`,
  e.g. "X.tailStream argument 1 .info: the object keyword has no shape
  to validate".
- Runtime RpcValidationError on a validated DurableObject's alarm() or
  webSocketMessage(), breaking those handlers in production.

Fix, in two parts:

- Transform: skip a method when its declaration's container is a
  platform/library RPC base (capnweb, @cloudflare/workers-types, the
  cloudflare:workers module), detected by declaration origin. No
  method-name list; covers WorkerEntrypoint, DurableObject, RpcTarget,
  and future hooks, while keeping a user method that happens to share a
  hook's name.
- Runtime: a method present on the target but absent from the generated
  validator is, by construction, a platform hook the transform excluded,
  so pass it through instead of throwing. We only validate methods we
  have a signature for. This removes the second copy of the name list
  and the targetKind gate.

Two prior tests asserted that a present-but-unvalidated method throws;
they used artificial empty-method validators the real transform never
produces, and encoded the bug as behavior. Updated to assert
pass-through, and added regression tests for the interface-extends-
WorkerEntrypoint case, DurableObject lifecycle methods, and a user
method named like a platform hook.

340 node tests and 145 workerd tests pass.
@teamchong

Copy link
Copy Markdown
Collaborator Author

Side note: While Cap'n Web's accepted types are similar to structured clone, it's explicitly not exactly the same set. There are some types we support that structured clone doesn't, some we don't support that it does, an notably structured clone supports cycles but we emphatically refuse to.

Just pointing that out since I see a lot of references to "structured clone" here.

good point, I’ll stop calling this “structured clone.” The intent here was not to claim Cap’n Web follows structured clone exactly.

Dimitri raised that Workers RPC accepts values like ArrayBuffer, Map/Set, RegExp, and typed arrays, and downstream had to change an existing Worker RPC contract from ArrayBuffer to Uint8Array only because capnweb-validate rejected it. So I tried to make the validator target the broader RPC-compatible runtime value set rather than exactly Cap’n Web’s current serializer set.

do you agree with that direction for capnweb-validate? In other words: should validation accept Workers-RPC-supported value types and let Cap’n Web transport reject anything it still can’t serialize, or should this package enforce Cap’n Web’s exact transport surface by default?

either way, I’ll rename/remove the “structured clone” wording

@kentonv

kentonv commented Jun 4, 2026

Copy link
Copy Markdown
Member

Yeah it makes sense to support a superset of what both RPC implementations support, and leave it up to the RPC system to complain if it can't serialize something.

@teamchong teamchong force-pushed the typescript-rpc-validators branch from 2231c98 to 942ec47 Compare June 5, 2026 05:45
@teamchong teamchong marked this pull request as ready for review June 5, 2026 13:35
@teamchong

Copy link
Copy Markdown
Collaborator Author

A single implemented interface or an explicit is only used to sharpen the signatures of methods that already exist on the class; extra public methods stay exposed and validated.

Hmm, I think I'd argue that if someone does explicitly write @validateRpc<T>(), then we should allow only the methods of T -- other public methods are essentially deemed "invalid" for any call. But if no <T> is specified, we should allow all public methods. This makes sense to me as saying "Validate that calls follow exactly the interface T".

pushed a commit to change the explicit type-argument behavior.

interface Api {
  greet(name: string): Promise<string>;
}

@validateRpc<Api>()
export class Service extends RpcTarget implements Api {
  async greet(name: string): Promise<string> { /* ... */ }

   // this method no in Api
  status() {
    return "email";
  }
}

in this case, Api is now the RPC surface. calling status() through the decorated/wrapped instance, including over RPC, will fail with a runtime validation error because status is not in the generated validator.

without the explicit type argument, the surface is still the class public RPC surface, so status() remains exposed and validated from the class signature

The Cap'n Web-specific code is just integration around Cap'n Web APIs. Server helpers wrap Cap'n Web boundary functions, and client helpers wrap Cap'n Web stubs because there is no class instance on the client. Workers built-in RPC clients are not wrapped client-side today; the decorated service still validates the server boundary.

Hmm, I don't understand what this means. What does it mean for things to be wrapped "client-side"? Why does it make sense for Cap'n Web but not Workers RPC?

I explained that poorly.

the "client-side wrapping" part is an extra feature for Cap'n Web clients. in Cap'n Web, client code usually creates a remote API object by calling something like newHttpBatchRpcSession<Api>(). Since that is a normal function call in user code, the plugin can rewrite it and put a validation wrapper around the returned client stub.

workers RPC client objects are different. the client usually gets them from the platform as env.SERVICE. there is no obvious function call like newHttpBatchRpcSession<Api>() for the plugin to rewrite. so this PR validates workers RPC at the decorated server boundary, but does not currently support adding an automatic client-side validation wrapper around Workers RPC bindings.

@kentonv

kentonv commented Jun 5, 2026

Copy link
Copy Markdown
Member

in Cap'n Web, client code usually creates a remote API object by calling something like newHttpBatchRpcSession(). Since that is a normal function call in user code, the plugin can rewrite it and put a validation wrapper around the returned client stub.

Ah so to be clear, this wrapper would validate method return values from Api, rather than arguments? (Or if the client passes a stub to the server, then it would validate method arguments on that stub?)

And you're saying capnweb-validate will "rewrite" calls to newHttpBatchRpcSession<T>() and similar functions to automatically add validation? How does this rewriting work? The type parameter is obviously needed in order to decide what validation to apply, so runtime monkey-patching can't do the job. I guess you must be using some sort of bundler plugin to do the rewrite?

I think in many cases this client-side validation is not really needed since the client fully trusts the server. So I'm not sure it's desirable to make it automatic. I would say we should give people an explicit way to add the validation, like:

let stub = validateStub<T>(newWebSocketRpcSession<T>(...))`

Then this explicit validateStub function could also be used on Workers RPC stubs.

By the way, we probably need a way to coerce a validated stub to another type, so that e.g. if an API returns RpcStub<any>, we can coerce it to a specific RpcStub<T>. Note the coercion requires removing the existing validation wrapper and then adding a new wrapper. So this may call for another function like coerceStub<T> that does this?

@teamchong

Copy link
Copy Markdown
Collaborator Author

in Cap'n Web, client code usually creates a remote API object by calling something like newHttpBatchRpcSession(). Since that is a normal function call in user code, the plugin can rewrite it and put a validation wrapper around the returned client stub.

Ah so to be clear, this wrapper would validate method return values from Api, rather than arguments? (Or if the client passes a stub to the server, then it would validate method arguments on that stub?)

And you're saying capnweb-validate will "rewrite" calls to newHttpBatchRpcSession<T>() and similar functions to automatically add validation? How does this rewriting work? The type parameter is obviously needed in order to decide what validation to apply, so runtime monkey-patching can't do the job. I guess you must be using some sort of bundler plugin to do the rewrite?

I think in many cases this client-side validation is not really needed since the client fully trusts the server. So I'm not sure it's desirable to make it automatic. I would say we should give people an explicit way to add the validation, like:

let stub = validateStub<T>(newWebSocketRpcSession<T>(...))`

Then this explicit validateStub function could also be used on Workers RPC stubs.

By the way, we probably need a way to coerce a validated stub to another type, so that e.g. if an API returns RpcStub<any>, we can coerce it to a specific RpcStub<T>. Note the coercion requires removing the existing validation wrapper and then adding a new wrapper. So this may call for another function like coerceStub<T> that does this?

yes. today this works through the capnweb-validate unplugin/CLI build transform.

the transform uses the TypeScript checker to find calls like newHttpBatchRpcSession<Api>(), resolves Api, emits a runtime validator, and rewrites the call to an internal helper that calls the real Cap’n Web constructor and wraps the returned stub in a Proxy.
that proxy validates outgoing args and resolved return values.
runtime monkey-patching would not work because the type argument is erased.

I agree with the new direction: client-side stub validation should be explicit rather than automatic. I’ll update this PR to remove the automatic Cap’n Web client constructor rewriting. Since server-boundary validation already exists through @validateRpc(), the client-side replacement would be an explicit API like validateStub<T>(stub), with a possible coerceStub<T>() for replacing an existing wrapper. I can include that here if we agree on the API shape, or split it into a follow-up PR.

@teamchong

Copy link
Copy Markdown
Collaborator Author

@kentonv, I pushed 153d6f6 removing the automatic client constructor rewrite.

so this PR is now only server-boundary validation via @validateRpc(). the example reflects that: the Worker is transformed, the React client uses normal Cap’n Web sessions.

for follow-up, I’d make client validation explicit:

const api = validateStub<Api>(newHttpBatchRpcSession<Api>("/api"));

and the same should work for Workers RPC stubs:

const api = validateStub<Api>(env.SERVICE);

I’d have validateStub<T>() reuse the old proxy behavior: validate args before calls through the stub, and validate resolved return values on the caller side. for normal client-to-server stubs, the return check is the main value; the arg check matters for callback/capability stubs passed over RPC.

I'd skip coerceStub<T>() for now unless we have a concrete RpcStub<any> case, and when we do, the unwrap-then-rewrap is the part to get right

@kentonv

kentonv commented Jun 5, 2026

Copy link
Copy Markdown
Member

that proxy validates outgoing args and resolved return values.

I don't think we ever need to validate outgoing values. This is redundant since the recipient will need to validate again on its end for security. Can we differentiate outgoing wrappers vs. incoming wrappers, where incoming wrappers validate only arguments and outgoing wrappers validate only return values?

@teamchong teamchong force-pushed the typescript-rpc-validators branch from fa9a759 to a47c5e4 Compare June 6, 2026 01:15
Per review feedback, narrow capnweb-validate to receive-side validation:
the server checks incoming arguments, the client checks incoming return
values, and neither validates what it sends. validateStub() wraps a
client
session to validate returns; @validateRpc validates server args.

Runtime:
- Receive-side-only model in wrapServerTarget / wrapClientStub /
  wrapRpcPromise; drop sender-side checks and the dead deferred-value
  plumbing (ValidationOptions, isDeferredValidationValue, IsAny).
- Bound validator recursion with MAX_VALIDATION_DEPTH, failing closed so
  an
  over-nested payload is rejected, not accepted unvalidated.
- Re-wrap dup() results so duplicated stubs stay validated; use own()
  for
  index-signature keys; tighten the stub brand check.

Transform:
- Client validators omit argument validators (the client never checks
  outgoing args), shrinking the emitted client bundle.
- Gate Response / Promise name matching on the global symbol so a user
  type
  of the same name is not misclassified as a capability/awaitable.
- Strip bundler query/hash suffixes so Vite worker imports still
  transform.

Tests: receive-side, end-to-end validateStub, and validation-depth
coverage.
Demo: a button that exercises a server-side validation failure.
@teamchong teamchong force-pushed the typescript-rpc-validators branch from a47c5e4 to ea460c1 Compare June 6, 2026 02:35
@teamchong

Copy link
Copy Markdown
Collaborator Author

that proxy validates outgoing args and resolved return values.

I don't think we ever need to validate outgoing values. This is redundant since the recipient will need to validate again on its end for security. Can we differentiate outgoing wrappers vs. incoming wrappers, where incoming wrappers validate only arguments and outgoing wrappers validate only return values?

Done. validation is now receive-side only: a wrapper validates what it receives, never what it sends, since the receiver re-validates anyway.

  • incoming wrappers (server target, @validateRpc()): validate arguments only; returns pass through unvalidated.
  • outgoing wrappers (client stub, validateStub<T>()): validate resolved returns only; outgoing args pass through, and the generated client validators omit argument validators entirely.
  • capabilities follow the same rule in both directions: a stub you receive and later call through is wrapped as outgoing (validates only its returns); a capability you expose is wrapped as incoming (validates only the args called into it).

client validation is also explicit now. I dropped the automatic Cap'n Web client-constructor rewrite, users opt in with validateStub<T>(stub), which works the same in a worker calling another service

@kentonv kentonv merged commit 2cb51eb into cloudflare:main Jun 9, 2026
5 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 9, 2026
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.

runtime type validation

3 participants