@webjsdev/cli 0.10.54 → 0.10.56

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.
Files changed (38) hide show
  1. package/lib/api-gallery.js +25 -1
  2. package/lib/create.js +35 -0
  3. package/lib/doctor/codes.js +66 -0
  4. package/lib/doctor/manifest.js +161 -0
  5. package/lib/doctor/policy.js +124 -0
  6. package/lib/doctor/probes/elision.js +111 -0
  7. package/lib/doctor/probes/env.js +53 -0
  8. package/lib/doctor/probes/framework-resolves.js +84 -0
  9. package/lib/doctor/probes/git-hook.js +58 -0
  10. package/lib/doctor/probes/importmap-coherence.js +158 -0
  11. package/lib/doctor/probes/node.js +37 -0
  12. package/lib/doctor/probes/static-asset-freshness.js +58 -0
  13. package/lib/doctor/probes/tsconfig.js +55 -0
  14. package/lib/doctor/probes/unmarked-asset-links.js +199 -0
  15. package/lib/doctor/probes/vendor-gitignore.js +84 -0
  16. package/lib/doctor/probes/vendor-pin.js +77 -0
  17. package/lib/doctor/probes/webjs-versions.js +85 -0
  18. package/lib/doctor/route-modules.js +100 -0
  19. package/lib/doctor/runner.js +71 -0
  20. package/lib/doctor/util.js +160 -0
  21. package/lib/doctor.js +5 -1634
  22. package/package.json +1 -1
  23. package/templates/.agents/skills/webjs/SKILL.md +41 -2
  24. package/templates/.agents/skills/webjs/references/built-ins.md +5 -1
  25. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +20 -2
  26. package/templates/.agents/skills/webjs/references/components.md +41 -1
  27. package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
  28. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +1 -1
  29. package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
  30. package/templates/.agents/skills/webjs/references/runtime.md +5 -0
  31. package/templates/gallery/app/features/boundaries/page.ts +11 -0
  32. package/templates/gallery/app/features/client-router/page.ts +8 -1
  33. package/templates/gallery/app/features/rate-limit/ping/middleware.ts +36 -4
  34. package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
  35. package/templates/gallery/modules/client-router/components/router-controls.ts +16 -2
  36. package/templates/gallery/modules/gallery/nav.ts +35 -26
  37. package/templates/gallery/test/rate-limit/rate-limit.test.ts +91 -0
  38. package/templates/scripts/clear-gallery.mjs +6 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.54",
3
+ "version": "0.10.56",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -24,6 +24,8 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
24
24
 
25
25
  **Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and a `<form action=${importedAction}>` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
26
26
 
27
+ **Islands, and why their size is a decision.** A WebJs page is server-rendered HTML with small interactive components embedded in it, each hydrating on its own when the browser upgrades its tag. That is the islands model, and what makes it pay is that the sea is free: static markup a page renders costs the browser nothing, because a page never hydrates. So an island is not a unit of code organisation, it is a unit of shipped JavaScript, and it should wrap the interactive part and stop. Absorbing a page's static markup into a component to keep things tidy converts free HTML into shipped JavaScript, and it takes every display-only child down with it, because a component rendered by a component that ships can no longer be elided. Size the island to the behaviour, not to the section of the design it happens to sit in. `references/components.md` has the stopping rule and a worked before/after.
28
+
27
29
  ## When To Use This Skill
28
30
 
29
31
  - New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests
@@ -31,9 +33,44 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
31
33
  - Answering "how should this be structured in WebJs?"
32
34
  - Finding the right export, reference doc, or default pattern for a task
33
35
 
