From e72978874de68cd33fbeef56980eeac2480b03dc Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Mon, 24 Aug 2026 11:33:34 -0500 Subject: [PATCH 1/2] docs: expand local RpcPromise examples --- .../src/content/docs/concepts/promises.md | 146 +++++++++++++++--- 1 file changed, 125 insertions(+), 21 deletions(-) diff --git a/packages/docs/src/content/docs/concepts/promises.md b/packages/docs/src/content/docs/concepts/promises.md index 2b2866e..f9db4ea 100644 --- a/packages/docs/src/content/docs/concepts/promises.md +++ b/packages/docs/src/content/docs/concepts/promises.md @@ -88,38 +88,142 @@ Normally an `RpcPromise` comes back from a call. You can also build one yourself ordinary `Promise`, with `new RpcPromise(promise)`. Pipelined calls then queue up in order and are delivered once the inner promise settles. +Wrapping a promise is semantically identical to making a local-loopback RPC that returns it: + ```ts -// You don't have the stub yet, but callers can start using it now. -let session = new RpcPromise(connectWhenReady()); +import { RpcPromise, RpcStub } from 'capnweb'; + +let myPromise = Promise.resolve({ value: 123 }); -// No await, no round trip, and nothing to wait for locally either. -let profile = session.getUserProfile(); +// This... +using direct = new RpcPromise(myPromise); + +// ...means the same as this. +using rpcFunc = new RpcStub(() => myPromise); +using loopback = rpcFunc(); ``` -This is for publishing a capability that does not exist yet. Without it, everything downstream of -`connectWhenReady()` has to be written inside a `.then()` or after an `await`, which is exactly the -sequencing that pipelining exists to avoid. +### Call a target that does not exist yet -It is not a new mechanism. Wrapping a promise is semantically identical to making a local-loopback -call that returns it: +`new RpcPromise(promise)` lets you call a target before the target exists. Here, two calls and a +getter queue before the counter is created. ```ts -// This... -let rpcPromise = new RpcPromise(myPromise); +import { RpcPromise, RpcTarget } from 'capnweb'; -// ...means the same as this. -let rpcFunc = new RpcStub(() => myPromise); -let rpcPromise = rpcFunc(); +class Counter extends RpcTarget { + #value: number; + + constructor(value = 0) { + super(); + this.#value = value; + } + + increment(by = 1) { + return this.#value += by; + } + + get value() { + return this.#value; + } +} + +{ + let { promise, resolve } = Promise.withResolvers(); + using counter = new RpcPromise(promise); + + using first = counter.increment(); + using second = counter.increment(10); + let value = counter.value; + + resolve(new Counter(0)); + console.log('ordered:', await first, await second, await value); +} +``` + +This prints `ordered: 1 11 11`: pending operations run in invocation order. Property promises such +as `counter.value` have no independent disposer. + +### Hide connection setup behind a stub + +This [MessagePort](/transports/message-port/) example exposes a stub while local setup finishes. + +```ts +import { newMessagePortRpcSession } from 'capnweb'; + +class Api extends RpcTarget { + authenticate(token: string) { + if (token !== 's3cret') { + throw new Error('bad token'); + } + return new Counter(100); + } +} + +{ + let channel = new MessageChannel(); + using serverSide = newMessagePortRpcSession(channel.port1, new Api()); + + async function connect() { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, 50); + await promise; + return newMessagePortRpcSession(channel.port2); + } + + using api = new RpcPromise(connect()); + using authed = api.authenticate('s3cret'); + using result = authed.increment(5); + + console.log('connected:', await result); +} ``` -Which is a useful thing to remember, because it tells you what the rules are without having to -learn a second set. The resolution goes over RPC, so: +This prints `connected: 105`. The caller does not await readiness or authentication, so the calls +remain pipelined. + +### Reject when readiness fails + +A rejected source breaks the wrapper and its queued operations. + +```ts +{ + using counter = new RpcPromise( + Promise.reject(new Error('connection failed')), + ); + + counter.onRpcBroken((error: Error) => console.log('broken:', error.message)); + using result = counter.increment(); + + try { + await result; + } catch (error) { + if (error instanceof Error) { + console.log('call:', error.message); + } + } +} +``` -- It has to be [serializable](/concepts/values/). -- `RpcTarget`s and functions in it come out the other side as stubs. -- Ownership of any stubs in the resolution transfers to the `RpcPromise`. Disposing the promise - disposes them. **If you also want to keep one, resolve with a `.dup()`.** -- A rejection propagates to every pipelined call. +The output is `broken: connection failed` followed by `call: connection failed`. Both receive the +same rejection. An unused wrapper observes its backing rejection, but every queued call or `.map()` +result must still be awaited or disposed. + +Cap'n Web processes the resolution with the same serialization, stub conversion, rejection, and +ownership semantics as an RPC return: + +- The backing value must be a real `Promise`. +- The promise may resolve to any [serializable value](/concepts/values/), an `RpcTarget` or function + that Cap'n Web converts to a stub, or an `RpcStub`. +- The wrapper [owns](/concepts/disposal/) every stub in the resolution. If another owner will keep + using a stub, resolve with `stub.dup()`. +- Rejection reaches queued operations, code awaiting the wrapper, and `onRpcBroken`. + +> **Pending calls have no built-in bound** +> +> Pending calls retain their arguments, and there is no queue limit or backpressure setting. A +> readiness or reconnection producer must eventually settle. Reject on terminal failure or an +> application deadline, and rate-limit untrusted callers. ## Disposal From ef53d4507409194d61b2fbbc0c3a5d35b8bc3efc Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Mon, 24 Aug 2026 11:46:47 -0500 Subject: [PATCH 2/2] docs: explain property promise await --- packages/docs/src/content/docs/concepts/promises.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/docs/src/content/docs/concepts/promises.md b/packages/docs/src/content/docs/concepts/promises.md index f9db4ea..7641a06 100644 --- a/packages/docs/src/content/docs/concepts/promises.md +++ b/packages/docs/src/content/docs/concepts/promises.md @@ -134,6 +134,7 @@ class Counter extends RpcTarget { using first = counter.increment(); using second = counter.increment(10); + // Property access queues the getter and returns an RpcPromise. let value = counter.value; resolve(new Counter(0));