@ultimat3/render 21.0.0 → 22.0.0

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/CLAUDE.md CHANGED
@@ -4,110 +4,102 @@ Owns: the `route` primitive, the four render modes, the route table, the surface
4
4
  islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
5
5
  loaders that make an app's `.tsx` and `.scss` runnable**.
6
6
 
7
- **Two entry points, disjoint, split 2026-08-22** — the same `"."` / `"./server"` shape
8
- `@ultimat3/realtime` took, for the same reason. `"."` (`index.ts`) is the CLIENT half and BUNDLES
9
- for the browser; `"./server"` (`server.ts`) is the build-time half — `css-modules`,
10
- `module-loader`, `render-html`, `render-isr`, `render-ssr`, `render-static`, `render-stream` — and
11
- does not. `css-modules.ts` imports `fileURLToPath`/`pathToFileURL` from `node:url`, which Bun's
12
- browser polyfill exports NEITHER of, so the single barrel was not a fat bundle, it was a
13
- `bun build --target=browser` that FAILED at link time on any app entry that reached this package.
14
- Measured: no `sideEffects` value repairs it (`false`, `[]`, an array naming only `errors.ts` — all
15
- fail identically), which is where this split differs from realtime's, where the array alone was
16
- enough. Only not importing the module does. Never re-export a name from both barrels: disjointness
17
- is what makes "which half does this live in" a mechanical fact, and `index.test.ts` asserts both
18
- the empty name intersection AND that `index.ts`'s transitive runtime import graph reaches none of
19
- the seven modules above. `scripts/browser-barrel.test.ts` holds the end property, both directions.
7
+ **Two entry points, disjoint.** `"."` (`index.ts`) is the CLIENT half and bundles for the browser;
8
+ `"./server"` (`server.ts`) is the build-time half — `css-modules`, `module-loader`, `render-html`,
9
+ `render-isr`, `render-ssr`, `render-static`, `render-stream` — and does not (`css-modules.ts` needs
10
+ `node:url`, which a browser build cannot link; no `sideEffects` value repairs that). Never re-export
11
+ a name from both barrels: `index.test.ts` asserts the empty name intersection and that `index.ts`'s
12
+ runtime import graph reaches none of those seven modules; `scripts/browser-barrel.test.ts` holds
13
+ the end property.
20
14
 
21
- `island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a
22
- second render mode — the same rule `llm()` and `backfill()` follow. It adds no key to
23
- `defineRoute`.
15
+ `island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a second
16
+ render mode. It adds no key to `defineRoute`.
24
17
 
25
18
  Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cache`, `seo`,
26
- `entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`, `ui`
27
- — all tier 4, so **sideways**, and an undeclared sideways edge is a build error. `ui` moved 5 → 4
28
- in 2026-08 and is held level with this package deliberately (`FLOOR_ABOVE` in
29
- `scripts/lib/tiers.ts`), so `render → ui` stays refused; this package sits above its own floor of 2
30
- for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upward).
19
+ `entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`, `ui` — all
20
+ tier 4, so sideways. This package sits above its floor of 2 so `render → pwa` stays refused, and
21
+ `ui` is held level with it so `render → ui` stays refused (`FLOOR_ABOVE` in `scripts/lib/tiers.ts`;
22
+ axiom 6). Never `cli` (upward).
31
23
 
32
24
  | Rule | Detail |
33
25
  |---|---|
34
26
  | `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