36
+ ## Reach For The Right Primitive
37
+
38
+ Scan this BEFORE deciding how to build something, while the shape of the code is still open. WebJs ships a primitive for most of the jobs below, and the mistake to guard against is rarely choosing badly between them. It is that the primitive never comes to mind at all, so the React-shaped version gets hand-rolled in its place. That version compiles, passes `webjs check`, and ships, so nothing catches it. The "reflex to resist" column is what the hand-rolled version usually looks like.
39
+
40
+ Rows point rather than explain. The reference is the authority on the rule, and it is the durable half: the demo lives in the scaffold gallery, which an app deletes with `npm run gallery:clear` once it has outgrown it.
41
+
42
+ | I need to... | Reach for | Reflex to resist | Reference | Demo |
43
+ | --- | --- | --- | --- | --- |
44
+ | add a URL, static or with a dynamic segment | a file at `app/<path>/page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` |
45
+ | abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` |
46
+ | set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `<head>` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` |
47
+ | make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` |
48
+ | render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` |
49
+ | get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` |
50
+ | call server code from the browser | import the `'use server'` function and call it | hand-writing `fetch()` against an endpoint | `references/data-and-actions.md` | `app/features/server-actions` |
51
+ | expose JSON to a caller outside the app | `route.ts` with named `GET` / `POST` exports | a server action, which is the in-app path | `references/routing-and-pages.md` | `app/features/route-handler` |
52
+ | write data from a form, JS off included | `<form action=${importedAction}>` | an `@submit` handler calling `fetch()` | `references/data-and-actions.md` | `app/features/forms` |
53
+ | make a mutation feel instant | `optimistic()` | a manual try-catch that restores a cached copy | `references/optimistic-ui.md` | `app/features/optimistic-ui` |
54
+ | navigate without a full page reload | nothing, the router is already on | importing or configuring a router | `references/client-router-and-streaming.md` | `app/features/client-router` |
55
+ | cross-fade a navigation instead of snapping | the `view-transition` meta, via page metadata | animating the swap yourself | `references/client-router-and-streaming.md` | `app/features/view-transitions` |
56
+ | show tokens or progress as the server produces them | an action returning an async generator | polling, or a socket for a one-shot answer | `references/client-router-and-streaming.md` | `app/features/streaming` |
57
+ | change ONE element after a write | `<webjs-stream>` | redrawing the whole list around it | `references/client-router-and-streaming.md` | `app/features/stream` |
58
+ | paint the page before a slow region is ready | `<webjs-suspense>` with a fallback | blocking the whole page on the slow await | `references/client-router-and-streaming.md` | `app/features/suspense` |
59
+ | refresh one region on its own, with no navigation | `<webjs-frame>` | a stateful component that fetches and re-renders | `references/client-router-and-streaming.md` | `app/features/frames` |
60
+ | hold a live two-way connection | a `WS()` route export plus `connectWS()` | polling on an interval | `references/client-router-and-streaming.md` | `app/features/websockets` |
61
+ | push one update to every client on a socket path | `broadcast()` | every client polling for changes | `references/client-router-and-streaming.md` | `app/features/broadcast` |
62
+ | add login and a signed-in-only route | `createAuth` plus a redirect in the page | rolling password hashing and session cookies | `references/auth-and-sessions.md` | `app/features/auth` |
63
+ | remember something per visitor across requests | `getSession()` on a signed cookie | a module-level map keyed by user | `references/auth-and-sessions.md` | `app/features/sessions` |
64
+ | stop re-rendering a page identical for everyone | `export const revalidate` | caching by hand in a module variable | `references/built-ins.md` | `app/features/caching` |
65
+ | read config or a secret at runtime | `process.env` server-side, `WEBJS_PUBLIC_` for the browser | importing a config module into a component | `references/built-ins.md` | `app/features/env` |
66
+ | stop one caller hammering an endpoint | the `rateLimit()` middleware | counting requests inside the handler | `references/built-ins.md` | `app/features/rate-limit` |
67
+ | accept an upload and serve it back | `FileStore` plus a streaming route | buffering the file in memory or writing to `public/` | `references/built-ins.md` | `app/features/file-storage` |
68
+ | keep the app usable offline | the opt-in service worker | caching responses in `localStorage` | `references/service-worker.md` | `app/features/service-worker` |
69
+ | see these composed in one real feature | the todo example app | stitching the single-feature demos together | `references/optimistic-ui.md` | `app/examples/todo` |
70
+
34
71
  ## Load Only The References You Need
35
72
 
36
- Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
73
+ The table above routes by the job; this one routes by the topic, for when you already know which surface you are working on. Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
37
74
 
38
75
  | Task involves... | Start with |
39
76
  | --------------------------------------------------------------------------- | --------------------------------------------- |
@@ -51,6 +88,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
51
88
  | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
52
89
  | Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` |
53
90
  | Offline support, an asset cache, the opt-in service worker | `references/service-worker.md` |
91
+ | Splitting a large file, or how big a module may be | `references/module-structure.md` |
54
92
  | A pattern that feels like Next.js or Lit but might not transfer | `references/muscle-memory-gotchas.md` |
55
93
 
56
94
  Common bundles:
@@ -66,7 +104,7 @@ Common bundles:
66
104
  2. **Start from the server.** Add the page/route and its server action or query before wiring interactive UI. A page render or a `<form>` POST should already return correct HTML before any component hydrates.
67
105
  3. **Put code in the narrowest owner.** Route-local first (`modules/<feature>/`), promote to `lib/` or `components/` only when reuse is real.
68
106
  4. **Keep server-only code behind `.server.ts`.** The DB driver, secrets, and `node:*` never belong in a page, layout, or component.
69
- 5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser.
107
+ 5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. Then wrap the interactive part and STOP: the static markup around it stays in the page, where it costs nothing.
70
108
  6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it.
71
109
  7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`).
72
110
  8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult<T>`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. See `references/typescript.md`.
@@ -231,6 +269,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
231
269
  ## Common Mistakes To Avoid
232
270
 
233
271
  - Treating a page or layout like a React component and expecting its markup to hydrate. It runs server-only; put interactivity in a component.
272
+ - Promoting a whole page section to a component so that one control inside it can be interactive. The island should wrap the control and the state it reads. An oversized island ships its own JS AND un-elides every display-only component inside it, so the cost is not linear in what you moved.
234
273
  - Importing a `.server.ts` utility (no `'use server'`) directly into a shipping component. Its browser stub throws at load; reach it through a `'use server'` action.
235
274
  - Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory.
236
275
  - Quoting an event / property / boolean hole (`@click="${fn}"`).
@@ -109,7 +109,11 @@ import { rateLimit } from '@webjsdev/server';
109
109
  export default rateLimit({ window: '1m', max: 60 });
