Skip to content
Open
2 changes: 2 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand All @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { documentsCoreVersion } from '../astro.config.mjs';
* other way to answer.
*/
const versionBanner = {
content: `These docs describe <strong>@green-tea/core ${documentsCoreVersion}</strong> — <a href="https://github.com/Expressive-Tea/green-tea/blob/main/CHANGELOG.md">what changed</a>. Install with <code>npm i @green-tea/core@beta</code>.`,
content: `These docs describe <strong>@green-tea/core ${documentsCoreVersion}</strong> — <a href="https://github.com/Expressive-Tea/green-tea/blob/main/CHANGELOG.md">what changed</a>. The public API is <a href="/docs/stability/">frozen from RC.1</a>. Install with <code>npm i @green-tea/core@beta</code>, where release candidates publish too.`,
};

export const collections = {
Expand Down
27 changes: 18 additions & 9 deletions src/content/docs/contributors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> @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),
Expand All @@ -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.
Expand All @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/content/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,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
Expand Down
112 changes: 108 additions & 4 deletions src/content/docs/guides/arguments.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) | `()` |

Expand All @@ -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`

Expand All @@ -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'])`).

Expand All @@ -75,6 +94,91 @@ 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. 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';

@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=<id>; 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,
Expand Down
Loading
Loading