diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8a1ca8c..a6ae61dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,24 @@ on: branches: [main] jobs: + lint-markdown: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Lint Markdown + run: npm run lint:md + test: runs-on: ubuntu-latest container: diff --git a/.gitignore b/.gitignore index d4e547ed..8afb7e58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ node_modules .wrangler/ examples/worker-react/client/dist/ +# Copied from dist/ by each example's wrangler build step. +examples/batch-pipelining/public/vendor/ +examples/session-recovery/public/vendor/ notes.txt /dist/ packages/*/dist/ +# Generated by packages/docs/scripts/build-playgrounds.mjs. +packages/docs/public/playground/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 00000000..9cc1f4f1 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,82 @@ +{ + // Markdown linting for the prose in this repository: the READMEs, the + // security policy and the documentation site's content collection. + // + // Everything below is markdownlint's default rule set. The handful of + // settings here either match a convention the files already follow or turn + // off a check that is measuring the wrong thing for this repository, and + // each one says which. + "config": { + "default": true, + + // The prose here wraps at 100 columns, not markdownlint's default 80. + // Code samples and tables are exempt because breaking either to fit a + // column limit makes them worse: a wrapped line of TypeScript no longer + // runs, and a wrapped table cell stops being a table. + "MD013": { + "line_length": 100, + "code_blocks": false, + "tables": false + }, + + // Tabs are wrong in prose and correct in the code samples that use them, + // which includes every sample copied out of a file that is itself + // tab-indented. + "MD010": { + "code_blocks": false + }, + + // The docs site takes each page's title from `title` in the frontmatter + // and renders it as the h1, so a document that opens with an h1 of its own + // would render two. markdownlint's own frontmatter-title escape hatch + // covers this, and is the default, but is stated here because the reason + // is not obvious from the outside. + "MD041": { + "front_matter_title": "^\\s*title\\s*[:=]" + }, + + // Headings in this documentation are sentences and are punctuated as + // sentences. A question mark or a full stop at the end of one is a + // deliberate choice, not a stray character; a trailing colon still is + // not, so the rule keeps working on those. + // The tables in these files were already written aligned, and aligned is + // what a table should be in a plain-text diff: the columns are the point. + // `scripts/align-markdown-tables.mjs` does the padding, since markdownlint + // can only report this one, not repair it. + "MD060": { + "style": "aligned" + }, + + "MD026": { + "punctuation": ",;:" + } + }, + + // Prose only. See the ignores for what is deliberately out of scope. + "globs": [ + "**/*.md", + + // Generated by changesets on release, and rewritten wholesale each time. + "!**/CHANGELOG.md", + + // Changeset fragments. Also generated, and intentionally headingless. + "!.changeset/*.md", + + // Agent prompts rather than documentation. They are structured with XML + // tags on purpose, which is exactly what MD033 exists to catch. + "!.opencode/**/*.md", + "!.github/**/*.md", + + "!**/node_modules/**", + "!**/dist/**" + ], + + // `.mdx` is deliberately absent from the globs above. markdownlint has no + // MDX parser, so it reads JSX components as raw HTML and reports every + // `` as inline HTML, and it mistakes the fenced blocks nested inside + // a component for unlabelled top-level ones. The findings would be noise + // and the fixes would be wrong. `astro check` covers those files instead. + "customRules": ["./scripts/markdownlint-no-code-after-heading.mjs"], + + "outputFormatters": [["markdownlint-cli2-formatter-default"]] +} diff --git a/.opencode/agents/bonk.md b/.opencode/agents/bonk.md index a2c39149..74f44c26 100644 --- a/.opencode/agents/bonk.md +++ b/.opencode/agents/bonk.md @@ -10,14 +10,17 @@ You are a senior engineer on capnweb (Cap'n Web), a JavaScript/TypeScript-native -The repository contains the core `capnweb` library (`src/`), the `capnweb-validate` package (`packages/capnweb-validate/`), runtime tests (`__tests__/`), compile-time type tests (`__type-tests__/`), examples (`examples/`), and the wire protocol specification (`protocol.md`). +The repository contains the core `capnweb` library (`src/`), the `capnweb-validate` package (`packages/capnweb-validate/`), runtime tests (`__tests__/`), compile-time type tests (`__type-tests__/`), examples (`examples/`), and the documentation site (`packages/docs/`), which contains the wire +protocol specification (`packages/docs/src/content/docs/reference/protocol.md`). -Key source files: `src/core.ts` (RPC session core), `src/rpc.ts` (stubs, RpcTarget, pipelining), `src/serialize.ts` (wire serialization -- handles untrusted input), with per-runtime entry points `src/index.ts`, `src/index-workers.ts`, and `src/index-bun.ts`. The library runs in browsers, Cloudflare Workers (workerd), Node.js, Bun, and Deno. +Key source files: `src/core.ts` (RPC session core), `src/rpc.ts` (stubs, RpcTarget, pipelining), `src/serialize.ts` (wire serialization, which handles untrusted input), with per-runtime entry points `src/index.ts`, `src/index-workers.ts`, and `src/index-bun.ts`. The library runs in browsers, Cloudflare Workers (workerd), Node.js, Bun, and Deno. - **Triggering comment is the task:** The comment that invoked you (`/bonk` or `@ask-bonk`) is your primary instruction. Read it first, before reading the PR description or any other context. Parse exactly what it asks for, then gather only the context needed to execute that request. Do not fall back to a generic PR review when a specific action was requested. +- **No em dashes.** Never write an em dash (`—`) in anything: code, comments, documentation, commit messages, PR descriptions, or review comments. Do not substitute an en dash (`–`) or a double hyphen either. Repunctuate instead. A semicolon or a full stop for two independent clauses, a comma for an appositive or trailing fragment, a colon where the second half defines the first, parentheses for a genuine aside, and often the best fix is rewording so no punctuation is needed. Vary the choice; the same device eight times in a row is worse than the dash was. This rule is about punctuation, so it says nothing about hyphens that are part of syntax: `git log --oneline`, a bare `--` pathspec separator, `npm run test -- --watch`, and a `--flag` quoted from a tool's output are all command text and stay exactly as the tool spells them. +- **Never put a code block directly under a heading.** A heading followed immediately by a fenced or indented code block reads as a dump. Introduce the sample in one line of prose first, saying what it does or what to look at. Very often the paragraph that explains the block already exists directly below it, and moving it above the block is the entire fix. `npm run lint:md` enforces this for `##` headings in Markdown; apply the same judgment in `.mdx`, where the linter does not reach. - **Scope constraint:** You are invoked on one specific GitHub issue or PR. Target only that issue or PR. - `$ISSUE_NUMBER` and `$PR_NUMBER` are the source of truth. Ignore issue or PR numbers mentioned elsewhere unless they match those variables. - Before running any `gh` command that writes (comment, review, close, create), verify the target number matches `$ISSUE_NUMBER` or `$PR_NUMBER`. @@ -33,9 +36,9 @@ Key source files: `src/core.ts` (RPC session core), `src/rpc.ts` (stubs, RpcTarg Choose one starting mode before acting. Use this precedence order: -1. **Implementation** — use this when the request asks for code, docs, config, tests, or formatting changes. -2. **Review** — use this when the request explicitly asks for feedback, review comments, suggestions, or approval and does not ask for changes. -3. **Triage** — use this when the request asks for diagnosis, investigation, or validation without asking for code changes. +1. **Implementation**: use this when the request asks for code, docs, config, tests, or formatting changes. +2. **Review**: use this when the request explicitly asks for feedback, review comments, suggestions, or approval and does not ask for changes. +3. **Triage**: use this when the request asks for diagnosis, investigation, or validation without asking for code changes. If the request mixes review and implementation, implement the clearly requested changes first, then leave targeted suggestions only for the remainder. @@ -104,7 +107,7 @@ Use triage mode when you are asked to investigate rather than change code. **Security model:** Everything arriving off the wire is untrusted. Deserialization and message handling must never trust peer-supplied values: validate types, guard recursion depth, avoid prototype pollution, and never leak capabilities that were not explicitly granted. -**Wire protocol:** The protocol is specified in `protocol.md`. Serialization changes must remain compatible with existing peers; intentional protocol changes must update `protocol.md` in the same PR. +**Wire protocol:** The protocol is specified in `packages/docs/src/content/docs/reference/protocol.md`. Serialization changes must remain compatible with existing peers; intentional protocol changes must update that document in the same PR. More broadly, `packages/docs/` is the source of truth for all user-facing documentation; behaviour changes should update the relevant page there, not the README. **Cross-runtime support:** Shared code paths must work in browsers, workerd, Node.js, Bun, and Deno. Runtime-specific code belongs in the per-runtime entry points, not in shared modules. diff --git a/README.md b/README.md index a1dc73e5..1c44220b 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,51 @@ + + +![Cap'n Web](assets/capnweb-banner.png) + # Cap'n Web: A JavaScript-native RPC system -Cap'n Web is a spiritual sibling to [Cap'n Proto](https://capnproto.org) (and is created by the same author), but designed to play nice in the web stack. That means: -* Like Cap'n Proto, it is an object-capability protocol. ("Cap'n" is short for "capabilities and".) We'll get into this more below, but it's incredibly powerful. -* Unlike Cap'n Proto, Cap'n Web has no schemas. In fact, it has almost no boilerplate whatsoever. This means it works more like the [JavaScript-native RPC system in Cloudflare Workers](https://blog.cloudflare.com/javascript-native-rpc/). +Cap'n Web is a spiritual sibling to [Cap'n Proto](https://capnproto.org) (and is created by the +same author), but designed to play nice in the web stack. That means: + +* Like Cap'n Proto, it is an **object-capability protocol**. ("Cap'n" is short for "capabilities + and.") It's incredibly powerful. +* Unlike Cap'n Proto, Cap'n Web has **no schemas**. In fact, it has almost no boilerplate + whatsoever. This means it works more like the + [JavaScript-native RPC system in Cloudflare Workers](https://blog.cloudflare.com/javascript-native-rpc/). * That said, it integrates nicely with TypeScript. -* Also unlike Cap'n Proto, Cap'n Web's underlying serialization is human-readable. In fact, it's just JSON, with a little pre-/post-processing. -* It works over HTTP, WebSocket, and postMessage() out-of-the-box, with the ability to extend it to other transports easily. -* It works in all major browsers, Cloudflare Workers, Node.js, Bun, Deno, and other modern JavaScript runtimes. -The whole thing compresses (minify+gzip) to under 10kB with no dependencies. - -Cap'n Web is more expressive than almost every other RPC system, because it implements an object-capability RPC model. That means it: -* Supports bidirectional calling. The client can call the server, and the server can also call the client. -* Supports passing functions by reference: If you pass a function over RPC, the recipient receives a "stub". When they call the stub, they actually make an RPC back to you, invoking the function where it was created. This is how bidirectional calling happens: the client passes a callback to the server, and then the server can call it later. -* Similarly, supports passing objects by reference: If a class extends the special marker type `RpcTarget`, then instances of that class are passed by reference, with method calls calling back to the location where the object was created. -* Supports promise pipelining. When you start an RPC, you get back a promise. Instead of awaiting it, you can immediately use the promise in dependent RPCs, thus performing a chain of calls in a single network round trip. -* Supports capability-based security patterns. +* Also unlike Cap'n Proto, Cap'n Web's underlying serialization is **human-readable**. It's just + JSON, with a little pre- and post-processing. +* It works over HTTP, WebSocket, and `postMessage()` out of the box, and can be extended to other + transports easily. +* It works in all major browsers, Cloudflare Workers, Node.js, Bun, Deno, and other modern + JavaScript runtimes. + +The whole thing compresses (minify + gzip) to **under 16 kB with no dependencies**. + +Cap'n Web is more expressive than almost every other RPC system, because it implements an +object-capability RPC model. That means it supports **bidirectional calling**, **passing functions +and objects by reference**, **promise pipelining** (chaining dependent calls into a single network +round trip), and **capability-based security patterns**, where holding a reference *is* the +permission to use it. ## Installation [Cap'n Web is an npm package.](https://www.npmjs.com/package/capnweb) -``` +```sh npm i capnweb ``` +There is no build step, no schema compiler, and no code generation. + +```js +import { RpcTarget, newWebSocketRpcSession } from "capnweb"; +``` + +To use `using` declarations, your `tsconfig.json` needs `"target": "esnext"` and matching `lib`s. +See [Installation](packages/docs/src/content/docs/start/installation.md). + ## Example A client looks like this: @@ -54,7 +76,7 @@ class MyApiServer extends RpcTarget { // Standard Cloudflare Workers HTTP handler. // -// (Node and other runtimes are supported too; see below.) +// (Node, Deno, Bun and other runtimes are supported too.) export default { fetch(request, env, ctx) { // Parse URL for routing. @@ -71,773 +93,100 @@ export default { } ``` -### More complicated example - -Here's an example that: -* Uses TypeScript -* Sends multiple calls, where the second call depends on the result of the first, in one round trip. - -We declare our interface in a shared types file: - -```ts -interface PublicApi { - // Authenticate the API token, and returned the authenticated API. - authenticate(apiToken: string): AuthedApi; - - // Get a given user's public profile info. (Doesn't require authentication.) - getUserProfile(userId: string): Promise; -} - -interface AuthedApi { - getUserId(): number; - - // Get the user IDs of all the user's friends. - getFriendIds(): number[]; -} - -type UserProfile = { - name: string; - photoUrl: string; -} -``` - -(Note: you don't _have to_ declare your interface separately. The client could just use `import("./server").ApiServer` as the type.) - -On the server, we implement the interface as an RpcTarget: - -```ts -import { newWorkersRpcResponse, RpcTarget } from "capnweb"; - -class ApiServer extends RpcTarget implements PublicApi { - // ... implement PublicApi ... -} - -export default { - async fetch(req, env, ctx) { - // ... same as previous example ... - } -} -``` - -On the client, we can use it in a batch request: - -```ts -import { newHttpBatchRpcSession } from "capnweb"; - -let api = newHttpBatchRpcSession("https://example.com/api"); - -// Call authenticate(), but don't await it. We can use the returned promise -// to make "pipelined" calls without waiting. -let authedApi: RpcPromise = api.authenticate(apiToken); - -// Make a pipelined call to get the user's ID. Again, don't await it. -let userIdPromise: RpcPromise = authedApi.getUserId(); - -// Make another pipelined call to fetch the user's public profile, based on -// the user ID. Notice how we can use `RpcPromise` in the parameters of a -// call anywhere where T is expected. The promise will be replaced with its -// resolution before delivering the call. -let profilePromise = api.getUserProfile(userIdPromise); - -// Make another call to get the user's friends. -let friendsPromise = authedApi.getFriendIds(); - -// That only returns an array of user IDs, but we want all the profile info -// too, so use the magic .map() function to get them, too! Still one round -// trip. -let friendProfilesPromise = friendsPromise.map((id: RpcPromise) => { - return { id, profile: api.getUserProfile(id) }; -}); - -// Now await the promises. The batch is sent at this point. It's important -// to simultaneously await all promises for which you actually want the -// result. If you don't actually await a promise before the batch is sent, -// the system detects this and doesn't actually ask the server to send the -// return value back! -let [profile, friendProfiles] = - await Promise.all([profilePromise, friendProfilesPromise]); - -console.log(`Hello, ${profile.name}!`); - -// Note that at this point, the `api` and `authedApi` stubs no longer work, -// because the batch is done. You must start a new batch. -``` - -Alternatively, for a long-running interactive application, we can set up a persistent WebSocket connection: - -```ts -import { newWebSocketRpcSession } from "capnweb"; - -// We declare `api` with `using` so that it'll be disposed at the end of the -// scope, which closes the connection. `using` is a fairly new JavaScript -// feature, part of the "explicit resource management" spec. Alternatively, -// we could declare `api` with `let` or `const` and make sure to call -// `api[Symbol.dispose]()` to dispose it and close the connection later. -using api = newWebSocketRpcSession("wss://example.com/api"); - -// Usage is exactly the same, except we don't have to await all the promises -// at once. - -// Authenticate and get the user ID in one round trip. Note we use `using` -// again so that `authedApi` will be disposed when we're done with it. In -// this case, it won't close the connection (since it's not the main stub), -// but disposing it does release the `AuthedApi` object on the server side. -using authedApi: RpcPromise = api.authenticate(apiToken); -let userId: number = await authedApi.getUserId(); - -// ... continue calling other methods, now or in the future ... -``` - -## RPC Basics - -### Pass-by-value types - -The following types can be passed over RPC (in arguments or return values), and will be passed "by value", meaning the content is serialized, producing a copy at the receiving end: - -* Primitive values: strings, numbers, booleans, null, undefined -* Plain objects (e.g., from object literals) -* Arrays -* `bigint` -* `Date` -* `ArrayBuffer`, `DataView`, and typed arrays -* `Error` and its well-known subclasses -* `Blob` -* `ReadableStream` and `WritableStream`, with automatic flow control. -* `URL` -* `Headers`, `Request`, and `Response` from the Fetch API. - -The following types are not supported as of this writing, but may be added in the future: -* `Map` and `Set` -* `RegExp` - -The following are intentionally NOT supported: -* Application-defined classes that do not extend `RpcTarget`. -* Cyclic values. Messages are serialized strictly as trees (like JSON). - -### `RpcTarget` - -To export an interface over RPC, you must write a class that `extends RpcTarget`. Extending `RpcTarget` tells the RPC system: instances of this class are _pass-by-reference_. When an instance is passed over RPC, the object should NOT be serialized. Instead, the RPC message will contain a "stub" that points back to the original target object. Invoking this stub calls back over RPC. - -When you send someone an `RpcTarget` reference, they will be able to call any class method over RPC, including getters. They will not, however, be able to access "own" properties. In precise JavaScript terms, they can access prototype properties but not instance properties. This policy is intended to "do the right thing" for typical JavaScript code, where private members are typically stored as instance properties. - -WARNING: If you are using TypeScript, note that declaring a method `private` does not hide it from RPC, because TypeScript annotations are "erased" at runtime, so cannot be enforced. To actually make methods private, you must prefix their names with `#`, which makes them private for JavaScript (not just TypeScript). Names prefixed with `#` are never available over RPC. - -### Functions - -When a plain function is passed over RPC, it will be treated similarly to an `RpcTarget`. The function will be replaced by a stub which, when invoked, calls back over RPC to the original function object. - -If the function has any own properties, those will be available over RPC. Note that this differs from `RpcTarget`: With `RpcTarget`, own properties are not exposed, but with functions, _only_ own properties are exposed. Generally functions don't have properties anyway, making the point moot. - -### `RpcStub` - -When a type `T` which extends `RpcTarget` (or is a function) is sent as part of an RPC message (in the arguments to a call, or in the return value), it is replaced with a stub of type `RpcStub`. - -Stubs are implemented using JavaScript `Proxy`s. A stub appears to have every possible method and property name. The stub does not know at runtime which properties actually exist on the server side. If you use a property that doesn't exist, an error will not be produced until you await the results. - -TypeScript, however, will know which properties exist from type parameter `T`. Thus, if you are using TypeScript, you will get full compile-time type checking, auto-complete, etc. Hooray! - -To read a property from the remote object (as opposed to calling a method), simply `await` the property, like `let foo = await stub.foo;`. - -A stub can be passed across RPC again, including over independent connections. If Alice is connected to Bob and Carol, and Alice receives a stub from Bob, Alice can pass the stub in an RPC to Carol, thus allowing Carol to call Bob. (As of this writing, any such calls will be proxied through Alice, but in the future we may support "three-party handoff" such that Carol can make a direct connection to Bob.) - -You may construct a stub explicitly without an RPC connection, using `new RpcStub(target)`. This is sometimes useful to be able to perform local calls as if they were remote, or to help manage disposal (see below). - -### `RpcPromise` - -Calling an RPC method returns an `RpcPromise` rather than a regular `Promise`. You can use an `RpcPromise` in all the ways a regular `Promise` can be used, that is, you can `await` it, call `.then()`, pass it to `Promise.resolve()`, etc. (This is all possible because `RpcPromise` is a ["thenable"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables).) - -However, you can do more with `RpcPromise`. `RpcPromise` supports _Promise Pipelining_: - -1. An `RpcPromise` also acts as a _stub_ for the eventual result of the promise. That means, you can access properties and invoke methods on it, without awaiting the promise first. - -```ts -// In a single round trip, authenticate the user, and fetch their notifications. -let user = api.authenticate(cookie); -let notifications = await user.getNotifications(); -``` - -2. An `RpcPromise` (or its properties) can be passed as parameters to other RPC calls. - -```ts -// In a single round trip, authenticate the user, and fetch their public profile -// given their ID. -let user = api.authenticate(cookie); -let profile = await api.getUserProfile(user.id); -``` - -Whenever an `RpcPromise` is passed in the parameters to an RPC, or returned as part of the result, the promise will be replaced with its resolution before delivery to the receiving application. So, you can use an `RpcPromise` anywhere where a `T` is required! - -#### Constructing `RpcPromise` from a `Promise` - -You can construct an `RpcPromise` directly from a regular `Promise`, allowing you to perform promise pipelining on a regular local promise. Pipelined calls will wait until the inner promise resolves, then will be delivered, in-order, to the resolution. This is useful when you plan to obtain some stub in the future, but you want to allow code to start queuing calls on it immediately. - -Wrapping a `Promise` in this way is semantically identical to creating a local-loopback RPC and then invoking it. That is: - -```ts -// this... -let rpcPromise = new RpcPromise(myPromise); - -// is semantically the same as this... -let rpcFunc = new RpcStub(() => myPromise); -let rpcPromise = rpcFunc(); -``` - -In other words, this means: -* The result of the promise must be serializable. -* If the promise resolution contains `RpcTarget`s or `Function`s, the `RpcPromise`'s resolution will replace them with stubs. -* Ownership of any stubs in the Promise result is transferred away. If you want to keep your own copies, you need to `dup()` them. -* If the promise rejects, the rejection propagates to all pipelined calls. -* etc. - -### The magic `map()` method - -Every RPC promise has a special method `.map()` which can be used to remotely transform a value, without pulling it back locally. Here's an example: - -```ts -// Get a list of user IDs. -let idsPromise = api.listUserIds(); - -// Look up the username for each one. -let names = await idsPromise.map(id => [id, api.getUserName(id)]); -``` - -This example calls one API method to get a list of user IDs, then, for each user ID in the list, makes another RPC call to look up the user's name, producing a list of id/name pairs. - -**All this happens in a single network round trip!** - -`promise.map(func)` transfers a representation of `func` to the server, where it is executed on the promise's result. Specifically: - -* If the promise resolves to an array, the mapper function executes on each element of the array. The overall `.map()` operation returns a promise for an array of the results. -* If the promise resolves to `null` or `undefined`, the map function is not executed at all. The result is the same value. -* If the promise resolves to any other value, the map function executes once on that value, returning the result. - -Thus, `map()` can be used both for handling arrays, and for handling nullable values. - -There are some restrictions: - -* The callback must have no side effects other than calling RPCs. -* The callback must be synchronous. It cannot await anything. -* The input to the callback is an `RpcPromise`, hence the callback cannot actually operate on it, other than to invoke its RPC methods, or to use it in the params of other RPC methods. -* Any stubs which you use in the callback -- and any parameters you pass to them -- will be sent to the peer. Be warned, a malicious peer can use these stubs for anything, not just calling your callback. Typically, it only makes sense to invoke stubs that came from the same peer originally, since this is what saves round-trips. - -**How the heck does that work?** - -Cap'n Web does NOT send arbitrary code over the wire! - -The trick here is record-replay: On the calling side, Cap'n Web will invoke your callback once, in a special "recording" mode, passing in a special placeholder stub which records what you do with it. During the invocation, any RPCs invoked by the callback (on *any* stub) will not actually be executed, but will be recorded as an action the callback performs. Any stubs you use during the recording are "captured" as well. Once the callback returns, the recording and the capture list can then be sent to the peer, where the recording can then be replayed as needed to process individual results. - -Since all of the not-yet-determined values seen by the callback are represented as `RpcPromise`s, the callback's behavior is deterministic. Any actual computation (arithmetic, branching, etc.) can't possibly use these promises as (meaningful) inputs, so would logically produce the same results for every invocation of the callback. Any such computation will actually end up being performed on the sending side, just once, with the results being imbued into the recording. - -### Streaming with flow control - -You may pass a `ReadableStream` or `WritableStream` over RPC. When doing so, the RPC system automatically creates an equivalent stream at the other end and pumps bytes (or arbitrarily-typed chunks) across. This is done in such a way as to ensure the available bandwidth is fully utilized while minimizing buffer bloat, by observing the bandwidth-delay product and applying backpressure when too much is written. Multiple streams can be sent across the same connection -- they will be multiplexed appropriately, similar to HTTP/2 stream multiplexing. - -### Cloudflare Workers RPC interoperability - -Cap'n Web works on any JavaScript platform. But, on Cloudflare Workers specifically, it's designed to play nicely with the [the built-in RPC system](https://blog.cloudflare.com/javascript-native-rpc/). The two have basically the same semantics, the only difference being that Workers RPC is a built-in API provided by the Workers Runtime, whereas Cap'n Web is implemented in pure JavaScript. - -To facilitate interoperability: -* On Workers, the `RpcTarget` class exported by "capnweb" is just an alias of the built-in one, so you can use them interchangeably. -* RPC stubs and promises originating from one RPC system can be passed over the other. This will automatically set up proxying. -* You can also send Workers Service Bindings and Durable Object stubs over Cap'n Web -- again, this sets up proxying. - -So basically, it "just works". - -With that said, as of this writing, the feature set is not exactly the same between the two. We aim to fix this over time, by adding missing features to both sides until they match. In particular, as of this writing: -* Workers RPC supports some types that Cap'n Web does not yet, like `Map`, streams, etc. -* Workers RPC supports sending values that contain aliases and cycles. This can actually cause problems, so we actually plan to *remove* this feature from Workers RPC (with a compatibility flag, of course). -* Workers RPC does not yet support placing an `RpcPromise` into the parameters of a request, to be replaced by its resolution. -* Workers RPC does not yet support the magic `.map()` method. - -## Resource Management and Disposal - -Unfortunately, garbage collection does not work well when remote resources are involved, for two reasons: - -1. Many JavaScript runtimes only run the garbage collector when they sense "memory pressure" -- if memory is not running low, then they figure there's no need to try to reclaim any. However, the runtime has no way to know if the other side of an RPC connection is suffering memory pressure. - -2. Garbage collectors need to trace the full object graph in order to detect which objects are unreachable, especially when those objects contain cyclic references. However, the garbage collector can only see local objects; it has no ability to trace through the remote graph to discover cycles that may cross RPC connections. - -Both of these problems might be solvable with sufficient work, but the problem seems exceedingly difficult. We make no attempt to solve it in this library. - -Instead, you may choose one of two strategies: - -1. Explicitly dispose stubs when you are done with them. This notifies the remote end that it can release the associated resources. - -2. Use short-lived sessions. When the session ends, all stubs are implicitly disposed. In particular, when using HTTP batch request, there's generally no need to dispose stubs. When using long-lived WebSocket sessions, however, disposal may be important. - -Note: We might extend Cap'n Web to use `FinalizationRegistry` to automatically dispose abandoned stubs in the future, but even if we do, it should not be relied upon, due to problems discussed above. - -### How to dispose - -Stubs integrate with JavaScript's [explicit resource management](https://v8.dev/features/explicit-resource-management), which became widely available in mid-2025 (and has been supported via transpilers and polyfills going back a few years earlier). In short: - -* Disposable objects (including stubs) have a method `[Symbol.dispose]`. You can call this like `stub[Symbol.dispose]()`. -* You can arrange for a stub to be disposed automatically at the end of a function scope by assigning it to a `using` variable, like `using stub = api.getStub();`. The disposer will automatically be invoked when the variable goes out-of-scope. - -### Automatic disposal - -This library implements several rules to help make resource management more manageable. These rules may appear a bit complicated, but are intended to implement the behavior you would naturally expect. - -The basic principle is: **The caller is responsible for disposing all stubs.** That is: -* Stubs passed in the params of a call remain property of the caller, and must be disposed by the caller, not by the callee. -* Stubs returned in the result of a call have their ownership transferred from the callee to the caller, and must be disposed by the caller. - -In practice, though, the callee and caller do not actually share the same stubs. When stubs are passed over RPC, they are _duplicated_, and the target object is only disposed when all duplicates of the stub are disposed. Thus, to achieve the rule that only the caller needs to dispose stubs, the RPC system implicitly disposes the callee's duplicates of all stubs when the call completes. That is: -* Any stubs the callee receives in the parameters are implicitly disposed when the call completes. -* Any stubs returned in the results are implicitly disposed some time after the call completes. (Specifically, the RPC system will dispose them once it knows there will be no more pipelined calls.) - -Some additional wonky details: -* Disposing an `RpcPromise` will automatically dispose the future result. (It may also cause the promise to be canceled and rejected, though this is not guaranteed.) If you don't intend to await an RPC promise, you should dispose it. -* Passing an `RpcPromise` in params or the return value of a call has the same ownership / disposal rules as passing an `RpcStub`. -* When you access a property of an `RpcStub` or `RpcPromise`, the result is itself an `RpcPromise`. However, this `RpcPromise` does not have its own disposer; you must dispose the stub or promise it came from. You can pass such properties in params or return values, but doing so will never lead to anything being implicitly disposed. -* The caller of an RPC may dispose any stubs used in the parameters immediately after initiating the RPC, without waiting for the RPC to complete. All stubs are duplicated at the moment of the call, so the callee is not responsible for keeping them alive. -* If the final result of an RPC returned to the caller is an object, it will always have a disposer. Disposing it will dispose all stubs found in that response. It's a good idea to always dispose return values even if you don't expect they contain any stubs, just in case the server changes the API in the future to add stubs to the result. - -WARNING: The ownership behavior of calls differs from the original behavior in the native RPC implementation built into the Cloudflare Workers Runtime. In the original Workers behavior, the callee loses ownership of stubs passed in a call's parameters. We plan to change the Workers Runtime to match Cap'n Web's behavior, as the original behavior has proven more problematic than helpful. - -### Duplicating stubs - -Sometimes you need to pass a stub somewhere where it will be disposed, but also keep the stub for later use. To prevent the disposer from disabling your copy of the stub, you can duplicate the stub by calling `stub.dup()`. The stub's target will only be disposed when all duplicates of the stub have been disposed. - -Hint: You can call `.dup()` on a property of a stub or promise, in order to create a stub backed by that property. This is particularly useful when you know in advance that the property is going to resolve to a stub: calling `.dup()` on it gives you a stub you can start using immediately, that otherwise behaves exactly the same as the eventual stub would if you awaited it. - -#### Holding on to a callback past the call that delivered it - -A common bidirectional-calling pattern is for the client to pass a callback to the server, which the server then invokes later (for example from a timer, an event handler, or a subsequent RPC). Because the callback parameter is a stub, and stubs in params are implicitly disposed when the call returns, the server must duplicate the stub with `.dup()` if it wants to invoke the callback after the call completes: - -```ts -import { type RpcStub, RpcTarget } from 'capnweb'; - -// A callback the client passes in: a stub wrapping a function. -type Listener = RpcStub<(msg: string) => void>; - -class Api extends RpcTarget { - #listener?: Listener; - - // Stubs passed as params are disposed when the call returns, so `.dup()` - // to keep a reference that outlives registerListener(). - registerListener(listener: Listener) { - this.#listener?.[Symbol.dispose](); // release any previous listener - this.#listener = listener.dup(); - } - - // A *later* call can invoke the retained callback -- still valid thanks to .dup(). - notify(msg: string) { - this.#listener?.(msg); - } - - // Dispose our duplicate when done so the client-side stub can be freed. - [Symbol.dispose]() { - this.#listener?.[Symbol.dispose](); - } -} -``` - -The same rule applies in the other direction: if the server returns a stub to the client and the client wants to keep using it after disposing the result, the client should `.dup()` the stub before the result is disposed. - -### Listening for disposal - -An `RpcTarget` may declare a `Symbol.dispose` method. If it does, the RPC system will automatically invoke it when a stub pointing at it (and all its duplicates) has been disposed. - -Note that if you pass the same `RpcTarget` instance to RPC multiple times -- thus creating multiple stubs -- you will eventually get a separate dispose call for each one. To avoid this, you could use `new RpcStub(target)` to create a single stub upfront, and then pass that stub across multiple RPCs. In this case, you will receive only one call to the target's disposer when all stubs are disposed. - -### Listening for disconnect - -You can monitor any stub for "brokenness" with its `onRpcBroken()` method: - -```ts -stub.onRpcBroken((error: any) => { - console.error(error); -}); -``` - -If anything happens to the stub that would cause all further method calls and property accesses to throw exceptions, then the callback will be called. In particular, this happens if: -* The stub's underlying connection is lost. -* The stub is a promise, and the promise rejects. - -## Security Considerations - -* The WebSocket API in browsers always permits cross-site connections, and does not permit setting headers. Because of this, you generally cannot use cookies nor other headers for authentication. Instead, we highly recommend the pattern shown in the second example above, in which authentication happens in-band via an RPC method that returns the authenticated API. - -* Cap'n Web's pipelining can make it easy for a malicious client to enqueue a large amount of work to occur on a server. To mitigate this, we recommend implementing rate limits on expensive operations. If using Cloudflare Workers, you may also consider configuring [per-request CPU limits](https://developers.cloudflare.com/workers/wrangler/configuration/#limits) to be lower than the default 30s. Note that in stateless Workers (i.e. not Durable Objects), the system considers an entire WebSocket session to be one "request" for CPU limits purposes. - -* Cap'n Web applies receiver-side resource limits before expensive message processing, including a maximum incoming message size before `JSON.parse`. If your app is exposed to untrusted peers, also configure native transport or socket payload limits where available, such as `ws`'s `maxPayload`, Bun's `maxPayloadLength`, or the runtime's built-in WebSocket cap. Cap'n Web's own check runs after `RpcTransport.receive()` has returned a complete message string, so transport-level limits are still the first line of defense against buffering very large frames. - -* Cap'n Web currently does not provide any runtime type checking. When using TypeScript, keep in mind that types are checked only at compile time. A malicious client can send types you did not expect, and this could cause you application to behave in unexpected ways. For example, MongoDB uses special property names to express queries; placing attacker-provided values directly into queries can result in query injection vulnerabilities (similar to SQL injection). Of course, JSON has always had the same problem, and there exists tooling to solve it. You might consider using a runtime type-checking framework like Zod to check your inputs. In the future, we hope to explore auto-generating type-checking code based on TypeScript types. - -## Setting up a session - -### HTTP batch client - -In HTTP batch mode, a batch of RPC calls can be made in a single HTTP request, with the server returning a batch of results. - -**Cap'n Web has a magic trick:** The results of one call in the batch can be used in the parameters to later calls in the same batch, even though the entire batch is sent at once. If you simply take the Promise returned by one call and use it in the parameters to another call, the Promise will be replaced with its resolution before delivering it to the callee. **This is called Promise Pipelining.** - -```ts -import { RpcTarget, RpcStub, newHttpBatchRpcSession } from "capnweb"; - -// Declare our RPC interface. -interface MyApi extends RpcTarget { - // Returns information about the logged-in user. - getUserInfo(): UserInfo; - - // Returns a friendly greeting for a user with the given name. - greet(name: string): string; -}; - -// Start a batch request using this interface. -using stub: RpcStub = newHttpBatchRpcSession("https://example.com/api"); - -// The batch will be sent on the next I/O tick (i.e. using setTimeout(sendBatch, 0)). You have -// until then to add calls to the batch. -// -// We can make any number of calls as part of the batch, as long as we store the promises without -// awaiting them yet. -let promise1 = stub.greet("Alice"); -let promise2 = stub.greet("Bob"); - -// Note that a promise returned by one call can be used in the input to another call. The first -// call's result will be substituted into the second call's parameters on the server side. If the -// first call returns an object, you can even specify a property of the object to pass to the -// second call, as shown here. -let userInfoPromise = stub.getUserInfo(); -let promise3 = stub.greet(userInfoPromise.name); - -// Use Promise.all() to wait on all the promises at once. NOTE: You don't necessarily have to -// use Promise.all(), but you must make sure you have explicitly awaited (or called `.then()` on) -// all promises before the batch is sent. The system will only ask the server to send back -// results for the promises you explicitly await. In this example, we have not awaited -// `userInfoPromise` -- we only used it as a parameter to another call -- so the result will -// not actually be returned. -let [greeting1, greeting2, greeting3] = await Promise.all([promise1, promise2, promise3]); - -// Now we can do stuff with the results. -console.log(greeting1); -console.log(greeting2); -console.log(greeting3); -``` - -### WebSocket client - -In WebSocket mode, the client forms a long-lived connection to the server, allowing us to make many calls over a long period of time. In this mode, the server can even make asynchronous calls back to the client. - -```ts -import { RpcTarget, RpcStub, newWebSocketRpcSession } from "capnweb"; - -// Declare our RPC interface. -interface MyApi extends RpcTarget { - // Returns information about the logged-in user. - getUserInfo(): UserInfo; - - // Returns a friendly greeting for a user with the given name. - greet(name: string): string; -}; - -// Start a WebSocket session. -// -// (Note that disposing the root stub will close the connection. Here we declare it with `using` so -// that the connection will be closed when the stub goes out of scope, but you can also call -// `stub[Symbol.dispose]()` directly.) -using stub: RpcStub = newWebSocketRpcSession("wss://example.com/api"); - -// With a WebSocket, we can freely make calls over time. -console.log(await stub.greet("Alice")); -console.log(await stub.greet("Bob")); - -// But we can still use Promise Pipelining to reduce round trips. Note that we should use `using` -// with promises we don't intend to await so that the system knows when we don't need them anymore. -{ - using userInfoPromise = stub.getUserInfo(); - console.log(await stub.greet(userInfoPromise.name)); -} - -// Note that since we never awaited `userInfoPromise`, the server won't even bother sending the -// response back over the wire. -``` - -### HTTP server on Cloudflare Workers - -The helper function `newWorkersRpcResponse()` makes it easy to implement an HTTP server that accepts both the HTTP batch and WebSocket APIs at once: - -```ts -import { RpcTarget, newWorkersRpcResponse } from "capnweb"; - -// Define our server implementation. -class MyApiImpl extends RpcTarget implements MyApi { - constructor(private userInfo: UserInfo) {} - - getUserInfo(): UserInfo { - return this.userInfo; - } - - greet(name: string): string { - return `Hello, ${name}!`; - } -}; - -// Define our Worker HTTP handler. -export default { - fetch(request: Request, env, ctx) { - let userInfo: UserInfo = authenticateFromCookie(request); - let url = new URL(request.url); - - // Serve API at `/api`. - if (url.pathname === "/api") { - return newWorkersRpcResponse(request, new MyApiImpl(userInfo)); - } - - return new Response("Not found", {status: 404}); - } -} -``` - -#### Compatibility with Workers' built-in RPC - -Cloudflare Workers has long featured [a built-in RPC system with semantics similar to Cap'n Web](https://developers.cloudflare.com/workers/runtime-apis/rpc/). - -Cap'n Web is designed to be compatible with Workers RPC, meaning you can pass Cap'n Web RPC stubs over Workers RPC and vice versa. The system will automatically wrap one stub type in the other and arrange to proxy calls. - -For best compatibility, make sure to set your [Workers compatibilty date](https://developers.cloudflare.com/workers/configuration/compatibility-dates/) to at least `2026-01-20`, or enable the [compatibility flag](https://developers.cloudflare.com/workers/configuration/compatibility-flags/) `rpc_params_dup_stubs`. (As of this writing, `2026-01-20` is in the future, so you will need to use the flag for now.) - -### HTTP server on Node.js - -A server on Node.js is a bit more involved, due to the awkward handling of WebSockets in Node's HTTP library. - -```ts -import http from "node:http"; -import { WebSocketServer } from 'ws'; // npm package -import { RpcTarget, newWebSocketRpcSession, nodeHttpBatchRpcResponse } from "capnweb"; - -class MyApiImpl extends RpcTarget implements MyApi { - // ... define API, same as above ... -} - -// Run standard HTTP server on a port. -httpServer = http.createServer(async (request, response) => { - if (request.headers.upgrade?.toLowerCase() === 'websocket') { - // Ignore, should be handled by WebSocketServer instead. - return; - } - - // Accept Cap'n Web requests at `/api`. - if (request.url === "/api") { - try { - await nodeHttpBatchRpcResponse(request, response, new MyApiImpl(), { - // If you are accepting WebSockets, then you might as well accept cross-origin HTTP, since - // WebSockets always permit cross-origin request anyway. But, see security considerations - // for further discussion. - headers: { "Access-Control-Allow-Origin": "*" } - }); - } catch (err) { - response.writeHead(500, { 'content-type': 'text/plain' }); - response.end(String(err?.stack || err)); - } - return; - } - - response.writeHead(404, { 'content-type': 'text/plain' }); - response.end("Not Found"); -}); - -// Arrange to handle WebSockets as well, using the `ws` package. You can skip this if you only -// want to handle HTTP batch requests. -wsServer = new WebSocketServer({ server: httpServer }) -wsServer.on('connection', (ws) => { - // The `as any` here is because the `ws` module seems to have its own `WebSocket` type - // declaration that's incompatible with the standard one. In practice, though, they are - // compatible enough for Cap'n Web! - newWebSocketRpcSession(ws as any, new MyApiImpl()); -}) - -// Accept requests on port 8080. -httpServer.listen(8080); -``` - -### HTTP server on Deno -```ts -import { - newHttpBatchRpcResponse, - newWebSocketRpcSession, - RpcTarget, -} from "npm:capnweb"; - -// This is the server implementation. -class MyApiImpl extends RpcTarget implements MyApi { - // ... define API, same as above ... -} - -Deno.serve(async (req) => { - const url = new URL(req.url); - if (url.pathname === "/api") { - if (req.headers.get("upgrade") === "websocket") { - const { socket, response } = Deno.upgradeWebSocket(req); - socket.addEventListener("open", () => { - newWebSocketRpcSession(socket, new MyApiImpl()); - }); - return response; - } else { - const response = await newHttpBatchRpcResponse(req, new MyApiImpl()); - // If you are accepting WebSockets, then you might as well accept cross-origin HTTP, since - // WebSockets always permit cross-origin request anyway. But, see security considerations - // for further discussion. - response.headers.set("Access-Control-Allow-Origin", "*"); - return response; - } - } - - return new Response("Not Found", { status: 404 }); -}); -``` - -### HTTP server on Bun - -Bun's server-side WebSocket API uses [callback-based handlers](https://bun.sh/docs/runtime/http/websockets) instead of the standard `addEventListener` interface. Cap'n Web provides `newBunWebSocketRpcHandler()` which returns a handler object you can pass directly to `Bun.serve()`. +And here is the part that makes it interesting. Three dependent calls, one round trip: ```ts -import { RpcTarget, newBunWebSocketRpcHandler, newHttpBatchRpcResponse } from "capnweb"; - -class MyApiImpl extends RpcTarget implements MyApi { - // ... define API, same as above ... -} - -// Create a WebSocket handler that manages RPC sessions automatically. -// The callback is invoked once per connection to create a fresh API instance. -let rpcHandler = newBunWebSocketRpcHandler(() => new MyApiImpl()); +using api = newHttpBatchRpcSession("https://example.com/api"); -Bun.serve({ - async fetch(req, server) { - let url = new URL(req.url); - if (url.pathname === "/api") { - // Upgrade WebSocket requests. - if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { - if (server.upgrade(req)) return; - return new Response("WebSocket upgrade failed", { status: 500 }); - } - - // Handle HTTP batch requests. - let response = await newHttpBatchRpcResponse(req, new MyApiImpl()); - response.headers.set("Access-Control-Allow-Origin", "*"); - return response; - } +// No awaits, so no round trips yet. +using authed = api.authenticate(apiToken); +let friendIds = authed.getFriendIds(); - return new Response("Not Found", { status: 404 }); - }, - - // Pass the handler directly — no manual wiring needed. - websocket: rpcHandler, -}); +// One await. One round trip. Everything above travelled together. +let friends = await friendIds.map(id => api.getUserProfile(id)); ``` -### HTTP server on other runtimes +## Documentation -Every runtime does HTTP handling and WebSockets a little differently, although most modern runtimes use the standard `Request` and `Response` types from the Fetch API, as well as the standard `WebSocket` API. You should be able to use these two functions (exported by `capnweb`) to implement both HTTP batch and WebSocket handling on all platforms: +**The [documentation site](packages/docs/) is the source of truth.** It is an Astro + Starlight site +under [`packages/docs/`](packages/docs/), and every page is readable as Markdown directly on GitHub. -```ts -// Run a single HTTP batch. -function newHttpBatchRpcResponse( - request: Request, yourApi: RpcTarget, options?: RpcSessionOptions) - : Promise; +Start here: -// Run a WebSocket session. -// -// This is actually the same function as is used on the client side! But on the -// server, you should pass in a `WebSocket` object representing the already-open -// connection, instead of a URL string, and you pass your API implementation as -// the second parameter. -// -// You can dispose the returned `Disposable` to close the connection, or just -// let it run until the client closes it. -function newWebSocketRpcSession( - webSocket: WebSocket, yourApi: RpcTarget, options?: RpcSessionOptions) - : Disposable; -``` - -### HTTP server using Hono +| Page | What it covers | +| -------------------------------------------------------------------------- | ---------------------------------------------------- | +| [Introduction](packages/docs/src/content/docs/start/introduction.md) | What Cap'n Web is and why object capabilities matter | +| [Quickstart](packages/docs/src/content/docs/start/quickstart.md) | A working client and server | +| [Pipelining tour](packages/docs/src/content/docs/start/pipelining-tour.md) | The part that makes it fast | +| [How it compares](packages/docs/src/content/docs/guides/comparisons.md) | Against tRPC, JSON-RPC, GraphQL and Cap'n Proto | -If your app is built on [Hono](https://hono.dev/) (on any runtime it supports), check out [`@hono/capnweb`](https://github.com/honojs/middleware/tree/main/packages/capnweb). - -### MessagePort - -Cap'n Web can also talk over MessagePorts. This can be used in a browser to talk to Web Workers, iframes, etc. - -```ts -import { RpcTarget, RpcStub, newMessagePortRpcSession } from "capnweb"; +Core concepts: +[What can be passed](packages/docs/src/content/docs/concepts/values.md) · +[RpcTarget](packages/docs/src/content/docs/concepts/rpc-target.md) · +[RpcStub](packages/docs/src/content/docs/concepts/stubs.md) · +[RpcPromise & pipelining](packages/docs/src/content/docs/concepts/promises.md) · +[The magic `map()`](packages/docs/src/content/docs/concepts/map.md) · +[Streaming](packages/docs/src/content/docs/concepts/streaming.md) · +[Disposal](packages/docs/src/content/docs/concepts/disposal.md) -// Declare our RPC interface. -class Greeter extends RpcTarget { - greet(name: string): string { - return `Hello, ${name}!`; - } -}; +Transports: +[Overview](packages/docs/src/content/docs/transports/index.md) · +[HTTP batch](packages/docs/src/content/docs/transports/http-batch.md) · +[WebSocket](packages/docs/src/content/docs/transports/websocket.md) · +[MessagePort](packages/docs/src/content/docs/transports/message-port.md) · +[Custom](packages/docs/src/content/docs/transports/custom.md) -// Create a MessageChannel (pair of MessagePorts). -let channel = new MessageChannel() +Server runtimes: +[Cloudflare Workers](packages/docs/src/content/docs/servers/workers.md) · +[Node.js](packages/docs/src/content/docs/servers/node.md) · +[Deno](packages/docs/src/content/docs/servers/deno.md) · +[Bun](packages/docs/src/content/docs/servers/bun.md) · +[Hono](packages/docs/src/content/docs/servers/hono.md) · +[Other](packages/docs/src/content/docs/servers/other.md) -// Initialize the server on port1. -newMessagePortRpcSession(channel.port1, new Greeter()); +Guides and reference: +[Security considerations](packages/docs/src/content/docs/guides/security.md) · +[Sessions & reconnection](packages/docs/src/content/docs/guides/sessions.md) · +[Runtime validation](packages/docs/src/content/docs/guides/validation.md) · +[Workers RPC interop](packages/docs/src/content/docs/guides/workers-rpc.md) · +[Wire protocol](packages/docs/src/content/docs/reference/protocol.md) · +[API cheat sheet](packages/docs/src/content/docs/reference/api.md) -// Initialize the client on port2. -using stub: RpcStub = newMessagePortRpcSession(channel.port2); +To run the site locally, with both examples embedded as live in-browser playgrounds: -// Now you can make calls. -console.log(await stub.greet("Alice")); -console.log(await stub.greet("Bob")); +```sh +cd packages/docs && npm install && npm run dev ``` -Of course, in a real-world scenario, you'd probably want to send one of the two ports to another context. A `MessagePort` can itself be transferred to another context using `postMessage()`, e.g. `window.postMessage()`, `worker.postMessage()`, or even `port.postMessage()` on some other existing `MessagePort`. - -Note that you should not use a `Window` object itself as a port for RPC -- you should always create a new `MessageChannel` and send one of the ports over. This is because anyone can `postMessage()` to a window, and the RPC system does not authenticate that messages came from the expected sender. You need to verify that you received the port itself from the expected sender first, then let the RPC system take over. +## Examples -### Custom transports +Runnable examples live in [`examples/`](examples/): -You can implement a custom RPC transport across any bidirectional stream. To do so, implement the interface `RpcTransport`, which is defined as follows: - -```ts -// Interface for an RPC transport, which is a simple bidirectional message stream. -export interface RpcTransport { - // Sends a message to the other end. - send(message: string): Promise; - - // Receives a message sent by the other end. - // - // If and when the transport becomes disconnected, this will reject. The thrown error will be - // propagated to all outstanding calls and future calls on any stubs associated with the session. - // If there are no outstanding calls (and none are made in the future), then the error does not - // propagate anywhere -- this is considered a "clean" shutdown. - receive(): Promise; - - // Indicates that the RPC system has suffered an error that prevents the session from continuing. - // The transport should ideally try to send any queued messages if it can, and then close the - // connection. (It's not strictly necessary to deliver queued messages, but the last message sent - // before abort() is called is often an "abort" message, which communicates the error to the - // peer, so if that is dropped, the peer may have less information about what happened.) - abort?(reason: any): void; -} -``` +* [`batch-pipelining`](examples/batch-pipelining/): three dependent calls in one HTTP round trip. +* [`worker-react`](examples/worker-react/): a React app against a Cap'n Web Worker, with runtime + validation at the RPC boundary. -You can then set up a connection over it: +## Related packages -```ts -// Create the transport. -let transport: RpcTransport = new MyTransport(); +* [`capnweb-validate`](packages/capnweb-validate/): generates runtime validators from your + TypeScript types at build time, since TypeScript types are erased and a malicious peer can send + anything. -// Create the main interface we will expose to the other end. -let localMain: RpcTarget = new MyMainInterface(); +## Security -// Start the session. -let session = new RpcSession(transport, localMain); +Cap'n Web gives you strong authorization tools, but a few things are your responsibility: +authenticating in-band rather than with cookies, rate-limiting because pipelining is cheap for +attackers, setting transport payload limits, and validating types at runtime. Read +[Security considerations](packages/docs/src/content/docs/guides/security.md) before exposing a +service to untrusted peers. -// Get a stub for the other end's main interface. -let stub: RemoteMainInterface = session.getRemoteMain(); +To report a vulnerability, see [SECURITY.md](SECURITY.md). -// Now we can call methods on the stub. -``` +## Contributing -Note that sessions are entirely symmetric: neither side is defined as the "client" nor the "server". Each side can optionally expose a "main interface" to the other. In typical scenarios with a logical client and server, the server exposes a main interface but the client does not. +Bug reports and pull requests are welcome. Note that `packages/docs/` is the source of truth for +user-facing documentation; behaviour changes should update the relevant page there. -By default, `send()` accepts a string, and `receive()` returns a string, with Cap'n Web handling the encoding all the way to and from strings. However, transports that want more control over the serialization can declare the property `encodingLevel` to control how much encoding Cap'n Web does before passing off the message: +## License -* `"string"` (default): Full JSON round-trip. The transport deals in strings only. Cap'n Web handles all encoding/decoding. This is what HTTP batch and WebSocket transports use. -* `"jsonCompatible"`: The transport works with JavaScript value trees, but they must be JSON-compatible. Cap'n Web still encodes special types, but skips the final `JSON.stringify`. The transport is responsible for serialization (e.g. to CBOR, MessagePack). -* `"jsonCompatibleWithBytes"`: Like `"jsonCompatible"` except that byte arrays are left as `Uint8Array` instead of base64-encoded, avoiding the ~33% base64 size overhead and the encode/decode CPU cost. Handy for use with serializations like CBOR or MessagePack that support this efficiently. -* `"structuredClonable"`: Messages are structured-clonable values. Cap'n Web passes through native structured-clone types where possible, while still handling RPC-specific values such as stubs. This is useful when the transport is a `MessagePort` or similar. +[MIT](LICENSE.txt) diff --git a/SECURITY.md b/SECURITY.md index 89c981cf..3f3f4378 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,17 @@ # Security Policy -https://www.cloudflare.com/disclosure + ## Reporting a Vulnerability -* https://hackerone.com/cloudflare - * All Cloudflare products are in scope for reporting. If you submit a valid report on bounty-eligible assets through our disclosure program, we will transfer your report to our private bug bounty program and invite you as a participant. +* + * All Cloudflare products are in scope for reporting. If you submit a valid report on + bounty-eligible assets through our disclosure program, we will transfer your report to our + private bug bounty program and invite you as a participant. * `mailto:security@cloudflare.com` - * If you'd like to encrypt your message, please do so within the body of the message. Our email system doesn't handle PGP-MIME well. - * https://www.cloudflare.com/gpg/security-at-cloudflare-pubkey-06A67236.txt - -All abuse reports should be submitted to our Trust & Safety team through our dedicated page: https://www.cloudflare.com/abuse/ + * If you'd like to encrypt your message, please do so within the body of the message. Our email + system doesn't handle PGP-MIME well. + * +All abuse reports should be submitted to our Trust & Safety team through our dedicated page: + diff --git a/assets/capnweb-banner.png b/assets/capnweb-banner.png new file mode 100644 index 00000000..c0bd0986 Binary files /dev/null and b/assets/capnweb-banner.png differ diff --git a/assets/capnweb-banner.svg b/assets/capnweb-banner.svg new file mode 100644 index 00000000..3c22beaa --- /dev/null +++ b/assets/capnweb-banner.svg @@ -0,0 +1,57 @@ + +Cap'n Web + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/README.md b/examples/README.md index b5f1a1fb..53b2b220 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,9 +1,72 @@ -Examples +# Examples -- batch-pipelining: Node server + client. Shows batching and pipelining to execute a dependent sequence of RPC calls in a single HTTP round trip, with timing vs sequential. -- worker-react: Cloudflare Worker backend + React frontend. Shows the same pattern from a browser app, served by the Worker. +The first two make the same point from opposite ends of the stack: a chain of dependent RPC calls +costs one HTTP round trip when pipelined, and three when it isn't. The third is about the other +half of the story: what a session is, and what breaks when it ends. -Notes +| Example | What it is | +| ---------------------------------------- | ------------------------------------------------------------------- | +| [`batch-pipelining`](./batch-pipelining) | Worker + zero-build browser page, plus a Node server and CLI client | +| [`worker-react`](./worker-react) | Worker + React/Vite app, with runtime validation | +| [`session-recovery`](./session-recovery) | Worker + WebSocket page: broken stubs, server push, gapless resume | -- Examples import from `../../dist/index.js`. Run `npm run build` at the repo root before running an example. +All three also run as playgrounds in the docs, under **Examples**; see +[In the docs](#in-the-docs). + +## Running them locally + +From the repo root: + +```sh +npm run setup # first time only: installs the docs and the React + # client, which sit outside the npm workspace +npm run build # the examples resolve `capnweb` to dist/ + +# then any of these, one per shell: each is a long-running server +npx wrangler dev --cwd examples/batch-pipelining --ip 127.0.0.1 --port 8788 +npx wrangler dev --cwd examples/worker-react --ip 127.0.0.1 --port 8787 +npx wrangler dev --cwd examples/session-recovery --ip 127.0.0.1 --port 8789 +``` + +This is the version worth reaching for when you are changing an example: it is a real Worker +answering real requests over a real network, which the in-page playground deliberately is not. + +> **Editing the React client while its `wrangler dev` is running?** Restart it. Wrangler builds its +> asset manifest from `worker-react/client/dist` at startup, so a fresh `vite build` mid-session +> leaves it serving a stale manifest and the new bundle 404s to a blank page. For a hot-reloading +> workflow, run the Vite dev server alongside it instead. See +> [`worker-react/README.md`](./worker-react/README.md). The `batch-pipelining` page has no build +> step, so a refresh is enough. + +## In the docs + +Each example has a page under **Examples** in the docs, showing its source next to the demo running +live. There is no server behind those: `packages/docs/scripts/build-playgrounds.mjs` bundles the +example's own Worker into the page next to its own client, then connects the two in-page. For the +HTTP examples it routes the client's `fetch` of the RPC path straight into the Worker's `fetch` +handler; for `session-recovery` it replaces the `WebSocket` constructor for that one path with a +linked pair of sockets and hands the far end to a real `newWebSocketRpcSession`. The protocol, the +batching, the round-trip counts and the disconnects are all genuine; only the network hop is +missing, which is what lets the docs deploy as static assets. + +Two consequences worth knowing when editing an example: + +- The docs read these files at build time and show them whole. Move or rename one that is listed in + `packages/docs/src/examples.ts` and the docs build fails until it is updated. Because they are + shown whole, a file worth putting in a tab is worth keeping short and free of unrelated wiring, + which is why each example splits its RPC code out from its DOM code. +- The playground bundles `dist/`, so a library change needs `npm run build` at the repo root before + it shows up in the docs. + +## Deploying + +These examples are not deployed anywhere. They exist to be read and to be run locally. Each still +has a working `wrangler.jsonc`, so `wrangler deploy --cwd examples/` will put one on your own +`workers.dev` subdomain if you want it. + +## Notes + +- Examples import `capnweb` as a bare specifier. Under Node that resolves through the repo's own + workspace self-link; under Workers it is mapped to the workerd build by the `alias` block in each + `wrangler.jsonc`. Either way, run `npm run build` at the repo root first: both resolve to `dist/`. - Requires Node 18+ (built-in `fetch`, `Request`, `Response`). diff --git a/examples/batch-pipelining/README.md b/examples/batch-pipelining/README.md index cddcab19..f76c178c 100644 --- a/examples/batch-pipelining/README.md +++ b/examples/batch-pipelining/README.md @@ -1,29 +1,79 @@ -Batch + Pipelining (Single Round Trip) +# Batch + pipelining (single round trip) -This example shows how to issue a sequence of dependent RPC calls that all execute on the server in a single HTTP round trip using batching and promise pipelining. +A sequence of dependent RPC calls that all execute on the server in **one** HTTP round trip, using +batching and promise pipelining, measured against the same calls made the ordinary way. -What it does +Runs as a playground in the docs under **Examples**, and locally as a real Worker. + +## What it does - Authenticates a user. -- Uses the returned user ID (without awaiting) to fetch the profile and notifications. -- Awaits all results together. Even though there are multiple calls and dependencies, they travel in one request and one response. +- Uses the returned user ID (**without awaiting it**) to fetch the profile and notifications. +- Awaits all three results together. + +Because the second and third calls are built from an unresolved promise, they are sent as pipelined +references rather than values. All three travel in one request and one response. The sequential +version does exactly the same work in three round trips. + +## Layout + +| File | Role | +| ------------------- | ------------------------------------------------------------------------- | +| `api.mjs` | The `Api` class and its data. Shared by both servers so they can't drift. | +| `worker.js` | Cloudflare Worker serving `/rpc` and the browser demo. | +| `public/index.html` | The browser demo's markup and styling. No build step. | +| `public/demo.js` | The two strategies being compared. No DOM in it. | +| `public/main.js` | The page wiring: slider, buttons, results. | +| `server-node.mjs` | The same API on a plain Node HTTP server. | +| `client.mjs` | Terminal client running the same comparison. | + +## Run it + +Build the library at the repo root first (every entry point resolves `capnweb` to `dist/`): + +```sh +npm run build +``` + +### In a browser + +```sh +npx wrangler dev --cwd examples/batch-pipelining --ip 127.0.0.1 --port 8788 # from the repo root +``` + +Then open `http://127.0.0.1:8788`. The page has a latency slider; the gap between the two columns +widens as latency grows, because only the number of round trips differs. + +### In a terminal + +```sh +node examples/batch-pipelining/server-node.mjs # terminal 1 +node examples/batch-pipelining/client.mjs # terminal 2 +``` -Run locally (Node 18+) +The client works against any of the servers. Point it wherever one is running: -1) Build the library at repo root: - npm run build +```sh +RPC_URL=http://127.0.0.1:8788/rpc node examples/batch-pipelining/client.mjs # the Worker +RPC_URL=http://127.0.0.1:3000/rpc node examples/batch-pipelining/client.mjs # the Node server +``` -2) Start the server: - node examples/batch-pipelining/server-node.mjs +## Where the latency comes from -3) In a separate terminal, run the client: - node examples/batch-pipelining/client.mjs +Two separate knobs, deliberately kept apart: -Files +- **Server-side work**: per-method delays, set by `DELAY_AUTH_MS`, `DELAY_PROFILE_MS` and + `DELAY_NOTIFS_MS` (`vars` in `wrangler.jsonc`, or environment variables for the Node server). + Identical in both modes; this is *not* what the demo is measuring. +- **Network round trips**: simulated on the client, by the slider in the browser or by + `SIMULATED_RTT_MS` / `SIMULATED_RTT_JITTER_MS` for `client.mjs`. This is the part pipelining + removes. -- server-node.mjs: Minimal Node HTTP server bridging to `newHttpBatchRpcResponse()`. -- client.mjs: Batching + pipelining client using `newHttpBatchRpcSession()`. +Keeping the round-trip cost on the client means the deployed Worker adds no artificial network +delay, and the page can change it without a redeploy. -Why this matters +## Why latency stops multiplying -- With normal HTTP or naive GraphQL usage, each dependent call often needs another round trip. Here, dependent calls are constructed locally, sent once, and executed on the server with results streamed back — minimizing latency dramatically. +With plain HTTP, or naive GraphQL usage, each dependent call usually needs another round trip. Here +the dependent calls are constructed locally, sent once, and resolved on the server, so latency +stops multiplying with the depth of the chain. diff --git a/examples/batch-pipelining/api.mjs b/examples/batch-pipelining/api.mjs new file mode 100644 index 00000000..4b8fa9aa --- /dev/null +++ b/examples/batch-pipelining/api.mjs @@ -0,0 +1,75 @@ +// The RPC API shared by every entry point in this example: the Node server +// (`server-node.mjs`) and the Cloudflare Worker (`worker.js`). +// +// `capnweb` is a bare specifier here rather than a relative path into `dist/`. +// Under Node it resolves through the repo's own workspace self-link; under +// Workers it is mapped to the workerd build by the `alias` block in +// `wrangler.jsonc`. Either way there is exactly one copy of the library, which +// matters because `RpcTarget` identity is checked at the session boundary. + +import { RpcTarget } from 'capnweb'; + +const sleep = (ms) => (ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve()); + +const USERS = new Map([ + ['cookie-123', { id: 'u_1', name: 'Ada Lovelace' }], + ['cookie-456', { id: 'u_2', name: 'Alan Turing' }], +]); + +const PROFILES = new Map([ + ['u_1', { id: 'u_1', bio: 'Mathematician & first programmer' }], + ['u_2', { id: 'u_2', bio: 'Mathematician & computer science pioneer' }], +]); + +const NOTIFICATIONS = new Map([ + ['u_1', ["Welcome to Cap'n Web!", 'You have 2 new followers']], + ['u_2', ['New feature: pipelining!', 'Security tips for your account']], +]); + +/** Per-method artificial latency, in milliseconds. */ +export const DEFAULT_DELAYS = { auth: 80, profile: 120, notifications: 120 }; + +/** + * Pull delay overrides out of an environment-shaped record. Works for both + * `process.env` (strings) and Workers `env` (numbers from `vars`). + */ +export function delaysFrom(source = {}) { + const num = (value, fallback) => { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; + }; + return { + auth: num(source.DELAY_AUTH_MS, DEFAULT_DELAYS.auth), + profile: num(source.DELAY_PROFILE_MS, DEFAULT_DELAYS.profile), + notifications: num(source.DELAY_NOTIFS_MS, DEFAULT_DELAYS.notifications), + }; +} + +export class Api extends RpcTarget { + #delays; + + constructor(delays = DEFAULT_DELAYS) { + super(); + this.#delays = { ...DEFAULT_DELAYS, ...delays }; + } + + // Simulate authentication from a session cookie/token. + async authenticate(sessionToken) { + await sleep(this.#delays.auth); + const user = USERS.get(sessionToken); + if (!user) throw new Error('Invalid session'); + return user; // { id, name } + } + + async getUserProfile(userId) { + await sleep(this.#delays.profile); + const profile = PROFILES.get(userId); + if (!profile) throw new Error('No such user'); + return profile; // { id, bio } + } + + async getNotifications(userId) { + await sleep(this.#delays.notifications); + return NOTIFICATIONS.get(userId) ?? []; + } +} diff --git a/examples/batch-pipelining/client.mjs b/examples/batch-pipelining/client.mjs index d6bc2aac..96612bef 100644 --- a/examples/batch-pipelining/client.mjs +++ b/examples/batch-pipelining/client.mjs @@ -6,7 +6,7 @@ // node examples/batch-pipelining/client.mjs import { performance } from 'node:perf_hooks'; -import { newHttpBatchRpcSession } from '../../dist/index.js'; +import { newHttpBatchRpcSession } from 'capnweb'; // Mirror of the server API shape (for reference only). // authenticate(sessionToken) -> { id, name } diff --git a/examples/batch-pipelining/public/demo.js b/examples/batch-pipelining/public/demo.js new file mode 100644 index 00000000..eb98cbdf --- /dev/null +++ b/examples/batch-pipelining/public/demo.js @@ -0,0 +1,66 @@ +// The two strategies being compared, and the fake network they run over. +// No DOM in this file -- main.js does the wiring, so this stays readable as +// an answer to "what is the actual difference between the two approaches?". +import { newHttpBatchRpcSession } from './vendor/capnweb.js'; + +export const RPC_URL = new URL('/rpc', location.href).href; + +const JITTER_MS = 40; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * Run `fn` with `fetch` wrapped so each RPC POST is counted and padded with + * simulated uplink and downlink latency. Restores the real fetch afterwards so + * a failed run cannot leave the page in a patched state. + * + * Latency is simulated on this side on purpose: the server does exactly the + * same work in both columns, so the difference you see is round trips and + * nothing else. + */ +async function withSimulatedNetwork(rttMs, fn) { + const realFetch = globalThis.fetch.bind(globalThis); + const latency = () => rttMs + Math.random() * JITTER_MS; + let posts = 0; + + globalThis.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); + if (url.startsWith(RPC_URL) && method === 'POST') { + posts++; + await sleep(latency()); + const response = await realFetch(input, init); + await sleep(latency()); + return response; + } + return realFetch(input, init); + }; + + const started = performance.now(); + try { + const value = await fn(); + return { value, posts, ms: performance.now() - started }; + } finally { + globalThis.fetch = realFetch; + } +} + +// One session. `user` is never awaited before being used, so `user.id` is sent +// as a pipelined reference rather than a resolved value. +export const pipelined = (rttMs) => + withSimulatedNetwork(rttMs, async () => { + const api = newHttpBatchRpcSession(RPC_URL); + const user = api.authenticate('cookie-123'); + const profile = api.getUserProfile(user.id); + const notifications = api.getNotifications(user.id); + const [u, p, n] = await Promise.all([user, profile, notifications]); + return { user: u, profile: p, notifications: n }; + }); + +// Three sessions, each awaited before the next can be built. +export const sequential = (rttMs) => + withSimulatedNetwork(rttMs, async () => { + const user = await newHttpBatchRpcSession(RPC_URL).authenticate('cookie-123'); + const profile = await newHttpBatchRpcSession(RPC_URL).getUserProfile(user.id); + const notifications = await newHttpBatchRpcSession(RPC_URL).getNotifications(user.id); + return { user, profile, notifications }; + }); diff --git a/examples/batch-pipelining/public/index.html b/examples/batch-pipelining/public/index.html new file mode 100644 index 00000000..c165bb7f --- /dev/null +++ b/examples/batch-pipelining/public/index.html @@ -0,0 +1,456 @@ + + + + + + + + + + + + + Batch + pipelining in Cap'n Web + + + + + +
Cap'n Web: batch + pipelining
+ +
+

