diff --git a/AGENTS.md b/AGENTS.md index d5ca7399..ff6afca8 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 postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. ### RPC + REST endpoint security diff --git a/agent-docs/recipes.md b/agent-docs/recipes.md index 247bd030..14d2c434 100644 --- a/agent-docs/recipes.md +++ b/agent-docs/recipes.md @@ -200,6 +200,16 @@ option: `export const POST = route(createPost, { validate })`. A validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a non-envelope return transforms the input. +To reuse the action's OWN declared `middleware` / `validate` config on the REST +boundary without re-listing it, pass the MODULE NAMESPACE to `route()` instead of +the function (#876): `import * as postActions from './create-post.server.ts'; +export const POST = route(postActions)`. The adapter finds the single action +function and applies its declared `middleware` and `validate`, so a guard +declared once next to the action protects the RPC and REST boundaries alike. +Passing the imported function itself (`import { createPost }` then +`route(createPost)`) cannot see sibling config exports, so it applies only what +you pass in `route(fn, { middleware, validate })`. + ## HTTP verbs, caching, streaming, and cancellation (#488, #489, #490, #492) A `'use server'` action is a POST by default. Reserved sibling exports, read diff --git a/blog/per-action-middleware.md b/blog/per-action-middleware.md index 079f56c9..f83c93be 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 postActions from '#modules/posts/actions/delete-post.server.ts'; +export const DELETE = route(postActions); // its declared middleware + validate apply ``` -I would honestly prefer `route(deletePost)` to pick the action's own middleware up on its own, the way the RPC boundary does. Today it does not, so you re-export the `middleware` array and hand it to the adapter. It is one line, and the guard still lives next to the action as its single source of truth, but it is a real seam to remember rather than an automatic guarantee. Verified by dogfooding: `route(action)` with no `middleware` option runs the body without the declared guards. +Passing the whole module lets the adapter read the `export const middleware` (and `export const validate`) sitting next to the action, so the guard you declared once protects the REST boundary automatically, the same way it does on the RPC one. The guard stays a property of the action, not of a single transport. If you instead import just the function (`import { deletePost }` then `route(deletePost)`), the adapter has no way to reach its sibling config, so there you pass the chain explicitly with `route(deletePost, { middleware })`. Verified by dogfooding: the module form applies the declared guards, the bare-function form runs only what you pass it. # Compose several, in order @@ -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. diff --git a/docs/app/docs/server-actions/page.ts b/docs/app/docs/server-actions/page.ts index a524a737..a929e3a2 100644 --- a/docs/app/docs/server-actions/page.ts +++ b/docs/app/docs/server-actions/page.ts @@ -243,8 +243,9 @@ export async function POST(req: Request) {
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).
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/cli/lib/create.js b/packages/cli/lib/create.js
index 105c7426..dff147e3 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 f70156bb..dbd626fc 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,15 @@ 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 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: [",
" Credentials({",
@@ -97,7 +111,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
" },",
" }),",
" ],",
- " secret: process.env.AUTH_SECRET,",
+ " secret: authSecret,",
"});",
"",
].join('\n'));
@@ -324,7 +338,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
" ",
" ",
" ",
- " Sign in
",
+ " Sign in
",
" Welcome back: log in to continue.
",
" ",
" ",
@@ -393,7 +407,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
" ",
" ",
" ",
- " Create an account
",
+ " Create an account
",
" Get started with your new workspace.
",
" ",
" ",
@@ -460,7 +474,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 +500,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 98eab5ce..14682eb1 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.
`;
-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/components/components/counter-card.ts b/packages/cli/templates/gallery/modules/components/components/counter-card.ts
index 5f2084dd..39412047 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}
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 96ce0569..528c80af 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 e6f785f2..945079dd 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 bc7b6b08..6c7a52af 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 {
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 0929457e..3bdf0dd3 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/packages/server/src/action-route.js b/packages/server/src/action-route.js
index 6f8f1a40..c9d1a033 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 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`
+ * / `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 9a41d21b..94542e02 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/);
+});
diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js
index 014adeb8..f8916ad4 100644
--- a/test/scaffolds/scaffold-integration.test.js
+++ b/test/scaffolds/scaffold-integration.test.js
@@ -415,8 +415,63 @@ 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: 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(/`);
}
+ // #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');
assert.match(schema, /export const users = table\('users'/, 'users table present');