Skip to content

Commit 72bb361

Browse files
authored
fix: dogfood triage (route() middleware, scaffold typecheck, scaffold a11y) (#879)
* fix: route(module) auto-applies an action's declared middleware + validate The route() adapter only ran middleware/validate passed via opts, so an action's declared `export const middleware` did not protect the REST boundary, only the RPC one, despite the docs claiming both. A function reference cannot reach its sibling config exports. route() now also accepts the action's module namespace (`import * as m`); given a module it resolves the single action function and auto-applies the declared middleware and validate, so a guard declared once beside the action protects the RPC and REST boundaries alike. The function form is unchanged and an explicit opts value still overrides the declared config. Corrects the AGENTS.md / agent-docs / docs-site overclaim to match. Closes #876 * fix: fresh saas scaffold passes typecheck and axe out of the box Two dogfood findings on a freshly generated saas app. Typecheck (#877): - The saas generator rewrote the registry cn() import but not the sibling onBeforeCache import, so dialog.ts kept the registry-relative ../lib/dom.ts (a nonexistent components/lib/dom.ts) and failed TS2307. Add the ../lib/dom.ts -> #lib/utils/dom.ts rewrite, mirroring create.js. - lib/auth.server.ts assigned process.env.AUTH_SECRET (string | undefined) to the required string secret (TS2322). Resolve it through a typed authSecret const with a dev fallback that fails fast in production. Accessibility (#878): - The login / signup / dashboard / settings pages used <h3> as their only heading, so axe flagged page-has-heading-one. Promote each page's title to <h1> (styling unchanged, cardTitleClass carries the size). - The counter-card demo label used text-muted-foreground/70, a 3.83:1 contrast against the card, below AA. Drop the /70 opacity. A fresh `webjs create --template saas` now typechecks clean and returns zero axe violations on /login and the feature gallery, in light and dark. Closes #877 Closes #878 * test: assert fresh saas scaffold typechecks and passes axe Extend the saas scaffold integration test with the regressions #877/#878 fix: the onBeforeCache import is rewritten to #lib/utils/dom.ts, the auth secret is a typed const (not the raw string | undefined env read), the login/signup pages carry an <h1>, and the counter-card label keeps full text-muted-foreground contrast. Each doubles as the counterfactual that fails when the corresponding fix is reverted. * fix: complete the scaffold a11y pass (self-review findings) Self-review of the h1/contrast fix surfaced three gaps a fuller axe sweep confirmed: - The dashboard and settings pages already carry a page <h1>, so promoting their card title to <h1> created a second h1. Demote those two card titles to <h2> (login/signup keep <h1>, their card title is the sole heading). - text-muted-foreground/70 (3.83:1) appeared in nine more gallery surfaces than the counter-card; drop the /70 across the gallery so no generated page ships sub-AA label text. - The file-upload input and the ref-focus demo input had no accessible name (axe label, critical); add aria-labels. A full axe sweep of every generated route (public and auth-gated) now returns zero violations, with exactly one h1 per page. Tests widened to assert one h1 on dashboard/settings, scan the whole gallery for the opacity token, and pin both aria-labels. * fix: tighten auth-secret guard and disambiguate route() docs Second self-review round: - The production AUTH_SECRET guard used `!process.env.AUTH_SECRET`, which a whitespace-only value passes, so a blank secret would sign tokens in production. Trim first and fail fast on empty/blank; the dev fallback now also triggers for a blank value. - The AGENTS.md and recipes.md #876 examples reused the `createPost` identifier for both the module-namespace binding and the bare-function form, so the same `route(createPost)` call read as doing two opposite things. Use distinct bindings (import * as postActions for the module form, import { createPost } for the function form). * docs: sync per-action-middleware blog to the route(module) capability #876 gives route() an automatic path (pass the module namespace) for applying an action's declared middleware, so the blog's earlier "route() does not pick it up on its own, a real seam to remember" framing is now stale. Rewrite that section, the heading, and the takeaway to show the module form auto-applies and the bare-function form takes the explicit option. * docs: use a distinct binding for the route() module form in AGENTS.md Match the recipes.md disambiguation (#876 follow-up): the module-namespace example used the same createPost identifier as the bare-function form, so route(createPost) read as doing two opposite things. Use postActions for the module form. * docs: make the route() bare-vs-module examples consistent everywhere The docs-site paragraph described the module form but its code example (and the blog + JSDoc examples) still reused one identifier for both forms, so route(createPost) read as auto-applying in prose while the example showed the non-applying bare-function form. Label the docs-site code block as the bare-function form and use `import * as postActions` for the module form across the docs site, the blog, and the JSDoc, to match AGENTS.md and recipes.md.
1 parent 22bfee0 commit 72bb361

16 files changed

Lines changed: 225 additions & 36 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap:
302302

303303
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`.
304304

305-
**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.
305+
**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.
306306

307307
### RPC + REST endpoint security
308308

agent-docs/recipes.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,16 @@ option: `export const POST = route(createPost, { validate })`. A
200200
validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
201201
non-envelope return transforms the input.
202202

203+
To reuse the action's OWN declared `middleware` / `validate` config on the REST
204+
boundary without re-listing it, pass the MODULE NAMESPACE to `route()` instead of
205+
the function (#876): `import * as postActions from './create-post.server.ts';
206+
export const POST = route(postActions)`. The adapter finds the single action
207+
function and applies its declared `middleware` and `validate`, so a guard
208+
declared once next to the action protects the RPC and REST boundaries alike.
209+
Passing the imported function itself (`import { createPost }` then
210+
`route(createPost)`) cannot see sibling config exports, so it applies only what
211+
you pass in `route(fn, { middleware, validate })`.
212+
203213
## HTTP verbs, caching, streaming, and cancellation (#488, #489, #490, #492)
204214

205215
A `'use server'` action is a POST by default. Reserved sibling exports, read

blog/per-action-middleware.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,22 @@ First, the middleware short-circuits by returning an `ActionResult` instead of c
7070

7171
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.
7272

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

7575
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`.
7676

7777
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.
7878

79-
The `route()` adapter is deliberately lower level, and here you pass the same chain explicitly:
79+
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:
8080

8181
```ts
8282
// app/api/posts/[id]/route.ts
8383
import { route } from '@webjsdev/server';
84-
import { deletePost, middleware } from '#modules/posts/actions/delete-post.server.ts';
85-
export const DELETE = route(deletePost, { middleware });
84+
import * as postActions from '#modules/posts/actions/delete-post.server.ts';
85+
export const DELETE = route(postActions); // its declared middleware + validate apply
8686
```
8787

88-
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.
88+
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.
8989

9090
# Compose several, in order
9191

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

112112
# The takeaway
113113

114-
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.
114+
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.

docs/app/docs/server-actions/page.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,9 @@ export async function POST(req: Request) {
243243
244244
<h3>The route() Adapter</h3>
245245
<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>
246+
<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>
246247
247-
<pre>// app/api/posts/route.ts
248+
<pre>// app/api/posts/route.ts (bare-function form)
248249
import { route } from '@webjsdev/server';
249250
import { createPost } from '../../../modules/posts/actions/create-post.server.ts';
250251

packages/cli/lib/create.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,8 +1179,9 @@ ${UI_THEME}
11791179
Add a new design token the canonical way, a --x variable below plus a
11801180
--color-x: var(--x) line in the @theme inline block, then use it as bg-x /
11811181
text-x. Reach for opacity modifiers
1182-
(bg-primary/10, hover:bg-primary/90, text-muted-foreground/70) before
1183-
inventing a new token. */
1182+
(bg-primary/10, hover:bg-primary/90, border-border/60) before inventing a
1183+
new token, but keep body text at full opacity so it stays above the AA
1184+
contrast floor (a faded text-muted-foreground/70 measured 3.83:1). */
11841185
:root {
11851186
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11861187
--font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;

0 commit comments

Comments
 (0)