110
110
  ```
111
111
 
112
- Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the client IP), `message`, `store`, `trustProxy` (honour the forwarded-IP headers; inert while `WEBJS_NO_TRUST_PROXY=1` is set, which outranks it and keeps the limiter on the framework-stamped peer). Over-limit responds `429` with `Retry-After` and `X-RateLimit-*` headers; an allowed response carries the remaining-quota headers too. For multi-instance scaling, set the global store to Redis once at startup.
112
+ Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the framework-stamped socket peer), `message`, `store`, `trustProxy` (honour the forwarded-IP headers; inert while `WEBJS_NO_TRUST_PROXY=1` is set, which outranks it and keeps the limiter on the framework-stamped peer). Over-limit responds `429` with `Retry-After` and `X-RateLimit-*` headers; an allowed response carries the remaining-quota headers too. For multi-instance scaling, set the global store to Redis once at startup.
113
+
114
+ **The default key is the socket PEER, which is the visitor only when the browser connects to you directly.** Deploy behind a CDN or a platform router and the peer is that proxy, so `trustProxy: true` is what a deployed limiter almost always wants. Get it wrong and nothing looks broken: a single shared proxy buckets every visitor together, and a proxy POOL (the common case) hands out one full allowance PER proxy, so the effective limit is multiplied by the pool size while `X-RateLimit-Remaining` still counts down convincingly inside each bucket. Diagnose it by sending the requests over ONE keep-alive connection, which pins them to one peer: counts that descend there but reset on a fresh connection mean you are bucketing proxies. `trustProxy: true` has one precondition, that the proxy in front strips an inbound `X-Forwarded-For` before adding its own (Cloudflare, Railway, Fly, Render, and Vercel do; nginx and Caddy only if configured), or a client can forge the header and choose its own bucket.
115
+
116
+ **Behind a CDN, `trustProxy: true` alone is usually still wrong, so name the header: `rateLimit({ trustProxy: true, clientIpHeader: 'cf-connecting-ip' })`.** The default chain starts at the leftmost `X-Forwarded-For` entry, which behind Cloudflare is Cloudflare's EGRESS address rather than the visitor. Cloudflare pins one egress IP per connection, so the symptom is a limiter that counts down correctly for a button that pings on one connection and never refuses anyone who opens a new one. When `clientIpHeader` is set it is the only wire header read (falling back to the peer, then `_anon_`), a blank value falls through rather than becoming a key every visitor shares, and a comma chain is split so an appending proxy cannot mint a bucket per hop. The framework will NOT prefer `CF-Connecting-IP` on its own, because Cloudflare overwrites it, which makes it unforgeable behind Cloudflare and forgeable anywhere else: on an nginx or bare-platform deploy a client could then send it and outrank the header the real proxy set. Name the header YOUR edge sets and overwrites.
113
117
 
114
118
  ## Broadcast
115
119
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## What This Covers
4
4
 
5
- - The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()`.
5
+ - The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()` / `refreshPage()`.
6
6
  - Link prefetch with device-adaptive defaults.
7
7
  - `<webjs-frame>` partial-swap regions (WebJs's Turbo Frames).
8
8
  - View Transitions opt-in.
@@ -56,6 +56,20 @@ revalidate(); // clear the entire snapshot cache
56
56
 
57
57
  The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.
58
58
 
59
+ **In-place refresh of the page you are on.** `refreshPage(mode)` re-renders the CURRENT url on the server and applies it without a page load.
60
+
61
+ ```js
62
+ import { refreshPage } from '@webjsdev/core';
63
+ await refreshPage(); // 'page': morph the deepest shared boundary
64
+ await refreshPage('shell'); // replace the whole body (the layout's own markup changed)
65
+ ```
66
+
67
+ It records no history entry and never scrolls, so the reader keeps their place and Back still goes to the previous page. `'page'` morphs the deepest shared boundary, so the outer layout's DOM and the hydrated state of its components survive; `'shell'` replaces the whole body, which is what a LAYOUT change needs, since a layout's own header, nav, and footer sit outside every children range and a boundary morph would leave them untouched. Component instances do not survive a `'shell'` refresh.
68
+
69
+ It sends no `X-Webjs-Have`, deliberately: the server short-circuits at the first layout the client already holds, and a same-url request matches every one of them, so the response would omit the very layout that changed. It resolves `false` when it did not apply (the router is disabled, or the fetch failed), so a caller falls back to a full load.
70
+
71
+ It does NOT reload changed component modules and cannot: `customElements.define` is once-per-tag and a module url is fetched once per document. A caller whose change touched browser code has to reload. This is exactly why the dev live-reload client calls `refreshPage` for a page or layout edit and `location.reload()` for a component edit (#1398, and see `references/runtime.md` for which dev modes get the refresh).
72
+
59
73
  **Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:
60
74
 
61
75
  - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
@@ -110,6 +124,10 @@ A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<for
110
124
 
111
125
  **The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on (#1106), since that request cannot serve any later navigation and only occupies a capped cache slot; a hover's intent timer routinely fires after the click it belongs to has already swapped, which is when that happens. Both behaviours are internal; nothing to configure.
112
126
 
127
+ **The cache also carries a FRAME dimension (#1407).** A link that drives a `<webjs-frame>` is prefetched with the same `X-Webjs-Frame` header its click will send, so the cached body is the frame subtree the swap actually needs and the click resolves with no round trip. The server marks that sliced response `X-Webjs-Frame: <id>` on the way out, and varies on the request header, so the cache keys the entry by URL plus frame id: a page fragment can never be applied into a frame region, nor a frame subtree into a page swap, and each dimension is a separate entry for one URL. A frame entry is validated differently from a page one, because a subtree carries no boundary comment to anchor on: what has to hold is that a live `<webjs-frame>` with that id is still in the document, checked at consume time. Two responses are refused outright. A body answering a framed request WITHOUT the server's marker is a whole document (a streamed render skips the slice, and so does an id absent from the output), so it is discarded rather than stored under either key. The REFUSAL is remembered though, in a small memo kept outside the fragment cache, so a link on a streaming route re-asks about once per TTL instead of on every hover, without occupying a cache slot a real fragment could use (that memo set is itself capped, so a page with many distinct refused frame links can re-ask sooner). Only a detected deploy or an in-place `refreshPage` drops those memos early, since those are the two moments the SOURCE can have changed. `revalidate()` leaves them, because it is the post-mutation api and clearing there would drop every memo on every write; a mutation CAN change a render's streamed shape (a page may render `Suspense` conditionally on fetched data), but a memo that outlives that costs one skipped warm-up for that key until the TTL runs out, which is the cheaper side of the trade. And a framed link pointing at the URL the page is already on is a frame refresh, which must show fresh bytes, so #1106 excludes it in its own dimension.
128
+
129
+ One consequence to know when reading a network tab: dedupe is per dimension, so a page holding TWO links to one URL, one driving a frame and one not, warms both and issues two speculative requests where it previously issued one. That is not redundancy, since the two responses genuinely differ and a click on either link needs its own; suppressing one would leave whichever link lost unwarmed ahead of the click, which bites hardest on touch, where `viewport` is the default and the only thing left is the `touchstart` warm at tap time. Both stay inside the same cache cap, concurrency gate, TTL, and `Save-Data` gate as every other prefetch, and the change adds no new trigger and no per-link fan-out.
130
+
113
131
  ## `<webjs-frame>` Partial-Swap Regions
114
132
 
115
133
  `<webjs-frame>` is WebJs's take on Turbo Frames, so most `<turbo-frame>` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express.
@@ -118,7 +136,7 @@ A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<for
118
136
  html`<webjs-frame id="activity">…contents…</webjs-frame>`
119
137
  ```
