diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index 5ab334902..66d82b1ed 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -22,7 +22,7 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou - **`*.server.ts`** is the one server boundary. With `'use server'` its exports are RPC-callable from the client (the import is rewritten to a stub); without it the file is a server-only utility whose browser import throws at load. This, not a component annotation, is how a dependency (the DB driver, secrets, `node:*`) is kept off the client. - **`route.ts`** is a server-only HTTP handler (named `GET`/`POST` exports), the one routing file that is NOT isomorphic. -**Progressive enhancement is the default architecture.** With JS off, content reads, `` navigates, and `
` server actions submit. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration. +**Progressive enhancement is the default architecture.** With JS off, content reads, `` navigates, and a `` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration. ## When To Use This Skill @@ -54,7 +54,7 @@ Classify the task first, then load the smallest useful reference set. Each refer Common bundles: -- **Form or CRUD feature** then routing-and-pages, data-and-actions, testing; add auth if user-specific +- **Form or CRUD feature** then data-and-actions, routing-and-pages, testing; add auth if user-specific - **Interactive widget** then components, styling; add client-router-and-streaming only if it streams - **Protected area** then auth-and-sessions, routing-and-pages, testing - **Instant-feeling mutation** then data-and-actions, optimistic-ui @@ -105,6 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection 9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s). 10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators. 11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor). +12. A form that writes binds its action: ``. A quoted `action="${fn}"`, a `formaction=${fn}`, `action=${fn}` off a ``, a bound form with `method="get"`, and a non-action function all throw. A page has no `action` export, so a bare `` is a 405. ## Export Map @@ -189,24 +190,32 @@ class Counter extends WebComponent({ count: prop(Number) }) { Counter.register('my-counter'); ``` -### The no-JS write path (a page action) +### The no-JS write path (a form-bound action) ```ts -// app/contact/page.ts -export const action = async ({ formData }) => { +// modules/contact/actions/send-message.server.ts +'use server'; +export async function sendMessage(formData: FormData) { const email = String(formData.get('email') || ''); if (!email) return { success: false, fieldErrors: { email: 'required' } }; return { success: true, redirect: '/thanks' }; -}; -export default function Contact({ actionData }) { /* render form + actionData errors */ } +} + +// app/contact/page.ts +import { sendMessage } from '#modules/contact/actions/send-message.server.ts'; +export default function Contact({ actionData }) { + return html``; +} ``` -Success is a 303 (PRG); failure re-renders the page at 422 with the result on `actionData`. With JS the client router applies the response in place. +Binding the action is the whole wiring: the renderer omits `action` (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` identity field. A form-bound action always receives the `FormData`. + +Success is a 303 (PRG); failure re-renders the page at 422 with the result on `actionData`. With JS the client router applies the response in place. A submission that binds nothing is a 405, and the submission is Origin-verified like an RPC call. ## Security And Session Defaults - Never ship demo secrets. Require session and provider secrets from the environment and fail fast if missing. -- Action RPC CSRF is an Origin / `Sec-Fetch-Site` check, not a token cookie. A safe GET action is CSRF-exempt. A `route.ts` REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit. +- CSRF is an Origin / `Sec-Fetch-Site` check on both the action RPC and the form-submit path, not a token cookie. A safe GET action is CSRF-exempt. A `route.ts` REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit. - Prod action errors are sanitized to a generic message plus a digest. Put a user-facing message on the `ActionResult` `{ success: false, error }` envelope, never on a raw throw. - Use `forbidden()` for an authenticated user lacking permission, `unauthorized()` for an unauthenticated request. Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure instead of throwing. - For CORS use `cors()` from `@webjsdev/server`; `credentials: true` REQUIRES an explicit origin allowlist, never `'*'`. @@ -225,6 +234,9 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a - Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory. - Quoting an event / property / boolean hole (`@click="${fn}"`). - Writing `fetch()` to call your own server instead of importing the action. +- Writing a bare `
` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405. +- Reaching for `formaction=${fn}` on a submit button to give one form several actions. It is refused; write one form per action. +- Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error. - Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there. - A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`. - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`. diff --git a/.agents/skills/webjs/references/auth-and-sessions.md b/.agents/skills/webjs/references/auth-and-sessions.md index 83bef7385..136ef5992 100644 --- a/.agents/skills/webjs/references/auth-and-sessions.md +++ b/.agents/skills/webjs/references/auth-and-sessions.md @@ -113,7 +113,7 @@ export const POST = handlers.POST;
``` -For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a page `action` can return directly. +For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a form-bound action can return directly (the framework honors a returned `Response` verbatim). Sessions are JWT by default (stateless, scales horizontally). OAuth providers handle the full redirect flow. Read the session anywhere on the @@ -208,7 +208,7 @@ export default async function Admin() { } ``` -Both work from a page or layout render AND from a page `action` (the no-JS +Both work from a page or layout render AND from a form-bound action (the no-JS write path). The boundary files (`app/forbidden.ts`, `app/unauthorized.ts`, or nested variants) live alongside the routes they cover and each default-export a function returning the boundary markup. diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index fa3390396..e36ef7e26 100644 --- a/.agents/skills/webjs/references/data-and-actions.md +++ b/.agents/skills/webjs/references/data-and-actions.md @@ -11,7 +11,7 @@ - Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`) - Keeping server-only types off the client (`import type` vs a value import) -Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (the page `action` write path, `route.ts` handlers), `auth-and-sessions.md` (protecting an action or endpoint), `optimistic-ui.md` (consuming `ActionResult` on the client), `typescript.md` (erasable syntax, full-stack types). +Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (where a bound form lives on a page, `route.ts` handlers), `auth-and-sessions.md` (protecting an action or endpoint), `optimistic-ui.md` (consuming `ActionResult` on the client), `typescript.md` (erasable syntax, full-stack types). ## The Architecture (read this first) @@ -100,7 +100,7 @@ A `.returning()` row is the table's own columns only, never `with` relations. Wh ## Input validation at the boundary -Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on the RPC boundary, receiving the action's FIRST argument. The framework only CALLS the validator (it ships no validation library) and reads its return: `{ success: true, data? }` runs the action (an optional `data` replaces the input), `{ success: false, fieldErrors }` returns a 422 WITHOUT running the body, and a THROW becomes a sanitized error. +Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on every BOUNDARY (the RPC endpoint, a `route()` REST endpoint, and a form submission), receiving the action's FIRST argument. On the form boundary that first argument is the `FormData`, and a `{ success: false, fieldErrors }` return becomes a 422 RE-RENDER of the page with the validator's result on `actionData`, rather than a JSON 422. The framework only CALLS the validator (it ships no validation library) and reads its return: `{ success: true, data? }` runs the action (an optional `data` replaces the input), `{ success: false, fieldErrors }` returns a 422 WITHOUT running the body, and a THROW becomes a sanitized error. ```ts // modules/posts/actions/create-post.server.ts @@ -126,6 +126,31 @@ export async function createPost(input: CreatePostInput) { /* runs only when val A client call resolves with the failure envelope (it does NOT throw), so the component reads `result.fieldErrors`. A zod adapter wraps `safeParse` so its result becomes the envelope; the framework stays zod-free. +## Binding an action to a form + +A `
` is the one way a form submits to a server action, and it is the whole wiring: + +```ts +import { createPost } from '#modules/posts/actions/create-post.server.ts'; +html`
`; +``` + +The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `/` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, so the two paths are identical by construction. + +**A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input. + +Everything the action declares applies here too, or an action would be protected over RPC and open over a form: + +- `validate` runs on the submitted `FormData`. +- the `middleware` chain runs, with the page's route context (`params`, `searchParams`, `url`) added to `ctx`. +- `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data. +- `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag. +- `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`. + +The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed. + +A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead. + ## HTTP-verb config exports A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`). diff --git a/.agents/skills/webjs/references/muscle-memory-gotchas.md b/.agents/skills/webjs/references/muscle-memory-gotchas.md index 3adfb0b76..c4bd5808d 100644 --- a/.agents/skills/webjs/references/muscle-memory-gotchas.md +++ b/.agents/skills/webjs/references/muscle-memory-gotchas.md @@ -27,7 +27,7 @@ export async function GET() { redirect('/login'); } export async function GET(req: Request) { return Response.redirect(new URL('/login', req.url), 303); } ``` -Do NOT throw `redirect()` from a page `action` to bounce a form POST either. The method-preserving 307 default re-POSTs the body and re-runs the mutation. Return an `ActionResult` with a `redirect` field instead (a 303 PRG), or throw only for a real external redirect. +Do NOT throw `redirect()` from a form-bound action to bounce a form POST either. The method-preserving 307 default re-POSTs the body and re-runs the mutation. Return an `ActionResult` with a `redirect` field instead (a 303 PRG), or throw only for a real external redirect. ### Reads are server actions, not `fetch()` in a Server Component @@ -43,11 +43,18 @@ const users = await getUsers(); There is no React `cache()`, `use()`, or `unstable_cache`. Caching is the `cache()` query helper, `export const revalidate` on a page, or `export const cache` on a GET action. -### A function in `
` is refused, not stringified +### `` binds, in exactly one shape -Next binds a Server Action with `` and React serializes the binding into hidden fields. WebJs does not read that shape. A function interpolated into `action=` is a hard render error. +Next binds a Server Action with ``, and WebJs reads the same shape, so this muscle memory transfers. The mechanism underneath differs, and the difference is what the rest of this section is about: React serializes the binding (bound arguments included) into hidden fields, while WebJs emits ONE hidden field carrying the action's `/` identity and no arguments. A per-row action therefore takes its row id from a hidden input in the form, never from `action.bind(null, id)`. -The reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike. +```ts +import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts'; +html``; +``` + +That is the whole wiring. The renderer omits the `action` attribute (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits the identity field. Writing `method="get"` on a bound form throws, because a GET form sends no body and the action could never run. + +**Only the bare, unquoted `action=${fn}` on a `
` binds.** Every near-miss is a hard render error rather than a silently-inert form, and the reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike. What escapes is the SOURCE the runtime reports, and how much that includes depends on the runtime. The body always goes: your query shapes, your table and column names, your internal paths, and any credential written inline. @@ -63,18 +70,26 @@ Do not go looking for the rule that decides when it folds. Export status, read c So treat everything reachable from the action as exposed. That is the assumption the refusal is built on, it is the only one that holds across runtimes, and it is the only one that stays true when the transpiler changes. -The refusal covers the shape, not one spelling of it. Every hole form is refused (`action=${fn}`, `action="${fn}"`, the mixed `action="/x/${fn}"`), and so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. Casing does not help either: `formAction=` and `ACTION=` fold to the same rule. +The refusal covers the shape, not one spelling of it. A quoted `action="${fn}"` and the mixed `action="/x/${fn}"` are refused, because quoting turns a binding hole back into a plain attribute; so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. `formaction=` is refused everywhere. Attribute names fold case, so `ACTION=${fn}` on a `` BINDS like the lowercase spelling, while `formAction=${fn}` on a button is refused like `formaction=`. -**Commenting the form out does not disable the hole.** A comment is HTML, the interpolation is JavaScript, and the renderer emits a comment's holes raw, so `` still ships the whole action body with no throw and no log. Delete the binding instead of commenting around it. This is the one shape in this section that leaks silently, which is exactly why it is worth knowing. +**Commenting the form out does not disable the hole.** A comment is HTML, the interpolation is JavaScript, and the renderer emits a comment's holes raw, so a hole inside `` never reaches the binding branch and is stringified instead: `` ships the whole action body with no throw and no log. Commenting out a WORKING binding is therefore not a way to disable it, it is a way to turn it into a leak. Delete the form or move it out of the template. This is the one shape in this section that leaks silently, which is exactly why it is worth knowing. -It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`
${fn}
`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are refused today; treat a function anywhere else in a template as a mistake that ships, and reach for `@event=${fn}` or a custom element's `.prop=${fn}`, neither of which stringifies. +It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`
${fn}
`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are claimed today; treat a function anywhere else in a template as a mistake that ships, and reach for `@event=${fn}` or a custom element's `.prop=${fn}`, neither of which stringifies. -The refused and allowed shapes in full. Every "no" row is a binding that stringifies nothing, so refusing it would break working code rather than close a leak: +The bound, refused, and allowed shapes in full. Every "no" row is a binding that stringifies nothing, so refusing it would break working code rather than close a leak: | Written as | Refused? | Why | |---|---|---| -| `action=` / `formaction=` | yes | ordinary attribute, stringified into the HTML | -| `.action=` on a native form | yes | the property reflects, so the source lands in the DOM on the client | +| `action=${fn}` unquoted, on a `` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified | +| `action=${fn}` on any other tag | yes | `action` submits nothing off a ``, so it is an ordinary attribute and the function would be stringified | +| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute | +| `formaction=` anywhere | yes | not supported YET rather than impossible (tracked in #1207). Today, write one form per action, or bind one action and dispatch on a submit button's `name="intent"` | +| `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it | +| `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes | +| a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position | +| a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page | +| `method=" post "` / `enctype=" multipart/form-data "` | yes | `method` and `enctype` are enumerated attributes matched against exact keywords with no whitespace stripping, so a padded value falls to the invalid-value default and the form submits as a GET with no body. Trimming it for you would emit the padded value anyway | +| `encoding="..."` as an ATTRIBUTE on a bound form | **no** | inert in HTML (`form.encoding` reads back `enctype`), so both renderers ignore it and still supply `enctype`. Only the `.encoding` PROPERTY aliases enctype, and that spelling IS refused, one row up | | `.formAction=` on a button or input | yes | same reason, that is where `formAction` reflects | | `.action=` on any other native tag | **no** | a plain expando (`
`, ` @@ -89,7 +92,9 @@ render() { } ``` -When a page owns SEVERAL mutations (create, toggle, delete), give each form a hidden `intent` field and let the single page `action` dispatch on it. Each interactive control (a toggle button, a delete button) is also its own tiny form so it still works with JS off. +`method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand. + +When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`). Each interactive control is its own tiny form, which is currently required rather than merely tidy: `formaction=${fn}` on a submit button is not supported yet (tracked in #1207). A form that genuinely needs several buttons binds ONE action and dispatches on a submit button's `name="intent"` inside it. ## Seed the list from the server for SSR plus optimistic diff --git a/.agents/skills/webjs/references/routing-and-pages.md b/.agents/skills/webjs/references/routing-and-pages.md index 6781bc82c..104e75552 100644 --- a/.agents/skills/webjs/references/routing-and-pages.md +++ b/.agents/skills/webjs/references/routing-and-pages.md @@ -7,7 +7,7 @@ - `route.ts` HTTP handlers and `middleware.ts`, the route-handler toolkit (`json` / `readBody` / `clientIp` / the no-arg accessors), and calling one from the client with `richFetch` - `metadata` and `generateMetadata` (folded in here), including image metadata routes that return a `Response` - Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()` -- The no-JS page `action` write path +- The no-JS write path: a `
` bound to a `'use server'` action - Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones Read this when a task touches the route contract, a URL, a `` tag, a redirect, a 404, or a form POST that a page owns. Sibling refs: `components.md` (anything interactive), `data-and-actions.md` (server actions, queries, validation, the `ActionResult` envelope), `auth-and-sessions.md` (`forbidden()` / `unauthorized()` flows). @@ -32,7 +32,7 @@ export default function About() { `params` and `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next.js 15/16 parity). Throw `notFound()` or `redirect(url)` to short-circuit. Reach data through a `.server.ts` query; never import the DB driver into a page. -Optional named exports: `metadata` / `generateMetadata` (below), `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor), and `export const action` (the write path, below). +Optional named exports: `metadata` / `generateMetadata` (below) and `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor). There is no `action` export; the write path is a form bound to a `'use server'` action (below). ## Layouts (`app/**/layout.ts`) @@ -115,24 +115,26 @@ Common fields: `title` (string or `{ template, default, absolute }`), `descripti ## Control-flow throws -From `@webjsdev/core`: throw to short-circuit a page / layout render or a page `action`. +From `@webjsdev/core`: throw to short-circuit a page / layout render or a form-bound action. - `notFound()` renders the nearest `not-found.ts` (nearest wins from the throwing chain). -- `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a page action. Override with `redirect(url, 308)` or `redirect(url, { status })`. +- `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a form-bound action. Override with `redirect(url, 308)` or `redirect(url, { status })`. - `forbidden()` renders the nearest `forbidden.ts` (authenticated user lacking permission); `unauthorized()` renders the nearest `unauthorized.ts` (request not authenticated). None of these belong in a `route.ts` (return a `Response` there). Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure rather than throwing (`data-and-actions.md`). -## The no-JS write path (a page `action`) +## The no-JS write path (a form-bound action) -A `page.ts` may export an `action` beside its default render function. A non-GET/HEAD submission to the page's own URL runs it, wrapped in the page's segment middleware. It works with JS off; with JS on the client router applies the response in place. +A page imports a `'use server'` action and binds it into the form: ``. There is no page `action` export; the binding IS the wiring. The renderer omits the `action` attribute (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `/` identity. A non-GET/HEAD submission carrying that identity runs the action, wrapped in the segment middleware of the URL it was submitted to AND the action's own declared `middleware`. It works with JS off; with JS on the client router posts the same body to the same url and applies the response in place. -```ts -// app/contact/page.ts -import { html } from '@webjsdev/core'; -import { sendMessage } from '#modules/contact/actions/send-message.server.ts'; +**Do not treat the page's segment middleware as the action's authorization gate.** An identity names the action, not the page that rendered it, so the same action is reachable by submitting to any page path (and, being a `'use server'` export, at its RPC endpoint too, where no segment middleware runs at all). That is the same reachability every server action has always had, and the removed page `action` export was the one shape where the URL really did scope the handler. Authorize the action itself: an `export const middleware = [requireAdmin]` on the action, or a check in its body, both of which run on either transport. -export async function action({ formData }: { formData: FormData }) { +A form-bound action always receives the `FormData` as its first argument, which is where it differs from the same action called over RPC. `validate` is the typing seam: it receives that `FormData`, and its transform-return becomes the action's typed input. + +```ts +// modules/contact/actions/send-message.server.ts +'use server'; +export async function sendMessage(formData: FormData) { const email = String(formData.get('email') || '').trim(); const body = String(formData.get('body') || '').trim(); const values = { email, body }; @@ -140,9 +142,15 @@ export async function action({ formData }: { formData: FormData }) { if (!email.includes('@')) fieldErrors.email = 'Enter a valid email'; if (body.length < 10) fieldErrors.body = 'Message is too short'; if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 }; - await sendMessage({ email, body }); + await deliver({ email, body }); return { success: true, redirect: '/contact/thanks' }; } +``` + +```ts +// app/contact/page.ts +import { html } from '@webjsdev/core'; +import { sendMessage } from '#modules/contact/actions/send-message.server.ts'; export default function Contact({ actionData }: { actionData?: { fieldErrors?: Record; values?: Record }; @@ -150,7 +158,7 @@ export default function Contact({ actionData }: { const errors = actionData?.fieldErrors || {}; const values = actionData?.values || {}; return html` - + ${errors.email ? html`

