diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index c7fdac7c0..8a7249fff 100644 --- a/.agents/skills/webjs/references/data-and-actions.md +++ b/.agents/skills/webjs/references/data-and-actions.md @@ -140,6 +140,7 @@ export async function updateUser(id: number, patch: Partial) { /* ... */ } ``` - A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) + `X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on success evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch is a `405` + `Allow`. +- The `cache` object maps onto the `Cache-Control` header. The number shorthand `cache = 60` means `{ maxAge: 60 }`. `maxAge` is the freshness window in seconds (`max-age=`), `swr` is a stale-while-revalidate grace window in seconds (`stale-while-revalidate=`, an expired response is still served instantly while the browser revalidates in the background, usually a bodyless 304 thanks to the ETag), and `public` flips the scope from the default `private`. So `{ maxAge: 60, swr: 300 }` emits `private, max-age=60, stale-while-revalidate=300`. - **SAFETY.** `cache` with `public: true` SHARES one response across ALL users, keyed only by URL + args. Use it ONLY for data identical for every visitor (the same rule as a page's `export const revalidate`), never for a session or per-user read. - Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`. Each middleware is `async (ctx, next) => result` where `ctx` is `{ request, args, signal, context }`. It writes to the shared bag `ctx.context.` (for example `ctx.context.user = user`), which is exactly what `actionContext().user` reads back in the action. A direct server-to-server call skips the RPC boundary (so its middleware does NOT run), so the action must guard rather than assume a middleware-set value is present. diff --git a/AGENTS.md b/AGENTS.md index 5991b97d8..480939f8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,7 +305,7 @@ The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `references/data-and-actions.md`. -**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. +**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`, where `swr` appends `stale-while-revalidate=` seconds so an expired response is still served instantly while the browser revalidates it in the background; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. ### RPC + REST endpoint security diff --git a/README.md b/README.md index 014773307..1d1c4d1d0 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ Code, Cursor, Copilot, Gemini, and opencode all read from one source. It gives you file-based routing, server actions with real end-to-end types, sessions, authentication, caching, rate limiting, WebSockets, and a database -layer in the box. `cache()` for queries, HTTP Cache-Control for pages, a -Session class with SessionStorage, NextAuth-style auth with providers, and -WebSocket broadcast all share one pluggable store, so a single `setStore()` -call moves them onto Redis with no config files in between. +layer in the box. `cache()` for queries, HTTP Cache-Control for pages and for +cached GET server actions, a Session class with SessionStorage, NextAuth-style +auth with providers, and WebSocket broadcast all share one pluggable store, so +a single `setStore()` call moves them onto Redis with no config files in +between. ```sh npm create webjs@latest my-app diff --git a/website/app/docs/data-fetching/page.ts b/website/app/docs/data-fetching/page.ts index bb4d84b70..45ff6c424 100644 --- a/website/app/docs/data-fetching/page.ts +++ b/website/app/docs/data-fetching/page.ts @@ -81,7 +81,7 @@ export async function getUser(id) { return db.user.find(id); } 'use server'; export const invalidates = (id) => ['user:' + id]; export async function updateUser(id, data) { /* ... */ } -

The call site never changes (await getUser(7)). A GET rides its args in the URL, is CSRF-exempt, and is served with Cache-Control + an ETag, so a repeat read within the window comes from the browser cache and a stale one revalidates with a 304. A mutation sends a body, is CSRF-protected, and on completion its invalidates tags evict the matching server cache and tell the client to refetch the affected reads. A wrong request method is a 405. It is additive: an action with no method stays a POST, exactly as before. The cache defaults to private; { public: true } shares the response across users keyed only by URL, so use it only for data identical for every visitor, never a per-user read.

+

The call site never changes (await getUser(7)). A GET rides its args in the URL, is CSRF-exempt, and is served with Cache-Control + an ETag, so a repeat read within the window comes from the browser cache and a stale one revalidates with a 304. A mutation sends a body, is CSRF-protected, and on completion its invalidates tags evict the matching server cache and tell the client to refetch the affected reads. A wrong request method is a 405. It is additive: an action with no method stays a POST, exactly as before. The cache defaults to private; { public: true } shares the response across users keyed only by URL, so use it only for data identical for every visitor, never a per-user read. In the object form maxAge is the freshness window in seconds and swr adds a stale-while-revalidate grace window (also seconds), during which an expired response is still served instantly while the browser refreshes it in the background. The full header reference lives on the server actions page.

A public REST endpoint is a route.ts that imports and calls the action; validate is a boundary concern (the RPC endpoint and the route handler), not a direct server-to-server call.

Cancellation is automatic: a superseded async render() (a newer prop or signal change while a fetch is in flight) aborts the previous render's in-flight action fetch, and on the server an action can read the request's AbortSignal via actionSignal() to stop expensive work when the client disconnects.

An action can declare export const middleware = [mw1, mw2] (each async (ctx, next) => result): the chain runs around the action on the RPC and REST boundaries, short-circuits (an auth middleware returning an ActionResult instead of calling next()), and accumulates context the action reads via actionContext().

diff --git a/website/app/docs/server-actions/page.ts b/website/app/docs/server-actions/page.ts index 4d650f483..b1714edcd 100644 --- a/website/app/docs/server-actions/page.ts +++ b/website/app/docs/server-actions/page.ts @@ -175,6 +175,7 @@ export async function getUser(id: number) { return db.query.users.findFirst({ wh export const invalidates = (id: number) => ['user:' + id]; export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }

A cached GET carries Cache-Control plus a weak ETag (answering If-None-Match with a 304) and an X-Webjs-Tags header. When a mutation completes (it did not throw), the framework evicts its invalidates tags from the server cache and reports them via X-Webjs-Invalidate, so the client browser-cache coordinator revalidates a later read.

+

The cache export maps directly onto that Cache-Control header. The number shorthand (cache = 60) means { maxAge: 60 }. maxAge sets the freshness window in seconds (max-age). swr, also seconds, appends stale-while-revalidate: for that many seconds past expiry the browser keeps serving the cached response instantly while it revalidates in the background, and the weak ETag usually turns that background revalidation into a bodyless 304. public switches the scope from the default private (see Safety below). So cache = { maxAge: 60, swr: 300 } emits private, max-age=60, stale-while-revalidate=300. Reach for swr when a read should never make a visitor wait on the network but a briefly stale value is acceptable, which is most dashboard and listing reads.

Safety. cache with public: true SHARES one response across all users, keyed only by URL plus arguments. Use it ONLY for data identical for every visitor, never a session or per-user read. This is the same safety rule as a page's export const revalidate. The default (private) cache is per-response and safe.

Per-action middleware