One round trip, three dependent calls

+

+ This page authenticates a user, then fetches that user's profile and notifications, + both of which need the user ID from the first call. Pipelined, all three + travel in a single HTTP request. Done the ordinary way, they take + three. Only the round trips differ; the server does identical work either way. +

+ +
+ + + +
+ +
+
+

Pipelined

+

One session, calls chained on unresolved promises.

+
HTTP round trips
+
Elapsed
+
+
Not run yet.
+
+ +
+

Sequential

+

A fresh session per call, each awaited in turn.

+
HTTP round trips
+
Elapsed
+
+
Not run yet.
+
+
+ + + +
+ Source: + examples/batch-pipelining. The same comparison runs in a terminal via client.mjs. Latency is + simulated in the browser, so the server is doing the same work in both columns; + see promise pipelining. +
+
+ + + + diff --git a/examples/batch-pipelining/public/main.js b/examples/batch-pipelining/public/main.js new file mode 100644 index 00000000..9dab3195 --- /dev/null +++ b/examples/batch-pipelining/public/main.js @@ -0,0 +1,63 @@ +// DOM wiring for the comparison. The RPC is all in demo.js. +import { pipelined, sequential } from './demo.js'; + +const $ = (id) => document.getElementById(id); +const rtt = () => Number($('rtt').value); + +function reset() { + for (const id of ['pPosts', 'pTime', 'sPosts', 'sTime']) $(id).textContent = '\u2026'; + $('pOut').textContent = $('sOut').textContent = 'Not run yet.'; + $('pBar').style.width = $('sBar').style.width = '0'; + $('verdict').hidden = true; + $('verdict').classList.remove('error'); +} + +async function run() { + $('run').disabled = true; + $('run').textContent = 'Running\u2026'; + reset(); + try { + const p = await pipelined(rtt()); + $('pPosts').textContent = p.posts; + $('pTime').textContent = `${Math.round(p.ms)} ms`; + $('pOut').textContent = JSON.stringify(p.value, null, 2); + + const s = await sequential(rtt()); + $('sPosts').textContent = s.posts; + $('sTime').textContent = `${Math.round(s.ms)} ms`; + $('sOut').textContent = JSON.stringify(s.value, null, 2); + + const worst = Math.max(p.ms, s.ms) || 1; + $('pBar').style.width = `${(p.ms / worst) * 100}%`; + $('sBar').style.width = `${(s.ms / worst) * 100}%`; + + const saved = Math.round(s.ms - p.ms); + const times = (s.ms / p.ms).toFixed(2); + $('verdict').innerHTML = + `${p.posts} round trip vs ${s.posts}. Pipelining finished ` + + `${saved} ms sooner (${times}× faster), returning identical data.`; + $('verdict').hidden = false; + } catch (err) { + $('verdict').classList.add('error'); + $('verdict').textContent = `Failed: ${err?.message ?? err}`; + $('verdict').hidden = false; + } finally { + $('run').disabled = false; + $('run').textContent = 'Run both'; + } +} + +/** Keeps the readout and the slider's painted fill in step with the value. */ +function syncRtt() { + const el = $('rtt'); + const min = Number(el.min); + const fraction = (Number(el.value) - min) / (Number(el.max) - min); + el.style.setProperty('--pct', `${fraction * 100}%`); + $('rttValue').textContent = `${el.value} ms`; +} + +$('rtt').addEventListener('input', syncRtt); +syncRtt(); +$('run').addEventListener('click', run); +$('reset').addEventListener('click', reset); +run(); diff --git a/examples/batch-pipelining/server-node.mjs b/examples/batch-pipelining/server-node.mjs index 1e9857e4..2ed1f969 100644 --- a/examples/batch-pipelining/server-node.mjs +++ b/examples/batch-pipelining/server-node.mjs @@ -4,53 +4,16 @@ // 1) From repo root: npm run build // 2) Start: node examples/batch-pipelining/server-node.mjs // 3) Client: node examples/batch-pipelining/client.mjs +// +// The same API is served from a Cloudflare Worker in `worker.js`; both share +// the `Api` class in `api.mjs`. import http from 'node:http'; -import { nodeHttpBatchRpcResponse, RpcTarget } from '../../dist/index.js'; - -// Simple helper to simulate server-side processing latency. -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -// Simple in-memory data -const USERS = new Map([ - ['cookie-123', { id: 'u_1', name: 'Ada Lovelace' }], - ['cookie-456', { id: 'u_2', name: 'Alan Turing' }], -]); - -const PROFILES = new Map([ - ['u_1', { id: 'u_1', bio: 'Mathematician & first programmer' }], - ['u_2', { id: 'u_2', bio: 'Mathematician & computer science pioneer' }], -]); - -const NOTIFICATIONS = new Map([ - ['u_1', ['Welcome to Cap\'n Web!', 'You have 2 new followers']], - ['u_2', ['New feature: pipelining!', 'Security tips for your account']], -]); - -// Define the server-side API by extending RpcTarget. -class Api extends RpcTarget { - // Simulate authentication from a session cookie/token. - async authenticate(sessionToken) { - await sleep(Number(process.env.DELAY_AUTH_MS ?? 80)); - const user = USERS.get(sessionToken); - if (!user) throw new Error('Invalid session'); - return user; // { id, name } - } - - async getUserProfile(userId) { - await sleep(Number(process.env.DELAY_PROFILE_MS ?? 120)); - const profile = PROFILES.get(userId); - if (!profile) throw new Error('No such user'); - return profile; // { id, bio } - } - - async getNotifications(userId) { - await sleep(Number(process.env.DELAY_NOTIFS_MS ?? 120)); - return NOTIFICATIONS.get(userId) ?? []; - } -} +import { nodeHttpBatchRpcResponse } from 'capnweb'; +import { Api, delaysFrom } from './api.mjs'; const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; +const delays = delaysFrom(process.env); const server = http.createServer(async (req, res) => { // Only handle POST /rpc as a batch endpoint. @@ -61,7 +24,7 @@ const server = http.createServer(async (req, res) => { } try { - await nodeHttpBatchRpcResponse(req, res, new Api()); + await nodeHttpBatchRpcResponse(req, res, new Api(delays)); } catch (err) { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(String(err?.stack || err)); diff --git a/examples/batch-pipelining/worker.js b/examples/batch-pipelining/worker.js new file mode 100644 index 00000000..d35de7ab --- /dev/null +++ b/examples/batch-pipelining/worker.js @@ -0,0 +1,61 @@ +// Cloudflare Worker serving the same API as `server-node.mjs`, plus the +// browser demo in `public/`. +// +// Static assets are served ahead of this Worker by the `assets` config, so +// `fetch` only ever sees `/rpc` (assets do not handle POST) and unknown paths. +// +// Note there is no artificial network latency here. The round-trip cost is +// simulated in the browser instead, so the page can expose it as a slider +// without a redeploy -- exactly what `client.mjs` does for the CLI. + +import { newWorkersRpcResponse } from 'capnweb'; +import { Api, delaysFrom } from './api.mjs'; + +/** The demo endpoint is public, so allow it to be called from anywhere. */ +function corsHeaders(request) { + const origin = request.headers.get('Origin'); + if (!origin) return null; + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': + request.headers.get('Access-Control-Request-Headers') ?? 'Content-Type', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }; +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname !== '/rpc') { + return new Response('Not found', { status: 404 }); + } + + const cors = corsHeaders(request); + + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors ?? {} }); + } + + if (request.method !== 'POST') { + return new Response('Method not allowed', { + status: 405, + headers: { Allow: 'POST, OPTIONS' }, + }); + } + + const response = await newWorkersRpcResponse(request, new Api(delaysFrom(env))); + + if (!cors) return response; + + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(cors)) headers.set(key, value); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }, +}; diff --git a/examples/batch-pipelining/wrangler.jsonc b/examples/batch-pipelining/wrangler.jsonc new file mode 100644 index 00000000..31f11a23 --- /dev/null +++ b/examples/batch-pipelining/wrangler.jsonc @@ -0,0 +1,38 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "capnweb-batch-pipelining", + "main": "worker.js", + "compatibility_date": "2026-02-05", + + // `capnweb` resolves to the workerd build. Under Node the same bare + // specifier resolves through the repo's workspace self-link, so `api.mjs` + // is shared by the Worker and `server-node.mjs` without either of them + // hard-coding a path into `dist/`. + "alias": { + "capnweb": "../../dist/index-workers.js" + }, + + // The browser demo imports the library directly, so stage the dependency-free + // ESM build alongside the page. `dist/index.js` has no imports and no Node + // globals, so it runs unmodified in a browser -- no bundler needed here. + "build": { + "command": "mkdir -p public/vendor && cp ../../dist/index.js public/vendor/capnweb.js" + }, + + "assets": { + "directory": "public" + }, + + + "observability": { + "enabled": true + }, + + // Per-method artificial delays (ms). Network latency is simulated in the + // browser instead, so the page can expose it as a slider. + "vars": { + "DELAY_AUTH_MS": 80, + "DELAY_PROFILE_MS": 120, + "DELAY_NOTIFS_MS": 120 + } +} diff --git a/examples/session-recovery/README.md b/examples/session-recovery/README.md new file mode 100644 index 00000000..b0896b60 --- /dev/null +++ b/examples/session-recovery/README.md @@ -0,0 +1,77 @@ +# Session recovery + +A Cap'n Web session over a WebSocket, with a button that kills it. + +The other two examples are about making calls cheap. This one is about what happens when the +connection they travel over goes away. + +## The point + +A Cap'n Web session is per-connection memory. When the socket dies: + +- every stub from that session is permanently broken, and calling one **rejects** rather than + hanging or quietly reconnecting; +- the authenticated user, which lived on the object `authenticate()` returned, is gone; +- nothing is re-established automatically, because the library cannot know whether the object a + stub pointed at still exists or should still be reachable by you. + +Anything that has to survive that must live somewhere else. In this example the event log is +created at module scope and passed into each session, and the client keeps a cursor: the id of the +last event it actually processed. The cursor is what makes the reconnect gapless, and it works +precisely because it is a number in client-side state rather than anything the session owns. + +Untick **Resume from cursor** in the page and disconnect again to watch the gap appear. + +## Running it + +From the repo root: + +```sh +npm run build # the example resolves `capnweb` to dist/ +npx wrangler dev --cwd examples/session-recovery --ip 127.0.0.1 --port 8789 +``` + +Then open . There is no build step for the page itself; it is plain ES +modules, and Wrangler stages the library next to it. + +With a real Worker you can also turn your network off instead of pressing the button, and watch the +same thing happen. + +## Files + +| File | What it is | +| ------------------- | -------------------------------------------------------------------- | +| `api.mjs` | The RPC API, and the event log that lives outside any session | +| `worker.js` | The Worker: one endpoint, upgrading to a WebSocket | +| `public/session.js` | The client: connect, authenticate, subscribe, recover. No DOM in it. | +| `public/main.js` | DOM wiring, kept separate so the file above stays about RPC | + +## Things worth reading the source for + +**Authentication is a capability, not a header.** `authenticate()` returns an `AuthedApi` object, +and holding the stub *is* the authorization. The token crosses the wire once per connection; no +later call carries a credential. + +**One round trip on connect.** `authenticate()` is not awaited before `subscribe()` is called on its +result. Both calls, plus `whoami()`, travel together. + +**Callbacks are just objects passed by reference.** The client passes an `RpcTarget`; the server +gets a stub and calls methods on it. That is the entire server-push mechanism. + +**`.dup()` is mandatory.** Stubs arriving as call parameters are disposed when the call returns, so +the subscription duplicates the sink to keep it. Its `[Symbol.dispose]()` releases the copy, and +also runs when the session dies; that is what stops the timer on an abrupt disconnect. + +**Replay is bounded.** A resume token from a client that has been gone a long time is a request to +replay a long time. The server caps it and reports a gap rather than obliging. + +## Caveats + +The event log lives in module scope, which lasts as long as the isolate. That is fine for a demo and +wrong for production: isolates come and go, and two clients can land on two different ones. A real +deployment would put it in a Durable Object, a database, or a queue. The point being demonstrated is +only that it must not live *in the session*. + +The docs playground runs both ends of the session inside one page, replacing the `WebSocket` +constructor for `/ws`. Everything except the Worker's upgrade handling is genuine, including the +disconnect. diff --git a/examples/session-recovery/api.mjs b/examples/session-recovery/api.mjs new file mode 100644 index 00000000..ba617b20 --- /dev/null +++ b/examples/session-recovery/api.mjs @@ -0,0 +1,203 @@ +// The RPC API for the session-recovery example, shared by the Cloudflare +// Worker (`worker.js`) and the in-page playground in the docs. +// +// `capnweb` is a bare specifier here rather than a relative path into `dist/`. +// Under Node it resolves through the repo's own workspace self-link; under +// Workers it is mapped to the workerd build by the `alias` block in +// `wrangler.jsonc`. Either way there is exactly one copy of the library, which +// matters because `RpcTarget` identity is checked at the session boundary. + +import { RpcTarget } from 'capnweb'; + +/** The only credential this demo knows about. */ +const TOKENS = new Map([ + ['demo-token', { id: 'u_1', name: 'Ada Lovelace' }], + ['other-token', { id: 'u_2', name: 'Alan Turing' }], +]); + +const HEADLINES = [ + 'Order filled', + 'Deployment finished', + 'Invoice paid', + 'Container recycled', + 'Cache purged', + 'Alert cleared', + 'Backup completed', + 'Certificate renewed', +]; + +/** + * How much history a client may ask for in one go. A resume token from a + * client that has been gone for a week should not turn into an unbounded + * replay: past this, the client is told it fell too far behind and should + * resynchronize from scratch. + */ +export const MAX_REPLAY = 40; + +/** + * The event log. + * + * Deliberately created *outside* any session and passed in, because that is + * the whole point of the example: an RPC session is per-connection memory that + * dies with the socket, and anything that must outlive a disconnect has to + * live somewhere else. + * + * Events are derived from the clock rather than stored, so this needs no + * timer, no storage, and behaves identically whether it is running in a Worker + * isolate or inside the docs page. Event `n` is defined to have happened at + * `epoch + n * intervalMs`. + */ +export function createEventLog({ intervalMs = 1200, epoch = Date.now() } = {}) { + const at = (id) => ({ + id, + at: epoch + id * intervalMs, + text: `${HEADLINES[id % HEADLINES.length]} #${1000 + id}`, + }); + + return { + intervalMs, + + /** Sequence number of the most recent event that has already happened. */ + latestId() { + return Math.max(0, Math.floor((Date.now() - epoch) / intervalMs)); + }, + + /** + * Everything after `sinceId`. Returns `{ events, truncated }` so the caller + * can tell "nothing happened" apart from "you missed more than we keep". + */ + since(sinceId) { + const latest = this.latestId(); + const from = Math.max(sinceId, latest - MAX_REPLAY); + const events = []; + for (let id = from + 1; id <= latest; id++) events.push(at(id)); + return { events, truncated: from > sinceId }; + }, + + /** Milliseconds until event `id` happens. Negative if it already has. */ + msUntil(id) { + return epoch + id * intervalMs - Date.now(); + }, + }; +} + +/** + * A live subscription. + * + * Returned by `AuthedApi.subscribe()` rather than being a fire-and-forget + * call, so the client holds a capability it can dispose. Disposal happens + * either explicitly or when the session drops -- see `[Symbol.dispose]`. + */ +class Subscription extends RpcTarget { + #log; + #sink; + #lastId; + #timer = null; + #stopped = false; + + constructor(log, sink, sinceId) { + super(); + this.#log = log; + this.#sink = sink; + this.#lastId = sinceId; + this.#pump(); + } + + /** The highest event id delivered so far. The client's resume token. */ + get cursor() { + return this.#lastId; + } + + #pump() { + if (this.#stopped) return; + + const { events, truncated } = this.#log.since(this.#lastId); + if (truncated) { + // Fire-and-forget, but still settled -- see the note in the loop below. + this.#sink.onGap(this.#lastId).catch(() => {}); + } + + for (const event of events) { + this.#lastId = event.id; + + // The client's sink is a stub, so this is an RPC back to the browser. + // We do not need the result, but we do settle the promise: an RPC + // promise that is never awaited and never disposed keeps an entry in the + // session's tables alive for as long as the session lasts. + this.#sink.onEvent(event).catch(() => {}); + } + + const wait = Math.max(20, this.#log.msUntil(this.#lastId + 1)); + this.#timer = setTimeout(() => this.#pump(), wait); + } + + /** + * Runs when the client disposes this stub, and also when the session dies, + * which is what stops the timer on an abrupt disconnect. + */ + [Symbol.dispose]() { + this.#stopped = true; + if (this.#timer !== null) clearTimeout(this.#timer); + this.#sink[Symbol.dispose](); + } +} + +/** + * The authenticated API. + * + * The client can only obtain one of these by calling `authenticate()` with a + * valid token. Holding the stub *is* the authorization: there is no session + * cookie, no bearer header on subsequent calls, and no way to reach these + * methods without the capability. It also means the credential crosses the + * wire exactly once per connection. + */ +class AuthedApi extends RpcTarget { + #log; + #user; + + constructor(log, user) { + super(); + this.#log = log; + this.#user = user; + } + + whoami() { + return { ...this.#user }; + } + + /** + * Start streaming events after `sinceId`. + * + * Pass `sinceId: null` to start from the present and accept a gap; pass the + * last id you actually processed to have the gap replayed. + */ + subscribe(sinceId, sink) { + const from = sinceId === null || sinceId === undefined ? this.#log.latestId() : sinceId; + + // Stubs received as parameters are disposed when the call returns, so a + // callback that will be used later has to be duplicated first. + return new Subscription(this.#log, sink.dup(), from); + } +} + +/** The interface a fresh connection starts with. */ +export class PublicApi extends RpcTarget { + #log; + + constructor(log) { + super(); + this.#log = log; + } + + /** Exchange a token for the authenticated API. */ + authenticate(token) { + const user = TOKENS.get(token); + if (!user) throw new Error(`Unknown API token: ${token}`); + return new AuthedApi(this.#log, user); + } + + /** Available without authenticating, so the page has something to show. */ + serverInfo() { + return { intervalMs: this.#log.intervalMs, maxReplay: MAX_REPLAY }; + } +} diff --git a/examples/session-recovery/public/index.html b/examples/session-recovery/public/index.html new file mode 100644 index 00000000..9e40caa4 --- /dev/null +++ b/examples/session-recovery/public/index.html @@ -0,0 +1,127 @@ + + + + + + + + + + + + + Session recovery in Cap'n Web + + + + +
Cap’n Web: session recovery
+ +
+