35
- | `hydrate` | **optional since 1.2.0, derived from `island()`** — `'interaction'` when the module declared one, `'never'` when it did not. Declaring it still wins and is the only way to reach `idle` / `visible`. Not a widening of the contract: it is the one key the declaration above it already answers, and requiring it meant two failures (`X_ISLAND_NOT_HYDRATED`, and a `site/` route refused for a missing `budget.js`) for one omission. Never give the island its own strategy instead — `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`, and two islands wanting different timings would leave it with no true answer. |
36
- | `defineRoute` shape | exactly the contract's 9 keys. New route *metadata* still goes inside `meta` — `load` is not metadata, it is the data `meta` already took a parameter for. |
37
- | `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Two resolutions is a `<title>` describing content the body does not contain. Absent `load`, the context IS the data (`{ params, url }`), which is what `meta` received before the key existed — so no consumer branches on whether a route declared one. |
38
- | `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter — `unknown` when `RouteContext` satisfies `TData`, a required `load` when it does not. That is what makes the no-`load` fallback true rather than asserted: it was `ctx as unknown as TData`, so a `meta` reading `data.post` off a route that loads nothing type-checked and rendered `undefined` in a `<title>`. `RouteContext` is a type ALIAS for the same reason — only an alias carries the implicit index signature that makes it a `RouteData`; as an `interface` the compiler cannot see it and the cast comes back. |
39
- | A loader's own error | rethrown only when `isUltimateError` says so — core's brand, never a `code` property. Every `ENOENT` is an `Error` with a string `code`, and the duck-type that preceded this let all of them out of `routeDataFor` unwrapped: no `X_ROUTE_LOAD_FAILED`, no fix line, no route named. A tier-0 error (`@ultimat3/schema` cannot import core) is branded, not a subclass — never narrow this to `instanceof UltimateError`. |
40
- | A loader's own STATUS | `withStatus(status, data)` in `route-status.ts` — the ONE way a page answers 404 (or 410, or 503) while still rendering its own component inside the app's shell. Measured in ai-maxxing 2026-09-07: the only route to a status was a throw, which is the framework's error page OUTSIDE the shell, so `/fleet/nope` rendered the right page and answered 200. The status rides on the data by IDENTITY, in a `WeakMap` — the same object comes back, so `load`'s type, `routeDataFor`'s signature and every consumer that never asks are untouched, and a frozen or class-instance result is never written into. Never a `RouteContext` method: every builder of a context (`x dev`, the prerenderer, the SEO scan, both scaffold templates) would have to supply it, and an optional method is a second way. `routeStatusOf(data)` is the reader, TOTAL, 200 when nothing asked. A 3xx is `X_ROUTE_STATUS_INVALID` — a redirect is a `Location` and no body — and the range is `finiteStatus`'s. A 4xx/5xx is `robots.index = false` BY CONSTRUCTION in `defineRoute`'s `meta` wrapper, the one function every `<head>` renderer calls; a 200 hands the author's `meta` object back by reference. The response status itself is minted by `@ultimat3/cli`'s `dev-render.ts` (`resultFor`), which reads `routeStatusOf(data)` once and hands it to the mode — this package owns the seam, never the `Response`. |
41
- | Type claims | `type-pins.tsx`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one. `.tsx` since 1.2.0: the island-as-JSX claim is only decidable by writing the JSX an author writes, checked against the same `solid-js` `JSX.Element` a page is. |
27
+ | `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
28
+ | `defineRoute` shape | exactly the contract's 9 keys. New route *metadata* goes inside `meta`. |
29
+ | `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
30
+ | `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
31
+ | A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
32
+ | A loader's own STATUS | `withStatus(status, data)` (`route-status.ts`) — the ONE way a page answers 404/410/503 while rendering its own component in the app's shell. The status rides on the data by IDENTITY in a `WeakMap`; `routeStatusOf(data)` is the total reader (200 by default). A 3xx is `X_ROUTE_STATUS_INVALID`. A 4xx/5xx is `robots.index = false` by construction in `defineRoute`'s `meta` wrapper. The `Response` status is minted by `@ultimat3/cli`'s `dev-render.ts`. |
33
+ | Type claims | `type-pins.tsx`, never a `.test.ts` — `tsconfig.json` excludes tests. `.tsx` so the island-as-JSX claim is checked against the JSX an author writes. |
42
34
  | Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
43
- | Descriptor `budget` | always an object, `{}` when undeclared. Its *fields* stay optional — `budget.js === undefined` is the site/ hydration failure. |
44
- | `RouteBudget` keys | **`js` and `lcp`, and every key is PROJECTED** (`As of 2026-08-24`). `css`, `cls` and `tbt` were declared on the contract, flattened away by `registerRoute` — the descriptor carries `budgetJs` + `budgetLcp` and nothing else — and read by NOTHING: `x verify`'s `budgets` step, `x.manifest.json`, `x routes` and the dev dashboard all read those two. So `budget: { cls: 0.1 }` was accepted, normalised, stored and ignored while the gate that exists to enforce budgets reported green — the shape `jobs.driver` and `realtime.tier` had one declaration surface over. **Deleted, not wired**, for `PwaConfig.installPrompt`'s reason: the measuring half does not exist either. `type-pins.tsx` derives the allowed set from `RouteDescriptor`'s own `budget*` keys, so a key added here without a projection is a build error; `scripts/declaration-readers.ts` is the rule that finds the class across every declaration. **Breaking**: three keys are gone from `RouteBudget`. |
45
- | No `describe()` on a route | `describeRoutes()` is the one route list. A per-route projection would be a second one. |
46
- | Mode invariants | `modes.ts` only. Never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component** — `spa` did not, and `renderSpa` never read `entry.component`, so a `spa` route served `<div id="x-root"></div>` in every app that ever declared one. A gated page is `ssr`; a body that belongs in the browser is an `island({ src })`. A fifth mode has to name the function that renders it. |
47
- | Island declaration | `island({ src })` — a **specifier**, never an import. That is the whole boundary: a string has no scope to close over and no edge for a bundler or `checkSurfaceBoundary` to follow, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
48
- | Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling, same rule as `page.tsx`: a module ships to the browser iff its name says so. Never widen it to accept a second, and never make it optional. |
49
- | Island timing | the route's `hydrate` and nothing else — derived from the declaration, never declared on the island. An island declaring its own strategy would be a second way to say "this route hydrates" (axiom 1) and a second thing `budget.js` would have to chase. `hydrate: 'never'` + an island is still `X_ISLAND_NOT_HYDRATED`, and so is an `island()` call *below* the `defineRoute` that drains it. The `fix:` names exactly ONE of the two edits — `islandNeverDrained(spec)` tells the causes apart at the throw site, and a message offering both makes half the instruction wrong for every reader. |
50
- | Island node shape | a **branded array** (`IslandNode extends Array<never>`), and every walker tests `isIslandNode` BEFORE `Array.isArray`. An app types JSX with `jsxImportSource: solid-js`, whose `JSX.Element` is a type ALIAS — unaugmentable — and whose only object-shaped member is `ArrayElement`; a plain object was TS2786 at every `<Island />`, so the feature only worked through `h(Island, …)`, which is what render's own tests used. The array stays empty; the shell is `props.children`. Never satisfy this by importing solid's union. |
51
- | Island declaration order | `island()` above `defineRoute`, drained by it (`drainDeclaredIslands`). Package-internal — reachable from `./island`, never re-exported by `src/index.ts`: an app calling the drain between its `island()` and its `defineRoute` would silently un-declare the islands the route derives everything from, and a public export is semver-locked the moment it ships. Ambient, and NOT the thing the collector refuses to be: that one is per RENDER, where two requests would bill each other; this one is per MODULE, evaluated once, before any request — and `src` is resolved relative to the route file, so an `island()` call is route-module-local by construction. |
52
- | Derived budget | `registry.ts`, not `defineRoute`: a ceiling is only meaningful against a surface baseline, and the surface is a fact of the file path the route table already reads. `site/` → `20kb`, `app/` → `34kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`). **Calibrated on a Solid island** `As of 2026-08`: it was `4kb`, sized from `contact-sales.island.tsx`, which imports no `solid-js` at all — and `render(() => <p>hello</p>, el)` measures 12,588 B, so the default sat a factor of three below the floor of every island that uses the JSX runtime, on every surface. A declared `budget.js` wins; a `'never'` route gets none, so the contradiction stays visible. |
53
- | `RouteEntry.islands` | filled from `config.islands` at registration, and from nothing else — `RegisterRouteInput` has no `islands` key. It was `input.islands ?? []`, undocumented and passed by nothing, so the now-deleted `routeJsBytes`'s "what registration declared" half read `[]` on every route in the framework's history; keeping it as a fallback would be a second answer to one question that can only ever weaken it, since a caller passing `[]` un-declares an island. The field survives its one former reader: it is the only record a build has of an island a page declared but did not render on a given pass. |
54
- | Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` — `island-props.ts` is the one gate. A structural walk, never a `JSON.stringify` round trip: stringify drops a function and an `undefined` silently, which is the footgun rather than the check. The cap is **16 KiB** `As of 2026-09-05` (was 4; a 34-row catalog at 8,812 B was a runtime 500) and its doc comment carries the arithmetic — the bag is inlined as a JSON script `measureDocumentJs` counts as zero JS, so this constant is the only ceiling on it. Over the cap, the cause names the **heaviest props with their bytes** and the fix names the endpoint pattern (`models: []` + `modelsEndpoint`), never "raise the cap". `X_ISLAND_PROPS_INVALID` has a **row** in `@ultimat3/http`'s table (500): unclassified, `problem+json` blanked the one sentence that is the instruction. |
55
- | A prop lands via `Object.defineProperty` | never `out[key] = v`. For exactly one name — `__proto__`, which `JSON.parse` mints as a real OWN key off any request body — the assignment runs `Object.prototype`'s setter: the prop was DROPPED from the browser payload (the footgun the walk exists to prevent), the record handed back as `IslandProps` carried a prototype built from request data, so a later `bag.row.isAdmin` on the SERVER read attacker-chosen values, and `ISLAND_PROPS_MAX_BYTES` under-counted because `JSON.stringify` could not see it. Same shape `@ultimat3/mcp`'s `validate-args.ts` uses for the same class. |
56
- | An attribute alias is a `Map` | never a record — an object lookup walks the prototype chain, so `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and `attribute.toLowerCase()` threw a bare `TypeError`: no code, no fix, the whole page 500s off a `load()` result. Same reason `MODE_SPECS[config.render]` in `modes.ts` is guarded by `Object.hasOwn`, where `render: 'constructor'` returned a frozen descriptor for a mode nothing implements. |
57
- | An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` — `/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`, refused as `null`. A name is emitted VERBATIM before the `=` and is escaped nowhere, so `{ 'q onmouseover=alert(1) r': 'ok' }` shipped a live event handler out of an object KEY. The handler check on the line under it folds case for the same reason: it was `name.startsWith('on')` while the two checks below it lowercased, so `ONERROR="alert(1)"` went out on the wire. Both are reachable by `<div {...row} />` over a JSON body or a JSONB column. `head.ts`'s `renderTag` is the package's OTHER attribute sink and shares the predicate (`isAttributeName`) — it emitted `<meta name="q" r onmouseover=alert(1) s="ok">` from an `attrs` key. One predicate, never two. |
58
- | Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four (`href`, `src`, `action`, `formaction`) plus `data`, `poster`, `ping` and `xlink:href`, each of which a browser FOLLOWS. `srcdoc` is refused outright: its value is entity-decoded and THEN parsed as HTML, so `escapeAttribute`'s `&lt;script&gt;` becomes a live `<script>` on this origin — escaping cannot make markup inert, so the attribute is never emitted, the same way `innerHTML` stays the one explicit escape hatch. |
59
- | Island collection | per render, passed as `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context — two concurrent requests would bill one page for the other's JS, and `assertNoPerRequestState` refuses a live context under `static` anyway. |
60
- | A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. This package's copy stopped at `kb`, so a 5 MB route read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa`'s own copy said `5mb` for the same bytes — two halves of one build disagreeing about the size of one artifact. Still on this barrel, because `@ultimat3/cli`'s budget reporter reads it beside the route table. |
61
- | Island bytes | **not this package's**, `As of 2026-08-23`. `routeJsBytes`, `graphFor`, `checkBudget`, `checkBudgets`, `assertBudget` and the `Island` / `BundleGraph` / `RouteBytes` / `BudgetReport` types were exported from the barrel and called by NOTHING outside this package's own tests — the near-miss is `@ultimat3/cli`'s own `checkBudgets` in `packages/cli/src/budgets.ts`, which measures the EMITTED document and is the gate that runs. Deleted rather than wired, because two answers to "what does this route weigh" — one of them never asked — is axiom 1, and a build error nothing calls is not a build error. What survives here is the budget GRAMMAR (`parseByteBudget`, which the CLI does import) and `defaultIslandBudget`, which `registerRoute` reaches. **Breaking**: the six functions and five types are gone from the public API. |
62
- | The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit, one per non-empty subset of the three strategies, seven in all. It is emitted INLINE in every document carrying an island, and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so under the enforced policy a container serves (`dev: false`) **no island ever booted** — invisible in `x dev`, where the policy is report-only. `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot, the mirror of `style-csp.ts`. Hashes and not a nonce, for `cspHashSource`'s own reason: a `render: 'static'` page is a file on disk. Never restate the concatenation — `runtimeBody` is the one place the served text and the hashed text are the same string. **Still uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>`, whose body is per-response and cannot be hashed. Unreachable today (`dev-render.ts` passes `holes: []`), and the first real hole needs a nonce, not a hash. |
63
- | Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`, so a document assembler has exactly one thing left to remember: `hydrateRuntime(directives)`. |
64
- | Island boot | `el.__x` holds the boot PROMISE, never a boolean. As a flag, a second interaction while the chunk was still loading got a resolved promise back and the replay queue flushed into an island that had not mounted — the events went nowhere and the listeners were already removed. |
65
- | Island mount markers | `data-x-mounted=""` when `mount()` RESOLVED, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`, because the server does not know the answer. `el.__x` alone cannot carry this: it is assigned when `import()` is CALLED, so a chunk still downloading and one whose `mount()` threw were the same observable, and telling those apart is what `x shot` gates on. Two attributes rather than two values of one, so `[data-x-mounted]` never counts a failure as a success. The rejection handler RETHROWS — swallowing it resolves `el.__x` and the interaction runtime flushes its replay queue into an island that never mounted, which is the row above reintroduced one layer out. Costs 129 B of shared prelude: `idle` 1,744, `visible` 846, `interaction` 1,629 (`As of 2026-09-22`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from — `idle` moved 774 -> 1,744 and `interaction` 1,251 -> 1,629 when both began sharing `catchUp` and aiming by path, the rows below). |
66
- | A runtime that calls `boot` | TERMINATES the chain, because `boot` rethrows. `idle` and `visible` end in `.catch(hush)`; `interaction` passes `off` as the rejection arm of its `then`. A bare `boot(el)` produced a fresh rejected promise per call — one unhandled rejection per user event on an island whose `mount()` threw — and, on `interaction`, left `done` false, the listeners attached and the queue growing by one retained `Event` (each with a live `target`) per click, for an island that will never mount. Nothing is lost by swallowing here: the DOM already carries the failure as `data-x-failed`, which is the row above and the documented observable. `hydrate-runtime.test.ts` runs all three against a real module; Bun's runner fails a test on an unhandled rejection, so the omission reds the suite by itself. |
67
- | `idle` replays too | An `idle` island is pressable for the idle wait plus its chunk's download, and a press in that window reached a node with no handler: every app's first click could vanish. `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` — capture listeners for `data-x-events` (default `click`), a queue, `aim`, one flush — which answers `go`: a caught event calls it (so a press wakes an `idle` island early), and `idle` calls it again from `requestIdleCallback`. Every `go` chains on the same `el.__x` and the first flush sets `done`, so each event replays once and an untouched island still drops its listeners at mount. Emitted once when either strategy is on the page; a `visible`-only page pays nothing. `hydrate-replay.test.ts`'s idle block holds it. |
68
- | Where `interaction` replays | `aim(el, ev)` in `hydrate.ts`, never `ev.target`. Every island's `mount` opens with `el.textContent = ''` — the documented idiom, and what `settings`, `feed` and `like` all do — so the node the visitor pressed is DETACHED by the time the replay runs and `ev.target.dispatchEvent(c)` reached nothing: the first press did nothing and the second worked, which reads as a slow network and is never filed as a bug. `examples/dummy/apps/web/app/posts/[id]/page.tsx` declares `hydrate: 'idle'` in writing to avoid it. The runtime CAN tell the two mounts apart — `el.contains(ev.target)` AFTER the mount is the exact question — so this is a repair and not a refusal: refusing the pairing would delete a strategy that works today for a takeover-style island (`contact-sales.island.tsx` attaches to the server's own form and replaces nothing). Kept → the original target. Replaced → `document.elementFromPoint(ev.clientX, ev.clientY)`, which is where the event would land had the visitor pressed a moment later. The island ROOT is the last resort and never the repair: Solid's delegated listener sits on `document` and walks UP from the target (`solid-js/web`'s `eventHandler`), so a handler on a CHILD of the root is never visited and dispatching at the root fixes nothing for the canonical island. A hit landing outside this island falls back to the root too — synthesizing a click on an element the visitor never pressed is worse than losing the replay. `typeof ev.clientX === 'number'`, never `ev.clientX || ev.clientY`: (0, 0) is a coordinate — unless `ev.detail === 0`, a keyboard-activated or scripted `click()`, which fires at (0, 0) and names no point. Between the hit test and the root sits the STRUCTURAL answer: `path(el, target)` records the child-element indices and tag when the event is caught, and the replay walks them in the mounted tree — same tag at the same place is the island's render of the pressed control; a different tag falls to the root. `hydrate-replay.test.ts` holds it, and it is a separate file because `hydrate-runtime.test.ts`'s element is BOTH the island root and every event's target — the two answers are the same node there, which is how this survived. |
69
- | `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string. Exported because a second reader has to agree — `x shot` waits before it photographs, and a settle shorter than this deadline reports an unhydrated page for one that hydrates perfectly. A constant the emitted string restates instead of reading is worse than no constant. |
70
- | Route truth | `registry.ts`. Never keep a second route list anywhere, and never a second *matcher*: this package's `matchRoute` was deleted in 2026-08 with zero consumers, because `@ultimat3/http`'s trie (`stages.ts`) is the one that serves requests and two matchers with different precedence rules is two answers to "which route is this?". `routeFor` is an exact-path `Map` lookup, not a pattern matcher. |
71
- | Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`, one per surface. The URL is the directory path. Anything else is `X_ROUTE_FILE_INVALID`; never widen the table to accept a second spelling. |
72
- | Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED` — `defineRoute` is the one normalizer of everything the declaration alone decides, and every reader downstream assumes it ran. The registry fills in exactly one value on top: the island budget, which needs the surface, which is a fact of the file path only the route table reads. |
73
- | Descriptors | `describeRoutes()` must stay JSON-safe, sorted by path, deterministic. |
74
- | Every string order in this package | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare`. With no locale argument `localeCompare` answers from the runtime's ICU **default locale** and **collation version**: `'/A'` sorted after `'/a'` on one machine and before it on the next, and `'/zoo'` before `'/ärzte'` under `sv-SE` but after it under `en-US` — for the same route table. Three sites shipped it: `routeEntries()` (so `describeRoutes()`, and with it `x.manifest.json`, the sitemap and `sw.js`'s rule table), `checkSurfaceBoundary`'s report, and `pageComponentOf`'s fallback, whose own comment promised "the same one on every machine". Package-internal on purpose — a comparator is not public API. Same rule `@ultimat3/pwa`'s `precache.ts` and `service-worker.ts` state for the artifact they emit. |
35
+ | Descriptor `budget` | always an object, `{}` when undeclared. `budget.js === undefined` is the site/ hydration failure. |
36
+ | `RouteBudget` keys | **`js` and `lcp` only, and every key is PROJECTED** (`budgetJs`, `budgetLcp`). `type-pins.tsx` derives the allowed set from `RouteDescriptor`'s `budget*` keys, so an unprojected key is a build error; `scripts/declaration-readers.ts` finds the class across every declaration. |
37
+ | No `describe()` on a route | `describeRoutes()` is the one route list. |
38
+ | Mode invariants | `modes.ts` only; never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component.** A fifth mode has to name the function that renders it. |
39
+ | Island declaration | `island({ src })` — a **specifier**, never an import, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
40
+ | Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling. Never widen it. |
41
+ | Island timing | the route's `hydrate` and nothing else. `hydrate: 'never'` + an island, or an `island()` call below the `defineRoute` that drains it, is `X_ISLAND_NOT_HYDRATED`; `islandNeverDrained(spec)` tells the two causes apart so the `fix:` names exactly one edit. |
42
+ | Island node shape | a **branded array** (`IslandNode extends Array<never>`); every walker tests `isIslandNode` BEFORE `Array.isArray`. Solid's `JSX.Element` is an unaugmentable alias whose only object member is `ArrayElement`. Never import solid's union. |
43
+ | Island declaration order | `island()` above `defineRoute`, drained by it (`drainDeclaredIslands`), reachable from `./island` and never from `src/index.ts`. Per MODULE (evaluated once), unlike the per-render collector. |
44
+ | Derived budget | `registry.ts`, from the surface: `site/` → `20kb`, `app/` → `34kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`), calibrated on a Solid island. A declared `budget.js` wins; a `'never'` route gets none. |
45
+ | `RouteEntry.islands` | filled from `config.islands` at registration and nothing else — `RegisterRouteInput` has no `islands` key. |
46
+ | Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` (16 KiB; the doc comment carries the arithmetic) — `island-props.ts` is the one gate, a structural walk, never a `JSON.stringify` round trip. Over the cap the cause names the heaviest props and the fix names the endpoint pattern, never "raise the cap". `X_ISLAND_PROPS_INVALID` has a row in `@ultimat3/http`'s table (500). |
47
+ | A prop lands via `Object.defineProperty` | never `out[key] = v` — `__proto__` (a real own key off `JSON.parse`) would run the prototype setter. |
48
+ | An attribute alias is a `Map` | never a record (`toString` resolved to a function). `MODE_SPECS[config.render]` is guarded by `Object.hasOwn` for the same reason. |
49
+ | An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` (`/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`), refused as `null`; the `on*` handler check folds case. `head.ts`'s `renderTag` shares the predicate (`isAttributeName`). One predicate, never two. |
50
+ | Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four plus `data`, `poster`, `ping`, `xlink:href`. `srcdoc` is refused outright (entity-decoded, then parsed as HTML). |
51
+ | Island collection | per render, `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context. |
52
+ | A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. |
53
+ | Island bytes | **not this package's**: `@ultimat3/cli`'s `packages/cli/src/budgets.ts` measures the EMITTED document and is the gate. What stays here is the budget grammar (`parseByteBudget`) and `defaultIslandBudget`. |
54
+ | The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets). It is inline in every document with an island and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot. `runtimeBody` is the one place the served and hashed text are one string. **Uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>` (unreachable today; the first real hole needs a nonce). |
55
+ | Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`; an assembler only adds `hydrateRuntime(directives)`. |
56
+ | Island boot | `el.__x` holds the boot PROMISE, never a boolean. |
57
+ | Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-09-22`: `idle` 1,744, `visible` 846, `interaction` 1,629 B (what `DEFAULT_ISLAND_JS_BYTES` derives from). |
58
+ | A runtime that calls `boot` | TERMINATES the chain: `idle`/`visible` end in `.catch(hush)`, `interaction` passes `off` as the rejection arm. `hydrate-runtime.test.ts` reds on an unhandled rejection. |
59
+ | `idle` replays too | `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` (capture listeners for `data-x-events`, default `click`; a queue; `aim`; one flush). A caught event wakes an `idle` island early; each event replays once. `hydrate-replay.test.ts`. |
60
+ | Where `interaction` replays | `aim(el, ev)`, never `ev.target` — `mount` usually clears the root, detaching the pressed node. Kept → original target; replaced → `path(el, target)`'s structural walk (same tag, same place), then `document.elementFromPoint(ev.clientX, ev.clientY)` (unless `ev.detail === 0`), then the island root. A hit outside the island falls back to the root. `hydrate-replay.test.ts`. |
61
+ | `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
62
+ | Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
63
+ | Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`. Anything else is `X_ROUTE_FILE_INVALID`. |
64
+ | Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED`; it fills in only the island budget. |
65
+ | Descriptors | `describeRoutes()` stays JSON-safe, sorted by path, deterministic. |
66
+ | Every string order | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare` (ICU default locale and collation version vary by machine). |
75
67
  | Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
