Skip to content
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/data-and-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const middleware = [requireAuth]; // async (ctx, next) => resul
export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
```

- 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`.
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries a weak `ETag` (304 on `If-None-Match`); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags` (without one it is `no-store`, so caching is opted into by `cache`, not by the verb). A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and once it completes without throwing evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate` (a returned `{ success: false }` envelope still evicts, since the action ran). 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=<n>`), `swr` is a stale-while-revalidate grace window in seconds (`stale-while-revalidate=<n>`, 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.<key>` (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.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, where `swr` appends `stale-while-revalidate=<swr>` 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.
**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=<swr>` 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, and carries a weak ETag (answering `If-None-Match` with a 304); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags`, and without one it is `no-store` (caching is opted into by `cache`, never granted by the verb); 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.
Comment thread
vivek7405 marked this conversation as resolved.

### RPC + REST endpoint security

Expand Down
6 changes: 4 additions & 2 deletions examples/blog/modules/posts/actions/delete-post.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
import type { ActionResult } from '#modules/auth/types.ts';

// A mutation (#488, POST by default). `invalidates` lists the cache tags to
Comment thread
vivek7405 marked this conversation as resolved.
// evict on success, so a later getPost GET for this slug refetches instead of
// serving a stale browser-cached value. (The listPosts.invalidate() call below
// evict once the action completes without throwing, so a later getPost GET for
// this slug refetches instead of serving a stale browser-cached value. Note the
// 401 path below still evicts: eviction is gated on the action having RUN, so a
// returned { success: false } envelope counts. (The listPosts.invalidate() call below
// is the separate server-side query cache; this is the HTTP-boundary tag set.)
export const invalidates = (input: { slug: string }) => ['posts', `post:${input.slug}`];
export async function deletePost(
Expand Down
9 changes: 6 additions & 3 deletions examples/blog/modules/posts/queries/get-post.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { formatPost } from '#modules/posts/utils/slugify.ts';
import type { PostFormatted } from '#modules/posts/types.ts';

// A GET server action (#488): a single-post read declares its HTTP semantics
// via reserved sibling exports. Calls from a page / route.ts run the function
// directly, but exposed at a route() boundary the GET is cacheable, ETag-aware
// and SSR-seeded. The per-post `post:` tag (resolved from the `slug` arg) is
// via reserved sibling exports. They shape the RPC endpoint a browser import
// hits, where the GET is cacheable and ETag-aware. A call from a page or a
Comment thread
vivek7405 marked this conversation as resolved.
// route.ts runs the function in-process and skips all of it (route() picks up
// only a declared validate and middleware, never the cache exports), so an
// endpoint that wants caching sets its own headers. The per-post `post:` tag
// (resolved from the `slug` arg) is
// what `deletePost` evicts. Cache stays private (the default); a public blog
// post is identical for everyone, but private is the safe baseline.
export const method = 'GET';
Expand Down
26 changes: 20 additions & 6 deletions packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -947,10 +947,13 @@ export default cors({

// A GET server action (#488): a read declares its HTTP semantics via reserved
// sibling exports the framework reads statically. 'method' makes the call ride
// the URL (cacheable, ETag/304-aware, SSR-seeded on first paint); 'cache' is the
// max-age in seconds (private by default, do NOT add { public: true } unless the
// data is identical for EVERY visitor); 'tags' label the cached entry so a
// mutation can evict it. One function per file.
// the URL and be ETag/304-aware; 'cache' is what makes the response cacheable at
// all (the max-age in seconds, private by default, do NOT add { public: true }
// unless the data is identical for EVERY visitor); 'tags' label the cached entry so a
// mutation can evict it. All three shape the RPC endpoint a browser import hits,
// so they do nothing for the in-process call in app/api/users/route.ts; they are
// here as the idiom to carry into a client that imports the action. One function
// per file.
export const method = 'GET';
export const cache = 30;
export const tags = () => ['users'];
Expand All @@ -966,8 +969,12 @@ export async function listUsers() {

// A mutation server action (#488). With no 'method' export it defaults to POST
// (CSRF-protected, rich request body). 'invalidates' lists the cache tags to
// evict on success, so the next listUsers() read refetches fresh instead of
// serving a stale browser-cached value. One function per file.
// evict once the action completes without throwing, so a client that read
// listUsers() refetches fresh instead of serving a stale browser-cached value.
// (A returned { success: false } envelope still evicts, since the action ran.)
// It applies on the RPC endpoint a browser import hits. The route.ts in this
// template calls the function directly, which is in-process, so the config
// exports do not fire there. One function per file.
export const invalidates = () => ['users'];
export async function createUser(input: { name: string; email: string }) {
// TODO: validate input, persist to database
Expand All @@ -977,6 +984,13 @@ export async function createUser(input: { name: string; email: string }) {
await writeFile(join(appDir, 'app', 'api', 'users', 'route.ts'), `/**
* /api/users: thin route wrapper over typed server actions.
* Business logic lives in modules/users/, not here.
*
Comment thread
vivek7405 marked this conversation as resolved.
Comment thread
vivek7405 marked this conversation as resolved.
* These calls are in-process, so the actions' config exports do NOT apply here.
* method / cache / tags / invalidates shape the RPC endpoint a browser import
* hits, and nothing else applies them, so this endpoint sets its own headers if
* it wants caching. The namespace form of the route() adapter from
* \@webjsdev/server (import * as q; export const GET = route(q)) picks up the
* declared validate and middleware, which are the two an endpoint can reuse.
*/
import { listUsers } from '#modules/users/queries/list-users.server.ts';
import { createUser } from '#modules/users/actions/create-user.server.ts';
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/templates/gallery/app/examples/todo/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import '#modules/todo/components/todo-app.ts';
export const metadata: Metadata = { title: 'Todo (optimistic UI) | examples' };

export default async function TodoExample() {
// SSR-fetched and seeded, so <todo-app> paints the real list on first byte.
// Fetched here on the server and handed down as a property, so <todo-app>
// paints the real list on the first byte with nothing to fetch on hydration.
const todos = await listTodos();
return html`
${pageHeading('Optimistic todo')}
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/templates/gallery/app/features/caching/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export default function CachingExample() {
Only for pages identical for every visitor. For per-user or per-query data
use <code>cache()</code> with <code>tags</code> and
<code>revalidateTag</code>, or a GET action's
<code>export const cache</code>.
<code>export const cache</code>, which the
<a class="text-primary underline underline-offset-2" href="/features/server-actions">server actions card</a>
demonstrates end to end.
</p>
<p class="text-muted-foreground text-sm">
A mutation evicts the cache on demand. Click below (it calls
Expand Down
Loading
Loading