Skip to content
Merged
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
22 changes: 12 additions & 10 deletions packages/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<subdomain>.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
`<name>.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://<number>.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:

Expand Down
147 changes: 126 additions & 21 deletions packages/docs/src/content/docs/concepts/promises.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Counter>();
using counter = new RpcPromise(promise);

using first = counter.increment();
using second = counter.increment(10);
// Property access queues the getter and returns an RpcPromise<number>.
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<void>();
setTimeout(resolve, 50);
await promise;
return newMessagePortRpcSession<Api>(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<Counter>(
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

Expand Down
23 changes: 18 additions & 5 deletions packages/docs/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.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
Expand Down
Loading