Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .changeset/result-stub-elision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"capnweb": minor
"capnweb-validate": minor
---

Methods returning `Promise<RpcStub<T>>` now have the same result type as methods returning `Promise<T>`, fixing awaits and pipelined calls on such results (in `capnweb-validate` too). If you annotated one of these results as `RpcPromise<RpcStub<T>>`, write `RpcPromise<T>` instead.
8 changes: 5 additions & 3 deletions __tests__/workerd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ describe("workerd compatibility", () => {
})

it("can wrap a userspace stub in a native stub", async () => {
let stub = new NativeRpcStub(new RpcStub(new JsCounter()));
// Cast: now that the `__RPC_TARGET_BRAND` no longer leaks onto stub surfaces, a userspace
// stub doesn't statically match workers-types' `Stubable` (runtime interop still works).
let stub = new NativeRpcStub(<any>new RpcStub(new JsCounter()));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugh this is probably a non-starter

  • capnweb stub → new NativeRpcStub(...): needs now

but its because runtime suffers from the same poised branded stub issue

expect(await stub.increment()).toBe(1);
expect(await stub.increment()).toBe(2);

Expand Down Expand Up @@ -238,7 +240,7 @@ describe("workerd compatibility", () => {
// Wrap a userspace RpcPromise in a native stub.
{
let factory = new RpcStub(new CounterFactory());
let stub = new NativeRpcStub(factory.getJs());
let stub = new NativeRpcStub(<any>factory.getJs()); // cast: see "userspace stub" above
expect(await stub.increment()).toBe(1);
expect(await stub.increment()).toBe(2);

Expand All @@ -248,7 +250,7 @@ describe("workerd compatibility", () => {
// Wrap a userspace property (which is actually also an RpcPromise) in a native stub.
{
let factory = new RpcStub(new CounterFactory());
let stub = new NativeRpcStub(factory.getJsEmbedded().stub);
let stub = new NativeRpcStub(<any>factory.getJsEmbedded().stub); // cast: see above
expect(await stub.increment()).toBe(1);
expect(await stub.increment()).toBe(2);

Expand Down
92 changes: 91 additions & 1 deletion __type-tests__/capnweb-validate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RpcTarget, type RpcCompatible } from "../src/index.js"
import { validateStub, type ValidatedStub } from "../packages/capnweb-validate/src/index.js"
import { expectAssignable, expectType, type Expect } from "./helpers.js"
import { expectAssignable, expectType, type Equal, type Expect } from "./helpers.js"

class Counter extends RpcTarget {
increment(by: number): number {
Expand Down Expand Up @@ -33,9 +33,99 @@ api.getPair().then((pair) => {
void mutablePair
})

// Stub elision mirrors the main package: a `Promise<ValidatedStub<T>>` return for a branded
// target produces the same type as a `Promise<T>` return, while plain-interface stubs keep
// the non-elided shape (they only await back to a stub when NOT elided).
type Formatter = (x: number) => string

interface StubReturningApi {
viaTarget(): Promise<Counter>
viaStub(): Promise<ValidatedStub<Counter>>
viaFn(): Promise<Formatter>
viaFnStub(): Promise<ValidatedStub<Formatter>>
getPlain(): Promise<ValidatedStub<Api>>
getAnyStub(): Promise<ValidatedStub<any>>
getAny(): Promise<any>
getUnknown(): Promise<unknown>
maybeStub(): Promise<ValidatedStub<Counter> | null>
consumeMaybe(counter: ValidatedStub<Counter> | null): Promise<number>
dies(): Promise<never>
}

// The brand-leak fix, mirrored: a validated stub of a branded target must not itself look
// branded, or `Stubify` would double-wrap it.
type _NoBrandLeak = Expect<
Equal<ValidatedStub<Counter> extends { readonly __RPC_TARGET_BRAND: never } ? true : false, false>
>

let stubApi = validateStub<StubReturningApi>(rawStub)

const viaTarget = stubApi.viaTarget()
const viaStub = stubApi.viaStub()
type _ValidatedStubElides = Expect<Equal<typeof viaStub, typeof viaTarget>>
expectAssignable<Promise<number>>(viaStub.increment(2))

// Callable stubs elide too.
const fnViaTarget = stubApi.viaFn()
const fnViaStub = stubApi.viaFnStub()
type _ValidatedCallableStubElides = Expect<Equal<typeof fnViaStub, typeof fnViaTarget>>

// A `never`-returning method stays `never` instead of matching the promise-normalization arm
// with `U = unknown`.
const neverResult = stubApi.dies()
type _NeverStaysNever = Expect<Equal<typeof neverResult, never>>

// Elision distributes over unions, so a `ValidatedStub<T> | null` result still passes as a
// pipelined argument.
const maybe = stubApi.maybeStub()
stubApi.consumeMaybe(maybe)

// Promise-backed stub results normalize: re-declaring a method as returning another method's
// result type produces that same type.
interface ChainApi {
chain(): typeof viaTarget
}
let chainApi = validateStub<ChainApi>(rawStub)
const chained = chainApi.chain()
type _RpcPromiseNormalizes = Expect<Equal<typeof chained, typeof viaTarget>>

const plainStubPromise = stubApi.getPlain()

async function assertValidatedStubShapes() {
const awaitedCounter = await viaStub
expectAssignable<Promise<number>>(awaitedCounter.increment(1))

// Plain-interface stubs keep the wrapper: awaiting still yields the stub itself.
const inner: ValidatedStub<Api> = await plainStubPromise
expectAssignable<Promise<number>>(inner.getCounter().increment(1))

// Union elision: awaiting yields the payload stub or null.
const maybeCounter = await maybe
if (maybeCounter !== null) {
expectAssignable<Promise<number>>(maybeCounter.increment(1))
} else {
expectType<null>(maybeCounter)
}

// `any` and `unknown` payloads must keep the full stub-result surface: `[any] extends [X]`
// is true for any `X`, so without the IsAny guards these would collapse to
// `Promise<unknown> & StubBase<unknown>`.
const anyStubResult = stubApi.getAnyStub()
const anyResult = stubApi.getAny()
expectAssignable<Disposable>(anyStubResult)
expectAssignable<Disposable>(anyResult)
expectAssignable<Disposable>(stubApi.getUnknown())
anyStubResult.dup()
anyResult.onRpcBroken((_error) => {})
}

void assertValidatedStubShapes

// @ts-expect-error wrong method name
api.missing()
// @ts-expect-error wrong argument type
counter.increment("1")
// @ts-expect-error array elements must be numbers
api.sum(["1"])
// @ts-expect-error pipelined methods keep signatures on elided stub returns
viaStub.increment("2")
157 changes: 157 additions & 0 deletions __type-tests__/stub-elision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Declared stub returns/properties (`Promise<RpcStub<T>>`, `RpcStub<T>`) must produce the same
// `RpcPromise<T>` as returning the payload directly (`Promise<T>`), matching what
// `new RpcPromise(Promise.resolve(stub))` produces. Plain-interface stubs are the exception:
// they are NOT elided, because `RpcPromise<U>` only awaits back to a stub when `U` is Stubable.
import { RpcPromise, RpcStub, RpcTarget } from "../src/index.js"
import type { Stubable } from "../src/types.js"
import { expectAssignable, expectType, type Equal, type Expect } from "./helpers.js"

class Counter extends RpcTarget {
increment(by: number): number {
return by
}

get value(): number {
return 0
}
}

type Formatter = (x: number) => string

interface PlainApi {
ping(): number
echo(name: string): Promise<string>
}

interface ElisionApi {
viaTarget(): Promise<Counter>
viaStub(): Promise<RpcStub<Counter>>
counterProp: RpcStub<Counter>
viaFn(): Promise<Formatter>
viaFnStub(): Promise<RpcStub<Formatter>>
wrapped(): Promise<{ s: RpcStub<Counter> }>
listStubs(): Promise<RpcStub<Counter>[]>
consumeCounter(counter: RpcStub<Counter>): Promise<number>
getApi(): Promise<RpcStub<PlainApi>>
getAnyStub(): Promise<RpcStub<any>>
anyStubProp: RpcStub<any>
maybeStub(): Promise<RpcStub<Counter> | null>
consumeMaybe(counter: RpcStub<Counter> | null): Promise<number>
}

// The brand-leak fix: a stub of a branded target no longer matches `Stubable` structurally
// (the string-keyed brand is excluded from the stub's surface), which is the root cause of
// double-stubification. Callable stubs remain `Stubable` — they are genuinely callable —
// which is why `Stubify` checks `StubBase` before `Stubable`.
type _BrandedStubIsNotStubable = Expect<Equal<RpcStub<Counter> extends Stubable ? true : false, false>>
type _CallableStubIsStillStubable = Expect<Equal<RpcStub<Formatter> extends Stubable ? true : false, true>>

declare const api: RpcStub<ElisionApi>

// 1. A `Promise<RpcStub<T>>` return is indistinguishable from a `Promise<T>` return.
const viaTarget = api.viaTarget()
const viaStub = api.viaStub()
type _StubReturnMatchesTargetReturn = Expect<Equal<typeof viaStub, typeof viaTarget>>
expectType<RpcPromise<Counter>>(viaStub)

// 2. Both forms can be passed as pipelined RPC arguments (previously TS2345 for viaStub).
api.consumeCounter(viaTarget)
api.consumeCounter(viaStub)

// 3. Awaiting yields a single stub, not a stub-of-stub (previously TS2322).
type _AwaitedViaStub = Expect<Equal<Awaited<typeof viaStub>, RpcStub<Counter>>>

// Pipelining on the elided promise works like any other RpcPromise<Counter>.
expectAssignable<Promise<number>>(viaStub.increment(3))
expectAssignable<Promise<number>>(viaStub.value)
viaStub.onRpcBroken((_error) => {})

// 5. An interface property typed `RpcStub<T>` elides identically.
const propPromise = api.counterProp
type _PropertyElides = Expect<Equal<typeof propPromise, typeof viaTarget>>

// 6. Callable stubs (`RpcStub<(x: number) => string>`) elide too — the second Stubable path.
const fnViaTarget = api.viaFn()
const fnViaStub = api.viaFnStub()
type _CallableStubElides = Expect<Equal<typeof fnViaStub, typeof fnViaTarget>>
type _AwaitedFnStub = Expect<Equal<Awaited<typeof fnViaStub>, RpcStub<Formatter>>>
expectAssignable<Promise<string>>(fnViaStub(4))

// 7. Constructor/method equivalence: wrapping a promised stub yourself produces exactly the
// same type as a method declared to return the stub, for every payload shape.
declare const counterStub: RpcStub<Counter>
const constructed = new RpcPromise(Promise.resolve(counterStub))
type _ConstructorMatchesMethodReturn = Expect<Equal<typeof constructed, typeof viaStub>>

// 7b. Explicitly annotating the payload type still compiles.
const explicit: RpcPromise<Counter> = new RpcPromise<Counter>(Promise.resolve(counterStub))
void explicit

// 7c. Callable stubs elide in the constructor too.
declare const formatterStub: RpcStub<Formatter>
const constructedFn = new RpcPromise(Promise.resolve(formatterStub))
type _CallableCtorMatchesMethodReturn = Expect<Equal<typeof constructedFn, typeof fnViaStub>>

// 7d. Plain-interface stubs are not elided in either form, and the two forms agree.
declare const plainStub: RpcStub<PlainApi>
const constructedPlain = new RpcPromise(Promise.resolve(plainStub))
const plainViaMethod = api.getApi()
type _PlainCtorMatchesMethodReturn = Expect<Equal<typeof constructedPlain, typeof plainViaMethod>>

// 7e. Union payloads distribute identically in both forms.
declare const maybePromise: Promise<RpcStub<Counter> | null>
const constructedMaybe = new RpcPromise(maybePromise)
const maybeViaMethod = api.maybeStub()
type _UnionCtorMatchesMethodReturn = Expect<Equal<typeof constructedMaybe, typeof maybeViaMethod>>
api.consumeMaybe(maybeViaMethod)
api.consumeMaybe(constructedMaybe)

// 8. map() over a declared `RpcStub<T>[]` return: the callback placeholder is `T`-shaped,
// so pipelined calls on elements typecheck.
const mapped = api.listStubs().map((c) => c.increment(2))
expectAssignable<Promise<number[]>>(mapped)

// 9. Self-referential stub returns compile (recursion in `Result` terminates).
declare class Node extends RpcTarget {
next(): Promise<RpcStub<Node>>
}
declare const nodeStub: RpcStub<Node>
const nextNode = nodeStub.next()
expectType<RpcPromise<Node>>(nextNode)
const grandchild = nextNode.next()
expectType<RpcPromise<Node>>(grandchild)

// 4 & 10. Awaited shapes: stubs nested in object results stay single stubs, and
// plain-interface stubs keep their wrapper (awaiting still yields the stub itself).
async function assertAwaitedShapes() {
const counter = await viaStub
expectType<RpcStub<Counter>>(counter)

const wrapped = await api.wrapped()
expectType<RpcStub<Counter>>(wrapped.s)
expectAssignable<Promise<number>>(wrapped.s.increment(1))

// Plain-interface stubs are not elided: this assignment is today's working behavior and
// must keep compiling (eliding would make the awaited value a stubified record).
const s: RpcStub<PlainApi> = await api.getApi()
expectAssignable<Promise<number>>(s.ping())
s.dup()

// `RpcStub<any>` results are not elided either: `[any] extends [Stubable]` is true, so
// without the IsAny guard these would collapse to `RpcPromise<unknown>` and await to
// `unknown`, losing the stub surface.
const anyFromMethod = await api.getAnyStub()
const anyFromProp = await api.anyStubProp
expectAssignable<Disposable>(anyFromMethod)
expectAssignable<Disposable>(anyFromProp)
anyFromMethod.dup()
anyFromProp.onRpcBroken((_error) => {})
}

void assertAwaitedShapes

// @ts-expect-error pipelined methods keep their signatures — increment requires a number
viaStub.increment("1")

// @ts-expect-error methods not on Counter are not available on the elided promise
viaStub.missing()
49 changes: 39 additions & 10 deletions packages/capnweb-validate/src/internal/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,17 @@ type BaseType =
| Response
| Headers;

type Stubify<T> = T extends Stubable
? ValidatedStub<T>
: T extends Promise<infer U>
? Stubify<U>
: T extends StubBase<unknown>
? T
// Arm ordering matters (mirrors the main package's `Stubify`):
// - `Promise` before `StubBase`: promise-backed stubs match both and must resolve through the
// Promise arm.
// - `StubBase` before `Stubable`: a stub of a callable `T` is itself callable, so it matches
// `Stubable`; checking `StubBase` first avoids double-wrapping existing stubs.
type Stubify<T> = T extends Promise<infer U>
? Stubify<U>
: T extends StubBase<unknown>
? T
: T extends Stubable
? ValidatedStub<T>
: T extends Map<infer K, infer V>
? Map<Stubify<K>, Stubify<V>>
: T extends Set<infer V>
Expand Down Expand Up @@ -157,10 +162,29 @@ type Unstubify<T> =
type UnstubifyAll<T extends readonly unknown[]> = {
[K in keyof T]: Unstubify<T[K]>;
};
type StubResult<T> = Promise<Stubify<T>> & ValidatedStub<T> & StubBase<T>;
type IsAny<T> = 0 extends 1 & T ? true : false;
type StubResultInner<T> = Promise<Stubify<T>> & ValidatedStub<T> & StubBase<T>;
// Mirrors the main package's `Result` stub elision: a declared stub return/property collapses
// to the same type as returning the payload directly — but only for `Stubable` payloads, since
// only those await back to a stub. Distributes over unions, like the main package's `Result`,
// so e.g. `ValidatedStub<T> | null` elides (this also makes `never` stay `never` instead of
// matching the promise arm with `U = unknown`).
// `any` needs explicit guards: `[any] extends [X]` is true for any `X`, so without them a
// `Promise<any>` or `ValidatedStub<any>` return would recurse through the eliding arms and
// collapse to `Promise<unknown> & StubBase<unknown>` instead of keeping the full stub surface.
type StubResult<T> =
IsAny<T> extends true ? StubResultInner<T>
: T extends PromiseLike<unknown> & StubBase<infer U> ? StubResult<U>
: T extends StubBase<infer U>
? (IsAny<U> extends true ? StubResultInner<T>
: [U] extends [Stubable] ? StubResult<U> : StubResultInner<T>)
: StubResultInner<T>;
type StubMethodOrProperty<T> = T extends (...args: infer P) => infer R
? (...args: UnstubifyAll<P>) => StubResult<Awaited<R>>
: StubResult<Awaited<T>>;
// Deliberately repeats `StubMethodOrProperty`'s function arm instead of delegating to it:
// the extra conditional layer of a delegation tips `ValidatedStub`'s recursive instantiation
// over TypeScript's depth limit (TS2589).
type MaybeCallableStub<T> = T extends (...args: infer P) => infer R
? (...args: UnstubifyAll<P>) => StubResult<Awaited<R>>
: unknown;
Expand Down Expand Up @@ -196,9 +220,14 @@ type MapCallbackReturn<V> =
export type ValidatedStub<T> = MaybeCallableStub<T> &
(T extends object
? {
[K in Exclude<keyof T, symbol | keyof StubBase<never>>]: StubMethodOrProperty<
T[K]
>;
[K in Exclude<
keyof T,
| symbol
| "__RPC_TARGET_BRAND"
| "__WORKER_ENTRYPOINT_BRAND"
| "__DURABLE_OBJECT_BRAND"
| keyof StubBase<never>
>]: StubMethodOrProperty<T[K]>;
} & {
map<V>(callback: (value: MapCallbackValue<NonNullable<T>>) => MapCallbackReturn<V>): StubResult<
Array<V>
Expand Down
9 changes: 6 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { RpcTarget as RpcTargetImpl, RpcStub as RpcStubImpl, RpcPromise as RpcPr
import { serialize, deserialize, EncodingLevel } from "./serialize.js";
import { RpcTransport, RpcTransportWithCustomEncoding, AnyRpcTransport, RpcSession as RpcSessionImpl, RpcSessionOptions } from "./rpc.js";
import { RpcLimits, DEFAULT_LIMITS, DEFAULT_MAX_DEPTH } from "./serialize.js";
import { RpcTargetBranded, RpcCompatible, Stub, type RpcPromise as RpcPromiseType,
__RPC_TARGET_BRAND } from "./types.js";
import { RpcTargetBranded, RpcCompatible, Stub, ElideStub, PayloadOrStub,
type RpcPromise as RpcPromiseType, __RPC_TARGET_BRAND } from "./types.js";
import { newWebSocketRpcSession as newWebSocketRpcSessionImpl,
newWorkersWebSocketRpcResponse, WebSocketTransport } from "./websocket.js";
import { newHttpBatchRpcSession as newHttpBatchRpcSessionImpl,
Expand Down Expand Up @@ -70,7 +70,10 @@ export const RpcStub: {
*/
export type RpcPromise<T extends RpcCompatible<T>> = RpcPromiseType<T>;
export const RpcPromise: {
new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>;
// The return type applies `ElideStub` — the same transformation `Result` applies to a
// declared stub return — so constructing from a promised stub produces exactly the type a
// method returning that stub would. See `PayloadOrStub` for what the promise may resolve to.
new <T extends RpcCompatible<T>>(value: Promise<PayloadOrStub<T>>): RpcPromiseType<ElideStub<T>>;
} = <any>RpcPromiseImpl;

/**
Expand Down
Loading
Loading