120
138
 
121
- On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree.
139
+ On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree. A link that drives a frame participates in link prefetch like any other, in that frame's own dimension (#1407), so a hovered or viewport-warmed frame link swaps on click with no round trip. A `<webjs-frame src>` SELF-load is the exception: it neither reads nor keeps that cache, since asking a frame to load its own src is a freshness request rather than a hover being followed. See the prefetch section above for the frame dimension's rules.
122
140
 
123
141
  **External targeting.** A trigger does not have to be nested inside the frame. An `<a>` or `<form>` carrying `data-webjs-frame="<id>"` drives that frame from anywhere (an explicit `data-webjs-frame` wins over the enclosing-frame default). `data-webjs-frame="_top"` is a reserved token forcing a full-page navigation that breaks out of the frame.
124
142
 
@@ -101,6 +101,44 @@ NavDrawer.register('nav-drawer');
101
101
 
102
102
  This repo's own website is the worked example. Before commit `b80de906` the docs drawer and the header menu were exactly the first shape, and every accessibility bug their tests now pin came out of the split. `website/components/docs-drawer.ts` and `website/components/site-nav-menu.ts` are the second shape, and `website/AGENTS.md` records the app-level version of these rules under "What stays inline script in the root layout".
103
103
 
104
+ ## Sizing an island: own the behaviour, not the section
105
+
106
+ The ownership rules above answer "what must not be split apart". They do NOT answer "how much to pull in", and read alone they push in one direction only: rule 1 says that if you are reaching for a selector, write the component that renders that markup instead. Applied without a stopping rule, that argument never terminates, because there is always more surrounding markup a component could render.
107
+
108
+ Here is the stopping rule. **A component owns the markup its own behaviour reads or writes.** Static markup that no handler touches, no state change re-renders, and no template hole depends on belongs to the page, not to the island.
109
+
110
+ This is a byte-cost rule, not a taste one. A page never hydrates, so markup it renders is free in the browser. Markup an island renders is not: the island's module is fetched, `@webjsdev/core` comes with it, and on upgrade the component re-renders, replacing the server's DOM for that subtree. Moving static markup across that boundary converts free HTML into shipped JavaScript that reproduces markup the server already sent.
111
+
112
+ ```ts
113
+ // TOO BIG. One button's worth of behaviour, a whole page's worth of markup.
114
+ class ProductPage extends WebComponent({ product: prop<Product>(Object) }) {
115
+ render() {
116
+ return html`
117
+ <h1>${this.product.name}</h1>
118
+ <p>${this.product.description}</p>
119
+ <spec-table .rows=${this.product.specs}></spec-table>
120
+ <button @click=${() => addToCart(this.product.id)}>Add to cart</button>
121
+ <review-list .reviews=${this.product.reviews}></review-list>
122
+ `;
123
+ }
124
+ }
125
+ ```
126
+
127
+ ```ts
128
+ // RIGHT SIZE. The page renders the static markup. The island is the button.
129
+ class AddToCart extends WebComponent({ productId: String }) {
130
+ render() {
131
+ return html`<button @click=${() => addToCart(this.productId)}>Add to cart</button>`;
132
+ }
133
+ }
134
+ ```
135
+
136
+ The first version ships three modules instead of one: itself, plus `<spec-table>` and `<review-list>`, which were display-only and elidable until a shipping component rendered them (see "Display-only elision" below). The second ships one small module, and the heading, description, spec table, and reviews stay HTML the browser never pays for.
137
+
138
+ **How to tell, on a component you are about to write.** Walk its template and ask of each element: does a handler in this class touch it, does a state or property change alter it, or does it sit in a template hole? If the answer is no for all three, that element is a passenger. A template that is mostly passengers is an island that wants splitting, and the split is usually "hoist the static markup back to the page and keep the interactive fragment".
139
+
140
+ **The exception, and it is a real one.** Markup that is static *today* but is the thing a near-term behaviour will read is fine to keep, because the alternative is a component that reaches outward for it later, which is what rule 1 forbids. Judge the behaviour you are building, not one you are speculating about. When those genuinely collide, ownership wins over bytes: a coherent component that ships a little extra markup beats a split feature that a selector holds together.
141
+
104
142
  ## Reactive properties: the base-class factory
