Skip to content

capnweb-validate: validateRpc() in static block, not just a decorator - #231

Draft
teamchong wants to merge 4 commits into
cloudflare:mainfrom
teamchong:validate-rpc-hoc
Draft

capnweb-validate: validateRpc() in static block, not just a decorator#231
teamchong wants to merge 4 commits into
cloudflare:mainfrom
teamchong:validate-rpc-hoc

Conversation

@teamchong

@teamchong teamchong commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Decorators don't survive Vite 8 / oxc unless experimentalDecorators is on, and
turning that on isn't free: it switches TypeScript to legacy decorator semantics,
which breaks anything already using standard decorators
(cloudflare/workers-sdk#12626). So validation shouldn't require a decorator.

Before:

@validateRpc()
export class Api extends RpcTarget {
  async authenticate(token: string): Promise<User> { ... }
}

After:

export class Api extends RpcTarget {
  static { validateRpc(); }

  async authenticate(token: string): Promise<User> { ... }
}

Each form emits the same validator as the decorator it replaces, and a module
using both shares one. A static block runs when the class is created and isn't
inherited, so the validator is per-class, same as the decorator.

Forms

Form Same as
static { validateRpc(); } @validateRpc()
static { validateRpc<Surface>(); } @validateRpc<Surface>()
static { validateRpc({ skip: ["raw"] }); } @skipRpcValidation() on raw()
static { validateRpc<Surface>({ skip: ["raw"] }); } both at once
export default validateRpc(Api); @validateRpc(), for a class exported as an expression

Build errors

Rule Why
A call whose result is discarded must be a static block in the class it validates Anywhere else the call is detached from the class, and only validates it if that code runs
validateRpc() with no class is only valid in a static block of a named class declaration That is the only place the class it validates is unambiguous, and has a name to emit
Surface goes through the factory form, not validateRpc<Surface>(Api) TypeScript has no partial type-argument inference, so the class type would be discarded
The argument must name a class declared in the same module, not an import or inline class { ... } The transform reads the declaration for @skipRpcValidation() members and platform method filtering
skip must be an inline object literal with an array of string literals The names are read at build time
A skip name must exist in the resolved surface, and isn't allowed twice; with a type argument it's keyof Surface too Same as a stray @skipRpcValidation(); a repeat is always a mistake

`validateRpc(Api)` and `validateRpc<Surface>()(Api)` now work as
higher-order functions, for builds where decorators aren't available.
Both emit the same validator as the decorator form and share it.

The argument must name a class declared in the same module: the
transform reads the declaration for `@skipRpcValidation` members and
platform method filtering. `@skipRpcValidation()` is a method decorator,
so the wrapper form takes names instead, read statically at build time:
`validateRpc(Api, { skip: ["raw"] })`.
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 615cca1

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

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 615cca1

@teamchong

This comment was marked as outdated.

ask-bonk[bot]

This comment was marked as outdated.

@ask-bonk

This comment was marked as outdated.

Narrow via `return bad(...)` instead of casting past the never-returning
helper, find the class among merged declarations, reject an options object
with no skip list, and cover the empty/absent skip cases in tests.
@teamchong teamchong closed this Aug 6, 2026
@teamchong
teamchong deleted the validate-rpc-hoc branch August 6, 2026 01:48
@teamchong
teamchong restored the validate-rpc-hoc branch August 6, 2026 01:48
@teamchong teamchong reopened this Aug 6, 2026
…skip names

A bare `validateRpc(Api);` statement only validates the class if it actually
runs, so the transform now requires it to be a top-level statement appearing
after the class declaration. Nesting it in a block, or placing it before the
declaration, is a build error rather than validation that silently never
happens.

Also reject a method named twice in the skip list, which is always a mistake.
@teamchong teamchong changed the title capnweb-validate: validateRpc() as a plain wrapper, not just a decorator capnweb-validate: validateRpc() in static block, not just a decorator Aug 7, 2026
…calls

A discarded `validateRpc(MyClass)` call can sit anywhere, and only validates
the class if that code runs. Require the discarded form to be a static block in
the class it validates, which runs exactly when the class is created, and let it
name its class implicitly:

    class Api extends RpcTarget { static { validateRpc(); } ... }

The static block form also takes the surface as a type argument and the skip
list as its only argument, and takes both together:

    static { validateRpc<Surface>({ skip: ["raw"] }); }

The transform already read both from the call. With a surface to name, `skip`
is checked against `keyof Surface` as well as against the resolved surface at
build time, which the form without a type argument has no type to do.
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