@webjsdev/cli 0.10.50 → 0.10.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/webjs.js +133 -30
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +37 -9
- package/lib/doctor.js +479 -7
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +25 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +25 -6
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +6 -2
- package/templates/.agents/skills/webjs/references/components.md +9 -1
- package/templates/.agents/skills/webjs/references/data-and-actions.md +40 -7
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +54 -18
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +61 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +3 -16
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +6 -6
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/scripts/clear-gallery.mjs +5 -4
- package/templates/test/hello/e2e/hello.test.ts +18 -1
|
@@ -22,7 +22,7 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
|
|
|
22
22
|
- **`*.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.
|
|
23
23
|
- **`route.ts`** is a server-only HTTP handler (named `GET`/`POST` exports), the one routing file that is NOT isomorphic.
|
|
24
24
|
|
|
25
|
-
**Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and `<form>` server
|
|
25
|
+
**Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and a `<form action=${importedAction}>` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
|
|
26
26
|
|
|
27
27
|
## When To Use This Skill
|
|
28
28
|
|
|
@@ -45,7 +45,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
45
45
|
| Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
|
|
46
46
|
| Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
|
|
47
47
|
| The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
|
|
48
|
-
| TypeScript at runtime, erasable syntax, full-stack types
|
|
48
|
+
| TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) | `references/typescript.md` |
|
|
49
49
|
| Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` |
|
|
50
50
|
| Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
|
|
51
51
|
| Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` |
|
|
@@ -54,7 +54,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
54
54
|
|
|
55
55
|
Common bundles:
|
|
56
56
|
|
|
57
|
-
- **Form or CRUD feature** then
|
|
57
|
+
- **Form or CRUD feature** then data-and-actions, routing-and-pages, testing; add auth if user-specific
|
|
58
58
|
- **Interactive widget** then components, styling; add client-router-and-streaming only if it streams
|
|
59
59
|
- **Protected area** then auth-and-sessions, routing-and-pages, testing
|
|
60
60
|
- **Instant-feeling mutation** then data-and-actions, optimistic-ui
|
|
@@ -68,7 +68,8 @@ Common bundles:
|
|
|
68
68
|
5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser.
|
|
69
69
|
6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it.
|
|
70
70
|
7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`).
|
|
71
|
-
8. **
|
|
71
|
+
8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult<T>`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. See `references/typescript.md`.
|
|
72
|
+
9. **Test the narrowest meaningful layer**, and render the app in a real browser for any UI change (static checks do not catch a collapsed layout).
|
|
72
73
|
|
|
73
74
|
## Project Layout
|
|
74
75
|
|
|
@@ -104,6 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection
|
|
|
104
105
|
9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
|
|
105
106
|
10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
|
|
106
107
|
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).
|
|
108
|
+
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>` inside a bound form. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, `formmethod="get"` or an unparseable `formenctype` on ANY submitter in a bound form, and a non-action function all throw. A page has no `action` export, so a bare `<form method="post">` is a 405.
|
|
107
109
|
|
|
108
110
|
## Export Map
|
|
109
111
|
|
|
@@ -188,24 +190,32 @@ class Counter extends WebComponent({ count: prop(Number) }) {
|
|
|
188
190
|
Counter.register('my-counter');
|
|
189
191
|
```
|
|
190
192
|
|
|
191
|
-
### The no-JS write path (a
|
|
193
|
+
### The no-JS write path (a form-bound action)
|
|
192
194
|
|
|
193
195
|
```ts
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
+
// modules/contact/actions/send-message.server.ts
|
|
197
|
+
'use server';
|
|
198
|
+
export async function sendMessage(formData: FormData) {
|
|
196
199
|
const email = String(formData.get('email') || '');
|
|
197
200
|
if (!email) return { success: false, fieldErrors: { email: 'required' } };
|
|
198
201
|
return { success: true, redirect: '/thanks' };
|
|
199
|
-
}
|
|
200
|
-
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// app/contact/page.ts
|
|
205
|
+
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
|
|
206
|
+
export default function Contact({ actionData }) {
|
|
207
|
+
return html`<form action=${sendMessage}><input name="email"></form>`;
|
|
208
|
+
}
|
|
201
209
|
```
|
|
202
210
|
|
|
203
|
-
|
|
211
|
+
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`.
|
|
212
|
+
|
|
213
|
+
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.
|
|
204
214
|
|
|
205
215
|
## Security And Session Defaults
|
|
206
216
|
|
|
207
217
|
- Never ship demo secrets. Require session and provider secrets from the environment and fail fast if missing.
|
|
208
|
-
-
|
|
218
|
+
- 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.
|
|
209
219
|
- 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.
|
|
210
220
|
- 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.
|
|
211
221
|
- For CORS use `cors()` from `@webjsdev/server`; `credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.
|
|
@@ -224,6 +234,10 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
224
234
|
- Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory.
|
|
225
235
|
- Quoting an event / property / boolean hole (`@click="${fn}"`).
|
|
226
236
|
- Writing `fetch()` to call your own server instead of importing the action.
|
|
237
|
+
- Writing a bare `<form method="post">` 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.
|
|
238
|
+
- Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
|
|
239
|
+
- Writing `formmethod="get"` or `formenctype="text/plain"` on any button inside a bound form. Neither can carry the action's body, so both are refused even when the button binds nothing.
|
|
240
|
+
- Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
|
|
227
241
|
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
|
|
228
242
|
- 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()`.
|
|
229
243
|
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
|
|
@@ -113,7 +113,7 @@ export const POST = handlers.POST;
|
|
|
113
113
|
<form method="POST" action="/api/auth/signout"><button>Log out</button></form>
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a
|
|
116
|
+
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).
|
|
117
117
|
|
|
118
118
|
Sessions are JWT by default (stateless, scales horizontally). OAuth
|
|
119
119
|
providers handle the full redirect flow. Read the session anywhere on the
|
|
@@ -208,7 +208,7 @@ export default async function Admin() {
|
|
|
208
208
|
}
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
Both work from a page or layout render AND from a
|
|
211
|
+
Both work from a page or layout render AND from a form-bound action (the no-JS
|
|
212
212
|
write path). The boundary files (`app/forbidden.ts`, `app/unauthorized.ts`,
|
|
213
213
|
or nested variants) live alongside the routes they cover and each
|
|
214
214
|
default-export a function returning the boundary markup.
|
|
@@ -8,7 +8,7 @@ Env vars, caching, rate limiting, broadcast, file storage, and the `package.json
|
|
|
8
8
|
- **Caching primitives.** `cache()` with tag invalidation, HTTP `Cache-Control`, the server HTML response cache (`export const revalidate`), content-hash asset URLs, conditional GET (ETag).
|
|
9
9
|
- **Rate limiting** (`rateLimit()` middleware) and **broadcast** (`broadcast()` over WebSockets).
|
|
10
10
|
- **File storage.** `FileStore` / `diskStore`, safe keys, signed URLs.
|
|
11
|
-
- **The `"webjs"` config block.** Security headers, CSP, redirects, trailing-slash, basePath, allowed origins, client-router opt-out, ingress caps, dev/start task orchestration.
|
|
11
|
+
- **The `"webjs"` config block.** Security headers, CSP, redirects, trailing-slash, basePath, allowed origins, client-router opt-out, ingress caps, dev/start task orchestration, the doctor severity gate.
|
|
12
12
|
- **Observability.** Access log, `requestId()`, the `onError` hook, `instrumentation.ts`, the build-info endpoint.
|
|
13
13
|
|
|
14
14
|
Read this when wiring caching or rate limiting, storing uploads, hardening headers, or configuring redirects and observability. **Auth and sessions are a separate reference (`auth-and-sessions.md`).** Server actions, `revalidateTag` from a mutation, and the `ActionResult` envelope live in `data-and-actions.md`.
|
|
@@ -71,14 +71,14 @@ export const metadata = { cacheControl: 'public, max-age=60' };
|
|
|
71
71
|
|
|
72
72
|
### Server HTML response cache (`export const revalidate`)
|
|
73
73
|
|
|
74
|
-
For a page that renders the **same HTML for every visitor**, opt into caching the SSR output (WebJs's no-build equivalent of ISR). Keyed by full URL
|
|
74
|
+
For a page that renders the **same HTML for every visitor**, opt into caching the SSR output (WebJs's no-build equivalent of ISR). Keyed by the request origin plus the full URL.
|
|
75
75
|
|
|
76
76
|
```ts
|
|
77
77
|
// app/blog/page.ts
|
|
78
78
|
export const revalidate = 60; // cache this page's HTML for 60s
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
**Safety.** This asserts the page is identical for everyone for N seconds. Never set it on a page that reads `cookies()`, a session, or per-user data. The framework auto-marks a request dynamic and refuses to cache when the render reads per-user state through a framework helper (`cookies()`, `headers()`, `getSession()`, `auth()`), so an `auth()`-gated page fails safe. It also never caches a non-200, a streamed Suspense body, a `Set-Cookie` response, or a page under CSP. Evict on a write with `revalidatePath('/blog')`; `revalidateAll()` clears everything (single-instance / dev). This differs from the client-side `revalidate()` in `@webjsdev/core`, which evicts the browser snapshot cache.
|
|
81
|
+
**Safety.** This asserts the page is identical for everyone for N seconds. Never set it on a page that reads `cookies()`, a session, or per-user data. The framework auto-marks a request dynamic and refuses to cache when the render reads per-user state through a framework helper (`cookies()`, `headers()`, `getSession()`, `auth()`), so an `auth()`-gated page fails safe. It also never caches a non-200, a streamed Suspense body, a `Set-Cookie` response, or a page under CSP. Evict on a write with `revalidatePath('/blog')`; `revalidateAll()` clears everything (single-instance / dev). This differs from the client-side `revalidate()` in `@webjsdev/core`, which evicts the browser snapshot cache. Keys carry the request ORIGIN as well as the path (#1097), because `ctx.url` comes from forwarded headers a proxy passes through rather than strips, so a hostile `X-Forwarded-Host` would otherwise bake an attacker-chosen origin into a body every later visitor gets served. A single-host deploy is unaffected (one origin, one entry per URL). It does mean a bare path does not name one entry, so `revalidatePath` resolves the origin from the calling request, which is exact for a server action; pass an absolute url from a background job that serves no requests of its own, where a bare path warns and evicts nothing. Under `webjs.basePath` the absolute url is the public one and its mount prefix is stripped, so it evicts the entry the write stored.
|
|
82
82
|
|
|
83
83
|
### Content-hash asset URLs and conditional GET
|
|
84
84
|
|
|
@@ -91,7 +91,9 @@ import { html, asset } from '@webjsdev/core';
|
|
|
91
91
|
html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way,
|
|
94
|
+
That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
|
|
95
|
+
|
|
96
|
+
Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `<link rel="stylesheet" href="/public/app.css">` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. A warning is easy to miss, so make it fatal in the app that cares: gate `UNMARKED_ASSET_LINKS` to `error` (see the doctor severity gate below) and one `npm run doctor` step in CI stops the un-versioned url reaching a deploy. The scaffold ships exactly that.
|
|
95
97
|
|
|
96
98
|
It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing).
|
|
97
99
|
|
|
@@ -107,7 +109,7 @@ import { rateLimit } from '@webjsdev/server';
|
|
|
107
109
|
export default rateLimit({ window: '1m', max: 60 });
|
|
108
110
|
```
|
|
109
111
|
|
|
110
|
-
Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the client IP), `message`, `store`, `trustProxy
|
|
112
|
+
Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the client IP), `message`, `store`, `trustProxy` (honour the forwarded-IP headers; inert while `WEBJS_NO_TRUST_PROXY=1` is set, which outranks it and keeps the limiter on the framework-stamped peer). Over-limit responds `429` with `Retry-After` and `X-RateLimit-*` headers; an allowed response carries the remaining-quota headers too. For multi-instance scaling, set the global store to Redis once at startup.
|
|
111
113
|
|
|
112
114
|
## Broadcast
|
|
113
115
|
|
|
@@ -143,7 +145,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
|
|
|
143
145
|
|
|
144
146
|
## The `"webjs"` config block (package.json)
|
|
145
147
|
|
|
146
|
-
All keys are optional
|
|
148
|
+
All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot with a warning, never crashing the pipeline. The one exception is `doctor.gate`, which is read by the `webjs doctor` CLI rather than the server and rejects a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
|
|
147
149
|
|
|
148
150
|
### Security headers
|
|
149
151
|
|
|
@@ -208,6 +210,23 @@ An over-limit body responds `413` without buffering the whole payload.
|
|
|
208
210
|
|
|
209
211
|
`before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree.
|
|
210
212
|
|
|
213
|
+
### Doctor severity gate
|
|
214
|
+
|
|
215
|
+
`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` depends on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
|
|
216
|
+
|
|
217
|
+
```jsonc
|
|
218
|
+
{ "webjs": {
|
|
219
|
+
"doctor": { "gate": {
|
|
220
|
+
"UNMARKED_ASSET_LINKS": "error", // fail the exit on this one
|
|
221
|
+
"ELISION_CARRIERS": "off" // silence it entirely, even under --strict
|
|
222
|
+
} }
|
|
223
|
+
} }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports without failing, `off` silences the check, meaning its finding is not printed and it cannot fail the exit (it still appears on the checklist as `[off]` and in the summary's silenced count, so a silenced check is never invisible, and `--json` still carries the whole result). A code with no entry keeps its default (`error` for a hard toolchain failure, `warn` otherwise), so an app that declares nothing behaves exactly as before. Read the codes off `webjs doctor --json`, where every result carries its `code` and its effective `severity`.
|
|
227
|
+
|
|
228
|
+
Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That covers an unknown code, a bad severity, a wrong shape (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`, since every one of those would otherwise leave the build un-gated while the `package.json` looks gated. Under `--json` the offenders come back as a `configErrors` array alongside an empty `results`, each entry a `{ kind }` of `malformed` / `unknown-key` / `unknown-code` / `bad-severity`. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow.
|
|
229
|
+
|
|
211
230
|
## Observability
|
|
212
231
|
|
|
213
232
|
Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.
|
|
@@ -65,7 +65,7 @@ document.addEventListener('webjs:navigation-error', (e) => {
|
|
|
65
65
|
});
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints.
|
|
68
|
+
**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`, `pre-boot-navigation`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints.
|
|
69
69
|
|
|
70
70
|
```ts
|
|
71
71
|
document.addEventListener('webjs:navigation-fallback', (e) => {
|
|
@@ -74,8 +74,12 @@ document.addEventListener('webjs:navigation-fallback', (e) => {
|
|
|
74
74
|
});
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
**`pre-boot-navigation` reports ABOUT a load, not during one (#1118).** The boot is a module script, which the HTML spec defers until parsing finishes, while the links it will intercept are clickable from first paint. A click in that window is a plain browser navigation, and the ARRIVING document reports it with `willReload: false` (the load already happened). The window is a few tens of milliseconds warm and network-sized on a cold, throttled first visit, which is why `@webjsdev/core` is hinted in the head with `<link rel="modulepreload">` (emitted only when the page actually ships a boot module) instead of being discovered a round trip later. Read the cause as a RATE: the check knows only that this document arrived by a same-origin navigation that was not a soft nav, so a `data-no-router` link, a `target="_blank"` open, a cross-document form post, and a `clientRouter: false` app all land here too. Excluded: a reload, a back/forward restore, an external or typed entry, and a full load the router itself chose (already reported under its own cause). The report rides the router's own boot, so a fully elided page that ships no client runtime reports nothing.
|
|
78
|
+
|
|
77
79
|
**Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events.
|
|
78
80
|
|
|
81
|
+
**Inline scripts in a swapped range re-execute, so write them to be re-runnable (#1102).** A script the swap brings in runs again on every navigation that swaps its range, whether it sits inside the swapped content or is a top-level node of the range itself (a layout emitting its enhancement script as a sibling of `${children}`). A script parsed out of the response carries the HTML spec's already-started flag and is inert, so the router replaces it with a fresh clone, and the clone is what runs; the clone carries the page-load CSP nonce rather than the one the response was rendered with. Giving the script an `id` does NOT make it run once: the keyed differ reuses the live element and the router still re-emits it. So a script that installs a listener or a `MutationObserver` must be idempotent or guard on a flag it sets the first time. The alternative default, running once and then never again, is the failure this replaced (a progressive-enhancement highlighter that stopped working after the first soft nav). When work genuinely must happen once, put it in the ROOT layout, whose markup is never swapped. `data-webjs-permanent` splits into two cases (#1252). A script that IS the marked element is re-emitted like any other, so the attribute is not an escape hatch for a script itself: its regraft only fires when the node exists on both sides, so exempting it would leave a script that runs on a cold load and never on a soft nav. A script INSIDE a marked element the swap actually preserved is left alone, because the attribute is SUBTREE-scoped and that node survived by identity. The exemption is conditional on real preservation, so a permanent element arriving for the first time, or one with no `id` (which can never be regrafted), still runs its scripts.
|
|
82
|
+
|
|
79
83
|
## Link Prefetch
|
|
80
84
|
|
|
81
85
|
Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. A reduced fragment is served `private`, so no shared cache can store it even if the CDN ignores `Vary` (Cloudflare honours only `Accept-Encoding`); the `Vary: X-Webjs-Have` marking stays as belt-and-braces rather than as the guarantee (#1140). A full document keeps whatever `metadata.cacheControl` declared, so page-level edge caching is unaffected. Router fetches (navigation and prefetch alike) are sent with `cache: 'no-cache'`, so a page cached in the browser with a `max-age` is revalidated rather than replayed: the deploy check reads `x-webjs-build` / `x-webjs-src` off these responses, and a cached response would hand it pre-deploy ids and hide a deploy for the whole freshness window (#1131). The revalidation is answered with a cheap 304, so the cost is a conditional round-trip rather than a re-download: on a page that opted into caching a fragment is `private` but still carries a validator, since `private` forbids only SHARED storage and has no bearing on whether a response can be validated (#1140); a default `no-store` page has nothing to validate either way. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.
|
|
@@ -134,7 +138,7 @@ A page (or layout) does not write raw `<head>` markup, so emit that meta through
|
|
|
134
138
|
export const metadata = { other: { 'view-transition': 'same-origin' } };
|
|
135
139
|
```
|
|
136
140
|
|
|
137
|
-
The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.
|
|
141
|
+
The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`. The attribute is SUBTREE-scoped, so once the element has actually been preserved, a `<script>` inside it is not re-emitted and does not re-run (#1252); a permanent element arriving for the first time, or one with no `id`, is ordinary new content and runs its scripts.
|
|
138
142
|
|
|
139
143
|
The opt-in is **per page**, so it is a page-scoped meta: put it on a page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped `<meta>` tags (a stale one the previous page declared is removed, not left to leak, #1046). View transitions **compose with Suspense streaming**: a streamed boundary (a `loading.{js,ts}` skeleton or a `<webjs-suspense>` region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before it applies (#1048).
|
|
140
144
|
|
|
@@ -48,7 +48,7 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
|
|
|
48
48
|
| Option | Default | Meaning |
|
|
49
49
|
|---|---|---|
|
|
50
50
|
| `type` | `String` | Constructor feeding the default attribute converter |
|
|
51
|
-
| `reflect` | `false` | Property changes write back to the HTML attribute |
|
|
51
|
+
| `reflect` | `false` | Property changes write back to the HTML attribute (a function value removes it instead, see below) |
|
|
52
52
|
| `state` | `false` | Internal-only. No attribute, not observed |
|
|
53
53
|
| `attribute` | derived from name | The HTML attribute name the property rides |
|
|
54
54
|
| `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
|
|
@@ -57,6 +57,8 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
|
|
|
57
57
|
|
|
58
58
|
For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type` flags the `Object` form). For anything the built-in converters cannot parse (Date, Map, Set) supply a `converter`.
|
|
59
59
|
|
|
60
|
+
**A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
|
|
61
|
+
|
|
60
62
|
**Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
61
63
|
|
|
62
64
|
## Signals are the default state primitive
|
|
@@ -177,6 +179,12 @@ Three decoupled concerns, do not conflate them.
|
|
|
177
179
|
|
|
178
180
|
Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent). The boundary covers the COMMIT as well as the fetch, so a template that throws while being applied (a refused binding, a value whose `toString` throws) reaches `renderError()` too, and `updateComplete` still settles. Those two halves used to disagree: a fetch rejection was contained and a commit throw escaped as an unhandled rejection that also left `updateComplete` pending forever.
|
|
179
181
|
|
|
182
|
+
The boundary also covers `watch(signal)` (its notify microtask) and `until()` (its promise resolution), which commit outside the update cycle. A throw from either used to surface at the window instead of the owning component. It routes to the component whose TEMPLATE holds the binding, which is not always the element the binding sits inside: `html`<child-el>${watch(sig)}</child-el>`` belongs to the parent that wrote it, not to `child-el`. `asyncAppend` / `asyncReplace` is the third such site and is covered the same way: a chunk's own commit throw, and a `watch` / `until` nested inside a chunk, both reach the owning component's `renderError()`. A chunk's own commit throw also STOPS the stream, since the boundary is about to render an error state and appending into a region it may have replaced is not a recovery; a nested directive throws from its own handler outside that loop, so it reaches the boundary but does not stop the stream, the same as a directive nested anywhere else. What stays at `console.error` is the author's own code, the iterable AND any `mapper` passed alongside it, on the standing reasoning that an author's iterable should handle its own errors. That ends the stream too, and always has. With a bare `render()` into a plain container there is no component to receive a commit throw, so it surfaces rather than being swallowed, which is what `watch` and `until` already do.
|
|
183
|
+
|
|
184
|
+
**A commit that throws leaves the directive's own state consistent, so the NEXT valid render is correct.** This matters because the corruption is otherwise silent: the renders that expose it are fully valid and log nothing after the first throw. The hole whose commit threw is marked so the next render re-applies it rather than skipping it as unchanged (its recorded value is never advanced past a throw, and would otherwise match exactly what the recovering render supplies, leaving a child region blank for good). Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would discard the node identity the reconcilers exist to preserve. `repeat()` re-unites its key map and repositions every row (the failure was a permanently duplicated row). A plain `.map()` array splices the part of its slot list the failed pass never reached back on, which matters whenever a slot is REPLACED rather than updated in place (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the failure was a stranded row that outlived even a render of an empty array). `guard()` records its new deps only once the commit succeeds, so a later render with those same deps re-renders the region instead of short-circuiting past a region the throw had blanked; `until()` advances its resolved priority only after the commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it.
|
|
185
|
+
|
|
186
|
+
**Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it.
|
|
187
|
+
|
|
180
188
|
Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
|
|
181
189
|
|
|
182
190
|
## Task: client-only async data
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`)
|
|
12
12
|
- Keeping server-only types off the client (`import type` vs a value import)
|
|
13
13
|
|
|
14
|
-
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` (
|
|
14
|
+
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).
|
|
15
15
|
|
|
16
16
|
## The Architecture (read this first)
|
|
17
17
|
|
|
@@ -100,24 +100,57 @@ A `.returning()` row is the table's own columns only, never `with` relations. Wh
|
|
|
100
100
|
|
|
101
101
|
## Input validation at the boundary
|
|
102
102
|
|
|
103
|
-
Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on the RPC
|
|
103
|
+
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.
|
|
104
104
|
|
|
105
105
|
```ts
|
|
106
106
|
// modules/posts/actions/create-post.server.ts
|
|
107
107
|
'use server';
|
|
108
|
-
export
|
|
108
|
+
export interface CreatePostInput { title: string; body: string }
|
|
109
|
+
|
|
110
|
+
// `unknown` is CORRECT here and nowhere else in this file: the validator IS
|
|
111
|
+
// the narrowing site for an untrusted wire payload. Never `any`, which would
|
|
112
|
+
// un-type the returned `data` and with it the action's own input.
|
|
113
|
+
export const validate = (input: unknown) => {
|
|
114
|
+
const raw = (input ?? {}) as Record<string, unknown>;
|
|
109
115
|
const fieldErrors: Record<string, string> = {};
|
|
110
|
-
const title = String(
|
|
116
|
+
const title = String(raw.title ?? '').trim();
|
|
111
117
|
if (!title) fieldErrors.title = 'Title is required';
|
|
112
|
-
if (String(
|
|
118
|
+
if (String(raw.body ?? '').length < 10) fieldErrors.body = 'Too short';
|
|
113
119
|
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
|
|
114
|
-
|
|
120
|
+
// `satisfies` ties the validator's output to the action's input, so the two
|
|
121
|
+
// cannot drift apart.
|
|
122
|
+
return { success: true, data: { title, body: String(raw.body) } satisfies CreatePostInput };
|
|
115
123
|
};
|
|
116
|
-
export async function createPost(input:
|
|
124
|
+
export async function createPost(input: CreatePostInput) { /* runs only when valid */ }
|
|
117
125
|
```
|
|
118
126
|
|
|
119
127
|
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.
|
|
120
128
|
|
|
129
|
+
## Binding an action to a form
|
|
130
|
+
|
|
131
|
+
A `<form action=${importedAction}>` is the one way a form submits to a server action, and it is the whole wiring:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
135
|
+
html`<form action=${createPost}><input name="title"></form>`;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
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 `<hash>/<fn>` 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.
|
|
139
|
+
|
|
140
|
+
**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.
|
|
141
|
+
|
|
142
|
+
Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
|
|
143
|
+
|
|
144
|
+
- `validate` runs on the submitted `FormData`.
|
|
145
|
+
- the `middleware` chain runs, with the page's route context (`params`, `searchParams`, `url`) added to `ctx`.
|
|
146
|
+
- `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.
|
|
147
|
+
- `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.
|
|
148
|
+
- `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`.
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
121
154
|
## HTTP-verb config exports
|
|
122
155
|
|
|
123
156
|
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)`).
|
|
@@ -27,7 +27,7 @@ export async function GET() { redirect('/login'); }
|
|
|
27
27
|
export async function GET(req: Request) { return Response.redirect(new URL('/login', req.url), 303); }
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Do NOT throw `redirect()` from a
|
|
30
|
+
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.
|
|
31
31
|
|
|
32
32
|
### Reads are server actions, not `fetch()` in a Server Component
|
|
33
33
|
|
|
@@ -43,11 +43,30 @@ const users = await getUsers();
|
|
|
43
43
|
|
|
44
44
|
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.
|
|
45
45
|
|
|
46
|
-
###
|
|
46
|
+
### `<form action=${fn}>` binds, in exactly one shape
|
|
47
47
|
|
|
48
|
-
Next binds a Server Action with `<form action={createTodo}
|
|
48
|
+
Next binds a Server Action with `<form action={createTodo}>`, 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 `<hash>/<fn>` 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)`.
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
```ts
|
|
51
|
+
import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
|
|
52
|
+
html`<form action=${submitFeedback}><input name="email"></form>`;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
**A form whose buttons run different actions binds each one on its submitter**, with the same unquoted spelling one level down:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
html`<form action=${saveDraft}>
|
|
61
|
+
<input name="title">
|
|
62
|
+
<button>Save</button>
|
|
63
|
+
<button formaction=${publishPost}>Publish</button>
|
|
64
|
+
</form>`;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed. The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
|
|
68
|
+
|
|
69
|
+
**Only the bare, unquoted `action=${fn}` on a `<form>` 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.
|
|
51
70
|
|
|
52
71
|
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.
|
|
53
72
|
|
|
@@ -63,22 +82,37 @@ Do not go looking for the rule that decides when it folds. Export status, read c
|
|
|
63
82
|
|
|
64
83
|
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.
|
|
65
84
|
|
|
66
|
-
The refusal covers the shape, not one spelling of it.
|
|
85
|
+
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. Unsupported `formaction=` shapes are refused, including non-submit controls, duplicate holes, and submitters carrying `name`, `value`, `form`, or static `formaction` attributes. Attribute names fold case, so `ACTION=${fn}` on a `<form>` BINDS like the lowercase spelling, while quoted or otherwise unsupported `formAction=${fn}` shapes are refused.
|
|
67
86
|
|
|
68
|
-
**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 `<!-- <form action=${createTodo}> -->`
|
|
87
|
+
**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: `<!-- <form action=${createTodo}> -->` 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.
|
|
69
88
|
|
|
70
|
-
It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`<div>${fn}</div>`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are
|
|
89
|
+
It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`<div>${fn}</div>`) 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.
|
|
71
90
|
|
|
72
|
-
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:
|
|
91
|
+
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:
|
|
73
92
|
|
|
74
93
|
| Written as | Refused? | Why |
|
|
75
94
|
|---|---|---|
|
|
76
|
-
| `action
|
|
77
|
-
|
|
|
95
|
+
| `action=${fn}` unquoted, on a `<form>` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified |
|
|
96
|
+
| `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
|
|
97
|
+
| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
|
|
98
|
+
| `formaction=${fn}` unquoted, on a submitter inside a bound form | **no, it BINDS** | the second supported shape (#1207). The identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, so no `formaction` url is emitted and the server takes the LAST `__webjs_action` entry |
|
|
99
|
+
| `formaction=${fn}` inside an UNBOUND `<form>` | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
|
|
100
|
+
| `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
|
|
101
|
+
| `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
|
|
102
|
+
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a form other than the bound one it sits in, so the boundness just checked was about the wrong element |
|
|
103
|
+
| `formmethod="get"` / `formenctype="text/plain"` on ANY submitter inside a bound form | yes | this is the Part B rule, and it applies whether or not that button binds an action of its own: a GET sends no body and `text/plain` is not parseable, so the submission works under JS (the router posts `FormData`) and 405s without it |
|
|
104
|
+
| `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
|
|
105
|
+
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so its own `formmethod` is the author's business and Part B leaves it alone |
|
|
106
|
+
| `.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 |
|
|
107
|
+
| `.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 |
|
|
108
|
+
| 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 |
|
|
109
|
+
| 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 |
|
|
110
|
+
| `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 |
|
|
111
|
+
| `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 |
|
|
78
112
|
| `.formAction=` on a button or input | yes | same reason, that is where `formAction` reflects |
|
|
79
113
|
| `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
|
|
80
|
-
| `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute
|
|
81
|
-
| `?action=` | yes | never leaked, but
|
|
114
|
+
| `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
|
|
115
|
+
| `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
|
|
82
116
|
| `@action=` unquoted | **no** | an event listener, and a function is exactly what one takes |
|
|
83
117
|
| `@action="${fn}"` quoted | yes | quoting makes it an ordinary attribute again, so it leaks |
|
|
84
118
|
|
|
@@ -94,15 +128,17 @@ Two things that "renders it empty" understates, both worth knowing before you go
|
|
|
94
128
|
- **On a route with a `loading.{js,ts}`, there is no log line either.** That wraps the page in a `Suspense` boundary, so the page body renders AFTER the 200 and the shell have been flushed, and a boundary that throws there is currently swallowed with no server log, no `onError`, and no error boundary. The visitor gets chrome and an empty body; with JS off the skeleton simply stays. That silence is a known framework gap rather than intended behaviour, so do not read the missing log line as evidence the render succeeded.
|
|
95
129
|
|
|
96
130
|
```ts
|
|
97
|
-
// WRONG: throws at render; it would have leaked the action's body.
|
|
98
131
|
import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// submission
|
|
102
|
-
|
|
132
|
+
// RIGHT: bind the imported action. method and enctype are supplied.
|
|
133
|
+
html`<form action=${submitFeedback}><input name="email"></form>`;
|
|
134
|
+
// WRONG: a bare form binds nothing, so the submission is a 405. There is no
|
|
135
|
+
// page `action` export to catch it.
|
|
136
|
+
html`<form method="post"><input name="email"></form>`;
|
|
103
137
|
```
|
|
104
138
|
|
|
105
|
-
A
|
|
139
|
+
A hole that resolves to `null` is NOT the same as omitting the attribute. `method=${null}` renders `method=""`, which cannot submit and is refused; `?method=${false}` emits nothing at all, so WebJs supplies `method="post"` and the form works. Both leave no attribute in the DOM, which is exactly why the check reads your template rather than the rendered element.
|
|
140
|
+
|
|
141
|
+
A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged, which is what a search form (`<form method="get" action="/search">`) and a `route.ts` endpoint both want. Other attributes keep their existing stringify behaviour; only a FUNCTION under `action` / `formaction` is claimed.
|
|
106
142
|
|
|
107
143
|
### `params` and `searchParams` are awaitable AND synchronously readable
|
|
108
144
|
|