105
143
 
106
144
  Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
@@ -356,6 +394,8 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
356
394
  - the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
357
395
  - being rendered by a component that itself ships
358
396
 
397
+ That last blocker is the one to DESIGN around rather than merely inspect, because it is the only one that is not about the component you are looking at. Elision propagates downward from whatever ships, so the size of your islands decides how much of the tree stays elidable. Ten display-only components rendered by a page are ten modules the browser never fetches. The same ten rendered by one oversized interactive wrapper all ship, and nothing about any of them changed. This is why "Sizing an island" above is a byte-cost rule and not a tidiness preference.
398
+
359
399
  A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis. `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
360
400
 
361
401
  ### What `static interactive = true` does and does not rescue
@@ -414,4 +454,4 @@ A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus
414
454
  - HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`).
415
455
  - WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete` (#1021: there is no WebJs slot API to override; slots are native). Only override one deliberately, with its exact signature; never repurpose the name for app logic.
416
456
 
417
- Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js`.
457
+ Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js` and its sibling `component/` directory, where the class body actually lives.
@@ -0,0 +1,229 @@
1
+ # Module structure: file size, design principles, and splitting a large module
2
+
3
+ Read this before splitting a large source file, and before arguing about how
4
+ big a module is allowed to be.
5
+
6
+ Two things live here. The first is what "well structured" means in this repo,
7
+ which is mostly judgment rather than a number. The second is the mechanical
8
+ procedure for barrelling a large module into a directory, which is NOT judgment:
9
+ it has a small number of failure modes that are silent, and every one of them
10
+ has bitten this codebase already.
11
+
12
+ ---
13
+
14
+ ## Design principles: judgment, not a checker
15
+
16
+ SOLID, DRY, and KISS apply here the way they apply anywhere. They are prose
17
+ guidance, followed by judgment, and deliberately NOT enforced by `webjs check`.
18
+ That split is the same one the rest of the project uses: `webjs check` carries
19
+ correctness rules only (code that is wrong to ship), and anything a sensible
20
+ project could reasonably do differently stays a convention.
21
+
22
+ What they mean in practice, in a buildless framework whose source IS what runs:
23
+
24
+ - **Single responsibility** is about what a module OWNS, not how long it is. A
25
+ module owns one concern when you can state that concern in a sentence without
26
+ the word "and". `router-client/prefetch.js` owns speculative fetching. It is
27
+ 584 lines, and it is one responsibility.
28
+ - **DRY applies to knowledge, not to text.** Two identical lines that would
29
+ change for different reasons are not duplication. A constant that appears in
30
+ three places IS, which is why this repo has drift guards that read one copy
31
+ and assert it against another (see the guard section below).
32
+ - **KISS beats cleverness in a framework more than in an app.** The source is
33
+ the documentation surface for the AI agents that use WebJs, and it ships
34
+ unbundled to be read. An indirection that saves five lines and costs a reader
35
+ a jump is a bad trade here.
36
+ - **Dependency direction matters more than dependency inversion.** Modules layer
37
+ downward: constants at the bottom, then pure helpers, then orchestration.
38
+ Nothing imports upward. This is not architectural taste, it is what keeps ESM
39
+ cycles out (see below). Two subsystems are genuinely mutually recursive and
40
+ cannot layer, the client router (a navigation fetches, the fetch swaps, the
41
+ swap upgrades, an upgraded element navigates) and light-DOM slots (projecting
42
+ installs the interceptors, an intercepted mutation re-projects). Those two are
43
+ named in `test/architecture/import-cycles.test.mjs`, which fails on any THIRD
44
+ cycle, so the rule holds everywhere it can.
45
+
46
+ ---
47
+
48
+ ## File size: target 800, ceiling around 1000
49
+
50
+ **A source module targets 800 lines and should stay around 1000 at the most.**
51
+ The ceiling is approximate on purpose. A module at 1040 is fine; one at 1900
52
+ needs either a split or a named exemption (below), and the question to ask at
53
+ that size is which responsibility it has picked up rather than how many lines
54
+ it has. A barrel is exempt entirely, because its length is a function of how
55
+ many names it re-exports.
56
+
57
+ **Where this number comes from.** It was set by measuring the frameworks this
58
+ project takes its cues from, not by picking a round figure:
59
+
60
+ | Module | Lines |
61
+ |---|---|
62
+ | `lit/packages/lit-html/src/lit-html.ts` | 2303 |
63
+ | `lit/packages/reactive-element/src/reactive-element.ts` | 1754 |
64
+ | `vite/packages/vite/src/node/optimizer/index.ts` | 1487 |
65
+ | `vite/packages/vite/src/node/server/index.ts` | 1447 |
66
+
67
+ Every one of them draws its seams by responsibility and lets the orchestration
68
+ entry stay large. None of them enforces a line count.
69
+
70
+ **A much smaller cap is a bad trade, and this repo has the measurement.**
71
+ Splitting ten modules into about ninety produced three bindings that needed
72
+ accessors because ESM forbids assigning an imported binding, one dropped import
73
+ that threw only inside a deferred callback, one symbol identity swap that
74
+ silently broke slot forwarding, and three drift guards that broke or would have
75
+ passed vacuously. Four of those six were invisible to `npm test`. Every module
76
+ boundary is a place where those failures can happen, so boundaries are worth
77
+ adding for cohesion and worth nothing when added to hit a number.
78
+
79
+ There is also a runtime cost. In dev the browser fetches core source files
80
+ individually rather than the bundle, so N modules is N requests at one more
81
+ level of import-graph depth.
82
+
83
+ **The ceiling is measured with a RAW line count** (the number `wc -l` prints,
84
+ the number you see when you open the file), because that is how #1365 specified
85
+ it and because a metric a reader cannot reproduce by looking at the file invites
86
+ argument about the metric instead of the module. The tension with dense
87
+ documentation is real: this repo's comment style can put a well-factored module
88
+ at twice its code size, and a raw ceiling must never become a reason to delete
89
+ explanation. The answer to that tension is the exemption list, not a different
90
+ metric.
91
+
92
+ **There is deliberately NO CI guard for this.** A line-count gate is a proxy
93
+ metric that fights cohesion: it reds forever on a generated data table like
94
+ `html-entities.js`, and it has to carry an exemption list that rots. So the
95
+ ceiling is a REVIEW-TIME check, not a test. Measure it when you split something:
96
+
97
+ ```sh
98
+ find <the tree you produced> -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'
99
+ ```
100
+
101
+ **A module that genuinely cannot or should not go under the ceiling gets a
102
+ NAMED exemption**, argued in the PR that produces it, with its measured size and
103
+ its reason. The three exemptions the #1365 split carries show what a valid
104
+ reason looks like:
105
+
106
+ - **lit parity** (`component/lifecycle.js`): the file tracks lit's
107
+ `reactive-element.ts`, which lit keeps WHOLE at 1754 lines, and the project's
108
+ standing decision is to keep lit-derived code close to lit rather than
109
+ restructure it.
110
+ - **mutual recursion** (`render-client/parts.js`): the apply and instance group
111
+ calls back into itself, so a real split creates the import cycle the D4 rule
112
+ forbids, and the escape (a runtime dispatch registry) is a worse trade.
113
+ - **a single closure over shared request state** (`dev/handler.js`): splitting
114
+ means threading that state through a context object, a high-risk rewrite of
115
+ every app's boot path for zero behaviour gain.
116
+
117
+ Note what a valid reason is NOT: "it is mostly comments." If a module is over
118
+ the ceiling only because it is well documented, that is a signal the ceiling is
119
+ being measured too literally, not grounds for an exemption. Say why the CODE
120
+ cannot be split, or split it.
121
+
122
+ ---
123
+
124
+ ## Splitting a module into a barrel plus a directory
125
+
126
+ The naming rule, settled once for the whole framework: **the original file keeps
127
+ its path and becomes the barrel, and its parts land in a sibling directory named
128
+ after it.** So `packages/core/src/slot.js` stays, and its parts go in
129
+ `packages/core/src/slot/`. Do NOT rename the barrel to match a public export
130
+ subpath. The `package.json` `exports` map, the hand-written `.d.ts` overlays and
131
+ their two guard tests, the docs pages that print importmap examples, and every
132
+ relative test import all key off the current path.
133
+
134
+ ### The rule that matters most
135
+
136
+ **A split is a MOVE, not a rewrite.** Retyping a function while relocating it is
137
+ how a refactor with a green export surface ships behaviour changes. Move the
138
+ lines verbatim. The only edits a move should produce are the `export ` keyword
139
+ where a declaration now crosses a module boundary, and the generated import
140
+ lines.
141
+
142
+ ### Where mutable module state goes
143
+
144
+ A module-scope `let` goes in the module that WRITES it, not the module that
145
+ looks like its topical home. ESM import bindings are read-only, so a module
146
+ cannot assign a binding it imported.
147
+
148
+ When two modules genuinely write the same binding, the owner exposes a
149
+ one-statement accessor and the other module calls it:
150
+
151
+ ```js
152
+ // scroll.js owns the counter.
153
+ export let restoreGeneration = 0;
154
+ export function bumpRestoreGeneration() { restoreGeneration += 1; }
155
+
156
+ // navigator.js reads the live binding and calls the accessor to write.
157
+ import { restoreGeneration, bumpRestoreGeneration } from './scroll.js';
158
+ ```
159
+
160
+ Keep importing the binding itself wherever it is READ. Dropping it from the
161
+ import list while a read site survives leaves a free variable, which throws only
162
+ when that line executes. In the client router that meant a Back-button scroll
163
+ restore silently landing at offset 0, with every node test still green.
164
+
165
+ ### Cycles and TDZ
166
+
167
+ Layer the modules and never import upward. Node tolerates an import cycle, but
168
+ reading a `const` or `class` binding during the cycle's evaluation phase throws
169
+ a TDZ `ReferenceError` at module load, and in a minified browser bundle that is
170
+ a blank page rather than a test failure.
171
+
172
+ Where a back edge is unavoidable, resolve it by calling a function at call time
173
+ rather than reading a binding at module scope.
174
+
175
+ ### Symbol identity
176
+
177
+ `Symbol('x')` mints a unique value. `Symbol.for('x')` looks one up in the global
178
+ registry by string. They are never interchangeable, and substituting one for the
179
+ other produces a value that no existing object carries, so every lookup quietly
180
+ returns `undefined`. Import the symbol from the module that created it.
181
+
182
+ ### Drift guards read source files by path
183
+
184
+ This repo has guard tests that `readFileSync` a source file and grep it for a
185
+ constant, to pin two copies of a value against each other. Barrelling a file
186
+ breaks every one of them, and breaks them in two different ways:
187
+
188
+ - an `assert.match` fails on its own precondition, which is loud and fine;
189
+ - an `assert.doesNotMatch` starts passing **vacuously**, which is silent and is
190
+ the reason this is written down.
191
+
192
+ After any split, grep the test tree for reads of the file you just barrelled and
193
+ point each guard at the barrel PLUS every module beneath it.
194
+
195
+ ### Verification, in order
196
+
197
+ ```sh
198
+ # 1. Export surface is identical, in BOTH directions. `added` must be empty too:
199
+ # a behaviour-preserving split adds no public surface.
200
+ node --input-type=module -e "
201
+ const before = await import('/tmp/before.js');
202
+ const after = await import('./packages/core/src/<file>.js');
203
+ const A = Object.keys(before).sort(), B = Object.keys(after).sort();
204
+ console.log(JSON.stringify({
205
+ missing: A.filter((k) => !B.includes(k)),
206
+ added: B.filter((k) => !A.includes(k)),
207
+ }));
208
+ "
209
+
210
+ # 2. Every code line survived. Normalize away comments and the `export ` prefix,
211
+ # then diff. Anything left is a line the split CHANGED, and each one needs a
212
+ # reason in the commit message.
213
+
214
+ # 3. The module loads at all (catches a TDZ throw introduced by a cycle).
215
+ node --input-type=module -e "await import('./packages/core/index-browser.js')"
216
+ node --input-type=module -e "await import('./packages/core/index.js')"
217
+
218
+ # 4. Rebuild dist BEFORE any e2e or Bun run, which resolve the built bundle and
219
+ # would otherwise test the pre-split code and pass vacuously.
220
+ node scripts/build-framework-dist.js
221
+
222
+ # 5. The browser suite is MANDATORY for a renderer, router, component, or slot
223
+ # split. Those defects are post-hydration: the export surface is unchanged,
224
+ # the SSR bytes are unchanged, and node tests stay green.
225
+ npm test && npm run test:browser
226
+ ```
227
+
228
+ Step 5 is not optional and not a formality. Of the two silent defects this
229
+ codebase has hit from splitting, both were caught by the browser suite alone.
@@ -202,7 +202,7 @@ Export `GET` / `POST` / etc. as named async functions `(request, { params }) =>
202
202
 