What a disconnect destroys

+

+ A Cap’n Web session over a WebSocket, with a button that kills it. The event + stream resumes without a gap only because the client keeps a cursor of its own. +

+ +
+
+ Session + offline +
+
+ Cursor + -- +
+ +
+ +
+ + + + +
+ +
+
+

Event stream

+
    +
    + +
    +

    Session log

    +
      +
      +
      + +
      +

      What to try

      +
        +
      1. + Connect, and watch the event ids climb. Authentication and + subscription happen in a single round trip, by pipelining. +
      2. +
      3. + Sever the connection, wait a few seconds, then reconnect. The + replay fills in exactly what you missed, because the client sent the id of the + last event it actually processed. +
      4. +
      5. + Untick Resume from cursor and do it again. Same disconnect, but + now the feed shows a gap marker: the events happened and nobody asked for them. +
      6. +
      7. + Call a stub from the old session after severing. It rejects. No + stub survives its session, and nothing is silently re-established. +
      8. +
      +
      + + +
      + + + + diff --git a/examples/session-recovery/public/main.js b/examples/session-recovery/public/main.js new file mode 100644 index 00000000..37d6284b --- /dev/null +++ b/examples/session-recovery/public/main.js @@ -0,0 +1,93 @@ +// DOM wiring for the session-recovery demo. The RPC lives in `session.js`; +// this file only moves its output onto the page. + +import { RecoveringClient } from './session.js'; + +const $ = (id) => document.getElementById(id); + +const els = { + state: $('state'), + cursor: $('cursor'), + feed: $('feed'), + log: $('log'), + connect: $('connect'), + sever: $('sever'), + probe: $('probe'), + disconnect: $('disconnect'), + resume: $('resume'), +}; + +/** Events the page has seen, so a gap in the sequence is visible. */ +let seen = []; + +function renderFeed() { + els.feed.replaceChildren( + ...seen.slice(-14).map((entry) => { + const li = document.createElement('li'); + li.className = entry.gap ? 'event event--gap' : 'event'; + li.innerHTML = entry.gap + ? `gap${entry.missed} event${ + entry.missed === 1 ? '' : 's' + } never arrived` + : `#${entry.id}`; + if (!entry.gap) li.querySelector('.event__text').textContent = entry.text; + return li; + }), + ); + els.feed.scrollTop = els.feed.scrollHeight; +} + +function report(message, tone = 'plain') { + const li = document.createElement('li'); + li.className = `line line--${tone}`; + const time = new Date().toLocaleTimeString([], { hour12: false }); + li.innerHTML = `${time}`; + li.querySelector('.line__text').textContent = message; + els.log.append(li); + while (els.log.children.length > 40) els.log.firstChild.remove(); + els.log.scrollTop = els.log.scrollHeight; +} + +/** + * Record an event, inserting a marker when the sequence jumps. + * + * This is what makes the demo worth looking at: without a resume token the + * numbers skip, and the marker says how many were lost. + */ +function pushEvent(event) { + const previous = seen.filter((entry) => !entry.gap).at(-1); + if (previous && event.id > previous.id + 1) { + seen.push({ gap: true, missed: event.id - previous.id - 1 }); + } + seen.push(event); + renderFeed(); +} + +const client = new RecoveringClient({ + // Same-origin, so this works under `wrangler dev` and in the docs playground + // without either of them knowing anything about the other. + url: new URL('/ws', location.href).href.replace(/^http/, 'ws'), + token: 'demo-token', + report, + onEvent: pushEvent, + onStateChange: (state) => { + els.state.textContent = state; + els.state.dataset.state = state; + els.connect.disabled = state !== 'offline'; + els.sever.disabled = state !== 'online'; + els.disconnect.disabled = state !== 'online'; + els.cursor.textContent = client.cursor === null ? '--' : `#${client.cursor}`; + }, +}); + +els.connect.addEventListener('click', () => client.connect({ resume: els.resume.checked })); +els.sever.addEventListener('click', () => client.sever()); +els.disconnect.addEventListener('click', () => client.disconnect()); +els.probe.addEventListener('click', async () => report(await client.probeStaleStub(), 'plain')); + +// Keep the cursor readout live while events stream in. +setInterval(() => { + els.cursor.textContent = client.cursor === null ? '--' : `#${client.cursor}`; +}, 250); + +report('idle -- press Connect', 'plain'); diff --git a/examples/session-recovery/public/session.js b/examples/session-recovery/public/session.js new file mode 100644 index 00000000..03bedaf0 --- /dev/null +++ b/examples/session-recovery/public/session.js @@ -0,0 +1,230 @@ +// The whole point of this example, with no DOM in it. +// +// Everything a Cap'n Web client has to do about disconnection lives here: +// noticing one, throwing away the capabilities it invalidated, establishing a +// fresh session, and picking the event stream back up without a gap. + +import { newWebSocketRpcSession, RpcTarget } from './vendor/capnweb.js'; + +/** + * The object the server calls back into. + * + * Passing this over RPC gives the server a stub for it, and calling a method + * on that stub is an RPC in the other direction. This is all "bidirectional + * calling" is: there is no separate subscription mechanism. + */ +class EventSink extends RpcTarget { + #onEvent; + #onGap; + + constructor({ onEvent, onGap }) { + super(); + this.#onEvent = onEvent; + this.#onGap = onGap; + } + + onEvent(event) { + this.#onEvent(event); + } + + onGap(sinceId) { + this.#onGap(sinceId); + } +} + +/** + * A client that reconnects. + * + * `report` is called with a log line for the UI; `onEvent` with each event as + * it arrives. Everything else is internal. + */ +export class RecoveringClient { + #url; + #token; + #report; + #onEvent; + #onStateChange; + + /** Set while connected. All four are invalidated together by a disconnect. */ + #socket = null; + #api = null; + #authed = null; + #subscription = null; + + /** + * The last authenticated stub we held, kept after teardown purely so the + * demo can call a method on it and show what a dead stub does. + */ + #staleAuthed = null; + + /** + * The resume token: the id of the last event we actually processed. + * + * This is the only thing that survives a reconnect, and it survives because + * it lives out here in our own state rather than in anything the session + * owns. A stub cannot survive; a number can. + */ + #cursor = null; + + /** Set when the caller asked to stop, to tell a deliberate close from a drop. */ + #closing = false; + + #state = 'offline'; + + constructor({ url, token, report, onEvent, onStateChange }) { + this.#url = url; + this.#token = token; + this.#report = report; + this.#onEvent = onEvent; + this.#onStateChange = onStateChange ?? (() => {}); + } + + get state() { + return this.#state; + } + + get cursor() { + return this.#cursor; + } + + #setState(state) { + this.#state = state; + this.#onStateChange(state); + } + + /** + * Connect, authenticate, and subscribe -- in one round trip. + * + * `authenticate()` returns a promise for the authenticated API, and we call + * `subscribe()` on that promise without awaiting it first. That is promise + * pipelining: the second call is sent immediately, carrying a reference to + * the not-yet-existing result of the first. + * + * @param {{ resume?: boolean }} options + * `resume: false` deliberately throws the cursor away, so you can watch + * the gap appear that a resume token exists to prevent. + */ + async connect({ resume = true } = {}) { + if (this.#state !== 'offline') return; + this.#closing = false; + this.#setState('connecting'); + + // We construct the socket ourselves rather than passing a URL string, so + // that we hold it and can close it on demand. `newWebSocketRpcSession` + // accepts either. + const socket = new WebSocket(this.#url); + this.#socket = socket; + + const api = newWebSocketRpcSession(socket, undefined); + this.#api = api; + + // Fires for any end of session: a clean close, a dropped connection, or a + // protocol error. There is no separate "disconnected" event to listen for. + api.onRpcBroken((error) => this.#onBroken(error)); + + const sink = new EventSink({ + onEvent: (event) => { + this.#cursor = event.id; + this.#onEvent(event); + }, + onGap: (sinceId) => { + this.#report( + `server dropped history before #${sinceId}: too far behind to replay`, + 'warn', + ); + }, + }); + + const sinceId = resume ? this.#cursor : null; + + try { + const authed = api.authenticate(this.#token); + const subscription = authed.subscribe(sinceId, sink); + + // One await, so everything above cost a single round trip. + const user = await authed.whoami(); + + this.#authed = authed; + this.#subscription = subscription; + this.#setState('online'); + + this.#report( + sinceId === null + ? `connected as ${user.name}; streaming from now (no resume)` + : `connected as ${user.name}; resuming after #${sinceId}`, + 'good', + ); + } catch (error) { + this.#report(`connect failed: ${error.message}`, 'bad'); + this.#teardown(); + this.#setState('offline'); + } + } + + /** + * Prove that the capability really is gone after a drop. + * + * Calling a method on a stub from a dead session does not hang or silently + * no-op; it rejects. This is the check the demo runs to make the point. + */ + async probeStaleStub() { + const stub = this.#authed ?? this.#staleAuthed; + if (!stub) return 'nothing to probe -- connect first'; + try { + const user = await stub.whoami(); + return `stub still works: ${user.name}`; + } catch (error) { + return `stub is broken: ${error.message}`; + } + } + + /** Simulate losing the network. The socket dies without a clean handshake. */ + sever() { + if (!this.#socket) return; + this.#report('severing the connection', 'warn'); + this.#socket.close(4000, 'simulated network loss'); + } + + /** A deliberate shutdown, so `onRpcBroken` is not treated as a failure. */ + disconnect() { + if (!this.#socket) return; + this.#closing = true; + this.#report('disconnecting', 'plain'); + + // Disposing the main stub closes the session, and with it the connection. + this.#api[Symbol.dispose](); + this.#teardown(); + this.#setState('offline'); + } + + #onBroken(error) { + if (this.#state === 'offline') return; + + this.#teardown(); + this.#setState('offline'); + + if (this.#closing) return; + + this.#report(`session broken: ${error.message}`, 'bad'); + this.#report( + this.#cursor === null + ? 'every stub from that session is now dead' + : `every stub from that session is now dead; cursor held at #${this.#cursor}`, + 'plain', + ); + } + + /** + * Drop our references to the session. + * + * Deliberately does *not* touch `#cursor`. Everything the session owned is + * gone; the resume token is ours. + */ + #teardown() { + this.#staleAuthed = this.#authed ?? this.#staleAuthed; + this.#socket = null; + this.#api = null; + this.#authed = null; + this.#subscription = null; + } +} diff --git a/examples/session-recovery/public/style.css b/examples/session-recovery/public/style.css new file mode 100644 index 00000000..14e49d1e --- /dev/null +++ b/examples/session-recovery/public/style.css @@ -0,0 +1,322 @@ +/* + * Palette matches the docs site: near-black with a blue undertone, azure for + * anything live, Cloudflare orange used sparingly enough to still mean + * something. `light-dark()` keeps both schemes in one declaration. + */ +:root { + color-scheme: light dark; + + /* The ground is tinted and the cards are not, so a card is separated by + its own lightness rather than by a hairline alone. */ + --ground: light-dark(#eef1f4, #070a11); + --surface: light-dark(#f7f9fb, #13171f); + --sunken: light-dark(#e4e9ee, #1d222d); + --ink: light-dark(#1a222b, #e8eef4); + --ink-dim: light-dark(#5b6b7a, #9aabba); + --rule: light-dark(rgba(26, 34, 43, 0.12), rgba(232, 238, 244, 0.12)); + /* `light-dark()` takes exactly two arguments, so the scheme switch happens + on the colour and the geometry is written once around it. */ + --shadow-near: light-dark(rgba(13, 26, 43, 0.07), rgba(0, 0, 0, 0.38)); + --shadow-far: light-dark(rgba(13, 26, 43, 0.07), rgba(0, 0, 0, 0.42)); + --shadow-sm: 0 1px 2px var(--shadow-near); + --shadow: 0 1px 2px var(--shadow-near), 0 8px 20px var(--shadow-far); + --accent: light-dark(#2f6fd6, #5b9bff); + --good: light-dark(#0d6d5b, #34d399); + --warn: light-dark(#96580c, #f5c37a); + --bad: light-dark(#b32a20, #ff9d94); + /* The one primary action. Kept apart from `--warn`, which has to darken in + light mode to stay readable as text and is the wrong colour for a filled + button. */ + --cta: #e85d2c; + --cta-ink: #fff8f4; + + --mono: 'Commit Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +:root[data-theme='light'] { + color-scheme: light; +} +:root[data-theme='dark'] { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--ground); + color: var(--ink); + font-family: + 'DM Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + line-height: 1.6; +} + +/* The standalone page's own chrome: a title bar, a centred column and a + footer, matching the sibling examples so that popping any of them out of the + docs lands you somewhere familiar. */ +.site { + padding: 0.9rem 1.5rem; + border-bottom: 1px solid var(--rule); +} + +.site a { + color: var(--ink); + font-weight: 600; + font-size: 1.15rem; + text-decoration: none; + letter-spacing: -0.01em; +} + +.page { + max-width: 60rem; + margin: 0 auto; + padding: 2.5rem 1.5rem 4rem; +} + +h1 { + font-size: clamp(1.9rem, 4vw, 2.6rem); + line-height: 1.15; + letter-spacing: -0.02em; + margin: 0 0 0.6rem; +} + +.lede { + margin: 0 0 2rem; + max-width: 46rem; + font-size: 1.1rem; + color: var(--ink-dim); +} + +footer { + border-top: 1px solid var(--rule); + margin-top: 3rem; + padding-top: 1.25rem; + color: var(--ink-dim); + font-size: 0.9rem; +} + +footer a, +.lede a { + color: var(--accent); +} + +/* Embedded in the docs playground, which supplies the title and the frame, so + the duplicated chrome comes off. */ +:root[data-embedded='true'] .site, +:root[data-embedded='true'] h1, +:root[data-embedded='true'] footer { + display: none; +} + +:root[data-embedded='true'] body { + background: var(--ground); +} + +:root[data-embedded='true'] .page { + padding: 1.25rem 1rem 2rem; +} + +:root[data-embedded='true'] .lede { + font-size: 1rem; + margin-bottom: 1.25rem; +} + +.panel { + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 0.6rem; + box-shadow: var(--shadow); + padding: 1rem 1.1rem; +} + +.panel h2 { + margin: 0 0 0.6rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--ink-dim); +} + +.status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.5rem; + margin-bottom: 0.85rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.stat__label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--ink-dim); +} + +.stat__value { + font-family: var(--mono); + font-size: 1.05rem; + font-weight: 600; +} + +.stat__value[data-state='online'] { + color: var(--good); +} +.stat__value[data-state='connecting'] { + color: var(--warn); +} +.stat__value[data-state='offline'] { + color: var(--ink-dim); +} + +.toggle { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + color: var(--ink-dim); + cursor: pointer; + user-select: none; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.btn { + padding: 0.55rem 1.1rem; + border: 1px solid var(--rule); + border-radius: 999px; + background: var(--surface); + color: var(--ink); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; +} + +.btn:hover:not(:disabled) { + border-color: var(--accent); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn--primary { + background: var(--cta); + border-color: var(--cta); + color: var(--cta-ink); + box-shadow: 0 2px 8px light-dark(rgba(246, 130, 31, 0.34), rgba(0, 0, 0, 0.35)); +} + +.btn--quiet { + color: var(--ink-dim); +} + +.columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.85rem; +} + +@media (max-width: 48rem) { + .columns { + grid-template-columns: 1fr; + } + .toggle { + margin-left: 0; + } +} + +.feed, +.log { + margin: 0; + padding: 0; + list-style: none; + height: 15rem; + overflow-y: auto; + background: var(--sunken); + border-radius: 0.4rem; + padding: 0.5rem 0.6rem; + font-family: var(--mono); + font-size: 0.8125rem; +} + +.event, +.line { + display: flex; + gap: 0.6rem; + padding: 0.16rem 0; +} + +.event__id { + color: var(--accent); + min-width: 3.6rem; +} + +.event--gap { + color: var(--warn); + border-top: 1px dashed currentColor; + border-bottom: 1px dashed currentColor; + margin: 0.25rem 0; + padding: 0.25rem 0; +} + +.event--gap .event__id { + color: inherit; + font-weight: 600; +} + +.line__time { + color: var(--ink-dim); + min-width: 4.6rem; +} + +.line--good .line__text { + color: var(--good); +} +.line--warn .line__text { + color: var(--warn); +} +.line--bad .line__text { + color: var(--bad); +} + +.notes { + margin-top: 1.5rem; + color: var(--ink-dim); +} + +.notes h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.09em; + margin-bottom: 0.5rem; +} + +.notes ol { + margin: 0; + padding-left: 1.2rem; + max-width: 52rem; +} + +.notes li { + margin-bottom: 0.4rem; +} + +.notes strong { + color: var(--ink); +} diff --git a/examples/session-recovery/worker.js b/examples/session-recovery/worker.js new file mode 100644 index 00000000..198636fe --- /dev/null +++ b/examples/session-recovery/worker.js @@ -0,0 +1,45 @@ +// Cloudflare Worker serving the session-recovery demo. +// +// Static assets are served ahead of this Worker by the `assets` config, so +// `fetch` only ever sees `/ws` and unknown paths. + +import { newWorkersRpcResponse } from 'capnweb'; +import { createEventLog, PublicApi } from './api.mjs'; + +/** + * The event log outlives any one connection. + * + * Module scope means it lives as long as the isolate, which is enough for a + * demo and is exactly the wrong answer for production: isolates come and go, + * and two clients can easily land on two different ones. Anything that must + * genuinely survive a disconnect belongs in storage that is addressable -- + * a Durable Object, a database, a queue. The point being made here is only + * that it has to live *somewhere that is not the session*. + */ +const log = createEventLog(); + +/** + * The main interface handed to each new connection. + * + * Also imported directly by the docs playground, which runs both ends of the + * session inside one page and so never goes through `fetch` at all. + */ +export function createMain() { + return new PublicApi(log); +} + +export default { + async fetch(request) { + const url = new URL(request.url); + + if (url.pathname !== '/ws') { + return new Response('Not found', { status: 404 }); + } + + if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') { + return new Response('This endpoint speaks WebSocket only.', { status: 426 }); + } + + return await newWorkersRpcResponse(request, createMain()); + }, +}; diff --git a/examples/session-recovery/wrangler.jsonc b/examples/session-recovery/wrangler.jsonc new file mode 100644 index 00000000..c6f3d29a --- /dev/null +++ b/examples/session-recovery/wrangler.jsonc @@ -0,0 +1,28 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "capnweb-session-recovery", + "main": "worker.js", + "compatibility_date": "2026-02-05", + + // `capnweb` resolves to the workerd build. Under Node the same bare + // specifier resolves through the repo's workspace self-link, so `api.mjs` + // is shared without hard-coding a path into `dist/`. + "alias": { + "capnweb": "../../dist/index-workers.js" + }, + + // The browser client imports the library directly, so stage the + // dependency-free ESM build alongside the page. `dist/index.js` has no + // imports and no Node globals, so it runs unmodified in a browser. + "build": { + "command": "mkdir -p public/vendor && cp ../../dist/index.js public/vendor/capnweb.js" + }, + + "assets": { + "directory": "public" + }, + + "observability": { + "enabled": true + } +} diff --git a/examples/worker-react/README.md b/examples/worker-react/README.md index cd0ee8ab..44a73b87 100644 --- a/examples/worker-react/README.md +++ b/examples/worker-react/README.md @@ -1,12 +1,28 @@ # Cloudflare Workers + React example -This example exposes a Cap'n Web API from a Worker and calls it from a React app. It demonstrates batched promise pipelining versus sequential requests, with server-boundary runtime validation through `@validateRpc()` and explicit client stub validation through `validateStub()`. +A Cap'n Web API served from a Worker and called from a React app, comparing batched promise +pipelining against the same calls made sequentially. Both ends are validated at runtime: +`@validateRpc()` on the server boundary, `validateStub()` on the client. + +Runs as a playground in the docs under **Examples**, and locally as a real Worker. + +## Quick start + +From the repo root: + +```sh +npm run build # the examples resolve `capnweb` to dist/ +npx wrangler dev --cwd examples/worker-react --ip 127.0.0.1 --port 8787 +``` + +The rest of this file covers running the pieces individually. ## Layout - `server/worker.ts`: Worker RPC endpoint at `/api`. - `client/`: React/Vite app. -- `wrangler.jsonc`: Worker config. Wrangler runs `capnweb-validate build` before starting and points `main` at the generated Worker copy. +- `wrangler.jsonc`: Worker config. Wrangler runs `capnweb-validate build` before starting and points + `main` at the generated Worker copy. ## Run locally @@ -41,7 +57,7 @@ The Vite dev server proxies `/api` to `http://127.0.0.1:8787`. ## VS Code debug -Use the `validate: debug all` launch configuration. It starts Wrangler and Vite without the old helper shell scripts. +Use the `validate: debug all` launch configuration, which starts Wrangler and Vite together. Worker validation output is generated under `.wrangler/validate/worker.ts`. The React client uses normal Cap'n Web client sessions wrapped explicitly with `validateStub()`. diff --git a/examples/worker-react/client/index.html b/examples/worker-react/client/index.html index 040235ef..c8d4d0ee 100644 --- a/examples/worker-react/client/index.html +++ b/examples/worker-react/client/index.html @@ -3,7 +3,50 @@ + Cap'n Web Cloudflare Workers + React Example + + + + +
      diff --git a/examples/worker-react/client/src/main/App.css b/examples/worker-react/client/src/main/App.css index a6a8bfe0..3e029e00 100644 --- a/examples/worker-react/client/src/main/App.css +++ b/examples/worker-react/client/src/main/App.css @@ -1,32 +1,51 @@ +/* Both schemes live in one place via `light-dark()`, so forcing a theme is a + single `color-scheme` switch rather than a second copy of the palette. The + docs playground forces it when this app is embedded; standalone it still + follows the OS. */ :root { - --theme-orange: #f6821f; - --theme-orange-hover: #f69a4aff; - --theme-orange-foreground: #000000; - - --bg-primary: #ffffff; - --bg-secondary: #fafafa; - --bg-pre: #f5f5f5; - --text-primary: #1f2937; - --text-secondary: #4b5563; - --border-color: #e5e7eb; + color-scheme: light dark; + + --theme-orange: #e85d2c; + --theme-orange-hover: #f07846; + --theme-orange-foreground: #fff8f4; + + /* The ground is tinted and the cards are not, so a card is separated by its + own lightness rather than by a hairline alone. */ + --bg-primary: light-dark(#eef1f4, #070a11); + --bg-secondary: light-dark(#f7f9fb, #13171f); + --bg-pre: light-dark(#e4e9ee, #1d222d); + --text-primary: light-dark(#1a222b, #e8eef4); + --text-secondary: light-dark(#5b6b7a, #9aabba); + --border-color: light-dark(rgba(26, 34, 43, 0.12), rgba(232, 238, 244, 0.12)); + /* `light-dark()` takes exactly two arguments, so the scheme switch happens + on the colour and the geometry is written once around it. */ + --shadow-near: light-dark(rgba(13, 26, 43, 0.07), rgba(0, 0, 0, 0.38)); + --shadow-far: light-dark(rgba(13, 26, 43, 0.07), rgba(0, 0, 0, 0.42)); + --shadow-sm: 0 1px 2px var(--shadow-near); + --shadow: 0 1px 2px var(--shadow-near), 0 8px 20px var(--shadow-far); --button-bg: var(--theme-orange); --button-hover: var(--theme-orange-hover); --button-foreground: var(--theme-orange-foreground); + /* Links are not the accent: tomato is spent on the one primary action, + which is what makes it read as the primary action. */ + --link: light-dark(#2f6fd6, #5b9bff); + + font-family: + 'DM Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; } -@media (prefers-color-scheme: dark) { - :root { - --bg-primary: #181818; - --bg-secondary: #272727; - --bg-pre: #313131; - --text-primary: #f9fafb; - --text-secondary: #d1d5db; - --border-color: #717171; - --button-bg: var(--theme-orange); - --button-hover: var(--theme-orange-hover); - --button-foreground: var(--theme-orange-foreground); - } +:root[data-theme='light'] { + color-scheme: light; +} + +:root[data-theme='dark'] { + color-scheme: dark; +} + +* { + box-sizing: border-box; } body { @@ -34,17 +53,77 @@ body { color: var(--text-primary); margin: 0; padding: 0; + line-height: 1.6; } -body::before { - content: ''; - position: fixed; - top: 0; - left: 0; - right: 0; - height: 5px; - background: var(--theme-orange); - z-index: 9999; +/* The standalone page's own chrome: a title bar, a centred column and a + footer, matching the sibling examples so that popping any of them out of the + docs lands you somewhere familiar. */ +.site { + padding: 0.9rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.site a { + color: var(--text-primary); + font-weight: 600; + font-size: 1.15rem; + text-decoration: none; + letter-spacing: -0.01em; +} + +.page { + max-width: 60rem; + margin: 0 auto; + padding: 2.5rem 1.5rem 4rem; +} + +h1 { + font-size: clamp(1.9rem, 4vw, 2.6rem); + line-height: 1.15; + letter-spacing: -0.02em; + margin: 0 0 0.6rem; +} + +.lede { + margin: 0 0 2rem; + max-width: 46rem; + font-size: 1.1rem; + color: var(--text-secondary); +} + +footer { + border-top: 1px solid var(--border-color); + margin-top: 3rem; + padding-top: 1.25rem; + color: var(--text-secondary); + font-size: 0.9rem; +} + +footer a, +.lede a { + color: var(--link); +} + +/* Embedded in the docs playground, which supplies the title and the frame, so + the duplicated chrome comes off. */ +:root[data-embedded] .site, +:root[data-embedded] h1, +:root[data-embedded] footer { + display: none; +} + +:root[data-embedded] body { + background: var(--bg-primary); +} + +:root[data-embedded] .page { + padding: 1.25rem 1rem 2rem; +} + +:root[data-embedded] .lede { + font-size: 1rem; + margin-bottom: 1.25rem; } .response-container { @@ -53,10 +132,14 @@ body::before { border-radius: 5px; } +/* The label and the block below it are one sunken unit, parted by a rule + rather than by a colour step, so the strip does not disappear now that the + card behind it is plain white. */ .response-title { - background: var(--bg-secondary); - color: var(--text-primary); - padding: 3px 6px; + background: var(--bg-pre); + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + padding: 4px 8px; font-size: 12px; border-radius: 4px 4px 0 0; margin: 0; @@ -68,19 +151,21 @@ pre { padding: 12px; border-radius: 0 0 4px 4px; overflow-x: auto; - font-family: 'Courier New', monospace; + font-family: 'Commit Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; margin: 0; } button { - padding: 10px 20px; - font-size: 16px; + font: inherit; + font-weight: 600; cursor: pointer; + border: 0; + border-radius: 999px; + padding: 0.55rem 1.4rem; background: var(--button-bg); color: var(--button-foreground); - border: none; - border-radius: 4px; + box-shadow: 0 2px 8px light-dark(rgba(246, 130, 31, 0.34), rgba(0, 0, 0, 0.35)); transition: background 0.2s; } @@ -90,31 +175,53 @@ button:hover:not(:disabled) { } button:disabled { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.55; + cursor: progress; } -section { +button.secondary { + background: transparent; + color: var(--text-primary); border: 1px solid var(--border-color); - padding: 16px; - border-radius: 8px; + box-shadow: none; +} + +button.secondary:hover:not(:disabled) { background: var(--bg-secondary); + color: var(--text-primary); } -h1 { - margin-top: 0; +section { + border: 1px solid var(--border-color); + padding: 1.1rem 1.25rem; + border-radius: 0.75rem; + background: var(--bg-secondary); + box-shadow: var(--shadow); + margin-top: 2rem; } h2 { margin-top: 0; + font-size: 1.15rem; + letter-spacing: -0.01em; color: var(--text-primary); } +.validation-error { + color: light-dark(#b32a20, #ff9d94); + margin: 0.75rem 0 0; + white-space: pre-wrap; + background: var(--bg-pre); + border-radius: 0.4rem; + padding: 0.7rem 0.9rem; + font-size: 0.9rem; +} + code { background-color: var(--bg-pre); padding: 2px 4px; border-radius: 3px; - font-family: 'Courier New', monospace; + font-family: 'Commit Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; } diff --git a/examples/worker-react/client/src/main/App.tsx b/examples/worker-react/client/src/main/App.tsx index 3afecff5..f3db45ca 100644 --- a/examples/worker-react/client/src/main/App.tsx +++ b/examples/worker-react/client/src/main/App.tsx @@ -1,131 +1,26 @@ import { useCallback, useMemo, useState } from 'react' -import { newHttpBatchRpcSession } from 'capnweb' -import { validateStub } from 'capnweb-validate' -import type { Api } from '../../../server/worker' +import { + createFetchInstrument, + runPipelined, + runSequential, + runValidationFailure, + type Result, + type Trace, +} from './runs' import './App.css' -type Result = { - posts: number - ms: number - user: any - profile: any - notifications: any - trace: Trace -} - -type CallEvent = { label: string, start: number, end: number } -type NetEvent = { label: string, start: number, end: number } -type Trace = { total: number, calls: CallEvent[], network: NetEvent[] } - -function connectApi() { - return validateStub(newHttpBatchRpcSession('/api')) -} - export function App() { const [pipelined, setPipelined] = useState(null) const [sequential, setSequential] = useState(null) const [running, setRunning] = useState(false) const [validationError, setValidationError] = useState(null) - // Network RTT is now simulated on the server (Worker). See wrangler.jsonc vars. - - /** Count RPC POSTs and capture network timing by wrapping fetch while this component is mounted. */ - const wrapFetch = useMemo(() => { - let posts = 0 - let origin = 0 - let events: NetEvent[] = [] - const orig = globalThis.fetch - function install() { - ;(globalThis as any).fetch = async (input: RequestInfo, init?: RequestInit) => { - const method = (init?.method) || (input instanceof Request ? input.method : 'GET') - const url = input instanceof Request ? input.url : String(input) - if (url.endsWith('/api') && method === 'POST') { - posts++ - const start = performance.now() - origin - const resp = await orig(input as any, init) - const end = performance.now() - origin - events.push({ label: 'POST /api', start, end }) - return resp - } - return orig(input as any, init) - } - } - function uninstall() { ;(globalThis as any).fetch = orig } - function get() { return posts } - function reset() { posts = 0; events = [] } - function setOrigin(o: number) { origin = o } - function getEvents(): NetEvent[] { return events.slice() } - return { install, uninstall, get, reset, setOrigin, getEvents } - }, []) - - const runPipelined = useCallback(async () => { - wrapFetch.reset() - const t0 = performance.now() - wrapFetch.setOrigin(t0) - const calls: CallEvent[] = [] - const api = connectApi() - const userStart = 0; calls.push({ label: 'authenticate', start: userStart, end: NaN }) - const user = api.authenticate('cookie-123') - user.then(() => { calls.find(c => c.label==='authenticate')!.end = performance.now() - t0 }) - - const profStart = performance.now() - t0; calls.push({ label: 'getUserProfile', start: profStart, end: NaN }) - const profile = api.getUserProfile(user.id) - profile.then(() => { calls.find(c => c.label==='getUserProfile')!.end = performance.now() - t0 }) - - const notiStart = performance.now() - t0; calls.push({ label: 'getNotifications', start: notiStart, end: NaN }) - const notifications = api.getNotifications(user.id) - notifications.then(() => { calls.find(c => c.label==='getNotifications')!.end = performance.now() - t0 }) - - const [u, p, n] = await Promise.all([user, profile, notifications]) - const t1 = performance.now() - const net = wrapFetch.getEvents() - const total = t1 - t0 - // Ensure any missing ends are set - calls.forEach(c => { if (!Number.isFinite(c.end)) c.end = total }) - return { posts: wrapFetch.get(), ms: total, user: u, profile: p, notifications: n, - trace: { total, calls, network: net } } - }, [wrapFetch]) - - const runSequential = useCallback(async () => { - wrapFetch.reset() - const t0 = performance.now() - wrapFetch.setOrigin(t0) - const calls: CallEvent[] = [] - const api1 = connectApi() - const aStart = 0; calls.push({ label: 'authenticate', start: aStart, end: NaN }) - const uPromise = api1.authenticate('cookie-123') - uPromise.then(() => { calls.find(c => c.label==='authenticate')!.end = performance.now() - t0 }) - const u = await uPromise - - const api2 = connectApi() - const pStart = performance.now() - t0; calls.push({ label: 'getUserProfile', start: pStart, end: NaN }) - const pPromise = api2.getUserProfile(u.id) - pPromise.then(() => { calls.find(c => c.label==='getUserProfile')!.end = performance.now() - t0 }) - const p = await pPromise + // Network RTT is simulated on the server (Worker). See wrangler.jsonc vars. + const wrapFetch = useMemo(createFetchInstrument, []) - const api3 = connectApi() - const nStart = performance.now() - t0; calls.push({ label: 'getNotifications', start: nStart, end: NaN }) - const nPromise = api3.getNotifications(u.id) - nPromise.then(() => { calls.find(c => c.label==='getNotifications')!.end = performance.now() - t0 }) - const n = await nPromise - - const t1 = performance.now() - const net = wrapFetch.getEvents() - const total = t1 - t0 - calls.forEach(c => { if (!Number.isFinite(c.end)) c.end = total }) - return { posts: wrapFetch.get(), ms: total, user: u, profile: p, notifications: n, - trace: { total, calls, network: net } } - }, [wrapFetch]) - - const runValidationFailure = useCallback(async () => { + const showValidationFailure = useCallback(async () => { setValidationError(null) - const api = connectApi() as any - try { - await api.authenticate(12345) - setValidationError('(no error — unexpected)') - } catch (err) { - setValidationError(err instanceof Error ? err.message : String(err)) - } + setValidationError(await runValidationFailure()) }, []) const runDemo = useCallback(async () => { @@ -133,78 +28,87 @@ export function App() { setRunning(true) wrapFetch.install() try { - const piped = await runPipelined() - setPipelined(piped) - const seq = await runSequential() - setSequential(seq) + setPipelined(await runPipelined(wrapFetch)) + setSequential(await runSequential(wrapFetch)) } finally { wrapFetch.uninstall() setRunning(false) } - }, [running, wrapFetch, runPipelined, runSequential]) + }, [running, wrapFetch]) return ( -
      -

      Cap'n Web: Cloudflare Workers + React

      -
      Network RTT (round-trip-time) is simulated on the server (configurable via SIMULATED_RTT_MS/SIMULATED_RTT_JITTER_MS in wrangler.jsonc).
      -

      This demo calls the Worker API in two ways:

      -
        -
      • Pipelined (batched): dependent calls in one round trip
      • -
      • Sequential (non-batched): three separate round trips
      • -
      - - -
      -

      Validation

      -

      Calls authenticate(12345) instead of a string — the server rejects the wrong-typed argument.

      - - {validationError && ( -
      {validationError}
      - )} -
      - - {(pipelined && sequential) ? (<> -
      -

      Pipelined (batched)

      -
      HTTP POSTs: {pipelined.posts}
      -
      Time: {pipelined.ms.toFixed(1)} ms
      - -
      -
      Response
      -
      {JSON.stringify({
      -              user: pipelined.user,
      -              profile: pipelined.profile,
      -              notifications: pipelined.notifications,
      -            }, null, 2)}
      -
      + <> +
      Cap'n Web: Workers + React
      + +
      +

      One round trip, from a React app

      +

      + Three dependent calls to a Worker, made both ways: pipelined into a single request, and + sequentially in three. The timeline shows when each call was in flight. Latency is + simulated on the server, so the work is identical either way; only the round trips + differ. +

      + + +
      +

      Validation

      +

      Calls authenticate(12345) instead of a string. The server rejects the wrong-typed argument.

      + + {validationError &&
      {validationError}
      }
      -
      -

      Sequential (non-batched)

      -
      HTTP POSTs: {sequential.posts}
      -
      Time: {sequential.ms.toFixed(1)} ms
      - -
      -
      Response
      -
      {JSON.stringify({
      -              user: sequential.user,
      -              profile: sequential.profile,
      -              notifications: sequential.notifications,
      -            }, null, 2)}
      -
      -
      - -
      -

      Summary

      -
      Pipelined: {pipelined.posts} POST, {pipelined.ms.toFixed(1)} ms
      -
      -
      Sequential: {sequential.posts} POSTs, {sequential.ms.toFixed(1)} ms
      -
      -
      - ) : null} -
      + {(pipelined && sequential) ? (<> +
      +

      Pipelined (batched)

      +
      HTTP POSTs: {pipelined.posts}
      +
      Time: {pipelined.ms.toFixed(1)} ms
      + +
      +
      Response
      +
      {JSON.stringify({
      +                user: pipelined.user,
      +                profile: pipelined.profile,
      +                notifications: pipelined.notifications,
      +              }, null, 2)}
      +
      +
      + +
      +

      Sequential (non-batched)

      +
      HTTP POSTs: {sequential.posts}
      +
      Time: {sequential.ms.toFixed(1)} ms
      + +
      +
      Response
      +
      {JSON.stringify({
      +                user: sequential.user,
      +                profile: sequential.profile,
      +                notifications: sequential.notifications,
      +              }, null, 2)}
      +
      +
      + +
      +

      Summary

      +
      Pipelined: {pipelined.posts} POST, {pipelined.ms.toFixed(1)} ms
      +
      +
      Sequential: {sequential.posts} POSTs, {sequential.ms.toFixed(1)} ms
      +
      +
      + ) : null} + + + + ) } diff --git a/examples/worker-react/client/src/main/runs.ts b/examples/worker-react/client/src/main/runs.ts new file mode 100644 index 00000000..a5d4e81c --- /dev/null +++ b/examples/worker-react/client/src/main/runs.ts @@ -0,0 +1,150 @@ +// Every RPC call this app makes, and the instrumentation used to time them. +// Kept out of App.tsx so the comparison can be read without the chart and the +// layout around it. Nothing here touches React or the DOM. +import { newHttpBatchRpcSession } from 'capnweb' +import { validateStub } from 'capnweb-validate' +import type { Api } from '../../../server/worker' + +export type CallEvent = { label: string, start: number, end: number } +export type NetEvent = { label: string, start: number, end: number } +export type Trace = { total: number, calls: CallEvent[], network: NetEvent[] } + +export type Result = { + posts: number + ms: number + user: any + profile: any + notifications: any + trace: Trace +} + +/** + * A new session. `validateStub` wraps it so arguments and return values are + * checked against the server's types at the boundary -- see runValidationFailure. + */ +function connectApi() { + return validateStub(newHttpBatchRpcSession('/api')) +} + +export type FetchInstrument = ReturnType + +/** + * Counts RPC POSTs and records when each one was in flight, by replacing + * `fetch` for as long as it is installed. Latency itself is simulated on the + * Worker (see `SIMULATED_RTT_MS` in wrangler.jsonc), so this only observes. + */ +export function createFetchInstrument() { + let posts = 0 + let origin = 0 + let events: NetEvent[] = [] + const orig = globalThis.fetch + + return { + install() { + ;(globalThis as any).fetch = async (input: RequestInfo, init?: RequestInit) => { + const method = (init?.method) || (input instanceof Request ? input.method : 'GET') + const url = input instanceof Request ? input.url : String(input) + if (url.endsWith('/api') && method === 'POST') { + posts++ + const start = performance.now() - origin + const resp = await orig(input as any, init) + const end = performance.now() - origin + events.push({ label: 'POST /api', start, end }) + return resp + } + return orig(input as any, init) + } + }, + uninstall() { ;(globalThis as any).fetch = orig }, + get() { return posts }, + reset() { posts = 0; events = [] }, + setOrigin(o: number) { origin = o }, + getEvents(): NetEvent[] { return events.slice() }, + } +} + +/** + * One session, three dependent calls, one round trip. `user` is never awaited + * before `user.id` is passed to the next two calls, so those travel as promise + * references in the same batch rather than waiting for a value to come back. + */ +export async function runPipelined(wrapFetch: FetchInstrument): Promise { + wrapFetch.reset() + const t0 = performance.now() + wrapFetch.setOrigin(t0) + const calls: CallEvent[] = [] + const api = connectApi() + + const userStart = 0; calls.push({ label: 'authenticate', start: userStart, end: NaN }) + const user = api.authenticate('cookie-123') + user.then(() => { calls.find(c => c.label==='authenticate')!.end = performance.now() - t0 }) + + const profStart = performance.now() - t0; calls.push({ label: 'getUserProfile', start: profStart, end: NaN }) + const profile = api.getUserProfile(user.id) + profile.then(() => { calls.find(c => c.label==='getUserProfile')!.end = performance.now() - t0 }) + + const notiStart = performance.now() - t0; calls.push({ label: 'getNotifications', start: notiStart, end: NaN }) + const notifications = api.getNotifications(user.id) + notifications.then(() => { calls.find(c => c.label==='getNotifications')!.end = performance.now() - t0 }) + + const [u, p, n] = await Promise.all([user, profile, notifications]) + const t1 = performance.now() + const net = wrapFetch.getEvents() + const total = t1 - t0 + // Ensure any missing ends are set + calls.forEach(c => { if (!Number.isFinite(c.end)) c.end = total }) + return { posts: wrapFetch.get(), ms: total, user: u, profile: p, notifications: n, + trace: { total, calls, network: net } } +} + +/** + * The same three calls, each awaited before the next can be built. Three + * sessions, three round trips -- the value of `u.id` has to arrive in the + * browser before the second call can name it. + */ +export async function runSequential(wrapFetch: FetchInstrument): Promise { + wrapFetch.reset() + const t0 = performance.now() + wrapFetch.setOrigin(t0) + const calls: CallEvent[] = [] + + const api1 = connectApi() + const aStart = 0; calls.push({ label: 'authenticate', start: aStart, end: NaN }) + const uPromise = api1.authenticate('cookie-123') + uPromise.then(() => { calls.find(c => c.label==='authenticate')!.end = performance.now() - t0 }) + const u = await uPromise + + const api2 = connectApi() + const pStart = performance.now() - t0; calls.push({ label: 'getUserProfile', start: pStart, end: NaN }) + const pPromise = api2.getUserProfile(u.id) + pPromise.then(() => { calls.find(c => c.label==='getUserProfile')!.end = performance.now() - t0 }) + const p = await pPromise + + const api3 = connectApi() + const nStart = performance.now() - t0; calls.push({ label: 'getNotifications', start: nStart, end: NaN }) + const nPromise = api3.getNotifications(u.id) + nPromise.then(() => { calls.find(c => c.label==='getNotifications')!.end = performance.now() - t0 }) + const n = await nPromise + + const t1 = performance.now() + const net = wrapFetch.getEvents() + const total = t1 - t0 + calls.forEach(c => { if (!Number.isFinite(c.end)) c.end = total }) + return { posts: wrapFetch.get(), ms: total, user: u, profile: p, notifications: n, + trace: { total, calls, network: net } } +} + +/** + * Deliberately passes a number where the server declares a string. Returns the + * rejection message, which comes from the validation wrapper rather than from + * anything the server had to hand-write. + */ +export async function runValidationFailure(): Promise { + const api = connectApi() as any + try { + await api.authenticate(12345) + return '(no error thrown, which is unexpected)' + } catch (err) { + return err instanceof Error ? err.message : String(err) + } +} diff --git a/examples/worker-react/wrangler.jsonc b/examples/worker-react/wrangler.jsonc index 81cba45c..116cf4e9 100644 --- a/examples/worker-react/wrangler.jsonc +++ b/examples/worker-react/wrangler.jsonc @@ -19,6 +19,9 @@ "assets": { "directory": "client/dist" }, + "observability": { + "enabled": true + }, "vars": { // Optional per-method artificial delays (ms) "DELAY_AUTH_MS": 80, diff --git a/package-lock.json b/package-lock.json index 49febfb3..64796443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "workspaces": [ ".", - "packages/*" + "packages/*", + "!packages/docs" ], "devDependencies": { "@changesets/changelog-github": "^0.5.2", @@ -20,12 +21,14 @@ "@types/bun": "^1.2.0", "@types/ws": "^8.18.1", "@vitest/browser": "^3.2.7", + "markdownlint-cli2": "^0.23.2", "pkg-pr-new": "^0.0.60", "playwright": "^1.56.1", "tsdown": "^0.22.0", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^3.2.7", + "wrangler": "4.63.0", "ws": "^8.21.1" } }, @@ -3348,6 +3351,19 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@speed-highlight/core": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", @@ -3428,6 +3444,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3449,6 +3475,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", @@ -3459,6 +3499,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3833,6 +3880,39 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -3873,6 +3953,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -3934,6 +4024,20 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decode-uri-component": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", @@ -3998,6 +4102,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -4073,6 +4191,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -4270,6 +4401,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.13.3", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.3.tgz", @@ -4381,6 +4525,43 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4404,6 +4585,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4414,6 +4606,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -4457,126 +4662,882 @@ "dev": true, "license": "ISC" }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdownlint": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.1.tgz", + "integrity": "sha512-qHKeU2E1bdyNAT077go2FVTNXvYcktN5IHtF6XyeD1l0PClxzSp2tUApAV14ORI8DGX4H9bNKZEzelZp4qn8IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.2.1" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.23.2.tgz", + "integrity": "sha512-eUhcnkSpzURo/o4htSqc7LPDszgOOTknhU4eY/sPHvMCLxnTCYscv1gw1/js/idmaZPisv9ECVEIORcllqjTUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "16.2.2", + "js-yaml": "5.2.2", + "jsonc-parser": "3.3.1", + "jsonpointer": "5.0.1", + "markdown-it": "14.3.0", + "markdownlint": "0.41.1", + "markdownlint-cli2-formatter-default": "0.0.6", + "micromatch": "4.0.8", + "smol-toml": "1.7.0" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.6.tgz", + "integrity": "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/markdownlint-cli2/node_modules/globby": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz", + "integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli2/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/markdownlint-cli2/node_modules/js-yaml": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/markdownlint-cli2/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/puzrin" + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" }, { - "type": "github", - "url": "https://github.com/sponsors/nodeca" + "type": "OpenCollective", + "url": "https://opencollective.com/unified" } ], "license": "MIT", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "micromark-util-types": "^2.0.0" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4837,6 +5798,26 @@ "quansync": "^0.2.7" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5045,6 +6026,16 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -5516,6 +6507,19 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5581,6 +6585,52 @@ "node": ">=0.6.19" } }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5964,6 +7014,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -6032,6 +7089,19 @@ "pathe": "^2.0.3" } }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universal-user-agent": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", diff --git a/package.json b/package.json index 0814a6f7..e6f3211f 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,17 @@ }, "workspaces": [ ".", - "packages/*" + "packages/*", + "!packages/docs" ], "scripts": { "build": "tsdown --config-loader native && node scripts/check-dist-ascii.mjs && npm run -w capnweb-validate build", "build:watch": "tsdown --config-loader native --watch", + "dev": "npm run build:watch", + "dev:docs": "npm run build && npm --prefix packages/docs run dev -- --port 4321", + "lint:md": "markdownlint-cli2 && node scripts/align-markdown-tables.mjs --check", + "lint:md:fix": "node scripts/align-markdown-tables.mjs && markdownlint-cli2 --fix", + "setup": "npm install && npm --prefix packages/docs install && npm --prefix examples/worker-react/client install", "test": "vitest run", "test:bun": "bun test __tests__/bun.test.ts", "test:ci": "vitest run && bun test __tests__/bun.test.ts", @@ -52,12 +58,14 @@ "@types/bun": "^1.2.0", "@types/ws": "^8.18.1", "@vitest/browser": "^3.2.7", + "markdownlint-cli2": "^0.23.2", "pkg-pr-new": "^0.0.60", "playwright": "^1.56.1", "tsdown": "^0.22.0", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^3.2.7", + "wrangler": "4.63.0", "ws": "^8.21.1" }, "repository": { @@ -67,5 +75,5 @@ "bugs": { "url": "https://github.com/cloudflare/capnweb/issues" }, - "homepage": "https://github.com/cloudflare/capnweb#readme" + "homepage": "https://capnweb.com" } diff --git a/packages/capnweb-validate/README.md b/packages/capnweb-validate/README.md index 41026603..b439a55f 100644 --- a/packages/capnweb-validate/README.md +++ b/packages/capnweb-validate/README.md @@ -12,6 +12,8 @@ error instead of silently running without validation. ## Install +Two packages, or one if you are on Workers RPC: + ```sh npm install capnweb capnweb-validate ``` @@ -22,6 +24,9 @@ helpers live under `capnweb-validate/capnweb` and internal transform outputs. ## Server Usage +Decorate the class you expose. Every call that arrives is checked against the +method's declared parameter types before your code runs: + ```ts import { newWorkersRpcResponse, RpcTarget } from "capnweb"; import { validateRpc } from "capnweb-validate"; @@ -197,9 +202,9 @@ try { Where errors surface depends on which boundary failed: -| Boundary | Failure | How it surfaces | -| -------- | ------- | --------------- | -| Client stub | Bad resolved return | The returned promise rejects. | +| Boundary | Failure | How it surfaces | +| ------------- | --------------------- | ----------------------------------------------------------- | +| Client stub | Bad resolved return | The returned promise rejects. | | Server target | Bad incoming argument | The server throws and the caller observes an RPC rejection. | ## Current Type Coverage @@ -243,12 +248,12 @@ not match the supported `Blob` validator. transform refuses to compile a service that uses them so the user finds out at build time, not at the first RPC call: -| Type | Build error hint | -| ------------------ | ---------------------------------------------------------- | -| `WeakMap` | `WeakMap` is not a supported RPC validation type. | -| `WeakSet` | `WeakSet` is not a supported RPC validation type. | -| `SharedArrayBuffer`| `SharedArrayBuffer` is not a supported RPC validation type.| -| `File` | Use a `Blob` or `Uint8Array`; `File` is not supported. | +| Type | Build error hint | +| ------------------- | ----------------------------------------------------------- | +| `WeakMap` | `WeakMap` is not a supported RPC validation type. | +| `WeakSet` | `WeakSet` is not a supported RPC validation type. | +| `SharedArrayBuffer` | `SharedArrayBuffer` is not a supported RPC validation type. | +| `File` | Use a `Blob` or `Uint8Array`; `File` is not supported. | If a method signature contains a leaf the resolver cannot lower, such as a generic type parameter with no inference source, an unsupported recursive corner, or a rejected diff --git a/packages/docs/.gitignore b/packages/docs/.gitignore new file mode 100644 index 00000000..aeb23a6f --- /dev/null +++ b/packages/docs/.gitignore @@ -0,0 +1,20 @@ +# build output +dist/ +# generated types +.astro/ +# nimbus build scratch: materialized lint config and the route manifest. +# `nimbus.json` next to it is the opposite -- hand-owned, and committed. +.nimbus/ +# wrangler local state +.wrangler/ +# dependencies +node_modules/ +# environment variables +.env +.env.production +.dev.vars +# generated at build time: the measured bundle size, and the playground bundles +src/generated/ +public/playground/ +# pagefind writes its index into dist/, but leaves a cache here +.pagefind/ diff --git a/packages/docs/AGENTS.md b/packages/docs/AGENTS.md new file mode 100644 index 00000000..b8cc7084 --- /dev/null +++ b/packages/docs/AGENTS.md @@ -0,0 +1,280 @@ +# The Cap'n Web docs site + +Astro, with [Nimbus](https://nimbus-docs.com) (`@cloudflare/nimbus-docs`) as the docs framework. The +package handles content schemas, sidebar/TOC, MDX to markdown, search, OG cards, `llms.txt`, build +hooks, and the `nimbus-docs` CLI. Everything in `src/` is a real file in this repo and yours to +edit, including the files the scaffold wrote. + +`README.md` next to this file explains why the site looks and works the way it does: the palette, +the page shell, the WebGL hero, the example playgrounds, the traps. Read it before changing anything +visual. + +## Working in here + +This package is **excluded from the repo's npm workspaces** and has its own `package-lock.json` and +`node_modules`, so install from this directory: + +```sh +cd packages/docs +npm install +npm run dev # http://localhost:4321 +npm run build # static output in ./dist +npm run check # astro check: types, content collections +npm run lint:docs +``` + +If `npm install` 404s on `@cloudflare/nimbus-docs`, your npmrc maps the `@cloudflare` scope to an +internal registry and these packages are on the public one: + +```sh +npm_config_@cloudflare:registry=https://registry.npmjs.org npm install +``` + +Don't commit an `.npmrc` to work around it, and don't add `wrangler` as a dependency here: the +version the starter asks for wants an unpublished miniflare. The root's wrangler deploys this. + +`predev` and `prebuild` run `bundle-size` and `playgrounds`. The playground bundler reads the +library's **build output**, so a change under the repo's `src/` needs `npm run build` at the root +before it shows up on an examples page. `npm run dev:docs` at the root does both. + +## File layout + +Where things are, and what each one is for: + +```text +astro.config.ts # nimbus(defineNimbusConfig({...})): sidebar, lint rules, markdown plugins +nimbus.json # what the scaffold and the registry installed. Committed. +.nimbus/ # build scratch: materialized lint config, route manifest. Gitignored. +fonts/ # build-time only, for the OG cards. Not under public/ on purpose. +scripts/ +├── build-playgrounds.mjs # bundles each example's worker + client into public/playground/ +├── measure-bundle.mjs # writes src/generated/bundle-size.json +└── mdast-bundle-size.mjs # Sätteri plugin: %BUNDLE_SIZE% in .md bodies +src/ +├── components.ts # MDX globals registry -- every component used in .mdx must be listed +├── components/ # ours: Hero, Features, NavList, Playground, Prose, and +│ # canvas-hero/ (the landing figure and its harness) +│ └── ui// # from the Nimbus registry, plus AgentDirective, Header, Render +├── content/docs/**.{md,mdx} # the pages, one directory per sidebar group +├── content.config.ts # docsCollection() + partialsCollection() + the %BUNDLE_SIZE% transform +├── examples.ts # the single list of playground examples, read by pages and bundler +├── generated/ # bundle-size.json, written by prebuild. Gitignored. +├── layouts/ # BaseLayout (head, theme bootstrap), DocsLayout (three columns) +├── lib/ # cn.ts, source.ts (reads real files) +├── pages/ # [...slug].astro, 404, llms.txt, robots.txt, og/ +└── styles/ # globals.css (tokens + shell), prose.css +public/ # favicon, _headers, and the generated playground bundles +wrangler.jsonc # static assets on a Worker, no script +``` + +## Writing docs + +Frontmatter validates against Nimbus's `docsSchema`. `title` is required. Sidebar **groups** are +declared in `astro.config.ts`; position **within** a group comes from frontmatter: + +```mdx +--- +title: My page +description: One-line summary. +sidebar: + order: 3 +--- + +Content here. The H1 comes from `title` -- don't repeat it in the body. + +## Section heading +``` + +Rules: + +- **Components must be PascalCase and registered in `src/components.ts`.** A pre-build validator + fails the build on an unregistered tag, with a "did you mean" hint. +- **Partials use ``.** Don't import `.mdx` directly. +- **Icons are `astro-icon` + Phosphor**: ``, imported from + `@cloudflare/nimbus-docs/components/Icon.astro` rather than `astro-icon/components`, which is not + a dependency here. Glyphs: [phosphoricons.com](https://phosphoricons.com). +- **A `mode: custom` page gets a bare `
      `** -- no sidebar, no TOC, and no `.docs-content` + wrapper or width cap either, so its prose must be wrapped in `` or it renders unstyled and + edge to edge. +- **Never type the library's size into prose.** Write `%BUNDLE_SIZE%` and it is substituted from the + measured value, in bodies and in frontmatter alike. +- **Don't remove `` from `BaseLayout.astro`.** It points agents at `/llms.txt`. + +House style for the prose itself: no em dashes (` -- ` in text, which the markdown pipeline leaves +alone), every code fence gets a language, and no code block directly under an `##` heading -- say +what it is first. + +## Adding things + +| Goal | Action | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| New doc page | `src/content/docs//.md`, with `sidebar.order`. The group autogenerates. | +| New sidebar group | A directory under `src/content/docs/` and an `autogenerate` entry in `astro.config.ts`. | +| Off-site sidebar link | Give the group an `items:` array: `{ autogenerate }` first, then `{ label, link }`. Nimbus adds `target="_blank"`. | +| New partial | `src/content/partials/.mdx` (the collection is registered; there are none yet), then ``. | +| UI from the registry | `npx nimbus-docs add `, then register it in `src/components.ts` if MDX uses it. | +| New playground example | An entry in `src/examples.ts` (`files` and `build`), and a page under `src/content/docs/examples/`. | +| Custom page route | A file under `src/pages/`. | +| OG card restyle | `src/pages/og/_og-card-config.ts`. | +| Check it builds | `npx nimbus-docs check` -- build-free preflight. `--json` for an agent loop, `--fix` to repair what's safe. | +| Check for updates | `npx nimbus-docs outdated` -- starter files behind their tag, registry components behind. | +| Review an upstream change | `npx nimbus-docs diff `, then `diff --apply `. | +| Update a registry item | `npx nimbus-docs add --overwrite`, then read `git diff`. | + +`npx nimbus-docs list` shows what is installable. + +Ten starter files are modified here, so `diff --apply` wants review rather than a blind apply: the +two layouts, `[...slug].astro`, `404.astro`, `components.ts`, `content.config.ts`, `globals.css`, +the OG config, `tsconfig.json`, and `index.mdx`. `README.md` says why for each. + +Two registry components are modified too, so `add --overwrite` will silently undo the changes. +`breadcrumbs/Breadcrumbs.astro` and `page-actions/PageActions.astro` shipped their separators as +`text-muted-foreground/50` and `/40`. The alpha modifier is the `opacity` sin by another name -- the +breadcrumb `/` measured 2.19:1 in light -- so the breadcrumb separators inherit +`text-muted-foreground` from the `
        ` (6.22:1 light) and the page-action divider sits at `/70`, +which is 3.21:1 light and 5.38:1 dark: past the 3:1 line for a graphical object, still visibly +quieter than the buttons it separates. + +`PageActions.astro` also pins the "Updated" date to `config.locale` and `timeZone: "UTC"`. It +formatted with an `undefined` locale in the build machine's zone, and this is a static site, so the +string was whatever the builder's environment happened to be: the same commit renders `Aug 11, 2026` +here, `12. Aug. 2026` under `de_DE`/`Asia/Tokyo`, and `2026年8月12日` under `ja_JP`. Note the day +moves too, because the timestamp is a real instant from `git log %at`. + +## Audit this site + +Start with `npx nimbus-docs check --json`. It runs the environment, structural, authoring, and type +checks build-free -- config validity, `site` placeholder, route collisions, MDX component +resolution, the lint rules, and a `tsc` type-check -- and returns three top-level signals plus +per-scope detail: + +- **`status`** (`passed` | `failed` | `partial`) and **`readiness`** (`buildable` | `blocked` | + `unknown`) are the primary signals. `status` is the whole-run verdict; `readiness` answers "does + env + structure say it builds?". `ok` (=== zero errors) is kept for back-compat only. +- **`findings[{scope,code,severity,file,line,message,fixable,fix}]`** are problems we evaluated. + Apply each `fix` (or `check --fix`). +- **`scopes[].notes[{code,reason,requiresBuild?,requiresInput?}]`** are checks we *couldn't* + evaluate yet (e.g. types before a build). A note is never a finding and never carries a `fix` -- + you resolve it by making the missing thing exist (usually a build), not by `--fix`. + `summary.notes` counts them. + +Loop terminates on `status !== "failed" && summary.fixable === 0` -- a `partial` run with nothing +left to fix is a **stop** (optionally build, then re-check), not a `--fix` retry. Exit is `1` only +when `status` is `"failed"`. For full coverage (types + link-checking) run a build first, then +`check` again. + +Only two authoring rules are errors here (`nimbus/frontmatter-shape`, `nimbus/internal-link`); the +rest are off because the repo already lints markdown at the root. Then walk these categories for +what `check` doesn't cover: + +- **Config** -- `astro.config.ts` calls `nimbus(defineNimbusConfig({ ... }))`; `site` is set; + `editPattern` contains `{path}`; `output:` matches the deploy target. +- **Content** -- `content.config.ts` registers `docsCollection()` and `partialsCollection()`; every + `.mdx` is inside a registered collection; frontmatter validates. +- **Sidebar** -- every group in the config resolves to a directory with pages in it; no orphans; no + slug collisions. +- **MDX** -- every PascalCase component in `*.mdx` is registered; every `` + resolves; code-fence languages are valid. +- **Routes** -- `llms.txt.ts`, `robots.txt.ts`, `[...slug]/index.md.ts`, `og.png.ts`, + `og/[...slug].ts` all exist. +- **Registry hygiene** -- every `src/components/ui//` is either MDX-registered or imported in + `src/`; transitive deps (`lib/cn.ts`) exist. +- **AI surface** -- `` renders in `BaseLayout.astro`; doc `` has + ``. +- **Search** -- `data-pagefind-body` is on the docs main wrapper; after a build, `dist/pagefind/` + exists with at least one indexed page. +- **Cloudflare** -- `wrangler.jsonc` has `name`, `compatibility_date`, + `assets.directory = "./dist"`, `not_found_handling`. +- **Dead CSS** -- a selector that matches nothing on any page is usually a rule left pointing at a + vendor the site no longer uses. That is how the playground's Expressive Code rules were found. + +Emit findings as `- [error|warn|info] FILE:LINE -- what + why + fix.` and end with +`Summary: N errors, N warnings.` + +## Don't + +- Hand-add a component under `src/components/ui/` that the registry already has -- use + `nimbus-docs add` so its dependencies come with it. +- Import `.mdx` files directly. Use ``. +- Attach remark/rehype plugins via `mdx({ remarkPlugins })`: Sätteri silently drops them. + Markdown transformations go in `markdown.mdastPlugins` / `hastPlugins`, and a Sätteri plugin is a + visitor over read-only nodes that writes through `context.setProperty`. +- Edit `src/components.ts` to bypass registration. If MDX uses a component, register it. +- Spend the tomato accent (`--cw-orange`, which is also `--nb-primary`) on anything else. It is the + call to action and almost nothing else -- prose links are ink, not accent -- and that restraint is + the point. If you do set text in it, use `--cw-orange-text`: the brand tomato is 3.1:1 on the + paper, and the darkened variant exists so light mode has a legal way to say the same thing. +- Assume the landing page is dark. It was, and is not any more -- it honours the toggle like every + other page, and the hero figure reads its colours from the same tokens as the prose. + `.cw-home` marks the page, not a scheme. +- Treat the hero figure as decoration. It is the page's argument, in the flow, at full contrast, so + its text is type: contrast-measured in both schemes, never dimmed with `globalAlpha`, and carried + in words by the `sr-only` `
        ` that `CanvasFigure.astro` requires as a prop. Any number + the scene draws has to match the caption in `lib/hero-copy.ts`, and nothing checks that for you. +- Freeze a reduced-motion still on a moment rather than composing one. A still is the only frame + some visitors ever see, so it has to carry the whole argument. Watch for fades in particular: the + figure once froze at the exact instant its slow verdict began fading in, so the number it exists + to show was painted at `globalAlpha` 0. Pixel and screenshot checks cannot see that -- assert on + the draw calls, and treat anything drawn at alpha 0 in a still as a bug. +- Size the figure's layout in breakpoints. Its margins are budgeted from the measured width of the + strings that go in them, which is why shortening one label narrowed the whole middle of the + figure. A width threshold cannot see what is beside a rail: the first cut used one and ran the + call labels straight through an axis tick at 900px. +- Judge the figure by its painted percentage. `painted > 0` says nothing about whether the thing + drew what it meant to. Check the scene's own draw calls. +- Dim text with `opacity` to make it secondary. `--nb-muted-foreground` is already that, measured; + multiplying it by 0.6 is how the figure captions ended up the least readable text on the site. + Tailwind's alpha modifier is the same sin with better manners: `text-muted-foreground/50` is not a + colour choice, it is 2.19:1. +- Set the wordmark as live ``, or hand-edit `logo-paths.ts`. It is generated by + `scripts/build-wordmark.mjs`; change the tilt, size, tracking or jitter there and regenerate. A + logo that falls back to Georgia is not the logo. +- Swap the wordmark's face to URW Bookman because it is already installed and is what the + reference uses. It is AGPL-3 and its font exception covers only Postscript and PDF, not SVG. + TeX Gyre Bonum Bold is the same Bookman design under the GUST Font License. +- Merge the wordmark's per-glyph paths into one path per line. The fill then floods their union + and swallows the keyline wherever two letters touch. +- Theme the mark or the seal. Both are fixed in both schemes -- white fill with a black keyline, + and tomato with `--cw-black` lettering at 5.69:1 -- because they are stamped objects rather than + page furniture. +- Take the seal's invisible `background` off `.cw-star-text`. It is the same tomato it sits on, and + it exists so contrast tools measure the real pair instead of walking past the SVG to the page. +- Regularise the wordmark's per-glyph jitter. The `rot`/`dy`/`scale` arrays are measured off the + reference, not decoration: a hand-set mark is what is being parodied, and zeroing them makes it + visibly deader. +- Soften the hero banner's bottom edge. It ends on a hard line because a cereal box is printed + rather than blended, and the seal crossing that line is what stops the band reading as a floating + slab. Nothing from the banner down may set `overflow: hidden`. +- Lighten the light-mode banner again. It is at its ceiling: the white mark averages 5.07:1 across + the band and is at exactly 4.50:1 over the brightest corner, so there is no headroom left. A pale + band means a black-filled mark, which is a different logo. +- Lower `.cw-hero-lockup`'s `max-width` to shrink the mark on a phone. That is the `min(100%, cap)` + branch that is not taken there; the band's inline padding is what sets the size below 48rem. +- Straighten the mark's left nudge because the artwork looks centred without it. The nudge offsets + the seal's visual weight, which hangs off the right and is not in the mark's box at all. +- Resize either line of the lockup without re-checking the clearance the build prints. `LEAD` is a + multiple of the lower line's cap height, so changing `size` moves the leading with it, and the + reference's range is 17-34px. +- Hide the hero seal with `display: none` below `34rem`. Its legend is the page's `

        `, so it is + hidden the `sr-only` way and the heading survives in the outline and the accessibility tree. + Pick `34rem` over a new number if you move it: `Features.astro` already breaks there. +- Re-express the hero seal's overhang as a fraction of the seal. `--cw-seal-overhang` is a length + because that is what the eye reads, and because a fraction means every resize of the seal moves + it without anyone asking. The seal's `top` is derived from the overhang, not the reverse. +- Tilt the seal as a whole. `--cw-star-tilt` belongs on `.cw-star-shape` so the legend stays level + and `.cw-star`'s layout box stays measurable; rotating the wrapper inflates its bounding box by + 17% and every harness then has to divide that back out. +- Replace `--cw-hero-nudge`'s clamp with a constant. The nudge only fits where the lockup has hit + its cap and left slack beside it; a fixed value walks the mark off the left edge of a phone. +- Point the favicon at the hero's 20-point star. It is a separate 11-point star at a deeper 0.55 + ratio because the seal's own geometry turns into a fuzzy disc at tab size, and its fill is a + literal hex -- a favicon is its own document and inherits no custom properties. +- Reach for `--cw-mark-fill` / `--cw-mark-stroke` to recolour the wordmark. They exist so the + header can drop to a one-colour silhouette at 40px, where the keyline is a third of a pixel and + renders as haze. A differently coloured stamp is a different decision; make it deliberately. +- Try to size `Wordmark.astro` or `StarBadge.astro` from a parent's scoped CSS. Astro stamps a + child's root element with the *child's* scope hash, so the rule silently matches nothing. Size + the wrapper the parent owns and let the svg fill it. Custom properties do inherit through. +- Remove `.cw-hero-lede` or `.cw-hero-actions` because nothing styles them. They are measurement + hooks for the hero's vertical rhythm, which is a claim `/tmp/opencode/vgaps.mjs` checks. +- Remove `` unless asked. diff --git a/packages/docs/README.md b/packages/docs/README.md new file mode 100644 index 00000000..cff0996b --- /dev/null +++ b/packages/docs/README.md @@ -0,0 +1,935 @@ +# capnweb-docs + +The documentation website for Cap'n Web, built with [Astro](https://astro.build/) and +[Nimbus](https://nimbus-docs.com) (`@cloudflare/nimbus-docs`), Cloudflare's docs framework. + +It was a Starlight site until the port that `git log` on this directory records. Nimbus is a +different proposition: rather than a theme with override slots, it scaffolds the layouts, routes and +components **into the repo** as ordinary files. Nothing here is behind a plugin boundary, which is +why this file can explain the whole site, and why upgrading is a review rather than a version bump. + +`AGENTS.md` next to this file is the operating manual: the commands, the file tree, the authoring +rules. This file is why the site is the way it is. + +## Running it + +This package is **deliberately excluded from the repo's npm workspaces** (see `!packages/docs` in the +root `package.json`). The docs site pulls in Astro, Vite and a few hundred transitive dependencies, +and we don't want any of that hoisted into the tree that builds and tests the library itself. It +therefore has its own `package-lock.json` and its own `node_modules`. + +```sh +cd packages/docs +npm install + +npm run dev # dev server at http://localhost:4321 +npm run build # static output in ./dist +npm run preview # serve ./dist +npm run check # astro check (types + content collections) +``` + +`dev` and `build` are both preceded by `npm run playgrounds`, which bundles the examples into +`public/playground/`. That step reads the library's **build output**, so run `npm run build` at the +repo root first, or just use `npm run dev:docs` there, which does both. + +The examples no longer need to be running for the docs to work: their demos are bundled into the +pages. To run one as a real Worker over a real network, see `examples/README.md`. + +Two things about installing, both of which have cost time: + +**The `@cloudflare` scope may not resolve.** `@cloudflare/nimbus-docs` is on the public registry. A +machine whose npmrc maps that scope to an internal registry gets a 404 on install; override it for +the one command rather than committing an `.npmrc`: + +```sh +npm_config_@cloudflare:registry=https://registry.npmjs.org npm install +``` + +**Wrangler is not a dependency here.** The starter lists one, at a version that resolves to an +unpublished alpha of miniflare. The root's wrangler deploys this site, so the dependency is simply +absent; `npm run deploy` in this package picks up the root's, which npm puts on the path (4.63.0). + +## What Nimbus owns, and what we changed + +Nimbus provides the content schemas, the sidebar and table of contents, the markdown pipeline, the +search index, the OG-card routes, the `llms.txt` family of routes, and the `nimbus-docs` CLI. What it +does **not** do is own the layouts: `src/layouts`, `src/pages`, `src/components/ui` and +`src/styles` are files the scaffold wrote into this repo and we have been editing ever since. + +That is a real trade. There is no `starlight.config` to read to find out what the page does, and no +upstream fix arrives on its own. In exchange, every question about this site has an answer in this +directory, and the framework cannot be blamed for anything visible. + +The CLI tracks which of those files came from the scaffold and at what version: + +```sh +npx nimbus-docs outdated # starter files behind their tag, registry components behind +npx nimbus-docs diff # what upstream changed vs what we changed +npx nimbus-docs check # build-free preflight: env, structure, authoring, types +``` + +Ten scaffold files are modified, so an upgrade to any of them is a merge and not an apply: + +| File | Why it diverges | +| --------------------------------- | ------------------------------------------------------------------------------------------- | +| `src/styles/globals.css` | The theme: palette, tokens, page shell, code chrome. Most of the port lives here. | +| `src/layouts/BaseLayout.astro` | The theme bootstrap: `is:inline`, dark as the no-preference answer, `data-theme` published. | +| `src/layouts/DocsLayout.astro` | Marks `
        ` as the content sheet. | +| `src/pages/[...slug].astro` | Serves the root index entry at `/` rather than `/index`. | +| `src/pages/404.astro` | `id="main-content"` on `
        `, without which the skip link goes nowhere. | +| `src/components.ts` | Registers our three components as MDX globals. | +| `src/content.config.ts` | The `%BUNDLE_SIZE%` frontmatter transform. | +| `src/pages/og/_og-card-config.ts` | Card palette, and the font moved out of `public/`. | +| `tsconfig.json` | Excludes the generated playground bundles; no deprecated `baseUrl`. | +| `src/content/docs/index.mdx` | It is our landing page. | + +Three of those are fixes to the starter rather than customisations, and should go upstream: the +404's missing skip-link target, the theme bootstrap emitting a deferred ``, + ); + if (!tag.test(out)) { + throw new Error(`No '); + } + + // The shim has to be evaluated before any client code, so it goes in front + // of the first module script rather than at the end of . + const first = out.indexOf('\n\t\t'; + out = out.slice(0, first) + head + out.slice(first); + + if (hasClientCss) { + out = out.replace('', '\t\n\t'); + } + return out; +} + +async function buildExample(example) { + const { slug, build: config } = example; + const outDir = path.join(outRoot, slug); + await mkdir(path.join(outDir, 'vendor'), { recursive: true }); + + // One shared copy of the library, as a real file the page imports. + await writeFile(path.join(outDir, 'vendor', 'capnweb.js'), await readFile(fromRoot('dist/index.js'))); + + const wrangler = parseJsonc(await readFile(fromRoot(config.wrangler), 'utf8'), config.wrangler); + const env = wrangler.vars ?? {}; + + // The shim imports the Worker by absolute path, so it needs a stable dir to + // be resolved from; the example's own directory keeps its relative imports + // and its node_modules working. + const shimPath = fromRoot(path.dirname(config.server), `.playground-entry-${slug}.mjs`); + const serverSpecifier = './' + path.basename(config.server); + await writeFile( + shimPath, + config.wsPath + ? socketShimSource({ + server: serverSpecifier, + mainExport: config.mainExport ?? 'createMain', + wsPath: config.wsPath, + env, + }) + : shimSource({ server: serverSpecifier, rpcPath: config.rpcPath, env }), + ); + + try { + await bundle({ + entry: shimPath, + outfile: path.join(outDir, 'runtime.js'), + alias: config.alias, + validate: config.validate?.server, + }); + } finally { + await rm(shimPath, { force: true }); + } + + if (config.client) { + await bundle({ + entry: fromRoot(config.client), + outfile: path.join(outDir, 'client.js'), + alias: config.alias, + validate: config.validate?.client, + }); + } + + // Static files the page references directly. A zero-build example keeps its + // stylesheet as a plain file rather than importing it from JavaScript, so + // there is nothing for esbuild to emit and it has to be copied. + for (const asset of config.assets ?? []) { + const name = path.basename(asset); + await writeFile(path.join(outDir, name), await readFile(fromRoot(asset))); + } + + const html = await readFile(fromRoot(config.html), 'utf8'); + await writeFile( + path.join(outDir, 'index.html'), + rewriteHtml(html, { + clientScript: config.clientScript, + // Only when esbuild actually emitted one -- it does that for a client + // that imports CSS, and not otherwise. + hasClientCss: existsSync(path.join(outDir, 'client.css')), + }), + ); + + return `${slug} -> public/playground/${slug}/`; +} + +const { examples } = await import(pathToFileURL(path.join(docsRoot, 'src', 'examples.ts')).href); + +await rm(outRoot, { recursive: true, force: true }); +for (const example of examples) { + console.log(' playground:', await buildExample(example)); +} diff --git a/packages/docs/scripts/build-wordmark.mjs b/packages/docs/scripts/build-wordmark.mjs new file mode 100644 index 00000000..bbf21a0a --- /dev/null +++ b/packages/docs/scripts/build-wordmark.mjs @@ -0,0 +1,665 @@ +/* + * Regenerates the Cap'n Web wordmark and seal: + * + * src/components/logo-paths.ts the lockup and the seal, as SVG path data + * public/favicon.svg the seal alone, as a standalone file + * + * Not part of the build. It runs when the mark itself changes, which is close + * to never, and it needs a font file and `opentype.js` that the site does not + * otherwise depend on: + * + * npm i opentype.js + * curl -sLO https://www.gust.org.pl/projects/e-foundry/tex-gyre/bonum/qbk2.004otf.zip + * unzip -j qbk2.004otf.zip 'texgyrebonum-bold.otf' + * node scripts/build-wordmark.mjs texgyrebonum-bold.otf + * + * Letterforms are TeX Gyre Bonum Bold, a Bookman clone under the GUST Font + * License (a free licence modelled on the LPPL). Converting glyphs to outlines + * is ordinary use of a font; only the resulting paths ship, never the font. + * + * URW Bookman Demi is the same design and is what capnproto.org's own mark is + * set in, but it is AGPL-3 and its font exception covers only "a Postscript or + * PDF file" -- not SVG on a web page. Bonum is the same shapes without that + * problem. Do not swap it back. + * + * --------------------------------------------------------------------------- + * Everything below is in the coordinate space of capnproto.org's `logo.png`, + * which is 635px wide, so the measurements taken off that image are the numbers + * written here. The mark is a parody of it and wants to sit at the same + * proportions. + */ +import opentype from 'opentype.js'; +import fs from 'node:fs'; + +const fontPath = process.argv[2] ?? new URL('./texgyrebonum-bold.otf', import.meta.url); +const font = opentype.parse(fs.readFileSync(fontPath).buffer); + +/** Cap height as a fraction of em, used to space the two lines off each other. */ +const CAP = font.charToGlyph('H').getMetrics().yMax / font.unitsPerEm; + +/* + * The reference is not text on a circle, which is what it looks like at a + * glance. Fitting each glyph of `logo.png` independently -- best scale and + * rotation by intersection-over-union -- and then least-squares fitting a + * baseline through the results gives a straight, tilted line per word, with the + * letters scattered around it. + * + * What makes it look hand-lettered rather than typed is that scatter, and it is + * bigger than it looks: `CAP'N` deviates 21px peak-to-peak from its own + * baseline, on a cap height of 100. Roughly a fifth of a letter. `PROTO` is + * calmer at 9px on a cap of 124. Both words also sit at different angles. + * + * So each glyph carries three of its own numbers -- an extra rotation, a + * baseline offset and a size multiplier -- on top of its line's tilt. For + * `CAP'N` these are the reference's measured residuals. For `WEB` there is + * nothing to measure, so they are invented to sit in the same range as + * `PROTO`'s, including its habit of letting the middle letter ride high. + * + * `O` and `C` fit poorly (IoU 0.67) because a round letter is nearly invariant + * under rotation, so their fitted *angles* are noise and were not used. Their + * positions are fine -- round letters overshoot the baseline, which the fit + * subtracts before measuring. + */ +const LINES = [ + { + text: 'CAP\u2019N', + size: 134, + track: 6, + tilt: -5.2, + // C A P ' N + rot: [3, 1, 0, 2, 4], + dy: [-2.4, 10.2, -11.8, -4, 4.1], + scale: [0.98, 1.01, 1.0, 1.0, 1.04], + }, + { + text: 'WEB', + /* + * Against `CAP'N`'s 134 this is a cap-height ratio of 1.28. The + * reference is 1.24 (a 124px cap under a 100px one) and an earlier pass + * here was 1.39, which read as a second, louder logo rather than the + * second line of one. It is not taken all the way down to 1.24 because + * `WEB` is three letters where `PROTO` is five: at the reference's ratio + * the lower line ends up visibly narrower than the upper one, and the + * lockup stops looking like a block. This is the compromise -- the two + * lines come out within a few percent of the same width. + */ + size: 172, + track: 4, + tilt: -7.4, + // The oversized initial is measured, not invented: `PROTO`'s P stands + // 157px against a 124px cap, a ratio of 1.27, and fitting outlines to it + // rather than measuring ink agrees at 1.24. But that is measured on a P, + // and a W is already the widest, tallest-feeling letter in the alphabet + // -- at 1.27 it swamps the lockup -- so it is dialled back. `CAP'N` has + // no oversized initial; the reference's is uniform. + initial: 1.15, + // W E B + rot: [1, 2, 3], + dy: [2.0, -6.0, 3.0], + scale: [1.0, 1.0, 1.01], + }, +]; + +/* + * Baseline-to-baseline, as a multiple of the lower line's cap height. Measured + * 150px against a 124px cap on the reference, which is 1.21. The lines do not + * interlock there: `PROTO`'s oversized P is far enough left that `CAP'N` never + * reaches over it, so its extra height is free. `WEB` has no such luxury -- its + * oversized initial sits directly under `CAP'N` -- so the lead is opened up + * until the clearance the build prints matches the reference's 17-34px. + */ +const LEAD = 1.47; + +/* + * `CAP'N` sits right of centre over the longer word, by 9% of that word's + * width on the reference. With the tilt, that is what stops the lockup reading + * as a rectangle. + */ +const INDENT = 0.13; + +/** Stroke width in design units. Half of it shows, outside the fill, under + * `paint-order: stroke`. The reference's outline is ~4px at this scale. */ +const STROKE = 8; + +const rad = (d) => (d * Math.PI) / 180; +const q = (n) => Math.round(n * 10) / 10; + +/* + * Closes every contour in an opentype.js path. + * + * opentype.js 2.0.0 returns glyph outlines as open runs of M/L/C/Q with no `Z` + * anywhere, on the assumption that whoever fills them does not need one. That + * assumption breaks this mark twice over. A fill does close an open subpath -- + * but with a straight chord from the last point back to the first, which cuts + * the corner off a slab serif. And a *stroke* does not close it at all, so the + * keyline is simply absent along each contour's final edge: letters came out + * with nicked feet and gaps in their outlines, which looked for all the world + * like neighbouring glyphs overprinting each other. + * + * Guarded against a future opentype.js that does emit `Z`, so this stays a + * no-op rather than doubling up. + */ +function closeContours(path) { + const out = []; + const closeIfOpen = () => { + if (out.length && out[out.length - 1].type !== 'Z') out.push({ type: 'Z' }); + }; + for (const c of path.commands) { + if (c.type === 'M') closeIfOpen(); + out.push(c); + } + closeIfOpen(); + path.commands = out; + return path; +} + +/** Applies `fn` to every coordinate pair in an opentype.js path. */ +function mapPath(path, fn) { + for (const c of path.commands) { + if (c.x !== undefined) [c.x, c.y] = fn(c.x, c.y); + if (c.x1 !== undefined) [c.x1, c.y1] = fn(c.x1, c.y1); + if (c.x2 !== undefined) [c.x2, c.y2] = fn(c.x2, c.y2); + } + return path; +} + +const box = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; +const grow = (b, x, y) => { + b.x0 = Math.min(b.x0, x); + b.y0 = Math.min(b.y0, y); + b.x1 = Math.max(b.x1, x); + b.y1 = Math.max(b.y1, y); +}; + +/** Per-glyph point size, folding in the oversized initial and the size jitter. */ +function sizesFor({ text, size, initial, scale }) { + return [...text].map((_, i) => size * (i === 0 && initial ? initial : 1) * (scale?.[i] ?? 1)); +} + +/** Advance widths and total width for one line. */ +function measure(spec) { + const chars = [...spec.text]; + const sizes = sizesFor(spec); + const adv = chars.map( + (ch, i) => (font.charToGlyph(ch).advanceWidth * sizes[i]) / font.unitsPerEm + spec.track, + ); + return { chars, sizes, adv, width: adv.reduce((a, b) => a + b, 0) - spec.track }; +} + +/* + * One line of the lockup as *one path per glyph*, plus its own tilted bbox. + * + * Per glyph, not one path for the line, and that is not a stylistic choice. In + * a single path the letters are subpaths of one shape: the fill floods their + * union, and because `paint-order: stroke` paints every subpath's keyline first + * and then covers it with that union, any keyline running through an overlap + * disappears. Tight pairs merge into one blob and stray serifs poke out of a + * neighbour as unstroked white. Painting each glyph as its own stroked and + * filled shape, left to right, keeps every letter's outline whole and lets + * tight pairs read as layered -- which is what the reference does. + */ +function line(spec, originX, baselineY) { + const mine = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; + const { chars, sizes, adv, width } = measure(spec); + const t = rad(spec.tilt); + const cos = Math.cos(t); + const sin = Math.sin(t); + let cursor = originX - width / 2; + const out = []; + chars.forEach((ch, i) => { + // Centre the glyph on its own advance so its rotation turns it in place + // rather than swinging it off the baseline. + const half = (adv[i] - spec.track) / 2; + const p = closeContours(font.charToGlyph(ch).getPath(-half, 0, sizes[i])); + const j = rad(spec.rot?.[i] ?? 0); + const jc = Math.cos(j); + const js = Math.sin(j); + const cx = cursor + half; + const cy = baselineY + (spec.dy?.[i] ?? 0); + mapPath(p, (x, y) => { + // rotate about the glyph's own origin, place it on its own offset + // baseline, then tilt the whole line about the lockup origin. + const rx = x * jc - y * js + cx; + const ry = x * js + y * jc + cy; + const fx = rx * cos - ry * sin; + const fy = rx * sin + ry * cos; + grow(box, fx, fy); + grow(mine, fx, fy); + return [q(fx), q(fy)]; + }); + out.push(p.toPathData(1)); + cursor += adv[i]; + }); + return { parts: out, box: mine, width }; +} + +const web = measure(LINES[1]); +const gap = LEAD * CAP * LINES[1].size; +const capnLine = line(LINES[0], INDENT * web.width, -gap); +const webLine = line(LINES[1], 0, 0); +const CAPN_PATHS = capnLine.parts; +const WEB_PATHS = webLine.parts; + +const pad = STROKE / 2 + 1; +const VIEWBOX = [ + q(box.x0 - pad), + q(box.y0 - pad), + q(box.x1 - box.x0 + pad * 2), + q(box.y1 - box.y0 + pad * 2), +].join(' '); + +/** A regular star of `points` points, outer radius 100, as SVG path data. */ +function star(points, ratio, offsetDeg) { + const d = []; + for (let k = 0; k < points * 2; k++) { + const r = 100 * (k % 2 ? ratio : 1); + const a = rad(offsetDeg) + (k * Math.PI) / points; + d.push(`${k ? 'L' : 'M'}${q(Math.cos(a) * r)} ${q(Math.sin(a) * r)}`); + } + return `${d.join('')}Z`; +} + +/* + * The seal. capnproto.org's is a perfectly regular 20-point star -- the peaks in + * `infinitely_faster.png` land on exact 18 degree centres -- so this one is too. + * An earlier version jittered the points on the theory that a printed seal would + * be irregular; the reference says otherwise. + */ +const STAR_PATH = star(20, 0.81, 8.8); + +/* + * The favicon is the same seal with the detail taken out of it. At 16px a + * 20-point star with an inner radius of 0.81 is a circle with a fuzzy edge: the + * points are two pixels long and antialiasing eats them. Sixteen deeper points + * survive the downsample and still read as the same object at 180px. + * + * The ratio is tuned for 32 physical pixels, not 16: a HiDPI tab strip asks for + * the icon at 2x, and that is where the star stops being a bumpy disc and + * resolves into points. Ratios were swept at 16/20/24/32/64 on both tab strips; + * below about 0.5 the star keeps its points but loses so much ink that the 16px + * rendering reads as a faint sparkle rather than a stamped seal. + * + * Eleven is an odd count, so the offset is zero and a point aims straight up + * with a flat-ish valley opposite it. An even count centred a point top and + * bottom and read as a cog. + */ +const FAVICON_POINTS = 11; +const FAVICON_RATIO = 0.55; +const FAVICON_STAR = star(FAVICON_POINTS, FAVICON_RATIO, 0); +/* `--cw-orange`, spelled out: a favicon is its own document and gets no page + custom properties. Keep in step with `globals.css`. */ +const FAVICON_FILL = '#e85d2c'; + +const paths = `// GENERATED -- do not hand-edit. Run \`scripts/build-wordmark.mjs\`. +// See README, "The wordmark". +// +// Outlines converted from TeX Gyre Bonum Bold (GUST Font License), a Bookman +// clone. Converted rather than set as live text on purpose: a logo that falls +// back to Georgia while a webfont loads is not a logo. Nothing here needs a +// font at runtime. +// +// Coordinates are in the space of capnproto.org's own \`logo.png\` -- 635 units +// across -- because that is what the mark parodies and what it was measured +// against. + +// One entry per glyph, in painting order. See the note in the build script: +// merging them into one path per line loses the keyline wherever two letters +// touch. + +/** CAP'N, tilted, sitting above and right of centre over WEB. */ +export const CAPN_PATHS: readonly string[] = [ +${CAPN_PATHS.map((d) => `\t'${d}',`).join('\n')} +]; + +/** WEB, tilted a little further, with an oversized initial. */ +export const WEB_PATHS: readonly string[] = [ +${WEB_PATHS.map((d) => `\t'${d}',`).join('\n')} +]; + +/** Tight viewBox for the tilted lockup, with room for the outermost stroke. */ +export const LOCKUP_VIEWBOX = '${VIEWBOX}'; + +/** Stroke width for the black keyline, in the same units. Half shows, outside + * the fill, under \`paint-order: stroke\`. */ +export const LOCKUP_STROKE = ${STROKE}; + +/** A regular 20-point seal on a 100 radius about the origin. */ +export const STAR_PATH = '${STAR_PATH}'; + +/** The seal's own square viewBox, with room for the points. */ +export const STAR_VIEWBOX = '-104 -104 208 208'; +`; + +const favicon = ` +Cap'n Web + + +`; + +/* + * The third output: the banner at the top of the repo's root README, which is + * also what npm renders on the package page. + * + * This cannot lean on CSS -- no custom properties, no `prefers-color-scheme`, + * no theme attribute -- and it has to look right on GitHub light, GitHub dark, + * and npm, which now has a dark theme of its own. The usual answer is a + * `` with a light and a dark file, but npm's markdown sanitiser is far + * more aggressive than GitHub's and drops ``, which would leave + * dark-mode npm users looking at the light variant on a dark page. + * + * So the whole band -- gradient and both accents together -- is laid down at + * `BAND_ALPHA`, and the page shows through it. On a white README it lifts to a + * soft slate; on a dark one it settles almost to the site's own navy. That is + * the point: an opaque band looks pasted on, identical on both themes and + * matching neither. + * + * The alpha is high rather than subtle, and that is a legibility floor, not + * timidity. The wordmark is white, so the band has to stay dark enough to carry + * it whatever is behind. At 0.82 the band lands near rgb(55 62 74) over white + * and rgb(11 19 32) over GitHub's dark -- clearly different, both far enough + * from white to hold the mark. Taking it much lower washes the band out on a + * light page and the wordmark goes with it. + * + * Only the seal breaks the edge of the band. It is the one element painted to + * survive on an unknown background, so it gets the site's treatment: flat + * orange, no keyline, and a soft drop shadow to lift it off whatever it lands + * on. Below the band the canvas is fully transparent, so it reads as + * overhanging a real edge. + */ +const BAND_ALPHA = 0.82; +const BAND_TOP_PAD = 58; +const BAND_BOTTOM_PAD = 55; +/* The lockup as a fraction of the banner's width. The band is much wider than + the mark, the way a site header is, rather than shrink-wrapped to it. */ +const LOCKUP_FRACTION = 0.34; +/* How much of the seal hangs below the band, as a fraction of its height. The + reference overhangs 18.9%. */ +const SEAL_OVERHANG = 0.19; +const SEAL_R = 87.5; +const SEAL_TILT = -11; +const SEAL_SCALE = SEAL_R / 100; + +/* + * The site's seal shadow is `drop-shadow(0 2px 3px rgb(0 0 0 / 0.32))` on a + * seal 128px across. This one is 175 units across, so both numbers scale by + * 175/128, and a CSS blur radius is twice a Gaussian's standard deviation. + */ +const SEAL_SHADOW_SCALE = (SEAL_R * 2) / 128; +const SEAL_SHADOW_DY = 2 * SEAL_SHADOW_SCALE; +const SEAL_SHADOW_BLUR = (3 / 2) * SEAL_SHADOW_SCALE; +/* + * Where the seal's centre sits, across the lockup. + * + * The site puts it at 91.5%, but the site's seal hangs off a full-bleed banner + * with the whole viewport to its right. Here the wordmark is the only thing on + * the band, and at 91.5% the seal lands squarely on the `B` and the lower line + * reads "WEE". + */ +const SEAL_AT = 1.0; + +/* + * Then the seal moves this much further right again, and the wordmark the same + * distance left, which clears the `B` without shifting where the pair sits as a + * whole. The `B` ends 459 units along a 512-unit mark and the seal's radius is + * 87.5, so the two stop touching once the nudge passes about 17. + */ +const SEAL_NUDGE = 20; + +const MARK_W = box.x1 - box.x0 + pad * 2; +const MARK_H = box.y1 - box.y0 + pad * 2; +const BAND_W = MARK_W / LOCKUP_FRACTION; +const BAND_H = MARK_H + BAND_TOP_PAD + BAND_BOTTOM_PAD; +const sealPad = (STROKE * SEAL_SCALE) / 2 + 1; + +/* + * Centre the mark and the seal together, not the mark alone. The seal sticks + * out past the wordmark's right edge, so centring just the wordmark would leave + * the whole assembly visibly sitting right of middle. + */ +const ASSEMBLY_W = Math.max(MARK_W, MARK_W * SEAL_AT + SEAL_NUDGE + SEAL_R + sealPad); +const MARK_LEFT = (BAND_W - ASSEMBLY_W) / 2; + +/* Put the lockup's own coordinates into the banner's. */ +const OX = MARK_LEFT - SEAL_NUDGE - (box.x0 - pad); +const OY = BAND_TOP_PAD - (box.y0 - pad); + +const SEAL_CX = MARK_LEFT + MARK_W * SEAL_AT + SEAL_NUDGE; +const SEAL_CY = BAND_H - SEAL_R + SEAL_OVERHANG * SEAL_R * 2; +const CANVAS_H = SEAL_CY + SEAL_R + sealPad + 4; + +/* + * A CSS `linear-gradient(Ndeg, ...)` as SVG gradient endpoints. + * + * CSS measures the angle clockwise from "to top"; the gradient line runs + * through the centre of the box and is long enough that the stops at 0% and + * 100% land on the corners, which is `|w*sin| + |h*cos|`. + */ +function cssLinear(deg, w, h) { + const t = rad(deg); + const dx = Math.sin(t); + const dy = -Math.cos(t); + const len = Math.abs(w * dx) + Math.abs(h * dy); + return { + x1: q(w / 2 - (dx * len) / 2), + y1: q(h / 2 - (dy * len) / 2), + x2: q(w / 2 + (dx * len) / 2), + y2: q(h / 2 + (dy * len) / 2), + }; +} + +/* + * A CSS `radial-gradient(RX RY at CX CY, colour 0%, transparent STOP%)`. + * + * SVG radial gradients are circles, so the ellipse is a circle of `rx` scaled + * on y about its own centre. The far stop repeats the colour at zero alpha + * rather than using `transparent`: fading to `transparent` fades towards + * transparent *black*, which greys the accent on its way out. + */ +function cssRadial(id, colour, alpha, rxF, ryF, cxF, cyF, stop, w, h) { + const cx = q(cxF * w); + const cy = q(cyF * h); + const rx = rxF * w; + const sy = q((ryF * h) / rx); + return ( + `\n` + + `\n` + + `\n` + + `` + ); +} + +const lin = cssLinear(104, BAND_W, BAND_H); + +/** Lays out one line of plain text, centred on x=0, as a single path. + * Fill-only, so unlike the wordmark it does not need a path per glyph. */ +function plain(text, size, baselineY) { + const width = font.getAdvanceWidth(text, size); + return closeContours(font.getPath(text, -width / 2, baselineY, size)).toPathData(1); +} + +/* + * The seal's legend. On the site these words are real DOM text so they stay + * selectable and translatable; here there is no DOM and no webfont, so they are + * outlined. That costs nothing and buys back the fidelity the site gives up -- + * these are Bookman, the same face as the wordmark, which the site cannot + * manage without shipping a font for three words. + * + * `HERO_TITLE`, lower-cased and broken the way the seal breaks it. + */ +const LEGEND = ['one', 'round', 'trip!']; + +/* + * Sized to the star's flat inner disc (radius 81 of 100), against both of the + * constraints a circle imposes: the widest line has to fit across it, and the + * stack of lines has to fit down it. With three short lines the height is what + * binds, where with two longer ones the width did. + */ +const LEGEND_FIT = 81 * 2 * 0.82; +const LEGEND_LEAD_RATIO = 1.04; +const LEGEND_SIZE = Math.min( + LEGEND_FIT / Math.max(...LEGEND.map((l) => font.getAdvanceWidth(l, 1))), + LEGEND_FIT / ((LEGEND.length - 1) * LEGEND_LEAD_RATIO + CAP), +); +const LEGEND_LEAD = LEGEND_SIZE * LEGEND_LEAD_RATIO; +const legendPaths = LEGEND.map((text, i) => + plain(text, LEGEND_SIZE, (i - (LEGEND.length - 1) / 2) * LEGEND_LEAD + (CAP * LEGEND_SIZE) / 2), +); + +/* 2x what the README displays it at, so it stays sharp on a HiDPI screen. */ +const BANNER_W = 1600; + +const banner = ` +Cap'n Web + + + + + + +${cssRadial('glow1', '#7aa2ff', 0.13, 0.72, 1.2, 0.12, 0, 0.62, BAND_W, BAND_H)} +${cssRadial('glow2', '#4fd6a8', 0.12, 0.66, 1.18, 0.9, 1.04, 0.6, BAND_W, BAND_H)} + + + + + + + + + + + +${[...CAPN_PATHS, ...WEB_PATHS].map((d) => ``).join('\n')} + + + + + + +${legendPaths.map((d) => ``).join('\n')} + + +`; + +/* + * The fourth and fifth outputs: art for the social cards. + * + * The cards themselves are drawn per page by `astro-og-canvas` (34 of them, one + * per title), and that stays as it is -- it rasterises with canvaskit and wants + * no browser at build time. What it cannot do is draw this band: it takes a + * list of gradient stops, and the band is a linear gradient with two elliptical + * accents over it. So the band is handed to it as a finished `bgImage`, and the + * mark as a `logo`, both generated here from the same geometry as the README + * banner. That keeps one source of truth without a browser in the docs build. + * + * The band is opaque here, unlike the README's. A social card is composited by + * Slack or a search engine onto a surface this repository does not control and + * cannot measure, and a translucent one would come out differently in each. The + * README can adapt to its page because there are only two of those and both are + * known. + */ +const OG_W = 1200; +const OG_H = 630; +/* + * The orange edge is painted into the band rather than left to the card's own + * `border` option, because a `bgImage` is drawn over that border and hides it. + * The option stays set in `_og-card-config.ts` at this same width, so the + * fallback path -- gradient with no image -- still gets an edge. + */ +const OG_BORDER = 12; +const ogLin = cssLinear(104, OG_W, OG_H); + +const ogBand = ` + + + + + + +${cssRadial('glow1', '#7aa2ff', 0.13, 0.72, 1.2, 0.12, 0, 0.62, OG_W, OG_H)} +${cssRadial('glow2', '#4fd6a8', 0.12, 0.66, 1.18, 0.9, 1.04, 0.6, OG_W, OG_H)} + + + + + + +`; + +/* + * The mark on its own, transparent, holding the same seal placement the banner + * uses. Keeping the relationship identical means the two read as one object: + * in the banner the mark moves left by `SEAL_NUDGE` and the seal right by the + * same, so here the seal sits `MARK_W * SEAL_AT + 2 * SEAL_NUDGE` along. + */ +const ogSealCx = box.x0 - pad + MARK_W * SEAL_AT + 2 * SEAL_NUDGE; +const ogSealCy = box.y0 - pad + (SEAL_CY - BAND_TOP_PAD); +const ogShadowPad = SEAL_SHADOW_DY + SEAL_SHADOW_BLUR * 3; +const mb = { + x0: Math.min(box.x0 - pad, ogSealCx - SEAL_R - ogShadowPad), + y0: Math.min(box.y0 - pad, ogSealCy - SEAL_R - ogShadowPad), + x1: Math.max(box.x1 + pad, ogSealCx + SEAL_R + ogShadowPad), + y1: Math.max(box.y1 + pad, ogSealCy + SEAL_R + ogShadowPad), +}; + +const ogMark = ` +Cap'n Web + + + + + + +${[...CAPN_PATHS, ...WEB_PATHS].map((d) => ``).join('\n')} + + + + + +${legendPaths.map((d) => ``).join('\n')} + + +`; + +const dest = new URL('../src/components/logo-paths.ts', import.meta.url); +const fav = new URL('../public/favicon.svg', import.meta.url); +const bnr = new URL('../../../assets/capnweb-banner.svg', import.meta.url); +fs.writeFileSync(dest, paths); +fs.writeFileSync(fav, favicon); +fs.mkdirSync(new URL('../../../assets/', import.meta.url), { recursive: true }); +fs.writeFileSync(bnr, banner); +fs.mkdirSync(new URL('../og-assets/', import.meta.url), { recursive: true }); +fs.writeFileSync(new URL('../og-assets/og-band.svg', import.meta.url), ogBand); +fs.writeFileSync(new URL('../og-assets/og-mark.svg', import.meta.url), ogMark); + +console.error( + `wrote ${dest.pathname}\n` + + ` ${fav.pathname}\n` + + ` lockup viewBox ${VIEWBOX}\n` + + ` CAP'N ${CAPN_PATHS.length} glyphs ${CAPN_PATHS.join('').length}b ` + + `WEB ${WEB_PATHS.length} glyphs ${WEB_PATHS.join('').length}b\n` + + ` CAP'N box x ${q(capnLine.box.x0)}..${q(capnLine.box.x1)} y ${q(capnLine.box.y0)}..${q(capnLine.box.y1)}\n` + + ` WEB box x ${q(webLine.box.x0)}..${q(webLine.box.x1)} y ${q(webLine.box.y0)}..${q(webLine.box.y1)}\n` + + ` vertical clearance (WEB top under CAP'N bottom): ${q(webLine.box.y0 - capnLine.box.y1)}\n` + + ` cap/em ${q(CAP * 1000) / 1000} baseline gap ${q(gap)}\n` + + ` favicon ${FAVICON_POINTS} points, inner/outer ${FAVICON_RATIO}\n` + + ` banner ${q(BAND_W)}x${q(CANVAS_H)} units, band ${q(BAND_H)}, ` + + `seal overhangs ${q(SEAL_OVERHANG * 100)}%`, +); diff --git a/packages/docs/scripts/mdast-bundle-size.mjs b/packages/docs/scripts/mdast-bundle-size.mjs new file mode 100644 index 00000000..6f1b22ae --- /dev/null +++ b/packages/docs/scripts/mdast-bundle-size.mjs @@ -0,0 +1,52 @@ +/** + * Replaces the `%BUNDLE_SIZE%` token with the measured size of the library. + * + * The number lives in exactly one place -- `src/generated/bundle-size.json`, written by + * `scripts/measure-bundle.mjs` during prebuild -- so that a claim about the library's size cannot + * go stale by being typed into prose. It is reached three different ways: + * + * - this plugin, for prose in `.md` bodies, wired in as a Sätteri `mdastPlugins` entry + * - a `transform` on the content collection schema, for frontmatter + * - a plain `import` of the JSON, for `.mdx` pages, which can interpolate it directly + * + * Frontmatter never reaches the markdown pipeline: it is parsed and validated by Zod before the + * body is compiled, and the layout reads the page description off the parsed entry. See + * `src/content.config.ts`. + * + * This is a Sätteri plugin, not a remark one. Sätteri replaces unified's pipeline with a visitor + * keyed by node type, where nodes are read-only and edits go through `context.setProperty`. The + * shape is different but the work is the same, and it keeps the default processor -- swapping in + * unified to run a remark plugin would give up Sätteri's performance for this one substitution. + */ + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const TOKEN = /%BUNDLE_SIZE%/g; + +/** + * A factory rather than a plain definition, so the file is read once per compile. Prebuild writes + * it, and a value captured when the Astro config was first evaluated would be stale for the rest of + * the build. + */ +export function mdastBundleSize() { + return () => { + const { label } = require('../src/generated/bundle-size.json'); + + /** Rewrites a node's `value` if it carries the token. */ + const substituteValue = (node, context) => { + if (typeof node.value !== 'string' || !node.value.includes('%BUNDLE_SIZE%')) return; + context.setProperty(node, 'value', node.value.replace(TOKEN, label)); + }; + + // JSX attributes are deliberately not handled: Sätteri's op-stream cannot encode a + // mutation of `attributes`, and an MDX page has a better option anyway -- import the JSON + // and interpolate, as `index.mdx` does for the hero tagline and the download card. + return { + name: 'capnweb-bundle-size', + text: substituteValue, + inlineCode: substituteValue, + }; + }; +} diff --git a/packages/docs/scripts/measure-bundle.mjs b/packages/docs/scripts/measure-bundle.mjs new file mode 100644 index 00000000..a7ad701f --- /dev/null +++ b/packages/docs/scripts/measure-bundle.mjs @@ -0,0 +1,61 @@ +/** + * Measures how big Cap'n Web actually is, so the docs can stop asserting it from memory. + * + * The site claims a size in several places, including two frontmatter strings and a card on the + * landing page. Those were written when the number was "under 10 kB" and were wrong by the time + * anyone noticed, which is the usual fate of a number typed into prose. This computes it during + * `prebuild` and writes it where the remark plugin and the Astro config can read it. + * + * The number is minify + gzip of the browser entry point, which is what a reader comparing + * libraries expects: what lands in a bundle, compressed the way a server would send it. Brotli is + * recorded too, since that is what most connections actually negotiate, but the headline stays + * gzip because that is the conservative figure and the one everyone else quotes. + */ + +import { build } from 'esbuild'; +import { gzipSync, brotliCompressSync } from 'node:zlib'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '../../..'); +const out = resolve(here, '../src/generated/bundle-size.json'); + +// Bundled from source rather than from `dist/`, so this works in a clean checkout and never +// reports a stale number left over from an older build of the library. +const result = await build({ + entryPoints: [resolve(repoRoot, 'src/index.ts')], + bundle: true, + minify: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + write: false, + logLevel: 'error', +}); + +const minified = result.outputFiles[0].contents; +const gzip = gzipSync(minified, { level: 9 }).length; +const brotli = brotliCompressSync(minified).length; + +// Round up to the next whole kB. "Under 16 kB" has to stay true as the library drifts upward +// within a kilobyte, and a claim that rounds down would go stale between releases. +const kb = Math.ceil(gzip / 1024); + +const data = { + minifiedBytes: minified.length, + gzipBytes: gzip, + brotliBytes: brotli, + kb, + /** The form used in prose, e.g. "under 16 kB". */ + label: `under ${kb} kB`, + measuredAt: new Date().toISOString(), +}; + +mkdirSync(dirname(out), { recursive: true }); +writeFileSync(out, `${JSON.stringify(data, null, '\t')}\n`); + +console.log( + `[bundle-size] minified ${minified.length} B, gzip ${gzip} B, brotli ${brotli} B -> "${data.label}"` +); diff --git a/packages/docs/scripts/measure-wordmark.mjs b/packages/docs/scripts/measure-wordmark.mjs new file mode 100644 index 00000000..87b48be6 --- /dev/null +++ b/packages/docs/scripts/measure-wordmark.mjs @@ -0,0 +1,434 @@ +/* + * Re-derives every number `build-wordmark.mjs` took off capnproto.org. + * + * node scripts/measure-wordmark.mjs # fetches the reference art + * node scripts/measure-wordmark.mjs logo.png infinitely_faster.png + * + * The wordmark is a parody of capnproto.org's, and the build script is full of + * constants that only mean anything because they were measured off it: the + * tilt of each line, how far the letters scatter off that line, how much + * bigger the first letter of the lower line is, how many points the seal has + * and how deep they cut. Those measurements were originally taken with a pile + * of throwaway scripts. This is that pile, cleaned up and committed, so the + * constants can be checked rather than trusted -- and so the mark can be + * rebuilt from the reference if it is ever lost. + * + * It reports what it measures next to what the build script currently uses, + * and explains the ones that deliberately differ. Nothing here writes files. + * + * No dependencies, on purpose. The originals decoded PNGs by handing them to a + * headless Chrome canvas, which is a browser download to read 226k pixels; the + * decoder below is ~50 lines of `node:zlib` and covers the only format these + * two files are in (8-bit RGBA, non-interlaced). It refuses anything else + * loudly rather than quietly mismeasuring. + */ +import fs from 'node:fs'; +import zlib from 'node:zlib'; + +const SOURCES = { + logo: 'https://capnproto.org/images/logo.png', + seal: 'https://capnproto.org/images/infinitely_faster.png', +}; + +/* ------------------------------------------------------------------ PNG -- */ + +/** Decodes an 8-bit RGBA non-interlaced PNG to `{ w, h, data }`. */ +function decodePng(buf) { + const sig = [137, 80, 78, 71, 13, 10, 26, 10]; + if (!sig.every((b, i) => buf[i] === b)) throw new Error('not a PNG'); + + const w = buf.readUInt32BE(16); + const h = buf.readUInt32BE(20); + const [depth, color, , , interlace] = [buf[24], buf[25], buf[26], buf[27], buf[28]]; + if (depth !== 8 || color !== 6 || interlace !== 0) { + throw new Error( + `unsupported PNG (depth ${depth}, colour type ${color}, interlace ${interlace}). ` + + 'This decoder only handles 8-bit RGBA, non-interlaced -- which is what both ' + + 'reference files are. Re-encode, or decode it some other way.', + ); + } + + const idat = []; + for (let o = 8; o < buf.length; ) { + const len = buf.readUInt32BE(o); + const type = buf.toString('ascii', o + 4, o + 8); + if (type === 'IDAT') idat.push(buf.subarray(o + 8, o + 8 + len)); + if (type === 'IEND') break; + o += 12 + len; + } + const raw = zlib.inflateSync(Buffer.concat(idat)); + + /* Undo the per-scanline filters. Each row is one filter byte then w*4 + bytes; filters 1-4 predict from the pixel left (a), above (b), and + above-left (c). See the PNG spec, chapter 9. */ + const bpp = 4; + const stride = w * bpp; + const out = Buffer.alloc(h * stride); + for (let y = 0; y < h; y++) { + const ft = raw[y * (stride + 1)]; + const src = y * (stride + 1) + 1; + const dst = y * stride; + for (let i = 0; i < stride; i++) { + const x = raw[src + i]; + const a = i >= bpp ? out[dst + i - bpp] : 0; + const b = y > 0 ? out[dst - stride + i] : 0; + const c = i >= bpp && y > 0 ? out[dst - stride + i - bpp] : 0; + let v; + if (ft === 0) v = x; + else if (ft === 1) v = x + a; + else if (ft === 2) v = x + b; + else if (ft === 3) v = x + ((a + b) >> 1); + else if (ft === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + } else throw new Error(`bad PNG filter ${ft} on row ${y}`); + out[dst + i] = v & 0xff; + } + } + return { w, h, data: out }; +} + +async function load(which, argPath) { + if (argPath) return decodePng(fs.readFileSync(argPath)); + const url = SOURCES[which]; + let res; + try { + res = await fetch(url); + } catch (cause) { + throw new Error( + `could not fetch ${url}: ${cause.message}\n` + + 'If you are offline, download the two reference images anywhere and pass them:\n' + + ` node scripts/measure-wordmark.mjs logo.png infinitely_faster.png\n` + + ` ${SOURCES.logo}\n ${SOURCES.seal}`, + { cause }, + ); + } + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + return decodePng(Buffer.from(await res.arrayBuffer())); +} + +/* --------------------------------------------------- connected components -- */ + +/** + * 8-connected components over opaque pixels, as bounding boxes. + * + * The reference letters are white with a black keyline, and both are opaque, + * so a component is a whole letter rather than one of its two colours. Holes + * (the counter of an O) are background and never their own component. + */ +function components(img, minPx) { + const { w, h, data } = img; + const ink = (x, y) => data[(y * w + x) * 4 + 3] > 128; + const seen = new Uint8Array(w * h); + const boxes = []; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (!ink(x, y) || seen[y * w + x]) continue; + seen[y * w + x] = 1; + const stack = [[x, y]]; + let x0 = x; + let x1 = x; + let y0 = y; + let y1 = y; + let n = 0; + while (stack.length) { + const [px, py] = stack.pop(); + n++; + if (px < x0) x0 = px; + if (px > x1) x1 = px; + if (py < y0) y0 = py; + if (py > y1) y1 = py; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = px + dx; + const ny = py + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + if (seen[ny * w + nx] || !ink(nx, ny)) continue; + seen[ny * w + nx] = 1; + stack.push([nx, ny]); + } + } + } + if (n >= minPx) boxes.push({ x0, x1, y0, y1, w: x1 - x0 + 1, h: y1 - y0 + 1, px: n }); + } + } + return boxes.sort((a, z) => a.x0 - z.x0); +} + +/** Splits boxes into rows by a gap in their vertical centres. */ +function rows(boxes) { + const sorted = [...boxes].sort((a, z) => (a.y0 + a.y1) / 2 - (z.y0 + z.y1) / 2); + const out = []; + let cur = [sorted[0]]; + for (const b of sorted.slice(1)) { + const prev = cur[cur.length - 1]; + const gap = (b.y0 + b.y1) / 2 - (prev.y0 + prev.y1) / 2; + if (gap > Math.max(prev.h, b.h) * 0.6) { + out.push(cur); + cur = []; + } + cur.push(b); + } + out.push(cur); + return out.map((r) => r.sort((a, z) => a.x0 - z.x0)); +} + +/** + * The glyphs on a row that actually sit on the baseline. + * + * `CAP'N` contains an apostrophe, which hangs at cap height and has its feet + * nowhere near the baseline. Including it drags a least-squares fit through + * the floor: with it the upper line measures -11.6 degrees and shows a 59px + * residual, against -5.2 and 22px once it is dropped. Anything much shorter + * than the line's tallest letter is punctuation, not a letter. + */ +const feet = (row) => { + const tall = Math.max(...row.map((b) => b.h)); + return row.filter((b) => b.h >= tall * 0.55); +}; + +/** + * Cap height for a row: the median of its baseline glyphs, ignoring an + * oversized initial. + * + * Not the maximum. On the lower line the maximum *is* the oversized initial, + * which reports the cap 38% too tall and makes the two lines look far more + * different in size than they are. The median also shrugs off the overshoot + * that round letters carry above and below the line. + */ +function capHeight(row) { + const hs = feet(row).map((b) => b.h); + const rest = hs.length > 2 ? hs.slice(1) : hs; + const sorted = [...rest].sort((a, z) => a - z); + return sorted[Math.floor(sorted.length / 2)]; +} + +/** Least-squares fit of y = m*x + c through a line's glyph feet. */ +function baseline(row) { + const pts = feet(row).map((b) => [(b.x0 + b.x1) / 2, b.y1]); + const n = pts.length; + const sx = pts.reduce((a, p) => a + p[0], 0); + const sy = pts.reduce((a, p) => a + p[1], 0); + const sxx = pts.reduce((a, p) => a + p[0] * p[0], 0); + const sxy = pts.reduce((a, p) => a + p[0] * p[1], 0); + const m = (n * sxy - sx * sy) / (n * sxx - sx * sx); + const c = (sy - m * sx) / n; + return { + m, + c, + degrees: (Math.atan(m) * 180) / Math.PI, + residuals: pts.map(([x, y]) => y - (m * x + c)), + }; +} + +/* ---------------------------------------------------------------- report -- */ + +const r1 = (n) => Math.round(n * 10) / 10; +const r2 = (n) => Math.round(n * 100) / 100; +const r3 = (n) => Math.round(n * 1000) / 1000; + +/** + * Pulls the committed constants out of the build script's source. + * + * Reading the source rather than importing it is deliberate: importing would + * execute it, and it wants a font file, `opentype.js`, and somewhere to write + * two generated files. This only needs the numbers. A pattern that stops + * matching reports itself as `?` rather than silently agreeing. + */ +function committed() { + const src = fs.readFileSync(new URL('./build-wordmark.mjs', import.meta.url), 'utf8'); + const one = (re) => { + const m = src.match(re); + return m ? m.slice(1).map(Number) : null; + }; + const capn = src.match(/text: 'CAP\\u2019N',[\s\S]*?tilt: (-?[\d.]+)/); + const web = src.match(/text: 'WEB',[\s\S]*?size: (\d+)[\s\S]*?tilt: (-?[\d.]+)/); + const capnSize = src.match(/text: 'CAP\\u2019N',\s*\n\s*size: (\d+)/); + return { + capnTilt: capn ? Number(capn[1]) : null, + capnSize: capnSize ? Number(capnSize[1]) : null, + webSize: web ? Number(web[1]) : null, + webTilt: web ? Number(web[2]) : null, + /* Anchored on the CAP'N entry rather than on whatever happens to follow + the array, so that editing a neighbouring field does not silently + turn this into a `?`. */ + capnDy: (() => { + const m = src.match(/text: 'CAP\\u2019N',[\s\S]*?dy: \[([-\d.,\s]+)\]/); + return m ? m[1].split(',').map((s) => Number(s.trim())) : null; + })(), + initial: one(/initial: ([\d.]+)/), + lead: one(/const LEAD = ([\d.]+);/), + indent: one(/const INDENT = ([\d.]+);/), + star: one(/const STAR_PATH = star\((\d+), ([\d.]+), ([\d.]+)\);/), + favPoints: one(/const FAVICON_POINTS = (\d+);/), + favRatio: one(/const FAVICON_RATIO = ([\d.]+);/), + }; +} + +const say = (label, measured, used, note) => + console.log( + ` ${label.padEnd(30)} measured ${String(measured).padStart(9)}` + + (used === undefined ? '' : ` uses ${String(used).padStart(8)}`) + + (note ? ` ${note}` : ''), + ); + +const [, , logoPath, sealPath] = process.argv; +const C = committed(); + +/* ------------------------------------------------------------- wordmark -- */ + +const logo = await load('logo', logoPath); +console.log(`\nlogo.png ${logo.w}x${logo.h}`); + +/* The tagline ("cerealization protocol") is a third row of much smaller + letters. Rows are found by gaps rather than by a hardcoded cutoff, then the + two with the tallest letters are the wordmark. */ +const all = rows(components(logo, 300)); +const lines = [...all].sort((a, z) => Math.max(...z.map((b) => b.h)) - Math.max(...a.map((b) => b.h))).slice(0, 2); +lines.sort((a, z) => a[0].y0 - z[0].y0); +const [upper, lower] = lines; + +console.log( + `\n rows found: ${all.map((r) => `${r.length} glyphs (cap ${capHeight(r)})`).join(', ')}` + + "\n The tagline ('cerealization protocol') is set small enough that every" + + '\n letter falls under the pixel threshold, so it never becomes a row.\n', +); + +const bUpper = baseline(upper); +const bLower = baseline(lower); + +const capUpper = capHeight(upper); +const capLower = capHeight(lower); + +console.log('CAP\'N (upper line)'); +say('tilt, degrees', r2(bUpper.degrees), C.capnTilt, 'least squares through the feet'); +say('cap height, px', capUpper, undefined, `build sets size ${C.capnSize ?? '?'} in design units`); +say('glyph heights, px', `[${feet(upper).map((g) => g.h).join(', ')}]`, undefined, 'they differ: the mark is scattered'); +say('scatter off baseline, px', r1(Math.max(...bUpper.residuals) - Math.min(...bUpper.residuals))); +say('per-glyph residuals', `[${bUpper.residuals.map(r1).join(', ')}]`, `[${C.capnDy ?? '?'}]`); +console.log( + ' ^ the build script carries these as `dy`, and has one more of them than\n' + + ' this measures: its fifth is the apostrophe, which hangs at cap height\n' + + ' and so is left out of the baseline fit entirely.\n' + + ' They are the whole argument for the jitter: a hand-set mark\n' + + ' scatters, a rendered one does not.\n', +); + +console.log('PROTO (lower line) -> WEB'); +say('tilt, degrees', r2(bLower.degrees), C.webTilt, 'least squares through the feet'); +say('cap height, px', capLower, undefined, `build sets size ${C.webSize ?? '?'} in design units`); +say('glyph heights, px', `[${feet(lower).map((g) => g.h).join(', ')}]`, undefined, 'first is the oversized initial'); +say('scatter off baseline, px', r1(Math.max(...bLower.residuals) - Math.min(...bLower.residuals))); + +/* The oversized initial, against the cap height of the letters after it. */ +say('initial / rest, by height', r3(feet(lower)[0].h / capLower), C.initial?.[0], 'W is dialled back'); + +console.log('\nboth lines'); +const capRatio = capLower / capUpper; +say('cap ratio, lower/upper', r3(capRatio), r3((C.webSize ?? 0) / (C.capnSize ?? 1)), 'ours is higher on purpose'); +const midX = (lower[0].x0 + lower[lower.length - 1].x1) / 2; +const gapPx = bLower.m * midX + bLower.c - (bUpper.m * midX + bUpper.c); +say('baseline gap, px', r1(gapPx)); +say('gap / lower cap = LEAD', r3(gapPx / capLower), C.lead?.[0], 'opened up; WEB has no free space'); +const wUpper = upper[upper.length - 1].x1 - upper[0].x0; +const wLower = lower[lower.length - 1].x1 - lower[0].x0; +const cUpper = (upper[upper.length - 1].x1 + upper[0].x0) / 2; +const cLower = (lower[lower.length - 1].x1 + lower[0].x0) / 2; +say('upper indent / lower width', r3((cUpper - cLower) / wLower), C.indent?.[0], 'INDENT'); +say('line widths, px', `${wUpper} / ${wLower}`, undefined, `lower/upper ${r2(wLower / wUpper)}`); + +/* ----------------------------------------------------------------- seal -- */ + +const seal = await load('seal', sealPath); +console.log(`\ninfinitely_faster.png ${seal.w}x${seal.h}`); + +/* Radius profile from the ink centroid. The peaks are the star's points and + the troughs are the valleys between them, so counting one and taking the + ratio of the two gives the whole shape. */ +const { w: sw, h: sh, data: sd } = seal; +let cx = 0; +let cy = 0; +let n = 0; +for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + if (sd[(y * sw + x) * 4 + 3] > 128) { + cx += x; + cy += y; + n++; + } + } +} +cx /= n; +cy /= n; + +const STEPS = 3600; +const radii = new Array(STEPS); +for (let i = 0; i < STEPS; i++) { + const a = (i / STEPS) * Math.PI * 2; + const dx = Math.cos(a); + const dy = Math.sin(a); + let r = 0; + for (let t = 1; t < Math.max(sw, sh); t += 0.5) { + const x = Math.round(cx + dx * t); + const y = Math.round(cy + dy * t); + if (x < 0 || y < 0 || x >= sw || y >= sh) break; + if (sd[(y * sw + x) * 4 + 3] > 128) r = t; + } + radii[i] = r; +} + +/* A sample is a peak if it is the largest in a window wider than one point. */ +const peaks = []; +const win = Math.floor(STEPS / 60); +for (let i = 0; i < STEPS; i++) { + let best = true; + for (let k = -win; k <= win && best; k++) { + if (radii[(i + k + STEPS) % STEPS] > radii[i]) best = false; + } + if (best && (!peaks.length || i - peaks[peaks.length - 1] > win)) peaks.push(i); +} +const outer = radii.filter((r) => r > 0).sort((a, z) => z - a); +const inner = [...radii].sort((a, z) => a - z).filter((r) => r > 0); +const outerR = outer[Math.floor(outer.length * 0.02)]; +const innerR = inner[Math.floor(inner.length * 0.02)]; +const spacing = 360 / peaks.length; + +console.log('\nthe seal'); +say('points', peaks.length, C.star?.[0]); +say('inner / outer radius', r2(innerR / outerR), C.star?.[1]); +say('outer radius, px', r1(outerR), undefined, `of a ${sw}px image`); +say('point spacing, degrees', r2(spacing), undefined, 'exact, so the points are regular'); +const first = (peaks[0] / STEPS) * 360; +say('first point, degrees', r2(first), C.star?.[2], 'the build script measures from -90'); +const jitter = peaks.map((p, i) => ((p / STEPS) * 360 - (first + i * spacing) + 540) % 360 - 180); +say('worst deviation, degrees', r2(Math.max(...jitter.map(Math.abs))), undefined, 'regular, not hand-jittered'); + +console.log('\nthe favicon is not measured from anything'); +say('points', 'n/a', C.favPoints?.[0], 'chosen for legibility at 16px'); +say('inner / outer radius', 'n/a', C.favRatio?.[0], 'swept on real tab strips'); + +console.log( + '\nReading the numbers\n' + + '\n' + + 'Differences of about a pixel, or a few hundredths on a ratio, are method\n' + + 'and not drift. This script measures the bounding box of each letter\'s\n' + + 'ink at an alpha threshold; the values in the build script were taken by\n' + + 'fitting glyph outlines to the reference and maximising overlap, which\n' + + 'puts a baseline through the outline rather than through the darkest\n' + + 'row of pixels. The two agree to about a pixel everywhere, which is the\n' + + 'useful result: neither is exact, and the mark does not need them to be.\n' + + '\n' + + 'Bigger gaps are deliberate, and the build script says why at each\n' + + 'constant. The short version: LEAD and INDENT are opened up, and the cap\n' + + 'ratio raised, because WEB is three letters where PROTO is five. At the\n' + + 'reference ratio the lower line comes out narrower than the upper one and\n' + + 'the lockup stops reading as a block.\n' + + '\n' + + 'The seal is the part that is copied outright: 20 points on exact 18\n' + + 'degree centres, deviating a degree at most. It is a regular star, and an\n' + + 'earlier pass that jittered its points on the theory that a printed seal\n' + + 'would be irregular was measuring nothing.\n', +); diff --git a/packages/docs/src/components.ts b/packages/docs/src/components.ts new file mode 100644 index 00000000..b50cedee --- /dev/null +++ b/packages/docs/src/components.ts @@ -0,0 +1,36 @@ +/** + * MDX globals registry — components available inside MDX without `import`. + * Wired via `` in `[...slug].astro`, and + * parsed at build time by the MDX validator, which fails the build on a + * PascalCase tag that is neither registered here nor imported by the page. + */ + +import { Aside } from './components/ui/aside'; +import { Card } from './components/ui/card'; +import { CardGrid } from './components/ui/card-grid'; +import { LinkCard } from './components/ui/link-card'; +import { PackageManagers } from './components/ui/package-managers'; +import Render from './components/Render.astro'; +import { Step, Steps } from './components/ui/steps'; +import { Tabs, TabItem } from './components/ui/tabs'; + +// Ours, not Nimbus's. +import Hero from './components/Hero.astro'; +import Playground from './components/Playground.astro'; +import Prose from './components/Prose.astro'; + +export const components = { + Aside, + Card, + CardGrid, + Hero, + LinkCard, + PackageManagers, + Playground, + Prose, + Render, + Step, + Steps, + TabItem, + Tabs, +}; diff --git a/packages/docs/src/components/AgentDirective.astro b/packages/docs/src/components/AgentDirective.astro new file mode 100644 index 00000000..c2617dfc --- /dev/null +++ b/packages/docs/src/components/AgentDirective.astro @@ -0,0 +1,16 @@ +--- +interface Props { + /** Absolute or site-relative URL for this page's markdown version. */ + markdownUrl: string; + /** Absolute or site-relative URL for the top-level llms.txt index. */ + llmsUrl: string; +} + +const { markdownUrl, llmsUrl } = Astro.props; +--- + + diff --git a/packages/docs/src/components/Features.astro b/packages/docs/src/components/Features.astro new file mode 100644 index 00000000..25277bd3 --- /dev/null +++ b/packages/docs/src/components/Features.astro @@ -0,0 +1,373 @@ +--- +/** + * The "why it's different" bento: white isometric line-art figures (à la + * Linear). A faint base layer with a brighter animated layer on top — a pulse + * travelling down the tube, flowing arrows, a call that lands and flashes the + * remote object, lines that write themselves in, a spinning globe, and a few + * lit cubes among many. + * + * The illustrations are built as SVG strings and injected with `set:html`, + * which means Astro's scoped styles do NOT reach them — so their styling lives + * in the `is:global` block below, namespaced under `.cw-bento`. All motion is + * transform / stroke-dashoffset / opacity and pauses under reduced motion. + */ +interface Props { + bundleLabel: string; +} +const { bundleLabel } = Astro.props; + +const dia = (cx: number, cy: number, s: number) => { + const X = s, + Y = s / 2; + return `M${cx} ${cy - Y} L${cx + X} ${cy} L${cx} ${cy + Y} L${cx - X} ${cy} Z`; +}; +const cube = (cx: number, cy: number, s: number, h: number) => { + const X = s, + Y = s / 2; + return { + top: dia(cx, cy, s), + left: `M${cx - X} ${cy} L${cx} ${cy + Y} L${cx} ${cy + Y + h} L${cx - X} ${cy + h} Z`, + right: `M${cx + X} ${cy} L${cx} ${cy + Y} L${cx} ${cy + Y + h} L${cx + X} ${cy + h} Z`, + }; +}; +const p = (cls: string, d: string, extra = "") => ``; +const cubeStr = (c: ReturnType, top = "cw-top", side = "cw-side") => + p(side, c.left) + p(side, c.right) + p(top, c.top); +const r1 = (n: number) => Math.round(n * 10) / 10; + +// 1) tube with a data pulse travelling down through transparent rings +const tube = (() => { + const x = 100, + rx = 32, + ry = 16; + let s = p("cw-side", `M${x - rx} 42 L${x - rx} 104 M${x + rx} 42 L${x + rx} 104`); + [42, 58, 74, 90, 104].forEach((y, i) => { + s += ``; + }); + return s; +})(); + +// 2) two level cubes exchanging over two flowing arrows. +// Both lines animate with the same keyframe: dashes travel along a path in the +// path's own direction, and the return line is already authored right to left +// (M116 -> L86), so reversing the animation too would cancel out and send its +// dashes back the way its arrowhead points. +const bidi = + cubeStr(cube(58, 82, 20, 26)) + + cubeStr(cube(142, 82, 20, 26)) + + p("cw-flow", "M84 84 L114 84") + + p("cw-arrow", "M114 84 l-6 -3 M114 84 l-6 3") + + p("cw-flow", "M116 98 L86 98") + + p("cw-arrow", "M86 98 l6 -3 M86 98 l6 3"); + +// 3) a reference orbiting the object it points at (pass by reference) +const reference = (() => { + const ring = "M60 75 a40 14 0 1 0 80 0 a40 14 0 1 0 -80 0"; + return ( + `` + + `` + + `` + ); +})(); + +// 4) a plain data card whose three lines write themselves in +const schemas = (() => { + const c = cube(100, 66, 42, 5); + const P = (u: number, v: number): [number, number] => [58 + u * 42 + v * 42, 66 + u * -21 + v * 21]; + let lines = ""; + ([ + [0.32, 0.2, 0.8], + [0.52, 0.2, 0.66], + [0.72, 0.2, 0.84], + ] as const).forEach(([v, u0, u1], i) => { + const a = P(u0, v), + b = P(u1, v); + lines += p( + "cw-write", + `M${r1(a[0])} ${r1(a[1])} L${r1(b[0])} ${r1(b[1])}`, + ` pathLength="1" style="animation-delay:${(i * 0.5).toFixed(2)}s"`, + ); + }); + return cubeStr(c) + lines; +})(); + +// 5) a real 3D wireframe globe (longitude rings in 3D, spinning on a tilt). +// Rendered as HTML/CSS rather than SVG so it can actually rotate in 3D. +const gmers = [0, 45, 90, 135] + .map((a) => '
        ') + .join(""); +const glats = [ + [-33, 15], + [-17, 4], + [17, 4], + [33, 15], +] + .map( + ([ty, ins]) => + '
        ', + ) + .join(""); +const globe = '
        ' + gmers + glats + '
        '; + +// 6) a few lit cubes among many faint ones +const bundle = (() => { + const lit = new Map([ + ["3,3", 0], + ["3,2", 0.5], + ["2,3", 1], + ]); + let s = ""; + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + const cu = cube(100 + (c - r) * 18, 46 + (c + r) * 9, 8, 9); + if (lit.has(`${r},${c}`)) { + const dl = ` style="animation-delay:${lit.get(`${r},${c}`)}s"`; + s += + p("cw-lit cw-fill2", cu.left, dl) + + p("cw-lit cw-fill2", cu.right, dl) + + p("cw-lit cw-fill", cu.top, dl); + } else { + s += p("cw-faint", cu.left) + p("cw-faint", cu.right) + p("cw-faint", cu.top); + } + } + } + return s; +})(); + +const figures = [ + { fig: "01", art: tube, title: "Promise pipelining", body: "Don't await a result before you use it. Chain dependent calls together and the whole chain resolves in a single network round trip — even over plain HTTP." }, + { fig: "02", art: bidi, title: "Bidirectional by default", body: "Sessions are symmetric: the client can call the server, and the server can call the client. Pass a function and the other side gets a stub that calls back." }, + { fig: "03", art: reference, title: "Pass by reference", body: "Classes that extend RpcTarget travel as references, not copies. You hold a stub; method calls run where the object actually lives." }, + { fig: "04", art: schemas, title: "No schemas, no codegen", body: "No .proto files, no build step, no generated clients. Types are just TypeScript — erased at runtime, and free." }, + { fig: "05", art: globe, title: "Runs everywhere", body: "Every major browser, Cloudflare Workers, Node, Deno, and Bun — over HTTP, WebSocket, MessagePort, or a transport you write yourself." }, + { fig: "06", art: bundle, title: bundleLabel, body: "Minified and gzipped, with zero dependencies. The whole protocol is human-readable JSON — you can read it straight from the network tab." }, +]; +--- + +
        + { + figures.map((f, i) => ( +
        + FIG. {f.fig} +
        + {i === 4 &&
        } + {i !== 4 && ( + + )} +
        +
        +

        {f.title}

        +

        {f.body}

        +
        +
        + )) + } +
        + + diff --git a/packages/docs/src/components/Header.astro b/packages/docs/src/components/Header.astro new file mode 100644 index 00000000..a1851e71 --- /dev/null +++ b/packages/docs/src/components/Header.astro @@ -0,0 +1,138 @@ +--- +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { Button } from "./ui/button"; +import { LinkButton } from "./ui/link-button"; +import { ThemeToggle } from "./ui/theme-toggle"; +import { SearchTrigger } from "./ui/search"; +import { config } from "virtual:nimbus/config"; +import { getSidebarSections } from "@cloudflare/nimbus-docs"; +import Wordmark from "./Wordmark.astro"; + +interface Props { + /** Astro collection id for the current page, forwarded from DocsLayout. */ + collection?: string; + /** Astro entry id for the current page, forwarded from DocsLayout. */ + entryId?: string; + /** + * Whether the page has a sidebar to open. When `false`, the mobile + * menu button is hidden — pages that opted out via `sidebar: false` + * shouldn't show a button that opens an empty dialog. Default `true`. + */ + showSidebar?: boolean; +} + +// `entryId` is part of Props as a forward-compat hook (the version-switcher +// recipe reads it) but the base Header doesn't use it — leave it off the +// destructure to avoid an unused-var warning. +const { collection, showSidebar = true } = Astro.props; + +// Normalize trailing slash so isActive matches sidebar hrefs. +const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; +const sections = await getSidebarSections(currentSlug, { collection }); +const showSections = sections.length >= 2; +--- + +
        +
        +
        + {/* + The wordmark, on a scrap of the hero's own banner. + + The mark is white with a black keyline, which needs a dark ground: on the + light scheme, dropped straight onto a `bg-background/80` header, the fill + disappears into the bar and all that is left is a hairline outline about + half a pixel thick at this size. The chip is the same gradient the hero + banner uses, so the header reads as a torn-off corner of it rather than a + second, unrelated treatment. + + The mark is `aria-hidden`; the link is named by the `sr-only` text, which + keeps the accessible name identical to what was here before. + + The `` around it is load-bearing. A scoped style here cannot size + ``'s own root `` -- Astro stamps that element with the + child's scope id, not this one -- so the width has to go on a wrapper + this file owns, and the svg's own `width: 100%` then fills it. + */} + + {config.title} + + + + {showSections && ( + + )} +
        + +
        + {config.search !== false && } + {config.github && ( + + + + )} + + {showSidebar && ( +
        +
        +
        + + diff --git a/packages/docs/src/components/Hero.astro b/packages/docs/src/components/Hero.astro new file mode 100644 index 00000000..2f4d41b0 --- /dev/null +++ b/packages/docs/src/components/Hero.astro @@ -0,0 +1,416 @@ +--- +/** + * The landing page hero. + * + * Under Starlight this came out of frontmatter, because Starlight owned the splash template and + * `hero:` was the only way in. Nimbus has no splash template -- `mode: custom` gives you a bare + * `
        ` and the page composes what it wants -- so the hero is an ordinary component used from + * the top of `index.mdx`, and its copy lives with the rest of the page's prose instead of in a + * frontmatter block that only one page can use. + * + * The backdrop -- a radial pool of stage colour, dissolved back into the page by + * a veil -- is drawn here rather than slotted in. It was a slot while a dozen + * comparison routes were swapping animations behind this same copy; with one + * landing page left, the slot was indirection around a caller that never varied, + * and its default (`NetworkHero`'s WebGL tunnel) was still being pulled into the + * bundle by the static import even once nothing rendered it. + * + * There is no canvas back here any more. The hero's animation is foreground + * content now -- see `canvas-hero/CanvasFigure.astro` -- and a second animation + * running behind it would only compete with it. + */ +import { LinkButton } from '@/components/ui/link-button'; +import Wordmark from './Wordmark.astro'; +import StarBadge from './StarBadge.astro'; + +interface Props { + title: string; + tagline: string; + /** + * Where the default slot sits relative to the headline. + * + * `above` is the landing page's own order and the default: the code windows + * lead, because they are the argument and the headline is the caption on it. + * That only works while the illustration is a static thing the eye can take in + * at a glance. An animated figure is read rather than glanced at, so it wants + * to be introduced first -- hence `below`, where the headline and tagline come + * first and the figure follows as the demonstration of what they claim. + */ + illustration?: 'above' | 'below'; +} + +const { title, tagline, illustration = 'above' } = Astro.props; +const hasIllus = Astro.slots.has('default'); +--- + +{/* + `relative` and nothing else. Two things this section must not do: + + `overflow-hidden` would clip the field, which is deliberately taller than the hero -- it runs on + behind the first band of prose and is dissolved by its own veil -- and clipping it put a hard + seam across the page where the two backgrounds met. + + `isolate` would make this section a stacking context, and a stacking context is painted as one + unit in the positioned layer: the field's `z-index: -1` would then be measured against the + section rather than the page, and the whole section, field included, would paint over the prose + that follows it. + + `overflow-x: clip` is safe where `overflow-hidden` is not: it stops the scrim's horizontal bleed + (`inset: ... -20%`) from pushing past the viewport on narrow screens -- the source of a stray + horizontal scroll -- while leaving vertical overflow visible so the taller field still runs on + behind the prose, and unlike `hidden`/`isolate` it creates no stacking context. +*/} +
        + {/* + The banner. Full-bleed, solid, and cut off square at the bottom -- the way + capnproto.org bands its masthead in red -- so the mark sits *on* something + rather than floating over the page. It ends where the tagline begins. + + This replaced a radial pool that was dissolved into the page by a veil. + A soft-edged backdrop and a stamped-on mark are arguing: the mark reads as + a physical object stuck to a surface, and the surface has to have an edge + for that to land. + */} +
        +
        + Cap'n Web +
        + {/* + The seal is wrapped rather than styled directly. A scoped style here + cannot reach a child component's own root element -- Astro gives that + element the child's scope id, not this one -- so `.cw-hero-seal` on + `` silently matched nothing. Positioning a wrapper this + file owns avoids reaching for `:global`, and `--cw-star-size` still + reaches the child because custom properties inherit. + */} +
        + {title} +
        +
        +
        + {/* + Top padding is the same 40px module as everything else here, measured from + the banner's hard edge to the tagline. No bottom padding: the gap from the + buttons to the first band of prose is `.docs-content`'s own 40px top + padding and nothing else. See the note on `cw-hero-illus` below. + */} +
        + {/* + Only ever one of these two renders, so the slot is consumed once. + + The margins either side of the figure are not the same number, and that is + not an oversight. Everything vertical in this hero is tuned to one 40px + module -- the same one `.docs-content` uses to space itself off the hero, + so the figure sits in the page's rhythm rather than its own. But the gap + the eye judges is to the figure's *ink*, and the canvas does not fill its + own box: the scene's `padTop` leaves 10px clear above the column headers + and the space reserved under the axis for the verdict leaves 21px below + it. So the CSS margins are 40 minus those, which lands all three gaps at + 40-42px measured ink to ink. `/tmp/opencode/vgaps.mjs` measures it. + + Compensating in CSS rather than tightening the scene is deliberate: the + dead space at the bottom is the verdict's descender room, and reclaiming + it would move `bottom`, which moves every vertical position in the figure + and every region in the contrast harness with it. + */} + { + hasIllus && illustration === 'above' && ( +
        + +
        + ) + } + {/* `cw-hero-lede` is a hook, not a style, for the same reason as + `cw-hero-actions`: the vertical rhythm above is a measured claim, and + `/tmp/opencode/vgaps.mjs` needs a stable handle on the tagline to check + it. It used to hang off `.cw-hero-scrim p`, which the banner replaced. */} +
        +

        + {tagline} +

        +
        + { + hasIllus && illustration === 'below' && ( +
        + +
        + ) + } + {/* `cw-hero-actions` is a hook, not a style: the canvas backdrops measure the + hero's content boxes so their scenes can lay out in the clear space beside + it, and the buttons are part of what they must not draw over. */} +
        + + Get started + + + View on GitHub + +
        +
        +
        + + diff --git a/packages/docs/src/components/NavList.astro b/packages/docs/src/components/NavList.astro new file mode 100644 index 00000000..a0f61f77 --- /dev/null +++ b/packages/docs/src/components/NavList.astro @@ -0,0 +1,88 @@ +--- +/** + * A clean, hairline-divided list of links -- the landing's alternative to a + * stack of filled cards. Transparent rows, one border around the group, an + * immediate hover tint, and an arrow that nudges. Pairs with the bento above. + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; + +interface Item { + title: string; + description?: string; + href: string; +} +interface Props { + items: Item[]; +} +const { items } = Astro.props; +--- + + + + diff --git a/packages/docs/src/components/Playground.astro b/packages/docs/src/components/Playground.astro new file mode 100644 index 00000000..0f803bed --- /dev/null +++ b/packages/docs/src/components/Playground.astro @@ -0,0 +1,465 @@ +--- +/** + * A live example, laid out like a code playground: the real source on the left, + * the running demo on the right, filling most of the viewport. + * + * Two things keep it honest. The source is read out of the repo at build time + * by `readRepoFile`, so it cannot drift from the code that ships -- a renamed + * file fails the build rather than rendering an empty tab. And the demo in the + * frame is the example's own Worker and client, bundled by + * `scripts/build-playgrounds.mjs` and running in the page; the request counts + * it reports are real. + * + * The chrome here deliberately does not use the site's palette. It is meant to + * read as an editor, so it borrows the code theme's own colours instead. + */ +import { Code } from '@/components/ui/code'; +import { readRepoFile } from '../lib/source'; +import type { Example } from '../examples'; + +interface Props { + example: Example; +} + +const { example } = Astro.props; + +const files = example.files.map((file) => ({ + ...file, + code: readRepoFile(file.path), +})); + +const id = `pg-${example.slug}`; +// Same-origin, so the frame can read the docs theme itself and `src` can be +// server-rendered -- no flash, and the code still shows with JS disabled. +const demoSrc = `${example.demoPath}?embed=1`; +--- + +
        +
        +
        + { + files.map((file, i) => ( + + )) + } +
        + +
        + { + files.map((file, i) => ( + + )) + } +
        +
        + +
        +
        + {example.demoPath} + + Open + + +
        + +
        +
        + + + + + + diff --git a/packages/docs/src/components/Prose.astro b/packages/docs/src/components/Prose.astro new file mode 100644 index 00000000..59769ccd --- /dev/null +++ b/packages/docs/src/components/Prose.astro @@ -0,0 +1,18 @@ +--- +/** + * The prose container a `mode: custom` page has to bring itself. + * + * `mode: custom` gives the page a bare `
        `: no sidebar, no table of contents, and -- the part + * that is easy to miss -- no `.docs-content` wrapper and no width cap either. Every prose rule in + * `styles/prose.css` is scoped to `.docs-content`, so without this the landing page's body text + * renders unstyled and edge to edge. + * + * Width matches a doc page's text measure: 784px, filled edge to edge. Mobile keeps a small gutter; + * from `lg` up the horizontal padding is dropped so the text is the full 784px rather than 784 minus + * padding. + */ +--- + +
        + +
        diff --git a/packages/docs/src/components/Render.astro b/packages/docs/src/components/Render.astro new file mode 100644 index 00000000..4ec3eefd --- /dev/null +++ b/packages/docs/src/components/Render.astro @@ -0,0 +1,106 @@ +--- +/** + * Render — include a reusable partial in any docs page. + * + * + * + * + * Partials live in `src/content/partials/` as MDX. Declare params in + * partial frontmatter (`params: [runtime, version?]`); required params + * fail at build time, optional use a `?` suffix. + */ +import { getCollection, getEntry, render } from "astro:content"; +import { components } from "../components"; + +interface Props { + /** Partial ID — path relative to src/content/partials/ without extension. */ + file: string; + /** Parameters passed to the partial as props. */ + params?: Record; +} + +const { file, params } = Astro.props; +const page = Astro.url.pathname; +const partial = await getEntry("partials", file); + +if (!partial) { + const allPartials = await getCollection("partials"); + const partialIds = allPartials.map((p) => p.id); + const hint = closest(file, partialIds); + const shortList = partialIds.sort().slice(0, 10).join(", "); + const tail = + partialIds.length > 10 + ? ` (and ${partialIds.length - 10} more)` + : partialIds.length === 0 + ? "none" + : ""; + throw new Error( + `[Render] Partial "${file}" not found, included on "${page}".` + + (hint ? ` Did you mean "${hint}"?` : "") + + ` Available: ${shortList}${tail}`, + ); +} + +const declaredParams = partial.data.params; +if (declaredParams) { + const required = declaredParams.filter((param: string) => !param.endsWith("?")); + const optional = declaredParams.filter((param: string) => param.endsWith("?")); + const allNames = [...required, ...optional.map((param: string) => param.slice(0, -1))]; + const received = Object.keys(params ?? {}); + + const missing = required.filter((param: string) => !received.includes(param)); + if (missing.length > 0) { + throw new Error( + `[Render] Missing required params ${JSON.stringify(missing)} for "${file}" on "${page}". ` + + `Expected: ${JSON.stringify(declaredParams)}, received: ${JSON.stringify(received)}`, + ); + } + + const unexpected = received.filter((param) => !allNames.includes(param)); + if (unexpected.length > 0) { + const unexpectedHints = unexpected + .map((u) => { + const h = closest(u, allNames); + return h ? `"${u}" (did you mean "${h}"?)` : `"${u}"`; + }) + .join(", "); + throw new Error( + `[Render] Unexpected params ${unexpectedHints} for "${file}" on "${page}". ` + + `Declared: ${JSON.stringify(declaredParams)}`, + ); + } +} + +const { Content } = await render(partial); + +/** Levenshtein distance — inlined to keep this component dep-free. */ +function distance(a: string, b: string): number { + if (a === b) return 0; + if (!a.length) return b.length; + if (!b.length) return a.length; + const v0 = new Array(b.length + 1); + const v1 = new Array(b.length + 1); + for (let i = 0; i <= b.length; i++) v0[i] = i; + for (let i = 0; i < a.length; i++) { + v1[0] = i + 1; + for (let j = 0; j < b.length; j++) { + const cost = a[i] === b[j] ? 0 : 1; + v1[j + 1] = Math.min(v1[j] + 1, v0[j + 1] + 1, v0[j] + cost); + } + for (let j = 0; j <= b.length; j++) v0[j] = v1[j]; + } + return v1[b.length]; +} + +function closest(target: string, candidates: string[], maxDist = 3): string | null { + const t = target.toLowerCase(); + let best: { name: string; dist: number } | null = null; + for (const c of candidates) { + const d = distance(t, c.toLowerCase()); + if (d <= maxDist && (!best || d < best.dist)) best = { name: c, dist: d }; + } + return best?.name ?? null; +} +--- + + diff --git a/packages/docs/src/components/StarBadge.astro b/packages/docs/src/components/StarBadge.astro new file mode 100644 index 00000000..2d788cf4 --- /dev/null +++ b/packages/docs/src/components/StarBadge.astro @@ -0,0 +1,126 @@ +--- +/* + * The seal, in the corner of the hero, the way capnproto.org puts "infinitely + * faster!" in the corner of its banner. + * + * The words inside it are real DOM text, not SVG `` and not part of the + * artwork. They are the page's `

        `, so they have to stay selectable, + * translatable, searchable and legible to the contrast harness. Only the star + * itself is drawn, and it is `aria-hidden`. + * + * That does cost some fidelity -- the reference's legend is Bookman, the same + * face as the wordmark, and this cannot be without shipping a webfont for three + * words. The stack below asks for Bookman first, which a good share of machines + * already have, and falls back to Georgia: a high-contrast serif that reads the + * same way in a stamp. Outlining these words to match the wordmark exactly was + * the alternative and was rejected; see the paragraph above for why. + * + * Its colours are fixed rather than themed, which is the one place this + * component departs from the rest of the site on purpose. A seal is a stamped + * object: it is the same colour on both schemes for the same reason the + * reference's yellow starburst is the same colour on every page. Tomato fill + * with `--cw-black` lettering measures 5.69:1, so it clears AA as a fixed pair + * without either value having to move when the toggle does. + */ +import { STAR_PATH } from './logo-paths.ts'; + +interface Props { + class?: string; + /** Rendered inside the seal. Wrapped in whatever the caller passes as `as`. */ + as?: 'h1' | 'p' | 'div'; +} +const { class: className = '', as: Tag = 'div' }: Props = Astro.props; +--- + +
        + + +
        + + diff --git a/packages/docs/src/components/Wordmark.astro b/packages/docs/src/components/Wordmark.astro new file mode 100644 index 00000000..aa74e1da --- /dev/null +++ b/packages/docs/src/components/Wordmark.astro @@ -0,0 +1,91 @@ +--- +/* + * The Cap'n Web wordmark: a parody of capnproto.org's own, which is itself a + * Cap'n Crunch parody by the author of this library. + * + * Drawn, not set. The geometry lives in `logo-paths.ts` and is regenerated by + * `scripts/build-wordmark.mjs` -- see the README, "The wordmark", for what the + * numbers in it mean and where they were measured from. Nothing here needs a + * font at runtime, which is the point: a logo that spends 200ms as Georgia is + * not a logo. + * + * The colours default to the stamp: white letters with a black keyline, the + * same on both schemes, the way `StarBadge` is fixed. The keyline is what + * carries it across the toggle -- on the dark scheme the white fill does the + * work and the keyline barely shows, on the light scheme the fill nearly + * vanishes into the band and the keyline draws the whole mark -- so there is no + * `prefers-color-scheme` rule here. + * + * `--cw-mark-fill` and `--cw-mark-stroke` exist for one caller: the header, + * which sets both to `currentColor` and gets a solid silhouette. That is not a + * theming hook, it is a size reduction. The stamp needs two colours and a + * keyline to read, and at 40px wide the keyline is `LOCKUP_STROKE` scaled to + * about a third of a pixel: it renders as grey haze, which on light paper + * leaves a white mark with nothing to draw it. Below roughly 6rem the two + * colours stop being legible detail and start being mush, and one colour is the + * honest reduction. Anything that wants a *differently coloured* stamp should + * be reconsidered rather than reaching for these. + */ +import { CAPN_PATHS, WEB_PATHS, LOCKUP_VIEWBOX, LOCKUP_STROKE } from './logo-paths.ts'; + +interface Props { + class?: string; + /** + * The mark's accessible name. It is an image of words, so by default it gets + * one; a screen reader should hear the product name and not "logo". + * + * Pass `""` when the caller already names the mark -- the hero does, because + * it needs the name to come before the seal that follows it. That makes this + * purely decorative, and it is then hidden outright rather than left as a + * `role="img"` with an empty name, which some screen readers still announce. + */ + label?: string; +} +const { class: className = '', label = "Cap'n Web" } = Astro.props; +const decorative = label === ''; +--- + + + {/* + `paint-order: stroke` puts the keyline under the fill, so only its outer + half shows and the letterforms keep their true weight. Stroking over the + fill instead would eat `LOCKUP_STROKE / 2` off every stem and thin the + counters to nothing at small sizes. + + One `` per glyph, painted in order. Collapsing them into one path + per line makes the letters subpaths of a single shape, and the fill then + floods their union and swallows every keyline that runs through a + touching pair. See `scripts/build-wordmark.mjs`. + */} + + {[...CAPN_PATHS, ...WEB_PATHS].map((d) => )} + + + + diff --git a/packages/docs/src/components/canvas-hero/CanvasFigure.astro b/packages/docs/src/components/canvas-hero/CanvasFigure.astro new file mode 100644 index 00000000..06d4e062 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/CanvasFigure.astro @@ -0,0 +1,77 @@ +--- +/** + * A canvas scene mounted as foreground content, rather than as a backdrop. + * + * The hero's backdrop is decoration: absolutely positioned, `z-index: -1`, + * `aria-hidden`, and softened by a veil. This is the opposite in every one of + * those respects. The scene is in the flow, at full contrast, with nothing over + * it, and it carries the hero's argument instead of decorating it -- so it is a + * `
        ` with a real caption for anyone who cannot see it, and the numbers in + * that caption are the same ones the scene draws. + * + * The canvas stays `aria-hidden`, because a canvas exposes nothing useful to a + * screen reader; the visually hidden `
        ` is what actually conveys it. + * That split is deliberate: a `role="img"` with an `aria-label` would work too, + * but a caption can carry the full comparison rather than one attribute's worth. + */ +interface Props { + /** The accessible description. Says what the animation says, in words. */ + caption: string; +} + +const { caption } = Astro.props; +--- + +
        + +
        {caption}
        +
        + + + + diff --git a/packages/docs/src/components/canvas-hero/canvas-hero.client.ts b/packages/docs/src/components/canvas-hero/canvas-hero.client.ts new file mode 100644 index 00000000..42bbc0f7 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/canvas-hero.client.ts @@ -0,0 +1,196 @@ +/** + * Canvas 2D scene harness. + * + * Holds everything a scene would otherwise have to get right for itself: + * + * - device pixel ratio capped at 2, matching the WebGL hero, because a 3x phone + * would otherwise rasterize nine times the pixels for no visible gain; + * - the loop is parked when the hero scrolls out of view and when the tab is + * hidden, and scene time does not advance while parked, so nothing teleports + * when it resumes; + * - the palette is re-read on a `data-theme` change and the frame is repainted + * even while parked, or a scene scrolled past during a toggle keeps the old + * colours until it comes back; + * - `prefers-reduced-motion` gets one composed still frame rather than no canvas + * at all, which is what the WebGL hero has to do. A canvas the harness never + * animates is not a motion problem, and an empty hero is worse than a static + * diagram. + */ +import { readPalette } from "./palette"; +import type { Palette, Scene, SceneFactory, SceneSize } from "./types"; + +export function mountCanvasHero(container: HTMLElement, factory: SceneFactory): () => void { + // One canvas per container, always. Mounting twice appends a second canvas and + // runs a second animation loop over it, which is how the hero figure briefly + // shipped drawn twice, one copy below the other. + if (container.dataset.cwCanvasMounted === "1") return () => {}; + container.dataset.cwCanvasMounted = "1"; + + const canvas = document.createElement("canvas"); + canvas.setAttribute("aria-hidden", "true"); + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.display = "block"; + container.appendChild(canvas); + + const ctx = canvas.getContext("2d", { alpha: true }); + if (!ctx) { + container.removeChild(canvas); + return () => {}; + } + + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + const scene: Scene = factory(); + let palette: Palette = readPalette(); + let size: SceneSize = { width: 1, height: 1 }; + /** Set by the disposer, so the one async path it cannot cancel can bail. */ + let disposed = false; + + const paint = (t: number, dt: number, still: boolean) => { + ctx.clearRect(0, 0, size.width, size.height); + // Saved unconditionally, so a scene cannot leak `font`, `textAlign`, + // `globalAlpha` or a line dash out of one frame and into the next. + ctx.save(); + scene.draw({ ctx, size, palette, t, dt, still }); + ctx.restore(); + }; + + // ---- sizing ---------------------------------------------------------------- + + const setSize = () => { + const rect = container.getBoundingClientRect(); + const w = Math.max(1, Math.floor(rect.width)); + const h = Math.max(1, Math.floor(rect.height)); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const cw = Math.floor(w * dpr); + const chh = Math.floor(h * dpr); + // `ResizeObserver` fires for sub-pixel changes that floor to the same integer, + // and continuously through a drag or a rotation. Assigning `canvas.width` resets + // the backing store, so without this every one of those ticks reallocates the + // canvas and re-lays out the scene for no change at all. + if (cw === canvas.width && chh === canvas.height && size.width === w) return; + canvas.width = cw; + canvas.height = chh; + // Scale once here so every scene can think in CSS pixels. + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + size = { width: w, height: h }; + scene.layout?.(size); + }; + + const ro = new ResizeObserver(() => { + setSize(); + paint(sceneTime, 0, reduced.matches); + }); + ro.observe(container); + + // Repaint once the webfonts land, which is always after first paint. Canvas text + // is rasterized at draw time with no reflow behind it, so a frame painted before + // the swap keeps its fallback font for as long as it is on screen -- and under + // reduced motion that is one frame, forever. The `disposed` guard matters + // because this is the one async path the disposer cannot cancel: `mount` tears + // down on `astro:before-swap`, and measuring a detached canvas would resize the + // scene to 1x1. + void document.fonts?.ready.then(() => { + if (disposed) return; + setSize(); + paint(sceneTime, 0, reduced.matches); + }); + + // ---- clock ----------------------------------------------------------------- + + // Scene time is accumulated rather than read off the timestamp, so parking the + // loop pauses the story instead of fast-forwarding it. + let sceneTime = 0; + let last = 0; + let raf = 0; + let isVisible = true; + let isPageVisible = !document.hidden; + + const loop = (now: number) => { + // First frame after a resume has no meaningful delta; clamp covers both that + // and a browser that throttled us in a background tab. + const dt = last === 0 ? 0 : Math.min((now - last) / 1000, 1 / 20); + last = now; + sceneTime += dt; + paint(sceneTime, dt, false); + raf = requestAnimationFrame(loop); + }; + + const tryStart = () => { + if (reduced.matches) return; + if (isVisible && isPageVisible && raf === 0) { + last = 0; + raf = requestAnimationFrame(loop); + } + }; + const tryStop = () => { + if (raf !== 0) { + cancelAnimationFrame(raf); + raf = 0; + } + }; + + // ---- scheme ---------------------------------------------------------------- + + const schemeObserver = new MutationObserver(() => { + palette = readPalette(); + // Repaint now: if the loop is parked, nothing else will. + paint(sceneTime, 0, reduced.matches); + }); + schemeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + // ---- visibility ------------------------------------------------------------ + + const io = new IntersectionObserver( + ([entry]) => { + isVisible = entry.isIntersecting; + if (isVisible) tryStart(); + else tryStop(); + }, + { threshold: 0 }, + ); + io.observe(container); + + const onVisibility = () => { + isPageVisible = !document.hidden; + if (isPageVisible) tryStart(); + else tryStop(); + }; + document.addEventListener("visibilitychange", onVisibility); + + // Honour a mid-session change to the motion preference in both directions. + const onReduced = () => { + if (reduced.matches) { + tryStop(); + paint(sceneTime, 0, true); + } else { + tryStart(); + } + }; + reduced.addEventListener("change", onReduced); + + // ---- go -------------------------------------------------------------------- + + setSize(); + if (reduced.matches) paint(0, 0, true); + else tryStart(); + + return () => { + disposed = true; + delete container.dataset.cwCanvasMounted; + tryStop(); + ro.disconnect(); + io.disconnect(); + schemeObserver.disconnect(); + document.removeEventListener("visibilitychange", onVisibility); + reduced.removeEventListener("change", onReduced); + try { + container.removeChild(canvas); + } catch { + /* already gone */ + } + }; +} diff --git a/packages/docs/src/components/canvas-hero/palette.ts b/packages/docs/src/components/canvas-hero/palette.ts new file mode 100644 index 00000000..590025c2 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/palette.ts @@ -0,0 +1,66 @@ +/** + * Scene colours, read from the theme rather than hardcoded. + * + * Every value comes from a `--cw-*` or `--nb-*` custom property already defined + * in `globals.css`, so a scene inherits the measured palette and follows the + * light/dark flip for free. The one thing deliberately absent is the tomato + * accent: `--cw-orange` is the call to action, and an animation is not that. + */ +import type { Palette } from "./types"; + +const readVar = (styles: CSSStyleDeclaration, name: string, fallback: string): string => { + const v = styles.getPropertyValue(name).trim(); + return v === "" ? fallback : v; +}; + +/** + * Resolves a colour to `"r g b"` via a 1x1 canvas. + * + * `color-mix()` and the `oklab()`/`color(srgb ...)` forms that `getComputedStyle` + * hands back cannot be split with a regex, and scenes need channels to build + * their own alphas. This is the same trick the contrast harness uses, for the + * same reason. + */ +const toRgbTriplet = (color: string): string => { + const cv = document.createElement("canvas"); + cv.width = cv.height = 1; + const cx = cv.getContext("2d", { willReadFrequently: true }); + if (!cx) return "128 128 128"; + // Assigning an invalid colour to `fillStyle` is a no-op that leaves the previous + // value, so seeding grey first means an unparseable input paints grey rather + // than transparent black, which would read as a legitimate `0 0 0`. + cx.fillStyle = "#808080"; + cx.fillStyle = color; + cx.clearRect(0, 0, 1, 1); + cx.fillRect(0, 0, 1, 1); + const d = cx.getImageData(0, 0, 1, 1).data; + return `${d[0]} ${d[1]} ${d[2]}`; +}; + +export function readPalette(): Palette { + const root = document.documentElement; + const styles = getComputedStyle(root); + const light = root.dataset.theme === "light"; + + // `--cw-art-stroke` is the bento line-art colour, already tuned per scheme to + // be dark on paper and light on ink, which is exactly what a scene needs. + const stroke = readVar(styles, "--cw-art-stroke", light ? "#253c6d" : "#8fb0ec"); + + return { + light, + stroke, + // `||` rather than a default argument: defaults are eager, so passing + // `toRgbTriplet(stroke)` would build a canvas and read pixels back on every + // theme toggle even though the property is always defined. + strokeRgb: readVar(styles, "--cw-art-stroke-rgb", "") || toRgbTriplet(stroke), + // Request and response must not be the same hue, or a round trip reads as + // one long line rather than as two legs. + request: light ? "#0a2bb5" : "#7aa2ff", + response: light ? "#0e6b52" : "#4fd6a8", + muted: readVar(styles, "--nb-muted-foreground", light ? "#4c5a6a" : "#adbccb"), + foreground: readVar(styles, "--nb-foreground", light ? "#1a222b" : "#e8eef4"), + fade: light ? "#8792a3" : "#59677a", + mono: readVar(styles, "--nb-font-mono", "ui-monospace, monospace"), + sans: readVar(styles, "--nb-font-sans", "system-ui, sans-serif"), + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/versus.ts b/packages/docs/src/components/canvas-hero/scenes/versus.ts new file mode 100644 index 00000000..ff1d5e49 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/versus.ts @@ -0,0 +1,556 @@ +/** + * Scene 9: the headline claim as a foreground figure, not a backdrop. + * + * Every other scene here is art behind the copy. This one is the copy's evidence, + * so it is mounted in the flow at full contrast with nothing over it, and it is + * allowed to draw text and to be measured for legibility like any other content. + * + * Two sequence diagrams side by side, sharing one vertical time axis. Left is + * what four dependent calls cost without pipelining: each one is awaited before + * the next can be written, so each pays for its own round trip and its own turn + * at the far end, which on a 100ms link with a 10ms handler is 440ms. Right is + * the same four calls with Cap'n Web: every push is written back to back without + * waiting, because an `RpcPromise` is also a stub for its own eventual result and + * can be passed as an argument before it resolves. The far end substitutes the + * real values on arrival, runs all four handlers, and answers once: 140ms. + * + * The 40ms of server work is identical on both sides, and that is deliberate. + * The saving is not the far end doing less, it is the four trips it no longer + * spends waiting between doing it. + * + * The shared axis is what makes this an argument rather than two pictures. Both + * panels are drawn against the same 0..440ms scale, so the right panel visibly + * stops a third of the way down and the 300ms below it is empty. That gap is the + * product, and it is drawn to scale rather than asserted. + * + * Numbers and call names are the docs' own, from `start/pipelining-tour.md`. + */ +import type { Palette, Scene, SceneSize } from "../types"; + +/* + * The figure's clock is milliseconds of story, not frames and not "legs". + * + * It was in legs, where one leg was one network crossing and everything else was + * expressed as a fraction of one. That was fine while the far end answered + * instantly, and stopped being fine the moment the server got a running time of + * its own: 10ms is a fifth of a leg, and a model that can only count crossings + * cannot place it. Everything below is in milliseconds and `yAt` is the only + * thing that knows how tall a millisecond is. + */ + +/** One network crossing. A round trip is two, and the docs' link is 100ms. */ +const MS_PER_LEG = 50; +/** + * What the far end spends on one call before it can answer. + * + * Small, but not zero, and drawing it as zero was a quiet lie: it made the server + * an ideal mirror and put the entire cost of the chain on the network. It also + * flattered the pipelined side, where four handlers run back to back and the + * saving comes from the trips they no longer each wait for -- not from the work + * disappearing. The work is the same 40ms on both sides. That is the point. + */ +const MS_SERVER = 10; +const CALL_COUNT = 4; +/** Await each: out, work, back, four times over. */ +const SLOW_MS = CALL_COUNT * (MS_PER_LEG * 2 + MS_SERVER); +/** Pipelined: out once, all four handlers, back once. */ +const FAST_MS = MS_PER_LEG * 2 + CALL_COUNT * MS_SERVER; +/** The axis is as tall as the slower of the two. */ +const TOTAL_MS = SLOW_MS; +const SAVED_MS = SLOW_MS - FAST_MS; + +/** Seconds of animation per millisecond of story. */ +const SEC_PER_MS = 0.62 / MS_PER_LEG; +const HOLD = 2.4; +const CYCLE = TOTAL_MS * SEC_PER_MS + HOLD; +/** How long a verdict or a label takes to fade up, in story milliseconds. */ +const FADE_MS = 25; +/** + * Where the reduced-motion still freezes. The middle of the hold, not its first + * instant: verdicts fade in from the moment they are earned, so freezing at + * exactly `TOTAL_MS` catches the slow lane's verdict at `globalAlpha === 0` and + * the still loses the very number it exists to show. + */ +const STILL_AT = TOTAL_MS + HOLD / SEC_PER_MS / 2; + +const CALLS = ["authenticate", "getUserId", "getUserProfile", "getFriendIds"]; +/** + * What lands at the far end: one request carrying all four calls. + * + * Named at the server rail as well as the client one because the two ends are + * making different halves of the same point. The client wrote a pipeline; what + * crossed the wire was a single batched request; and the far end therefore runs + * all four handlers before it answers at all. + */ +const ARRIVAL_NOTE = "batched request"; +/** Mono advance per character at 1em, the same approximation `space.ts` uses. */ +const MONO_ADV = 0.6; +const LABEL_PX = 11; +/** Width of a margin label, plus a little air. */ +const needFor = (text: string) => text.length * LABEL_PX * MONO_ADV + 6; +const CALL_NEED = Math.max(...CALLS.map(needFor)); +/** + * The outer margins are equal by construction, so they share one budget. + * + * The call names sit in the left one and the arrival note in the right one; the + * layout mirrors about the axis, so whichever is longer decides whether either + * can be shown. Taking the max rather than assuming the call names always win + * keeps that true if the strings change. + */ +const OUTER_NEED = Math.max(CALL_NEED, needFor(ARRIVAL_NOTE)); +/** Gap between a margin label and the rail it hangs off. */ +const LABEL_GAP = 9; +/** + * How far the rails sit either side of a panel's centre. + * + * 100 rather than the 120 this started at, which is not a taste change: the + * margin left of the client rail is where the call labels live, and at 11px the + * longest of them needs ~98px of it. At 120 the labels did not fit inside the + * hero's 976px figure at any desktop width and were suppressed everywhere. The + * rails are still 200px apart, which is more than the diagram needs. + */ +const RAIL_HALF = 100; +/** Clear space between the axis column and the nearest ink either side of it. */ +const AXIS_PAD = 8; +/** Half the width of a centred rail label, the outermost ink when labels are off. */ +const RAIL_LABEL_HALF = 22; +/** The widest thing hanging left of the axis line: a tick number, plus its gap. */ +const TICK_NEED = 6 + Math.ceil(String(TOTAL_MS - (TOTAL_MS % 100)).length * LABEL_PX * MONO_ADV); +/** + * Distance from the shared axis to the nearest rail of each panel. + * + * The axis stands between the two diagrams rather than to the left of both, so + * this is the width of the column it lives in, per side, and the two sides do not + * need the same thing. Computed rather than typed, because every previous value + * here was a number someone had measured once and then left behind: it was 104 + * while the fast panel's client rail carried a "one message" note, then 88 when + * that note was shortened to "pipeline". + * + * The note is now gone entirely, so the right side has nothing in it but the fast + * panel's "client" rail label and the left side is the binding one: the slow + * panel's "server" label, then the tick numbers reaching back towards it. That is + * 56, which takes another 64px of dead space out of the middle of the figure. + */ +const AXIS_HALF = + Math.max( + // Left: the "server" rail label, then the tick numbers approaching it. + RAIL_LABEL_HALF + TICK_NEED, + // Right: the "client" rail label, and nothing else any more. + RAIL_LABEL_HALF, + ) + AXIS_PAD; +/** The same column when there is no axis in it, so the panels merely separate. */ +const BARE_HALF = 24; + +interface Panel { + cx: number; + clientX: number; + serverX: number; +} + +interface Layout { + panels: [Panel, Panel]; + /** Top and bottom of the time axis, which both panels share. */ + top: number; + bottom: number; + axisX: number; + showAxis: boolean; + /** Drop the per-call labels when the panels are too narrow to hold them. */ + compact: boolean; + /** + * Whether the saving is named as well as shaded. + * + * A separate threshold from `compact`, because they are different lengths in + * different places: a call label sits in the margin left of the client rail and + * needs ~110px there, while "300 ms saved" sits centred in a band that is the + * full width between the rails. Tying both to one flag dropped the figure's + * punchline at 600px, where it plainly fitted. + */ + showSaved: boolean; + headerY: number; +} + +export function versus(): Scene { + let L: Layout | null = null; + + const layout = (s: SceneSize) => { + const padX = 12; + const padTop = 10; + const padBottom = 14; + // The axis earns its column only when there is width to spare; below that the + // panels need every pixel and the verdicts still carry the numbers. + const showAxis = s.width >= 520; + /* + * The axis is the middle of the figure, and the two diagrams are placed + * symmetrically either side of it. + * + * It used to hang off the left edge with both panels to the right of it, which + * is the conventional place for a y-axis and the wrong one here. This axis is + * not one panel's scale, it is the single shared clock that makes the two + * readable against each other, and standing it between them says so. It also + * fixes the composition: the axis was the leftmost ink on the figure with + * nothing answering it on the right, so the whole thing leaned, and no amount + * of centring the panels among themselves could correct for an element that + * only existed on one side. + * + * Placing the diagrams by construction rather than by tiling the width is what + * makes this symmetric for free. There is no panel box any more: each diagram + * is `inner` from the axis and `half * 2` wide, so the pair is a mirror about + * the centre at every width, and the margins outside them are equal without + * being computed. + */ + const cx = s.width / 2; + const inner = showAxis ? AXIS_HALF : BARE_HALF; + // What is left over once the axis column and the outer rail labels are paid + // for, split between the two diagrams. The cap is the real width; the formula + // only binds on narrow figures. + const spare = (s.width - padX * 2 - inner * 2 - RAIL_LABEL_HALF * 2) / 4; + const half = Math.max(28, Math.min(RAIL_HALF, spare)); + const panels: [Panel, Panel] = [ + { cx: cx - inner - half, clientX: cx - inner - half * 2, serverX: cx - inner }, + { cx: cx + inner + half, clientX: cx + inner, serverX: cx + inner + half * 2 }, + ]; + // Headers, then the rail labels, then the diagram. The verdict lives in the + // bottom padding under the axis. + const headerY = padTop; + const top = padTop + 44; + const bottom = s.height - padBottom - 26; + const axisX = cx; + /* + * Whether the margin labels fit, measured rather than guessed. + * + * Both remaining labels -- the call names off the slow panel's client rail and + * the arrival note off the fast panel's server rail -- now live in the figure's + * outer margins, which are equal by the mirror, so one budget stands for both. + * There used to be a second budget for the space between the axis and the fast + * client rail; nothing hangs there any more. + * + * A width threshold cannot see what is beside a rail. At 900px a `panelW < 240` + * test once said there was room while "getUserProfile" was in fact running into + * the `200` tick, which is why this is a subtraction of real coordinates rather + * than a breakpoint. + */ + const budget = panels[0].clientX - LABEL_GAP - padX; + L = { + panels, + top, + bottom, + axisX, + showAxis, + compact: budget < OUTER_NEED, + showSaved: half * 2 >= 120, + headerY, + }; + }; + + /** y for a point `ms` into the shared time axis. */ + const yAt = (l: Layout, ms: number) => + l.top + (l.bottom - l.top) * Math.min(1, Math.max(0, ms / TOTAL_MS)); + + const rail = ( + ctx: CanvasRenderingContext2D, + p: Palette, + l: Layout, + x: number, + label: string, + ) => { + ctx.strokeStyle = `rgb(${p.strokeRgb} / 0.35)`; + ctx.lineWidth = 1; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(x, l.top); + ctx.lineTo(x, l.bottom); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = p.muted; + ctx.globalAlpha = 1; + ctx.font = `500 ${LABEL_PX}px ${p.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, x, l.top - 7); + }; + + /** + * One crossing, from `fromX` to `toX`, departing at `startMs`. + * + * Draws nothing before it departs and leaves the finished line in place once it + * has arrived, so the diagram accumulates into a readable trace rather than + * being a single dot that erases its own history. + */ + const leg = ( + ctx: CanvasRenderingContext2D, + p: Palette, + l: Layout, + fromX: number, + toX: number, + startMs: number, + now: number, + colour: string, + label?: string, + ) => { + if (now < startMs) return; + const f = Math.min(1, (now - startMs) / MS_PER_LEG); + const y0 = yAt(l, startMs); + const y1 = yAt(l, startMs + MS_PER_LEG); + const x = fromX + (toX - fromX) * f; + const y = y0 + (y1 - y0) * f; + + ctx.strokeStyle = colour; + ctx.globalAlpha = 0.6; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(fromX, y0); + ctx.lineTo(x, y); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.fillStyle = colour; + ctx.fillRect(x - 2.5, y - 2.5, 5, 5); + + if (label && !l.compact) { + // Outside the rails, not between them. Inside, a label sits directly under + // the departing line it belongs to and the line crosses its own caption + // within a few pixels; there is a couple of hundred pixels of clear panel + // to the left of the client rail, so the label goes there and the diagram + // reads like the sequence diagram it is. + ctx.font = `400 ${LABEL_PX}px ${p.mono}`; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + // Fades in with the leg, but reaches full strength: `muted` is already the + // measured secondary colour, and multiplying it by a constant is the + // `opacity` sin that put these labels at 3.48:1 in light. + ctx.globalAlpha = f; + ctx.fillStyle = p.muted; + ctx.fillText(label, fromX - LABEL_GAP, y0); + ctx.globalAlpha = 1; + } + }; + + /** The finish line for a panel, plus what it cost. */ + const verdict = ( + ctx: CanvasRenderingContext2D, + p: Palette, + l: Layout, + panel: Panel, + atMs: number, + now: number, + text: string, + colour: string, + ) => { + if (now < atMs) return; + const f = Math.min(1, (now - atMs) / FADE_MS); + const y = yAt(l, atMs); + ctx.globalAlpha = f * 0.85; + ctx.strokeStyle = colour; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(panel.clientX - 14, y); + ctx.lineTo(panel.serverX + 14, y); + ctx.stroke(); + ctx.globalAlpha = f; + ctx.fillStyle = colour; + ctx.font = `600 12px ${p.sans}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(text, panel.cx, y + 7); + ctx.globalAlpha = 1; + }; + + const drawAxis = (ctx: CanvasRenderingContext2D, p: Palette, l: Layout, now: number) => { + if (!l.showAxis) return; + ctx.strokeStyle = `rgb(${p.strokeRgb} / 0.3)`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(l.axisX, l.top); + ctx.lineTo(l.axisX, l.bottom); + ctx.stroke(); + ctx.font = `400 ${LABEL_PX}px ${p.mono}`; + ctx.textAlign = "right"; + // The unit, once, at the head of the axis, so the ticks can stay bare numbers + // and the column between the panels stays narrow. + ctx.textBaseline = "bottom"; + ctx.fillStyle = p.muted; + ctx.fillText("ms", l.axisX - 6, l.top - 7); + ctx.textBaseline = "middle"; + // A round tick every 100ms. The axis runs past the last one, to 440, because + // the scale is the slow lane's real cost and rounding it down to fit the + // labels would be drawing a different number from the one in the verdict. The + // ticks cross the line rather than stopping at it, because the axis has a + // diagram on both sides and a tick reaching only one would imply the scale + // belonged to that one. + for (let ms = 0; ms <= TOTAL_MS; ms += 100) { + const y = yAt(l, ms); + ctx.strokeStyle = `rgb(${p.strokeRgb} / 0.35)`; + ctx.beginPath(); + ctx.moveTo(l.axisX - 3, y); + ctx.lineTo(l.axisX + 3, y); + ctx.stroke(); + ctx.fillStyle = p.muted; + ctx.fillText(`${ms}`, l.axisX - 6, y); + } + // The head of the clock, so the axis reads as elapsed time rather than a ruler. + const y = yAt(l, Math.min(now, TOTAL_MS)); + ctx.strokeStyle = p.muted; + ctx.globalAlpha = 0.5; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(l.axisX - 4, y); + ctx.lineTo(l.axisX + 4, y); + ctx.stroke(); + ctx.globalAlpha = 1; + }; + + /** + * The span the right panel is finished and the left is not. + * + * Drawn as a band down the fast panel from its finish line to the slow panel's, + * because the saving is not a number that belongs in a footnote: it is three + * quarters of the height of this figure. + */ + const savings = (ctx: CanvasRenderingContext2D, p: Palette, l: Layout, panel: Panel, now: number) => { + if (now < FAST_MS) return; + const yFrom = yAt(l, FAST_MS); + const yTo = yAt(l, Math.min(now, TOTAL_MS)); + if (yTo - yFrom < 2) return; + ctx.globalAlpha = 0.09; + ctx.fillStyle = p.response; + ctx.fillRect(panel.clientX, yFrom, panel.serverX - panel.clientX, yTo - yFrom); + ctx.globalAlpha = 1; + // Only once the whole saving has played out, so the number never contradicts + // the band it is labelling. + if (now >= TOTAL_MS && l.showSaved) { + ctx.fillStyle = p.response; + ctx.font = `600 12px ${p.sans}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(`${SAVED_MS} ms saved`, panel.cx, (yFrom + yTo) / 2); + ctx.globalAlpha = 1; + } + }; + + /** + * The far end working, drawn on the server rail from arrival to answer. + * + * Ten milliseconds is seven pixels of a 306px axis, and seven pixels of nothing + * between an incoming line and an outgoing one reads as a rendering fault + * rather than as time passing. A solid cap on the rail says the gap is the + * point. It is a graphical object, so it answers to 3:1 rather than 4.5:1. + */ + const work = ( + ctx: CanvasRenderingContext2D, + p: Palette, + l: Layout, + x: number, + fromMs: number, + toMs: number, + now: number, + ) => { + if (now <= fromMs) return; + const y0 = yAt(l, fromMs); + const y1 = yAt(l, Math.min(now, toMs)); + ctx.strokeStyle = p.muted; + ctx.globalAlpha = 0.75; + ctx.lineWidth = 3; + ctx.lineCap = "butt"; + ctx.beginPath(); + ctx.moveTo(x, y0); + ctx.lineTo(x, Math.max(y1, y0 + 1)); + ctx.stroke(); + ctx.globalAlpha = 1; + }; + + const header = ( + ctx: CanvasRenderingContext2D, + p: Palette, + l: Layout, + panel: Panel, + text: string, + colour: string, + ) => { + ctx.fillStyle = colour; + ctx.font = `600 13px ${p.sans}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(text, panel.cx, l.headerY); + }; + + return { + // Not ambient: this is a figure in its own box, so there is no copy to avoid + // and nothing to clip it against. + layout, + draw(c) { + const { ctx, palette: p } = c; + if (!L) return; + const l = L; + // The still is the moment the argument is complete and settled: the fast + // panel long since finished, the slow one landed, both verdicts fully up. + const now = c.still ? STILL_AT : (c.t % CYCLE) / SEC_PER_MS; + + const [slow, fast] = l.panels; + + drawAxis(ctx, p, l, now); + + header(ctx, p, l, slow, "Without Cap'n Web", p.muted); + header(ctx, p, l, fast, "With Cap'n Web", p.foreground); + + // Slow panel: out, work, back, four times over. + rail(ctx, p, l, slow.clientX, "client"); + rail(ctx, p, l, slow.serverX, "server"); + for (let i = 0; i < CALL_COUNT; i++) { + // Nothing about call i+1 can be written until call i has come back, which + // is the whole reason this column is as tall as it is. + const sent = i * (MS_PER_LEG * 2 + MS_SERVER); + const landed = sent + MS_PER_LEG; + leg(ctx, p, l, slow.clientX, slow.serverX, sent, now, p.request, CALLS[i]); + work(ctx, p, l, slow.serverX, landed, landed + MS_SERVER, now); + leg(ctx, p, l, slow.serverX, slow.clientX, landed + MS_SERVER, now, p.response); + } + verdict(ctx, p, l, slow, SLOW_MS, now, `4 round trips \u00b7 ${SLOW_MS} ms`, p.muted); + + // Fast panel: four pushes inside one crossing, four handlers back to back, + // then a single reply. + rail(ctx, p, l, fast.clientX, "client"); + rail(ctx, p, l, fast.serverX, "server"); + savings(ctx, p, l, fast, now); + for (let i = 0; i < CALL_COUNT; i++) { + // Three milliseconds apart: enough to count, not enough to look like they + // are waiting on each other. + const start = i * 3; + if (now < start) continue; + // Normalised against the distance still to run, so however late a push + // left it still lands at exactly one crossing. The far end cannot start + // before its arguments arrive, and the claim is that it answers once. + const f = Math.min(1, (now - start) / (MS_PER_LEG - start)); + const y0 = yAt(l, start); + const y1 = yAt(l, start + MS_PER_LEG); + const x = fast.clientX + (fast.serverX - fast.clientX) * f; + const y = y0 + (y1 - y0) * f; + ctx.strokeStyle = p.request; + ctx.globalAlpha = 0.5; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(fast.clientX, y0); + ctx.lineTo(x, y); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.fillStyle = p.request; + ctx.fillRect(x - 2.5, y - 2.5, 5, 5); + } + // All four handlers run back to back before anything goes back, which is + // the same 40ms of work the other column spends in four separate visits. + work(ctx, p, l, fast.serverX, MS_PER_LEG, MS_PER_LEG + CALL_COUNT * MS_SERVER, now); + // What arrived, against the server rail, level with the marks the pushes + // land in. Held back until they have actually landed: labelling an arrival + // before anything has arrived is a lie the eye notices. + if (!l.compact && now > MS_PER_LEG) { + ctx.globalAlpha = Math.min(1, (now - MS_PER_LEG) / FADE_MS); + ctx.fillStyle = p.muted; + ctx.font = `400 ${LABEL_PX}px ${p.mono}`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(ARRIVAL_NOTE, fast.serverX + LABEL_GAP, yAt(l, MS_PER_LEG)); + ctx.globalAlpha = 1; + } + leg(ctx, p, l, fast.serverX, fast.clientX, FAST_MS - MS_PER_LEG, now, p.response); + verdict(ctx, p, l, fast, FAST_MS, now, `1 round trip \u00b7 ${FAST_MS} ms`, p.response); + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/types.ts b/packages/docs/src/components/canvas-hero/types.ts new file mode 100644 index 00000000..1663cf94 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/types.ts @@ -0,0 +1,77 @@ +/** + * The contract between the canvas hero harness and a scene. + * + * A scene owns nothing but drawing. The harness owns the canvas, the device + * pixel ratio, resize, pausing, the palette, and the reduced-motion path, so + * five scenes cannot drift into five different sets of lifecycle bugs. + */ + +/** Resolved from CSS custom properties, so a scene never hardcodes a colour. */ +export interface Palette { + /** + * True when `data-theme="light"` is on ``. + * + * There is deliberately no background colour here. A scene must never fill one: + * the harness clears to transparent so the canvas composites over whatever the + * page puts behind it, and an opaque fill would erase it. The figure now sits + * on the plain page background below the hero banner, but that is the page's + * business and not a scene's. + */ + light: boolean; + /** Structural line work: cables, table rules, node links. */ + stroke: string; + /** The same colour as `stroke`, as `"r g b"`, for scenes that need alphas. */ + strokeRgb: string; + /** A request travelling away from its origin. */ + request: string; + /** A response coming back. Deliberately distinct from `request`. */ + response: string; + /** Quiet text: annotations, counts. */ + muted: string; + /** + * Full-strength body text. + * + * Only the foreground figure needs this. A backdrop scene has no business + * drawing at full contrast -- it sits behind the copy and under a veil -- but + * `/9` puts its diagram in the flow as real content, where a label rendered in + * `muted` on the page background is a legibility problem rather than a + * tasteful one. + */ + foreground: string; + /** Something being retired: a release, a disposal, a dropped reply. */ + fade: string; + /** `--nb-font-mono`, for the scenes that draw real wire messages. */ + mono: string; + /** `--nb-font-sans`, for the figure's headings and its verdicts. */ + sans: string; +} + +export interface SceneSize { + /** CSS pixels. The context is already scaled, so scenes work in these. */ + width: number; + height: number; +} + +export interface SceneContext { + ctx: CanvasRenderingContext2D; + size: SceneSize; + palette: Palette; + /** Seconds since the scene started. Monotonic, and it does not advance while paused. */ + t: number; + /** Seconds since the previous frame, clamped so a long pause cannot jump the state. */ + dt: number; + /** + * True when the harness only wants one frame, because the visitor asked for + * reduced motion. Scenes should draw a composed, readable still: the moment in + * the story that explains the most, not frame zero of the loop. + */ + still: boolean; +} + +export interface Scene { + /** Called once per size change, before the next draw. Scenes lay out here. */ + layout?(size: SceneSize): void; + draw(c: SceneContext): void; +} + +export type SceneFactory = () => Scene; diff --git a/packages/docs/src/components/logo-paths.ts b/packages/docs/src/components/logo-paths.ts new file mode 100644 index 00000000..1804915a --- /dev/null +++ b/packages/docs/src/components/logo-paths.ts @@ -0,0 +1,44 @@ +// GENERATED -- do not hand-edit. Run `scripts/build-wordmark.mjs`. +// See README, "The wordmark". +// +// Outlines converted from TeX Gyre Bonum Bold (GUST Font License), a Bookman +// clone. Converted rather than set as live text on purpose: a logo that falls +// back to Georgia while a webfont loads is not a logo. Nothing here needs a +// font at runtime. +// +// Coordinates are in the space of capnproto.org's own `logo.png` -- 635 units +// across -- because that is what the mark parodies and what it was measured +// against. + +// One entry per glyph, in painting order. See the note in the build script: +// merging them into one path per line loses the keyline wherever two letters +// touch. + +/** CAP'N, tilted, sitting above and right of centre over WEB. */ +export const CAPN_PATHS: readonly string[] = [ + 'M-90.3-190.9L-101.5-195.4C-103.4-191.7-112.3-174.2-129.7-173.6C-139.8-173.2-155.2-180.1-156.2-207.1C-157-228.9-148.6-242.9-134.5-243.4C-128-243.7-121.7-240.8-115.5-234.8C-112.2-231.5-110.2-228.9-103.9-218.8L-94-223L-106.2-253.6L-115.3-253.2L-116.2-250.2C-122.6-253-130.5-254.4-138.3-254.1C-172-252.8-182.1-225.4-181.4-206C-180.3-179-159.9-160.3-132.5-161.4C-112.7-162.1-95.9-173.8-90.3-190.9Z', + 'M22.7-163.8L21.9-174.8L19.7-174.9C12.5-175.8 11.2-178.3 5.3-188.7L-26-252.6L-46.9-251.1L-68.9-185C-73.3-172-76-168.9-83.5-167.4L-85.7-166.9L-84.9-155.9L-43.1-158.9L-44-170L-46.1-170.1C-52.5-170.5-56.3-171.1-56.5-175C-56.6-176.1-56.2-177.4-55.6-179.2L-52.2-191.1L-23.1-193.2L-18.2-182.1C-17.3-179.7-16.7-178.1-16.5-176.6C-16.3-173.9-18.2-172.6-24-171.7L-27.4-171.2L-26.5-160.1ZM-28.4-204.4L-48.7-202.9L-40.4-230Z', + 'M102.8-258.4C100.8-280.4 84.4-284.1 63.1-282.1L15.2-277.8L16.2-266.8L18.9-266.8C26.5-266.7 28.2-265 29-256.4L33.2-210.4C34-201.8 32.5-199.8 25.1-198.3L22.5-197.8L23.5-186.9L70.3-191.1L69.3-202.1L66.6-202.1C59.1-202.2 57.3-204 56.6-212.5L55.2-226.9L67.9-228.1C92.3-230.3 104.4-240.8 102.8-258.4ZM77.2-256.4C78.1-246.6 71.4-240.4 58.6-239.2C57.4-239.1 56.3-239 54.1-239.1L52.3-258.9C51.8-265.2 51.4-268.7 60.5-269.5C69.6-270.3 76.4-265 77.2-256.4Z', + 'M143.4-270.1C143-277.6 138.3-282.6 131.7-282.2C125.9-281.9 121.6-277.1 121.9-271.1C122.2-265.9 125.3-262.5 131.1-261.8C129.8-258.9 128.1-256.5 125-253.5L131.1-247.8C139.2-253.8 143.9-262.2 143.4-270.1Z', + 'M263.9-273L263.7-284.4L226.2-283.6L226.4-272.2L229.2-272C237.1-271.3 238.8-269.4 239-260.4L239.6-229.1L206.7-283.2L165.6-282.3L165.8-270.9L168.6-270.7C176.4-270 178.2-268.1 178.3-259.2L179.4-211.1C179.5-202.2 177.9-200.2 170.1-199.2L167.3-198.9L167.6-187.4L205.1-188.2L204.8-199.7L202-199.9C194.2-200.5 192.5-202.5 192.3-211.4L191.3-261.3L236.1-188.9L253.4-189.2L251.9-260.7C251.7-269.6 253.4-271.6 261.1-272.6Z', +]; + +/** WEB, tilted a little further, with an oversized initial. */ +export const WEB_PATHS: readonly string[] = [ + 'M-43.4-110.1L-45.2-126.2L-104.4-119.6L-102.6-103.4L-100-103.3C-91-104.4-85.1-102.2-84.4-95.5C-84.3-94.6-84.2-93.6-84.3-92.6L-96.1-26L-121.5-89C-122.3-91.1-122.7-92.8-122.9-94.2C-123.3-98.3-121.1-100-114.7-101.5L-110.1-102.6L-111.9-118.7L-173.2-111.9L-171.4-95.7C-164.3-96.3-161.7-94.4-157.6-86.3L-149.1-69.2L-159.2-20.3L-183.5-82.6C-184.3-84.7-184.9-86.7-185.1-88.2C-185.5-91.8-183.7-93.9-177.5-95L-179.3-111.2L-238.5-104.5L-236.7-88.4C-227.4-89.1-223.4-85.5-218.3-74.1L-174.7 23.9L-149.3 21L-136.7-42.3L-111.6 16.8L-85.9 13.9L-65.8-83.9C-61.8-103.3-59.5-106.1-46.5-109.3Z', + 'M87.9-56.2L75.4-60.7C62.5-37.9 48.5-27.2 29-25.4C14.8-24 14.6-26.1 12.9-43.4L11-63.6L15.8-64C25.7-65 31.4-64 35.2-46.9L48.6-48.1L43.6-101.4L30.2-100.1C31.6-81.9 27-81 14.5-79.8L9.5-79.3L8.2-93.9C7-105.9 6.6-109.8 24.8-111.5C39.9-113 55.1-105.2 68.2-89.7L79.2-96.6L59-130.3L-39.8-121L-38.5-106.9L-35.1-106.9C-25.4-106.8-23.1-104.6-22.1-93.6L-16.5-34.6C-15.4-23.6-17.3-21-26.8-19.1L-30.1-18.4L-28.8-4.4L72.9-14Z', + 'M213-56.6C211.9-70.6 203.3-80.2 188.9-83.6C198.9-89.5 204.4-99.3 203.6-109.9C201.8-133.8 180.6-137.9 155.8-136L90-130.9L91.1-116.7L94.6-116.6C104.3-116.3 106.6-114.1 107.5-103L112.1-43.2C112.9-32.1 111-29.6 101.4-27.8L98-27.2L99.1-13L168.3-18.3C185.7-19.6 215.5-23.8 213-56.6ZM174.4-105C175.1-95.6 167.2-89.6 153-88.5L139.1-87.5L137.5-109C137.1-114.5 136.8-118 151-119.1C165-120.1 173.6-114.9 174.4-105ZM181.3-55.9C182.3-42.7 173.7-34.2 158.5-33C144.6-32 143.1-35.5 142.7-40.7L140.4-71.5L154.8-72.6C172.4-74 180.3-69 181.3-55.9Z', +]; + +/** Tight viewBox for the tilted lockup, with room for the outermost stroke. */ +export const LOCKUP_VIEWBOX = '-243.5 -289.4 512.4 318.2'; + +/** Stroke width for the black keyline, in the same units. Half shows, outside + * the fill, under `paint-order: stroke`. */ +export const LOCKUP_STROKE = 8; + +/** A regular 20-point seal on a 100 radius about the origin. */ +export const STAR_PATH = 'M98.8 15.3L77.1 24.8L89.3 45.1L65.7 47.4L71 70.5L47.8 65.4L45.7 88.9L25.3 76.9L16 98.7L0.3 81L-15.3 98.8L-24.8 77.1L-45.1 89.3L-47.4 65.7L-70.5 71L-65.4 47.8L-88.9 45.7L-76.9 25.3L-98.7 16L-81 0.3L-98.8 -15.3L-77.1 -24.8L-89.3 -45.1L-65.7 -47.4L-71 -70.5L-47.8 -65.4L-45.7 -88.9L-25.3 -76.9L-16 -98.7L-0.3 -81L15.3 -98.8L24.8 -77.1L45.1 -89.3L47.4 -65.7L70.5 -71L65.4 -47.8L88.9 -45.7L76.9 -25.3L98.7 -16L81 -0.3Z'; + +/** The seal's own square viewBox, with room for the points. */ +export const STAR_VIEWBOX = '-104 -104 208 208'; diff --git a/packages/docs/src/components/ui/aside/Aside.astro b/packages/docs/src/components/ui/aside/Aside.astro new file mode 100644 index 00000000..5635f959 --- /dev/null +++ b/packages/docs/src/components/ui/aside/Aside.astro @@ -0,0 +1,76 @@ +--- +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"aside"> { + // Keeps `type` (not `variant`) for Starlight compatibility — Starlight's