@webjsdev/mcp 0.1.7 → 0.1.9
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 +1 -1
- package/package.json +4 -1
- package/resources/AGENTS.md +86 -72
- package/resources/SKILL.md +226 -0
- package/resources/references/auth-and-sessions.md +220 -0
- package/resources/references/built-ins.md +200 -0
- package/resources/references/client-router-and-streaming.md +204 -0
- package/resources/references/components.md +167 -0
- package/resources/references/data-and-actions.md +187 -0
- package/resources/references/muscle-memory-gotchas.md +170 -0
- package/resources/references/optimistic-ui.md +128 -0
- package/resources/references/routing-and-pages.md +158 -0
- package/resources/references/runtime.md +80 -0
- package/resources/references/service-worker.md +78 -0
- package/resources/references/styling.md +123 -0
- package/resources/references/testing.md +125 -0
- package/resources/references/typescript.md +148 -0
- package/src/mcp-docs.js +27 -14
- package/src/mcp-source.js +1 -1
- package/src/mcp.js +9 -47
- package/src/routes-report.js +90 -0
- package/resources/agent-docs/advanced.md +0 -1334
- package/resources/agent-docs/built-ins.md +0 -389
- package/resources/agent-docs/components-shadowing.md +0 -87
- package/resources/agent-docs/components.md +0 -734
- package/resources/agent-docs/configuration.md +0 -284
- package/resources/agent-docs/framework-dev.md +0 -73
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -470
- package/resources/agent-docs/metadata.md +0 -334
- package/resources/agent-docs/orm.md +0 -198
- package/resources/agent-docs/recipes.md +0 -664
- package/resources/agent-docs/runtime.md +0 -65
- package/resources/agent-docs/service-worker.md +0 -104
- package/resources/agent-docs/styling.md +0 -279
- package/resources/agent-docs/testing.md +0 -479
- package/resources/agent-docs/types-and-mutations.md +0 -171
- package/resources/agent-docs/typescript.md +0 -402
|
@@ -1,1334 +0,0 @@
|
|
|
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
|
-
### Component-level streaming: `<webjs-suspense>` (#471)
|
|
22
|
-
|
|
23
|
-
`Suspense({ fallback, children })` above is the page/region-level primitive (a promise passed as `children`). With **async render** (`agent-docs/components.md`), a COMPONENT is the suspending unit: a component doing `async render() { const u = await getUser(this.uid); … }` BLOCKS the first byte by default (real data in the first paint). To STREAM a slow component, wrap it in the renderer-recognized `<webjs-suspense>` element:
|
|
24
|
-
|
|
25
|
-
```js
|
|
26
|
-
html`
|
|
27
|
-
<webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
|
|
28
|
-
<user-profile uid="42"></user-profile>
|
|
29
|
-
<user-activity uid="42"></user-activity>
|
|
30
|
-
</webjs-suspense>
|
|
31
|
-
`;
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
`.fallback` is read at SSR as the inline placeholder (`render-server.js`'s `processSuspenseElements` carries it via `data-webjs-fallback`, since a `TemplateResult` is not serializer-safe) and flushed on the first byte; the children push to `ctx.pending` and stream in via the same `<template data-webjs-resolve>` engine. Multiple boundaries resolve via `Promise.all`, so they fetch concurrently (no server waterfall). One boundary groups several components under one fallback (the boundary `.fallback` wins over a contained component's `renderFallback()`), and a throwing component inside is isolated to its own error state while siblings stream. Without a streaming context (`renderToString`) the children render inline (blocking).
|
|
35
|
-
|
|
36
|
-
### Progressive soft-nav streaming (#473)
|
|
37
|
-
|
|
38
|
-
On a client-router navigation to a streamed page, the router applies the response PROGRESSIVELY: the SSR stream flushes the shell (with fallbacks) plus a `<!--wj-stream-shell-->` sentinel, the router's `readStreamedShell` swaps the shell in immediately and advances the URL, then `streamBoundariesProgressively` applies each resolved boundary into the live DOM as it streams (fast-before-slow), upgrading the custom elements inside. So a soft nav matches the initial-load experience instead of buffering the whole response. A non-streaming page is read to completion and applied once; a navigation superseded mid-stream stops and cancels the reader; a mid-stream transport failure leaves the applied boundaries in place (non-destructive).
|
|
39
|
-
|
|
40
|
-
## First-paint performance without a build step
|
|
41
|
-
|
|
42
|
-
Five stacked zero-build optimizations:
|
|
43
|
-
|
|
44
|
-
1. **`<link rel="modulepreload">` per used component + transitive deps + reached vendors.**
|
|
45
|
-
The SSR pass knows every custom element in the final HTML. A startup
|
|
46
|
-
module-graph scan adds their transitive import dependencies too, AND the
|
|
47
|
-
npm vendor URLs those shipped modules import (#754, flattening the
|
|
48
|
-
cross-origin CDN waterfall one level; see the no-build model below for the
|
|
49
|
-
shallow-dependency caveat). All preload hints are deduplicated and emitted
|
|
50
|
-
in `<head>`, vendor hints carrying their SRI `integrity` + `crossorigin`.
|
|
51
|
-
2. **HTTP/2 multiplex at the edge.** The production server (`npm run start`) speaks plain
|
|
52
|
-
HTTP/1.1. PaaS edges (Railway, Fly, Render, Vercel, Cloudflare Pages,
|
|
53
|
-
Heroku) and reverse proxies (nginx, Caddy, Traefik) speak
|
|
54
|
-
HTTP/2 to the browser and proxy 1.1 to the container, fetching many module
|
|
55
|
-
fetches in parallel over one TCP+TLS connection.
|
|
56
|
-
3. **103 Early Hints.** Before SSR starts computing the response, the
|
|
57
|
-
server sends `103 Interim Response` with the page's module URLs as
|
|
58
|
-
`rel=modulepreload`. Chrome/Edge and edge proxies (Cloudflare, fly-proxy,
|
|
59
|
-
Fastly) forward these.
|
|
60
|
-
4. **Lazy component loading (opt-in).** Components with `static lazy = true`
|
|
61
|
-
are excluded from modulepreload and loaded on-demand via
|
|
62
|
-
`IntersectionObserver` (200px root margin). The SSR-rendered DSD content
|
|
63
|
-
is visible immediately. `static hydrate = 'visible'` further defers
|
|
64
|
-
`connectedCallback` activation. Ideal for below-the-fold widgets.
|
|
65
|
-
5. **Auto-vendor via jspm.io (Rails 7 + importmap-rails posture).** At
|
|
66
|
-
startup the server scans client-reachable source for bare npm import
|
|
67
|
-
specifiers. The WHOLE scanned set is resolved in ONE
|
|
68
|
-
`api.jspm.io/generate` call (a single `install[]` array) so jspm
|
|
69
|
-
computes one mutually-consistent dependency graph: a directly-imported
|
|
70
|
-
package and a transitive that needs a newer version of the same package
|
|
71
|
-
agree on one CDN URL, instead of skewing (a direct dep pinned to its
|
|
72
|
-
local version while a transitive floats to jspm-latest, which crashed
|
|
73
|
-
the browser with a missing export). Each resolved entry is a CDN URL
|
|
74
|
-
(`https://ga.jspm.io/npm:<pkg>@<version>/...`) added to the import map;
|
|
75
|
-
the browser fetches each package directly from the CDN. If the unified
|
|
76
|
-
call fails because some install is unresolvable (a 401 for a private or
|
|
77
|
-
server-only dep), the resolver falls back to per-package isolation for
|
|
78
|
-
the offending install(s) and re-resolves the resolvable survivors as one
|
|
79
|
-
graph, so one bad dep can never collapse the whole map.
|
|
80
|
-
**SRI integrity (SHA-384) is computed on BOTH paths.** A
|
|
81
|
-
live-resolved (unpinned) app hashes each cross-origin bundle at warmup
|
|
82
|
-
and emits `integrity` + `crossorigin` on the importmap and modulepreload
|
|
83
|
-
tags, so a swapped or compromised CDN response is rejected by the browser
|
|
84
|
-
even with no pin file (#235). The hashing is bounded (parallel fetches
|
|
85
|
-
with a small concurrency cap and a per-fetch timeout) and FAIL-OPEN: a
|
|
86
|
-
bundle fetch failure skips that one URL's integrity (it loads without SRI,
|
|
87
|
-
the same as before) and emits a one-time warning, so a CDN hiccup never
|
|
88
|
-
takes the app down. Added warmup cost is one HEAD-like GET per distinct
|
|
89
|
-
cross-origin bundle, cached per process by URL so a re-resolve does not
|
|
90
|
-
re-fetch. `webjs vendor pin` still commits the resolved URLs + integrity
|
|
91
|
-
hashes to `.webjs/vendor/importmap.json` for reproducible deploys (and a
|
|
92
|
-
stable boot-time build id with no warmup fetch); `webjs vendor pin
|
|
93
|
-
--download` also caches the bundle bytes locally under
|
|
94
|
-
`.webjs/vendor/<pkg>@<version>.js` for air-gapped / strict-CSP
|
|
95
|
-
deployments. No bundler runs at any point. **`webjs doctor` validates
|
|
96
|
-
importmap coherence** (#450): for each resolved package it checks that the
|
|
97
|
-
version pinned for every OTHER resolved package it depends on satisfies the
|
|
98
|
-
declared dependency / peer range, and WARNS naming both packages, the range,
|
|
99
|
-
and the pinned version when they skew (the class of bug where a pinned
|
|
100
|
-
package needs a newer minor of another pinned package than is pinned, so a
|
|
101
|
-
symbol it expects is missing at runtime). It is a validation over the
|
|
102
|
-
produced importmap, not a re-resolution and not bundling, and it runs the
|
|
103
|
-
SAME check with the SAME verdict over the live importmap and the vendored
|
|
104
|
-
`.webjs/vendor/importmap.json` (vendoring freezes the runtime-resolved
|
|
105
|
-
graph, so a coherent live graph stays coherent vendored). It reads
|
|
106
|
-
dependency metadata from the installed `node_modules` (no network of its
|
|
107
|
-
own) and degrades to "could not verify" when a manifest is unavailable.
|
|
108
|
-
|
|
109
|
-
## No-build production model
|
|
110
|
-
|
|
111
|
-
webjs has no bundler and no `webjs build` step. The same `.js` / `.ts`
|
|
112
|
-
source files that run in dev are served as-is to the browser in
|
|
113
|
-
production. The Rails 7+ / Hotwire pattern:
|
|
114
|
-
|
|
115
|
-
- **Importmap-driven**: bare-specifier imports (`from "react"`) are
|
|
116
|
-
resolved via `<script type="importmap">` emitted into the document
|
|
117
|
-
head. By default the whole scanned set resolves through one
|
|
118
|
-
`api.jspm.io/generate` call (one consistent graph, see point 5 above)
|
|
119
|
-
to `https://ga.jspm.io/npm:<pkg>@<version>/...` URLs and the browser
|
|
120
|
-
fetches them from the CDN directly, with an `integrity` SRI hash on every
|
|
121
|
-
cross-origin entry (computed live at warmup for an unpinned app, or read
|
|
122
|
-
from the pin file for a pinned one). `webjs vendor pin` commits the
|
|
123
|
-
resolved URLs + SHA-384 integrity hashes to `.webjs/vendor/importmap.json`
|
|
124
|
-
for reproducible deploys; `webjs vendor pin --download` additionally
|
|
125
|
-
caches each bundle to `.webjs/vendor/<pkg>@<version>.js` and rewrites
|
|
126
|
-
the importmap to `/__webjs/vendor/<pkg>@<version>.js` so the server
|
|
127
|
-
serves the bytes from disk (air-gapped / strict-CSP path). No bundler
|
|
128
|
-
runs at any point. Because the pins are meant to be committed,
|
|
129
|
-
`webjs vendor pin` makes its output committable: the scaffold already
|
|
130
|
-
un-ignores `.webjs/vendor/`, and if a `.gitignore` would swallow the
|
|
131
|
-
pins it heals it (or prints how to commit them when the ignore is not
|
|
132
|
-
in the app's own `.gitignore`). A no-vendor app, which never pins, is
|
|
133
|
-
unaffected.
|
|
134
|
-
- **Per-file ESM serving**: every app `.js` / `.ts` becomes its own HTTP
|
|
135
|
-
resource. The browser walks the import graph and fetches each module
|
|
136
|
-
on demand.
|
|
137
|
-
- **`<link rel="modulepreload">` hints at SSR time**: for every component
|
|
138
|
-
the route uses + its transitive deps from the module graph, AND for the
|
|
139
|
-
npm **vendor** URLs those shipped modules reach (#754). The browser
|
|
140
|
-
parallelizes fetches instead of waterfalling through nested imports. This
|
|
141
|
-
is what closes most of the perceived gap vs a bundle. **The honest caveat:**
|
|
142
|
-
a bundle still wins on a DEEP vendor tree. webjs flattens the FIRST level
|
|
143
|
-
(the vendor entries your code imports are hinted up front, with their SRI
|
|
144
|
-
`integrity`, byte-identical to the importmap target so there is no double
|
|
145
|
-
fetch), but a vendor's OWN transitive deps are still discovered by parsing
|
|
146
|
-
each fetched CDN module in turn, level by level, over the cross-origin CDN
|
|
147
|
-
connection. So the complementary mitigation is **shallow-dependency
|
|
148
|
-
discipline**: prefer few, shallow ESM dependencies (a library with a flat
|
|
149
|
-
or one-level graph fully benefits; a deep tree still waterfalls past level
|
|
150
|
-
one). Only REACHED vendors are hinted: a vendor imported solely by an
|
|
151
|
-
elided display-only component, by a page/layout module dropped from the boot
|
|
152
|
-
(an inert or import-only page whose binding vendor import is used only during
|
|
153
|
-
SSR, the canonical SSR-only-dependency case), by a `.server.*` file, or
|
|
154
|
-
pinned-but-unimported, is never preloaded (no over-fetch).
|
|
155
|
-
- **HTTP/2 multiplex** is what makes per-file serving competitive: one
|
|
156
|
-
TCP+TLS handshake, many module fetches in parallel over the same
|
|
157
|
-
connection. The production server (`npm run start`) speaks plain HTTP/1.1.
|
|
158
|
-
TLS + HTTP/2 is the proxy's job. PaaS edges (Railway, Fly, Render, Vercel,
|
|
159
|
-
Cloudflare Pages, Heroku) do this automatically. For bare-VM
|
|
160
|
-
deploys, put nginx, Caddy, or Traefik in front.
|
|
161
|
-
|
|
162
|
-
Content-hashed cache-busting and granular cache invalidation come from
|
|
163
|
-
the same per-file model: edit one file, only that file's URL hash
|
|
164
|
-
changes, only that one re-downloads.
|
|
165
|
-
|
|
166
|
-
## Content-hash asset caching: `?v=<digest>` immutable URLs (prod) (#243)
|
|
167
|
-
|
|
168
|
-
In PRODUCTION the framework appends a per-file content hash to every
|
|
169
|
-
SAME-ORIGIN asset URL it emits (the importmap targets, the
|
|
170
|
-
`<link rel="modulepreload">` hrefs, the boot script's module specifiers).
|
|
171
|
-
The hash is a short prefix of a sha-256 over the file's BYTES, computed at
|
|
172
|
-
serve time (no build step) and memoized. A request whose URL carries that
|
|
173
|
-
`?v=<digest>` query is served `Cache-Control: public, max-age=31536000,
|
|
174
|
-
immutable` instead of the 1-hour fallback, so a browser / CDN holds it for
|
|
175
|
-
a year without revalidating.
|
|
176
|
-
|
|
177
|
-
This is safe precisely because the hash IS the version: a deploy that
|
|
178
|
-
changes a module's bytes changes its hash, so its emitted URL changes, so a
|
|
179
|
-
returning client fetches the NEW URL rather than serving a stale immutable
|
|
180
|
-
copy. The framework's own `@webjsdev/core` runtime (`/__webjs/core/*`) is
|
|
181
|
-
fingerprinted too, which fixes the exact regression an un-versioned
|
|
182
|
-
`immutable` would otherwise cause (a year-pinned old core renderer running
|
|
183
|
-
against a server emitting the new SSR shape after a version bump).
|
|
184
|
-
|
|
185
|
-
- **Per-file digest, not the build id.** The importmap build id
|
|
186
|
-
(`data-webjs-build`) does not change on an app-module byte change, so it
|
|
187
|
-
cannot be the per-asset fingerprint; each file carries its own hash.
|
|
188
|
-
- **Cross-origin URLs are NEVER fingerprinted.** A `https://ga.jspm.io/...`
|
|
189
|
-
vendor target keeps its exact URL: jspm already versions it, and #235's
|
|
190
|
-
SRI integrity is keyed by the un-hashed cross-origin URL. A downloaded
|
|
191
|
-
`/__webjs/vendor/<pkg>@<ver>.js` bundle is already version-named, so it is
|
|
192
|
-
left unchanged too.
|
|
193
|
-
- **Composes with `webjs.basePath` (#256).** A sub-path deploy emits
|
|
194
|
-
`<basePath>/app/foo.js?v=<digest>`: the base path is prefixed first, the
|
|
195
|
-
`?v` query appended after, and the ingress base-path strip never touches a
|
|
196
|
-
query.
|
|
197
|
-
- **DEV is byte-identical to before.** Fingerprinting is enabled only in
|
|
198
|
-
`webjs start` (prod). `webjs dev` emits no `?v` and serves every module
|
|
199
|
-
`no-cache`, so the dev wire is unchanged.
|
|
200
|
-
- **Un-fingerprinted requests keep the 1-hour fallback.** Only the presence
|
|
201
|
-
of a `?v` query flips the cache header; a hand-typed bare URL still
|
|
202
|
-
resolves and serves `public, max-age=3600`.
|
|
203
|
-
|
|
204
|
-
## Connection-warming hints: `preconnect` / `dnsPrefetch` + auto vendor preconnect (#243)
|
|
205
|
-
|
|
206
|
-
A page can warm a cross-origin connection it is about to use (an API host,
|
|
207
|
-
a font / image CDN) by declaring it in `metadata`:
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
export const metadata = {
|
|
211
|
-
preconnect: ['https://api.example.com', { url: 'https://fonts.gstatic.com', crossorigin: true }],
|
|
212
|
-
dnsPrefetch: 'https://analytics.example.com',
|
|
213
|
-
};
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
Each emits a head hint: `<link rel="preconnect" href="..." [crossorigin]>`
|
|
217
|
-
(warms DNS + TLS + TCP) and `<link rel="dns-prefetch" href="...">` (DNS
|
|
218
|
-
only). Each field takes a URL string, `{ url, crossorigin? }`, or an array;
|
|
219
|
-
hrefs are HTML-escaped. See `agent-docs/metadata.md`.
|
|
220
|
-
|
|
221
|
-
**Auto vendor preconnect.** For an UNPINNED app resolving vendors live from
|
|
222
|
-
a cross-origin CDN, the framework auto-emits ONE
|
|
223
|
-
`<link rel="preconnect" href="<cdn-origin>" crossorigin>` (the resolved
|
|
224
|
-
vendor CDN origin, e.g. `https://ga.jspm.io`, derived from the importmap, so
|
|
225
|
-
a `--from jsdelivr` app preconnects to jsdelivr) so the browser warms that
|
|
226
|
-
connection before the importmap resolves. It is DEDUPED against an
|
|
227
|
-
author-declared preconnect to the same origin, and emits NOTHING for a
|
|
228
|
-
same-origin pinned app (vendors served from the app's own origin) or an app
|
|
229
|
-
with no cross-origin vendors.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
## Rate limiting via `rateLimit()`
|
|
233
|
-
|
|
234
|
-
Fixed-window limiter shaped as middleware. Place in `middleware.ts` at
|
|
235
|
-
whatever level you want to protect:
|
|
236
|
-
|
|
237
|
-
```js
|
|
238
|
-
// app/api/auth/middleware.ts: protect login/signup from brute force
|
|
239
|
-
import { rateLimit } from '@webjsdev/server';
|
|
240
|
-
export default rateLimit({ window: '10s', max: 5 });
|
|
241
|
-
|
|
242
|
-
// Custom key: rate limit per authenticated user instead of IP
|
|
243
|
-
export default rateLimit({
|
|
244
|
-
window: '1m', max: 30,
|
|
245
|
-
key: async (req) => {
|
|
246
|
-
const session = await auth(req);
|
|
247
|
-
return session?.user?.id ?? 'anon';
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
**Options:** `window` (`'10s'`, `'1m'`, `'1h'`, ms), `max` (default 60),
|
|
253
|
-
`key` (string prefix or `(req) => string`, defaulting to the
|
|
254
|
-
framework-stamped client IP from the TCP socket), `message`, `store`,
|
|
255
|
-
`trustProxy` (default `false`).
|
|
256
|
-
|
|
257
|
-
**`trustProxy`** controls how the default key is derived:
|
|
258
|
-
|
|
259
|
-
- **`false` (default, safe):** key on the framework-stamped
|
|
260
|
-
`x-webjs-remote-ip` header, which `startServer` sets from the
|
|
261
|
-
underlying TCP socket on every request (and strips any inbound
|
|
262
|
-
copy of, so clients cannot spoof it). Forwarded-IP headers like
|
|
263
|
-
`x-forwarded-for`, `cf-connecting-ip`, `x-real-ip` are ignored.
|
|
264
|
-
Correct for direct deployments (bare Node, no proxy in front).
|
|
265
|
-
- **`true`:** honour forwarded-IP headers, preferring the leftmost
|
|
266
|
-
entry of `x-forwarded-for`, then `cf-connecting-ip`, then
|
|
267
|
-
`x-real-ip`. Production deploys MUST run behind a reverse proxy
|
|
268
|
-
(nginx, Caddy, Cloudflare, Fly, Railway, Render edge) that STRIPS
|
|
269
|
-
inbound `x-forwarded-for` before adding its own. If the proxy
|
|
270
|
-
doesn't strip, the option reintroduces the per-request bucket
|
|
271
|
-
rotation it exists to defend against.
|
|
272
|
-
|
|
273
|
-
```js
|
|
274
|
-
// Direct deploy (default): safe, ignores spoofable forwarded headers.
|
|
275
|
-
export default rateLimit({ window: '10s', max: 5 });
|
|
276
|
-
|
|
277
|
-
// Behind a trusted reverse proxy: opt in, MUST strip inbound XFF.
|
|
278
|
-
export default rateLimit({ window: '10s', max: 5, trustProxy: true });
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
The `clientIp(req, { trustProxy })` helper is exported separately so
|
|
282
|
-
custom `key` functions can reuse the same resolution:
|
|
283
|
-
|
|
284
|
-
```js
|
|
285
|
-
import { rateLimit, clientIp } from '@webjsdev/server';
|
|
286
|
-
export default rateLimit({
|
|
287
|
-
window: '1m', max: 30,
|
|
288
|
-
key: (req) => `${req.headers.get('x-tenant') || 'global'}:${clientIp(req, { trustProxy: true })}`,
|
|
289
|
-
});
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
**Embedded use** (`createRequestHandler` running under Express / Bun /
|
|
293
|
-
Deno / edge adapters): the host adapter is responsible for stripping
|
|
294
|
-
any inbound `x-webjs-remote-ip` from the wire AND stamping its own
|
|
295
|
-
from the trusted socket address, otherwise a malicious client forges
|
|
296
|
-
the header and the framework trusts it. Call the exported helper:
|
|
297
|
-
|
|
298
|
-
```js
|
|
299
|
-
import { createRequestHandler, stampRemoteIp } from '@webjsdev/server';
|
|
300
|
-
const handler = await createRequestHandler({ appDir });
|
|
301
|
-
|
|
302
|
-
// Inside the host adapter's per-request callback. `nodeReq` is the
|
|
303
|
-
// host's request object (Express req, Node IncomingMessage, etc.).
|
|
304
|
-
// `webReq` is whatever Request you constructed from the wire (URL,
|
|
305
|
-
// method, headers, body); building that is host-specific. The
|
|
306
|
-
// security-relevant line is the one that wraps it in stampRemoteIp:
|
|
307
|
-
const safe = stampRemoteIp(webReq, nodeReq.socket.remoteAddress);
|
|
308
|
-
const webRes = await handler.handle(safe);
|
|
309
|
-
// Pipe webRes back through the host's response API.
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
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.
|
|
313
|
-
|
|
314
|
-
If the adapter cannot expose a trusted socket address, pass a custom
|
|
315
|
-
`key` function to `rateLimit()` that reads whatever client identifier
|
|
316
|
-
the host actually provides. The default rate-limit collapsing to
|
|
317
|
-
`_anon_` is preferable to silently trusting wire-set headers.
|
|
318
|
-
|
|
319
|
-
**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`.
|
|
320
|
-
|
|
321
|
-
**Scaling:** in-memory by default. `setStore(redisStore({ url: process.env.REDIS_URL }))` shares limits across instances.
|
|
322
|
-
|
|
323
|
-
## Sub-path deployment: `webjs.basePath` (#256)
|
|
324
|
-
|
|
325
|
-
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`:
|
|
326
|
-
|
|
327
|
-
```jsonc
|
|
328
|
-
{ "webjs": { "basePath": "/app" } }
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
`'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.
|
|
332
|
-
|
|
333
|
-
**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.
|
|
334
|
-
|
|
335
|
-
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.
|
|
336
|
-
|
|
337
|
-
**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.
|
|
338
|
-
|
|
339
|
-
## CORS via `cors()`
|
|
340
|
-
|
|
341
|
-
`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.
|
|
342
|
-
|
|
343
|
-
```js
|
|
344
|
-
// middleware.js (applies to every request)
|
|
345
|
-
import { cors } from '@webjsdev/server';
|
|
346
|
-
export default cors({
|
|
347
|
-
origin: ['https://app.example.com', 'http://localhost:3000'],
|
|
348
|
-
credentials: true,
|
|
349
|
-
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
350
|
-
allowedHeaders: ['content-type', 'authorization'],
|
|
351
|
-
maxAge: 86400,
|
|
352
|
-
});
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
Wrap a single handler instead of going app-wide:
|
|
356
|
-
|
|
357
|
-
```js
|
|
358
|
-
// app/api/widgets/route.js
|
|
359
|
-
import { cors } from '@webjsdev/server';
|
|
360
|
-
const corsMw = cors({ origin: '*' });
|
|
361
|
-
export async function GET(req) {
|
|
362
|
-
return corsMw(req, async () => Response.json({ widgets: [] }));
|
|
363
|
-
}
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
### Options
|
|
367
|
-
|
|
368
|
-
| Option | Meaning |
|
|
369
|
-
|---|---|
|
|
370
|
-
| `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 `'*'`. |
|
|
371
|
-
| `credentials` | Sets `Access-Control-Allow-Credentials: true` for an allowed specific origin. |
|
|
372
|
-
| `methods` | Advertised on a preflight (`Access-Control-Allow-Methods`). |
|
|
373
|
-
| `allowedHeaders` | Advertised on a preflight; defaults to reflecting `Access-Control-Request-Headers`. |
|
|
374
|
-
| `exposedHeaders` | `Access-Control-Expose-Headers` on the actual response. |
|
|
375
|
-
| `maxAge` | Preflight cache lifetime in seconds. |
|
|
376
|
-
|
|
377
|
-
### Behavior
|
|
378
|
-
|
|
379
|
-
- **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.
|
|
380
|
-
- **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, so a mismatched actual request is never 403'd server-side).
|
|
381
|
-
- **`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.
|
|
382
|
-
|
|
383
|
-
### `credentials: true` requires an explicit origin allowlist (spec, enforced, warned)
|
|
384
|
-
|
|
385
|
-
**For any credentialed endpoint, pass an explicit `origin` allowlist (string / array / RegExp / function). Never combine `credentials: true` with a wildcard `origin` (`'*'` / `true`).**
|
|
386
|
-
|
|
387
|
-
`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.
|
|
388
|
-
|
|
389
|
-
`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):
|
|
390
|
-
|
|
391
|
-
```
|
|
392
|
-
cors(): credentials with a wildcard origin reflects ANY origin with credentials.
|
|
393
|
-
Use an explicit origin allowlist for credentialed requests.
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
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.
|
|
397
|
-
|
|
398
|
-
## Client router: nested-layout-aware partial swap
|
|
399
|
-
|
|
400
|
-
The client router auto-enables when `@webjsdev/core` loads in the browser (any page that ships a component), no import needed (#620). It gives SPA-style navigation that
|
|
401
|
-
preserves outer-layout DOM identity at any depth. Intercepts same-origin
|
|
402
|
-
`<a>` clicks (incl. inside shadow DOM via `composedPath()`), fetches the
|
|
403
|
-
target HTML, and replaces only the inside of the deepest shared layout.
|
|
404
|
-
Outer header / sidenav / footer DOM nodes are never re-rendered, so
|
|
405
|
-
scroll positions, input values, and `<details>` open state survive
|
|
406
|
-
navigation automatically.
|
|
407
|
-
|
|
408
|
-
### How it works
|
|
409
|
-
|
|
410
|
-
1. SSR injects `<!--wj:children:<segment-path>-->...<!--/wj:children-->`
|
|
411
|
-
comment markers around each layout's `${children}` interpolation,
|
|
412
|
-
one pair per layout in the chain. Auto-derived from folder
|
|
413
|
-
structure. Layout authors write nothing extra.
|
|
414
|
-
2. On click, the router walks both the live DOM and the incoming HTML
|
|
415
|
-
for these markers and builds `Map<path, {start, end}>`.
|
|
416
|
-
3. Picks the **longest shared path**, the deepest layout boundary
|
|
417
|
-
both pages have in common.
|
|
418
|
-
4. Replaces nodes between that marker pair using a keyed `data-key`
|
|
419
|
-
reconciler. Elements with matching tag + matching key are reused
|
|
420
|
-
with in-place attribute diffing. **Live attributes** (`value`,
|
|
421
|
-
`checked`, `selected`, `indeterminate`, `disabled`, `open`,
|
|
422
|
-
`popover`) are NEVER overwritten by the server HTML.
|
|
423
|
-
5. Merges `<head>` (add-only on partial swaps so runtime-injected
|
|
424
|
-
styles like Tailwind survive, with a full merge on the
|
|
425
|
-
root-layout-change fallback), re-runs `<script>` elements,
|
|
426
|
-
`customElements.upgrade()`s the swapped subtree, `pushState`s the
|
|
427
|
-
URL, scrolls.
|
|
428
|
-
6. Dispatches `webjs:navigate` event on `document`.
|
|
429
|
-
|
|
430
|
-
### In-place navigation-error recovery (`webjs:navigation-error`)
|
|
431
|
-
|
|
432
|
-
A successful swap (2xx/3xx) applies in place, and an HTML error body of any
|
|
433
|
-
status (a 4xx/5xx page, e.g. a `422` re-rendered form) is ALSO applied in
|
|
434
|
-
place. The remaining failure cases are a **non-HTML error response** (a
|
|
435
|
-
`500` carrying a JSON body) and a **transport/parse failure** (the `fetch`
|
|
436
|
-
rejected, or the body claimed HTML but did not parse). For those the router
|
|
437
|
-
no longer abandons the SPA with a destructive full `location.href` reload
|
|
438
|
-
(which would discard the partial-swap shell, scroll, focus, and in-flight
|
|
439
|
-
client state, and eat a second round-trip that may itself fail to the
|
|
440
|
-
browser's default error page).
|
|
441
|
-
|
|
442
|
-
Instead the router dispatches a cancelable, bubbling
|
|
443
|
-
`webjs:navigation-error` CustomEvent on `document`, with detail
|
|
444
|
-
`{ url, status, error }`: `status` is the HTTP status when a response
|
|
445
|
-
arrived (else `null`), and `error` is the `Error` for a transport/parse
|
|
446
|
-
failure (else `null`).
|
|
447
|
-
|
|
448
|
-
- **`preventDefault()` hands recovery to the app.** The router does NOTHING
|
|
449
|
-
further: the current page is left exactly as it is (shell, scroll, focus,
|
|
450
|
-
and client state all preserved), so the app can show its own toast, retry,
|
|
451
|
-
or navigate elsewhere.
|
|
452
|
-
- **Not cancelled (the default)** renders a MINIMAL in-place error surface,
|
|
453
|
-
a `<div role="alert" data-webjs-nav-error>` carrying a generic message
|
|
454
|
-
plus the status, into the deepest layout children slot (the same target a
|
|
455
|
-
normal partial swap writes to, so outer chrome and nav are preserved). No
|
|
456
|
-
full reload, the shell survives, and the user sees the failure.
|
|
457
|
-
- **Last-resort hard load** happens only when there is NO shared layout
|
|
458
|
-
marker to render into (a genuine cross-document nav), and only after the
|
|
459
|
-
event was not cancelled, so a truly unrecoverable case is not a silent
|
|
460
|
-
dead-end. This is the exception, not the default.
|
|
461
|
-
|
|
462
|
-
An **AbortError** (a newer navigation superseding this one) is a normal
|
|
463
|
-
supersede, NOT an error, and never dispatches `webjs:navigation-error`.
|
|
464
|
-
|
|
465
|
-
```ts
|
|
466
|
-
document.addEventListener('webjs:navigation-error', (e) => {
|
|
467
|
-
// e.detail = { url, status, error }
|
|
468
|
-
e.preventDefault(); // app handles recovery; page left intact
|
|
469
|
-
showToast(`Could not load ${e.detail.url} (status ${e.detail.status})`);
|
|
470
|
-
});
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
### Form submission state (`webjs:submit-start` / `webjs:submit-end` + `aria-busy`)
|
|
474
|
-
|
|
475
|
-
When a `<form>` submits through the JS-enhanced router, the form gets a
|
|
476
|
-
submission lifecycle a component can read to disable the submit button, show a
|
|
477
|
-
spinner, or set a pending style:
|
|
478
|
-
|
|
479
|
-
- The router sets the native `aria-busy="true"` on the form for the in-flight
|
|
480
|
-
duration (cleared on settle). This IS the readable "is this form submitting"
|
|
481
|
-
primitive: any component can poll `form.getAttribute('aria-busy')` or style
|
|
482
|
-
`form[aria-busy="true"]` in CSS.
|
|
483
|
-
- It dispatches a bubbling `webjs:submit-start` (detail `{ form, url }`) when the
|
|
484
|
-
submission fetch starts, and `webjs:submit-end` (detail `{ form, url, ok }`,
|
|
485
|
-
`ok` is whether the submission settled as a success) on EVERY settle (success,
|
|
486
|
-
a 4xx/5xx validation re-render, a navigation error, or an abort by a
|
|
487
|
-
superseding submit). The pair is balanced even under a rapid re-submit (a
|
|
488
|
-
nav-token guard keeps a superseded submit's teardown from clearing the busy
|
|
489
|
-
state a newer submit set, the same guard `<webjs-frame>` uses).
|
|
490
|
-
|
|
491
|
-
```ts
|
|
492
|
-
// A submit button that disables itself while its form is submitting.
|
|
493
|
-
form.addEventListener('webjs:submit-start', () => { button.disabled = true; });
|
|
494
|
-
form.addEventListener('webjs:submit-end', (e) => {
|
|
495
|
-
button.disabled = false; // e.detail = { form, url, ok }
|
|
496
|
-
});
|
|
497
|
-
/* or purely in CSS, no JS: */
|
|
498
|
-
/* form[aria-busy="true"] button[type="submit"] { opacity: .5; pointer-events: none; } */
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
Progressive enhancement is unaffected: with JS off the form is a normal POST;
|
|
502
|
-
the events + `aria-busy` are a client-only enhancement.
|
|
503
|
-
|
|
504
|
-
### Optimistic mutations (`optimistic()`)
|
|
505
|
-
|
|
506
|
-
`optimistic()` from `@webjsdev/core` shows a mutation's expected result IMMEDIATELY (the UI feels instant), runs the real server action, and ROLLS BACK on failure. **Use it for every user-facing mutation where the client can predict the result** (create, update, delete, like, toggle, reorder). See `agent-docs/recipes.md` for complete examples.
|
|
507
|
-
|
|
508
|
-
#### Declarative API (preferred)
|
|
509
|
-
|
|
510
|
-
`optimistic(host, { source, update })` returns an `OptimisticState<State, Action>` with a `.value` getter and `.add(payload, promise?)` method. The reducer transforms the current state with each payload; `.add()` pushes an update and schedules a re-render. When a `promise` is passed, the update auto-releases on settlement (`.finally()` or a `.then()` fallback for thenables lacking `.finally`).
|
|
511
|
-
|
|
512
|
-
```ts
|
|
513
|
-
import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
|
|
514
|
-
import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
|
|
515
|
-
|
|
516
|
-
class TodoList extends WebComponent({
|
|
517
|
-
todos: prop<Todo[]>(Array),
|
|
518
|
-
}) {
|
|
519
|
-
private optimisticTodos = optimistic(this, {
|
|
520
|
-
source: () => this.todos,
|
|
521
|
-
update: (state, title: string) => [
|
|
522
|
-
...state,
|
|
523
|
-
{ id: crypto.randomUUID() as any, title, completed: false, pending: true },
|
|
524
|
-
],
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
async handleSubmit(e: SubmitEvent) {
|
|
528
|
-
e.preventDefault();
|
|
529
|
-
const title = new FormData(e.target as HTMLFormElement).get('title') as string;
|
|
530
|
-
if (!title) return;
|
|
531
|
-
(e.target as HTMLFormElement).reset();
|
|
532
|
-
|
|
533
|
-
const promise = createTodo({ title });
|
|
534
|
-
this.optimisticTodos.add(title, promise);
|
|
535
|
-
|
|
536
|
-
const result = await promise;
|
|
537
|
-
if (result.success && result.data) {
|
|
538
|
-
this.todos = [...this.todos.filter(t => t.pending), result.data, ...this.todos.filter(t => !t.pending)];
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
render() {
|
|
543
|
-
return html`<ul>${this.optimisticTodos.value.map(todo => html`
|
|
544
|
-
<li class=${todo.pending ? 'opacity-50' : ''}>${todo.title}</li>
|
|
545
|
-
`)}</ul>`;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
TodoList.register('todo-list');
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
Multiple `.add()` calls stack independently; each `release()` removes its own update by ID. When `update` is omitted, the payload replaces the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
|
|
552
|
-
|
|
553
|
-
#### Imperative API (simple flips)
|
|
554
|
-
|
|
555
|
-
`optimistic(signal, value, action)` is a thin wrapper over the signal primitive for simple boolean flips:
|
|
556
|
-
|
|
557
|
-
```ts
|
|
558
|
-
import { signal, optimistic } from '@webjsdev/core';
|
|
559
|
-
import { likePost } from '../actions/like-post.server.js';
|
|
560
|
-
|
|
561
|
-
const liked = signal(false);
|
|
562
|
-
// in an @click handler:
|
|
563
|
-
const result = await optimistic(liked, true, () => likePost(postId));
|
|
564
|
-
// `liked` flips to true instantly. If likePost THROWS or returns
|
|
565
|
-
// { success: false }, `liked` rolls back to its prior value; the throw
|
|
566
|
-
// re-throws and the { success: false } result is returned (read its
|
|
567
|
-
// error / fieldErrors). On success the optimistic value stays; reconcile
|
|
568
|
-
// to the authoritative value from `result` if you need it.
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. Client-only (it mutates a signal), so a component importing it is never elided as display-only.
|
|
572
|
-
|
|
573
|
-
### Wire-byte optimization
|
|
574
|
-
|
|
575
|
-
The router sends `X-Webjs-Have: <paths>` listing the marker paths it
|
|
576
|
-
already has rendered. The server walks the target route's layout chain
|
|
577
|
-
innermost → outermost and **short-circuits at the first match**. The
|
|
578
|
-
inner tree is wrapped in that layout's marker pair and returned. Outer
|
|
579
|
-
layouts are not loaded, not rendered, not re-serialized. Real savings
|
|
580
|
-
on every same-shell navigation.
|
|
581
|
-
|
|
582
|
-
### Cross-deploy hard-reload signals
|
|
583
|
-
|
|
584
|
-
Two complementary mechanisms tell the client when a partial swap is
|
|
585
|
-
unsafe and a hard reload is required:
|
|
586
|
-
|
|
587
|
-
1. **Importmap drift** (the common case after a vendor pin change).
|
|
588
|
-
Server stamps the PUBLISHED build id on `<script type="importmap"
|
|
589
|
-
data-webjs-build="…">` AND emits the same value as `X-Webjs-Build`
|
|
590
|
-
on every response, including X-Webjs-Have partial responses with no
|
|
591
|
-
head. The published id is the importmap hash, but advertised only
|
|
592
|
-
once the importmap is authoritatively final (at boot for a pinned
|
|
593
|
-
app, after the first successful vendor resolve otherwise); while the
|
|
594
|
-
map is still warming it stays empty. Client compares the response
|
|
595
|
-
header against the live document's `data-webjs-build`. A hard reload
|
|
596
|
-
(`location.href = target`) fires only when both ids are present and
|
|
597
|
-
differ (a real cross-deploy). An empty id on either side means
|
|
598
|
-
"version unknown" (a warming runtime-first-boot server) and never
|
|
599
|
-
reloads, so the warmup window cannot hard-reload and wipe a
|
|
600
|
-
half-filled form. Works for every nav, including partial-response navs.
|
|
601
|
-
|
|
602
|
-
2. **Generic `data-webjs-track="reload"`** (for non-importmap concerns,
|
|
603
|
-
e.g. a CSS bundle hash, a build-id meta tag). Any head element with
|
|
604
|
-
the attribute joins a signature computed from concatenated outerHTML.
|
|
605
|
-
On nav, mismatched signatures trigger reload. Mirrors hotwired/turbo's
|
|
606
|
-
`data-turbo-track="reload"`.
|
|
607
|
-
|
|
608
|
-
```html
|
|
609
|
-
<link rel="stylesheet" href="/build/main-abc123.css" data-webjs-track="reload">
|
|
610
|
-
<meta name="build-id" content="rev-42" data-webjs-track="reload">
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
Both paths share a one-shot `sessionStorage` reload guard so a
|
|
614
|
-
genuinely-churning resource doesn't loop reloads.
|
|
615
|
-
|
|
616
|
-
### Snapshot cache + revalidation
|
|
617
|
-
|
|
618
|
-
URL-keyed `Map<url, snapshot>` (LRU, cap 16). Back/forward via
|
|
619
|
-
popstate restores from cache instantly, then refetches in the
|
|
620
|
-
background.
|
|
621
|
-
|
|
622
|
-
```js
|
|
623
|
-
import { revalidate } from '@webjsdev/core';
|
|
624
|
-
revalidate('/products/123'); // evict one URL
|
|
625
|
-
revalidate(); // clear the entire cache
|
|
626
|
-
```
|
|
627
|
-
|
|
628
|
-
Call `revalidate(path)` after a server action mutates data that
|
|
629
|
-
affects a cached page.
|
|
630
|
-
|
|
631
|
-
### Link prefetch
|
|
632
|
-
|
|
633
|
-
Same-origin in-app links are prefetched speculatively so a click
|
|
634
|
-
resolves from a warm cache with no round-trip. On by default (no per-link
|
|
635
|
-
opt-in needed), the way Next / Nuxt / SvelteKit ship auto-prefetch. The
|
|
636
|
-
prefetch request carries the same `X-Webjs-Have` header a real navigation
|
|
637
|
-
sends, so the server returns the same divergent fragment; that fragment
|
|
638
|
-
lands in a dedicated prefetch cache (separate from the back/forward
|
|
639
|
-
snapshot cache) and `fetchAndApply` consumes it via `prefetchTake` before
|
|
640
|
-
falling back to the network.
|
|
641
|
-
|
|
642
|
-
The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve
|
|
643
|
-
both input modalities. On a hover-capable fine pointer (mouse / trackpad)
|
|
644
|
-
the default is `intent` (warm on hover / focus, a real head-start before
|
|
645
|
-
the click). On touch the default is `viewport` (warm as links settle
|
|
646
|
-
on-screen), because a touch device has no hover and `touchstart` fires at
|
|
647
|
-
tap time, too late to front-run the navigation. The modality is detected
|
|
648
|
-
with `matchMedia('(hover: hover) and (pointer: fine)')`, not a user-agent
|
|
649
|
-
sniff. A per-link `data-prefetch` always overrides the adaptive default.
|
|
650
|
-
|
|
651
|
-
Per link, set `data-prefetch` (a valid-HTML `data-*` attribute, the shape
|
|
652
|
-
SvelteKit and Astro use; Next / Nuxt / Remix use a component prop, which
|
|
653
|
-
webjs has no equivalent for since links are plain `<a href>`):
|
|
654
|
-
|
|
655
|
-
```html
|
|
656
|
-
<a href="/dashboard">adaptive: intent on pointer, viewport on touch (default)</a>
|
|
657
|
-
<a href="/dashboard" data-prefetch="intent">hover / focus / touch</a>
|
|
658
|
-
<a href="/dashboard" data-prefetch="render">eager on insert</a>
|
|
659
|
-
<a href="/dashboard" data-prefetch="viewport">on scroll-into-view</a>
|
|
660
|
-
<a href="/dashboard" data-prefetch="none">never</a>
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
Next-style aliases are accepted: `true` = `render`, `auto` = `viewport`,
|
|
664
|
-
`false` = `none`. `intent` waits a short dwell (~100ms) after hover/focus
|
|
665
|
-
so a pointer passing over a link does not fetch it. `viewport` uses an
|
|
666
|
-
IntersectionObserver at threshold 0.5 and waits a ~250ms dwell, cancelled
|
|
667
|
-
the instant the link scrolls back out, so a fast scroll through a long
|
|
668
|
-
list spends no requests (the same gate Astro / Next / Nuxt / Remix /
|
|
669
|
-
TanStack / Turbo apply). On touch, `touchstart` additionally warms the
|
|
670
|
-
tapped link itself (a single request for a link about to be navigated).
|
|
671
|
-
`render` and `viewport` are applied by a document scan on enable and after
|
|
672
|
-
each navigation.
|
|
673
|
-
|
|
674
|
-
Only internal links qualify, using the same eligibility as a click:
|
|
675
|
-
cross-origin, `download`, `target` other than `_self`, non-HTML
|
|
676
|
-
extensions, `data-no-router`, and pure same-page hash jumps are skipped.
|
|
677
|
-
Opt out with `data-prefetch="none"`, `data-no-prefetch`, or
|
|
678
|
-
`rel="external"`. Speculation is bounded by a concurrency cap (excess
|
|
679
|
-
requests queue and drain as slots free, rather than being dropped),
|
|
680
|
-
in-flight de-dupe, and an LRU + TTL cache, and is disabled entirely under
|
|
681
|
-
`Save-Data`, `prefers-reduced-data`, or a 2g `effectiveType` connection.
|
|
682
|
-
The guiding rule: snappy, but never at the cost of bloating the client
|
|
683
|
-
network tab; when the two conflict, the gate under-fetches. A mutating form submission and
|
|
684
|
-
`revalidate(url)` both evict the prefetch cache alongside the snapshot
|
|
685
|
-
cache, so a fragment prefetched before a mutation is never served stale.
|
|
686
|
-
|
|
687
|
-
There is no logout-style safeguard, matching every framework that
|
|
688
|
-
auto-prefetches: a prefetch issues a real GET, so a `/logout` or any
|
|
689
|
-
mutating endpoint MUST be a POST or a `<form>` submission (which the
|
|
690
|
-
router never prefetches), not a GET link. A native `<link rel="prefetch">`
|
|
691
|
-
in the document head is the browser's own mechanism and is left untouched.
|
|
692
|
-
|
|
693
|
-
The router dispatches a `webjs:prefetch` event on `document` the instant a
|
|
694
|
-
speculative fragment lands in the cache and becomes consumable (which is
|
|
695
|
-
strictly later than the request going out, since the entry is stored only
|
|
696
|
-
after the response body is read). The detail is `{ url, key, from: 'prefetch' }`,
|
|
697
|
-
mirroring `webjs:navigate` so one listener can split the two by `detail.from`.
|
|
698
|
-
Listen to instrument prefetch hit rate, or to gate work on a warm cache:
|
|
699
|
-
|
|
700
|
-
```ts
|
|
701
|
-
document.addEventListener('webjs:prefetch', (e) => {
|
|
702
|
-
console.log('prefetched', e.detail.url); // fragment is now cached
|
|
703
|
-
});
|
|
704
|
-
```
|
|
705
|
-
|
|
706
|
-
### `webjs:before-cache` (strip transient state before a back/forward snapshot)
|
|
707
|
-
|
|
708
|
-
The router keeps a URL-keyed snapshot cache (Turbo's SnapshotCache pattern) so
|
|
709
|
-
Back/Forward restores instantly. Because a snapshot is a raw `outerHTML` clone of
|
|
710
|
-
the live page, anything OPEN at navigate-away time (a hover-card, a dropdown, a
|
|
711
|
-
toast) is captured open and **restored open** on Forward. To prevent that, the
|
|
712
|
-
router dispatches `webjs:before-cache` on `document` **synchronously, on the page
|
|
713
|
-
being cached, immediately before the `outerHTML` read** (detail `{ url }`). A
|
|
714
|
-
handler's DOM mutations are captured in the snapshot; the live edits are
|
|
715
|
-
invisible because the page is being navigated away from (Turbo's
|
|
716
|
-
`turbo:before-cache` contract). Reset transient state here:
|
|
717
|
-
|
|
718
|
-
```ts
|
|
719
|
-
document.addEventListener('webjs:before-cache', () => {
|
|
720
|
-
document.querySelectorAll('[data-transient]').forEach((el) => el.remove());
|
|
721
|
-
// close open menus, clear in-progress toasts, reset a wizard step, ...
|
|
722
|
-
});
|
|
723
|
-
```
|
|
724
|
-
|
|
725
|
-
The kit's overlays (hover-card, tooltip, dropdown-menu, dialog, alert-dialog,
|
|
726
|
-
sonner) already listen and reset their open state, so they come back closed.
|
|
727
|
-
|
|
728
|
-
### View Transitions (opt-in, all three swap paths)
|
|
729
|
-
|
|
730
|
-
The router can wrap a client navigation's DOM mutation in the native
|
|
731
|
-
[View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
|
|
732
|
-
(`document.startViewTransition`), so a same-shell partial swap cross-fades
|
|
733
|
-
(or runs your `::view-transition-*` CSS) instead of snapping. It is OFF by
|
|
734
|
-
default and purely OPT-IN, so an unconfigured app behaves exactly as
|
|
735
|
-
before (no animation surprise, no regression in a browser without the
|
|
736
|
-
API). Opt in by adding a meta to the page head, mirroring Turbo's
|
|
737
|
-
`<meta name="view-transition">` convention:
|
|
738
|
-
|
|
739
|
-
```html
|
|
740
|
-
<!-- in the root layout's <head>, or any page's head -->
|
|
741
|
-
<meta name="view-transition" content="same-origin">
|
|
742
|
-
```
|
|
743
|
-
|
|
744
|
-
The accepted opt-in value is `same-origin` (every client-router swap is
|
|
745
|
-
same-origin by construction, so it reads as "animate these in-app
|
|
746
|
-
navigations"); any other value, or the meta being absent, keeps
|
|
747
|
-
transitions off. The meta is re-read PER navigation, so a page can turn
|
|
748
|
-
transitions on or off as the user moves through the app (the head merge
|
|
749
|
-
brings in the new page's head).
|
|
750
|
-
|
|
751
|
-
When enabled and supported, the transition wraps ALL THREE swap paths,
|
|
752
|
-
the deepest-marker layout swap, the `<webjs-frame>` swap, AND the
|
|
753
|
-
full-body fallback, not just the full-body case (the inverse of what an
|
|
754
|
-
author expects, since the marker + frame swaps are the common
|
|
755
|
-
designed-for paths). The transition wraps the DOM MUTATION ONLY, never
|
|
756
|
-
the fetch (which already happened); the browser captures the
|
|
757
|
-
before/after around the synchronous swap. When `startViewTransition` is
|
|
758
|
-
unavailable (Firefox / older Safari), the swap runs synchronously,
|
|
759
|
-
byte-identical to the no-transition path, with no flash and no throw.
|
|
760
|
-
|
|
761
|
-
### Persisting elements across a swap (`data-webjs-permanent`)
|
|
762
|
-
|
|
763
|
-
An element marked `data-webjs-permanent` (it MUST also carry an `id`)
|
|
764
|
-
survives a navigation as the SAME live DOM node, by node identity, so a
|
|
765
|
-
playing `<audio>` / `<video>`, a live widget, an open menu, or any element
|
|
766
|
-
with accumulated JS state keeps running across the swap instead of being
|
|
767
|
-
destroyed and re-created from the incoming HTML. Mirrors Turbo's
|
|
768
|
-
permanent-element behaviour.
|
|
769
|
-
|
|
770
|
-
```html
|
|
771
|
-
<audio id="player" data-webjs-permanent controls src="/track.mp3"></audio>
|
|
772
|
-
```
|
|
773
|
-
|
|
774
|
-
Mechanism: before the destructive swap, for each `[data-webjs-permanent]
|
|
775
|
-
[id]` in the CURRENT DOM the router looks for a matching `#id` in the
|
|
776
|
-
INCOMING document; when BOTH exist, the LIVE node is moved into the
|
|
777
|
-
incoming tree's position (replacing the incoming placeholder), so the swap
|
|
778
|
-
adopts the live node rather than recreating it. It works for the
|
|
779
|
-
full-body path AND the in-region (marker / frame) paths, and is a STRONGER
|
|
780
|
-
guarantee than the keyed reconciler (which preserves identity for matched
|
|
781
|
-
keyed children): a permanent node keeps EXACT identity even where the
|
|
782
|
-
reconciler would otherwise recreate it. Rules:
|
|
783
|
-
|
|
784
|
-
- The element must have an `id` (the match key) and the attribute on BOTH
|
|
785
|
-
the current and incoming render of the page.
|
|
786
|
-
- An id present in the current but ABSENT from the incoming doc is NOT
|
|
787
|
-
force-persisted (it is being removed; the swap removes it as usual).
|
|
788
|
-
- Only a CURRENT node actually carrying `data-webjs-permanent` is moved
|
|
789
|
-
(an incoming `#id` that resolves to a non-permanent current element is
|
|
790
|
-
left untouched).
|
|
791
|
-
- The node is placed exactly where the incoming document puts it, so it
|
|
792
|
-
never escapes a frame / region boundary.
|
|
793
|
-
|
|
794
|
-
Progressive enhancement: with JS off, `data-webjs-permanent` is an inert
|
|
795
|
-
attribute and the navigation is a normal full-page load.
|
|
796
|
-
|
|
797
|
-
### Per-segment loading skeletons
|
|
798
|
-
|
|
799
|
-
Each `loading.{js,ts}` in the route chain is rendered into a hidden
|
|
800
|
-
`<template id="wj-loading:<segment-path>">`. On nav-start, the client
|
|
801
|
-
clones the deepest matching template into the swap slot. Users see
|
|
802
|
-
an instant per-segment skeleton during the fetch.
|
|
803
|
-
|
|
804
|
-
### Programmatic navigation
|
|
805
|
-
|
|
806
|
-
```js
|
|
807
|
-
import { navigate } from '@webjsdev/core';
|
|
808
|
-
await navigate('/about'); // push history
|
|
809
|
-
await navigate('/login', { replace: true }); // replace
|
|
810
|
-
```
|
|
811
|
-
|
|
812
|
-
### Form submissions
|
|
813
|
-
|
|
814
|
-
`<form action="..." method="...">` submissions are intercepted alongside
|
|
815
|
-
link clicks and routed through the same partial-swap pipeline.
|
|
816
|
-
Submitter attributes (`formmethod` / `formaction` / `formenctype` on a
|
|
817
|
-
clicked `<button>`) take precedence over the form's own, per the HTML5
|
|
818
|
-
form-submission algorithm.
|
|
819
|
-
|
|
820
|
-
- **GET**: `FormData` is promoted to the URL query string (replacing
|
|
821
|
-
any existing `?...` on `action`), then the URL is fetched and applied
|
|
822
|
-
exactly like a link click.
|
|
823
|
-
- **POST / PUT / PATCH / DELETE**: `FormData` is sent as the request
|
|
824
|
-
body. After a successful response the snapshot cache is cleared (the
|
|
825
|
-
submission may have mutated server state that other cached URLs
|
|
826
|
-
depend on, so back/forward must refetch instead of restoring stale).
|
|
827
|
-
|
|
828
|
-
Forms calling a server action via `@submit=${e => this.handleSubmit(e)}`
|
|
829
|
-
+ `e.preventDefault()` are unaffected: the router only intercepts when
|
|
830
|
-
`event.defaultPrevented` is false. Opt out per form or per submitter
|
|
831
|
-
with `data-no-router`:
|
|
832
|
-
|
|
833
|
-
```html
|
|
834
|
-
<form action="/legacy" data-no-router>...</form>
|
|
835
|
-
<form action="/x"><button data-no-router>Full reload</button></form>
|
|
836
|
-
```
|
|
837
|
-
|
|
838
|
-
Auto-skipped (no `data-no-router` needed):
|
|
839
|
-
- `method="dialog"` (browser-native dialog dismissal)
|
|
840
|
-
- `target` / `formtarget` ≠ `_self` (iframe / popup)
|
|
841
|
-
- Cross-origin `action`
|
|
842
|
-
- Non-HTML extensions on `action` (`.pdf`, etc.)
|
|
843
|
-
|
|
844
|
-
### Non-2xx HTML responses are rendered in place
|
|
845
|
-
|
|
846
|
-
A response with a `text/html` body is applied to the DOM regardless of
|
|
847
|
-
status code:
|
|
848
|
-
|
|
849
|
-
- **2xx**: normal navigation.
|
|
850
|
-
- **4xx (e.g. 422)**: server-rendered validation errors. The form is
|
|
851
|
-
re-rendered with `value` attributes preserving what the user typed,
|
|
852
|
-
inline error messages visible, no full-page reload. Standard Rails /
|
|
853
|
-
Django / Laravel / Phoenix pattern.
|
|
854
|
-
- **5xx with HTML**: error page rendered in place (not a flash of
|
|
855
|
-
blank then reload).
|
|
856
|
-
|
|
857
|
-
Non-HTML responses (JSON error envelopes, downloads, opaque) fall back
|
|
858
|
-
to `location.href = url` and let the browser handle them.
|
|
859
|
-
|
|
860
|
-
**204 No Content** = "stay on current page" (autosave-style
|
|
861
|
-
submissions). DOM is untouched. History records the requested URL.
|
|
862
|
-
|
|
863
|
-
**Server-side redirects** (3xx that `fetch()` follows automatically)
|
|
864
|
-
record the **final** URL in history, not the originally-requested one.
|
|
865
|
-
The Post-Redirect-Get pattern works correctly.
|
|
866
|
-
|
|
867
|
-
### Page server actions (a `<form>` that re-renders with errors)
|
|
868
|
-
|
|
869
|
-
The server side of the no-JS validation pattern is a page `action`
|
|
870
|
-
export. A `page.{js,ts}` may export an `action` alongside its default
|
|
871
|
-
render function. A non-GET/HEAD submission to that page's own URL runs
|
|
872
|
-
the action (inside the page's segment middleware), so a plain
|
|
873
|
-
`<form method="POST">` works with JS disabled AND through the client
|
|
874
|
-
router, same UI either way.
|
|
875
|
-
|
|
876
|
-
```ts
|
|
877
|
-
// app/signup/page.ts
|
|
878
|
-
import { html } from '@webjsdev/core';
|
|
879
|
-
import { signup } from '../../modules/auth/actions/signup.server.ts';
|
|
880
|
-
|
|
881
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
882
|
-
const email = String(formData.get('email') || '').trim();
|
|
883
|
-
const values = { email };
|
|
884
|
-
if (!email.includes('@')) {
|
|
885
|
-
return { success: false, fieldErrors: { email: 'Enter a valid email' }, values, status: 422 };
|
|
886
|
-
}
|
|
887
|
-
const r = await signup({ email });
|
|
888
|
-
if (!r.success) return { success: false, fieldErrors: { email: r.error }, values, status: r.status };
|
|
889
|
-
return { success: true, redirect: '/login' };
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
export default function Signup({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {
|
|
893
|
-
const errors = actionData?.fieldErrors || {};
|
|
894
|
-
const values = actionData?.values || {};
|
|
895
|
-
return html`
|
|
896
|
-
<form method="POST">
|
|
897
|
-
<input name="email" type="email" value=${values.email || ''} required>
|
|
898
|
-
${errors.email ? html`<p class="error">${errors.email}</p>` : ''}
|
|
899
|
-
<button>Sign up</button>
|
|
900
|
-
</form>
|
|
901
|
-
`;
|
|
902
|
-
}
|
|
903
|
-
```
|
|
904
|
-
|
|
905
|
-
The action receives `{ request, params, searchParams, url, formData }`
|
|
906
|
-
(`formData` is the already-parsed body, `request` is the raw Request)
|
|
907
|
-
and returns an `ActionResult`. The server interprets the result:
|
|
908
|
-
|
|
909
|
-
- **Success** (`{ success: true, redirect? }`, or any non-`false`
|
|
910
|
-
result): a `303 See Other` to `result.redirect` if present, else the
|
|
911
|
-
page's own path (Post/Redirect/Get, so a reload does not resubmit).
|
|
912
|
-
This 303 is the success-result path only; a `redirect()` *thrown* from
|
|
913
|
-
the action is a separate case (see below).
|
|
914
|
-
- **Failure** (`{ success: false, fieldErrors?, values?, status? }`):
|
|
915
|
-
re-SSR the SAME page with `status` (default `422`) and the result on
|
|
916
|
-
`ctx.actionData`. The page reads `actionData.fieldErrors.<field>` for
|
|
917
|
-
messages and `actionData.values.<field>` to repopulate native
|
|
918
|
-
`<input value=...>`, so the user's typed input survives.
|
|
919
|
-
- A thrown `notFound()` yields a 404. A thrown `redirect()` carries no
|
|
920
|
-
baked-in status; the catching site picks the convention: thrown from an
|
|
921
|
-
action (a POST) it defaults to `307 Temporary Redirect`
|
|
922
|
-
(method-preserving, so the bounce keeps the POST's intent), thrown
|
|
923
|
-
during a GET page/layout render or gate it defaults to `302 Found` (the
|
|
924
|
-
code an auth bounce conventionally wants). An explicit status overrides
|
|
925
|
-
either default, in both the positional `redirect(url, 308)` and the
|
|
926
|
-
options `redirect(url, { status })` forms. PRG's 303 (above) is only
|
|
927
|
-
the success-result path, distinct from these thrown defaults.
|
|
928
|
-
|
|
929
|
-
A page WITHOUT an `action` export keeps the old behavior, a non-GET to
|
|
930
|
-
it 404s. There is no form library: native input repopulation plus the
|
|
931
|
-
browser's Constraint Validation API (`required`, `type="email"`,
|
|
932
|
-
`minlength`) cover the rest. Field-level errors come from the server
|
|
933
|
-
action result. See `agent-docs/recipes.md` for the full recipe and the
|
|
934
|
-
`ActionResult` shape.
|
|
935
|
-
|
|
936
|
-
### Concurrent navigations + cancellation
|
|
937
|
-
|
|
938
|
-
Each navigation/submission `abort()`s any in-flight fetch from the prior
|
|
939
|
-
one (Turbo Drive's `navigator.stop()` pattern). Rapid clicks won't
|
|
940
|
-
produce N parallel requests competing to be applied last. A monotonic
|
|
941
|
-
nav-token additionally short-circuits any response that arrives after a
|
|
942
|
-
newer navigation has settled, so a slow first request that races past
|
|
943
|
-
its abort cannot revert the newer page.
|
|
944
|
-
|
|
945
|
-
### Scroll restoration on back/forward
|
|
946
|
-
|
|
947
|
-
On snapshot, the router records `{ window.scrollX, window.scrollY }`
|
|
948
|
-
alongside the HTML. On popstate cache-hit, the cached DOM is applied
|
|
949
|
-
and scroll is restored to where the user left it. The background
|
|
950
|
-
revalidation fetch that follows does **not** scroll, so the restored
|
|
951
|
-
position survives the refresh. Cache miss → browser-native scroll
|
|
952
|
-
restoration takes over.
|
|
953
|
-
|
|
954
|
-
Every programmatic nav scroll (the popstate restore and the
|
|
955
|
-
scroll-to-top on a forward nav) is issued with `behavior: 'instant'`, so
|
|
956
|
-
an app-level `html { scroll-behavior: smooth }` does not animate it. The
|
|
957
|
-
restore matches a native page load (an instant jump), not a visible
|
|
958
|
-
slide. The one nav scroll left smooth is a hash-anchor (`#section`)
|
|
959
|
-
target, where smooth scrolling is the intent. So `scroll-behavior:
|
|
960
|
-
smooth` on `<html>` only affects in-page anchors, not route
|
|
961
|
-
transitions. In development the router logs a one-time console hint when
|
|
962
|
-
it detects that setting (and notes that pairing it with a sticky
|
|
963
|
-
`backdrop-filter` header can flash on iOS during navigation).
|
|
964
|
-
|
|
965
|
-
Inner scroll containers (e.g. `.docs-sidenav`) are preserved
|
|
966
|
-
automatically by the outer-layout-DOM-identity invariant. They stay
|
|
967
|
-
mounted across nav and keep their `scrollTop` natively.
|
|
968
|
-
|
|
969
|
-
### `<webjs-frame>` escape hatch
|
|
970
|
-
|
|
971
|
-
`<webjs-frame>` is webjs's take on **Turbo Frames** (Hotwire Turbo), so the
|
|
972
|
-
mental model and most `<turbo-frame>` muscle memory transfer directly: a lazy,
|
|
973
|
-
URL-addressable region that swaps on its own, driven by a link or form
|
|
974
|
-
targeting its id. It is the unit for a region that loads or refreshes
|
|
975
|
-
INDEPENDENTLY of a full-page navigation (something a page/layout, which
|
|
976
|
-
re-renders only at the route level, cannot express), and it ships zero
|
|
977
|
-
component JS. A frame's route can itself use `<webjs-suspense>`, so a deferred
|
|
978
|
-
(`loading="lazy"`) frame whose data is slow streams that data behind a fallback
|
|
979
|
-
inside the frame (one caveat: a streamed framed route skips the byte-saving
|
|
980
|
-
subtree extraction, so the full page renders and the client slices out the
|
|
981
|
-
region). See the data-fetching doc page (`docs/app/docs/data-fetching/page.ts`)
|
|
982
|
-
for the async-render vs `<webjs-suspense>` vs `<webjs-frame>` vs `<webjs-stream>`
|
|
983
|
-
decision boundary.
|
|
984
|
-
|
|
985
|
-
For partial-swap regions NOT tied to a folder layout (a marketing-page
|
|
986
|
-
widget, tabbed UI, etc.), wrap the region in a frame:
|
|
987
|
-
|
|
988
|
-
```ts
|
|
989
|
-
html`<webjs-frame id="activity">…contents…</webjs-frame>`
|
|
990
|
-
```
|
|
991
|
-
|
|
992
|
-
On click, the router walks `closest('webjs-frame')` from the click
|
|
993
|
-
target. If a frame is found AND the response contains a matching
|
|
994
|
-
`<webjs-frame id="...">`, the swap is scoped to that frame's children,
|
|
995
|
-
which takes precedence over the layout-marker mechanism.
|
|
996
|
-
|
|
997
|
-
#### External targeting (`data-webjs-frame`) and `_top` breakout
|
|
998
|
-
|
|
999
|
-
A trigger does NOT have to be nested inside the frame it drives. Mirroring
|
|
1000
|
-
Turbo's `data-turbo-frame`, an `<a>` or `<form>` (or any ancestor of it)
|
|
1001
|
-
carrying `data-webjs-frame="<id>"` drives the frame with that id, resolved
|
|
1002
|
-
via `getElementById` in the current document. So an external nav/sidebar
|
|
1003
|
-
link or a filter form can drive a content frame it does not enclose:
|
|
1004
|
-
|
|
1005
|
-
```ts
|
|
1006
|
-
html`
|
|
1007
|
-
<nav data-webjs-frame="results">
|
|
1008
|
-
<a href="/products?sort=new">Newest</a>
|
|
1009
|
-
<a href="/products?sort=top">Top rated</a>
|
|
1010
|
-
</nav>
|
|
1011
|
-
<form action="/products" data-webjs-frame="results">…filters…</form>
|
|
1012
|
-
|
|
1013
|
-
<webjs-frame id="results">…current results…</webjs-frame>
|
|
1014
|
-
`
|
|
1015
|
-
```
|
|
1016
|
-
|
|
1017
|
-
Resolution precedence: an explicit `data-webjs-frame` WINS over the
|
|
1018
|
-
closest-enclosing-frame default. So a link physically inside frame A that
|
|
1019
|
-
carries `data-webjs-frame="b"` drives frame B.
|
|
1020
|
-
|
|
1021
|
-
- **`data-webjs-frame="_top"`** is a reserved token: it forces a full-page
|
|
1022
|
-
navigation (the normal layout/marker swap or a full nav), breaking OUT of
|
|
1023
|
-
the enclosing frame. Put it on a link inside a frame that should escape it.
|
|
1024
|
-
- **An id that does not resolve** to a live `<webjs-frame>` emits a one-time
|
|
1025
|
-
`console.warn` and falls back to a normal navigation (fail-safe; the
|
|
1026
|
-
router never throws and never swaps the wrong region).
|
|
1027
|
-
- **With JS disabled** a `data-webjs-frame` link is an inert attribute on a
|
|
1028
|
-
plain `<a href>`, so the click is a normal full-page navigation, the
|
|
1029
|
-
correct progressive-enhancement fallback.
|
|
1030
|
-
|
|
1031
|
-
#### Busy state (`aria-busy` + `webjs:frame-busy`)
|
|
1032
|
-
|
|
1033
|
-
While a frame's navigation is in flight the router sets the native
|
|
1034
|
-
`aria-busy="true"` on the frame element and clears it (to `"false"`) on
|
|
1035
|
-
completion, on EVERY exit (a successful swap, a frame-missing response, an
|
|
1036
|
-
HTTP/transport error, or an abort by a newer nav). So assistive tech
|
|
1037
|
-
announces the loading state for free, and CSS can style the busy region:
|
|
1038
|
-
|
|
1039
|
-
```css
|
|
1040
|
-
webjs-frame[aria-busy="true"] { opacity: 0.6; }
|
|
1041
|
-
```
|
|
1042
|
-
|
|
1043
|
-
The router also dispatches a bubbling `webjs:frame-busy` event on the frame
|
|
1044
|
-
at both edges, so app code can hook the start and finish:
|
|
1045
|
-
|
|
1046
|
-
```ts
|
|
1047
|
-
document.addEventListener('webjs:frame-busy', (e) => {
|
|
1048
|
-
const { frameId, busy } = e.detail; // busy: true at start, false at finish
|
|
1049
|
-
spinner.toggle(frameId, busy);
|
|
1050
|
-
});
|
|
1051
|
-
```
|
|
1052
|
-
|
|
1053
|
-
#### `webjs:frame-missing` (response lacks the requested frame)
|
|
1054
|
-
|
|
1055
|
-
When a frame-scoped navigation's response does NOT carry a matching
|
|
1056
|
-
`<webjs-frame id="...">` (e.g. an auth gate returns a login page without
|
|
1057
|
-
the frame), the router does NOT fall through to a full-page swap, because
|
|
1058
|
-
that would silently destroy the page. Instead it dispatches a cancelable,
|
|
1059
|
-
bubbling `webjs:frame-missing` CustomEvent on the frame element (so a
|
|
1060
|
-
document-level listener catches it) and returns.
|
|
1061
|
-
|
|
1062
|
-
- **Default (not prevented):** the router emits a one-line `console.warn`
|
|
1063
|
-
and leaves the frame UNCHANGED (its current content stays, now stale).
|
|
1064
|
-
No full-page swap ever happens.
|
|
1065
|
-
- **Calling `preventDefault()`** keeps the framework silent and doing nothing
|
|
1066
|
-
further. The listener owns the outcome, e.g. it may call `navigate(url)`
|
|
1067
|
-
for a deliberate full swap, or `location.assign(url)` for a hard load.
|
|
1068
|
-
|
|
1069
|
-
`event.detail` is `{ frameId, url, document }`, where `frameId` is the
|
|
1070
|
-
requested frame id, `url` is the navigation target, and `document` is the
|
|
1071
|
-
parsed response document (so a listener can inspect what came back).
|
|
1072
|
-
|
|
1073
|
-
```ts
|
|
1074
|
-
document.addEventListener('webjs:frame-missing', (e) => {
|
|
1075
|
-
// The frame wasn't in the response (auth redirect, say). Take over
|
|
1076
|
-
// with a deliberate full navigation to the URL the server returned.
|
|
1077
|
-
e.preventDefault();
|
|
1078
|
-
location.assign(e.detail.url);
|
|
1079
|
-
});
|
|
1080
|
-
```
|
|
1081
|
-
|
|
1082
|
-
#### Self-loading frames (`src` + `loading`, #253)
|
|
1083
|
-
|
|
1084
|
-
A `<webjs-frame>` can fetch its OWN content instead of waiting for a click
|
|
1085
|
-
or a form. Give it a `src` and it self-fetches that URL as a frame nav and
|
|
1086
|
-
applies the matching `<webjs-frame id>` subtree from the response into itself,
|
|
1087
|
-
through the SAME frame-swap path a click-driven frame nav uses (so the busy
|
|
1088
|
-
lifecycle, the navigation-error recovery, the keyed reconciler, and the
|
|
1089
|
-
frame-missing fallback all apply for free).
|
|
1090
|
-
|
|
1091
|
-
```html
|
|
1092
|
-
<!-- Eager (default): fetches on connect. -->
|
|
1093
|
-
<webjs-frame id="rail" src="/widgets/rail"></webjs-frame>
|
|
1094
|
-
|
|
1095
|
-
<!-- Lazy: fetches when the frame first scrolls into view. -->
|
|
1096
|
-
<webjs-frame id="comments" src="/posts/42/comments" loading="lazy">
|
|
1097
|
-
<p>Loading comments...</p>
|
|
1098
|
-
</webjs-frame>
|
|
1099
|
-
```
|
|
1100
|
-
|
|
1101
|
-
The `loading` attribute picks WHEN:
|
|
1102
|
-
|
|
1103
|
-
- **`loading="eager"`** (or absent, the default) fetches on `connectedCallback`.
|
|
1104
|
-
- **`loading="lazy"`** fetches when the frame first enters the viewport, reusing
|
|
1105
|
-
the same IntersectionObserver budget (`rootMargin: '200px'`) as a
|
|
1106
|
-
`static lazy = true` component.
|
|
1107
|
-
|
|
1108
|
-
A `src` change after connect re-loads. Eager connect, the lazy observer, and a
|
|
1109
|
-
`src` mutation never double-fetch the same URL (a per-element loaded/loading
|
|
1110
|
-
guard keyed on the resolved URL coalesces them). The request carries the same
|
|
1111
|
-
`x-webjs-frame: <id>` header a click-driven frame nav sends, so a `src` self-load
|
|
1112
|
-
and a click that targets the same frame produce identical DOM.
|
|
1113
|
-
|
|
1114
|
-
**The server returns ONLY the requested subtree.** When a request carries
|
|
1115
|
-
`x-webjs-frame: <id>` and the route renders a `<webjs-frame id>` with that id,
|
|
1116
|
-
the server returns JUST that frame subtree (extracted from the full render, so
|
|
1117
|
-
byte-equivalent to what the client would slice from a full-page response) rather
|
|
1118
|
-
than the whole page. So a region swap pays only for the region, not the full
|
|
1119
|
-
document shell and every other region, the same spirit as the `X-Webjs-Have`
|
|
1120
|
-
partial-nav optimization. When the requested frame is NOT in the rendered output
|
|
1121
|
-
(an auth redirect to a login page, a route that dropped the frame), the server
|
|
1122
|
-
falls back to the full page and the client handles the absence via
|
|
1123
|
-
`webjs:frame-missing`. A request with no `x-webjs-frame` header is unaffected
|
|
1124
|
-
(byte-identical full-page render).
|
|
1125
|
-
|
|
1126
|
-
**PROGRESSIVE ENHANCEMENT CAVEAT: a `src`-driven frame is JS-DEPENDENT.** The
|
|
1127
|
-
browser does NOT natively fetch `<webjs-frame src>` (unlike `<iframe>`), so with
|
|
1128
|
-
JS off the frame shows only whatever children were server-rendered into it. Use
|
|
1129
|
-
`src`/`loading` for DEFERRED content (comments, a recommendations rail, an
|
|
1130
|
-
expensive card) where a JS-off placeholder / empty state is acceptable, exactly
|
|
1131
|
-
the lazy-content use case. For content that MUST exist without JS, render it
|
|
1132
|
-
server-side into the frame instead of using `src` (the self-load then replaces
|
|
1133
|
-
those fallback children).
|
|
1134
|
-
|
|
1135
|
-
### Stream actions: surgical element-level updates (#248)
|
|
1136
|
-
|
|
1137
|
-
`<webjs-stream>` is webjs's take on **Turbo Streams** (Hotwire Turbo); the
|
|
1138
|
-
action set (`append` / `prepend` / `before` / `after` / `replace` / `update` /
|
|
1139
|
-
`remove`) mirrors `<turbo-stream>`, so that muscle memory transfers directly. It
|
|
1140
|
-
is the ONLY surgical single-element update primitive (and the live-channel
|
|
1141
|
-
applier); a region swap or a `<webjs-frame>` reload redraws a whole region, and
|
|
1142
|
-
`<webjs-suspense>` is for streaming, so none of them cover "append one row".
|
|
1143
|
-
|
|
1144
|
-
A region swap (a layout marker or a `<webjs-frame>`) is the right tool for "this
|
|
1145
|
-
part of the page changed". It is too coarse for "append ONE comment", "remove
|
|
1146
|
-
ONE row", "bump a count", or "insert a toast". For those, a server response can
|
|
1147
|
-
declare per-element actions, carried as plain HTML, a `<webjs-stream action
|
|
1148
|
-
target>` wrapping one `<template>`:
|
|
1149
|
-
|
|
1150
|
-
```html
|
|
1151
|
-
<webjs-stream action="append" target="comments">
|
|
1152
|
-
<template><li>Nice post!</li></template>
|
|
1153
|
-
</webjs-stream>
|
|
1154
|
-
```
|
|
1155
|
-
|
|
1156
|
-
The `<webjs-stream>` element clones its `<template>` content on connect, applies
|
|
1157
|
-
the action against the target by native DOM, then removes itself. Actions
|
|
1158
|
-
(Turbo's set): `append` / `prepend` (last / first child of the target id),
|
|
1159
|
-
`before` / `after` (sibling of the target), `replace` (the target element
|
|
1160
|
-
itself), `update` (the target's children), `remove` (delete the target, no
|
|
1161
|
-
template). A `targets="<css-selector>"` applies to every match instead of a
|
|
1162
|
-
single `target` id.
|
|
1163
|
-
|
|
1164
|
-
**One applier, two delivery paths.**
|
|
1165
|
-
|
|
1166
|
-
1. **HTTP (content-negotiated form).** A `<form>` submission rides the client
|
|
1167
|
-
router, which adds `Accept: text/vnd.webjs-stream.html`. The server returns a
|
|
1168
|
-
stream ONLY when that Accept is present; the router then applies the
|
|
1169
|
-
`<webjs-stream>` body surgically (no region swap). With JS OFF the browser
|
|
1170
|
-
sends no such Accept, so the SAME endpoint returns a normal render/redirect
|
|
1171
|
-
and the form is a plain full-page POST. The grammar is additive and
|
|
1172
|
-
progressive-enhancement-safe.
|
|
1173
|
-
|
|
1174
|
-
2. **Live channel (`broadcast()` / `connectWS`).** `renderStream(message)` parses
|
|
1175
|
-
a server-pushed payload and inserts the `<webjs-stream>` elements (which
|
|
1176
|
-
self-apply), so chat / notifications / presence reuse the SAME applier:
|
|
1177
|
-
|
|
1178
|
-
```js
|
|
1179
|
-
import { connectWS, renderStream } from '@webjsdev/core';
|
|
1180
|
-
connectWS('/feed', { onMessage: (m) => renderStream(m) });
|
|
1181
|
-
```
|
|
1182
|
-
|
|
1183
|
-
**Server-side, build the payload with the `@webjsdev/server` helpers:**
|
|
1184
|
-
|
|
1185
|
-
```ts
|
|
1186
|
-
// app/post/[id]/route.ts (or a page `action`)
|
|
1187
|
-
import { stream, streamResponse, acceptsStream } from '@webjsdev/server';
|
|
1188
|
-
import { broadcast } from '@webjsdev/server';
|
|
1189
|
-
|
|
1190
|
-
export async function POST(req: Request, { params }) {
|
|
1191
|
-
const comment = await addComment(params.id, await req.formData());
|
|
1192
|
-
const html = stream.append('comments', `<li>${escapeHtml(comment.text)}</li>`);
|
|
1193
|
-
// Fan the SAME action out to every other connected viewer.
|
|
1194
|
-
broadcast(`post:${params.id}`, html);
|
|
1195
|
-
// Negotiate: a stream for the JS client, a redirect for the no-JS form.
|
|
1196
|
-
if (acceptsStream(req)) return streamResponse(html);
|
|
1197
|
-
return Response.redirect(`/post/${params.id}`, 303);
|
|
1198
|
-
}
|
|
1199
|
-
```
|
|
1200
|
-
|
|
1201
|
-
`stream.*` returns the `<webjs-stream>` string (the target id is
|
|
1202
|
-
attribute-escaped; the CONTENT is server-authored and NOT escaped, so escape any
|
|
1203
|
-
user substring yourself, like an `html` hole). `streamResponse(...)` wraps one or
|
|
1204
|
-
more parts in a `Response` with the stream content type. A page `action` may
|
|
1205
|
-
return `streamResponse(...)` directly (it is honored verbatim); on the no-JS
|
|
1206
|
-
branch return a normal `ActionResult` instead. `renderStream` is auto-registered
|
|
1207
|
-
by the client router, so it (and the `<webjs-stream>` element) is available
|
|
1208
|
-
on any page that loads `@webjsdev/core` (the router auto-enables there).
|
|
1209
|
-
|
|
1210
|
-
### Streaming RPC results: an action returns a stream (#489)
|
|
1211
|
-
|
|
1212
|
-
`<webjs-stream>` above is the SERVER-PUSHED render-side primitive (the server
|
|
1213
|
-
decides what changes and ships HTML actions). Streaming RPC is the complementary
|
|
1214
|
-
PULL-side primitive: a `'use server'` action that RETURNS a `ReadableStream`, an
|
|
1215
|
-
async iterable, or an async generator streams its chunks over the single RPC
|
|
1216
|
-
response, and the client gets back an async iterable to `for await`. This is the
|
|
1217
|
-
token-stream / progress / incremental-result case a component consumes
|
|
1218
|
-
imperatively after an interaction.
|
|
1219
|
-
|
|
1220
|
-
```ts
|
|
1221
|
-
// modules/ai/actions/stream-answer.server.ts
|
|
1222
|
-
'use server';
|
|
1223
|
-
export async function* streamAnswer(prompt: string) {
|
|
1224
|
-
for await (const token of llm.complete(prompt)) yield token;
|
|
1225
|
-
}
|
|
1226
|
-
```
|
|
1227
|
-
|
|
1228
|
-
```ts
|
|
1229
|
-
// inside a component
|
|
1230
|
-
for await (const token of await streamAnswer(q)) {
|
|
1231
|
-
this.text.set(this.text.get() + token); // renders incrementally
|
|
1232
|
-
}
|
|
1233
|
-
```
|
|
1234
|
-
|
|
1235
|
-
The wire is a sequence of length-prefixed frames (`application/vnd.webjs+stream`):
|
|
1236
|
-
each chunk is rich-serialized (a `Date` / `Map` / `BigInt` round-trips), then a
|
|
1237
|
-
terminal frame closes the stream. Detection is purely on the RETURN value, so any
|
|
1238
|
-
verb (#488) can stream and there is no config export to declare. Back-pressure is
|
|
1239
|
-
respected (a slow consumer throttles a fast producer), and the request
|
|
1240
|
-
`AbortSignal` (#492) cancels the source generator on a client disconnect or a
|
|
1241
|
-
superseded `async render()`. A streamed result is never cached, ETagged, or
|
|
1242
|
-
seeded (#472); a mutation that streams still emits its `X-Webjs-Invalidate`
|
|
1243
|
-
header. A mid-stream throw surfaces as an error from the iterable (the HTTP status
|
|
1244
|
-
is already 200, so wrap the `for await` in `try/catch`), the author message in
|
|
1245
|
-
prod. A truncated stream (a server crash, a dropped connection) also throws
|
|
1246
|
-
rather than completing silently: a healthy stream always ends in an explicit
|
|
1247
|
-
terminal frame, so a missing one is an error. For a slow region you want behind a fallback on the FIRST paint, use
|
|
1248
|
-
`<webjs-suspense>` instead; streaming RPC is for an imperative stream consumed
|
|
1249
|
-
after an interaction. Full reference: the [Data fetching](https://docs.webjs.dev/docs/data-fetching) page.
|
|
1250
|
-
|
|
1251
|
-
### Opt out per link
|
|
1252
|
-
|
|
1253
|
-
```html
|
|
1254
|
-
<a href="/legacy" data-no-router>Full reload</a>
|
|
1255
|
-
```
|
|
1256
|
-
|
|
1257
|
-
Use `data-no-router` for:
|
|
1258
|
-
- **Auth flows**: `/logout`, OAuth redirects. Full reload wipes in-memory module state.
|
|
1259
|
-
- **Print views / embed pages.**
|
|
1260
|
-
- **Experimental routes** with a different client runtime.
|
|
1261
|
-
|
|
1262
|
-
### Auto-skipped (no `data-no-router` needed)
|
|
1263
|
-
|
|
1264
|
-
- `download`, non-`_self` target, modifier-key click.
|
|
1265
|
-
- Cross-origin hrefs.
|
|
1266
|
-
- Pure hash fragments on same page.
|
|
1267
|
-
- Non-HTML extensions (`.pdf`, `.zip`, `.json`, images, media): browser handles.
|
|
1268
|
-
- Response `Content-Type` not `text/html`: falls back to full nav.
|
|
1269
|
-
|
|
1270
|
-
### Loading indicator
|
|
1271
|
-
|
|
1272
|
-
**When to use it.** You want a CSS-only progress affordance (a top bar, a `cursor: progress`) while a client navigation is in flight, with no JavaScript. For a JS-driven indicator, skip this and listen for the `webjs:navigate` event (and `webjs:submit-start` for forms) instead, which always fires.
|
|
1273
|
-
|
|
1274
|
-
**How to use it.** It is OPT-IN. Add `data-webjs-nav-progress` to your `<html>` element (once, in the root layout), and the router then sets a `data-navigating` attribute on `<html>` while a nav is in flight (deferred 150ms, so quick sub-150ms navs never trigger it). Style off that attribute:
|
|
1275
|
-
|
|
1276
|
-
```html
|
|
1277
|
-
<html data-webjs-nav-progress> <!-- opt in once -->
|
|
1278
|
-
```
|
|
1279
|
-
|
|
1280
|
-
```css
|
|
1281
|
-
html[data-navigating] { cursor: progress; }
|
|
1282
|
-
html[data-navigating]::after {
|
|
1283
|
-
content: ''; position: fixed; top: 0; left: 0; right: 0; height: 2px;
|
|
1284
|
-
background: var(--accent); animation: progress 1s ease-in-out infinite;
|
|
1285
|
-
}
|
|
1286
|
-
```
|
|
1287
|
-
|
|
1288
|
-
**Why it is opt-in, not default (#610).** Toggling an attribute on `<html>` re-runs global style resolution, and on WebKit (so every iOS browser) that re-resolves the page's `oklch()` / `color-mix()` design tokens to an equivalent oklab representation and repaints them for one frame. On a token-driven theme that is a visible background flash, worst on a slow mobile forward nav (which exceeds the 150ms defer). So without the opt-in the attribute is never written and the flash cannot happen. Enable it only when your theme does not lean on wide-gamut color tokens, or use the event-based path above.
|
|
1289
|
-
|
|
1290
|
-
## WebSockets
|
|
1291
|
-
|
|
1292
|
-
### Server: `WS` export in `route.{js,ts}`
|
|
1293
|
-
|
|
1294
|
-
```js
|
|
1295
|
-
export function WS(ws, req, { params }) {
|
|
1296
|
-
ws.on('message', (data) => ws.send('echo:' + data));
|
|
1297
|
-
ws.on('close', () => { /* cleanup */ });
|
|
1298
|
-
}
|
|
1299
|
-
```
|
|
1300
|
-
|
|
1301
|
-
In **dev mode** the module re-imports per connection to pick up edits.
|
|
1302
|
-
Store shared state on `globalThis`:
|
|
1303
|
-
|
|
1304
|
-
```js
|
|
1305
|
-
const clients = globalThis.__my_clients ?? (globalThis.__my_clients = new Set());
|
|
1306
|
-
```
|
|
1307
|
-
|
|
1308
|
-
### Client: `connectWS`
|
|
1309
|
-
|
|
1310
|
-
`connectWS(url, { onOpen, onMessage, onClose, onError, reconnect })` from `@webjsdev/core`. Auto-reconnects with exponential backoff, JSON parse/stringify, queues sends while disconnected.
|
|
1311
|
-
|
|
1312
|
-
### Broadcast (single-instance)
|
|
1313
|
-
|
|
1314
|
-
```js
|
|
1315
|
-
import { broadcast } from '@webjsdev/server';
|
|
1316
|
-
|
|
1317
|
-
export function WS(ws, req) {
|
|
1318
|
-
ws.on('message', (data) => {
|
|
1319
|
-
broadcast('/api/chat', data); // → all connected clients on this path
|
|
1320
|
-
});
|
|
1321
|
-
}
|
|
1322
|
-
```
|
|
1323
|
-
|
|
1324
|
-
For multi-instance, the user adds Redis pub/sub themselves. No framework magic.
|
|
1325
|
-
|
|
1326
|
-
## Per-segment middleware
|
|
1327
|
-
|
|
1328
|
-
`middleware.js` can live at any level under `app/` and only applies to
|
|
1329
|
-
its subtree. Chain runs outermost → innermost, root-sibling → app-root
|
|
1330
|
-
first, then segment-scoped files.
|
|
1331
|
-
|
|
1332
|
-
## Raw-text templates
|
|
1333
|
-
|
|
1334
|
-
`<script>` and `<style>` are parsed as raw-text. `<` and `>` inside them aren't tag starts. Holes interpolate verbatim (no HTML escaping).
|