203
203
  ### `middleware.ts` is per-segment and chainable, not one matcher config
204
204
 
205
- The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every request, outermost to innermost.
205
+ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every app request, outermost to innermost. Some requests are answered before it and never reach it, and the RULE is what to remember, not the list: anything the listener shell or the framework's pre-analysis stage answers bypasses root middleware, and everything routed with the app reaches it. That covers WebSocket upgrades bound for a `route.ts` exporting `WS`, the dev SSE stream at `/__webjs/events`, and the framework's own `/__webjs/*` runtime assets and probes; in DEV only it also covers `/public/*` plus the `/sw.js` / `/offline.html` root remaps and `/favicon.ico`, so a stylesheet is never queued behind the dev startup analysis. In production those static files go through root middleware normally. **`webjs.redirects` and `webjs.trailingSlash` are the case intuition gets wrong**: you configure them, but the framework resolves them ahead of middleware, so a 308 from a redirect rule is answered without root middleware running (redirect in the middleware instead when it has to observe those requests). Server actions go the other way: they are routed with the app, so middleware DOES run for an action call, which is what lets you gate actions with auth or rate limiting.
206
206
 
207
207
  ### No `<Link>`, no `next/navigation`, no `next/*` libraries
208
208
 
@@ -18,6 +18,30 @@ Pages and layouts run **only on the server** to produce HTML. They do NOT hydrat
18
18
 
