Add capnweb-validate RPC validators#169
Conversation
🦋 Changeset detectedLatest commit: 06e7be7 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 |
|
All contributors have signed the CLA ✍️ ✅ |
commit: |
c0dc243 to
a762c16
Compare
400182b to
7d866ac
Compare
bda749b to
4ceeec5
Compare
Add the capnweb-typecheck package, marker transform, plugin adapters, CLI, runtime validators, tests, and Worker React debug example.
4ceeec5 to
7f94aff
Compare
0a6e258 to
2e725b9
Compare
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.
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 do you agree with that direction for either way, I’ll rename/remove the “structured clone” wording |
|
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. |
2231c98 to
942ec47
Compare
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
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 workers RPC client objects are different. the client usually gets them from the platform as env.SERVICE. there is no obvious function call like |
Ah so to be clear, this wrapper would validate method return values from And you're saying capnweb-validate will "rewrite" calls to 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: Then this explicit By the way, we probably need a way to coerce a validated stub to another type, so that e.g. if an API returns |
yes. today this works through the the transform uses the TypeScript checker to find calls like 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 |
|
@kentonv, I pushed so this PR is now only server-boundary validation via 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 I'd skip |
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? |
fa9a759 to
a47c5e4
Compare
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.
a47c5e4 to
ea460c1
Compare
Done. validation is now receive-side only: a wrapper validates what it receives, never what it sends, since the receiver re-validates anyway.
client validation is also explicit now. I dropped the automatic Cap'n Web client-constructor rewrite, users opt in with |
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 fromcapnweb, 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:
If
@validateRpc()is left untransformed, it throws a configuration error instead of silently running without validation.How it works
@validateRpc()decorators imported fromcapnweb-validate.@validateRpc<T>()type argument is given, the transform emits a warning and falls back to class public methods.capnweb-validate/internal/core.capnweband injects client-side validation wrappers.Supported pass-by-value types include primitives, arrays, tuples, plain objects, unions,
Record<string, T>, selected platform built-ins, andPromise<T>return unwrapping.Pass-by-reference values include plain functions,
RpcStub<T>,RpcPromise<T>,RpcTargetsubclasses, and Workers RPC stubs.Unsupported wire types such as
Map,Set,WeakMap,WeakSet,RegExp,ArrayBuffer, and non-Uint8Arraytyped arrays fail at build time.Included
packages/capnweb-validateworkspace package:@validateRpc()class decorator@skipRpcValidation()method opt-outRpcValidationErrorcapnweb-validate/capnwebcapnwebis an optional peer dependency. The root entry andcapnweb-validate/internal/corehave nocapnwebimports; Cap'n Web-specific helpers live undercapnweb-validate/capnwebandcapnweb-validate/internal/capnweb. Workers RPC-only users can installcapnweb-validatealone.WorkerEntrypointsupport. Runtime-invoked methods onWorkerEntrypointsubclasses (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 TypeErrorwitherr.rpcValidation = { path, expected, actual, value }.pathis JSON-pointer-style, for example["Api", "authenticate", 0, "profile", "email"].capnweb.examples/worker-reactvalidation wiring.Example
Server:
Client:
Wrangler flow
Wrangler does not expose a bundler plugin hook, so use the CLI for Worker source:
Point Wrangler's
mainat 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 testRepo:
Example: