@webjsdev/cli 0.10.11 → 0.10.12

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.
@@ -0,0 +1,1090 @@
1
+ # Advanced features
2
+
3
+ ## Streaming SSR / Suspense
4
+
5
+ ```js
6
+ import { html, Suspense } from '@webjsdev/core';
7
+
8
+ export default function Page() {
9
+ return html`
10
+ <h1>Catalogue</h1>
11
+ ${Suspense({ fallback: html`<p>Loading…</p>`, children: fetchExpensive() })}
12
+ `;
13
+ }
14
+ ```
15
+
16
+ TTFB = time to render everything *outside* the Suspense boundary. The
17
+ fallback flushes immediately, and the resolved content streams in as a
18
+ `<template>` + inline `__webjsResolve('id')` script when the promise
19
+ lands. Nested Suspense supported.
20
+
21
+ ## First-paint performance without a build step
22
+
23
+ Five stacked zero-build optimizations:
24
+
25
+ 1. **`<link rel="modulepreload">` per used component + transitive deps.**
26
+ The SSR pass knows every custom element in the final HTML. A startup
27
+ module-graph scan adds their transitive import dependencies too. All
28
+ preload hints are deduplicated and emitted in `<head>`.
29
+ 2. **HTTP/2 multiplex at the edge.** The production server (`npm run start`) speaks plain
30
+ HTTP/1.1. PaaS edges (Railway, Fly, Render, Vercel, Cloudflare Pages,
31
+ Heroku) and reverse proxies (nginx, Caddy, Traefik) speak
32
+ HTTP/2 to the browser and proxy 1.1 to the container, fetching many module
33
+ fetches in parallel over one TCP+TLS connection.
34
+ 3. **103 Early Hints.** Before SSR starts computing the response, the
35
+ server sends `103 Interim Response` with the page's module URLs as
36
+ `rel=modulepreload`. Chrome/Edge and edge proxies (Cloudflare, fly-proxy,
37
+ Fastly) forward these.
38
+ 4. **Lazy component loading (opt-in).** Components with `static lazy = true`
39
+ are excluded from modulepreload and loaded on-demand via
40
+ `IntersectionObserver` (200px root margin). The SSR-rendered DSD content
41
+ is visible immediately. `static hydrate = 'visible'` further defers
42
+ `connectedCallback` activation. Ideal for below-the-fold widgets.
43
+ 5. **Auto-vendor via jspm.io (Rails 7 + importmap-rails posture).** At
44
+ startup the server scans client-reachable source for bare npm import
45
+ specifiers. Each `pkg@version` is resolved through `api.jspm.io/generate`
46
+ to a CDN URL (`https://ga.jspm.io/npm:<pkg>@<version>/...`) and added
47
+ to the import map; the browser fetches each package directly from
48
+ the CDN. **SRI integrity (SHA-384) is computed on BOTH paths.** A
49
+ live-resolved (unpinned) app hashes each cross-origin bundle at warmup
50
+ and emits `integrity` + `crossorigin` on the importmap and modulepreload
51
+ tags, so a swapped or compromised CDN response is rejected by the browser
52
+ even with no pin file (#235). The hashing is bounded (parallel fetches
53
+ with a small concurrency cap and a per-fetch timeout) and FAIL-OPEN: a
54
+ bundle fetch failure skips that one URL's integrity (it loads without SRI,
55
+ the same as before) and emits a one-time warning, so a CDN hiccup never
56
+ takes the app down. Added warmup cost is one HEAD-like GET per distinct
57
+ cross-origin bundle, cached per process by URL so a re-resolve does not
58
+ re-fetch. `webjs vendor pin` still commits the resolved URLs + integrity
59
+ hashes to `.webjs/vendor/importmap.json` for reproducible deploys (and a
60
+ stable boot-time build id with no warmup fetch); `webjs vendor pin
61
+ --download` also caches the bundle bytes locally under
62
+ `.webjs/vendor/<pkg>@<version>.js` for air-gapped / strict-CSP
63
+ deployments. No bundler runs at any point.
64
+
65
+ ## No-build production model
66
+
67
+ webjs has no bundler and no `webjs build` step. The same `.js` / `.ts`
68
+ source files that run in dev are served as-is to the browser in
69
+ production. The Rails 7+ / Hotwire pattern:
70
+
71
+ - **Importmap-driven**: bare-specifier imports (`from "react"`) are
72
+ resolved via `<script type="importmap">` emitted into the document
73
+ head. By default each package resolves through `api.jspm.io/generate`
74
+ to a `https://ga.jspm.io/npm:<pkg>@<version>/...` URL and the browser
75
+ fetches it from the CDN directly, with an `integrity` SRI hash on every
76
+ cross-origin entry (computed live at warmup for an unpinned app, or read
77
+ from the pin file for a pinned one). `webjs vendor pin` commits the
78
+ resolved URLs + SHA-384 integrity hashes to `.webjs/vendor/importmap.json`
79
+ for reproducible deploys; `webjs vendor pin --download` additionally
80
+ caches each bundle to `.webjs/vendor/<pkg>@<version>.js` and rewrites
81
+ the importmap to `/__webjs/vendor/<pkg>@<version>.js` so the server
82
+ serves the bytes from disk (air-gapped / strict-CSP path). No bundler
83
+ runs at any point.
84
+ - **Per-file ESM serving**: every app `.js` / `.ts` becomes its own HTTP
85
+ resource. The browser walks the import graph and fetches each module
86
+ on demand.
87
+ - **`<link rel="modulepreload">` hints at SSR time**: for every component
88
+ the route uses + its transitive deps from the module graph. The
89
+ browser parallelizes fetches instead of waterfalling through nested
90
+ imports. This is what eliminates the perceived gap vs a bundle.
91
+ - **HTTP/2 multiplex** is what makes per-file serving competitive: one
92
+ TCP+TLS handshake, many module fetches in parallel over the same
93
+ connection. The production server (`npm run start`) speaks plain HTTP/1.1.
94
+ TLS + HTTP/2 is the proxy's job. PaaS edges (Railway, Fly, Render, Vercel,
95
+ Cloudflare Pages, Heroku) do this automatically. For bare-VM
96
+ deploys, put nginx, Caddy, or Traefik in front.
97
+
98
+ Content-hashed cache-busting and granular cache invalidation come from
99
+ the same per-file model: edit one file, only that file's URL hash
100
+ changes, only that one re-downloads.
101
+
102
+ ## Content-hash asset caching: `?v=<digest>` immutable URLs (prod) (#243)
103
+
104
+ In PRODUCTION the framework appends a per-file content hash to every
105
+ SAME-ORIGIN asset URL it emits (the importmap targets, the
106
+ `<link rel="modulepreload">` hrefs, the boot script's module specifiers).
107
+ The hash is a short prefix of a sha-256 over the file's BYTES, computed at
108
+ serve time (no build step) and memoized. A request whose URL carries that
109
+ `?v=<digest>` query is served `Cache-Control: public, max-age=31536000,
110
+ immutable` instead of the 1-hour fallback, so a browser / CDN holds it for
111
+ a year without revalidating.
112
+
113
+ This is safe precisely because the hash IS the version: a deploy that
114
+ changes a module's bytes changes its hash, so its emitted URL changes, so a
115
+ returning client fetches the NEW URL rather than serving a stale immutable
116
+ copy. The framework's own `@webjsdev/core` runtime (`/__webjs/core/*`) is
117
+ fingerprinted too, which fixes the exact regression an un-versioned
118
+ `immutable` would otherwise cause (a year-pinned old core renderer running
119
+ against a server emitting the new SSR shape after a version bump).
120
+
121
+ - **Per-file digest, not the build id.** The importmap build id
122
+ (`data-webjs-build`) does not change on an app-module byte change, so it
123
+ cannot be the per-asset fingerprint; each file carries its own hash.
124
+ - **Cross-origin URLs are NEVER fingerprinted.** A `https://ga.jspm.io/...`
125
+ vendor target keeps its exact URL: jspm already versions it, and #235's
126
+ SRI integrity is keyed by the un-hashed cross-origin URL. A downloaded
127
+ `/__webjs/vendor/<pkg>@<ver>.js` bundle is already version-named, so it is
128
+ left unchanged too.
129
+ - **Composes with `webjs.basePath` (#256).** A sub-path deploy emits
130
+ `<basePath>/app/foo.js?v=<digest>`: the base path is prefixed first, the
131
+ `?v` query appended after, and the ingress base-path strip never touches a
132
+ query.
133
+ - **DEV is byte-identical to before.** Fingerprinting is enabled only in
134
+ `webjs start` (prod). `webjs dev` emits no `?v` and serves every module
135
+ `no-cache`, so the dev wire is unchanged.
136
+ - **Un-fingerprinted requests keep the 1-hour fallback.** Only the presence
137
+ of a `?v` query flips the cache header; a hand-typed bare URL still
138
+ resolves and serves `public, max-age=3600`.
139
+
140
+ ## Connection-warming hints: `preconnect` / `dnsPrefetch` + auto vendor preconnect (#243)
141
+
142
+ A page can warm a cross-origin connection it is about to use (an API host,
143
+ a font / image CDN) by declaring it in `metadata`:
144
+
145
+ ```ts
146
+ export const metadata = {
147
+ preconnect: ['https://api.example.com', { url: 'https://fonts.gstatic.com', crossorigin: true }],
148
+ dnsPrefetch: 'https://analytics.example.com',
149
+ };
150
+ ```
151
+
152
+ Each emits a head hint: `<link rel="preconnect" href="..." [crossorigin]>`
153
+ (warms DNS + TLS + TCP) and `<link rel="dns-prefetch" href="...">` (DNS
154
+ only). Each field takes a URL string, `{ url, crossorigin? }`, or an array;
155
+ hrefs are HTML-escaped. See `agent-docs/metadata.md`.
156
+
157
+ **Auto vendor preconnect.** For an UNPINNED app resolving vendors live from
158
+ a cross-origin CDN, the framework auto-emits ONE
159
+ `<link rel="preconnect" href="<cdn-origin>" crossorigin>` (the resolved
160
+ vendor CDN origin, e.g. `https://ga.jspm.io`, derived from the importmap, so
161
+ a `--from jsdelivr` app preconnects to jsdelivr) so the browser warms that
162
+ connection before the importmap resolves. It is DEDUPED against an
163
+ author-declared preconnect to the same origin, and emits NOTHING for a
164
+ same-origin pinned app (vendors served from the app's own origin) or an app
165
+ with no cross-origin vendors.
166
+
167
+
168
+ ## Rate limiting via `rateLimit()`
169
+
170
+ Fixed-window limiter shaped as middleware. Place in `middleware.ts` at
171
+ whatever level you want to protect:
172
+
173
+ ```js
174
+ // app/api/auth/middleware.ts: protect login/signup from brute force
175
+ import { rateLimit } from '@webjsdev/server';
176
+ export default rateLimit({ window: '10s', max: 5 });
177
+
178
+ // Custom key: rate limit per authenticated user instead of IP
179
+ export default rateLimit({
180
+ window: '1m', max: 30,
181
+ key: async (req) => {
182
+ const session = await auth(req);
183
+ return session?.user?.id ?? 'anon';
184
+ },
185
+ });
186
+ ```
187
+
188
+ **Options:** `window` (`'10s'`, `'1m'`, `'1h'`, ms), `max` (default 60),
189
+ `key` (string prefix or `(req) => string`, defaulting to the
190
+ framework-stamped client IP from the TCP socket), `message`, `store`,
191
+ `trustProxy` (default `false`).
192
+
193
+ **`trustProxy`** controls how the default key is derived:
194
+
195
+ - **`false` (default, safe):** key on the framework-stamped
196
+ `x-webjs-remote-ip` header, which `startServer` sets from the
197
+ underlying TCP socket on every request (and strips any inbound
198
+ copy of, so clients cannot spoof it). Forwarded-IP headers like
199
+ `x-forwarded-for`, `cf-connecting-ip`, `x-real-ip` are ignored.
200
+ Correct for direct deployments (bare Node, no proxy in front).
201
+ - **`true`:** honour forwarded-IP headers, preferring the leftmost
202
+ entry of `x-forwarded-for`, then `cf-connecting-ip`, then
203
+ `x-real-ip`. Production deploys MUST run behind a reverse proxy
204
+ (nginx, Caddy, Cloudflare, Fly, Railway, Render edge) that STRIPS
205
+ inbound `x-forwarded-for` before adding its own. If the proxy
206
+ doesn't strip, the option reintroduces the per-request bucket
207
+ rotation it exists to defend against.
208
+
209
+ ```js
210
+ // Direct deploy (default): safe, ignores spoofable forwarded headers.
211
+ export default rateLimit({ window: '10s', max: 5 });
212
+
213
+ // Behind a trusted reverse proxy: opt in, MUST strip inbound XFF.
214
+ export default rateLimit({ window: '10s', max: 5, trustProxy: true });
215
+ ```
216
+
217
+ The `clientIp(req, { trustProxy })` helper is exported separately so
218
+ custom `key` functions can reuse the same resolution:
219
+
220
+ ```js
221
+ import { rateLimit, clientIp } from '@webjsdev/server';
222
+ export default rateLimit({
223
+ window: '1m', max: 30,
224
+ key: (req) => `${req.headers.get('x-tenant') || 'global'}:${clientIp(req, { trustProxy: true })}`,
225
+ });
226
+ ```
227
+
228
+ **Embedded use** (`createRequestHandler` running under Express / Bun /
229
+ Deno / edge adapters): the host adapter is responsible for stripping
230
+ any inbound `x-webjs-remote-ip` from the wire AND stamping its own
231
+ from the trusted socket address, otherwise a malicious client forges
232
+ the header and the framework trusts it. Call the exported helper:
233
+
234
+ ```js
235
+ import { createRequestHandler, stampRemoteIp } from '@webjsdev/server';
236
+ const handler = await createRequestHandler({ appDir });
237
+
238
+ // Inside the host adapter's per-request callback. `nodeReq` is the
239
+ // host's request object (Express req, Node IncomingMessage, etc.).
240
+ // `webReq` is whatever Request you constructed from the wire (URL,
241
+ // method, headers, body); building that is host-specific. The
242
+ // security-relevant line is the one that wraps it in stampRemoteIp:
243
+ const safe = stampRemoteIp(webReq, nodeReq.socket.remoteAddress);
244
+ const webRes = await handler.handle(safe);
245
+ // Pipe webRes back through the host's response API.
246
+ ```
247
+
248
+ The host-specific Request construction has its own gotchas (Node `Readable` to WHATWG `ReadableStream` for bodies; coalescing array-valued raw headers into comma-joined strings; URL synthesis from host + path). The `stampRemoteIp` line is what makes the result safe to hand off to webjs's rate limit; it MUST come after the inbound headers land in `webReq` and before `handler.handle(safe)` is called.
249
+
250
+ If the adapter cannot expose a trusted socket address, pass a custom
251
+ `key` function to `rateLimit()` that reads whatever client identifier
252
+ the host actually provides. The default rate-limit collapsing to
253
+ `_anon_` is preferable to silently trusting wire-set headers.
254
+
255
+ **Exceeded:** returns `429 Too Many Requests` with JSON `{ error: "Too Many Requests" }` and headers `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset`, `retry-after`.
256
+
257
+ **Scaling:** in-memory by default. `setStore(redisStore({ url: process.env.REDIS_URL }))` shares limits across instances.
258
+
259
+ ## Sub-path deployment: `webjs.basePath` (#256)
260
+
261
+ An app served under a sub-path (`example.com/app/`) behind a proxy that does NOT strip the prefix needs every framework-emitted absolute URL to carry that prefix, or module resolution 404s and the page never hydrates. Set the prefix in `package.json`:
262
+
263
+ ```jsonc
264
+ { "webjs": { "basePath": "/app" } }
265
+ ```
266
+
267
+ `'app'`, `'/app'`, and `'/app/'` all normalize to `'/app'`; a nested `'/foo/bar'` is allowed; the empty default (or absence) is a root mount and a pure no-op (an unconfigured app is byte-identical to before this feature). An unsafe value (a `..`, a protocol, a `//host` network-path reference, whitespace, a backslash) is rejected to the empty default so a typo fails safe.
268
+
269
+ **The model is strip-at-ingress + prefix-on-emit, two seams only.** At the very start of request handling, when the request path is under the base path, the framework STRIPS the prefix and rewrites the request, so all downstream logic (route matching, the `/__webjs/*` checks, the source-file gate, redirects, trailing-slash, the `webjs.headers` path config, the HTML cache key) sees a root-relative path and works unchanged. A request whose path is NOT under the base path is not for this mounted app, so it 404s. On the way out, every framework-emitted same-origin absolute URL gets the prefix prepended: the importmap targets (the `/__webjs/core/*` runtime entries and any same-origin `/__webjs/vendor/*` local target; a cross-origin `https://` CDN vendor URL is left untouched), the `<link rel="modulepreload">` hrefs, the boot script's per-route module specifiers and lazy-loader entries, the dev reload `src`, and the 103 Early Hints preloads. So a sub-path deploy serves `<basePath>/__webjs/core/*` and resolves every module under the prefix.
270
+
271
+ The whole config surface (`webjs.redirects` / `webjs.trailingSlash` / `webjs.headers` `source` patterns) is authored app-root-relative, exactly as without a base path, because the ingress strip runs first.
272
+
273
+ **OUT OF SCOPE (a documented follow-up).** Author-written `<a href="/about">` links and client-router navigation are NOT auto-prefixed under a base path. This is the same boundary Next draws between basePath auto-prefixing its `<Link>` component and a raw `<a href>`: webjs links are plain `<a href>`, so an author targeting a sub-path deploy writes the prefix into their own hrefs (or a future helper does) until client-side prefixing lands. The acceptance for #256 covers the server-emitted-URL + matching surface only.
274
+
275
+ ## CORS via `cors()`
276
+
277
+ `cors()` is a middleware factory (same `(req, next) => Response` contract as `rateLimit()`), usable in `middleware.js` (root or per-segment) or wrapped around a `route.js` handler. It handles origin reflection, the `OPTIONS` preflight, `Vary: Origin`, and the credentials rule, so route handlers do not hand-roll any of it. The `--template api` scaffold ships a root `middleware.ts` demonstrating it.
278
+
279
+ ```js
280
+ // middleware.js (applies to every request)
281
+ import { cors } from '@webjsdev/server';
282
+ export default cors({
283
+ origin: ['https://app.example.com', 'http://localhost:3000'],
284
+ credentials: true,
285
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
286
+ allowedHeaders: ['content-type', 'authorization'],
287
+ maxAge: 86400,
288
+ });
289
+ ```
290
+
291
+ Wrap a single handler instead of going app-wide:
292
+
293
+ ```js
294
+ // app/api/widgets/route.js
295
+ import { cors } from '@webjsdev/server';
296
+ const corsMw = cors({ origin: '*' });
297
+ export async function GET(req) {
298
+ return corsMw(req, async () => Response.json({ widgets: [] }));
299
+ }
300
+ ```
301
+
302
+ ### Options
303
+
304
+ | Option | Meaning |
305
+ |---|---|
306
+ | `origin` | A string (exact), `string[]` allow-list, a `RegExp`, a function `(origin) => boolean`, or `'*'` / `true` (any). Entries in an array may mix strings and RegExps. Defaults to `'*'`. |
307
+ | `credentials` | Sets `Access-Control-Allow-Credentials: true` for an allowed specific origin. |
308
+ | `methods` | Advertised on a preflight (`Access-Control-Allow-Methods`). |
309
+ | `allowedHeaders` | Advertised on a preflight; defaults to reflecting `Access-Control-Request-Headers`. |
310
+ | `exposedHeaders` | `Access-Control-Expose-Headers` on the actual response. |
311
+ | `maxAge` | Preflight cache lifetime in seconds. |
312
+
313
+ ### Behavior
314
+
315
+ - **Preflight.** An `OPTIONS` request carrying `Access-Control-Request-Method` short-circuits with a `204` carrying the Allow-Methods / Allow-Headers / Max-Age headers. `next()` is NOT called. A disallowed-origin preflight returns a bare `204` with no CORS headers, so the browser blocks the follow-up.
316
+ - **Actual request.** `next()` runs, then `Access-Control-Allow-Origin` (plus credentials / exposed headers) is attached. A disallowed origin gets NO `Access-Control-Allow-Origin`, and the browser blocks the cross-origin read, but the server still serves the response (CORS is browser-enforced, not a server gate; this matches the `expose()` path, which never 403s a mismatched actual request).
317
+ - **`Vary: Origin`.** Appended (never clobbering an existing `Vary`) whenever the allowed origin is dynamic (a reflected, per-origin value), so a shared cache keys on `Origin` and cannot poison one origin's response onto another. A constant `*` (no credentials) does not vary, so no `Vary` is added.
318
+
319
+ ### `credentials: true` requires an explicit origin allowlist (spec, enforced, warned)
320
+
321
+ **For any credentialed endpoint, pass an explicit `origin` allowlist (string / array / RegExp / function). Never combine `credentials: true` with a wildcard `origin` (`'*'` / `true`).**
322
+
323
+ `Access-Control-Allow-Origin: *` is INVALID together with `Access-Control-Allow-Credentials: true`, and the browser rejects the pair. Worse, the usual workaround (reflecting the request origin) under credentials effectively grants credentialed access (cookies, `Authorization`) to EVERY origin, a real footgun.
324
+
325
+ `cors()` keeps the request working rather than failing it: when `credentials: true` meets a wildcard `origin` it NARROWS to the reflected request origin instead of sending `*` (and appends `Vary: Origin`). With no `Origin` header under that combination it refuses entirely (no ACAO). Because that reflects any origin with credentials, it ALSO emits a one-time `console.warn` (deduped, not per-request):
326
+
327
+ ```
328
+ cors(): credentials with a wildcard origin reflects ANY origin with credentials.
329
+ Use an explicit origin allowlist for credentialed requests.
330
+ ```
331
+
332
+ The warning is informational; the request still proceeds. Treat it as a prompt to replace the wildcard with a real allowlist. An explicit allowlist with `credentials: true` is the safe, silent path.
333
+
334
+ ## Client router: nested-layout-aware partial swap
335
+
336
+ `import '@webjsdev/core/client-router'` enables SPA-style navigation that
337
+ preserves outer-layout DOM identity at any depth. Intercepts same-origin
338
+ `<a>` clicks (incl. inside shadow DOM via `composedPath()`), fetches the
339
+ target HTML, and replaces only the inside of the deepest shared layout.
340
+ Outer header / sidenav / footer DOM nodes are never re-rendered, so
341
+ scroll positions, input values, and `<details>` open state survive
342
+ navigation automatically.
343
+
344
+ ### How it works
345
+
346
+ 1. SSR injects `<!--wj:children:<segment-path>-->...<!--/wj:children-->`
347
+ comment markers around each layout's `${children}` interpolation,
348
+ one pair per layout in the chain. Auto-derived from folder
349
+ structure. Layout authors write nothing extra.
350
+ 2. On click, the router walks both the live DOM and the incoming HTML
351
+ for these markers and builds `Map<path, {start, end}>`.
352
+ 3. Picks the **longest shared path**, the deepest layout boundary
353
+ both pages have in common.
354
+ 4. Replaces nodes between that marker pair using a keyed `data-key`
355
+ reconciler. Elements with matching tag + matching key are reused
356
+ with in-place attribute diffing. **Live attributes** (`value`,
357
+ `checked`, `selected`, `indeterminate`, `disabled`, `open`,
358
+ `popover`) are NEVER overwritten by the server HTML.
359
+ 5. Merges `<head>` (add-only on partial swaps so runtime-injected
360
+ styles like Tailwind survive, with a full merge on the
361
+ root-layout-change fallback), re-runs `<script>` elements,
362
+ `customElements.upgrade()`s the swapped subtree, `pushState`s the
363
+ URL, scrolls.
364
+ 6. Dispatches `webjs:navigate` event on `document`.
365
+
366
+ ### In-place navigation-error recovery (`webjs:navigation-error`)
367
+
368
+ A successful swap (2xx/3xx) applies in place, and an HTML error body of any
369
+ status (a 4xx/5xx page, e.g. a `422` re-rendered form) is ALSO applied in
370
+ place. The remaining failure cases are a **non-HTML error response** (a
371
+ `500` carrying a JSON body) and a **transport/parse failure** (the `fetch`
372
+ rejected, or the body claimed HTML but did not parse). For those the router
373
+ no longer abandons the SPA with a destructive full `location.href` reload
374
+ (which would discard the partial-swap shell, scroll, focus, and in-flight
375
+ client state, and eat a second round-trip that may itself fail to the
376
+ browser's default error page).
377
+
378
+ Instead the router dispatches a cancelable, bubbling
379
+ `webjs:navigation-error` CustomEvent on `document`, with detail
380
+ `{ url, status, error }`: `status` is the HTTP status when a response
381
+ arrived (else `null`), and `error` is the `Error` for a transport/parse
382
+ failure (else `null`).
383
+
384
+ - **`preventDefault()` hands recovery to the app.** The router does NOTHING
385
+ further: the current page is left exactly as it is (shell, scroll, focus,
386
+ and client state all preserved), so the app can show its own toast, retry,
387
+ or navigate elsewhere.
388
+ - **Not cancelled (the default)** renders a MINIMAL in-place error surface,
389
+ a `<div role="alert" data-webjs-nav-error>` carrying a generic message
390
+ plus the status, into the deepest layout children slot (the same target a
391
+ normal partial swap writes to, so outer chrome and nav are preserved). No
392
+ full reload, the shell survives, and the user sees the failure.
393
+ - **Last-resort hard load** happens only when there is NO shared layout
394
+ marker to render into (a genuine cross-document nav), and only after the
395
+ event was not cancelled, so a truly unrecoverable case is not a silent
396
+ dead-end. This is the exception, not the default.
397
+
398
+ An **AbortError** (a newer navigation superseding this one) is a normal
399
+ supersede, NOT an error, and never dispatches `webjs:navigation-error`.
400
+
401
+ ```ts
402
+ document.addEventListener('webjs:navigation-error', (e) => {
403
+ // e.detail = { url, status, error }
404
+ e.preventDefault(); // app handles recovery; page left intact
405
+ showToast(`Could not load ${e.detail.url} (status ${e.detail.status})`);
406
+ });
407
+ ```
408
+
409
+ ### Form submission state (`webjs:submit-start` / `webjs:submit-end` + `aria-busy`)
410
+
411
+ When a `<form>` submits through the JS-enhanced router, the form gets a
412
+ submission lifecycle a component can read to disable the submit button, show a
413
+ spinner, or set a pending style:
414
+
415
+ - The router sets the native `aria-busy="true"` on the form for the in-flight
416
+ duration (cleared on settle). This IS the readable "is this form submitting"
417
+ primitive: any component can poll `form.getAttribute('aria-busy')` or style
418
+ `form[aria-busy="true"]` in CSS.
419
+ - It dispatches a bubbling `webjs:submit-start` (detail `{ form, url }`) when the
420
+ submission fetch starts, and `webjs:submit-end` (detail `{ form, url, ok }`,
421
+ `ok` is whether the submission settled as a success) on EVERY settle (success,
422
+ a 4xx/5xx validation re-render, a navigation error, or an abort by a
423
+ superseding submit). The pair is balanced even under a rapid re-submit (a
424
+ nav-token guard keeps a superseded submit's teardown from clearing the busy
425
+ state a newer submit set, the same guard `<webjs-frame>` uses).
426
+
427
+ ```ts
428
+ // A submit button that disables itself while its form is submitting.
429
+ form.addEventListener('webjs:submit-start', () => { button.disabled = true; });
430
+ form.addEventListener('webjs:submit-end', (e) => {
431
+ button.disabled = false; // e.detail = { form, url, ok }
432
+ });
433
+ /* or purely in CSS, no JS: */
434
+ /* form[aria-busy="true"] button[type="submit"] { opacity: .5; pointer-events: none; } */
435
+ ```
436
+
437
+ Progressive enhancement is unaffected: with JS off the form is a normal POST;
438
+ the events + `aria-busy` are a client-only enhancement.
439
+
440
+ ### Optimistic mutations (`optimistic()`)
441
+
442
+ `optimistic(signal, value, action)` from `@webjsdev/core` shows a mutation's
443
+ expected result IMMEDIATELY (the UI feels instant), runs the real server action,
444
+ and ROLLS BACK on failure. It is a thin wrapper over the signal primitive, no
445
+ state machine.
446
+
447
+ ```ts
448
+ import { signal, optimistic } from '@webjsdev/core';
449
+ import { likePost } from '../actions/like-post.server.js';
450
+
451
+ const liked = signal(false);
452
+ // in an @click handler:
453
+ const result = await optimistic(liked, true, () => likePost(postId));
454
+ // `liked` flips to true instantly. If likePost THROWS or returns
455
+ // { success: false }, `liked` rolls back to its prior value; the throw
456
+ // re-throws and the { success: false } result is returned (read its
457
+ // error / fieldErrors). On success the optimistic value stays; reconcile
458
+ // to the authoritative value from `result` if you need it.
459
+ ```
460
+
461
+ It rolls back on a thrown error OR an `ActionResult` `{ success: false }`
462
+ envelope, and never on success. Client-only (it mutates a signal), so a
463
+ component importing it is never elided as display-only.
464
+
465
+ ### Wire-byte optimization
466
+
467
+ The router sends `X-Webjs-Have: <paths>` listing the marker paths it
468
+ already has rendered. The server walks the target route's layout chain
469
+ innermost → outermost and **short-circuits at the first match**. The
470
+ inner tree is wrapped in that layout's marker pair and returned. Outer
471
+ layouts are not loaded, not rendered, not re-serialized. Real savings
472
+ on every same-shell navigation.
473
+
474
+ ### Cross-deploy hard-reload signals
475
+
476
+ Two complementary mechanisms tell the client when a partial swap is
477
+ unsafe and a hard reload is required:
478
+
479
+ 1. **Importmap drift** (the common case after a vendor pin change).
480
+ Server stamps the PUBLISHED build id on `<script type="importmap"
481
+ data-webjs-build="…">` AND emits the same value as `X-Webjs-Build`
482
+ on every response, including X-Webjs-Have partial responses with no
483
+ head. The published id is the importmap hash, but advertised only
484
+ once the importmap is authoritatively final (at boot for a pinned
485
+ app, after the first successful vendor resolve otherwise); while the
486
+ map is still warming it stays empty. Client compares the response
487
+ header against the live document's `data-webjs-build`. A hard reload
488
+ (`location.href = target`) fires only when both ids are present and
489
+ differ (a real cross-deploy). An empty id on either side means
490
+ "version unknown" (a warming runtime-first-boot server) and never
491
+ reloads, so the warmup window cannot hard-reload and wipe a
492
+ half-filled form. Works for every nav, including partial-response navs.
493
+
494
+ 2. **Generic `data-webjs-track="reload"`** (for non-importmap concerns,
495
+ e.g. a CSS bundle hash, a build-id meta tag). Any head element with
496
+ the attribute joins a signature computed from concatenated outerHTML.
497
+ On nav, mismatched signatures trigger reload. Mirrors hotwired/turbo's
498
+ `data-turbo-track="reload"`.
499
+
500
+ ```html
501
+ <link rel="stylesheet" href="/build/main-abc123.css" data-webjs-track="reload">
502
+ <meta name="build-id" content="rev-42" data-webjs-track="reload">
503
+ ```
504
+
505
+ Both paths share a one-shot `sessionStorage` reload guard so a
506
+ genuinely-churning resource doesn't loop reloads.
507
+
508
+ ### Snapshot cache + revalidation
509
+
510
+ URL-keyed `Map<url, snapshot>` (LRU, cap 16). Back/forward via
511
+ popstate restores from cache instantly, then refetches in the
512
+ background.
513
+
514
+ ```js
515
+ import { revalidate } from '@webjsdev/core';
516
+ revalidate('/products/123'); // evict one URL
517
+ revalidate(); // clear the entire cache
518
+ ```
519
+
520
+ Call `revalidate(path)` after a server action mutates data that
521
+ affects a cached page.
522
+
523
+ ### Link prefetch
524
+
525
+ Same-origin in-app links are prefetched speculatively so a click
526
+ resolves from a warm cache with no round-trip. On by default with the
527
+ `intent` strategy (no per-link opt-in needed), the way Next / Nuxt /
528
+ SvelteKit ship auto-prefetch. The prefetch request carries the same
529
+ `X-Webjs-Have` header a real navigation sends, so the server returns the
530
+ same divergent fragment; that fragment lands in a dedicated prefetch
531
+ cache (separate from the back/forward snapshot cache) and `fetchAndApply`
532
+ consumes it via `prefetchTake` before falling back to the network.
533
+
534
+ Per link, set `data-prefetch` (a valid-HTML `data-*` attribute, the shape
535
+ SvelteKit and Astro use; Next / Nuxt / Remix use a component prop, which
536
+ webjs has no equivalent for since links are plain `<a href>`):
537
+
538
+ ```html
539
+ <a href="/dashboard">intent: hover / focus / touch (default)</a>
540
+ <a href="/dashboard" data-prefetch="render">eager on insert</a>
541
+ <a href="/dashboard" data-prefetch="viewport">on scroll-into-view</a>
542
+ <a href="/dashboard" data-prefetch="none">never</a>
543
+ ```
544
+
545
+ Next-style aliases are accepted: `true` = `render`, `auto` = `viewport`,
546
+ `false` = `none`. `intent` waits a short dwell (~100ms) after hover/focus
547
+ so a pointer passing over a link does not fetch it; `viewport` uses an
548
+ IntersectionObserver at threshold 0.5; `render` and `viewport` are
549
+ applied by a document scan on enable and after each navigation.
550
+
551
+ Only internal links qualify, using the same eligibility as a click:
552
+ cross-origin, `download`, `target` other than `_self`, non-HTML
553
+ extensions, `data-no-router`, and pure same-page hash jumps are skipped.
554
+ Opt out with `data-prefetch="none"`, `data-no-prefetch`, or
555
+ `rel="external"`. Speculation is bounded by a concurrency cap (excess
556
+ requests queue and drain as slots free, rather than being dropped),
557
+ in-flight de-dupe, and an LRU + TTL cache, and is disabled entirely under
558
+ `Save-Data` or `prefers-reduced-data`. A mutating form submission and
559
+ `revalidate(url)` both evict the prefetch cache alongside the snapshot
560
+ cache, so a fragment prefetched before a mutation is never served stale.
561
+
562
+ There is no logout-style safeguard, matching every framework that
563
+ auto-prefetches: a prefetch issues a real GET, so a `/logout` or any
564
+ mutating endpoint MUST be a POST or a `<form>` submission (which the
565
+ router never prefetches), not a GET link. A native `<link rel="prefetch">`
566
+ in the document head is the browser's own mechanism and is left untouched.
567
+
568
+ The router dispatches a `webjs:prefetch` event on `document` the instant a
569
+ speculative fragment lands in the cache and becomes consumable (which is
570
+ strictly later than the request going out, since the entry is stored only
571
+ after the response body is read). The detail is `{ url, key, from: 'prefetch' }`,
572
+ mirroring `webjs:navigate` so one listener can split the two by `detail.from`.
573
+ Listen to instrument prefetch hit rate, or to gate work on a warm cache:
574
+
575
+ ```ts
576
+ document.addEventListener('webjs:prefetch', (e) => {
577
+ console.log('prefetched', e.detail.url); // fragment is now cached
578
+ });
579
+ ```
580
+
581
+ ### View Transitions (opt-in, all three swap paths)
582
+
583
+ The router can wrap a client navigation's DOM mutation in the native
584
+ [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
585
+ (`document.startViewTransition`), so a same-shell partial swap cross-fades
586
+ (or runs your `::view-transition-*` CSS) instead of snapping. It is OFF by
587
+ default and purely OPT-IN, so an unconfigured app behaves exactly as
588
+ before (no animation surprise, no regression in a browser without the
589
+ API). Opt in by adding a meta to the page head, mirroring Turbo's
590
+ `<meta name="view-transition">` convention:
591
+
592
+ ```html
593
+ <!-- in the root layout's <head>, or any page's head -->
594
+ <meta name="view-transition" content="same-origin">
595
+ ```
596
+
597
+ The accepted opt-in value is `same-origin` (every client-router swap is
598
+ same-origin by construction, so it reads as "animate these in-app
599
+ navigations"); any other value, or the meta being absent, keeps
600
+ transitions off. The meta is re-read PER navigation, so a page can turn
601
+ transitions on or off as the user moves through the app (the head merge
602
+ brings in the new page's head).
603
+
604
+ When enabled and supported, the transition wraps ALL THREE swap paths,
605
+ the deepest-marker layout swap, the `<webjs-frame>` swap, AND the
606
+ full-body fallback, not just the full-body case (the inverse of what an
607
+ author expects, since the marker + frame swaps are the common
608
+ designed-for paths). The transition wraps the DOM MUTATION ONLY, never
609
+ the fetch (which already happened); the browser captures the
610
+ before/after around the synchronous swap. When `startViewTransition` is
611
+ unavailable (Firefox / older Safari), the swap runs synchronously,
612
+ byte-identical to the no-transition path, with no flash and no throw.
613
+
614
+ ### Persisting elements across a swap (`data-webjs-permanent`)
615
+
616
+ An element marked `data-webjs-permanent` (it MUST also carry an `id`)
617
+ survives a navigation as the SAME live DOM node, by node identity, so a
618
+ playing `<audio>` / `<video>`, a live widget, an open menu, or any element
619
+ with accumulated JS state keeps running across the swap instead of being
620
+ destroyed and re-created from the incoming HTML. Mirrors Turbo's
621
+ permanent-element behaviour.
622
+
623
+ ```html
624
+ <audio id="player" data-webjs-permanent controls src="/track.mp3"></audio>
625
+ ```
626
+
627
+ Mechanism: before the destructive swap, for each `[data-webjs-permanent]
628
+ [id]` in the CURRENT DOM the router looks for a matching `#id` in the
629
+ INCOMING document; when BOTH exist, the LIVE node is moved into the
630
+ incoming tree's position (replacing the incoming placeholder), so the swap
631
+ adopts the live node rather than recreating it. It works for the
632
+ full-body path AND the in-region (marker / frame) paths, and is a STRONGER
633
+ guarantee than the keyed reconciler (which preserves identity for matched
634
+ keyed children): a permanent node keeps EXACT identity even where the
635
+ reconciler would otherwise recreate it. Rules:
636
+
637
+ - The element must have an `id` (the match key) and the attribute on BOTH
638
+ the current and incoming render of the page.
639
+ - An id present in the current but ABSENT from the incoming doc is NOT
640
+ force-persisted (it is being removed; the swap removes it as usual).
641
+ - Only a CURRENT node actually carrying `data-webjs-permanent` is moved
642
+ (an incoming `#id` that resolves to a non-permanent current element is
643
+ left untouched).
644
+ - The node is placed exactly where the incoming document puts it, so it
645
+ never escapes a frame / region boundary.
646
+
647
+ Progressive enhancement: with JS off, `data-webjs-permanent` is an inert
648
+ attribute and the navigation is a normal full-page load.
649
+
650
+ ### Per-segment loading skeletons
651
+
652
+ Each `loading.{js,ts}` in the route chain is rendered into a hidden
653
+ `<template id="wj-loading:<segment-path>">`. On nav-start, the client
654
+ clones the deepest matching template into the swap slot. Users see
655
+ an instant per-segment skeleton during the fetch.
656
+
657
+ ### Programmatic navigation
658
+
659
+ ```js
660
+ import { navigate } from '@webjsdev/core';
661
+ await navigate('/about'); // push history
662
+ await navigate('/login', { replace: true }); // replace
663
+ ```
664
+
665
+ ### Form submissions
666
+
667
+ `<form action="..." method="...">` submissions are intercepted alongside
668
+ link clicks and routed through the same partial-swap pipeline.
669
+ Submitter attributes (`formmethod` / `formaction` / `formenctype` on a
670
+ clicked `<button>`) take precedence over the form's own, per the HTML5
671
+ form-submission algorithm.
672
+
673
+ - **GET**: `FormData` is promoted to the URL query string (replacing
674
+ any existing `?...` on `action`), then the URL is fetched and applied
675
+ exactly like a link click.
676
+ - **POST / PUT / PATCH / DELETE**: `FormData` is sent as the request
677
+ body. After a successful response the snapshot cache is cleared (the
678
+ submission may have mutated server state that other cached URLs
679
+ depend on, so back/forward must refetch instead of restoring stale).
680
+
681
+ Forms calling a server action via `@submit=${e => this.handleSubmit(e)}`
682
+ + `e.preventDefault()` are unaffected: the router only intercepts when
683
+ `event.defaultPrevented` is false. Opt out per form or per submitter
684
+ with `data-no-router`:
685
+
686
+ ```html
687
+ <form action="/legacy" data-no-router>...</form>
688
+ <form action="/x"><button data-no-router>Full reload</button></form>
689
+ ```
690
+
691
+ Auto-skipped (no `data-no-router` needed):
692
+ - `method="dialog"` (browser-native dialog dismissal)
693
+ - `target` / `formtarget` ≠ `_self` (iframe / popup)
694
+ - Cross-origin `action`
695
+ - Non-HTML extensions on `action` (`.pdf`, etc.)
696
+
697
+ ### Non-2xx HTML responses are rendered in place
698
+
699
+ A response with a `text/html` body is applied to the DOM regardless of
700
+ status code:
701
+
702
+ - **2xx**: normal navigation.
703
+ - **4xx (e.g. 422)**: server-rendered validation errors. The form is
704
+ re-rendered with `value` attributes preserving what the user typed,
705
+ inline error messages visible, no full-page reload. Standard Rails /
706
+ Django / Laravel / Phoenix pattern.
707
+ - **5xx with HTML**: error page rendered in place (not a flash of
708
+ blank then reload).
709
+
710
+ Non-HTML responses (JSON error envelopes, downloads, opaque) fall back
711
+ to `location.href = url` and let the browser handle them.
712
+
713
+ **204 No Content** = "stay on current page" (autosave-style
714
+ submissions). DOM is untouched. History records the requested URL.
715
+
716
+ **Server-side redirects** (3xx that `fetch()` follows automatically)
717
+ record the **final** URL in history, not the originally-requested one.
718
+ The Post-Redirect-Get pattern works correctly.
719
+
720
+ ### Page server actions (a `<form>` that re-renders with errors)
721
+
722
+ The server side of the no-JS validation pattern is a page `action`
723
+ export. A `page.{js,ts}` may export an `action` alongside its default
724
+ render function. A non-GET/HEAD submission to that page's own URL runs
725
+ the action (inside the page's segment middleware), so a plain
726
+ `<form method="POST">` works with JS disabled AND through the client
727
+ router, same UI either way.
728
+
729
+ ```ts
730
+ // app/signup/page.ts
731
+ import { html } from '@webjsdev/core';
732
+ import { signup } from '../../modules/auth/actions/signup.server.ts';
733
+
734
+ export async function action({ formData }: { formData: FormData }) {
735
+ const email = String(formData.get('email') || '').trim();
736
+ const values = { email };
737
+ if (!email.includes('@')) {
738
+ return { success: false, fieldErrors: { email: 'Enter a valid email' }, values, status: 422 };
739
+ }
740
+ const r = await signup({ email });
741
+ if (!r.success) return { success: false, fieldErrors: { email: r.error }, values, status: r.status };
742
+ return { success: true, redirect: '/login' };
743
+ }
744
+
745
+ export default function Signup({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {
746
+ const errors = actionData?.fieldErrors || {};
747
+ const values = actionData?.values || {};
748
+ return html`
749
+ <form method="POST">
750
+ <input name="email" type="email" value=${values.email || ''} required>
751
+ ${errors.email ? html`<p class="error">${errors.email}</p>` : ''}
752
+ <button>Sign up</button>
753
+ </form>
754
+ `;
755
+ }
756
+ ```
757
+
758
+ The action receives `{ request, params, searchParams, url, formData }`
759
+ (`formData` is the already-parsed body, `request` is the raw Request)
760
+ and returns an `ActionResult`. The server interprets the result:
761
+
762
+ - **Success** (`{ success: true, redirect? }`, or any non-`false`
763
+ result, or a thrown `redirect()`): a `303 See Other` to
764
+ `result.redirect` if present, else the page's own path
765
+ (Post/Redirect/Get, so a reload does not resubmit).
766
+ - **Failure** (`{ success: false, fieldErrors?, values?, status? }`):
767
+ re-SSR the SAME page with `status` (default `422`) and the result on
768
+ `ctx.actionData`. The page reads `actionData.fieldErrors.<field>` for
769
+ messages and `actionData.values.<field>` to repopulate native
770
+ `<input value=...>`, so the user's typed input survives.
771
+ - A thrown `notFound()` yields a 404, a thrown `redirect()` keeps its
772
+ own 307/308 status (PRG uses 303 only for the success-result path).
773
+
774
+ A page WITHOUT an `action` export keeps the old behavior, a non-GET to
775
+ it 404s. There is no form library: native input repopulation plus the
776
+ browser's Constraint Validation API (`required`, `type="email"`,
777
+ `minlength`) cover the rest. Field-level errors come from the server
778
+ action result. See `agent-docs/recipes.md` for the full recipe and the
779
+ `ActionResult` shape.
780
+
781
+ ### Concurrent navigations + cancellation
782
+
783
+ Each navigation/submission `abort()`s any in-flight fetch from the prior
784
+ one (Turbo Drive's `navigator.stop()` pattern). Rapid clicks won't
785
+ produce N parallel requests competing to be applied last. A monotonic
786
+ nav-token additionally short-circuits any response that arrives after a
787
+ newer navigation has settled, so a slow first request that races past
788
+ its abort cannot revert the newer page.
789
+
790
+ ### Scroll restoration on back/forward
791
+
792
+ On snapshot, the router records `{ window.scrollX, window.scrollY }`
793
+ alongside the HTML. On popstate cache-hit, the cached DOM is applied
794
+ and scroll is restored to where the user left it. The background
795
+ revalidation fetch that follows does **not** scroll, so the restored
796
+ position survives the refresh. Cache miss → browser-native scroll
797
+ restoration takes over.
798
+
799
+ Inner scroll containers (e.g. `.docs-sidenav`) are preserved
800
+ automatically by the outer-layout-DOM-identity invariant. They stay
801
+ mounted across nav and keep their `scrollTop` natively.
802
+
803
+ ### `<webjs-frame>` escape hatch
804
+
805
+ For partial-swap regions NOT tied to a folder layout (a marketing-page
806
+ widget, tabbed UI, etc.), wrap the region in a frame:
807
+
808
+ ```ts
809
+ html`<webjs-frame id="activity">…contents…</webjs-frame>`
810
+ ```
811
+
812
+ On click, the router walks `closest('webjs-frame')` from the click
813
+ target. If a frame is found AND the response contains a matching
814
+ `<webjs-frame id="...">`, the swap is scoped to that frame's children,
815
+ which takes precedence over the layout-marker mechanism.
816
+
817
+ #### External targeting (`data-webjs-frame`) and `_top` breakout
818
+
819
+ A trigger does NOT have to be nested inside the frame it drives. Mirroring
820
+ Turbo's `data-turbo-frame`, an `<a>` or `<form>` (or any ancestor of it)
821
+ carrying `data-webjs-frame="<id>"` drives the frame with that id, resolved
822
+ via `getElementById` in the current document. So an external nav/sidebar
823
+ link or a filter form can drive a content frame it does not enclose:
824
+
825
+ ```ts
826
+ html`
827
+ <nav data-webjs-frame="results">
828
+ <a href="/products?sort=new">Newest</a>
829
+ <a href="/products?sort=top">Top rated</a>
830
+ </nav>
831
+ <form action="/products" data-webjs-frame="results">…filters…</form>
832
+
833
+ <webjs-frame id="results">…current results…</webjs-frame>
834
+ `
835
+ ```
836
+
837
+ Resolution precedence: an explicit `data-webjs-frame` WINS over the
838
+ closest-enclosing-frame default. So a link physically inside frame A that
839
+ carries `data-webjs-frame="b"` drives frame B.
840
+
841
+ - **`data-webjs-frame="_top"`** is a reserved token: it forces a full-page
842
+ navigation (the normal layout/marker swap or a full nav), breaking OUT of
843
+ the enclosing frame. Put it on a link inside a frame that should escape it.
844
+ - **An id that does not resolve** to a live `<webjs-frame>` emits a one-time
845
+ `console.warn` and falls back to a normal navigation (fail-safe; the
846
+ router never throws and never swaps the wrong region).
847
+ - **With JS disabled** a `data-webjs-frame` link is an inert attribute on a
848
+ plain `<a href>`, so the click is a normal full-page navigation, the
849
+ correct progressive-enhancement fallback.
850
+
851
+ #### Busy state (`aria-busy` + `webjs:frame-busy`)
852
+
853
+ While a frame's navigation is in flight the router sets the native
854
+ `aria-busy="true"` on the frame element and clears it (to `"false"`) on
855
+ completion, on EVERY exit (a successful swap, a frame-missing response, an
856
+ HTTP/transport error, or an abort by a newer nav). So assistive tech
857
+ announces the loading state for free, and CSS can style the busy region:
858
+
859
+ ```css
860
+ webjs-frame[aria-busy="true"] { opacity: 0.6; }
861
+ ```
862
+
863
+ The router also dispatches a bubbling `webjs:frame-busy` event on the frame
864
+ at both edges, so app code can hook the start and finish:
865
+
866
+ ```ts
867
+ document.addEventListener('webjs:frame-busy', (e) => {
868
+ const { frameId, busy } = e.detail; // busy: true at start, false at finish
869
+ spinner.toggle(frameId, busy);
870
+ });
871
+ ```
872
+
873
+ #### `webjs:frame-missing` (response lacks the requested frame)
874
+
875
+ When a frame-scoped navigation's response does NOT carry a matching
876
+ `<webjs-frame id="...">` (e.g. an auth gate returns a login page without
877
+ the frame), the router does NOT fall through to a full-page swap, because
878
+ that would silently destroy the page. Instead it dispatches a cancelable,
879
+ bubbling `webjs:frame-missing` CustomEvent on the frame element (so a
880
+ document-level listener catches it) and returns.
881
+
882
+ - **Default (not prevented):** the router emits a one-line `console.warn`
883
+ and leaves the frame UNCHANGED (its current content stays, now stale).
884
+ No full-page swap ever happens.
885
+ - **Calling `preventDefault()`** keeps the framework silent and doing nothing
886
+ further. The listener owns the outcome, e.g. it may call `navigate(url)`
887
+ for a deliberate full swap, or `location.assign(url)` for a hard load.
888
+
889
+ `event.detail` is `{ frameId, url, document }`, where `frameId` is the
890
+ requested frame id, `url` is the navigation target, and `document` is the
891
+ parsed response document (so a listener can inspect what came back).
892
+
893
+ ```ts
894
+ document.addEventListener('webjs:frame-missing', (e) => {
895
+ // The frame wasn't in the response (auth redirect, say). Take over
896
+ // with a deliberate full navigation to the URL the server returned.
897
+ e.preventDefault();
898
+ location.assign(e.detail.url);
899
+ });
900
+ ```
901
+
902
+ #### Self-loading frames (`src` + `loading`, #253)
903
+
904
+ A `<webjs-frame>` can fetch its OWN content instead of waiting for a click
905
+ or a form. Give it a `src` and it self-fetches that URL as a frame nav and
906
+ applies the matching `<webjs-frame id>` subtree from the response into itself,
907
+ through the SAME frame-swap path a click-driven frame nav uses (so the busy
908
+ lifecycle, the navigation-error recovery, the keyed reconciler, and the
909
+ frame-missing fallback all apply for free).
910
+
911
+ ```html
912
+ <!-- Eager (default): fetches on connect. -->
913
+ <webjs-frame id="rail" src="/widgets/rail"></webjs-frame>
914
+
915
+ <!-- Lazy: fetches when the frame first scrolls into view. -->
916
+ <webjs-frame id="comments" src="/posts/42/comments" loading="lazy">
917
+ <p>Loading comments...</p>
918
+ </webjs-frame>
919
+ ```
920
+
921
+ The `loading` attribute picks WHEN:
922
+
923
+ - **`loading="eager"`** (or absent, the default) fetches on `connectedCallback`.
924
+ - **`loading="lazy"`** fetches when the frame first enters the viewport, reusing
925
+ the same IntersectionObserver budget (`rootMargin: '200px'`) as a
926
+ `static lazy = true` component.
927
+
928
+ A `src` change after connect re-loads. Eager connect, the lazy observer, and a
929
+ `src` mutation never double-fetch the same URL (a per-element loaded/loading
930
+ guard keyed on the resolved URL coalesces them). The request carries the same
931
+ `x-webjs-frame: <id>` header a click-driven frame nav sends, so a `src` self-load
932
+ and a click that targets the same frame produce identical DOM.
933
+
934
+ **The server returns ONLY the requested subtree.** When a request carries
935
+ `x-webjs-frame: <id>` and the route renders a `<webjs-frame id>` with that id,
936
+ the server returns JUST that frame subtree (extracted from the full render, so
937
+ byte-equivalent to what the client would slice from a full-page response) rather
938
+ than the whole page. So a region swap pays only for the region, not the full
939
+ document shell and every other region, the same spirit as the `X-Webjs-Have`
940
+ partial-nav optimization. When the requested frame is NOT in the rendered output
941
+ (an auth redirect to a login page, a route that dropped the frame), the server
942
+ falls back to the full page and the client handles the absence via
943
+ `webjs:frame-missing`. A request with no `x-webjs-frame` header is unaffected
944
+ (byte-identical full-page render).
945
+
946
+ **PROGRESSIVE ENHANCEMENT CAVEAT: a `src`-driven frame is JS-DEPENDENT.** The
947
+ browser does NOT natively fetch `<webjs-frame src>` (unlike `<iframe>`), so with
948
+ JS off the frame shows only whatever children were server-rendered into it. Use
949
+ `src`/`loading` for DEFERRED content (comments, a recommendations rail, an
950
+ expensive card) where a JS-off placeholder / empty state is acceptable, exactly
951
+ the lazy-content use case. For content that MUST exist without JS, render it
952
+ server-side into the frame instead of using `src` (the self-load then replaces
953
+ those fallback children).
954
+
955
+ ### Stream actions: surgical element-level updates (#248)
956
+
957
+ A region swap (a layout marker or a `<webjs-frame>`) is the right tool for "this
958
+ part of the page changed". It is too coarse for "append ONE comment", "remove
959
+ ONE row", "bump a count", or "insert a toast". For those, a server response can
960
+ declare per-element actions, carried as plain HTML, a `<webjs-stream action
961
+ target>` wrapping one `<template>`:
962
+
963
+ ```html
964
+ <webjs-stream action="append" target="comments">
965
+ <template><li>Nice post!</li></template>
966
+ </webjs-stream>
967
+ ```
968
+
969
+ The `<webjs-stream>` element clones its `<template>` content on connect, applies
970
+ the action against the target by native DOM, then removes itself. Actions
971
+ (Turbo's set): `append` / `prepend` (last / first child of the target id),
972
+ `before` / `after` (sibling of the target), `replace` (the target element
973
+ itself), `update` (the target's children), `remove` (delete the target, no
974
+ template). A `targets="<css-selector>"` applies to every match instead of a
975
+ single `target` id.
976
+
977
+ **One applier, two delivery paths.**
978
+
979
+ 1. **HTTP (content-negotiated form).** A `<form>` submission rides the client
980
+ router, which adds `Accept: text/vnd.webjs-stream.html`. The server returns a
981
+ stream ONLY when that Accept is present; the router then applies the
982
+ `<webjs-stream>` body surgically (no region swap). With JS OFF the browser
983
+ sends no such Accept, so the SAME endpoint returns a normal render/redirect
984
+ and the form is a plain full-page POST. The grammar is additive and
985
+ progressive-enhancement-safe.
986
+
987
+ 2. **Live channel (`broadcast()` / `connectWS`).** `renderStream(message)` parses
988
+ a server-pushed payload and inserts the `<webjs-stream>` elements (which
989
+ self-apply), so chat / notifications / presence reuse the SAME applier:
990
+
991
+ ```js
992
+ import { connectWS, renderStream } from '@webjsdev/core';
993
+ connectWS('/feed', { onMessage: (m) => renderStream(m) });
994
+ ```
995
+
996
+ **Server-side, build the payload with the `@webjsdev/server` helpers:**
997
+
998
+ ```ts
999
+ // app/post/[id]/route.ts (or a page `action`)
1000
+ import { stream, streamResponse, acceptsStream } from '@webjsdev/server';
1001
+ import { broadcast } from '@webjsdev/server';
1002
+
1003
+ export async function POST(req: Request, { params }) {
1004
+ const comment = await addComment(params.id, await req.formData());
1005
+ const html = stream.append('comments', `<li>${escapeHtml(comment.text)}</li>`);
1006
+ // Fan the SAME action out to every other connected viewer.
1007
+ broadcast(`post:${params.id}`, html);
1008
+ // Negotiate: a stream for the JS client, a redirect for the no-JS form.
1009
+ if (acceptsStream(req)) return streamResponse(html);
1010
+ return Response.redirect(`/post/${params.id}`, 303);
1011
+ }
1012
+ ```
1013
+
1014
+ `stream.*` returns the `<webjs-stream>` string (the target id is
1015
+ attribute-escaped; the CONTENT is server-authored and NOT escaped, so escape any
1016
+ user substring yourself, like an `html` hole). `streamResponse(...)` wraps one or
1017
+ more parts in a `Response` with the stream content type. A page `action` may
1018
+ return `streamResponse(...)` directly (it is honored verbatim); on the no-JS
1019
+ branch return a normal `ActionResult` instead. `renderStream` is auto-registered
1020
+ by the client router, so it (and the `<webjs-stream>` element) is available
1021
+ wherever a layout imports `@webjsdev/core/client-router`.
1022
+
1023
+ ### Opt out per link
1024
+
1025
+ ```html
1026
+ <a href="/legacy" data-no-router>Full reload</a>
1027
+ ```
1028
+
1029
+ Use `data-no-router` for:
1030
+ - **Auth flows**: `/logout`, OAuth redirects. Full reload wipes in-memory module state.
1031
+ - **Print views / embed pages.**
1032
+ - **Experimental routes** with a different client runtime.
1033
+
1034
+ ### Auto-skipped (no `data-no-router` needed)
1035
+
1036
+ - `download`, non-`_self` target, modifier-key click.
1037
+ - Cross-origin hrefs.
1038
+ - Pure hash fragments on same page.
1039
+ - Non-HTML extensions (`.pdf`, `.zip`, `.json`, images, media): browser handles.
1040
+ - Response `Content-Type` not `text/html`: falls back to full nav.
1041
+
1042
+ ### Loading indicator
1043
+
1044
+ `<html>` gets `data-navigating` during fetch. Style a progress bar off that attribute.
1045
+
1046
+ ## WebSockets
1047
+
1048
+ ### Server: `WS` export in `route.{js,ts}`
1049
+
1050
+ ```js
1051
+ export function WS(ws, req, { params }) {
1052
+ ws.on('message', (data) => ws.send('echo:' + data));
1053
+ ws.on('close', () => { /* cleanup */ });
1054
+ }
1055
+ ```
1056
+
1057
+ In **dev mode** the module re-imports per connection to pick up edits.
1058
+ Store shared state on `globalThis`:
1059
+
1060
+ ```js
1061
+ const clients = globalThis.__my_clients ?? (globalThis.__my_clients = new Set());
1062
+ ```
1063
+
1064
+ ### Client: `connectWS`
1065
+
1066
+ `connectWS(url, { onOpen, onMessage, onClose, onError, reconnect })` from `@webjsdev/core`. Auto-reconnects with exponential backoff, JSON parse/stringify, queues sends while disconnected.
1067
+
1068
+ ### Broadcast (single-instance)
1069
+
1070
+ ```js
1071
+ import { broadcast } from '@webjsdev/server';
1072
+
1073
+ export function WS(ws, req) {
1074
+ ws.on('message', (data) => {
1075
+ broadcast('/api/chat', data); // → all connected clients on this path
1076
+ });
1077
+ }
1078
+ ```
1079
+
1080
+ For multi-instance, the user adds Redis pub/sub themselves. No framework magic.
1081
+
1082
+ ## Per-segment middleware
1083
+
1084
+ `middleware.js` can live at any level under `app/` and only applies to
1085
+ its subtree. Chain runs outermost → innermost, root-sibling → app-root
1086
+ first, then segment-scoped files.
1087
+
1088
+ ## Raw-text templates
1089
+
1090
+ `<script>` and `<style>` are parsed as raw-text. `<` and `>` inside them aren't tag starts. Holes interpolate verbatim (no HTML escaping).