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 f73d0170..71b6443c 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,46 @@ # 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 +71,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,751 +88,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! - -### 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()); - -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; - } - - return new Response("Not Found", { status: 404 }); - }, +using api = newHttpBatchRpcSession("https://example.com/api"); - // Pass the handler directly — no manual wiring needed. - websocket: rpcHandler, -}); -``` - -### HTTP server on other runtimes - -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: - -```ts -// Run a single HTTP batch. -function newHttpBatchRpcResponse( - request: Request, yourApi: RpcTarget, options?: RpcSessionOptions) - : Promise; +// No awaits, so no round trips yet. +using authed = api.authenticate(apiToken); +let friendIds = authed.getFriendIds(); -// 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; +// One await. One round trip. Everything above travelled together. +let friends = await friendIds.map(id => api.getUserProfile(id)); ``` -### HTTP server using Hono +## Documentation -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). +**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. -### MessagePort +Start here: -Cap'n Web can also talk over MessagePorts. This can be used in a browser to talk to Web Workers, iframes, etc. +| 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 | -```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/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 b311046e..35a4d97f 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 0627d486..abe01be9 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": { 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..6fad1721 --- /dev/null +++ b/packages/docs/AGENTS.md @@ -0,0 +1,227 @@ +# 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, NetworkHero, LightTunnel, HeroExample, Features, NavList, +│ # Playground, Prose +│ └── 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`. | +| 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 its hero animation has a second palette that draws in ink rather than light. + `.cw-home` marks the page, not a scheme. +- Place a canvas hero scene by eye, or in fractions of the viewport. The harness measures the hero's + real boxes and hands the scene a `KeepOut`; a scene that picks its own coordinates ends up drawing + under the headline at some breakpoint. Diagram scenes lay out in `sideBands` and are left unclipped + on purpose so collisions stay visible to the harness in `/tmp`-style sweeps; only `ambient` scenes + get clipped. If a scene has no room, return `fits() === false` and let the harness substitute the + field rather than shipping a clipped diagram or a dead canvas. README has the measured numbers. +- Re-implement the node field or the lane stage in a new scene. Six scenes share `scenes/field.ts` + and three share `scenes/stage.ts`; a scene that rolls its own drift loop will also roll its own + edge-wrap teleport, which is invisible in a screenshot and takes 90 simulated seconds to surface. +- Draw text in a scene numbered `/1a` or later. Those heroes are abstract on purpose. `all.mjs` + fails the moment any of them calls `fillText`, and that assertion is the guarantee, not a comment. +- Judge a scene by its painted percentage. `painted > 0` cannot tell a scene from the fallback field, + and a cast that fails to build leaves the phase clock running over a bare field that still measures + as painted. Check the stage geometry or the scene's own draw calls, not the pixel count. +- 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. +- Remove `` unless asked. diff --git a/packages/docs/README.md b/packages/docs/README.md new file mode 100644 index 00000000..88ae7fd9 --- /dev/null +++ b/packages/docs/README.md @@ -0,0 +1,684 @@ +# 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/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/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..2e10b732 --- /dev/null +++ b/packages/docs/src/components/Header.astro @@ -0,0 +1,93 @@ +--- +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"; + +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; +--- + +
        +
        +
        + + + {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..71659157 --- /dev/null +++ b/packages/docs/src/components/Hero.astro @@ -0,0 +1,127 @@ +--- +/** + * 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 node field goes behind it, absolutely positioned against this section. + * + * The backdrop is a slot with `NetworkHero` as its fallback, so the landing keeps + * the WebGL tunnel while the `/1`../`/5` comparison pages can swap in a Canvas 2D + * scene without duplicating the hero's copy, buttons or layout notes below. + */ +import { LinkButton } from '@/components/ui/link-button'; +import NetworkHero from './NetworkHero.astro'; + +interface Props { + title: string; + tagline: string; +} + +const { title, tagline } = Astro.props; +--- + +{/* + `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. +*/} +
        + +
        + { + Astro.slots.has('default') && ( +
        + +
        + ) + } +
        +

        + {title} +

        +

        + {tagline} +

        +
        + {/* `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/HeroExample.astro b/packages/docs/src/components/HeroExample.astro new file mode 100644 index 00000000..9918d96a --- /dev/null +++ b/packages/docs/src/components/HeroExample.astro @@ -0,0 +1,175 @@ +--- +/** + * The landing hero's centrepiece: the call and its answer as a vertical + * request/response diagram. The client window sits above the server window with + * a connector between them -- the call pulses down, the value pulses back up. + * Transform/opacity only; the pulse rests mid-line under reduced-motion. + */ +import { Code } from "@/components/ui/code"; + +const clientCode = `let api = newWebSocketRpcSession(url); + +let greeting = await api.hello("World");`; + +const serverCode = `class Api extends RpcTarget { + hello(name) { + return \`Hello, \${name}!\`; + } +}`; +--- + +
        +
        +
        + client + Browser +
        +
        +
        + + + +
        +
        + server + Edge Function +
        +
        +
        +
        + + diff --git a/packages/docs/src/components/LightTunnel.astro b/packages/docs/src/components/LightTunnel.astro new file mode 100644 index 00000000..de69a0ab --- /dev/null +++ b/packages/docs/src/components/LightTunnel.astro @@ -0,0 +1,151 @@ +--- +/** + * Vanilla port of React Bits' LightTunnel. WebGL2 via ogl, no React. + * A fibre-optic tunnel of cables radiating from a vanishing point, with light + * pulses running along them. Decorative only: the canvas is aria-hidden and + * pointer-events are off unless mouse interaction is explicitly enabled. + */ +interface Props { + cableColor?: string; + pulseColor?: string; + tunnelColor?: string; + tunnelOpacity?: number; + speed?: number; + flowDirection?: "inward" | "outward"; + pulseSpeed?: number; + pulseLength?: number; + pulseBlend?: number; + pulseWidth?: number; + cableCount?: number; + thickness?: number; + rimWidth?: number; + waviness?: number; + sway?: number; + spiral?: number; + spinSpeed?: number; + size?: number; + centerX?: number; + centerY?: number; + glow?: number; + fadeNear?: number; + fadeFar?: number; + brightness?: number; + colorVariance?: boolean; + grain?: boolean; + grainIntensity?: number; + opacity?: number; + mouseInteraction?: boolean; + mouseStrength?: number; + /* Light-mode palette. Without it the tunnel stays emissive in both schemes, + which on a light page means invisible. See `uInk` in the client. */ + cableColorLight?: string; + pulseColorLight?: string; + tunnelColorLight?: string; + glowLight?: number; + brightnessLight?: number; + grainIntensityLight?: number; + class?: string; +} + +const { + cableColor = "#112039", + pulseColor = "#3B82F6", + tunnelColor = "#5227FF", + tunnelOpacity = 0, + speed = 0.1, + flowDirection = "inward", + pulseSpeed = 2, + pulseLength = 0.2, + pulseBlend = 0.8, + pulseWidth = 0.12, + cableCount = 44, + thickness = 0.5, + rimWidth = 0, + waviness = 0.6, + sway = 0.2, + spiral = 0.5, + spinSpeed = 0.02, + size = 1.25, + centerX = 0, + centerY = 0, + glow = 1.6, + fadeNear = 0.45, + fadeFar = 1.8, + brightness = 1, + colorVariance = true, + grain = true, + grainIntensity = 0.05, + opacity = 1, + mouseInteraction = false, + mouseStrength = 0.12, + cableColorLight, + pulseColorLight, + tunnelColorLight, + glowLight, + brightnessLight, + grainIntensityLight, + class: className = "", +} = Astro.props; +--- + + + + + + 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/NetworkHero.astro b/packages/docs/src/components/NetworkHero.astro new file mode 100644 index 00000000..a1456305 --- /dev/null +++ b/packages/docs/src/components/NetworkHero.astro @@ -0,0 +1,127 @@ +--- +/** + * Full-bleed backdrop for the splash hero. + * + * React Bits' LightTunnel, ported to vanilla JS + ogl. The tunnel is centred in + * its own canvas so it reads as concentric rings / a loop, and that canvas is + * parked in the top-right of the hero so the cables sweep in from the corner + * while the copy on the left stays on the page background. + * + * Both schemes get the same tunnel, drawn two different ways. Dark is emissive: + * a navy pool with light pulses blooming out of it. Light is ink: the same + * cables and pulses drawn as strokes on paper, because added light does nothing + * to a white page. The palette swap happens in the shader (`uInk`), live, when + * the toggle rewrites `data-theme`. + * + * Decorative only: the canvas is aria-hidden, pointer-events stay off, and + * reduced-motion visitors get the static stage with no canvas at all. + */ +import LightTunnel from "./LightTunnel.astro"; +--- + + + + 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/canvas-hero/CanvasHero.astro b/packages/docs/src/components/canvas-hero/CanvasHero.astro new file mode 100644 index 00000000..b3797dcf --- /dev/null +++ b/packages/docs/src/components/canvas-hero/CanvasHero.astro @@ -0,0 +1,118 @@ +--- +/** + * Canvas 2D backdrop for a hero, in place of `NetworkHero`'s WebGL tunnel. + * + * The field, stage and veil geometry are deliberately identical to + * `NetworkHero.astro`: same height, same radial pool, same veil that dissolves + * the edges into the page and fades harder at the bottom so the backdrop meets + * the copy cleanly. Only the thing doing the drawing changes, so the five + * variants differ by their animation and nothing else. + * + * Decorative only: the canvas is aria-hidden and pointer-events stay off. Unlike + * the WebGL hero, reduced motion still gets a canvas here, holding a single + * composed frame, because a still diagram is not a motion problem and an empty + * hero is worse. + */ +import type { SceneKey } from "./scenes"; + +interface Props { + scene: SceneKey; + /** + * Scene opacity. The reference field this is modelled on sits at 0.42; the + * text-carrying scenes need more presence than that to stay readable through + * the veil, so each page can tune it. + */ + opacity?: number; +} + +const { scene, opacity = 0.72 } = Astro.props; +--- + + + + + + diff --git a/packages/docs/src/components/canvas-hero/HeroVariantNav.astro b/packages/docs/src/components/canvas-hero/HeroVariantNav.astro new file mode 100644 index 00000000..44c885c9 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/HeroVariantNav.astro @@ -0,0 +1,103 @@ +--- +/** + * Switcher for the hero backdrop comparison pages. + * + * These pages exist to be looked at side by side, so getting between them has to + * cost nothing. Real links rather than a client-side swap, because each variant + * should be reachable, shareable and reloadable on its own. + */ +import { sceneMeta, VARIANTS, type SceneKey } from "./scenes"; + +interface Props { + /** The current route slug, so the switcher can mark itself. */ + current: string; + scene: SceneKey; +} + +const { current, scene } = Astro.props; +const meta = sceneMeta[scene]; +--- + + + + diff --git a/packages/docs/src/components/canvas-hero/HeroVariantPage.astro b/packages/docs/src/components/canvas-hero/HeroVariantPage.astro new file mode 100644 index 00000000..b082e0f4 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/HeroVariantPage.astro @@ -0,0 +1,51 @@ +--- +/** + * The body of a hero backdrop comparison page. + * + * Every variant route wants the identical page with one prop different, so the + * page files under `src/pages/` are thin wrappers around this and there is only + * one copy of the layout to keep correct. The hero itself comes from `Hero.astro` + * with its `backdrop` slot filled and the copy imported from `lib/hero-copy.ts`, + * so the headline, tagline, buttons and example windows are all the real landing + * ones rather than a lookalike. That matters more than tidiness here: the + * tagline's length decides the height of the box the scenes lay out against. + */ +import BaseLayout from "@/layouts/BaseLayout.astro"; +import Header from "@/components/Header.astro"; +import Hero from "@/components/Hero.astro"; +import HeroExample from "@/components/HeroExample.astro"; +import bundleSize from "@/generated/bundle-size.json"; +import { HERO_TITLE, heroTagline } from "@/lib/hero-copy"; +import CanvasHero from "./CanvasHero.astro"; +import HeroVariantNav from "./HeroVariantNav.astro"; +import { sceneMeta, variantBySlug } from "./scenes"; + +interface Props { + /** The route slug, e.g. `"1"` or `"1a"`. The scene and its opacity come from + `VARIANTS`, so a page cannot pass a slug and a scene that disagree. */ + slug: string; +} + +const { slug } = Astro.props; +const variant = variantBySlug(slug); +if (!variant) throw new Error(`HeroVariantPage: no variant "${slug}"`); +const { scene, opacity } = variant; +const meta = sceneMeta[scene]; +--- + +{/* `noindex`: a dozen near-duplicates of the landing copy are exactly what a + search engine should not be offered. They are for looking at, not ranking. */} + +
        +
        + + + + + +
        + 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..9c457bb7 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/canvas-hero.client.ts @@ -0,0 +1,367 @@ +/** + * Canvas 2D hero harness. + * + * Holds everything the five scenes would otherwise each get wrong: + * + * - 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 { roundTripField } from "./scenes/round-trip-field"; +import type { KeepOut, Palette, Rect, Scene, SceneFactory, SceneSize } from "./types"; + +/** Elements a scene must not lay a diagram over. */ +const CONTENT = ".cw-hero-illus, .cw-hero-scrim, .cw-hero-actions"; + +/** + * Of those, the ones that are bare text on the page background. + * + * The code windows are excluded on purpose: they are a near-opaque panel, so ink + * behind them never reaches the eye. The headline, tagline and buttons have + * nothing behind them, so ink there competes with the words. + */ +const BARE_TEXT = ".cw-hero-scrim, .cw-hero-actions"; + +/** Over how many pixels an ambient scene fades out as it approaches bare text. */ +const FEATHER = 56; + +/** Breathing room, so a scene never quite touches the copy. */ +const PAD = 16; + +/** + * Measures the hero's content boxes in canvas-local coordinates. + * + * One `getBoundingClientRect` pass per resize, not per frame: reading layout in + * the animation loop would force a synchronous reflow sixty times a second for a + * number that only changes when the page does. + */ +function measureKeepOut(container: HTMLElement, size: SceneSize): KeepOut { + const base = container.getBoundingClientRect(); + // Scoped to the hero section, not the document. This is a per-instance mount, so + // a document-wide query would make two heroes on one page, or a stray + // `.cw-hero-actions` anywhere else, lay every scene out against the union. + const root: ParentNode = container.closest("section") ?? document; + const measure = (selector: string): Rect[] => { + const out: Rect[] = []; + for (const el of root.querySelectorAll(selector)) { + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + out.push({ x: r.left - base.left, y: r.top - base.top, width: r.width, height: r.height }); + } + return out; + }; + return buildKeepOut(measure(CONTENT), measure(BARE_TEXT), size); +} + +/** + * The `KeepOut` behaviour, given boxes that have already been measured. + * + * Split out so the initial value can be built without touching the DOM: measuring + * before the canvas has been sized forces a reflow to produce an object that is + * discarded, and whose `sideBands` would return negative widths if anything read + * it in the meantime. + */ +function buildKeepOut(boxes: Rect[], bare: Rect[], size: SceneSize): KeepOut { + // A hero without copy has nothing to avoid, and every helper still has to + // return something sane, so degrade to an empty box at the centre. + const box: Rect = boxes.length + ? { + x: Math.min(...boxes.map((b) => b.x)), + y: Math.min(...boxes.map((b) => b.y)), + width: 0, + height: 0, + } + : { x: size.width / 2, y: size.height / 2, width: 0, height: 0 }; + if (boxes.length) { + box.width = Math.max(...boxes.map((b) => b.x + b.width)) - box.x; + box.height = Math.max(...boxes.map((b) => b.y + b.height)) - box.y; + } + + const overlapsY = (b: Rect, y: number, height: number) => + b.y < y + height + PAD && b.y + b.height + PAD > y; + + const widest = boxes.length ? boxes.reduce((m, b) => (b.width > m.width ? b : m)) : box; + + return { + boxes, + bareText: bare, + box, + widest, + bandTop: { x: 0, y: 0, width: size.width, height: Math.max(0, box.y - PAD) }, + sideBands(y, height) { + const hit = boxes.filter((b) => overlapsY(b, y, height)); + if (hit.length === 0) { + // Clear all the way across. Split it so a scene that wants two columns + // still gets two. Clamped like the other branch, so "zero width means no + // room" holds on both paths rather than handing back a negative. + const half = Math.max(0, size.width / 2 - PAD); + return { + left: { x: 0, y, width: half, height }, + right: { x: size.width / 2 + PAD, y, width: half, height }, + }; + } + const x0 = Math.min(...hit.map((b) => b.x)) - PAD; + const x1 = Math.max(...hit.map((b) => b.x + b.width)) + PAD; + return { + left: { x: 0, y, width: Math.max(0, x0), height }, + right: { x: x1, y, width: Math.max(0, size.width - x1), height }, + }; + }, + clarity(x, y) { + let min = 1; + for (const b of bare) { + // Euclidean distance from the point to the rect, zero inside it. + const dx = Math.max(b.x - x, 0, x - (b.x + b.width)); + const dy = Math.max(b.y - y, 0, y - (b.y + b.height)); + const f = Math.min(1, Math.hypot(dx, dy) / FEATHER); + if (f < min) min = f; + } + return min; + }, + hits(r) { + return boxes.some( + (b) => + r.x < b.x + b.width + PAD && + r.x + r.width + PAD > b.x && + r.y < b.y + b.height + PAD && + r.y + r.height + PAD > b.y, + ); + }, + }; +} + +export function mountCanvasHero(container: HTMLElement, factory: SceneFactory): () => void { + 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(); + // Built lazily, and only if the primary scene ever reports it cannot fit, so a + // desktop visitor never pays for a field they will not see. + let fallback: Scene | null = null; + let palette: Palette = readPalette(); + let size: SceneSize = { width: 1, height: 1 }; + let keepOut: KeepOut = buildKeepOut([], [], size); + /** The canvas with the bare-text boxes punched out, for clipping ambient scenes. */ + let bareTextPath: Path2D | null = null; + /** Set by the disposer, so the one async path it cannot cancel can bail. */ + let disposed = false; + + /** The primary scene, or the ambient field when the primary has no room. */ + const current = (): Scene => { + if (scene.fits?.() !== false) return scene; + if (!fallback) { + fallback = roundTripField(); + fallback.layout?.(size, keepOut); + } + return fallback; + }; + + const paint = (t: number, dt: number, still: boolean) => { + ctx.clearRect(0, 0, size.width, size.height); + const active = current(); + // Saved unconditionally, so no scene can leak `font`, `textAlign`, + // `globalAlpha` or a line dash into the next frame or into the other scene. + // A resize can swap between the primary scene and the fallback, and without + // this the incoming scene's first frame inherits the outgoing one's state. + ctx.save(); + if (active.ambient && bareTextPath) { + // Canvas 2D has no "clip everything but", so the path is the whole canvas + // with each text rect punched out and the even-odd rule inverting them. + ctx.clip(bareTextPath, "evenodd"); + } + active.draw({ ctx, size, keepOut, palette, t, dt, still }); + ctx.restore(); + }; + + // ---- sizing ---------------------------------------------------------------- + + /** + * Rects merged until none overlap. + * + * The even-odd rule counts crossings, so a point inside two punched rects is + * back to being inside the clip and would be drawn on. The keep-out boxes do not + * overlap today, but `BARE_TEXT` is a selector anyone can extend and the failure + * mode is silent ink in the worst possible place. + */ + const merged = (rects: Rect[]): Rect[] => { + const out = rects.map((r) => ({ ...r })); + for (let i = 0; i < out.length; i++) { + for (let j = i + 1; j < out.length; j++) { + const a = out[i]!; + const b = out[j]!; + if (a.x >= b.x + b.width || b.x >= a.x + a.width) continue; + if (a.y >= b.y + b.height || b.y >= a.y + a.height) continue; + const x = Math.min(a.x, b.x); + const y = Math.min(a.y, b.y); + a.width = Math.max(a.x + a.width, b.x + b.width) - x; + a.height = Math.max(a.y + a.height, b.y + b.height) - y; + a.x = x; + a.y = y; + out.splice(j, 1); + // The union may now overlap something already passed over. + i = -1; + break; + } + } + return out; + }; + + 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 }; + keepOut = measureKeepOut(container, size); + bareTextPath = new Path2D(); + bareTextPath.rect(0, 0, size.width, size.height); + for (const r of merged(keepOut.bareText)) bareTextPath.rect(r.x, r.y, r.width, r.height); + scene.layout?.(size, keepOut); + fallback?.layout?.(size, keepOut); + }; + + const ro = new ResizeObserver(() => { + setSize(); + paint(sceneTime, 0, reduced.matches); + }); + ro.observe(container); + + // The keep-out boxes depend on text metrics, so they move when the webfonts swap + // in, which is always after first paint. 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 resizes 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; + 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..dfc81daf --- /dev/null +++ b/packages/docs/src/components/canvas-hero/palette.ts @@ -0,0 +1,64 @@ +/** + * 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"), + fade: light ? "#8792a3" : "#59677a", + mono: readVar(styles, "--nb-font-mono", "ui-monospace, monospace"), + }; +} diff --git a/packages/docs/src/components/canvas-hero/routes.ts b/packages/docs/src/components/canvas-hero/routes.ts new file mode 100644 index 00000000..2bb03240 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/routes.ts @@ -0,0 +1,37 @@ +/** + * The hero backdrop comparison routes, as plain data. + * + * This is separate from `scenes/index.ts` on purpose. Three things need to agree + * on what these routes are -- the pages, the sitemap filter in `astro.config.ts`, + * and `BaseLayout`'s `cw-home` test -- and the config is loaded by Astro outside + * the app's module graph, so having it import the scene registry would drag every + * scene factory into config evaluation to read a list of strings. + * + * So the list lives here with no imports at all, and `scenes/index.ts` is + * type-checked against it: `VARIANTS` must cover exactly these slugs, no more and + * no fewer, or the build fails rather than quietly shipping a route the sitemap + * still advertises. + */ +export const VARIANT_SLUGS = [ + "1", + "1a", + "1b", + "1c", + "1d", + "1e", + "2", + "3", + "4", + "5", + "6", + "7", + "8", +] as const; + +export type VariantSlug = (typeof VARIANT_SLUGS)[number]; + +/** True for a hero backdrop comparison route, with or without a trailing slash. */ +export function isVariantPath(pathname: string): boolean { + const slug = pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + return (VARIANT_SLUGS as readonly string[]).includes(slug); +} diff --git a/packages/docs/src/components/canvas-hero/scenes/amplify.ts b/packages/docs/src/components/canvas-hero/scenes/amplify.ts new file mode 100644 index 00000000..d2decbc7 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/amplify.ts @@ -0,0 +1,195 @@ +/** + * Scene 1d: one call in, a detonation in the middle, one value out. + * + * A single request arrives at a node. What leaves the other side is also a single + * value, and the two are the same size, drawn the same way, a couple of seconds + * apart. Between them the call fans out through the field one level at a time + * until most of the canvas is lit, then collapses back to nothing. + * + * The asymmetry is the entire argument. A server that meters what it receives and + * what it returns sees two small things and concludes it was a small request. The + * cost is in the middle, where nobody is looking, and it grows by a factor per + * level rather than by an increment -- which is why the wave visibly accelerates + * outward and why the third level fills the field when the first was four nodes. + * + * Docs: `guides/security.md` (amplification and resource limits), `concepts/map.md` + * (a callback the server runs, once per element). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; + +type Phase = "arrive" | "expand" | "peak" | "collapse" | "leave" | "rest"; + +const DUR: Record = { + arrive: 1.0, + expand: 1.5, + peak: 0.4, + collapse: 1.2, + leave: 1.0, + rest: 0.7, +}; +const NEXT: Record = { + arrive: "expand", + expand: "peak", + peak: "collapse", + collapse: "leave", + leave: "rest", + rest: "arrive", +}; + +/** How many levels the blast walks. Three is enough to fill a hero. */ +const LEVELS = 3; + +interface Blast { + /** The caller's route into the root. One request travels this. */ + approach: number[]; + /** Node indices by distance from the root, root at index 0. */ + levels: number[][]; + /** Child to parent, for drawing each edge of the tree. */ + parent: Map; + gen: number[]; +} + +export function amplify(): Scene { + const field = new Field(); + let blast: Blast | null = null; + let phase: Phase = "arrive"; + let p = 0; + let age = 0; + + const allNodes = (b: Blast) => [...new Set([...b.approach, ...b.levels.flat()])]; + + const build = (): Blast | null => { + const approach = field.findPath(2, 3); + if (!approach) return null; + const root = approach[approach.length - 1]!; + const seen = new Set([root]); + const levels: number[][] = [[root]]; + const parent = new Map(); + for (let k = 1; k <= LEVELS; k++) { + const next: number[] = []; + for (const i of levels[k - 1]!) { + for (const j of field.neighbours(i)) { + if (seen.has(j)) continue; + seen.add(j); + parent.set(j, i); + next.push(j); + } + } + if (next.length === 0) break; + levels.push(next); + } + // Two levels is the minimum that reads as growth rather than a star. + if (levels.length < 3) return null; + const b: Blast = { approach, levels, parent, gen: [] }; + b.gen = field.gensOf(allNodes(b)); + return b; + }; + + return { + ambient: true, + layout(s: SceneSize) { + field.layout(s); + if (blast && allNodes(blast).some((i) => i >= field.nodes.length)) blast = null; + }, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + + if (!c.still) { + field.advance(c.dt); + age += c.dt; + p += c.dt / DUR[phase]; + if (p >= 1) { + p = 0; + phase = NEXT[phase]; + if (phase === "arrive") blast = null; + } + } else if (!blast) { + // The still is the peak: the small arrival still visible, the field full. + phase = "peak"; + p = 0.5; + } + + if (!blast) { + blast = build(); + if (blast) age = 0; + } + if (blast && field.wrapped(allNodes(blast), blast.gen)) blast = null; + + field.draw(ctx, palette); + if (!blast) return; + + const fade = c.still ? 1 : Math.min(1, age / 0.35); + const root = blast.levels[0]![0]!; + const caller = blast.approach[0]!; + const depth = blast.levels.length - 1; + + /** + * How far the wave has travelled, in levels. + * + * Expansion eases *in* rather than out: each level has more nodes than the + * last, so a constant rate would look like it was slowing down. Collapse runs + * the same curve backwards. + */ + const wave = + phase === "expand" + ? depth * (p * p) + : phase === "peak" + ? depth + : phase === "collapse" + ? depth * (1 - p * p) + : phase === "arrive" + ? 0 + : 0; + + field.drawRoute(ctx, palette, blast.approach, 0.3 * fade); + field.drawEndpoint(ctx, caller, palette.request, 0, fade); + + if (wave > 0) { + // The tree, level by level. Each edge fills as the wave passes it and the + // colour walks from request to response as the blast turns into results. + ctx.lineWidth = 1.2; + for (let k = 1; k < blast.levels.length; k++) { + const front = Math.max(0, Math.min(1, wave - (k - 1))); + if (front <= 0) continue; + for (const child of blast.levels[k]!) { + const a = field.nodes[blast.parent.get(child)!]; + const b = field.nodes[child]; + if (!a || !b) continue; + const m = field.lineClarity(a, b); + if (m <= 0.01) continue; + ctx.globalAlpha = 0.5 * fade * m * front; + ctx.strokeStyle = palette.request; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(a.x + (b.x - a.x) * front, a.y + (b.y - a.y) * front); + ctx.stroke(); + if (front >= 0.999) { + // A lit node at the tip of every completed edge: the work being done. + field.dot(ctx, b, palette.response, fade * 0.85, 2); + } + } + } + ctx.globalAlpha = 1; + } + + field.drawEndpoint( + ctx, + root, + wave > 0 ? palette.response : palette.stroke, + phase === "peak" ? 1 : wave / Math.max(1, depth), + fade, + ); + + const total = field.pathLength(blast.approach); + const ease = p * p * (3 - 2 * p); + if (phase === "arrive") { + // One mark. The same size as the one that leaves. + field.dot(ctx, field.pointAt(blast.approach, total * ease), palette.request, fade, 2); + } else if (phase === "leave") { + field.dot(ctx, field.pointAt(blast.approach, total * (1 - ease)), palette.response, fade, 2); + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/batch-body.ts b/packages/docs/src/components/canvas-hero/scenes/batch-body.ts new file mode 100644 index 00000000..24737638 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/batch-body.ts @@ -0,0 +1,265 @@ +/** + * Scene 3: the wire, literally. + * + * The HTTP batch transport puts the whole session in one request body, and that + * body is newline-delimited JSON: "Each message is serialized as a single line of + * JSON with no embedded newlines, and messages are separated by a newline + * character" (`reference/protocol.md`, Transport and framing). So the animation + * is a body being written a line at a time, sent once, and answered once. + * + * Every line here is real, captured from the library's own HTTP batch transport + * running the shape in `transports/http-batch.md`. Three details are the reason + * this scene exists, and all three are visible: + * + * - `["pipeline",1,["id"]]` is a *property path into a result that does not + * exist yet*. Line 2 reads `.id` off line 1's answer before line 1 has been + * sent, which is the docs' "Properties pipeline too". + * - `["pull", n]` is sent only for what the application actually awaits. + * - so `["pull",5]` is absent, and the response therefore has no line 5. + * `getUserInfo` was used only as an argument, so its value is never shipped: + * "the system detects this and doesn't ask the server to send the return value + * back at all; it saves the bandwidth." + * + * Six request lines. Five reply lines. One round trip. + */ +import type { KeepOut, Scene, SceneContext, SceneSize } from "../types"; +import { fitFont, space } from "./space"; + +interface Line { + text: string; + /** Character range to pick out in ink, for the pipelined argument. */ + mark?: [number, number]; + /** A pull line, drawn quieter than a push. */ + pull?: boolean; +} + +/** + * A line whose pipelined argument is highlighted, located rather than counted. + * + * Hand-written offsets into a 68-character JSON literal were wrong by one or two + * characters on all three marked lines, which is invisible because the highlight + * lands on identical glyphs either side. Searching for the substring cannot drift + * when the literal is edited. + */ +const marked = (text: string, arg: string): Line => { + const i = text.indexOf(arg); + if (i < 0) throw new Error(`batch-body: ${arg} not found in ${text}`); + return { text, mark: [i, i + arg.length] }; +}; + +const REQUEST: Line[] = [ + { text: '["push",["pipeline",0,["authenticate"],["cookie-123"]]]' }, + marked('["push",["pipeline",0,["getUserProfile"],[["pipeline",1,["id"]]]]]', '["pipeline",1,["id"]]'), + marked( + '["push",["pipeline",0,["getNotifications"],[["pipeline",1,["id"]]]]]', + '["pipeline",1,["id"]]', + ), + { text: '["push",["pipeline",0,["greet"],["Alice"]]]' }, + { text: '["push",["pipeline",0,["getUserInfo"],[]]]' }, + marked('["push",["pipeline",0,["greet"],[["pipeline",5,["name"]]]]]', '["pipeline",5,["name"]]'), + { text: '["pull",1]', pull: true }, + { text: '["pull",2]', pull: true }, + { text: '["pull",3]', pull: true }, + { text: '["pull",4]', pull: true }, + { text: '["pull",6]', pull: true }, +]; + +const RESPONSE: Line[] = [ + { text: '["resolve",1,["export",-1]]' }, + { text: '["resolve",2,{"name":"u42"}]' }, + { text: '["resolve",3,[["a","b"]]]' }, + { text: '["resolve",4,"Hello, Alice!"]' }, + { text: '["resolve",6,"Hello, Alice!"]' }, +]; + +const WRITE_PER_LINE = 0.2; +const WRITE = REQUEST.length * WRITE_PER_LINE; +const FLIGHT = 0.9; +const REPLY_PER_LINE = 0.16; +const REPLY = RESPONSE.length * REPLY_PER_LINE; +const HOLD = 2.4; +const CYCLE = WRITE + FLIGHT + REPLY + HOLD; + +export function batchBody(): Scene { + let fontSize = 10; + let lineHeight = 15; + let reqX = 0; + let resX = 0; + let bandX0 = 0; + let bandX1 = 0; + let bandY = 0; + let top = 0; + /** + * Starts hidden, so a scene whose `layout` has not run yet reports that it does + * not fit rather than drawing a degenerate diagram at the origin. + */ + let hide = true; + + /** + * The request body goes in the left column and the response in the right, so + * neither runs under the hero copy, and the one flight between them crosses the + * clear band above it. + * + * Type size is derived from the column width and the longest line rather than + * chosen: these are 68-character lines of JSON and the whole point is being + * able to read them. Below the floor the scene hides, because a body of clipped + * JSON says less than no body at all. That happens under about 1280px, which is + * honest: there is no width at which this diagram and the copy both fit. + */ + const layout = (s: SceneSize, k: KeepOut) => { + const sp = space(s, k); + const longest = REQUEST.reduce((m, l) => Math.max(m, l.text.length), 0); + const col = Math.min(sp.left.width, sp.right.width); + fontSize = fitFont(col, longest); + lineHeight = fontSize * 1.5; + // Room for a heading, every request line, and the two-line footnote. + const needed = lineHeight * (REQUEST.length + 3.5); + hide = fontSize < 7 || sp.left.height < needed || sp.band.height < lineHeight * 2.5; + // Right-align the request against the copy and left-align the response, so + // both bodies sit next to the thing they are talking to. + reqX = Math.max(2, sp.left.x + sp.left.width - longest * fontSize * 0.6); + resX = sp.right.x; + bandX0 = sp.crossX0; + bandX1 = sp.crossX1; + bandY = sp.band.y + sp.band.height * 0.62; + top = sp.left.y + lineHeight * 1.6; + }; + + const heading = (c: SceneContext, x: number, text: string, alpha: number) => { + const { ctx, palette } = c; + ctx.font = `600 ${fontSize}px ${palette.mono}`; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.globalAlpha = alpha * 0.8; + ctx.fillStyle = palette.muted; + ctx.fillText(text, x, top - lineHeight); + ctx.globalAlpha = 1; + }; + + /** + * Draws one line, optionally part-typed, with the marked range in ink and the + * trailing newline shown as a dim glyph because the framing is the point. + */ + const drawLine = ( + c: SceneContext, + line: Line, + x: number, + y: number, + reveal: number, + baseColour: string, + ) => { + const { ctx, palette } = c; + ctx.font = `400 ${fontSize}px ${palette.mono}`; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + const shown = Math.floor(line.text.length * Math.max(0, Math.min(1, reveal))); + const text = line.text.slice(0, shown); + ctx.fillStyle = baseColour; + ctx.globalAlpha = line.pull ? 0.55 : 0.8; + ctx.fillText(text, x, y); + + if (line.mark) { + // Re-draw just the pipelined argument, brighter, in place. + const [a, b] = line.mark; + if (shown > a) { + const prefix = line.text.slice(0, a); + const seg = line.text.slice(a, Math.min(b, shown)); + ctx.globalAlpha = 1; + ctx.fillStyle = palette.request; + ctx.fillText(seg, x + ctx.measureText(prefix).width, y); + } + } + + if (shown >= line.text.length) { + ctx.globalAlpha = 0.32; + ctx.fillStyle = palette.fade; + ctx.fillText("\\n", x + ctx.measureText(line.text).width + 3, y); + } + ctx.globalAlpha = 1; + }; + + const caret = (c: SceneContext, x: number, y: number, t: number) => { + const { ctx, palette } = c; + if (Math.floor(t * 2) % 2 === 0) return; + ctx.fillStyle = palette.request; + ctx.globalAlpha = 0.8; + ctx.fillRect(x, y - fontSize * 0.8, 1.5, fontSize); + ctx.globalAlpha = 1; + }; + + return { + layout, + fits: () => !hide, + draw(c) { + if (hide) return; + const { ctx, palette } = c; + // The still holds the frame where both bodies are complete, because the + // missing reply line is the whole point and it only exists at the end. + const t = c.still ? WRITE + FLIGHT + REPLY : c.t % CYCLE; + + heading(c, reqX, "POST /api -- request body", 1); + + let y = top; + for (const [i, line] of REQUEST.entries()) { + const start = i * WRITE_PER_LINE; + const reveal = (t - start) / (WRITE_PER_LINE * 0.85); + if (reveal > 0) drawLine(c, line, reqX, y, reveal, palette.stroke); + if (reveal > 0 && reveal < 1) { + ctx.font = `400 ${fontSize}px ${palette.mono}`; + const shown = Math.floor(line.text.length * reveal); + caret(c, reqX + ctx.measureText(line.text.slice(0, shown)).width + 1, y, c.t); + } + y += lineHeight; + } + + // One flight, one band. The two bodies are on opposite sides of the copy, so + // the flight is drawn in the clear strip above it rather than straight + // through the headline. + if (t > WRITE) { + const f = Math.min(1, (t - WRITE) / FLIGHT); + const x0 = bandX0; + const x1 = bandX1; + ctx.strokeStyle = palette.request; + ctx.globalAlpha = 0.5 * (1 - Math.max(0, (f - 0.7) / 0.3)); + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(x0, bandY); + ctx.lineTo(x0 + (x1 - x0) * f, bandY); + ctx.stroke(); + ctx.fillStyle = palette.request; + ctx.globalAlpha = 0.9 * (1 - Math.max(0, (f - 0.8) / 0.2)); + ctx.fillRect(x0 + (x1 - x0) * f - 2, bandY - 2, 4, 4); + ctx.globalAlpha = 0.6; + ctx.font = `500 ${fontSize}px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillStyle = palette.muted; + ctx.fillText("6 pushes, 5 replies, 1 round trip", (x0 + x1) / 2, bandY - 6); + ctx.globalAlpha = 1; + } + + if (t > WRITE + FLIGHT * 0.75) { + heading(c, resX, "200 OK -- response body", 1); + const rt = t - WRITE - FLIGHT * 0.75; + let ry = top; + for (const [i, line] of RESPONSE.entries()) { + const reveal = (rt - i * REPLY_PER_LINE) / (REPLY_PER_LINE * 0.85); + if (reveal > 0) drawLine(c, line, resX, ry, reveal, palette.response); + ry += lineHeight; + } + // The gap. Five replies for six pushes, and this says which one and why. + if (rt > REPLY) { + const f = Math.min(1, (rt - REPLY) / 0.5); + ctx.globalAlpha = f * 0.7; + ctx.fillStyle = palette.fade; + ctx.font = `400 ${fontSize}px ${palette.mono}`; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.fillText("no line 5:", resX, ry + lineHeight * 0.7); + ctx.fillText("never awaited, so never pulled", resX, ry + lineHeight * 1.7); + ctx.globalAlpha = 1; + } + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/depth.ts b/packages/docs/src/components/canvas-hero/scenes/depth.ts new file mode 100644 index 00000000..ee94ac07 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/depth.ts @@ -0,0 +1,179 @@ +/** + * Scene 6: four dependent calls, done the slow way and the pipelined way, racing. + * + * Two lanes, same four calls, same network, started in the same frame. The upper + * lane waits for each answer before it can ask the next question, so it crosses + * the gap eight times. The lower lane sends all four at once, because a call that + * depends on a previous call's *result* does not have to wait for that result to + * come home -- it can name it. It crosses twice. + * + * The lower lane finishes early and then does nothing at all, holding a settled + * mark at the near end while the upper lane is still on its second or third trip. + * That stretch of doing nothing is the whole scene: it is the same idle time the + * tour measures as "400 ms of doing nothing", drawn to scale rather than + * described. Depth costs latency only if you make it. + * + * Docs: `start/pipelining-tour.md` (four dependent calls, one round trip), + * `concepts/promises.md` (awaiting is what costs a round trip). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; +import { endpost, MIN_PITCH, stage, type Stage } from "./stage"; + +/** Calls in the chain. The tour's example is a chain of four. */ +const CALLS = 4; +/** Seconds for one crossing of the gap. */ +const LEG = 0.62; +/** The far end's turnaround, per call. */ +const WORK = 0.1; +/** How long the finished picture holds before the loop restarts. */ +const HOLD = 1.4; + +/** The naive lane: out, work, back, for each call in turn. */ +const NAIVE_TOTAL = CALLS * (LEG * 2 + WORK); +/** The pipelined lane: one out, one turnaround, one back. */ +const PIPED_TOTAL = LEG * 2 + WORK; +const CYCLE = NAIVE_TOTAL + HOLD; + +export function depth(): Scene { + const field = new Field(34); + let st: Stage | null = null; + let ok = false; + let clock = 0; + + /** + * Where the naive lane is at time `t`: which call, and how far through it. + * + * Returns null once the lane has finished, which is when it stops drawing + * anything moving and the comparison is over. + */ + const naiveAt = (t: number) => { + const per = LEG * 2 + WORK; + const index = Math.floor(t / per); + if (index >= CALLS) return null; + const local = t - index * per; + if (local < LEG) return { index, phase: "out" as const, p: local / LEG }; + if (local < LEG + WORK) return { index, phase: "work" as const, p: (local - LEG) / WORK }; + return { index, phase: "back" as const, p: (local - LEG - WORK) / LEG }; + }; + + const drawLane = ( + ctx: CanvasRenderingContext2D, + s: Stage, + y: number, + colour: string, + alpha: number, + ) => { + ctx.globalAlpha = alpha; + ctx.strokeStyle = colour; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(s.x0, y); + ctx.lineTo(s.x1, y); + ctx.stroke(); + ctx.globalAlpha = 1; + }; + + const mark = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + colour: string, + alpha: number, + r = 2, + ) => { + if (alpha <= 0.01) return; + ctx.globalAlpha = alpha; + ctx.fillStyle = colour; + ctx.fillRect(x - r, y - r, r * 2, r * 2); + ctx.globalAlpha = 1; + }; + + return { + ambient: true, + layout(size: SceneSize, keepOut) { + field.layout(size); + st = stage(size, keepOut, 2); + // Two lanes need real vertical room. Below that the harness swaps in the + // field rather than showing two lines on top of each other. + ok = st.pitch >= MIN_PITCH && st.span > 240; + }, + fits: () => ok, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + if (!st) return; + const s = st; + + if (c.still) { + // The composed still is the moment the argument is clearest: the pipelined + // lane already settled, the naive lane barely half done. + clock = PIPED_TOTAL + (NAIVE_TOTAL - PIPED_TOTAL) * 0.45; + } else { + field.advance(c.dt); + clock = (clock + c.dt) % CYCLE; + } + + // The field is body here, not content, so it sits well back. + field.draw(ctx, palette, 0.45); + + const half = Math.min(7, s.pitch * 0.32); + const yN = s.lanes[0]!; + const yP = s.lanes[1]!; + + for (const y of [yN, yP]) { + drawLane(ctx, s, y, `rgb(${palette.strokeRgb})`, palette.light ? 0.34 : 0.28); + endpost(ctx, s.x0, y, half, `rgb(${palette.strokeRgb})`, 0.55); + endpost(ctx, s.x1, y, half, `rgb(${palette.strokeRgb})`, 0.55); + } + + // Upper lane: one call in flight at a time, and the marks it has banked. + const n = naiveAt(clock); + for (let i = 0; i < CALLS; i++) { + const settled = n === null || i < n.index; + if (settled) mark(ctx, s.x0 + 5 + i * 7, yN - half - 5, palette.response, 0.75, 1.6); + } + if (n) { + if (n.phase === "out") { + mark(ctx, s.x0 + s.span * n.p, yN, palette.request, 0.95); + } else if (n.phase === "work") { + endpost(ctx, s.x1, yN, half + 2, palette.response, Math.sin(n.p * Math.PI)); + } else { + mark(ctx, s.x1 - s.span * n.p, yN, palette.response, 0.95); + } + } + + // Lower lane: all four leave together, one turnaround, one value comes back. + const t = clock; + if (t < LEG) { + for (let k = 0; k < CALLS; k++) { + const d = s.span * (t / LEG) - k * 10; + if (d < 0) continue; + mark(ctx, s.x0 + d, yP, palette.request, 0.95 * (1 - k / (CALLS + 2)), 1.7); + } + } else if (t < LEG + WORK) { + // One turnaround for the whole chain: the far end resolves all four. + endpost(ctx, s.x1, yP, half + 2, palette.response, Math.sin(((t - LEG) / WORK) * Math.PI)); + } else if (t < PIPED_TOTAL) { + mark(ctx, s.x1 - s.span * ((t - LEG - WORK) / LEG), yP, palette.response, 0.95); + } + if (t >= PIPED_TOTAL) { + // Settled, and then simply waiting. The mark does not move again. + for (let i = 0; i < CALLS; i++) { + mark(ctx, s.x0 + 5 + i * 7, yP - half - 5, palette.response, 0.75, 1.6); + } + // A quiet bar growing along the lower lane for exactly as long as it is + // idle, which is the difference between the two strategies made visible. + const idle = Math.min(1, (t - PIPED_TOTAL) / (NAIVE_TOTAL - PIPED_TOTAL)); + ctx.globalAlpha = 0.3; + ctx.strokeStyle = palette.response; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(s.x0, yP + half + 4); + ctx.lineTo(s.x0 + s.span * idle, yP + half + 4); + ctx.stroke(); + ctx.globalAlpha = 1; + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/field.ts b/packages/docs/src/components/canvas-hero/scenes/field.ts new file mode 100644 index 00000000..648fe965 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/field.ts @@ -0,0 +1,387 @@ +/** + * The drifting node field, shared by every scene in the `/1` family. + * + * The substrate is modelled on the canvas behind proteus.ashishkumarsingh.com: a + * slow drift of small square nodes wrapping at the edges, with a link drawn + * between any two that come within a threshold and its alpha falling off with + * distance. Those specifics are the look, so they are kept: squares rather than + * circles, one shared threshold, links faded by proximity, and the same node + * count curve of one node per 24px of width, clamped. + * + * Six scenes draw on this field and each says something different on top of it. + * The field itself is identical in all of them, so it lives here rather than + * being copied six times with six sets of drifting constants. + * + * Everything is masked by `clarity`. The field covers the whole canvas, which is + * the reference's whole character, so it cannot be laid out in a clear column + * like the diagram scenes; it dims to nothing under the headline, the tagline and + * the buttons instead. + */ +import type { Palette, SceneSize } from "../types"; + +export interface FieldNode { + x: number; + y: number; + vx: number; + vy: number; + /** + * Bumped every time the node wraps an edge. + * + * Anything holding a route reads node positions live, so a route bends as the + * field drifts, which is the effect worth having. The cost is that a node + * wrapping from one edge to the other mid-flight turns one segment into a + * canvas-width line and throws whatever is travelling across the hero in a + * single frame. Callers snapshot these and retire the route when they change. + */ + gen: number; +} + +export interface Point { + x: number; + y: number; +} + +/** Link distance, in CSS pixels. Proteus uses 170 and it reads well. */ +export const LINK_DIST = 170; +/** Proteus drifts at 0.28px per frame; expressed per second so `dt` drives it. */ +const DRIFT = 0.28 * 60; +const MARGIN = 20; + +export class Field { + nodes: FieldNode[] = []; + size: SceneSize = { width: 1, height: 1 }; + /** Set from `keepOut.clarity` every frame, so scenes never plumb it by hand. */ + clarity: (x: number, y: number) => number = () => 1; + + /** How dense the field is, as pixels of width per node. Higher is sparser. */ + constructor(private readonly spacing = 24) {} + + /** + * Rescale in place rather than re-seed. + * + * `layout` runs on every `ResizeObserver` tick and once more when the webfonts + * land, which is always after first paint. Reallocating the field there made it + * visibly reshuffle a few hundred milliseconds into every visit, and re-randomise + * on every frame of a window drag. Nodes keep their identity, so the field + * stretches with the canvas and anything in flight stays valid. + */ + layout(s: SceneSize): void { + const prev = this.size; + this.size = s; + const count = Math.max(22, Math.min(58, Math.floor(s.width / this.spacing))); + const sx = prev.width > 1 ? s.width / prev.width : 1; + const sy = prev.height > 1 ? s.height / prev.height : 1; + for (const n of this.nodes) { + n.x *= sx; + n.y *= sy; + } + if (this.nodes.length > count) this.nodes.length = count; + while (this.nodes.length < count) this.nodes.push(this.spawn(s)); + } + + private spawn(s: SceneSize): FieldNode { + return { + x: Math.random() * s.width, + y: Math.random() * s.height, + vx: (Math.random() - 0.5) * DRIFT, + vy: (Math.random() - 0.5) * DRIFT, + gen: 0, + }; + } + + /** Drifts every node one step and wraps at the edges. */ + advance(dt: number): void { + for (const n of this.nodes) { + n.x += n.vx * dt; + n.y += n.vy * dt; + if (n.x < -MARGIN) { + n.x = this.size.width + MARGIN; + n.gen++; + } else if (n.x > this.size.width + MARGIN) { + n.x = -MARGIN; + n.gen++; + } + if (n.y < -MARGIN) { + n.y = this.size.height + MARGIN; + n.gen++; + } else if (n.y > this.size.height + MARGIN) { + n.y = -MARGIN; + n.gen++; + } + } + } + + /** + * A link's visibility, sampled at both ends and the middle. + * + * Three samples because a 170px link can straddle the copy with both of its + * endpoints outside it. + */ + lineClarity(a: Point, b: Point): number { + return Math.min( + this.clarity(a.x, a.y), + this.clarity(b.x, b.y), + this.clarity((a.x + b.x) / 2, (a.y + b.y) / 2), + ); + } + + /** Current neighbours of `i` within the link threshold. */ + neighbours(i: number): number[] { + const out: number[] = []; + const from = this.nodes[i]; + if (!from) return out; + for (let j = 0; j < this.nodes.length; j++) { + if (j === i) continue; + const to = this.nodes[j]!; + const dx = from.x - to.x; + const dy = from.y - to.y; + if (dx * dx + dy * dy < LINK_DIST * LINK_DIST) out.push(j); + } + return out; + } + + /** + * A path of at least `minDepth` hops, so traffic visibly traverses the graph + * rather than hopping one link. + * + * Breadth-first from `start` (random when omitted), taking the first frontier + * far enough out. Returns null when the field is too sparse right now, which + * simply means nothing spawns this tick. + */ + findPath(minDepth = 3, maxDepth = 4, start = Math.floor(Math.random() * this.nodes.length)): number[] | null { + if (this.nodes.length < 4) return null; + const prev = new Map([[start, -1]]); + let frontier = [start]; + for (let depth = 1; depth <= maxDepth; depth++) { + const next: number[] = []; + for (const i of frontier) { + for (const j of this.neighbours(i)) { + if (prev.has(j)) continue; + prev.set(j, i); + next.push(j); + } + } + if (next.length === 0) break; + frontier = next; + if (depth >= minDepth && Math.random() < 0.6) break; + } + if (frontier.length === 0 || frontier[0] === start) return null; + const end = frontier[Math.floor(Math.random() * frontier.length)]!; + const path: number[] = []; + for (let at: number | undefined = end; at !== undefined && at !== -1; at = prev.get(at)) { + path.push(at); + } + path.reverse(); + return path.length >= minDepth ? path : null; + } + + /** + * The candidate path, out of `tries`, whose endpoints are furthest apart. + * + * `findPath` takes the first route it finds, which is right for anonymous + * traffic where several trips are in flight and between them they cover the + * canvas. The scenes that tell one story at a time cannot do that: a single + * BFS route spans three or four links, which is around a quarter of the hero, + * and the result was a story happening in one corner with the rest of the + * field inert. Sampling and keeping the widest costs a few BFS walks per cast + * and makes the story cross the canvas. + */ + findSpanningPath(minDepth = 3, maxDepth = 4, tries = 8): number[] | null { + let best: number[] | null = null; + let bestSpan = -1; + for (let k = 0; k < tries; k++) { + const path = this.findPath(minDepth, maxDepth); + if (!path) continue; + const a = this.nodes[path[0]!]!; + const b = this.nodes[path[path.length - 1]!]!; + const span = Math.hypot(b.x - a.x, b.y - a.y); + if (span > bestSpan) { + bestSpan = span; + best = path; + } + } + return best; + } + + /** + * The shortest path from `from` to `to`, or null when they are not connected + * within `maxDepth` hops right now. + * + * `findPath` picks its own destination, which is fine for anonymous traffic. The + * scenes with named parties need to route back to a node they already chose. + */ + pathTo(from: number, to: number, maxDepth = 6): number[] | null { + if (from === to) return null; + const prev = new Map([[from, -1]]); + let frontier = [from]; + for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) { + const next: number[] = []; + for (const i of frontier) { + for (const j of this.neighbours(i)) { + if (prev.has(j)) continue; + prev.set(j, i); + if (j === to) { + const path: number[] = []; + for (let at: number | undefined = to; at !== undefined && at !== -1; at = prev.get(at)) { + path.push(at); + } + return path.reverse(); + } + next.push(j); + } + } + frontier = next; + } + return null; + } + + /** The generation of every node on a path, for retiring it after a wrap. */ + gensOf(path: number[]): number[] { + return path.map((i) => this.nodes[i]?.gen ?? 0); + } + + /** True when any node on the path has wrapped since `gens` was taken. */ + wrapped(path: number[], gens: number[]): boolean { + return path.some((i, k) => this.nodes[i]?.gen !== gens[k]); + } + + /** Total length of the live polyline through a path. */ + pathLength(path: number[]): number { + let total = 0; + for (let k = 1; k < path.length; k++) { + const a = this.nodes[path[k - 1]!]!; + const b = this.nodes[path[k]!]!; + total += Math.hypot(b.x - a.x, b.y - a.y); + } + return total; + } + + /** Point at `d` pixels along the live polyline. */ + pointAt(path: number[], d: number): Point { + let travelled = 0; + for (let k = 1; k < path.length; k++) { + const a = this.nodes[path[k - 1]!]!; + const b = this.nodes[path[k]!]!; + const seg = Math.hypot(b.x - a.x, b.y - a.y); + if (travelled + seg >= d || k === path.length - 1) { + const f = seg === 0 ? 0 : Math.max(0, Math.min(1, (d - travelled) / seg)); + return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f }; + } + travelled += seg; + } + const last = this.nodes[path[path.length - 1]!]!; + return { x: last.x, y: last.y }; + } + + /** The node index nearest a point, for placing a fixed actor in a moving field. */ + nearest(x: number, y: number, exclude: number[] = []): number { + let best = -1; + let bestD = Infinity; + for (let i = 0; i < this.nodes.length; i++) { + if (exclude.includes(i)) continue; + const n = this.nodes[i]!; + const d = (n.x - x) ** 2 + (n.y - y) ** 2; + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + } + + /** Links then nodes, both faded by proximity and masked by the copy. */ + draw(ctx: CanvasRenderingContext2D, palette: Palette, dim = 1): void { + // Alpha rides `globalAlpha` and the colour is set once: with 58 nodes this + // loop runs ~1,650 times a frame, and a template-string `strokeStyle` per link + // is 1,650 allocations and CSS colour parses a frame to express a number the + // context already takes directly. + ctx.lineWidth = 1; + ctx.strokeStyle = `rgb(${palette.strokeRgb})`; + // Proteus fades to 0.22 at its brightest; ink on paper needs a little more to + // survive, so light gets a higher ceiling. + const peak = (palette.light ? 0.34 : 0.26) * dim; + for (let i = 0; i < this.nodes.length; i++) { + const a = this.nodes[i]!; + for (let j = i + 1; j < this.nodes.length; j++) { + const b = this.nodes[j]!; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d >= LINK_DIST) continue; + const m = this.lineClarity(a, b); + if (m <= 0.01) continue; + ctx.globalAlpha = (1 - d / LINK_DIST) * peak * m; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + } + // Squares, not circles. That is the reference's signature. + ctx.fillStyle = `rgb(${palette.strokeRgb} / ${palette.light ? 0.8 : 0.62})`; + for (const n of this.nodes) { + const m = this.clarity(n.x, n.y); + if (m <= 0.01) continue; + ctx.globalAlpha = m * dim; + ctx.fillRect(n.x - 2, n.y - 2, 4, 4); + } + ctx.globalAlpha = 1; + } + + /** Brings one route up out of the field, per segment so each can dim separately. */ + drawRoute( + ctx: CanvasRenderingContext2D, + palette: Palette, + path: number[], + alpha: number, + colour?: string, + ): void { + ctx.lineWidth = 1; + for (let k = 1; k < path.length; k++) { + const a = this.nodes[path[k - 1]!]; + const b = this.nodes[path[k]!]; + if (!a || !b) continue; + const m = this.lineClarity(a, b); + if (m <= 0.01) continue; + ctx.globalAlpha = alpha * m; + ctx.strokeStyle = colour ?? `rgb(${palette.strokeRgb})`; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + + /** A ringed square: an endpoint that matters, as opposed to field furniture. */ + drawEndpoint( + ctx: CanvasRenderingContext2D, + i: number, + colour: string, + glow = 0, + alpha = 1, + ): void { + const n = this.nodes[i]; + if (!n) return; + const m = this.clarity(n.x, n.y) * alpha; + if (m <= 0.01) return; + ctx.strokeStyle = colour; + ctx.lineWidth = 1.2; + ctx.globalAlpha = 0.9 * m; + ctx.beginPath(); + ctx.arc(n.x, n.y, 5 + glow * 4, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = m; + ctx.fillStyle = colour; + ctx.fillRect(n.x - 2.5, n.y - 2.5, 5, 5); + ctx.globalAlpha = 1; + } + + /** A small travelling mark, masked where it crosses the copy. */ + dot(ctx: CanvasRenderingContext2D, p: Point, colour: string, alpha: number, r = 2): void { + const m = this.clarity(p.x, p.y) * alpha; + if (m <= 0.01) return; + ctx.globalAlpha = m; + ctx.fillStyle = colour; + ctx.fillRect(p.x - r, p.y - r, r * 2, r * 2); + ctx.globalAlpha = 1; + } +} diff --git a/packages/docs/src/components/canvas-hero/scenes/id-tables.ts b/packages/docs/src/components/canvas-hero/scenes/id-tables.ts new file mode 100644 index 00000000..767b796e --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/id-tables.ts @@ -0,0 +1,290 @@ +/** + * Scene 4: two tables, and the arrow that turns around. + * + * Cap'n Web keeps exactly two tables per side, imports and exports, and one + * side's exports are the other's imports. IDs are allocated by sign and never + * reused: "the importing side picks the next positive ID (from 1 up), the + * exporting side picks the next negative ID (from -1 down)", and zero is the + * main interface (`reference/protocol.md`, Imports and exports). + * + * That sign convention is what makes the scene readable. Positive rows grow down + * the client's side; negative rows grow down the server's; zero sits still in the + * middle because it is the interface everything starts from. + * + * The script is a real captured session: the client passes an `RpcTarget` as an + * argument, so the message carries `["export",-1]` rather than the object; the + * server `dup()`s it and, later, calls *back* through that same ID with + * `["pipeline",-1,["onProgress"],[50]]`. The arrow reverses and the ID is the + * pivot, because "when describing the meaning of any RPC message, we always take + * the perspective of the sender". Then `["release", id, refcount]` retires each + * row, and the next allocation carries on past it: 2 and -2, never 1 and -1 + * again. + */ +import type { KeepOut, Scene, SceneContext, SceneSize } from "../types"; +import { fitFont, space } from "./space"; + +type Dir = "out" | "back"; + +interface Event { + t: number; + /** Wire text, drawn on the message in flight. */ + msg: string; + dir: Dir; + /** A row this event opens. */ + opens?: { id: number; label: string }; + /** A row this event retires. */ + closes?: number; +} + +const SCRIPT: Event[] = [ + { t: 0.0, msg: '["push",["pipeline",0,["startJob"],["j1",["export",-1]]]]', dir: "out", opens: { id: -1, label: "ProgressSink" } }, + { t: 0.9, msg: '["pull",1]', dir: "out", opens: { id: 1, label: "startJob()" } }, + { t: 1.7, msg: '["resolve",1,"started"]', dir: "back" }, + { t: 2.5, msg: '["release",1,1]', dir: "out", closes: 1 }, + { t: 3.4, msg: '["push",["pipeline",-1,["onProgress"],[50]]]', dir: "back" }, + { t: 4.4, msg: '["push",["pipeline",-1,["onProgress"],[100]]]', dir: "back" }, + { t: 5.4, msg: '["release",-1,1]', dir: "back", closes: -1 }, +]; + +const FLIGHT = 0.72; +const CYCLE = 8.4; +/** How long an arrived message keeps its trail before it starts to go. */ +const LINGER = 0.5; +const FADE = 0.4; + +interface Row { + id: number; + label: string; + /** 0..1 fade in. */ + in: number; + /** 0..1 fade to retired. */ + out: number; +} + +export function idTables(): Scene { + let clientX = 0; + let serverX = 0; + let top = 0; + let laneOut = 0; + let laneBack = 0; + let midX = 0; + let fontSize = 10; + let rowH = 17; + /** + * Starts hidden, so a scene whose `layout` has not run yet reports that it does + * not fit rather than drawing a degenerate diagram at the origin. + */ + let hide = true; + + /** + * The two ledgers sit in the two clear columns: positive IDs down the client's + * side, negative down the server's, which is the sign convention the scene is + * about, laid out as the geometry. + * + * The messages have to cross from one side to the other, and the middle is + * where the copy lives. So the flight lanes go in the clear strip above the + * hero content rather than between the ledgers, and there are two of them so an + * outbound and an inbound message never overlap. + */ + const layout = (s: SceneSize, k: KeepOut) => { + const sp = space(s, k); + const col = Math.min(sp.left.width, sp.right.width); + fontSize = Math.min(10, fitFont(col, 26, 10)); + rowH = fontSize * 1.7; + // The deepest ink is the rail, which runs to `top + rowH * 6`, and `top` is + // itself one row down, so the scene needs seven rows below the column's origin + // plus a hair for the descender on the last annotation. Getting this wrong + // clips the bottom line off instead of hiding, and no width sweep finds it + // because the constraint is on height. + hide = fontSize < 7.5 || sp.band.height < rowH * 3 || sp.left.height < rowH * 7.1; + clientX = sp.left.x + sp.left.width / 2; + serverX = sp.right.x + sp.right.width / 2; + midX = sp.midX; + // Two lanes in the strip above, the lower one for traffic coming back. + laneBack = sp.band.y + sp.band.height - 10; + laneOut = laneBack - rowH * 1.25; + top = sp.left.y + rowH; + }; + + /** Rows present at time `t`, with their fades resolved. */ + const rowsAt = (t: number): Row[] => { + const rows: Row[] = []; + for (const ev of SCRIPT) { + // Hoisted so the narrowing survives into the closure below, which is what a + // non-null assertion would otherwise be papering over. + const opens = ev.opens; + if (!opens) continue; + const born = ev.t + FLIGHT; + if (t < born) continue; + const closer = SCRIPT.find((e) => e.closes === opens.id); + const died = closer ? closer.t + FLIGHT : Infinity; + rows.push({ + id: opens.id, + label: opens.label, + in: Math.min(1, (t - born) / 0.35), + out: t < died ? 0 : Math.min(1, (t - died) / 0.4), + }); + } + return rows; + }; + + const rail = (c: SceneContext, x: number, label: string) => { + const { ctx, palette } = c; + ctx.strokeStyle = `rgb(${palette.strokeRgb} / 0.28)`; + ctx.lineWidth = 1; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(x, laneOut); + ctx.lineTo(x, top + rowH * 6); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = palette.muted; + ctx.font = `600 ${fontSize}px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.globalAlpha = 0.8; + ctx.fillText(label, x, laneOut - 6); + ctx.globalAlpha = 1; + }; + + /** One ledger row. Positive rows sit on the client side, negative on the server's. */ + const drawRow = (c: SceneContext, row: Row, slot: number) => { + const { ctx, palette } = c; + const positive = row.id > 0; + const x = positive ? clientX + 10 : serverX - 10; + const y = top + slot * rowH; + const alpha = row.in * (1 - row.out * 0.55); + const colour = row.out > 0 ? palette.fade : positive ? palette.request : palette.response; + + ctx.textAlign = positive ? "left" : "right"; + ctx.textBaseline = "middle"; + ctx.globalAlpha = alpha; + ctx.font = `600 ${fontSize}px ${palette.mono}`; + ctx.fillStyle = colour; + const idText = String(row.id); + ctx.fillText(idText, x, y); + const idW = ctx.measureText(idText).width + 7; + ctx.font = `400 ${fontSize}px ${palette.mono}`; + ctx.fillStyle = row.out > 0 ? palette.fade : palette.muted; + ctx.globalAlpha = alpha * 0.85; + ctx.fillText(row.label, positive ? x + idW : x - idW, y); + + // A retired row keeps its slot and gets struck through: the ID is gone, and + // it is never coming back. + if (row.out > 0) { + const labelW = ctx.measureText(row.label).width; + const x0 = positive ? x : x - idW - labelW; + const x1 = positive ? x + idW + labelW : x; + ctx.strokeStyle = palette.fade; + ctx.globalAlpha = row.out * 0.65; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x0, y); + ctx.lineTo(x0 + (x1 - x0) * Math.min(1, row.out * 1.6), y); + ctx.stroke(); + } + ctx.globalAlpha = 1; + }; + + return { + layout, + fits: () => !hide, + draw(c) { + if (hide) return; + const { ctx, palette } = c; + // The still is a composition, not a moment: it sits late enough that every + // ID the session ever allocated is on the ledger, with the release in + // flight, and it forces the counter on so the "never reused" point lands in + // the one frame a reduced-motion visitor gets. + const t = c.still ? 5.4 + FLIGHT * 0.7 : c.t % CYCLE; + + rail(c, clientX, "client"); + rail(c, serverX, "server"); + + // ID zero: the main interface, present from the start and belonging to + // neither side's allocation, so it sits on the server rail where the main + // interface actually lives rather than floating over the copy. + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + ctx.font = `600 ${fontSize}px ${palette.mono}`; + ctx.fillStyle = palette.stroke; + ctx.globalAlpha = 0.85; + ctx.fillText("0", serverX - 10, top); + ctx.font = `400 ${fontSize}px ${palette.mono}`; + ctx.fillStyle = palette.muted; + ctx.globalAlpha = 0.6; + ctx.fillText("main interface", serverX - 10 - fontSize * 1.4, top); + ctx.globalAlpha = 1; + + const rows = rowsAt(t); + const positives = rows.filter((r) => r.id > 0); + const negatives = rows.filter((r) => r.id < 0); + positives.forEach((r, i) => drawRow(c, r, 1.2 + i)); + negatives.forEach((r, i) => drawRow(c, r, 1.2 + i)); + + // Messages in flight. + // Messages linger, and the lanes are only 0.9s apart, so two can be on the + // same lane at once. Only the newest one in each lane gets its text, because + // two wire messages centred on the same x are unreadable. + const newest = (dir: "out" | "back") => + SCRIPT.filter((e) => e.dir === dir && t - e.t > 0).at(-1); + const speaking = new Set([newest("out"), newest("back")]); + + for (const ev of SCRIPT) { + const age = t - ev.t; + // A message is in flight for FLIGHT, then its trail and its text linger, + // fading. Without the linger the lane is empty most of the time and the + // wire text, which is the most informative thing on screen, is a flash you + // cannot read. + if (age <= 0 || age >= FLIGHT + LINGER + FADE) continue; + const f = Math.min(1, age / FLIGHT); + const decay = 1 - Math.max(0, (age - FLIGHT - LINGER) / FADE); + const fromX = ev.dir === "out" ? clientX : serverX; + const toX = ev.dir === "out" ? serverX : clientX; + const y = ev.dir === "out" ? laneOut : laneBack; + const x = fromX + (toX - fromX) * f; + const colour = ev.closes !== undefined ? palette.fade : ev.dir === "out" ? palette.request : palette.response; + + ctx.strokeStyle = colour; + ctx.globalAlpha = 0.4 * decay; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(fromX, y); + ctx.lineTo(x, y); + ctx.stroke(); + // The head only exists while it is moving; once arrived it is just a trail. + if (f < 1) { + ctx.globalAlpha = 0.95; + ctx.fillStyle = colour; + ctx.fillRect(x - 2, y - 2, 4, 4); + } + + // The wire text rides above its lane, centred on the page rather than on + // the moving dot so it never drifts off the edge. + if (speaking.has(ev)) { + ctx.font = `400 ${fontSize - 0.5}px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.globalAlpha = 0.72 * Math.min(1, age / 0.25) * decay; + ctx.fillStyle = palette.muted; + ctx.fillText(ev.msg, midX, y - 6); + } + ctx.globalAlpha = 1; + } + + // The counter that makes "never reused" concrete. It goes in the left + // gutter under the client's rows, which is where the positive IDs are + // allocated from. + if (c.still || t > 6.2) { + ctx.globalAlpha = (c.still ? 1 : Math.min(1, (t - 6.2) / 0.5)) * 0.7; + ctx.font = `400 ${fontSize}px ${palette.mono}`; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillStyle = palette.muted; + ctx.fillText("next: 2 and -2", clientX + 10, top + rowH * 4.4); + ctx.fillText("never reused", clientX + 10, top + rowH * 5.4); + ctx.globalAlpha = 1; + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/index.ts b/packages/docs/src/components/canvas-hero/scenes/index.ts new file mode 100644 index 00000000..be9bd089 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/index.ts @@ -0,0 +1,160 @@ +/** + * The scene registry, and the one list that says which scene each route shows. + * + * `CanvasHero.astro` takes a key from here, so a page names a scene rather than + * importing a module. Every factory is statically imported, so a variant page + * ships all of them in one chunk: that is fine for throwaway comparison pages, + * and making it code-split would mean `mountCanvasHero` accepting a + * `Promise` to save a few kilobytes on pages that will not survive the + * decision. + */ +import { VARIANT_SLUGS, type VariantSlug } from "../routes"; +import type { SceneFactory } from "../types"; +import { roundTripField } from "./round-trip-field"; +import { relay } from "./relay"; +import { reverse } from "./reverse"; +import { sessionLoss } from "./session-loss"; +import { amplify } from "./amplify"; +import { streams } from "./streams"; +import { pipelineLadder } from "./pipeline-ladder"; +import { batchBody } from "./batch-body"; +import { idTables } from "./id-tables"; +import { mapReplay } from "./map-replay"; +import { depth } from "./depth"; +import { substitute } from "./substitute"; +import { unpulled } from "./unpulled"; + +export const scenes = { + "round-trip-field": roundTripField, + relay, + reverse, + "session-loss": sessionLoss, + amplify, + streams, + "pipeline-ladder": pipelineLadder, + "batch-body": batchBody, + "id-tables": idTables, + "map-replay": mapReplay, + depth, + substitute, + unpulled, +} satisfies Record; + +export type SceneKey = keyof typeof scenes; + +/** Title and one-line gloss, used by the variant switcher and each page's ``. */ +export const sceneMeta: Record<SceneKey, { title: string; blurb: string }> = { + "round-trip-field": { + title: "Round-trip field", + blurb: "A drifting node network. Calls ride out together and one answer comes back.", + }, + relay: { + title: "Delegated authority", + blurb: "A capability is granted, passed on, and then used directly by a third party.", + }, + reverse: { + title: "Direction reverses", + blurb: "One route. A call goes out, is answered, and then the far end calls back.", + }, + "session-loss": { + title: "Session loss", + blurb: "Every stub on one connection breaks in the same frame, then regrows elsewhere.", + }, + amplify: { + title: "Amplification", + blurb: "One small call in, one small value out, and a detonation in between.", + }, + streams: { + title: "Multiplexed streams", + blurb: "Three continuous flows sharing one cable, each at its own rate.", + }, + "pipeline-ladder": { + title: "Pipeline ladder", + blurb: "Four dependent calls, awaited one at a time against pipelined, on one time axis.", + }, + "batch-body": { + title: "Batch body", + blurb: "One HTTP body of newline-delimited JSON, and the reply line that never comes.", + }, + "id-tables": { + title: "Import and export tables", + blurb: "IDs allocated by sign, a call going back the other way, and a release.", + }, + "map-replay": { + title: "Record and replay", + blurb: "The callback runs once locally; the recorded instructions run once per element.", + }, + depth: { + title: "Depth is free", + blurb: "The same four calls raced: eight crossings against two, and the idle time after.", + }, + substitute: { + title: "The hollow socket", + blurb: "A call ships with a hole in it, and the far end drops the missing value in.", + }, + unpulled: { + title: "Only what was pulled", + blurb: "Five calls run at the far end. Two answers come back, and three never move.", + }, +}; + +/** + * Route slug to scene, with the canvas opacity each one wants. + * + * One list, because the mapping used to live in three: the registry's key order, + * a hand-maintained array in the switcher, and page files each passing a number + * and a scene independently, with nothing to stop them disagreeing. + * + * The slug is carried rather than derived from the index, because the routes are + * no longer a clean sequence: the `1x` family are variations on scene 1 and are + * named to say so, and deriving `/1a` from an array position would put the + * relationship in the reader's head instead of in the code. + * + * The opacities are not uniform on purpose. A drifting node field is texture and + * can sit near the reference's 0.42; a diagram made of 8px monospace has to be + * legible, so it sits higher. The lane scenes are line work on a dimmed field, + * which needs less than type but more than texture. + */ +export interface Variant { + /** The route, without its leading slash. */ + slug: VariantSlug; + scene: SceneKey; + opacity: number; +} + +export const VARIANTS = [ + { slug: "1", scene: "round-trip-field", opacity: 0.62 }, + { slug: "1a", scene: "relay", opacity: 0.66 }, + { slug: "1b", scene: "reverse", opacity: 0.66 }, + { slug: "1c", scene: "session-loss", opacity: 0.66 }, + { slug: "1d", scene: "amplify", opacity: 0.62 }, + { slug: "1e", scene: "streams", opacity: 0.66 }, + { slug: "2", scene: "pipeline-ladder", opacity: 0.8 }, + { slug: "3", scene: "batch-body", opacity: 0.88 }, + { slug: "4", scene: "id-tables", opacity: 0.85 }, + { slug: "5", scene: "map-replay", opacity: 0.85 }, + { slug: "6", scene: "depth", opacity: 0.78 }, + { slug: "7", scene: "substitute", opacity: 0.78 }, + { slug: "8", scene: "unpulled", opacity: 0.78 }, +] as const satisfies readonly Variant[]; + +/** + * Every declared route has a variant, and every variant has a declared route. + * + * `Variant["slug"]` already stops a typo, but it cannot catch the interesting + * failure: adding a slug to `routes.ts` and forgetting the variant, which ships a + * page that throws at build, or dropping a variant and leaving the sitemap filter + * hiding a route that no longer exists. This is a type-level check, so it costs + * nothing at runtime and fails at `astro check` instead of in the browser. + */ +type Covered = (typeof VARIANTS)[number]["slug"]; +type Missing = Exclude<VariantSlug, Covered>; +const _allSlugsCovered: Missing extends never ? true : Missing = true; +void _allSlugsCovered; +if (VARIANTS.length !== VARIANT_SLUGS.length) { + throw new Error(`canvas-hero: ${VARIANTS.length} variants for ${VARIANT_SLUGS.length} routes`); +} + +export function variantBySlug(slug: string): Variant | undefined { + return VARIANTS.find((v) => v.slug === slug); +} diff --git a/packages/docs/src/components/canvas-hero/scenes/map-replay.ts b/packages/docs/src/components/canvas-hero/scenes/map-replay.ts new file mode 100644 index 00000000..c7985f1c --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/map-replay.ts @@ -0,0 +1,229 @@ +/** + * Scene 5: record once, replay N times. + * + * `.map()` looks impossible at first glance, because Cap'n Web never ships code. + * What it ships is a recording. Your callback is invoked exactly once, locally, + * with a placeholder `RpcPromise` as its argument; the RPCs it makes are not + * executed but written down as instructions, and the stubs it touches are + * captured. That becomes one `["remap", importId, path, captures, instructions]` + * expression, and the peer replays the instruction list once per element + * (`concepts/map.md`, How the heck does that work?). + * + * The three-column shape is the animation: one callback on the left, one message + * in the middle, three replays on the right. The instruction list is real, from a + * captured trace of the docs' own example: + * + * let names = await idsPromise.map(id => [id, api.getUserName(id)]); + * + * The index convention on the replay side is from `reference/protocol.md#remap` + * and is drawn literally, because it is what makes the scene legible: negative + * indices are the captures, zero is the element, positive indices are the results + * of earlier instructions. + */ +import type { KeepOut, Scene, SceneContext, SceneSize } from "../types"; +import { fitFont, space } from "./space"; + +const INSTRUCTIONS = [ + '0 ["pipeline",-1,["getUserName"],[["pipeline",0]]]', + '1 [[["pipeline",0],["pipeline",1]]]', +]; + +/** + * The callback source, in one place. + * + * The recording bar's width is measured from this string, so a second copy meant + * the bar could sweep the wrong distance the moment one of them was edited. + */ +const CALLBACK = "id => [id, api.getUserName(id)]"; + +/** Paired, so the element and its result cannot fall out of step by index. */ +const REPLAYS = [ + { element: "1", result: '[1,"n1"]' }, + { element: "2", result: '[2,"n2"]' }, + { element: "3", result: '[3,"n3"]' }, +]; + +const RECORD_AT = 0.5; +const RECORD_FOR = 1.1; +const SEND_AT = 2.0; +const SEND_FOR = 0.85; +const REPLAY_AT = 3.0; +const REPLAY_STEP = 0.42; +const RESOLVE_AT = 4.7; +const CYCLE = 8.0; + +export function mapReplay(): Scene { + let leftX = 0; + let midX = 0; + let rightX = 0; + let top = 0; + let laneY = 0; + let bandX0 = 0; + let bandX1 = 0; + let fontSize = 10; + let rowH = 16; + let compact = false; + /** + * Starts hidden, so a scene whose `layout` has not run yet reports that it does + * not fit rather than drawing a degenerate diagram at the origin. + */ + let hide = true; + + /** + * Recording on the left, replays on the right, and the one message that carries + * the recording between them travelling through the clear strip above the copy. + * + * Type size comes from the column width and the longest string the scene draws, + * for the same reason as the batch body: an instruction list is only interesting + * if you can read the indices. + */ + const layout = (s: SceneSize, k: KeepOut) => { + const sp = space(s, k); + const col = Math.min(sp.left.width, sp.right.width); + const longest = INSTRUCTIONS.reduce((m, l) => Math.max(m, l.length), 0); + fontSize = fitFont(col, longest, 10); + rowH = fontSize * 1.6; + hide = fontSize < 7 || sp.band.height < rowH * 3 || sp.left.height < rowH * 7; + compact = col < 300; + leftX = sp.left.x + 6; + rightX = sp.right.x + 6; + midX = sp.midX; + bandX0 = sp.crossX0; + bandX1 = sp.crossX1; + laneY = sp.band.y + sp.band.height - 10; + top = sp.left.y + rowH * 1.6; + }; + + const label = (c: SceneContext, text: string, x: number, y: number, alpha: number, align: CanvasTextAlign = "left") => { + const { ctx, palette } = c; + ctx.font = `600 ${fontSize}px ${palette.mono}`; + ctx.textAlign = align; + ctx.textBaseline = "alphabetic"; + ctx.globalAlpha = alpha * 0.8; + ctx.fillStyle = palette.muted; + ctx.fillText(text, x, y); + ctx.globalAlpha = 1; + }; + + const mono = ( + c: SceneContext, + text: string, + x: number, + y: number, + alpha: number, + colour: string, + weight = 400, + ) => { + const { ctx, palette } = c; + ctx.font = `${weight} ${fontSize}px ${palette.mono}`; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.globalAlpha = alpha; + ctx.fillStyle = colour; + ctx.fillText(text, x, y); + ctx.globalAlpha = 1; + }; + + return { + layout, + fits: () => !hide, + draw(c) { + if (hide) return; + const { ctx, palette } = c; + // The still shows all three replays done and the single answer forming. + const t = c.still ? RESOLVE_AT + 0.5 : c.t % CYCLE; + + // ---- left: the callback, run once ------------------------------------- + label(c, "your callback \u00b7 runs once, locally", leftX, top - rowH, 1); + const recF = Math.max(0, Math.min(1, (t - RECORD_AT) / RECORD_FOR)); + mono(c, CALLBACK, leftX, top + rowH * 0.4, 0.85, palette.stroke); + if (t > RECORD_AT) { + // A recording bar sweeping the callback once. + ctx.strokeStyle = palette.request; + ctx.globalAlpha = 0.5 * (1 - Math.max(0, (recF - 0.75) / 0.25)); + ctx.lineWidth = 1.5; + // Set explicitly rather than relying on whatever `mono` left behind. + ctx.font = `400 ${fontSize}px ${palette.mono}`; + const w = ctx.measureText(CALLBACK).width; + const bx = leftX + w * recF; + ctx.beginPath(); + ctx.moveTo(bx, top + rowH * 0.4 - fontSize); + ctx.lineTo(bx, top + rowH * 0.4 + 4); + ctx.stroke(); + ctx.globalAlpha = 1; + mono(c, "recording", leftX, top + rowH * 1.7, Math.min(1, recF * 2) * 0.6, palette.request); + } + + // ---- the instructions it produced ------------------------------------- + if (recF > 0.4) { + const a = Math.min(1, (recF - 0.4) / 0.4); + label(c, "captures [[\"import\",0]]", leftX, top + rowH * 3.1, a); + INSTRUCTIONS.forEach((line, i) => { + mono(c, line, leftX, top + rowH * (4.1 + i), a * 0.85, palette.stroke); + }); + } + + // ---- middle: one message ---------------------------------------------- + if (t > SEND_AT) { + const f = Math.min(1, (t - SEND_AT) / SEND_FOR); + const y = laneY; + const x0 = bandX0; + const x1 = bandX1; + ctx.strokeStyle = palette.request; + ctx.globalAlpha = 0.45 * (1 - Math.max(0, (f - 0.75) / 0.25)); + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(x0, y); + ctx.lineTo(x0 + (x1 - x0) * f, y); + ctx.stroke(); + ctx.fillStyle = palette.request; + ctx.globalAlpha = 0.9 * (1 - Math.max(0, (f - 0.85) / 0.15)); + ctx.fillRect(x0 + (x1 - x0) * f - 2, y - 2, 4, 4); + ctx.globalAlpha = 0.68 * Math.sin(Math.min(1, f) * Math.PI); + ctx.font = `400 ${fontSize - 0.5}px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillStyle = palette.muted; + ctx.fillText(compact ? '["remap",6,...]' : '["remap",6,[],captures,instructions]', midX, y - 7); + ctx.globalAlpha = 1; + } + + // ---- right: replayed once per element --------------------------------- + if (t > REPLAY_AT - 0.3) { + label(c, "peer \u00b7 replays per element", rightX, top - rowH, 1); + // The index convention, which is the key to reading the instructions. + if (!compact) { + mono(c, "-1 capture 0 element 1+ earlier", rightX, top + rowH * 0.4, 0.55, palette.muted); + } + REPLAYS.forEach(({ element, result }, i) => { + const start = REPLAY_AT + i * REPLAY_STEP; + const f = Math.max(0, Math.min(1, (t - start) / REPLAY_STEP)); + if (f <= 0) return; + const y = top + rowH * (2.1 + i * 1.5); + mono(c, `0 = ${element}`, rightX, y, f * 0.8, palette.stroke); + // Instruction 0 runs, then instruction 1 builds the pair. + if (f > 0.35) { + mono(c, "\u2192 getUserName", rightX + fontSize * 5.2, y, Math.min(1, (f - 0.35) / 0.3) * 0.7, palette.request); + } + if (f >= 1) { + mono(c, result, rightX + fontSize * 13.5, y, 0.9, palette.response, 600); + } + }); + } + + // ---- one answer ------------------------------------------------------- + // In the band, above the message lane: the middle of the page belongs to + // the headline. + if (t > RESOLVE_AT) { + const f = Math.min(1, (t - RESOLVE_AT) / 0.6); + ctx.globalAlpha = f * 0.8; + ctx.font = `500 ${fontSize}px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillStyle = palette.response; + ctx.fillText("1 recording \u00b7 3 replays \u00b7 1 round trip", midX, laneY - rowH * 1.3); + ctx.globalAlpha = 1; + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/pipeline-ladder.ts b/packages/docs/src/components/canvas-hero/scenes/pipeline-ladder.ts new file mode 100644 index 00000000..02b737f4 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/pipeline-ladder.ts @@ -0,0 +1,258 @@ +/** + * Scene 2: the headline claim, drawn as two sequence diagrams racing. + * + * Left lane is the ordinary way: four dependent calls, each awaited before the + * next can be written, so each one pays for its own round trip. Right lane is + * the same four calls pipelined: 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 server substitutes + * the real values on arrival and answers once. + * + * The two lanes start together and are drawn on one shared time axis, which is + * the only honest way to show the difference: the right lane is finished and + * idle while the left lane is still on its second trip. The numbers on the + * labels are the docs' own, from `start/pipelining-tour.md`: "Four dependent + * calls means four round trips... On a 100 ms link, that's 400 ms of doing + * nothing", against "arbitrary depth of dependency, one round trip". + * + * The message text is real, taken from a captured trace of the tour's example: + * a push carries `["pipeline", importId, path, args]`, and a dependent call + * references an import ID that does not exist yet. + */ +import type { KeepOut, Scene, SceneContext, SceneSize } from "../types"; +import { space } from "./space"; + +/** Seconds of animation that stand for one 100 ms network leg. */ +const LEG = 0.85; +/** Four trips on the left, so the cycle is four legs out and back, plus a hold. */ +const CYCLE = LEG * 8 + 2.2; + +interface Lane { + /** Centre x of the lane. */ + cx: number; + clientX: number; + serverX: number; +} + +const CALLS = [ + "authenticate", + "getUserId", + "getUserProfile", + "getFriendIds", +]; + +export function pipelineLadder(): Scene { + let left: Lane = { cx: 0, clientX: 0, serverX: 0 }; + let right: Lane = { cx: 0, clientX: 0, serverX: 0 }; + let top = 0; + let bottom = 0; + let compact = false; + /** + * Starts hidden, so a scene whose `layout` has not run yet reports that it does + * not fit rather than drawing a degenerate diagram at the origin. + */ + let hide = true; + + /** + * One lane per clear column, so the two diagrams frame the copy instead of + * running under it. A lane needs room for two rails plus their labels; below + * that the scene is better off absent than clipped. + */ + const layout = (s: SceneSize, k: KeepOut) => { + const sp = space(s, k); + const w = Math.min(sp.left.width, sp.right.width); + hide = w < 150 || sp.left.height < 150; + compact = w < 300; + const half = Math.min(130, w * 0.34); + const leftCx = sp.left.x + sp.left.width / 2; + const rightCx = sp.right.x + sp.right.width / 2; + left = { cx: leftCx, clientX: leftCx - half, serverX: leftCx + half }; + right = { cx: rightCx, clientX: rightCx - half, serverX: rightCx + half }; + // Rails run the height of the column. Above them sit two stacked labels: the + // lane's name, then `client` and `server` on the rails themselves. + top = sp.left.y + 42; + bottom = sp.left.y + sp.left.height - 28; + }; + + /** y for a point `legs` legs into the shared time axis. */ + const yAt = (legs: number) => top + (bottom - top) * Math.min(1, legs / 8); + + const rail = (c: SceneContext, x: number, label: string, align: CanvasTextAlign) => { + const { ctx, palette } = c; + ctx.strokeStyle = `rgb(${palette.strokeRgb} / 0.3)`; + ctx.lineWidth = 1; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(x, top); + ctx.lineTo(x, bottom); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = palette.muted; + ctx.font = `500 10px ${palette.mono}`; + ctx.textAlign = align; + ctx.textBaseline = "bottom"; + ctx.globalAlpha = 0.75; + ctx.fillText(label, x, top - 6); + ctx.globalAlpha = 1; + }; + + /** + * One leg of travel. `from`/`to` are x positions, `startLeg` is when it left, + * and `now` is the current position on the shared axis. Draws nothing until it + * has departed, and leaves a static line once it has arrived. + */ + const leg = ( + c: SceneContext, + fromX: number, + toX: number, + startLeg: number, + now: number, + colour: string, + label?: string, + ) => { + const { ctx, palette } = c; + if (now < startLeg) return; + const f = Math.min(1, now - startLeg); + const y0 = yAt(startLeg); + const y1 = yAt(startLeg + 1); + const x = fromX + (toX - fromX) * f; + const y = y0 + (y1 - y0) * f; + + ctx.strokeStyle = colour; + ctx.globalAlpha = 0.55; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(fromX, y0); + ctx.lineTo(x, y); + ctx.stroke(); + + ctx.globalAlpha = 1; + ctx.fillStyle = colour; + ctx.fillRect(x - 2, y - 2, 4, 4); + + // Labels hug the departure rail rather than the moving head: a label that + // travels with the dot ends up outside the lane and over the copy. They hang + // *below* the departure point, because above it on the first leg is where the + // `client` rail label already is. + if (label && !compact) { + ctx.font = `400 9.5px ${palette.mono}`; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.globalAlpha = 0.55 * f; + ctx.fillStyle = palette.muted; + ctx.fillText(label, fromX + 5, y0 + 4); + ctx.globalAlpha = 1; + } + }; + + const verdict = (c: SceneContext, lane: Lane, atLeg: number, now: number, text: string, colour: string) => { + const { ctx, palette } = c; + if (now < atLeg) return; + const f = Math.min(1, (now - atLeg) / 0.6); + const y = yAt(atLeg) + 20; + ctx.globalAlpha = f * 0.95; + ctx.fillStyle = colour; + ctx.font = `600 ${compact ? 10 : 11}px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(text, lane.cx, y); + ctx.globalAlpha = f * 0.5; + ctx.strokeStyle = colour; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(lane.cx - 34, y - 7); + ctx.lineTo(lane.cx + 34, y - 7); + ctx.stroke(); + ctx.globalAlpha = 1; + }; + + /** Left lane: await, reply, await, reply. Four trips, eight legs. */ + const drawSequential = (c: SceneContext, now: number) => { + const { palette } = c; + rail(c, left.clientX, "client", "center"); + rail(c, left.serverX, "server", "center"); + for (let i = 0; i < 4; i++) { + leg(c, left.clientX, left.serverX, i * 2, now, palette.request, CALLS[i]); + leg(c, left.serverX, left.clientX, i * 2 + 1, now, palette.response); + } + verdict(c, left, 8, now, "4 round trips \u00b7 400 ms", palette.fade); + }; + + /** + * Right lane: four pushes leave inside the first leg, staggered only enough to + * be countable, then one resolve comes back. The chain never waits. + */ + const drawPipelined = (c: SceneContext, now: number) => { + const { ctx, palette } = c; + rail(c, right.clientX, "client", "center"); + rail(c, right.serverX, "server", "center"); + + for (let i = 0; i < 4; i++) { + // All four depart inside the first leg, a sixteenth of one apart: enough to + // be countable, not enough to look like they are waiting on each other. + const start = i * 0.06; + if (now < start) continue; + // Normalised against the distance still to run, so however late a push left, + // it lands at exactly one leg. The server cannot answer before its arguments + // arrive, and this scene's whole claim is that it answers once, after them. + const f = Math.min(1, (now - start) / (1 - start)); + const y0 = yAt(start); + const y1 = yAt(start + 1); + const x = right.clientX + (right.serverX - right.clientX) * f; + const y = y0 + (y1 - y0) * f; + ctx.strokeStyle = palette.request; + ctx.globalAlpha = 0.45; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(right.clientX, y0); + ctx.lineTo(x, y); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.fillStyle = palette.request; + ctx.fillRect(x - 2, y - 2, 4, 4); + } + + // The dependent argument, spelled the way the wire spells it. Shortened + // rather than dropped when the gutter is tight: the point is that an + // argument can *be* a reference to a result that does not exist yet. + if (now > 0.35) { + ctx.globalAlpha = Math.min(1, (now - 0.35) / 0.5) * 0.62; + ctx.fillStyle = palette.muted; + ctx.font = `400 9.5px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(compact ? '["pipeline",2]' : '["pipeline",2,["getUserProfile"]]', right.cx, yAt(1.05)); + ctx.globalAlpha = 1; + } + + leg(c, right.serverX, right.clientX, 1.05, now, palette.response); + verdict(c, right, 2.05, now, "1 round trip \u00b7 100 ms", palette.response); + }; + + const drawLabel = (c: SceneContext) => { + const { ctx, palette } = c; + ctx.font = `500 10px ${palette.mono}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.globalAlpha = 0.5; + ctx.fillStyle = palette.muted; + ctx.textBaseline = "bottom"; + ctx.fillText("await each", left.cx, top - 21); + ctx.fillText("pipelined", right.cx, top - 21); + ctx.globalAlpha = 1; + }; + + return { + layout, + fits: () => !hide, + draw(c) { + if (hide) return; + // The still shows the moment the right lane has answered and the left is + // only halfway: the comparison, in one frame. + const now = c.still ? 4.2 : (c.t % CYCLE) / LEG; + drawLabel(c); + drawSequential(c, now); + drawPipelined(c, now); + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/relay.ts b/packages/docs/src/components/canvas-hero/scenes/relay.ts new file mode 100644 index 00000000..bafdbfcd --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/relay.ts @@ -0,0 +1,196 @@ +/** + * Scene 1a: a capability is granted, relayed onward, and then used directly. + * + * Three parties in the drifting field. A grants a capability to B; B passes that + * same capability on to C; C then calls straight back to A along a route that has + * carried nothing until now. The token keeps its identity the whole way -- it is + * drawn as an open diamond, the only diamond on the canvas -- so what the eye + * follows is one thing changing hands rather than three unrelated messages. + * + * The last leg is the point. C never received anything from A and has no prior + * relationship with it, yet the call it makes is direct. That is what "a stub can + * be passed across RPC again, including over independent connections" looks like + * when you draw it: authority travels, and it still works at the far end. + * + * Docs: `concepts/stubs.md` (passing stubs onward), `guides/security.md` + * (capabilities as the unit of authority). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; + +type Phase = "grant" | "relay" | "use" | "reply" | "hold"; + +/** Seconds per phase, in order. `hold` lets the finished picture sit a moment. */ +const DUR: Record<Phase, number> = { + grant: 1.5, + relay: 1.5, + use: 1.4, + reply: 1.0, + hold: 1.1, +}; +const NEXT: Record<Phase, Phase> = { + grant: "relay", + relay: "use", + use: "reply", + reply: "hold", + hold: "grant", +}; + +interface Cast { + /** A to B, B to C, then C back to A. Node indices, positions read live. */ + grant: number[]; + relay: number[]; + use: number[]; + gen: number[]; +} + +export function relay(): Scene { + const field = new Field(); + let cast: Cast | null = null; + let phase: Phase = "grant"; + let p = 0; + let age = 0; + + /** + * Cast three parties such that A-B and B-C are connected, and C can reach A. + * + * All three legs have to exist in the *current* field or the story cannot be + * told, so this is a search that is allowed to fail: on a sparse frame nothing + * is cast and the field simply drifts until it can be. + */ + const castParties = (): Cast | null => { + for (let attempt = 0; attempt < 12; attempt++) { + // Start from the end of a wide route, so the three parties are spread + // across the hero rather than clustered in one corner of the field. + const seed = field.findSpanningPath(3, 4, 6); + const a = seed ? seed[0]! : Math.floor(Math.random() * field.nodes.length); + const grant = field.findPath(3, 4, a); + if (!grant) continue; + const b = grant[grant.length - 1]!; + const relayLeg = field.findPath(3, 4, b); + if (!relayLeg) continue; + const c = relayLeg[relayLeg.length - 1]!; + if (c === a || grant.includes(c)) continue; + const use = field.pathTo(c, a, 8); + if (!use || use.length < 3) continue; + const all = [...new Set([...grant, ...relayLeg, ...use])]; + return { grant, relay: relayLeg, use, gen: field.gensOf(all) }; + } + return null; + }; + + const allNodes = (k: Cast) => [...new Set([...k.grant, ...k.relay, ...k.use])]; + + /** The open diamond that *is* the capability, wherever it currently sits. */ + const drawToken = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + colour: string, + alpha: number, + r: number, + ) => { + const m = field.clarity(x, y) * alpha; + if (m <= 0.01) return; + ctx.globalAlpha = m; + ctx.strokeStyle = colour; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(x, y - r); + ctx.lineTo(x + r, y); + ctx.lineTo(x, y + r); + ctx.lineTo(x - r, y); + ctx.closePath(); + ctx.stroke(); + ctx.globalAlpha = 1; + }; + + return { + ambient: true, + layout(s: SceneSize) { + field.layout(s); + if (cast && allNodes(cast).some((i) => i >= field.nodes.length)) cast = null; + }, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + + if (!c.still) { + field.advance(c.dt); + age += c.dt; + p += c.dt / DUR[phase]; + if (p >= 1) { + p = 0; + phase = NEXT[phase]; + // One full circuit per cast, so the parties change between stories. + if (phase === "grant") cast = null; + } + } else if (!cast) { + // The composed still: the moment C calls A, with both earlier legs drawn. + phase = "use"; + p = 0.55; + } + + if (!cast) { + cast = castParties(); + if (cast) age = 0; + } + // A node under the cast wrapped to the far edge, so its routes are nonsense. + if (cast && field.wrapped(allNodes(cast), cast.gen)) cast = null; + + field.draw(ctx, palette); + if (!cast) return; + + const fade = c.still ? 1 : Math.min(1, age / 0.4); + const a = cast.grant[0]!; + const b = cast.grant[cast.grant.length - 1]!; + const cc = cast.relay[cast.relay.length - 1]!; + const done = (ph: Phase) => Object.keys(DUR).indexOf(phase) > Object.keys(DUR).indexOf(ph); + + // Routes that have already carried the token stay faintly lit, so by the end + // the whole chain of custody is visible at once. + field.drawRoute(ctx, palette, cast.grant, (done("grant") ? 0.3 : 0.55) * fade); + if (phase !== "grant") field.drawRoute(ctx, palette, cast.relay, (done("relay") ? 0.3 : 0.55) * fade); + if (done("relay")) { + // The route C uses to reach A, drawn in the request colour: it is new, and + // it is the leg that has no prior relationship behind it. + field.drawRoute(ctx, palette, cast.use, 0.55 * fade, palette.request); + } + + field.drawEndpoint(ctx, a, palette.request, 0, fade); + field.drawEndpoint(ctx, b, palette.stroke, 0, fade); + field.drawEndpoint(ctx, cc, done("relay") ? palette.request : palette.stroke, 0, fade); + + const ease = p * p * (3 - 2 * p); + if (phase === "grant" || phase === "relay") { + // The token in transit, handed from one holder to the next. + const leg = phase === "grant" ? cast.grant : cast.relay; + const total = field.pathLength(leg); + const pt = field.pointAt(leg, total * ease); + drawToken(ctx, pt.x, pt.y, palette.response, fade, 6); + } else { + // Held by C from here on, pulsing gently: it is C's to use now. + const held = field.nodes[cc]!; + const pulse = 6 + Math.sin(age * 3) * 0.9; + drawToken(ctx, held.x, held.y, palette.response, fade, pulse); + } + + if (phase === "use") { + // C exercises the capability against A. A train, as ever: one trip. + const total = field.pathLength(cast.use); + const head = total * ease; + for (let k = 0; k < 4; k++) { + const d = head - k * 11; + if (d < 0) continue; + field.dot(ctx, field.pointAt(cast.use, d), palette.request, fade * (1 - k / 5) * 0.95, 1.6); + } + } else if (phase === "reply") { + // A honours it, because the authority is genuine however far it travelled. + const total = field.pathLength(cast.use); + const pt = field.pointAt(cast.use, total * (1 - ease)); + field.dot(ctx, pt, palette.response, fade, 2); + field.drawEndpoint(ctx, a, palette.response, Math.sin(p * Math.PI), fade); + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/reverse.ts b/packages/docs/src/components/canvas-hero/scenes/reverse.ts new file mode 100644 index 00000000..deec157e --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/reverse.ts @@ -0,0 +1,143 @@ +/** + * Scene 1b: one route, and the direction of calls reverses on it. + * + * A calls B and B answers, which is the shape everyone expects. Then nothing is + * torn down and nothing is dialled: B calls A along the identical route, and A + * answers. The two halves are drawn the same way in the same colours, mirrored, + * so the only difference the eye can find is which end started it. + * + * The colours carry the argument. A call is always the request colour and always + * a train; an answer is always the response colour and always a single mark. So + * the second half is unmistakably a *call* travelling right to left, not a late + * reply -- there is no such thing as a client and a server here, only two peers + * that happen to take turns. + * + * Docs: `concepts/rpc-target.md` (both ends export interfaces), + * `guides/sessions.md` (bidirectional by default). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; + +/** One direction's story, then the other's. `turn` flips at each cycle's end. */ +type Phase = "call" | "work" | "answer" | "settle"; + +const DUR: Record<Phase, number> = { call: 1.35, work: 0.26, answer: 1.0, settle: 0.5 }; +const NEXT: Record<Phase, Phase> = { call: "work", work: "answer", answer: "settle", settle: "call" }; + +export function reverse(): Scene { + const field = new Field(); + let route: number[] | null = null; + let gen: number[] = []; + let phase: Phase = "call"; + let p = 0; + let age = 0; + /** 0 = the left end is calling, 1 = the right end is. */ + let turn = 0; + /** Cycles completed on this route, so a route is not reused indefinitely. */ + let cycles = 0; + + const pick = () => { + const path = field.findSpanningPath(4, 5, 8); + if (!path) return; + route = path; + gen = field.gensOf(path); + age = 0; + cycles = 0; + }; + + return { + ambient: true, + layout(s: SceneSize) { + field.layout(s); + if (route && route.some((i) => i >= field.nodes.length)) route = null; + }, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + + if (!c.still) { + field.advance(c.dt); + age += c.dt; + p += c.dt / DUR[phase]; + if (p >= 1) { + p = 0; + phase = NEXT[phase]; + if (phase === "call") { + // The turn passes. This is the whole scene. + turn ^= 1; + cycles++; + if (cycles >= 4) route = null; + } + } + } else if (!route) { + // The still shows the reversed half in flight: the surprising one. + turn = 1; + phase = "call"; + p = 0.6; + } + + if (!route) pick(); + if (route && field.wrapped(route, gen)) route = null; + + field.draw(ctx, palette); + if (!route) return; + + const fade = c.still ? 1 : Math.min(1, age / 0.4); + // The route is read forwards or backwards depending on whose turn it is, so + // every calculation below is written once and simply runs mirrored. + const path = turn === 0 ? route : [...route].reverse(); + const caller = path[0]!; + const callee = path[path.length - 1]!; + const total = field.pathLength(path); + const ease = p * p * (3 - 2 * p); + + field.drawRoute(ctx, palette, route, 0.5 * fade); + + // The caller wears the request colour, the callee the response colour, and + // both swap over at the turn. Nothing else about them changes. + field.drawEndpoint(ctx, caller, palette.request, 0, fade); + field.drawEndpoint( + ctx, + callee, + phase === "work" ? palette.response : palette.stroke, + phase === "work" ? Math.sin(p * Math.PI) : 0, + fade, + ); + + if (phase === "call") { + for (let k = 0; k < 4; k++) { + const d = total * ease - k * 11; + if (d < 0) continue; + field.dot(ctx, field.pointAt(path, d), palette.request, fade * (1 - k / 5) * 0.95, 1.6); + } + } else if (phase === "answer") { + const pt = field.pointAt(path, total * (1 - ease)); + field.dot(ctx, pt, palette.response, fade, 2); + const tail = field.pointAt(path, Math.min(total, total * (1 - ease) + 14)); + const m = field.clarity(pt.x, pt.y); + ctx.strokeStyle = palette.response; + ctx.globalAlpha = fade * 0.5 * m; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(pt.x, pt.y); + ctx.lineTo(tail.x, tail.y); + ctx.stroke(); + ctx.globalAlpha = 1; + } else if (phase === "settle") { + // A ring opening at the end that is about to take over, which is the only + // cue that the next thing to happen is a reversal rather than a repeat. + const n = field.nodes[callee]!; + const m = field.clarity(n.x, n.y) * fade; + if (m > 0.01) { + ctx.globalAlpha = m * (1 - p) * 0.7; + ctx.strokeStyle = palette.request; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.arc(n.x, n.y, 5 + p * 12, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + } + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/round-trip-field.ts b/packages/docs/src/components/canvas-hero/scenes/round-trip-field.ts new file mode 100644 index 00000000..b9c91168 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/round-trip-field.ts @@ -0,0 +1,148 @@ +/** + * Scene 1: a drifting node field crossed by single round trips. + * + * The field itself lives in `field.ts`, shared with the `/1a`../`/1e` variations. + * What is layered on top is the part that is ours. Proteus draws a field that only + * shimmers. Here the field is a network, and traffic crosses it as a *round trip*: + * a train of requests leaves a client node, walks the graph to a server node, the + * server flashes once, and a single response walks back along the identical path. + * Out and back, once. The train is the point: four or five calls ride together in + * the outbound leg, because in Cap'n Web a chain of dependent calls is still one + * trip, and the returning payload is single. + * + * Docs: `start/pipelining-tour.md` (The trick), `concepts/promises.md` + * (Awaiting is what costs a round trip). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; + +type Phase = "out" | "work" | "back" | "done"; + +interface Trip { + /** Node indices, client first, server last. Positions are read live. */ + path: number[]; + /** Each path node's `gen` when the trip spawned. See `FieldNode.gen`. */ + gen: number[]; + phase: Phase; + /** 0..1 within the current phase. */ + p: number; + /** How many calls ride the outbound leg. */ + calls: number; + /** Fades the whole trip in and out so it does not pop. */ + age: number; +} + +const OUT_SECS = 1.5; +const WORK_SECS = 0.28; +const BACK_SECS = 1.1; +const SPAWN_EVERY = 1.15; +const MAX_TRIPS = 3; + +export function roundTripField(): Scene { + const field = new Field(); + let trips: Trip[] = []; + let sinceSpawn = SPAWN_EVERY; + + const newTrip = (path: number[], phase: Phase = "out", p = 0, age = 0): Trip => ({ + path, + gen: field.gensOf(path), + phase, + p, + // Four or five pushes in the train: the tour's example is five calls. + calls: 4 + Math.floor(Math.random() * 2), + age, + }); + + const layout = (s: SceneSize) => { + field.layout(s); + // Anything routed through a node that no longer exists has to go. + trips = trips.filter((t) => t.path.every((i) => i < field.nodes.length)); + }; + + const advance = (dt: number) => { + field.advance(dt); + + sinceSpawn += dt; + if (sinceSpawn >= SPAWN_EVERY && trips.length < MAX_TRIPS) { + sinceSpawn = 0; + const path = field.findPath(); + if (path) trips.push(newTrip(path)); + } + + for (const trip of trips) { + trip.age += dt; + const dur = trip.phase === "out" ? OUT_SECS : trip.phase === "work" ? WORK_SECS : BACK_SECS; + trip.p += dt / dur; + if (trip.p >= 1) { + trip.p = 0; + trip.phase = trip.phase === "out" ? "work" : trip.phase === "work" ? "back" : "done"; + } + // A node under this trip wrapped to the far edge, so the route it was using + // no longer exists in any meaningful sense. Retire it rather than draw a + // dot jumping the width of the hero. + if (field.wrapped(trip.path, trip.gen)) trip.phase = "done"; + } + trips = trips.filter((t) => t.phase !== "done"); + }; + + return { + // A texture over the whole canvas, so the harness clips the copy out of it. + ambient: true, + layout, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + if (c.still && trips.length === 0) { + // A composed still: one trip caught mid-return, so both legs are legible. + const path = field.findPath(); + if (path) trips = [newTrip(path, "back", 0.45, 1)]; + } + if (!c.still) advance(c.dt); + field.draw(ctx, palette); + + for (const trip of trips) { + const total = field.pathLength(trip.path); + if (total <= 0) continue; + const fade = Math.min(1, trip.age / 0.35); + const client = trip.path[0]!; + const server = trip.path[trip.path.length - 1]!; + + field.drawRoute(ctx, palette, trip.path, 0.4 * fade); + field.drawEndpoint(ctx, client, palette.request); + + if (trip.phase === "out") { + // The train: `calls` pushes riding one trip, tight together, the leader + // brightest. Written back to back, they never wait for each other. + const head = total * trip.p; + for (let k = 0; k < trip.calls; k++) { + const d = head - k * 11; + if (d < 0) continue; + const a = fade * (1 - k / (trip.calls + 1)) * 0.95; + field.dot(ctx, field.pointAt(trip.path, d), palette.request, a, 1.6); + } + field.drawEndpoint(ctx, server, palette.stroke); + } else if (trip.phase === "work") { + // One flash at the far end: the whole chain evaluates there. + field.drawEndpoint(ctx, server, palette.response, Math.sin(trip.p * Math.PI)); + } else { + // One response comes back. Not a train: the chain resolved to a value. + const d = total * (1 - trip.p); + const pt = field.pointAt(trip.path, d); + field.dot(ctx, pt, palette.response, fade); + // A short comet tail, pointing the way it is going. + const tail = field.pointAt(trip.path, Math.min(total, d + 14)); + const m = field.clarity(pt.x, pt.y); + ctx.strokeStyle = palette.response; + ctx.globalAlpha = fade * 0.5 * m; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(pt.x, pt.y); + ctx.lineTo(tail.x, tail.y); + ctx.stroke(); + ctx.globalAlpha = 1; + field.drawEndpoint(ctx, server, palette.stroke); + } + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/session-loss.ts b/packages/docs/src/components/canvas-hero/scenes/session-loss.ts new file mode 100644 index 00000000..cb83915c --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/session-loss.ts @@ -0,0 +1,220 @@ +/** + * Scene 1c: a session dies, and everything it was holding dies with it. + * + * A hub holds a handful of live stubs, drawn as rings on the far end of each + * spoke, with traffic ticking along them. Then the session drops. Every ring + * breaks in the same frame -- not one after another, not the nearest first -- + * because they were never independent things; they were all names on one + * connection, and the connection is what went away. The field goes bare for a + * beat, which is the honest picture of what a caller is left holding. + * + * Then a new hub lights somewhere else and the spokes grow back one at a time. + * Recovery is deliberately slower than loss and visibly sequential, because + * reconnecting is work and losing is not. + * + * Docs: `concepts/disposal.md` (a broken session breaks every stub on it), + * `guides/sessions.md` (lifetime is the connection's lifetime). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; + +type Phase = "live" | "die" | "dark" | "grow"; + +const DUR: Record<Phase, number> = { live: 3.0, die: 1.05, dark: 0.9, grow: 2.2 }; +const NEXT: Record<Phase, Phase> = { live: "die", die: "dark", dark: "grow", grow: "live" }; + +interface Spoke { + /** Hub first, stub last. */ + path: number[]; + /** Staggers both the idle traffic and the regrowth order. */ + offset: number; +} + +export function sessionLoss(): Scene { + const field = new Field(); + let hub = -1; + let spokes: Spoke[] = []; + let gen: number[] = []; + let phase: Phase = "live"; + let p = 0; + let age = 0; + + const nodesInUse = () => [hub, ...spokes.flatMap((s) => s.path)]; + + /** A hub with three to five reachable stubs, or nothing if the field is thin. */ + const build = () => { + spokes = []; + hub = -1; + for (let attempt = 0; attempt < 10 && spokes.length < 3; attempt++) { + // Hub the session on the middle of a wide route, so its spokes reach out + // across the hero instead of bunching into one corner. + const seed = field.findSpanningPath(3, 4, 6); + const h = seed ? seed[Math.floor(seed.length / 2)]! : Math.floor(Math.random() * field.nodes.length); + const found: Spoke[] = []; + const taken = new Set<number>([h]); + for (let k = 0; k < 12 && found.length < 6; k++) { + const path = field.findPath(3, 4, h); + if (!path) continue; + const tip = path[path.length - 1]!; + if (taken.has(tip)) continue; + taken.add(tip); + found.push({ path, offset: found.length * 0.37 }); + } + if (found.length >= 3) { + hub = h; + spokes = found; + } + } + if (hub >= 0) { + gen = field.gensOf(nodesInUse()); + age = 0; + } + }; + + /** A ring with a gap in it: a stub that is no longer connected to anything. */ + const drawBroken = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + colour: string, + alpha: number, + grow: number, + ) => { + const m = field.clarity(x, y) * alpha; + if (m <= 0.01) return; + ctx.globalAlpha = m; + ctx.strokeStyle = colour; + ctx.lineWidth = 1.6; + const r = 6 + grow * 10; + // The gap widens as it goes, so the ring visibly comes apart rather than + // just fading, which would read as "finished" instead of "broken". + const gap = 0.25 + grow * 1.1; + for (const start of [gap, Math.PI + gap]) { + ctx.beginPath(); + ctx.arc(x, y, r, start, start + Math.PI - gap * 2); + ctx.stroke(); + } + ctx.globalAlpha = 1; + }; + + return { + ambient: true, + layout(s: SceneSize) { + field.layout(s); + if (hub >= 0 && nodesInUse().some((i) => i >= field.nodes.length)) hub = -1; + }, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + + if (!c.still) { + field.advance(c.dt); + age += c.dt; + p += c.dt / DUR[phase]; + if (p >= 1) { + p = 0; + phase = NEXT[phase]; + // The session that comes back is a different one, in a different place. + if (phase === "grow") hub = -1; + } + } else if (hub < 0) { + // The still is the instant of loss: rings coming apart, spokes retreating. + phase = "die"; + p = 0.5; + } + + if (hub < 0) { + build(); + // `build` runs during `dark`/`grow`, so do not let a fresh cast appear + // mid-collapse with its rings intact. + if (phase === "die") phase = "dark"; + } + // A wrap is retired in every phase, `die` included. Exempting the collapse + // to keep it from being interrupted meant a spoke whose tip wrapped mid-death + // was drawn from the hub to the far edge of the canvas: a 1179px segment, in + // the one phase the eye is meant to be following. Cutting the collapse short + // and going dark is the lesser fault, and it lands on a frame that is already + // supposed to be emptying out. + if (hub >= 0 && field.wrapped(nodesInUse(), gen)) hub = -1; + + // The whole field dims while the session is down, so the loss is felt in the + // background too and not only along the spokes. + const dim = phase === "dark" ? 0.55 + 0.45 * p : phase === "die" ? 1 - 0.45 * p : 1; + field.draw(ctx, palette, dim); + if (hub < 0) return; + + const fade = c.still ? 1 : Math.min(1, age / 0.4); + const alive = phase === "live" || phase === "grow"; + + for (let i = 0; i < spokes.length; i++) { + const spoke = spokes[i]!; + const tip = spoke.path[spoke.path.length - 1]!; + const n = field.nodes[tip]; + if (!n) continue; + const total = field.pathLength(spoke.path); + + // During regrowth each spoke arrives in its own time. Everywhere else the + // whole set shares one value, which is exactly the point at `die`. + const grown = + phase === "grow" + ? Math.max(0, Math.min(1, (p - i * 0.16) / 0.42)) + : phase === "live" + ? 1 + : phase === "die" + ? 1 - p + : 0; + if (grown <= 0.001) continue; + + // The spoke itself, drawn only as far as it has grown or retreated. + ctx.lineWidth = 1; + let travelled = 0; + for (let k = 1; k < spoke.path.length; k++) { + const a = field.nodes[spoke.path[k - 1]!]!; + const b = field.nodes[spoke.path[k]!]!; + const seg = Math.hypot(b.x - a.x, b.y - a.y); + const from = travelled / total; + const to = (travelled + seg) / total; + travelled += seg; + if (from >= grown) break; + const f = Math.min(1, (grown - from) / Math.max(1e-6, to - from)); + const m = field.lineClarity(a, b); + if (m <= 0.01) continue; + ctx.globalAlpha = 0.52 * fade * m * (phase === "die" ? 0.9 : 1); + ctx.strokeStyle = phase === "die" ? palette.fade : `rgb(${palette.strokeRgb})`; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f); + ctx.stroke(); + } + ctx.globalAlpha = 1; + + if (phase === "die") { + // Every ring, same frame, same amount. No stagger anywhere in here. + drawBroken(ctx, n.x, n.y, palette.fade, fade * (1 - p * 0.45), p); + } else if (grown >= 0.999) { + field.drawEndpoint(ctx, tip, palette.stroke, 0, fade); + if (alive && phase === "live") { + // Idle chatter, so the session reads as in use rather than merely drawn. + const t = (age * 0.55 + spoke.offset) % 1; + field.dot(ctx, field.pointAt(spoke.path, total * t), palette.request, fade * 0.8, 1.5); + } + } + } + + // The hub last, on top of its own spokes. + if (phase === "die") { + const n = field.nodes[hub]!; + const m = field.clarity(n.x, n.y) * fade; + if (m > 0.01) { + ctx.globalAlpha = m * (1 - p); + ctx.fillStyle = palette.fade; + ctx.fillRect(n.x - 3, n.y - 3, 6, 6); + ctx.globalAlpha = 1; + } + } else if (phase !== "dark") { + const settling = phase === "grow" ? Math.min(1, p / 0.2) : 1; + field.drawEndpoint(ctx, hub, palette.request, 0, fade * settling); + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/space.ts b/packages/docs/src/components/canvas-hero/scenes/space.ts new file mode 100644 index 00000000..a0da5fcf --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/space.ts @@ -0,0 +1,53 @@ +/** + * Where in the canvas a scene is allowed to draw. + * + * Four of the five scenes are diagrams made of monospace text, and they all want + * the same two things: a pair of readable columns, and a lane for the one message + * that has to cross between them. Measured at 1440px the hero leaves 413px a + * side *below the illustration* but only 213px beside it, and a full-width strip + * 128px tall above it. So the columns go low and the lane goes high, and this + * module is the single place that decides that, rather than four scenes each + * inventing their own fractions. + */ + +import type { KeepOut, Rect, SceneSize } from "../types"; + +export interface Space { + /** Readable column, left of the copy, below the illustration. */ + left: Rect; + /** The same on the right. */ + right: Rect; + /** Full-width clear strip above all content, for messages in flight. */ + band: Rect; + /** Horizontal extent of the content the lane crosses. */ + crossX0: number; + crossX1: number; + /** Centre of the canvas, where a lane's label goes. */ + midX: number; +} + +export function space(size: SceneSize, k: KeepOut): Space { + // Start below the illustration, which is the only wide box. + const y = k.widest.y + k.widest.height + 20; + const h = Math.max(0, size.height - y - 8); + const { left, right } = k.sideBands(y, h); + return { + left, + right, + band: k.bandTop, + crossX0: Math.max(0, k.box.x - 2), + crossX1: Math.min(size.width, k.box.x + k.box.width + 2), + midX: size.width / 2, + }; +} + +/** + * The type size at which `chars` monospace characters fit `width`. + * + * 0.6em is the advance width of a monospace glyph in every font this site ships, + * and these scenes draw real wire messages: a body of clipped JSON says less than + * no body at all, so the caller hides the scene rather than shrink past legible. + */ +export function fitFont(width: number, chars: number, max = 10.5): number { + return Math.min(max, (width - 8) / (chars * 0.6)); +} diff --git a/packages/docs/src/components/canvas-hero/scenes/stage.ts b/packages/docs/src/components/canvas-hero/scenes/stage.ts new file mode 100644 index 00000000..6f0a2db2 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/stage.ts @@ -0,0 +1,66 @@ +/** + * The lane stage the three pipelining scenes are drawn on. + * + * Those scenes argue about *time*, so they need one axis that runs uninterrupted + * from a near end to a far end. The only place in the hero with full canvas width + * and nothing in it is `keepOut.bandTop`, the strip above all the content, so that + * is where they go. Everything else on the canvas is a dimmed field behind them, + * which is body rather than content: if the band is thin the scene is still + * correct, just shorter. + * + * Nothing here picks a coordinate. The band comes from the harness's measurement + * of the real boxes, and the lanes are divided out of whatever it turns out to be. + */ +import type { KeepOut, SceneSize } from "../types"; + +export interface Stage { + /** The near end, where calls originate. */ + x0: number; + /** The far end, where they are executed. */ + x1: number; + /** Usable width between the two endpoint columns. */ + span: number; + /** Lane centres, top to bottom, one per requested lane. */ + lanes: number[]; + /** Vertical distance between adjacent lanes. */ + pitch: number; +} + +/** Room for the endpoint columns to sit in without touching the canvas edge. */ +const EDGE = 28; +/** Below this the band cannot hold legible lanes and the scene should say so. */ +export const MIN_PITCH = 13; + +export function stage(size: SceneSize, keepOut: KeepOut, laneCount: number): Stage { + const band = keepOut.bandTop; + // A band with no measured height yet (first frame, before layout) must not + // collapse every lane onto one line, so fall back to a sane slice of the canvas. + const height = band.height > 8 ? band.height : Math.min(120, size.height * 0.2); + const top = band.height > 8 ? band.y : 0; + const pitch = height / (laneCount + 1); + const lanes: number[] = []; + for (let i = 0; i < laneCount; i++) lanes.push(top + pitch * (i + 1)); + const x0 = EDGE; + const x1 = Math.max(x0 + 1, size.width - EDGE); + return { x0, x1, span: x1 - x0, lanes, pitch }; +} + +/** A vertical tick marking an endpoint column, which is as much as a lane needs. */ +export function endpost( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + half: number, + colour: string, + alpha: number, +): void { + if (alpha <= 0.01) return; + ctx.globalAlpha = alpha; + ctx.strokeStyle = colour; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(x, y - half); + ctx.lineTo(x, y + half); + ctx.stroke(); + ctx.globalAlpha = 1; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/streams.ts b/packages/docs/src/components/canvas-hero/scenes/streams.ts new file mode 100644 index 00000000..15230e4d --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/streams.ts @@ -0,0 +1,189 @@ +/** + * Scene 1e: several streams sharing one cable, each running at its own rate. + * + * Every other scene in this family draws traffic as discrete marks, because every + * other scene is about individual calls. This one is about flow, so it is drawn as + * flow: continuous dashed ribbons rather than dots, moving without beginning or + * end. That difference in motion is the fastest way to tell this hero from the + * others at a glance, before any detail resolves. + * + * Three ribbons run along a shared trunk and separate at the fork. On the trunk + * they are drawn as parallel offsets a couple of pixels apart, so the cable is + * visibly carrying all three at once rather than taking turns: one connection, + * many independent conversations. Their speeds differ and drift slowly, because + * they are independent -- a slow consumer on one stream does not slow the others. + * + * Docs: `concepts/streaming.md` (streams over a session), `guides/sessions.md` + * (one connection multiplexes everything). + */ +import type { Scene, SceneSize } from "../types"; +import { Field, type Point } from "./field"; + +interface Ribbon { + /** Trunk then branch, as one path. The trunk prefix is shared with the others. */ + path: number[]; + /** Perpendicular offset in pixels, so the shared trunk shows three cables. */ + lane: number; + /** Pixels per second along the path. */ + speed: number; + /** Distance travelled, which is just the dash offset. */ + flow: number; + /** Independent rate wobble, so no two ever lock into step. */ + wobble: number; +} + +/** Dash geometry, in pixels. Long marks read as flow; short ones read as dots. */ +const DASH = 9; +const GAP = 7; +const TRUNK_LEN = 3; + +export function streams(): Scene { + const field = new Field(); + let ribbons: Ribbon[] = []; + let trunk: number[] = []; + let gen: number[] = []; + let age = 0; + let life = 0; + + const allNodes = () => [...new Set(ribbons.flatMap((r) => r.path))]; + + /** A trunk, then up to three branches off its far end. */ + const build = () => { + ribbons = []; + trunk = []; + for (let attempt = 0; attempt < 10 && ribbons.length < 2; attempt++) { + const t = field.findPath(TRUNK_LEN, TRUNK_LEN); + if (!t) continue; + const fork = t[t.length - 1]!; + const found: Ribbon[] = []; + const taken = new Set<number>(t); + for (let k = 0; k < 8 && found.length < 3; k++) { + const branch = field.findPath(2, 2, fork); + if (!branch) continue; + const tip = branch[branch.length - 1]!; + if (taken.has(tip)) continue; + taken.add(tip); + found.push({ + // `branch` starts at the fork, which the trunk already ends on. + path: [...t, ...branch.slice(1)], + lane: 0, + speed: 34 + Math.random() * 26, + flow: Math.random() * 200, + wobble: Math.random() * Math.PI * 2, + }); + } + if (found.length >= 2) { + trunk = t; + ribbons = found; + } + } + if (ribbons.length > 0) { + // Centre the lanes on the cable: -2.5, 0, +2.5 for three. + const mid = (ribbons.length - 1) / 2; + ribbons.forEach((r, i) => { + r.lane = (i - mid) * 2.6; + }); + gen = field.gensOf(allNodes()); + age = 0; + life = 0; + } + }; + + /** + * The path offset sideways by `lane` pixels. + * + * Each vertex moves along the average of the normals of the segments meeting + * there, which keeps the three ribbons parallel through a corner instead of + * letting them cross on the inside of the bend. + */ + const offsetPoints = (path: number[], lane: number): Point[] => { + const pts = path.map((i) => field.nodes[i]!).map((n) => ({ x: n.x, y: n.y })); + if (lane === 0) return pts; + const normals: Point[] = []; + for (let k = 0; k < pts.length - 1; k++) { + const dx = pts[k + 1]!.x - pts[k]!.x; + const dy = pts[k + 1]!.y - pts[k]!.y; + const len = Math.hypot(dx, dy) || 1; + normals.push({ x: -dy / len, y: dx / len }); + } + return pts.map((pt, k) => { + const a = normals[Math.max(0, k - 1)]!; + const b = normals[Math.min(normals.length - 1, k)]!; + const nx = (a.x + b.x) / 2; + const ny = (a.y + b.y) / 2; + const len = Math.hypot(nx, ny) || 1; + return { x: pt.x + (nx / len) * lane, y: pt.y + (ny / len) * lane }; + }); + }; + + return { + ambient: true, + layout(s: SceneSize) { + field.layout(s); + if (ribbons.length > 0 && allNodes().some((i) => i >= field.nodes.length)) ribbons = []; + }, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + + if (!c.still) { + field.advance(c.dt); + age += c.dt; + life += c.dt; + for (const r of ribbons) { + // A slow breathing rate difference, never enough to stall a ribbon. + const rate = r.speed * (1 + Math.sin(life * 0.5 + r.wobble) * 0.28); + r.flow += rate * c.dt; + } + // Long-lived, because a stream that keeps restarting is not a stream. + if (life > 14) ribbons = []; + } + + if (ribbons.length === 0) build(); + if (ribbons.length > 0 && field.wrapped(allNodes(), gen)) ribbons = []; + + field.draw(ctx, palette); + if (ribbons.length === 0) return; + + const fade = c.still ? 1 : Math.min(1, age / 0.5); + // The cable itself, under the traffic. + field.drawRoute(ctx, palette, trunk, 0.34 * fade); + for (const r of ribbons) field.drawRoute(ctx, palette, r.path.slice(TRUNK_LEN - 1), 0.26 * fade); + + ctx.setLineDash([DASH, GAP]); + ctx.lineWidth = 1.8; + ctx.lineCap = "butt"; + for (let i = 0; i < ribbons.length; i++) { + const r = ribbons[i]!; + const pts = offsetPoints(r.path, r.lane); + // Alternating colours so the three are separable where they run parallel. + ctx.strokeStyle = i % 2 === 0 ? palette.request : palette.response; + let travelled = 0; + for (let k = 1; k < pts.length; k++) { + const a = pts[k - 1]!; + const b = pts[k]!; + const seg = Math.hypot(b.x - a.x, b.y - a.y); + const m = field.lineClarity(a, b); + if (m > 0.01) { + // Each segment is stroked on its own so it can carry its own dimming, + // so the dash phase has to be carried across the joins by hand. + ctx.lineDashOffset = -(r.flow + travelled); + ctx.globalAlpha = 0.8 * fade * m; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + travelled += seg; + } + } + ctx.setLineDash([]); + ctx.lineDashOffset = 0; + ctx.globalAlpha = 1; + + // The endpoints: one source, and a consumer on the end of every branch. + field.drawEndpoint(ctx, trunk[0]!, palette.request, 0, fade); + for (const r of ribbons) field.drawEndpoint(ctx, r.path[r.path.length - 1]!, palette.stroke, 0, fade); + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/substitute.ts b/packages/docs/src/components/canvas-hero/scenes/substitute.ts new file mode 100644 index 00000000..a3118ca8 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/substitute.ts @@ -0,0 +1,198 @@ +/** + * Scene 7: three calls ship with holes in them, and the holes are filled at the + * far end by each other's results. + * + * Three dependent calls leave together. The first carries a real argument, drawn + * solid. The second and third each carry an open ring: an argument their sender + * does not have and cannot supply, because it is the result of a call that has + * not been answered yet. They are sent anyway. + * + * At the far end the chain resolves downward. The first call runs, and its result + * drops into the second call's ring along a short vertical link -- the only + * movement in the scene that is not horizontal, because it is the only movement + * that is not on the wire. Then the second runs and fills the third. Then one + * value, the last one, makes the return trip. The two intermediate results never + * travel at all; they are born and consumed at the same end. + * + * That vertical hop is the whole idea. Pipelining is not the calls being sent + * quickly, it is the arguments being resolved where the data already is. + * + * Docs: `concepts/promises.md` (a promise used as an argument), + * `reference/protocol.md` (`["pipeline", importId, path, args]`). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; +import { endpost, MIN_PITCH, stage, type Stage } from "./stage"; + +/** Calls in the chain. Each one after the first depends on the one above it. */ +const LINKS = 3; + +const LEG = 1.7; +/** + * Stagger between departures. + * + * Small, because all three ride one trip -- but not zero: with the calls exactly + * superimposed the three rings crossing the hero read as one, and the fact that + * there are three separate messages in flight is half the picture. + */ +const STEP = 0.28; +/** Per link at the far end: the flash, then the handoff into the next ring. */ +const RUN = 0.2; +const HAND = 0.34; +const CHAIN = LINKS * (RUN + HAND); +const OUT_END = (LINKS - 1) * STEP + LEG; +const BACK_END = OUT_END + CHAIN + LEG; +const CYCLE = BACK_END + 0.9; + +export function substitute(): Scene { + const field = new Field(34); + let st: Stage | null = null; + let ok = false; + let clock = 0; + + /** + * How full link `i`'s argument ring is, 0 to 1. + * + * The first link's argument is concrete from the start. Every other link's is + * empty until the link above it has run and handed its result down. + */ + const filled = (i: number, t: number): number => { + if (i === 0) return 1; + const handStart = OUT_END + (i - 1) * (RUN + HAND) + RUN; + return Math.max(0, Math.min(1, (t - handStart) / HAND)); + }; + + /** The travelling call: a solid body and an argument ring beside it. */ + const drawCall = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + request: string, + response: string, + fill: number, + alpha: number, + ) => { + ctx.globalAlpha = alpha; + ctx.fillStyle = request; + ctx.fillRect(x - 6, y - 2, 4, 4); + ctx.strokeStyle = fill > 0 ? response : request; + ctx.lineWidth = 1.3; + ctx.beginPath(); + ctx.arc(x + 1.5, y, 3.6, 0, Math.PI * 2); + ctx.stroke(); + if (fill > 0) { + ctx.globalAlpha = alpha * fill; + ctx.fillStyle = response; + ctx.beginPath(); + ctx.arc(x + 1.5, y, 2.4 * fill, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + }; + + return { + ambient: true, + layout(size: SceneSize, keepOut) { + field.layout(size); + st = stage(size, keepOut, LINKS); + ok = st.pitch >= MIN_PITCH && st.span > 240; + }, + fits: () => ok, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + if (!st) return; + const s = st; + + if (c.still) { + // The still is caught on the second handoff: one ring already filled, one + // filling, and the third still open. The mechanism in one frame. + clock = OUT_END + RUN + HAND * 0.6 + (RUN + HAND); + } else { + field.advance(c.dt); + clock = (clock + c.dt) % CYCLE; + } + const t = clock; + + field.draw(ctx, palette, 0.45); + + const half = Math.min(7, s.pitch * 0.32); + const strokeCss = `rgb(${palette.strokeRgb})`; + + for (let i = 0; i < LINKS; i++) { + const y = s.lanes[i]!; + ctx.globalAlpha = palette.light ? 0.34 : 0.28; + ctx.strokeStyle = strokeCss; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(s.x0, y); + ctx.lineTo(s.x1, y); + ctx.stroke(); + ctx.globalAlpha = 1; + endpost(ctx, s.x0, y, half, strokeCss, 0.5); + endpost(ctx, s.x1, y, half, strokeCss, 0.5); + } + + // The vertical handoffs at the far post. Drawn before the calls so the + // ring sits on top of the link that is filling it. + for (let i = 0; i < LINKS - 1; i++) { + const from = s.lanes[i]!; + const to = s.lanes[i + 1]!; + const handStart = OUT_END + i * (RUN + HAND) + RUN; + const g = Math.max(0, Math.min(1, (t - handStart) / HAND)); + if (g <= 0) continue; + ctx.globalAlpha = 0.75 * (t > handStart + HAND ? 0.4 : 1); + ctx.strokeStyle = palette.response; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(s.x1, from); + ctx.lineTo(s.x1, from + (to - from) * g); + ctx.stroke(); + ctx.globalAlpha = 1; + } + + for (let i = 0; i < LINKS; i++) { + const y = s.lanes[i]!; + const depart = i * STEP; + const arrive = depart + LEG; + const runStart = OUT_END + i * (RUN + HAND); + const fill = filled(i, t); + + if (t < depart) continue; + + if (t < arrive) { + // Outbound, holes and all. + const ease = (t - depart) / LEG; + drawCall(ctx, s.x0 + s.span * ease, y, palette.request, palette.response, fill, 0.95); + } else if (t < runStart) { + // Landed, waiting for its argument to exist. Nothing is being awaited on + // the caller's side; the message is simply parked at the far end. + drawCall(ctx, s.x1 - 8, y, palette.request, palette.response, fill, 0.95); + } else if (t < runStart + RUN) { + drawCall(ctx, s.x1 - 8, y, palette.request, palette.response, 1, 0.95); + endpost(ctx, s.x1, y, half + 2, palette.response, Math.sin(((t - runStart) / RUN) * Math.PI)); + } else if (i < LINKS - 1) { + // Done, and its result is going sideways rather than home. + drawCall(ctx, s.x1 - 8, y, palette.request, palette.response, 1, 0.4); + } + } + + // One value returns, on the last lane only. + const backStart = OUT_END + CHAIN; + const yLast = s.lanes[LINKS - 1]!; + if (t >= backStart && t < backStart + LEG) { + const ease = (t - backStart) / LEG; + ctx.globalAlpha = 0.95; + ctx.fillStyle = palette.response; + const x = s.x1 - s.span * ease; + ctx.fillRect(x - 2.5, yLast - 2.5, 5, 5); + ctx.globalAlpha = 1; + } else if (t >= backStart + LEG) { + ctx.globalAlpha = 0.85; + ctx.fillStyle = palette.response; + ctx.fillRect(s.x0 - 2.5, yLast - 2.5, 5, 5); + ctx.globalAlpha = 1; + } + }, + }; +} diff --git a/packages/docs/src/components/canvas-hero/scenes/unpulled.ts b/packages/docs/src/components/canvas-hero/scenes/unpulled.ts new file mode 100644 index 00000000..d6790ce8 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/scenes/unpulled.ts @@ -0,0 +1,126 @@ +/** + * Scene 8: five calls go out, and two answers come back. + * + * Every call in the batch is real. Each one crosses, lands, and runs at the far + * end -- all five flash. But only the two the caller actually awaited are pulled, + * so only two answers make the return trip. The other three end where they ran: + * the tooth stops at the far post and dims out. + * + * The gap in the returning comb is the point. Intermediate results in a pipelined + * chain are named, used, and never shipped; the wire only carries what somebody + * is waiting on. A protocol that returned all five would be paying for four values + * that get dropped on arrival. + * + * Docs: `reference/protocol.md` (`["pull", importId]` is sent only for awaited + * values), `concepts/promises.md` (awaiting is what costs a round trip). + */ +import type { Scene, SceneSize } from "../types"; +import { Field } from "./field"; +import { endpost, MIN_PITCH, stage, type Stage } from "./stage"; + +/** Calls in the batch. */ +const TEETH = 5; +/** Which of them were awaited, and therefore pulled. */ +const PULLED = new Set([1, 4]); + +const LEG = 0.72; +const WORK = 0.22; +/** Stagger between successive calls in the batch. They ride one trip, not five. */ +const STEP = 0.075; +const HOLD = 1.5; +const CYCLE = (TEETH - 1) * STEP + LEG + WORK + LEG + HOLD; + +export function unpulled(): Scene { + const field = new Field(34); + let st: Stage | null = null; + let ok = false; + let clock = 0; + + return { + ambient: true, + layout(size: SceneSize, keepOut) { + field.layout(size); + st = stage(size, keepOut, TEETH); + ok = st.pitch >= MIN_PITCH && st.span > 240; + }, + fits: () => ok, + draw(c) { + const { ctx, palette } = c; + field.clarity = c.keepOut.clarity; + if (!st) return; + const s = st; + + if (c.still) { + // The still is caught on the return leg, where the comb has its gaps. + clock = (TEETH - 1) * STEP + LEG + WORK + LEG * 0.55; + } else { + field.advance(c.dt); + clock = (clock + c.dt) % CYCLE; + } + + field.draw(ctx, palette, 0.45); + + const half = Math.min(6, s.pitch * 0.3); + const strokeCss = `rgb(${palette.strokeRgb})`; + + for (let i = 0; i < TEETH; i++) { + const y = s.lanes[i]!; + const pulled = PULLED.has(i); + const t = clock - i * STEP; + + ctx.globalAlpha = palette.light ? 0.3 : 0.24; + ctx.strokeStyle = strokeCss; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(s.x0, y); + ctx.lineTo(s.x1, y); + ctx.stroke(); + ctx.globalAlpha = 1; + endpost(ctx, s.x0, y, half, strokeCss, 0.5); + endpost(ctx, s.x1, y, half, strokeCss, 0.5); + + if (t < 0) continue; + + if (t < LEG) { + // Outbound. Every tooth, no exceptions: all five calls are sent. + ctx.globalAlpha = 0.95; + ctx.fillStyle = palette.request; + const x = s.x0 + s.span * (t / LEG); + ctx.fillRect(x - 1.8, y - 1.8, 3.6, 3.6); + ctx.globalAlpha = 1; + } else if (t < LEG + WORK) { + // Every tooth runs at the far end, pulled or not. + endpost(ctx, s.x1, y, half + 2, palette.response, Math.sin(((t - LEG) / WORK) * Math.PI)); + } else if (t < LEG + WORK + LEG) { + const local = (t - LEG - WORK) / LEG; + if (pulled) { + ctx.globalAlpha = 0.95; + ctx.fillStyle = palette.response; + const x = s.x1 - s.span * local; + ctx.fillRect(x - 2, y - 2, 4, 4); + ctx.globalAlpha = 1; + } else { + // The result exists and stays where it was made. A short dissolve at + // the far post, then nothing: this tooth's return trip never happens. + const out = Math.max(0, 1 - local / 0.28); + if (out > 0) { + ctx.globalAlpha = 0.5 * out; + ctx.strokeStyle = palette.fade; + ctx.lineWidth = 1.1; + ctx.beginPath(); + ctx.arc(s.x1, y, 3 + (1 - out) * 5, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + } + } + } else if (pulled) { + // Landed. Only the two awaited values are ever held at the near end. + ctx.globalAlpha = 0.8; + ctx.fillStyle = palette.response; + ctx.fillRect(s.x0 - 2, y - 2, 4, 4); + ctx.globalAlpha = 1; + } + } + }, + }; +} 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..ea48e8f4 --- /dev/null +++ b/packages/docs/src/components/canvas-hero/types.ts @@ -0,0 +1,147 @@ +/** + * 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 `<html>`. + * + * There is deliberately no background colour here. A scene must never fill one: + * the harness clears to transparent so the canvas composites over + * `.cw-hero-field`'s radial pool and under `.cw-hero-veil`, and an opaque fill + * would erase the stage the whole backdrop is composed against. + */ + 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; + /** 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; +} + +export interface SceneSize { + /** CSS pixels. The context is already scaled, so scenes work in these. */ + width: number; + height: number; +} + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Where the hero's own content sits, in canvas-local CSS pixels, and the clear + * space around it. + * + * A backdrop that draws under the headline and the example windows is not a + * backdrop, it is a collision. The harness measures the real boxes rather than + * guessing fractions of the viewport, because the content is a centred column + * whose width is capped in `rem` and therefore moves against the viewport at + * every breakpoint and every root font size. + * + * The clear space is deliberately *not* a single pair of gutters beside the + * union of the boxes. Measured at 1440px, the union is 976px wide and leaves + * 213px a side, but that width belongs only to the illustration in the top + * third: below it the copy narrows to 576px and the real clear column is 429px. + * Taking the union would throw away half the usable canvas, and at 1024px it + * would report 8px and every scene would hide. So scenes ask for the clear + * columns beside a specific horizontal band, via `sideBands`. + */ +export interface KeepOut { + /** Every measured content box. */ + boxes: Rect[]; + /** The subset that is bare text on the page background, with nothing behind it. */ + bareText: Rect[]; + /** Union of them all. Useful for "is this point over the copy at all". */ + box: Rect; + /** + * The widest single box, which is the illustration. Scenes that want the roomy + * lower columns start their band just below this. + */ + widest: Rect; + /** Full-canvas-width clear strip above all content. Measured at 96 to 128px tall. */ + bandTop: Rect; + /** + * The widest clear column each side of the content that intersects + * `y .. y + height`. A zero-width rect means there is no room on that side. + */ + sideBands(y: number, height: number): { left: Rect; right: Rect }; + /** True when the rect touches any content box. */ + hits(r: Rect): boolean; + /** + * How freely a scene may draw at a point: 1 in the open, 0 under bare text, + * ramped over a feather in between. + * + * This is for the scenes that are an ambient texture rather than a diagram. + * They are *supposed* to cover the whole canvas, so they cannot simply lay + * themselves out in a clear column, and cutting a hard rectangle out of a star + * field reads as a missing rectangle, which is worse than the collision. Note + * that only bare text counts: ink behind the code windows is invisible, because + * the windows are an opaque panel. + */ + clarity(x: number, y: number): number; +} + +export interface SceneContext { + ctx: CanvasRenderingContext2D; + size: SceneSize; + keepOut: KeepOut; + 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 { + /** + * True for a scene that is a texture over the whole canvas rather than a + * diagram placed in the clear space. + * + * The harness clips bare text out of an ambient scene, which is a hard + * guarantee that `clarity`'s feather cannot give on its own: a 4px node square + * whose centre is 3px outside the headline still puts a column of pixels + * inside it. Diagram scenes are deliberately *not* clipped, so that a label + * drifting onto the copy shows up as a collision to be fixed rather than being + * silently truncated. + */ + ambient?: boolean; + /** Called once per size change, before the next draw. Scenes lay out here. */ + layout?(size: SceneSize, keepOut: KeepOut): void; + /** + * False when the last `layout` left the scene no room to draw legibly. + * + * The four diagram scenes are monospace text at a size derived from the clear + * column, and below about 1280px there is no width at which 68 characters of + * JSON and the hero copy both fit. Rather than clip, they say so, and the + * harness substitutes the ambient field: a hero should never have a dead + * backdrop, least of all on the commonest class of viewport. + */ + fits?(): boolean; + draw(c: SceneContext): void; +} + +export type SceneFactory = () => Scene; diff --git a/packages/docs/src/components/light-tunnel.client.ts b/packages/docs/src/components/light-tunnel.client.ts new file mode 100644 index 00000000..ca6fc4cb --- /dev/null +++ b/packages/docs/src/components/light-tunnel.client.ts @@ -0,0 +1,491 @@ +import { Renderer, Program, Mesh, Triangle } from "ogl"; + +type FlowDirection = "inward" | "outward"; + +export interface LightTunnelOptions { + cableColor?: string; + pulseColor?: string; + tunnelColor?: string; + tunnelOpacity?: number; + speed?: number; + flowDirection?: FlowDirection; + pulseSpeed?: number; + pulseLength?: number; + pulseBlend?: number; + pulseWidth?: number; + cableCount?: number; + thickness?: number; + rimWidth?: number; + waviness?: number; + sway?: number; + spiral?: number; + spinSpeed?: number; + size?: number; + centerX?: number; + centerY?: number; + glow?: number; + fadeNear?: number; + fadeFar?: number; + brightness?: number; + colorVariance?: boolean; + grain?: boolean; + grainIntensity?: number; + opacity?: number; + mouseInteraction?: boolean; + mouseStrength?: number; + /* + * The light-mode palette. Same tunnel, drawn as ink on paper rather than + * light in a room (see `uInk` in the shader). Left undefined, the tunnel is + * emissive in both schemes, which on a light page means invisible. + */ + cableColorLight?: string; + pulseColorLight?: string; + tunnelColorLight?: string; + glowLight?: number; + brightnessLight?: number; + grainIntensityLight?: number; +} + +const hexToRgb = (hex: string): [number, number, number] => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) return [1, 1, 1]; + return [ + parseInt(result[1], 16) / 255, + parseInt(result[2], 16) / 255, + parseInt(result[3], 16) / 255, + ]; +}; + +const vertex = `#version 300 es +in vec2 position; +void main() { + gl_Position = vec4(position, 0.0, 1.0); +} +`; + +const fragment = `#version 300 es +precision highp float; +uniform vec2 iResolution; +uniform float iTime; +uniform float uSpeed; +uniform float uFlowDir; +uniform float uPulseSpeed; +uniform float uPulseLength; +uniform float uPulseBlend; +uniform float uPulseWidth; +uniform float uCableCount; +uniform float uThickness; +uniform float uRimWidth; +uniform float uWaviness; +uniform float uSway; +uniform float uSpiral; +uniform float uSpinSpeed; +uniform float uSize; +uniform vec2 uCenter; +uniform vec2 uMouseOffset; +uniform float uGlow; +uniform float uFadeNear; +uniform float uFadeFar; +uniform float uBrightness; +uniform float uColorVariance; +uniform float uOpacity; +uniform vec3 uCableColor; +uniform vec3 uPulseColor; +uniform vec3 uTunnelColor; +uniform float uTunnelOpacity; +uniform float uGrain; +uniform float uGrainIntensity; +uniform float uInk; +out vec4 fragColor; + +void mainImage(out vec4 o, in vec2 fragCoord) { + float size = uSize * 2.0; + float flowDir = uFlowDir; + float speedBase = uSpeed * 4.0 * flowDir; + float waviness = uWaviness * 0.15; + float rotationOsc = uSway * 0.5; + float baseThick = uThickness * 0.35 + 0.05; + float borderWeight = uRimWidth * 0.15 + 0.01; + float cablesCount = floor(uCableCount); + + vec2 res = iResolution.xy; + vec2 uv = (fragCoord - 0.5 * res) / min(res.y, res.x); + uv -= (uCenter + uMouseOffset); + uv /= (size + 0.0001); + + float r = length(uv); + float angle = atan(uv.y, uv.x); + float depth = -log(r + 0.0001); + + float swing = sin(iTime * (uSpeed * 0.5 + 0.1)) * rotationOsc; + float waveOffset = sin(depth * 1.2 + iTime * speedBase * 0.25) * waviness; + + float angleNormalized = (angle / 6.2831853) + 0.5; + // Spiral: twist the cables' angle as a function of depth so the straight + // radial tunnel winds into a spiral, plus a slow continuous rotation so the + // whole field turns like a vortex rather than swaying back and forth. + float twist = depth * uSpiral; + float spin = iTime * uSpinSpeed; + float finalAngle = fract(angleNormalized + waveOffset + swing + twist + spin); + + float cableID = floor(finalAngle * cablesCount); + float gvX = (fract(finalAngle * cablesCount) - 0.5); + + float rand = fract(sin(cableID * 12.9898) * 43758.5453); + float randSpeed = (0.4 + rand * 0.6) * speedBase * uPulseSpeed; + float cableThick = baseThick * (0.6 + rand * 0.4); + + vec3 cableCol = uCableColor; + cableCol *= 1.0 + (rand - 0.5) * 0.4 * uColorVariance; + cableCol = mix(cableCol, uPulseColor, rand * 0.25 * uColorVariance); + + float scroll = depth + (iTime * randSpeed); + float pulseFact = fract(scroll); + + float distToCore = abs(gvX); + float wireMask = smoothstep(cableThick, cableThick - 0.05, distToCore); + float rimGlow = smoothstep(borderWeight, 0.0, abs(distToCore - cableThick)); + + float pulseThick = cableThick * uPulseWidth; + float pulseMask = smoothstep(pulseThick, pulseThick - 0.05 * uPulseWidth, distToCore); + + float pulseDist = abs(pulseFact - 0.5); + float pulseTotal = uPulseLength; + float pulseCore = pulseTotal * (1.0 - uPulseBlend); + float pulseLo = min(pulseCore, pulseTotal - max(fwidth(scroll), 1e-4)); + float dataPulse = 1.0 - smoothstep(pulseLo, pulseTotal, pulseDist); + + float aBody = wireMask * uTunnelOpacity; + float aRim = rimGlow; + float aPulse = clamp(dataPulse * pulseMask, 0.0, 1.0); + + float distFade = smoothstep(0.0, uFadeNear, r) * smoothstep(uFadeFar, uFadeFar - 0.9, r); + // uGlow scales emitted light on a lit stage, where it multiplies the rim + // colour. Ink has no light to scale, so there it scales the weight of the + // stroke instead -- the one knob means "how much cable do you see" in both. + float rimWeight = uInk > 0.5 ? clamp(aRim * uGlow, 0.0, 1.0) : aRim; + float inten = clamp(aBody + rimWeight + aPulse, 0.0, 1.0); + /* + * Emission and coverage are not perceptually interchangeable, and the gap is + * widest exactly where the drawing is supposed to disappear. A cable at 10% + * alpha over a near-black stage is nothing; the same 10% of ink on paper is + * still a line. So the distance fade, which retires the far field on the dark + * stage, does not retire it on the light one -- measured, that left the light + * hero with half again as much visible stroke as the dark one, spread evenly, + * which reads as dust or moire rather than as cables. + * + * Ink therefore gets a contrast curve applied *after* the fade: below the + * floor is paper, above the ceiling is full ink. That gives the far field a + * real horizon instead of an asymptote, and leaves the drawing to the arcs + * near the vortex, which is what the dark stage does on its own. + */ + inten *= distFade; + if (uInk > 0.5) inten = smoothstep(0.15, 0.42, inten); + + /* + * Two ways to draw the same tunnel. + * + * Emissive (uInk == 0) is the original: colour is summed light, so a bright + * pulse is literally three times the pulse colour, and it composites onto a + * dark stage the way light behaves. On a light stage it disappears -- adding + * light to paper is a no-op, and that is why "just use the dark hero on the + * light page" is the usual answer to this problem. + * + * Ink (uInk == 1) keeps every bit of the geometry above and changes what the + * intensity *means*: coverage rather than emission. The colour never + * brightens, it saturates, so a pulse is the deepest ink on the cable instead + * of the brightest light, and the same tunnel reads as drawn rather than lit. + * Alpha is unchanged either way, so the fades, the grain and the veil all + * behave identically. + */ + vec3 finalCol; + if (uInk > 0.5) { + float pulseAmt = clamp(dataPulse * pulseMask, 0.0, 1.0); + finalCol = mix(cableCol, uPulseColor, pulseAmt); + finalCol = mix(finalCol, uTunnelColor, aBody * uTunnelOpacity); + // uBrightness < 1 lightens ink toward the stage instead of dimming light. + finalCol = mix(vec3(1.0), finalCol, clamp(uBrightness, 0.0, 1.0)); + } else { + vec3 fiberCol = uTunnelColor * aBody + + cableCol * aRim * 1.3 * uGlow + + uPulseColor * dataPulse * 3.0 * pulseMask; + finalCol = fiberCol * uBrightness; + } + + float alpha = clamp(inten, 0.0, 1.0) * uOpacity; + vec3 outRgb = finalCol * alpha; + + if (uGrain > 0.5) { + float gv = (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233)) + iTime) * 43758.5453) - 0.5) * uGrainIntensity; + // Grain is a brightness wobble on a lit stage and a density wobble on an + // inked one; on paper, adding to the premultiplied colour would wash the + // stroke out, so only the coverage moves. + if (uInk > 0.5) { + alpha = clamp(alpha + gv * alpha, 0.0, 1.0); + outRgb = finalCol * alpha; + } else { + outRgb = clamp(outRgb + gv, 0.0, 1.0); + alpha = clamp(alpha + gv, 0.0, 1.0); + } + } + + o = vec4(outRgb, alpha); +} + +void main() { + vec4 o = vec4(0.0); + mainImage(o, gl_FragCoord.xy); + fragColor = o; +} +`; + +function readOptions(el: HTMLElement): Required<LightTunnelOptions> { + const d = el.dataset; + const attr = (name: keyof DOMStringMap, fallback: string) => d[name] ?? fallback; + const num = (name: keyof DOMStringMap, fallback: number) => { + const value = Number(d[name]); + return Number.isFinite(value) ? value : fallback; + }; + const bool = (name: keyof DOMStringMap, fallback: boolean) => { + const value = d[name]; + if (value === undefined) return fallback; + return value !== "false"; + }; + const flow = attr("flowDirection", "outward"); + return { + cableColor: attr("cableColor", "#112039"), + pulseColor: attr("pulseColor", "#3B82F6"), + tunnelColor: attr("tunnelColor", "#5227FF"), + tunnelOpacity: num("tunnelOpacity", 0), + speed: num("speed", 0.1), + flowDirection: flow === "outward" ? "outward" : "inward", + pulseSpeed: num("pulseSpeed", 2), + pulseLength: num("pulseLength", 0.2), + pulseBlend: num("pulseBlend", 0.8), + pulseWidth: num("pulseWidth", 0.12), + cableCount: num("cableCount", 44), + thickness: num("thickness", 0.5), + rimWidth: num("rimWidth", 0), + waviness: num("waviness", 0.6), + sway: num("sway", 0.2), + spiral: num("spiral", 0.5), + spinSpeed: num("spinSpeed", 0.02), + size: num("size", 1.25), + centerX: num("centerX", 0), + centerY: num("centerY", 0), + glow: num("glow", 1.6), + fadeNear: num("fadeNear", 0.45), + fadeFar: num("fadeFar", 1.8), + brightness: num("brightness", 1), + colorVariance: bool("colorVariance", true), + grain: bool("grain", true), + grainIntensity: num("grainIntensity", 0.05), + opacity: num("opacity", 1), + mouseInteraction: bool("mouseInteraction", false), + mouseStrength: num("mouseStrength", 0.12), + cableColorLight: attr("cableColorLight", ""), + pulseColorLight: attr("pulseColorLight", ""), + tunnelColorLight: attr("tunnelColorLight", ""), + glowLight: num("glowLight", num("glow", 1.6)), + brightnessLight: num("brightnessLight", num("brightness", 1)), + grainIntensityLight: num("grainIntensityLight", num("grainIntensity", 0.05)), + }; +} + +export function mountLightTunnel(container: HTMLElement) { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return () => {}; + + const o = readOptions(container); + const renderer = new Renderer({ + webgl: 2, + alpha: true, + premultipliedAlpha: true, + antialias: false, + dpr: Math.min(window.devicePixelRatio || 1, 2), + }); + + const gl = renderer.gl; + gl.clearColor(0, 0, 0, 0); + const canvas = gl.canvas as HTMLCanvasElement; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.display = "block"; + canvas.setAttribute("aria-hidden", "true"); + container.appendChild(canvas); + + const geometry = new Triangle(gl); + const program = new Program(gl, { + vertex, + fragment, + uniforms: { + iTime: { value: 0 }, + iResolution: { value: new Float32Array([1, 1]) }, + uSpeed: { value: o.speed }, + uFlowDir: { value: o.flowDirection === "outward" ? -1.0 : 1.0 }, + uPulseSpeed: { value: o.pulseSpeed }, + uPulseLength: { value: o.pulseLength }, + uPulseBlend: { value: o.pulseBlend }, + uPulseWidth: { value: o.pulseWidth }, + uCableCount: { value: o.cableCount }, + uThickness: { value: o.thickness }, + uRimWidth: { value: o.rimWidth }, + uWaviness: { value: o.waviness }, + uSway: { value: o.sway }, + uSpiral: { value: o.spiral }, + uSpinSpeed: { value: o.spinSpeed }, + uSize: { value: o.size }, + uCenter: { value: new Float32Array([o.centerX, o.centerY]) }, + uMouseOffset: { value: new Float32Array([0, 0]) }, + uGlow: { value: o.glow }, + uFadeNear: { value: o.fadeNear }, + uFadeFar: { value: o.fadeFar }, + uBrightness: { value: o.brightness }, + uColorVariance: { value: o.colorVariance ? 1.0 : 0.0 }, + uOpacity: { value: o.opacity }, + uCableColor: { value: new Float32Array(hexToRgb(o.cableColor)) }, + uPulseColor: { value: new Float32Array(hexToRgb(o.pulseColor)) }, + uTunnelColor: { value: new Float32Array(hexToRgb(o.tunnelColor)) }, + uTunnelOpacity: { value: o.tunnelOpacity }, + uGrain: { value: o.grain ? 1.0 : 0.0 }, + uGrainIntensity: { value: o.grainIntensity }, + uInk: { value: 0 }, + }, + }); + + const mesh = new Mesh(gl, { geometry, program }); + + /* + * Which palette is live follows the page's scheme, and the page's scheme can + * change under us: the toggle in the masthead rewrites `data-theme` on + * <html> without a navigation. `BaseLayout` publishes that attribute for the + * playground iframes; reusing it here means the tunnel and the rest of the + * page can never disagree about which mode they are in. + */ + const hasLightPalette = o.cableColorLight !== "" && o.pulseColorLight !== ""; + const setVec3 = (name: string, hex: string) => { + const v = program.uniforms[name].value as Float32Array; + const [r, g, b] = hexToRgb(hex); + v[0] = r; + v[1] = g; + v[2] = b; + }; + + const applyScheme = () => { + const light = + hasLightPalette && document.documentElement.dataset.theme === "light"; + program.uniforms.uInk.value = light ? 1 : 0; + setVec3("uCableColor", light ? o.cableColorLight : o.cableColor); + setVec3("uPulseColor", light ? o.pulseColorLight : o.pulseColor); + setVec3( + "uTunnelColor", + light && o.tunnelColorLight !== "" ? o.tunnelColorLight : o.tunnelColor, + ); + program.uniforms.uGlow.value = light ? o.glowLight : o.glow; + program.uniforms.uBrightness.value = light ? o.brightnessLight : o.brightness; + program.uniforms.uGrainIntensity.value = light + ? o.grainIntensityLight + : o.grainIntensity; + }; + applyScheme(); + + // Repaint on a scheme change even while the loop is parked, or a tunnel that + // is off-screen (or in a hidden tab) keeps the old palette until it is + // scrolled back into view. + const schemeObserver = new MutationObserver(() => { + applyScheme(); + renderer.render({ scene: mesh }); + }); + schemeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + const setSize = () => { + const rect = container.getBoundingClientRect(); + const w = Math.max(1, Math.floor(rect.width)); + const h = Math.max(1, Math.floor(rect.height)); + renderer.setSize(w, h); + const res = program.uniforms.iResolution.value as Float32Array; + res[0] = gl.drawingBufferWidth; + res[1] = gl.drawingBufferHeight; + renderer.render({ scene: mesh }); + }; + + const ro = new ResizeObserver(setSize); + ro.observe(container); + setSize(); + + const currentMouse = [0.5, 0.5]; + const targetMouse = [0.5, 0.5]; + const onPointerMove = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + targetMouse[0] = (e.clientX - rect.left) / rect.width; + targetMouse[1] = 1.0 - (e.clientY - rect.top) / rect.height; + }; + const onPointerLeave = () => { + targetMouse[0] = 0.5; + targetMouse[1] = 0.5; + }; + if (o.mouseInteraction) { + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerleave", onPointerLeave); + } + + let raf = 0; + let isVisible = true; + let isPageVisible = !document.hidden; + const t0 = performance.now(); + + const loop = (t: number) => { + program.uniforms.iTime.value = (t - t0) * 0.001; + const tx = o.mouseInteraction ? targetMouse[0] : 0.5; + const ty = o.mouseInteraction ? targetMouse[1] : 0.5; + currentMouse[0] += 0.05 * (tx - currentMouse[0]); + currentMouse[1] += 0.05 * (ty - currentMouse[1]); + const off = program.uniforms.uMouseOffset.value as Float32Array; + off[0] = (currentMouse[0] - 0.5) * o.mouseStrength; + off[1] = (currentMouse[1] - 0.5) * o.mouseStrength; + renderer.render({ scene: mesh }); + raf = requestAnimationFrame(loop); + }; + + const tryStart = () => { + if (isVisible && isPageVisible && raf === 0) raf = requestAnimationFrame(loop); + }; + const tryStop = () => { + if (raf !== 0) { + cancelAnimationFrame(raf); + raf = 0; + } + }; + + const io = new IntersectionObserver( + ([entry]) => { + isVisible = entry.isIntersecting; + isVisible ? tryStart() : tryStop(); + }, + { threshold: 0 }, + ); + io.observe(container); + + const onVisibility = () => { + isPageVisible = !document.hidden; + isPageVisible ? tryStart() : tryStop(); + }; + document.addEventListener("visibilitychange", onVisibility); + tryStart(); + + return () => { + tryStop(); + ro.disconnect(); + io.disconnect(); + schemeObserver.disconnect(); + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerleave", onPointerLeave); + try { + container.removeChild(canvas); + } catch {} + gl.getExtension("WEBGL_lose_context")?.loseContext(); + }; +} 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 <Aside> uses `type`. + type?: "note" | "tip" | "caution" | "danger"; + title?: string; +} + +const { type = "note", title, class: className, ...attrs } = Astro.props; + +const config: Record<string, { label: string; color: string; tint: string }> = { + note: { label: "Note", color: "var(--nb-info)", tint: "var(--nb-info-muted)" }, + tip: { label: "Tip", color: "var(--nb-success)", tint: "var(--nb-success-muted)" }, + caution: { label: "Caution", color: "var(--nb-warning)", tint: "var(--nb-warning-muted)" }, + danger: { label: "Danger", color: "var(--nb-danger)", tint: "var(--nb-danger-muted)" }, +}; + +const c = config[type] ?? config.note; +const displayTitle = title ?? c.label; +--- + +<aside + role="note" + aria-label={displayTitle} + class={cn("aside-card flex items-start gap-3 rounded-lg px-4 py-3 my-4", className)} + style={`--_c: ${c.color}; --_t: ${c.tint};`} + {...attrs} +> + <span class="shrink-0 flex items-center h-[1.375em]" aria-hidden="true"> + {type === "note" && <Icon name="ph:info" class="w-[1em] h-[1em]" />} + {type === "tip" && <Icon name="ph:lightbulb" class="w-[1em] h-[1em]" />} + {type === "caution" && <Icon name="ph:warning" class="w-[1em] h-[1em]" />} + {type === "danger" && <Icon name="ph:warning-circle" class="w-[1em] h-[1em]" />} + </span> + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> + <p class="m-0 text-base font-semibold leading-snug">{displayTitle}</p> + <div class="aside-card-body text-sm leading-normal"> + <slot /> + </div> + </div> +</aside> + +<style> + .aside-card { + border: 1px solid color-mix(in oklch, var(--_c) 25%, transparent); + background: var(--_t); + } + + /* Icon + title stay in semantic color; body text stays readable */ + .aside-card > :global(:first-child) { color: var(--_c); } + .aside-card > :global(:last-child) > :global(:first-child) { color: var(--_c); } + .aside-card-body { color: var(--nb-foreground); } + + .aside-card-body :global(p) { margin: 0; } + .aside-card-body :global(p + p) { margin-top: 0.375rem; } + .aside-card-body :global(a) { text-decoration: underline; text-underline-offset: 2px; } + + /* Inline code only — exclude code inside Expressive Code pre blocks */ + .aside-card-body :global(code:not(:where(pre *))) { + font-family: var(--nb-font-mono); + font-size: 0.8125em; + background: color-mix(in oklch, var(--_c) 10%, transparent); + border: 1px solid color-mix(in oklch, var(--_c) 15%, transparent); + border-radius: 0.25rem; + padding: 0.0625rem 0.3125rem; + } + .aside-card-body :global(strong) { font-weight: 600; color: inherit; } + + /* Shiki code blocks inside Asides */ + .aside-card-body :global(.astro-code) { + margin: 0.75rem 0; + } +</style> diff --git a/packages/docs/src/components/ui/aside/index.ts b/packages/docs/src/components/ui/aside/index.ts new file mode 100644 index 00000000..816ef78d --- /dev/null +++ b/packages/docs/src/components/ui/aside/index.ts @@ -0,0 +1 @@ +export { default as Aside } from "./Aside.astro"; diff --git a/packages/docs/src/components/ui/badge/Badge.astro b/packages/docs/src/components/ui/badge/Badge.astro new file mode 100644 index 00000000..d6c5f565 --- /dev/null +++ b/packages/docs/src/components/ui/badge/Badge.astro @@ -0,0 +1,43 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"span"> { + /** Badge text. When omitted, slotted children render instead. */ + text?: string; + variant?: "default" | "info" | "note" | "success" | "tip" | "warning" | "caution" | "danger"; + size?: "small" | "medium" | "large"; +} + +const { text, variant = "default", size = "small", class: className, ...attrs } = Astro.props; + +// note→info, tip→success, caution→warning: variant aliases +const variantClass: Record<string, string> = { + default: "bg-accent text-muted-foreground", + info: "bg-info-muted text-info", + note: "bg-info-muted text-info", + success: "bg-success-muted text-success", + tip: "bg-success-muted text-success", + warning: "bg-warning-muted text-warning", + caution: "bg-warning-muted text-warning", + danger: "bg-danger-muted text-danger", +}; + +const sizeClass: Record<string, string> = { + small: "px-2 py-0.5 text-xs", + medium: "px-2.5 py-0.5 text-[0.8125rem]", + large: "px-3 py-1 text-sm", +}; +--- + +<span + class={cn( + "inline-flex items-center rounded-full font-medium whitespace-nowrap leading-none", + variantClass[variant] ?? variantClass.default, + sizeClass[size] ?? sizeClass.small, + className, + )} + {...attrs} +> + {text ?? <slot />} +</span> diff --git a/packages/docs/src/components/ui/badge/index.ts b/packages/docs/src/components/ui/badge/index.ts new file mode 100644 index 00000000..2a0ea5df --- /dev/null +++ b/packages/docs/src/components/ui/badge/index.ts @@ -0,0 +1 @@ +export { default as Badge } from "./Badge.astro"; diff --git a/packages/docs/src/components/ui/banner/Banner.astro b/packages/docs/src/components/ui/banner/Banner.astro new file mode 100644 index 00000000..58f2bd98 --- /dev/null +++ b/packages/docs/src/components/ui/banner/Banner.astro @@ -0,0 +1,83 @@ +--- +/** + * Banner — announcement strip. Pass `dismissible: { id, days? }` to + * show a close button that persists to localStorage. + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + content: string; + variant?: "note" | "tip" | "caution" | "danger"; + dismissible?: { id: string; days?: number }; +} + +const { content, variant = "note", dismissible, class: className, ...attrs } = Astro.props; + +function sanitizeBannerHtml(value: string): string { + return value + .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "") + .replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "") + .replace(/\s+(href|src)\s*=\s*(["'])\s*javascript:[\s\S]*?\2/gi, ""); +} + +const config: Record<string, { color: string; tint: string }> = { + note: { color: "var(--nb-info)", tint: "var(--nb-info-muted)" }, + tip: { color: "var(--nb-success)", tint: "var(--nb-success-muted)" }, + caution: { color: "var(--nb-warning)", tint: "var(--nb-warning-muted)" }, + danger: { color: "var(--nb-danger)", tint: "var(--nb-danger-muted)" }, +}; + +const c = config[variant] ?? config.note; +const safeContent = sanitizeBannerHtml(content); +--- + +<div + role={variant === "danger" || variant === "caution" ? "alert" : "status"} + class={cn("banner-card my-4 flex w-full items-start gap-3 rounded-lg px-4 py-3 text-sm leading-normal text-foreground", className)} + style={`--_c: ${c.color}; --_t: ${c.tint};`} + {...(dismissible + ? { "data-nb-banner-dismiss": dismissible.id, "data-nb-banner-days": dismissible.days } + : {})} + {...attrs} +> + <div class="banner-card-body min-w-0 flex-1"> + <Fragment set:html={safeContent} /> + </div> + {dismissible && ( + <button + type="button" + data-nb-banner-close + class="-my-1 -mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-current opacity-60 transition-[background-color,opacity] hover:bg-card/60 hover:opacity-100" + aria-label="Dismiss banner" + > + <Icon name="ph:x" class="w-3.5 h-3.5" /> + </button> + )} +</div> + +<script> + import "./banner.client"; +</script> + +<style> + .banner-card { + border: 1px solid color-mix(in oklch, var(--_c) 25%, transparent); + background: var(--_t); + } + + .banner-card-body { min-width: 0; overflow-wrap: break-word; } + .banner-card-body :global(p) { margin: 0; } + .banner-card-body :global(p + p) { margin-top: 0.375rem; } + .banner-card-body :global(a) { text-decoration: underline; text-underline-offset: 2px; } + + .banner-card-body :global(code:not(:where(pre *))) { + font-family: var(--nb-font-mono); + font-size: 0.8125em; + background: color-mix(in oklch, var(--_c) 10%, transparent); + border: 1px solid color-mix(in oklch, var(--_c) 15%, transparent); + border-radius: 0.25rem; + padding: 0.0625rem 0.3125rem; + } +</style> diff --git a/packages/docs/src/components/ui/banner/banner.client.ts b/packages/docs/src/components/ui/banner/banner.client.ts new file mode 100644 index 00000000..4ab68c3a --- /dev/null +++ b/packages/docs/src/components/ui/banner/banner.client.ts @@ -0,0 +1,49 @@ +/** + * Storage key: `nb-banner-dismissed-{id}`. Value is "0" for permanent, + * or a future timestamp (ms) for time-limited dismissal. + */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +const KEY_PREFIX = "nb-banner-dismissed-"; + +function initBanner(banner: HTMLElement): () => void { + const id = banner.dataset.nbBannerDismiss; + if (!id) return () => {}; + + const key = `${KEY_PREFIX}${id}`; + + try { + const stored = localStorage.getItem(key); + if (stored) { + const expiry = Number(stored); + if (expiry === 0 || expiry > Date.now()) { + banner.remove(); + return () => {}; + } + localStorage.removeItem(key); + } + } catch { + // localStorage unavailable; show without persistence. + } + + const btn = banner.querySelector<HTMLButtonElement>("[data-nb-banner-close]"); + if (!btn) return () => {}; + + function handleClick() { + const days = Number(banner.dataset.nbBannerDays) || 0; + const value = days > 0 ? String(Date.now() + days * 86400000) : "0"; + try { + localStorage.setItem(key, value); + } catch { + // localStorage unavailable; dismissal is session-only. + } + banner.remove(); + } + + btn.addEventListener("click", handleClick); + + return () => btn.removeEventListener("click", handleClick); +} + +mount("[data-nb-banner-dismiss]", initBanner); diff --git a/packages/docs/src/components/ui/banner/index.ts b/packages/docs/src/components/ui/banner/index.ts new file mode 100644 index 00000000..9b90dcf9 --- /dev/null +++ b/packages/docs/src/components/ui/banner/index.ts @@ -0,0 +1 @@ +export { default as Banner } from "./Banner.astro"; diff --git a/packages/docs/src/components/ui/breadcrumbs/Breadcrumbs.astro b/packages/docs/src/components/ui/breadcrumbs/Breadcrumbs.astro new file mode 100644 index 00000000..cadfc6fb --- /dev/null +++ b/packages/docs/src/components/ui/breadcrumbs/Breadcrumbs.astro @@ -0,0 +1,84 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { Breadcrumb } from "@cloudflare/nimbus-docs/types"; + +interface Props extends HTMLAttributes<"nav"> { + items: Breadcrumb[]; + /** Max visible crumbs before collapsing middle items (default: 4) */ + maxVisible?: number; +} + +const { items, maxVisible = 4, class: className, ...attrs } = Astro.props; + +// Determine if we need to collapse the middle +const shouldCollapse = items.length > maxVisible; +// When collapsed: Home + first segment + ... + last 2 (parent + current) +const headCount = 2; // Home + first segment +const tailCount = 2; // parent + current page +const headItems = shouldCollapse ? items.slice(0, headCount) : items; +const collapsedItems = shouldCollapse ? items.slice(headCount, items.length - tailCount) : []; +const tailItems = shouldCollapse ? items.slice(items.length - tailCount) : []; +--- + +{items.length > 1 && ( + <nav aria-label="Breadcrumb" class={cn("text-xs font-medium", className)} {...attrs}> + <ol class="flex flex-wrap items-center gap-y-0.5 text-muted-foreground"> + {headItems.map((crumb, i) => ( + <li class="flex items-center"> + {i > 0 && <span class="mx-1.5">/</span>} + {(!shouldCollapse && i === items.length - 1) || !crumb.href ? ( + <span class="text-foreground truncate max-w-[12rem]">{crumb.label}</span> + ) : ( + <a href={crumb.href} class="hover:text-foreground transition-colors truncate max-w-[12rem]"> + {crumb.label} + </a> + )} + </li> + ))} + + {shouldCollapse && collapsedItems.length > 0 && ( + <li class="flex items-center"> + <span class="mx-1.5">/</span> + <details class="relative group"> + <summary + class="list-none cursor-pointer select-none rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground [&::-webkit-details-marker]:hidden group-open:before:fixed group-open:before:inset-0 group-open:before:z-[39] group-open:before:cursor-default group-open:before:content-['']" + aria-label={`Show ${collapsedItems.length} more path segments`} + > + <span class="tracking-widest">…</span> + </summary> + <div class="absolute left-0 top-full z-40 mt-1 min-w-[10rem] max-w-[16rem] rounded-lg border border-border bg-card p-1 shadow-lg"> + {collapsedItems.map((crumb) => ( + crumb.href ? ( + <a + href={crumb.href} + class="block truncate rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors" + > + {crumb.label} + </a> + ) : ( + <span class="block truncate rounded-md px-2.5 py-1.5 text-xs text-muted-foreground"> + {crumb.label} + </span> + ) + ))} + </div> + </details> + </li> + )} + + {shouldCollapse && tailItems.map((crumb, i) => ( + <li class="flex items-center"> + <span class="mx-1.5">/</span> + {i === tailItems.length - 1 || !crumb.href ? ( + <span class="text-foreground truncate max-w-[12rem]">{crumb.label}</span> + ) : ( + <a href={crumb.href} class="hover:text-foreground transition-colors truncate max-w-[12rem]"> + {crumb.label} + </a> + )} + </li> + ))} + </ol> + </nav> +)} diff --git a/packages/docs/src/components/ui/breadcrumbs/index.ts b/packages/docs/src/components/ui/breadcrumbs/index.ts new file mode 100644 index 00000000..734e2089 --- /dev/null +++ b/packages/docs/src/components/ui/breadcrumbs/index.ts @@ -0,0 +1 @@ +export { default as Breadcrumbs } from "./Breadcrumbs.astro"; diff --git a/packages/docs/src/components/ui/button/Button.astro b/packages/docs/src/components/ui/button/Button.astro new file mode 100644 index 00000000..ed039d94 --- /dev/null +++ b/packages/docs/src/components/ui/button/Button.astro @@ -0,0 +1,69 @@ +--- +/** + * Button — primary action trigger. + * + * <Button variant="primary">Save</Button> + * <Button variant="secondary" icon="ph:plus">Create</Button> + * <Button variant="ghost" loading>Saving…</Button> + * <Button variant="outline" shape="square" icon="ph:gear" aria-label="Settings" /> + * + * Variants: primary · secondary (default) · ghost · destructive · + * secondary-destructive · outline. + * Sizes: xs · sm · base (default) · lg. + * Shapes: base (default) · square · circle (icon-only — pass `aria-label`). + * + * `icon` takes an iconify name (astro-icon) rendered before the label; + * `loading` swaps it for a spinner and disables the button. + * + * Styling lives in `./variants` (shared with LinkButton). For an anchor + * styled as a button, use `~/components/ui/link-button`. + */ +import { cn } from "@/lib/cn"; +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import type { HTMLAttributes } from "astro/types"; +import { + buttonVariants, + buttonIconSize, + type ButtonVariant, + type ButtonSize, + type ButtonShape, +} from "./variants"; + +interface Props extends Omit<HTMLAttributes<"button">, "size"> { + variant?: ButtonVariant; + size?: ButtonSize; + shape?: ButtonShape; + /** Iconify name rendered before the label, e.g. "ph:plus". */ + icon?: string; + /** Show a spinner and disable interaction. */ + loading?: boolean; +} + +const { + variant = "secondary", + size = "base", + shape = "base", + icon, + loading = false, + type = "button", + disabled, + class: className, + ...attrs +} = Astro.props; +--- + +<button + type={type} + disabled={disabled || loading} + class={cn(buttonVariants({ variant, size, shape }), className)} + {...attrs} +> + { + loading ? ( + <Icon name="ph:circle-notch" class={cn("animate-spin", buttonIconSize[size])} /> + ) : ( + icon && <Icon name={icon} class={buttonIconSize[size]} /> + ) + } + <slot /> +</button> diff --git a/packages/docs/src/components/ui/button/index.ts b/packages/docs/src/components/ui/button/index.ts new file mode 100644 index 00000000..32440d6b --- /dev/null +++ b/packages/docs/src/components/ui/button/index.ts @@ -0,0 +1,13 @@ +export { default as Button } from "./Button.astro"; +export { + buttonVariants, + buttonBase, + buttonVariantClasses, + buttonSizeText, + buttonSizeCompact, + buttonIconSize, + type ButtonVariant, + type ButtonSize, + type ButtonShape, + type ButtonVariantsOptions, +} from "./variants"; diff --git a/packages/docs/src/components/ui/button/variants.ts b/packages/docs/src/components/ui/button/variants.ts new file mode 100644 index 00000000..68f9211e --- /dev/null +++ b/packages/docs/src/components/ui/button/variants.ts @@ -0,0 +1,82 @@ +/** + * Shared button styling — the single source of truth for both <Button> + * (a real button) and <LinkButton> (an anchor styled as a button), so the + * two stay visually identical. + * + * Token-mapped to Nimbus. Import `buttonVariants()` to compose the trigger + * classes for a button-shaped element; `buttonIconSize` sizes a leading/ + * trailing icon for a given size. + */ +import { cn } from "@/lib/cn"; + +export type ButtonVariant = + | "primary" + | "secondary" + | "ghost" + | "destructive" + | "secondary-destructive" + | "outline"; +export type ButtonSize = "xs" | "sm" | "base" | "lg"; +export type ButtonShape = "base" | "square" | "circle"; + +// `rounded-lg` is the default radius for every button; `circle` overrides +// it to `rounded-full` (see `buttonVariants`), `square` keeps it. +export const buttonBase = + "group inline-flex w-max shrink-0 items-center justify-center rounded-md font-medium whitespace-nowrap no-underline transition-all cursor-pointer select-none focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; + +export const buttonVariantClasses: Record<ButtonVariant, string> = { + primary: + "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover hover:shadow", + secondary: + "bg-card text-foreground ring ring-border shadow-sm hover:bg-accent hover:ring-border-strong", + ghost: "bg-transparent text-foreground shadow-none hover:bg-accent", + destructive: "bg-danger text-white shadow-sm hover:bg-danger/90", + "secondary-destructive": + "bg-card text-danger ring ring-border shadow-sm hover:bg-accent hover:ring-danger/40", + outline: + "bg-transparent text-foreground ring ring-border hover:ring-border-strong", +}; + +// Rectangular sizing (shape="base"). Radius comes from `buttonBase`. +export const buttonSizeText: Record<ButtonSize, string> = { + xs: "gap-1 px-2 py-1 text-xs", + sm: "gap-1 px-3 py-1.5 text-xs", + base: "gap-1.5 px-4 py-2 text-sm", + lg: "gap-2 px-5 py-2.5 text-sm", +}; + +// Square/circle sizing (icon-only): equal dimensions, no padding. +export const buttonSizeCompact: Record<ButtonSize, string> = { + xs: "size-7", + sm: "size-8", + base: "size-9", + lg: "size-10", +}; + +export const buttonIconSize: Record<ButtonSize, string> = { + xs: "h-3.5 w-3.5", + sm: "h-3.5 w-3.5", + base: "h-4 w-4", + lg: "h-[1.125rem] w-[1.125rem]", +}; + +export interface ButtonVariantsOptions { + variant?: ButtonVariant; + size?: ButtonSize; + shape?: ButtonShape; +} + +/** Compose the base + variant + size/shape classes for a button-shaped element. */ +export function buttonVariants({ + variant = "secondary", + size = "base", + shape = "base", +}: ButtonVariantsOptions = {}): string { + // base + square inherit `rounded-lg` from buttonBase; circle overrides it + // to a full pill. + const dims = + shape === "base" + ? buttonSizeText[size] + : cn(buttonSizeCompact[size], "p-0", shape === "circle" && "rounded-full"); + return cn(buttonBase, buttonVariantClasses[variant], dims); +} diff --git a/packages/docs/src/components/ui/card-grid/CardGrid.astro b/packages/docs/src/components/ui/card-grid/CardGrid.astro new file mode 100644 index 00000000..a7c50fb7 --- /dev/null +++ b/packages/docs/src/components/ui/card-grid/CardGrid.astro @@ -0,0 +1,15 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...attrs } = Astro.props; +--- + +<div + class={cn("grid grid-cols-1 gap-4 my-4 sm:grid-cols-2 [&>*]:my-0", className)} + {...attrs} +> + <slot /> +</div> diff --git a/packages/docs/src/components/ui/card-grid/index.ts b/packages/docs/src/components/ui/card-grid/index.ts new file mode 100644 index 00000000..417cc1fd --- /dev/null +++ b/packages/docs/src/components/ui/card-grid/index.ts @@ -0,0 +1 @@ +export { default as CardGrid } from "./CardGrid.astro"; diff --git a/packages/docs/src/components/ui/card/Card.astro b/packages/docs/src/components/ui/card/Card.astro new file mode 100644 index 00000000..7b2605ba --- /dev/null +++ b/packages/docs/src/components/ui/card/Card.astro @@ -0,0 +1,29 @@ +--- +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"article"> { + title: string; + /** Iconify icon name, e.g. `ph:lightning`. */ + icon?: string; +} + +const { title, icon, class: className, ...attrs } = Astro.props; +--- + +<article + class={cn( + "my-4 flex flex-col gap-1.5 rounded-lg bg-card px-5 py-4 ring-1 ring-border", + className, + )} + {...attrs} +> + <div class="flex items-center gap-2"> + {icon && <Icon name={icon} class="h-4 w-4 shrink-0 text-muted-foreground" />} + <h3 class="m-0 text-[0.9375rem] font-medium leading-snug text-foreground">{title}</h3> + </div> + <div class="text-sm leading-relaxed text-muted-foreground"> + <slot /> + </div> +</article> diff --git a/packages/docs/src/components/ui/card/index.ts b/packages/docs/src/components/ui/card/index.ts new file mode 100644 index 00000000..b491741f --- /dev/null +++ b/packages/docs/src/components/ui/card/index.ts @@ -0,0 +1 @@ +export { default as Card } from "./Card.astro"; diff --git a/packages/docs/src/components/ui/code/Code.astro b/packages/docs/src/components/ui/code/Code.astro new file mode 100644 index 00000000..34feef72 --- /dev/null +++ b/packages/docs/src/components/ui/code/Code.astro @@ -0,0 +1,36 @@ +--- +/** + * Code — syntax-highlighted code block from a string prop. + * + * <Code code={generated} lang="ts" /> + * <Code code={...} lang="ts" meta='title="src/foo.ts" {1,3-5}' /> + */ +import { Code as AstroCode } from "astro:components"; +import { defaultCodeTransformers } from "@cloudflare/nimbus-docs"; + +type Props = Parameters<typeof AstroCode>[0]; +const rawProps = Astro.props as Props; +const usesNimbusDefaultThemes = !("theme" in rawProps) && !("themes" in rawProps); +const themed = usesNimbusDefaultThemes + ? { + ...rawProps, + themes: { light: "github-light", dark: "github-dark" }, + defaultColor: false, + } + : rawProps; +const userTransformers = themed.transformers ?? []; +const props = { + ...themed, + transformers: defaultCodeTransformers({ + beforeTitleTransformers: userTransformers, + }), +}; +// One boundary cast back to Astro's own `<Code>` props. Everything above is +// runtime-valid; the only type friction is nominal — nimbus-docs and Astro +// can resolve different `@shikijs/types` copies (e.g. 4.2.x vs 4.1.x), so the +// `ShikiTransformer`/`ThemePresets` shapes differ on paper while matching at +// runtime. `Astro.props` was already `as Props`; re-asserting here keeps the +// component compiling regardless of which shiki types version dedupes in. +--- + +<AstroCode {...(props as Props)} /> diff --git a/packages/docs/src/components/ui/code/index.ts b/packages/docs/src/components/ui/code/index.ts new file mode 100644 index 00000000..152859d8 --- /dev/null +++ b/packages/docs/src/components/ui/code/index.ts @@ -0,0 +1 @@ +export { default as Code } from "./Code.astro"; diff --git a/packages/docs/src/components/ui/collapsible/Collapsible.astro b/packages/docs/src/components/ui/collapsible/Collapsible.astro new file mode 100644 index 00000000..24b51c2d --- /dev/null +++ b/packages/docs/src/components/ui/collapsible/Collapsible.astro @@ -0,0 +1,25 @@ +--- +/** Collapsible — disclosure. Compose with CollapsibleTrigger + CollapsibleContent. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + /** Start open. Default false. */ + open?: boolean; +} + +const { open = false, class: className, ...attrs } = Astro.props; +--- + +<div + data-nb-collapsible + data-nb-default-open={open ? "true" : undefined} + class={cn(className)} + {...attrs} +> + <slot /> +</div> + +<script> + import "./collapsible.client"; +</script> diff --git a/packages/docs/src/components/ui/collapsible/CollapsibleContent.astro b/packages/docs/src/components/ui/collapsible/CollapsibleContent.astro new file mode 100644 index 00000000..e8b76860 --- /dev/null +++ b/packages/docs/src/components/ui/collapsible/CollapsibleContent.astro @@ -0,0 +1,27 @@ +--- +/** + * CollapsibleContent — the panel that animates open/closed. + * Uses `grid-template-rows: 0fr → 1fr` for smooth height transition. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<div + data-nb-collapsible-content + class={cn( + "grid grid-rows-[0fr] transition-[grid-template-rows] duration-250 ease-[cubic-bezier(0.87,0,0.13,1)]", + "data-[nb-state=open]:grid-rows-[1fr]", + "motion-reduce:transition-none", + className, + )} + {...attrs} +> + <div class="overflow-hidden min-h-0"> + <slot /> + </div> +</div> diff --git a/packages/docs/src/components/ui/collapsible/CollapsibleTrigger.astro b/packages/docs/src/components/ui/collapsible/CollapsibleTrigger.astro new file mode 100644 index 00000000..d5f8cea9 --- /dev/null +++ b/packages/docs/src/components/ui/collapsible/CollapsibleTrigger.astro @@ -0,0 +1,21 @@ +--- +/** + * CollapsibleTrigger — the button that toggles the Collapsible. + * Slot accepts arbitrary content; component author provides full visuals. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +const { class: className, type, ...attrs } = Astro.props; +--- + +<button + type={type ?? "button"} + data-nb-collapsible-trigger + class={cn("w-full text-left cursor-pointer select-none", className)} + {...attrs} +> + <slot /> +</button> diff --git a/packages/docs/src/components/ui/collapsible/collapsible.client.ts b/packages/docs/src/components/ui/collapsible/collapsible.client.ts new file mode 100644 index 00000000..71a1af40 --- /dev/null +++ b/packages/docs/src/components/ui/collapsible/collapsible.client.ts @@ -0,0 +1,22 @@ +/** Wires Collapsible via the disclosure module. */ + +import { mount, makeDisclosure } from "@cloudflare/nimbus-docs/client"; + +function initCollapsible(root: HTMLElement): () => void { + const trigger = root.querySelector<HTMLElement>("[data-nb-collapsible-trigger]"); + const content = root.querySelector<HTMLElement>("[data-nb-collapsible-content]"); + + if (!trigger || !content) return () => {}; + + const defaultOpen = root.dataset.nbDefaultOpen === "true"; + + const disclosure = makeDisclosure({ + trigger, + content, + defaultOpen, + }); + + return () => disclosure.destroy(); +} + +mount("[data-nb-collapsible]", initCollapsible); diff --git a/packages/docs/src/components/ui/collapsible/index.ts b/packages/docs/src/components/ui/collapsible/index.ts new file mode 100644 index 00000000..a4c42216 --- /dev/null +++ b/packages/docs/src/components/ui/collapsible/index.ts @@ -0,0 +1,3 @@ +export { default as Collapsible } from "./Collapsible.astro"; +export { default as CollapsibleTrigger } from "./CollapsibleTrigger.astro"; +export { default as CollapsibleContent } from "./CollapsibleContent.astro"; diff --git a/packages/docs/src/components/ui/dialog/Dialog.astro b/packages/docs/src/components/ui/dialog/Dialog.astro new file mode 100644 index 00000000..509b5b74 --- /dev/null +++ b/packages/docs/src/components/ui/dialog/Dialog.astro @@ -0,0 +1,33 @@ +--- +/** + * Dialog — modal overlay built on the native <dialog>. + * + * Open with `el.showModal()`, close with `el.close()` or Escape. + * Scroll-lock and backdrop-click-to-close are handled automatically. + * + * <Dialog id="confirm"> + * <DialogContent> + * <div>Are you sure?</div> + * <DialogClose>Cancel</DialogClose> + * </DialogContent> + * </Dialog> + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"dialog"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<dialog + data-dialog + class={cn("fixed inset-0 z-50 m-0 h-full w-full max-h-full max-w-full bg-transparent p-0 backdrop:bg-black/40", className)} + {...attrs} +> + <slot /> +</dialog> + +<script> + import "./dialog.client"; +</script> diff --git a/packages/docs/src/components/ui/dialog/DialogClose.astro b/packages/docs/src/components/ui/dialog/DialogClose.astro new file mode 100644 index 00000000..9d62d990 --- /dev/null +++ b/packages/docs/src/components/ui/dialog/DialogClose.astro @@ -0,0 +1,25 @@ +--- +/** + * DialogClose — button that closes the nearest ancestor <dialog>. + * Consumer provides the visual (icon, text, kbd hint) via default slot. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<button + data-dialog-close + type="button" + class={cn(className)} + {...attrs} +> + <slot /> +</button> + +<script> + import "./dialog-close.client"; +</script> diff --git a/packages/docs/src/components/ui/dialog/DialogContent.astro b/packages/docs/src/components/ui/dialog/DialogContent.astro new file mode 100644 index 00000000..429a537d --- /dev/null +++ b/packages/docs/src/components/ui/dialog/DialogContent.astro @@ -0,0 +1,21 @@ +--- +/** + * DialogContent — inner frame of a Dialog. Centered, constrained, styled. + * Consumer controls max-width/height via class override. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<div class="flex items-start justify-center pt-[10vh] px-4 pointer-events-none"> + <div + class={cn("pointer-events-auto flex max-h-[70vh] w-full flex-col overflow-y-auto rounded-lg bg-muted shadow-lg ring-1 ring-border", className)} + {...attrs} + > + <slot /> + </div> +</div> diff --git a/packages/docs/src/components/ui/dialog/dialog-close.client.ts b/packages/docs/src/components/ui/dialog/dialog-close.client.ts new file mode 100644 index 00000000..3b5ac7bd --- /dev/null +++ b/packages/docs/src/components/ui/dialog/dialog-close.client.ts @@ -0,0 +1,7 @@ +import { mount } from "@cloudflare/nimbus-docs/client"; + +mount("[data-dialog-close]", (btn) => { + const close = () => btn.closest("dialog")?.close(); + btn.addEventListener("click", close); + return () => btn.removeEventListener("click", close); +}); diff --git a/packages/docs/src/components/ui/dialog/dialog.client.ts b/packages/docs/src/components/ui/dialog/dialog.client.ts new file mode 100644 index 00000000..1a68d674 --- /dev/null +++ b/packages/docs/src/components/ui/dialog/dialog.client.ts @@ -0,0 +1,25 @@ +import { lockScroll, mount, unlockScroll } from "@cloudflare/nimbus-docs/client"; + +mount("[data-dialog]", (root) => { + if (!(root instanceof HTMLDialogElement)) return () => {}; + const dialog = root; + + const sync = () => (dialog.open ? lockScroll() : unlockScroll()); + const observer = new MutationObserver(sync); + observer.observe(dialog, { attributes: true, attributeFilter: ["open"] }); + + const onClose = () => unlockScroll(); + const onBackdrop = (e: MouseEvent) => { + if (e.target === dialog) dialog.close(); + }; + dialog.addEventListener("close", onClose); + dialog.addEventListener("click", onBackdrop); + + return () => { + observer.disconnect(); + dialog.removeEventListener("close", onClose); + dialog.removeEventListener("click", onBackdrop); + // A swap while open never fires `close`; balance the scroll lock. + if (dialog.open) unlockScroll(); + }; +}); diff --git a/packages/docs/src/components/ui/dialog/index.ts b/packages/docs/src/components/ui/dialog/index.ts new file mode 100644 index 00000000..fe7fb641 --- /dev/null +++ b/packages/docs/src/components/ui/dialog/index.ts @@ -0,0 +1,3 @@ +export { default as Dialog } from "./Dialog.astro"; +export { default as DialogContent } from "./DialogContent.astro"; +export { default as DialogClose } from "./DialogClose.astro"; diff --git a/packages/docs/src/components/ui/layer-card/LayerCard.astro b/packages/docs/src/components/ui/layer-card/LayerCard.astro new file mode 100644 index 00000000..8473c7bc --- /dev/null +++ b/packages/docs/src/components/ui/layer-card/LayerCard.astro @@ -0,0 +1,23 @@ +--- +/** + * LayerCard — two-layer card (recessed header + raised content). + * + * <LayerCard> + * <LayerCardHeader>Title or tabs</LayerCardHeader> + * <LayerCardContent>Main content</LayerCardContent> + * </LayerCard> + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<div + class={cn("flex w-full flex-col overflow-hidden rounded-lg bg-muted text-sm ring ring-border", className)} + {...attrs} +> + <slot /> +</div> diff --git a/packages/docs/src/components/ui/layer-card/LayerCardContent.astro b/packages/docs/src/components/ui/layer-card/LayerCardContent.astro new file mode 100644 index 00000000..12bdaeac --- /dev/null +++ b/packages/docs/src/components/ui/layer-card/LayerCardContent.astro @@ -0,0 +1,16 @@ +--- +/** LayerCardContent — raised content layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<div + class={cn("relative flex flex-col gap-2 overflow-hidden rounded-lg bg-card p-4 pr-3 text-sm leading-6 text-foreground ring ring-border", className)} + {...attrs} +> + <slot /> +</div> diff --git a/packages/docs/src/components/ui/layer-card/LayerCardHeader.astro b/packages/docs/src/components/ui/layer-card/LayerCardHeader.astro new file mode 100644 index 00000000..b9be4045 --- /dev/null +++ b/packages/docs/src/components/ui/layer-card/LayerCardHeader.astro @@ -0,0 +1,16 @@ +--- +/** LayerCardHeader — recessed header layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<div + class={cn("flex items-center gap-2 bg-muted px-3 py-2 text-[0.8125rem] font-medium leading-5 text-muted-foreground", className)} + {...attrs} +> + <slot /> +</div> diff --git a/packages/docs/src/components/ui/layer-card/index.ts b/packages/docs/src/components/ui/layer-card/index.ts new file mode 100644 index 00000000..dd347fe4 --- /dev/null +++ b/packages/docs/src/components/ui/layer-card/index.ts @@ -0,0 +1,3 @@ +export { default as LayerCard } from "./LayerCard.astro"; +export { default as LayerCardHeader } from "./LayerCardHeader.astro"; +export { default as LayerCardContent } from "./LayerCardContent.astro"; diff --git a/packages/docs/src/components/ui/link-button/LinkButton.astro b/packages/docs/src/components/ui/link-button/LinkButton.astro new file mode 100644 index 00000000..48335c8c --- /dev/null +++ b/packages/docs/src/components/ui/link-button/LinkButton.astro @@ -0,0 +1,76 @@ +--- +/** + * LinkButton — an anchor styled as a button. + * + * <LinkButton href="/start" variant="primary">Get started</LinkButton> + * <LinkButton href="/docs" variant="secondary">Read the docs</LinkButton> + * <LinkButton href="#" variant="minimal" icon>Learn more</LinkButton> + * <LinkButton href="/x" shape="square" aria-label="Open"> … </LinkButton> + * + * The `icon` prop appends a right-caret that nudges on hover. + * + * Styling is delegated to the shared `ui/button/variants`, so LinkButton and + * Button stay visually identical. The original props are preserved (used by + * MDX): the LinkButton-only aliases map onto the shared vocabulary — + * `minimal → ghost`, `md → base`. + */ +import { cn } from "@/lib/cn"; +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import type { HTMLAttributes } from "astro/types"; +import { + buttonVariants, + buttonIconSize, + type ButtonVariant, + type ButtonSize, + type ButtonShape, +} from "../button/variants"; + +interface Props extends HTMLAttributes<"a"> { + href: string; + /** Original `primary | secondary | minimal`, plus Button's variants. */ + variant?: ButtonVariant | "minimal"; + /** Original `sm | md | lg`, plus Button's `xs | base`. */ + size?: ButtonSize | "md"; + /** `base` (default) · `square` · `circle` (icon-only — pass `aria-label`). */ + shape?: ButtonShape; + /** Append a caret-right that nudges on hover. */ + icon?: boolean; +} + +const { + href, + variant = "primary", + size = "md", + shape = "base", + icon = false, + class: className, + ...attrs +} = Astro.props; + +// Map the LinkButton-only aliases onto the shared Button vocabulary. +const resolvedVariant: ButtonVariant = variant === "minimal" ? "ghost" : variant; +const resolvedSize: ButtonSize = size === "md" ? "base" : size; +--- + +<a + href={href} + class={cn( + buttonVariants({ variant: resolvedVariant, size: resolvedSize, shape }), + className, + )} + {...attrs} +> + <slot /> + { + icon && ( + <Icon + name="ph:caret-right" + class={cn( + "transition-transform group-hover:translate-x-0.5", + buttonIconSize[resolvedSize], + )} + + /> + ) + } +</a> diff --git a/packages/docs/src/components/ui/link-button/index.ts b/packages/docs/src/components/ui/link-button/index.ts new file mode 100644 index 00000000..71411b5e --- /dev/null +++ b/packages/docs/src/components/ui/link-button/index.ts @@ -0,0 +1 @@ +export { default as LinkButton } from "./LinkButton.astro"; diff --git a/packages/docs/src/components/ui/link-card/LinkCard.astro b/packages/docs/src/components/ui/link-card/LinkCard.astro new file mode 100644 index 00000000..8e603e97 --- /dev/null +++ b/packages/docs/src/components/ui/link-card/LinkCard.astro @@ -0,0 +1,40 @@ +--- +/** + * LinkCard — link-styled card with title, optional description, and a trailing + * arrow that nudges on hover. Ring (not border+shadow), optical padding, and an + * immediate hover (no colour transition), per the Kumo design skill. + * + * <LinkCard title="Get started" description="5-min intro" href="/start" /> + * <LinkCard title="Title only" href="/x" /> + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"a"> { + title: string; + description?: string; + href: string; +} + +const { title, description, href, class: className, ...attrs } = Astro.props; +--- + +<a + href={href} + class={cn( + "group my-4 flex h-full items-center justify-between gap-3 rounded-lg bg-card px-5 py-4 ring-1 ring-border no-underline text-inherit hover:ring-foreground/25", + className, + )} + {...attrs} +> + <div class="grid min-w-0 gap-1"> + <h3 class="m-0 text-[0.9375rem] font-medium leading-snug text-foreground">{title}</h3> + {description && <p class="text-sm leading-relaxed text-muted-foreground">{description}</p>} + </div> + <Icon + name="ph:arrow-right" + class="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" + is:inline + /> +</a> diff --git a/packages/docs/src/components/ui/link-card/index.ts b/packages/docs/src/components/ui/link-card/index.ts new file mode 100644 index 00000000..8f4cf167 --- /dev/null +++ b/packages/docs/src/components/ui/link-card/index.ts @@ -0,0 +1 @@ +export { default as LinkCard } from "./LinkCard.astro"; diff --git a/packages/docs/src/components/ui/package-managers/PackageManagers.astro b/packages/docs/src/components/ui/package-managers/PackageManagers.astro new file mode 100644 index 00000000..47f92635 --- /dev/null +++ b/packages/docs/src/components/ui/package-managers/PackageManagers.astro @@ -0,0 +1,129 @@ +--- +/** + * PackageManagers — code block with a tab per package manager (npm, + * pnpm, yarn, bun). Selection syncs across instances via sessionStorage; + * an inline custom element restores the saved tab before paint. + */ +import { createHash } from "node:crypto"; +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { getTabs } from "@cloudflare/nimbus-docs/lib/pkgm"; +import type { CommandType, CommandOptions } from "@cloudflare/nimbus-docs/lib/pkgm"; +import { LayerCard, LayerCardHeader } from "@/components/ui/layer-card"; + +interface Props extends CommandOptions { + pkg?: string; + type?: CommandType; +} + +const { pkg, type = "add", args, dev, comment } = Astro.props; +const tabs = getTabs(type, pkg, { args, dev, comment }); +// Prop hash + a per-page counter (on Astro.locals) so two blocks with +// identical props still get distinct ids. Counter order is stable within a +// render, so incremental/warm builds match cold — unlike crypto.randomUUID(). +const localsAny = Astro.locals as Record<string, unknown>; +const counters = + (localsAny.__nbCounters as Map<string, number>) ?? + (localsAny.__nbCounters = new Map<string, number>()); +const n = (counters.get("package-managers") ?? 0) + 1; +counters.set("package-managers", n); +const uid = + "pm-" + + createHash("sha256") + .update(JSON.stringify({ pkg, type, args, dev, comment })) + .digest("hex") + .slice(0, 12) + + "-" + + n.toString(16).padStart(4, "0"); +--- + +<script> + if (!customElements.get("nb-pm-restore")) { + customElements.define( + "nb-pm-restore", + class extends HTMLElement { + connectedCallback() { + const card = this.closest("[data-nb-pm]"); + if (!card) return; + let saved; + try { + saved = sessionStorage.getItem("ui-pm-tab"); + } catch { + return; + } + if (!saved) return; + const tabs = card.querySelectorAll("[data-nb-pm-tab]"); + let idx = -1; + tabs.forEach(function (t, i) { + if (t.textContent.trim() === saved) idx = i; + }); + if (idx < 1) return; + tabs.forEach(function (t, i) { + t.setAttribute("aria-selected", String(i === idx)); + }); + (card.querySelectorAll("[data-nb-pm-panel]") as NodeListOf<HTMLElement>).forEach(function (p, i) { + p.hidden = i !== idx; + }); + } + }, + ); + } +</script> + +<div data-nb-pm class="w-full"> + <LayerCard> + <LayerCardHeader role="tablist" aria-label="Package manager"> + {tabs.map((tab, i) => ( + <button + role="tab" + type="button" + aria-selected={i === 0 ? "true" : "false"} + aria-controls={`pm-panel-${uid}-${tab.mgr}`} + id={`pm-tab-${uid}-${tab.mgr}`} + data-nb-pm-tab + class="m-0 cursor-pointer rounded-md border-0 bg-transparent px-2 py-0.5 text-xs leading-5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground aria-selected:bg-selected aria-selected:text-foreground" + > + {tab.mgr} + </button> + ))} + </LayerCardHeader> + + {tabs.map((tab, i) => { + const cmdLines = tab.cmd.split("\n"); + const commentPrefix = cmdLines.length > 1 ? cmdLines.slice(0, -1).join("\n") + "\n" : ""; + const codeLine = cmdLines[cmdLines.length - 1]; + const spaceIdx = codeLine.indexOf(" "); + const codeFirst = spaceIdx === -1 ? codeLine : codeLine.slice(0, spaceIdx); + const codeRest = spaceIdx === -1 ? "" : codeLine.slice(spaceIdx); + return ( + <div + role="tabpanel" + id={`pm-panel-${uid}-${tab.mgr}`} + aria-labelledby={`pm-tab-${uid}-${tab.mgr}`} + hidden={i !== 0} + data-nb-pm-panel + class="relative overflow-hidden rounded-lg bg-card text-inherit ring ring-border" + > + <div class="flex items-stretch"> + <pre class="my-0 min-w-0 grow overflow-x-auto border-0 bg-transparent px-4 py-3 text-sm leading-relaxed whitespace-pre font-mono text-foreground"><code data-nb-pm-code>{commentPrefix && <span class="text-muted-foreground">{commentPrefix}</span>}<span class="text-success">{codeFirst}</span><span class="text-warning">{codeRest}</span></code></pre> + <button + type="button" + data-nb-pm-copy + data-nb-command={tab.cmd} + aria-label="Copy to clipboard" + class="m-0 flex shrink-0 cursor-pointer items-center justify-center border-0 border-l border-solid border-border bg-transparent px-3 text-muted-foreground transition-colors hover:text-foreground" + > + <Icon name="ph:copy" class="w-[18px] h-[18px]" /> + </button> + </div> + </div> + ); + })} + <nb-pm-restore style="display:contents"></nb-pm-restore> + </LayerCard> + <template data-nb-pm-icon-copy><Icon name="ph:copy" class="w-[18px] h-[18px]" /></template> + <template data-nb-pm-icon-check><Icon name="ph:check" class="w-[18px] h-[18px]" /></template> +</div> + +<script> + import "./package-managers.client"; +</script> diff --git a/packages/docs/src/components/ui/package-managers/index.ts b/packages/docs/src/components/ui/package-managers/index.ts new file mode 100644 index 00000000..248021d7 --- /dev/null +++ b/packages/docs/src/components/ui/package-managers/index.ts @@ -0,0 +1 @@ +export { default as PackageManagers } from "./PackageManagers.astro"; diff --git a/packages/docs/src/components/ui/package-managers/package-managers.client.ts b/packages/docs/src/components/ui/package-managers/package-managers.client.ts new file mode 100644 index 00000000..28b33f85 --- /dev/null +++ b/packages/docs/src/components/ui/package-managers/package-managers.client.ts @@ -0,0 +1,55 @@ +/** + * Sync key `ui-pm-tab` (sessionStorage) is shared with the + * `<nb-pm-restore>` early-paint element to avoid flash across navigations. + */ + +import { mount, initTabs } from "@cloudflare/nimbus-docs/client"; + +function cloneIcon(tpl: HTMLTemplateElement | null): Node { + return tpl ? tpl.content.cloneNode(true) : document.createTextNode(""); +} + +function initPackageManager(container: HTMLElement): () => void { + const copyTpl = container.querySelector<HTMLTemplateElement>("[data-nb-pm-icon-copy]"); + const checkTpl = container.querySelector<HTMLTemplateElement>("[data-nb-pm-icon-check]"); + + const tabs = initTabs({ + container, + tabSelector: "[data-nb-pm-tab]", + panelSelector: "[data-nb-pm-panel]", + rovingTabindex: true, + sync: { key: "ui-pm-tab", storage: "session" }, + }); + + const copyHandlers: Array<{ btn: HTMLButtonElement; handler: () => void; timer?: number }> = []; + + container.querySelectorAll<HTMLButtonElement>("[data-nb-pm-copy]").forEach((btn) => { + const handlerInfo: { btn: HTMLButtonElement; handler: () => void; timer?: number } = { + btn, + handler: async () => { + try { + await navigator.clipboard.writeText(btn.dataset.nbCommand ?? ""); + } catch { + return; + } + btn.replaceChildren(cloneIcon(checkTpl)); + if (handlerInfo.timer) window.clearTimeout(handlerInfo.timer); + handlerInfo.timer = window.setTimeout(() => { + btn.replaceChildren(cloneIcon(copyTpl)); + }, 1500); + }, + }; + btn.addEventListener("click", handlerInfo.handler); + copyHandlers.push(handlerInfo); + }); + + return () => { + tabs.destroy(); + copyHandlers.forEach(({ btn, handler, timer }) => { + btn.removeEventListener("click", handler); + if (timer) window.clearTimeout(timer); + }); + }; +} + +mount("[data-nb-pm]", initPackageManager); diff --git a/packages/docs/src/components/ui/page-actions/PageActions.astro b/packages/docs/src/components/ui/page-actions/PageActions.astro new file mode 100644 index 00000000..83901d68 --- /dev/null +++ b/packages/docs/src/components/ui/page-actions/PageActions.astro @@ -0,0 +1,73 @@ +--- +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { config } from "virtual:nimbus/config"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + markdownUrl?: string; + lastUpdated?: Date; +} + +const { markdownUrl, lastUpdated, class: className, ...attrs } = Astro.props; + +const baseBtn = + "inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-transparent px-2 py-1 text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"; + +// The site is static, so this runs once at build time on whatever machine did +// the build. An `undefined` locale would resolve to *that* machine's default, +// which is neither the reader's locale nor a stable build output. Pin it to the +// locale the document declares in `<html lang>`, which is this same value. +const locale = config.locale ?? "en"; +const formattedDate = lastUpdated + ? new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" }).format( + lastUpdated, + ) + : null; +--- + +{(markdownUrl || lastUpdated) && ( + <div + data-nb-page-actions + data-md-url={markdownUrl} + class={cn("not-prose -ml-2 flex flex-wrap items-center gap-y-1 text-[0.8125rem]", className)} + {...attrs} + > + {lastUpdated && ( + <span class="inline-flex items-center gap-1.5 px-2 py-1 text-muted-foreground"> + <Icon name="ph:clock" class="w-3.5 h-3.5" /> + Updated <time datetime={lastUpdated.toISOString()}>{formattedDate}</time> + </span> + )} + + {markdownUrl && lastUpdated && ( + <span aria-hidden="true" class="select-none text-muted-foreground/70">|</span> + )} + + {markdownUrl && ( + <> + <button type="button" data-nb-page-actions-copy class={baseBtn}> + <Icon name="ph:copy" class="w-3.5 h-3.5" data-nb-page-actions-copy-icon /> + <Icon name="ph:check" class="hidden w-3.5 h-3.5 text-success" data-nb-page-actions-check-icon /> + <span data-nb-page-actions-label aria-live="polite">Copy page</span> + </button> + + <span aria-hidden="true" class="select-none text-muted-foreground/70">|</span> + + <a + href={markdownUrl} + target="_blank" + rel="noopener noreferrer" + class={baseBtn} + > + <Icon name="ph:markdown-logo" class="w-3.5 h-3.5" /> + View as Markdown + </a> + </> + )} + </div> +)} + +<script> + import "./page-actions.client"; +</script> diff --git a/packages/docs/src/components/ui/page-actions/index.ts b/packages/docs/src/components/ui/page-actions/index.ts new file mode 100644 index 00000000..3d7bcd1a --- /dev/null +++ b/packages/docs/src/components/ui/page-actions/index.ts @@ -0,0 +1 @@ +export { default as PageActions } from "./PageActions.astro"; diff --git a/packages/docs/src/components/ui/page-actions/page-actions.client.ts b/packages/docs/src/components/ui/page-actions/page-actions.client.ts new file mode 100644 index 00000000..0f4d9a42 --- /dev/null +++ b/packages/docs/src/components/ui/page-actions/page-actions.client.ts @@ -0,0 +1,54 @@ +import { mount } from "@cloudflare/nimbus-docs/client"; + +function initPageActions(root: HTMLElement): () => void { + const copyBtn = root.querySelector<HTMLButtonElement>("[data-nb-page-actions-copy]"); + const copyIcon = root.querySelector<SVGElement>("[data-nb-page-actions-copy-icon]"); + const checkIcon = root.querySelector<SVGElement>("[data-nb-page-actions-check-icon]"); + const label = root.querySelector<HTMLSpanElement>("[data-nb-page-actions-label]"); + const mdUrl = root.dataset.mdUrl; + + if (!copyBtn || !mdUrl) return () => {}; + + let resetTimer: number | undefined; + + function showState(state: "copied" | "error") { + if (!copyIcon || !checkIcon || !label) return; + if (state === "copied") { + copyIcon.classList.add("hidden"); + checkIcon.classList.remove("hidden"); + label.textContent = "Copied"; + } else { + label.textContent = "Couldn't copy"; + } + if (resetTimer) window.clearTimeout(resetTimer); + resetTimer = window.setTimeout(() => { + copyIcon.classList.remove("hidden"); + checkIcon.classList.add("hidden"); + label.textContent = "Copy page"; + }, 1500); + } + + async function handleCopyPage() { + try { + const res = await fetch(mdUrl!); + if (!res.ok) { + showState("error"); + return; + } + const text = await res.text(); + await navigator.clipboard.writeText(text); + showState("copied"); + } catch { + showState("error"); + } + } + + copyBtn.addEventListener("click", handleCopyPage); + + return () => { + if (resetTimer) window.clearTimeout(resetTimer); + copyBtn.removeEventListener("click", handleCopyPage); + }; +} + +mount("[data-nb-page-actions]", initPageActions); diff --git a/packages/docs/src/components/ui/pagination/Pagination.astro b/packages/docs/src/components/ui/pagination/Pagination.astro new file mode 100644 index 00000000..6c9687ed --- /dev/null +++ b/packages/docs/src/components/ui/pagination/Pagination.astro @@ -0,0 +1,36 @@ +--- +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { PrevNext } from "@cloudflare/nimbus-docs/types"; + +interface Props extends HTMLAttributes<"nav"> { + prevNext: PrevNext; +} + +const { prevNext, class: className, ...attrs } = Astro.props; +const { prev, next } = prevNext; +--- + +{(prev || next) && ( + <nav aria-label="Pagination" class={cn("flex items-center justify-between mt-12 pt-6 border-t border-border", className)} {...attrs}> + {prev ? ( + <a href={prev.href} class="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline"> + <Icon name="ph:caret-left" class="w-4 h-4 transition-transform group-hover:-translate-x-0.5" /> + <span> + <span class="block font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">Previous</span> + <span class="font-medium tracking-[-0.02em] text-foreground">{prev.label}</span> + </span> + </a> + ) : <span />} + {next ? ( + <a href={next.href} class="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors text-right no-underline"> + <span> + <span class="block font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">Next</span> + <span class="font-medium tracking-[-0.02em] text-foreground">{next.label}</span> + </span> + <Icon name="ph:caret-right" class="w-4 h-4 transition-transform group-hover:translate-x-0.5" /> + </a> + ) : <span />} + </nav> +)} diff --git a/packages/docs/src/components/ui/pagination/index.ts b/packages/docs/src/components/ui/pagination/index.ts new file mode 100644 index 00000000..8972ed7f --- /dev/null +++ b/packages/docs/src/components/ui/pagination/index.ts @@ -0,0 +1 @@ +export { default as Pagination } from "./Pagination.astro"; diff --git a/packages/docs/src/components/ui/search/SearchDialog.astro b/packages/docs/src/components/ui/search/SearchDialog.astro new file mode 100644 index 00000000..1fb0af6f --- /dev/null +++ b/packages/docs/src/components/ui/search/SearchDialog.astro @@ -0,0 +1,54 @@ +--- +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { Dialog, DialogClose, DialogContent } from "@/components/ui/dialog"; +--- + +<Dialog aria-label="Search documentation" data-search-dialog> + <DialogContent class="max-w-xl bg-muted shadow-xl ring-border"> + <div class="flex flex-col overflow-hidden rounded-t-lg bg-card border-b border-border"> + <div class="flex items-center gap-3 border-b border-border px-4 py-3"> + <Icon name="ph:magnifying-glass" class="h-4 w-4 shrink-0 text-muted-foreground" /> + <input + data-search-input + role="combobox" + aria-expanded="false" + aria-haspopup="listbox" + aria-autocomplete="list" + aria-controls="search-listbox" + type="text" + placeholder="Search documentation…" + class="min-w-0 flex-1 border-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + autocomplete="off" + spellcheck="false" + autocorrect="off" + autocapitalize="none" + /> + <DialogClose class="rounded border border-border bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"> + Esc + </DialogClose> + </div> + + <div + data-search-results + role="listbox" + id="search-listbox" + aria-orientation="vertical" + class="min-h-40 overflow-y-auto p-2" + > + <p data-search-empty class="py-8 text-center text-sm text-muted-foreground"> + Type to search… + </p> + </div> + </div> + + <div class="flex items-center gap-4 px-4 py-2.5 text-xs text-muted-foreground/80"> + <span>↑↓ navigate</span> + <span>↵ select</span> + <span>Esc close</span> + </div> + </DialogContent> +</Dialog> + +<script> + import "./search.client"; +</script> diff --git a/packages/docs/src/components/ui/search/SearchTrigger.astro b/packages/docs/src/components/ui/search/SearchTrigger.astro new file mode 100644 index 00000000..8f68c47c --- /dev/null +++ b/packages/docs/src/components/ui/search/SearchTrigger.astro @@ -0,0 +1,22 @@ +--- +/** Button that opens the search dialog. Cmd+K on macOS, Ctrl+K elsewhere. */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +--- + +<button + data-search-trigger + type="button" + class="flex items-center gap-2 rounded-md border border-border bg-transparent font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground p-2 sm:px-2.5 sm:py-1.5" + aria-label="Search documentation" + aria-keyshortcuts="Control+K" +> + <Icon name="ph:magnifying-glass" class="h-4 w-4" /> + <span class="hidden md:inline">Search</span> + <kbd class="hidden sm:inline-flex items-center gap-0.5 rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.625rem] font-medium uppercase tracking-[0.08em] text-muted-foreground"> + <span data-shortcut-key>Ctrl</span>K + </kbd> +</button> + +<script> + import "./search-trigger.client"; +</script> diff --git a/packages/docs/src/components/ui/search/index.ts b/packages/docs/src/components/ui/search/index.ts new file mode 100644 index 00000000..9f7c86a1 --- /dev/null +++ b/packages/docs/src/components/ui/search/index.ts @@ -0,0 +1,2 @@ +export { default as SearchDialog } from "./SearchDialog.astro"; +export { default as SearchTrigger } from "./SearchTrigger.astro"; diff --git a/packages/docs/src/components/ui/search/providers/pagefind.ts b/packages/docs/src/components/ui/search/providers/pagefind.ts new file mode 100644 index 00000000..3e9dc49a --- /dev/null +++ b/packages/docs/src/components/ui/search/providers/pagefind.ts @@ -0,0 +1,80 @@ +import type { SearchProvider, SearchResult } from "@cloudflare/nimbus-docs/types"; +import { config } from "virtual:nimbus/config"; + +interface PagefindSubResult { + title?: string; + url?: string; +} + +interface PagefindResultData { + url: string; + excerpt?: string; + meta?: { title?: string }; + sub_results?: PagefindSubResult[]; +} + +interface PagefindSearchResponse { + results: Array<{ data(): Promise<PagefindResultData> }>; +} + +interface PagefindFilters { + [key: string]: string | string[] | { none?: string | string[]; any?: string | string[] }; +} + +interface PagefindApi { + init(): Promise<void>; + search(query: string, options?: { filters?: PagefindFilters }): Promise<PagefindSearchResponse>; +} + +let pagefind: PagefindApi | undefined; + +/** + * Default Pagefind filters applied to every search. + * + * Versioning: when the site has a `versions.deprecated` list, the + * layout emits `data-pagefind-filter="status:deprecated"` on every + * deprecated-version page. Search defaults to excluding those results + * (readers searching for "auth" want the current version's auth page, + * not the deprecated one). Future UI work can expose a "include + * deprecated" toggle; for now the default is current + non-deprecated. + * + * Versions are still searchable individually — readers on a v0 page + * who explicitly search from there can opt the UI into a version-scoped + * filter. The default exclusion is just for the top-level search. + * + * Computed at module-import time so we don't pay the config lookup on + * every keystroke. + */ +const defaultFilters: PagefindFilters | undefined = + config.versions && config.versions.deprecated && config.versions.deprecated.length > 0 + ? { status: { none: "deprecated" } } + : undefined; + +export const provider: SearchProvider = { + async init() { + if (pagefind) return; + const baseUrl = new URL(import.meta.env.BASE_URL ?? "/", window.location.origin); + const pagefindUrl = new URL("pagefind/pagefind.js", baseUrl); + pagefind = (await import(/* @vite-ignore */ pagefindUrl.href)) as PagefindApi; + await pagefind.init(); + }, + + async search(query) { + if (!pagefind) await this.init?.(); + if (!pagefind) return []; + + const search = await pagefind.search( + query, + defaultFilters ? { filters: defaultFilters } : undefined, + ); + const results = await Promise.all(search.results.slice(0, 10).map((result) => result.data())); + return results.map((result): SearchResult => ({ + title: result.meta?.title ?? "Untitled", + url: result.url, + snippet: result.excerpt, + subResults: result.sub_results + ?.filter((sub): sub is Required<PagefindSubResult> => Boolean(sub.title && sub.url)) + .map((sub) => ({ title: sub.title, url: sub.url })), + })); + }, +}; diff --git a/packages/docs/src/components/ui/search/search-trigger.client.ts b/packages/docs/src/components/ui/search/search-trigger.client.ts new file mode 100644 index 00000000..7e75f014 --- /dev/null +++ b/packages/docs/src/components/ui/search/search-trigger.client.ts @@ -0,0 +1,17 @@ +/** Sets the platform-correct shortcut hint on the search trigger (⌘ on macOS, Ctrl elsewhere). */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +mount("[data-search-trigger]", (btn) => { + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform ?? ""; + const isMac = platform + ? /mac/i.test(platform) + : /mac|iphone|ipod|ipad/i.test(navigator.userAgent); + if (isMac) { + btn.setAttribute("aria-keyshortcuts", "Meta+K"); + const key = btn.querySelector("[data-shortcut-key]"); + if (key) key.textContent = "⌘"; + } + return () => {}; +}); diff --git a/packages/docs/src/components/ui/search/search.client.ts b/packages/docs/src/components/ui/search/search.client.ts new file mode 100644 index 00000000..95d1b19b --- /dev/null +++ b/packages/docs/src/components/ui/search/search.client.ts @@ -0,0 +1,275 @@ +import { mount } from "@cloudflare/nimbus-docs/client"; +import type { SearchProvider, SearchResult } from "@cloudflare/nimbus-docs/types"; +import { provider } from "./providers/pagefind"; + +export interface SearchConfig { + input: HTMLInputElement; + resultsContainer: HTMLElement; + emptyState: HTMLElement; + provider: SearchProvider; + onNavigate?: () => void; +} + +export interface SearchInstance { + reset(): Promise<void>; + destroy(): void; +} + +export function initSearch(config: SearchConfig): SearchInstance { + const { input, resultsContainer, emptyState, provider, onNavigate } = config; + + let initialized = false; + let activeIndex = -1; + let resultIdCounter = 0; + let debounceTimer: ReturnType<typeof setTimeout> | undefined; + let activeController: AbortController | undefined; + + function getOptions(): HTMLElement[] { + return Array.from(resultsContainer.querySelectorAll<HTMLElement>("[role='option']")); + } + + function updateActive(newIndex: number): void { + const options = getOptions(); + if (options.length === 0) { + activeIndex = -1; + input.removeAttribute("aria-activedescendant"); + return; + } + activeIndex = Math.max(-1, Math.min(newIndex, options.length - 1)); + options.forEach((option, index) => { + if (index === activeIndex) { + option.setAttribute("data-highlighted", ""); + option.scrollIntoView({ block: "nearest" }); + input.setAttribute("aria-activedescendant", option.id); + } else { + option.removeAttribute("data-highlighted"); + } + }); + if (activeIndex < 0) input.removeAttribute("aria-activedescendant"); + } + + function clearResults(): void { + for (const result of resultsContainer.querySelectorAll("[role='option']")) result.remove(); + input.setAttribute("aria-expanded", "false"); + input.removeAttribute("aria-activedescendant"); + } + + function resultLink(title: string, href: string, className: string): HTMLAnchorElement { + const link = document.createElement("a"); + link.href = href; + link.className = className; + link.textContent = title; + link.addEventListener("click", () => onNavigate?.()); + return link; + } + + function buildResult(result: SearchResult): HTMLElement { + const option = document.createElement("div"); + option.id = `search-result-${resultIdCounter++}`; + option.setAttribute("role", "option"); + option.className = "rounded-lg px-2 py-2 transition-colors cursor-pointer hover:bg-accent focus-within:bg-accent data-[highlighted]:bg-accent"; + + const link = resultLink(result.title, result.url, "block truncate text-sm font-medium text-foreground no-underline focus-visible:outline-none"); + option.appendChild(link); + + if (result.snippet) { + const snippet = document.createElement("p"); + snippet.className = "mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground"; + snippet.innerHTML = result.snippet; + option.appendChild(snippet); + } + + if (result.subResults?.length) { + const subList = document.createElement("div"); + subList.className = "mt-2 border-l border-border pl-3"; + for (const sub of result.subResults.slice(0, 3)) { + subList.appendChild(resultLink(sub.title, sub.url, "block truncate py-0.5 text-xs text-muted-foreground no-underline hover:text-foreground")); + } + option.appendChild(subList); + } + + option.addEventListener("click", (event) => { + if ((event.target as Element | null)?.closest("a")) return; + link.click(); + }); + + return option; + } + + async function ensureInitialized(): Promise<boolean> { + if (initialized) return true; + try { + await provider.init?.(); + initialized = true; + return true; + } catch { + emptyState.textContent = "Search is available after a production build."; + return false; + } + } + + async function runSearch(query: string): Promise<void> { + activeController?.abort(); + activeController = new AbortController(); + const signal = activeController.signal; + + emptyState.style.display = ""; + emptyState.textContent = "Searching…"; + clearResults(); + + if (!(await ensureInitialized()) || signal.aborted) return; + + try { + const results = await provider.search(query, { signal }); + if (signal.aborted) return; + + clearResults(); + activeIndex = -1; + + if (results.length === 0) { + emptyState.style.display = ""; + emptyState.textContent = "No results found."; + return; + } + + emptyState.style.display = "none"; + input.setAttribute("aria-expanded", "true"); + for (const result of results) resultsContainer.appendChild(buildResult(result)); + } catch { + if (signal.aborted) return; + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Search is temporarily unavailable."; + } + } + + function handleInput(): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const query = input.value.trim(); + if (!query) { + activeController?.abort(); + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Type to search…"; + return; + } + void runSearch(query); + }, 150); + } + + function handleKeydown(event: KeyboardEvent): void { + const options = getOptions(); + if (event.key === "ArrowDown") { + event.preventDefault(); + updateActive(activeIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + updateActive(activeIndex - 1); + } else if (event.key === "Home") { + event.preventDefault(); + updateActive(0); + } else if (event.key === "End") { + event.preventDefault(); + updateActive(options.length - 1); + } else if (event.key === "Enter" && activeIndex >= 0) { + event.preventDefault(); + options[activeIndex]?.querySelector<HTMLAnchorElement>("a")?.click(); + } + } + + input.addEventListener("input", handleInput); + input.closest("dialog")?.addEventListener("keydown", handleKeydown); + + return { + async reset() { + activeController?.abort(); + if (debounceTimer) clearTimeout(debounceTimer); + input.value = ""; + input.focus(); + activeIndex = -1; + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Type to search…"; + await ensureInitialized(); + }, + destroy() { + activeController?.abort(); + if (debounceTimer) clearTimeout(debounceTimer); + input.removeEventListener("input", handleInput); + input.closest("dialog")?.removeEventListener("keydown", handleKeydown); + }, + }; +} + +// --------------------------------------------------------------------------- +// Bootstrap — imported for its side effects by SearchDialog.astro +// (`import "./search.client"`). Wires each dialog through mount() and binds the +// global open shortcut once. +// --------------------------------------------------------------------------- + +type SearchDialogElement = HTMLDialogElement & { + __openSearchDialog?: () => void; +}; + +function primaryDialog(): SearchDialogElement | null { + return document.querySelector<SearchDialogElement>("[data-search-dialog][data-search-ready]"); +} + +// The open shortcut and trigger delegation live on `document`, which survives +// view transitions, so they are bound once for the page's lifetime — never +// through mount()'s per-element setup/teardown. A module-scoped boolean (not an +// <html> attribute) is the guard: ClientRouter resets <html> on every swap but +// keeps document listeners, so an attribute guard would stack a duplicate +// keydown handler each navigation (Cmd+K then toggles twice). +let globalsBound = false; + +function bindGlobals() { + if (globalsBound) return; + globalsBound = true; + + document.addEventListener("click", (event) => { + const trigger = (event.target as Element | null)?.closest("[data-search-trigger]"); + if (!trigger) return; + primaryDialog()?.__openSearchDialog?.(); + }); + + document.addEventListener("keydown", (event) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return; + const dialog = primaryDialog(); + if (!dialog) return; + event.preventDefault(); + if (dialog.open) dialog.close(); + else dialog.__openSearchDialog?.(); + }); +} + +// Per-element wiring: idempotent discovery now and on astro:page-load, teardown +// on astro:before-swap. Replaces the hand-rolled data-search-ready init loop; +// data-search-ready is now just the "wired" marker primaryDialog() selects on. +mount("[data-search-dialog]", (root) => { + const dialog = root as SearchDialogElement; + dialog.setAttribute("data-search-ready", "true"); + + const input = dialog.querySelector<HTMLInputElement>("[data-search-input]"); + const resultsContainer = dialog.querySelector<HTMLElement>("[data-search-results]"); + const emptyState = dialog.querySelector<HTMLElement>("[data-search-empty]"); + if (!input || !resultsContainer || !emptyState) return () => {}; + + const search = initSearch({ + input, + resultsContainer, + emptyState, + provider, + onNavigate: () => dialog.close(), + }); + + dialog.__openSearchDialog = () => { + if (!dialog.open) dialog.showModal(); + void search.reset(); + }; + + return () => search.destroy(); +}); + +bindGlobals(); diff --git a/packages/docs/src/components/ui/sidebar/Sidebar.astro b/packages/docs/src/components/ui/sidebar/Sidebar.astro new file mode 100644 index 00000000..5795b0bb --- /dev/null +++ b/packages/docs/src/components/ui/sidebar/Sidebar.astro @@ -0,0 +1,65 @@ +--- +/** + * Sidebar — recursive navigation tree from a `SidebarItem[]`. Composes + * SidebarGroup + SidebarLink. Pass `persist` to opt into sessionStorage + * for open/scroll state (desktop only). + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import SidebarGroup from "./SidebarGroup.astro"; +import SidebarLink from "./SidebarLink.astro"; +import type { SidebarItem } from "@cloudflare/nimbus-docs/types"; +import { sidebarHash } from "@cloudflare/nimbus-docs"; + +interface Props extends HTMLAttributes<"div"> { + items: SidebarItem[]; + /** Persist open/scroll state to sessionStorage. Desktop sidebar only. */ + persist?: boolean; +} + +const { items, persist = false, class: className, ...attrs } = Astro.props; +const hash = sidebarHash(items); +--- + +<div + data-nb-sidebar + data-nb-sidebar-hash={hash} + data-nb-sidebar-persist={persist ? "" : undefined} + class={cn(className)} + {...attrs} +> + <ul class="top-level flex list-none flex-col gap-0.5 p-0"> + {items.map((item) => + item.type === "group" ? ( + <li> + <SidebarGroup + label={item.label} + items={item.children} + collapsed={item.collapsed} + badge={item.badge} + icon={item.icon} + indexHref={item.indexHref} + indexIsCurrent={item.indexIsCurrent} + indexIsExternal={item.indexIsExternal} + /> + </li> + ) : item.type === "external" ? ( + <li> + <SidebarLink label={item.label} href={item.href} badge={item.badge} target="_blank" rel="noopener" /> + </li> + ) : ( + <li> + <SidebarLink label={item.label} href={item.href} isCurrent={item.isCurrent} badge={item.badge} /> + </li> + ), + )} + </ul> +</div> + +<script> + import "./sidebar.client"; +</script> + +<style is:global> + [data-nb-sidebar-hidden] { display: none; } +</style> diff --git a/packages/docs/src/components/ui/sidebar/SidebarFilter.astro b/packages/docs/src/components/ui/sidebar/SidebarFilter.astro new file mode 100644 index 00000000..1f569204 --- /dev/null +++ b/packages/docs/src/components/ui/sidebar/SidebarFilter.astro @@ -0,0 +1,28 @@ +--- +/** SidebarFilter — text input that filters the adjacent Sidebar. Press "/" to focus. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + placeholder?: string; +} + +const { class: className, placeholder = "Filter…", ...attrs } = Astro.props; +--- + +<div class={cn("relative mb-3", className)} {...attrs}> + <input + data-nb-sidebar-filter-input + type="search" + placeholder={placeholder} + class="peer w-full rounded-md border border-border bg-card py-1.5 pl-3 pr-9 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus-visible:border-brand focus-visible:outline-2 focus-visible:outline-ring [&:not(:placeholder-shown)]:pr-2" + aria-label="Filter navigation" + autocomplete="off" + /> + {/* Shortcut hint — press "/" to focus. Hidden once the input is focused or has a value. */} + <kbd + aria-hidden="true" + class="pointer-events-none absolute right-2 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded border border-border bg-muted px-1 font-mono text-[0.6875rem] font-semibold leading-none text-muted-foreground transition-opacity duration-150 peer-focus:opacity-0 peer-[:not(:placeholder-shown)]:opacity-0 motion-reduce:transition-none" + >/</kbd + > +</div> diff --git a/packages/docs/src/components/ui/sidebar/SidebarGroup.astro b/packages/docs/src/components/ui/sidebar/SidebarGroup.astro new file mode 100644 index 00000000..2975d766 --- /dev/null +++ b/packages/docs/src/components/ui/sidebar/SidebarGroup.astro @@ -0,0 +1,198 @@ +--- +/** + * SidebarGroup — autogenerated section header in the sidebar rail. + * + * Renders in one of two shapes depending on whether the group has a + * landing page (`indexHref`): + * + * - Has landing: the label is an `<a>` linking to indexHref. The + * collapse caret sits next to it as a separate `<button>`. The + * group label IS the link to the landing page; children are listed + * separately below. + * - No landing: the entire row is a single `<button>` that toggles + * the collapse. Label is non-interactive. Used for directories + * without an `index.mdx`, where the group is a pure visual section + * divider over its children. + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; +import SidebarLink from "./SidebarLink.astro"; +import { Badge } from "@/components/ui/badge"; +import type { SidebarItem, SidebarBadge } from "@cloudflare/nimbus-docs/types"; + +interface Props extends HTMLAttributes<"div"> { + label: string; + items: SidebarItem[]; + collapsed?: boolean; + badge?: SidebarBadge; + /** Landing-page URL when the group has an `index.mdx`. Renders the label as a link. */ + indexHref?: string; + /** True when the landing page is the current route. */ + indexIsCurrent?: boolean; + /** True when `indexHref` is an off-site URL — render with target="_blank" rel="noopener". */ + indexIsExternal?: boolean; + /** Optional leading icon (astro-icon name) rendered before the label. */ + icon?: string; +} + +const { + label, + items, + collapsed, + badge, + indexHref, + indexIsCurrent, + indexIsExternal, + icon, + class: className, + ...attrs +} = Astro.props; + +function hasActiveDescendant(items: SidebarItem[]): boolean { + return items.some((item) => + item.type === "link" + ? Boolean(item.isCurrent) + : item.type === "group" + ? Boolean(item.indexIsCurrent) || hasActiveDescendant(item.children) + : false, + ); +} + +const hasActive = Boolean(indexIsCurrent) || hasActiveDescendant(items); +const isOpen = Boolean(hasActive || collapsed === false || collapsed === undefined); + +// Shared classes for the row that holds the label + caret in both +// rendering modes (landing-as-link vs. label-as-trigger). +const rowClass = cn( + "group/expander flex min-h-[1.875rem] items-center rounded-md px-2.5 py-1 font-mono text-[0.6875rem] uppercase tracking-[0.14em] no-underline transition-colors duration-150 focus-visible:outline-offset-[-2px]", + "hover:bg-accent hover:text-foreground", + hasActive ? "text-foreground" : "text-muted-foreground", +); +// Chevron icon classes. Rotation is driven by an explicit CSS rule in +// `<style is:global>` at the bottom of this file. +const caretClass = + "ml-auto -mr-px shrink-0 w-5 h-5 text-muted-foreground opacity-50 transition-[rotate,color] duration-[250ms] ease-[cubic-bezier(0.87,0,0.13,1)] group-hover/expander:text-foreground"; +--- + +<Collapsible + class={cn("group/accordion", className)} + open={isOpen} + data-nb-sidebar-group + {...attrs} +> + {indexHref ? ( + <div + class={cn( + rowClass, + "p-0", + // Active state covers the WHOLE row (label-link + chevron), + // not just the link half. + indexIsCurrent ? "bg-accent text-foreground font-semibold" : "", + )} + data-nb-state={isOpen ? "open" : "closed"} + data-nb-sidebar-group-label + > + <a + href={indexHref} + aria-current={indexIsCurrent ? "page" : undefined} + target={indexIsExternal ? "_blank" : undefined} + rel={indexIsExternal ? "noopener" : undefined} + class={cn( + "flex flex-1 min-w-0 items-center min-h-[2rem] px-3 py-1 rounded-l-lg focus-visible:outline-offset-[-2px]", + )} + > + <span class="flex items-center gap-2 flex-1 min-w-0"> + {icon && <Icon name={icon} class="shrink-0 h-4 w-4 text-muted-foreground" />} + <span class="break-words">{label}</span> + {badge && + (typeof badge === "string" ? ( + <Badge text={badge} /> + ) : ( + <Badge text={badge.text} variant={badge.variant} /> + ))} + </span> + </a> + <CollapsibleTrigger + class={cn( + // `w-auto` overrides CollapsibleTrigger's default `w-full`, + // which would otherwise compete with the label-link's + // `flex-1` and starve the label's width. + "w-auto flex items-center shrink-0 min-h-[2rem] px-1.5 py-1 rounded-r-lg", + "hover:bg-accent hover:text-foreground focus-visible:outline-offset-[-2px]", + // The chevron icon uses `group-data-[nb-state=open]/expander:rotate-90` + // to rotate on toggle. That variant resolves to the nearest + // ancestor with `group/expander` *AND* `data-nb-state="open"`. + // The disclosure JS only updates `data-nb-state` on the + // trigger and content elements (not the outer row container), + // so the trigger itself must carry `group/expander` — + // otherwise the icon's class would match the outer row, + // whose `data-nb-state` is stale after the first click. + "group/expander", + )} + data-nb-state={isOpen ? "open" : "closed"} + aria-expanded={isOpen ? "true" : "false"} + aria-label={`Toggle ${label} section`} + > + <Icon name="ph:caret-right" class={caretClass} data-nb-caret /> + </CollapsibleTrigger> + </div> + ) : ( + <CollapsibleTrigger + class={cn(rowClass, "justify-between w-full")} + data-nb-sidebar-group-label + data-nb-state={isOpen ? "open" : "closed"} + aria-expanded={isOpen ? "true" : "false"} + > + <span class="flex items-center gap-2 flex-1 min-w-0"> + {icon && <Icon name={icon} class="shrink-0 h-4 w-4 text-muted-foreground" />} + <span class="break-words">{label}</span> + {badge && + (typeof badge === "string" ? ( + <Badge text={badge} /> + ) : ( + <Badge text={badge.text} variant={badge.variant} /> + ))} + </span> + <Icon name="ph:caret-right" class={caretClass} data-nb-caret /> + </CollapsibleTrigger> + )} + + <CollapsibleContent data-nb-state={isOpen ? "open" : "closed"}> + <ul class="mt-0.5 ml-3 list-none border-l border-border p-0 pl-2 flex flex-col gap-px"> + {items.map((item) => ( + <li class="break-words"> + {item.type === "group" ? ( + <Astro.self + label={item.label} + items={item.children} + collapsed={item.collapsed} + badge={item.badge} + icon={item.icon} + indexHref={item.indexHref} + indexIsCurrent={item.indexIsCurrent} + indexIsExternal={item.indexIsExternal} + /> + ) : item.type === "external" ? ( + <SidebarLink label={item.label} href={item.href} badge={item.badge} target="_blank" rel="noopener" /> + ) : ( + <SidebarLink label={item.label} href={item.href} isCurrent={item.isCurrent} badge={item.badge} /> + )} + </li> + ))} + </ul> + </CollapsibleContent> +</Collapsible> + +<style is:global> + /* + * Chevron rotation, driven by the trigger's `data-nb-state`. The + * disclosure runtime (`@/components/ui/collapsible/collapsible.client`) + * keeps this attribute in sync on toggle; this rule turns the + * attribute change into the visual rotation. + */ + [data-nb-collapsible-trigger][data-nb-state="open"] [data-nb-caret] { + rotate: 90deg; + } +</style> diff --git a/packages/docs/src/components/ui/sidebar/SidebarLink.astro b/packages/docs/src/components/ui/sidebar/SidebarLink.astro new file mode 100644 index 00000000..8abcd535 --- /dev/null +++ b/packages/docs/src/components/ui/sidebar/SidebarLink.astro @@ -0,0 +1,37 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import { Badge } from "@/components/ui/badge"; +import type { SidebarBadge } from "@cloudflare/nimbus-docs/types"; + +interface Props extends HTMLAttributes<"a"> { + label: string; + href: string; + isCurrent?: boolean; + badge?: SidebarBadge; +} + +const { label, href, isCurrent, badge, class: className, ...attrs } = Astro.props; +--- + +<a + href={href} + aria-current={isCurrent ? "page" : undefined} + data-nb-sidebar-link + class={cn( + "flex min-h-[1.875rem] items-center gap-2 rounded-md px-2.5 py-1 text-[0.8125rem] font-medium no-underline focus-visible:outline-offset-[-2px]", + isCurrent + ? "bg-accent text-foreground" + : "text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground", + className, + )} + {...attrs} +> + <span class="break-words">{label}</span> + {badge && + (typeof badge === "string" ? ( + <Badge text={badge} /> + ) : ( + <Badge text={badge.text} variant={badge.variant} /> + ))} +</a> diff --git a/packages/docs/src/components/ui/sidebar/index.ts b/packages/docs/src/components/ui/sidebar/index.ts new file mode 100644 index 00000000..8b4c73ae --- /dev/null +++ b/packages/docs/src/components/ui/sidebar/index.ts @@ -0,0 +1,4 @@ +export { default as Sidebar } from "./Sidebar.astro"; +export { default as SidebarFilter } from "./SidebarFilter.astro"; +export { default as SidebarGroup } from "./SidebarGroup.astro"; +export { default as SidebarLink } from "./SidebarLink.astro"; diff --git a/packages/docs/src/components/ui/sidebar/sidebar.client.ts b/packages/docs/src/components/ui/sidebar/sidebar.client.ts new file mode 100644 index 00000000..27aad7c6 --- /dev/null +++ b/packages/docs/src/components/ui/sidebar/sidebar.client.ts @@ -0,0 +1,209 @@ +/** Sidebar runtime: filter, persistence, "/" shortcut. */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +const STORAGE_KEY = "sidebar-state"; + +interface SidebarState { + hash: string; + open: boolean[]; + scroll: number; +} + +function initSidebar(root: HTMLElement): () => void { + const teardowns: Array<() => void> = []; + const persist = root.hasAttribute("data-nb-sidebar-persist"); + + const filterTeardown = initFilter(root); + if (filterTeardown) teardowns.push(filterTeardown); + + if (persist) { + const persistTeardown = initPersistence(root); + if (persistTeardown) teardowns.push(persistTeardown); + } + + return () => teardowns.forEach((t) => t()); +} + +// --------------------------------------------------------------------------- +// Filter +// --------------------------------------------------------------------------- + +function initFilter(root: HTMLElement): (() => void) | null { + const input = root.querySelector<HTMLInputElement>("[data-nb-sidebar-filter-input]"); + // SidebarFilter is rendered *next to* Sidebar (sibling), so also look in + // the parent — preserves the existing layout where filter sits above. + const inputElement = + input ?? root.parentElement?.querySelector<HTMLInputElement>("[data-nb-sidebar-filter-input]") ?? null; + if (!inputElement) return null; + + function handleInput() { + const query = inputElement!.value.trim().toLowerCase(); + if (!query) { + resetFilter(root); + return; + } + applyFilter(root, query); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") { + inputElement!.value = ""; + handleInput(); + inputElement!.blur(); + } + } + + inputElement.addEventListener("input", handleInput); + inputElement.addEventListener("keydown", handleKeydown); + + return () => { + inputElement.removeEventListener("input", handleInput); + inputElement.removeEventListener("keydown", handleKeydown); + resetFilter(root); + }; +} + +function resetFilter(root: HTMLElement): void { + root.querySelectorAll<HTMLElement>("[data-nb-sidebar-hidden]").forEach((el) => { + el.removeAttribute("data-nb-sidebar-hidden"); + }); + // Reset groups opened by the filter back to their saved state. + root + .querySelectorAll<HTMLElement>("[data-nb-sidebar-group][data-nb-opened-by-filter]") + .forEach((group) => { + const trigger = group.querySelector<HTMLElement>("[data-nb-collapsible-trigger]"); + trigger?.click(); + group.removeAttribute("data-nb-opened-by-filter"); + }); +} + +function applyFilter(root: HTMLElement, query: string): void { + const links = root.querySelectorAll<HTMLElement>("[data-nb-sidebar-link]"); + const groups = root.querySelectorAll<HTMLElement>("[data-nb-sidebar-group]"); + + links.forEach((link) => link.setAttribute("data-nb-sidebar-hidden", "")); + groups.forEach((group) => group.setAttribute("data-nb-sidebar-hidden", "")); + + links.forEach((link) => { + const text = link.textContent?.toLowerCase() ?? ""; + if (!text.includes(query)) return; + link.removeAttribute("data-nb-sidebar-hidden"); + revealAncestors(link, root); + }); + + groups.forEach((group) => { + const label = group.querySelector("[data-nb-sidebar-group-label]"); + const text = label?.textContent?.toLowerCase() ?? ""; + if (!text.includes(query)) return; + group.removeAttribute("data-nb-sidebar-hidden"); + openGroup(group); + group.querySelectorAll<HTMLElement>("[data-nb-sidebar-link], [data-nb-sidebar-group]") + .forEach((child) => child.removeAttribute("data-nb-sidebar-hidden")); + }); +} + +function revealAncestors(el: HTMLElement, scope: Element): void { + let parent: HTMLElement | null = el.parentElement; + while (parent && parent !== scope) { + if (parent.hasAttribute("data-nb-sidebar-group")) { + parent.removeAttribute("data-nb-sidebar-hidden"); + openGroup(parent); + } + parent = parent.parentElement; + } +} + +function openGroup(group: HTMLElement): void { + const trigger = group.querySelector<HTMLElement>("[data-nb-collapsible-trigger]"); + if (!trigger) return; + if (trigger.getAttribute("data-nb-state") === "open") return; + group.setAttribute("data-nb-opened-by-filter", ""); + trigger.click(); +} + +// --------------------------------------------------------------------------- +// Persistence (open state + scroll) +// --------------------------------------------------------------------------- + +function initPersistence(root: HTMLElement): (() => void) | null { + // The scrollable container is the closest <aside> or the root itself. + const scrollHost: HTMLElement = root.closest("aside") ?? root; + const hash = root.dataset.nbSidebarHash ?? ""; + + function readState(): SidebarState { + const groups = root.querySelectorAll<HTMLElement>("[data-nb-sidebar-group]"); + const open: boolean[] = []; + groups.forEach((group) => { + const trigger = group.querySelector<HTMLElement>("[data-nb-collapsible-trigger]"); + open.push(trigger?.getAttribute("data-nb-state") === "open"); + }); + return { hash, open, scroll: scrollHost.scrollTop }; + } + + function save() { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(readState())); + } catch {} + } + + // Observe state changes on each group's trigger. + const observer = new MutationObserver(save); + root.querySelectorAll<HTMLElement>("[data-nb-collapsible-trigger]").forEach((trigger) => { + observer.observe(trigger, { + attributes: true, + attributeFilter: ["data-nb-state"], + }); + }); + + function handleVisibility() { + if (document.visibilityState === "hidden") save(); + } + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("pagehide", save); + + let raf = 0; + function handleScroll() { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(save); + } + scrollHost.addEventListener("scroll", handleScroll); + + return () => { + observer.disconnect(); + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("pagehide", save); + scrollHost.removeEventListener("scroll", handleScroll); + cancelAnimationFrame(raf); + }; +} + +// --------------------------------------------------------------------------- +// Global `/` shortcut — bound once at module load +// --------------------------------------------------------------------------- + +(function bindFilterShortcut() { + if (document.documentElement.hasAttribute("data-nb-sidebar-shortcut-bound")) return; + document.documentElement.setAttribute("data-nb-sidebar-shortcut-bound", ""); + + document.addEventListener("keydown", (e) => { + if (e.key !== "/") return; + const active = document.activeElement as HTMLElement | null; + if ( + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable) + ) { + return; + } + const desktopInput = document.querySelector<HTMLInputElement>( + "[data-nb-sidebar-persist] ~ * [data-nb-sidebar-filter-input], [data-nb-desktop-sidebar] [data-nb-sidebar-filter-input]", + ); + if (!desktopInput) return; + e.preventDefault(); + desktopInput.focus(); + }); +})(); + +mount("[data-nb-sidebar]", initSidebar); diff --git a/packages/docs/src/components/ui/steps/Step.astro b/packages/docs/src/components/ui/steps/Step.astro new file mode 100644 index 00000000..e63869dc --- /dev/null +++ b/packages/docs/src/components/ui/steps/Step.astro @@ -0,0 +1,26 @@ +--- +/** + * Step — a single step inside <Steps>. Alternative to authoring an + * <ol><li> directly. + * + * <Steps> + * <Step title="Install">Run <code>npm install</code>.</Step> + * <Step title="Configure">Edit <code>config.json</code>.</Step> + * </Steps> + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + title?: string; +} + +const { title, class: className, ...attrs } = Astro.props; +--- + +<div data-step class={cn(className)} {...attrs}> + {title && <p class="m-0 font-semibold text-foreground">{title}</p>} + <div class="mt-1 text-sm leading-snug text-foreground [&_p:first-child]:mt-0 [&_p:last-child]:mb-0"> + <slot /> + </div> +</div> diff --git a/packages/docs/src/components/ui/steps/Steps.astro b/packages/docs/src/components/ui/steps/Steps.astro new file mode 100644 index 00000000..10bb3302 --- /dev/null +++ b/packages/docs/src/components/ui/steps/Steps.astro @@ -0,0 +1,115 @@ +--- +/** + * Steps — ordered list with numbered circles and connecting lines. + * Wrap a markdown ordered list. Set `start` to offset the counter. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + start?: number; +} + +const { start, class: className, ...attrs } = Astro.props; +--- + +<div + data-steps + class={cn("steps", className)} + style={start !== undefined ? `--steps-start: ${start};` : undefined} + {...attrs} +> + <slot /> +</div> + +<script> + import "./steps.client"; +</script> + +<style> + .steps { + --_start: var(--steps-start, 1); + margin: 1.5rem 0; + counter-reset: step calc(var(--_start) - 1); + } + + .steps :global(ol) { + counter-reset: step calc(var(--_start) - 1); + list-style: none; + margin: 0; + padding: 0; + } + + /* Markdown mode (ol > li) AND component mode ([data-step]) share styling. + Grid layout so the circle (col 1) lines up with the first row of column 2 + via `align-items: center` — no font-metric guesswork. */ + .steps :global(ol > li), + .steps :global([data-step]) { + display: grid; + grid-template-columns: 1.75rem 1fr; + column-gap: 0.75rem; + row-gap: 0.5rem; + align-items: center; + counter-increment: step; + position: relative; + min-height: 1.75rem; + /* Cancel the prose li margin — step rhythm comes from padding/row-gap. */ + margin: 0; + padding-bottom: 1.5rem; + } + + .steps :global(ol > li:last-child), + .steps :global([data-step]:last-child) { + padding-bottom: 0; + } + + /* All non-marker content lives in column 2 — prevents the second+ child + from falling into column 1 (which is reserved for the circle). The + child selector lives inside :global() so Astro's scope hash doesn't + skip elements coming from Step.astro. */ + .steps :global(ol > li > *), + .steps :global([data-step] > *) { + grid-column: 2; + min-width: 0; + } + + /* Number marker — auto-placed in row 1 col 1, centered with col 2's first row. */ + .steps :global(ol > li)::before, + .steps :global([data-step])::before { + content: counter(step); + grid-row: 1; + grid-column: 1; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.5rem; + background: var(--nb-accent); + color: var(--nb-foreground); + border: 1px solid var(--nb-border-strong); + font-size: 0.6875rem; + font-weight: 600; + display: grid; + place-items: center; + z-index: 1; + } + + /* Connecting line — runs from below the circle to the bottom of the step. */ + .steps :global(ol > li:not(:last-child))::after, + .steps :global([data-step]:not(:last-child))::after { + content: ""; + position: absolute; + left: calc(1.75rem / 2 - 0.75px); + top: 1.75rem; + bottom: 0; + width: 1.5px; + background: var(--nb-border); + } + + .steps :global(ol > li > p:first-child > strong:only-child) { + color: var(--nb-foreground); + } + + /* Vertical rhythm comes from the grid row-gap, not p margins. */ + .steps :global(ol > li > p) { + margin: 0; + } +</style> diff --git a/packages/docs/src/components/ui/steps/index.ts b/packages/docs/src/components/ui/steps/index.ts new file mode 100644 index 00000000..ae327b22 --- /dev/null +++ b/packages/docs/src/components/ui/steps/index.ts @@ -0,0 +1,2 @@ +export { default as Steps } from "./Steps.astro"; +export { default as Step } from "./Step.astro"; diff --git a/packages/docs/src/components/ui/steps/steps.client.ts b/packages/docs/src/components/ui/steps/steps.client.ts new file mode 100644 index 00000000..c0b727a2 --- /dev/null +++ b/packages/docs/src/components/ui/steps/steps.client.ts @@ -0,0 +1,32 @@ +/** + * steps.client.ts — Safari list-role restoration. + * + * Safari strips list semantics when `list-style: none` is applied + * (which we do for the numbered counter styling). Restoring `role="list"` + * on the inner `<ol>` makes VoiceOver announce the item count again. + */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +function initSteps(root: HTMLElement): () => void { + const lists = root.querySelectorAll<HTMLOListElement>("ol"); + if ( + import.meta.env.DEV && + lists.length === 0 && + root.querySelector("[data-step]") === null && + root.children.length > 0 + ) { + console.warn( + "[nimbus] <Steps> expects an ordered list (`1.` items) or <Step> " + + "children. A bullet list renders with no numbers or connectors — " + + "use an ordered list.", + ); + } + lists.forEach((ol) => ol.setAttribute("role", "list")); + + return () => { + lists.forEach((ol) => ol.removeAttribute("role")); + }; +} + +mount("[data-steps]", initSteps); diff --git a/packages/docs/src/components/ui/tabs/TabItem.astro b/packages/docs/src/components/ui/tabs/TabItem.astro new file mode 100644 index 00000000..52e9195f --- /dev/null +++ b/packages/docs/src/components/ui/tabs/TabItem.astro @@ -0,0 +1,30 @@ +--- +/** + * TabItem — single tab panel in the `<Tabs>` component. + * + * The `label` prop is read by `tabs.client.ts` to synthesize the matching + * trigger button. Each TabItem becomes one `[data-nb-tabs-content]` panel. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + label: string; +} + +const { label, class: className, ...attrs } = Astro.props; + +if (!label) { + throw new Error("Missing required `label` prop on `<TabItem>` component."); +} +--- + +<div + role="tabpanel" + data-nb-tabs-content + data-nb-tab-label={label} + class={cn(className)} + {...attrs} +> + <slot /> +</div> diff --git a/packages/docs/src/components/ui/tabs/Tabs.astro b/packages/docs/src/components/ui/tabs/Tabs.astro new file mode 100644 index 00000000..e5c356fa --- /dev/null +++ b/packages/docs/src/components/ui/tabs/Tabs.astro @@ -0,0 +1,36 @@ +--- +/** + * <Tabs syncKey="pkg"> + * <TabItem label="npm">npm install</TabItem> + * <TabItem label="pnpm">pnpm install</TabItem> + * </Tabs> + * + * For manual control: TabsList, TabsTrigger, TabsContent. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import TabsList from "./TabsList.astro"; + +interface Props extends HTMLAttributes<"div"> { + /** Sync tab selection across instances with the same key via localStorage */ + syncKey?: string; +} + +const { syncKey, class: className, ...attrs } = Astro.props; +--- + +<div + data-nb-tabs + data-nb-sync-key={syncKey} + class={cn(className)} + {...attrs} +> + <TabsList /> + <div class="mt-3"> + <slot /> + </div> +</div> + +<script> + import "./tabs.client"; +</script> diff --git a/packages/docs/src/components/ui/tabs/TabsContent.astro b/packages/docs/src/components/ui/tabs/TabsContent.astro new file mode 100644 index 00000000..6e6d5c49 --- /dev/null +++ b/packages/docs/src/components/ui/tabs/TabsContent.astro @@ -0,0 +1,24 @@ +--- +/** + * TabsContent — individual tab panel matched to a TabsTrigger by value. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + /** Value matching a TabsTrigger. */ + value: string; +} + +const { value, class: className, ...attrs } = Astro.props; +--- + +<div + role="tabpanel" + data-nb-tabs-content + data-nb-value={value} + class={cn(className)} + {...attrs} +> + <slot /> +</div> diff --git a/packages/docs/src/components/ui/tabs/TabsList.astro b/packages/docs/src/components/ui/tabs/TabsList.astro new file mode 100644 index 00000000..8a1bc3c7 --- /dev/null +++ b/packages/docs/src/components/ui/tabs/TabsList.astro @@ -0,0 +1,34 @@ +--- +/** + * TabsList — container for tab triggers with animated indicator bar. + * Renders `role="tablist"` with a sliding underline indicator. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<div + class={cn( + // Scroll horizontally when the triggers exceed the column instead of + // leaking past the page. Scrollbar hidden so it doesn't collide with the + // underline/indicator on classic-scrollbar platforms; the strip stays + // scrollable via wheel/touch and the tabs remain keyboard-navigable. + "relative flex overflow-x-auto overscroll-x-contain border-b border-border", + "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + className, + )} + role="tablist" + data-nb-tabs-list + {...attrs} +> + <span + class="pointer-events-none absolute bottom-0 h-0.5 rounded-t-sm bg-primary transition-[left,width] duration-200 ease-out" + data-nb-tabs-indicator + aria-hidden="true" + ></span> + <slot /> +</div> diff --git a/packages/docs/src/components/ui/tabs/TabsTrigger.astro b/packages/docs/src/components/ui/tabs/TabsTrigger.astro new file mode 100644 index 00000000..dd61d9bc --- /dev/null +++ b/packages/docs/src/components/ui/tabs/TabsTrigger.astro @@ -0,0 +1,31 @@ +--- +/** + * TabsTrigger — individual tab button within a TabsList. + * Use `value` to match with a TabsContent of the same value. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> { + /** Value matching a TabsContent. */ + value: string; +} + +const { value, class: className, ...attrs } = Astro.props; +--- + +<button + role="tab" + type="button" + data-nb-tabs-trigger + data-nb-value={value} + class={cn( + "shrink-0 cursor-pointer px-4 py-2 text-sm font-medium leading-6 whitespace-nowrap text-muted-foreground transition-colors", + "hover:text-foreground aria-selected:text-primary", + "focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-[-2px]", + className, + )} + {...attrs} +> + <slot /> +</button> diff --git a/packages/docs/src/components/ui/tabs/index.ts b/packages/docs/src/components/ui/tabs/index.ts new file mode 100644 index 00000000..07214f69 --- /dev/null +++ b/packages/docs/src/components/ui/tabs/index.ts @@ -0,0 +1,5 @@ +export { default as Tabs } from "./Tabs.astro"; +export { default as TabItem } from "./TabItem.astro"; +export { default as TabsList } from "./TabsList.astro"; +export { default as TabsTrigger } from "./TabsTrigger.astro"; +export { default as TabsContent } from "./TabsContent.astro"; diff --git a/packages/docs/src/components/ui/tabs/tabs.client.ts b/packages/docs/src/components/ui/tabs/tabs.client.ts new file mode 100644 index 00000000..b8ec414e --- /dev/null +++ b/packages/docs/src/components/ui/tabs/tabs.client.ts @@ -0,0 +1,109 @@ +/** Wires <Tabs>; auto-detects manual triggers vs. synthesized-from-TabItem mode. */ + +import { mount, initTabs } from "@cloudflare/nimbus-docs/client"; + +const TRIGGER_CLASS = + "shrink-0 cursor-pointer px-4 py-2 text-sm font-medium leading-6 whitespace-nowrap text-muted-foreground transition-colors hover:text-foreground aria-selected:text-primary focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-[-2px]"; + +let counter = 0; + +function initTabContainer(container: HTMLElement): () => void { + const id = `nb-tabs-${counter++}`; + const syncKey = container.dataset.nbSyncKey; + const tablist = container.querySelector<HTMLElement>("[role=tablist]"); + const indicator = container.querySelector<HTMLElement>("[data-nb-tabs-indicator]"); + + // Scope to this container so a nested <Tabs>'s triggers don't flip the + // parent into manual mode (or vice-versa), independent of mount order. + const existingTriggers = Array.from( + container.querySelectorAll("[data-nb-tabs-trigger]"), + ).filter((t) => (t as HTMLElement).closest("[data-nb-tabs]") === container); + const synthesize = existingTriggers.length === 0; + + if (synthesize && tablist) { + // Only this container's own panels — exclude a nested <Tabs>'s panels, + // whose nearest [data-nb-tabs] ancestor is the inner container. + const panels = Array.from( + container.querySelectorAll<HTMLElement>("[data-nb-tabs-content]"), + ).filter((p) => p.closest("[data-nb-tabs]") === container); + + panels.forEach((panel, i) => { + const label = panel.dataset.nbTabLabel ?? "Tab"; + const btn = document.createElement("button"); + btn.role = "tab"; + btn.type = "button"; + btn.className = TRIGGER_CLASS; + btn.textContent = label; + btn.setAttribute("data-nb-tabs-trigger", ""); + + const panelId = `${id}-panel-${i}`; + const tabId = `${id}-tab-${i}`; + btn.id = tabId; + btn.setAttribute("aria-controls", panelId); + panel.id = panelId; + panel.setAttribute("aria-labelledby", tabId); + + if (indicator) { + tablist.insertBefore(btn, indicator); + } else { + tablist.appendChild(btn); + } + }); + } + + const instance = initTabs({ + container, + tabSelector: "[data-nb-tabs-trigger]", + panelSelector: "[data-nb-tabs-content]", + boundarySelector: "[data-nb-tabs]", + indicator, + sync: syncKey ? { key: `ui-synced-tabs__${syncKey}` } : undefined, + // Keep the active tab within the horizontally-scrollable (scrollbar-hidden) + // strip's visible range. Fires on every activate() — including the initial + // paint and a synced/restored selection — so a right-edge active tab can't + // render off-screen with no affordance. scrollLeft directly (not + // scrollIntoView, which would also scroll the page vertically). + onActivate: (index) => { + if (!tablist) return; + const trigger = + tablist.querySelectorAll<HTMLElement>("[data-nb-tabs-trigger]")[index]; + if (!trigger) return; + const left = trigger.offsetLeft; + const right = left + trigger.offsetWidth; + if (left < tablist.scrollLeft) { + tablist.scrollLeft = left; + } else if (right > tablist.scrollLeft + tablist.clientWidth) { + tablist.scrollLeft = right - tablist.clientWidth; + } + }, + }); + + // Cross-instance sync is keyed by trigger label; duplicate labels in a group + // resolve by first-match and activate the wrong panel. Surface it in dev. + if (import.meta.env.DEV && syncKey && tablist) { + const labels = Array.from( + tablist.querySelectorAll<HTMLElement>("[data-nb-tabs-trigger]"), + ) + .filter((t) => t.closest("[data-nb-tabs]") === container) + .map((t) => (t.textContent ?? "").trim()); + const dupes = [...new Set(labels.filter((l, i) => labels.indexOf(l) !== i))]; + if (dupes.length) { + console.warn( + `[nimbus] <Tabs syncKey="${syncKey}"> has duplicate tab labels (${dupes + .map((d) => `"${d}"`) + .join(", ")}). Sync is keyed by label, so a duplicate activates the ` + + `first match. Give each tab a unique label.`, + ); + } + } + + return () => { + instance.destroy(); + // Remove synthesized triggers so re-mount doesn't double up. + if (synthesize && tablist) { + tablist.querySelectorAll("[data-nb-tabs-trigger]").forEach((b) => b.remove()); + } + }; +} + +mount("[data-nb-tabs]", initTabContainer); diff --git a/packages/docs/src/components/ui/theme-toggle/ThemeToggle.astro b/packages/docs/src/components/ui/theme-toggle/ThemeToggle.astro new file mode 100644 index 00000000..9437210f --- /dev/null +++ b/packages/docs/src/components/ui/theme-toggle/ThemeToggle.astro @@ -0,0 +1,32 @@ +--- +/** + * ThemeToggle — light/dark switcher. Persists to localStorage; theme + * applies to `<html data-mode="dark">` via the inline script in BaseLayout + * (no FOUC). + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +const { class: className, ...attrs } = Astro.props; +--- + +<button + data-nb-theme-toggle + class={cn( + "group flex items-center justify-center w-8 h-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors", + className, + )} + {...attrs} + aria-label="Toggle dark mode" + data-nb-state="light" +> + <Icon name="ph:sun" class="w-[1.125rem] h-[1.125rem] hidden group-data-[nb-state=dark]:block" /> + <Icon name="ph:moon" class="w-[1.125rem] h-[1.125rem] group-data-[nb-state=dark]:hidden" /> +</button> + +<script> + import "./theme-toggle.client"; +</script> diff --git a/packages/docs/src/components/ui/theme-toggle/index.ts b/packages/docs/src/components/ui/theme-toggle/index.ts new file mode 100644 index 00000000..23685f52 --- /dev/null +++ b/packages/docs/src/components/ui/theme-toggle/index.ts @@ -0,0 +1 @@ +export { default as ThemeToggle } from "./ThemeToggle.astro"; diff --git a/packages/docs/src/components/ui/theme-toggle/theme-toggle.client.ts b/packages/docs/src/components/ui/theme-toggle/theme-toggle.client.ts new file mode 100644 index 00000000..a7a143ab --- /dev/null +++ b/packages/docs/src/components/ui/theme-toggle/theme-toggle.client.ts @@ -0,0 +1,31 @@ +/** + * theme-toggle.client.ts — light/dark toggle. Writes pref to localStorage + * ("ui-mode"); BaseLayout's pre-paint script owns DOM application so view + * transitions, OS changes, and cross-tab edits stay in sync. + */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +declare global { + interface Window { + __nbApplyTheme?: () => void; + } +} + +function initThemeToggle(button: HTMLElement): () => void { + function handleClick() { + const isDark = document.documentElement.getAttribute("data-mode") === "dark"; + try { + localStorage.setItem("ui-mode", isDark ? "light" : "dark"); + } catch { + // Ignore storage errors (private mode / restricted contexts). + } + window.__nbApplyTheme?.(); + } + + window.__nbApplyTheme?.(); + button.addEventListener("click", handleClick); + return () => button.removeEventListener("click", handleClick); +} + +mount("[data-nb-theme-toggle]", initThemeToggle); diff --git a/packages/docs/src/components/ui/toc/MobileTOC.astro b/packages/docs/src/components/ui/toc/MobileTOC.astro new file mode 100644 index 00000000..eb9569da --- /dev/null +++ b/packages/docs/src/components/ui/toc/MobileTOC.astro @@ -0,0 +1,60 @@ +--- +/** + * MobileTOC — compact "jump to section" menu for narrow viewports. + * + * The desktop `TOC` rail only renders at `xl+` (right column). Below that + * there's no on-this-page nav, so this native `<select>` fills the gap: it + * sits under the page title, tracks the active heading via scroll-spy, and + * jumps to a section on change. + * + * A native `<select>` whose options are the page headings, indented by depth. + * The caret is an overlaid icon since native selects can't be styled, and the + * client mirrors the active heading back into the select value. + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { TOCItem } from "@cloudflare/nimbus-docs/types"; + +interface Props extends HTMLAttributes<"nav"> { + headings: TOCItem[]; +} + +const { headings, class: className, ...attrs } = Astro.props; + +const hasHeadings = headings.length > 0; +--- + +{ + hasHeadings && ( + <nav + data-nb-mobile-toc + aria-label="On this page" + class={cn("relative", className)} + {...attrs} + > + <select + data-nb-mobile-toc-select + aria-label="Jump to section" + class="w-full appearance-none rounded-md border border-border bg-background py-2.5 pl-3 pr-9 text-sm font-medium text-foreground transition-colors hover:border-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" + > + <option value="_top">Overview</option> + {headings.map((heading) => ( + <option value={heading.slug}> + {`${" ".repeat(Math.max(0, heading.depth - 2))}${heading.text}`} + </option> + ))} + </select> + <Icon + name="ph:caret-down" + aria-hidden="true" + class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" + + /> + </nav> + ) +} + +<script> + import "./mobile-toc.client"; +</script> diff --git a/packages/docs/src/components/ui/toc/TOC.astro b/packages/docs/src/components/ui/toc/TOC.astro new file mode 100644 index 00000000..f3976886 --- /dev/null +++ b/packages/docs/src/components/ui/toc/TOC.astro @@ -0,0 +1,95 @@ +--- +/** + * TOC — on-this-page nav with scroll-spy. Static rail rendered inline + * (border-l + S-curve SVGs at indent changes); animated active indicator + * lives in toc.client.ts. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { TOCItem } from "@cloudflare/nimbus-docs/types"; + +interface Props extends HTMLAttributes<"div"> { + headings: TOCItem[]; +} + +const { headings, class: className, ...attrs } = Astro.props; +--- + +{headings.length > 0 && ( + <div data-nb-toc class={cn("toc-container", className)} {...attrs}> + <h2 class="mb-3 font-mono text-[0.6875rem] font-medium uppercase tracking-[0.16em] text-muted-foreground">On this page</h2> + <nav aria-label="Table of contents" class="relative"> + <svg + data-nb-toc-rail + aria-hidden="true" + class="pointer-events-none absolute inset-0 h-full w-full overflow-visible" + fill="none" + > + <path + data-nb-toc-rail-active + class="stroke-primary opacity-0 transition-[stroke-dasharray,stroke-dashoffset,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ready=true]:opacity-100 data-[initial=true]:transition-opacity motion-reduce:transition-opacity" + stroke-width="2" + stroke-linecap="round" + /> + </svg> + + <ul class="flex list-none flex-col m-0 p-0"> + {headings.map((h, i) => { + const prevDepth = i > 0 ? headings[i - 1].depth : h.depth; + const nextDepth = i < headings.length - 1 ? headings[i + 1].depth : h.depth; + const indent = h.depth - 2; + const goingDeeper = h.depth > prevDepth; + const goingShallower = nextDepth < h.depth; + + return ( + <li> + {goingDeeper && ( + <svg + aria-hidden="true" + class="block h-2 text-border" + style={`margin-left: calc(${prevDepth - 2}rem - 0.0625rem); width: ${h.depth - prevDepth}rem;`} + viewBox="0 0 1 1" + preserveAspectRatio="none" + fill="none" + stroke="currentColor" + stroke-width="2" + overflow="visible" + > + <path d="M 0 0 C 0 0.5, 1 0.5, 1 1" vector-effect="non-scaling-stroke" /> + </svg> + )} + <a + href={`#${h.slug}`} + data-nb-toc-link + data-nb-slug={h.slug} + class="block border-l-2 border-border pl-5 py-1.5 text-[0.8125rem] leading-snug text-muted-foreground no-underline transition-colors duration-150 hover:border-foreground/20 hover:text-foreground aria-[current=true]:font-medium aria-[current=true]:text-foreground" + style={`margin-left: calc(${indent}rem - 0.125rem);`} + > + {h.text} + </a> + {goingShallower && ( + <svg + aria-hidden="true" + class="block h-2 text-border" + style={`margin-left: calc(${nextDepth - 2}rem - 0.0625rem); width: ${h.depth - nextDepth}rem;`} + viewBox="0 0 1 1" + preserveAspectRatio="none" + fill="none" + stroke="currentColor" + stroke-width="2" + overflow="visible" + > + <path d="M 1 0 C 1 0.5, 0 0.5, 0 1" vector-effect="non-scaling-stroke" /> + </svg> + )} + </li> + ); + })} + </ul> + </nav> + </div> +)} + +<script> + import "./toc.client"; +</script> diff --git a/packages/docs/src/components/ui/toc/index.ts b/packages/docs/src/components/ui/toc/index.ts new file mode 100644 index 00000000..7c9df3e8 --- /dev/null +++ b/packages/docs/src/components/ui/toc/index.ts @@ -0,0 +1,2 @@ +export { default as TOC } from "./TOC.astro"; +export { default as MobileTOC } from "./MobileTOC.astro"; diff --git a/packages/docs/src/components/ui/toc/mobile-toc.client.ts b/packages/docs/src/components/ui/toc/mobile-toc.client.ts new file mode 100644 index 00000000..4e6a8459 --- /dev/null +++ b/packages/docs/src/components/ui/toc/mobile-toc.client.ts @@ -0,0 +1,136 @@ +/** + * Mobile TOC — keeps the "jump to section" <select> in sync with the page. + * + * - select → page: on change, scroll to the chosen heading and suppress the + * observer briefly so the value doesn't flicker while the page scrolls to + * the target. + * - page → select: an IntersectionObserver mirrors the active heading back + * into the select value — the topmost heading inside the reading band, or + * the first/last heading clamped by scroll position when none intersect. + * + * Teardown via AbortController for view transitions; a persistent in-band set + * (like the desktop rail in `toc.client.ts`) so the active heading is stable + * when several fall inside the band at once; rect-based clamping so it doesn't + * rely on `offsetParent` layout. + */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +// Ignore the top 10% and bottom 70% of the viewport so the "active" heading is +// whatever sits near the top of the reading area. The reading band is [10%, +// 30%] of the viewport height; BAND_TOP is its upper edge, reused by the +// first/last clamp below. +const BAND_TOP = 0.1; +const ROOT_MARGIN = "-10% 0px -70% 0px"; +const SUPPRESS_MS = 1000; + +function prefersReducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function initMobileToc(root: HTMLElement): () => void { + const select = root.querySelector<HTMLSelectElement>( + "[data-nb-mobile-toc-select]", + ); + if (!select) return () => {}; + + // Paired so slug/element indices stay aligned; `inBand` indexes into this. + type Heading = { slug: string; el: HTMLElement }; + const headings: Heading[] = Array.from(select.options) + .map((o) => o.value) + .filter((v) => v !== "_top") + .map((slug) => ({ slug, el: document.getElementById(slug) })) + .filter((h): h is Heading => h.el !== null); + + const controller = new AbortController(); + + // While true, observer callbacks are ignored so a click-driven scroll + // doesn't fight the value we just set. + let suppress = false; + let suppressTimer: ReturnType<typeof setTimeout> | undefined; + + function setActive(slug: string) { + if (select!.value !== slug) select!.value = slug; + } + + // select → page + select.addEventListener( + "change", + () => { + const slug = select.value; + suppress = true; + clearTimeout(suppressTimer); + suppressTimer = setTimeout(() => { + suppress = false; + }, SUPPRESS_MS); + + const behavior: ScrollBehavior = prefersReducedMotion() + ? "auto" + : "smooth"; + if (slug === "_top") { + window.scrollTo({ top: 0, behavior }); + return; + } + document.getElementById(slug)?.scrollIntoView({ behavior }); + }, + { signal: controller.signal }, + ); + + if (headings.length === 0) { + return () => { + controller.abort(); + clearTimeout(suppressTimer); + }; + } + + // page → select. Track every heading currently inside the band so the active + // one is stable when multiple short sections share it. + const inBand = new Set<number>(); + + function resolve() { + if (suppress) return; + + if (inBand.size > 0) { + // Topmost in-band heading (smallest document-order index). + setActive(headings[Math.min(...inBand)].slug); + return; + } + + // Nothing in the band — clamp to the first or last heading based on where + // the boundary headings sit relative to the band; otherwise keep the + // current value (we're mid-section between two headings). + const bandTop = window.innerHeight * BAND_TOP; + const firstTop = headings[0].el.getBoundingClientRect().top; + const lastTop = + headings[headings.length - 1].el.getBoundingClientRect().top; + if (firstTop > bandTop) { + setActive("_top"); + } else if (lastTop < bandTop) { + setActive(headings[headings.length - 1].slug); + } + } + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const i = headings.findIndex((h) => h.el === entry.target); + if (i === -1) continue; + if (entry.isIntersecting) inBand.add(i); + else inBand.delete(i); + } + resolve(); + }, + { rootMargin: ROOT_MARGIN, threshold: 0 }, + ); + + for (const { el } of headings) observer.observe(el); + resolve(); // initial sync before the observer's first async callback + + return () => { + controller.abort(); + observer.disconnect(); + clearTimeout(suppressTimer); + }; +} + +mount("[data-nb-mobile-toc]", initMobileToc); diff --git a/packages/docs/src/components/ui/toc/toc.client.ts b/packages/docs/src/components/ui/toc/toc.client.ts new file mode 100644 index 00000000..14112f6e --- /dev/null +++ b/packages/docs/src/components/ui/toc/toc.client.ts @@ -0,0 +1,334 @@ +/** + * Scroll-spy + animated rail indicator. Active heading tracked via a single + * IntersectionObserver; the dash slides by arc-length so it weaves through the + * rail's curves instead of cutting across. + */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +const READING_BAND = 0.25; +const BOTTOM_EPSILON = 2; +const REVEAL_PADDING = 12; + +function initToc(root: HTMLElement): () => void { + const nav = root.querySelector<HTMLElement>("nav"); + const activePath = root.querySelector<SVGPathElement>("[data-nb-toc-rail-active]"); + const links = root.querySelectorAll<HTMLElement>("[data-nb-toc-link]"); + if (!nav || !activePath || links.length === 0) return () => {}; + + const scrollHost = root.closest<HTMLElement>("[data-nb-toc-scroll-host]") ?? root; + const slugs = Array.from(links).map((l) => l.dataset.nbSlug!); + // Observe only resolvable headings, each carrying its original index, so + // scroll-spy stays aligned with the full-length links/segments even when a + // heading slugs to "" (e.g. emoji-only `## 🎉`) and has no DOM target. + const observed = slugs + .map((slug, index) => ({ el: document.getElementById(slug), index })) + .filter((o): o is { el: HTMLElement; index: number } => o.el !== null); + if (observed.length === 0) return () => {}; + const indexOfEl = new Map<HTMLElement, number>( + observed.map((o) => [o.el, o.index]), + ); + + let segments: { start: number; length: number }[] = []; + let totalLength = 0; + let currentIndex = -1; + let currentLink: HTMLElement | null = null; + let hasApplied = false; + + // Measure the rail from the DOM so the path stays pixel-perfect over the + // static gray rail, capturing each link's arc-length range as we go. + function buildRail() { + const navRect = nav!.getBoundingClientRect(); + + const m = Array.from(links).map((link) => { + const r = link.getBoundingClientRect(); + return { + x: r.left - navRect.left + 1, + yTop: r.top - navRect.top, + yBot: r.top - navRect.top + r.height, + }; + }); + + let d = ""; + const newSegments: { start: number; length: number }[] = []; + + // Measure each command in isolation (O(1)) and accumulate, rather than + // re-measuring the whole cumulatively-growing path with getTotalLength() + // on every iteration — the latter is O(n^2) and blocks the main thread on + // pages with hundreds of headings. Arc length is additive across + // contiguous commands, so summing isolated sub-paths matches the total. + // activePath doubles as the scratch measurer here; the full `d` is written + // back once at the end. + const measure = (subPath: string) => { + activePath!.setAttribute("d", subPath); + return activePath!.getTotalLength(); + }; + + let cumulative = 0; + let prevX = 0; + let prevYBot = 0; + + for (let i = 0; i < m.length; i++) { + const cur = m[i]; + + if (i === 0) { + d += `M ${cur.x} ${cur.yTop} `; + } else { + const prev = m[i - 1]; + let connector: string; + if (Math.abs(cur.x - prev.x) < 0.5) { + connector = `L ${cur.x} ${cur.yTop} `; + } else { + // Indent change → S-curve matching the static gap SVG. + const midY = (prev.yBot + cur.yTop) / 2; + connector = `C ${prev.x} ${midY}, ${cur.x} ${midY}, ${cur.x} ${cur.yTop} `; + } + d += connector; + cumulative += measure(`M ${prevX} ${prevYBot} ${connector}`); + } + + const start = cumulative; + + const seg = `L ${cur.x} ${cur.yBot} `; + d += seg; + cumulative += measure(`M ${cur.x} ${cur.yTop} ${seg}`); + + newSegments.push({ start, length: cumulative - start }); + + prevX = cur.x; + prevYBot = cur.yBot; + } + + activePath!.setAttribute("d", d); + segments = newSegments; + totalLength = cumulative; + } + + function applyActive(index: number, instant: boolean) { + const seg = segments[index]; + if (!seg) return; + + if (instant) { + activePath!.setAttribute("data-initial", "true"); + // Force recalc so only opacity transitions on first paint (no dash sweep). + void activePath!.getBoundingClientRect(); + } + + activePath!.style.strokeDasharray = `${seg.length} ${totalLength + 1}`; + activePath!.style.strokeDashoffset = `${-seg.start}`; + + if (instant) { + requestAnimationFrame(() => { + activePath!.setAttribute("data-ready", "true"); + requestAnimationFrame(() => { + activePath!.removeAttribute("data-initial"); + }); + }); + } + } + + function revealActiveLink(link: HTMLElement) { + const hostRect = scrollHost.getBoundingClientRect(); + const linkRect = link.getBoundingClientRect(); + + if (linkRect.top < hostRect.top + REVEAL_PADDING) { + scrollHost.scrollTop += linkRect.top - hostRect.top - REVEAL_PADDING; + return; + } + + if (linkRect.bottom > hostRect.bottom - REVEAL_PADDING) { + scrollHost.scrollTop += linkRect.bottom - hostRect.bottom + REVEAL_PADDING; + } + } + + function setActive(index: number) { + if (index === currentIndex) return; + currentIndex = index; + + currentLink?.removeAttribute("aria-current"); + const activeLink = links[index] ?? null; + activeLink?.setAttribute("aria-current", "true"); + currentLink = activeLink; + if (activeLink) revealActiveLink(activeLink); + + applyActive(index, !hasApplied); + hasApplied = true; + } + + const inBand = new Set<number>(); + let observedIndex = 0; + let atBottom = false; + let pinnedIndex: number | null = null; + let pinnedEnteredViewport = false; + + function resolve() { + if (pinnedIndex !== null) { + setActive(pinnedIndex); + return; + } + setActive(atBottom ? links.length - 1 : observedIndex); + } + + // rootMargin collapses the root to the top band; deepest in-band heading wins. + const spy = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const i = indexOfEl.get(entry.target as HTMLElement); + if (i === undefined) continue; + if (entry.isIntersecting) inBand.add(i); + else inBand.delete(i); + } + if (inBand.size > 0) observedIndex = Math.max(...inBand); + resolve(); + }, + { rootMargin: `0px 0px -${(1 - READING_BAND) * 100}% 0px`, threshold: 0 }, + ); + observed.forEach((o) => spy.observe(o.el)); + + function updateBottom() { + const scrollEl = document.scrollingElement ?? document.documentElement; + const maxScroll = scrollEl.scrollHeight - window.innerHeight; + const next = + maxScroll > BOTTOM_EPSILON && + scrollEl.scrollTop >= maxScroll - BOTTOM_EPSILON; + if (next !== atBottom) { + atBottom = next; + resolve(); + } + } + + function updateObservedIndex() { + const bandBottom = window.innerHeight * READING_BAND; + let nextIndex = 0; + for (const o of observed) { + if (o.el.getBoundingClientRect().top <= bandBottom) nextIndex = o.index; + else break; + } + observedIndex = nextIndex; + } + + function releaseStalePin() { + if (pinnedIndex === null) return; + const heading = document.getElementById(slugs[pinnedIndex]); + if (!heading) { + pinnedIndex = null; + pinnedEnteredViewport = false; + return; + } + + const rect = heading.getBoundingClientRect(); + const inViewport = rect.bottom >= 0 && rect.top <= window.innerHeight; + if (inViewport) { + pinnedEnteredViewport = true; + return; + } + + if (pinnedEnteredViewport) { + pinnedIndex = null; + pinnedEnteredViewport = false; + } + } + + let ticking = false; + function onScroll() { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + updateObservedIndex(); + updateBottom(); + releaseStalePin(); + resolve(); + ticking = false; + }); + } + + function onLayoutChange() { + buildRail(); + updateObservedIndex(); + updateBottom(); + releaseStalePin(); + resolve(); + if (currentIndex >= 0) { + applyActive(currentIndex, true); + const activeLink = links[currentIndex]; + if (activeLink) revealActiveLink(activeLink); + } + } + + const controller = new AbortController(); + + nav.addEventListener( + "click", + (e) => { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const link = (e.target as Element).closest<HTMLElement>("[data-nb-toc-link]"); + if (!link) return; + const i = slugs.indexOf(link.dataset.nbSlug!); + if (i === -1) return; + pinnedIndex = i; + const heading = document.getElementById(slugs[i]); + const rect = heading?.getBoundingClientRect(); + pinnedEnteredViewport = !!rect && rect.bottom >= 0 && rect.top <= window.innerHeight; + resolve(); + }, + { signal: controller.signal }, + ); + + // Hand-driven scrolling releases the pin and resumes auto-tracking. + function releasePin() { + if (pinnedIndex === null) return; + pinnedIndex = null; + pinnedEnteredViewport = false; + resolve(); + } + const NAV_KEYS = new Set([ + "ArrowUp", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", + " ", + "Spacebar", + ]); + window.addEventListener("wheel", releasePin, { + passive: true, + signal: controller.signal, + }); + window.addEventListener("touchmove", releasePin, { + passive: true, + signal: controller.signal, + }); + window.addEventListener( + "keydown", + (e) => { + if (NAV_KEYS.has(e.key)) releasePin(); + }, + { signal: controller.signal }, + ); + + window.addEventListener("scroll", onScroll, { + passive: true, + signal: controller.signal, + }); + window.addEventListener("resize", onLayoutChange, { + passive: true, + signal: controller.signal, + }); + + const ro = new ResizeObserver(onLayoutChange); + ro.observe(nav); + + buildRail(); + updateObservedIndex(); + updateBottom(); + resolve(); + + return () => { + controller.abort(); + ro.disconnect(); + spy.disconnect(); + }; +} + +mount("[data-nb-toc]", initToc); diff --git a/packages/docs/src/content.config.ts b/packages/docs/src/content.config.ts new file mode 100644 index 00000000..a8c87359 --- /dev/null +++ b/packages/docs/src/content.config.ts @@ -0,0 +1,42 @@ +import { defineCollection } from 'astro:content'; +import { docsCollection, partialsCollection } from '@cloudflare/nimbus-docs/content'; +import bundleSize from './generated/bundle-size.json' with { type: 'json' }; + +/** + * Substitutes `%BUNDLE_SIZE%` in frontmatter. + * + * The markdown plugin handles the token in page bodies, but frontmatter never reaches it: the + * collection parses and validates frontmatter with Zod before the body is compiled, and the layout + * reads the page description off the parsed entry. Doing it here covers that, and anything else + * that grows a size claim later. + * + * See `scripts/mdast-bundle-size.mjs` for the body half and `scripts/measure-bundle.mjs` for where + * the number comes from. + */ +function substituteTokens<T>(value: T): T { + if (typeof value === 'string') { + return value.replaceAll('%BUNDLE_SIZE%', bundleSize.label) as T; + } + if (Array.isArray(value)) { + return value.map(substituteTokens) as T; + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, inner]) => [key, substituteTokens(inner)]) + ) as T; + } + return value; +} + +const docs = docsCollection(); + +export const collections = { + docs: defineCollection({ + loader: docs.loader, + // `substituteTokens` is identity-typed (`<T>(value: T) => T`), so the + // transform keeps Nimbus's inferred frontmatter type intact and + // `entry.data` stays fully typed at every call site. + schema: docs.schema.transform(substituteTokens), + }), + partials: defineCollection(partialsCollection()), +}; diff --git a/packages/docs/src/content/docs/concepts/disposal.md b/packages/docs/src/content/docs/concepts/disposal.md new file mode 100644 index 00000000..0d8def31 --- /dev/null +++ b/packages/docs/src/content/docs/concepts/disposal.md @@ -0,0 +1,168 @@ +--- +title: Disposal +description: Why garbage collection can't manage remote references, and the ownership rules Cap'n Web uses instead. +sidebar: + order: 7 +--- + +## Why you have to think about this + +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, they figure there's no need to reclaim any. But a 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** to detect which objects are + unreachable, especially when those objects contain cyclic references. But a collector can only + see local objects; it cannot trace through the remote graph to discover cycles that cross RPC + connections. + +Both problems might be solvable with sufficient work, but the problem seems exceedingly difficult. +This library makes no attempt to solve it. + +:::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, for the reasons above. +::: + +## 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 a session ends, all stubs are implicitly disposed. With +[HTTP batch](/transports/http-batch/) requests there's generally no need to dispose stubs at all. +With long-lived [WebSocket](/transports/websocket/) sessions, disposal may be important. + +## 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 for a few years +before that). In short: + +- Disposable objects, including stubs, have a `[Symbol.dispose]` method. You can call it directly: + `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 is invoked + automatically when the variable goes out of scope. + +## Automatic disposal rules + +This library implements several rules to make resource management more manageable. They may appear +a bit complicated, but they are intended to implement the behaviour 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 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, 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 are +disposed. So, to achieve the rule that only the caller needs to dispose, the RPC system implicitly +disposes the callee's duplicates when the call completes: + +- 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 once the RPC system knows there will be no more pipelined calls. + +### Wonky details + +- Disposing an `RpcPromise` automatically disposes the future result. It may also cause the promise + to be cancelled and rejected, though this is not guaranteed. If you don't intend to await an RPC + promise, dispose it. +- Passing an `RpcPromise` in params or the return value of a call has the same ownership and + disposal rules as passing an `RpcStub`. +- When you access a property of an `RpcStub` or `RpcPromise`, the result is itself an `RpcPromise`, + but it 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 never leads to anything being + implicitly disposed. +- The caller of an RPC may dispose stubs used in the parameters immediately after initiating the + RPC, without waiting for it 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 is an object, it will always have a disposer. Disposing it disposes + all stubs found in that response. It's a good idea to always dispose return values even if you + don't expect them to contain stubs, in case the API adds stubs to the result in the future. + +## Duplicating stubs + +Sometimes you need to pass a stub somewhere it will be disposed, but also keep it for later use. To +prevent the disposer from disabling your copy, duplicate it with `stub.dup()`. The stub's target is +only disposed when all duplicates have been disposed. + +:::tip +You can call `.dup()` on a *property* of a stub or promise, to create a stub backed by that +property. This is particularly useful when you know in advance that the property will resolve to a +stub: `.dup()` gives you a stub you can start using immediately, that otherwise behaves exactly like +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 (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 automatically +invokes it when a stub pointing at it, and all its duplicates, have been disposed. + +If you pass the same `RpcTarget` instance to RPC multiple times (creating multiple stubs), you will +eventually get a separate dispose call for each one. To avoid this, use `new RpcStub(target)` to +create a single stub upfront, then pass that stub across multiple RPCs. You will then receive only +one call to the target's disposer, when all stubs are disposed. + +## Listening for disconnect + +Monitor any stub for "brokenness" with `onRpcBroken()`: + +```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, the callback is called. In particular, this happens if: + +- The stub's underlying connection is lost. +- The stub is a promise, and the promise rejects. diff --git a/packages/docs/src/content/docs/concepts/map.md b/packages/docs/src/content/docs/concepts/map.md new file mode 100644 index 00000000..bf7aad44 --- /dev/null +++ b/packages/docs/src/content/docs/concepts/map.md @@ -0,0 +1,212 @@ +--- +title: The magic map() +description: Transform a remote value in place with .map(), the rules it imposes, and the record-replay mechanism that makes it possible. +sidebar: + order: 5 +--- + +Every RPC promise has a special method `.map()` which can be used to remotely transform a value, +without pulling it back locally. + +```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 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.** + +## Semantics + +`promise.map(func)` transfers a representation of `func` to the peer, where it is executed on the +promise's result. Specifically: + +- If the promise resolves to an **array**, the mapper executes on each element. The overall `.map()` + returns a promise for an array of the results. +- If the promise resolves to **`null` or `undefined`**, the mapper is not executed at all. The + result is the same value. +- If the promise resolves to **any other value**, the mapper executes once on that value, returning + the result. + +So `map()` handles both arrays and nullable values; it doubles as an "optional chaining" operator +across the network. + +## Restrictions + +:::caution + +- 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`, so the callback cannot actually operate on it, other + than to invoke its RPC methods, or use it in the params of other RPC methods. +- Any stubs you use in the callback (and any parameters you pass to them) **will be sent to the + peer**. 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 that + is what saves the round trip. + +::: + +Because the callback's input is an opaque promise, you cannot branch on it: + +```ts +// ❌ Doesn't do what you want: `id` is an RpcPromise, always truthy. +ids.map(id => (id > 100 ? api.getBigUser(id) : api.getUser(id))); + +// ✅ Do the branching on the server side instead. +ids.map(id => api.getUser(id)); +``` + +:::danger[TypeScript catches only some of these mistakes] +The callback parameter is typed as a placeholder rather than a value, so TypeScript rejects the more +obvious abuses, but not all of them, and the ones it misses are the quiet ones: + +| You wrote | What actually happens | TypeScript | +| ------------------ | --------------------------------------- | ---------- | +| `if (id)` | Always true (every object is truthy) | Compiles | +| `` `user-${id}` `` | The string `"user-[object RpcPromise]"` | Compiles | +| `id > 100` | Always false | Error | +| `[...id]` | Throws: the placeholder is not iterable | Error | + +`id.length` is a third case: it is neither an error nor a mistake, because property access on a +placeholder is a legitimate pipelined operation. It just gives you a promise for the length, not a +number you can branch on. + +Use TypeScript for `.map()`, but don't assume it caught everything. +::: + +Two mistakes are caught at runtime rather than silently: + +```ts +// ❌ Throws: "RPC map() callbacks cannot be async." +ids.map(async id => await api.getUser(id)); + +// ❌ Throws: can't construct an RpcTarget or RPC callback inside a mapper. +ids.map(id => api.subscribe(id, new MyListener())); + +// ✅ Create the stub outside, then use it inside. +using listener = new RpcStub(new MyListener()); +ids.map(id => api.subscribe(id, listener.dup())); +``` + +The `.dup()` in that last line is required. Stubs captured by a recording are consumed when +the map completes, so passing `listener` bare leaves your outer stub disposed, and the next thing +you do with it throws `Attempted to use an RPC StubHook after it was disposed.` + +## Which side runs what + +The answer has two halves: + +- **Your JavaScript runs on the calling side, exactly once**, when the recording is made. +- **The RPC calls it recorded run on the receiving side, once per element.** + +So local computation is not repeated per element; it is evaluated once and the result is baked +into the recording: + +```ts +let n = 0; + +ids.map(id => api.log(id, n++, new Date().toISOString())); +``` + +Every element receives `n === 0` and the *same* timestamp, taken from the **caller's** clock. `n` +ends up as `1`, not `ids.length`. Anything locale- or environment-dependent, such as +`toLocaleString()`, `Math.random()`, or `Date.now()`, samples the calling side once. That is usually +not what you want inside a map, so compute it on the peer instead by calling a method. + +RPC calls, including calls on stubs you captured from outside the callback, *do* run once per +element: + +```ts +let counter = new RpcStub(new Counter(0)); + +// counter.increment() is called once per element, not once total. +await ids.map(id => { counter.increment(); return api.getUser(id); }); +``` + +There is also no index argument. `.map()` passes one parameter, and TypeScript will reject a +callback that declares two. An index would have to be a promise for the index, which you could not +do arithmetic on anyway. If you need positions, have the peer return them. + +## Why only `.map()`? + +`.map()` is the only array combinator Cap'n Web special-cases, and that is a deliberate stopping +point rather than a to-do item. + +`.map()` works precisely because the common case *does no computation on the values*: it just +pipelines each element into another RPC, which is exactly what a recording can express. `filter()`, +`find()`, `reduce()` and `sort()` all require actually evaluating something about each value, and +the callback never sees values. + +Making those work would mean shipping an expression library in the protocol: `eq`, `gt`, `and`, +`not`, arithmetic, and then everything anyone ever wants next. That library would only ever grow, +it would bloat every implementation, and every operator is new surface for a peer to abuse. + +The supported answer is to expose the operation as an ordinary RPC method, which you can then call +from inside a map callback: + +```ts +// On the server. +class Api extends RpcTarget { + // Filtering as an explicit method that takes the whole array. + getActiveUsers(ids: number[]): User[] { + return ids.map(lookup).filter(u => u.active); + } +} + +// On the client, still one round trip. +using active = await api.getActiveUsers(api.listUserIds()); +``` + +This is better than a generic filter anyway: the server does the work in one query instead of +answering a predicate N times, and you get to name the operation. + +## How the heck does that work? + +Cap'n Web does **NOT** send arbitrary code over the wire. + +The trick is **record-replay**. On the calling side, Cap'n Web invokes your callback once, in a +special "recording" mode, passing in a placeholder stub that records what you do with it. During +that invocation: + +- Any RPCs invoked by the callback (on *any* stub) are not actually executed, but 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 are sent to the peer, where the +recording can 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 behaviour is deterministic. Any actual computation (arithmetic, branching, etc.) +can't possibly use these promises as meaningful inputs, so it would logically produce the same +result for every invocation. Any such computation ends up being performed on the sending side, just +once, with the results baked into the recording. + +The wire format for this is the `["remap", ...]` expression. See the +[protocol reference](/reference/protocol/#remap). Because the recording is data rather than code, +no arbitrary code ever crosses the wire, and a non-JavaScript peer could evaluate one; see +[How it compares](/guides/comparisons/#is-it-a-protocol-or-a-javascript-library). + +## Nesting and recursion + +`.map()` callbacks may contain further `.map()` calls, and this works exactly as you would hope: + +```ts +await api.listTeams().map(team => + team.memberIds.map(id => api.getUserName(id))); +``` + +:::caution +Nesting **multiplies** server-side work. A map over N elements whose callback maps over M produces +N × M calls from a single client message, which is the cheapest denial-of-service in the library. +[Rate-limit expensive operations](/guides/security/#rate-limit-because-pipelining-is-cheap-for-attackers). + +Unbounded *recursion* is comparatively harmless: recording happens on the calling side, so an +infinitely recursive callback overflows the caller's own stack before anything is sent. That +protects you from your own bug, not from a peer who crafts a large recording deliberately. Server-side +resource limits remain your responsibility. +::: diff --git a/packages/docs/src/content/docs/concepts/promises.md b/packages/docs/src/content/docs/concepts/promises.md new file mode 100644 index 00000000..3d12e4dc --- /dev/null +++ b/packages/docs/src/content/docs/concepts/promises.md @@ -0,0 +1,123 @@ +--- +title: RpcPromise & Pipelining +description: Why RPC calls return RpcPromise instead of Promise, and how that enables single-round-trip call chains. +sidebar: + order: 4 +--- + +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: you can `await` it, +call `.then()`, pass it to `Promise.resolve()`, and so on. This all works because `RpcPromise` is a +["thenable"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables). + +But you can do more with an `RpcPromise`, because it supports **promise pipelining**. + +## 1. A promise is also a stub + +An `RpcPromise` is a stub for the eventual result of the promise. You can access properties and +invoke methods on it without awaiting it 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. A promise can be an argument + +An `RpcPromise`, or a property of one, can be passed as a parameter 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 a result, the +promise is replaced with its resolution before delivery to the receiving application. So you can use +an `RpcPromise<T>` anywhere a `T` is required. + +## Awaiting is what costs a round trip + +**Building the chain is free; awaiting is what talks to the network.** Structure your code so that +everything you need is expressed before the first `await`. + +```ts +// ❌ Three round trips. +let a = await api.first(); +let b = await api.second(a); +let c = await api.third(b); + +// ✅ One round trip. +let c = await api.third(api.second(api.first())); +``` + +### One round trip is not the same as one message + +"One round trip" is a claim about **waiting**, not about message count. + +| Transport | Three chained calls send… | Round trips | +| ------------------------------------- | ------------------------------------------- | ----------- | +| [WebSocket](/transports/websocket/) | Three `push` messages, written back-to-back | 1 | +| [HTTP batch](/transports/http-batch/) | One request body containing all three | 1 | + +Over a WebSocket, Cap'n Web really does send a separate message per call, so if you go looking in +your browser's network inspector, you will find three frames, plus a `pull` for the result you +awaited and a `release` afterwards. What it does *not* do is wait for a reply in between: they all +go out in the same tick and the results come back together, which in elapsed network time is +indistinguishable from sending one message. The HTTP batch transport goes further and concatenates +the whole batch into a single request body. + +**Count your `await`s, not your calls.** If you can set up an entire chain without awaiting +anything, it costs one round trip no matter how many calls are in it. + +## Transforming without pulling data back + +If you need to do something for each element of a result, use +[the magic `.map()` method](/concepts/map/) rather than awaiting the array and looping: + +```ts +let names = await api.listUserIds().map(id => [id, api.getUserName(id)]); +``` + +## Disposal + +`RpcPromise` participates in [disposal](/concepts/disposal/) just like a stub: + +- Disposing an `RpcPromise` automatically disposes the future result. It may also cause the promise + to be cancelled and rejected, though this is not guaranteed. **If you don't intend to await an RPC + promise, dispose it.** +- Passing an `RpcPromise` in the params or return value of a call follows the same ownership rules + as passing an `RpcStub`. +- When you access a property of an `RpcStub` or `RpcPromise`, the result is itself an `RpcPromise`, + but this one does **not** have its own disposer. You must dispose the stub or promise it came + from. + +```ts +{ + using userInfoPromise = stub.getUserInfo(); + console.log(await stub.greet(userInfoPromise.name)); +} +// Never awaited, so the server won't even send the response back over the wire. +``` + +:::caution[Never disposing is a memory leak, on both sides] +Un-awaited, un-disposed promises accumulate. Each one holds an entry in the session's import table, +and pins the corresponding export (and the object it refers to) alive on the peer. A client that +keeps issuing calls and never settles them will grow your server's memory for as long as the +session lasts. + +This is only bounded by the session ending. The library has no reference-count limit to configure, +so if you serve untrusted peers you have to bound it in application code. Attaching disposers to +the values you return gives you something to count. See +[Security considerations](/guides/security/) and [Sessions](/guides/sessions/). +::: + +## `.dup()` on a property + +You can call `.dup()` on a property of a stub or promise to create a stub backed by that property. +This is particularly useful when you know in advance that the property will resolve to a stub: +calling `.dup()` on it gives you a stub you can start using immediately, that otherwise behaves +exactly like the eventual stub would if you awaited it. diff --git a/packages/docs/src/content/docs/concepts/rpc-target.md b/packages/docs/src/content/docs/concepts/rpc-target.md new file mode 100644 index 00000000..3c87a325 --- /dev/null +++ b/packages/docs/src/content/docs/concepts/rpc-target.md @@ -0,0 +1,139 @@ +--- +title: RpcTarget & Functions +description: Export an interface over RPC by extending RpcTarget, and understand exactly which members become reachable. +sidebar: + order: 2 +--- + +## `RpcTarget` + +To export an interface over RPC, 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 is NOT serialized. Instead, the message contains a stub that points back to the +original target object, and invoking the stub calls back over RPC. + +```ts +import { RpcTarget } from 'capnweb'; + +class Counter extends RpcTarget { + #count = 0; + + increment(by: number) { + this.#count += by; + return this.#count; + } + + get value() { + return this.#count; + } +} +``` + +## What is reachable + +When you send someone an `RpcTarget` reference, they can call **any class method, including +getters**. They cannot 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 +usually stored as instance properties. + +**Instance properties are treated as private.** Anything assigned in the constructor, or with a +class field, is an instance property and is unreachable over RPC. Reading one does not return +`undefined`; it throws, so a peer cannot probe for its existence: + +```text +Attempted to access property 'apiKey', which is an instance property of the RpcTarget. To avoid +leaking private internals, instance properties cannot be accessed over RPC. +``` + +That default is the safe one, and it means exposing a value is a deliberate act. + +### Exposing a property with a getter + +A getter lives on the prototype, so it is reachable. This is the sanctioned way to publish a value +that is stored privately: + +```ts +class Document extends RpcTarget { + #title = 'Untitled'; + wordCount = 0; // instance property: NOT reachable over RPC + + // Reachable: the peer reads `doc.title` and this getter runs. + get title() { + return this.#title; + } + + // Reachable: writes have to be a method call. See below. + setTitle(title: string) { + this.#title = title; + } +} +``` + +:::caution +A **setter can never fire over RPC**. Assigning to a stub throws +`Can't assign properties on RPC stubs`, because the protocol has no message for assignment. Pairing +a setter with your getter is harmless but dead code as far as a peer is concerned. + +Expose writes as a method instead, as `setTitle` does above. A method is also the honest shape for +a write that crosses a network: it returns a promise you can await, and it can fail. +::: + +:::danger +If you are using TypeScript, note that declaring a method `private` does **not** hide it from RPC. +TypeScript annotations are erased at runtime, so they cannot be enforced. + +To actually make a member private, prefix its name with `#`, which makes it private to JavaScript +itself. Names prefixed with `#` are never available over RPC. +::: + +```ts +class Api extends RpcTarget { + #secret = 'not reachable'; // ✅ truly private + private alsoSecret = 'reachable'; // ❌ TypeScript-only; erased at runtime + + #internalHelper() {} // ✅ truly private + private helper() {} // ❌ callable over RPC +} +``` + +## Functions + +When a plain function is passed over RPC, it is treated similarly to an `RpcTarget`. The function is +replaced by a stub which, when invoked, calls back over RPC to the original function object. + +```ts +// The client passes a callback... +await api.subscribe((event) => console.log('got', event)); + +// ...and the server can invoke it, calling back into the client. +``` + +If the function has any own properties, those *will* be available over RPC. Note this is the +opposite of `RpcTarget`: with `RpcTarget`, own properties are not exposed; with functions, *only* +own properties are exposed. Generally functions don't have properties anyway. + +:::caution +A callback stub received in a call's parameters is disposed when that call returns. If the server +wants to invoke it later, it must call `.dup()`. See +[holding a callback past the call](/concepts/disposal/#holding-on-to-a-callback-past-the-call-that-delivered-it). +::: + +## Listening for disposal + +An `RpcTarget` may declare a `Symbol.dispose` method. If it does, the RPC system automatically +invokes it when a stub pointing at it, and all its duplicates, has been disposed. + +```ts +class Session extends RpcTarget { + [Symbol.dispose]() { + // release resources held by this session + } +} +``` + +If you pass the same `RpcTarget` instance over RPC multiple times, creating multiple stubs, you will +eventually get a separate dispose call for each one. To avoid that, use `new RpcStub(target)` to +create a single stub upfront and pass that across multiple RPCs; you will then receive only one call +to the target's disposer, when all stubs are disposed. diff --git a/packages/docs/src/content/docs/concepts/streaming.md b/packages/docs/src/content/docs/concepts/streaming.md new file mode 100644 index 00000000..e29890cf --- /dev/null +++ b/packages/docs/src/content/docs/concepts/streaming.md @@ -0,0 +1,141 @@ +--- +title: Streaming +description: Pass ReadableStream and WritableStream over RPC with automatic flow control and multiplexing. +sidebar: + order: 6 +--- + +You may pass a `ReadableStream` or `WritableStream` over RPC. When you do, the RPC system +automatically creates an equivalent stream at the other end and pumps bytes (or arbitrarily typed +chunks) across. + +```ts +class FileService extends RpcTarget { + download(name: string): ReadableStream { + return getFileStream(name); + } + + async upload(name: string, data: ReadableStream) { + await saveFileStream(name, data); + } +} +``` + +On the client, these look like ordinary streams: + +```ts +let stream = await api.download('report.csv'); +for await (let chunk of stream) { + // ... +} +``` + +## Flow control + +Streaming is done in such a way as to ensure the available bandwidth is fully used while +minimizing buffer bloat, by observing the bandwidth-delay product and applying backpressure when too +much is written. + +You do not configure this. Write to the stream and the RPC system throttles the writer for you. + +## Multiplexing + +Multiple streams can be sent across the same connection; they are multiplexed appropriately, +similar to HTTP/2 stream multiplexing. A large upload will not block small RPC calls happening +concurrently on the same session. + +## Blobs + +`Blob` is also supported by value. Because reading a `Blob`'s bytes is asynchronous, +blobs always travel over the same pipe machinery as streams, even when small. The receiver collects +all chunks before delivering the value to application code. + +## Async generators are not streams + +`ReadableStream` is the supported way to stream. Async iteration is not an RPC concept, and the two +ways of getting it wrong fail very differently: + +- **An async generator** is not a supported type. Returning one fails loudly, with + `Cannot serialize value: [object AsyncGenerator]`. +- **A plain object that merely implements `Symbol.asyncIterator`** is worse. As far as the + serializer is concerned it is an ordinary object, and symbol-keyed properties are not sent, so it + arrives at the peer as `{}` with no error at all. + +Either way, wrap it in a stream: + +```ts +class LogService extends RpcTarget { + tail(): ReadableStream { + let lines = this.#lines(); + return new ReadableStream({ + async pull(controller) { + let { value, done } = await lines.next(); + if (done) controller.close(); + else controller.enqueue(value); + }, + cancel() { + // The peer went away. Let the generator clean up. + lines.return(undefined); + }, + }); + } + + async *#lines() { /* ... */ } +} +``` + +Do not skip the `cancel` handler. A consumer dropping the stream, including the peer releasing it +or the session dying, is the normal way a `tail()` ends, and without `cancel` the generator is +never finalized, so anything it holds open leaks. + +This only constrains what you *return*. The receiving side iterates a stream with `for await` just +fine, as at the top of this page. + +## Callbacks, when a stream is the wrong shape + +Streams are for a sequence of chunks flowing one way. When you want the peer to call *you* (events, +progress, subscriptions), pass an object by reference instead and let it call your methods: + +```ts +// Client +class ProgressSink extends RpcTarget { + onProgress(pct: number) { updateBar(pct); } + onDone() { celebrate(); } +} + +await api.startJob(jobId, new ProgressSink()); +``` + +```ts +// Server +class JobService extends RpcTarget { + // Stubs in params are disposed when the call returns, so dup() to hold on. + async startJob(jobId: string, sink: RpcStub<ProgressSink>) { + using held = sink.dup(); + await this.#run(jobId, held); + } // `held` disposed here, releasing the client's ProgressSink +} +``` + +This is more general than streaming, since the peer can call any method you expose in any order, but +it gives you no backpressure. Awaiting every call costs a round trip per event and underuses the +link; awaiting none lets the queue grow without bound. If what you actually have is a sequence of +chunks, use a stream and let the flow control above do the work. + +Disposal doubles as a lifetime signal: your object's `[Symbol.dispose]()` runs when the peer +releases it, including when the session drops, so you can detect an abandoned job. See +[Disposal](/concepts/disposal/) and [Sessions](/guides/sessions/). + +## How it works on the wire + +A `WritableStream` is exported as a `["writable", exportId]` expression whose target accepts +`write(chunk)`, `close()`, and `abort(reason?)`, mirroring `WritableStreamDefaultWriter`. + +A `ReadableStream` is sent by first creating a pipe with a `["pipe"]` message, pumping data into the +writable end immediately, and referencing the readable end via `["readable", importId]`. This means +data starts flowing without waiting for a round trip. + +If a writable export is released without `close()` having been called, the sender aborts the stream, +signalling abnormal termination such as a network disconnect. + +See the [protocol reference](/reference/protocol/#writable) for details. diff --git a/packages/docs/src/content/docs/concepts/stubs.md b/packages/docs/src/content/docs/concepts/stubs.md new file mode 100644 index 00000000..4b873550 --- /dev/null +++ b/packages/docs/src/content/docs/concepts/stubs.md @@ -0,0 +1,80 @@ +--- +title: RpcStub +description: How stubs work, what TypeScript knows about them, and how to forward them between peers. +sidebar: + order: 3 +--- + +When a type `T` which extends [`RpcTarget`](/concepts/rpc-target/) (or is a function) is sent as +part of an RPC message (in the arguments to a call, or in a return value), it is replaced with a +stub of type `RpcStub<T>`. + +## Stubs are proxies + +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 other +side. If you use a property that doesn't exist, no error is produced until you await the result. + +TypeScript, however, knows which properties exist from the type parameter `T`. So if you are using +TypeScript, you get full compile-time type checking, autocomplete, and refactoring. Hooray! + +## Reading properties + +To read a property from the remote object, as opposed to calling a method, `await` the property: + +```ts +let foo = await stub.foo; +``` + +Property access itself is lazy and free: it produces an [`RpcPromise`](/concepts/promises/) you can +also pipeline through without awaiting. + +## Forwarding stubs to third parties + +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 that stub in an RPC +to Carol, thus allowing Carol to call Bob. + +```text +Carol ──call──▶ Alice ──proxied──▶ Bob +``` + +As of this writing, any such calls are proxied through Alice. In the future we may support +"three-party handoff" so that Carol can make a direct connection to Bob. + +In the object-capability model, possession of the stub *is* the authority to use it, and that +authority is delegated by passing it along. + +## Constructing a stub locally + +You may construct a stub explicitly, without an RPC connection: + +```ts +let stub = new RpcStub(target); +``` + +This is useful to perform local calls as if they were remote, and to manage disposal; passing one +explicitly-created stub across several RPCs means the underlying target's disposer runs only once, +when all copies are gone. See [Disposal](/concepts/disposal/). + +## Duplicating + +`stub.dup()` returns an independent duplicate. The target is disposed only when *all* duplicates +have been disposed. You need this whenever you want to keep a stub that the RPC system would +otherwise dispose out from under you, most commonly a callback received in a call's parameters. + +## Detecting breakage + +Monitor any stub for "brokenness" with `onRpcBroken()`: + +```ts +stub.onRpcBroken((error: any) => { + console.error(error); +}); +``` + +The callback fires if anything happens to the stub that would cause all further method calls and +property accesses to throw. In particular: + +- The stub's underlying connection is lost. +- The stub is a promise, and the promise rejects. diff --git a/packages/docs/src/content/docs/concepts/values.md b/packages/docs/src/content/docs/concepts/values.md new file mode 100644 index 00000000..f395b04c --- /dev/null +++ b/packages/docs/src/content/docs/concepts/values.md @@ -0,0 +1,65 @@ +--- +title: What Can Be Passed +description: The types Cap'n Web serializes by value, the types it passes by reference, and the types it deliberately refuses. +sidebar: + order: 1 +--- + +Values crossing an RPC boundary are either **passed by value** (serialized, producing a copy at the +receiving end) or **passed by reference** (replaced with a stub pointing back at the original). + +## Passed by value + +The following types can be passed over RPC, in arguments or return values: + +- 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 (see + [Streaming](/concepts/streaming/)) +- `URL` +- `Headers`, `Request`, and `Response` from the Fetch API + +## Passed by reference + +- Classes that extend [`RpcTarget`](/concepts/rpc-target/) +- Functions +- Existing [`RpcStub`](/concepts/stubs/) and [`RpcPromise`](/concepts/promises/) values + +Anything passed by reference produces a **stub** on the far side, and stubs must be +[disposed](/concepts/disposal/). + +## Not supported yet + +These may be added in the future: + +- `Map` and `Set` +- `RegExp` + +## Intentionally not supported + +- **Application-defined classes that do not extend `RpcTarget`.** There is no safe, general way to + reconstruct an arbitrary class on the other side. Convert to a plain object, or extend + `RpcTarget` to pass it by reference. +- **Cyclic values.** Messages are serialized strictly as trees, like JSON. + +## Errors + +`Error` and its well-known subclasses survive the trip, including the error `message` and error +subclass name. Extra own enumerable properties, `cause`, and `AggregateError`'s `errors` are carried +along too; values that cannot be represented are silently dropped, but the error itself always +arrives. + +Stack traces are **redacted by default** for security reasons, so a client cannot learn about your +server's internals from a thrown error. + +## On the wire + +All of this is JSON with a preprocessing step: non-JSON types become arrays whose first element is a +type tag, like `["date", 1749342170815]`. You can read your own traffic in the browser network tab. +See the [wire protocol reference](/reference/protocol/) for the full encoding. diff --git a/packages/docs/src/content/docs/examples/batch-pipelining.mdx b/packages/docs/src/content/docs/examples/batch-pipelining.mdx new file mode 100644 index 00000000..eedc0999 --- /dev/null +++ b/packages/docs/src/content/docs/examples/batch-pipelining.mdx @@ -0,0 +1,107 @@ +--- +title: Batch + pipelining +description: Three dependent RPC calls in a single HTTP round trip, running live, with the source that produces it. +tableOfContents: false +sidebar: + order: 1 +--- + +import { exampleBySlug } from '../../../examples.ts'; + +<Playground example={exampleBySlug('batch-pipelining')} /> + +Authenticate a user, then fetch that user's profile and notifications, both of which need the user +ID that the first call returns. Pipelined, all three travel in **one** HTTP request. Written the +ordinary way, they take three. + +Drag the latency slider and run it again. The server does identical work in both columns; the only +difference is how many times the browser has to cross the network. + +:::note[There is no server behind this page.] +The Worker on the left is bundled into the page and answers its own requests, so the round-trip +counts are real but nothing crosses the network. The code and the protocol are identical either way. +See [how the playground works](#how-this-page-runs). +::: + +## What makes it one round trip + +The three calls are issued against a single session, and the second and third are built from a +promise that has not resolved yet: + +```js +const api = newHttpBatchRpcSession(RPC_URL); + +const user = api.authenticate('cookie-123'); // not awaited +const profile = api.getUserProfile(user.id); // uses user.id anyway +const notifications = api.getNotifications(user.id); + +const [u, p, n] = await Promise.all([user, profile, notifications]); +``` + +`user.id` is not a string here. It is a *reference* to a field of a result the server has not +produced yet. Cap'n Web sends that reference as part of the same batch, and the server substitutes +the real value when it gets there. Nothing has to come back to the client in between. + +The sequential version awaits each call before building the next, so the client cannot know +`user.id` until a full round trip has completed: + +```js +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); +``` + +Same data, same server work, three times the network cost, and that cost grows with the length of +the chain, which is the part that hurts on a slow connection. + +## Where the latency comes from + +Two separate knobs, deliberately kept apart: + +- **Server-side work**: per-method delays set by `DELAY_AUTH_MS`, `DELAY_PROFILE_MS` and + `DELAY_NOTIFS_MS`. Identical in both modes, so it is *not* what the demo measures. +- **Network round trips**: simulated in the browser by the slider. This is the part pipelining + removes. + +Keeping the round-trip cost on the client means the deployed Worker adds no artificial delay, and +the page can change it without a redeploy. + +## How this page runs + +The demo above is the example's unmodified Worker and browser client, bundled together into one +page. A small shim replaces `fetch` for the `/rpc` path and hands the request straight to the +Worker's `fetch` handler: + +```js +globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + if (new URL(request.url).pathname === '/rpc') { + return await worker.fetch(request, ENV, ctx); + } + return upstream(input, init); +}; +``` + +Everything above that line is untouched: the same session setup, the same batch encoding, the same +`newWorkersRpcResponse` on the other end. Only the transport hop is gone, which is why the round-trip +counts mean what they say and why these docs deploy as static files. + +## Run it yourself + +```sh +npm run build # the examples resolve capnweb to dist/ +npx wrangler dev --cwd examples/batch-pipelining --ip 127.0.0.1 --port 8788 +``` + +The terminal client runs the same comparison against a server in a separate process: + +```sh +node examples/batch-pipelining/server-node.mjs # in one shell +node examples/batch-pipelining/client.mjs # in another +``` + +## Next + +- [RpcPromise & pipelining](/concepts/promises/): how the promise references work. +- [The pipelining tour](/start/pipelining-tour/): a step-by-step walkthrough, including `.map()`. +- [HTTP batch transport](/transports/http-batch/): what this example is running on. diff --git a/packages/docs/src/content/docs/examples/session-recovery.mdx b/packages/docs/src/content/docs/examples/session-recovery.mdx new file mode 100644 index 00000000..03e15dfc --- /dev/null +++ b/packages/docs/src/content/docs/examples/session-recovery.mdx @@ -0,0 +1,154 @@ +--- +title: Session recovery +description: A WebSocket session with a button that kills it, showing what a disconnect destroys and what it takes to resume without a gap. +tableOfContents: false +sidebar: + order: 3 +--- + +import { exampleBySlug } from '../../../examples.ts'; + +<Playground example={exampleBySlug('session-recovery')} /> + +Connect, watch the events arrive, then sever the connection and reconnect. Everything comes back, +including the events that happened while you were gone, because the client held on to one number. + +Untick **Resume from cursor** and do it again. Same disconnect, same reconnect, but now the feed +shows a gap, because nothing told the server where to start. + +:::note[There is no server behind this page.] +Both ends of the session run in this page, wired together by a shim that replaces `WebSocket` for +one path. The session, the protocol, the callbacks and the disconnect are all genuine. See +[how this page runs](#how-this-page-runs). +::: + +## What a disconnect destroys + +Everything the session owned, and nothing else. + +| Survives | Does not survive | +| ----------------------------------- | -------------------------------------------------- | +| The event log on the server | The `AuthedApi` stub | +| The cursor, in client-side state | The `Subscription` stub | +| The token, in client-side state | The authenticated user held on the server object | +| | Any call in flight | + +The **Call a stub from the old session** button makes the second column concrete. It holds on to +the `AuthedApi` from before the drop and calls `whoami()` on it. That call does not hang and does +not quietly reconnect. It rejects: + +```js +const stub = this.#authed ?? this.#staleAuthed; +try { + const user = await stub.whoami(); + return `stub still works: ${user.name}`; +} catch (error) { + return `stub is broken: ${error.message}`; +} +``` + +There is no automatic reconnection in Cap'n Web, and this is why: the library cannot know whether +the object a stub pointed at still exists, still means the same thing, or should still be reachable +by you. Recovery is a decision only the application can make. + +## Why the log lives outside the session + +The server's API object is created fresh for every connection, and the authenticated user lives on +the object that `authenticate()` returns. That is the object-capability pattern doing its job: the +token crosses the wire once, and after that, holding the stub *is* the authorization. + +But it means all of that state dies with the socket. Anything that has to outlive a disconnect has +to be somewhere else, which is why `createEventLog()` is called at module scope and passed in: + +```js +const log = createEventLog(); + +export function createMain() { + return new PublicApi(log); +} +``` + +:::caution +Module scope is the right shape and the wrong storage. It lives as long as the isolate, and two +clients can easily land on two different isolates. In a real deployment the log belongs somewhere +addressable: a [Durable Object](/servers/workers/), a database, a queue. It must not live *in the +session*. +::: + +## Resuming without a gap + +The subscription takes the id of the last event the client actually processed: + +```js +subscribe(sinceId, sink) { + const from = sinceId ?? this.#log.latestId(); + + // 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 cursor is the client's, not the server's.** It is a plain number in client-side state, which +is exactly why it survives; a stub could not. Design the API so the caller can say where it left +off, and reconnection becomes a normal operation rather than a recovery procedure. + +**`sink` is a callback going the other way.** The client passes an `RpcTarget`, the server receives +a stub for it, and calling a method on that stub is an RPC back into the browser. That is all +server-initiated messaging is here; there is no separate subscription mechanism. + +**The `.dup()` is mandatory.** Stubs arriving in parameters are disposed when the call returns, so +holding one past that requires duplicating it. The `Subscription` disposes its copy in +`[Symbol.dispose]()`, which also runs when the session dies, and that is what stops the timer on an +abrupt disconnect. + +## Replay has to be bounded + +A resume token from a client that has been gone for a week is a request to replay a week. The +server caps it and tells the client when it has fallen too far behind: + +```js +const from = Math.max(sinceId, latest - MAX_REPLAY); +return { events, truncated: from > sinceId }; +``` + +The client surfaces that as a gap rather than pretending it received everything. An unbounded +replay is a denial-of-service vector. See [Security considerations](/guides/security/). + +## How this page runs + +The other two playgrounds shim `fetch`. A WebSocket upgrade cannot be expressed that way inside a +page, so this one replaces the `WebSocket` constructor for `/ws` and returns one end of a pair +whose other end is handed to a real session: + +```js +function Shim(url, protocols) { + if (new URL(url, location.href).pathname !== WS_PATH) { + return new NativeWebSocket(url, protocols); + } + const { client, server } = connectedPair(String(url)); + newWebSocketRpcSession(server, createMain(ENV)); + return client; +} +``` + +This skips the Worker's `fetch` handler, so upgrade handling is the one part of the example the page +does not exercise. It keeps the API implementation, the session, the wire protocol, calls in both +directions, and a connection that can genuinely be severed, which is the only thing this example is +really about. + +## Run it yourself + +```sh +npm run build # the examples resolve capnweb to dist/ +npx wrangler dev --cwd examples/session-recovery --ip 127.0.0.1 --port 8789 +``` + +That version uses a WebSocket to a Worker, so you can also disconnect it by turning off your +network. + +## Next + +- [Sessions & reconnection](/guides/sessions/): the patterns here, written out in full. +- [Disposal](/concepts/disposal/): why `.dup()` is needed and when disposers run. +- [WebSocket transport](/transports/websocket/): what this example is running on. diff --git a/packages/docs/src/content/docs/examples/worker-react.mdx b/packages/docs/src/content/docs/examples/worker-react.mdx new file mode 100644 index 00000000..f1a7fb63 --- /dev/null +++ b/packages/docs/src/content/docs/examples/worker-react.mdx @@ -0,0 +1,88 @@ +--- +title: Workers + React +description: A React app calling a Cap'n Web Worker, with a request timeline and runtime validation at the RPC boundary. +tableOfContents: false +sidebar: + order: 2 +--- + +import { exampleBySlug } from '../../../examples.ts'; + +<Playground example={exampleBySlug('worker-react')} /> + +The same comparison as the [batch + pipelining example](/examples/batch-pipelining/), but from a +real front end: a React app served as static assets by the same Worker that answers its RPC calls. +It draws a timeline of the requests, so you can watch the sequential version wait out three round +trips while the pipelined version makes one. + +It also shows the two halves of runtime validation, `@validateRpc()` on the server and +`validateStub()` on the client, including what a rejected call looks like. + +:::note[There is no server behind this page.] +The Worker is bundled into the page and answers its own requests, with the simulated latency from +its `wrangler.jsonc`. The POST counts and the validation error are real: +see [how the playground works](/examples/batch-pipelining/#how-this-page-runs). +::: + +## One Worker, both jobs + +The Worker serves the built React app *and* the RPC endpoint. Static assets are matched first, so +`fetch` only ever sees `/api`: + +```ts +export default { + async fetch(request: Request, env: Env) { + const url = new URL(request.url); + if (url.pathname === '/api') { + return newWorkersRpcResponse(request, new Api(env)); + } + return new Response('Not found', { status: 404 }); + }, +}; +``` + +The client points at a relative `/api`, so the same build works when served by the Worker and when +served by the Vite dev server, which proxies `/api` across: + +```ts +const api = validateStub<Api>(newHttpBatchRpcSession<Api>('/api')); +``` + +## Typed end to end, checked at runtime + +`runs.ts` imports the `Api` class from `server/worker.ts` **as a type**. That gives the client full +autocomplete and compile-time checking against the real server interface, with no schema, no +codegen step, and nothing shipped to the browser. The import disappears at build time. + +Types alone stop at the network boundary though, since anything can POST to `/api`. That is what the +validation layer is for: + +- `@validateRpc()` on the server generates argument and return validators from the TypeScript + types, and rejects malformed calls before they reach your method. +- `validateStub()` on the client checks that what came back matches what the types promised. + +The **Test validation failure** button calls `authenticate(12345)` with a number where a string is +declared, so you can see the server refuse it. + +:::note +Validation is opt-in and lives in a separate package, `capnweb-validate`. Cap'n Web itself does not +require it. See [runtime validation](/guides/validation/). +::: + +## Run it yourself + +```sh +npm run build # the examples resolve capnweb to dist/ +npx wrangler dev --cwd examples/worker-react --ip 127.0.0.1 --port 8787 +``` + +That serves it from a Worker, so the round trips cross the network. + +For React hot reloading, run the Worker and the Vite dev server side by side. See the +[example's README](https://github.com/cloudflare/capnweb/tree/main/examples/worker-react). + +## Next + +- [Runtime validation](/guides/validation/): the validation package in full. +- [Cloudflare Workers](/servers/workers/): serving Cap'n Web from a Worker. +- [RpcPromise & pipelining](/concepts/promises/): the mechanism being demonstrated. diff --git a/packages/docs/src/content/docs/guides/comparisons.md b/packages/docs/src/content/docs/guides/comparisons.md new file mode 100644 index 00000000..6cabb814 --- /dev/null +++ b/packages/docs/src/content/docs/guides/comparisons.md @@ -0,0 +1,192 @@ +--- +title: How It Compares +description: Cap'n Web against tRPC, JSON-RPC, GraphQL, Cap'n Proto and the older distributed-object systems, including the things it deliberately does not do. +sidebar: + order: 4 +--- + +Cap'n Web claims to be more expressive than most RPC systems. This page is the receipt: what that +buys you against each of the usual alternatives, and where the claim runs out. + +## vs. tRPC, oRPC, and friends + +[tRPC](https://trpc.io/), [oRPC](https://orpc.unnoq.com/) and similar libraries share a lot with +Cap'n Web: TypeScript inference instead of code generation, and no separate IDL to compile. The +difference is what a call can *return*. + +| | Cap'n Web | Typical TS RPC library | +| --------------------------------- | ----------------- | -------------------------- | +| TypeScript types as the contract | Yes | Yes | +| Separate IDL and codegen step | No | No | +| Runtime validation of inputs | Opt-in, see below | Usually built in | +| Return an **object** by reference | Yes | No, results are plain data | +| Pass a **function** by reference | Yes | No | +| Server calls the client | Yes | Subscriptions only | +| Dependent calls in one round trip | Yes | No | +| Reference lifetime management | Yes | N/A | + +One row there goes against us, and it is worth being straight about. "No schema language" is often +claimed for this whole family, but it is only true of the *transport contract*. In practice a tRPC +or oRPC procedure declares its input with a schema library, usually [Zod](https://zod.dev/) or +anything else implementing [Standard Schema](https://standardschema.dev/), and the TypeScript type +is inferred *from* that schema: + +```ts +// tRPC: the schema is the contract, and the static type is derived from it. +publicProcedure.input(z.object({ id: z.string() })).query(({ input }) => getUser(input.id)); +``` + +So those libraries validate arriving data by default, and Cap'n Web does not. A Cap'n Web method +signature is erased at runtime like any other TypeScript, and nothing checks the values against it +unless you arrange for that. The direction of derivation is simply reversed: they generate types +from a schema, while [`capnweb-validate`](/guides/validation/) generates the checks from your +TypeScript signatures at build time. Either way you describe the boundary once, but with Cap'n Web +it is a step you have to take. See [Types are not validation](/guides/security/#types-are-not-validation). + +Those libraries can batch calls, but batching and pipelining solve different problems. Batching +combines calls that are **independent**: you already know all the arguments. Pipelining combines +calls that are **dependent**, where the argument to the second call is the result of the first. +That is the case that otherwise forces a round trip, and it is the case +[`.map()`](/concepts/map/) and [`RpcPromise`](/concepts/promises/) exist to collapse. + +## vs. JSON-RPC + +[JSON-RPC](https://www.jsonrpc.org/specification) is also JSON over any transport, and it also +supports notifications in both directions. What it has no concept of: + +- **Pass-by-reference.** Every JSON-RPC method is addressed by a global name and every argument is + plain data. There is no way to hand the other side a reference to one particular object and let + them call methods on it, which is the whole basis of + [capability-based authorization](/guides/security/). +- **Lifetime management.** Nothing in JSON-RPC tracks that you are holding something the peer must + keep alive, so there is nothing to release. Cap'n Web has + [import/export tables and disposal](/concepts/disposal/). +- **Pipelining.** No way to refer to the result of a call you have not received yet. + +Cap'n Web's closest relative is not JSON-RPC but CapTP, the object-capability protocol family that +[Cap'n Proto](https://capnproto.org) also belongs to. + +## vs. GraphQL, and the N+1 question + +GraphQL and Cap'n Web attack the same problem from opposite ends. GraphQL gives the client a query +language so it can describe a whole dependent graph in one request. Cap'n Web gives the client +promise pipelining so it can *write ordinary code* that happens to produce one request. + +The comparison is aggressive, though, and it breaks down in places. + +:::caution[`.map()` does not remove N+1; it relocates it] +Pipelining collapses **network** round trips. It does not collapse **database** queries. + +```ts +// One network round trip. Still N+1 queries on the server. +let names = await api.listUserIds().map(id => api.getUserName(id)); +``` + +The client waits once instead of N+1 times, which is a real and often dominant win. But the server +still runs one `listUserIds` query and N `getUserName` queries. + +**Whether that second half matters depends on where your database is.** If it is across a network, +you have moved the problem rather than solved it, and you want a batched method. If it is +[SQLite embedded in a Durable Object](#where-n1-stops-mattering), those N queries are in-process +function calls and N+1 is a normal way to write code. Read on; that case is the interesting one. +::: + +Things GraphQL has that Cap'n Web does not: + +- **A DataLoader equivalent.** There is no built-in batching or per-request caching layer to fold + those N queries into one. If you need that, expose a method that takes the whole array and does a + single `WHERE id IN (...)`, then call *that* from the map callback. +- **Query cost analysis.** A GraphQL server can inspect a query and reject it as too expensive + before executing any of it. Cap'n Web has no query planner, so there is nothing to analyse. Rate + limiting is your job. See [Security considerations](/guides/security/). + +### Where N+1 stops mattering + +The N+1 problem is not really about the number of queries. It is about the number of **round +trips**, and a query is only expensive because the database is usually on the other side of a +network. + +Take that network away and the arithmetic changes. With SQLite embedded in a +[Durable Object](/servers/workers/), the database lives in the same process as your code, so a query +is a function call measured in microseconds. SQLite's own documentation makes the argument directly: +[many small queries are efficient in SQLite](https://www.sqlite.org/np1queryprob.html), and N+1 is +not an anti-pattern there. + +Pair that with pipelining and both halves are gone: `.map()` removes the client's N round trips, and +in-process SQLite removes the server's. The `getUserName` loop above stops being something to +engineer around and becomes what it looks like, a loop. + +The two ideas are also not mutually exclusive. Nothing stops you exposing a GraphQL-style +`query(document)` method over a Cap'n Web session, or using Cap'n Web for the interactive, +capability-bearing parts of an app and GraphQL for the reporting queries. + +## vs. Cap'n Proto, and using Cap'n Web from other languages + +Cap'n Web is **deliberately scoped to JavaScript and TypeScript.** It is not a port of Cap'n Proto +and the two do not interoperate on the wire. See +[the comparison table in the introduction](/start/introduction/#how-it-compares). + +If your backend is not JavaScript, the answer today is to use Cap'n Proto instead. It is the same +object-capability model with the same promise pipelining, plus schemas and code generation for a +long list of languages. A proxy that translated between the two given a Cap'n Proto schema would be +a lovely thing to have; it does not exist. + +### Why not a port? + +Cap'n Web's implementation works by walking arbitrary objects at runtime without knowing their +types; that is what lets it serialize anything and forward any call without a schema. That is +natural in a dynamic language and awkward in a static one. + +- **Another dynamic language** (Python, Ruby) would probably port fine. +- **A statically-typed language** is much harder, because the type-agnostic object walking has no + direct equivalent. +- **A shared Rust/WASM core** does not obviously help either. The values Cap'n Web moves are + JavaScript objects, so such an implementation would spend most of its size marshalling values + across the JS/WASM boundary, plausibly more code than the entire TypeScript implementation, which + is [%BUNDLE_SIZE%](/start/introduction/) in total. + +## Isn't this distributed objects all over again? + +CORBA, Java RMI and .NET Remoting all tried to make remote objects work, and all are cautionary +tales. It is a fair challenge, and Cap'n Web is making a specific bet about *why* they failed. + +The usual diagnosis is that they tried to make a remote call look exactly like a local call, to +hide the network. That cannot work, because the differences are not cosmetic: remote calls have +latency, they fail independently of your process, and the thing on the other end has a lifetime you +do not control. + +Cap'n Web does not hide any of those: + +| Reality of the network | How it surfaces in Cap'n Web | +| ---------------------- | --------------------------------------------------------------------------------------------- | +| Latency | Every call returns an [`RpcPromise`](/concepts/promises/). You can see every place you wait. | +| Partial failure | A dropped session [breaks every stub](/guides/sessions/) and rejects pending calls. | +| Remote lifetime | [Disposal](/concepts/disposal/) is explicit; there is no distributed GC pretending otherwise. | + +The second failure was being **synchronous first.** In CORBA a call blocked until it returned, so a +chain of N dependent calls cost N round trips, which made fine-grained object graphs unusable over +a network and pushed everyone toward coarse, chatty-avoiding "service" interfaces. Promise +pipelining inverts that: fine-grained interfaces are the ones that pipeline well. + +The third was sheer size. Cap'n Web is a single dependency-free package with a +[wire protocol](/reference/protocol/) you can read in one sitting. + +None of this makes distributed systems easy. You still have to decide what happens on reconnect and +how your API evolves (see [Sessions & reconnection](/guides/sessions/)). + +## Is it a protocol, or a JavaScript library? + +Both. The `capnweb` npm package is one implementation; the [wire protocol](/reference/protocol/) is +a specification you can implement yourself, which is what you would do to build an interoperating +peer. + +The protocol is JavaScript-flavoured to roughly the same extent JSON is. Its value types are the +JavaScript built-ins, but nothing about the framing, the import/export tables or the expression +language requires a JavaScript peer. + +The one genuinely language-dependent corner is *producing* a [`.map()`](/concepts/map/) recording. +The `["remap", ...]` expression that goes over the wire is plain data, so any implementation can +**evaluate** one. Turning a natural-looking lambda into that data structure is the hard part, and +JavaScript can only do it by [record-replay](/concepts/map/#how-the-heck-does-that-work) because it +cannot reflect on a function body. A language with first-class expression trees, such as C#, could +build the same structure directly from a lambda. diff --git a/packages/docs/src/content/docs/guides/security.md b/packages/docs/src/content/docs/guides/security.md new file mode 100644 index 00000000..4404799a --- /dev/null +++ b/packages/docs/src/content/docs/guides/security.md @@ -0,0 +1,194 @@ +--- +title: Security Considerations +description: Authentication over WebSocket, denial-of-service from pipelining, payload limits, and why types are not validation. +sidebar: + order: 1 +--- + +Cap'n Web is an object-capability system, which gives you strong tools for authorization, but there +are a handful of things you must get right yourself. + +## Authenticate in-band, not with cookies + +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 authenticating in-band, via an RPC method that returns the +authenticated API: + +```ts +interface PublicApi { + // Authenticate the API token, and return the authenticated API. + authenticate(apiToken: string): AuthedApi; + + // Doesn't require authentication. + getUserProfile(userId: string): Promise<UserProfile>; +} +``` + +```ts +// The client never gets an AuthedApi without presenting a valid token. +using api = newWebSocketRpcSession<PublicApi>('wss://example.com/api'); +using authed = api.authenticate(apiToken); +``` + +On the server, `authenticate()` checks the credential once and returns a **new object holding the +result**: + +```ts +class PublicApi extends RpcTarget { + authenticate(apiToken: string): AuthedApi { + let user = verifyToken(apiToken); // throws if invalid + return new AuthedApi(user); + } +} + +class AuthedApi extends RpcTarget { + constructor(private user: User) { super(); } + + // No token, no re-check. Holding this object is the proof. + getUserId() { return this.user.id; } +} +``` + +This is the object-capability pattern doing real work: the returned `AuthedApi` stub *is* the +authorization. There is no ambient authority to confuse, and no way to call an authenticated method +without holding the capability. Thanks to [pipelining](/concepts/promises/), it also costs no extra +round trip. + +Yes, this means the server holds state, but only in memory, and only for the lifetime of that one +session: the WebSocket connection, or the single HTTP batch. Nothing is persisted, and there is no +session store to secure or expire. See [Sessions & reconnection](/guides/sessions/). + +## Rate-limit, because pipelining is cheap for attackers + +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, in a single message. + +To mitigate this, implement **rate limits on expensive operations**. Note that limits applied by a +load balancer or gateway will not help here: they count requests or frames, and pipelining makes +one frame arbitrarily expensive. The limit has to live in the application. + +Two amplifiers worth knowing about: + +- **Nested `.map()` multiplies.** A map over N elements whose callback maps over M produces N × M + server-side calls from one client message. Unbounded *recursion* is less dangerous than it looks: + [the recording is built on the caller's stack](/concepts/map/#nesting-and-recursion), so a runaway + callback overflows the client first. But a deliberately crafted deep recording is not + self-limiting. +- **Un-awaited calls accumulate.** Every outstanding promise and every stub the peer holds pins an + entry in your export table, and the object behind it, for the life of the session. A peer that + never settles or disposes anything grows your memory monotonically. + + There is no library setting for this. `RpcSessionOptions.limits` covers message size, nesting + depth and bigint digits (not reference counts), so if you need a bound on how much one session + can pin, you have to enforce it in your own code. Attaching disposers to the objects you return + gives you something to count. + +If using Cloudflare Workers, 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 (that is, not Durable Objects), +the system considers an entire WebSocket session to be one "request" for CPU limit purposes. + +## Set transport payload limits + +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**: + +| Runtime | Option | +| ----------------- | ------------------------------------------------ | +| Node.js `ws` | `new WebSocketServer({ maxPayload })` | +| Bun | `Bun.serve({ websocket: { maxPayloadLength } })` | +| Browsers / others | 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 defence against buffering very large frames. + +## Types are not validation + +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 your 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. + +Can a peer pass a callback where you declared a `string`? Yes. It will arrive as an `RpcStub`, your +code will do something surprising with it, and TypeScript will have told you nothing. Validate at +the boundary. + +### Use `capnweb-validate` + +The companion package [`capnweb-validate`](/guides/validation/) is the recommended answer, and it is +built for exactly this problem. It keeps your **TypeScript method signatures as the source of +truth** and generates the runtime checks from them at build time, so the boundary is described once +rather than twice: + +```ts +import { validateRpc } from 'capnweb-validate'; + +@validateRpc() +export class Api extends RpcTarget { + // Arguments are checked against these types before the method body runs. + getUser(id: string, opts: { includeEmail: boolean }) { + // ... + } +} +``` + +Two properties make it worth reaching for over hand-written checks: + +- **Every method is covered.** Validation is applied to the class, so a method added next year is + checked without anyone remembering to check it. Hand-rolled guards protect only the boundary + someone thought about. +- **It fails closed.** If the decorator is left untransformed because the bundler plugin is not + wired up, it throws a configuration error at startup rather than quietly running unvalidated. You + cannot ship a service that only looks validated. + +See [Runtime Validation](/guides/validation/) for setup. + +A general-purpose schema library such as [Zod](https://zod.dev/) works too, and is the better choice +if you already validate with one elsewhere in the codebase, or if you need constraints a type cannot +express, like "a string of at most 200 characters" or "a positive integer". The two compose: derive +the shape from the types, then apply your own checks to the values. In the future we hope to explore +auto-generating type-checking code based on TypeScript types in the core library. + +### What the protocol does guarantee + +Type confusion is your problem, but prototype pollution is not. The protocol hardens two things +regardless of what you do: + +- **`Object.prototype` members are unreachable.** Any property name that exists on + `Object.prototype` (`constructor`, `__proto__`, `valueOf`, `hasOwnProperty` and friends) is + blocked both when resolving a property path and when deserializing an object literal. This holds + even if the target object has legitimately overridden the name. +- **`toJSON` is stripped on the way in.** It is not an `Object.prototype` member, but it would let a + peer influence how your values serialize, so an incoming object carrying one has it removed. Note + that this applies to deserialization only, not to property paths. +- **Array paths accept only non-negative integer indices**, matching what serialization can produce. + +What is reachable *on* an object depends on what kind of object it is, and the two rules are +opposites: an `RpcTarget` exposes its **prototype** members and explicitly refuses instance +properties, while a plain object exposes its **own** properties only. That distinction decides where +it is safe to put a secret, so read [RpcTarget](/concepts/rpc-target/) rather than guessing. + +## `private` and `.map()` + +**`private` is not private.** TypeScript's `private` is erased at runtime and does not hide a method +from RPC. Use `#`-prefixed names for genuinely private members. See +[RpcTarget](/concepts/rpc-target/). + +**Stubs captured by `.map()` are handed to the peer.** Any stubs you use in a `.map()` callback, and +any parameters you pass to them, are sent to the peer, and a malicious peer can use them for +anything, not just calling your callback. Typically it only makes sense to invoke stubs that came +from that same peer originally. See [The magic `map()`](/concepts/map/). + +## Reporting vulnerabilities + +Please report security issues in Cap'n Web according to the +[project's security policy](https://github.com/cloudflare/capnweb/blob/main/SECURITY.md). diff --git a/packages/docs/src/content/docs/guides/sessions.md b/packages/docs/src/content/docs/guides/sessions.md new file mode 100644 index 00000000..32a9ff22 --- /dev/null +++ b/packages/docs/src/content/docs/guides/sessions.md @@ -0,0 +1,207 @@ +--- +title: Sessions & Reconnection +description: How long RPC state lives, what a dropped connection destroys, how to reconnect and resume, and how to evolve and scale a Cap'n Web service. +sidebar: + order: 2 +--- + +Cap'n Web keeps state, but only for the life of one session, and never on disk. Understanding +exactly how long "one session" is answers most operational questions about it. + +## Nothing is persistent + +Each side of a session maintains [import and export tables](/reference/protocol/) mapping IDs to +live objects. Those tables exist only in memory, only for that session: + +| Transport | A session lasts | +| ---------------------------------------- | ------------------------------ | +| [WebSocket](/transports/websocket/) | The lifetime of the socket | +| [HTTP batch](/transports/http-batch/) | A single HTTP request/response | +| [MessagePort](/transports/message-port/) | The lifetime of the port | + +So there is no persistence story, because there is nothing to persist. You do **not** need a +database, a session store, or a serialization format for the tables. You could not write one +anyway. An export ID is a reference to a live object in a live process. Serializing it would be +like serializing a file descriptor. + +Server-side state naturally attaches to the objects you export, and dies with them: + +```ts +class PublicApi extends RpcTarget { + authenticate(token: string): AuthedApi { + let user = verifyToken(token); // throws if bad + return new AuthedApi(user); // state lives on this object + } +} + +class AuthedApi extends RpcTarget { + constructor(private user: User) { super(); } + + getUserId() { return this.user.id; } // no token re-check needed +} +``` + +The client's ability to call `getUserId()` *is* the `AuthedApi` reference it holds. That object +(and the `user` it closed over) lives until the session ends or the client disposes the stub. On an +HTTP batch it lives for a few milliseconds. See +[Security considerations](/guides/security/#authenticate-in-band-not-with-cookies). + +## Design for the session going away + +Because a session can end at any moment: + +:::tip +**It must always be possible to reconnect and reconstruct.** Never design an interaction where +losing the session part-way through leaves the client unable to recover, or the system in a state +nobody can repair. +::: + +In practice that means: + +- Don't hand out a capability that can only ever be obtained once. If the client's only route to + some object is a one-shot method, a dropped socket strands them permanently. +- Make mutations idempotent, or give them a client-supplied ID, so a retry after an ambiguous + failure is safe. +- Anything long-running should be resumable from a checkpoint the client already knows about. + +## Reconnecting + +Cap'n Web does not reconnect automatically, and it deliberately does not try to re-establish your +stubs for you: it cannot know whether the objects they referred to still make sense. + +When a session drops, **every stub from that session is permanently broken.** Pending calls reject, +and new calls on old stubs fail immediately. Detect it with `onRpcBroken`: + +```ts +stub.onRpcBroken((error) => { + console.error('connection lost:', error); +}); +``` + +Recovery means creating a new session and calling the methods again to get fresh objects. The +pattern that makes this bearable is to have exactly one place that owns the root stub, and derive +everything else from it: + +```ts +function connect(): RpcStub<PublicApi> { + return newWebSocketRpcSession<PublicApi>('wss://example.com/api'); +} +``` + +### The React pattern + +Hold the root stub in state at the top of the tree and pass it down as a prop. Child components +call the methods they need off that stub rather than storing sub-stubs of their own. + +```tsx +function connect(onBroken: () => void): RpcStub<PublicApi> { + let api = newWebSocketRpcSession<PublicApi>('wss://example.com/api'); + api.onRpcBroken(onBroken); + return api; +} + +function App() { + let [api, setApi] = useState(() => connect(reconnect)); + + function reconnect() { + // The thunk is load-bearing -- see below. + setApi(() => connect(reconnect)); + } + + return <Dashboard api={api} />; +} +``` + +On reconnect you set a *new* root stub, React re-renders the tree, and every child re-derives its +own capabilities from the new session. There is no per-component reconnection logic, and no risk of +a component holding a stub from the previous session. + +:::danger[Three things that will bite you here] +**A stub is callable.** `RpcStub` is a `Proxy` whose target is a function, so +`typeof stub === 'function'` is true. React's state setters treat a function argument as an *updater +callback*, which means `setApi(newStub)` calls your stub instead of storing it; because the +call returns an `RpcPromise` rather than throwing, you get a rejected promise in state and no +obvious error. Always `setApi(() => newStub)`. + +**Don't dispose the session in an effect cleanup.** Disposing the root stub closes the connection. +React StrictMode runs effects mount → cleanup → mount in development, so a cleanup that disposes +would tear down a session the remounted component then keeps using, and every later call throws. +Tie disposal to real unmount or page unload, not to an effect. (StrictMode also double-invokes +`useState` initializers, so expect one extra socket in development.) + +**`onRpcBroken` cannot be unregistered.** It returns nothing, and registering twice on the same stub +fires twice. Register it where the session is created (as `connect()` does above) rather than in +an effect that might re-run. +([#234](https://github.com/cloudflare/capnweb/issues/234) proposes returning a disposable handle.) +::: + +### Resumable subscriptions + +A subscription that just pushes events will silently lose whatever happened while the client was +disconnected. Design the API so the caller can say where it left off: + +```ts +interface AuthedApi { + // Deliver every event after `sinceId`, then keep streaming live ones. + subscribe(sinceId: string | null, sink: EventSink): void; +} +``` + +On reconnect the client passes the ID of the last event it actually processed, and the server +replays the gap. This costs you nothing when nothing was missed, and is the difference between a +subscription that survives a train tunnel and one that does not. + +## Versioning and deploys + +Cap'n Web has no schema, so it also has no schema-evolution mechanism: no field numbers, no +reserved tags, no wire-level compatibility rules to learn. The rules are the ones you already know +for **evolving a JavaScript API without breaking existing callers.** + +| Safe | Breaking | +| --------------------------------------------- | -------------------------------------- | +| Add a new method \* | Rename or remove a method | +| Add a new **optional** parameter at the end | Add a required parameter | +| Add a property to a returned object | Remove or rename a returned property | +| Accept a wider type than before | Accept a narrower type than before | +| Return a new capability alongside the old one | Change what an existing method returns | + +\* These are the rules for Cap'n Web itself. If you use [`capnweb-validate`](/guides/validation/), +its generated validators are stricter; a peer whose validator was built before you added a method +will refuse the call. Its own compatibility table is under +[Schema evolution](/guides/validation/#schema-evolution), and you should read both. + +Two deployment realities to plan for: + +- **Both versions run at once** during a rolling deploy, so a client may talk to an old instance on + one connection and a new one on the next. +- **WebSocket clients can be very old.** A browser tab left open for a week is still holding a + session against whatever you deployed a week ago. Add capabilities; don't take them away. + +## Load balancing and scaling + +Nothing persists, but a WebSocket session *is* in-memory state, which means it is pinned to one +process for its lifetime. The consequences are the ordinary ones for stateful connections, plus one +that is specific to pipelining. + +- **Every frame of a socket must reach the same backend.** Any load balancer that routes a + WebSocket as a single connection already does this. Do not put a session behind something that + can re-route mid-stream. +- **Scale-in is the awkward part.** Long-lived connections do not drain by themselves, so an + instance can stay alive for hours waiting for the last tab to close. Since clients must handle + reconnection anyway, you can lean on it: cap session age, then close with a normal closure and + let clients come back on a fresh instance. +- **Request-count metrics will lie to you.** [Pipelining](/concepts/promises/) means one message can + carry an enormous amount of work, so a load balancer counting requests, or a rate limiter counting + frames, sees almost nothing. Rate-limit expensive *operations* inside the application. See + [Security considerations](/guides/security/#rate-limit-because-pipelining-is-cheap-for-attackers). +- **Budget memory per session.** Everything the peer holds a reference to is pinned in your export + table until it is released, and the library has no export-count limit to set; bounding what a + single session may accumulate is your own bookkeeping. See + [Security considerations](/guides/security/). + +[HTTP batch](/transports/http-batch/) sidesteps all of this: a batch is one request, any instance +can serve it, and everything is released when the response is written. It is the right transport +for a stateless edge deployment. + +On Cloudflare Workers, a [Durable Object](/servers/workers/) gives a session a natural, addressable +home. diff --git a/packages/docs/src/content/docs/guides/validation.md b/packages/docs/src/content/docs/guides/validation.md new file mode 100644 index 00000000..61cc6cfa --- /dev/null +++ b/packages/docs/src/content/docs/guides/validation.md @@ -0,0 +1,262 @@ +--- +title: Runtime Validation +description: Generate runtime validators from your TypeScript types at build time with capnweb-validate. +sidebar: + order: 3 +--- + +Your method signature says `id: string`. Nothing stops a peer from sending an array, an object with +a `$ne` property, or a callback that arrives as an `RpcStub`. TypeScript is erased long before the +call does, so at runtime that signature is a comment. + +[`capnweb-validate`](https://www.npmjs.com/package/capnweb-validate) makes it enforceable. Mark a +class with `@validateRpc()`, and at build time a bundler plugin (or the CLI) reads the resolved +TypeScript types and injects a validator for each method, which runs before your code does: + +```ts +@validateRpc() +export class Api extends RpcTarget { + // A caller sending anything but a string now gets an error, not your method body. + getUser(id: string) { + return this.db.find(id); + } +} +``` + +You do not write a schema. The types you already wrote are the schema. + +:::note +If a validation decorator is left untransformed, it throws a configuration error rather than +silently running without validation. You cannot accidentally ship an unvalidated service. +::: + +## Does this mean defining my API twice? + +No, and this is the reason `capnweb-validate` exists in this shape. + +"Schemaless" means the *library* needs no schema: Cap'n Web forwards whatever call you make without +being told about it in advance. It does not mean you have no contract. Your contract is the +TypeScript interface, used on both ends. The only problem is that TypeScript is erased before your +code ever meets a hostile input. + +There are two honest ways to close that gap without writing the interface out twice: + +- **Generate the validators from the types.** That is what `capnweb-validate` does: the TypeScript + signature stays the single source of truth and the runtime check is derived from it at build time. +- **Generate the types from the validators.** Schema libraries like [Zod](https://zod.dev/) infer + TypeScript types from the schema object, so you write the schema and get the types for free. + [ArkType](https://arktype.io/), [typia](https://typia.io/) and + [ts-runtime-checks](https://github.com/GoogleFeud/ts-runtime-checks) occupy similar territory, + the last two also transforming TypeScript types directly into checks. + +Either way you write one description of the boundary, not two. What you must not do is write only +the TypeScript and assume it is doing something at runtime. + +## Install + +Two packages, or one if you are on Workers RPC: + +```sh +npm install capnweb capnweb-validate +``` + +Workers RPC users can install `capnweb-validate` without installing `capnweb`. The root package has +no runtime dependency on `capnweb`; Cap'n Web-specific helpers live under `capnweb-validate/capnweb`. + +There is no TypeScript peer dependency to satisfy. Reading your resolved types needs a compiler with +the JavaScript API, so the package depends on `typescript` (`>=5.7.0 <7`) directly and uses that +copy. This is what keeps the transform working in a TypeScript 7 (`tsgo`) workspace, where the +compiler your editor and `tsc` use no longer exposes that API. + +## 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'; + +type User = { id: string; name: string }; + +@validateRpc() +export class Api extends RpcTarget { + async authenticate(sessionToken: string): Promise<User> { + // ... + } +} + +export default { + async fetch(request: Request, env: Env) { + return newWorkersRpcResponse(request, new Api()); + }, +}; +``` + +`@validateRpc()` validates calls on class instances, so it works with Cap'n Web, Workers +`WorkerEntrypoint`, and Workers `DurableObject` services. + +With no explicit type argument, the RPC surface is the class's public string-named methods and +RPC-readable getters/properties, matching Cap'n Web dispatch. `implements SomeInterface` can sharpen +matching signatures, but it does **not** hide extra public class methods. Keep local-only helpers +private or symbol-named. + +An explicit `@validateRpc<SomeInterface>()` makes `SomeInterface` the RPC surface. Public class +methods outside that interface are rejected over RPC. + +## Client usage + +Client-side stub validation is explicit. Wrap a client stub with `validateStub<T>()` when the caller +wants return values and pipelined calls checked against a concrete surface: + +```ts +import { newHttpBatchRpcSession } from 'capnweb'; +import { validateStub } from 'capnweb-validate'; + +import type { Api } from './worker'; + +export const api = validateStub<Api>(newHttpBatchRpcSession<Api>('/rpc')); +``` + +`validateStub<T>()` validates resolved return values on the caller side. It does **not** validate +outgoing arguments; the receiver validates those on arrival. + +## Wiring it into your build + +### Bundler plugins + +```ts +import capnwebValidate from 'capnweb-validate/vite'; // or +import capnwebValidate from 'capnweb-validate/rollup'; // or +import capnwebValidate from 'capnweb-validate/webpack'; // or +import capnwebValidate from 'capnweb-validate/rspack'; // or +import capnwebValidate from 'capnweb-validate/esbuild'; // or +import capnwebValidate from 'capnweb-validate/farm'; + +export default { + plugins: [capnwebValidate()], +}; +``` + +The plugin transforms matching modules in memory; your source files are not modified on disk. + +### CLI + +Wrangler does not expose a bundler plugin hook. For Wrangler, CI, or any flow that needs transformed +files on disk: + +```sh +capnweb-validate build --out .capnweb-validate +``` + +| Option | Meaning | +| ------------------- | ----------------------------------------------------- | +| `--out <dir>` | Where to write the transformed source tree. Required. | +| `--tsconfig <path>` | Defaults to `./tsconfig.json`. | +| `--cwd <dir>` | Defaults to `process.cwd()`. | + +Point the downstream build tool at the generated entry under `--out`. + +## Opting out per method + +`@skipRpcValidation()` exempts one method from an otherwise validated class: + +```ts +import { RpcTarget } from 'capnweb'; +import { skipRpcValidation, validateRpc } from 'capnweb-validate'; + +@validateRpc() +class Api extends RpcTarget { + @skipRpcValidation() + unsafe(payload: unknown): unknown { + return payload; + } +} +``` + +The method still goes through Cap'n Web normally. This only disables `capnweb-validate` validation +for that method. + +## Validation errors + +Failures throw `TypeError`, so they keep their standard error type when crossing RPC boundaries. The +message includes the failing path, expected type, and actual type. + +| 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. | + +## Type coverage + +The supported set matches Cap'n Web's published wire format: every type Cap'n Web guarantees can +travel over RPC also has a precise build-time validator. That includes primitives and literal types, +arrays, tuples, `Map`/`Set`, plain object shapes, unions, `Record`/index signatures, `Promise<T>` +returns, and the RPC-compatible built-ins (`Date`, `ArrayBuffer`, typed arrays, `Error` subclasses, +`Blob`, streams, `URL`, `Headers`, `Request`, `Response`). Pass-by-reference values are validated +as stubs: functions, `RpcStub<T>`, `RpcPromise<T>`, `RpcTarget` subclasses, and Workers +`Fetcher<T>`. + +These are rejected **at build time** so you find out before the first RPC call: + +| Type | Reason | +| ------------------- | ------------------------------------- | +| `WeakMap` | Not a supported RPC validation type. | +| `WeakSet` | Not a supported RPC validation type. | +| `SharedArrayBuffer` | Not a supported RPC validation type. | +| `File` | Use a `Blob` or `Uint8Array` instead. | + +Overloaded methods are passed through unvalidated with a warning. Validating against one signature +would reject valid calls to the others. Collapse the overloads into a single signature with union +parameters, or use `@skipRpcValidation()` to silence the warning. + +For generics, the transform emits one validator at the class declaration, so it cannot specialize +per-`new`-expression. Use an explicit surface such as `@validateRpc<Cursor<string>>()` when the type +arguments are known at the decorator site. An unconstrained type parameter defaults to `any` with a +warning; a constrained one validates against its constraint. + +## Schema evolution + +A validator built from one version of your types will eventually meet a peer built from another. +Additive changes go through; changes that would let an unchecked value reach your code do not: + +| Change | Result | +| -------------------------------------- | ------- | +| Extra argument | Allowed | +| Extra object property | Allowed | +| Extra index-signature key | Allowed | +| New optional parameter or property | Allowed | +| Missing required parameter or property | Refused | +| Renamed or retyped member | Refused | +| Changed tuple length, no rest element | Refused | +| New union member | Refused | +| New method | Refused | + +To remove a required member, make it optional in one release and delete it in a later one, so no +build ever requires something a peer has already stopped sending. + +"Allowed" is not the same as "visible". Extra arguments are **dropped before the method runs**, so an +implementation cannot read an argument no validator checked: + +```ts +// spec generated from: greet(name: string) +greet(name: string, ...rest: unknown[]) { + // rest is always empty +} + +// spec generated from: sum(label: string, ...values: number[]) +sum(label: string, ...values: number[]) { + // gets every argument, each one validated +} +``` + +Truncation only applies where the spec declares its parameters. A client-side spec omits `args` +entirely, so nothing is dropped there. Extra *object properties*, by contrast, are forwarded to the +implementation unvalidated; an index signature is the exception, since it validates every property +outside the declared ones. + +Keep `strictNullChecks` on. Without it TypeScript erases `null` from your types, and the generated +validator will refuse a `null` that a peer built with the flag on considers perfectly valid. + +Full details are in the +[`capnweb-validate` README](https://github.com/cloudflare/capnweb/tree/main/packages/capnweb-validate). diff --git a/packages/docs/src/content/docs/guides/workers-rpc.md b/packages/docs/src/content/docs/guides/workers-rpc.md new file mode 100644 index 00000000..cd69845a --- /dev/null +++ b/packages/docs/src/content/docs/guides/workers-rpc.md @@ -0,0 +1,84 @@ +--- +title: Workers RPC Interop +description: How Cap'n Web interoperates with the RPC system built into the Cloudflare Workers Runtime, and where the two still differ. +sidebar: + order: 5 +--- + +Cap'n Web works on any JavaScript platform. But on Cloudflare Workers specifically, it's designed to +play nicely with [the built-in RPC system](https://blog.cloudflare.com/javascript-native-rpc/). + +The two have basically the same semantics. The only fundamental difference is that Workers RPC is a +built-in API provided by the Workers Runtime, whereas Cap'n Web is implemented in pure JavaScript. + +## What interoperates + +- 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 + automatically sets 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". + +```ts +import { RpcTarget, newWorkersRpcResponse } from 'capnweb'; + +export class Api extends RpcTarget { + constructor(private env: Env) { + super(); + } + + // Hand a browser client a capability backed by a Durable Object. + getRoom(name: string) { + let id = this.env.ROOMS.idFromName(name); + return this.env.ROOMS.get(id); // a DO stub, proxied over Cap'n Web + } +} +``` + +## Compatibility date + +For best compatibility, set your +[Workers compatibility 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`. + +This aligns the Workers Runtime with Cap'n Web's stub ownership rules for call parameters. + +## Where they still differ + +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. + +Expect Cap'n Web to run ahead. It is a library rather than a runtime built-in, so it can ship a new +idea in a version bump instead of a compatibility flag, and that makes it the natural place to +experiment. `.map()` is the current example: it exists in Cap'n Web and is on the list for Workers +RPC. The intent is that the two converge, with Cap'n Web arriving first. + +| Capability | Cap'n Web | Workers RPC | +| ------------------------------------------- | --------- | ----------- | +| `Map`, `Set`, and some other built-ins | Not yet | Yes | +| Values containing aliases and cycles | No | Yes\* | +| `RpcPromise` in the parameters of a request | Yes | Not yet | +| The magic `.map()` method | Yes | Not yet | +| `onRpcBroken()` | Yes | Not yet | + +\* Workers RPC supports sending values that contain aliases and cycles. This can cause problems, so +we plan to **remove** this feature from Workers RPC, with a compatibility flag, of course. + +[`onRpcBroken()`](/guides/sessions/#reconnecting) is worth calling out, because there +is no clean way to reconstruct it. It is how you learn that a peer went away, which is what drives +reconnection and what lets a server drop a subscription whose subscriber has vanished. Code holding +a native Workers stub has to fall back to noticing that calls have started failing, or to watching +for the disposer of a stub it handed out. + +## When to use which + +- **Worker-to-Worker or Worker-to-Durable-Object**, inside Cloudflare: use built-in Workers RPC. It + is faster and needs no library. +- **Browser-to-Worker**, or anything crossing the public internet: use Cap'n Web. Workers RPC does + not speak to browsers. +- **Both**: mix freely. Stubs cross the boundary and Cap'n Web proxies them. diff --git a/packages/docs/src/content/docs/index.mdx b/packages/docs/src/content/docs/index.mdx new file mode 100644 index 00000000..1cbca68b --- /dev/null +++ b/packages/docs/src/content/docs/index.mdx @@ -0,0 +1,72 @@ +--- +title: Cap'n Web +description: A JavaScript-native, object-capability RPC system with promise pipelining. No schemas, no boilerplate, %BUNDLE_SIZE%. +mode: custom +--- + +import { examples } from '../../examples.ts'; +import bundleSize from '../../generated/bundle-size.json'; +import HeroExample from '../../components/HeroExample.astro'; +import Features from '../../components/Features.astro'; +import NavList from '../../components/NavList.astro'; +import { HERO_TITLE, heroTagline } from '../../lib/hero-copy.ts'; + +<Hero + title={HERO_TITLE} + tagline={heroTagline(bundleSize.label)} +> + <HeroExample /> +</Hero> + +<Prose> + +Cap'n Web is a spiritual sibling to [Cap'n Proto](https://capnproto.org), created by the same +author, but designed to play nice in the web stack. Like Cap'n Proto it is an **object-capability** +protocol: "Cap'n" is short for "capabilities and". Unlike Cap'n Proto, it has no schemas, almost no +boilerplate, and its serialization is just JSON with a little pre- and post-processing. + +## Why it's different + +<Features bundleLabel={bundleSize.label} /> + +## The magic trick + +Every call returns an `RpcPromise`. You can use that promise, or a property of it, as the input to +another call *before it has resolved*. The server substitutes the real value on arrival, so a chain +of dependent calls costs exactly one round trip: + +```ts +// Authenticate, get the user's ID, fetch their profile, and +// fetch every friend's profile too. One request, one response. +let authed = api.authenticate(apiToken); +let profile = api.getUserProfile(authed.getUserId()); +let friends = authed.getFriendIds().map(id => api.getUserProfile(id)); + +let [me, myFriends] = await Promise.all([profile, friends]); +``` + +[See how promise pipelining works →](/start/pipelining-tour/) — a step-by-step tour, and the +record-replay trick behind `.map()`. + +## See it running + +Both examples run right here in the docs, with the source alongside them. They make the same point +from opposite ends of the stack: the pipelined version issues one HTTP request where the ordinary +version issues three. + +<NavList items={examples.map((example) => ({ + title: example.title, + description: example.description, + href: example.docsPath, +}))} /> + +## Get going + +<NavList items={[ + { title: "Installation", description: "npm i capnweb", href: "/start/installation/" }, + { title: "Quickstart", description: "A working client and server in a few minutes.", href: "/start/quickstart/" }, + { title: "Core concepts", description: "Stubs, targets, promises, and disposal.", href: "/concepts/values/" }, + { title: "Wire protocol", description: "What actually goes over the socket.", href: "/reference/protocol/" }, +]} /> + +</Prose> diff --git a/packages/docs/src/content/docs/reference/api.md b/packages/docs/src/content/docs/reference/api.md new file mode 100644 index 00000000..fae0a1fe --- /dev/null +++ b/packages/docs/src/content/docs/reference/api.md @@ -0,0 +1,112 @@ +--- +title: API Cheat Sheet +description: Every export of the capnweb package at a glance, with links to the page that explains it. +sidebar: + order: 2 +--- + +Everything below is exported from the `capnweb` package. + +## Starting a session + +| Function | Use | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `newHttpBatchRpcSession<T>(url, options?)` | Client. One HTTP request carrying a whole batch. [Docs](/transports/http-batch/) | +| `newWebSocketRpcSession<T>(urlOrSocket, localMain?, options?)` | Client *and* server. Long-lived bidirectional session. [Docs](/transports/websocket/) | +| `newMessagePortRpcSession<T>(port, localMain?, options?)` | Both ends. Web Workers, iframes. [Docs](/transports/message-port/) | +| `new RpcSession<T>(transport, localMain?, options?)` | Both ends, over any [custom transport](/transports/custom/). | + +`RpcSession` exposes `getRemoteMain(): T` to obtain a stub for the peer's main interface. + +## Answering requests + +| Function | Runtime | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| `newWorkersRpcResponse(request, api, options?)` | [Cloudflare Workers](/servers/workers/). Handles batch *and* WebSocket. | +| `newHttpBatchRpcResponse(request, api, options?)` | Any Fetch-API runtime. [Deno](/servers/deno/), [Bun](/servers/bun/), [others](/servers/other/). | +| `nodeHttpBatchRpcResponse(request, response, api, options?)` | [Node.js](/servers/node/) `http` module. | +| `newBunWebSocketRpcHandler(factory)` | [Bun](/servers/bun/). Returns a `Bun.serve()` `websocket` handler. | +| `newWebSocketRpcSession(socket, api, options?)` | Any runtime, given an open `WebSocket`. | + +## Types and classes + +### `RpcTarget` + +Base class marking instances as **pass-by-reference**. Callers reach prototype methods and getters, +never instance properties. Names prefixed with `#` are never exposed. +[Docs](/concepts/rpc-target/) + +### `RpcStub<T>` + +A `Proxy` standing in for a remote object. + +| Member | Meaning | +| -------------------- | ---------------------------------------------------------------------------- | +| *any method* | Invokes the corresponding method on the remote target. Returns `RpcPromise`. | +| *any property* | Returns an `RpcPromise` for the remote property. | +| `.dup()` | Independent duplicate; target released when all duplicates are disposed. | +| `.onRpcBroken(cb)` | Called if the stub becomes permanently unusable. | +| `[Symbol.dispose]()` | Release this stub. | + +`new RpcStub(target)` creates one locally, without a connection. [Docs](/concepts/stubs/) + +### `RpcPromise<T>` + +A thenable that is *also* a stub for its own eventual result. + +| Member | Meaning | +| ------------------------ | ---------------------------------------------------- | +| `await` / `.then()` | Resolve normally. | +| *any method or property* | Pipelined, no round trip. | +| `.map(fn)` | Transform the value remotely. [Docs](/concepts/map/) | +| `.dup()` | Duplicate, usable immediately. | +| `[Symbol.dispose]()` | Release; disposes the future result too. | + +[Docs](/concepts/promises/) + +### `RpcTransport` + +Interface for custom transports. + +```ts +interface RpcTransport { + send(message: string): Promise<void>; + receive(): Promise<string>; + abort?(reason: any): void; + // Optional: "string" | "jsonCompatible" | "jsonCompatibleWithBytes" | "structuredClonable" + encodingLevel?: string; +} +``` + +[Docs](/transports/custom/) + +### `RpcSessionOptions` + +Passed as the last argument to the session and response helpers. Commonly used for response +`headers` (e.g. CORS) on the HTTP batch helpers. + +## Disposal at a glance + +| You have | You must | +| ---------------------------------------------------- | ------------------------------------------------------ | +| A stub you created or received as a **return value** | Dispose it. | +| A stub you passed as a **parameter** | Dispose your copy; the callee's copy is auto-disposed. | +| A stub received as a **parameter**, needed later | `.dup()` it, then dispose the duplicate later. | +| A promise you will never await | Dispose it (or use `using`). | +| A property of a stub or promise | Nothing; dispose the parent. | +| Anything, in an HTTP batch | Nothing; the batch end disposes everything. | + +[Full rules](/concepts/disposal/) + +## Value types on the wire + +**By value:** primitives, plain objects, arrays, `bigint`, `Date`, `ArrayBuffer`, `DataView`, typed +arrays, `Error` and well-known subclasses, `Blob`, `ReadableStream`, `WritableStream`, `URL`, +`Headers`, `Request`, `Response`. + +**By reference:** `RpcTarget` subclasses, functions, existing stubs and promises. + +**Not supported:** `Map`, `Set`, `RegExp` (not yet); non-`RpcTarget` classes and cyclic values +(intentionally). + +[Docs](/concepts/values/) diff --git a/packages/docs/src/content/docs/reference/protocol.md b/packages/docs/src/content/docs/reference/protocol.md new file mode 100644 index 00000000..d6ca24d6 --- /dev/null +++ b/packages/docs/src/content/docs/reference/protocol.md @@ -0,0 +1,552 @@ +--- +title: Wire Protocol +description: The complete Cap'n Web wire protocol, covering serialization, import/export tables, top-level messages, and expressions. +tableOfContents: + minHeadingLevel: 2 + maxHeadingLevel: 3 +sidebar: + order: 1 +--- + +This page documents what actually goes over the socket. You do not need it to use Cap'n Web, but you +do need it to implement an interoperating peer, or to debug traffic in a network inspector. + +## Serialization + +The protocol uses JSON as its basic serialization, with a preprocessing step to support non-JSON +types. + +Why not a binary format? While the author is a big fan of optimized binary protocols in other +contexts, it cannot be denied that in a browser, JSON has big advantages. Being built into the +browser gives it a leg up in performance, code size, and developer tooling. + +The usual argument for binary is size on the wire, and it is weaker here than it looks: + +- `JSON.parse` and `JSON.stringify` are native. A binary codec written in JavaScript has to beat + optimized C++ from inside the JS engine, which is a hard start. +- In a browser, **the code you ship is part of the cost**. A binary format that saves bytes per + message but adds kilobytes to the bundle can easily lose, especially for an application that is + not chatty. +- The obvious redundancy (the same property names repeating in every message) is exactly what + compression is good at. Where it is available, WebSocket `permessage-deflate` or HTTP content + encoding removes most of it without the protocol having to. (Availability is not universal: + Cloudflare Workers' `WebSocketPair` does not negotiate compression extensions.) + +You also get to read the traffic in your browser's network inspector, which is not nothing. + +Non-JSON types are encoded using arrays. The first element of the array contains a string type code, +and the remaining elements contain the parameters needed to construct that type. For example, a +`Date` might be encoded as: + +```json +["date", 1749342170815] +``` + +To encode an array, the array must be wrapped in a second layer of array to create an array +expression: + +```json +[["just", "an", "array"]] +``` + +## Client vs. server + +The protocol does not have a "client" or a "server"; it is fully bidirectional. Either side can call +interfaces exported by the other. + +With that said, for documentation purposes, we often use the words "client" and "server" when +describing specific interactions, in order to make the language easier to understand. The word +"client" generally refers to the caller of an RPC, or the importer of a stub. The word "server" +refers to the callee, or the exporter. This is merely a convention to make explanations more +natural. + +## Transport and framing + +The protocol operates on a bidirectional stream of discrete messages. Each message is a single JSON +value, typically an array. The protocol does not define how messages are framed on the wire; this is +the responsibility of the transport layer. + +For transports that natively provide message framing, such as WebSocket or `MessagePort`, each +transport-level message corresponds to exactly one RPC message. + +The built-in HTTP transport is **newline-delimited**, packing a series of messages into a single +HTTP request or response body. Each message is serialized as a single line of JSON with no embedded +newlines, and messages are separated by a newline character (`\n`). An empty body is interpreted as +zero messages. + +Other transports are free to use other framing strategies. + +## Imports and exports + +Each side of an RPC session maintains two tables: **imports** and **exports**. One side's exports +correspond to the other side's imports. Imports and exports are assigned sequential numeric IDs. +However, in some cases an ID needs to be chosen by the importing side, and in some cases by the +exporting side. To avoid conflicts: + +- When the **importing** side chooses the ID, it chooses the next **positive** ID, starting from 1 + and going up. +- When the **exporting** side chooses the ID, it chooses the next **negative** ID, starting from -1 + and going down. +- ID **zero** is automatically assigned to the "main" interface. + +To be more specific: + +- The importing side chooses the ID when it initiates a call: the ID represents the result of the + call. +- The exporting side chooses the ID when it sends a message containing a stub: the ID represents the + target of the stub. + +For comparison, in CapTP and Cap'n Proto there are four tables instead of two: imports, exports, +questions, and answers. In this library, questions are unified with imports, and answers with +exports. + +By convention, when describing the meaning of any RPC message, we always take the perspective of the +sender. So if a message contains an "import ID", it is an import from the perspective of the sender, +and an export from the perspective of the recipient. + +Note that IDs are never reused. This differs from Cap'n Proto, which always tries to choose the +smallest available ID. We assume no session will ever exceed 2^53 IDs, so assigning sequentially is +fine. + +## Push and pull + +An RPC call follows this sequence: + +1. The client sends the server a **push** message containing an expression to evaluate. + - The push is implicitly assigned the next positive ID in the client's import table. + - The expression expresses the call to make. + - Upon receipt, the server evaluates the expression and delivers the call to the application. +2. The client subsequently sends a **pull** message specifying the import ID just created by the + push. This expresses that the client is interested in receiving the result as a **resolve** + message. +3. The client may subsequently refer to the import ID in pipelined requests. +4. When the server is done executing the call, it sends a **resolve** message specifying the export + ID of the push and an expression for its result. +5. Upon receiving the resolution, the client no longer needs the import table entry, so it sends a + **release** message. Upon receipt, the server disposes its copy of the return value, if + necessary. + +Some notes: + +- The client does not need to send a pull message if it doesn't care to receive the results. In + practice, if the application never awaits the promise, it is never pulled. **The promise can still + be used in pipelining without pulling.** +- Technically, the pushed expression can contain any number of calls, including none. A client + could, for example, push a large data structure containing no calls, then subsequently make + multiple calls that use this data structure via pipelining, avoiding sending the same data + multiple times. +- If the call throws an exception, the server sends a **reject** message instead of resolve. +- Resolve and reject are the same messages used to resolve exported promises (that is, a promise + introduced when it was sent as part of some other RPC message). Thus, calls and exported promises + work the same. This differs from Cap'n Proto, where returning from a call and resolving an + exported promise were entirely different messages, with a lot of duplicated semantics. + +## Top-level messages + +The following are the top-level messages that can be sent over the RPC transport. + +### push + +```json +["push", expression] +``` + +Asks the recipient to evaluate the given expression. The expression is implicitly assigned the next +sequential import ID, in the positive direction. The recipient evaluates the expression, delivering +any calls therein to the application. The final result can be pulled, or used in promise pipelining. + +### pull + +```json +["pull", importId] +``` + +Signals that the sender would like to receive a resolve message for the resolution of the given +import, which must refer to a promise. This is normally only used for imports created by a push, as +exported promises are pulled automatically. + +### resolve + +```json +["resolve", exportId, expression] +``` + +Instructs the recipient to evaluate the given expression and then use it as the resolution of the +given promise export. + +### reject + +```json +["reject", exportId, expression] +``` + +Instructs the recipient to evaluate the given expression and then use it to reject the given promise +export. The expression is not permitted to contain stubs. It typically evaluates to an `Error`, +although technically JavaScript does not require that thrown values are `Error`s. + +### release + +```json +["release", importId, refcount] +``` + +Instructs the recipient to release the given entry in the import table, disposing whatever it is +connected to. If the import is a promise, the recipient is no longer obliged to send a resolve +message for it, though it is still permitted to do so. + +`refcount` is the total number of times this import ID has been "introduced": the number of times +it has been the subject of an `export` or `promise` expression, plus 1 if it was created by a push. +The refcount must be sent to avoid a race condition if the receiving side has recently exported the +same ID again. The exporter remembers how many times it has exported this ID, decrements by the +refcount of any release messages received, and only actually releases the ID when the count reaches +zero. + +### stream + +```json +["stream", expression] +``` + +Like push, asks the recipient to evaluate the given expression, and the expression is implicitly +assigned the next sequential positive import ID. However, unlike push: + +- Promise pipelining on the result is **not** supported. The caller must not refer to the import ID + in subsequent expressions. +- The expression is automatically considered pulled. No separate pull message is needed. +- Once the recipient sends a resolve or reject for the result, the export is implicitly released + with a refcount of 1. No separate release message is needed. + +This message type is designed for streaming writes, where the result is expected to be empty and the +overhead of separate pull and release messages is high. + +### pipe + +```json +["pipe"] +``` + +Creates a "pipe" on the remote end. A pipe consists of a `ReadableStream` end and a `WritableStream` +end. The pipe is implicitly assigned the next sequential positive import ID, similar to push. + +The new import is **not** a promise. It is immediately usable as if it were a `WritableStream`; the +sender can call `write`, `close`, and/or `abort` on it, using the same interface described for the +[`writable`](#writable) expression. + +The readable end of the pipe can be referenced in a subsequent message using the +[`readable`](#readable) expression. That expression can only be used once per pipe. + +The purpose of the pipe mechanism is to support sending `ReadableStream` over RPC. When a message +contains a `ReadableStream`, the sender first sends a `["pipe"]` message to establish the writable +end, then begins pumping the stream's data through it, and includes the readable end in the +subsequent message via `["readable", importId]`. This allows data to start flowing immediately +without waiting for a network round trip. + +### abort + +```json +["abort", expression] +``` + +Indicates that the sender has experienced an error causing it to terminate the session. The +expression evaluates to the error which caused the abort. No further messages will be sent nor +received. + +## Expressions + +Expressions are JSON-serializable object trees. All JSON types except arrays are interpreted +literally. Arrays are further evaluated into a final value as follows. + +### array + +```json +[[...]] +``` + +An array expression. The inner array contains expressions, one for each array element, which are +individually evaluated to produce the final array value. + +For example, this expression represents an object containing an array: + +```json +{ + "key": [[ + "abc", + ["date", 1757214689123], + [[0]] + ]] +} +``` + +- The 1st item in the array expression is an expression for the string `"abc"`. +- The 2nd item is an expression for a date object. +- The 3rd item is another array expression containing an integer expression representing zero. + +It evaluates to: + +```js +{ + key: [ + "abc", + Date(1757214689123), + [0] + ] +} +``` + +### undefined + +```json +["undefined"] +``` + +The literal value `undefined`. + +### inf, -inf, nan + +```json +["inf"], ["-inf"], ["nan"] +``` + +The values `Infinity`, `-Infinity`, and `NaN`. + +### bytes + +```json +["bytes", base64] +["bytes", base64, type] +``` + +A byte container, represented as a base64-encoded string. If `type` is omitted, the receiver should +deserialize bytes as its default `Uint8Array` for backwards compatibility. Otherwise, `type` +preserves the byte container type across the wire. + +Supported `type` values: `ArrayBuffer`, `DataView`, `Int8Array`, `Uint8Array`, `Uint8ClampedArray`, +`Int16Array`, `Uint16Array`, `Int32Array`, `Uint32Array`, `BigInt64Array`, `BigUint64Array`, +`Float32Array`, `Float64Array`. + +Multi-byte typed array elements are encoded in **little-endian** byte order. + +### blob + +```json +["blob", type, readableExpression] +``` + +A `Blob` value. `type` is the MIME type string (`blob.type`), which may be an empty string. +`readableExpression` evaluates to a `ReadableStream` carrying the blob's bytes; in practice the +encoder always uses a [`readable`](#readable) expression backed by a pipe. + +Because reading a `Blob`'s bytes is inherently asynchronous, the pipe path is always used. There is +no inline fast path even for small blobs. The receiver must collect all chunks from the stream before +delivering the value to application code. + +### bigint + +```json +["bigint", decimal] +``` + +A `bigint` value, represented as a decimal string. Receivers cap the maximum length of this string to +bound parsing cost. + +### date + +```json +["date", number] +``` + +A JavaScript `Date` value. The number is milliseconds since the Unix epoch. + +### error + +```json +["error", type, message, stack?, props?] +``` + +A JavaScript `Error` value. `type` is the name of the specific well-known `Error` subclass, e.g. +`"TypeError"`. `message` is the error message. `stack` may optionally contain the stack trace, +though **by default stacks are redacted for security reasons**. + +`props` is an optional fifth element carrying any extra data attached to the error. It is a JSON +object whose keys are the error's own enumerable properties (plus the standard non-enumerable +`cause` slot, and `errors` for `AggregateError`), and whose values are themselves valid expressions +of this protocol, so they round-trip naturally. Property values that cannot be represented are +silently dropped from `props`; the error itself always reaches the receiver. + +When `props` is present, `stack` is normalised to `null` if absent, so that positional indexing for +`props` is unambiguous. When there are no extras, the legacy 3- or 4-element form is emitted +unchanged. + +### url + +```json +["url", href] +``` + +A `URL` object. `href` is the fully-serialized, normalized URL string, the value of the URL's +`href` property. The receiver reconstructs it with `new URL(href)`. For example: + +```json +["url", "https://example.com/path?q=1"] +``` + +### headers + +```json +["headers", pairs] +``` + +A `Headers` object from the Fetch API. `pairs` is an array of `[name, value]` pairs, where both are +strings. For example: + +```json +["headers", [["content-type", "text/plain"], ["x-custom", "hello"]]] +``` + +### request + +```json +["request", url, init] +``` + +A `Request` object from the Fetch API. `url` and `init` are the parameters to pass to `Request`'s +constructor. The sender should omit properties from `init` when their value would be the default +anyway. + +`init.headers`, if present, must contain an array of pairs suitable to pass to the `Headers` +constructor. `init.body`, if present, is an expression for the body, which must evaluate to `null`, +a string, `Uint8Array`, or `ReadableStream`. Other properties of `init` must be plain values; they +are not evaluated as expressions before being passed to the constructor. + +At this time, `init.signal` is not supported and must not be sent, though that will change when +`AbortSignal` gains support for serialization. + +### response + +```json +["response", body, init] +``` + +A `Response` object from the Fetch API. `body` and `init` are the parameters to pass to `Response`'s +constructor. `body` is an expression which must evaluate to `null`, a string, `Uint8Array`, or +`ReadableStream`. `init.headers`, if present, must contain an array of pairs suitable for the +`Headers` constructor. Other properties of `init` must be plain values. + +At this time, `init.webSocket` (a Cloudflare Workers extension) is not supported and must not be +sent, though that may change if `WebSocket` gains support for serialization. + +### import / pipeline + +```json +["import", importId, propertyPath, callArguments] +["pipeline", importId, propertyPath, callArguments] +``` + +References an entry on the import table, from the perspective of the sender, possibly performing +actions on it. + +If the type is `import`, the expression evaluates to a **stub**. If it is `pipeline`, the expression +evaluates to a **promise**. The difference is important because promises must be replaced with their +resolution before delivering the message to the application, whereas stubs are delivered as stubs +without waiting for any resolution. + +`propertyPath` is optional. If specified, it is an array of property names (strings or numbers) +leading to a specific property of the import's target. The expression evaluates to that property, +unless `callArguments` is also specified. + +`callArguments` is also optional. If specified, the given property is called as a function. +`callArguments` is an array of expressions, evaluated to produce the arguments to the call. + +### remap + +```json +["remap", importId, propertyPath, captures, instructions] +``` + +Implements the [`.map()`](/concepts/map/) operation. We call it "remap" so as not to confuse it with +the serialization of a `Map` object. + +`importId` and `propertyPath` are the same as for `import`; they identify the particular property to +be mapped. `captures` and `instructions` define the mapper function to apply to the target value. + +`captures` defines the set of stubs which the mapper function has captured, in the sense of a lambda +capture. The body of the function may call these stubs. The format is an array where each member is +either `["import", importId]` or `["export", exportId]`, referring to an entry on the sender's import +or export table respectively. + +`instructions` contains a list of expressions to evaluate in order to execute the mapper on a +particular input value. Each instruction is an expression in the same format described here, but with +special handling of imports and exports. For the purpose of mapper instructions, **there is no export +table**, and the import table is defined as follows: + +| Index | Refers to | +| -------- | ---------------------------------------------------------------- | +| Negative | The `captures` list, starting from -1 (`-1` is `captures[0]`). | +| Zero | The input value of the map function. | +| Positive | The result of a previous instruction (`1` is `instructions[0]`). | + +The instructions are always evaluated in order. Each instruction may only import results of +instructions that came before it. The last instruction evaluates to the return value of the map +function. + +### export + +```json +["export", exportId] +``` + +The sender is exporting a new stub, or re-exporting a stub that was exported before. The expression +evaluates to a stub. + +### promise + +```json +["promise", exportId] +``` + +Like `export`, but the expression evaluates to a promise. Promises must be replaced with their +resolution before the message is finally delivered to the application. + +The `exportId` in this case is always a newly-allocated ID. The sender will proactively send a +resolve or reject message for this ID when the promise resolves, unless it is released first. The +recipient does not need to pull the promise explicitly; it is assumed the recipient always wants the +resolution. + +### writable + +```json +["writable", exportId] +``` + +Represents a `WritableStream`. The sender has called `getWriter()` on the stream, locking it, and +holds the writer to handle incoming operations. The `exportId` refers to an export table entry that +accepts the following method calls: + +| Method | Meaning | +| ---------------- | --------------------------------------------------------- | +| `write(chunk)` | Write a chunk. The chunk can be any RPC-compatible value. | +| `close()` | Close the stream normally; all data has been written. | +| `abort(reason?)` | Abort the stream with an optional reason. | + +These correspond to the methods of `WritableStreamDefaultWriter`. + +If the export is released without `close()` having been called, the sender aborts the stream, +indicating abnormal termination such as a network disconnect. + +The receiver does not need to wait for each `write()` call to complete before sending the next one, +nor before sending `close()`. The sender processes writes in order. The receiver should wait for +`close()` to complete to verify that all writes were successful; if any write failed, `close()` also +fails with that error. + +### readable + +```json +["readable", importId] +``` + +References the readable end of a pipe previously created by a [`pipe`](#pipe) message. `importId` +must refer to an import table entry that was created as a pipe. The expression evaluates to a +`ReadableStream`. + +This expression can only be used **once per pipe**. Once the readable end has been retrieved, it is +removed from the pipe entry. diff --git a/packages/docs/src/content/docs/servers/bun.md b/packages/docs/src/content/docs/servers/bun.md new file mode 100644 index 00000000..7314eae9 --- /dev/null +++ b/packages/docs/src/content/docs/servers/bun.md @@ -0,0 +1,55 @@ +--- +title: Bun +description: Serve Cap'n Web from Bun.serve using newBunWebSocketRpcHandler. +sidebar: + order: 4 +--- + +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()`. + +```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()); + +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; + } + + return new Response('Not Found', { status: 404 }); + }, + + // Pass the handler directly -- no manual wiring needed. + websocket: rpcHandler, +}); +``` + +Note that the callback passed to `newBunWebSocketRpcHandler()` runs **once per connection**, so each +client gets its own API instance. That is usually what you want: per-connection state such as +authentication lives naturally on that instance. + +## Payload limits + +Bun's `Bun.serve()` accepts `maxPayloadLength` in its `websocket` options. Set it when exposed to +untrusted peers. See [Security considerations](/guides/security/). diff --git a/packages/docs/src/content/docs/servers/deno.md b/packages/docs/src/content/docs/servers/deno.md new file mode 100644 index 00000000..c8dcbc30 --- /dev/null +++ b/packages/docs/src/content/docs/servers/deno.md @@ -0,0 +1,54 @@ +--- +title: Deno +description: Serve Cap'n Web from Deno.serve, handling both HTTP batch and WebSocket upgrades. +sidebar: + order: 3 +--- + +Import the package with the `npm:` specifier and use the standard Fetch-API helpers. + +```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 requests + // anyway. But see security considerations for further discussion. + response.headers.set('Access-Control-Allow-Origin', '*'); + return response; + } + } + + return new Response('Not Found', { status: 404 }); +}); +``` + +:::note +Start the RPC session from the `open` event, not immediately after `upgradeWebSocket()`. The socket +is not ready to send until then. +::: + +Run it with network permission: + +```sh +deno run --allow-net server.ts +``` diff --git a/packages/docs/src/content/docs/servers/hono.md b/packages/docs/src/content/docs/servers/hono.md new file mode 100644 index 00000000..b7629024 --- /dev/null +++ b/packages/docs/src/content/docs/servers/hono.md @@ -0,0 +1,47 @@ +--- +title: Hono +description: Use the @hono/capnweb middleware to mount a Cap'n Web endpoint in a Hono app. +sidebar: + order: 5 +--- + +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). + +```sh +npm i @hono/capnweb +``` + +The middleware mounts a Cap'n Web endpoint on a route in your existing Hono app, so your RPC API can +live alongside your regular HTTP routes and share the same middleware stack for logging, CORS, and +so on. + +Refer to the +[`@hono/capnweb` README](https://github.com/honojs/middleware/tree/main/packages/capnweb) for +current usage and options; it is maintained in the Hono middleware repository, not here. + +## Rolling your own + +If you'd rather not add a dependency, Hono handlers receive standard `Request` objects and return +standard `Response` objects, so the generic Fetch-API helpers work directly: + +```ts +import { Hono } from 'hono'; +import { RpcTarget, newHttpBatchRpcResponse } from 'capnweb'; + +class MyApiImpl extends RpcTarget { + greet(name: string) { + return `Hello, ${name}!`; + } +} + +const app = new Hono(); + +app.post('/api', (c) => newHttpBatchRpcResponse(c.req.raw, new MyApiImpl())); + +export default app; +``` + +WebSocket support depends on the runtime you deploy Hono to. See +[Cloudflare Workers](/servers/workers/), [Node.js](/servers/node/), [Deno](/servers/deno/), or +[Bun](/servers/bun/). diff --git a/packages/docs/src/content/docs/servers/node.md b/packages/docs/src/content/docs/servers/node.md new file mode 100644 index 00000000..1a729add --- /dev/null +++ b/packages/docs/src/content/docs/servers/node.md @@ -0,0 +1,84 @@ +--- +title: Node.js +description: Serve Cap'n Web from Node's http module, including WebSocket support via the ws package. +sidebar: + order: 2 +--- + +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. +let 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 + // requests anyway. But see security considerations for 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. +let 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); +``` + +## Install the WebSocket dependency + +You only need `ws` if you want to accept WebSocket sessions. HTTP batch works with nothing but +`node:http`. + +```sh +npm i ws +npm i -D @types/ws +``` + +## Payload limits + +`ws` supports a `maxPayload` option, and you should set it if you are exposed to untrusted peers: + +```ts +let wsServer = new WebSocketServer({ + server: httpServer, + maxPayload: 1024 * 1024, // 1 MiB +}); +``` + +Cap'n Web's own message-size check runs *after* the transport has returned a complete message, so +transport-level limits are the first line of defence. See +[Security considerations](/guides/security/). diff --git a/packages/docs/src/content/docs/servers/other.md b/packages/docs/src/content/docs/servers/other.md new file mode 100644 index 00000000..7480ab3e --- /dev/null +++ b/packages/docs/src/content/docs/servers/other.md @@ -0,0 +1,68 @@ +--- +title: Other Runtimes +description: The two portable functions that let you serve Cap'n Web from any modern JavaScript runtime. +sidebar: + order: 6 +--- + +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: + +```ts +// Run a single HTTP batch. +function newHttpBatchRpcResponse( + request: Request, + yourApi: RpcTarget, + options?: RpcSessionOptions +): Promise<Response>; + +// 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; +``` + +## The general shape + +Both transports hang off one request handler. Check the path, then decide between a WebSocket +upgrade and a batch: + +```ts +async function handle(request: Request): Promise<Response> { + let url = new URL(request.url); + if (url.pathname !== '/api') { + return new Response('Not Found', { status: 404 }); + } + + if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') { + // Runtime-specific: obtain a WebSocket for this request, then: + // newWebSocketRpcSession(socket, new MyApiImpl()); + // ...and return whatever response the runtime expects for an upgrade. + } + + return newHttpBatchRpcResponse(request, new MyApiImpl()); +} +``` + +The only genuinely runtime-specific part is obtaining the `WebSocket` object for an upgrade request. +Everything else is portable. + +## If your runtime isn't HTTP at all + +Cap'n Web only needs a bidirectional stream of discrete messages. If you have one (a TCP socket, a +message queue, a serial link, an `ipc` channel between processes), implement +[`RpcTransport`](/transports/custom/) and use `new RpcSession(transport, localMain)` directly. diff --git a/packages/docs/src/content/docs/servers/workers.md b/packages/docs/src/content/docs/servers/workers.md new file mode 100644 index 00000000..5f805f07 --- /dev/null +++ b/packages/docs/src/content/docs/servers/workers.md @@ -0,0 +1,101 @@ +--- +title: Cloudflare Workers +description: Serve Cap'n Web from a Worker with newWorkersRpcResponse, handling HTTP batch and WebSocket at once. +sidebar: + order: 1 +--- + +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 }); + }, +}; +``` + +That single call handles content negotiation: a normal POST is treated as an +[HTTP batch](/transports/http-batch/), and an upgrade request becomes a +[WebSocket session](/transports/websocket/). + +:::caution +Authenticating from a cookie works for HTTP batch, but browsers do not send custom headers on +WebSocket connections and always allow cross-site WebSocket connections. For anything reachable by +WebSocket, authenticate in-band instead. See [Security considerations](/guides/security/). +::: + +## 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 it: you can pass Cap'n Web RPC stubs over Workers RPC +and vice versa, and the system automatically wraps one stub type in the other and arranges to proxy +calls. + +For best compatibility, set your +[compatibility 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`. + +```jsonc +// wrangler.jsonc +{ + "name": "my-api", + "main": "src/index.ts", + "compatibility_date": "2026-01-20", + // Or, until that date is reachable: + // "compatibility_flags": ["rpc_params_dup_stubs"] +} +``` + +See [Workers RPC interop](/guides/workers-rpc/) for the full feature comparison. + +## CPU limits + +Pipelining lets a client enqueue a lot of work in one message. Consider configuring +[per-request CPU limits](https://developers.cloudflare.com/workers/wrangler/configuration/#limits) +lower than the default 30s. + +Note that in stateless Workers (that is, not Durable Objects), the system considers an entire +WebSocket session to be one "request" for CPU limit purposes. + +```jsonc +// wrangler.jsonc +{ + "limits": { "cpu_ms": 5000 } +} +``` + +## Durable Objects + +For stateful sessions such as chat rooms, collaborative documents, or anything where clients need +to reach the *same* server object, route the WebSocket to a Durable Object and start the session +there. The Durable Object's `fetch()` can call `newWorkersRpcResponse()` exactly the same way. diff --git a/packages/docs/src/content/docs/start/installation.md b/packages/docs/src/content/docs/start/installation.md new file mode 100644 index 00000000..ed6642eb --- /dev/null +++ b/packages/docs/src/content/docs/start/installation.md @@ -0,0 +1,81 @@ +--- +title: Installation +description: Install Cap'n Web from npm, and the TypeScript settings you need for `using` declarations. +sidebar: + order: 2 +--- + +Cap'n Web is [a single npm package](https://www.npmjs.com/package/capnweb) with no dependencies. + +```sh +npm i capnweb +``` + +There is no build step, no schema compiler, and no code generation. Import it and go: + +```ts +import { RpcTarget, RpcStub, newWebSocketRpcSession } from 'capnweb'; +``` + +## Runtime support + +Cap'n Web works in all major browsers, Cloudflare Workers, Node.js, Bun, Deno, and other modern +JavaScript runtimes. The package ships runtime-specific entry points and your bundler or runtime +will pick the right one automatically: + +| Runtime | Notes | +| ------------------ | ------------------------------------------------------------------------------------ | +| Browsers | `fetch` and `WebSocket` are used directly. | +| Cloudflare Workers | Uses the `workerd` export condition; `RpcTarget` aliases the built-in. | +| Node.js | Use the [`ws`](https://www.npmjs.com/package/ws) package for server-side WebSockets. | +| Deno | Import as `npm:capnweb`. | +| Bun | Uses the `bun` export condition. | + +Beyond `fetch` or `WebSocket`, Cap'n Web's serializer inspects a handful of WHATWG globals to decide +how to encode a value, and expects them to exist: `ReadableStream`, `WritableStream`, `Blob`, `URL`, +`Headers`, `Request` and `Response`. All five runtimes above provide them. + +Other JavaScript environments may need help. [React Native](https://reactnative.dev/) is the usual +example: it has `fetch`, `WebSocket`, `Blob` and `URL`, but no `ReadableStream` or +`WritableStream`, so you will need a WHATWG streams polyfill loaded before `capnweb`. React Native +is not covered by CI, so treat it as untested rather than unsupported; a CI contribution would be +very welcome. + +If a runtime has no suitable network API at all, you can still use Cap'n Web by supplying a +[custom transport](/transports/custom/) over any bidirectional message stream. + +## TypeScript setup + +Cap'n Web is written in TypeScript and ships its own types; you do not need a `@types` package. + +Stubs integrate with JavaScript's +[explicit resource management](https://v8.dev/features/explicit-resource-management), so many +examples in these docs use `using` declarations. To compile `using`, your `tsconfig.json` needs a +recent target and the matching libs: + +```json +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true + } +} +``` + +`using` became widely available in JavaScript engines in mid-2025, and has been supported via +transpilers and polyfills for a few years before that. If you cannot use it, every disposable value +also has an explicit `[Symbol.dispose]()` method you can call yourself. See +[Disposal](/concepts/disposal/). + +## Optional: build-time validation + +Cap'n Web does not perform runtime type checking by default. The companion package +[`capnweb-validate`](/guides/validation/) generates runtime validators from your TypeScript types +at build time. + +```sh +npm i -D capnweb-validate +``` diff --git a/packages/docs/src/content/docs/start/introduction.md b/packages/docs/src/content/docs/start/introduction.md new file mode 100644 index 00000000..7faad71c --- /dev/null +++ b/packages/docs/src/content/docs/start/introduction.md @@ -0,0 +1,84 @@ +--- +title: Introduction +description: What Cap'n Web is, how it relates to Cap'n Proto, and why object-capability RPC makes it more expressive than most RPC systems. +sidebar: + order: 1 +--- + +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", making this *capabilities and the web*. The nautical breakfast-cereal overtones are + inherited from Cap'n Proto, which bills itself as a "cerealization protocol", and are entirely + deliberate.) Possession of a stub is itself the authority to use it, which is what + [Security](/guides/security/) builds on. +- 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**. 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 **%BUNDLE_SIZE% with no dependencies**. + +## Why object-capability RPC + +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. +- **Supports passing objects by reference.** If a class extends the special marker type + [`RpcTarget`](/concepts/rpc-target/), 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.** Holding a reference *is* the permission to use + it, which makes authorization patterns fall out naturally. + +## How it compares + +| | Cap'n Web | Cap'n Proto | +| ------------------- | --------------------- | ------------------------ | +| Schemas | None | `.capnp` schema language | +| Codegen | None | Required | +| Serialization | JSON (human-readable) | Binary, zero-copy | +| Object capabilities | Yes | Yes | +| Promise pipelining | Yes | Yes | +| Primary home | The web stack | C++ and systems software | + +Cap'n Web is *not* a port of Cap'n Proto, and the two do not interoperate on the wire. They share +a model, an author, and a sense of humour. + +For how Cap'n Web stacks up against tRPC, JSON-RPC, GraphQL and the older distributed-object +systems, see [How it compares](/guides/comparisons/). + +## A protocol, or a library? + +Both, and the two are worth keeping apart. + +The `capnweb` npm package is an implementation. The [wire protocol](/reference/protocol/) is a +specification, and you can write your own peer against it. The protocol is JavaScript-flavoured to +about the same extent JSON is (its value types are the JavaScript built-ins), but nothing in the +framing or the expression language demands a JavaScript implementation. + +The *library*, on the other hand, is deliberately scoped to JavaScript and TypeScript. If your +backend is written in something else, Cap'n Proto is the answer today; see +[using Cap'n Web from other languages](/guides/comparisons/#vs-capn-proto-and-using-capn-web-from-other-languages). + +## Where to next + +- [Installation](/start/installation/): one npm package, no build step. +- [Quickstart](/start/quickstart/): a working client and server. +- [Pipelining tour](/start/pipelining-tour/): the part that makes it fast. +- [How it compares](/guides/comparisons/): against the alternatives, including the honest gaps. diff --git a/packages/docs/src/content/docs/start/pipelining-tour.md b/packages/docs/src/content/docs/start/pipelining-tour.md new file mode 100644 index 00000000..db1f145b --- /dev/null +++ b/packages/docs/src/content/docs/start/pipelining-tour.md @@ -0,0 +1,127 @@ +--- +title: Pipelining Tour +description: Chain dependent RPC calls into a single network round trip, including the record-replay trick behind .map(). +sidebar: + order: 4 +--- + +Pipelining is what lets a chain of dependent calls cost one round trip instead of one per call. The +rest of the library is built around it. + +## The problem + +A naive RPC client waits for each result before it can use it. Four dependent calls means four +round trips: + +```ts +let authed = await api.authenticate(apiToken); // round trip 1 +let userId = await authed.getUserId(); // round trip 2 +let profile = await api.getUserProfile(userId); // round trip 3 +let friends = await authed.getFriendIds(); // round trip 4 +``` + +On a 100 ms link, that's 400 ms of doing nothing. + +## The trick + +Calling an RPC method returns an [`RpcPromise`](/concepts/promises/), not a regular `Promise`. An +`RpcPromise` is *also a stub for its own eventual result*. So you can call methods on it, read +properties of it, and pass it as an argument to other calls, all before it resolves. + +When the peer receives a call whose arguments contain an unresolved promise, it substitutes the +resolved value before delivering the call to your application code. + +```ts +import { newHttpBatchRpcSession } from 'capnweb'; + +let api = newHttpBatchRpcSession<PublicApi>('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<AuthedApi> = api.authenticate(apiToken); + +// Make a pipelined call to get the user's ID. Again, don't await it. +let userIdPromise: RpcPromise<number> = authedApi.getUserId(); + +// Fetch the user's public profile, based on the user ID. Notice how we can use +// `RpcPromise<T>` anywhere a `T` is expected. The promise will be replaced with +// its resolution before delivering the call. +let profilePromise = api.getUserProfile(userIdPromise); + +// 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. Still one round trip. +let friendProfilesPromise = friendsPromise.map((id: RpcPromise<number>) => { + return { id, profile: api.getUserProfile(id) }; +}); + +// Now await. The batch is sent at this point. +let [profile, friendProfiles] = await Promise.all([profilePromise, friendProfilesPromise]); + +console.log(`Hello, ${profile.name}!`); +``` + +Five logical calls, arbitrary depth of dependency, **one round trip**. + +:::note +Await every promise whose result you actually want, and await them together. If you +don't await a promise before the batch is sent, the system detects this and doesn't ask the server +to send the return value back at all; it saves the bandwidth. +::: + +## Properties pipeline too + +You don't only pipeline calls. You can pipeline into a *property* of a pending result: + +```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); +``` + +## How `.map()` can possibly work + +`friendsPromise.map(...)` applies your callback to a value that doesn't exist yet on the client, and +it does so without sending any code over the wire. Cap'n Web does **not** ship arbitrary code. + +The trick is **record-replay**: + +1. On the calling side, Cap'n Web invokes your callback once in a special *recording* mode, passing + a placeholder stub that records what you do with it. +2. During that invocation, any RPCs the callback invokes (on *any* stub) are not executed, only + recorded as actions the callback performs. Any stubs used are "captured" as well. +3. The recording plus the capture list is sent to the peer, where it can be replayed as needed for + each individual result. + +Because every not-yet-determined value the callback sees is an `RpcPromise`, the callback's +behaviour is deterministic. Real computation (arithmetic, branching) can't meaningfully consume +those promises, so it must produce the same result on every invocation, and it gets performed once +on the sending side, with the result baked into the recording. + +**Your JavaScript runs on the calling side exactly once; the RPCs it recorded run on the peer, once +per element.** See [Which side runs what](/concepts/map/#which-side-runs-what) for the consequences, +and [The magic `map()`](/concepts/map/) for the full rules and restrictions. + +## With a WebSocket instead + +Pipelining is not exclusive to batches. On a long-lived +[WebSocket session](/transports/websocket/) you get the same round-trip savings, but you're free to +await whenever you like: + +```ts +using api = newWebSocketRpcSession<PublicApi>('wss://example.com/api'); + +// Authenticate and get the user ID in one round trip. +using authedApi: RpcPromise<AuthedApi> = api.authenticate(apiToken); +let userId: number = await authedApi.getUserId(); + +// ... continue calling other methods, now or in the future ... +``` + +:::caution +Pipelining makes it cheap for a malicious client to enqueue a *lot* of server work in one message. +Rate-limit expensive operations. See [Security considerations](/guides/security/). +::: diff --git a/packages/docs/src/content/docs/start/quickstart.md b/packages/docs/src/content/docs/start/quickstart.md new file mode 100644 index 00000000..bc7c9997 --- /dev/null +++ b/packages/docs/src/content/docs/start/quickstart.md @@ -0,0 +1,169 @@ +--- +title: Quickstart +description: Build a working Cap'n Web client and server, first in plain JavaScript and then with TypeScript types. +sidebar: + order: 3 +--- + +Let's build the smallest useful Cap'n Web service, then add types. + +## A client + +Open a session and call a method on it: + +```js +import { newWebSocketRpcSession } from 'capnweb'; + +// One-line setup. +let api = newWebSocketRpcSession('wss://example.com/api'); + +// Call a method on the server! +let result = await api.hello('World'); + +console.log(result); +``` + +There is no client generation and no interface registration. `api` is a *stub*: a `Proxy` that +appears to have every possible method. Calling one sends an RPC. + +## A server + +The other half, implementing the `hello` the client just called: + +```js +import { RpcTarget, newWorkersRpcResponse } from 'capnweb'; + +// This is the server implementation. +class MyApiServer extends RpcTarget { + hello(name) { + return `Hello, ${name}!`; + } +} + +// Standard Cloudflare Workers HTTP handler. +// +// (Node and other runtimes are supported too.) +export default { + fetch(request, env, ctx) { + // Parse URL for routing. + let url = new URL(request.url); + + // Serve API at `/api`. + if (url.pathname === '/api') { + return newWorkersRpcResponse(request, new MyApiServer()); + } + + // You could serve other endpoints here... + return new Response('Not found', { status: 404 }); + }, +}; +``` + +Extending [`RpcTarget`](/concepts/rpc-target/) is what makes the object available over RPC. Callers +can invoke its prototype methods and getters, but not its instance properties. + +See [Server runtimes](/servers/workers/) for Node.js, Deno, Bun, and Hono equivalents. + +## Adding types + +You don't *have to* declare your interface separately; the client could just use +`import("./server").ApiServer` as the type. But a shared types file is often cleaner: + +```ts +// shared/api.ts +interface PublicApi { + // Authenticate the API token, and return the authenticated API. + authenticate(apiToken: string): AuthedApi; + + // Get a given user's public profile info. (Doesn't require authentication.) + getUserProfile(userId: string): Promise<UserProfile>; +} + +interface AuthedApi { + getUserId(): number; + + // Get the user IDs of all the user's friends. + getFriendIds(): number[]; +} + +type UserProfile = { + name: string; + photoUrl: string; +}; +``` + +On the server, 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, the stub is fully typed: you get compile-time checking and autocomplete, even +though nothing was generated: + +```ts +import { newWebSocketRpcSession } from 'capnweb'; + +using api = newWebSocketRpcSession<PublicApi>('wss://example.com/api'); + +using authed = api.authenticate(apiToken); +let userId: number = await authed.getUserId(); +``` + +:::caution +TypeScript types are erased at runtime. A malicious client can send values of types you did not +expect. See [Security considerations](/guides/security/) and +[Runtime validation](/guides/validation/). +::: + +## Documenting your API + +There is no OpenAPI document to generate, because there is no schema to generate it from, and no +separate artifact that can drift out of date with the implementation. + +The shared types file *is* the API description, so document it there: + +```ts +// shared/api.ts + +export interface AuthedApi { + /** + * The user IDs of everyone this user is friends with. + * + * Returns at most 5000 entries; there is currently no pagination. + */ + getFriendIds(): number[]; +} +``` + +That gives you three things at once: hover documentation in every editor for anyone importing the +file, a single file you can hand someone as "the API", and, if you want a browsable site, ordinary +TypeScript documentation output from a tool like [TypeDoc](https://typedoc.org/). + +The catch is the one you already know from OpenAPI: comments are not enforced. If you want the +boundary actually checked, that is [runtime validation](/guides/validation/). + +## Which transport? + +| You want | Use | +| -------------------------------------------- | ---------------------------------------- | +| A burst of calls, then done | [HTTP batch](/transports/http-batch/) | +| A long-lived session, server-initiated calls | [WebSocket](/transports/websocket/) | +| Talk to a Web Worker or iframe | [MessagePort](/transports/message-port/) | +| Something else entirely | [Custom transport](/transports/custom/) | + +## Next steps + +- [Pipelining tour](/start/pipelining-tour/): do all of the above in one round trip. +- [What can be passed](/concepts/values/): the type system on the wire. +- [Disposal](/concepts/disposal/): the one piece of bookkeeping Cap'n Web asks of you. diff --git a/packages/docs/src/content/docs/transports/custom.md b/packages/docs/src/content/docs/transports/custom.md new file mode 100644 index 00000000..b41a32f4 --- /dev/null +++ b/packages/docs/src/content/docs/transports/custom.md @@ -0,0 +1,140 @@ +--- +title: Custom Transports +description: Implement RpcTransport to run Cap'n Web over any bidirectional stream, and tune how much encoding it does for you. +sidebar: + order: 4 +--- + +You can implement a custom RPC transport across any bidirectional stream. + +## The interface + +A transport is two required methods, plus `abort()` if there is something useful to do with a +fatal error: + +```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<void>; + + // 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<string>; + + // 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; +} +``` + +## Starting a session + +Hand the transport to `RpcSession`, along with whatever you want the other end to be able to +call: + +```ts +// Create the transport. +let transport: RpcTransport = new MyTransport(); + +// Create the main interface we will expose to the other end. +let localMain: RpcTarget = new MyMainInterface(); + +// Start the session. +let session = new RpcSession<RemoteMainInterface>(transport, localMain); + +// Get a stub for the other end's main interface. +let stub: RemoteMainInterface = session.getRemoteMain(); + +// Now we can call methods on the stub. +``` + +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 client/server scenarios, the server +exposes a main interface and the client does not. + +## Encoding levels + +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. Transports that want more control over serialization can +declare an `encodingLevel` property: + +| `encodingLevel` | What the transport receives | Use when | +| --------------------------- | -------------------------------------------------------- | ------------------------------------------------------ | +| `"string"` *(default)* | Fully-serialized JSON strings. | HTTP batch and WebSocket use this. | +| `"jsonCompatible"` | JavaScript value trees that are JSON-compatible. | You serialize to CBOR, MessagePack, etc. | +| `"jsonCompatibleWithBytes"` | Same, but byte arrays stay as `Uint8Array`. | Your format has native binary; avoids base64 overhead. | +| `"structuredClonable"` | Structured-clonable values, native types passed through. | `MessagePort` and similar. | + +Details: + +- **`"string"`**: full JSON round-trip. The transport deals in strings only; Cap'n Web handles all + encoding and decoding. +- **`"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. +- **`"jsonCompatibleWithBytes"`**: like `"jsonCompatible"`, except byte arrays are left as + `Uint8Array` instead of base64-encoded, avoiding the ~33% base64 size overhead and the + encode/decode CPU cost. Handy with CBOR or MessagePack. +- **`"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. + +## Framing is your job + +The protocol operates on a stream of **discrete messages**; it does not define how they are framed. +If your underlying stream is byte-oriented (a TCP socket, a serial line), you must add framing +using length prefixes or newline delimiting, as the built-in HTTP transport does. + +## A worked example + +A minimal transport over a pair of async queues: + +```ts +class QueueTransport implements RpcTransport { + #outgoing: (msg: string) => void; + #incoming: string[] = []; + #waiters: ((msg: string) => void)[] = []; + + constructor(outgoing: (msg: string) => void) { + this.#outgoing = outgoing; + } + + // Call this when the underlying stream delivers a message. + deliver(message: string) { + let waiter = this.#waiters.shift(); + if (waiter) waiter(message); + else this.#incoming.push(message); + } + + async send(message: string) { + this.#outgoing(message); + } + + receive(): Promise<string> { + let queued = this.#incoming.shift(); + if (queued !== undefined) return Promise.resolve(queued); + return new Promise((resolve) => this.#waiters.push(resolve)); + } + + abort(reason: any) { + console.error('transport aborted', reason); + } +} +``` + +:::caution +Apply payload size limits at the transport layer. Cap'n Web enforces a maximum incoming message size +before `JSON.parse`, but that check runs only after `receive()` has returned a *complete* message +string, so transport-level limits are the first line of defence against buffering very large +frames. See [Security considerations](/guides/security/). +::: diff --git a/packages/docs/src/content/docs/transports/http-batch.md b/packages/docs/src/content/docs/transports/http-batch.md new file mode 100644 index 00000000..73c056d2 --- /dev/null +++ b/packages/docs/src/content/docs/transports/http-batch.md @@ -0,0 +1,102 @@ +--- +title: HTTP Batch +description: Send a whole dependent call graph in one HTTP request using newHttpBatchRpcSession. +sidebar: + order: 1 +--- + +In HTTP batch mode, a batch of RPC calls is 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 take the +promise returned by one call and use it in the parameters to another, the promise is replaced with +its resolution before delivering it to the callee. This is +[promise pipelining](/concepts/promises/). + +## Client + +Declare the interface, then open a batch against it: + +```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<MyApi> = newHttpBatchRpcSession<MyApi>('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'); + +// 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]); + +console.log(greeting1); +console.log(greeting2); +console.log(greeting3); +``` + +## When the batch is sent + +The batch is dispatched on the next I/O tick. Everything you queue synchronously ends up in the same +request. The first `await` is your deadline. + +:::caution +Once the batch completes, the `stub` and everything derived from it stops working. You must start a +new batch for further calls. +::: + +## Why you might prefer batch over WebSocket + +- **Stateless.** Works on any HTTP endpoint, including cached/edge deployments with no persistent + connections. +- **Cheap.** No connection to keep alive, no reconnection logic, no heartbeats. +- **No disposal bookkeeping.** All stubs are implicitly disposed when the batch ends. + +In exchange, the server cannot call you back later, and there is no subscription model. + +## Server side + +Any of the [server runtimes](/servers/workers/) can answer a batch request: + +- Cloudflare Workers: `newWorkersRpcResponse()` handles batch *and* WebSocket. +- Fetch-API runtimes: `newHttpBatchRpcResponse(request, api, options?)`. +- Node.js: `nodeHttpBatchRpcResponse(request, response, api, options?)`. + +## Cross-origin + +Batch requests are subject to normal CORS rules. If you also accept WebSockets, you might as well +accept cross-origin HTTP, since WebSockets always permit cross-origin requests anyway: + +```ts +response.headers.set('Access-Control-Allow-Origin', '*'); +``` + +Read [Security considerations](/guides/security/) before doing this; in particular, do not rely on +cookies for authentication. diff --git a/packages/docs/src/content/docs/transports/index.md b/packages/docs/src/content/docs/transports/index.md new file mode 100644 index 00000000..fa6a3ff4 --- /dev/null +++ b/packages/docs/src/content/docs/transports/index.md @@ -0,0 +1,66 @@ +--- +title: Transports +description: Choosing between HTTP batch, WebSocket, MessagePort, and custom transports, and the session model they share. +--- + +Cap'n Web runs over any bidirectional stream of discrete messages. Four options ship in the box, and +you can write your own. + +| Transport | Long-lived | Server can call client | Best for | +| ---------------------------------------- | ---------- | ---------------------- | ---------------------------------------------- | +| [HTTP batch](/transports/http-batch/) | No | No | A burst of calls, then done. Stateless edges. | +| [WebSocket](/transports/websocket/) | Yes | Yes | Interactive apps, subscriptions, callbacks. | +| [MessagePort](/transports/message-port/) | Yes | Yes | Web Workers, iframes, same-process boundaries. | +| [Custom](/transports/custom/) | Up to you | Yes | Anything else with two directions. | + +## Sessions are symmetric + +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 and the client does not. + +The words "client" and "server" appear throughout these docs only as a convention to make +explanations natural. "Client" generally means the caller of an RPC or the importer of a stub; +"server" means the callee or exporter. + +## Disposal ends the session + +Disposing the root stub of a session closes the connection: + +```ts +{ + using api = newWebSocketRpcSession<MyApi>('wss://example.com/api'); + // ... use api ... +} // connection closed here +``` + +Only the **root** stub behaves this way. Disposing any other stub releases the object it points at +on the peer, but leaves the connection open: + +```ts +using api = newWebSocketRpcSession<PublicApi>('wss://example.com/api'); + +{ + using authed = api.authenticate(apiToken); + // ... +} // AuthedApi released on the server; the session is still up. +``` + +For HTTP batch, the session ends when the batch completes, and all stubs are implicitly disposed at +that point. See [Disposal](/concepts/disposal/). + +Session state is in-memory and lasts exactly as long as the session; there is nothing to persist +and no session store to run. [Sessions & reconnection](/guides/sessions/) covers what that means +for reconnecting, versioning and load balancing. + +## Message framing + +The protocol operates on a bidirectional stream of discrete messages, each a single JSON value. The +protocol itself does not define framing: that is the transport's job. + +- Transports with native framing (WebSocket, `MessagePort`) map one transport message to one RPC + message. +- The built-in HTTP transport is newline-delimited, packing a series of messages into a single + request or response body. An empty body means zero messages. + +See the [wire protocol reference](/reference/protocol/) for the full picture. diff --git a/packages/docs/src/content/docs/transports/message-port.md b/packages/docs/src/content/docs/transports/message-port.md new file mode 100644 index 00000000..992bb79e --- /dev/null +++ b/packages/docs/src/content/docs/transports/message-port.md @@ -0,0 +1,86 @@ +--- +title: MessagePort +description: Use Cap'n Web to talk to Web Workers, iframes, and other same-process contexts. +sidebar: + order: 3 +--- + +Cap'n Web can talk over `MessagePort`s. In a browser, this lets you use the same RPC model to talk +to Web Workers, iframes, and other contexts: no serialization boilerplate, no ad-hoc message +protocols with `type` fields and switch statements. + +```ts +import { RpcTarget, RpcStub, newMessagePortRpcSession } from 'capnweb'; + +// Declare our RPC interface. +class Greeter extends RpcTarget { + greet(name: string): string { + return `Hello, ${name}!`; + } +} + +// Create a MessageChannel (pair of MessagePorts). +let channel = new MessageChannel(); + +// Initialize the server on port1. +newMessagePortRpcSession(channel.port1, new Greeter()); + +// Initialize the client on port2. +using stub: RpcStub<Greeter> = newMessagePortRpcSession<Greeter>(channel.port2); + +// Now you can make calls. +console.log(await stub.greet('Alice')); +console.log(await stub.greet('Bob')); +``` + +## Sending a port somewhere else + +In a real-world scenario you'd send one of the two ports to another context. A `MessagePort` can +itself be transferred using `postMessage()`: `window.postMessage()`, `worker.postMessage()`, or +even `port.postMessage()` on some other existing `MessagePort`. + +```ts +// Main thread +let worker = new Worker('./worker.js', { type: 'module' }); +let channel = new MessageChannel(); + +// Hand one end to the worker. +worker.postMessage({ rpcPort: channel.port2 }, [channel.port2]); + +// Keep the other end and start talking. +using api = newMessagePortRpcSession<WorkerApi>(channel.port1); +console.log(await api.crunchNumbers([1, 2, 3])); +``` + +```ts +// worker.js +import { RpcTarget, newMessagePortRpcSession } from 'capnweb'; + +class WorkerApi extends RpcTarget { + crunchNumbers(values: number[]) { + return values.reduce((a, b) => a + b, 0); + } +} + +self.addEventListener('message', (event) => { + if (event.data?.rpcPort) { + newMessagePortRpcSession(event.data.rpcPort, new WorkerApi()); + } +}); +``` + +:::danger +Do not use a `Window` object itself as a port for RPC. Always create a new `MessageChannel` and send +one of the ports over. + +Anyone can `postMessage()` to a window, and the RPC system does not authenticate that messages came +from the expected sender. Verify that you received the *port itself* from the expected sender first, +then let the RPC system take over. +::: + +## Structured clone + +A `MessagePort` transport can avoid JSON entirely. Custom transports may declare +`encodingLevel: "structuredClonable"` so that messages stay as structured-clonable values, passing +through native types where possible while still handling RPC-specific values such as stubs. See +[Custom transports](/transports/custom/#encoding-levels). diff --git a/packages/docs/src/content/docs/transports/websocket.md b/packages/docs/src/content/docs/transports/websocket.md new file mode 100644 index 00000000..81ebab7f --- /dev/null +++ b/packages/docs/src/content/docs/transports/websocket.md @@ -0,0 +1,120 @@ +--- +title: WebSocket +description: Long-lived, fully bidirectional Cap'n Web sessions with newWebSocketRpcSession. +sidebar: + order: 2 +--- + +In WebSocket mode, the client forms a long-lived connection to the server and makes many calls over +it. The server can also make asynchronous calls **back to the client**. + +## Client + +Declare the interface, then open a session against it: + +```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<MyApi> = newWebSocketRpcSession<MyApi>('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)); +} + +// Since we never awaited `userInfoPromise`, the server won't even bother +// sending the response back over the wire. +``` + +## Server calling the client + +Pass a callback and the server can invoke it whenever it likes: + +```ts +// Client +await api.subscribe((event) => { + console.log('server pushed:', event); +}); +``` + +```ts +// Server +class Api extends RpcTarget { + #subscribers: RpcStub<(e: unknown) => void>[] = []; + + subscribe(cb: RpcStub<(e: unknown) => void>) { + // Stubs in params are disposed when the call returns -- dup() to keep it. + this.#subscribers.push(cb.dup()); + } +} +``` + +The `.dup()` there is mandatory. See +[holding on to a callback](/concepts/disposal/#holding-on-to-a-callback-past-the-call-that-delivered-it). + +## Server side + +`newWebSocketRpcSession()` is the *same function* used on the client. On the server, pass a +`WebSocket` object representing the already-open connection, and your API implementation as the +second parameter: + +```ts +function newWebSocketRpcSession( + webSocket: WebSocket, + yourApi: RpcTarget, + options?: RpcSessionOptions +): Disposable; +``` + +Dispose the returned `Disposable` to close the connection, or let it run until the client closes it. + +Runtime-specific wiring: + +- [Cloudflare Workers](/servers/workers/): `newWorkersRpcResponse()` does it for you. +- [Node.js](/servers/node/): use the `ws` package. +- [Deno](/servers/deno/): `Deno.upgradeWebSocket()`. +- [Bun](/servers/bun/): `newBunWebSocketRpcHandler()`. + +## Disconnection + +A dropped connection breaks every stub associated with the session. Detect it with: + +```ts +stub.onRpcBroken((error) => { + console.error('connection lost:', error); + // tear down UI state, schedule a reconnect, ... +}); +``` + +Cap'n Web does not reconnect automatically. Reconnection means establishing a new session and +re-acquiring any capabilities you held, since stubs from the old session are permanently broken. +See [Sessions & reconnection](/guides/sessions/) for the patterns that make this manageable, +including the React one and how to resume a subscription without gaps. + +:::danger +The WebSocket API in browsers always permits cross-site connections, and does not permit setting +headers. Because of this, you generally **cannot use cookies or other headers for authentication.** +Instead, authenticate in-band via an RPC method that returns the authenticated API. See +[Security considerations](/guides/security/). +::: diff --git a/packages/docs/src/examples.ts b/packages/docs/src/examples.ts new file mode 100644 index 00000000..0405ce20 --- /dev/null +++ b/packages/docs/src/examples.ts @@ -0,0 +1,272 @@ +/** + * The live examples: the ones in `examples/`, rendered as playground pages here. + * + * This file is the single source of truth for two consumers: + * + * - the playground pages, which read `files` to show the source, and + * - `scripts/build-playgrounds.mjs`, which reads `build` to bundle each + * example into a self-contained page that runs in an iframe. + * + * There is no server behind the playgrounds. Each one bundles the example's + * real Worker into the page alongside its real client, and routes the client's + * `fetch` of `rpcPath` straight into that Worker's `fetch` handler. The wire + * format is the genuine HTTP batch protocol, so the request counts the demos + * report are real -- there is simply no network under them. That is what lets + * the whole site deploy as static assets. + */ + +export interface PlaygroundFile { + /** Path from the repo root. Read at build time; a bad path fails the build. */ + path: string; + /** Tab label. */ + label: string; + /** + * Language for syntax highlighting. Narrow on purpose: the value goes + * straight to Shiki through `<Code lang>`, which rejects anything it does + * not know, so a typo should be a type error here rather than a build + * failure three layers down. Widen the union when a tab needs more. + */ + lang: 'js' | 'ts'; + /** One line on what this file is for, shown above the code. */ + note: string; +} + +export interface PlaygroundBuild { + /** HTML shell, copied from the example and rewritten. Repo-relative. */ + html: string; + /** Worker module whose default export has the `fetch` handler. */ + server: string; + /** Requests to this path are served by `server`, in-page. Omit for `wsPath`. */ + rpcPath?: string; + /** + * For WebSocket examples: `new WebSocket()` on this path is answered by an + * in-page pair rather than a real socket, with `mainExport` on the other + * end. Mutually exclusive with `rpcPath`. + */ + wsPath?: string; + /** Named export of `server` returning the session's main interface. */ + mainExport?: string; + /** + * The example's Wrangler config. Its `vars` become the Worker's `env`, so + * the playground runs with the same delays as a real deployment instead of + * a second copy of those numbers drifting out of sync over here. + */ + wrangler: string; + /** + * Client entry to bundle. Omit when the client is inline in `html`, as it + * is for the batch example. + */ + client?: string; + /** `src` of the script tag in `html` to point at the bundled client. */ + clientScript?: string; + /** Bare specifier -> repo-relative file, for esbuild. */ + alias?: Record<string, string>; + /** + * Static files the page references by name, copied next to it. Needed for a + * zero-build example, whose stylesheet is a plain `<link>` rather than + * something imported from JavaScript for a bundler to find. + */ + assets?: string[]; + /** + * Directories to run the `capnweb-validate` codegen plugin in. Its + * `@validateRpc()` decorator is a build-time transform, so a bundle built + * without this silently loses the validation the example is demonstrating. + */ + validate?: { server?: string; client?: string }; +} + +export interface Example { + /** Directory under `examples/`, and the page slug under `/examples/`. */ + slug: string; + title: string; + /** Short form, for cards and the sidebar. */ + tagline: string; + description: string; + /** Source on GitHub. */ + source: string; + /** The playground page on this site. */ + docsPath: string; + /** The generated, self-contained demo. Loaded into the playground iframe. */ + demoPath: string; + files: PlaygroundFile[]; + build: PlaygroundBuild; +} + +const REPO = 'https://github.com/cloudflare/capnweb/tree/main/examples'; + +/** + * Where `scripts/build-playgrounds.mjs` writes each demo. Derived from the slug + * so the config and the bundler cannot disagree about the path. + * + * `index.html` is spelled out on purpose. Astro's dev server does not resolve a + * directory request under `public/` to its index, so `/playground/<slug>/` is a + * 404 in dev even though most static hosts would serve it. + */ +const demoPathFor = (slug: string) => `/playground/${slug}/index.html`; + +/** Shared by both examples: the monorepo's own builds, not published copies. */ +const VALIDATE_ALIAS = { + 'capnweb-validate/internal/core': 'packages/capnweb-validate/dist/internal/core.mjs', + 'capnweb-validate/internal/capnweb': 'packages/capnweb-validate/dist/internal/capnweb.mjs', + 'capnweb-validate/internal': 'packages/capnweb-validate/dist/internal/runtime.mjs', + 'capnweb-validate/capnweb': 'packages/capnweb-validate/dist/capnweb.mjs', + 'capnweb-validate': 'packages/capnweb-validate/dist/index.mjs', +}; + +const entries: Omit<Example, 'demoPath'>[] = [ + { + slug: 'batch-pipelining', + title: 'Batch + pipelining', + tagline: 'One round trip, three dependent calls', + description: + 'Three dependent calls in a single HTTP round trip, measured against the same calls made sequentially. Drag the latency slider to see the gap widen.', + source: `${REPO}/batch-pipelining`, + docsPath: '/examples/batch-pipelining/', + build: { + html: 'examples/batch-pipelining/public/index.html', + server: 'examples/batch-pipelining/worker.js', + client: 'examples/batch-pipelining/public/main.js', + clientScript: './main.js', + rpcPath: '/rpc', + wrangler: 'examples/batch-pipelining/wrangler.jsonc', + }, + files: [ + { + path: 'examples/batch-pipelining/public/demo.js', + label: 'demo.js', + lang: 'js', + note: 'The two strategies, exactly as the running demo does them. No DOM in it -- main.js does the wiring.', + }, + { + path: 'examples/batch-pipelining/public/main.js', + label: 'main.js', + lang: 'js', + note: 'The page: reads the slider, runs both strategies, fills in the numbers.', + }, + { + path: 'examples/batch-pipelining/api.mjs', + label: 'api.mjs', + lang: 'js', + note: 'The RPC API. Shared by the Worker and the Node server so the two cannot drift.', + }, + { + path: 'examples/batch-pipelining/worker.js', + label: 'worker.js', + lang: 'js', + note: 'The Cloudflare Worker serving /rpc. This is the code answering the calls in the demo.', + }, + { + path: 'examples/batch-pipelining/server-node.mjs', + label: 'server-node.mjs', + lang: 'js', + note: 'The same API on a plain Node HTTP server, for running it outside Workers.', + }, + { + path: 'examples/batch-pipelining/client.mjs', + label: 'client.mjs', + lang: 'js', + note: 'The same comparison from a terminal. Point it at any of the servers with RPC_URL.', + }, + ], + }, + { + slug: 'worker-react', + title: 'Workers + React', + tagline: 'The same trick from a React app', + description: + 'The same comparison from a React app served by a Worker, with a request timeline and runtime validation at the RPC boundary.', + source: `${REPO}/worker-react`, + docsPath: '/examples/worker-react/', + build: { + html: 'examples/worker-react/client/index.html', + server: 'examples/worker-react/server/worker.ts', + client: 'examples/worker-react/client/src/main.tsx', + clientScript: '/src/main.tsx', + rpcPath: '/api', + wrangler: 'examples/worker-react/wrangler.jsonc', + alias: VALIDATE_ALIAS, + validate: { + server: 'examples/worker-react/server', + client: 'examples/worker-react/client', + }, + }, + files: [ + { + path: 'examples/worker-react/server/worker.ts', + label: 'worker.ts', + lang: 'ts', + note: 'The Worker. @validateRpc() adds runtime type checks at the RPC boundary.', + }, + { + path: 'examples/worker-react/client/src/main/runs.ts', + label: 'runs.ts', + lang: 'ts', + note: 'Every RPC call the app makes, and the timing instrumentation. App.tsx is the chart and the layout.', + }, + { + path: 'examples/worker-react/client/vite.config.ts', + label: 'vite.config.ts', + lang: 'ts', + note: 'Vite config, including the validation plugin and the /api dev proxy.', + }, + ], + }, + { + slug: 'session-recovery', + title: 'Session recovery', + tagline: 'What a disconnect destroys', + description: + 'A WebSocket session with a button that kills it. Watch every stub break, then watch the event stream resume without a gap because the client kept a cursor of its own.', + source: `${REPO}/session-recovery`, + docsPath: '/examples/session-recovery/', + build: { + html: 'examples/session-recovery/public/index.html', + server: 'examples/session-recovery/worker.js', + client: 'examples/session-recovery/public/main.js', + clientScript: './main.js', + wsPath: '/ws', + mainExport: 'createMain', + assets: ['examples/session-recovery/public/style.css'], + wrangler: 'examples/session-recovery/wrangler.jsonc', + }, + files: [ + { + path: 'examples/session-recovery/public/session.js', + label: 'session.js', + lang: 'js', + note: 'The client. Connecting, authenticating, subscribing, and recovering from a drop -- with no DOM in it.', + }, + { + path: 'examples/session-recovery/api.mjs', + label: 'api.mjs', + lang: 'js', + note: 'The RPC API. The event log is created outside the session on purpose: that is what lets a resume work.', + }, + { + path: 'examples/session-recovery/worker.js', + label: 'worker.js', + lang: 'js', + note: 'The Worker. One endpoint, upgrading to a WebSocket and handing over a fresh main interface.', + }, + { + path: 'examples/session-recovery/public/main.js', + label: 'main.js', + lang: 'js', + note: 'DOM wiring. Kept separate so the file above stays about RPC.', + }, + ], + }, +]; + +export const examples: Example[] = entries.map((entry) => ({ + ...entry, + demoPath: demoPathFor(entry.slug), +})); + +export function exampleBySlug(slug: string): Example { + const found = examples.find((example) => example.slug === slug); + if (!found) { + throw new Error(`Unknown example "${slug}". Known: ${examples.map((e) => e.slug).join(', ')}`); + } + return found; +} diff --git a/packages/docs/src/layouts/BaseLayout.astro b/packages/docs/src/layouts/BaseLayout.astro new file mode 100644 index 00000000..328197c1 --- /dev/null +++ b/packages/docs/src/layouts/BaseLayout.astro @@ -0,0 +1,158 @@ +--- +import "@fontsource-variable/dm-sans"; +import "@fontsource/commit-mono"; +import "../styles/globals.css"; +import "../styles/prose.css"; +import { config } from "virtual:nimbus/config"; +import { getCollectionLlmsUrl, getVersionStatus } from "@cloudflare/nimbus-docs"; +import AgentDirective from "@/components/AgentDirective.astro"; +import { SearchDialog } from "@/components/ui/search"; +import NimbusHead from "@cloudflare/nimbus-docs/components/NimbusHead.astro"; +import type { BasePageProps } from "@cloudflare/nimbus-docs/types"; +import { cn } from "@/lib/cn"; +import { isVariantPath } from "@/components/canvas-hero/routes"; + +type Props = BasePageProps; + +const { + title, + description, + noindex, + markdownUrl, + socialImage, + lastUpdated, + head: pageHead = [], + collection, + entryId, +} = Astro.props; + +const lang = config.locale ?? "en"; +// `cw-home` marks a page built around the landing hero, for the handful of rules +// that only apply there (the hero's ground, the outline-only code blocks in +// dark). It no longer forces a colour scheme: the landing honours the toggle like +// every other page. The canvas-backdrop comparison routes carry the same hero and +// so need the same rules; they come from the one declared list rather than a +// pattern, so adding a variant cannot leave its page missing the hero's styling. +const path = Astro.url.pathname.replace(/\/+$/, ""); +const isHome = path === "" || isVariantPath(path); +// Per-page agent-index pointer. For pages in non-primary or version +// collections, this resolves to `/<prefix>/llms.txt` (e.g. `/v0/llms.txt` +// for a v0 docs page, `/blog/llms.txt` for a blog post). Falls back to +// the root `/llms.txt` when no collection is provided. +// Hidden-version pages don't advertise an agent index (the per-version +// llms.txt isn't emitted for them); suppress the AgentDirective entirely. +const versionStatus = collection ? await getVersionStatus(collection) : null; +const isHiddenVersion = versionStatus?.isHidden === true; +const llmsIndexPath = collection + ? await getCollectionLlmsUrl(collection) + : "/llms.txt"; +const llmsUrl = Astro.site + ? new URL(llmsIndexPath, Astro.site).href + : llmsIndexPath; +--- + +<!doctype html> +<html lang={lang}> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + + {/* + `is:inline` is load-bearing. Without a directive Astro processes this and + emits `<script type="module">`, which is deferred: the document can paint + before it runs, and a reader whose stored choice is dark gets a flash of + the light scheme first. Measured at 20x CPU throttling, that flash lasted + about sixty frames. Inline makes it a classic blocking script that runs + during head parsing, before anything is painted. Nothing in here needs + processing anyway -- no imports, no TypeScript. + */} + <script is:inline> + (() => { + const KEY = "ui-mode"; + // Queried the other way round on purpose: the site is designed dark, so + // "no preference" resolves to dark rather than light. A stored choice + // still wins over both. + const media = window.matchMedia("(prefers-color-scheme: light)"); + + const readPref = () => { + try { + const v = localStorage.getItem(KEY); + return v === "dark" || v === "light" ? v : null; + } catch { + return null; + } + }; + + const resolveMode = () => readPref() ?? (media.matches ? "light" : "dark"); + + const applyTheme = () => { + const root = document.documentElement; + const mode = resolveMode(); + if (mode === "dark") root.setAttribute("data-mode", "dark"); + else root.removeAttribute("data-mode"); + // Published for same-origin embeds: the example playgrounds read the + // embedding page's `data-theme` before their first paint, so there is + // no flash of the wrong scheme inside the frame. They cannot read + // `data-mode`, which is absent rather than "light" in light mode, and + // an attribute that only exists half the time cannot be read + // positively by a document that may load either way round. + root.dataset.theme = mode; + root.style.colorScheme = mode; + // The two theme-color metas are media-scoped, which answers the OS + // but not a stored choice that disagrees with it. Drop the media + // condition on the one that won and neuter the other, so the browser + // chrome matches the page a reader actually gets. + document.querySelectorAll('meta[name="theme-color"]').forEach((m) => { + const wants = m.dataset.mode || (m.media.includes("light") ? "light" : "dark"); + m.dataset.mode = wants; + m.media = wants === mode ? "" : "not all"; + }); + document.querySelectorAll("[data-nb-theme-toggle]").forEach((b) => { + b.setAttribute("data-nb-state", mode); + }); + }; + + applyTheme(); + media.addEventListener("change", () => { + if (!readPref()) applyTheme(); + }); + window.addEventListener("storage", (e) => { + if (e.key === KEY) applyTheme(); + }); + + window.__nbApplyTheme = applyTheme; + })(); + </script> + + <NimbusHead + title={title} + description={description} + noindex={noindex} + markdownUrl={markdownUrl} + socialImage={socialImage} + lastUpdated={lastUpdated} + head={pageHead} + collection={collection} + entryId={entryId} + /> + </head> + <!-- No `bg-background` here: the body paints the ground (see globals.css) and + the content sheet paints itself, so the ground has somewhere to show. --> + <body class={cn("min-h-screen text-foreground antialiased", isHome && "cw-home")}> + <a + href="#main-content" + class="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-100 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:no-underline focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2" + > + Skip to content + </a> + {markdownUrl && !isHiddenVersion && <AgentDirective markdownUrl={markdownUrl} llmsUrl={llmsUrl} />} + <slot /> + {config.search !== false && <SearchDialog />} + + <script> + import { codeCopy, headingAnchors } from "@cloudflare/nimbus-docs/client"; + codeCopy(); + headingAnchors(); + </script> + </body> +</html> diff --git a/packages/docs/src/layouts/DocsLayout.astro b/packages/docs/src/layouts/DocsLayout.astro new file mode 100644 index 00000000..23740370 --- /dev/null +++ b/packages/docs/src/layouts/DocsLayout.astro @@ -0,0 +1,346 @@ +--- +/** + * DocsLayout — three-column docs layout. + * + * Named slots for overrides (all optional, defaults render otherwise): + * header, sidebar, toc, page-title, content-footer, pagination + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import BaseLayout from "./BaseLayout.astro"; +import Header from "@/components/Header.astro"; +import { Banner } from "@/components/ui/banner"; +import { Sidebar, SidebarFilter } from "@/components/ui/sidebar"; +import { TOC, MobileTOC } from "@/components/ui/toc"; +import { Breadcrumbs } from "@/components/ui/breadcrumbs"; +import { Pagination } from "@/components/ui/pagination"; +import { PageActions } from "@/components/ui/page-actions"; +import { Badge } from "@/components/ui/badge"; +import type { DocsPageProps } from "@cloudflare/nimbus-docs/types"; +import { getVersionStatus, getVersionAlternates } from "@cloudflare/nimbus-docs"; + +type Props = DocsPageProps & { audience?: "human" }; + +const { title, description, sidebar, headings, breadcrumbs, prevNext, mode = "doc", banner, head = [], searchable, noindex, markdownUrl, socialImage, lastUpdated, editUrl, draft, audience, collection, entryId } = Astro.props; + +// `searchable` derives from `noindex` when omitted: a non-crawlable page is +// by default not in the site search either. Explicit `searchable: true` +// overrides for the edge case of "no search engines, yes internal search." +const effectiveSearchable = searchable ?? !noindex; +const isCustom = mode === "custom"; + +// `sidebar: false` / `tableOfContents: false` in frontmatter come through +// from the route as literal `false` (not coerced to `[]`). They drive +// per-column suppression — chrome, mobile dialog, header menu button. +const showSidebar = sidebar !== false; + +// Versioning: per-page status drives Pagefind faceting, deprecation +// banner, and the data-pagefind-ignore exclusion for hidden versions. +const versionStatus = collection ? await getVersionStatus(collection) : null; +const versionAlternates = collection && entryId + ? await getVersionAlternates(collection, entryId) + : null; +const currentSiblingUrl = versionAlternates?.canonical?.url ?? null; + +// Page-body attribute resolution: +// - If the user opted out via `searchable: false` (or `noindex: true` +// with no explicit override) → ignore. +// - If the page is in a hidden version → ignore unconditionally +// (hidden takes precedence; you can't index hidden content even +// if the page frontmatter says searchable=true). +// - Otherwise → mark as a Pagefind body, plus emit per-version and +// per-status filters so the search UI can scope by them. +const pagefindAttrs: Record<string, string> = {}; +if (!effectiveSearchable || versionStatus?.isHidden) { + pagefindAttrs["data-pagefind-ignore"] = ""; +} else { + pagefindAttrs["data-pagefind-body"] = ""; + if (versionStatus) { + pagefindAttrs["data-pagefind-filter"] = `version:${versionStatus.version}`; + if (versionStatus.isDeprecated) { + // Pagefind supports multiple filter attributes on the same element + // via `data-pagefind-filter` repeated as a comma-separated list in + // the value. The status filter pairs with the version filter so a + // search UI can offer "exclude deprecated" as a default toggle. + pagefindAttrs["data-pagefind-filter"] += `, status:deprecated`; + } + } +} + +const hasHeader = Astro.slots.has("header"); +const hasSidebar = Astro.slots.has("sidebar"); +const hasToc = Astro.slots.has("toc"); +const hasPageTitle = Astro.slots.has("page-title"); +const hasContentFooter = Astro.slots.has("content-footer"); +const hasPagination = Astro.slots.has("pagination"); +--- + +<BaseLayout title={title} description={description} noindex={noindex || draft} markdownUrl={markdownUrl} socialImage={socialImage} lastUpdated={lastUpdated} head={head} collection={collection} entryId={entryId}> + <div class="flex flex-col min-h-screen"> + {hasHeader ? <slot name="header" /> : <Header collection={collection} entryId={entryId} showSidebar={showSidebar} />} + + {isCustom ? ( + <main id="main-content" class="flex-1"> + <slot /> + </main> + ) : ( + <div class="flex-1 mx-auto w-full"> + <div class="flex"> + {showSidebar && ( + <aside id="desktop-sidebar" data-nb-desktop-sidebar class="hidden lg:flex w-(--nb-sidebar-width) shrink-0 border-r border-border bg-background sticky top-14 h-[calc(100vh-3.5rem)] flex-col overflow-y-auto overscroll-contain relative"> + {hasSidebar ? ( + <slot name="sidebar" /> + ) : ( + <nav class="px-4 pb-12 pt-5 flex-1"> + <SidebarFilter /> + <Sidebar items={sidebar} persist /> + </nav> + )} + <div class="pointer-events-none sticky bottom-0 h-8 bg-gradient-to-t from-base to-transparent" /> + </aside> + )} + + {/* Sidebar state restore — pre-sets data attributes before the + disclosure module loads so collapsed/scrolled state survives nav. + Only emit when the sidebar is rendered; otherwise the script is + dead code (the `desktop-sidebar` element doesn't exist). */} + {showSidebar && ( + <script is:inline aria-hidden="true"> + (function () { + try { + if (!matchMedia("(min-width: 64rem)").matches) return; + const sidebar = document.getElementById("desktop-sidebar"); + if (!sidebar) return; + const content = sidebar.querySelector("[data-nb-sidebar]"); + if (!content) return; + + function setGroupOpen(group, open) { + group.setAttribute("data-nb-default-open", open ? "true" : "false"); + const trigger = group.querySelector("[data-nb-collapsible-trigger]"); + const panel = group.querySelector("[data-nb-collapsible-content]"); + const state = open ? "open" : "closed"; + if (trigger) { + trigger.setAttribute("data-nb-state", state); + trigger.setAttribute("aria-expanded", String(open)); + } + if (panel) panel.setAttribute("data-nb-state", state); + } + + const raw = sessionStorage.getItem("sidebar-state"); + let state; + if (raw) { + state = JSON.parse(raw); + if (state && content.dataset.nbSidebarHash === state.hash) { + const groups = content.querySelectorAll("[data-nb-sidebar-group]"); + for (let i = 0; i < groups.length; i++) { + if (typeof state.open[i] === "boolean") { + setGroupOpen(groups[i], state.open[i]); + } + } + } + } + + // Active page's group must always be open, regardless of stored state + const active = sidebar.querySelector("[aria-current='page']"); + if (active) { + let d = active.closest("[data-nb-sidebar-group]"); + while (d) { + setGroupOpen(d, true); + d = d.parentElement && d.parentElement.closest("[data-nb-sidebar-group]"); + } + } + + // Restore scroll or center active item + if (raw && state && state.scroll) { + sidebar.scrollTop = state.scroll; + } else if (active) { + const sr = sidebar.getBoundingClientRect(); + const ar = active.getBoundingClientRect(); + sidebar.scrollTop += ar.top - sr.top - sr.height / 2 + ar.height / 2; + } + } catch (_) {} + })(); + </script> + )} + + <main id="main-content" class="flex-1 min-w-0" data-cw-sheet> + <div class="mx-auto max-w-(--nb-content-max) px-4 lg:px-6 pt-6 pb-12"> + {versionStatus?.isDeprecated && ( + <Banner + variant="caution" + content={ + currentSiblingUrl + ? `This is the <strong>${versionStatus.version}</strong> version of the docs and is no longer maintained. The latest version of this page is at <a href="${currentSiblingUrl}">${currentSiblingUrl}</a>.` + : `This is the <strong>${versionStatus.version}</strong> version of the docs and is no longer maintained. See the <a href="/">current docs</a> for up-to-date content.` + } + /> + )} + {banner && <Banner content={banner.content} variant={banner.type} dismissible={banner.dismissible} />} + <Breadcrumbs items={breadcrumbs} class="pb-2" /> + <div {...pagefindAttrs}> + {hasPageTitle ? ( + <slot name="page-title" /> + ) : ( + <Fragment> + <div class="flex items-center gap-3 flex-wrap mt-6"> + <h1 class="text-foreground mb-0 leading-tight [text-wrap:balance]" style={`font-size:var(--nb-h1-size);font-weight:var(--nb-h1-weight);letter-spacing:var(--nb-h1-tracking)`}>{title}</h1> + {draft && <Badge text="Draft" variant="warning" size="medium" />} + </div> + {description && ( + <p class="text-[0.9375rem] sm:text-[1.0625rem] text-muted-foreground leading-relaxed mt-2 mb-2 [text-wrap:pretty]">{description}</p> + )} + <PageActions markdownUrl={markdownUrl} lastUpdated={lastUpdated} class={description ? undefined : "mt-3"} /> + {headings !== false && headings.length > 0 && ( + <div class="sticky top-14 z-20 mt-4 xl:hidden"> + <MobileTOC headings={headings} /> + </div> + )} + {audience === "human" && ( + <div class="mt-4 mb-6 flex items-center gap-3" role="none"> + <div class="h-px flex-1 bg-border"></div> + <Badge text="For humans" variant="success" size="small" class="font-mono uppercase border border-success/15" /> + <div class="h-px flex-1 bg-border"></div> + </div> + )} + {audience !== "human" && <div class="mb-6" />} + </Fragment> + )} + <article class="docs-content max-w-none"> + <slot /> + </article> + {hasContentFooter ? ( + <slot name="content-footer" /> + ) : editUrl && ( + <div class="mt-8 mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"> + <a href={editUrl} class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors no-underline" target="_blank" rel="noopener"> + <Icon name="ph:pencil-simple" class="w-3.5 h-3.5" /> + Edit this page + </a> + </div> + )} + </div> + {hasPagination ? <slot name="pagination" /> : <Pagination prevNext={prevNext} />} + </div> + </main> + + {headings !== false && (hasToc || headings.length > 0) && ( + <aside data-nb-toc-scroll-host class="hidden xl:block w-(--nb-toc-width) shrink-0 sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto"> + <div class="pt-6 pb-8 pl-8 pr-6"> + {hasToc ? <slot name="toc" /> : <TOC headings={headings} />} + </div> + </aside> + )} + </div> + </div> + )} + + </div> + + {showSidebar && ( + /* Mobile sidebar — native <dialog> for free focus trap, escape, backdrop. */ + <dialog + data-mobile-sidebar + data-state="closed" + class="group fixed inset-0 m-0 h-full w-full max-h-full max-w-full border-0 bg-transparent p-0 [&::backdrop]:bg-transparent" + aria-label="Site navigation" + > + <div data-mobile-sidebar-panel class="h-full w-(--nb-sidebar-width) max-w-[85vw] -translate-x-full overflow-y-auto overscroll-contain border-r border-border bg-card transition-transform duration-250 ease-out group-data-[state=open]:translate-x-0 motion-reduce:transition-none"> + <div class="sticky top-0 z-[1] flex items-center justify-between border-b border-border bg-card px-4 py-3"> + <span class="font-semibold text-sm text-foreground">Navigation</span> + <button data-close-sidebar class="flex items-center justify-center w-8 h-8 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors" aria-label="Close sidebar"> + <Icon name="ph:x" class="w-5 h-5" /> + </button> + </div> + <nav class="px-4 pb-8 pt-5"> + {hasSidebar ? <slot name="sidebar" /> : ( + <Fragment> + <SidebarFilter /> + <Sidebar items={sidebar} /> + </Fragment> + )} + </nav> + </div> + </dialog> + )} + + <script> + import { mount, lockScroll, unlockScroll } from "@cloudflare/nimbus-docs/client"; + + const CLOSE_DURATION_MS = 250; + + // mount() re-binds on every astro:page-load and tears down on + // astro:before-swap, so the hamburger survives client-side navigation + // (a one-shot module script would go dead after the first swap). + mount("[data-mobile-sidebar]", (root) => { + const menuBtn = document.querySelector<HTMLElement>("[data-menu-btn]"); + if (!(root instanceof HTMLDialogElement) || !menuBtn) return () => {}; + const dialog = root; + + const controller = new AbortController(); + const { signal } = controller; + let closeTimer: ReturnType<typeof setTimeout> | undefined; + + const openSidebar = () => { + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = undefined; + } + if (dialog.open) return; + + dialog.showModal(); + dialog.dataset.state = "closed"; + lockScroll(); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + dialog.dataset.state = "open"; + }); + }); + + dialog.querySelector<HTMLElement>("[data-close-sidebar]")?.focus(); + }; + + const closeSidebar = () => { + if (!dialog.open || dialog.dataset.state === "closing") return; + + dialog.dataset.state = "closing"; + closeTimer = setTimeout(() => { + dialog.close(); + }, CLOSE_DURATION_MS); + }; + + menuBtn.addEventListener("click", openSidebar, { signal }); + + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + closeSidebar(); + }, { signal }); + + // Fires for Escape, close button, and backdrop click. + dialog.addEventListener("close", () => { + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = undefined; + } + + dialog.dataset.state = "closed"; + unlockScroll(); + menuBtn.focus(); + }, { signal }); + + dialog.querySelector("[data-close-sidebar]") + ?.addEventListener("click", closeSidebar, { signal }); + + dialog.addEventListener("click", (e) => { + if (e.target === dialog) closeSidebar(); + }, { signal }); + + return () => { + controller.abort(); + if (closeTimer) clearTimeout(closeTimer); + // A swap while open never fires `close`; balance the scroll lock. + if (dialog.open) unlockScroll(); + }; + }); + </script> + +</BaseLayout> diff --git a/packages/docs/src/lib/cn.ts b/packages/docs/src/lib/cn.ts new file mode 100644 index 00000000..75424d66 --- /dev/null +++ b/packages/docs/src/lib/cn.ts @@ -0,0 +1,7 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** Compose class names with Tailwind conflict resolution. Consumer class (last arg) wins. */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/packages/docs/src/lib/hero-copy.ts b/packages/docs/src/lib/hero-copy.ts new file mode 100644 index 00000000..7571eeb5 --- /dev/null +++ b/packages/docs/src/lib/hero-copy.ts @@ -0,0 +1,16 @@ +/** + * The landing hero's headline and tagline, in one place. + * + * The `/1`../`/5` backdrop comparison pages render the same hero, and the copy + * used to be hand-duplicated into `HeroVariantPage.astro`. That is worse than the + * usual copy-paste: the tagline's length decides how tall `.cw-hero-scrim` is, and + * that box is exactly the `KeepOut` geometry the canvas scenes lay themselves out + * against. Editing the landing tagline alone would leave all five comparison pages + * judging backdrops against boxes the real page no longer has. + */ + +export const HERO_TITLE = "One round trip"; + +/** `label` is `bundle-size.json`'s measured figure, never a typed-in number. */ +export const heroTagline = (label: string): string => + `A JavaScript-native, object-capability RPC system. Chain dependent calls and the whole chain resolves in a single trip. No schemas, no boilerplate, ${label}.`; diff --git a/packages/docs/src/lib/source.ts b/packages/docs/src/lib/source.ts new file mode 100644 index 00000000..a360e049 --- /dev/null +++ b/packages/docs/src/lib/source.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Locate the repo root by walking up until two sentinels are both present. + * + * Deriving it from a fixed number of `..` segments does not work: for a + * production build this module is bundled into `dist/.prerender/chunks/`, so + * `import.meta.url` points somewhere else entirely and the offset silently + * changes. `process.cwd()` is also not dependable, since the docs are built + * both from this directory and from the repo root via `npm --prefix`. + * + * Searching for the sentinels handles every one of those cases. Requiring two + * of them makes a false positive effectively impossible. + */ +function findRepoRoot(): string { + const sentinels = ['examples', join('packages', 'docs', 'astro.config.ts')]; + const starts = [process.cwd(), dirname(fileURLToPath(import.meta.url))]; + + for (const start of starts) { + let dir = resolve(start); + for (;;) { + if (sentinels.every((sentinel) => existsSync(join(dir, sentinel)))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + + throw new Error( + `Could not locate the capnweb repo root from ${starts.join(' or ')}. ` + + `Looked for a directory containing: ${sentinels.join(' and ')}.`, + ); +} + +let cachedRoot: string | undefined; + +function repoRoot(): string { + return (cachedRoot ??= findRepoRoot()); +} + +/** + * Read a file from the repo, given a path relative to the repo root. + * + * The example playgrounds render straight from the real source files, so the + * code on the site cannot drift from the code that is actually deployed. That + * only holds if a missing file is a hard error -- silently rendering an empty + * tab would defeat the point -- so this throws and fails the build. + */ +export function readRepoFile(relativePath: string): string { + const absolute = join(repoRoot(), relativePath); + let contents: string; + try { + contents = readFileSync(absolute, 'utf8'); + } catch (cause) { + throw new Error( + `Cannot read example source "${relativePath}" (resolved to ${absolute}). ` + + `Playground file lists live in src/examples.ts; update them if a file moved.`, + { cause }, + ); + } + // Trim trailing blank lines so the code frame has no dead space at the end. + return `${contents.replace(/\s+$/, '')}\n`; +} diff --git a/packages/docs/src/pages/1.astro b/packages/docs/src/pages/1.astro new file mode 100644 index 00000000..e9dde29b --- /dev/null +++ b/packages/docs/src/pages/1.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 1. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="1" /> diff --git a/packages/docs/src/pages/1a.astro b/packages/docs/src/pages/1a.astro new file mode 100644 index 00000000..238780d5 --- /dev/null +++ b/packages/docs/src/pages/1a.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 1a. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="1a" /> diff --git a/packages/docs/src/pages/1b.astro b/packages/docs/src/pages/1b.astro new file mode 100644 index 00000000..25006754 --- /dev/null +++ b/packages/docs/src/pages/1b.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 1b. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="1b" /> diff --git a/packages/docs/src/pages/1c.astro b/packages/docs/src/pages/1c.astro new file mode 100644 index 00000000..cd9aff03 --- /dev/null +++ b/packages/docs/src/pages/1c.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 1c. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="1c" /> diff --git a/packages/docs/src/pages/1d.astro b/packages/docs/src/pages/1d.astro new file mode 100644 index 00000000..f361889b --- /dev/null +++ b/packages/docs/src/pages/1d.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 1d. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="1d" /> diff --git a/packages/docs/src/pages/1e.astro b/packages/docs/src/pages/1e.astro new file mode 100644 index 00000000..8e1afeef --- /dev/null +++ b/packages/docs/src/pages/1e.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 1e. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="1e" /> diff --git a/packages/docs/src/pages/2.astro b/packages/docs/src/pages/2.astro new file mode 100644 index 00000000..03ce001e --- /dev/null +++ b/packages/docs/src/pages/2.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 2. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="2" /> diff --git a/packages/docs/src/pages/3.astro b/packages/docs/src/pages/3.astro new file mode 100644 index 00000000..edda0cff --- /dev/null +++ b/packages/docs/src/pages/3.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 3. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="3" /> diff --git a/packages/docs/src/pages/4.astro b/packages/docs/src/pages/4.astro new file mode 100644 index 00000000..7ca8aeef --- /dev/null +++ b/packages/docs/src/pages/4.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 4. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="4" /> diff --git a/packages/docs/src/pages/404.astro b/packages/docs/src/pages/404.astro new file mode 100644 index 00000000..d84f3517 --- /dev/null +++ b/packages/docs/src/pages/404.astro @@ -0,0 +1,32 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; +import Header from "../components/Header.astro"; +import { config } from "virtual:nimbus/config"; +--- + +<BaseLayout title={`Page not found · ${config.title}`} description="The page you're looking for doesn't exist."> + <div class="flex flex-col min-h-screen"> + <Header /> + {/* `id` matters: BaseLayout renders a "Skip to content" link to + `#main-content` on every page, and the starter's 404 was the one page + with no such target for it to reach. */} + <main id="main-content" class="flex-1 mx-auto max-w-2xl w-full px-6 py-24 text-center"> + <p class="text-sm font-medium text-muted-foreground">404</p> + <h1 + class="mt-2 text-foreground leading-tight" + style="font-size: var(--nb-h1-size); font-weight: var(--nb-h1-weight); letter-spacing: var(--nb-h1-tracking);" + > + Page not found + </h1> + <p class="mt-2 text-[1.0625rem] text-muted-foreground leading-relaxed"> + The page you're looking for doesn't exist or has moved. + </p> + <a + href="/" + class="mt-8 inline-block rounded-lg border border-border px-4 py-2 font-medium text-foreground no-underline transition-colors hover:border-border-strong" + > + Back home + </a> + </main> + </div> +</BaseLayout> diff --git a/packages/docs/src/pages/5.astro b/packages/docs/src/pages/5.astro new file mode 100644 index 00000000..4fe5ad4f --- /dev/null +++ b/packages/docs/src/pages/5.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 5. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="5" /> diff --git a/packages/docs/src/pages/6.astro b/packages/docs/src/pages/6.astro new file mode 100644 index 00000000..fc449ed8 --- /dev/null +++ b/packages/docs/src/pages/6.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 6. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="6" /> diff --git a/packages/docs/src/pages/7.astro b/packages/docs/src/pages/7.astro new file mode 100644 index 00000000..169979df --- /dev/null +++ b/packages/docs/src/pages/7.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 7. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="7" /> diff --git a/packages/docs/src/pages/8.astro b/packages/docs/src/pages/8.astro new file mode 100644 index 00000000..74eb72cc --- /dev/null +++ b/packages/docs/src/pages/8.astro @@ -0,0 +1,7 @@ +--- +// Hero backdrop variant 8. Which scene this is, and at what opacity, is decided +// in src/components/canvas-hero/scenes/index.ts so the mapping lives in one place. +import HeroVariantPage from "@/components/canvas-hero/HeroVariantPage.astro"; +--- + +<HeroVariantPage slug="8" /> diff --git a/packages/docs/src/pages/[...slug].astro b/packages/docs/src/pages/[...slug].astro new file mode 100644 index 00000000..0f4f5c07 --- /dev/null +++ b/packages/docs/src/pages/[...slug].astro @@ -0,0 +1,84 @@ +--- +import type { GetStaticPaths } from "astro"; +import DocsLayout from "../layouts/DocsLayout.astro"; +import { + getDocsStaticPaths, + getDocsPageProps, + getRouteFlags, + getSidebar, + getPrevNext, + getBreadcrumbs, + getEditUrl, + getLastUpdated, + getTOC, +} from "@cloudflare/nimbus-docs"; +import { components } from "../components"; + +export const prerender = true; +// Serve the root index entry (`docs/index.mdx`) at `/` instead of `/index`, +// mirroring the `.md`/`.mdx` twin routes which already map `id === "index"` +// to the bare root segment. The starter leaves this to the site, since a site +// may prefer a hand-written `pages/index.astro`; ours is authored content. +export const getStaticPaths: GetStaticPaths = async (options) => { + const paths = await getDocsStaticPaths(options); + return paths.map((path) => + path.params.slug === "index" + ? { ...path, params: { ...path.params, slug: undefined } } + : path, + ); +}; + +const { entry, Content, headings } = await getDocsPageProps(Astro); + +const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; + +const { sidebar: sidebarOn, tableOfContents: tocOn } = await getRouteFlags(entry); + +const sidebar = sidebarOn + ? await getSidebar(currentSlug, { collection: entry.collection }) + : false; +const prevNext = await getPrevNext(currentSlug, { + sidebarTree: sidebar === false ? [] : sidebar, + overrides: { prev: entry.data.prev, next: entry.data.next }, +}); +// Pass `collection` so versioned pages (docs-<v>) get version-prefixed +// breadcrumb hrefs — parity with the getSidebar call above. +const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collection }); +const editUrl = await getEditUrl(entry); +// Frontmatter wins; git is the fallback. +const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); +// `tocOn` already implies `tableOfContents !== false`, but TS can't carry +// that boolean narrowing to the value here — re-check it so `getTOC` only +// ever sees its options object (or undefined), never `false`. +const tocConfig = entry.data.tableOfContents; +const toc = tocOn && tocConfig !== false ? getTOC(headings, tocConfig) : false; +// Root index emits at `/index.md`, every other entry at `/<id>/index.md`. +// Mirrors the twin route's mapping so the home page's markdown link resolves. +const markdownPath = entry.id === "index" ? "/index.md" : `/${entry.id}/index.md`; +const markdownUrl = Astro.site ? new URL(markdownPath, Astro.site).href : markdownPath; +const socialImage = entry.data.socialImage ?? `/og/${entry.id}.png`; +--- + +<DocsLayout + title={entry.data.title} + description={entry.data.description} + sidebar={sidebar} + headings={toc} + breadcrumbs={breadcrumbs} + prevNext={prevNext} + mode={entry.data.mode} + banner={entry.data.banner} + head={entry.data.head} + searchable={entry.data.searchable} + noindex={entry.data.noindex} + markdownUrl={markdownUrl} + socialImage={socialImage} + lastUpdated={lastUpdated} + editUrl={editUrl} + draft={entry.data.draft} + audience={entry.data.audience} + collection={entry.collection} + entryId={entry.id} +> + <Content components={components} /> +</DocsLayout> diff --git a/packages/docs/src/pages/[...slug]/index.md.ts b/packages/docs/src/pages/[...slug]/index.md.ts new file mode 100644 index 00000000..d27efbb8 --- /dev/null +++ b/packages/docs/src/pages/[...slug]/index.md.ts @@ -0,0 +1,78 @@ +/** + * Per-page `/<slug>/index.md` — the clean-markdown alternate for every + * indexable entry of the primary `docs` collection. + * + * Non-primary collections (`api`, `blog`, …) mount under their own + * URL namespace by convention; their `.md` alternates live at the + * sibling route `pages/<collection>/[...slug]/index.md.ts`. This route + * filters to the primary collection so multi-collection sites don't + * generate conflicting `[...slug]` paths at root. + */ + +import { getIndexedEntries, renderEntryAsMarkdown, type IndexedEntry } from "@cloudflare/nimbus-docs"; +import { config } from "virtual:nimbus/config"; + +export const prerender = true; + +const PRIMARY_COLLECTION = "docs"; + +interface SlugProps { + item: IndexedEntry; +} + +export async function getStaticPaths() { + const indexed = await getIndexedEntries(); + return indexed + .filter((item) => item.collection === PRIMARY_COLLECTION) + .map((item) => ({ + // Root index (`entry.id === "index"`) emits at `/index.md`; Astro's + // rest-segment treats `undefined` as "no segment" so the URL is + // `/index.md` rather than `/index/index.md`. Every other entry emits + // at `/<entry.id>/index.md` — the convention `<page>/index.md`. + params: { + slug: item.entry.id === "index" ? undefined : item.entry.id, + }, + props: { item } as SlugProps, + })); +} + +export async function GET({ props }: { props: SlugProps }) { + const { item } = props; + const { entry, title, description, markdownUrl, sourceUrl, version } = item; + const data = (entry.data ?? {}) as Record<string, unknown>; + const rawImage = data.socialImage; + const socialImage = + typeof rawImage === "string" && rawImage.length > 0 + ? rawImage + : config.socialImage; + + const markdown = renderEntryAsMarkdown(entry); + + const body = [ + "---", + `title: ${JSON.stringify(title)}`, + ...(description ? [`description: ${JSON.stringify(description)}`] : []), + ...(socialImage + ? [`image: ${JSON.stringify(new URL(socialImage, config.site).href)}`] + : []), + ...(version ? [`version: ${JSON.stringify(version)}`] : []), + "---", + "", + "> Documentation Index", + `> Fetch the complete documentation index at: ${new URL("/llms.txt", config.site).href}`, + "> Use this file to discover all available pages before exploring further.", + "", + `# ${title}`, + "", + markdown, + "", + // Point at the authored source (`.mdx` twin) when it exists — the + // `.md` alternate referencing itself was a placeholder. + `Source: ${new URL(sourceUrl ?? markdownUrl, config.site).href}`, + "", + ].join("\n"); + + return new Response(body, { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); +} diff --git a/packages/docs/src/pages/[...slug]/index.mdx.ts b/packages/docs/src/pages/[...slug]/index.mdx.ts new file mode 100644 index 00000000..c8afde83 --- /dev/null +++ b/packages/docs/src/pages/[...slug]/index.mdx.ts @@ -0,0 +1,69 @@ +/** + * Per-page `/<slug>/index.mdx` — the raw authored source for every + * indexable entry of the primary `docs` collection that has a string body. + * + * Twin grammar: `index.md` is the downleveled render for reading, + * `index.mdx` is the source — imports, JSX, and directives intact. The + * body is served verbatim; only the canonical frontmatter block (shared + * with the `.md` twin) is framework-shaped. + * + * Non-primary collections (`api`, `blog`, …) follow the same sibling-route + * convention as `index.md.ts`: their `.mdx` alternates live at + * `pages/<collection>/[...slug]/index.mdx.ts`. + */ + +import { getIndexedEntries, type IndexedEntry } from "@cloudflare/nimbus-docs"; +import { config } from "virtual:nimbus/config"; + +export const prerender = true; + +const PRIMARY_COLLECTION = "docs"; + +interface SlugProps { + item: IndexedEntry; +} + +export async function getStaticPaths() { + const indexed = await getIndexedEntries(); + return indexed + .filter( + (item) => + item.collection === PRIMARY_COLLECTION && item.sourceUrl !== undefined, + ) + .map((item) => ({ + // Same root-index shape as the `.md` twin: `entry.id === "index"` + // emits at `/index.mdx`, everything else at `/<entry.id>/index.mdx`. + params: { + slug: item.entry.id === "index" ? undefined : item.entry.id, + }, + props: { item } as SlugProps, + })); +} + +export async function GET({ props }: { props: SlugProps }) { + const { item } = props; + const { entry, title, description, version } = item; + const data = (entry.data ?? {}) as Record<string, unknown>; + const rawImage = data.socialImage; + const socialImage = + typeof rawImage === "string" && rawImage.length > 0 + ? rawImage + : config.socialImage; + + const body = [ + "---", + `title: ${JSON.stringify(title)}`, + ...(description ? [`description: ${JSON.stringify(description)}`] : []), + ...(socialImage + ? [`image: ${JSON.stringify(new URL(socialImage, config.site).href)}`] + : []), + ...(version ? [`version: ${JSON.stringify(version)}`] : []), + "---", + "", + entry.body ?? "", + ].join("\n"); + + return new Response(body, { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); +} diff --git a/packages/docs/src/pages/[section]/llms.txt.ts b/packages/docs/src/pages/[section]/llms.txt.ts new file mode 100644 index 00000000..48b4fe0f --- /dev/null +++ b/packages/docs/src/pages/[section]/llms.txt.ts @@ -0,0 +1,64 @@ +/** + * Per-section /<section>/llms.txt — sub-index files that drill down + * from the root `/llms.txt` into a named slice of the site's docs. + * + * A "section" is one of two things: + * 1. A folder inside the primary `docs` collection with more than + * one page (e.g. `src/content/docs/<folder>/*` → `/<folder>/llms.txt`). + * 2. A whole non-primary collection — `api`, `blog`, etc. — which + * becomes a single section mounted at `/<collection>/llms.txt`. + * + * Both cases produce the same shape at the same URL pattern, so + * agents follow one rule: every link in `/llms.txt` that ends in + * `.llms.txt` resolves here. + * + * `getIndexedTopLevel()` decides which sections exist and what they + * contain; this route just renders one file per section it returns. + */ + +import { getIndexedTopLevel, type IndexedEntry } from "@cloudflare/nimbus-docs"; +import { config } from "virtual:nimbus/config"; + +export const prerender = true; + +interface SectionProps { + slug: string; + label: string; + members: IndexedEntry[]; +} + +export async function getStaticPaths() { + const { groups } = await getIndexedTopLevel(); + return groups + // Versioning: hidden versions don't get a per-section llms.txt + // index. They're URL-reachable for direct navigation, but every + // agent-discovery surface should treat them as if they don't exist. + .filter((group) => !group.hidden) + .map((group) => ({ + params: { section: group.slug }, + props: { + slug: group.slug, + label: group.label, + members: group.members, + } as SectionProps, + })); +} + +export async function GET({ props }: { props: SectionProps }) { + const { label, members } = props; + + const lines = [`# ${label}`, "", "## Pages", ""]; + + for (const item of members) { + const description = item.description ? ` — ${item.description}` : ""; + lines.push( + `- [${item.title}](${new URL(item.markdownUrl, config.site).href})${description}`, + ); + } + + lines.push(""); + + return new Response(lines.join("\n"), { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/packages/docs/src/pages/llms-full.txt.ts b/packages/docs/src/pages/llms-full.txt.ts new file mode 100644 index 00000000..a2da87a2 --- /dev/null +++ b/packages/docs/src/pages/llms-full.txt.ts @@ -0,0 +1,12 @@ +// Full-corpus markdown for AI agents — every published page in one +// document. Scope and collation live in the framework helper; reshape or +// delete this route to change the site's corpus policy. +import { renderCorpusMarkdown } from "@cloudflare/nimbus-docs"; + +export const prerender = true; + +export async function GET() { + return new Response(await renderCorpusMarkdown(), { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/packages/docs/src/pages/llms.txt.ts b/packages/docs/src/pages/llms.txt.ts new file mode 100644 index 00000000..f290b06c --- /dev/null +++ b/packages/docs/src/pages/llms.txt.ts @@ -0,0 +1,50 @@ +// Root /llms.txt — sectioned index for AI agents. +import { getIndexedTopLevel } from "@cloudflare/nimbus-docs"; +import { config } from "virtual:nimbus/config"; + +export const prerender = true; + +export async function GET() { + const { leaves, groups } = await getIndexedTopLevel(); + + const lines = [ + `# ${config.title}`, + "", + config.description ?? "Documentation index for AI agents.", + "", + `Full corpus (all pages, one document): ${new URL("/llms-full.txt", config.site).href}`, + "", + "## Pages", + "", + ]; + + // Sort leaves + groups alphabetically into a single stable list. + type Row = { key: string; line: string }; + const rows: Row[] = []; + + for (const leaf of leaves) { + const description = leaf.description ? ` — ${leaf.description}` : ""; + rows.push({ + key: leaf.url, + line: `- [${leaf.title}](${new URL(leaf.markdownUrl, config.site).href})${description}`, + }); + } + + for (const group of groups) { + // Older doc versions have their own /<v>/llms.txt; don't list them here. + if (group.kind === "version") continue; + rows.push({ + key: `/${group.slug}`, + line: `- [${group.label}](${new URL(`/${group.slug}/llms.txt`, config.site).href})`, + }); + } + + rows.sort((a, b) => a.key.localeCompare(b.key)); + for (const row of rows) lines.push(row.line); + + lines.push(""); + + return new Response(lines.join("\n"), { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/packages/docs/src/pages/og.png.ts b/packages/docs/src/pages/og.png.ts new file mode 100644 index 00000000..e03cacb7 --- /dev/null +++ b/packages/docs/src/pages/og.png.ts @@ -0,0 +1,17 @@ +import { generateOpenGraphImage } from "astro-og-canvas"; +import { config } from "virtual:nimbus/config"; +import { ogCardConfig } from "./og/_og-card-config"; + +export const prerender = true; + +export async function GET() { + const body = await generateOpenGraphImage({ + title: config.title, + description: config.description, + ...ogCardConfig, + }); + + return new Response(body, { + headers: { "Content-Type": "image/png" }, + }); +} diff --git a/packages/docs/src/pages/og/[...slug].ts b/packages/docs/src/pages/og/[...slug].ts new file mode 100644 index 00000000..9e16df1a --- /dev/null +++ b/packages/docs/src/pages/og/[...slug].ts @@ -0,0 +1,24 @@ +import { getCollection } from "astro:content"; +import { OGImageRoute } from "astro-og-canvas"; +import { ogCardConfig } from "./_og-card-config"; + +const entries = await getCollection("docs", (entry) => !entry.data.draft); + +const pages = Object.fromEntries( + entries.map((entry) => [ + entry.id, + { + title: entry.data.title, + description: entry.data.description ?? "", + }, + ]), +); + +export const { getStaticPaths, GET } = await OGImageRoute({ + pages, + getImageOptions: (_path, page) => ({ + title: page.title, + description: page.description, + ...ogCardConfig, + }), +}); diff --git a/packages/docs/src/pages/og/_og-card-config.ts b/packages/docs/src/pages/og/_og-card-config.ts new file mode 100644 index 00000000..4b4ba092 --- /dev/null +++ b/packages/docs/src/pages/og/_og-card-config.ts @@ -0,0 +1,46 @@ +/** + * Shared visual config for build-time OG cards. + * + * Edit this file to retune generated card colors, spacing, and fonts. Both + * the per-page endpoint (`og/[...slug].ts`) and the homepage fallback + * (`og.png.ts`) spread this object into `astro-og-canvas`. + * + * Leading underscore tells Astro to skip routing for this file — it sits + * inside `src/pages/` to be next to its consumers, but it's not a route. + */ + +import type { OGImageOptions } from "astro-og-canvas"; + +// The site's dark scheme, since a social card has no scheme to follow: charcoal +// paper and the orange edge -- which is the only place the spark appears here. +export const ogCardConfig = { + bgGradient: [ + [12, 16, 20], + [44, 54, 65], + ], + border: { color: [232, 93, 44], width: 12, side: "inline-start" }, + padding: 96, + // Build-time only, and deliberately not under `public/`, where the starter + // puts it: this path is resolved from the project root when the cards are + // rasterized, and nothing ever requests the file over HTTP. Left in `public/` + // it is copied into `dist/` and deployed -- 420 kB in the asset store that no + // page links to. The rendered cards are byte-identical either way. + fonts: ["./fonts/Inter-Bold.ttf"], + font: { + title: { + color: [242, 247, 253], + size: 64, + weight: "Bold", + families: ["Inter"], + lineHeight: 1.1, + }, + description: { + color: [130, 153, 182], + size: 32, + weight: "Bold", + families: ["Inter"], + lineHeight: 1.3, + }, + }, + format: "PNG", +} satisfies Partial<OGImageOptions>; diff --git a/packages/docs/src/pages/robots.txt.ts b/packages/docs/src/pages/robots.txt.ts new file mode 100644 index 00000000..3659d962 --- /dev/null +++ b/packages/docs/src/pages/robots.txt.ts @@ -0,0 +1,17 @@ +import { config } from "virtual:nimbus/config"; + +export const prerender = true; + +export function GET() { + const body = [ + "User-agent: *", + "Allow: /", + "", + `Sitemap: ${new URL("/sitemap-index.xml", config.site).href}`, + "", + ].join("\n"); + + return new Response(body, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/packages/docs/src/styles/globals.css b/packages/docs/src/styles/globals.css new file mode 100644 index 00000000..286dd218 --- /dev/null +++ b/packages/docs/src/styles/globals.css @@ -0,0 +1,715 @@ +@import "tailwindcss"; + +/* ============================================================ + * Nimbus Theme — THE file you edit to customize your site. + * + * Architecture: + * 1. CSS custom properties (--nb-*) define the design tokens + * 2. `@theme` block exposes them to Tailwind so utilities like + * `bg-card`, `text-foreground`, `border-border` work + * 3. Dark mode uses the `[data-mode="dark"]` attribute + * 4. All colors use oklch for perceptual uniformity + * + * Naming convention: + * - Surfaces paired with foregrounds: `card` / `card-foreground` + * - Functional names: `primary` (action), `accent` (hover surface), + * `muted` (de-emphasized), not visual relationships + * - Reserved Tailwind keywords avoided (no `text-base`, `font-sans`, + * `shadow-lg`, etc. as color names — they collide with built-ins) + * ============================================================ */ + +/* ---- Palette ------------------------------------------------------------- + * Soft cool grey paper, slate ink, and a restrained tomato CTA. The yellow + * cream was too loud; this is closer to a quiet product surface that the + * homepage waves can sit on without fighting the type. + * + * Dark is the scheme the site was designed in; light is a genuine second + * scheme rather than an inversion. Which one loads is decided by the bootstrap + * in BaseLayout: a stored choice wins, and failing that the OS decides, with + * dark as the answer when the OS expresses no preference. + */ +:root { + --cw-black: #070a11; + --cw-ink-900: #0c0f17; + --cw-ink-800: #13171f; + --cw-ink-700: #1d222d; + --cw-ink-600: #272d38; + --cw-ink-500: #3d4a57; + --cw-ink-400: #5b6b7a; + + --cw-paper: #eef1f4; + --cw-paper-deep: #e2e7ec; + + /* The spark. Homepage waves and the primary CTA. */ + --cw-orange: #e85d2c; + --cw-orange-bright: #f07846; + --cw-orange-deep: #c4471c; +} + +/* ---- Light mode ---- */ +:root { + color-scheme: light; + + /* Surfaces. `--nb-background` is the content sheet; `--cw-ground` is the + ground it rests on. */ + --nb-background: var(--cw-paper); + --nb-foreground: #1a222b; + + --nb-card: #f7f9fb; + --nb-card-foreground: var(--nb-foreground); + + --nb-muted: #e4e9ee; + /* + * Secondary text: sidebar links, page descriptions, captions, the TOC. The + * binding constraint is the darkest surface it ever sits on, which is the + * ground (`--cw-ground`) showing through the TOC rail and the masthead, not + * the sheet. At the old #5b6b7a that was 4.41:1 -- under AA -- so this is + * solved for the ground: 5.7:1 there, 6.2:1 on the sheet, still well clear + * of the 14:1 body ink so the hierarchy survives. + */ + --nb-muted-foreground: #4c5a6a; + + --nb-accent: #dce3ea; + --nb-accent-foreground: #1a222b; + + /* Tomato is the action colour. Links stay ink so the spark stays rare. + * + * The label on a tomato button is ink, not white. White on this orange is + * 3.5:1 and gets worse on hover as the fill brightens (2.8:1); ink is 4.6:1 + * and gets better (5.7:1). cloudflare.com resolves the same problem the same + * way -- its orange buttons carry near-black labels. */ + --nb-primary: var(--cw-orange); + --nb-primary-foreground: #1a222b; + --nb-primary-hover: var(--cw-orange-bright); + + /* + * Tomato as *text* rather than as a fill. The brand orange is 3.1:1 on the + * sheet, so anything set in it at body size needs to be darkened; this is + * the same hue at 5.2:1. Dark mode has no such problem and uses the orange + * itself. + */ + --cw-orange-text: #b03f18; + + --nb-border: rgba(26, 34, 43, 0.1); + --nb-border-strong: rgba(26, 34, 43, 0.2); + --nb-selected: rgba(26, 34, 43, 0.07); + --nb-ring: rgba(91, 111, 134, 0.45); + + --nb-info: #3d5a73; + --nb-info-foreground: #24384a; + --nb-info-muted: #dce6ee; + + --nb-success: #0d6d5b; + --nb-success-foreground: #054034; + --nb-success-muted: #d5f2ea; + + --nb-warning: #96580c; + --nb-warning-foreground: #573208; + --nb-warning-muted: #fbebd5; + + --nb-danger: #b32a20; + --nb-danger-foreground: #6d1610; + --nb-danger-muted: #fbdedb; + + /* Layout */ + --nb-sidebar-width: 18.75rem; + --nb-toc-width: 18rem; + --nb-content-max: 43.5rem; + /* + * The landing page's column. It has no rails to share the viewport with, and + * it carries two code samples side by side that a doc-page measure would + * crush, so it is wider than `--nb-content-max` -- and one value, shared by + * the hero and the prose under it, so both have the same left edge. + */ + --cw-landing-max: 64rem; + + /* Typography — self-hosted via @fontsource in BaseLayout.astro. + * + * DM Sans carries body, UI, and headings alike -- one neutral grotesque + * throughout instead of a + * separate display face, which reads as intentional rather than decorated. + * Per the Kumo design skill headings keep the font's natural tracking (never + * letter-spaced) and use semibold weight rather than bold. */ + --nb-font-sans: + "DM Sans Variable", "DM Sans", system-ui, -apple-system, "Segoe UI", Roboto, + sans-serif; + --nb-font-display: var(--nb-font-sans); + --nb-font-mono: + "Commit Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, + Consolas, monospace; + --nb-h1-size: 2.5rem; + --nb-h1-weight: 600; + --nb-h1-tracking: normal; + --nb-h2-size: 1.5rem; + --nb-h2-weight: 600; + --nb-h2-tracking: normal; + --nb-h3-size: 1.1875rem; + --nb-h3-weight: 600; + --nb-h3-tracking: normal; + --nb-h4-weight: 600; + + /* Depth. Hairlines do most of the work; shadows stay almost invisible. */ + --nb-shadow-sm: 0 1px 1px rgba(23, 22, 20, 0.04); + --nb-shadow: 0 1px 2px rgba(23, 22, 20, 0.05), 0 10px 24px -18px rgba(23, 22, 20, 0.18); + --nb-shadow-lg: 0 1px 2px rgba(23, 22, 20, 0.05), 0 22px 40px -24px rgba(23, 22, 20, 0.28); + + /* --- Ours, read by the page shell and by the ring field --- */ + + /* The ground the sheet rests on. Sidebar and content share `--nb-background`. */ + --cw-ground: var(--cw-paper-deep); + + --cw-wash-1: rgba(91, 111, 134, 0.08); + --cw-wash-2: rgba(232, 93, 44, 0.05); + + /* The hero sits on the page background; the warm waves flow in from the top + right so they never sit under the copy. */ + /* Light mode: the stage stays paper. The tunnel is drawn in ink rather than + light here (see NetworkHero), so it needs a pale surface to be drawn on, + not a pool to glow against -- just enough cool tint to separate the hero + from the prose below it. */ + --cw-hero-stage-bg: rgba(96, 128, 196, 0.13); + --cw-hero-glow: rgba(59, 130, 246, 0.1); + --cw-hero-title: linear-gradient(180deg, #1a222b 0%, #4c5a6a 120%); + + /* The crest of the wire pulse in HeroExample: a highlight on a lit page, a + deepening on an inked one. */ + --cw-wire-spark: #07143d; + + /* The bento diagrams in Features. Line art needs to be dark on paper and + light on ink, and its fills follow the same flip. The `-rgb` pair exists + because the art draws the same two colours at a dozen different alphas. */ + --cw-art-stroke: #253c6d; + --cw-art-stroke-rgb: 37 60 109; + --cw-art-fill-rgb: 20 44 96; +} + +/* Wider content area on large displays (e.g. MacBook Pro 16″) */ +@media (min-width: 1536px) { + :root { + --nb-content-max: 52rem; + } +} + +/* ---- Dark mode ---- + * The landing page forces this same mode via `data-mode="dark"` on <html> + * (set in BaseLayout's pre-paint script), so it shares this one code path + * rather than duplicating tokens. */ +[data-mode="dark"] { + color-scheme: dark; + + --nb-background: var(--cw-black); + --nb-foreground: #e8eef4; + + --nb-card: var(--cw-ink-800); + --nb-card-foreground: var(--nb-foreground); + + --nb-muted: var(--cw-ink-700); + /* See the light-mode note. Dark was never below AA, but the sidebar and the + captions read thin against near-black at 13px, so this lifts them from + 8.4:1 to 10.2:1 on the page and 6.8:1 to 8.2:1 on the accent surface. */ + --nb-muted-foreground: #adbccb; + + --nb-accent: var(--cw-ink-600); + --nb-accent-foreground: #f4f7fa; + + --nb-primary: var(--cw-orange); + --nb-primary-foreground: #1a222b; + --nb-primary-hover: var(--cw-orange-bright); + + /* On a dark page the brand orange clears AA as text on its own. */ + --cw-orange-text: var(--cw-orange); + + --nb-border: rgba(232, 238, 244, 0.1); + --nb-border-strong: rgba(232, 238, 244, 0.2); + --nb-selected: rgba(232, 238, 244, 0.08); + --nb-ring: rgba(154, 171, 186, 0.5); + + --nb-info: #8aa3b8; + --nb-info-foreground: #c5d4e0; + --nb-info-muted: #151c24; + + --nb-success: #12866f; + --nb-success-foreground: #6ee7c8; + --nb-success-muted: #04231d; + + --nb-warning: #b26a12; + --nb-warning-foreground: #f5c37a; + --nb-warning-muted: #2b1a04; + + --nb-danger: #c2372c; + --nb-danger-foreground: #ff9d94; + --nb-danger-muted: #2d0b08; + + --nb-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5); + --nb-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 6px 18px -8px rgba(0, 0, 0, 0.7); + --nb-shadow-lg: 0 1px 3px rgba(0, 0, 0, 0.6), 0 18px 40px -22px rgba(0, 0, 0, 0.9); + + --cw-ground: #04060b; + + --cw-wash-1: rgba(154, 171, 186, 0.06); + --cw-wash-2: rgba(232, 93, 44, 0.05); + + /* Dark mode: the page is already dark, so a slightly deeper navy pool lets + the blue pulses bloom. */ + --cw-hero-stage-bg: #070d1c; + --cw-hero-glow: rgba(59, 130, 246, 0.28); + --cw-hero-title: linear-gradient(180deg, #f4f7fa 0%, #adbccb 120%); + + --cw-wire-spark: #ffffff; + + --cw-art-stroke: #8fb0ec; + --cw-art-stroke-rgb: 143 176 236; + --cw-art-fill-rgb: 255 255 255; +} + +/* ---- Tailwind theme mapping ---- + * These generate Tailwind utility classes: + * bg-card, text-foreground, border-border, ring-ring, etc. + */ +@theme { + --color-background: var(--nb-background); + --color-foreground: var(--nb-foreground); + + --color-card: var(--nb-card); + --color-card-foreground: var(--nb-card-foreground); + + --color-muted: var(--nb-muted); + --color-muted-foreground: var(--nb-muted-foreground); + + --color-accent: var(--nb-accent); + --color-accent-foreground: var(--nb-accent-foreground); + + --color-primary: var(--nb-primary); + --color-primary-foreground: var(--nb-primary-foreground); + --color-primary-hover: var(--nb-primary-hover); + + --color-border: var(--nb-border); + --color-border-strong: var(--nb-border-strong); + --color-selected: var(--nb-selected); + --color-ring: var(--nb-ring); + + --color-info: var(--nb-info); + --color-info-foreground: var(--nb-info-foreground); + --color-info-muted: var(--nb-info-muted); + + --color-success: var(--nb-success); + --color-success-foreground: var(--nb-success-foreground); + --color-success-muted: var(--nb-success-muted); + + --color-warning: var(--nb-warning); + --color-warning-foreground: var(--nb-warning-foreground); + --color-warning-muted: var(--nb-warning-muted); + + --color-danger: var(--nb-danger); + --color-danger-foreground: var(--nb-danger-foreground); + --color-danger-muted: var(--nb-danger-muted); + + --font-sans: var(--nb-font-sans); + --font-mono: var(--nb-font-mono); + + --shadow-sm: var(--nb-shadow-sm); + --shadow-default: var(--nb-shadow); + --shadow-lg: var(--nb-shadow-lg); +} + +/* ---- Minimal overrides the plugin doesn't cover ---- */ +html { + font-family: var(--nb-font-sans); + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + /* + * Reserve the scrollbar's width whether or not the page needs one. Without + * this, navigating from a page that scrolls to one that does not widens the + * viewport by the scrollbar and every centred thing on the page jumps. + */ + scrollbar-gutter: stable; +} + +body { + opacity: 1; + /* + * The ground, not the sheet. BaseLayout deliberately leaves `bg-background` + * off the body, so the ground and its wash are visible exactly where the + * page paints nothing: the TOC rail, the margins, and, blurred, the + * masthead. + */ + background-color: var(--cw-ground); +} + +/* + * Ground wash: two soft gradients, fixed, so they do not scroll with the + * content and cannot be hit by the pointer. Kept at `z-index: -2`, below every + * element, so nothing on the page has to opt out of it. + */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; + background-image: + radial-gradient(58rem 34rem at 8% -8%, var(--cw-wash-1), transparent 68%), + radial-gradient(46rem 30rem at 104% 4%, var(--cw-wash-2), transparent 66%); +} + +/* + * The landing page paints its own dark stage edge to edge behind the hero, so + * the ground wash behind it would only muddy that canvas. The body sits at the + * page background, which is exactly where the hero's veil finishes fading, so + * the seam below the hero lands on a single value. + */ +body:has(.cw-hero-field)::before { + display: none; +} + +body:has(.cw-hero-field) { + background-color: var(--nb-background); +} + +/* + * The content column on a doc page is a sheet over the ground, so the washed + * ground shows through the TOC rail and the page margins. The sidebar keeps + * Nimbus's own `bg-background` and `border-r`; we do not add a second border, + * radius, or current-page marker on top of those. + */ +[data-cw-sheet] { + background-color: var(--nb-background); +} + +@media (min-width: 64rem) { + [data-cw-sheet] { + min-height: calc(100vh - 3.5rem); + } +} + +@media (prefers-reduced-motion: no-preference) { + html:focus-within { + scroll-behavior: smooth; + } +} + +::selection { + background: color-mix(in oklch, var(--nb-primary) 10%, transparent); + color: var(--nb-foreground); +} + +/* ---- Focus indicators ---- */ +@layer base { + :focus-visible { + outline: 2px solid var(--nb-ring); + outline-offset: 2px; + } +} + +/* ---- Reduced motion ---- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ---- Scroll lock (used by native <dialog> overlays) ---- */ +[data-scroll-locked] { + overflow: hidden; +} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--nb-foreground) 20%, transparent); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--nb-muted-foreground); +} + +/* ---- Shiki code-block styling + copy button ---- */ + +pre.astro-code { + position: relative; + margin: 1rem 0; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + border: 1px solid var(--nb-border); + background-color: var(--nb-card); + overflow-x: auto; + font-family: var(--nb-font-mono); + font-size: 0.875rem; + line-height: 1.625; +} +pre.astro-code > code { + background: transparent; +} + +/* + * Classed Shiki tokens set --shiki-light/--shiki-dark once in _nimbus/shiki.css: + * eight classes, 546 bytes, instead of an inline style on every span. Keeping + * that contract is why the themes below are adjusted here rather than swapped + * in `astro.config.ts` -- Nimbus only classes tokens while the themes are its + * own default pair, and switching to GitHub's high-contrast variants put the + * styles back inline and added 4.6 kB to every page. + * + * The adjustment: github-light and github-dark both ship tokens that miss WCAG + * AA on our surfaces -- comments at 4.25:1 in light and 3.73:1 in dark (they + * are the same grey in both themes, which is the flaw), keywords at 4.03:1 in + * light. Pushing every token a fixed step away from its own background lifts + * those without touching the hue relationships that make the theme legible as + * a theme, and it applies to tokens we have not enumerated. Light needs a + * slightly smaller step than dark because github-dark's comment grey is the + * same grey as github-light's, which on a near-black card is the worst token in + * either theme. Measured after: every token on a quickstart code block is + * >= 4.9:1 in light and >= 5.2:1 in dark, from 3.1 and 3.7. + */ +pre.astro-code span { + color: color-mix(in oklab, var(--shiki-light, currentColor) 85%, #000); +} +[data-mode="dark"] pre.astro-code span { + color: color-mix(in oklab, var(--shiki-dark, currentColor) 82%, #fff); +} + +/* Copy button injected by initCodeCopy. */ +.nb-code-copy { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + margin: 0; + padding: 0; + border: 1px solid var(--nb-border); + border-radius: 0.375rem; + background: var(--nb-card); + color: var(--nb-muted-foreground); + cursor: pointer; + opacity: 0; + transition: + opacity 0.15s ease, + color 0.15s ease, + background 0.15s ease; + z-index: 2; +} +.nb-code-figure:hover .nb-code-copy, +.nb-code-copy:focus-visible { + opacity: 1; +} +.nb-code-copy:hover { + color: var(--nb-foreground); + background: var(--nb-muted); +} +.nb-code-copy svg { + width: 0.875rem; + height: 0.875rem; +} +.nb-code-copy[data-state="copied"] { + color: var(--nb-success); +} + +/* ---- Premium code chrome ---- */ + +/* Every Shiki block is wrapped in a .nb-code-figure (titled or not). + Chrome lives on the figure so the language badge + copy button can sit + on a non-scrolling ancestor — iOS Safari slides absolutely-positioned + children of overflow:auto containers. */ +.nb-code-figure { + position: relative; + margin: 1rem 0; + border: 1px solid var(--nb-border); + border-radius: 0.5rem; + background-color: var(--nb-card); + overflow: hidden; +} + +/* + * On the dark landing the code samples read better as outlines: the page is + * nearly black already, so a filled card is a lighter rectangle floating in it. + * In light mode the opposite is true -- an unfilled block loses its edges + * against the paper, and its comments lose half a point of contrast -- so the + * landing keeps the card there, like the docs pages and like the white cards + * cloudflare.com stacks on its own light background. + */ +[data-mode="dark"] .cw-home pre.astro-code, +[data-mode="dark"] .cw-home .nb-code-figure { + background-color: transparent; +} +.nb-code-figure > pre.astro-code { + margin: 0; + border: 0; + border-radius: 0; + background-color: transparent; +} +.nb-code-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0.875rem; + border-bottom: 1px solid var(--nb-border); + background: color-mix(in oklch, var(--nb-muted) 50%, transparent); + font-family: var(--nb-font-mono); + font-size: 0.75rem; + line-height: 1.4; +} +.nb-code-title-name { + color: var(--nb-foreground); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.nb-code-title-lang { + flex-shrink: 0; + padding: 0.0625rem 0.375rem; + border-radius: 0.25rem; + background: var(--nb-muted); + color: var(--nb-muted-foreground); + font-size: 0.6875rem; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +/* Language badge — pinned to the figure (which doesn't scroll), so it + stays at top-right even when the pre overflows horizontally. Hidden + when a figcaption is already showing the language (titled blocks). */ +.nb-code-figure[data-nb-lang]:not(.nb-code-figure-titled)::before { + content: attr(data-nb-lang); + position: absolute; + top: 0.5rem; + right: 0.625rem; + padding: 0.0625rem 0.375rem; + border-radius: 0.25rem; + background: var(--nb-muted); + color: var(--nb-muted-foreground); + font-family: var(--nb-font-mono); + font-size: 0.6875rem; + letter-spacing: 0.02em; + text-transform: uppercase; + pointer-events: none; + transition: opacity 0.15s ease; + z-index: 1; +} +.nb-code-figure:hover::before { + opacity: 0; +} +/* Hide plaintext/text "language" — it's noise. */ +.nb-code-figure[data-nb-lang="text"]::before, +.nb-code-figure[data-nb-lang="plaintext"]::before, +.nb-code-figure[data-nb-lang="plain"]::before, +.nb-code-figure[data-nb-lang="txt"]::before { + display: none; +} + +/* + * The badge is pinned top-right and fades on hover, so it doesn't need a + * reserved row -- a blank line above the code reads as empty, especially now + * that the block has no background. Short first lines clear it; long ones are + * recoverable on hover. + */ +.nb-code-figure[data-nb-lang]:not(.nb-code-figure-titled) > pre.astro-code { + padding-top: 0.75rem; +} + +/* Nothing to make room for where the badge is suppressed. */ +.nb-code-figure[data-nb-lang="text"] > pre.astro-code, +.nb-code-figure[data-nb-lang="plaintext"] > pre.astro-code, +.nb-code-figure[data-nb-lang="plain"] > pre.astro-code, +.nb-code-figure[data-nb-lang="txt"] > pre.astro-code { + padding-top: 0.75rem; +} + +/* Block-level lines so tints + gutters paint across the full row. */ +pre.astro-code code { + display: block; + width: max-content; + min-width: 100%; +} +pre.astro-code .line { + display: inline-block; + width: 100%; + padding-inline: 1rem; + margin-inline: -1rem; + box-sizing: content-box; +} + +/* Highlighted lines (`{1,3-5}` meta or `// [!code highlight]`). */ +pre.astro-code .line.highlighted { + background: color-mix(in oklch, var(--nb-info) 12%, transparent); + box-shadow: inset 2px 0 0 var(--nb-info); +} + +/* Diff lines (`// [!code ++]` / `// [!code --]`). */ +pre.astro-code .line.diff { + position: relative; +} +pre.astro-code .line.diff.add { + background: color-mix(in oklch, var(--nb-success) 10%, transparent); + box-shadow: inset 2px 0 0 var(--nb-success); +} +pre.astro-code .line.diff.remove { + background: color-mix(in oklch, var(--nb-danger) 10%, transparent); + box-shadow: inset 2px 0 0 var(--nb-danger); +} +pre.astro-code .line.diff.add::before, +pre.astro-code .line.diff.remove::before { + position: absolute; + left: 0.25rem; + color: var(--nb-muted-foreground); + font-weight: 600; +} +pre.astro-code .line.diff.add::before { + content: "+"; + color: var(--nb-success); +} +pre.astro-code .line.diff.remove::before { + content: "−"; + color: var(--nb-danger); +} + +/* Focus mode — dim non-focused lines; clears on hover. */ +pre.astro-code.has-focused .line:not(.focused) { + opacity: 0.45; + filter: blur(0.25px); + transition: opacity 0.2s ease, filter 0.2s ease; +} +pre.astro-code.has-focused:hover .line:not(.focused) { + opacity: 1; + filter: none; +} + +/* Error / warning lines — use muted tokens for theme-tuned tints. */ +pre.astro-code .line.highlighted.error { + background: var(--nb-danger-muted); + box-shadow: inset 2px 0 0 var(--nb-danger); +} +pre.astro-code .line.highlighted.warning { + background: var(--nb-warning-muted); + box-shadow: inset 2px 0 0 var(--nb-warning); +} + +/* Word highlight (`// [!code word:foo]` and meta `/foo/`). */ +pre.astro-code .highlighted-word { + padding: 0.0625rem 0.25rem; + margin: -0.0625rem -0.125rem; + border-radius: 0.25rem; + background: color-mix(in oklch, var(--nb-info) 18%, transparent); + box-shadow: 0 0 0 1px color-mix(in oklch, var(--nb-info) 30%, transparent); +} + +/* ---- Mobile sidebar toggle ---- */ + +/* Hide hamburger on pages without a sidebar dialog. */ +body:not(:has([data-mobile-sidebar])) [data-menu-btn] { + display: none; +} diff --git a/packages/docs/src/styles/prose.css b/packages/docs/src/styles/prose.css new file mode 100644 index 00000000..5819a30a --- /dev/null +++ b/packages/docs/src/styles/prose.css @@ -0,0 +1,335 @@ +/* Docs markdown styling scoped to docs content containers only. */ + +.docs-content { + font-size: 0.9375rem; + line-height: 1.75; + color: var(--nb-foreground); + /* Break a long unbroken token (URL, hash, no-space string) so it wraps within + the content column instead of leaking past the page width. Inherited, so it + covers headings, paragraphs, list items, and inline code; `pre` code blocks + are unaffected (their `white-space: pre` doesn't wrap — they scroll instead, + via `pre.astro-code { overflow-x: auto }`), and wide tables keep their own + `.nb-table-scroll` handling. */ + overflow-wrap: break-word; +} + +/* Vertical rhythm — every direct child gets spacing by default. + Components can override with their own margin utilities (my-4, mt-6, etc.) + because :where() keeps specificity at (0,1,0) and utilities come later. */ +.docs-content > :where(:not(:first-child)) { + margin-top: 1.25rem; +} + +.docs-content :where(ul:not([class])) { + padding-left: 1.25rem; + list-style-type: disc; +} + +.docs-content :where(ol:not([class])) { + padding-left: 1.25rem; + list-style-type: decimal; +} + +.docs-content :where(ul:not([class]) ul:not([class])) { + list-style-type: circle; +} + +.docs-content :where(ul:not([class]) ul:not([class]) ul:not([class])) { + list-style-type: square; +} + +.docs-content :where(li:not([class])) { + margin: 0.25rem 0; +} + +.docs-content :where(blockquote:not([class])) { + margin: 1.25rem 0; + padding-left: 1rem; + border-left: 2px solid var(--nb-border); + color: var(--nb-muted-foreground); +} + +.docs-content :where(hr:not([class])) { + border: 0; + border-top: 1px solid var(--nb-border); + margin: 1.5rem 0; +} + +/* Component boundary — elements with a class attribute are component-owned. + Markdown never adds classes to h1–h6 or p; components always do. */ +.docs-content :where(h1, h2, h3, h4, h5, h6, p):where([class]) { + margin: 0; + font-size: inherit; +} + +/* Markdown heading typography is token-driven from globals.css. Everything is + set in DM Sans at semibold; the display token now resolves to the body face. */ +.docs-content :where(h1:not([class])) { + font-family: var(--nb-font-display); + font-size: var(--nb-h1-size); + font-weight: var(--nb-h1-weight); + letter-spacing: var(--nb-h1-tracking); + margin-top: 0; +} + +.docs-content :where(h2:not([class])) { + font-family: var(--nb-font-display); + font-size: var(--nb-h2-size); + font-weight: var(--nb-h2-weight); + letter-spacing: var(--nb-h2-tracking); + margin-top: 2.5rem; +} + +.docs-content :where(h3:not([class])) { + font-family: var(--nb-font-display); + font-size: var(--nb-h3-size); + font-weight: var(--nb-h3-weight); + letter-spacing: var(--nb-h3-tracking); + margin-top: 2rem; +} + +.docs-content :where(h4:not([class]), h5:not([class]), h6:not([class])) { + font-weight: var(--nb-h4-weight); +} + +.docs-content :where(h1:not([class]), h2:not([class]), h3:not([class]), h4:not([class])) { + scroll-margin-top: 5rem; +} + +/* Typographic wrapping — balance multi-line headings (no orphan words), + pretty-wrap body copy (no single-word last lines). Progressive + enhancement; unsupported browsers ignore both. */ +.docs-content :where(h1:not([class]), h2:not([class]), h3:not([class]), h4:not([class])) { + text-wrap: balance; +} + +.docs-content :where(p:not([class]), li:not([class]), blockquote:not([class])) { + text-wrap: pretty; +} + +.docs-content :where(h2[id], h3[id], h4[id]) { + position: relative; +} + +.docs-content .heading-anchor { + margin-left: 0.5rem; + color: var(--nb-muted-foreground); + text-decoration: none; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} + +.docs-content :where(h2[id], h3[id], h4[id]):hover .heading-anchor, +.docs-content .heading-anchor:focus-visible { + opacity: 1; +} + +.docs-content .heading-anchor:hover { + color: var(--nb-foreground); +} + +.docs-content :where(code:not([class])):not(:where(pre *)) { + font-family: var(--nb-font-mono); + direction: ltr; + unicode-bidi: isolate; + background: var(--nb-muted); + border: 1px solid var(--nb-border); + border-radius: 0.375rem; + padding: 0.125rem 0.375rem; + font-size: 0.8125em; + font-weight: 450; +} + +.docs-content :where(code:not([class])):not(:where(pre *))::before, +.docs-content :where(code:not([class])):not(:where(pre *))::after { + content: none; +} + +.docs-content :where(pre:not([class])) { + font-family: var(--nb-font-mono); +} + +/* Shiki-rendered code blocks — wider vertical margin than base rhythm + (1.25rem) because code blocks are visually dense. Inline code-token + styling (background, border, padding) is reset because Shiki spans + already carry their own colors via inline styles. */ +.docs-content .nb-code-figure { + margin: 1.5rem 0; +} + +.docs-content pre.astro-code code { + direction: ltr; + unicode-bidi: isolate; + background: none; + border: none; + padding: 0; + font-size: inherit; + font-weight: inherit; + border-radius: 0; +} + +/* Fills its column; scroll is owned by the `.nb-table-scroll` wrapper (injected + by the tableScroll() hast plugin), since `overflow` is ignored on a table. */ +.docs-content :where(table:not([class])) { + display: table; + width: 100%; + max-width: 100%; + text-align: left; + border-collapse: separate; + border-spacing: 0; + border-radius: 0.75rem; + border: 1px solid var(--nb-border); + table-layout: auto; +} + +/* Wide tables scroll within this box at all widths instead of overflowing the + page; short tables still fill it via the inner `width: 100%`. */ +.docs-content :where(.nb-table-scroll) { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + +.docs-content :where(th:not([class])), +.docs-content :where(td:not([class])) { + padding: 0.625rem 0.75rem; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--nb-border); +} + +.docs-content :where(th:not([class])) { + background: var(--nb-muted); + font-weight: 600; + font-size: 0.8125rem; + white-space: nowrap; +} + +.docs-content :where(tbody:not([class]) tr:not([class]):last-child td:not([class])) { + border-bottom: none; +} + +/* Round the outer corner cells to match the table's border-radius — + `border-collapse: separate` leaves square cell backgrounds (the muted + thead fill in particular) that would otherwise poke past the rounded + table border. */ +.docs-content :where(thead:not([class]) tr:not([class]):first-child th:not([class]):first-child) { + border-top-left-radius: 0.75rem; +} +.docs-content :where(thead:not([class]) tr:not([class]):first-child th:not([class]):last-child) { + border-top-right-radius: 0.75rem; +} +.docs-content :where(tbody:not([class]) tr:not([class]):last-child td:not([class]):first-child) { + border-bottom-left-radius: 0.75rem; +} +.docs-content :where(tbody:not([class]) tr:not([class]):last-child td:not([class]):last-child) { + border-bottom-right-radius: 0.75rem; +} + +.docs-content :where(a:not([class])) { + color: var(--nb-foreground); + text-decoration-line: underline; + text-decoration-color: color-mix(in oklch, var(--nb-foreground) 28%, transparent); + text-underline-offset: 3px; + text-decoration-thickness: 1px; + font-weight: 500; + transition: text-decoration-color 0.15s; +} + +.docs-content :where(a:not([class]):hover) { + text-decoration-color: var(--nb-foreground); +} + +/* ---- Long-tail prose vocabulary ---- + Elements markdown/MDX authors can reach for that would otherwise + render with raw browser defaults. Same component boundary as the rest + of the file: `:not([class])` leaves component-owned markup alone. */ + +/* Keycaps. The thicker bottom border stands in for a pressed edge — + border darkening, not a drop shadow. */ +.docs-content :where(kbd:not([class])) { + display: inline-block; + font-family: var(--nb-font-mono); + font-size: 0.75em; + font-weight: 500; + line-height: 1; + color: var(--nb-foreground); + background: var(--nb-muted); + border: 1px solid var(--nb-border); + border-bottom-width: 2px; + border-radius: 0.375rem; + padding: 0.1875rem 0.375rem; + vertical-align: 0.0625rem; +} + +/* Inline highlight — harmonized with ::selection instead of the + browser's fluorescent yellow. `box-decoration-break` keeps the tint + coherent when the highlight wraps across lines. */ +.docs-content :where(mark:not([class])) { + background: color-mix(in oklch, var(--nb-primary) 12%, transparent); + color: var(--nb-foreground); + border-radius: 0.25rem; + padding: 0.0625rem 0.25rem; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +/* Native disclosure. Scope guard: FileTree builds classless <details> + internally (`.file-tree :global(details)`), so exclude its subtree — + without this the rows below would grow double carets and borders. */ +.docs-content :where(details:not([class]):not(.file-tree *)) { + border: 1px solid var(--nb-border); + border-radius: 0.5rem; + padding: 0.625rem 0.875rem; +} + +.docs-content :where(details:not([class]):not(.file-tree *) > summary) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.625rem; + cursor: pointer; + font-weight: 500; + list-style: none; + user-select: none; +} + +.docs-content :where(details:not([class]):not(.file-tree *) > summary)::-webkit-details-marker { + display: none; +} + +/* Trailing chevron — label left, indicator right, matching Collapsible. */ +.docs-content :where(details:not([class]):not(.file-tree *) > summary)::after { + content: ""; + flex-shrink: 0; + width: 0.4375em; + height: 0.4375em; + border-right: 1.5px solid var(--nb-muted-foreground); + border-bottom: 1.5px solid var(--nb-muted-foreground); + transform: rotate(-45deg); + transition: transform 0.2s cubic-bezier(0.32, 0.72, 0, 1); +} + +.docs-content :where(details[open]:not([class]):not(.file-tree *) > summary)::after { + transform: rotate(45deg) translateY(-0.125em); +} + +.docs-content :where(details[open]:not([class]):not(.file-tree *) > summary) { + margin-bottom: 0.5rem; +} + +/* Definition lists — term/description pairs. */ +.docs-content :where(dt:not([class])) { + font-weight: 600; +} + +.docs-content :where(dt:not([class]):not(:first-child)) { + margin-top: 0.75rem; +} + +.docs-content :where(dd:not([class])) { + margin-left: 1rem; + color: var(--nb-muted-foreground); +} diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json new file mode 100644 index 00000000..8b6f8b52 --- /dev/null +++ b/packages/docs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + // No `baseUrl`: it is deprecated as of TypeScript 6 and `tsc` errors on it + // outright (ts/5101), which `nimbus-docs check --types` surfaces even though + // `astro check` does not. Since TypeScript 4.4 `paths` resolves relative to + // the file it is declared in, so the alias works without it. + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [".astro/types.d.ts", "**/*"], + // `public/playground` is generated by scripts/build-playgrounds.mjs and + // contains bundled third-party output; checking it is pure noise. + "exclude": ["dist", "public/playground"] +} diff --git a/packages/docs/wrangler.jsonc b/packages/docs/wrangler.jsonc new file mode 100644 index 00000000..45352d46 --- /dev/null +++ b/packages/docs/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "capnweb-docs", + "compatibility_date": "2026-02-05", + + // A static site: no `main`, so there is no Worker script and every request is + // served from the asset store. The playgrounds run their "server" inside the + // browser, so nothing here needs to execute server-side. + "assets": { + "directory": "dist", + + // Astro emits `about/index.html` for `/about/`, and the docs link to those + // directory paths throughout. `auto-trailing-slash` resolves them and + // redirects the non-slashed form instead of 404ing. + "html_handling": "auto-trailing-slash", + + // A missing page should render the site's own 404, not the bare + // asset-store one. + "not_found_handling": "404-page" + } +} diff --git a/protocol.md b/protocol.md deleted file mode 100644 index 3697c5ab..00000000 --- a/protocol.md +++ /dev/null @@ -1,280 +0,0 @@ -# RPC Protocol - -## Serialization - -The protocol uses JSON as its basic serialization, with a preprocessing step to support non-JSON types. - -Why not a binary format? While the author is a big fan of optimized binary protocols in other contexts, it cannot be denied that in a browser, JSON has big advantages. Being built-in to the browser gives it a leg up in performance, code size, and developer tooling. - -Non-JSON types are encoded using arrays. The first element of the array contains a string type code, and the remaining elements contain the parameters needed to construct that type. For example, a `Date` might be encoded as: - -``` -["date", 1749342170815] -``` - -To encode an array, the array must be wrapped in a second layer of array to create an array expression: - -``` -[["just", "an", "array"]] -``` - -## Client vs. Server - -The protocol does not have a "client" or a "server"; it is fully bidirectional. Either side can call interfaces exported by the other. - -With that said, for documentation purposes, we often use the words "client" and "server" when describing specific interactions, in order to make the language easier to understand. The word "client" generally refers to the caller of an RPC, or the importer of a stub. The word "server" refers to the callee, or the exporter. This is merely a convention to make explanations more natural. - -## Transport and Framing - -The protocol operates on a bidirectional stream of discrete messages. Each message is a single JSON value (typically an array). The protocol does not define how messages are framed on the wire; this is the responsibility of the transport layer. - -For transports that natively provide message framing (e.g. WebSocket or MessagePort), each transport-level message corresponds to exactly one RPC message. - -The built-in HTTP transport is newline-delimited, packing a series of messages into a single HTTP request or response body. Each message is serialized as a single line of JSON (no embedded newlines), and messages are separated by a newline character (`\n`). An empty body is interpreted as zero messages. - -Other transports are free to use other framing strategies. - -## Imports and Exports - -Each side of an RPC session maintains two tables: imports and exports. One side's exports correspond to the other side's imports. Imports and exports are assigned sequential numeric IDs. However, in some cases an ID needs to be chosen by the importing side, and in some cases by the exporting side. In order to avoid conflicts: - -* When the importing side chooses the ID, it chooses the next positive ID (starting from 1 and going up). -* When the exporting side chooses the ID, it chooses the next negative ID (starting from -1 and going down). -* ID zero is automatically assigned to the "main" interface. - -To be more specific: - -* The importing side chooses the ID when it initiates a call: the ID represents the result of the call. -* The exporting side chooses the ID when it sends a message containing a stub: the ID represents the target of the stub. - -For comparison, in CapTP and Cap'n Proto, there are four tables instead of two: imports, exports, questions, and answers. In this library, we have unified questions with imports, and answers with exports. - -By convention, when describing the meaning of any RPC message, we always take the perspective of the sender. So, if a message contains an "import ID", it is an import from the perspective of the sender, and an export from the perspective of the recipient. - -Note that IDs are never reused. This differs from Cap'n Proto, which always tries to choose the smallest available ID. We assume no session will ever exceed 2^53 IDs, so simply assigning sequentially should be fine. - -## Push and pull - -An RPC call follows this sequence: - -* The client sends the server a "push" message, containing an expression to evaluate. - * The "push" message is implicitly assigned the next positive ID in the client's import table. - * The expression expresses the call to make. - * Upon receipt, the server evaluates the expression and delivers the call to the application. -* The client subsequently sends the server a "pull" message, specifying the import ID just created by the "push". This expresses that the client is interested in receiving the result of the call as a "resolve" message. -* The client may subsequently refer to the import ID in pipelined requests. -* When the server is done executing the call, it sends a "resolve" message, specifying the export ID of the "push" and an expression for its result. -* Upon receiving the resolution, the client no longer needs the import table entry, so sends a "release" message. - * Upon receipt, the server disposes its copy of the return value, if necessary. - -Some notes: - -* The client does not need to send a "pull" message if it doesn't care to receive the results. In practice, if the application never awaits the promise, then it is never pulled. The promise can still be used in pipelining without pulling. -* Technically, the pushed expression can contain any number of calls, including none. A client could, for example, push a large data structure containing no calls, and then subsequently make multiple calls that use this data structure via "pipelining", to avoid having to send the same data multiple times. -* If the call throws an exception, the server will send a "reject" message instead of "resolve". -* "resolve" and "reject" are the same messages used to resolve exported promises, that is, a promise that was introduced when it was sent as part of some other RPC message. Thus, calls and exported promises work the same. This differs from Cap'n Proto, where returning from a call and resolving an exported promise were entirely different messages (with a lot of duplicated semantics). - -## Top-level RPC Messages - -The following are the top-level messages that can be sent over the RPC transport. - -`["push", expression]` - -Asks the recipient to evaluate the given expression. The expression is implicitly assigned the next sequential import ID (in the positive direction). The recipient will evaluate the expression, delivering any calls therein to the application. The final result can be pulled, or used in promise pipelining. - -`["pull", importId]` - -Signals that the sender would like to receive a "resolve" message for the resolution of the given import, which must refer to a promise. This is normally only used for imports created by a "push", as exported promises are pulled automatically. - -`["resolve", exportId, expression]` - -Instructs the recipient to evaluate the given expression and then use it as the resolution of the given promise export. - -`["reject", exportId, expression]` - -Instructs the recipient to evaluate the given expression and then use it to reject the given promise export. The expression is not permitted to contain stubs. It typically evaluates to an `Error`, although technically JavaScript does not require that thrown values are `Error`s. - -`["release", importId, refcount]` - -Instructs the recipient to release the given entry in the import table, disposing whatever it is connected to. If the import is a promise, the recipient is no longer obliged to send a "resolve" message for it, though it is still permitted to do so. - -`refcount` is the total number of times this import ID has been "introduced", i.e. the number of times it has been the subject of an "export" or "promise" expression, plus 1 if it was created by a "push". The refcount must be sent to avoid a race condition if the receiving side has recently exported the same ID again. The exporter remembers how many times they have exported this ID, decrementing it by the refcount of any release messages received, and only actually releases the ID when this count reaches zero. - -`["stream", expression]` - -Like `["push", expression]`, asks the recipient to evaluate the given expression. The expression is implicitly assigned the next sequential import ID (in the positive direction). However, unlike "push": - -* Promise pipelining on the result is not supported. The caller must not refer to the import ID in subsequent expressions. -* The expression is automatically considered "pulled". The sender does not need to send a separate "pull" message. -* Once the recipient sends a "resolve" or "reject" message for the expression's result, the export is implicitly released (with a refcount of 1). The sender does not need to send a separate "release" message. - -This message type is designed for streaming writes, where the result is expected to be empty, and the overhead of separate "pull" and "release" messages is high. - -`["pipe"]` - -Creates a "pipe" on the remote end. A pipe consists of a `ReadableStream` end and a `WritableStream` end. The pipe is implicitly assigned the next sequential import ID (in the positive direction), similar to `["push", expression]`. - -The new import is not a promise. It is immediately usable as if it were a `WritableStream` — the sender can call `write`, `close`, and/or `abort` on it, using the same interface as described for the `["writable", exportId]` expression. - -The readable end of the pipe can be referenced in a subsequent message using the `["readable", importId]` expression. This expression can only be used once per pipe. - -The purpose of the pipe mechanism is to support sending `ReadableStream` over RPC. When a message contains a `ReadableStream`, the sender first sends a `["pipe"]` message to establish the writable end, then begins pumping the stream's data through it (by calling `write`, `close`, `abort`), and includes the readable end in the subsequent message via `["readable", importId]`. This allows data to start flowing immediately without waiting for a network round trip. - -`["abort", expression]` - -Indicates that the sender has experienced an error causing it to terminate the session. The expression evaluates to the error which caused the abort. No further messages will be sent nor received. - -## Expressions - -Expressions are JSON-serializable object trees. All JSON types except arrays are interpreted literally. Arrays are further evaluated into a final value as follows. - -`[[...]]` - -An array expression. The inner array contains expressions (one for each array element), which are individually evaluated to produce the final array value. - -For example, this expression represents an object containing an array: - -``` -{ - "key": [[ - "abc", - ["date", 1757214689123], - [[0]] - ]] -} -``` - -This is an expression which will evaluate to an object. The expression representing the value of the "key" field is an array expression. -- The 1st item in the array expression is an expression for the string "abc" -- The 2nd item is an expression for a date object -- The 3rd item is another array expression containing an integer expression representing zero. - -This expression will evaluate to the following object: -``` -{ - "key": [ - "abc", - Date(1757214689123), - [0] - ] -} -``` - -`["undefined"]` - -The literal value `undefined`. - -`["inf"]`, `["-inf"]`, `["nan"]` - -The values Infinity, -Infinity, and NaN. - -`["bytes", base64]`, `["bytes", base64, type]` - -A byte container, represented as a base64-encoded string. If `type` is omitted, the receiver -should deserialize bytes as its default `Uint8Array` for backwards compatibility. Otherwise, -`type` preserves the byte container type across the wire. The supported `type` values are -`ArrayBuffer`, `DataView`, `Int8Array`, `Uint8Array`, `Uint8ClampedArray`, -`Int16Array`, `Uint16Array`, `Int32Array`, `Uint32Array`, `BigInt64Array`, `BigUint64Array`, -`Float32Array`, and `Float64Array`. Multi-byte typed array elements are encoded in little-endian -byte order. - -`["blob", type, readableExpression]` - -A `Blob` value. `type` is the MIME type string (`blob.type`), which may be an empty string. `readableExpression` is an expression that evaluates to a `ReadableStream` carrying the blob's bytes; in practice, the encoder always uses a `["readable", importId]` expression backed by a pipe. Because reading a `Blob`'s bytes is inherently asynchronous, the pipe path is always used — there is no inline fast path even for small blobs. The receiver must collect all chunks from the stream before delivering the value to application code. - -`["bigint", decimal]` - -A bigint value, represented as a decimal string. Receivers cap the maximum length of this string to -bound parsing cost. - -`["date", number]` - -A JavaScript `Date` value. The number represents milliseconds since the Unix epoch. - -`["error", type, message, stack?, props?]` - -A JavaScript `Error` value. `type` is the name of the specific well-known `Error` subclass, e.g. "TypeError". `message` is a string containing the error message. `stack` may optionally contain the stack trace, though by default stacks will be redacted for security reasons. - -`props` is an optional fifth element carrying any extra data attached to the error. It is a JSON object whose keys are the error's own enumerable properties (plus the standard non-enumerable `cause` slot, and `errors` for `AggregateError`), and whose values are themselves valid expressions of this protocol round-trip naturally. Property values that cannot be represented are silently dropped from `props`; the error itself always reaches the receiver. - -When `props` is present, `stack` is normalised to `null` if absent so that positional indexing for `props` is unambiguous. When there are no extras, the legacy 3- or 4-element form is emitted unchanged. - -`["url", href]` - -A `URL` object. `href` is the fully-serialized (and normalized) URL string, i.e. the value of the URL's `href` property. The receiver reconstructs the `URL` via `new URL(href)`. For example: `["url", "https://example.com/path?q=1"]`. - -`["headers", pairs]` - -A `Headers` object from the Fetch API. `pairs` is an array of `[name, value]` pairs, where both `name` and `value` are strings. For example: `["headers", [["content-type", "text/plain"], ["x-custom", "hello"]]]`. - -`["request", url, init]` - -A `Request` object from the Fetch API. `url` and `init` are the parameters to pass to `Request`'s constructor to create the desired `Request` instance. The sender should omit properties from `init` when their value would be the default value anyway. `init.headers`, if present, must contain an array of pairs, suitable to pass to the constructor of `Headers`. `init.body`, if present, is an expression for the response body, which must evaluate to `null`, a string, `Uint8Array`, or `ReadableStream`. Other properties of `init` must be plain values; they will not be evaluated as expressions before passing to the `Request` constructor. - -At this time, `init.signal` is not supported and must not be sent, though that will change when `AbortSignal` gains support for serialization. - -`["response", body, init]` - -A `Response` object from the Fetch API. `body` and `init` are the parameters to pass to `Response`'s constructor to create the desired `Response` instance. `body` is an expression which must evaluate to `null`, a string, `UInt8Array`, or `ReadableStream`. `init.headers`, if present, must contain an array of pairs, suitable to pass to the constructor of `Headers`. Other properties of `init` must be plain values; they will not be evaluated as expressions before passing to the `Response` constructor. - -At this time, `init.webSocket` (a Cloudflare Workers extension) is not supported and must not be sent, though that may change if `WebSocket` gains support for serialization. - -`["import", importId, propertyPath, callArguments]` -`["pipeline", importId, propertyPath, callArguments]` - -References an entry on the import table (from the perspective of the sender), possibly performing actions on it. - -If the type is "import", the expression evaluates to a stub. If it is "pipeline", the expression evaluates to a promise. The difference is important because promises must be replaced with their resolution before delivering the message to the application, whereas stubs will be delivered as stubs without waiting for any resolution. - -`propertyPath` is optional. If specified, it is an array of property names (strings or numbers) leading to a specific property of the import's target. The expression evaluates to that property (unless `callArguments` is also specified). - -`callArguments` is also optional. If specified, then the given property should be called as a function. `callArguments` is an array of expressions; these expressions are evaluated to produce the arguments to the call. - -`["remap", importId, propertyPath, captures, instructions]` - -Implements the `.map()` operation. (We call this "remap" so as not to confuse with the serialization of a `Map` object.) - -`importId` and `propertyPath` are the same as for the `"import"` operation. These identify the particular property which is to be mapped. - -`captures` and `instructions` define the mapper function which is to apply to the target value. - -`captures` defines the set of stubs which the mapper function has captured, in the sense of a lambda capture. The body of the function may call these stubs. The format of `captures` is an array, where each member of the array is either `["import", importId]` or `["export", exportId]`, which refer to an entry on the (sender's) import or export table, respectively. - -`instructions` contains a list of expressions which should be evaluated to execute the mapper function on a particular input value. Each instruction is an expression in the same format described in this doc, but with special handling of imports and exports. For the purpose of the instructions in a mapper, there is no export table. The import table, meanwhile, is defined as follows: -* Negative values refer to the `captures` list, starting from -1. So, -1 is `captures[0]`, -2 is `captures[1]`, and so on. -* Zero refers to the input value of the map function. -* Positive values refer to the results of previous instructions, starting from 1. So, 1 is the result of evaluating `instructions[0]`, 2 is the result of evaluating `instructions[1]`, and so on. - -The instructions are always evaluated in order. Each instruction may only import results of instructions that came before it. The last instruction evaluates to the return value of the map function. - -`["export", exportId]` - -The sender is exporting a new stub (or re-exporting a stub that was exported before). The expression evaluates to a stub. - -`["promise", exportId]` - -Like "export", but the expression evaluates to a promise. Promises must be replaced with their resolution before the message is finally delivered to the application. - -The `exportId` in this case is always a newly-allocated ID. The sender will proactively send a "resolve" (or "reject") message for this ID when the promise resolves (unless it is released first). The recipient does not need to "pull" the promise explicitly; it is assumed that the recipient always wants the resolution. - -`["writable", exportId]` - -Represents a `WritableStream`. The sender has called `getWriter()` on the stream, locking it, and holds the writer to handle incoming operations. The `exportId` refers to an entry on the export table that accepts the following method calls: - -- `write(chunk)` - Write a chunk to the stream. The chunk can be any RPC-compatible value. -- `close()` - Close the stream normally, indicating all data has been written. -- `abort(reason?)` - Abort the stream with an optional reason. - -These methods correspond to the methods of `WritableStreamDefaultWriter`. - -If the export is released without `close()` having been called, the sender will abort the stream, indicating an abnormal termination (e.g., network disconnect). - -The receiver does not need to wait for each `write()` call to complete before sending the next one, nor before sending `close()`. The sender will process writes in order. The receiver should wait for `close()` to complete to verify that all writes were successful; if any write failed, `close()` will also fail with that error. - -`["readable", importId]` - -References the readable end of a pipe previously created by a `["pipe"]` message. `importId` must refer to an import table entry that was created as a pipe. The expression evaluates to a `ReadableStream`. - -This expression can only be used once per pipe. Once the readable end has been retrieved, it is removed from the pipe entry. - -See the description of `["pipe"]` in the top-level messages section for an explanation of how pipes and readable streams work together. diff --git a/scripts/align-markdown-tables.mjs b/scripts/align-markdown-tables.mjs new file mode 100644 index 00000000..7c8e4a31 --- /dev/null +++ b/scripts/align-markdown-tables.mjs @@ -0,0 +1,214 @@ +/** + * Pads every Markdown table in the repository so its pipes line up. + * + * markdownlint's MD060 can tell you a table is ragged but cannot repair it, and + * its own `--fix` leaves the delimiter row at its original width while + * shrinking the data rows, which is worse than either extreme. This does the + * padding properly: every cell in a column is widened to the column's widest + * cell, and the delimiter row is rebuilt to the same width with its alignment + * colons preserved. + * + * Usage: + * node scripts/align-markdown-tables.mjs # rewrite in place + * node scripts/align-markdown-tables.mjs --check # exit 1 if any file would change + * + * Only tables in prose are touched. Anything inside a fenced code block is left + * exactly as written, because a table in a code block is a code sample. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { argv, exit } from 'node:process'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const run = promisify(execFile); + +const CHECK_ONLY = argv.includes('--check'); + +/** + * Split a table row into cells on unescaped pipes that are not inside an inline + * code span. Cap'n Web's tables contain both `\|` and things like `` `a|b` ``, + * and splitting naively on `|` corrupts them. + */ +function splitCells(line) { + const cells = []; + let cell = ''; + let inCode = false; + let tickRun = 0; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + + if (ch === '\\' && i + 1 < line.length) { + cell += ch + line[i + 1]; + i++; + continue; + } + + if (ch === '`') { + // Count the run so ``a`b`` style spans open and close symmetrically. + let run = 0; + while (line[i + run] === '`') run++; + if (!inCode) { + inCode = true; + tickRun = run; + } else if (run === tickRun) { + inCode = false; + tickRun = 0; + } + cell += '`'.repeat(run); + i += run - 1; + continue; + } + + if (ch === '|' && !inCode) { + cells.push(cell); + cell = ''; + continue; + } + + cell += ch; + } + cells.push(cell); + + // A row written `| a | b |` yields empty strings at both ends. Drop them, + // since they are the outer pipes rather than real cells. + if (cells.length > 1 && cells[0].trim() === '') cells.shift(); + if (cells.length > 1 && cells.at(-1).trim() === '') cells.pop(); + + return cells.map((c) => c.trim()); +} + +/** A delimiter row: every cell is dashes with optional leading/trailing colon. */ +function isDelimiterRow(line) { + const cells = splitCells(line); + return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c)); +} + +function alignmentOf(cell) { + const left = cell.startsWith(':'); + const right = cell.endsWith(':'); + if (left && right) return 'center'; + if (right) return 'right'; + if (left) return 'left'; + return 'none'; +} + +function buildDelimiter(width, alignment) { + switch (alignment) { + case 'center': + return ':' + '-'.repeat(Math.max(1, width - 2)) + ':'; + case 'right': + return '-'.repeat(Math.max(1, width - 1)) + ':'; + case 'left': + return ':' + '-'.repeat(Math.max(1, width - 1)); + default: + return '-'.repeat(Math.max(3, width)); + } +} + +/** Rewrite one table, given its header row, delimiter row and body rows. */ +function formatTable(rows, delimiterIndex) { + const cellRows = rows.map(splitCells); + const columns = Math.max(...cellRows.map((r) => r.length)); + + const alignments = cellRows[delimiterIndex].map(alignmentOf); + while (alignments.length < columns) alignments.push('none'); + + const widths = []; + for (let c = 0; c < columns; c++) { + let width = 3; // a delimiter needs `---` at minimum + for (const [index, cells] of cellRows.entries()) { + if (index === delimiterIndex) continue; + width = Math.max(width, (cells[c] ?? '').length); + } + // Centre and right alignment spend two and one character on colons. + if (alignments[c] === 'center') width = Math.max(width, 5); + widths.push(width); + } + + return cellRows.map((cells, index) => { + const out = []; + for (let c = 0; c < columns; c++) { + if (index === delimiterIndex) { + out.push(buildDelimiter(widths[c], alignments[c])); + continue; + } + const text = cells[c] ?? ''; + const pad = ' '.repeat(widths[c] - text.length); + out.push(alignments[c] === 'right' ? pad + text : text + pad); + } + return `| ${out.join(' | ')} |`; + }); +} + +function alignTables(source) { + const lines = source.split('\n'); + const out = []; + let fence = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Track fenced code blocks and copy them through untouched. + const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + if (fence && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) { + fence = null; + } else if (!fence) { + fence = fenceMatch[1]; + } + out.push(line); + continue; + } + if (fence) { + out.push(line); + continue; + } + + // A table is a run of lines starting with `|`, with a delimiter row second. + if (line.trimStart().startsWith('|') && isDelimiterRow(lines[i + 1] ?? '')) { + const rows = []; + let j = i; + while (j < lines.length && lines[j].trimStart().startsWith('|')) { + rows.push(lines[j]); + j++; + } + out.push(...formatTable(rows, 1)); + i = j - 1; + continue; + } + + out.push(line); + } + + return out.join('\n'); +} + +const { stdout } = await run('git', ['ls-files', '*.md']); +const files = stdout + .split('\n') + .filter(Boolean) + .filter((f) => !/(^|\/)CHANGELOG\.md$/.test(f) && !f.startsWith('.changeset/')); + +const changed = []; +for (const file of files) { + const before = await readFile(file, 'utf8'); + const after = alignTables(before); + if (before === after) continue; + changed.push(file); + if (!CHECK_ONLY) await writeFile(file, after); +} + +if (CHECK_ONLY && changed.length > 0) { + console.error('Tables are not aligned in:'); + for (const file of changed) console.error(` ${file}`); + console.error('\nRun: node scripts/align-markdown-tables.mjs'); + exit(1); +} + +console.log( + changed.length === 0 + ? `${files.length} files checked, all tables already aligned` + : `${changed.length} of ${files.length} files realigned` +); diff --git a/scripts/markdownlint-no-code-after-heading.mjs b/scripts/markdownlint-no-code-after-heading.mjs new file mode 100644 index 00000000..819a51be --- /dev/null +++ b/scripts/markdownlint-no-code-after-heading.mjs @@ -0,0 +1,67 @@ +/** + * A custom markdownlint rule: a heading must not be followed immediately by a + * fenced or indented code block. + * + * Two reasons, one visual and one about the reader. Visually, headings on the + * documentation site carry a bottom rule, and a code frame butted against that + * rule produces two horizontal lines a few pixels apart with nothing between + * them. For the reader, a heading names a topic; it does not say what the + * sample below it demonstrates, what to look at in it, or why it is there. + * Arriving at a wall of code with no orientation is a small unkindness that + * repeats on every page. + * + * The fix is usually already written. When a code block opens a section, the + * paragraph that explains it is very often sitting directly underneath, and + * moving it above the block is the whole edit. Where no such paragraph exists, + * a sentence naming what the sample shows has to be written. + * + * Configurable via `levels`, an array of heading depths to enforce. The default + * is `[2]`: section headings, which are the ones with the rule under them. + */ + +/** Heading depth from the run of `#` characters micromark recorded. */ +function atxLevel(headingToken) { + const sequence = headingToken.children?.find((child) => child.type === 'atxHeadingSequence'); + if (!sequence) return 0; + return sequence.endColumn - sequence.startColumn; +} + +/** Tokens that carry no content and so do not separate a heading from a block. */ +const INSIGNIFICANT = new Set(['lineEnding', 'lineEndingBlank', 'linePrefix', 'lineSuffix']); + +const CODE_BLOCK = new Set(['codeFenced', 'codeIndented']); +const HEADING = new Set(['atxHeading', 'setextHeading']); + +export default { + names: ['CW001', 'no-code-block-after-heading'], + description: 'Heading should be followed by prose, not immediately by a code block', + tags: ['headings', 'code'], + parser: 'micromark', + function: (params, onError) => { + const levels = new Set(params.config.levels ?? [2]); + + // Only top-level tokens matter. A code block nested inside a list item or + // a blockquote is not what this rule is about. + const tokens = params.parsers.micromark.tokens.filter( + (token) => !INSIGNIFICANT.has(token.type) + ); + + for (const [index, token] of tokens.entries()) { + if (!CODE_BLOCK.has(token.type)) continue; + + const previous = tokens[index - 1]; + if (!previous || !HEADING.has(previous.type)) continue; + + // A setext heading has no `#` run to measure, so it is always in scope. + if (previous.type === 'atxHeading' && !levels.has(atxLevel(previous))) continue; + + onError({ + lineNumber: token.startLine, + detail: + 'Introduce the sample first. The paragraph that explains it is often the ' + + 'one directly below the block, and moving it up is the whole fix.', + context: params.lines[previous.startLine - 1]?.trim(), + }); + } + }, +};