19
19
  `route.ts` is the one routing file that is NOT isomorphic: a server-only HTTP handler, never shipped to the client.
20
20
 
21
+ ### How much of the page belongs in the component
22
+
23
+ "Put every interactive behaviour in a component" is not "put the section containing it in a component". Because a page never hydrates, the markup it renders costs the browser nothing, so a page that keeps its static content and delegates only the interactive fragment is both the cheapest and the conventional shape:
24
+
25
+ ```ts
26
+ // app/products/[id]/page.ts
27
+ export default async function Product({ params }: PageProps<'/products/[id]'>) {
28
+ const product = await getProduct(params.id);
29
+ return html`
30
+ <article>
31
+ <h1>${product.name}</h1>
32
+ <p>${product.description}</p>
33
+ <spec-table .rows=${product.specs}></spec-table>
34
+
35
+ <add-to-cart product-id=${product.id}></add-to-cart>
36
+
37
+ <review-list .reviews=${product.reviews}></review-list>
38
+ </article>
39
+ `;
40
+ }
41
+ ```
42
+
43
+ Only `<add-to-cart>` ships. `<spec-table>` and `<review-list>` are display-only, so the framework elides them and the browser fetches neither. Wrapping the whole article in a `<product-page>` component to "own the page" would ship all three, because a component rendered by a component that ships can no longer be elided. The sizing rule and a before/after are in `components.md` under "Sizing an island".
44
+
21
45
  ## Pages (`app/**/page.ts`)
22
46
 
23
47
  The default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. It returns a `TemplateResult`; it never calls `render()` itself.
@@ -187,8 +211,18 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere
187
211
 
188
212
  - `error.ts` default-exports `({ error, ...ctx }) => TemplateResult`; catches sibling-page and deeper render errors, innermost wins (prod sends only `error.message`).
