From d1fd729b7aa7a3f0d15a6c5c2b1b0fdfc9e9a4f3 Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 09:55:14 -0500 Subject: [PATCH 1/8] docs: RC.1 freeze notice, API stability page and a rewritten What's new - Add the API stability page, which mirrors the policy in core's README (D18). - Link it from the banner, the home page and Getting started. - Rewrite What's new for RC.1: each breaking change comes with its migration. - Name RC.1 where pages said 'the next release'. - Credit the RC.1 contributor. - Add stability and caching to the sidebar. Signed-off-by: Diego Resendez --- astro.config.mjs | 2 + src/content.config.ts | 2 +- src/content/docs/contributors.md | 27 ++-- src/content/docs/getting-started.md | 16 +- src/content/docs/index.mdx | 6 +- src/content/docs/stability.md | 72 +++++++++ src/content/docs/whats-new.md | 219 +++++++++++++++++----------- 7 files changed, 244 insertions(+), 100 deletions(-) create mode 100644 src/content/docs/stability.md diff --git a/astro.config.mjs b/astro.config.mjs index 723f900..05db7cc 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -48,6 +48,7 @@ export default defineConfig({ // names it, and a second copy of the number in the sidebar can only drift out of step — // which it did, sitting at beta.1 through the beta.2 deploy. { label: "What's new", slug: 'whats-new' }, + { label: 'API stability', slug: 'stability' }, ], }, { @@ -69,6 +70,7 @@ export default defineConfig({ { label: 'Streaming & real-time', slug: 'guides/streaming' }, { label: 'File uploads', slug: 'guides/uploads' }, { label: 'HTML & views', slug: 'guides/html' }, + { label: 'Caching & compression', slug: 'guides/caching-and-compression' }, { label: 'Transport security', slug: 'guides/security' }, { label: 'Plugins', slug: 'guides/plugins' }, { label: 'Circuit breakers', slug: 'guides/circuit-breaker' }, diff --git a/src/content.config.ts b/src/content.config.ts index 1341ea9..fdfc545 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -16,7 +16,7 @@ import { documentsCoreVersion } from '../astro.config.mjs'; * other way to answer. */ const versionBanner = { - content: `These docs describe @green-tea/core ${documentsCoreVersion} — what changed. Install with npm i @green-tea/core@beta.`, + content: `These docs describe @green-tea/core ${documentsCoreVersion} — what changed. The public API is frozen from RC.1. Install with npm i @green-tea/core@beta, where release candidates publish too.`, }; export const collections = { diff --git a/src/content/docs/contributors.md b/src/content/docs/contributors.md index db269ff..67649b5 100644 --- a/src/content/docs/contributors.md +++ b/src/content/docs/contributors.md @@ -7,6 +7,20 @@ green-tea is one person's spare-time project that other people have started ship names them and what they built, because "thanks to our contributors" is worth nothing next to a sentence someone can point at. +## In RC.1 + +**[@otrumb](https://github.com/otrumb)** — [#32](https://github.com/Expressive-Tea/green-tea/pull/32), +closing [#31](https://github.com/Expressive-Tea/green-tea/issues/31): a `@Module` without a +`mountpoint` now names itself at boot. The type requires the field, but JavaScript and +transpile-only code can still leave it out, and `createApp` used to fail with +`Cannot read properties of undefined (reading 'replace')` from inside the path helper — an error +that points at the framework rather than at the module that was missing something. It now fails +with ` @Module metadata is missing mountpoint`, and `mountpoint: ''` still mounts at the +root. + +It came with a test written to fail first, and with the checks that did not pass locally listed in +the pull request rather than left for review to find. + ## In 26.9.0-beta.1 **[@hgshreyas](https://github.com/hgshreyas)** — [#23](https://github.com/Expressive-Tea/green-tea/pull/23), @@ -19,7 +33,10 @@ and sheds with `503` + `Retry-After` rather than queueing. The interesting part is what it deliberately does *not* count: a returned stream releases its slot when the handler returns, not when the connection closes, so an SSE route does not sit on a budget slot for hours. A follow-up in the same branch made a dropped connection on Node log a warning -instead of vanishing silently. +instead of vanishing silently. That warning closed +[#17](https://github.com/Expressive-Tea/green-tea/issues/17), which reported that a hit connection +cap was silent: an operator can now see the drop, though the client still gets a closed socket +rather than a response. It arrived with [its own documentation pull request](https://github.com/Expressive-Tea/green-tea-docs/pull/8), unprompted, which is the part worth naming. @@ -42,14 +59,6 @@ Everything this release added on top — an app-wide `shutdownTimeoutMs`, the bo Deno and Bun adapters, and the teardown budget — is built on the deadline that pull request introduced. -## Also in flight - -[@hgshreyas](https://github.com/hgshreyas) is working on -[#17](https://github.com/Expressive-Tea/green-tea/issues/17) — making a hit connection cap answer the -client instead of dropping the socket silently. The warning that shipped above is a step toward it, -not the whole thing: an operator can now see the drop, but the client still gets a closed socket -rather than a response. - ## Contributing Issues are assigned when someone claims them, so an unassigned open issue is genuinely free. The diff --git a/src/content/docs/getting-started.md b/src/content/docs/getting-started.md index e64952f..8da856c 100644 --- a/src/content/docs/getting-started.md +++ b/src/content/docs/getting-started.md @@ -15,20 +15,26 @@ cd my-api && matcha run ## Or install by hand ```bash -npm install @green-tea/core reflect-metadata +npm install @green-tea/core@beta reflect-metadata # optional, only if you use them: npm install ws # WebSocket routes (@Ws) and mesh npm install busboy # multipart/form-data file uploads ``` +Until the first stable release, every version publishes under the npm `beta` dist-tag, release +candidates included. Install with `@beta` explicitly: the day a stable ships, `latest` moves to it +and your installs stay on the channel you meant. + green-tea runs on **Node ≥ 22, Deno, Bun, and the edge** — the same app, you only swap the entry point. This guide uses Node; see [runtimes](/docs/guides/runtimes/) for the others and for what the edge can't offer. -:::caution[Breaking after 26.9.0-beta.2] -Up to 26.9.0-beta.2, `@green-tea/core` ran on Node 18 and 20. Both are end-of-life, so from the -next release Node 22 is the minimum. If you deploy on Node 18 or 20, upgrade Node before you upgrade -green-tea. +:::caution[Breaking in RC.1] +Up to 26.9.0-beta.2, `@green-tea/core` ran on Node 18 and 20. Both are end-of-life, so from RC.1 +Node 22 is the minimum. If you deploy on Node 18 or 20, upgrade Node before you upgrade green-tea. ::: +From RC.1 the public API is frozen: releases up to the first stable one are fixes only. See +[API stability](/docs/stability/) for what that covers and how deprecations work after stable. + green-tea uses **legacy** TypeScript decorators. Enable them in your `tsconfig.json`: ```json diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx index 0324fed..4dfc59a 100644 --- a/src/content/docs/index.mdx +++ b/src/content/docs/index.mdx @@ -40,10 +40,10 @@ green-tea moves that bookkeeping into the framework. You declare what each step Declare `@Sse`, `@Stream`, or `@Ws` and return an `AsyncIterable` — the transport streams it (SSE, ndjson, or a WebSocket duplex) with backpressure and cleanup handled. No separate gateway. - Node, Deno, Bun, and the edge — the same app, you only swap the entry point. HTTP, SSE, and WebSocket behave identically on all four. + Node, Deno, Bun, and the edge — the same app, you only swap the entry point. HTTP, SSE, and WebSocket share one API on all four; [Runtimes](/docs/guides/runtimes/) lists where a platform differs. -:::note[Beta] -green-tea is **beta**, on the RC track. One runtime dependency (`reflect-metadata`); `ws` and `busboy` are optional peers. [mesh](/docs/guides/mesh/) is alpha and gated behind an explicit opt-in. +:::note[RC.1: the API is frozen] +From RC.1 the public API of all four entry points is frozen, and releases up to stable are fixes only — see [API stability](/docs/stability/). One runtime dependency (`reflect-metadata`); `ws` and `busboy` are optional peers. [mesh](/docs/guides/mesh/) is alpha, gated behind an explicit opt-in, and outside the freeze. ::: diff --git a/src/content/docs/stability.md b/src/content/docs/stability.md new file mode 100644 index 0000000..aa43337 --- /dev/null +++ b/src/content/docs/stability.md @@ -0,0 +1,72 @@ +--- +title: API stability +description: "What the RC.1 freeze covers, what may change between RC.1 and stable, and how deprecations work after stable." +--- + +**From the first release candidate (RC.1), the public API of `@green-tea/core` is frozen.** The +public API is everything exported from the four entry points: + +| Entry point | Import | +|---|---| +| root | `@green-tea/core` | +| Deno | `@green-tea/core/deno` | +| Bun | `@green-tea/core/bun` | +| edge | `@green-tea/core/edge` | + +Anything you can import from one of those is covered. Anything you cannot import is internal, +whatever its file is called. + +The freeze is there so that code written against RC.1 still compiles and behaves the same against +the first stable release. Nothing published in `26.9.0-beta.2` was removed to reach it: the one +rename, `Headers` → `RequestHeaders`, kept the old name as a deprecated alias, and the mesh exports +stayed exported and were marked `@experimental`. Moving from beta.2 to RC.1 still has breaking +changes, all of them behaviour fixed before the freeze; [What's new](/docs/whats-new/) lists each +one with its migration. + +## RC.1 to stable: fixes only + +No release between RC.1 and stable adds, removes, renames or narrows a public API. It does not +deprecate anything either. + +If a fix turns out to need an API change, the freeze is reopened for it on purpose rather than +bent quietly, and the CHANGELOG lists the change under `### Breaking`. So the CHANGELOG is the one +place to check: a release candidate with no `### Breaking` section changed no public API. + +## After stable: deprecations come before removals + +Once a stable release has shipped, nothing public disappears without notice: + +- A deprecated API keeps working for **at least two monthly releases**. +- It carries a JSDoc `@deprecated` tag that names its replacement, so your editor shows it where + you use it. +- Where it has a runtime path, it **warns once per process** through `app.logger` — never + `console`, so the warning goes wherever you sent the rest of the framework's diagnostics. +- It is removed only in a release whose CHANGELOG lists the removal under `### Breaking`. + +A type has no runtime path, so a deprecated type warns only in your editor. + +## Every API change shows up in review + +The core repository commits an API report for each entry point under +[`api/`](https://github.com/Expressive-Tea/green-tea/tree/main/api) (`api/*.api.md`), and CI +fails when the build no longer matches it. A deliberate change regenerates the report in the same +pull request, so a reviewer reads the API change as a diff instead of finding it after the release. + +## Mesh is outside the promise + +[Mesh](/docs/guides/mesh/) is `@experimental` and gated behind `createApp({ experimental: true })`, +so it may change in any release, including between release candidates. Its exports — `MeshConfig`, +`connectLink`, the manifest and frame types, and the rest — are still in the API reports, so a mesh +change still shows up in review. The [createApp reference](/docs/reference/createapp/#importable-types) +marks which exported types are mesh. + +## Currently deprecated + +| Deprecated | Use instead | Since | Notes | +|---|---|---|---| +| `Headers` (type) | `RequestHeaders` | RC.1 | the same type, under a name that does not shadow the Fetch `Headers` global in the files that import it. `Headers` stays as an alias, so nothing breaks | + +## What this page does not cover + +The [matcha](/docs/guides/cli/) CLI ships from its own repository on its own version line, so +nothing here speaks for it. diff --git a/src/content/docs/whats-new.md b/src/content/docs/whats-new.md index 8609d9a..1e03a8d 100644 --- a/src/content/docs/whats-new.md +++ b/src/content/docs/whats-new.md @@ -1,116 +1,171 @@ --- title: What's new -description: "What changed in @green-tea/core 26.9.0-beta.2, and what it means if you are already using it." +description: "What changed in @green-tea/core RC.1, and what it means if you are already using it." --- -These pages document **26.9.0-beta.2**. This is what changed since `26.9.0-beta.1`, in the order it -matters to you rather than the order it was built. The [full changelog](https://github.com/Expressive-Tea/green-tea/blob/main/CHANGELOG.md) -has every release; this page is only the current one. +:::note[The API is frozen from RC.1] +RC.1 is the first release candidate. From here to the first stable release, releases are fixes +only: nothing public is added, removed, renamed or narrowed. [API stability](/docs/stability/) has +the full policy, including how deprecations work after stable. +::: -Two of these are breaking, and both are one line to migrate. If you serve on Deno or Bun, read the -first section. If you ship a plugin, read the second. Everything else is additive. +This is what changed in **RC.1** since `26.9.0-beta.2`, in the order it matters to you rather than +the order it was built. The [full changelog](https://github.com/Expressive-Tea/green-tea/blob/main/CHANGELOG.md) +has every release and every detail; this page is only the current one. -## `serveDeno()` and `serveBun()` boot before they bind +The breaking changes come first, because they are what can stop an upgrade. Each one is behaviour +that had to move before the freeze, and each has a migration. Nothing published in beta.2 was +removed: the one renamed type keeps its old name as a deprecated alias. -They are async now, returning `Promise` and `Promise`. The values they -resolve to are unchanged, so migration is one keyword: +## Breaking -```diff -- const server = serveDeno(app, { port }); -+ const server = await serveDeno(app, { port }); -``` +### Node 22 is the minimum -Both runtimes support top-level `await`, so a module that serves at import time needs nothing else. +Node 18 and 20 are end-of-life and receive no security patches, so `engines.node` is now `>=22`. +**Migrate:** upgrade Node before you upgrade green-tea. -They did not boot the app before, and the documented fix was to remember `await app.boot()` first. -That made the correct use of a core helper depend on reading a page — in a framework whose argument -is that order should not be something you have to get right. Forget it and a missing signing key was -not a failed deploy: the boot memo keeps the rejection, so the port bound, every request got a 500 -from the runtime, and none of them reached `onError`. The server looked healthy to anything that -only checked whether it was listening. +### Response header values are `string | string[]` -`edgeHandler` is deliberately untouched. On workerd there is no startup outside a request, so there -is no earlier moment to move the failure to — see the table in [Runtimes](/docs/guides/runtimes/). +`TransformerFn`, `ErrorResponse.headers`, `HttpError.headers` / `HttpErrorLike.headers` and the +pipeline's `ResponseShape.headers` widen from `Record`, so a response can carry +several `Set-Cookie` lines. A `set-cookie` array goes out as one header line per element on every +runtime; before, the fetch adapters joined it into one broken `a,b` cookie. Any other array is sent +as one line joined with `, `. -→ [Runtimes](/docs/guides/runtimes/) +**Migrate:** code that *produces* headers needs nothing. Code that *reads* `headers[k]` from one of +these types as a `string` stops compiling and must handle the array. -## A plugin is a named object +### Inbound WebSocket messages are capped at 1 MiB, and 16 unread messages -`Plugin` is now `{ name, mount(api) }`. It was `(api) => void`, and the name that `plugin:mounted` -reports came from the function — empty for an arrow returned by a factory, `"plugin"` for a `const`, -and whatever a minifier leaves. The event that exists to say which plugins mounted named none of -them. +A larger message closes the connection with `1009`; a 17th unread message closes it with `1013`. +Before, Node allowed 100 MiB, the other runtimes applied no cap, and the inbound queue was +unbounded everywhere. A `@Ws` handler that never reads `inbound` counts too. -```diff -- const jwt = (options) => ({ scope }) => { scope.add(node); }; -+ const jwt = (options) => ({ -+ name: options.provides ?? 'jwt', -+ mount({ scope }) { scope.add(node); }, -+ }); -``` +**Migrate:** raise the bounds with `limits.maxWsMessageBytes` and `limits.maxWsQueue` if your +clients legitimately need more. See [Streaming](/docs/guides/streaming/#inbound-limits). -The name is the author's word now. A failed mount reads `plugin "jwt" failed to mount: …` and keeps -the original error as its `cause`, and two plugins sharing a name fail `createApp`. +### Overlapping constrained route params fail boot -→ [Plugins](/docs/guides/plugins/) +Two routes of one method that branch at the same segment now fail `createApp` when both constraints +accept a common segment (`/u/:id(\d+)` and `/u/:n(\d{1,3})`), or when a constraint that accepts +every segment makes a plain sibling unreachable. Before, registration order picked silently. -## `app.boot()` is public +**Migrate:** make the router's choice explicit — make the constraints disjoint (`\d{1,3}` and +`\d{4,}`), keep one route and branch in its handler, or move one under a static segment of its own. +Delete a plain param reported as unreachable: it never answered a request. For a WebSocket pair, +declare the constrained route first. See +[Routing](/docs/guides/routing/#overlapping-constraints-fail-at-boot). -It runs the provider factories now instead of on the first request. `listen()`, `serveDeno()` and -`serveBun()` all call it for you, so you need it when you drive `app.fetch` / `app.upgrade` from a -server you own: +### A token has one producer -```ts -const app = createApp({ modules: [ApiModule] }); -await app.boot(); // throws here instead of on every request -Deno.serve({ port: 8000 }, app.fetch); -``` +A second node providing a token fails `createApp`, naming both: +`token 'user' is provided twice: by 'user' (module:AppModule) and by 'shadow' (plugin)`. Before, +the last one registered won, so a plugin could silently replace the app's own provider. The rule +covers modules, plugins, mesh steps and the built-ins `logger`, `rooms` and `events`. `overrides` +keep working. -It is idempotent and shares its memo with `listen()` and `fetch()`. On workerd there is no startup -outside a request, so it moves nothing there. +**Migrate:** rename one producer's token. See [Plugins](/docs/guides/plugins/#what-a-plugin-can-and-cannot-touch). -→ [Runtimes](/docs/guides/runtimes/) +### A node that needs the token it provides fails boot -## Errors are recognised by brand, not `instanceof` +A step with `needs: ['user']` and `provides: 'user'` used to boot and then read a value nobody had +written. It now fails as a cycle: `cycle detected: user -> user (it needs 'user', which only it +provides)`. Seeded tokens such as `req` and `params` still satisfy a node's own need. -`HttpError` and `ValidationError` carry `Symbol.for('green-tea.http-error')` and -`Symbol.for('green-tea.validation-error')`, and `isHttpError` checks the brand. +**Migrate:** remove the token from the node's `needs`, or move the work that produces it into a +separate provider or step the node needs. See +[Dependency injection](/docs/guides/dependency-injection/#a-node-cannot-need-what-it-provides). -This matters when more than one copy of core is in play — a plugin package with its own dependency, -an app that installed from npm and JSR both. An `Unauthorized` thrown by the other copy failed -`instanceof` and rendered as a 500, which is the least useful status it could have chosen. It now -renders with its own. +### `/__mesh__/control` is reserved in every app -The string is the public protocol: code that imports only types can write -`Symbol.for('green-tea.http-error')` itself. `HttpErrorLike` and `isValidationError` are exported -alongside. +A `@Ws`, `@Stream` or HTTP route that claims it — or a mountpoint that adds up to it — fails +`createApp`, mesh or not. Before, Node served a user route there when no mesh was configured, while +Deno, Bun and the edge refused it. -→ [Errors](/docs/guides/errors/) +**Migrate:** move the route, for example to `/mesh/control`. See +[Routing](/docs/guides/routing/#reserved-paths). -## Mesh (alpha): a teapot exports steps and routes, not providers +### Static files are revalidated, not re-downloaded -`@Provider({ export: true })` now fails the boot, with an error naming the provider and the -replacement. A provider's value *is* the object it builds — a pool, a client, a `db` — and an object -is not what a JSON wire carries. What crossed before was whatever half of it survived serialization, -and it arrived as an app-scope binding: the teacup resolved it once at boot and served that value -for the life of the process. Restarting the teapot changed nothing until the teacup restarted too. +Every static hit carries a weak `ETag`, `Last-Modified` and `Cache-Control: no-cache`, and a +matching conditional request answers `304` without reading the file. -Export a `@Step` instead. It runs on the teapot, per request, and only its result comes back. +**Migrate:** to send a different `Cache-Control`, set `static: { cacheControl }` — for example +`'public, max-age=31536000, immutable'` for content-hashed assets. See +[Caching & compression](/docs/guides/caching-and-compression/). -Two things follow from that. The manifest now carries `{ steps: string[], routes }` rather than -`{ scopes: [{ token, scope }], routes }`, since every export is request-scope and the lifetime field -had one value left. And an **unreachable teapot no longer stops a teacup from booting**: there is no -app-scope value that has to resolve at boot, so after the `bootTimeoutMs` grace the teacup warns and -starts without it. +### A handler that returns `undefined` answers `204 No Content` -Be precise about what that last one buys, because it is narrower than it sounds. A teapot that never -connected sent no manifest, so its routes **404** — nothing was registered to match — and a local -step that `@needs` one of its tokens **still fails the boot**, naming the teapot. `503` is what a -teapot that connected and *later* died answers. What the change buys is the teacup that does not -depend on that teapot starting anyway. +Before, Node sent an empty `200` with a JSON `content-type`, and Deno, Bun and the edge answered +`500`. Every runtime now sends `204` with no body. `null` still gives `200` with `null`, and a route +with its own `@Transformer` is not affected. -`MESH_PROTOCOL_VERSION` stays at `1` on purpose: mesh is alpha behind `experimental: true`, both -peers ship from the same repository, and there is no deployed pair of versions for a bump to -protect. +**Migrate:** a client that checked for `200` after a handler with no `return` now sees `204`; return +a value from the handler to keep the `200`. -→ [Mesh](/docs/guides/mesh/) +### `serveDeno()` and `serveBun()` reject an app created with `tls` + +Only Node's `listen()` ever read `tls`, so on Deno and Bun the same app came up as plain HTTP without +a word. Both adapters now reject before they boot or bind. + +**Migrate:** drop `tls` from `createApp` and configure TLS on the runtime's server, or terminate it +at a proxy. See [Runtimes](/docs/guides/runtimes/). + +### Edge WebSocket upgrades trust `cf-connecting-ip` only under `trustProxy` + +`edgeHandler` copied the header into `ctx.ip` on every upgrade, so any client chose its own +address. It now counts only under `trustProxy: true`; without it, `ctx.ip` on an edge WebSocket is +`''`, as on edge HTTP. Edge HTTP, which never read the header, now reads it under `trustProxy` too. + +**Migrate:** an app behind Cloudflare that keys anything on the client address sets +`createApp({ trustProxy: true })`. See [Runtimes](/docs/guides/runtimes/#the-client-address--ctxip). + +## Added + +- **Health probes** — `createApp({ health: true })` serves `/__live__` and `/__ready__`, answered + ahead of the route table, with `/__ready__` reporting `booting`, `ok`, `degraded` and `draining`. + `health: { drainDelayMs }` holds the listener open after `close()` starts so a load balancer sees + the `503`. See [Graph introspection](/docs/guides/introspection/#health-probes--createapp-health-). +- **Response compression** — `createApp({ compression })`, off by default; br and gzip negotiated + on `Accept-Encoding`, streams included. On the edge Cloudflare encodes and core sets `Vary`. + See [Caching & compression](/docs/guides/caching-and-compression/). +- **ETags** — `createApp({ etag: true })` tags buffered `200` responses and answers a matching + `If-None-Match` with `304`; `static` accepts `{ dir?, cacheControl? }`. See + [Caching & compression](/docs/guides/caching-and-compression/). +- **Cookies** — `@cookie('name')`, `@cookies()` and `setCookie()`, with `HttpOnly`, + `SameSite=Lax`, `Path=/` and `Secure` by default. See + [Argument decorators](/docs/guides/arguments/#cookies--cookies-cookie-and-setcookie). +- **Importable types** — every type a public signature uses can be imported: `AppOptions`, + `ExplainNode`, `WsRequest`, `WsSocket`, `CompressionOptions`, `CookieOptions` and more, plus the + runtime entry points' option and server types. See + [createApp options](/docs/reference/createapp/#importable-types). +- **`RequestHeaders`** — the request headers type under a name that does not shadow the Fetch + `Headers` global. + +## Changed and fixed + +- **Route lookup uses a radix index per method.** Internal, and the same route still wins with the + same params, `Allow`, implicit `HEAD` and 404/405. See [Routing](/docs/guides/routing/#matcher-scale). +- **A query string keeps every `?` after the first**: `/search?q=what?now` gives `q: 'what?now'`. + See [Routing](/docs/guides/routing/#path-policy-and-404-vs-405). +- **Every `304` is bodiless**, on every adapter, including one from `throw new NotModified()`. +- **WebSocket outbound respects backpressure**: a fast producer waits while a slow peer's send + buffer is over 1 MiB. workerd reports no buffered amount, so there the platform buffers. See + [Streaming](/docs/guides/streaming/#outbound-backpressure). +- **`ctx.ip` is resolved when it is read**, so a route that never reads it pays nothing, and + `serveDeno()` and `serveBun()` now fill it with the client's address. Inside a stream body it is + `''` unless something read it first. See [Runtimes](/docs/guides/runtimes/#the-client-address--ctxip). +- **`listen()` rejects when the port is taken** (`EADDRINUSE`, `EACCES`) instead of never settling. +- **Mesh (alpha) follows its teapots.** A teapot that missed `bootTimeoutMs` is retried in the + background and its routes are served once it connects; a link that gives up for good has its + routes removed; every change emits `mesh:routes`. The mesh exports are now marked + `@experimental`, outside the freeze. See [Mesh](/docs/guides/mesh/). + +## Deprecated + +- **The `Headers` type.** Import `RequestHeaders` instead — the same type. `Headers` stays as an + alias, so nothing breaks. See [API stability](/docs/stability/#currently-deprecated). + +The changelog's *Fixed* section has the rest, among them `@header('Content-Type')` finding the +header whatever its case, the body parser choosing by media type, and the npm ESM build loading on +Cloudflare Workers. From 0f90a757b0b730c8c7705cf4c6cdd0b490245bc8 Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 09:55:14 -0500 Subject: [PATCH 2/8] docs(reference): bring createApp, routing, plugins and DI up to RC.1 - createApp: the option table now matches AppOptions, adding etag, compression and health and fixing the static object form. Add an Importable types section and handleSignals. - Routing: matching uses a radix index now. Document how the query string is parsed and which paths are reserved. - Plugins and DI: a token has one producer; it is not silently taken over. A node cannot need what it provides. - Decorators: cookies, undefined returning 204, and string | string[] headers. Signed-off-by: Diego Resendez --- .../docs/guides/dependency-injection.md | 34 ++++++++++ src/content/docs/guides/plugins.md | 29 ++++++-- src/content/docs/guides/routing.md | 17 ++++- src/content/docs/reference/createapp.md | 68 +++++++++++++++++-- src/content/docs/reference/decorators.md | 12 +++- 5 files changed, 145 insertions(+), 15 deletions(-) diff --git a/src/content/docs/guides/dependency-injection.md b/src/content/docs/guides/dependency-injection.md index 1234c49..b728f9c 100644 --- a/src/content/docs/guides/dependency-injection.md +++ b/src/content/docs/guides/dependency-injection.md @@ -247,6 +247,40 @@ served. When you `@needs` a token, green-tea verifies some provider or step actu it. If nothing does, **`createApp` throws with a clear error** instead of letting your handler receive `undefined`. Boot fails loudly, so you never serve `undefined`. +The same check refuses two other wirings that would also end in a value nobody wrote. + +### One producer per token + +A token has exactly one producer. Two nodes that both `provides` the same token fail `createApp`, +naming both and where they came from — `token 'user' is provided twice: by 'user' +(module:AppModule) and by 'shadow' (plugin)`. This holds across modules, +[plugins](/docs/guides/plugins/#what-a-plugin-can-and-cannot-touch), mesh steps and the built-in +tokens. `overrides` are not a second producer: they replace the runner of a token that already +exists. + +### A node cannot need what it provides + +A node whose `needs` include a token it `provides` itself fails as a cycle of one: + +```typescript +@Step({ provides: 'user', needs: ['db', 'user'] }) +class Authenticate { /* ... */ } +``` + +```text +cycle detected: user -> user (it needs 'user', which only it provides). Remove 'user' from its needs, or move the work that produces 'user' into a step it needs. +``` + +A `@Step`'s node is named after the token it provides, which is why the message says `user`. + +Before RC.1 this booted, and at request time the step read a value nobody had written. Since a token +has one producer, nothing else can supply it, so the need can never be met. The one exception is a +seeded token such as `req` or `params`: the node reads the seed, so needing it is fine. `overrides` +do not exempt the node, because they replace its runner after the graph is checked. + +To fix it, remove the token from the node's `needs`, or move the work that produces it into a +separate provider or step the node needs. + ### Names the framework owns `logger`, `rooms`, `events` and `bus` are reserved. Declaring one — from a module, a plugin or a diff --git a/src/content/docs/guides/plugins.md b/src/content/docs/guides/plugins.md index 1985dfb..ccfdd42 100644 --- a/src/content/docs/guides/plugins.md +++ b/src/content/docs/guides/plugins.md @@ -84,14 +84,27 @@ Node names are unique across the app. A plugin node named like an existing provi duplicate provider/step name 'user' — names must be unique across modules and plugins ``` -Tokens are a different matter. `scope.add` takes a node's name and the tokens it provides -separately, so a plugin node with a name of its own can still provide a token the app already -provides, and when it does, it takes that token over without a warning. The app's provider drops -out of every route that needed it. The framework's own tokens (`logger`, `events`, and the request -seeds `req` and `params`) are not affected, because they are registered after plugins. +Tokens follow the same rule. `scope.add` takes a node's name and the tokens it provides +separately, so a plugin node with a name of its own could still try to provide a token something +else already provides. **A token has one producer**, and a second one fails `createApp`, naming +both nodes and where they came from: -So give a plugin a token of its own, derived from its `provides` option, and never provide a token -the app owns. +```text +token 'user' is provided twice: by 'user' (module:AppModule) and by 'shadow' (plugin) — a token has exactly one producer; rename one of them, or use `overrides` to replace a runner +``` + +The rule is checked on the whole graph, so it covers modules, plugins, steps spliced in from +[mesh](/docs/guides/mesh/) teapots at boot, and the framework's own tokens `logger`, `rooms` and +`events`. A plugin node that provides `logger` under another name fails too, whatever order things +are registered in. `overrides` are not producers — they replace the runner of a token that already +exists — so they keep working. A step that provides nothing is unaffected. + +Before RC.1 the last node registered for a token won, and plugin nodes are registered after module +nodes, so a plugin could silently replace the app's own provider for every route that needed it. +Boot failing is the replacement for that. + +So give a plugin a token of its own, derived from its `provides` option. The official plugins +already use name = node name = token. :::note Because execution is per-route (a route runs only the transitive closure of its handler's @@ -121,6 +134,8 @@ them live in [Observability](/docs/guides/observability/). The short version: - `boot:provider:*` — provider lifecycle during boot - `stream:open | stream:close | stream:error` — SSE/WS stream lifecycle - `mesh:connect | mesh:disconnect | mesh:rpc:error` — mesh link and RPC activity +- `mesh:boot:retry` — a teapot did not answer during boot and the teacup is trying again +- `mesh:routes` — a teacup's remote routes changed, with `{ url, added, removed }` - `plugin:mounted` — a plugin finished mounting Every per-request payload carries a `requestId`, so a plugin can attribute events to the request diff --git a/src/content/docs/guides/routing.md b/src/content/docs/guides/routing.md index 06d877f..8155f61 100644 --- a/src/content/docs/guides/routing.md +++ b/src/content/docs/guides/routing.md @@ -122,14 +122,29 @@ Without an explicit OPTIONS route, OPTIONS on an existing path returns `204` wit `/path` and `/path/` are equivalent. Repeated slashes such as `/path//item` are rejected rather than collapsed, and malformed percent encoding is rejected; both return `400 Bad Request` with the configured security/CORS headers. Query strings do not participate in route matching. +The query string is everything after the **first** `?`, up to a `#` if there is one. A `?` inside the query is part of a value, as RFC 3986 allows: `/search?q=what?now` gives `q: 'what?now'`, and `/search?a=1&b=?` gives `b: '?'`. A key repeated in the query keeps its **last** value — `/search?tag=a&tag=b` gives `tag: 'b'`. + **`.` and `..` are resolved, not rejected.** `/public/../admin` matches a route declared as `/admin`, exactly as the URL bar and every HTTP client already treat it, and `%2e` counts as a dot — `/public/%2e%2e/admin` resolves the same way, so the encoded spelling cannot reach a route the plain one resolves away from. Resolving rather than rejecting is not the stricter choice, and it is not green-tea's to make: Deno, Bun and Cloudflare Workers resolve dot segments inside the `Request` constructor, before the framework is handed anything, so the raw path is unrecoverable there. Rejecting would be implementable on Node alone, and the same request would then reach different routes depending on where you deployed. If a path traversal matters to you, it is worth knowing that a proxy or WAF matching on the literal string sees `/public/...` while the app sees `/admin`. A path that no route matches returns `404`. A path that **does** exist but under a different method returns **`405 Method Not Allowed`** with an `Allow` header listing the methods that do match — not a `404`. +## Reserved paths + +Some paths are answered by green-tea itself, ahead of the route table, on every runtime. A route that claims one fails `createApp`, naming the declaration and the reserved path: + +| Path | Reserved | What answers it | +|---|---|---| +| `/__mesh__/control` | in every app, mesh or not | the [mesh](/docs/guides/mesh/) control channel | +| `/__live__`, `/__ready__` | while `createApp({ health })` is on | the [health probes](/docs/guides/introspection/#health-probes--createapp-health-) | + +A route claims a reserved path when its first segment is the same literal and the pattern matches the path: `@Ws('/__mesh__/control')`, `@Get('/__mesh__/control')`, `@Ws('/__mesh__/:x')`, `/__mesh__/:rest*`, or a mountpoint that adds up to one of these. A pattern whose first segment is a param (`/:org/:repo`, `/:rest*`) still boots; it simply never sees the reserved path, because the framework answers it first. A route exported by a teapot is checked the same way: at boot it fails the teacup's boot, and a teapot that connects later has that route refused and logged. + +`/__mesh__/control` is reserved even without mesh so that one route cannot work on one runtime and not on another — before RC.1 the Node listener served a user route there while Deno, Bun and the edge refused it. An upgrade to `/__mesh__/control` on an app with no control channel is refused the same way everywhere: the socket is accepted and closed with `1008` (`mesh control channel not enabled: this app exports nothing, or has no mesh.secret`). The one exception is a Node app with no `@Ws`/`@Stream` routes and no mesh: it attaches no upgrade listener, so there the request reaches the HTTP route table as before. To migrate a route that used the path, move it, for example to `/mesh/control`. + ## Matcher scale -Route matching remains a **linear scan** — fine for typical route tables. A radix-tree matcher for very large route tables remains on the roadmap. +HTTP route lookup walks a **radix index per method**, built once from the route table, so a lookup — including the miss behind a `404` or `405` — no longer scans the route list. Routes spliced in from mesh teapots sit in an index of their own. The index changes nothing about which route wins: precedence, params, `Allow`, implicit `HEAD` and 404/405 are exactly what the rules above say. `graph()`, `explain()` and `openapi()` read the declared routes, not the index. See [Streaming & real-time](/docs/guides/streaming/) for `@Sse` / `@Ws` / `@Stream`, and [Argument decorators](/docs/guides/arguments/) for everything a handler can read from a request. diff --git a/src/content/docs/reference/createapp.md b/src/content/docs/reference/createapp.md index 1a969b0..83d81fd 100644 --- a/src/content/docs/reference/createapp.md +++ b/src/content/docs/reference/createapp.md @@ -4,11 +4,16 @@ description: "Every option accepted by createApp and every method on the App it --- ```typescript +import { createApp, type AppOptions } from '@green-tea/core'; + +const options: AppOptions = { modules: [ApiModule] }; const app = createApp(options); ``` ## Options +The options object is the exported `AppOptions` interface. Everything except `modules` is optional. + | Option | Type | Default | Meaning | |---|---|---|---| | `modules` | `Ctor[]` | — | the `@Module` classes to wire (required) | @@ -26,13 +31,16 @@ const app = createApp(options); | `onError` | `ErrorRenderer` | — | render errors your way ([error handling](/docs/guides/errors/)); returns a response or `undefined` to fall back to JSON | | `views` | `string` | `process.cwd()` | base dir `@Html('file.html')` paths resolve against — relative paths join it, absolute paths are used as-is ([HTML & views](/docs/guides/html/)) | | `viewEngine` | `(source: string, data: unknown) => string` | built-in `render` | swap in your own template engine for `@Html(..., { template: true })`; template mode only ([HTML & views](/docs/guides/html/)) | -| `static` | `boolean \| string` | — | serve a directory as a `GET`/`HEAD` fallback (after declared routes, before `404`); `true` → `./public`, a string → that dir; path-traversal-safe; needs a filesystem — Node/Deno/Bun only; a symlink that resolves outside the root is a `404` ([HTML & views](/docs/guides/html/#static-files--createapp-static-)) | +| `static` | `boolean \| string \| { dir?: string; cacheControl?: string }` | — | serve a directory as a `GET`/`HEAD` fallback (after declared routes, before `404`); `true` → `./public`, a string → that dir, the object form → `dir` (default `./public`) plus the `Cache-Control` sent with its files and `304`s (default `no-cache`). Every hit is revalidated with a weak `ETag` and `Last-Modified`. An unknown key, or a `cacheControl` with CR/LF, throws. Path-traversal-safe; a symlink that resolves outside the root is a `404`; needs a filesystem — Node/Deno/Bun only ([HTML & views](/docs/guides/html/#static-files--createapp-static-), [caching](/docs/guides/caching-and-compression/)) | +| `etag` | `boolean` | `false` | weak ETags for buffered `200` responses to `GET`/`HEAD`; a matching `If-None-Match` gets a bodiless `304`. The handler still runs. Anything but a boolean throws ([caching](/docs/guides/caching-and-compression/)) | +| `compression` | `boolean \| CompressionOptions` | `false` | response compression negotiated on `Accept-Encoding`; `true` uses the defaults below. On the edge core sets only `Vary` and Cloudflare encodes. Invalid options throw ([compression](/docs/guides/caching-and-compression/)) | +| `health` | `boolean \| { drainDelayMs?: number }` | `false` | serve `GET`/`HEAD` `/__live__` and `/__ready__`, answered ahead of the route table; while on, a route at either path fails boot. `drainDelayMs` (default `0`) keeps the listener open that long after `close()` starts, with `/__ready__` already `503`. `createApp` throws when `drainDelayMs` + `teardownTimeoutMs` exceeds `shutdownTimeoutMs`, and when `health` is anything but `true`, `false` or `{ drainDelayMs?: number }` ([health probes](/docs/guides/introspection/#health-probes--createapp-health-)) | | `logger` | `Logger` | structured JSON (readable on a TTY) | where every framework diagnostic is written; also injectable as `@needs('logger')` ([observability](/docs/guides/observability/)) | | `logRequests` | `boolean` | `false` | log one line per completed request and per failure; a removable Bus subscriber, not a middleware ([observability](/docs/guides/observability/)) | | `shutdownTimeoutMs` | `number` | `10_000` | how long `close()` gives in-flight work before forcing the rest shut; `close({ timeoutMs })` still wins per call. Set it here when `close()` is reached from a signal handler or shutdown hook you do not own ([runtimes](/docs/guides/runtimes/)) | | `teardownTimeoutMs` | `number` | — | milliseconds **reserved out of** `shutdownTimeoutMs` for teardown, so a slow drain cannot leave a connection unclosed. Unset, the drain may use the whole budget and teardown takes what is left. Larger than `shutdownTimeoutMs` throws at boot ([teardown](/docs/guides/dependency-injection/#releasing-what-a-provider-opened)) | | `handleSignals` | `boolean` | `false` | register `SIGINT`/`SIGTERM` to run `close()` and exit, using each runtime's own API ([teardown](/docs/guides/dependency-injection/#who-calls-close)) | -| `mesh` | `MeshConfig` | — | distributed DI — **requires `experimental: true`** ([mesh](/docs/guides/mesh/)) | +| `mesh` | `MeshConfig` | — | distributed DI — **requires `experimental: true`**; `@experimental`, outside the [API freeze](/docs/stability/) ([mesh](/docs/guides/mesh/)) | | `experimental` | `boolean` | `false` | opt in to alpha features (currently gates `mesh`) | | `warnGraphDepth` | `number \| false` | `20` | warn when one route resolves to more than this many steps; `false` disables the design warning | @@ -47,7 +55,7 @@ const app = createApp(options); | `headersTimeoutMs` | `10_000` | | `keepAliveTimeoutMs` | `5_000` | | `maxParts` | `1000` (multipart) | -| `maxWsMessageBytes` | `1_048_576` (1 MiB): the largest inbound WebSocket message, in bytes (UTF-8 bytes for text); a fragmented message counts as a whole. A larger one closes the connection with `1009` (`1006` on Bun) | +| `maxWsMessageBytes` | `1_048_576` (1 MiB): the largest inbound WebSocket message, in bytes (UTF-8 bytes for text); a fragmented message counts as a whole. A larger one closes the connection with `1009` — on Bun before 1.4 the connection is dropped instead, so the peer sees `1006`; Bun 1.4 and later send `1009` | | `maxWsQueue` | `16`: inbound WebSocket messages a connection may hold that the handler has not read yet. One more closes the connection with `1013` | `maxBodyBytes` is enforced while the body is read, on every runtime: a `Content-Length` that already declares more is refused before any of the body is read, and otherwise reading stops at the chunk that crosses the limit ([uploads](/docs/guides/uploads/#limits)). @@ -56,6 +64,10 @@ const app = createApp(options); `maxConcurrentRequests` is enforced before request-body acquisition in both the Node and Fetch paths, so it applies across Node, Deno, Bun, and edge — as a budget per server or Fetch adapter instance, not one ceiling shared by the app ([runtimes](/docs/guides/runtimes/)). Long-lived streams release their slot once request handling returns, and WebSocket upgrades are outside this budget. +### `CompressionOptions` + +`{ threshold?, encodings? }` — `threshold` is the smallest buffered body worth encoding, in bytes (default `1024`; streams ignore it). `encodings` is the codings core may produce, in preference order: an array of `'br'` and `'gzip'` (default `['br', 'gzip']`). See [Caching & compression](/docs/guides/caching-and-compression/). + ### `TlsOptions` `{ key, cert, ca?, passphrase? }` — `key`/`cert` are `Buffer | string`. @@ -70,7 +82,9 @@ const app = createApp(options); ### `MeshConfig` -`{ secret?, teapots?, timeoutMs?, heartbeatMs?, reconnect?, onManifestChange?, bootTimeoutMs? }` — `teapots` is `{ url, secret }[]`; `timeoutMs` bounds an RPC (default 30s, → 504), `heartbeatMs` is the ping gap that detects a half-open link (default 15s, → 503). `reconnect` is `boolean | { initialDelayMs?, maxDelayMs? }` (default on; 500ms doubling to 30s with jitter), `onManifestChange` is `'refuse'` — the default, and the only policy today — and `bootTimeoutMs` is how long boot waits for a teapot that has not started yet (default `timeoutMs`; `0` for a single attempt) — exhausting it warns and starts without that teapot, whose routes then 404; the boot still fails if anything local needs one of its tokens. A refused handshake is never retried. See [Mesh](/docs/guides/mesh/). +`{ secret?, teapots?, timeoutMs?, heartbeatMs?, reconnect?, onManifestChange?, bootTimeoutMs? }` — `teapots` is `{ url, secret }[]`; `timeoutMs` bounds an RPC (default 30s, → 504), `heartbeatMs` is the ping gap that detects a half-open link (default 15s, → 503). `reconnect` is `boolean | { initialDelayMs?, maxDelayMs? }` (default on; 500ms doubling to 30s with jitter), `onManifestChange` is `'refuse'` — the default, and the only policy today — and `bootTimeoutMs` is how long boot waits for a teapot that has not started yet (default `timeoutMs`; `0` for a single attempt). Exhausting it warns and boots without that teapot: its routes `404` and none of its steps are in the graph, and the boot still fails if anything local needs one of its tokens. The teapot is then retried in the background with the `reconnect` backoff, and once it connects its routes are spliced in and served, with no restart; its steps are not added, because the graph was validated without them. With `reconnect: false` it is not retried. A refused handshake is never retried. Every change to the remote routes emits `mesh:routes`. See [Mesh](/docs/guides/mesh/). + +`MeshConfig` and every other mesh export are `@experimental`: outside the [API freeze](/docs/stability/), and they may change in any release. ## The `App` @@ -78,12 +92,13 @@ const app = createApp(options); | Member | Returns | Notes | |---|---|---| -| `listen(port)` | `Promise` | boots providers, then serves | +| `listen(port)` | `Promise` | boots providers, then serves. Rejects with the bind error (`EADDRINUSE`, `EACCES`) when the port cannot be bound; the app then counts as never having listened, so `close()` resolves and a second `listen()` can try another port | | `close({ timeoutMs? })` | `Promise` | drains in-flight, closes streams + mesh links, then runs registered teardown (`dispose()`, `onShutdown`); after the deadline (default `10s`, or `createApp({ shutdownTimeoutMs })`) it warns and force-closes what is left. **Draining** is Node-only — on Deno and Bun use the server `serveDeno()`/`serveBun()` returned, which also runs teardown ([runtimes](/docs/guides/runtimes/)) | +| `handleSignals(closer)` | the same type as `closer` | wires `SIGINT`/`SIGTERM` to `closer` and returns a close that also unregisters them — or returns `closer` untouched when `createApp({ handleSignals })` is not set. `listen()`, `serveDeno()` and `serveBun()` call it for you; call it yourself only when you serve the app some other way ([teardown](/docs/guides/dependency-injection/#who-calls-close)) | | `ready()` | `Promise` | resolves the graph and mesh links without booting providers | | `boot()` | `Promise` | boots providers now instead of on the first request; idempotent, shares its memo with `listen()` and `fetch()`, and a failure stays failed. A no-op on workerd, which has no startup outside a request ([runtimes](/docs/guides/runtimes/)) | | `fetch(request)` | `Promise` | Web-Standards HTTP/SSE handler for Node, Deno, Bun, and edge | -| `upgrade(request, socket)` | `Promise` | neutral WebSocket upgrade used by non-Node adapters | +| `upgrade(request, socket)` | `Promise` | neutral WebSocket upgrade used by non-Node adapters; takes a `WsRequest` and a `WsSocket` | | `inspect(route)` | `InspectLine[]` | the provider/step/handler chain for a route | | `explain(route)` | `Explain` | the chain annotated with each node's needs/provides | | `graph()` | `GraphView` | the full node + route graph | @@ -96,3 +111,44 @@ const app = createApp(options); :::note On mesh apps, `graph` / `explain` / `inspect` / `degraded` are only available after `ready()` or the first serving call — the graph is finalized once the teapots have announced their steps and routes. ::: + +## Importable types + +Every type a public signature uses can be imported, so code that calls the API can also name what it +passes and gets back. These are the types RC.1 adds to the exports — most were already in the +declarations without a name you could import, and the rest arrive with new options: + +```typescript +import type { AppOptions, ExplainNode, WsRequest, WsSocket } from '@green-tea/core'; +``` + +| Type | Entry point | What it is | +|---|---|---| +| `AppOptions` | `@green-tea/core` | the options object of `createApp`, described above | +| `RequestHeaders` | `@green-tea/core` | the incoming request headers, `Record` | +| `Headers` | `@green-tea/core` | already exported before RC.1, now a **deprecated** alias of `RequestHeaders`; it shadows the Fetch `Headers` global in the files that import it ([API stability](/docs/stability/#currently-deprecated)) | +| `ExplainNode` | `@green-tea/core` | one entry of `Explain.chain` ([introspection](/docs/guides/introspection/)) | +| `WsRequest`, `WsSocket` | `@green-tea/core` | the two arguments of `app.upgrade` | +| `CompressionOptions` | `@green-tea/core` | the object form of `compression` | +| `CookieOptions` | `@green-tea/core` | the third argument of `setCookie()` ([cookies](/docs/guides/arguments/#cookies--cookies-cookie-and-setcookie)) | +| `ModuleOptions` | `@green-tea/core` | the argument of `@Module` | +| `Ctor` | `@green-tea/core` | any class constructor, as `modules`, `providers`, `steps` and `controllers` take them | +| `BusLike` | `@green-tea/core` | the slice of the `Bus` that `logRequests(bus, logger)` subscribes to | +| `ArgSource` | `@green-tea/core` | where an argument decorator reads from (`ArgSpec.source`) | +| `GraphNodeView`, `GraphRouteView` | `@green-tea/core` | a node and a route in the `GraphView` that `app.graph()` returns | +| `StandardIssue`, `StandardResult` | `@green-tea/core` | the issue and result shapes of [Standard Schema](/docs/guides/validation/) | +| `ResponseShape` | `@green-tea/core` | a materialized response, `{ status, headers, body }`, with `headers` values `string \| string[]` | +| `DenoServeOptions`, `DenoHttpServer` | `@green-tea/core/deno` | the options of `serveDeno()`, and the server the `DenoServer` it resolves to extends | +| `BunServeOptions`, `BunServer` | `@green-tea/core/bun` | the options of `serveBun()`, and the server the `BunServeResult` it resolves to extends | +| `EdgeCtx` | `@green-tea/core/edge` | the Workers execution context (`waitUntil`) that the handler returned by `edgeHandler` takes as its third argument | + +### Mesh types — experimental + +These are exported from `@green-tea/core` and marked `@experimental`, like the rest of the mesh: +outside the [API freeze](/docs/stability/#mesh-is-outside-the-promise), so they may change in any +release. + +`MeshConfig`, `Link`, `ReconnectOptions`, `ManifestPolicy`, `Manifest`, `RequestEnvelope`, `Frame`, +`RouteEntry`, `RemoteScopeNode`, `RemoteRoute`, `MeshControlDeps`, `SocketCtor` and +`EventfulSocket`, along with the functions and constants `connectLink`, `buildRemote`, +`envelopeFrom`, `buildManifest`, `createMeshControl` and `MESH_CONTROL_PATH`. diff --git a/src/content/docs/reference/decorators.md b/src/content/docs/reference/decorators.md index b7aa304..b549369 100644 --- a/src/content/docs/reference/decorators.md +++ b/src/content/docs/reference/decorators.md @@ -21,6 +21,10 @@ Marks a class as a module and records what it wires together. class ApiModule {} ``` +`mountpoint` is required, and `''` mounts at the root. A module that omits it (possible from +JavaScript or transpile-only code, which the type cannot stop) fails `createApp` with +` @Module metadata is missing mountpoint`. + ### `@Route(prefix)` Marks a class as a controller; `prefix` is prepended to each route path. @@ -55,7 +59,9 @@ A request-scope transform that merges keys into the context. Same options as `@P ### `@Transformer(fn)` -Method decorator. Turns a handler's return value into `{ status?, headers?, body }`. `JsonTransformer` is the default. +Method decorator. Turns a handler's return value into `{ status?, headers?, body }`. `JsonTransformer` is the default: it answers `200` with the value as JSON, except that a handler returning `undefined` (no `return`) answers `204 No Content` with no body and no `content-type`. `null` is a value, so it still answers `200` with `null`. A route with its own `@Transformer` decides for itself. + +Header values are `string | string[]`. A `set-cookie` array is sent as one header line per element on every runtime; any other array is sent as one line joined with `, `. A `vary` array is merged with the `Vary` that security and CORS inject, and those injected headers otherwise stay authoritative. Every `304` goes out without a body or `content-*` headers, whatever the transformer returned. ### `@Html` / `@Html(path, opts?)` @@ -103,7 +109,11 @@ A handler's signature declares exactly what it wants, in any order. | `@body(...)` | parsed body (JSON / urlencoded / multipart) | `()` · `('field')` · `(schema)` | | `@headers(...)` | request headers (whole bag or picked) | `()` · `('authorization')` · `(['a','b'])` · `(schema)` | | `@header('name')` | one request header (singular alias of `@headers`) | `('x-trace')` · `('x-count', schema)` | +| `@cookies(...)` | request cookies from the `Cookie` header (also `@needs('cookies')`) | `()` · `('session')` · `(['a','b'])` · `(schema)` | +| `@cookie('name')` | one request cookie (singular alias of `@cookies`); `undefined` when absent | `('session')` · `('session', schema)` | | `@inbound()` | the incoming message stream (WebSocket) | `()` | | `@abort()` | the request's `AbortSignal` | `()` | Passing a [Standard Schema](/docs/guides/validation/) validates and coerces the value before the handler sees it. Full details in [Argument decorators](/docs/guides/arguments/). + +To send a cookie, build the header value with `setCookie(name, value, options?)` — `HttpOnly`, `SameSite=Lax`, `Path=/` and `Secure` by default — and put it in a response's `set-cookie` header ([cookies](/docs/guides/arguments/#cookies--cookies-cookie-and-setcookie)). From 5dae52002638fa17bc45ee8075764ab70443181f Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 09:55:14 -0500 Subject: [PATCH 3/8] docs(guides): document cookies, caching, compression and health probes; fix mesh, streaming and runtimes - New guide: Caching and compression. Covers compression with its Vary rules, etag, and static revalidation. - Add cookies to arguments, health probes to introspection, and the ctx.ip semantics. - Mesh: background retry for a late teapot, route replacement, retry-after, the new control-channel frame bounds, and the @experimental note. - Streaming and runtimes: outbound backpressure and drained(), the Bun 1006/1009 split, cf-connecting-ip under trustProxy on the edge, and a list of real runtime differences in place of 'identical'. Signed-off-by: Diego Resendez --- src/content/docs/guides/arguments.md | 111 +++++++++++- .../docs/guides/caching-and-compression.md | 165 ++++++++++++++++++ src/content/docs/guides/errors.md | 30 +++- src/content/docs/guides/html.md | 14 ++ src/content/docs/guides/introspection.md | 58 +++++- src/content/docs/guides/mesh.md | 38 ++-- src/content/docs/guides/observability.md | 5 + src/content/docs/guides/openapi.md | 4 +- src/content/docs/guides/runtimes.md | 78 +++++++-- src/content/docs/guides/streaming.md | 29 ++- src/content/docs/guides/uploads.md | 5 +- 11 files changed, 496 insertions(+), 41 deletions(-) create mode 100644 src/content/docs/guides/caching-and-compression.md diff --git a/src/content/docs/guides/arguments.md b/src/content/docs/guides/arguments.md index 4f00175..ba6279a 100644 --- a/src/content/docs/guides/arguments.md +++ b/src/content/docs/guides/arguments.md @@ -1,6 +1,6 @@ --- title: Argument decorators -description: "Everything a handler can inject with needs, ctx, param, query, body, header, inbound, and abort." +description: "Everything a handler can inject with needs, ctx, param, query, body, header, cookie, inbound, and abort." --- A handler's signature declares exactly what it wants — in any order, nothing more. Each @@ -19,6 +19,8 @@ most just read from the request. | `@body(...)` | parsed body (JSON / urlencoded / multipart) | no | `()` · `('field')` · `(schema)` | | `@headers(...)` | request headers (whole bag or picked) | no | `()` · `('authorization')` · `(['a','b'])` · `(schema)` | | `@header('name')` | one request header (singular alias of `@headers`) | no | `('x-trace')` · `('x-count', schema)` | +| `@cookies(...)` | request cookies, parsed from the `Cookie` header | no | `()` · `('session')` · `(['a','b'])` · `(schema)` | +| `@cookie('name')` | one request cookie (singular alias of `@cookies`) | no | `('session')` · `('session', schema)` | | `@inbound()` | the incoming WS message channel | no (WS only) | `()` | | `@abort()` | an `AbortSignal` that fires on disconnect | no (stream/WS) | `()` | @@ -40,8 +42,22 @@ graph. ## `@ctx` — the whole context `@ctx()` hands you the entire accumulated context — everything providers and steps have merged -in, plus built-ins. Use it to read values like `ctx.protocol` and `ctx.ip` (populated when -`trustProxy` is on). +in, plus built-ins. Use it to read values like `ctx.protocol` and `ctx.ip`. + +`ctx.ip` is the client address: the socket peer, or, when `trustProxy` is on and the request has +an `X-Forwarded-For` header, that header's first hop. On the edge, under `trustProxy`, +`cf-connecting-ip` wins over both, because Cloudflare sets it itself. A bare `app.fetch(request)` +from your own code has no socket, so there it is `''` unless a trusted header supplies it. + +The address is resolved the first time something reads `ctx.ip` and kept for the rest of the +request, so a route that never reads it pays nothing (on Bun, resolving it is a native call). + +:::caution[`ctx.ip` inside a stream body] +A stream body runs after the response has started, when the socket the address would come from +may already be gone. So once a handler returns a stream, an address nothing has read yet is `''`. +If a stream needs the address, read `ctx.ip` in a step or in the handler before returning the +stream, and close over the value. +::: ## Envelope decorators — `@param`, `@query`, `@body`, `@headers` @@ -61,7 +77,10 @@ getUser(@param('id') id: string) { /* ... */ } - **`@query`** reads the parsed query string. Query values are always strings on the wire — a schema is often the coercion point (see below). - **`@body`** reads the parsed body. It handles `application/json`, - `application/x-www-form-urlencoded`, and `multipart/form-data` (see the multipart note). + `application/x-www-form-urlencoded`, and `multipart/form-data` (see the multipart note). The + parser is chosen by the media type — the part of `Content-Type` before any `;`, trimmed and + compared case-insensitively — and any `+json` type (`application/problem+json`, + `application/vnd.api+json`) is parsed as JSON too. - **`@headers`** reads request headers — the whole bag (`@headers()`), one key (`@headers('authorization')`), or a subset (`@headers(['a','b'])`). @@ -75,6 +94,90 @@ who(@header('x-trace') trace: string) { /* ... */ } It also accepts a schema in slot 2 — `@header('x-count', schema)`. +## Cookies — `@cookies`, `@cookie`, and `setCookie()` + +`@cookies()` injects the request's cookies as a name → value map (`{}` when the request sent +none); `@cookie('name')` picks one, and is `undefined` when it is absent. Both accept the same +forms as `@headers`, including a schema. `@needs('cookies')` gives the same map, for a step that +reads cookies. + +```typescript +import { Route, Get, cookie, setCookie } from '@green-tea/core'; + +@Route('/session') +class SessionController { + @Get('/') + current(@cookie('session') session: string | undefined) { + return { signedIn: session !== undefined }; + } +} +``` + +The `Cookie` header is parsed on first use and the result is kept for the request, so a route that +declares no cookie argument never parses it, and one that declares several parses it once. Parsing +follows RFC 6265: for a repeated name the first value wins (browsers send the most specific cookie +first), `%`-sequences are decoded, and a malformed pair is skipped. A broken cookie never becomes a +`400`: the header is client state the server did not choose, and the route may not even need it. +The same decorators work on WebSocket routes. + +### Setting a cookie + +Core has no response-cookie API of its own: a cookie is a `Set-Cookie` header. `setCookie(name, +value, options?)` builds that header's value, with the safe defaults on: + +```typescript +setCookie('session', id, { maxAge: 3600 }); +// 'session=; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax' +``` + +| Option | Default | Meaning | +|---|---|---| +| `httpOnly` | `true` | page scripts cannot read the cookie | +| `sameSite` | `'lax'` | `'strict'`, `'lax'` or `'none'`; `'none'` requires `secure` | +| `path` | `'/'` | `Path` | +| `secure` | `true` | `Secure`; pass `false` where the browser will not accept it (below) | +| `maxAge` | — | `Max-Age` in seconds, an integer; `0` or less tells the browser to drop it | +| `expires` | — | `Expires`, from a `Date` | +| `domain` | — | omitted, so the cookie is scoped to the exact host that set it | +| `partitioned` | `false` | `Partitioned` (CHIPS); requires `secure` | + +`Secure` is on by default because Chrome, Edge and Firefox accept a `Secure` cookie on +`http://localhost`. Safari, a LAN IP or a plain-HTTP dev hostname do not; there, pass +`secure: false` (for example `secure: !isDev`). + +`setCookie` throws a `TypeError`, naming the field, rather than produce a header that would split +or reshape the response: a name outside the RFC 6265 token set, a value containing CR, LF, `;`, +`,`, whitespace or a control character, an attribute containing CR, LF, `;` or a control character, +a non-integer `maxAge`, an invalid `expires`, and `sameSite: 'none'` or `partitioned` without +`secure`. The value is written as given, so encode it yourself (`encodeURIComponent`) if it can hold +any of those characters; the cookie decorators decode it on the way back in. + +Several cookies on one response go in an array, and each element is sent as its own `Set-Cookie` +line on every runtime: + +```typescript +import { setCookie, type TransformerFn } from '@green-tea/core'; + +const signIn: TransformerFn = (value) => { + const { sessionId, user } = value as { sessionId: string; user: object }; + return { + status: 200, + headers: { + 'content-type': 'application/json', + 'set-cookie': [setCookie('session', sessionId), setCookie('theme', 'dark', { httpOnly: false })], + }, + body: JSON.stringify(user), + }; +}; + +// @Post('/login') @Transformer(signIn) login(...) { return { sessionId, user }; } +``` + +The same array works in `HttpError`'s `headers` argument and in an `onError` response. + +`parseCookies(header)` — the parser the decorators use — and the `CookieOptions` type are exported +too. Signing and sessions are deliberately not in core. + ## Schema forms — validation & coercion `@body`, `@query`, `@headers`, and `@param` accept an optional **Standard Schema** (zod, diff --git a/src/content/docs/guides/caching-and-compression.md b/src/content/docs/guides/caching-and-compression.md new file mode 100644 index 0000000..d074c4c --- /dev/null +++ b/src/content/docs/guides/caching-and-compression.md @@ -0,0 +1,165 @@ +--- +title: Caching and compression +description: "Response compression, weak ETags and 304s for handler responses, and how static files are revalidated." +--- + +Three `createApp` options decide how much of a response actually crosses the wire: `compression` +encodes bodies, `etag` lets a client that already has a response skip the body, and `static` +revalidates files instead of re-sending them. All three are off by default except static +revalidation, which comes with `static` itself. None of them needs a dependency. + +## Compression — `createApp({ compression })` + +```typescript +const app = createApp({ modules: [ApiModule], compression: true }); + +// or, with the defaults spelled out: +const app2 = createApp({ + modules: [ApiModule], + compression: { threshold: 1024, encodings: ['br', 'gzip'] }, +}); +``` + +| Option | Default | Meaning | +|---|---|---| +| `threshold` | `1024` | smallest buffered body, in bytes, worth encoding; streams ignore it | +| `encodings` | `['br', 'gzip']` | the codings core may produce, in server preference order | + +`threshold` must be a non-negative integer and `encodings` a non-empty array of `'br'` and +`'gzip'`; anything else throws at `createApp`. + +The coding is negotiated on the request's `Accept-Encoding`, with q-values, `identity`, `*` and +`q=0`. The highest `q` wins and a tie goes to the order of `encodings`. A request without +`Accept-Encoding` gets identity: the spec allows any coding then, but a client that sends no header +is overwhelmingly one that would not decode it. + +### What gets compressed + +A response is a candidate when its `Content-Type` is text (`text/*`), JSON, JavaScript, ndjson, XML, +SVG, wasm, or any `+json` / `+xml` type. It is left alone when it: + +- already has a `Content-Encoding` or a `Content-Range`; +- has no `Content-Type`; +- says `Cache-Control: no-transform`; +- is a `204`. + +That covers handler responses, error responses and [static files](/docs/guides/html/#static-files--createapp-static-) +alike. An encoded body loses its `Content-Length`, which described the identity bytes. + +A buffered body under `threshold` is sent identity whatever the client accepts. A `HEAD` is never +encoded, since it has no body; it only gets `Vary` (below). + +### `Vary: Accept-Encoding` + +A shared cache has to know that a response depends on `Accept-Encoding`, or it hands a brotli body +to a client that cannot read one. Core adds `Vary: Accept-Encoding`, merged with any `Vary` the +response already has, wherever the request decided the outcome: + +- an encoded response; +- an identity response to a client that accepted none of `encodings`; +- a `HEAD`; +- every `304`, so a cache revalidates under the same key it stored the `200` under. + +A body under the threshold gets **no** `Vary`. It is identity for every client, so `Accept-Encoding` +played no part in it, and a cache is right to store one copy for everyone. Skipping the header also +matters for speed: on Deno it costs about 10% of a small request (about 3% on Bun). + +A response that is not a candidate at all (an image, `no-transform`, already encoded) gets no +`Vary` either. + +### Streams + +SSE and ndjson streams are compressed too, and flushed after every event, error frame and ping, so +each event can be decoded the moment it arrives instead of waiting in the compressor's buffer. +`threshold` does not apply to them. + +### Per runtime + +- **Node and Bun:** `node:zlib`, always through its async API, with brotli at quality 4. The sync + API, and brotli's default quality of 11, stall the event loop under load. +- **Deno:** the same code, but Deno runs `node:zlib`'s async API on the main thread, so compression + costs event-loop time there — about 4× slower than Node for a 51 KB gzip under load. Deno's own + `automaticCompression` is about 3× faster than core's for gzip (slower for brotli, and it weakens + strong ETags on what it encodes). If gzip is all you need on Deno, leave `compression` off and let + the runtime do it. +- **Edge (Cloudflare Workers):** core never encodes. Cloudflare compresses in front of the Worker, + and a body core had already encoded would be encoded a second time. Core sets only `Vary`, on every + candidate response, since there the encoding is decided outside the app. The edge is recognised + through `edgeHandler`, or at `createApp` by detecting workerd. + +## ETags for handler responses — `createApp({ etag: true })` + +```typescript +const app = createApp({ modules: [ApiModule], etag: true }); +``` + +A buffered `200` to `GET` or `HEAD` gets a weak `ETag`, `W/"-"`, from a 32-bit FNV-1a +hash of its body plus its byte length. A request whose `If-None-Match` matches it — weak comparison, +so `W/"x"` matches `"x"`; a comma-separated list; or `*` — gets a `304` with no body. + +- **A handler that sets its own `etag` header keeps it**, and that tag is what `If-None-Match` is + compared against. Use it when you already have a version or revision to tag with. +- **The handler still runs.** The ETag is computed from the body it produced, so this saves + bandwidth, not work. +- Streams, non-`200` responses and other methods are never tagged. +- With `compression` on, the tag is taken on the identity body and is weak, so one tag describes + every coding of it. + +FNV-1a is not collision-resistant, and need not be: an ETag detects a change, it does not +authenticate one. It is synchronous and dependency-free, where `crypto.subtle.digest` would cost a +promise per response. + +`etag` takes a boolean only; anything else throws at `createApp`. + +## Static files are revalidated + +Every file `static` serves carries: + +- a weak `ETag`, `W/"-"`, from the file's `stat`; +- `Last-Modified`, from the same `stat`; +- `Cache-Control: no-cache`, unless you configure another value. + +A request with a matching `If-None-Match` — or, when it sends no `If-None-Match`, an +`If-Modified-Since` no earlier than the file's modification time — gets a `304` decided from the +`stat` alone, without reading the file. A `HEAD` does not read the file either. These validators +are sent whether or not `etag` is on. + +`no-cache` does not mean "do not cache". It means "cache, but ask before reusing": the browser keeps +the file and revalidates it, which costs a `304` with no body when nothing changed. Without it, +a browser seeing `Last-Modified` is free to guess a lifetime from it and reuse a stale file without +asking. + +### Choosing `Cache-Control` — `static: { dir, cacheControl }` + +The object form of `static` sets the `Cache-Control` sent with every static file and its `304`s: + +```typescript +// Content-hashed assets (app.3f2a.js) never change under the same name. +const app = createApp({ + modules: [ApiModule], + static: { dir: 'dist/assets', cacheControl: 'public, max-age=31536000, immutable' }, +}); +``` + +| Key | Default | Meaning | +|---|---|---| +| `dir` | `./public` | the directory, as in `static: ''` | +| `cacheControl` | `'no-cache'` | the `Cache-Control` value for static responses | + +`static: true` and `static: ''` keep working and mean `cacheControl: 'no-cache'`. An unknown +key, a `dir` that is not a string, or a `cacheControl` containing CR or LF throws at `createApp`. + +:::caution[Changed in this release] +Before this release a static hit carried only `Content-Type`: no validators and no +`Cache-Control`, and every request — `HEAD` included — read the file and sent it in full. Static +responses now always carry `ETag`, `Last-Modified` and `Cache-Control: no-cache`. If a proxy or CDN +in front of the app relied on the old headers, set `static: { cacheControl }` to the value it +expects. +::: + +## Related + +- [HTML & views](/docs/guides/html/) — `@Html` and the `static` option itself. +- [Streaming](/docs/guides/streaming/) — the SSE and ndjson transports that compression flushes per event. +- [Runtimes](/docs/guides/runtimes/) — what differs between Node, Deno, Bun and the edge. +- [createApp](/docs/reference/createapp/) — every option in one place. diff --git a/src/content/docs/guides/errors.md b/src/content/docs/guides/errors.md index 687656b..3eebdcc 100644 --- a/src/content/docs/guides/errors.md +++ b/src/content/docs/guides/errors.md @@ -13,10 +13,10 @@ All live in `@green-tea/core`: |---|---| | `Unauthorized(msg?)` | `401` | | `NotFound(msg?)` | `404` | -| `NotModified()` | `304` | +| `NotModified()` | `304`, with no body | | `Redirect(location)` | `302` + `Location` | | `ValidationError` | `422` (raised for you by schema validation) | -| `HttpError(status, msg?, body?)` | any status | +| `HttpError(status, msg?, body?, headers?)` | any status | | anything else | `500` (message hidden — internals never leak) | ```typescript @@ -41,6 +41,32 @@ throw new HttpError(409, 'conflict', { code: 'DUP_EMAIL', field: 'email' }); // → 409 { "code": "DUP_EMAIL", "field": "email" } ``` +The fourth argument adds response headers. Values are `string | string[]`, the same as on +`onError`'s response and on a transformer's: a `set-cookie` array goes out as one `Set-Cookie` +line per element, any other array as one line joined with `, `. + +```typescript +throw new HttpError(401, 'session expired', undefined, { + 'set-cookie': setCookie('session', '', { maxAge: 0 }), +}); +``` + +## Responses without a body + +Two statuses never carry a body, whatever produced them: + +- **`304`.** A `304` goes out with no body and none of the `content-*` headers, on every runtime — + including one from `throw new NotModified()`, which used to go out with the JSON envelope's + `content-type`. +- **`204` from a handler that returns `undefined`.** With the default JSON transformer, a handler + with no `return` answers `204 No Content`, with no body and no `content-type`. Return a value — + `null` included, which answers `200` with `null` — to keep a `200`. A route with its own + `@Transformer` decides for itself. + +That `undefined` is not an error. It used to be: `JSON.stringify(undefined)` is `undefined`, not a +string, so Node sent an empty `200` labelled `application/json`, and Deno, Bun and the edge answered +`500` for a handler that did nothing wrong. + ## Render errors your way — `onError` Not everything should be JSON. Pass `onError` to `createApp` to render errors however you like — HTML, [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807), content-negotiated, anything. It receives the error and the request, and returns a response — or `undefined` to fall back to the default JSON. diff --git a/src/content/docs/guides/html.md b/src/content/docs/guides/html.md index e8757b0..2170f9b 100644 --- a/src/content/docs/guides/html.md +++ b/src/content/docs/guides/html.md @@ -126,6 +126,7 @@ const app = createApp({ const app = createApp({ modules: [ApiModule], static: true }); // ./public, relative to cwd const app2 = createApp({ modules: [ApiModule], static: 'assets' }); // ./assets, relative to cwd const app3 = createApp({ modules: [ApiModule], static: '/srv/site' }); // absolute, used as-is +const app4 = createApp({ modules: [ApiModule], static: { dir: 'assets', cacheControl: 'public, max-age=3600' } }); ``` - Files are served at their URL path; content-type is inferred from the extension. @@ -152,6 +153,19 @@ One race stays open: a link swapped inside the root between the check and the re escape. Exploiting it needs write access to the served directory. ::: +### Caching + +Every static file is sent with a weak `ETag` and `Last-Modified` from its `stat`, and with +`Cache-Control: no-cache`, so browsers keep the file but ask before reusing it. A request whose +`If-None-Match` (or, failing that, `If-Modified-Since`) still matches answers `304` from the `stat` +alone, without reading the file; a `HEAD` never reads it either. The object form, +`static: { dir, cacheControl }`, replaces `no-cache` — `'public, max-age=31536000, immutable'` suits +content-hashed assets. An unknown key, or a `cacheControl` with CR or LF, throws at `createApp`. + +This changed in this release: static hits used to carry only `Content-Type` and were always sent in +full. Details, and the migration note, are in +[Caching and compression](/docs/guides/caching-and-compression/#static-files-are-revalidated). + Like the file/template modes of `@Html`, `static` needs a filesystem: it's Node/Deno/Bun only. Configuring it on an edge runtime throws at `createApp()` time — see [Runtimes](/docs/guides/runtimes/). ## The boot rule diff --git a/src/content/docs/guides/introspection.md b/src/content/docs/guides/introspection.md index 7cf0408..3a3c277 100644 --- a/src/content/docs/guides/introspection.md +++ b/src/content/docs/guides/introspection.md @@ -1,6 +1,6 @@ --- title: Graph introspection -description: "The graph is data. Explain one route, walk the structure, or render it as Mermaid or DOT." +description: "The graph is data. Explain one route, walk the structure, render it as Mermaid or DOT, and serve liveness and readiness probes." --- Because the request/boot pipeline is an explicit [dependency graph](/docs/concepts/the-graph/), @@ -105,8 +105,60 @@ optional dependency that failed to initialize but did not abort boot. const down = app.degraded(); // → ['cache'] ``` -It is a health signal: surface it on a readiness endpoint so partial availability is something -you report rather than something a user discovers. +It is a health signal: partial availability should be something you report rather than something +a user discovers. The built-in readiness probe below already reports it, so you do not need an +endpoint of your own for it. + +## Health probes — `createApp({ health })` + +`health: true` serves two endpoints for load balancers and orchestrators: + +```typescript +const app = createApp({ modules: [ApiModule], health: true }); +// GET /__live__ → 200 {"status":"ok"} +// GET /__ready__ → 503 {"status":"booting"} … 200 {"status":"ok"} … 503 {"status":"draining"} +``` + +- **`/__live__`** answers `200 {"status":"ok"}` whenever the process answers. It says the process is + up, nothing more. +- **`/__ready__`** says whether to send it traffic: + + | State | Response | + |---|---| + | booting — until `boot()` resolves | `503 {"status":"booting"}` | + | ready | `200 {"status":"ok"}` | + | ready, with degraded optional providers | `200 {"status":"degraded","degraded":["cache"]}` (from `app.degraded()`) | + | draining — from the moment `close()` starts | `503 {"status":"draining"}` | + + The state only moves forward: a boot that finishes after `close()` started stays draining. + `close()` here is Node's `app.close()` or the `close()` that `serveDeno()` / `serveBun()` return. + On the edge there is no `close()`, so `/__ready__` never reports `draining` there. + +Probes are answered by the adapter, ahead of `maxConcurrentRequests` and the route table, so a busy +server never sheds them, and they run no step, provider, plugin or `onError`. They also emit no +lifecycle event: `logRequests` and metrics plugins do not see them, which keeps a probe every few +seconds out of your request logs. Security headers still apply, plus `cache-control: no-store`. +Both paths answer `GET` and `HEAD`; any other method gets `405` with `Allow: GET, HEAD`. The match is +on the exact path, so a query string is ignored. + +While `health` is on, both paths are reserved: a route at `/__live__` or `/__ready__`, local or +spliced from a teapot, fails the boot. + +### Draining before shutdown — `drainDelayMs` + +```typescript +createApp({ modules: [ApiModule], health: { drainDelayMs: 5000 }, shutdownTimeoutMs: 15000 }); +``` + +A load balancer only stops routing to an instance after it has seen `/__ready__` fail. Without a +delay, Node closes idle keep-alive connections as soon as `close()` starts, so the probe usually +sees a refused connection rather than a `503`. `drainDelayMs` (default `0`) keeps the listener open +that long after `close()` starts, with `/__ready__` already answering `503`, before connections +drain. + +That window is spent out of the shutdown budget, not added to it. `createApp` throws when +`drainDelayMs` plus `teardownTimeoutMs` exceeds `shutdownTimeoutMs` (default 10 s), and on any +`health` value other than `true`, `false` or `{ drainDelayMs?: number }`. ## Related diff --git a/src/content/docs/guides/mesh.md b/src/content/docs/guides/mesh.md index 5751d97..b50f5e2 100644 --- a/src/content/docs/guides/mesh.md +++ b/src/content/docs/guides/mesh.md @@ -5,6 +5,8 @@ description: "Distributed dependency injection over a secret-gated WebSocket con :::caution[Alpha] Mesh is **alpha** — its API and wire protocol may change between releases, and discovery, load-balancing, and failover are not built. It is gated behind an explicit opt-in: `createApp({ mesh, experimental: true })`, and `createApp` **throws** if you configure `mesh` without `experimental: true`. **Don't ship mesh to production yet.** + +The mesh exports (`connectLink`, `createMeshControl`, `Manifest`, `MeshConfig` and the rest) are marked `@experimental` and sit outside the API freeze, so they may change in a minor release. ::: A **teacup** can depend on a token that physically lives on another node — a **teapot**. `@needs('billing')` resolves the same whether `billing` runs in this process or on a remote one. There's no gRPC layer or message-pattern DSL: there's the [graph](/docs/concepts/the-graph/), and some nodes happen to live elsewhere. @@ -140,6 +142,8 @@ something with behaviour, it has never worked — it has been failing at the cal Mesh proxies **buffered** endpoints. `@Sse`, `@Stream` and `@Ws` routes are not exportable: a remote route is registered as `transport: 'buffer'`, and a handler that returns an `AsyncIterable` over a mesh call fails with `cannot proxy a streaming route`. Streams are a live socket between client and server; there is no meaningful way to relay one through an RPC hop today. +`HEAD` on a remote `GET` route works like a local one: the teacup forwards it as a `GET` and drops the body, so the client gets the `GET`'s status and headers. An explicit remote `HEAD` route still takes precedence. + ## One teapot per route A route must be exported by exactly **one** teapot. If two export the same method and effective match shape — including patterns that differ only by parameter name, such as `/:id` and `/:name` — boot **fails**, naming both patterns and both teapots: @@ -168,8 +172,9 @@ A dead upstream is not a broken service, and the status says which: | What happened | Status | | --- | --- | -| The teapot never connected, so its routes were never registered | **404** Not Found — see [below](#when-a-teapot-is-not-there-yet) | -| The link is down (closed, or the heartbeat gave up) | **503** Service Unavailable | +| The teapot has not connected yet, so its routes are not registered | **404** Not Found, until it connects — see [below](#when-a-teapot-is-not-there-yet) | +| The link is down and reconnecting (closed, or the heartbeat gave up) | **503** Service Unavailable, with `retry-after` | +| The link gave up for good (`reconnect: false`, or a permanent refusal) | **404** Not Found — its routes were removed | | The link is up but the teapot didn't answer in `timeoutMs` | **504** Gateway Timeout | | The teapot answered with an error | whatever it said | @@ -192,15 +197,17 @@ mesh: { teapots: [...], reconnect: { initialDelayMs: 500, maxDelayMs: 30_000 } } mesh: { teapots: [...], reconnect: false } // fail once and stay down ``` -While a link is down its RPCs answer **503 immediately**, and a reconnected link is simply usable again on the next RPC. There is nothing to invalidate or re-register: a remote export holds nothing between requests, so there is no cached value a returning teapot could disagree with. +While a link is down its RPCs answer **503 immediately**, with `retry-after` set to the seconds until the next attempt, and `Allow` still lists its routes. A reconnected link is simply usable again on the next RPC. There is nothing to invalidate or re-register: a remote export holds nothing between requests, so there is no cached value a returning teapot could disagree with. `app.close()` is terminal for a link: one the application hung up on never reconnects. Otherwise closing an app would leave a process that cannot exit. +**A link that gives up has its routes removed.** That is a link dropped with `reconnect: false`, or one the teapot refuses permanently (a wrong secret, a protocol mismatch) while it is reconnecting. Its routes then answer **404** and leave `Allow`, because no 503 can promise that waiting helps. A refused reconnect used to retry forever; it now stops, with a warning that names the teapot. + **Reconnection is not failover.** It returns to the *same* teapot. Switching to a different one exporting the same thing needs load balancing, which is not built. ### A returning manifest that no longer backs the graph -The graph was validated at boot against the manifest the teapot announced then, and it is already serving requests. So if a teapot comes back exporting *less* — a token gone, a route withdrawn — the connection is **refused** rather than adopted: +The graph was validated at boot against the manifest the teapot announced then, and it is already serving requests. So if a teapot comes back without a **step** the graph was validated against, the connection is **refused** rather than adopted: ``` mesh: refusing to reconnect to ws://a/__mesh__/control — its manifest no longer exports @@ -210,7 +217,14 @@ a partial deploy. The link keeps retrying, because a rollback or a half-finished deploy can still restore it, and the refusal is logged once per distinct manifest rather than once per attempt. Serving against a manifest that no longer backs the graph would surface as a 500 that looks like your code. -Extra exports are the other direction and are simply **ignored**: the graph is fixed at boot, so new tokens are not spliced into a running app. +Extra steps are the other direction and are simply **ignored**: the graph is fixed at boot, so new tokens are not spliced into a running app. + +**Routes are not part of the graph, so a returning teapot's routes replace its old ones.** A route it added is served; one it dropped answers `404`. A missing route never refuses the connection. Two cases refuse a single route instead, logged with a warning while the teapot's other routes keep serving: + +- it conflicts with a route another teapot already serves (the same shape, or an [overlapping constraint](#one-teapot-per-route)); +- it sits on a reserved path: `/__mesh__/control`, or `/__live__` and `/__ready__` with `health` on. + +At boot either one still fails the boot. Every change to a teapot's routes emits `mesh:routes` with `{ url, added, removed }` ([lifecycle events](/docs/guides/observability/)). This is a named policy rather than implicit behaviour: @@ -229,11 +243,13 @@ mesh: { teapots: [...], bootTimeoutMs: 30_000 } // the default is timeoutMs mesh: { teapots: [...], bootTimeoutMs: 0 } // one attempt, no grace ``` -Within that budget the connection is retried with backoff. When it passes, the teacup **warns and starts without that teapot**. It can, because every export is a step or a proxied route: nothing needed that link resolved *at boot*, and a step is nothing but later. +Within that budget the connection is retried with backoff. When it passes, the teacup **warns and starts without that teapot**, and keeps retrying it in the background with the `reconnect` backoff (not with `reconnect: false`). It can, because every export is a step or a proxied route: nothing needed that link resolved *at boot*, and a step is nothing but later. Starting is not degrading, though, and the difference is worth stating exactly. A teapot that never connected sent no manifest, so the teacup learned nothing about it — no step runners, no routes, a graph identical to the one it would have had if that teapot were never configured. Its routes therefore **404**, through the ordinary unmatched-route path, because nothing was ever registered to match. **503** is what a teapot that connected and *later* died answers: that link exists, its steps and routes are registered, and the dead link is what returns the status. Never reachable and reachable-then-gone are different situations, and they read differently on purpose. -The warning names the teapot and says no manifest was exchanged, so a later 404 on one of its routes has a line to point back to: +**When a late teapot connects, its routes are served, with no restart. Its steps are not added**, because the graph was validated at boot without them. A local node that needed one of them has already failed the boot, as described below, so nothing that is running depends on them. + +The warning names the teapot and says no manifest was exchanged, so a 404 on one of its routes before it connects has a line to point back to: ``` mesh: teapot ws://a/__mesh__/control unreachable after 7 attempt(s) over 30000ms (…) — starting @@ -244,7 +260,7 @@ needs one of its tokens. This line is what a later 404 on one of its routes poin That last clause is the one thing that still stops a deploy: a local step or handler that `@needs` a token only that teapot exported **fails the boot**, naming the teapot that did not connect. So a teacup that does not depend on an absent teapot starts, and one that does still fails where you can see it. To make a teapot's absence fatal on purpose, have something local need one of its tokens. -**A refusal is not retried.** A wrong secret or a protocol-version mismatch is the teapot's decision and will be the same decision in thirty seconds, so it fails at once rather than spending the whole budget to reach an identical error. The two are told apart by whether the socket ever opened: a peer that accepted the connection and then hung up rejected you on purpose, while one that never accepted it may simply not be listening yet. +**A refusal is not retried.** A wrong secret or a protocol-version mismatch is the teapot's decision and will be the same decision in thirty seconds, so it fails at once rather than spending the whole budget to reach an identical error. The same refusal while a link is reconnecting ends the link, as [above](#reconnecting). The two are told apart by whether the socket ever opened: a peer that accepted the connection and then hung up rejected you on purpose, while one that never accepted it may simply not be listening yet. Every retry is logged *and* emitted as `mesh:boot:retry`, so a slow boot is visible to whatever collects [lifecycle events](/docs/guides/observability/) and not only to whoever happens to be watching a terminal. It is kept separate from `mesh:disconnect`, which means a link that was up and went away — this one never came up. @@ -261,7 +277,9 @@ Both are **optional on the wire**, which is why the protocol version did not mov The channel is authenticated, but an unauthenticated peer still gets to send bytes at it, so two limits bound what it can do before proving anything: - **A handshake deadline.** A peer that connects and never sends `hello` is hung up on after 10 seconds. The teacup has always bounded its side; this is the teapot's. -- **A frame size cap.** `decode` runs `JSON.parse` on peer-controlled input, so frames above 4,000,000 characters are refused with close code `1009` — checked *before* parsing, since parsing is the expensive part being defended. The cap sits above the 1 MB default body limit a legitimate RPC can carry. +- **Frame size and queue caps, tighter before `hello`.** `decode` runs `JSON.parse` on peer-controlled input, so an oversized frame is refused with close code `1009` *before* parsing, since parsing is the expensive part being defended. Until the peer's `hello` is accepted, the channel takes frames of at most 64 KiB and queues at most 16, closing `1009` or `1013` past that, so an unauthenticated peer can make a teapot hold about 1 MiB per connection. Once `hello` is accepted, a frame may be up to `6 × limits.maxBodyBytes + 1 MiB` (7,048,576 bytes at the 1 MB default) — six times because JSON escaping can grow a body that much, plus room for the envelope — and the queue holds 4,096 frames, or `maxWsQueue` if larger. These bounds are the control channel's own; user `@Ws` routes keep `maxWsMessageBytes` and `maxWsQueue`. + + On Node a peer that sends more than 64 KiB before its `hello` is dropped before `ws` assembles the frame. Bun and Deno offer no per-socket limit to hold that to, so there an unauthenticated peer can still make the teapot receive one frame up to the post-`hello` bound before it is refused. If untrusted peers can reach a Bun or Deno teapot's `/__mesh__/control`, limit frame size at the proxy in front of it. :::caution[The secret travels in the handshake] The shared secret is sent verbatim in the `hello` frame. Over `ws://` that puts it in front of anyone on the path, so **use `wss://`** unless the link already runs inside an encrypted network. green-tea warns at boot for a `ws://` teapot that is not on loopback — a warning rather than a refusal, because a private network doing its own mutual TLS is a real deployment and the framework cannot tell the two apart. @@ -274,5 +292,5 @@ Peers are separate processes on separate deploy cadences, so the wire is version **The version is a compatibility boundary, not a changelog.** It moves only when a peer on the old version would *misparse a frame or misbehave silently* — a field removed, renamed or retyped, a new **required** field, or a new frame type that expects an answer the old peer cannot decode. Adding an **optional** field is none of those, so it does not move the number: `decode` validates only what a frame type requires and passes extras through. Bumping per change would make the number mean "work happened" rather than "we are incompatible", which is the one thing it exists to say. :::note[Skeleton limitations (by design)] -No discovery, load-balancing, or failover yet. A teapot that is down when a teacup *boots* is started without — its routes 404 and nothing stands in for it — and reconnection covers only links that connected at least once. A returning manifest is refused rather than reconciled, and extra exports in it are not spliced into the running graph. +No discovery, load-balancing, or failover yet. A teapot that is down when a teacup *boots* is started without — its routes 404 until it connects, and its steps are never added. A returning manifest missing a step is refused rather than reconciled, and extra steps in it are not spliced into the running graph; its routes replace the old ones. ::: diff --git a/src/content/docs/guides/observability.md b/src/content/docs/guides/observability.md index 7db2c4d..0d5ddfb 100644 --- a/src/content/docs/guides/observability.md +++ b/src/content/docs/guides/observability.md @@ -113,8 +113,13 @@ evidence. `bus` is also a reserved token name, so nothing you declare can quietl | `stream:open` / `:close` / `:error` | an SSE, ndjson or WebSocket connection — see [below](#a-stream-is-not-a-request) | | `mesh:connect` / `:disconnect` / `:rpc:error` | mesh links | | `mesh:boot:retry` | a teapot that has not come up yet, still inside the boot grace ([mesh](/docs/guides/mesh/)) | +| `mesh:routes` | a teapot's routes changed after boot — it connected late, came back with different routes, or gave up — with `{ url, added, removed }`, each route as `'METHOD /pattern'` | | `plugin:mounted` | a plugin registered | +[Health probes](/docs/guides/introspection/#health-probes--createapp-health-) (`createApp({ health: true })`, at `/__live__` and `/__ready__`) emit no lifecycle event: they +are answered by the adapter ahead of the route table, so `logRequests` and metrics plugins do not see +them. + ### A stream is not a request `request:end` fires when the **handler returns**, not when a stream it produced finishes. A route diff --git a/src/content/docs/guides/openapi.md b/src/content/docs/guides/openapi.md index 7072e12..92720db 100644 --- a/src/content/docs/guides/openapi.md +++ b/src/content/docs/guides/openapi.md @@ -17,7 +17,7 @@ It reads the routes and handler signatures and produces, for each route: - the **path**, templated — `/users/:id` → `/users/{id}`, constrained `:id(\d+)` → `{id}`, catch-all `:path*` → `{path}`; - the **explicitly declared method**, including `@Head` and `@Options`; -- **parameters** — path params from the pattern, plus query/header params from the handler's `@query` / `@headers` / `@header` decorators; +- **parameters** — path params from the pattern, plus query, header and cookie params (`in: cookie`) from the handler's `@query` / `@headers` / `@header` / `@cookies` / `@cookie` decorators. Only named keys become parameters — `@cookie('session')` or `@cookies(['a','b'])`, not a bare `@cookies()`; - a **request body** entry for routes that read `@body()`; - the systematic **responses** — `200`, a `422` when any argument is schema-validated, and `500`. @@ -40,4 +40,4 @@ This is a **structural** spec: paths, methods, parameters, which routes take a b - **Request/response body schemas.** [Standard Schema](/docs/guides/validation/) (zod/valibot/arktype) validates input but does not expose a JSON Schema representation, so body shapes are documented generically. Parameter types default to `string`. -Full schemas — declared and enforced on the way out via a `@Produce(schema)` decorator that also validates responses — are on the roadmap for the stable release. For beta, the structural document already drives Swagger UI, client codegen, and API diffing. +Full schemas — declared and enforced on the way out via a `@Produce(schema)` decorator that also validates responses — are planned for after the stable release, since the API is frozen until then. Until they arrive, the structural document already drives Swagger UI, client codegen, and API diffing. diff --git a/src/content/docs/guides/runtimes.md b/src/content/docs/guides/runtimes.md index dc43164..36dbce1 100644 --- a/src/content/docs/guides/runtimes.md +++ b/src/content/docs/guides/runtimes.md @@ -79,6 +79,17 @@ same `@Ws` handlers, same rooms/channels everywhere. `app.listen()`, TLS and per remain Node-only; on Deno/Bun/edge you get `app.fetch` + the runtime's adapter. **Mesh (alpha) runs on Node, Deno and Bun** — teapot and teacup, in any combination — but not on edge. +Same API is not the same behaviour in every detail. The differences this page covers: + +- **Compression:** on the edge core never encodes and sets only `Vary`; Cloudflare applies the + encoding. Node, Deno and Bun encode in-process. +- **WebSocket outbound backpressure:** Node, Deno and Bun wait for a slow client's send buffer; + workerd reports no buffered amount, so the edge [never waits](#websocket-backpressure). +- **TLS** is Node-only: `serveDeno()` and `serveBun()` reject an app created with `createApp({ tls })` + before they boot or bind, rather than serving plain HTTP to someone who asked for HTTPS. +- **WebSocket message size** and **`ctx.ip`** are refused and resolved differently per runtime; see + [below](#websocket-message-size). + **Connection-limit enforcement is Node-only:** `limits.maxConnections` defaults to `1000`; values `<= 0` leave Node's connection count unlimited. Positive values map to Node's `server.maxConnections` when the app runs through `app.listen()`. When the cap is reached, Node @@ -140,7 +151,9 @@ resolves and the effect is the same; keep serving and it is not. **Body-size enforcement is the same on every runtime.** `maxBodyBytes` is checked while the body is read: a `Content-Length` that already declares more than the limit is refused before any of the body is read, and otherwise reading stops at the chunk that crosses it, so an oversized upload is -never buffered in full. The answer is the same `413` everywhere. The one difference is on workerd, +never buffered in full. (On Deno, Bun and the edge, a well-formed `Content-Length` within the limit +is read in one `arrayBuffer()`, since the runtime frames the body by that header; only a chunked +upload takes the chunk-by-chunk read.) The answer is the same `413` everywhere. The one difference is on workerd, which may drop `Content-Length` from a chunked upload, so there the refusal comes from the running count rather than up front. See [uploads](/docs/guides/uploads/#limits). @@ -166,11 +179,13 @@ run on Node. Behaviour matches the Node reference. **Advanced:** `app.upgrade(request, socket)` is the neutral primitive `serveDeno` uses. Any runtime can build a `WsSocket` capability (`inbound`, `abort`, `isOpen`, `send`, -`close`, `terminate`) and drive the graph — `serveBun` and `edgeHandler` build on the -same primitive (see below). +`close`, `terminate`, and optionally `drained`) and drive the graph — `serveBun` and `edgeHandler` +build on the same primitive (see below). `drained()` resolves once the socket's send buffer is at or +below the adapter's high-water mark, or the socket has closed; the outbound pump awaits it before +each pull. A socket built without it is never waited on. **Still Node-only:** `app.listen()`, TLS and per-request timeouts. On Deno you get -`app.fetch` + `serveDeno`. [Mesh](/docs/guides/mesh/) works here: the control channel is +`app.fetch` + `serveDeno`, and `serveDeno()` rejects an app created with `tls`. [Mesh](/docs/guides/mesh/) works here: the control channel is served through `app.upgrade` and the teacup connects with Deno's global `WebSocket`. ## WebSocket on Bun @@ -194,7 +209,7 @@ await serveBun(app, { port: 8000 }); run on Node and Deno. Behaviour matches the Node reference. **Still Node-only:** `app.listen()`, TLS and per-request timeouts. On Bun you get -`app.fetch` + `serveBun`. [Mesh](/docs/guides/mesh/) works here too. +`app.fetch` + `serveBun`, and `serveBun()` rejects an app created with `tls`. [Mesh](/docs/guides/mesh/) works here too. ## Cloudflare Workers / edge @@ -241,7 +256,7 @@ every runtime, but only Node and Bun let the server refuse a message before it i | Runtime | Oversized message refused | The client sees | |---|---|---| | Node | before buffering (`ws`'s `maxPayload`) | `1009` | -| Bun | before buffering (Bun's `maxPayloadLength`) | `1006`: Bun drops the connection without a close frame | +| Bun | before buffering (Bun's `maxPayloadLength`) | `1009` on Bun 1.4 and later; `1006` before 1.4, which drops the connection without a close frame | | Deno | after the whole message is received | `1009` | | edge (workerd) | after the whole message is received | `1009` | @@ -249,6 +264,23 @@ Deno and workerd offer no per-message size option, so there the message is held the limit is checked, and only the platform's own cap bounds it. `maxWsQueue` behaves the same on all four. +A Bun app that serves as a mesh teapot raises Bun's native limit to the control channel's larger +bound, so on a user route the framework refuses an oversized message instead, with `1009` on every +Bun version. + +## WebSocket backpressure + +A `@Ws` handler's outbound iterator is pulled only while the socket's send buffer is at or below +1 MiB, so a slow client cannot grow it without limit (see +[streaming](/docs/guides/streaming/#outbound-backpressure)). Each runtime signals in its own way: + +| Runtime | How the wait ends | +|---|---| +| Node | `ws`'s `bufferedAmount` and send callback | +| Bun | `getBufferedAmount()` and the `drain` callback | +| Deno | polls `bufferedAmount`, since its `WebSocket` has no drain event | +| edge (workerd) | **never waits**: workerd reports no buffered amount, so buffering is left to the platform | + ## The client address — `ctx.ip` `serveDeno()` and `serveBun()` give `app.fetch` the socket peer, so `ctx.ip` is populated on Deno and @@ -256,19 +288,35 @@ Bun as it is on Node. All three follow one rule: with `trustProxy` on and an `X- header present, `ctx.ip` is the header's first hop; otherwise it is the socket peer. That includes `trustProxy` with no `X-Forwarded-For`, which falls back to the peer. +`ctx.ip` is resolved the first time a step, a handler or a plugin reads it, and kept for the rest of +the request; a route that never reads it pays nothing. What reading it costs depends on the runtime: + +| Runtime | Where the address comes from | Cost when read | +|---|---|---| +| Node | `req.socket.remoteAddress`, cached by Node per connection | negligible | +| Deno | `info.remoteAddr`, handed over by `Deno.serve` | negligible | +| Bun | `server.requestIP()`, a native call per request | 7–11% of a small request | +| edge | the `cf-connecting-ip` header, under `trustProxy` | negligible | + +Behind a proxy that sets `X-Forwarded-For`, `trustProxy: true` takes the address from the header on +every runtime and never asks the socket. Inside a stream body `ctx.ip` is `''` unless a step or the +handler read it before returning the stream, because the socket may already be gone by then. + Addresses are passed through as each runtime reports them. The IPv6 loopback is `::1` on all three, but Node may report an IPv4 client in IPv4-mapped form, such as `::ffff:127.0.0.1`. Normalize before comparing addresses across runtimes. -Two cases have no socket to report, and `ctx.ip` is `''` there (or the forwarded hop, with -`trustProxy`): - -- `app.fetch` handed to a server directly, as in `Deno.serve(app.fetch)` or - `Bun.serve({ fetch: app.fetch })`, or called from your own code. Only `serveDeno()` and - `serveBun()` pass the peer along. A `Request` copied from the one the adapter received carries - nothing either. -- `edgeHandler`. Trusting `CF-Connecting-IP` is only correct behind Cloudflare, - so the edge adapter does not read it. +`app.fetch` handed to a server directly, as in `Deno.serve(app.fetch)` or +`Bun.serve({ fetch: app.fetch })`, or called from your own code, has no socket to report, so +`ctx.ip` is `''` there (or the forwarded hop, with `trustProxy`). Only `serveDeno()` and `serveBun()` +pass the peer along. A `Request` copied from the one the adapter received carries nothing either. + +`edgeHandler` has no peer address either: workerd exposes none. Under `createApp({ trustProxy: true })` +it takes `ctx.ip` from `CF-Connecting-IP`, ahead of `X-Forwarded-For`, because Cloudflare sets that +header itself while `X-Forwarded-For` can carry hops the client supplied. This applies to HTTP and +WebSocket upgrades alike. Without `trustProxy` the header is ignored and `ctx.ip` is `''`, since +trusting it is only correct behind Cloudflare. Only `edgeHandler` reads it; the Node, Deno and Bun +adapters, and a bare `app.fetch`, ignore it. Anything keyed on `ctx.ip`, such as a rate limiter, needs one of the populated paths. Otherwise every client lands in the same bucket. diff --git a/src/content/docs/guides/streaming.md b/src/content/docs/guides/streaming.md index d227df1..c5e1b7e 100644 --- a/src/content/docs/guides/streaming.md +++ b/src/content/docs/guides/streaming.md @@ -123,6 +123,14 @@ cleanly, and emits `stream:error` on the [bus](/docs/guides/observability/). A c an `error` event knows what happened; a connection that merely dropped tells it nothing, which is why the frame comes first. +### `ctx.ip` inside a stream body + +`ctx.ip` is worked out the first time something reads it, not on every request. A stream body runs +after the response has started, when the socket the address would come from may already be gone, so +**inside a stream body `ctx.ip` is `''`** unless a step or the handler read it before returning the +stream. A route that needs the address in a stream reads it first, as rate-limit already does. See +[Runtimes](/docs/guides/runtimes/#the-client-address--ctxip) for what reading it costs. + ## Streaming — WebSocket duplex A WS handler receives the **inbound** channel and returns the **outbound** channel — a step that consumes one channel and produces another. @@ -169,7 +177,9 @@ const app = createApp({ Nothing is dropped silently: the client learns which bound it hit from the close code. Both values must be positive integers, or `createApp` throws a `TypeError`. There is no per-route override, and -the bounds apply to every inbound WebSocket the server accepts, the mesh control channel included. +the bounds apply to every `@Ws` and `@Stream` WebSocket the server accepts. The mesh control channel +starts under tighter bounds and has its own larger ones once its peer authenticates; see +[mesh](/docs/guides/mesh/#keeping-the-control-channel-honest). **The queue is per consumer.** Each iterator over `@inbound()` gets its own queue, receiving every message from the moment it starts reading, and `maxWsQueue` applies to each one. A message that @@ -183,8 +193,8 @@ channel, or raise `maxWsQueue`, if your protocol lets the client send without wa **How early a large message is refused depends on the runtime.** Node and Bun refuse it before buffering it. Deno and the edge receive it in full and then close, so their memory is bounded by the -platform's own cap. Bun closes without a close frame, so its client sees `1006` instead of `1009`. -See [Runtimes](/docs/guides/runtimes/#websocket-message-size). +platform's own cap. Bun before 1.4 closes without a close frame, so its client sees `1006` instead +of `1009`; Bun 1.4 and later send `1009`. See [Runtimes](/docs/guides/runtimes/#websocket-message-size). **Worst case.** Buffered inbound memory can reach about `maxConnections × maxWsQueue × maxWsMessageBytes`. At the Node defaults that is @@ -192,7 +202,7 @@ See [Runtimes](/docs/guides/runtimes/#websocket-message-size). to untrusted clients. `maxConnections` is enforced only on Node. Deno, Bun and the edge have no connection cap of their own, so set one at the platform or reverse proxy there. -:::caution[Breaking after 26.9.0-beta.2] +:::caution[Breaking in RC.1] Before these limits, Node accepted messages up to `ws`'s own default of 100 MiB, Deno, Bun and the edge applied no cap, and the inbound queue was unbounded on every runtime. Now a message over 1 MiB closes with `1009`, and a connection holding 16 unread messages closes on the 17th with @@ -201,6 +211,17 @@ discarded, and now they count. If your clients send larger messages or bursts, r `maxWsMessageBytes` or `maxWsQueue`. ::: +### Outbound backpressure + +The values a `@Ws` handler yields are pulled only as fast as the client reads them. Before each pull +the framework waits while the socket's send buffer is over 1 MiB, an internal mark rather than an +option, so a slow client holds at most about that plus one frame. A client that keeps up never makes +it wait. If the client goes away during the wait, the wait ends and the iterator's `return()` runs, as +on any other close. + +Node, Bun and Deno each report their send buffer, so the wait works there. **workerd reports no +buffered amount, so on the edge the framework never waits** and buffering is left to the platform. + ## Fan-out — `channel()` and `rooms` Fan-out is a primitive too: `channel()` is a **multicast** `AsyncIterable` (bounded, drop-oldest) so one source feeds many subscribers, and `rooms` are named broadcast hubs — publish once, every connection in the room receives it. diff --git a/src/content/docs/guides/uploads.md b/src/content/docs/guides/uploads.md index da48f18..a7a7c1a 100644 --- a/src/content/docs/guides/uploads.md +++ b/src/content/docs/guides/uploads.md @@ -73,7 +73,10 @@ A route's `maxBodyBytes` / `maxParts` fall back to the server-wide `limits` when - A `Content-Length` that already declares more than the limit is refused with `413` before any of the body is read. - Otherwise the body is read chunk by chunk, and reading stops at the chunk that crosses the limit. - The rest of the upload is not pulled, so a 10 GB upload is not buffered before it is refused. + The rest of the upload is not pulled, so a 10 GB upload is not buffered before it is refused. On + Deno, Bun and the edge, a well-formed `Content-Length` within the limit is read in one + `arrayBuffer()` instead, because the runtime frames the body by that header; only a chunked upload + takes the chunk-by-chunk read. - A malformed or absent `Content-Length` proves nothing and is ignored. The running count enforces the limit either way. From 3ea998ac27be40587a9665dabeaaac6f2477166a Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 09:56:18 -0500 Subject: [PATCH 4/8] docs(reference): list ResponseShape with the experimental mesh types Core tags it @experimental. It is public only through the mesh signatures. Signed-off-by: Diego Resendez --- src/content/docs/reference/createapp.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/content/docs/reference/createapp.md b/src/content/docs/reference/createapp.md index 83d81fd..ce93218 100644 --- a/src/content/docs/reference/createapp.md +++ b/src/content/docs/reference/createapp.md @@ -137,7 +137,6 @@ import type { AppOptions, ExplainNode, WsRequest, WsSocket } from '@green-tea/co | `ArgSource` | `@green-tea/core` | where an argument decorator reads from (`ArgSpec.source`) | | `GraphNodeView`, `GraphRouteView` | `@green-tea/core` | a node and a route in the `GraphView` that `app.graph()` returns | | `StandardIssue`, `StandardResult` | `@green-tea/core` | the issue and result shapes of [Standard Schema](/docs/guides/validation/) | -| `ResponseShape` | `@green-tea/core` | a materialized response, `{ status, headers, body }`, with `headers` values `string \| string[]` | | `DenoServeOptions`, `DenoHttpServer` | `@green-tea/core/deno` | the options of `serveDeno()`, and the server the `DenoServer` it resolves to extends | | `BunServeOptions`, `BunServer` | `@green-tea/core/bun` | the options of `serveBun()`, and the server the `BunServeResult` it resolves to extends | | `EdgeCtx` | `@green-tea/core/edge` | the Workers execution context (`waitUntil`) that the handler returned by `edgeHandler` takes as its third argument | @@ -149,6 +148,6 @@ outside the [API freeze](/docs/stability/#mesh-is-outside-the-promise), so they release. `MeshConfig`, `Link`, `ReconnectOptions`, `ManifestPolicy`, `Manifest`, `RequestEnvelope`, `Frame`, -`RouteEntry`, `RemoteScopeNode`, `RemoteRoute`, `MeshControlDeps`, `SocketCtor` and -`EventfulSocket`, along with the functions and constants `connectLink`, `buildRemote`, +`RouteEntry`, `RemoteScopeNode`, `RemoteRoute`, `MeshControlDeps`, `ResponseShape` (a materialized +response, public only through those mesh signatures), `SocketCtor` and `EventfulSocket`, along with the functions and constants `connectLink`, `buildRemote`, `envelopeFrom`, `buildManifest`, `createMeshControl` and `MESH_CONTROL_PATH`. From c7e8bafa315002ad5be2a8279431aa431b6f6d9b Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 10:16:32 -0500 Subject: [PATCH 5/8] docs(stability): mesh adapts to the stable API and never changes it Mesh is alpha and may change in any release. The mesh guide said 'a minor release', so make it consistent. Its changes stay inside its own surface: the @experimental exports, MeshConfig, and the mesh:* events. Code that does not use mesh is never affected by a mesh change. Signed-off-by: Diego Resendez --- src/content/docs/guides/mesh.md | 2 +- src/content/docs/reference/createapp.md | 2 +- src/content/docs/stability.md | 12 +++++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/content/docs/guides/mesh.md b/src/content/docs/guides/mesh.md index b50f5e2..7ea60c2 100644 --- a/src/content/docs/guides/mesh.md +++ b/src/content/docs/guides/mesh.md @@ -6,7 +6,7 @@ description: "Distributed dependency injection over a secret-gated WebSocket con :::caution[Alpha] Mesh is **alpha** — its API and wire protocol may change between releases, and discovery, load-balancing, and failover are not built. It is gated behind an explicit opt-in: `createApp({ mesh, experimental: true })`, and `createApp` **throws** if you configure `mesh` without `experimental: true`. **Don't ship mesh to production yet.** -The mesh exports (`connectLink`, `createMeshControl`, `Manifest`, `MeshConfig` and the rest) are marked `@experimental` and sit outside the API freeze, so they may change in a minor release. +The mesh exports (`connectLink`, `createMeshControl`, `Manifest`, `MeshConfig` and the rest) are marked `@experimental` and sit outside the API freeze, so they may change in any release. A mesh change stays inside mesh: it never alters the stable API around it. See [API stability](/docs/stability/#mesh-is-alpha-and-outside-the-promise). ::: A **teacup** can depend on a token that physically lives on another node — a **teapot**. `@needs('billing')` resolves the same whether `billing` runs in this process or on a remote one. There's no gRPC layer or message-pattern DSL: there's the [graph](/docs/concepts/the-graph/), and some nodes happen to live elsewhere. diff --git a/src/content/docs/reference/createapp.md b/src/content/docs/reference/createapp.md index ce93218..2dd08db 100644 --- a/src/content/docs/reference/createapp.md +++ b/src/content/docs/reference/createapp.md @@ -144,7 +144,7 @@ import type { AppOptions, ExplainNode, WsRequest, WsSocket } from '@green-tea/co ### Mesh types — experimental These are exported from `@green-tea/core` and marked `@experimental`, like the rest of the mesh: -outside the [API freeze](/docs/stability/#mesh-is-outside-the-promise), so they may change in any +outside the [API freeze](/docs/stability/#mesh-is-alpha-and-outside-the-promise), so they may change in any release. `MeshConfig`, `Link`, `ReconnectOptions`, `ManifestPolicy`, `Manifest`, `RequestEnvelope`, `Frame`, diff --git a/src/content/docs/stability.md b/src/content/docs/stability.md index aa43337..d01e5e0 100644 --- a/src/content/docs/stability.md +++ b/src/content/docs/stability.md @@ -52,7 +52,7 @@ The core repository commits an API report for each entry point under fails when the build no longer matches it. A deliberate change regenerates the report in the same pull request, so a reviewer reads the API change as a diff instead of finding it after the release. -## Mesh is outside the promise +## Mesh is alpha and outside the promise [Mesh](/docs/guides/mesh/) is `@experimental` and gated behind `createApp({ experimental: true })`, so it may change in any release, including between release candidates. Its exports — `MeshConfig`, @@ -60,6 +60,16 @@ so it may change in any release, including between release candidates. Its expor change still shows up in review. The [createApp reference](/docs/reference/createapp/#importable-types) marks which exported types are mesh. +**Mesh adapts to the stable API; it does not change it.** Mesh's own surface is: + +- its `@experimental` exports; +- the shape of `MeshConfig` behind the `mesh` option; +- the `mesh:*` lifecycle events. + +A mesh change can alter those and nothing else. It cannot add a `createApp` option, an `App` method or a +decorator, and it cannot change a stable type. If mesh needs something the stable API does not +offer, it builds on what is there. So code that does not use mesh is never affected by a mesh change. + ## Currently deprecated | Deprecated | Use instead | Since | Notes | From 3375d6c9580059be5c2a3b79139424d8c10ac961 Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 10:27:01 -0500 Subject: [PATCH 6/8] docs(reference): point createApp at the reserved paths CARD-21's criterion names the createApp reference as the place a reader finds the reserved paths. The detail lives in the routing guide, so the reference lists them and links there. Signed-off-by: Diego Resendez --- src/content/docs/reference/createapp.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/content/docs/reference/createapp.md b/src/content/docs/reference/createapp.md index 2dd08db..76c70ff 100644 --- a/src/content/docs/reference/createapp.md +++ b/src/content/docs/reference/createapp.md @@ -44,6 +44,16 @@ The options object is the exported `AppOptions` interface. Everything except `mo | `experimental` | `boolean` | `false` | opt in to alpha features (currently gates `mesh`) | | `warnGraphDepth` | `number \| false` | `20` | warn when one route resolves to more than this many steps; `false` disables the design warning | +### Reserved paths + +Some paths belong to core, and a route that claims one fails `createApp`: + +- `/__mesh__/control` is reserved in every app, with or without `mesh`. +- `/__live__` and `/__ready__` are reserved while `health` is on. + +[Routing → Reserved paths](/docs/guides/routing/#reserved-paths) has the details, including how a +WebSocket upgrade to the mesh path is closed. + ### `RequestLimits` | Field | Default | From eb8b8b6c9ad7d2cb4f52fbb773d0536d9546136a Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 11:02:24 -0500 Subject: [PATCH 7/8] docs: four statements the RC.1 code contradicts Found while writing core's llms.txt and checked against src: - An override for an unknown token fails at boot (or on the first fetch, which rejects), not when createApp runs. - A @Step cannot declare needs: ['cookies']; it parses ctx.headers.cookie itself. Handlers can use @needs('cookies'). - events is a built-in node, alongside logger and rooms. - app.listen() returns a promise that rejects when the port is taken, so the examples await it. Signed-off-by: Diego Resendez --- src/content/docs/getting-started.md | 2 +- src/content/docs/guides/arguments.md | 5 +++-- src/content/docs/guides/introspection.md | 2 +- src/content/docs/guides/runtimes.md | 2 +- src/content/docs/guides/testing.md | 3 ++- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/content/docs/getting-started.md b/src/content/docs/getting-started.md index 8da856c..4849ace 100644 --- a/src/content/docs/getting-started.md +++ b/src/content/docs/getting-started.md @@ -89,7 +89,7 @@ class ApiModule {} const app = createApp({ modules: [ApiModule] }); console.log(app.explain('/api/users/:id')); // auditable: the ordered chain, with origins -app.listen(3000); +await app.listen(3000); // rejects if the port is taken or a provider fails to boot ``` ```bash diff --git a/src/content/docs/guides/arguments.md b/src/content/docs/guides/arguments.md index ba6279a..54f2bf5 100644 --- a/src/content/docs/guides/arguments.md +++ b/src/content/docs/guides/arguments.md @@ -98,8 +98,9 @@ It also accepts a schema in slot 2 — `@header('x-count', schema)`. `@cookies()` injects the request's cookies as a name → value map (`{}` when the request sent none); `@cookie('name')` picks one, and is `undefined` when it is absent. Both accept the same -forms as `@headers`, including a schema. `@needs('cookies')` gives the same map, for a step that -reads cookies. +forms as `@headers`, including a schema. In a handler, `@needs('cookies')` gives the same map. A +`@Step` cannot declare `needs: ['cookies']` (that fails `createApp`). A step that reads cookies +parses them itself with `parseCookies(ctx.headers.cookie)`. ```typescript import { Route, Get, cookie, setCookie } from '@green-tea/core'; diff --git a/src/content/docs/guides/introspection.md b/src/content/docs/guides/introspection.md index 3a3c277..025b9ac 100644 --- a/src/content/docs/guides/introspection.md +++ b/src/content/docs/guides/introspection.md @@ -55,7 +55,7 @@ const view = app.graph(); Two things worth knowing before you assert against it: -- **The framework contributes its own nodes.** `logger` and `rooms` appear with +- **The framework contributes its own nodes.** `logger`, `events` and `rooms` appear with `origin: 'builtin'` even in an application that declared neither. Filter on `origin` when you mean "the nodes I wrote". - `routes[].chain` holds node **names**, while `explain()` returns the resolved nodes. Use diff --git a/src/content/docs/guides/runtimes.md b/src/content/docs/guides/runtimes.md index 36dbce1..cb56042 100644 --- a/src/content/docs/guides/runtimes.md +++ b/src/content/docs/guides/runtimes.md @@ -10,7 +10,7 @@ same app runs on any runtime that speaks the Fetch API — for **HTTP and SSE** const app = createApp({ modules: [ApiModule] }); // Node (native listener, TLS, timeouts) -app.listen(3000); +await app.listen(3000); // rejects if the port is taken or a provider fails to boot // Deno Deno.serve(app.fetch); diff --git a/src/content/docs/guides/testing.md b/src/content/docs/guides/testing.md index b9ff592..ae753dc 100644 --- a/src/content/docs/guides/testing.md +++ b/src/content/docs/guides/testing.md @@ -26,7 +26,8 @@ route still runs its full closure — you've just substituted what one node prod :::note An override for an unknown token throws. The set of tokens is validated against the real -graph, so a typo'd or stale override is a construction-time error, not a silent no-op. +graph when the app boots, so a typo'd or stale override fails `app.boot()`, or the first +`app.fetch()` (which rejects rather than answering 500). It is never a silent no-op. ::: This pairs well with [`app.explain(route)`](/docs/guides/introspection/) — explain the chain to From 8b6fcf23bc21e46ae1f60a4e9053ea2cf65ad580 Mon Sep 17 00:00:00 2001 From: Diego Resendez Date: Fri, 30 Oct 2026 11:23:21 -0500 Subject: [PATCH 8/8] docs: provide()/run() must return their token; a handler may not return a Response These document core's fix for both mistakes, which used to pass silently. A provider that returns its value bare now fails the boot, a step fails the request, and a returned Response fails the request instead of going out as 200 {}. Signed-off-by: Diego Resendez --- src/content/docs/guides/dependency-injection.md | 11 ++++++++++- src/content/docs/guides/errors.md | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/content/docs/guides/dependency-injection.md b/src/content/docs/guides/dependency-injection.md index b728f9c..a882b1b 100644 --- a/src/content/docs/guides/dependency-injection.md +++ b/src/content/docs/guides/dependency-injection.md @@ -153,6 +153,14 @@ class Database { Because it's app-scope, a provider is the right home for things you build once and share: database clients, config, connection pools. +:::caution[Return the token, not the value] +`provide()` must return an object holding the token it provides: `return { db: client }`, not +`return client`. Anything else fails the boot and names the fix, for example `provider 'db' returned +an object with 'query', but it must return an object holding what it provides: return { db: }`. +The same rule holds for a step's `run()`, which fails the request instead, and for a plugin node, once +for every token in its `provides`. A key whose value is `undefined` still counts. +::: + :::note An optional provider (`optional: true`) that throws on boot does **not** abort startup — it is left unregistered and logged, and the failure surfaces at request time for routes that need it. @@ -163,7 +171,8 @@ Query the degraded set with `app.degraded()` or listen for the `boot:provider:fa A step runs once per request. Its `run(ctx)` receives the accumulated context and either: -- **returns an object** → the pipeline continues and the object is merged into `ctx`, or +- **returns an object holding the token it provides** (`{ user }`) → the pipeline continues and the + object is merged into `ctx`, or - **throws** → the request short-circuits (here `Unauthorized` becomes a `401`). ```typescript diff --git a/src/content/docs/guides/errors.md b/src/content/docs/guides/errors.md index 3eebdcc..909eded 100644 --- a/src/content/docs/guides/errors.md +++ b/src/content/docs/guides/errors.md @@ -67,6 +67,13 @@ That `undefined` is not an error. It used to be: `JSON.stringify(undefined)` is string, so Node sent an empty `200` labelled `application/json`, and Deno, Bun and the edge answered `500` for a handler that did nothing wrong. +## A handler returns a value, not a `Response` + +A handler returns a plain value, and its transformer turns that into the response. A Fetch +`Response` is not a value. A handler that returns one fails the request with a `TypeError` instead of +going out as `200 {}`, which is what JSON-encoding a `Response` produces. To set the status or +headers, use `@Transformer`. For an error status, throw an `HttpError`. + ## Render errors your way — `onError` Not everything should be JSON. Pass `onError` to `createApp` to render errors however you like — HTML, [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807), content-negotiated, anything. It receives the error and the request, and returns a response — or `undefined` to fall back to the default JSON.