76
- | Stream cancellation | the underlying source has a `cancel()`, and `write` is guarded on it. A client that disconnects mid-stream aborts `StreamHole.resolve(signal)` and every later `write`/`close` is a no-op — `settle()` on a cancelled controller threw out of a `void`ed promise, one unhandled rejection per response, while the resolved holes kept doing their database work with nowhere to write. |
77
- | ISR detach | `attach()`'s returned function clears the revalidator as well as the dependents — and only if the slot is still its own, tracked in `installedRevalidator` because `@ultimat3/cache` holds ONE and offers no read back. Left installed, a detached controller and its whole store stayed reachable and kept receiving revalidations while the live one's pages never went stale. |
78
- | "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below both `modes.ts` and `render-isr.ts` (importing the latter from the former is a cycle through `registry.ts`). `hasRevalidateTrigger` accepted any non-empty string, so `revalidate: { ttl: '5 minutes' }` passed registration and parsed to `null` at serve time: generated ONCE, served for the life of the process, while the CDN was told `s-maxage=60` — the exact costume the `isr`-needs-a-trigger check refuses. |
79
- | A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts` — never `error instanceof Error ? error.message : String(error)`. A component that throws `Object.create(null)` escaped as a bare `TypeError` and one whose `message` getter throws as a bare `Error`, where `X_PRERENDER_FAILED` naming the file belongs. `bun run error-render` does NOT see this shape: it follows a direct interpolation, not a value laundered through a file-local `describe()` helper. |
80
- | `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `new URL(ctx.url)` inside the catch made a relative `ctx.url` — what a prerender pass, `x build` and every test harness hand in — throw a bare `TypeError` on the one path whose job is a coded error. `pathnameOf` never throws. |
81
- | The ISR key | `isrKey(url, locale)` — pathname, the negotiated LOCALE in the reserved `__x_locale` parameter, then the query, params sorted. The locale is a REQUIRED argument, so every call site has to answer: a document is rendered with `<html lang>` and every `t()` in the request's own locale, so one entry per path served visitor 2 the document negotiated for visitor 1 — for the whole TTL, and `s-maxage` told the CDN to do the same. `examples/dummy` ships en + es and three `isr` routes, so it was all three. A parameter and not a prefix because `routePathOf` splits a key at its `?`: a `es:/blog` key matches no route, so `descriptorFor` answers `undefined` and a declared `ttl` silently becomes tag-only. The time zone is deliberately NOT a dimension — unbounded where a locale set is declared — so a date on an `isr` page belongs in a zone the page itself names. `toResult` emits `vary: accept-language` for the CDN half; the rest of the shared key is added by `@ultimat3/http`'s `cache-headers` stage, which sees the actor this function cannot. |
82
- | A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set` — the same mechanism `CacheStack`'s read-through fill uses, never a second one grown here. `regenerate` rendered and then wrote `{ stale: false }` unconditionally, so a `markStale` arriving in between was ERASED by HTML built from pre-write rows; for a tag-only route `isFresh` is then true forever and the process serves it for the rest of its life. `registerPath` runs BEFORE the render for the other half: `revalidateByTags` reads the graph, so a bust could not see a cold path whose first render was still in flight. |
83
- | Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })`. `set` means "this page was just generated" and the default store orders eviction by exactly that, so the read-modify-write made the STALEST page the newest: a tag bust protected the pages that most needed regenerating and evicted the freshest one instead. **Breaking**: `markStale` is a required member of `IsrStore`. |
84
- | ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first — a route table supports `:params` and `*`, so `/blog/:slug` retains one full HTML string per slug ever requested, 404-shaped ones included. |
85
- | A `RenderResult.status` | `finiteStatus(subject, status)` in `finite-status.ts`, at every site that takes one from a caller (`renderSsr`'s `options.status`, `streamResult`'s third argument, `withStatus`'s first, and an `IsrRenderFn` answering `{ html, status }` at generation — `isRenderStatus`, the same range as a predicate, is the TOTAL read of a stored `IsrEntry.status` on the request path, because a custom store may JSON-round-trip one and a throw there is a 500 for the page's whole TTL) — 200–599, whole. It reaches `new Response(body, { status })`, which answers a bare `RangeError` for anything else: `NaN` arrives there as `The status provided (-9223372036854775808)`, no code, no fix, and the render fails two frames above the route that set it. The screen is NARROWER than the boundary on purpose — `new Response` also takes `101`, and no rendered document is a protocol switch. The name carries `finite` because `bun run finite-bounds` recognises a repair by the shape of the CALL; spelled `renderStatus` it read as no screen at all. |
86
- | `IsrEntry.ttlMs` off a store | normalised by `entryTtlMs`, TOTAL, never a throw — a ttl that is not a POSITIVE FINITE number of ms is the tag-only `null` `parseTtlMs` would have answered. `IsrStore` is a driver seam, and one backed by Redis round-trips the entry through JSON where a `ttlMs` nobody wrote reads back as `undefined`, so `entry.ttlMs === null` is false. Two failures, neither raising: `now - generatedAt < NaN` is false so the page is NEVER fresh and every request regenerates it, and the CDN is handed `s-maxage=NaN` — a directive a conforming cache IGNORES, dropping the page to heuristic caching. Read on the request path, so a refusal would turn a bad stored entry into a 500; the `isr.entry_ttl_invalid` warning is what keeps it from being silent. |
87
- | Route path from file | ONE reader of the surface segment: `locateSurface()` answers which surface AND where it starts. `registry.ts` sliced at `indexOf('app/')` instead, which matched inside `myapp/`, so every route under `apps/myapp/app/` served at `/app/…`. Never re-derive the offset from the surface NAME. |
88
- | An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts` is the one reader. `decodeURIComponent('%zz')` is a bare `URIError`, so a typo in a path segment was a 500 where `@ultimat3/http`'s router already answers "this branch does not match". A literal route matching the same text still wins. `router-client.ts` was the second reader and went with `createRouter`. |
89
- | ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). The store is bounded and evicts silently; `registered` and the cache graph behind it only ever grew, one edge per slug ever requested. Never a store callback — a custom `IsrStore` has none. |
90
- | Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15s), `holeTimeoutMs: null` to opt out, and a declared one is a whole number of at least **1** — a deadline is the bound a non-finite value does not disable but MOVES: `setTimeout(fn, NaN)` is `setTimeout(fn, 0)`, so every hole misses a deadline nobody set and the document is all fallbacks. There is no spelling of `holeTimeoutMs` that means "immediately". A hole is app code the framework `await`s and nothing else bounds it; one that never settles held the response and its whole closure open for the life of the process. A hole reveals exactly once — its promise, its rejection, or its deadline, whichever lands first. |
68
+ | Stream cancellation | the source has a `cancel()` and `write` is guarded on it; a disconnect aborts `StreamHole.resolve(signal)`. |
69
+ | ISR detach | `attach()`'s returned function clears the revalidator too — only if the slot is still its own (`installedRevalidator`). |
70
+ | "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below `modes.ts` and `render-isr.ts`. |
71
+ | A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts`. `bun run error-render` cannot see a value laundered through a local helper. |
72
+ | `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `pathnameOf` never throws on a relative `ctx.url`. |
73
+ | The ISR key | `isrKey(url, locale)` — pathname, the negotiated locale in the reserved `__x_locale` parameter, then the sorted query. The locale is REQUIRED. The time zone is deliberately not a dimension. `toResult` emits `vary: accept-language`; the rest of the shared key is `@ultimat3/http`'s `cache-headers` stage. |
74
+ | A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set`. `registerPath` runs BEFORE the render. |
75
+ | Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })` (`set` means "just generated" and orders eviction). |
76
+ | ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first. |
77
+ | A `RenderResult.status` | `finiteStatus(subject, status)` (`finite-status.ts`), 200–599 whole, at every site that takes one from a caller; `isRenderStatus` is the TOTAL read of a stored `IsrEntry.status`. The name carries `finite` so `bun run finite-bounds` recognises it. |
78
+ | `IsrEntry.ttlMs` off a store | normalised by `entryTtlMs`, TOTAL — anything not positive-finite is tag-only `null`, with an `isr.entry_ttl_invalid` warning. |
79
+ | Route path from file | `locateSurface()` answers which surface AND where it starts. Never re-derive the offset from the surface name. |
80
+ | An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts`. |
81
+ | ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). |
82
+ | Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15 s), `holeTimeoutMs: null` to opt out, otherwise a whole number ≥ 1. A hole reveals exactly once — promise, rejection or deadline. |
91
83
  | Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