189
213
  - `loading.ts` wraps the sibling page in `Suspense` with an immediately-flushed fallback.
190
- - `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal.
191
- - Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
214
+ - `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal, and receive the same ctx a page does (`params`, `searchParams`, `url`).
215
+ - **Every one of those boundaries renders INSIDE the layouts at and above its own segment** (#1298), so it carries the keyed `wj:children` pairs and a client-router navigation into a failing page stays a SOFT navigation with the surrounding chrome and its hydrated state intact. A layout deeper than the boundary is not rendered (it never rendered on the way in), and its module is not in the boundary's boot script; the boundary's own module IS. Since a boundary sits inside its own segment's layout, it cannot catch that layout, matching Next's `layout -> error -> page` hierarchy. What happens next differs by path, and the difference is worth knowing:
216
+
217
+ - On the **500 path**, a throwing layout is handled by the next `error.{js,ts}` OUT: the walk tries each boundary in the chain, innermost first, and a layout that throws fails every attempt whose wrapped set contains it, so control ends at `global-error` (or the default 500 page) when they are exhausted.
218
+ - On the **404 / 403 / 401 paths** there is NO outward walk. Each renders the one nearest boundary, so a throwing layout degrades that response to a chrome-less standalone render of the boundary with no boot script, keeping its status. A control-flow throw from a wrapped layout there (an auth-gate layout calling `redirect('/login')`) is discarded rather than honoured, deliberately: the status is already decided and the boundary page is the answer to that request.
219
+
220
+ A genuine layout crash is reported to `onError` (and to the dev overlay on the 404 / 403 / 401 paths, where nothing else claimed the frame) rather than being swallowed. Repeats of the SAME cause within one request collapse to a single report, because one shared layout can fail every boundary attempt (and, when the layout is what threw, arrives again as the error that produced the 500). The key is the STAGE plus the error's name, message and construction site: the stack below that site records how the throw was reached and differs on every re-render, so it cannot be part of the key, and the stage is what keeps `global-error`'s own crash from being swallowed by a boundary that failed through the same helper. Two DIFFERENT failures are both reported, and anything whose key cannot be derived safely (a non-Error throw) is always reported rather than risking a drop. A control-flow sentinel never is, since it is routing rather than a crash.
221
+
222
+ The BOUNDARY FILE's own crash (it throws, or fails to import at all) is reported the same way, and its response body follows the framework's standard rule for a thrown error: shown in dev, withheld in prod, where the page carries only its status. A thrown message is not author-controlled and may name a driver, a path or a connection string, so it does not reach the client; sanitizing the response never means losing the failure.
223
+
224
+ Two further consequences: a layout that fetches runs its fetch again on a boundary response, and a `<webjs-suspense>` inside a wrapped layout shows its fallback, because a boundary response is buffered so its status is final before the first byte. A 404 for a URL that matched NO route has no chain to wrap in and stays a bare document.
225
+ - Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). That verbatim document is exactly why it is the one boundary left UNWRAPPED: a second shell would nest inside the root layout's, wrapping it would re-run the code that just threw, and with no boot script it could not soft-swap anyway. `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
192
226
 
193
227
  Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.
194
228
 
@@ -35,8 +35,13 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
35
35
  | Hot reload | `node --watch` | `bun --hot` |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
+ | Dev edit to a page / layout | full reload (the `node --watch` restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
38
39
  | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
39
40
 
41
+ **The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
42
+
43
+ Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. Node's `bun --hot` equivalent is `node --watch`, which RESTARTS the process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
44
+
40
45
  The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
41
46
 
42
47
  Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site.
@@ -51,6 +51,17 @@ export default function BoundariesExample() {
51
51
  <code class="font-mono">not-found.ts</code> (404).
52
52
  </li>
53
53
  </ul>
54
+ <p class="text-muted-foreground text-sm">
55
+ Follow the first three links and notice what does NOT happen: the page
56
+ does not reload. A boundary renders inside the layouts at and above its
57
+ own segment, so the surrounding chrome survives and the navigation stays
58
+ a soft one. Two cases still reload, both for the same reason (there is no
59
+ shared shell to swap into): the fourth link, whose URL matches no route
60
+ at all, so there is no layout chain to render the
61
+ <code class="font-mono">not-found.ts</code> inside; and
62
+ <code class="font-mono">global-error.ts</code>, which owns its whole
63
+ document.
64
+ </p>
54
65
  <p class="text-muted-foreground text-sm">
55
66
  <code class="font-mono">forbidden()</code> is for an authenticated user who
56
67
  lacks permission (403); <code class="font-mono">unauthorized()</code> is for
@@ -26,7 +26,14 @@ export default function ClientRouterExample() {
26
26
  <a href="/features/client-router/second" class="${buttonClass()} no-underline">Go to page two</a>
27
27
  <a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
28
28
  </div>
29
- <p class="text-muted-foreground text-sm mt-6">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code>:</p>
29
+ <p class="text-muted-foreground text-sm mt-6">
30
+ Rendered on the server at
31
+ <code class="font-mono">${new Date().toISOString().slice(11, 19)}</code> UTC.
32
+ This page function runs only on the server, so this stamp changes on every
33
+ render, which is what makes <code class="font-mono">refreshPage()</code>
34
+ below visible.
35
+ </p>
36
+ <p class="text-muted-foreground text-sm mt-6">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code> / <code class="font-mono">refreshPage()</code>:</p>
30
37
  <router-controls></router-controls>
31
38
  <p class="text-muted-foreground text-sm mt-6">
32
39
  Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,