${errors.email}

` : ''} @@ -161,7 +169,17 @@ export default function Contact({ actionData }: { } ``` -How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a `` + page action over `fetch` in a `@click` for any write a form can express. +How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a bound `` over `fetch` in a `@click` for any write a form can express. + +Three responses that are not the happy path: + +- **A submission carrying no identity is a `405` + `Allow: GET, HEAD`.** A bare `` binds nothing, and the page path exists but only renders, so the method is what is wrong rather than the url. +- **An identity whose hash no longer resolves re-renders at `422`** with a resubmit message and the submitted values on `actionData`. That is a form held open across a deploy; a 404 would discard what was typed and a silent no-op would show success for a write that never happened. +- **A bound action declaring `export const method = 'GET'` is a `405`.** A GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. `webjs check`'s `form-action-not-a-get-action` catches it at edit time. + +The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field. + +Refusals worth knowing: `formaction=${fn}` on a submit button is refused (write one form per action), a bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table. ## Error, loading, and 404 boundaries diff --git a/.agents/skills/webjs/references/testing.md b/.agents/skills/webjs/references/testing.md index 98ac8ae15..24ee83685 100644 --- a/.agents/skills/webjs/references/testing.md +++ b/.agents/skills/webjs/references/testing.md @@ -48,11 +48,11 @@ test/ ## The `handle()` harness (`@webjsdev/server/testing`) -`createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, page actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session. +`createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, form-dispatched actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session. ```js import { createRequestHandler } from '@webjsdev/server'; -import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie } +import { testRequest, submitForm, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing'; const app = await createRequestHandler({ appDir: process.cwd(), dev: true }); @@ -77,6 +77,28 @@ const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, c assert.equal(dash.status, 200); ``` +### `submitForm`: the no-JS write path + +Use it for any page whose form binds an action (``). A bound form carries a hidden `__webjs_action` field holding the action's identity, and that field is what tells the dispatcher which action to run, so a hand-written `POST` that omits it is not a form submission at all and is answered **405**. `submitForm` renders the page, reuses the identity the server put there, and posts it back, which is exactly what a browser with JS off does. + +```js +import { submitForm } from '@webjsdev/server/testing'; + +// success is a 303 PRG +const ok = await submitForm(app.handle, '/signup', { name: 'Ada', email: 'ada@example.com', password: 'hunter2' }); +assert.equal(ok.status, 303); +assert.equal(ok.headers.get('location'), '/dashboard'); + +// a failure result re-renders the SAME page at 422 with the result on actionData +const bad = await submitForm(app.handle, '/signup', { email: 'not-an-email' }); +assert.equal(bad.status, 422); +assert.match(await bad.text(), /Enter a valid email/); +``` + +Options: `cookies` (submit as a logged-in user, paired with `loginAndGetCookies`), `match` (a string or RegExp the form's markup must contain, for a page with several forms), `index` (pick by position instead), `submitPath` (render one page, submit to another), and `headers`. + +Do not hand-roll the identity scrape. When it is wrong the symptom is a 405, an assertion fails, and a surrounding `catch` reports it as a database that was never migrated, so the test goes quiet rather than red. + ### `invokeActionForTest`: round-trip an action through the REAL endpoint ```js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25e2b2244..7227f8d5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,13 @@ jobs: # machines, which are independent and must be kept in step by hand. - name: WebJs form-action guard parity on Bun run: bun test/bun/form-action-guard.mjs + # Form-action dispatch (#1155) on Bun: the 'use server' load hook that + # registers action identity is installed by a different mechanism on each + # runtime (module.registerHooks vs Bun.plugin), and the submission path + # runs through FormData / multipart parsing and Web Crypto. A divergence + # means a no-JS form silently posts nowhere. + - name: WebJs form-action dispatch parity on Bun + run: bun test/bun/form-action-dispatch.mjs # Listener overhead reductions on Bun (#756): the out-of-band IP stamp (no # Request clone), the buffered sync-compression fast path, the streamed-head # non-blocking classifier, and the basePath rebuild spoof guard. Run as the diff --git a/AGENTS.md b/AGENTS.md index 86feb9d61..f653c9415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rai - **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `references/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target. See `references/runtime.md` for the Node vs Bun command + difference reference. - **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR. -- **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `
` navigates, `` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `` + server action would do. +- **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `` navigates, `` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `` would do. - **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `references/components.md`. - **Server actions with rich types.** A `*.server.{js,ts}` file with `'use server'` exports functions importable from the client (the import is rewritten to a typed RPC stub); the wire round-trips `Date` / `Map` / `Set` / `BigInt` / `Error` / typed arrays / `Blob` / `File` / `FormData` / Symbols / cycles. The source is never served to the browser (invariant 1). - **Only files reachable from a browser-bound entry are servable.** The dev server walks the import graph from every page / layout / error / loading / not-found / component file (lazily on first request, re-derived after each `fs.watch` rebuild); that Set is the authorisation gate, so anything no client code imports returns 404. The walk follows static `import` / `export … from` AND string-literal dynamic `import('./x.ts')` edges (#751), so a lazily-imported app module is servable (it is NOT eagerly preloaded, since a dynamic import is lazy by intent). A computed `import(expr)` cannot be resolved statically and 404s with a dev hint. @@ -210,7 +210,7 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server | `render(v, el)` | Client-side render into a DOM element. | | `renderToString` | Server-side async render to HTML with DSD (from `/server`). | | `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. **NEVER** throw `redirect()` inside API route handlers (`route.ts`), as they must return a standard `Response.redirect(url, 303)` response object instead. | -| `forbidden()` / `unauthorized()` | Throw to return 403 / 401 (#848, Next 15/16 parity). Renders the nearest `forbidden.{js,ts}` / `unauthorized.{js,ts}` boundary (innermost wins), else a default page, from a page/layout render OR a page `action` (the no-JS write path). `forbidden()` for an authenticated user lacking permission, `unauthorized()` for a request that is not authenticated. Same control-flow-throw model as `notFound()`: NOT for a `route.ts` handler, and inside a `'use server'` RPC action return an `ActionResult` for an auth failure instead (a raw throw there is a generic 500, like `notFound()`/`redirect()`). | +| `forbidden()` / `unauthorized()` | Throw to return 403 / 401 (#848, Next 15/16 parity). Renders the nearest `forbidden.{js,ts}` / `unauthorized.{js,ts}` boundary (innermost wins), else a default page, from a page/layout render OR a form-bound server action (the no-JS write path). `forbidden()` for an authenticated user lacking permission, `unauthorized()` for a request that is not authenticated. Same control-flow-throw model as `notFound()`: NOT for a `route.ts` handler, and inside a `'use server'` RPC action return an `ActionResult` for an auth failure instead (a raw throw there is a generic 500, like `notFound()`/`redirect()`). | | `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. | | `` | Component-level streaming boundary element (#471): wraps one or more components, flushes `.fallback` on the first byte, streams the resolved content in (concurrently across boundaries, progressively on soft nav). The renderer-recognized opt-in for SLOW async-render data. | | `asset(path)` | Content-hash a `public/` asset url so a deploy cannot serve stale bytes: `href=${asset('/public/app.css')}` emits `?v=` and is served `immutable` for a year (#1194). Prod-only, `public/` paths only. Use it in a PAGE, LAYOUT, or metadata route, not inside a component that ships: hydration re-renders on the client where there is no resolver, so the hashed url is swapped for the bare path and the asset is fetched twice. Call it inside the render function, since a module-scope call is a side effect that ships the module. Mark only files that change with a DEPLOY (the hash is memoized for the process lifetime). Opt-in on purpose: do NOT mark a `rel=preload` hint whose asset is fetched by CSS `url()`, or the preload can never match. | @@ -226,7 +226,7 @@ lit-html parity: `repeat` (keyed lists), `unsafeHTML(str)` (trusted raw HTML, ** ### `html` expression prefixes -`
${x}
` text child; `class=${x}` attribute (escaped); `@click=${fn}` event listener (client-only, drops at SSR); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event` and `.prop` on native elements. +`
${x}
` text child; `class=${x}` attribute (escaped); `action=${importedAction}` on a `` binds a server action (#1155: the ONE function-valued attribute hole, the attribute is omitted so the form posts to the page's own url, `method="post"` + an enctype are supplied, and a hidden `__webjs_action` identity field is emitted; a quoted `action="${fn}"`, a `formaction=${fn}`, or `action=${fn}` on any other tag is a render error, since stringifying a function would leak a server action's source); `@click=${fn}` event listener (client-only, drops at SSR); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event` and `.prop` on native elements. --- @@ -270,7 +270,7 @@ MyThing.register('my-thing'); - Default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. Runs **only on the server**. Throw `notFound()` / `redirect(url)` to short-circuit. `params` / `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next 15/16 parity, #848). - Named exports: `metadata` (static), `generateMetadata(ctx)` (async, takes precedence). Type both with `Metadata`. See `references/routing-and-pages.md`. - Optional `export const revalidate` (seconds) opts into the server HTML response cache (#241). SAFETY: only on a page identical for every visitor (no `cookies()` / session / per-user data); keyed by URL only. See `references/built-ins.md`. -- Optional `export const action`: a fn `({ request, params, searchParams, url, formData })` handling a non-GET submission to the page's own URL (the no-JS write-path, #244), returning an `ActionResult`. Success is a `303` (PRG); failure re-renders the SAME page at `422` with the result on `ctx.actionData`. See `references/data-and-actions.md`. +- A page has NO `action` export. The no-JS write path is a `` binding a `'use server'` action (#1155): a non-GET submission to the page's own URL is dispatched to the action the hidden `__webjs_action` field names, returning an `ActionResult`. Success is a `303` (PRG); failure re-renders the SAME page at `422` with the result on `ctx.actionData`; a submission binding nothing is a `405`. See `references/data-and-actions.md`. - Page modules also load on the client so imported components register; keep top-level imports browser-safe. **Server-only code goes only in `.server.{js,ts}`, `route.ts`, or `middleware.ts`. Never in pages, layouts, or components.** ### Layouts (`app/**/layout.{js,ts}`) @@ -297,7 +297,7 @@ Optional app-root file default-exporting a **schema object** (env-var names to a | File | `'use server'`? | What it is | |---|---|---| -| `*.server.ts` | yes | **Server action.** Source-protected AND RPC-callable; client imports become stubs POSTing to `/__webjs/action//`. | +| `*.server.ts` | yes | **Server action.** Source-protected AND RPC-callable; client imports become stubs POSTing to `/__webjs/action//`. The same `/` identity is what a bound `` submits, so one action is reachable over RPC AND as a plain HTML form post to the page's own url. | | `*.server.ts` | no | **Server-only utility.** Source-protected; browser imports get a throw-at-load stub. | | Plain `.ts` | yes | **Lint violation** (`use-server-needs-extension`). Rename to add `.server.`. | | Plain `.ts` | no | Browser-safe; standard. | @@ -356,7 +356,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe ## Client navigation: automatic, nothing to opt into -The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. +The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **``** partial-swap regions, **View Transitions** (opt-in via ``, plus `data-webjs-permanent` to persist a live element), **stream actions** (`` element-level updates, #248), and the **opt-in nav-loading indicator** (`` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2). @@ -462,6 +462,8 @@ const result = await optimistic(liked, true, () => likePost(postId)); 11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a ``, an `[expr]` subscript, or a `foo()` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs ` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing). +12. **A form that writes binds its action: ``** (#1155). That is the only shape the renderer reads, and every near-miss throws rather than producing a form that posts nowhere: a quoted `action="${fn}"`, a `formaction=${fn}` on a submit button (write one form per action, #1207), `action=${fn}` on any tag other than ``, a bound form declaring `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding on a bound form (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole on one form, a plain `action="/url"` attribute alongside the bound hole (SSR keeps it and the client drops it, so the no-JS submission would post somewhere else), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. WebJs supplies `method` and `enctype` only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` emits nothing so it is supplied, while `method=${null}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `` is a `405`. **The near-miss guarantee is form-level and stops at the submitter:** a button's `formmethod` / `formenctype` is seen by neither renderer, so ` + + `; +} +``` + +Binding the action is the whole wiring: no `method`, no `enctype`, no adapter. Works with JS off; with JS the client router applies the 422 in place and follows the 303. + ### Add a server action ```ts @@ -555,7 +586,7 @@ export async function updateProfile(input: { name: string }) { } ``` -Call it from a client component via a normal import (rewritten to an RPC stub). A component is one custom element per file (`class X extends WebComponent { render() { return html\`…\`; } }` then `X.register('x-tag')`). Full recipes, including the no-JS page-action form, are in `references/data-and-actions.md`. +Call it from a client component via a normal import (rewritten to an RPC stub). A component is one custom element per file (`class X extends WebComponent { render() { return html\`…\`; } }` then `X.register('x-tag')`). Full recipes, including the no-JS bound form, are in `references/data-and-actions.md`. --- diff --git a/blog/nextjs-16-file-routing-parity.md b/blog/nextjs-16-file-routing-parity.md index a644f7146..b6f8d7498 100644 --- a/blog/nextjs-16-file-routing-parity.md +++ b/blog/nextjs-16-file-routing-parity.md @@ -39,7 +39,7 @@ export default function AdminForbidden() { } ``` -This is the one place the habit needs adjusting, because the throw model has edges. These work from a page or layout render, and from a page `action` (the no-JS write path, the function that handles a form POST to the page's own URL). They do NOT belong in a `route.ts` handler, which is raw HTTP and should return a `Response` itself. And inside a `'use server'` RPC action, a raw `forbidden()` throw becomes a generic 500, because an action's job is to return a value, not to throw control flow. For an auth failure inside an action, return an `ActionResult`. +This is the one place the habit needs adjusting, because the throw model has edges. These work from a page or layout render, and from a form-bound server action (the no-JS write path, the action a `
` submits to). They do NOT belong in a `route.ts` handler, which is raw HTTP and should return a `Response` itself. And inside a `'use server'` RPC action, a raw `forbidden()` throw becomes a generic 500, because an action's job is to return a value, not to throw control flow. For an auth failure inside an action, return an `ActionResult`. ```ts // modules/posts/actions/delete-post.server.ts diff --git a/blog/works-without-javascript.md b/blog/works-without-javascript.md index efbeeb073..528b35d09 100644 --- a/blog/works-without-javascript.md +++ b/blog/works-without-javascript.md @@ -27,18 +27,22 @@ WebJs is built to make the correct path the easy one. Server-known data arrives Reading without JavaScript is the easy half. The harder half is writing, and this is where a lot of frameworks quietly give up and require a client fetch. WebJs keeps the write path working with a plain form. -A page can export an `action`: +You bind a server action into the form: ```ts -// app/contact/page.ts -export async function action({ formData }) { +// modules/leads/actions/save-lead.server.ts +'use server'; +export async function saveLead(formData: FormData) { const email = String(formData.get('email') || ''); if (!email) return { success: false, fieldErrors: { email: 'required' } }; - await saveLead(email); + await db.insert(leads).values({ email }); return { success: true, redirect: '/thanks' }; } + +// app/contact/page.ts +import { saveLead } from '#modules/leads/actions/save-lead.server.ts'; export default function Contact({ actionData }) { - return html` + return html` ${actionData?.fieldErrors?.email ? html`

${actionData.fieldErrors.email}

` : ''} @@ -46,9 +50,11 @@ export default function Contact({ actionData }) { } ``` -With JavaScript off, this is a normal HTML form. It POSTs to its own URL, the `action` runs on the server, and a success returns a `303` redirect (the post-redirect-get pattern, so a refresh does not resubmit) while a failure re-renders the same page at `422` with the result on `actionData`. No JavaScript touched any of it. The validation errors render server-side. +That binding is the whole wiring. There is no `method`, no `enctype`, and no adapter in the page. What the browser receives is a plain HTML form: the framework drops the `action` attribute so the form posts to its own URL, adds the attributes a submission needs, and writes one hidden field naming the action to run. The action's code never goes anywhere near the browser. + +With JavaScript off, that is a normal HTML form. It POSTs to its own URL, the action runs on the server, and a success returns a `303` redirect (the post-redirect-get pattern, so a refresh does not resubmit) while a failure re-renders the same page at `422` with the result on `actionData`. No JavaScript touched any of it. The validation errors render server-side. The submission is origin-checked, so there is no CSRF token to add either. -With JavaScript on, the client router intercepts the same submission and applies the response in place: a `303` is followed via fetch without a full reload, a `422` swaps the re-rendered page without losing scroll position. Same form, same server action, same result. The enhancement is that it happens without a page flash, not that it happens at all. You opt a form out of the router with `data-no-router` and it falls back to the native submit, which still works. +With JavaScript on, the client router intercepts the same submission and applies the response in place: a `303` is followed via fetch without a full reload, a `422` swaps the re-rendered page without losing scroll position. Same form, same server action, same result, because with JavaScript on the router posts the same body to the same URL rather than taking a second code path. The enhancement is that it happens without a page flash, not that it happens at all. You opt a form out of the router with `data-no-router` and it falls back to the native submit, which still works. # Why this is worth the discipline diff --git a/examples/blog/.agents/rules/workflow.md b/examples/blog/.agents/rules/workflow.md index ca11c6a2e..d3f7c7c1d 100644 --- a/examples/blog/.agents/rules/workflow.md +++ b/examples/blog/.agents/rules/workflow.md @@ -139,13 +139,13 @@ self-review loop. is never called on the server, so anything there only runs after hydration. Initial data for components comes from the page function (server-side fetch plus pass as attribute/property), NOT from `fetch` calls - in `connectedCallback`. For write-paths, prefer a plain `` - posting to the page's own URL, handled by that page's `action` export, over - `fetch` plus a click handler. The framework upgrades plain forms to - partial-swap submissions automatically. Never interpolate a server action - into the attribute (``): that Next binding is - refused at render time, since stringifying the function would leak its - source into the HTML. + in `connectedCallback`. For write-paths, bind a `'use server'` action into + the form (``) over `fetch` plus a click handler. + The renderer omits the attribute so the form posts to the page's own URL, + supplies `method="post"` and an enctype, and emits a hidden field naming the + action, so nothing about the action's source reaches the browser. The + framework upgrades the form to a partial-swap submission automatically. A + bare `` binds nothing and is a 405. - **Client navigation is auto-magic.** Real `
` and `` get partial-swap behavior with no opt-in. Because layouts persist across navigation, put shared chrome (sidenav, header) in `layout.ts` and diff --git a/examples/blog/CONVENTIONS.md b/examples/blog/CONVENTIONS.md index 89cf29c81..cfe763e12 100644 --- a/examples/blog/CONVENTIONS.md +++ b/examples/blog/CONVENTIONS.md @@ -698,13 +698,14 @@ state. Only the *interactivity itself* (the +/- click, the open/close toggle, the tab switch) requires JS. **Default rules:** -- **Forms must work as plain HTML POSTs.** Post to the page's own URL - (omit `action` entirely) and handle the submission in that page's - `action` export. Never `fetch` + a JS click handler for the happy - path. Do NOT interpolate a server action into the attribute - (``): that is the Next binding, it is not - how WebJs reaches an action, and it is refused at render time because - stringifying the function would write its source into the HTML. The framework upgrades the form to a partial-swap +- **Forms must work as plain HTML POSTs.** Bind the `'use server'` action + into the form (``), which is the whole + wiring: the renderer omits the attribute so the form posts to the + page's own URL, supplies `method="post"` and an enctype, and emits a + hidden field carrying the action's identity, so the action's source + never reaches the browser. Never `fetch` + a JS click handler for the + happy path. A bare `` binds nothing and is a 405. + The framework upgrades the form to a partial-swap submission automatically when the client router is active, and with JS disabled the same form does a full-page POST and works identically end-to-end. diff --git a/examples/blog/app/feedback/live/page.ts b/examples/blog/app/feedback/live/page.ts new file mode 100644 index 000000000..ebb5c1541 --- /dev/null +++ b/examples/blog/app/feedback/live/page.ts @@ -0,0 +1,28 @@ +import { html } from '@webjsdev/core'; +import '#modules/feedback/components/feedback-form.ts'; + +/** + * The same bound action as `/feedback`, but inside a SHIPPING component. + * + * `/feedback` is a plain page: it never hydrates, so the client renderer never + * touches its form. This route is the hydrated counterpart, which is where the + * client half of the binding actually runs (see `feedback-form.ts`). + */ + +type PageCtx = { + actionData?: { fieldErrors?: Record; values?: Record }; +}; + +export const metadata = { title: 'Live feedback - WebJs Blog' }; + +export default function LiveFeedbackPage({ actionData }: PageCtx) { + return html` +
+

Send feedback (hydrated)

+ +
+ `; +} diff --git a/examples/blog/app/feedback/page.ts b/examples/blog/app/feedback/page.ts index e0365c0c6..593f0541e 100644 --- a/examples/blog/app/feedback/page.ts +++ b/examples/blog/app/feedback/page.ts @@ -1,37 +1,14 @@ import { html } from '@webjsdev/core'; +import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts'; /** - * Page server action e2e fixture (#244): the no-JS form write-path. + * The no-JS form write path (#1155), bound straight to a module action. * - * A `` posts to this page's own `action`. Invalid input - * re-renders the SAME page (422) with a field error and the user's typed value - * preserved; valid input redirects (303 PRG) to `/feedback/thanks`. Works with - * JavaScript disabled, and the client router upgrades it to an in-place swap - * when JS is on. No fetch handler, no form library. - * - * Kept intentionally minimal and self-contained so the e2e probe can assert the - * headline behavior in a real browser with JS both off and on. + * `action=${submitFeedback}` is the whole wiring: the renderer omits the + * attribute so the form posts to this page's own url, and emits the hidden + * field that names the action. The page never mentions the transport. */ -type ActionCtx = { formData: FormData }; - -export async function action({ formData }: ActionCtx) { - const email = String(formData.get('email') || '').trim(); - // Server-side validation the browser cannot do: this address is "already on - // the list". The input is a valid email format (so the native Constraint - // Validation API lets it submit), but the server rejects it and re-renders - // with the field error, the canonical server-validation case. - if (email.toLowerCase() === 'taken@example.com') { - return { - success: false as const, - fieldErrors: { email: 'That email is already subscribed' }, - values: { email }, - status: 422, - }; - } - return { success: true as const, redirect: '/feedback/thanks' }; -} - type PageCtx = { actionData?: { fieldErrors?: Record; values?: Record }; }; @@ -44,7 +21,7 @@ export default function FeedbackPage({ actionData }: PageCtx) { return html`

Send feedback

- +
` - navigates, `
` + a page action submits, all with JavaScript off; opt into + navigates, a `` submits, all with JavaScript off; opt into interactivity per behaviour inside a component. - **Commit per logical unit** as soon as it is complete, and never push to `main`. diff --git a/packages/cli/templates/gallery/app/examples/todo/page.ts b/packages/cli/templates/gallery/app/examples/todo/page.ts index 92bf5c74f..9f41ab452 100644 --- a/packages/cli/templates/gallery/app/examples/todo/page.ts +++ b/packages/cli/templates/gallery/app/examples/todo/page.ts @@ -1,14 +1,12 @@ // A THIN route adapter: app/ is routing only. It fetches the initial data // (server-side) via the 'use server' query and renders the interactive -// component, plus a page `action` for the no-JS write path. All the real logic -// lives in modules/todo/. This is the idiomatic app-thin + modules-logic split. +// component. All the real logic lives in modules/todo/, including the action +// the component's forms bind to. This is the idiomatic app-thin + +// modules-logic split. import { html } from '@webjsdev/core'; import type { Metadata } from '@webjsdev/core'; // Metadata is a @webjsdev/core type import { pageHeading } from '#lib/utils/ui.ts'; import { listTodos } from '#modules/todo/queries/list-todos.server.ts'; -import { createTodo } from '#modules/todo/actions/create-todo.server.ts'; -import { toggleTodo } from '#modules/todo/actions/toggle-todo.server.ts'; -import { deleteTodo } from '#modules/todo/actions/delete-todo.server.ts'; import '#modules/todo/components/todo-app.ts'; export const metadata: Metadata = { title: 'Todo (optimistic UI) | examples' }; @@ -22,14 +20,3 @@ export default async function TodoExample() { `; } - -// No-JS write path: the component's s post here; with JS the component -// intercepts and mutates optimistically instead. Success is a 303 PRG. -export async function action({ formData }: { formData: FormData }) { - const intent = String(formData.get('intent') ?? ''); - const id = String(formData.get('id') ?? ''); - if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') }); - if (intent === 'toggle') return toggleTodo({ id }); - if (intent === 'delete') return deleteTodo({ id }); - return { success: false as const, error: 'Unknown action.', status: 400 }; -} diff --git a/packages/cli/templates/gallery/app/features/auth/signup/page.ts b/packages/cli/templates/gallery/app/features/auth/signup/page.ts index 142a21199..dbf66d717 100644 --- a/packages/cli/templates/gallery/app/features/auth/signup/page.ts +++ b/packages/cli/templates/gallery/app/features/auth/signup/page.ts @@ -8,28 +8,6 @@ export const metadata = { title: 'Sign up' }; const inputCls = inputClass(); -// Page server action: handles the POST from the form below. With JS disabled this -// is a plain round-trip; with JS the client router swaps the 422 re-render -// (errors) or follows the 302 (success) in place. A validation failure returns -// fieldErrors + values so the page re-renders with messages and the user's typed -// input preserved. -export async function action({ formData }: { formData: FormData }) { - const name = String(formData.get('name') || '').trim(); - const email = String(formData.get('email') || '').trim(); - const password = String(formData.get('password') || ''); - const values = { name, email }; - const fieldErrors: Record = {}; - if (!name) fieldErrors.name = 'Name is required'; - if (!email.includes('@')) fieldErrors.email = 'Enter a valid email'; - if (password.length < 8) fieldErrors.password = 'At least 8 characters'; - if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 }; - const result = await signup({ name, email, password }); - // On success signup returns signIn's 302 Response (auto-login -> dashboard); a - // page action may return a Response, so pass it straight through. - if (result instanceof Response) return result; - return { success: false, fieldErrors: { email: result.error }, values, status: result.status }; -} - export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record; values?: Record } }) { const errors = actionData?.fieldErrors || {}; const values = actionData?.values || {}; @@ -37,7 +15,10 @@ export default function SignupPage({ actionData }: { actionData?: { fieldErrors?

Create an account

Get started with your new workspace.

- + +
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 8270e1a3d..5ab499fd6 100644 --- a/packages/cli/templates/gallery/app/features/file-storage/page.ts +++ b/packages/cli/templates/gallery/app/features/file-storage/page.ts @@ -1,9 +1,10 @@ -// File storage: a no-JS upload. A multipart posts to this page's `action` -// (the progressive-enhancement write path); the action calls a 'use server' -// helper that streams the bytes into the FileStore. On success it redirects -// (PRG) with the new key in the query, and the page renders a download link that -// streams the file back through file/[key]/route.ts. Works with JS off; the -// client router applies the same flow in place with JS on. +// File storage: a no-JS upload. A bound to a 'use server' action streams +// the bytes into the FileStore (the progressive-enhancement write path). The +// framework emits the multipart enctype an upload needs, so the binding is the +// whole wiring. On success the action redirects (PRG) with the new key in the +// query, and the page renders a download link that streams the file back +// through file/[key]/route.ts. Works with JS off; the client router applies the +// same flow in place with JS on. import { html } from '@webjsdev/core'; import { buttonClass } from '#components/ui/button.ts'; import { cardClass } from '#components/ui/card.ts'; @@ -13,18 +14,6 @@ import { storeUpload } from '#modules/file-storage/actions/store-upload.server.t export const metadata: Metadata = { title: 'File storage (upload + serve) | features' }; -export async function action({ formData }: { formData: FormData }) { - const file = formData.get('file'); - if (!(file instanceof File) || file.size === 0) { - return { success: false, error: 'Choose a file to upload.' }; - } - const result = await storeUpload(file); - if (!result.success) return result; - const { key, name, size } = result.data; - const q = new URLSearchParams({ key, name, size: String(size) }); - return { success: true, redirect: '/features/file-storage?' + q.toString() }; -} - export default function FileStorageExample({ searchParams, actionData, @@ -43,7 +32,7 @@ export default function FileStorageExample({ gitignored). Swap the backend for S3/R2 with one setFileStore() call, no call-site change. `)} - + `, container); container.querySelector('button').click(); await tick(); - const post = calls.find((c) => c.url.includes('/signup')); + const post = calls[0]; assert.ok(post, 'router issued the submission fetch'); + assert.equal(new URL(post.url).pathname, here, 'posts to the page own url'); assert.equal((post.init.method || 'GET').toUpperCase(), 'POST', 'method is POST'); assert.ok(post.init.body instanceof FormData, 'body is FormData'); assert.equal(post.init.body.get('email'), 'a@b.com', 'FormData carries the field'); + assert.equal(post.init.body.get('__webjs_action'), 'a1b2c3d4e5/signup', + 'and the identity, without which the server has nothing to dispatch on'); + } finally { teardown(); } + }); + + test("a submit button's own name/value rides along with the identity", async () => { + // A multi-button form tells its buttons apart by the submitter's name. + // Both have to survive, which is also why `formaction=${fn}` is refused + // rather than supported: a per-submitter identity would want that same pair. + setup(() => new Response('

ok

', { + headers: { 'content-type': 'text/html', 'x-webjs-build': '' }, + })); + try { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + const body = calls[0].init.body; + assert.equal(body.get('intent'), 'publish', "the submitter's name/value is submitted"); + assert.equal(body.get('__webjs_action'), 'a1b2c3d4e5/act', 'alongside the identity'); } finally { teardown(); } }); @@ -131,7 +164,7 @@ suite('Client router: page-action form submissions (#244)', () => { // than leaning on a single inline flag. const marker = `pa-422-${Math.random().toString(36).slice(2)}`; setup(() => new Response( - `

Enter a valid email

` + + `

Enter a valid email

` + '
', { status: 422, headers: { 'content-type': 'text/html', 'x-webjs-build': '' } }, )); @@ -139,7 +172,8 @@ suite('Client router: page-action form submissions (#244)', () => { try { render(html`
-
+ +
@@ -148,7 +182,7 @@ suite('Client router: page-action form submissions (#244)', () => { container.querySelector('button').click(); await tick(); - assert.ok(calls.some((c) => c.url.includes('/signup')), 'fetch was issued'); + assert.ok(calls.length, 'fetch was issued'); assert.equal(reload.count(), 0, '422 HTML must be applied in place, never a full reload'); // The 422 body was actually applied to the live DOM (the field error is // now present), which a full reload would never achieve from a fetch stub. @@ -171,13 +205,14 @@ suite('Client router: page-action form submissions (#244)', () => { const before = location.pathname; try { render(html` -
+ +
`, container); container.querySelector('button').click(); await tick(); - assert.ok(calls.some((c) => c.url.includes('/signup')), 'fetch was issued'); + assert.ok(calls.length, 'fetch was issued'); assert.equal(location.pathname, '/welcome', 'history advanced to the redirected URL'); } finally { // Restore history so later tests start clean. diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 3f55f49f3..1d6bbf838 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -33,12 +33,13 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. **Root middleware resolution:** `loadMiddleware` tries `middleware.ts` / `.js` / `.mts` / `.mjs` beside `appDir` in that order (`.ts` first, matching the dev supervisor's watch order). It used to look up the single literal `middleware.js`, so a root `middleware.ts`, what the scaffold writes and what the docs document, was silently never loaded: no error, no warning, indistinguishable from an app with no middleware. Tests: `test/dev/root-middleware-resolution.test.js` plus the cross-runtime `test/bun/root-middleware.mjs`. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). **Graceful reload (#893):** a reload never paints into a half-restarted server (Node's `node --watch` briefly kills the process on an app edit), so the client gates every reload on a `/__webjs/version` readiness probe (probe-then-reload, instant under an in-process reload, waits out a restart), and a reconnect after a drop is itself treated as an edit signal so an app edit whose in-process reload frame was killed with the old process still reloads without a manual refresh (the SSE `hello` carries a short `retry: 300` so the reconnect is prompt). The reconnect-reload lives in the `dev-reload-worker.js` relay (shared connection) and the per-tab `__webjsDirectEvents` fallback. **Extra watch roots (#894):** `startServer`'s recursive `fs.watch` also follows the dirs in `webjs.dev.watch` (`readDevWatchPathsFromApp`), for content the app reads from OUTSIDE its appDir (blog markdown in a repo-root `blog/`). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s. **Listener shell (#511):** `startServer` builds a shared `SseHub` + `ListenerContext`, then selects a shell by `serverRuntime()`: `startNodeListener` (in this file, the node:http path: `toWebRequest` -> `app.handle` -> `sendWebResponse`, 103 Early Hints, node WS via `attachWebSocket`, node:http timeouts) on Node, or the dynamically-imported `startBunListener` (`listener-bun.js`) on Bun. The SSE registry/fanout, the live-reload predicate, the WS module loader, and the lifecycle wiring live in `listener-core.js` so the two shells share them. `isCompressible` (used by `sendWebResponse`) also moved there | | `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`). Page order is set by `compareSpecificity` (#750): POSITIONAL specificity (per URL segment, static `0` < dynamic `1` < catch-all `2` via `segKind`, lexicographic on the kind arrays with shorter-prefix-first), so the catch-all kind is lowest AT ITS POSITION (a literal-prefixed catch-all like `docs/[[...slug]]` outranks an all-dynamic `[org]/[repo]`), NOT a global catch-all-last bucket, then a stable alphabetical `routeDir` tiebreak. This replaces the old coarse 3-bucket `dynScore` whose same-bucket ties resolved by fs-walk order (so `/[org]/[repo]` vs `/[user]/settings` could match the wrong page). `matchPage` returns the first pattern that matches in that deterministic order | | `route-types.js` | Route-types generator (#258). `generateRouteTypes(appDir)` reuses `buildRouteTable` to emit the `.d.ts` text that augments `@webjsdev/core` (the `WebjsRoutes` href union + `RouteParamMap` per-route params), backing `webjs types` and the dev-startup emit. Pages-only (a `route.{js,ts}` API path is not a navigable href); strips route groups, excludes `_private`; an optional catch-all `[[...x]]` emits both the without-segment and a normalized `[...x]` href key while keeping the doubled literal as the param-map key. Deterministic (sorted keys). Helpers `routeKeyFromDir` / `dynamicSegments` / `paramTypeForKey` / `webjsRoutesKeysForKey` are exported for unit tests | -| `ssr.js` | SSR pipeline: nested layouts, metadata → ``, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the page-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the page-action re-render and partial-nav (`X-Webjs-Have`) requests. **Partial responses are never shared-cacheable (#1140):** a reduced `X-Webjs-Have` body is sliced by a request header, so `privateFragment(res)` rewrites its `Cache-Control` to `private` (stripping `public` / `s-maxage` / `proxy-revalidate`, and any qualified `private="field"`, which per RFC 9111 would leave the response shared-storable). `Vary: X-Webjs-Have` is still sent, but as belt-and-braces: it is not the guarantee, because Cloudflare and others honour only `Accept-Encoding`. The helper is quote-aware (a comma inside `no-cache="Set-Cookie,X-Foo"` is not a separator), fails CLOSED on an absent header (no `Cache-Control` is heuristically shared-storable), and is exported for unit testing (not re-exported from the package index, so it is not public API). The fragment KEEPS its ETag, which is why `conditional-get.js` no longer excludes `private`. A non-200 (the 422 page-action re-render, which carries the submitter's own field values) never inherits the page's `cacheControl`. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame: ` (a `` self-load or a click-driven frame nav) AND the render is non-streamed, it extracts the matching `` subtree from the rendered body via `frame-render.js` and returns ONLY that (byte-equivalent to the client's extraction from a full page, but far fewer bytes); an absent frame id falls through to the full page (the client's `webjs:frame-missing` handles it), and a request with no `x-webjs-frame` header is byte-identical to before. **Vendor modulepreload (#754):** `reachedVendorSpecifiers(graph, shippedEntryFiles, componentUrls, appDir, elidable, serverFiles)` collects the bare specifiers (`bareImports(graph)`) reached by the page's SHIPPED modules. Its walk ROOTS are the boot's actually-shipped set: the caller passes the absolute paths of `moduleUrls` (which already drops INERT page/layout modules and substitutes an IMPORT-ONLY page with its components) plus the rendered `componentUrls`, then it walks the non-elided transitive closure and collects each reached file's bare imports, skipping server files (the `.server.*` suffix AND the `serverFiles` action index). Because the roots are the shipped set, a vendor reached ONLY through a dropped module, a dropped page's SSR-only DIRECT vendor import OR its SSR-only RELATIVE HELPER's vendor, is never collected (pages/layouts are not importable, so nothing that ships reaches them), so there is no over-fetch. `vendorPreloadTargets` (importmap.js) maps that set to `{ href, integrity }`, and `wrapHead` emits one `` per target (no `fp()` rewrite, deduped against the app module/component preloads, with `crossorigin` + `integrity`), flattening the cross-origin CDN waterfall one level. Only reached + non-elided + pinned vendors are hinted. **App-module modulepreload shipped-roots (#780):** `deduplicatedPreloads` (the app-module + component preload walk) roots at the SAME shipped set as the vendor walk. `shippedRoots` (the absolute paths of `moduleUrls`) is computed once and passed to BOTH `deduplicatedPreloads` and `reachedVendorSpecifiers`, NOT the raw `[route.file, ...route.layouts]`. So a module reached ONLY through a dropped page/layout (its SSR-only DIRECT app import OR its SSR-only RELATIVE HELPER) gets no `modulepreload` hint, the app-module analog of the #754 vendor over-fetch. A module that also ships another way (a component shared with a live route, or one reached through an import-only page's substituted components) is still reached via a real shipped root, so its hint stays (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped page/layout URLs themselves, so this closes the TRANSITIVE gap only | +| `ssr.js` | SSR pipeline: nested layouts, metadata → ``, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the form-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the form-action re-render and partial-nav (`X-Webjs-Have`) requests. **Partial responses are never shared-cacheable (#1140):** a reduced `X-Webjs-Have` body is sliced by a request header, so `privateFragment(res)` rewrites its `Cache-Control` to `private` (stripping `public` / `s-maxage` / `proxy-revalidate`, and any qualified `private="field"`, which per RFC 9111 would leave the response shared-storable). `Vary: X-Webjs-Have` is still sent, but as belt-and-braces: it is not the guarantee, because Cloudflare and others honour only `Accept-Encoding`. The helper is quote-aware (a comma inside `no-cache="Set-Cookie,X-Foo"` is not a separator), fails CLOSED on an absent header (no `Cache-Control` is heuristically shared-storable), and is exported for unit testing (not re-exported from the package index, so it is not public API). The fragment KEEPS its ETag, which is why `conditional-get.js` no longer excludes `private`. A non-200 (the 422 form-action re-render, which carries the submitter's own field values) never inherits the page's `cacheControl`. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame: ` (a `` self-load or a click-driven frame nav) AND the render is non-streamed, it extracts the matching `` subtree from the rendered body via `frame-render.js` and returns ONLY that (byte-equivalent to the client's extraction from a full page, but far fewer bytes); an absent frame id falls through to the full page (the client's `webjs:frame-missing` handles it), and a request with no `x-webjs-frame` header is byte-identical to before. **Vendor modulepreload (#754):** `reachedVendorSpecifiers(graph, shippedEntryFiles, componentUrls, appDir, elidable, serverFiles)` collects the bare specifiers (`bareImports(graph)`) reached by the page's SHIPPED modules. Its walk ROOTS are the boot's actually-shipped set: the caller passes the absolute paths of `moduleUrls` (which already drops INERT page/layout modules and substitutes an IMPORT-ONLY page with its components) plus the rendered `componentUrls`, then it walks the non-elided transitive closure and collects each reached file's bare imports, skipping server files (the `.server.*` suffix AND the `serverFiles` action index). Because the roots are the shipped set, a vendor reached ONLY through a dropped module, a dropped page's SSR-only DIRECT vendor import OR its SSR-only RELATIVE HELPER's vendor, is never collected (pages/layouts are not importable, so nothing that ships reaches them), so there is no over-fetch. `vendorPreloadTargets` (importmap.js) maps that set to `{ href, integrity }`, and `wrapHead` emits one `` per target (no `fp()` rewrite, deduped against the app module/component preloads, with `crossorigin` + `integrity`), flattening the cross-origin CDN waterfall one level. Only reached + non-elided + pinned vendors are hinted. **App-module modulepreload shipped-roots (#780):** `deduplicatedPreloads` (the app-module + component preload walk) roots at the SAME shipped set as the vendor walk. `shippedRoots` (the absolute paths of `moduleUrls`) is computed once and passed to BOTH `deduplicatedPreloads` and `reachedVendorSpecifiers`, NOT the raw `[route.file, ...route.layouts]`. So a module reached ONLY through a dropped page/layout (its SSR-only DIRECT app import OR its SSR-only RELATIVE HELPER) gets no `modulepreload` hint, the app-module analog of the #754 vendor over-fetch. A module that also ships another way (a component shared with a live route, or one reached through an import-only page's substituted components) is still reached via a real shipped root, so its hint stays (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped page/layout URLs themselves, so this closes the TRANSITIVE gap only | | `dev-error.js` | Dev error overlay frame builder (#264). `buildDevErrorFrame(error, { kind, appDir, file?, line?, hint? })` returns a JSON-serializable frame (message, parsed `file`/`line`/`column`, a source `codeFrame`, an optional `hint`); `parseStackLocation(stack, appDir)` finds the first app frame (preferring non-`node_modules`, splitting off the dev loader's `?t=` cache-bust query); `readCodeFrame(file, line, column)` reads the source excerpt with a `>` line marker + a caret. PURE (the only side effect is a guarded source read) and DEV-ONLY by the caller's contract, so no path / source is built in prod | | `frame-render.js` | Server-side `` subtree extraction (#253). `requestedFrameId(req)` reads the `x-webjs-frame` header (null when absent, the normal full-page path); `extractFrameSubtree(html, id)` returns the `...` slice from rendered HTML verbatim (so byte-equivalent by construction), balancing nested `` tags and reading the `id` attribute (not a substring match), or null when the id is absent. Used by `ssr.js`'s frame-render branch | -| `page-action.js` | Page server actions (#244): `loadPageAction` reads a page module's optional `action` export, `runPageAction` parses the form body, runs the action, and maps the `ActionResult` to a response (303 PRG on success, 422 re-render with `actionData` on failure, honoring thrown `redirect()`/`notFound()`). A page action that returns a `Response` DIRECTLY (e.g. a content-negotiated `streamResponse`, #248) is honored verbatim. `dev.js` routes a non-GET/HEAD page request here only when the page exports `action`, wrapped in the page's segment middleware | -| `action-seed.js` | SSR action-result seeding (#472, Bun-enabled #529). `registerSeedHooks()` (async) installs a load hook chosen by `serverRuntime()`: Node's synchronous `module.registerHooks`, or a `Bun.plugin` `onLoad` on Bun (the glue is in `action-seed-bun.js`, dynamically imported so `Bun.*` never loads on Node). For a `'use server'` `*.server.*` module the hook returns a transparent FACADE re-exporting each function wrapped in a `Proxy` (`__seedWrap`); the faceting decision (`isSeedCandidate`) + facade source (`buildSeedFacade`) are runtime-neutral, so both runtimes emit the identical seed. (Bun's `onLoad` must return an object for every filter match, so the non-facet cases serve the raw source.) The Proxy records `(file, fn, args) -> result` into an ambient `AsyncLocalStorage` collector when one is active (a pure passthrough otherwise, so the RPC endpoint path is untouched, and any metadata attached to the function forwards through the Proxy). `collectSeeds(fn)` runs the SSR render inside a fresh collector; `buildSeedScript(collector)` serializes it into an HTML-escaped `