92
84
  | Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
93
- | A gated route is never a cached one | `modes.ts` refuses `policy` on BOTH `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `isr` was not refused until 2026-08 and `dev-render.ts` keys the cache on `url.pathname` alone — no actor, no query string — so a gated ISR route rendered the first actor's document and served it to every later actor who passed the same policy. Keying on more is a trap, not a fix: the key would have to enumerate everything a `policy` and a `load` can read. `ssr` is the one gated mode there is. |
85
+ | A gated route is never a cached one | `modes.ts` refuses `policy` on both `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `ssr` is the one gated mode. |
94
86
  | Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
95
- | Who owns `cache-control` | a render mode states the MODE's intent; `@ultimat3/http`'s `cache-headers` stage makes the final answer and may overrule it. `ssrHeaders` writes `s-maxage=30` for any route without a `policy` — and `meta.auth` is `'public' \| 'required'`, so the page that greets a signed-in visitor by name is a `'public'` route whose own header offered it to a CDN. This package cannot see the actor and must never try: the fix is not a second actor check here. |
96
- | Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. The client half runs in an island chunk, which `@ultimat3/cli`'s `solid-loader.ts` compiles with `babel-preset-solid`: Solid's reactivity is a COMPILE-time contract, so nothing this package could inject would substitute for it. `router-client.ts` was the one file built on that premise ("inject primitives") and it never had a caller. |
97
- | Root element | `ROOT_ELEMENT_ID` (`render-html.ts`), the id every document's body wraps its component in. It was `SPA_ROOT_ID` in `render-spa.ts`, naming a mode that never used it and that no longer exists. |
98
- | The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once — `index.ts` until the barrel split, and it cannot be there again: the client barrel would carry `sass` and `node:url`. A plugin only affects modules loaded after it, so a second install point is a page that renders in one entry point and not another. Anything that loads an app's `.tsx` reaches `@ultimat3/render/server` first, which is why `packages/cli/src/app-load.ts` imports it for the side effect and nothing else. |
99
- | Client sync tags | `client-sync-tags.ts` — `ultimate-sync` (the socket URL: `/_x/sync` or a declared `wss://…`), `ultimate-build` (the build id a script cannot read off `x-ultimate-build`) and `ultimate-sync-worker` (absent when no worker was built). Principal-free, so every document carries them. The three names are literals here until core or realtime owns constants a reader imports. `As of 2026-09-22`. |
100
- | Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope" content="<opaque>">`, read once by core's `pageClient()` (plan 101). `documentCarriesScope(headers)` is the ONE rule for which documents may carry it: `cache-control` says `private` — a gated `ssr` page and every `stream`. A `static`/`isr`/ungated-`ssr` document is shareable and carries NO tag; absent means "not rendered for anyone", never `''` (anonymous). The literal `ultimate-scope` is duplicated in core's reader because core cannot import this package — the pin is owed once core exports its constant. `As of 2026-09-22`. |
101
- | `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can still override any of them. Absent until `As of 2026-08`, and the missing `viewport` is why every deployed app rendered zoomed-out on a phone whatever its CSS said. |
102
- | Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `escapeAttribute` itself is `@ultimat3/seo`'s (tier 1), re-exported by `html.ts` rather than reimplemented — the copy that lived here was the second escaper this row forbids, and `pwa/CLAUDE.md` already named seo's as the one. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
103
- | Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `&lt;` corrupts the code AND leaves the hole. |
104
- | Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
105
- | Stylesheets | compiled by `css-modules.ts`, still grouped per surface, and served by the CLI as **one content-hashed file per surface** (`@ultimat3/cli`'s `style-bundle.ts`) rather than inlined — measured 2026-09-06, the inline block was 156,738 bytes inside a `no-store` document, re-sent on every navigation. `stylesFor` is unchanged and is what that file is built from. `sass` is this package's only third-party dependency and its only reason to exist here. |
106
- | CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`), never plain insertion order — the reset styles bare elements at the lowest specificity there is, so whichever page loaded first must not decide who wins a tie. `shared/` is carried by both graphs, like a package sheet: it is where an app's own global layer lives, and filtering it out is what made every deployed app render token-less. |
107
- | The global layer | this package may not import `@ultimat3/ui` (tier 4, the same tier — sideways, not upward: `ui` moved 5 → 4 in 2026-08 and `render → ui` stays forbidden because a same-tier edge has to be declared in `scripts/lib/tiers.ts`, and this one deliberately is not — the static bundle graph may not reach the design system, axiom 6), so the app's source graph carries it: one `shared/global.scss` that `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts`. One file, because each stylesheet is its own Sass compilation — a token file `@use`d per module duplicates its `:root` block per module. `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
87
+ | Who owns `cache-control` | a mode states intent; `@ultimat3/http`'s `cache-headers` stage decides and may overrule. This package cannot see the actor and never tries. |
88
+ | Solid | no `solid-js` import anywhere in this package; `type-pins.tsx` satisfies `JSX.Element` structurally. `jsx.ts` builds inert nodes. Islands are compiled by `@ultimat3/cli`'s `solid-loader.ts` with `babel-preset-solid`. |
89
+ | Root element | `ROOT_ELEMENT_ID` (`render-html.ts`). |
90
+ | The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once. `packages/cli/src/app-load.ts` imports `@ultimat3/render/server` for that side effect. |
91
+ | Client sync tags | `client-sync-tags.ts` — `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker`. Principal-free, on every document. |
92
+ | Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope">`, read by core's `pageClient()`. `documentCarriesScope(headers)` is the one rule: only a `private` document carries it; absent means "not rendered for anyone". The literal is duplicated in core until core exports a constant. |
93
+ | `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can override any of them. |
94
+ | Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeAttribute` is `@ultimat3/seo`'s, re-exported by `html.ts`. |
95
+ | Script and style CONTENT | never raw: `escapeText`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
96
+ | Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
97
+ | Stylesheets | compiled by `css-modules.ts`, grouped per surface, served by the CLI as one content-hashed file per surface (`@ultimat3/cli`'s `style-bundle.ts`) from `stylesFor`. `sass` is this package's only third-party dependency. |
98
+ | CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`). `shared/` is carried by both graphs. |
99
+ | The global layer | the app's `shared/global.scss` `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts` (this package may not import `ui`). `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
108
100
  | Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
109
- | An island's `mount` | may return `() => void`, its disposer — `return render(…)`. The runtime resolves `el.__x` with it and never calls it; `@ultimat3/testing`'s `mountIsland` does, on dispose. |
110
- | `<head>` binding | `head.ts` stays injection-only (testable with no catalog); `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. A caller writing its own converter is the drift this file prevents. |
101
+ | An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
102
+ | `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
111
103
 
112
104
  Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
113
105
  Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
@@ -117,3 +109,5 @@ bun test # from packages/render
117
109
  bun run typecheck
118
110
  bun run --cwd ../.. verify # the contract
119
111
  ```
112
+
113
+ Why each rule above is shaped the way it is: [`docs/history/render.md`](../../docs/history/render.md).
package/README.md CHANGED
@@ -433,6 +433,28 @@ does.
433
433
  side effect. Anything that loads an app's source — `x dev`, `x build`, `server.ts`, a test that
434
434
  `await import()`s a `page.tsx` — reaches it before the module it loads.
435
435
 
436
+ ## Error classes
437
+
438
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
439
+ a job boundary the class is gone and the `code` is what survives — match on that.
440
+
441
+ | Class | Code | Declared in |
442
+ |---|---|---|
443
+ | `BudgetExceededError` | `X_BUDGET_EXCEEDED` | `src/errors.ts` |
444
+ | `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
445
+ | `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
446
+ | `IslandPropsInvalidError` | `X_ISLAND_PROPS_INVALID` | `src/errors.ts` |
447
+ | `PrerenderFailedError` | `X_PRERENDER_FAILED` | `src/errors.ts` |
448
+ | `RouteDuplicateError` | `X_ROUTE_DUPLICATE` | `src/errors.ts` |
449
+ | `RouteFileInvalidError` | `X_ROUTE_FILE_INVALID` | `src/errors.ts` |
450
+ | `RouteLoadFailedError` | `X_ROUTE_LOAD_FAILED` | `src/errors.ts` |
451
+ | `RouteLoadInvalidError` | `X_ROUTE_LOAD_INVALID` | `src/errors.ts` |
452
+ | `RouteMetaMissingError` | `X_ROUTE_META_MISSING` | `src/errors.ts` |
453
+ | `RouteModeInvalidError` | `X_ROUTE_MODE_INVALID` | `src/errors.ts` |
454
+ | `RouteOfflineMissingError` | `X_ROUTE_OFFLINE_MISSING` | `src/errors.ts` |
455
+ | `RouteStatusInvalidError` | `X_ROUTE_STATUS_INVALID` | `src/errors.ts` |
456
+ | `SurfaceBoundaryError` | `X_SURFACE_BOUNDARY` | `src/errors.ts` |
457
+
436
458
  ## Public API
437
459
 
438
460
  `†` marks a name on `@ultimat3/render/server`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "21.0.0",
3
+ "version": "22.0.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "21.0.0",
40
- "@ultimat3/core": "21.0.0",
41
- "@ultimat3/i18n": "21.0.0",
42
- "@ultimat3/seo": "21.0.0",
39
+ "@ultimat3/cache": "22.0.0",
40
+ "@ultimat3/core": "22.0.0",
41
+ "@ultimat3/i18n": "22.0.0",
42
+ "@ultimat3/seo": "22.0.0",
43
43
  "sass": "1.104.0"
44
44
  }
45
45
  }
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
8
8
  import { basename, dirname, resolve } from 'node:path';
9
9
  import { fileURLToPath, pathToFileURL } from 'node:url';
10
10
  import { renderThrowable } from '@ultimat3/core';
11
- import * as sass from 'sass';
11
+ import type * as Sass from 'sass';
12
12
  import { PrerenderFailedError } from './errors';
13
13
  import { contentHash } from './render-static';
14
14
 
@@ -38,7 +38,7 @@ export function isGlobalStylesheet(file: string): boolean {
38
38
  * Sass resolves relative `@use` itself; a bare specifier is Bun's job, because `@ultimat3/ui/tokens`
39
39
  * is an `exports` entry and only the module resolver knows where that lands.
40
40
  */
41
- const packageImporter = (from: string): sass.FileImporter<'sync'> => ({
41
+ const packageImporter = (from: string): Sass.FileImporter<'sync'> => ({
42
42
  findFileUrl(url: string, context: { readonly containingUrl?: URL | null }): URL | null {
43
43
  // Sass routes every load inside a file THIS importer supplied back to this importer, including
44
44
  // `_index.scss`'s own relative `@forward`s — so the filesystem lookup has to live here too, or
@@ -185,11 +185,24 @@ export function stripCharset(css: string): string {
185
185
  return css.replace(CHARSET_HEAD, '');
186
186
  }
187
187
 
188
+ let loadedSass: typeof Sass | undefined;
189
+
190
+ /**
191
+ * Dart Sass, loaded on the first compile and never at import: it is ~290ms of module evaluation,
192
+ * measured, and every process importing `@ultimat3/render/server` paid it — `x --help`, `x doctor`,
193
+ * a container role that renders no stylesheet. `require` and not `import()` because this function
194
+ * is synchronous: Bun's loader-plugin `onLoad` path in `module-loader.ts` calls it inline.
195
+ */
196
+ const sassCompiler = (): typeof Sass => {
197
+ loadedSass ??= require('sass') as typeof Sass;
198
+ return loadedSass;
199
+ };
200
+
188
201
  export function compileStylesheet(file: string, source: string): CompiledStylesheet {
189
202
  let css: string;
190
203
  try {
191
204
  css = stripCharset(
192
- sass.compileString(source, {
205
+ sassCompiler().compileString(source, {
193
206
  url: pathToFileURL(file),
194
207
  loadPaths: [dirname(file)],
195
208
  importers: [packageImporter(dirname(file))],
package/src/duration.ts CHANGED
@@ -6,13 +6,14 @@
6
6
  * the process while the CDN was told `s-maxage=60`.
7
7
  */
8
8
 
9
- const DURATION_UNITS: Readonly<Record<string, number>> = {
10
- ms: 1,
11
- s: 1_000,
12
- m: 60_000,
13
- h: 3_600_000,
14
- d: 86_400_000,
15
- };
9
+ /** A `Map`, never an object literal: a unit is read by a string key, and a `Map` has no prototype chain to answer for it. */
10
+ const DURATION_UNITS: ReadonlyMap<string, number> = new Map([
11
+ ['ms', 1],
12
+ ['s', 1_000],
13
+ ['m', 60_000],
14
+ ['h', 3_600_000],
15
+ ['d', 86_400_000],
16
+ ]);
16
17
 
17
18
  /** `'5m'` → 300000. Numbers pass through as milliseconds. */
18
19
  export function parseTtlMs(ttl: string | number | null | undefined): number | null {
@@ -22,6 +23,6 @@ export function parseTtlMs(ttl: string | number | null | undefined): number | nu
22
23
  const amount = match?.[1];
23
24
  const unit = match?.[2];
24
25
  if (amount === undefined || unit === undefined) return null;
25
- const factor = DURATION_UNITS[unit];
26
+ const factor = DURATION_UNITS.get(unit);
26
27
  return factor === undefined ? null : Number(amount) * factor;
27
28
  }
package/src/index.ts CHANGED
@@ -67,15 +67,13 @@ export {
67
67
  headFromMeta,
68
68
  mergeHead,
69
69
  renderHead,
70
- THEME_SCRIPT_MAX_BYTES,
71
70
  THEME_STORAGE_KEY,
72
71
  themeScript,
73
72
  themeScriptBody,
74
73
  } from './head';
75
- export { headTagKey, seoRenderers, toHeadTag } from './head-seo';
74
+ export { seoRenderers } from './head-seo';
76
75
  export type { IslandDirective } from './hydrate';
77
76
  export {
78
- DEFAULT_REPLAY_EVENTS,
79
77
  emitIslandAttributes,
80
78
  emitIslandProps,
81
79
  HYDRATE_RUNTIME_BODIES,
@@ -84,13 +82,10 @@ export {
84
82
  IDLE_HYDRATE_TIMEOUT_MS,
85
83
  ISLAND_FAILED_ATTRIBUTE,
86
84
  ISLAND_MOUNTED_ATTRIBUTE,
87
- requiredStrategies,
88
85
  } from './hydrate';
89
86
  export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
90
87
  export {
91
88
  ISLAND_EXTENSION,
92
- ISLAND_NODE,
93
- isEmittableSpecifier,
94
89
  isIslandNode,
95
90
  island,
96
91
  islandModuleId,
@@ -98,7 +93,7 @@ export {
98
93
  export type { IslandCollector, IslandCollectorInput } from './island-collector';
99
94
  export { createIslandCollector, islandModuleIds } from './island-collector';
100
95
  export type { IslandProps, JsonValue } from './island-props';
101
- export { checkIslandProps, ISLAND_PROPS_MAX_BYTES } from './island-props';
96
+ export { ISLAND_PROPS_MAX_BYTES } from './island-props';
102
97
  export { parseByteBudget } from './islands';
103
98
  export type { JsxComponent, JsxNode, JsxProps } from './jsx';
104
99
  export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
@@ -123,7 +118,6 @@ export {
123
118
  describeRoutes,
124
119
  ROUTE_FILENAME,
125
120
  registerRoute,
126
- routeCount,
127
121
  routeEntries,
128
122
  routeFor,
129
123
  routePathFromFile,
@@ -150,7 +144,8 @@ export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './r
150
144
  export type { RouteComponent } from './route-component';
151
145
  export { pageComponentOf } from './route-component';
152
146
  export { metaContextFor, routeDataFor } from './route-data';
153
- export { DEFAULT_ROUTE_STATUS, isErrorStatus, routeStatusOf, withStatus } from './route-status';
147
+ export { routeStatusOf, withStatus } from './route-status';
148
+ export { STREAM_REVEAL_BODIES } from './stream-scripts';
154
149
  export type {
155
150
  BoundaryRule,
156
151
  BoundaryViolation,
@@ -152,6 +152,9 @@ export function transformTsx(source: string): string {
152
152
  */
153
153
  export function loadStylesheet(path: string, source: string): string {
154
154
  const compiled = compileStylesheet(path, source);
155
+ // An EMPTY compile unregisters: under `x dev` an edit that deleted every rule left the old entry
156
+ // in place, serving rules the file no longer had until the process restarted.
157
+ if (compiled.css.length === 0 && stylesheets.delete(path)) revision += 1;
155
158
  if (compiled.css.length > 0) {
156
159
  if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
157
160
  stylesheets.set(path, {
package/src/registry.ts CHANGED
@@ -211,6 +211,8 @@ export function compilePattern(path: string): CompiledPattern {
211
211
  }
212
212
 
213
213
  const routes = new Map<string, RouteEntry>();
214
+ /** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
215
+ let described: readonly RouteDescriptor[] | undefined;
214
216
 
215
217
  export interface RegisterRouteInput<TData = RouteData> {
216
218
  readonly file: string;
@@ -290,6 +292,7 @@ export function registerRoute<TData = RouteData>(
290
292
  ...(input.component === undefined ? {} : { component: input.component }),
291
293
  };
292
294
  routes.set(path, entry as RouteEntry);
295
+ described = undefined;
293
296
  return entry;
294
297
  }
295
298
 
@@ -316,6 +319,7 @@ function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface):
316
319
 
317
320
  export function clearRoutes(): void {
318
321
  routes.clear();
322
+ described = undefined;
319
323
  }
320
324
 
321
325
  export function routeCount(): number {
@@ -337,6 +341,14 @@ export function routeFor(path: string): RouteEntry | undefined {
337
341
  * Determinism matters because `sw.js` and the sitemap are diffed across deploys.
338
342
  */
339
343
  export function describeRoutes(): readonly RouteDescriptor[] {
344
+ // Built once per registry change and handed out as the SAME frozen array: an ISR regeneration
345
+ // looked its route up through this on every request, re-sorting the whole table each time, and
346
+ // a stable identity is what lets `render-isr.ts` compile its matchers once per table.
347
+ described ??= Object.freeze(buildDescriptors());
348
+ return described;
349
+ }
350
+
351
+ function buildDescriptors(): RouteDescriptor[] {
340
352
  return routeEntries().map((entry) => ({
341
353
  path: entry.path,
342
354
  file: entry.file,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The ISR store: what an entry is, the driver seam an app may back with its own storage, and the
3
+ * bounded in-memory default. Split from `render-isr.ts`, which is the controller that reads it.
4
+ */
5
+
6
+ import { finiteCount } from '@ultimat3/core';
7
+
8
+ export type IsrState = 'miss' | 'hit' | 'stale';
9
+
10
+ export interface IsrEntry {
11
+ /**
12
+ * The store key: the request's pathname AND its query, params sorted. Not the route's pattern
13
+ * and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
14
+ * keying both as `/blog` served the second visitor the first one's HTML (#171).
15
+ */
16
+ readonly path: string;
17
+ readonly html: string;
18
+ readonly hash: string;
19
+ readonly generatedAt: number;
20
+ readonly ttlMs: number | null;
21
+ /** Set by a tag invalidation; independent of the TTL clock. */
22
+ readonly stale: boolean;
23
+ /**
24
+ * What the page answers, 200–599. Optional because an entry can come back from an app's own
25
+ * store, written before this field existed or JSON-round-tripped without it; absent reads as
26
+ * 200, the only status an entry ever had until `withStatus`.
27
+ */
28
+ readonly status?: number;
29
+ }
30
+
31
+ export interface IsrStore {
32
+ get(path: string): IsrEntry | undefined;
33
+ set(entry: IsrEntry): void;
34
+ /**
35
+ * Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
36
+ * not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
37
+ * store is entitled to order its eviction by that: the read-modify-write made the STALEST page
38
+ * the newest, so a tag bust protected exactly the pages that most needed regenerating.
39
+ */
40
+ markStale(path: string): boolean;
41
+ delete(path: string): void;
42
+ paths(): readonly string[];
43
+ }
44
+
45
+ /**
46
+ * How many rendered pages the default store holds. A route table supports `:params` and `*`, so
47
+ `/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
48
+ * included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
49
+ * the process.
50
+ */
51
+ export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
52
+
53
+ export interface MemoryIsrStoreOptions {
54
+ /** Pages retained. The least recently generated goes first. */
55
+ readonly maxEntries?: number;
56
+ }
57
+
58
+ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
59
+ // `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
60
+ // cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
61
+ const maxEntries = finiteCount(
62
+ 'memoryIsrStore',
63
+ 'maxEntries',
64
+ options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
65
+ );
66
+ const map = new Map<string, IsrEntry>();
67
+ return {
68
+ get: (path) => map.get(path),
69
+ set: (entry) => {
70
+ // Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
71
+ // the first key is the least recently generated page.
72
+ map.delete(entry.path);
73
+ map.set(entry.path, entry);
74
+ while (map.size > maxEntries) {
75
+ const oldest = map.keys().next();
76
+ if (oldest.done === true) break;
77
+ map.delete(oldest.value);
78
+ }
79
+ },
80
+ // In place: `map.set` on a key the Map already holds keeps its position, and that position is
81
+ // the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
82
+ markStale: (path) => {
83
+ const entry = map.get(path);
84
+ if (entry === undefined) return false;
85
+ map.set(path, { ...entry, stale: true });
86
+ return true;
87
+ },
88
+ delete: (path) => {
89
+ map.delete(path);
90
+ },
91
+ paths: () => [...map.keys()].sort(),
92
+ };
93
+ }
package/src/render-isr.ts CHANGED
@@ -15,101 +15,16 @@ import {
15
15
  sampleFence,
16
16
  unregisterDependent,
17
17
  } from '@ultimat3/cache';
