Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/capnweb-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ symbol-named.
An explicit `@validateRpc<SomeInterface>()` makes `SomeInterface` the RPC
surface. Public class methods outside that interface are rejected over RPC.

## Higher-order function form

`validateRpc` is also a higher-order function that takes a class and returns it,
for builds that can't enable decorators (no `experimentalDecorators`, a toolchain
that strips them, or a downstream consumer that rejects them):

```ts
validateRpc(Api) // same as @validateRpc()
validateRpc<Surface>()(Api) // same as @validateRpc<Surface>()
validateRpc(Api, { skip: ["raw"] }) // same as @skipRpcValidation() on raw()
```

Each form emits the same validator as the decorator it replaces.

Constraints, all reported as build errors:

| Rule | Why |
|---|---|
| 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 | Same as a stray `@skipRpcValidation()` |

## Generic service classes

A decorator emits one validator at the class declaration. If the class itself
Expand Down
203 changes: 203 additions & 0 deletions packages/capnweb-validate/__tests__/validate-rpc-wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the MIT license found in the LICENSE.txt file or at:
// https://opensource.org/license/mit

// The higher-order function form: `validateRpc(Api)` and
// `validateRpc<Surface>()(Api)` must emit the same validator and the same
// applied decorator as `@validateRpc()`.
import { describe, expect, it } from "vitest";
import {
checkedMethod,
loadValidator,
SHIM,
transformError,
transformFixture,
} from "./helpers.js";
import type { ServiceValidator } from "../src/internal/core.js";

// Mirrors the real overloads: direct call returns the class, the factory
// returns the decorator.
const WRAPPER_SHIM = `${SHIM}
declare module "capnweb-validate" {
export function validateRpc<T>(value: T, context?: unknown): T;
export function validateRpc<T>(value: T, options: { skip: readonly string[] }): T;
export function validateRpc<S = unknown>(): <T>(value: T, context?: unknown) => T;
export function skipRpcValidation(...a: unknown[]): unknown;
}`;

const IMPORTS = `import { skipRpcValidation, validateRpc } from "capnweb-validate";
import { RpcTarget } from "capnweb";
`;

function compile(body: string): { code: string; warns: string[] } {
return transformFixture(body, { shim: WRAPPER_SHIM, imports: IMPORTS });
}

function compileError(body: string): string {
return transformError(body, { shim: WRAPPER_SHIM, imports: IMPORTS });
}

const API = `class Api extends RpcTarget {
async authenticate(token: string): Promise<number> {
return token.length;
}
}
`;

