Skip to content
Merged
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,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 `agent-docs/recipes.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`, run around the action on BOTH the RPC and `route.ts` (including the `route()` adapter) boundaries; 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 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`; **`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.
Comment thread
vivek7405 marked this conversation as resolved.

### RPC + REST endpoint security

Expand Down
10 changes: 10 additions & 0 deletions agent-docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ option: `export const POST = route(createPost, { validate })`. A
validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
non-envelope return transforms the input.

To reuse the action's OWN declared `middleware` / `validate` config on the REST
boundary without re-listing it, pass the MODULE NAMESPACE to `route()` instead of
Comment thread
vivek7405 marked this conversation as resolved.
the function (#876): `import * as postActions from './create-post.server.ts';
export const POST = route(postActions)`. The adapter finds the single action
function and applies its declared `middleware` and `validate`, so a guard
declared once next to the action protects the RPC and REST boundaries alike.
Passing the imported function itself (`import { createPost }` then
`route(createPost)`) cannot see sibling config exports, so it applies only what
you pass in `route(fn, { middleware, validate })`.

## HTTP verbs, caching, streaming, and cancellation (#488, #489, #490, #492)

A `'use server'` action is a POST by default. Reserved sibling exports, read
Expand Down
12 changes: 6 additions & 6 deletions blog/per-action-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,22 +70,22 @@ First, the middleware short-circuits by returning an `ActionResult` instead of c

Second, it accumulates context. Anything the middleware writes onto `ctx` is readable inside the action via `actionContext()` (imported from `@webjsdev/server`). The action's own signature never changes. `deletePost(id)` still takes one argument. The user arrives out of band, through the context the middleware built, so the calling code stays exactly as clean as it was.

# Where it runs, and the one place you wire it through by hand
# Where it runs on every entry point

This is the part that made me want the feature. A WebJs action is reachable two ways. A client component imports it and the import becomes a typed RPC (Remote Procedure Call) stub. Or a `route.ts` REST endpoint imports and calls it, often through the `route()` adapter from `@webjsdev/server`.

On the RPC boundary the declared middleware runs automatically. When the browser calls `deletePost` over RPC, `requireUser` runs first, because the framework reads the `export const middleware` config off the action and wraps the chain around it for you. That is the primary path for a WebJs app, since components import actions directly, and it needs no wiring at all.

The `route()` adapter is deliberately lower level, and here you pass the same chain explicitly:
The `route()` adapter picks the declared chain up too, as long as you hand it the action's module namespace rather than the bare function:
Comment thread
vivek7405 marked this conversation as resolved.

```ts
// app/api/posts/[id]/route.ts
import { route } from '@webjsdev/server';
import { deletePost, middleware } from '#modules/posts/actions/delete-post.server.ts';
export const DELETE = route(deletePost, { middleware });
import * as postActions from '#modules/posts/actions/delete-post.server.ts';
Comment thread
vivek7405 marked this conversation as resolved.
export const DELETE = route(postActions); // its declared middleware + validate apply
```

I would honestly prefer `route(deletePost)` to pick the action's own middleware up on its own, the way the RPC boundary does. Today it does not, so you re-export the `middleware` array and hand it to the adapter. It is one line, and the guard still lives next to the action as its single source of truth, but it is a real seam to remember rather than an automatic guarantee. Verified by dogfooding: `route(action)` with no `middleware` option runs the body without the declared guards.
Passing the whole module lets the adapter read the `export const middleware` (and `export const validate`) sitting next to the action, so the guard you declared once protects the REST boundary automatically, the same way it does on the RPC one. The guard stays a property of the action, not of a single transport. If you instead import just the function (`import { deletePost }` then `route(deletePost)`), the adapter has no way to reach its sibling config, so there you pass the chain explicitly with `route(deletePost, { middleware })`. Verified by dogfooding: the module form applies the declared guards, the bare-function form runs only what you pass it.

# Compose several, in order

Expand All @@ -111,4 +111,4 @@ One rule ties this together. A configured action file holds exactly one callable

# The takeaway

Auth, rate-limit checks, logging, and tenant resolution are cross-cutting, so they do not belong copy-pasted into the top of every action body. WebJs lets a `'use server'` action declare `export const middleware = [mw1, mw2]`, an array of `async (ctx, next) => result` functions that wrap the action. On the RPC boundary they run automatically; a `route.ts` REST endpoint built with the `route()` adapter takes the same array through its `middleware` option. A middleware short-circuits by returning an `ActionResult` before the body runs, and feeds context the action reads through `actionContext()` with no change to its signature. You write the guard once, next to the function, and reuse that one array on every path into the action. Next.js has no first-class primitive for this (you wrap manually or lean on route middleware that cannot even see the action), which is exactly the gap this closes.
Auth, rate-limit checks, logging, and tenant resolution are cross-cutting, so they do not belong copy-pasted into the top of every action body. WebJs lets a `'use server'` action declare `export const middleware = [mw1, mw2]`, an array of `async (ctx, next) => result` functions that wrap the action. On the RPC boundary they run automatically, and a `route.ts` REST endpoint built with the `route()` adapter picks them up automatically too when you hand it the action's module namespace (or takes the array through its `middleware` option if you pass the bare function). A middleware short-circuits by returning an `ActionResult` before the body runs, and feeds context the action reads through `actionContext()` with no change to its signature. You write the guard once, next to the function, and reuse that one array on every path into the action. Next.js has no first-class primitive for this (you wrap manually or lean on route middleware that cannot even see the action), which is exactly the gap this closes.
3 changes: 2 additions & 1 deletion docs/app/docs/server-actions/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,9 @@ export async function POST(req: Request) {

<h3>The route() Adapter</h3>
<p>For the common case (merge query + route params + JSON body into one input object, run an optional validator, JSON-respond the result), the <code>route()</code> helper from <code>@webjsdev/server</code> writes that handler for you in one line. The hand-written <code>route.ts</code> above is always available for full control (custom headers, content negotiation, streaming).</p>
<p>The examples below import the action FUNCTION, which is the bare-function form: it applies only the <code>middleware</code> / <code>validate</code> you pass in <code>route(fn, {'{'} middleware, validate {'}'})</code>, because a function reference cannot reach its sibling config exports. To auto-apply the action's OWN declared <code>middleware</code> and <code>validate</code>, import the module namespace instead (<code>import * as postActions from '.../create-post.server.ts'</code>) and pass that: <code>export const POST = route(postActions)</code>, so a guard declared once beside the action protects the RPC and REST boundaries alike (issue #876).</p>
Comment thread
vivek7405 marked this conversation as resolved.

<pre>// app/api/posts/route.ts
<pre>// app/api/posts/route.ts (bare-function form)
import { route } from '@webjsdev/server';
import { createPost } from '../../../modules/posts/actions/create-post.server.ts';

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -1179,8 +1179,9 @@ ${UI_THEME}
Add a new design token the canonical way, a --x variable below plus a
--color-x: var(--x) line in the @theme inline block, then use it as bg-x /
text-x. Reach for opacity modifiers
(bg-primary/10, hover:bg-primary/90, text-muted-foreground/70) before
inventing a new token. */
(bg-primary/10, hover:bg-primary/90, border-border/60) before inventing a
new token, but keep body text at full opacity so it stays above the AA
contrast floor (a faded text-muted-foreground/70 measured 3.83:1). */
:root {
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
Expand Down
Loading
Loading