diff --git a/packages/docs/README.md b/packages/docs/README.md index 91e05040..18764a90 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -980,21 +980,23 @@ fingerprinted assets a year. identifier, not a credential, and pinning it is what stops a deploy from an operator who can see several accounts landing in the wrong one -- wrangler refuses to guess and fails the deploy instead. -`workers_dev` is off and `preview_urls` is on, and they are not the same switch. `workers_dev` is -production's `capnweb-docs..workers.dev` copy, which we do not want: every URL the build -emits names `https://capnweb.com`, so a second live origin serving those same pages is a duplicate -for crawlers and a link people paste by accident. `preview_urls` is what gives a **Preview** a -hostname. With it off, `wrangler preview` still succeeds and still returns a Preview -- with an empty -`urls` array, which is a deploy nobody can look at. +`workers_dev` and top-level `preview_urls` are both off. Both would put a second origin on +`*.workers.dev`, and every URL the build emits already names `https://capnweb.com`, so a live +workers.dev copy is a duplicate for crawlers and a link people paste by accident. Previews get a +hostname a different way: a second custom-domain route on `pr.capnweb.com` with `enabled: false` +and `previews_enabled: true`. That keeps the bare `pr.capnweb.com` dark and routes +`.pr.capnweb.com` to the matching Preview. Without `previews_enabled`, `wrangler preview` +still succeeds and still returns a Preview -- with an empty `urls` array, which is a deploy nobody +can look at. Routes are applied by `wrangler deploy`, not by `wrangler preview`, so a production +deploy is what turns the switch on. ## Previews Every pull request from a branch in this repo gets its own copy of the site at `https://.pr.capnweb.com`, posted as a comment on the pull request and deleted when it -closes. `.github/workflows/preview-docs.yml` uses -[Worker Previews](https://developers.cloudflare.com/workers/previews/) -- `wrangler preview` rather -than `wrangler deploy`, against the same Worker, so a Preview is a branch of `capnweb-docs` rather -than a second Worker to operate. +closes. `.github/workflows/preview-docs.yml` uses Worker Previews (`wrangler preview`) rather than +`wrangler deploy`, against the same Worker, so a Preview is a branch of `capnweb-docs` rather than a +second Worker to operate. Two details are load-bearing: diff --git a/packages/docs/src/content/docs/concepts/promises.md b/packages/docs/src/content/docs/concepts/promises.md index 2b2866e2..7641a06d 100644 --- a/packages/docs/src/content/docs/concepts/promises.md +++ b/packages/docs/src/content/docs/concepts/promises.md @@ -88,38 +88,143 @@ 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); + // Property access queues the getter and returns an RpcPromise. + 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 diff --git a/packages/docs/wrangler.jsonc b/packages/docs/wrangler.jsonc index 7e216ee6..dc9c1064 100644 --- a/packages/docs/wrangler.jsonc +++ b/packages/docs/wrangler.jsonc @@ -9,15 +9,28 @@ // accounts an operator can see is the right one. "account_id": "b14f364f7066ca93045436e8450ce7e2", - // The site is the apex domain and nothing else. `custom_domain` makes Cloudflare - // own the DNS record and the certificate for it, so the record cannot drift away - // from the Worker by hand. - "routes": [{ "pattern": "capnweb.com", "custom_domain": true }], + // Production is the apex only. Previews are a separate custom domain that is not + // production-enabled: `enabled: false` keeps `pr.capnweb.com` itself dark, while + // `previews_enabled: true` routes `.pr.capnweb.com` to the matching Preview + // (so PR 257 is `https://257.pr.capnweb.com`). Omitting `previews_enabled` defaults + // it off, which is why `wrangler preview` was succeeding with empty URL arrays. + // `custom_domain` makes Cloudflare own the DNS and certificate for each pattern. + "routes": [ + { "pattern": "capnweb.com", "custom_domain": true }, + { + "pattern": "pr.capnweb.com", + "custom_domain": true, + "enabled": false, + "previews_enabled": true + } + ], // No `*.workers.dev` copy. Canonical URLs, the sitemap and the links inside // `/llms.txt` all name `https://capnweb.com` (see `site` in astro.config.ts), and a // second origin serving the same pages with the same canonicals is a duplicate for - // crawlers and a link people paste by accident. + // crawlers and a link people paste by accident. Top-level `preview_urls` is the + // workers.dev version of the same switch and stays off for the same reason -- + // Previews use the `pr.capnweb.com` custom domain above instead. "workers_dev": false, // A static site: no `main`, so there is no Worker script and every request is