18
- import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
18
+ import { logger, renderThrowable } from '@ultimat3/core';
19
19
  import { parseTtlMs } from './duration';
20
20
  import { finiteStatus, isRenderStatus } from './finite-status';
21
21
  import type { RouteDescriptor } from './registry';
22
22
  import { describeRoutes } from './registry';
23
+ import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
24
+ import { memoryIsrStore } from './render-isr-store';
23
25
  import { contentHash, staticHeaders } from './render-static';
24
26
  import type { RenderResult } from './route';
25
27
 
26
- export type IsrState = 'miss' | 'hit' | 'stale';
27
-
28
- export interface IsrEntry {
29
- /**
30
- * The store key: the request's pathname AND its query, params sorted. Not the route's pattern
31
- * and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
32
- * keying both as `/blog` served the second visitor the first one's HTML (#171).
33
- */
34
- readonly path: string;
35
- readonly html: string;
36
- readonly hash: string;
37
- readonly generatedAt: number;
38
- readonly ttlMs: number | null;
39
- /** Set by a tag invalidation; independent of the TTL clock. */
40
- readonly stale: boolean;
41
- /**
42
- * What the page answers, 200–599. Optional because an entry can come back from an app's own
43
- * store, written before this field existed or JSON-round-tripped without it; absent reads as
44
- * 200, the only status an entry ever had until `withStatus`.
45
- */
46
- readonly status?: number;
47
- }
48
-
49
- export interface IsrStore {
50
- get(path: string): IsrEntry | undefined;
51
- set(entry: IsrEntry): void;
52
- /**
53
- * Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
54
- * not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
55
- * store is entitled to order its eviction by that: the read-modify-write made the STALEST page
56
- * the newest, so a tag bust protected exactly the pages that most needed regenerating.
57
- */
58
- markStale(path: string): boolean;
59
- delete(path: string): void;
60
- paths(): readonly string[];
61
- }
62
-
63
- /**
64
- * How many rendered pages the default store holds. A route table supports `:params` and `*`, so
65
- `/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
66
- * included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
67
- * the process.
68
- */
69
- export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
70
-
71
- export interface MemoryIsrStoreOptions {
72
- /** Pages retained. The least recently generated goes first. */
73
- readonly maxEntries?: number;
74
- }
75
-
76
- export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
77
- // `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
78
- // cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
79
- const maxEntries = finiteCount(
80
- 'memoryIsrStore',
81
- 'maxEntries',
82
- options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
83
- );
84
- const map = new Map<string, IsrEntry>();
85
- return {
86
- get: (path) => map.get(path),
87
- set: (entry) => {
88
- // Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
89
- // the first key is the least recently generated page.
90
- map.delete(entry.path);
91
- map.set(entry.path, entry);
92
- while (map.size > maxEntries) {
93
- const oldest = map.keys().next();
94
- if (oldest.done === true) break;
95
- map.delete(oldest.value);
96
- }
97
- },
98
- // In place: `map.set` on a key the Map already holds keeps its position, and that position is
99
- // the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
100
- markStale: (path) => {
101
- const entry = map.get(path);
102
- if (entry === undefined) return false;
103
- map.set(path, { ...entry, stale: true });
104
- return true;
105
- },
106
- delete: (path) => {
107
- map.delete(path);
108
- },
109
- paths: () => [...map.keys()].sort(),
110
- };
111
- }
112
-
113
28
  /**
114
29
  * The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
115
30
  * because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
@@ -231,7 +146,7 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
231
146
  function descriptorFor(key: string): RouteDescriptor | undefined {
232
147
  const path = routePathOf(key);
233
148
  const table = routes();
234
- return table.find((r) => r.path === path) ?? table.find((r) => matchesRoute(path, r.path));
149
+ return table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
235
150
  }
236
151
 
237
152
  /**
@@ -413,11 +328,27 @@ function parseWireTag(wire: string): CacheTag {
413
328
  return { entity: wire.slice(0, split), id: wire.slice(split + 1) };
414
329
  }
415
330
 
331
+ interface RouteMatcher {
332
+ readonly route: RouteDescriptor;
333
+ test(storedPath: string): boolean;
334
+ }
335
+
336
+ /** One compiled set per route TABLE — `describeRoutes()` hands out one array per registry change. */
337
+ const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
338
+
416
339
  /** A stored path belongs to a route when the route's pattern matches it. */
