From 44d7430c97224e64100362556021ca549f38496a Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 18:01:10 +0530 Subject: [PATCH 1/8] 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 --- AGENTS.md | 2 +- agent-docs/recipes.md | 9 +++ docs/app/docs/server-actions/page.ts | 1 + packages/server/src/action-route.js | 64 ++++++++++++++++--- .../server/test/actions/action-route.test.js | 59 +++++++++++++++++ 5 files changed, 126 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5ca73992..2fe9d8be9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 createPost from '...'; export const POST = route(createPost)`), which reads the declared `middleware` + `validate`; the bare-function form `route(createPost)` runs only what you pass in `route(fn, { middleware })` (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/agent-docs/recipes.md b/agent-docs/recipes.md index 247bd0307..8d8342e9c 100644 --- a/agent-docs/recipes.md +++ b/agent-docs/recipes.md @@ -200,6 +200,15 @@ 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 +the function (#876): `import * as createPost from './create-post.server.ts'; +export const POST = route(createPost)`. 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. The +bare-function form (`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 diff --git a/docs/app/docs/server-actions/page.ts b/docs/app/docs/server-actions/page.ts index a524a737a..ca33c7a27 100644 --- a/docs/app/docs/server-actions/page.ts +++ b/docs/app/docs/server-actions/page.ts @@ -243,6 +243,7 @@ export async function POST(req: Request) {

The route() Adapter

For the common case (merge query + route params + JSON body into one input object, run an optional validator, JSON-respond the result), the route() helper from @webjsdev/server writes that handler for you in one line. The hand-written route.ts above is always available for full control (custom headers, content negotiation, streaming).

+

Pass the action's module namespace (import * as createPost) instead of the function and route() auto-applies the action's declared middleware and validate, so a guard declared once beside the action protects the RPC and REST boundaries alike (issue #876): export const POST = route(createPost). The bare-function form cannot see sibling config exports, so it applies only what you pass in route(fn, {'{'} middleware, validate {'}'}).

// app/api/posts/route.ts
 import { route } from '@webjsdev/server';
diff --git a/packages/server/src/action-route.js b/packages/server/src/action-route.js
index 6f8f1a40c..aa2c8decf 100644
--- a/packages/server/src/action-route.js
+++ b/packages/server/src/action-route.js
@@ -28,6 +28,22 @@
  * export const POST = route(createPost, { validate: createPostSchema });
  * ```
  *
+ * Passing the action's MODULE NAMESPACE instead of the function auto-applies the
+ * action's declared `export const middleware` and `export const validate`, so a
+ * guard declared once next to the action protects the REST boundary the same way
+ * it protects the RPC boundary (#876). This is the way to keep "declare once,
+ * protected on every entry point" true without re-listing the config:
+ *
+ * ```js
+ * // app/api/posts/route.ts
+ * import { route } from '@webjsdev/server';
+ * import * as createPost from '../../../modules/posts/actions/create-post.server.ts';
+ * export const POST = route(createPost);   // its declared middleware + validate apply
+ * ```
+ *
+ * The function form still takes explicit `opts`, and an explicit `opts.middleware`
+ * / `opts.validate` overrides the module's declared config when both are given.
+ *
  * Adapter rules when invoked over HTTP:
  *   - URL query params, route params (`ctx.params`), and the parsed JSON body
  *     (for a method with a body) are merged into a single object argument
@@ -51,6 +67,7 @@
 import { runValidate } from './actions.js';
 import { runWithActionSignal } from './action-signal.js';
 import { runActionChain } from './action-middleware.js';
+import { actionFunctionNames, actionMiddleware } from './action-config.js';
 import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
 import { getBodyLimits } from './context.js';
 
@@ -69,16 +86,48 @@ function jsonBodyLimit() {
  * Expose a plain `'use server'` action over REST as a `route.ts`-style handler.
  *
  * @template A, R
- * @param {(input: A, ctx: { req: Request, params: Record }) => R | Promise} action
- *   the server action to call (its source is unchanged; this only wraps the
- *   HTTP-to-action adapter around it).
+ * @param {((input: A, ctx: { req: Request, params: Record }) => R | Promise) | Record} actionOrModule
+ *   the server action to call, OR the action's module namespace (`import * as m`)
+ *   so its declared `middleware` / `validate` config is auto-applied (#876). Its
+ *   source is unchanged; this only wraps the HTTP-to-action adapter around it.
  * @param {{
  *   validate?: (input: any) => any,
  *   middleware?: Function[],
  * }} [opts]
  * @returns {(req: Request, ctx?: { params?: Record }) => Promise}
  */
-export function route(action, opts = {}) {
+export function route(actionOrModule, opts = {}) {
+  // Resolve the callable action plus any config the caller did not pass
+  // explicitly. A function is used verbatim (the original form). A module
+  // namespace exposes the single action function and its declared config, so
+  // `route(mod)` inherits the same `middleware` / `validate` the RPC boundary
+  // reads (#876). An explicit `opts` value still wins over the declared one.
+  let action;
+  /** @type {{ middleware?: Function[], validate?: Function }} */
+  let declared = {};
+  if (typeof actionOrModule === 'function') {
+    action = actionOrModule;
+  } else if (actionOrModule && typeof actionOrModule === 'object') {
+    const names = actionFunctionNames(actionOrModule);
+    if (names.length !== 1) {
+      throw new Error(
+        `route(module) expects exactly one action function in the module, found ${names.length}` +
+          (names.length ? ` (${names.join(', ')})` : ''),
+      );
+    }
+    action = /** @type {Function} */ (actionOrModule[names[0]]);
+    declared = {
+      middleware: actionMiddleware(actionOrModule),
+      validate: typeof actionOrModule.validate === 'function'
+        ? /** @type {Function} */ (actionOrModule.validate)
+        : undefined,
+    };
+  } else {
+    throw new Error('route() expects an action function or a module namespace');
+  }
+  const validate = opts.validate ?? declared.validate;
+  const middlewareChain = opts.middleware ?? declared.middleware ?? [];
+
   return async function handler(req, ctx) {
     const params = (ctx && ctx.params) || {};
     const url = new URL(req.url);
@@ -102,13 +151,13 @@ export function route(action, opts = {}) {
     }
 
     let arg = { ...query, ...params, ...body };
-    if (typeof opts.validate === 'function') {
+    if (typeof validate === 'function') {
       // Run the validator through the SHARED contract (#245) so the REST adapter
       // and the RPC path interpret a `{ success, fieldErrors }` envelope, a
       // throw, and a transform-return identically. A structured failure becomes
       // a 422 JSON; a throw stays a 400 (keeping a schema lib's `issues`); a
       // transform-return replaces the input.
-      const v = runValidate(opts.validate, arg);
+      const v = runValidate(validate, arg);
       if (!v.ok) {
         if (v.thrown !== undefined) {
           const msg = v.result.error || 'Invalid input';
@@ -126,10 +175,9 @@ export function route(action, opts = {}) {
     // The action runs inside the request signal scope (#492) and the per-action
     // middleware chain (#490). `ranAction` distinguishes a real completion from
     // a middleware short-circuit (the action never ran).
-    const middleware = opts.middleware || [];
     let ranAction = false;
     const result = await runWithActionSignal(req.signal, () =>
-      runActionChain(middleware, { request: req, args: [arg], signal: req.signal }, () => {
+      runActionChain(middlewareChain, { request: req, args: [arg], signal: req.signal }, () => {
         ranAction = true;
         return action(/** @type any */ (arg), { req, params });
       }));
diff --git a/packages/server/test/actions/action-route.test.js b/packages/server/test/actions/action-route.test.js
index 9a41d21b0..94542e027 100644
--- a/packages/server/test/actions/action-route.test.js
+++ b/packages/server/test/actions/action-route.test.js
@@ -135,3 +135,62 @@ test('a validator transform-return reaches the action body (REST boundary, #245)
   await handler(new Request('http://x/api/x?a=1', { method: 'GET' }));
   assert.deepEqual(received, { a: '1', coerced: true });
 });
+
+// --- Module-namespace form: auto-apply the action's declared config (#876) ---
+
+test('route(module) applies the action-declared middleware (short-circuit)', async () => {
+  let ran = false;
+  // A module namespace: one action function + a declared `export const middleware`.
+  const mod = {
+    middleware: [async () => ({ success: false, error: 'mw-blocked', status: 403 })],
+    async guarded() { ran = true; return { success: true }; },
+  };
+  const handler = route(mod);
+  const res = await handler(new Request('http://x/api/x?a=1', { method: 'POST' }));
+  assert.equal(res.status, 403);
+  assert.deepEqual(await res.json(), { success: false, error: 'mw-blocked' });
+  assert.equal(ran, false, 'declared middleware must gate the route boundary too');
+});
+
+test('COUNTERFACTUAL: route(fn) without opts does NOT apply the declared middleware', async () => {
+  // Passing the bare function (not the module) cannot see sibling config, so the
+  // body runs. This is the exact gap #876 fixes for the module form.
+  let ran = false;
+  const mod = {
+    middleware: [async () => ({ success: false, error: 'mw-blocked', status: 403 })],
+    async guarded() { ran = true; return { success: true }; },
+  };
+  const handler = route(mod.guarded);
+  const res = await handler(new Request('http://x/api/x?a=1', { method: 'POST' }));
+  assert.equal(res.status, 200);
+  assert.equal(ran, true, 'the bare-function form has no access to declared middleware');
+});
+
+test('route(module) applies the action-declared validate', async () => {
+  const mod = {
+    validate: (input) => (input.title ? { success: true } : { success: false, fieldErrors: { title: 'required' } }),
+    async createPost(input) { return { made: input.title }; },
+  };
+  const handler = route(mod);
+  const bad = await handler(new Request('http://x/api/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }));
+  assert.equal(bad.status, 422);
+  const ok = await handler(new Request('http://x/api/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'hi' }) }));
+  assert.deepEqual(await ok.json(), { made: 'hi' });
+});
+
+test('explicit opts override the module-declared config', async () => {
+  const mod = {
+    middleware: [async () => ({ success: false, error: 'declared', status: 403 })],
+    async guarded() { return { success: true, from: 'body' }; },
+  };
+  // Passing an empty middleware array explicitly overrides the declared one.
+  const handler = route(mod, { middleware: [] });
+  const res = await handler(new Request('http://x/api/x?a=1', { method: 'POST' }));
+  assert.equal(res.status, 200);
+  assert.deepEqual(await res.json(), { success: true, from: 'body' });
+});
+
+test('route(module) with more than one action function throws', () => {
+  const mod = { async a() {}, async b() {} };
+  assert.throws(() => route(mod), /exactly one action function/);
+});

From ff3d4c0e8cc4465e0551f357fef03bac0a6f63cd Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Thu, 9 Jul 2026 18:10:59 +0530
Subject: [PATCH 2/8] 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 

as their only heading, so axe flagged page-has-heading-one. Promote each page's title to

(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 --- packages/cli/lib/saas-template.js | 25 ++++++++++++++----- .../components/components/counter-card.ts | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/cli/lib/saas-template.js b/packages/cli/lib/saas-template.js index f70156bb1..ea1df29d4 100644 --- a/packages/cli/lib/saas-template.js +++ b/packages/cli/lib/saas-template.js @@ -27,7 +27,12 @@ async function readUiComponent(name) { // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts). return raw .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'") - .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"'); + .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"') + // onBeforeCache lives in its own client-only module so cn() stays pure (#819). + // Without this rewrite dialog.ts keeps the registry-relative `../lib/dom.ts` + // (which resolves to a nonexistent components/lib/dom.ts) and fails typecheck. + .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'") + .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"'); } /** Copy named registry components into `/components/ui/`. */ @@ -87,6 +92,14 @@ export async function writeSaasFiles(appDir, opts = {}) { "import { db } from '#db/connection.server.ts';", "import { compare } from './password.server.ts';", "", + "// AUTH_SECRET signs session tokens. Set a strong value in .env for any real", + "// deployment. The dev fallback keeps a fresh scaffold booting, but is NOT", + "// safe for production, so we fail fast if it is missing there.", + "const authSecret = process.env.AUTH_SECRET ?? 'dev-insecure-secret-change-me';", + "if (process.env.NODE_ENV === 'production' && !process.env.AUTH_SECRET) {", + " throw new Error('AUTH_SECRET must be set in production');", + "}", + "", "export const { auth, signIn, signOut, handlers } = createAuth({", " providers: [", " Credentials({", @@ -97,7 +110,7 @@ export async function writeSaasFiles(appDir, opts = {}) { " },", " }),", " ],", - " secret: process.env.AUTH_SECRET,", + " secret: authSecret,", "});", "", ].join('\n')); @@ -324,7 +337,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "
", "
", "
", - "

Sign in

", + "

Sign in

", "

Welcome back: log in to continue.

", "
", "
", @@ -393,7 +406,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "
", "
", "
", - "

Create an account

", + "

Create an account

", "

Get started with your new workspace.

", "
", "
", @@ -460,7 +473,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "
", "
", "
", - "

Welcome, ${user?.name || user?.email}!

", + "

Welcome, ${user?.name || user?.email}!

", "

You're authenticated. Replace this scaffold with your real app.

", "
", "
", @@ -486,7 +499,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "

Settings

", "
", "
", - "

Account

", + "

Account

", "

Your basic profile information.

", "
", "
", diff --git a/packages/cli/templates/gallery/modules/components/components/counter-card.ts b/packages/cli/templates/gallery/modules/components/components/counter-card.ts index 5f2084dda..394120470 100644 --- a/packages/cli/templates/gallery/modules/components/components/counter-card.ts +++ b/packages/cli/templates/gallery/modules/components/components/counter-card.ts @@ -24,7 +24,7 @@ export class CounterCard extends WebComponent({
${this.count.get()} - ${this.label} + ${this.label}
From b0d143c98306d5969293e5ad8a319773cca223d8 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 18:12:45 +0530 Subject: [PATCH 3/8] 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

, and the counter-card label keeps full text-muted-foreground contrast. Each doubles as the counterfactual that fails when the corresponding fix is reverted. --- test/scaffolds/scaffold-integration.test.js | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index 014adeb8b..bbeee8392 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -415,8 +415,41 @@ test('scaffoldApp saas: writes auth + dashboard + Drizzle User model', async () if (!c.endsWith('.ts')) continue; const src = readFileSync(join(appDir, 'components', 'ui', c), 'utf8'); assert.doesNotMatch(src, /from ['"]\.\.\/lib\/utils\.ts['"]/, `${c} must not keep the stale ../lib/utils.ts cn import`); + // #877: the onBeforeCache import must be rewritten the same way. The saas + // generator previously rewrote only the cn() import, so dialog.ts kept the + // registry-relative `../lib/dom.ts` (a nonexistent components/lib/dom.ts) + // and failed `webjs typecheck` with TS2307. Counterfactual: the missing + // rewrite leaves `'../lib/dom.ts'` and this fails. + assert.doesNotMatch(src, /from ['"]\.\.\/lib\/dom\.ts['"]/, `${c} must not keep the stale ../lib/dom.ts import`); + if (/onBeforeCache/.test(src)) { + assert.match(src, /from ['"]#lib\/utils\/dom\.ts['"]/, `${c} imports onBeforeCache from #lib/utils/dom.ts`); + } + } + + // #877: lib/auth.server.ts must not assign `process.env.AUTH_SECRET` + // (string | undefined) straight to the required `string` secret (TS2322). + // It resolves through a typed const with a dev fallback + prod guard. + const authSrc = readFileSync(join(appDir, 'lib', 'auth.server.ts'), 'utf8'); + assert.doesNotMatch(authSrc, /secret:\s*process\.env\.AUTH_SECRET\b/, 'secret must not be the raw string | undefined env read'); + assert.match(authSrc, /const authSecret =/, 'auth secret resolved through a typed const'); + assert.match(authSrc, /secret:\s*authSecret\b/, 'createAuth uses the typed authSecret'); + assert.match(authSrc, /NODE_ENV === 'production'[\s\S]*AUTH_SECRET must be set/, 'production fails fast when AUTH_SECRET is unset'); + + // #878: each auth page needs exactly one

(axe page-has-heading-one). + // The pages previously used

as their only heading. Counterfactual: + // the

-only page fails this. + for (const p of [['login'], ['signup']]) { + const pageSrc = readFileSync(join(appDir, 'app', ...p, 'page.ts'), 'utf8'); + assert.match(pageSrc, /`); } + // #878: the counter-card gallery label must not drop below AA contrast. + // `text-muted-foreground/70` measured 3.83:1 on the card; full-opacity + // `text-muted-foreground` passes. Counterfactual: the /70 opacity fails. + const counterCard = readFileSync( + join(appDir, 'modules', 'components', 'components', 'counter-card.ts'), 'utf8'); + assert.doesNotMatch(counterCard, /text-muted-foreground\/\d/, 'counter-card label uses full-contrast text-muted-foreground'); + // Drizzle User model (saas overwrites db/schema.server.ts to add passwordHash) const schema = readFileSync(join(appDir, 'db', 'schema.server.ts'), 'utf8'); assert.match(schema, /export const users = table\('users'/, 'users table present'); From cf9c80ad9b4fac21ec9fe0a455f6da1beda44b71 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 18:28:09 +0530 Subject: [PATCH 4/8] 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

, so promoting their card title to

created a second h1. Demote those two card titles to

(login/signup keep

, 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. --- packages/cli/lib/create.js | 5 ++- packages/cli/lib/saas-template.js | 4 +- .../gallery/app/features/file-storage/page.ts | 4 +- .../gallery/app/features/forms/page.ts | 2 +- .../directives/components/directive-demo.ts | 3 +- .../rate-limit/components/rate-probe.ts | 2 +- .../server-actions/components/greeter.ts | 2 +- .../modules/todo/components/todo-app.ts | 10 ++--- test/scaffolds/scaffold-integration.test.js | 44 ++++++++++++++----- 9 files changed, 50 insertions(+), 26 deletions(-) diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 105c7426c..dff147e34 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -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; diff --git a/packages/cli/lib/saas-template.js b/packages/cli/lib/saas-template.js index ea1df29d4..c93e8f2a2 100644 --- a/packages/cli/lib/saas-template.js +++ b/packages/cli/lib/saas-template.js @@ -473,7 +473,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "

", "
", "
", - "

Welcome, ${user?.name || user?.email}!

", + "

Welcome, ${user?.name || user?.email}!

", "

You're authenticated. Replace this scaffold with your real app.

", "
", "
", @@ -499,7 +499,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "

Settings

", "
", "
", - "

Account

", + "

Account

", "

Your basic profile information.

", "
", "
", diff --git a/packages/cli/templates/gallery/app/features/file-storage/page.ts b/packages/cli/templates/gallery/app/features/file-storage/page.ts index 98eab5cec..14682eb10 100644 --- a/packages/cli/templates/gallery/app/features/file-storage/page.ts +++ b/packages/cli/templates/gallery/app/features/file-storage/page.ts @@ -42,7 +42,7 @@ export default function FileStorageExample({ setFileStore() call, no call-site change.

- @@ -54,7 +54,7 @@ export default function FileStorageExample({ ? html`
Stored ${name} - (${size} bytes) + (${size} bytes) download
` : ''} diff --git a/packages/cli/templates/gallery/app/features/forms/page.ts b/packages/cli/templates/gallery/app/features/forms/page.ts index 7df0754d9..9c5368c35 100644 --- a/packages/cli/templates/gallery/app/features/forms/page.ts +++ b/packages/cli/templates/gallery/app/features/forms/page.ts @@ -26,7 +26,7 @@ const field = (label: string, name: string, input: unknown, error?: string) => h
`; -const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground/70'; +const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground'; export default function FormsFeature({ searchParams, actionData }: { searchParams: Record; actionData?: Result }) { if (searchParams.sent) { diff --git a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts index 96ce05691..528c80af8 100644 --- a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts +++ b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts @@ -60,7 +60,7 @@ export class DirectiveDemo extends WebComponent {
    ${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html`
  • - #${it.id}${it.label} + #${it.id}${it.label}
  • `)}
@@ -76,6 +76,7 @@ export class DirectiveDemo extends WebComponent {
this.text.set((e.target as HTMLInputElement).value)}> diff --git a/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts b/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts index e6f785f26..945079dd1 100644 --- a/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts +++ b/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts @@ -38,7 +38,7 @@ export class RateProbe extends WebComponent {
  • #${p.n} ${p.status === 429 ? '429 limited' : '200 ok'} - remaining: ${p.remaining} + remaining: ${p.remaining}
  • `)} diff --git a/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts b/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts index bc7b6b083..6c7a52afc 100644 --- a/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts +++ b/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts @@ -18,7 +18,7 @@ export class Greeter extends WebComponent { this.run(e)} class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border"> + class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" /> diff --git a/packages/cli/templates/gallery/modules/todo/components/todo-app.ts b/packages/cli/templates/gallery/modules/todo/components/todo-app.ts index 0929457e4..3bdf0dd31 100644 --- a/packages/cli/templates/gallery/modules/todo/components/todo-app.ts +++ b/packages/cli/templates/gallery/modules/todo/components/todo-app.ts @@ -82,7 +82,7 @@ export class TodoApp extends WebComponent({

    Tasks

    -

    ${done} of ${list.length} done

    +

    ${done} of ${list.length} done

    ${pct}%
    @@ -97,7 +97,7 @@ export class TodoApp extends WebComponent({ class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]"> + class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" /> @@ -120,17 +120,17 @@ export class TodoApp extends WebComponent({ - + `) : html` -
  • No tasks yet. Add your first one above.
  • +
  • No tasks yet. Add your first one above.
  • `} diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index bbeee8392..f8916ad42 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -435,20 +435,42 @@ test('scaffoldApp saas: writes auth + dashboard + Drizzle User model', async () assert.match(authSrc, /secret:\s*authSecret\b/, 'createAuth uses the typed authSecret'); assert.match(authSrc, /NODE_ENV === 'production'[\s\S]*AUTH_SECRET must be set/, 'production fails fast when AUTH_SECRET is unset'); - // #878: each auth page needs exactly one

    (axe page-has-heading-one). - // The pages previously used

    as their only heading. Counterfactual: - // the

    -only page fails this. - for (const p of [['login'], ['signup']]) { + // #878: every top-level page needs EXACTLY one

    (axe page-has-heading-one + // wants one, and a second h1 is its own violation). The auth cards are the + // sole heading so their title is the h1; the dashboard/settings pages already + // carry a page

    , so their card title stays a subordinate

    (promoting + // it to h1 was the regression this pins). Counterfactual: an

    -only page, + // or a double-h1 dashboard, fails this. + const h1Count = (src) => (src.match(/`); + assert.equal(h1Count(pageSrc), 1, `${p.join('/')} page has exactly one

    `); } - // #878: the counter-card gallery label must not drop below AA contrast. - // `text-muted-foreground/70` measured 3.83:1 on the card; full-opacity - // `text-muted-foreground` passes. Counterfactual: the /70 opacity fails. - const counterCard = readFileSync( - join(appDir, 'modules', 'components', 'components', 'counter-card.ts'), 'utf8'); - assert.doesNotMatch(counterCard, /text-muted-foreground\/\d/, 'counter-card label uses full-contrast text-muted-foreground'); + // #878: no gallery surface may drop label text below AA contrast. The + // `text-muted-foreground/70` opacity measured 3.83:1; full-opacity + // `text-muted-foreground` passes. Scan the WHOLE generated gallery (every + // component + feature page), not just one demo, so a stray low-contrast + // token anywhere reds this. Counterfactual: any `/NN` opacity fails. + const galleryDirs = [join(appDir, 'modules'), join(appDir, 'app', 'features')]; + const walk = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { walk(full); continue; } + if (!e.name.endsWith('.ts')) continue; + const src = readFileSync(full, 'utf8'); + assert.doesNotMatch(src, /text-muted-foreground\/\d/, `${full} keeps full-contrast text-muted-foreground (no /NN opacity)`); + } + }; + for (const d of galleryDirs) if (existsSync(d)) walk(d); + + // #878: gallery form controls need an accessible name (axe `label`). The + // file-upload input and the directive-demo text input carried none, so a + // full axe sweep flagged them critical. Pin their aria-labels. + const fileStorage = readFileSync(join(appDir, 'app', 'features', 'file-storage', 'page.ts'), 'utf8'); + assert.match(fileStorage, /type="file"[^>]*aria-label=/, 'the file input has an aria-label'); + const directiveDemo = readFileSync(join(appDir, 'modules', 'directives', 'components', 'directive-demo.ts'), 'utf8'); + assert.match(directiveDemo, /aria-label="Editable text/, 'the ref-focus input has an aria-label'); // Drizzle User model (saas overwrites db/schema.server.ts to add passwordHash) const schema = readFileSync(join(appDir, 'db', 'schema.server.ts'), 'utf8'); From 6c1020a0a7ea6c0d4f9db3bbaeadc6da585d2b68 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 18:46:29 +0530 Subject: [PATCH 5/8] 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). --- AGENTS.md | 2 +- agent-docs/recipes.md | 11 ++++++----- packages/cli/lib/saas-template.js | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fe9d8be9..23d697538 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`; 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 createPost from '...'; export const POST = route(createPost)`), which reads the declared `middleware` + `validate`; the bare-function form `route(createPost)` runs only what you pass in `route(fn, { middleware })` (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`; **`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 createPost from '...'; export const POST = route(createPost)`), 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/agent-docs/recipes.md b/agent-docs/recipes.md index 8d8342e9c..14d2c4345 100644 --- a/agent-docs/recipes.md +++ b/agent-docs/recipes.md @@ -202,12 +202,13 @@ 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 -the function (#876): `import * as createPost from './create-post.server.ts'; -export const POST = route(createPost)`. The adapter finds the single action +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. The -bare-function form (`route(createPost)`) cannot see sibling config exports, so it -applies only what you pass in `route(fn, { middleware, validate })`. +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) diff --git a/packages/cli/lib/saas-template.js b/packages/cli/lib/saas-template.js index c93e8f2a2..dbd626fc3 100644 --- a/packages/cli/lib/saas-template.js +++ b/packages/cli/lib/saas-template.js @@ -94,11 +94,12 @@ export async function writeSaasFiles(appDir, opts = {}) { "", "// AUTH_SECRET signs session tokens. Set a strong value in .env for any real", "// deployment. The dev fallback keeps a fresh scaffold booting, but is NOT", - "// safe for production, so we fail fast if it is missing there.", - "const authSecret = process.env.AUTH_SECRET ?? 'dev-insecure-secret-change-me';", - "if (process.env.NODE_ENV === 'production' && !process.env.AUTH_SECRET) {", + "// safe for production, so we fail fast if it is missing or blank there.", + "const trimmedSecret = process.env.AUTH_SECRET?.trim();", + "if (process.env.NODE_ENV === 'production' && !trimmedSecret) {", " throw new Error('AUTH_SECRET must be set in production');", "}", + "const authSecret = trimmedSecret || 'dev-insecure-secret-change-me';", "", "export const { auth, signIn, signOut, handlers } = createAuth({", " providers: [", From 4aba91d7d10018ce1d299350a731f3a6c40ee657 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 18:53:56 +0530 Subject: [PATCH 6/8] 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. --- blog/per-action-middleware.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/blog/per-action-middleware.md b/blog/per-action-middleware.md index 079f56c96..ec0e9c6f6 100644 --- a/blog/per-action-middleware.md +++ b/blog/per-action-middleware.md @@ -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: ```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 deletePost from '#modules/posts/actions/delete-post.server.ts'; +export const DELETE = route(deletePost); // 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 }`), 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 @@ -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. From e9e89cd510cf3a75f822ef5633936f45b3f7a8aa Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 18:58:14 +0530 Subject: [PATCH 7/8] 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. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 23d697538..ff6afca89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`; 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 createPost from '...'; export const POST = route(createPost)`), 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`; **`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 From 758b65d81ffcdfb768c164203e351b847a655393 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 19:03:22 +0530 Subject: [PATCH 8/8] 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. --- blog/per-action-middleware.md | 6 +++--- docs/app/docs/server-actions/page.ts | 4 ++-- packages/server/src/action-route.js | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/blog/per-action-middleware.md b/blog/per-action-middleware.md index ec0e9c6f6..f83c93be5 100644 --- a/blog/per-action-middleware.md +++ b/blog/per-action-middleware.md @@ -81,11 +81,11 @@ The `route()` adapter picks the declared chain up too, as long as you hand it th ```ts // app/api/posts/[id]/route.ts import { route } from '@webjsdev/server'; -import * as deletePost from '#modules/posts/actions/delete-post.server.ts'; -export const DELETE = route(deletePost); // its declared middleware + validate apply +import * as postActions from '#modules/posts/actions/delete-post.server.ts'; +export const DELETE = route(postActions); // its declared middleware + validate apply ``` -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 }`), 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. +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 diff --git a/docs/app/docs/server-actions/page.ts b/docs/app/docs/server-actions/page.ts index ca33c7a27..a929e3a25 100644 --- a/docs/app/docs/server-actions/page.ts +++ b/docs/app/docs/server-actions/page.ts @@ -243,9 +243,9 @@ export async function POST(req: Request) {

    The route() Adapter

    For the common case (merge query + route params + JSON body into one input object, run an optional validator, JSON-respond the result), the route() helper from @webjsdev/server writes that handler for you in one line. The hand-written route.ts above is always available for full control (custom headers, content negotiation, streaming).

    -

    Pass the action's module namespace (import * as createPost) instead of the function and route() auto-applies the action's declared middleware and validate, so a guard declared once beside the action protects the RPC and REST boundaries alike (issue #876): export const POST = route(createPost). The bare-function form cannot see sibling config exports, so it applies only what you pass in route(fn, {'{'} middleware, validate {'}'}).

    +

    The examples below import the action FUNCTION, which is the bare-function form: it applies only the middleware / validate you pass in route(fn, {'{'} middleware, validate {'}'}), because a function reference cannot reach its sibling config exports. To auto-apply the action's OWN declared middleware and validate, import the module namespace instead (import * as postActions from '.../create-post.server.ts') and pass that: export const POST = route(postActions), so a guard declared once beside the action protects the RPC and REST boundaries alike (issue #876).

    -
    // app/api/posts/route.ts
    +    
    // app/api/posts/route.ts (bare-function form)
     import { route } from '@webjsdev/server';
     import { createPost } from '../../../modules/posts/actions/create-post.server.ts';
     
    diff --git a/packages/server/src/action-route.js b/packages/server/src/action-route.js
    index aa2c8decf..c9d1a0333 100644
    --- a/packages/server/src/action-route.js
    +++ b/packages/server/src/action-route.js
    @@ -37,8 +37,8 @@
      * ```js
      * // app/api/posts/route.ts
      * import { route } from '@webjsdev/server';
    - * import * as createPost from '../../../modules/posts/actions/create-post.server.ts';
    - * export const POST = route(createPost);   // its declared middleware + validate apply
    + * import * as postActions from '../../../modules/posts/actions/create-post.server.ts';
    + * export const POST = route(postActions);   // its declared middleware + validate apply
      * ```
      *
      * The function form still takes explicit `opts`, and an explicit `opts.middleware`