describe("validateRpc wrapper form", () => {
it("rewrites `validateRpc(Api)` to the applied decorator, keeping the class argument", () => {
const { code } = compile(`${API}export default validateRpc(Api);`);

expect(code).toContain(
"export default __cw.__validateRpcClass(__capnweb_validate_Api_server)(Api);"
);
const validator: ServiceValidator = loadValidator(code);
expect(validator.serviceName).toBe("Api");
expect(Object.keys(validator.methods)).toEqual(["authenticate"]);
expect(checkedMethod(validator, "authenticate").args).toHaveLength(1);
});

it("takes the RPC surface from the factory type argument", () => {
const { code } = compile(
`interface Surface {
authenticate(token: string): Promise<number>;
}
class Api extends RpcTarget implements Surface {
async authenticate(token: string): Promise<number> {
return token.length;
}
async internal(): Promise<void> {}
}
export default validateRpc<Surface>()(Api);`
);

expect(code).toContain("__cw.__validateRpcClass(");
expect(code).toContain(")(Api);");
// `internal` is outside the declared surface, so it is not validated.
expect(Object.keys(loadValidator(code).methods)).toEqual(["authenticate"]);
});

it("honors @skipRpcValidation on the wrapped class", () => {
const { code } = compile(
`class Api extends RpcTarget {
async authenticate(token: string): Promise<number> {
return token.length;
}
@skipRpcValidation()
async raw(blob: unknown): Promise<void> {}
}
export default validateRpc(Api);`
);

const validator = loadValidator(code);
expect(validator.methods.raw).toEqual({ unchecked: true });
});

it("skips methods named by the skip option", () => {
const { code } = compile(
`class Api extends RpcTarget {
async authenticate(token: string): Promise<number> {
return token.length;
}
async raw(blob: unknown): Promise<void> {}
}
export default validateRpc(Api, { skip: ["raw"] });`
);

const validator = loadValidator(code);
expect(validator.methods.raw).toEqual({ unchecked: true });
expect(checkedMethod(validator, "authenticate").args).toHaveLength(1);
// The option is an argument, not the callee, so it survives the rewrite.
expect(code).toContain(`)(Api, { skip: ["raw"] });`);
});

it("rejects a skipped name that is not in the surface", () => {
expect(
compileError(`${API}export default validateRpc(Api, { skip: ["nope"] });`)
).toContain("Api.nope");
});

it("rejects skip names the transform cannot read at build time", () => {
expect(
compileError(
`${API}const opts = { skip: ["authenticate"] };
export default validateRpc(Api, opts);`
)
).toContain("must be written inline");
});

it("dedups identical shapes across wrapper sites", () => {
const { code } = compile(
`${API}export const A = validateRpc(Api);
export const B = validateRpc(Api);`
);

expect(code.match(/const __capnweb_validate_\w+ =/g)).toHaveLength(1);
expect(
code.match(/__cw\.__validateRpcClass\(__capnweb_validate_Api_server\)\(Api\)/g)
).toHaveLength(2);
});

it("does not double-rewrite a decorator that also parses as a call", () => {
const { code } = compile(`@validateRpc()
class Api extends RpcTarget {
async authenticate(token: string): Promise<number> {
return token.length;
}
}
export default Api;`);

expect(code.match(/__validateRpcClass/g)).toHaveLength(1);
});

it("rewrites namespace-qualified wrapper calls", () => {
const { code } = transformFixture(
`${API}export const A = cv.validateRpc(Api);
export const B = cv.validateRpc<Api>()(Api);`,
{
shim: WRAPPER_SHIM,
imports: `import * as cv from "capnweb-validate";
import { RpcTarget } from "capnweb";
`,
}
);

expect(code.match(/__cw\.__validateRpcClass\(/g)).toHaveLength(2);
expect(code).not.toContain("cv.validateRpc");
expect(checkedMethod(loadValidator(code), "authenticate").args).toHaveLength(
1
);
});

it("rejects a type argument on the direct call, pointing at the factory form", () => {
const message = compileError(
`interface Surface {
authenticate(token: string): Promise<number>;
}
${API}export default validateRpc<Surface>(Api as any);`
);

expect(message).toContain("validateRpc<Surface>()(MyClass)");
});

it("rejects an argument that is not a class declared in this module", () => {
const message = compileError(
`${API}const Alias = Api;
export default validateRpc(Alias);`
);

expect(message).toContain("name of a class declared in this module");
});

it("rejects an inline class expression", () => {
const message = compileError(
`export default validateRpc(class Api extends RpcTarget {
async authenticate(token: string): Promise<number> {
return token.length;
}
});`
);

expect(message).toContain("name of a class declared in this module");
});
});
21 changes: 17 additions & 4 deletions packages/capnweb-validate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ type AnyMethod = (this: unknown, ...args: any[]) => unknown;
// Optional `@validateRpc<TSurface>()` arg: the class instance must satisfy
// TSurface, and the transform uses TSurface as the exact RPC surface. No arg =>
// `unknown`, so any class is accepted and the transform uses the class surface.
//
// `context` is optional and the class is returned so the same marker works as a
// plain wrapper for codebases that can't enable decorators:
// `export default validateRpc<TSurface>()(MyApi)`.
type ClassDecoratorMarker<TSurface = unknown> = <
TClass extends abstract new (...args: any[]) => TSurface
>(
value: TClass,
context: ClassDecoratorContext<TClass>
) => void | TClass;
context?: ClassDecoratorContext<TClass>
) => TClass;

type MethodDecoratorMarker = <This, Value extends AnyMethod>(
value: Value,
Expand All @@ -29,10 +33,19 @@ type LegacyMethodDecoratorMarker = (
descriptor: PropertyDescriptor
) => void;

// Serves `@validateRpc` and the wrapper form `validateRpc(MyApi)`: the class is
// argument 0 either way, and returning it is legal for a class decorator.
export function validateRpc<TClass extends AnyClass = AnyClass>(
value: TClass,
context: ClassDecoratorContext<TClass>
): void | TClass;
context?: ClassDecoratorContext<TClass>
): TClass;
// Wrapper-form equivalent of `@skipRpcValidation()`, which is a method
// decorator and so out of reach for codebases that can't enable decorators.
// The transform reads the array literal, so it must be written inline.
export function validateRpc<TClass extends AnyClass>(
value: TClass,
options: { skip: readonly (keyof InstanceType<TClass> & string)[] }
): TClass;
export function validateRpc<TSurface = unknown>(): ClassDecoratorMarker<TSurface>;
export function validateRpc(
...args: unknown[]
Expand Down
Loading
Loading