417
- function matchesRoute(storedPath: string, routePath: string): boolean {
418
- if (!routePath.includes(':') && !routePath.includes('*')) return storedPath === routePath;
419
- const parts = routePath.split('/').map(segmentPattern);
420
- return new RegExp(`^${parts.join('/')}/?$`).test(storedPath);
340
+ function matchersOf(table: readonly RouteDescriptor[]): readonly RouteMatcher[] {
341
+ const cached = compiledTables.get(table);
342
+ if (cached !== undefined) return cached;
343
+ const compiled = table.map((route): RouteMatcher => {
344
+ if (!route.path.includes(':') && !route.path.includes('*')) {
345
+ return { route, test: (storedPath) => storedPath === route.path };
346
+ }
347
+ const pattern = new RegExp(`^${route.path.split('/').map(segmentPattern).join('/')}/?$`);
348
+ return { route, test: (storedPath) => pattern.test(storedPath) };
349
+ });
350
+ compiledTables.set(table, compiled);
351
+ return compiled;
421
352
  }
422
353
 
423
354
  function segmentPattern(segment: string): string {
@@ -8,12 +8,13 @@ import { renderThrowable, useContext } from '@ultimat3/core';
8
8
  import { PrerenderFailedError, RouteModeInvalidError } from './errors';
9
9
  import type { RouteEntry } from './registry';
10
10
  import type { RenderResult, RouteParams } from './route';
11
+ import { filePathOf, filledSegments, urlPathOf } from './static-path';
11
12
 
12
13
  export interface StaticArtifact {
13
14
  readonly path: string;
14
15
  readonly params: RouteParams;
15
16
  readonly html: string;
16
- /** FNV-1a of the HTML. Stable across machines and across Bun versions. */
17
+ /** `contentHash` (xxHash32) of the HTML. Stable across machines and across Bun versions. */
17
18
  readonly hash: string;
18
19
  /** Where the file lands on disk, relative to the build output root. */
19
20
  readonly outputPath: string;
@@ -25,14 +26,15 @@ export type StaticRenderFn = (input: {
25
26
  readonly params: RouteParams;
26
27
  }) => string | Promise<string>;
27
28
 
28
- /** Deterministic, dependency-free 32-bit FNV-1a, hex. */
29
+ /**
30
+ * xxHash32 (seed 0) of the UTF-8 bytes, 8 hex characters. Native: FNV-1a in JS measured 134 µs on a
31
+ * 96 kB document against 21 µs here, and every static page, ISR regeneration and CSS module hashes
32
+ * through it. xxHash32 is a SPECIFIED algorithm — the test pins its reference vectors — so the value
33
+ * is stable across machines and Bun versions, as the FNV one was. Switching was a one-time cache
34
+ * bust: every ETag and every scoped CSS class name changed once, in 22.0.0.
35
+ */
29
36
  export function contentHash(input: string): string {
30
- let hash = 0x811c9dc5;
31
- for (let i = 0; i < input.length; i += 1) {
32
- hash ^= input.charCodeAt(i);
33
- hash = Math.imul(hash, 0x01000193) >>> 0;
34
- }
35
- return hash.toString(16).padStart(8, '0');
37
+ return Bun.hash.xxHash32(input).toString(16).padStart(8, '0');
36
38
  }
37
39
 
38
40
  /**
@@ -115,7 +117,8 @@ export async function renderStatic(
115
117
 
116
118
  const artifacts: StaticArtifact[] = [];
117
119
  for (const params of paramSets) {
118
- const path = fillPath(entry.pattern.source, params);
120
+ const segments = filledSegments(entry.pattern.source, params);
121
+ const path = urlPathOf(segments);
119
122
  let html: string;
120
123
  try {
121
124
  html = await render({ path, params });
@@ -131,7 +134,7 @@ export async function renderStatic(
131
134
  params,
132
135
  html,
133
136
  hash,
134
- outputPath: `${path === '/' ? '' : path}/${indexFile}`.replace(/^\/+/, ''),
137
+ outputPath: filePathOf(segments, indexFile),
135
138
  headers: staticHeaders(hash, options.buildId),
136
139
  });
137
140
  }
@@ -152,17 +155,11 @@ export function staticResult(artifact: StaticArtifact): RenderResult {
152
155
  return { status: 200, headers: artifact.headers, body: artifact.html };
153
156
  }
154
157
 
155
- /** `/blog/:slug` + `{ slug: 'hello' }` → `/blog/hello`. */
158
+ /**
159
+ * `/blog/:slug` + `{ slug: 'hello' }` → `/blog/hello`, each segment percent-encoded. A missing
160
+ * param, a dot segment, a separator inside a `:param`, NUL, `?` and `#` are `X_PRERENDER_FAILED`
161
+ * (`static-path.ts`) — they wrote a `:slug` directory, or a file outside the build output.
162
+ */
156
163
  export function fillPath(pattern: string, params: RouteParams): string {
157
- return (
158
- pattern
159
- .split('/')
160
- .map((segment) => {
161
- if (segment.startsWith(':')) return params[segment.slice(1)] ?? segment;
162
- if (segment.startsWith('*')) return params[segment.slice(1)] ?? '';
163
- return segment;
164
- })
165
- .join('/')
166
- .replace(/\/+$/, '') || '/'
167
- );
164
+ return urlPathOf(filledSegments(pattern, params));
168
165
  }
@@ -13,8 +13,9 @@
13
13
 
14
14
  import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
15
15
  import { finiteStatus } from './finite-status';
16
- import { escapeAttribute, escapeRawTextContent } from './html';
16
+ import { escapeAttribute } from './html';
17
17
  import type { RenderResult } from './route';
18
+ import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
18
19
 
19
20
  export interface StreamHole {
20
21
  /** Stable within a response; becomes the DOM id, so keep it short. */
@@ -60,20 +61,14 @@ export function holeMarker(id: string, fallback: string): string {
60
61
  * The entire client half of out-of-order streaming. Inline, uncompressed, ~200 bytes; it
61
62
  * moves a late `<template>`'s content into the placeholder that is already on screen.
62
63
  */
63
- export const REVEAL_SCRIPT =
64
- "<script>window.$X=function(i){var t=document.querySelector('template[data-x-hole=\"'+i+'\"]')," +
65
- 's=document.getElementById(i);if(t&&s){s.replaceWith(t.content);t.remove()}}</script>';
64
+ export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
66
65
 
67
66
  export function revealChunk(id: string, html: string): string {
68
- const key = holeId(id);
69
- // Two contexts, two encoders — the attribute takes `escapeAttribute`, and the script argument is
70
- // built by `JSON.stringify` so the id is a JS string LITERAL rather than text pasted between two
71
- // quotes: `a");alert(1);//` closed the call and ran on the page's own origin. `</script` inside
72
- // it would still end the element, so the raw-text rule applies over the top, as `html.ts` says.
73
- const argument = escapeRawTextContent(JSON.stringify(key));
67
+ // The id reaches markup ONLY through `escapeAttribute` — never a script — so a quote in it
68
+ // cannot close anything, and no per-hole body exists for a CSP to fail to list.
74
69
  return (
75
- `<template data-x-hole="${escapeAttribute(key)}">${html}</template>` +
76
- `<script>$X(${argument})</script>`
70
+ `<template data-x-hole="${escapeAttribute(holeId(id))}">${html}</template>` +
71
+ `<script>${REVEAL_CALL}</script>`
77
72
  );
78
73
  }
79
74
 
@@ -186,7 +181,7 @@ export function renderStreamHtml(
186
181
  timeoutMs === null
187
182
  ? undefined
188
183
  : setTimeout(() => {
189
- logger.warn(`stream hole ${hole.id} missed its ${timeoutMs}ms deadline`);
184
+ logger.warn('render.stream.hole_deadline', { hole: hole.id, timeoutMs });
190
185
  reveal(errorFallback(hole.id));
191
186
  }, timeoutMs);
192
187
  // A response nobody is reading must not hold the process open until its deadline.
@@ -200,7 +195,12 @@ export function renderStreamHtml(
200
195
  // `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
201
196
  // and a read that raises here skips the `reveal` below — the hole never fills and the
202
197
  // response is held to its deadline for a failure that was already handled.
203
- logger.warn(`stream hole ${hole.id} rejected: ${renderThrowable(error)}`);
198
+ // A FIELD, never the message: `logger` redacts fields and never `msg`, so a hole's
199
+ // failure text interpolated into the message reached the log past every redactor.
200
+ logger.warn('render.stream.hole_rejected', {
201
+ hole: hole.id,
202
+ error: renderThrowable(error),
203
+ });
204
204
  reveal(errorFallback(hole.id));
205
205
  },
206
206
  );
package/src/server.ts CHANGED
@@ -40,22 +40,18 @@ export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
40
40
  export type {
41
41
  IsrController,
42
42
  IsrControllerOptions,
43
- IsrEntry,
44
43
  IsrRendered,
45
44
  IsrRenderFn,
46
45
  IsrServeResult,
47
- IsrState,
48
- IsrStore,
49
- MemoryIsrStoreOptions,
50
46
  } from './render-isr';
51
47
  export {
52
48
  createIsrController,
53
- DEFAULT_ISR_MAX_ENTRIES,
54
49
  ISR_LOCALE_PARAM,
55
50
  invalidateAndRevalidate,
56
51
  isrKey,
57
- memoryIsrStore,
58
52
  } from './render-isr';
53
+ export type { IsrEntry, IsrState, IsrStore, MemoryIsrStoreOptions } from './render-isr-store';
54
+ export { DEFAULT_ISR_MAX_ENTRIES, memoryIsrStore } from './render-isr-store';
59
55
  export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
60
56
  export { renderSsr, ssrHeaders } from './render-ssr';
61
57
  export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
@@ -0,0 +1,77 @@
1
+ // Single responsibility: a route pattern plus `prerender()` params, as the segments a static build
2
+ // writes. `prerender()` returns APP data, and each value becomes a directory on disk: a raw `..`
3
+ // wrote outside the build output, and a missing param wrote a directory literally named `:slug`.
4
+
5
+ import { renderCauseValue } from '@ultimat3/core';
6
+ import { PrerenderFailedError } from './errors';
7
+ import type { RouteParams } from './route';
8
+
9
+ /** Anything a single path segment may not carry: separators, `?` and `#` (controls: `hasControl`). */
10
+ const UNSAFE = /[/\\?#]/;
11
+
12
+ /** NUL and every C0 control, plus DEL — by code point, so no control character sits in a regex. */
13
+ const hasControl = (text: string): boolean => {
14
+ for (const char of text) {
15
+ const code = char.codePointAt(0) ?? 0;
16
+ if (code < 0x20 || code === 0x7f) return true;
17
+ }
18
+ return false;
19
+ };
20
+
21
+ const refuse = (pattern: string, param: string, value: unknown, why: string): never => {
22
+ throw new PrerenderFailedError(
23
+ `prerender() gave ${pattern} the ${param} ${renderCauseValue(value)}, which ${why}`,
24
+ `return a value for every param of ${pattern} from prerender(), each one plain path text — slugify it (lowercase, [a-z0-9-]) where it comes from a title or a user`,
25
+ );
26
+ };
27
+
28
+ const checked = (pattern: string, param: string, segment: string): string => {
29
+ if (segment === '.' || segment === '..') {
30
+ return refuse(pattern, param, segment, 'is a dot segment — it would write outside its route');
31
+ }
32
+ if (UNSAFE.test(segment) || hasControl(segment)) {
33
+ return refuse(pattern, param, segment, 'carries a separator, a control character, ? or #');
34
+ }
35
+ return segment;
36
+ };
37
+
38
+ /**
39
+ * The raw (decoded) segments, validated. `:name` must be present and one segment; `*name` may span
40
+ * several (`a/b`) and may be absent, but none of its parts may be a dot segment or unsafe.
41
+ */
42
+ export function filledSegments(pattern: string, params: RouteParams): readonly string[] {
43
+ const out: string[] = [];
44
+ for (const segment of pattern.split('/')) {
45
+ if (segment === '') continue;
46
+ if (segment.startsWith(':')) {
47
+ const name = segment.slice(1);
48
+ const value = Object.hasOwn(params, name) ? params[name] : undefined;
49
+ if (value === undefined || value === '') {
50
+ return refuse(
51
+ pattern,
52
+ name,
53
+ value,
54
+ 'is missing — the file would be named after the pattern',
55
+ );
56
+ }
57
+ out.push(checked(pattern, name, value));
58
+ continue;
59
+ }
60
+ if (segment.startsWith('*')) {
61
+ const name = segment.slice(1);
62
+ const value = Object.hasOwn(params, name) ? (params[name] ?? '') : '';
63
+ for (const part of value.split('/')) if (part !== '') out.push(checked(pattern, name, part));
64
+ continue;
65
+ }
66
+ out.push(segment);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** The URL form: each segment percent-encoded, as a browser's `pathname` spells it. */
72
+ export const urlPathOf = (segments: readonly string[]): string =>
73
+ segments.length === 0 ? '/' : `/${segments.map(encodeURIComponent).join('/')}`;
74
+
75
+ /** The file form: the DECODED segments, which is what a static server maps a URL back onto. */
76
+ export const filePathOf = (segments: readonly string[], indexFile: string): string =>
77
+ [...segments, indexFile].join('/');
@@ -0,0 +1,18 @@
1
+ // Single responsibility: the inline script bodies out-of-order streaming emits, as constants. Its own
2
+ // module so a CSP builder can import the list without importing the stream renderer and its logger.
3
+
4
+ export const REVEAL_BODY =
5
+ "window.$X=function(){document.querySelectorAll('template[data-x-hole]').forEach(function(t){" +
6
+ "var s=document.getElementById(t.getAttribute('data-x-hole'));if(s){s.replaceWith(t.content);t.remove()}})}";
7
+
8
+ /** The one call every reveal makes. Constant, so a hash-based CSP can admit it. */
9
+ export const REVEAL_CALL = '$X()';
10
+
11
+ /**
12
+ * Every inline script body a streamed document can carry — two, whatever the holes. A production
13
+ * policy admits inline script by HASH (a `render: 'stream'` response gets no nonce), and the reveal
14
+ * used to be one `$X("<id>")` per hole: a body per id that no policy could list, so every reveal
15
+ * was blocked. The id now rides only in the escaped `data-x-hole` attribute, and `$X()` reveals
16
+ * every template that has arrived.
17
+ */
18
+ export const STREAM_REVEAL_BODIES: readonly string[] = Object.freeze([REVEAL_BODY, REVEAL_CALL]);
package/src/surfaces.ts CHANGED
@@ -47,7 +47,10 @@ export const SURFACE_SPECS = Object.freeze<Record<Surface, SurfaceSpec>>({
47
47
  defaultMode: null,
48
48
  allowedModes: [],
49
49
  jsBaselineBytes: 0,
50
- mayImport: ['shared'],
50
+ // `app` too, measured the day this table became the rule: both tracked apps' `api/index.ts`
51
+ // and `api/tasks.ts` import the app slices' actions and jobs to register them. Both surfaces
52
+ // are server-only, so no browser bundle pays for the edge; `site` stays out.
53
+ mayImport: ['shared', 'app'],
51
54
  mayImportTypes: ['shared'],
52
55
  },
53
56
  shared: {
@@ -118,7 +121,11 @@ export function importGraph(
118
121
  return graph;
119
122
  }
120
123
 
121
- export type BoundaryRule = 'site-imports-app' | 'shared-is-a-leaf' | 'app-imports-api-at-runtime';
124
+ export type BoundaryRule =
125
+ | 'site-imports-app'
126
+ | 'shared-is-a-leaf'
127
+ | 'app-imports-api-at-runtime'
128
+ | 'surface-imports-surface';
122
129
 
123
130
  export interface BoundaryViolation {
124
131
  readonly rule: BoundaryRule;
@@ -210,50 +217,57 @@ interface ClassifyInput {
210
217
  readonly chain: readonly string[];
211
218
  }
212
219
 
220
+ /**
221
+ * One edge against `SURFACE_SPECS` — the table IS the rule. `mayImport` and `mayImportTypes` were
222
+ * read by nothing, so an `api → site`, `site → api` or `app → site` value import classified as no
223
+ * violation at all. The three pairs that had rules of their own keep them (and their codes); every
224
+ * other crossing the table does not allow is `surface-imports-surface`.
225
+ */
213
226
  function classify(i: ClassifyInput): BoundaryViolation | null {
214
227
  const chainText = i.chain.join(' → ');
228
+ const base = { entry: i.entry, importer: i.importer, imported: i.imported, chain: i.chain };
215
229
 
230
+ // Transitive, from the ENTRY: a site page reaching app/ through any number of hops.
216
231
  if (i.entrySurface === 'site' && i.importedSurface === 'app' && !i.typeOnly) {
217
232
  return {
233
+ ...base,
218
234
  rule: 'site-imports-app',
219
- entry: i.entry,
220
- importer: i.importer,
221
- imported: i.imported,
222
- chain: i.chain,
223
235
  cause: chainText,
224
236
  fix: `x fix boundary ${i.entry} (or move ${i.imported} out of the shared graph)`,
225
237
  };
226
238
  }
227
-
228
- if (
229
- i.importerSurface === 'shared' &&
230
- (i.importedSurface === 'app' || i.importedSurface === 'site') &&
231
- !i.typeOnly
232
- ) {
239
+ const from = i.importerSurface;
240
+ const to = i.importedSurface;
241
+ if (from === null || to === null || from === to) return null;
242
+ const spec = SURFACE_SPECS[from];
243
+ if (spec.mayImport.includes(to)) return null;
244
+ if (i.typeOnly && spec.mayImportTypes.includes(to)) return null;
245
+ // Reported above, from the site entry that reaches it — one crossing, one finding.
246
+ if (from === 'site' && to === 'app' && !i.typeOnly) return null;
247
+
248
+ if (from === 'shared' && !i.typeOnly) {
233
249
  return {
250
+ ...base,
234
251
  rule: 'shared-is-a-leaf',
235
- entry: i.entry,
236
- importer: i.importer,
237
- imported: i.imported,
238
- chain: i.chain,
239
252
  cause: `${chainText} (shared/ is a leaf — it may not import a surface)`,
240
253
  fix: `move the shared part of ${i.imported} into shared/ and import it from ${i.importer}`,
241
254
  };
242
255
  }
243
-
244
- if (i.importerSurface === 'app' && i.importedSurface === 'api' && !i.typeOnly) {
256
+ if (from === 'app' && to === 'api' && !i.typeOnly) {
245
257
  return {
258
+ ...base,
246
259
  rule: 'app-imports-api-at-runtime',
247
- entry: i.entry,
248
- importer: i.importer,
249
- imported: i.imported,
250
- chain: i.chain,
251
260
  cause: `${chainText} (app/ → api/ is types-only)`,
252
261
  fix: `change to \`import type\` in ${i.importer} and call the typed client instead`,
253
262
  };
254
263
  }
255
-
256
- return null;
264
+ const field = i.typeOnly ? 'mayImportTypes' : 'mayImport';
265
+ return {
266
+ ...base,
267
+ rule: 'surface-imports-surface',
268
+ cause: `${chainText} (${from}/ may not import ${to}/${i.typeOnly ? ', even as a type' : ''}: SURFACE_SPECS.${from}.${field} is [${spec[field].join(', ')}])`,
269
+ fix: `move what ${i.importer} needs from ${i.imported} into shared/ and import it from there`,
270
+ };
257
271
  }
258
272
 
259
273
  /** Build-time gate. `x verify` and the dev server both call this. */