Skip to content

Commit 8515194

Browse files
ryanrasticlaude
andcommitted
capnweb shim: doRpc overload for call-result stubs + byRef
- doRpc gains an overload accepting a raw-capability-typed stub (a call result typed Stubbed<T> = T & Disposable), not just ShimStub<T> — the mapped type can't reverse-infer T from those. Stubbed is now exported. - byRef(fn) wraps a function as a pass-by-reference capability (an RpcStub) instead of the default record-replay closure, so a callback survives to be called back per invocation (e.g. a push/subscription API) rather than recorded once at serialization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d6cfbb6 commit 8515194

2 files changed

Lines changed: 63 additions & 6 deletions

File tree

src/capnweb/shim.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
22
import { RpcStub, RpcTarget } from "capnweb";
33
import z from "zod";
44
import { expose } from "../exoeval/tool";
5-
import { doRpc, fromRpc, toRpc } from "./shim";
5+
import { byRef, doRpc, fromRpc, toRpc } from "./shim";
66
import { CapnwebHarness as Harness } from "./harness";
77

88
// --- test fixtures: a miniature @expose query-builder, no DB required ---
@@ -137,6 +137,23 @@ describe("toRpc/fromRpc wrapping", () => {
137137
expect(fromRpc(stub)).toBe(cap);
138138
});
139139

140+
it("byRef wraps a function as a pass-by-reference capability, not a closure", () => {
141+
const fn = (n: number) => n + 1;
142+
// A bare function heading to the wire is adapted as an (outgoing)
143+
// closure — a plain function, not a stub.
144+
const asClosure = toRpc(fn);
145+
expect(typeof asClosure).toBe("function");
146+
expect(asClosure instanceof RpcStub).toBe(false);
147+
148+
// byRef(fn) is a stub: toRpc passes it through by reference (the
149+
// capability branch), so the remote side calls back into `fn` rather
150+
// than replaying a one-time recording of it. byRef returns F & Disposable,
151+
// so `using` disposes it.
152+
using ref = byRef(fn);
153+
expect(ref instanceof RpcStub).toBe(true);
154+
expect(toRpc(ref)).toBe(ref);
155+
});
156+
140157
it("is stable and invertible", () => {
141158
const expr = new Expr("id");
142159
const wrapped = toRpc(expr);
@@ -217,4 +234,15 @@ describe("@expose over capnweb RPC", () => {
217234
expect(described).toStrictEqual({ isQuery: true, reprs: [] });
218235
void a;
219236
});
237+
238+
it("doRpc accepts a call-result stub (raw-capability overload), not just the main stub", async () => {
239+
await using h = new Harness(new Api());
240+
// query() returns a Query capability stub; doRpc must accept it as its
241+
// own `stub` argument — the ShimStub<T> overload can't reverse-infer T
242+
// from a Stubbed<Query> result, so the raw-capability overload does.
243+
using q = await h.stub.query();
244+
const sql = await doRpc(q, (query) => query.where((row) => row.id.eq(5)).sql());
245+
expect(sql).toBe('(id = 5)');
246+
});
247+
220248
});

src/capnweb/shim.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { isPlainObject, isThenable } from "../util";
3737
// A capability return really is disposable client-side; plain-data
3838
// returns just never use the member. Arrays distribute so capability
3939
// elements (hydrated rows) carry their own disposers.
40-
type Stubbed<R> = R extends readonly (infer E)[] ? Stubbed<E>[] & Disposable
40+
export type Stubbed<R> = R extends readonly (infer E)[] ? Stubbed<E>[] & Disposable
4141
: R extends object ? R & Disposable
4242
: R;
4343

@@ -57,10 +57,39 @@ export type ShimStub<T> = Disposable & {
5757
* callback still yields a stub at runtime; unwrap with fromRpc if needed
5858
* in-process.)
5959
*/
60-
export const doRpc = <T, R>(stub: ShimStub<T>, fn: (api: T) => R): Promise<Stubbed<Awaited<R>>> => {
61-
const mappable = stub as unknown as { map: (cb: unknown) => unknown };
62-
return Promise.resolve(mappable.map(fn)) as Promise<Stubbed<Awaited<R>>>;
63-
};
60+
export function doRpc<T, R>(stub: ShimStub<T>, fn: (api: T) => R): Promise<Stubbed<Awaited<R>>>;
61+
// Stubs that arrive as call results are typed `Stubbed<T>` — `T & Disposable`
62+
// for a capability, or a bare `T`. The ShimStub overload can't reverse-infer
63+
// T through its mapped type from those, so match them as-is. Deliberately
64+
// `stub: T`, not `T & Disposable`: a capability stub is disposable at runtime,
65+
// but requiring it in the type would force every caller who *aliases* a stub
66+
// type (e.g. `InstanceType<ReturnType<typeof X.forFacet>>`) to remember to
67+
// intersect `& Disposable` or fail to compile. The `& Disposable` that leaks
68+
// into the callback's `api` type is harmless (nothing disposes inside a
69+
// replayed closure).
70+
export function doRpc<T extends object, R>(stub: T, fn: (api: T) => R): Promise<Stubbed<Awaited<R>>>;
71+
export function doRpc(stub: object, fn: unknown): Promise<unknown> {
72+
const mappable = stub as { map: (cb: unknown) => unknown };
73+
return Promise.resolve(mappable.map(fn));
74+
}
75+
76+
/**
77+
* Force pass-by-REFERENCE for a function captured into a doRpc closure.
78+
*
79+
* Inside closure recording, bare function captures serialize as nested
80+
* record-replay closures: invoked once at serialization time (so they must
81+
* be side-effect free) and replayed remotely. A callback that should be
82+
* CALLED BACK — e.g. a live-query observer's onNext — must instead cross as
83+
* a capability. Wrapping it in a stub exports it by reference: the remote
84+
* side invokes it over RPC with plain (already-materialized) arguments.
85+
*
86+
* The `never[]` param constraint is the "any function" bound — it accepts
87+
* every callback signature (a `(n: number) => …` included); widening it to
88+
* `unknown[]` would, by parameter contravariance, *reject* those. The
89+
* returned value is the RPC stub, so it is also `Disposable`.
90+
*/
91+
export const byRef = <F extends (...args: never[]) => unknown>(fn: F): F & Disposable =>
92+
new (RpcStub as unknown as new (target: unknown) => unknown)(fn) as F & Disposable;
6493

6594
// The Proxy base must be an RpcTarget so capnweb classifies the proxy as a capability
6695
// (pass-by-reference) rather than trying to serialize it by value.

0 commit comments

Comments
 (0)