@ultimat3/render 9.0.0 → 11.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 +9 -3
- package/README.md +14 -10
- package/package.json +5 -5
- package/src/errors.ts +6 -15
- package/src/hydrate.ts +61 -9
- package/src/index.ts +2 -9
- package/src/islands.ts +13 -161
- package/src/modes.ts +10 -7
- package/src/registry.ts +5 -3
- package/src/render-html.ts +19 -6
- package/src/render-isr.ts +63 -9
- package/src/server.ts +1 -0
package/CLAUDE.md
CHANGED
|
@@ -48,7 +48,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
48
48
|
| 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. |
|
|
49
49
|
| 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. |
|
|
50
50
|
| 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. |
|
|
51
|
-
| `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 `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-
|
|
51
|
+
| `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. |
|
|
52
52
|
| 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. |
|
|
53
53
|
| 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. |
|
|
54
54
|
| 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. |
|
|
@@ -56,10 +56,12 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
56
56
|
| 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 `<script>` 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. |
|
|
57
57
|
| 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. |
|
|
58
58
|
| 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. |
|
|
59
|
-
| Island bytes | `routeJsBytes`
|
|
59
|
+
| 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. |
|
|
60
|
+
| 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. |
|
|
60
61
|
| 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)`. |
|
|
61
62
|
| 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. |
|
|
62
|
-
| 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`
|
|
63
|
+
| 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` 774, `visible` 846, `interaction` 1,067 (`As of 2026-08-23`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from). |
|
|
64
|
+
| 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. |
|
|
63
65
|
| `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. |
|
|
64
66
|
| 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. |
|
|
65
67
|
| 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. |
|
|
@@ -71,6 +73,9 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
71
73
|
| "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. |
|
|
72
74
|
| 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. |
|
|
73
75
|
| `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. |
|
|
76
|
+
| 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. |
|
|
77
|
+
| 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. |
|
|
78
|
+
| 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`. |
|
|
74
79
|
| 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. |
|
|
75
80
|
| 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. |
|
|
76
81
|
| 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`. |
|
|
@@ -80,6 +85,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
80
85
|
| Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
|
|
81
86
|
| 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. |
|
|
82
87
|
| Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
|
|
88
|
+
| 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. |
|
|
83
89
|
| 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. |
|
|
84
90
|
| 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. |
|
|
85
91
|
| 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. |
|
package/README.md
CHANGED
|
@@ -280,18 +280,22 @@ are never serialized.
|
|
|
280
280
|
const collector = createIslandCollector({ file, hydrate: config.hydrate, resolve });
|
|
281
281
|
const html = await renderToHtml(page, { islands: collector });
|
|
282
282
|
document.body += hydrateRuntime(collector.directives); // the one thing left to remember
|
|
283
|
-
assertBudget(entry, measuredIslands, collector.directives);
|
|
284
283
|
```
|
|
285
284
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
285
|
+
Declaring an island is what puts a `budget.js` on the route — `defaultIslandBudget(surface)`,
|
|
286
|
+
applied by `registerRoute`, so a page that declares one is charged without saying so. **Weighing it
|
|
287
|
+
is `x verify`'s `budgets` step**, which measures the emitted document against the manifest's
|
|
288
|
+
per-route budget and fails with `X_BUDGET_EXCEEDED`.
|
|
289
|
+
|
|
290
|
+
**Removed `As of 2026-08-23`** (breaking): `routeJsBytes`, `graphFor`, `checkBudget`, `checkBudgets`, `assertBudget` and
|
|
291
|
+
the `Island` / `BundleGraph` / `RouteBytes` / `BudgetReport` types. They were a second, graph-based
|
|
292
|
+
answer to the same question that nothing in the framework ever asked — the gate has always been the
|
|
293
|
+
CLI's. `parseByteBudget` (the `'40kb'` grammar) and `defaultIslandBudget` stay.
|
|
289
294
|
|
|
290
295
|
`entry.islands` is filled from `config.islands` at registration and from nothing else, so a declared
|
|
291
|
-
island is
|
|
292
|
-
ever passed `islands
|
|
293
|
-
|
|
294
|
-
declaration.
|
|
296
|
+
island is on the record even on a route no render has touched. It was `input.islands ?? []` and
|
|
297
|
+
nothing ever passed `islands`; `RegisterRouteInput` no longer carries the key, because the only
|
|
298
|
+
thing a caller could do with it was un-declare an island.
|
|
295
299
|
|
|
296
300
|
An island on a route that resolves to `hydrate: 'never'`, or rendered with no collector, is
|
|
297
301
|
`X_ISLAND_NOT_HYDRATED` — inert markup either way. With `hydrate` derived it means one of exactly
|
|
@@ -359,9 +363,9 @@ side effect. Anything that loads an app's source — `x dev`, `x build`, `server
|
|
|
359
363
|
| `renderSsr`†, `streamResult`† | the per-request modes |
|
|
360
364
|
| `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
|
|
361
365
|
| `installRenderLoader`†, `compileStylesheet`† | the `.tsx`/`.scss` loaders, installed on import |
|
|
362
|
-
| `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
|
|
366
|
+
| `emitIslandAttributes`, `hydrateRuntime`, `HYDRATE_RUNTIME_BODIES` | the four hydration strategies, and every body the runtime script can hold — a host hashes that list into `script-src`, because the runtime is emitted inline and no `render: 'static'` file can receive a nonce |
|
|
363
367
|
| `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
|
|
364
|
-
| `
|
|
368
|
+
| `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
|
|
365
369
|
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
|
|
366
370
|
|
|
367
371
|
## Notes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.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": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/i18n": "
|
|
42
|
-
"@ultimat3/seo": "
|
|
39
|
+
"@ultimat3/cache": "11.0.0",
|
|
40
|
+
"@ultimat3/core": "11.0.0",
|
|
41
|
+
"@ultimat3/i18n": "11.0.0",
|
|
42
|
+
"@ultimat3/seo": "11.0.0",
|
|
43
43
|
"sass": "1.102.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/errors.ts
CHANGED
|
@@ -54,7 +54,12 @@ registerErrorCodes(
|
|
|
54
54
|
Object.fromEntries(Object.entries(RENDER_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
|
|
58
|
+
// which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
59
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
|
|
60
|
+
// which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
|
|
61
|
+
// answered 404, host included, on every error it has ever thrown; restating the replacement here
|
|
62
|
+
// would be the same constant in eight places waiting to drift again.
|
|
58
63
|
|
|
59
64
|
/** A render mode's invariant was violated at registration (see `modes.ts`). */
|
|
60
65
|
export class RouteModeInvalidError extends UltimateError {
|
|
@@ -64,7 +69,6 @@ export class RouteModeInvalidError extends UltimateError {
|
|
|
64
69
|
code: RouteModeInvalidError.code,
|
|
65
70
|
cause,
|
|
66
71
|
fix,
|
|
67
|
-
docs: docsFor(RouteModeInvalidError.code),
|
|
68
72
|
});
|
|
69
73
|
}
|
|
70
74
|
}
|
|
@@ -77,7 +81,6 @@ export class RouteOfflineMissingError extends UltimateError {
|
|
|
77
81
|
code: RouteOfflineMissingError.code,
|
|
78
82
|
cause,
|
|
79
83
|
fix,
|
|
80
|
-
docs: docsFor(RouteOfflineMissingError.code),
|
|
81
84
|
});
|
|
82
85
|
}
|
|
83
86
|
}
|
|
@@ -90,7 +93,6 @@ export class RouteMetaMissingError extends UltimateError {
|
|
|
90
93
|
code: RouteMetaMissingError.code,
|
|
91
94
|
cause,
|
|
92
95
|
fix,
|
|
93
|
-
docs: docsFor(RouteMetaMissingError.code),
|
|
94
96
|
});
|
|
95
97
|
}
|
|
96
98
|
}
|
|
@@ -107,7 +109,6 @@ export class RouteUnnormalizedError extends UltimateError {
|
|
|
107
109
|
code: RouteUnnormalizedError.code,
|
|
108
110
|
cause,
|
|
109
111
|
fix,
|
|
110
|
-
docs: docsFor(RouteUnnormalizedError.code),
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -120,7 +121,6 @@ export class RouteDuplicateError extends UltimateError {
|
|
|
120
121
|
code: RouteDuplicateError.code,
|
|
121
122
|
cause,
|
|
122
123
|
fix,
|
|
123
|
-
docs: docsFor(RouteDuplicateError.code),
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
}
|
|
@@ -138,7 +138,6 @@ export class RouteFileInvalidError extends UltimateError {
|
|
|
138
138
|
code: RouteFileInvalidError.code,
|
|
139
139
|
cause,
|
|
140
140
|
fix,
|
|
141
|
-
docs: docsFor(RouteFileInvalidError.code),
|
|
142
141
|
});
|
|
143
142
|
}
|
|
144
143
|
}
|
|
@@ -151,7 +150,6 @@ export class SurfaceBoundaryError extends UltimateError {
|
|
|
151
150
|
code: SurfaceBoundaryError.code,
|
|
152
151
|
cause,
|
|
153
152
|
fix,
|
|
154
|
-
docs: docsFor(SurfaceBoundaryError.code),
|
|
155
153
|
});
|
|
156
154
|
}
|
|
157
155
|
}
|
|
@@ -164,7 +162,6 @@ export class BudgetExceededError extends UltimateError {
|
|
|
164
162
|
code: BudgetExceededError.code,
|
|
165
163
|
cause,
|
|
166
164
|
fix,
|
|
167
|
-
docs: docsFor(BudgetExceededError.code),
|
|
168
165
|
});
|
|
169
166
|
}
|
|
170
167
|
}
|
|
@@ -177,7 +174,6 @@ export class PrerenderFailedError extends UltimateError {
|
|
|
177
174
|
code: PrerenderFailedError.code,
|
|
178
175
|
cause,
|
|
179
176
|
fix,
|
|
180
|
-
docs: docsFor(PrerenderFailedError.code),
|
|
181
177
|
});
|
|
182
178
|
}
|
|
183
179
|
}
|
|
@@ -190,7 +186,6 @@ export class RouteLoadInvalidError extends UltimateError {
|
|
|
190
186
|
code: RouteLoadInvalidError.code,
|
|
191
187
|
cause,
|
|
192
188
|
fix,
|
|
193
|
-
docs: docsFor(RouteLoadInvalidError.code),
|
|
194
189
|
});
|
|
195
190
|
}
|
|
196
191
|
}
|
|
@@ -207,7 +202,6 @@ export class IslandInvalidError extends UltimateError {
|
|
|
207
202
|
code: IslandInvalidError.code,
|
|
208
203
|
cause,
|
|
209
204
|
fix,
|
|
210
|
-
docs: docsFor(IslandInvalidError.code),
|
|
211
205
|
});
|
|
212
206
|
}
|
|
213
207
|
}
|
|
@@ -224,7 +218,6 @@ export class IslandPropsInvalidError extends UltimateError {
|
|
|
224
218
|
code: IslandPropsInvalidError.code,
|
|
225
219
|
cause,
|
|
226
220
|
fix,
|
|
227
|
-
docs: docsFor(IslandPropsInvalidError.code),
|
|
228
221
|
});
|
|
229
222
|
}
|
|
230
223
|
}
|
|
@@ -242,7 +235,6 @@ export class IslandNotHydratedError extends UltimateError {
|
|
|
242
235
|
code: IslandNotHydratedError.code,
|
|
243
236
|
cause,
|
|
244
237
|
fix,
|
|
245
|
-
docs: docsFor(IslandNotHydratedError.code),
|
|
246
238
|
});
|
|
247
239
|
}
|
|
248
240
|
}
|
|
@@ -259,7 +251,6 @@ export class RouteLoadFailedError extends UltimateError {
|
|
|
259
251
|
code: RouteLoadFailedError.code,
|
|
260
252
|
cause,
|
|
261
253
|
fix,
|
|
262
|
-
docs: docsFor(RouteLoadFailedError.code),
|
|
263
254
|
});
|
|
264
255
|
}
|
|
265
256
|
}
|
package/src/hydrate.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { HydrateStrategy } from '@ultimat3/core';
|
|
9
|
+
import { HYDRATE_STRATEGIES } from '@ultimat3/core';
|
|
9
10
|
import { escapeAttribute, escapeJsonContent } from './html';
|
|
10
11
|
|
|
11
12
|
export interface IslandDirective {
|
|
@@ -85,7 +86,10 @@ export function emitIslandAttributes(directive: IslandDirective): string {
|
|
|
85
86
|
export function emitIslandProps(directive: IslandDirective): string {
|
|
86
87
|
if (directive.props === undefined || directive.strategy === 'never') return '';
|
|
87
88
|
return (
|
|
88
|
-
|
|
89
|
+
// The id goes through the SAME escaper every other attribute in this file does. Safe today —
|
|
90
|
+
// `islandModuleId` reduces to `[a-z0-9-]` — which is exactly why it costs nothing to route it
|
|
91
|
+
// now, rather than after an id starts being derived from something an author did not type.
|
|
92
|
+
`<script type="application/json" data-x-props="${escapeAttribute(directive.islandId)}">` +
|
|
89
93
|
`${escapeJsonContent(JSON.stringify(directive.props))}</script>`
|
|
90
94
|
);
|
|
91
95
|
}
|
|
@@ -122,31 +126,44 @@ return el.__x=import(e).then(function(m){return m.mount(el,props)}).then(
|
|
|
122
126
|
function(r){el.setAttribute('${ISLAND_MOUNTED_ATTRIBUTE}','');return r},
|
|
123
127
|
function(x){el.setAttribute('${ISLAND_FAILED_ATTRIBUTE}',x&&x.message||'1');throw x})}
|
|
124
128
|
function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
|
|
129
|
+
function hush(){}
|
|
125
130
|
`.trim();
|
|
131
|
+
// `hush` above: `boot` rethrows, so every runtime below has to terminate the chain it starts or
|
|
132
|
+
// the page reports an unhandled rejection for a failure it already recorded on the element.
|
|
126
133
|
|
|
127
134
|
const RUNTIME_IDLE = `
|
|
128
135
|
each('[data-x-hydrate="idle"]',function(el){
|
|
129
|
-
var go=function(){boot(el)};
|
|
136
|
+
var go=function(){boot(el).catch(hush)};
|
|
130
137
|
if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:${IDLE_HYDRATE_TIMEOUT_MS}});else setTimeout(go,1)})
|
|
131
138
|
`.trim();
|
|
132
139
|
|
|
133
140
|
const RUNTIME_VISIBLE = `
|
|
134
141
|
each('[data-x-hydrate="visible"]',function(el){
|
|
135
142
|
var io=new IntersectionObserver(function(es){es.forEach(function(en){
|
|
136
|
-
if(en.isIntersecting){io.disconnect();boot(el)}})},{rootMargin:el.getAttribute('data-x-margin')||'200px'});
|
|
143
|
+
if(en.isIntersecting){io.disconnect();boot(el).catch(hush)}})},{rootMargin:el.getAttribute('data-x-margin')||'200px'});
|
|
137
144
|
io.observe(el)})
|
|
138
145
|
`.trim();
|
|
139
146
|
|
|
140
147
|
// Event replay: the listener is registered before the chunk exists, records the event that
|
|
141
148
|
// woke the island, and re-dispatches it once mounted. Without this, the first click on a
|
|
142
149
|
// cold island is silently lost — the failure users read as "the button does nothing".
|
|
150
|
+
//
|
|
151
|
+
// `off` is BOTH arms of the `then`, and the rejection arm is the reason it is a named function.
|
|
152
|
+
// `boot` rethrows on purpose (see the prelude), so `el.__x` holds a rejected promise from the
|
|
153
|
+
// first failed mount onward — and a `.then` with no rejection handler makes a fresh rejected
|
|
154
|
+
// promise out of it on EVERY event, i.e. one unhandled rejection per user click, forever. The
|
|
155
|
+
// queue was the second half: nothing ever set `done`, so the listeners stayed attached and `q`
|
|
156
|
+
// grew by one retained `Event` — each holding a live `target` — per click, for an island that
|
|
157
|
+
// will never mount. Swallowing here loses no signal: the DOM already carries the failure as
|
|
158
|
+
// `data-x-failed`, which is the documented observable.
|
|
143
159
|
const RUNTIME_INTERACTION = `
|
|
144
160
|
each('[data-x-hydrate="interaction"]',function(el){
|
|
145
161
|
var evs=(el.getAttribute('data-x-events')||'click').split(' ');
|
|
146
162
|
var q=[],done=false;
|
|
163
|
+
var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
|
|
147
164
|
var on=function(ev){if(done)return;q.push(ev);
|
|
148
|
-
boot(el).then(function(){
|
|
149
|
-
|
|
165
|
+
boot(el).then(function(){var r=q;off();
|
|
166
|
+
r.forEach(function(ev){var c=new ev.constructor(ev.type,ev);ev.target.dispatchEvent(c)})},off)};
|
|
150
167
|
evs.forEach(function(n){el.addEventListener(n,on,true)})})
|
|
151
168
|
`.trim();
|
|
152
169
|
|
|
@@ -156,6 +173,44 @@ const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>>
|
|
|
156
173
|
interaction: RUNTIME_INTERACTION,
|
|
157
174
|
};
|
|
158
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Emission order, and the order the subsets below are enumerated in. Derived from core's one
|
|
178
|
+
* declaration of the vocabulary, never restated: a second literal of this set is what
|
|
179
|
+
* `bun run render-modes` refuses, and it refuses it on the MEMBERS, not on the name.
|
|
180
|
+
*/
|
|
181
|
+
const RUNTIME_ORDER: readonly Exclude<HydrateStrategy, 'never'>[] = HYDRATE_STRATEGIES.filter(
|
|
182
|
+
(strategy): strategy is Exclude<HydrateStrategy, 'never'> => strategy !== 'never',
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The TEXT of the runtime script for exactly these strategies — the body a CSP `script-src` hash
|
|
187
|
+
* is taken over. Split out of `hydrateRuntime` so the served document and the policy that admits
|
|
188
|
+
* it read one function: a second copy of this concatenation is a hash that stops matching the
|
|
189
|
+
* moment the runtime changes, and the failure it produces is an island that never boots on a page
|
|
190
|
+
* that otherwise looks correct.
|
|
191
|
+
*/
|
|
192
|
+
const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): string =>
|
|
193
|
+
[
|
|
194
|
+
RUNTIME_PRELUDE,
|
|
195
|
+
...RUNTIME_ORDER.filter((strategy) => needed.has(strategy)).map(
|
|
196
|
+
(strategy) => RUNTIME_PARTS[strategy],
|
|
197
|
+
),
|
|
198
|
+
].join('\n');
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Every body `hydrateRuntime` can emit: one per non-empty subset of the three strategies, seven in
|
|
202
|
+
* all, deterministic. A policy that admits inline script by HASH has to enumerate them before the
|
|
203
|
+
* socket opens — the alternative is a per-response nonce, which a `render: 'static'` page (a file
|
|
204
|
+
* on disk) can never receive. Enumerated rather than derived from the route table because the
|
|
205
|
+
* runtime is a function of the SET a document needs, and a table read at boot cannot answer for
|
|
206
|
+
* a document assembled later.
|
|
207
|
+
*/
|
|
208
|
+
export const HYDRATE_RUNTIME_BODIES: readonly string[] = Array.from(
|
|
209
|
+
{ length: 2 ** RUNTIME_ORDER.length - 1 },
|
|
210
|
+
(_unused, index) =>
|
|
211
|
+
runtimeBody(new Set(RUNTIME_ORDER.filter((_s, bit) => (((index + 1) >> bit) & 1) === 1))),
|
|
212
|
+
);
|
|
213
|
+
|
|
159
214
|
/**
|
|
160
215
|
* Emit only the runtime the page's islands actually use. A page of `never` islands gets
|
|
161
216
|
* the empty string — an unused strategy must not cost a byte.
|
|
@@ -163,10 +218,7 @@ const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>>
|
|
|
163
218
|
export function hydrateRuntime(directives: readonly IslandDirective[]): string {
|
|
164
219
|
const needed = requiredStrategies(directives);
|
|
165
220
|
if (needed.size === 0) return '';
|
|
166
|
-
|
|
167
|
-
.filter((strategy) => needed.has(strategy))
|
|
168
|
-
.map((strategy) => RUNTIME_PARTS[strategy]);
|
|
169
|
-
return `<script type="module">${RUNTIME_PRELUDE}\n${parts.join('\n')}</script>`;
|
|
221
|
+
return `<script type="module">${runtimeBody(needed)}</script>`;
|
|
170
222
|
}
|
|
171
223
|
|
|
172
224
|
/** Rough emitted size of the hydration runtime for a page, for the budget check. */
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,7 @@ export {
|
|
|
57
57
|
DEFAULT_REPLAY_EVENTS,
|
|
58
58
|
emitIslandAttributes,
|
|
59
59
|
emitIslandProps,
|
|
60
|
+
HYDRATE_RUNTIME_BODIES,
|
|
60
61
|
hydrateRuntime,
|
|
61
62
|
hydrateRuntimeBytes,
|
|
62
63
|
IDLE_HYDRATE_TIMEOUT_MS,
|
|
@@ -77,15 +78,7 @@ export type { IslandCollector, IslandCollectorInput } from './island-collector';
|
|
|
77
78
|
export { createIslandCollector, islandModuleIds } from './island-collector';
|
|
78
79
|
export type { IslandProps, JsonValue } from './island-props';
|
|
79
80
|
export { checkIslandProps, ISLAND_PROPS_MAX_BYTES } from './island-props';
|
|
80
|
-
export
|
|
81
|
-
export {
|
|
82
|
-
assertBudget,
|
|
83
|
-
checkBudget,
|
|
84
|
-
checkBudgets,
|
|
85
|
-
graphFor,
|
|
86
|
-
parseByteBudget,
|
|
87
|
-
routeJsBytes,
|
|
88
|
-
} from './islands';
|
|
81
|
+
export { parseByteBudget } from './islands';
|
|
89
82
|
export type { JsxComponent, JsxNode, JsxProps } from './jsx';
|
|
90
83
|
export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
|
|
91
84
|
export type { ModeCheckContext, ModeSpec, RouteShape } from './modes';
|
package/src/islands.ts
CHANGED
|
@@ -1,50 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* How a byte budget is WRITTEN — `'40kb'` — and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* It was the whole graph-based budget API: `Island`, `BundleGraph`, `graphFor`, `routeJsBytes`,
|
|
5
|
+
* `checkBudget`, `checkBudgets`, `assertBudget`. Every one of them was exported from the barrel and
|
|
6
|
+
* called by nothing outside this package's own tests. The real gate is `@ultimat3/cli`'s
|
|
7
|
+
* `checkBudgets` (`packages/cli/src/budgets.ts`), which measures the EMITTED document against the
|
|
8
|
+
* manifest's per-route `budget` and has its own `parseByteBudget` caller — the near-miss that made
|
|
9
|
+
* the dead half look reached. Deleted 2026-08-23 rather than wired: two answers to "what does this
|
|
10
|
+
* route weigh", one of which never ran, is the ambiguity axiom 1 refuses, and a build error nothing
|
|
11
|
+
* calls is not a build error.
|
|
12
|
+
*
|
|
13
|
+
* `defaultIslandBudget` in `modes.ts` is the surviving half of the island-budget story: it is
|
|
14
|
+
* reached, through `registerRoute`, and it is what puts a number on a route that declares none.
|
|
6
15
|
*/
|
|
7
16
|
|
|
8
|
-
import type { HydrateStrategy } from '@ultimat3/core';
|
|
9
|
-
// One formatter, in `@ultimat3/core`: the copy that lived here had no `mb` branch, so a 5 MB route
|
|
10
|
-
// read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa` said `5mb` for the same bytes.
|
|
11
|
-
import { formatBytes } from '@ultimat3/core';
|
|
12
|
-
import { BudgetExceededError } from './errors';
|
|
13
|
-
import type { IslandDirective } from './hydrate';
|
|
14
|
-
import { hydrateRuntimeBytes } from './hydrate';
|
|
15
|
-
import type { RouteEntry } from './registry';
|
|
16
|
-
import type { Surface } from './surfaces';
|
|
17
|
-
import { SURFACE_SPECS } from './surfaces';
|
|
18
|
-
|
|
19
|
-
/** A bundle graph is per surface. There are exactly two that ship JS: `site` and `app`. */
|
|
20
|
-
export type GraphName = 'site' | 'app';
|
|
21
|
-
|
|
22
|
-
export interface Island {
|
|
23
|
-
readonly id: string;
|
|
24
|
-
readonly file: string;
|
|
25
|
-
readonly graph: GraphName;
|
|
26
|
-
readonly strategy: HydrateStrategy;
|
|
27
|
-
/** Measured from the real bundle, gzip-before-brotli agnostic: raw bytes. */
|
|
28
|
-
readonly bytes: number;
|
|
29
|
-
/** The import chain that pulled the heaviest dependency in, for error messages. */
|
|
30
|
-
readonly heaviestChain?: readonly string[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface BundleGraph {
|
|
34
|
-
readonly name: GraphName;
|
|
35
|
-
readonly baselineBytes: number;
|
|
36
|
-
readonly islands: readonly Island[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function graphFor(surface: Surface, islands: readonly Island[]): BundleGraph {
|
|
40
|
-
const name: GraphName = surface === 'site' ? 'site' : 'app';
|
|
41
|
-
return {
|
|
42
|
-
name,
|
|
43
|
-
baselineBytes: SURFACE_SPECS[name].jsBaselineBytes,
|
|
44
|
-
islands: islands.filter((island) => island.graph === name && island.strategy !== 'never'),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
17
|
const UNITS: Readonly<Record<string, number>> = { b: 1, kb: 1024, mb: 1024 * 1024 };
|
|
49
18
|
|
|
50
19
|
/** `'40kb'` → 40960. Throws nothing: an unparseable budget is `null` and skipped. */
|
|
@@ -57,120 +26,3 @@ export function parseByteBudget(budget: string | undefined): number | null {
|
|
|
57
26
|
const factor = UNITS[unit];
|
|
58
27
|
return factor === undefined ? null : Math.round(Number(amount) * factor);
|
|
59
28
|
}
|
|
60
|
-
|
|
61
|
-
export interface RouteBytes {
|
|
62
|
-
readonly total: number;
|
|
63
|
-
readonly baseline: number;
|
|
64
|
-
readonly islandBytes: number;
|
|
65
|
-
readonly runtimeBytes: number;
|
|
66
|
-
readonly heaviest: Island | null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function routeJsBytes(
|
|
70
|
-
entry: RouteEntry,
|
|
71
|
-
islands: readonly Island[],
|
|
72
|
-
directives: readonly IslandDirective[] = [],
|
|
73
|
-
): RouteBytes {
|
|
74
|
-
const graph = graphFor(entry.surface, islands);
|
|
75
|
-
// Two sources, unioned: `entry.islands` is what registration declared, and the directives are
|
|
76
|
-
// what the page actually rendered. Reading only the first is how the runtime bytes of an island
|
|
77
|
-
// could be charged while its chunk was not — a budget that counts the wrapper and not the code.
|
|
78
|
-
const onRoute = graph.islands.filter(
|
|
79
|
-
(island) =>
|
|
80
|
-
entry.islands.includes(island.id) ||
|
|
81
|
-
directives.some((directive) => (directive.moduleId ?? directive.islandId) === island.id),
|
|
82
|
-
);
|
|
83
|
-
const islandBytes = onRoute.reduce((sum, island) => sum + island.bytes, 0);
|
|
84
|
-
const runtimeBytes = directives.length > 0 ? hydrateRuntimeBytes(directives) : 0;
|
|
85
|
-
const baseline = islandBytes === 0 && runtimeBytes === 0 ? 0 : graph.baselineBytes;
|
|
86
|
-
const heaviest = onRoute.reduce<Island | null>(
|
|
87
|
-
(max, island) => (max === null || island.bytes > max.bytes ? island : max),
|
|
88
|
-
null,
|
|
89
|
-
);
|
|
90
|
-
return {
|
|
91
|
-
total: baseline + islandBytes + runtimeBytes,
|
|
92
|
-
baseline,
|
|
93
|
-
islandBytes,
|
|
94
|
-
runtimeBytes,
|
|
95
|
-
heaviest,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface BudgetReport {
|
|
100
|
-
readonly path: string;
|
|
101
|
-
readonly measured: number;
|
|
102
|
-
readonly limit: number | null;
|
|
103
|
-
readonly ok: boolean;
|
|
104
|
-
readonly cause: string | null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The build gate. Failure names the *cause* — the island and the transitive import that
|
|
109
|
-
* added the bytes — because "bundle too big" without a chain is not an instruction.
|
|
110
|
-
*/
|
|
111
|
-
export function checkBudget(
|
|
112
|
-
entry: RouteEntry,
|
|
113
|
-
islands: readonly Island[],
|
|
114
|
-
directives: readonly IslandDirective[] = [],
|
|
115
|
-
): BudgetReport {
|
|
116
|
-
const bytes = routeJsBytes(entry, islands, directives);
|
|
117
|
-
const limit = parseByteBudget(entry.config.budget.js);
|
|
118
|
-
|
|
119
|
-
// site/ has a 0kb default: shipping JS there without declaring a budget is the failure.
|
|
120
|
-
if (limit === null && entry.surface === 'site' && bytes.total > 0) {
|
|
121
|
-
return {
|
|
122
|
-
path: entry.path,
|
|
123
|
-
measured: bytes.total,
|
|
124
|
-
limit: 0,
|
|
125
|
-
ok: false,
|
|
126
|
-
cause:
|
|
127
|
-
`${entry.path} is in site/ (0kb JS baseline) and ships ${formatBytes(bytes.total)} ` +
|
|
128
|
-
`with no budget.js${chainOf(bytes.heaviest)}`,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (limit === null) {
|
|
133
|
-
return { path: entry.path, measured: bytes.total, limit: null, ok: true, cause: null };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const ok = bytes.total <= limit;
|
|
137
|
-
return {
|
|
138
|
-
path: entry.path,
|
|
139
|
-
measured: bytes.total,
|
|
140
|
-
limit,
|
|
141
|
-
ok,
|
|
142
|
-
cause: ok
|
|
143
|
-
? null
|
|
144
|
-
: `${entry.path} js ${formatBytes(bytes.total)} > ${formatBytes(limit)}${chainOf(bytes.heaviest)}`,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function chainOf(island: Island | null): string {
|
|
149
|
-
if (island === null) return '';
|
|
150
|
-
const chain = island.heaviestChain;
|
|
151
|
-
if (chain === undefined || chain.length === 0) return ` (heaviest island: ${island.file})`;
|
|
152
|
-
return ` (${chain.join(' → ')})`;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function assertBudget(
|
|
156
|
-
entry: RouteEntry,
|
|
157
|
-
islands: readonly Island[],
|
|
158
|
-
directives: readonly IslandDirective[] = [],
|
|
159
|
-
): void {
|
|
160
|
-
const report = checkBudget(entry, islands, directives);
|
|
161
|
-
if (report.ok || report.cause === null) return;
|
|
162
|
-
throw new BudgetExceededError(
|
|
163
|
-
report.cause,
|
|
164
|
-
`raise budget.js in ${entry.file} deliberately, or remove the import that added the bytes`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** `x verify` prints every route, not just the first failure. */
|
|
169
|
-
export function checkBudgets(
|
|
170
|
-
entries: readonly RouteEntry[],
|
|
171
|
-
islands: readonly Island[],
|
|
172
|
-
): readonly BudgetReport[] {
|
|
173
|
-
return entries
|
|
174
|
-
.map((entry) => checkBudget(entry, islands))
|
|
175
|
-
.sort((a, b) => a.path.localeCompare(b.path));
|
|
176
|
-
}
|
package/src/modes.ts
CHANGED
|
@@ -225,18 +225,21 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
225
225
|
* (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
|
|
226
226
|
* any surface, because the allowance is measured above the baseline and not against it.
|
|
227
227
|
*
|
|
228
|
-
* The number: 17,797 (the heaviest island this repo actually ships) + 1,
|
|
228
|
+
* The number: 17,797 (the heaviest island this repo actually ships) + 1,067 (`hydrateRuntimeBytes`
|
|
229
229
|
* for one directive at `DEFAULT_ISLAND_HYDRATE`, which is `'interaction'` — `route.ts:33`, applied
|
|
230
|
-
* at `:253` to any island route declaring no `hydrate`) = **18,
|
|
230
|
+
* at `:253` to any island route declaring no `hydrate`) = **18,864**. That is the worst case an
|
|
231
231
|
* app reaches without writing a number down. 20,480 is NOT that rounded up — the next whole
|
|
232
|
-
* kilobyte above it is 19,456 — it is one whole kB further, leaving 1,
|
|
233
|
-
* under 2x 18,
|
|
234
|
-
* asserts all three. `idle` costs
|
|
232
|
+
* kilobyte above it is 19,456 — it is one whole kB further, leaving 1,616 B of headroom and still
|
|
233
|
+
* under 2x 18,864, so a route bundling the same island twice is refused. `island-budget.test.ts`
|
|
234
|
+
* asserts all three. `idle` costs 774 and `visible` 846, so an island route that declares its
|
|
235
235
|
* strategy pays less; the default is what the budget has to clear.
|
|
236
236
|
*
|
|
237
237
|
* All three grew by 129 B on 2026-08-21 (from 881 / 615 / 687), when the prelude learned to mark a
|
|
238
|
-
* mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading
|
|
239
|
-
*
|
|
238
|
+
* mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading, and
|
|
239
|
+
* again on 2026-08-23 — +18 B in the shared prelude (`hush`) for all three, +39 B more on
|
|
240
|
+
* `interaction` — when each runtime learned to TERMINATE the promise chain `boot` starts rather
|
|
241
|
+
* than emit one unhandled rejection per user event. The headroom absorbed both and the conclusion
|
|
242
|
+
* is unchanged, which is the point of stating the
|
|
240
243
|
* arithmetic here rather than the answer alone. It is not
|
|
241
244
|
* derived from Solid's own size on purpose — this package may not import or name `solid-js`
|
|
242
245
|
* (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
|
package/src/registry.ts
CHANGED
|
@@ -269,9 +269,11 @@ export function registerRoute<TData = RouteData>(
|
|
|
269
269
|
config,
|
|
270
270
|
suspenseBoundaries,
|
|
271
271
|
// The declaration is the ONLY source. It was `input.islands ?? []`, which nothing ever passed,
|
|
272
|
-
// so `routeJsBytes`'s "what registration declared" half read `[]` on every
|
|
273
|
-
// framework's history — and keeping the input as a fallback would be a second
|
|
274
|
-
// question that can only ever weaken it: a caller passing `[]` un-
|
|
272
|
+
// so the now-deleted `routeJsBytes`'s "what registration declared" half read `[]` on every
|
|
273
|
+
// route in the framework's history — and keeping the input as a fallback would be a second
|
|
274
|
+
// answer to one question that can only ever weaken it: a caller passing `[]` un-declares an
|
|
275
|
+
// island. The field outlives that reader: it is the only record a build has of an island a
|
|
276
|
+
// page declared but did not render on a given pass.
|
|
275
277
|
islands: config.islands.map((spec) => spec.moduleId),
|
|
276
278
|
pattern: compilePattern(path),
|
|
277
279
|
// Spread, never assigned: `exactOptionalPropertyTypes` makes an explicit `undefined` a
|
package/src/render-html.ts
CHANGED
|
@@ -30,6 +30,20 @@ export const ROOT_ELEMENT_ID = 'x-root';
|
|
|
30
30
|
/** Depth is bounded so a component that renders itself fails with a cause instead of a stack trace. */
|
|
31
31
|
const MAX_DEPTH = 500;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Asserted at the top of `unwrap`, not only in `renderNode`. The element path was the only one
|
|
35
|
+
* bounded, and it is not the only one that recurses: `unwrap` walks arrays and calls thunks, both
|
|
36
|
+
* of which a component's `children` routinely are, so a self-referencing array or a self-returning
|
|
37
|
+
* accessor escaped `renderToHtml` as a bare `RangeError` — no code, no `fix:`, nothing to catch by.
|
|
38
|
+
*/
|
|
39
|
+
function assertDepth(depth: number): void {
|
|
40
|
+
if (depth <= MAX_DEPTH) return;
|
|
41
|
+
throw new PrerenderFailedError(
|
|
42
|
+
`component tree exceeded ${MAX_DEPTH} levels, so it renders itself`,
|
|
43
|
+
'remove the self-reference from the component that renders its own tag',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
48
|
* What the walk carries besides depth. One object per render, never module-global: two concurrent
|
|
35
49
|
* requests render different params, and a shared collector would bill one page for the other's JS.
|
|
@@ -41,8 +55,12 @@ export interface RenderHtmlOptions {
|
|
|
41
55
|
/**
|
|
42
56
|
* A thunk is called, not stringified. Solid's reactive reads are accessors (`count()`), and a
|
|
43
57
|
* `children` prop is routinely a function — evaluating it once is exactly the server's job.
|
|
58
|
+
*
|
|
59
|
+
* Which is also why the depth bound belongs HERE and not only on the element path: the array and
|
|
60
|
+
* thunk branches below recurse, and both are ordinary shapes for a component's children.
|
|
44
61
|
*/
|
|
45
62
|
async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): Promise<string> {
|
|
63
|
+
assertDepth(depth);
|
|
46
64
|
if (value === null || value === undefined || value === false || value === true) return '';
|
|
47
65
|
if (typeof value === 'string') return escapeText(value);
|
|
48
66
|
if (typeof value === 'number' || typeof value === 'bigint') return escapeText(String(value));
|
|
@@ -106,12 +124,7 @@ async function renderNode(
|
|
|
106
124
|
depth: number,
|
|
107
125
|
walk: RenderHtmlOptions,
|
|
108
126
|
): Promise<string> {
|
|
109
|
-
|
|
110
|
-
throw new PrerenderFailedError(
|
|
111
|
-
`component tree exceeded ${MAX_DEPTH} levels, so it renders itself`,
|
|
112
|
-
'remove the self-reference from the component that renders its own tag',
|
|
113
|
-
);
|
|
114
|
-
}
|
|
127
|
+
assertDepth(depth);
|
|
115
128
|
if (typeof type === 'string') return renderElement(type, props, depth, walk);
|
|
116
129
|
return unwrap(type(props), depth + 1, walk);
|
|
117
130
|
}
|
package/src/render-isr.ts
CHANGED
|
@@ -9,8 +9,10 @@ import type { CacheTag, Revalidator } from '@ultimat3/cache';
|
|
|
9
9
|
import {
|
|
10
10
|
dependentsOfKind,
|
|
11
11
|
invalidateTags,
|
|
12
|
+
markInvalidated,
|
|
12
13
|
registerDependent,
|
|
13
14
|
registerRevalidator,
|
|
15
|
+
sampleFence,
|
|
14
16
|
unregisterDependent,
|
|
15
17
|
} from '@ultimat3/cache';
|
|
16
18
|
import { logger, renderThrowable } from '@ultimat3/core';
|
|
@@ -40,6 +42,13 @@ export interface IsrEntry {
|
|
|
40
42
|
export interface IsrStore {
|
|
41
43
|
get(path: string): IsrEntry | undefined;
|
|
42
44
|
set(entry: IsrEntry): void;
|
|
45
|
+
/**
|
|
46
|
+
* Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
|
|
47
|
+
* not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
|
|
48
|
+
* store is entitled to order its eviction by that: the read-modify-write made the STALEST page
|
|
49
|
+
* the newest, so a tag bust protected exactly the pages that most needed regenerating.
|
|
50
|
+
*/
|
|
51
|
+
markStale(path: string): boolean;
|
|
43
52
|
delete(path: string): void;
|
|
44
53
|
paths(): readonly string[];
|
|
45
54
|
}
|
|
@@ -73,6 +82,14 @@ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
|
73
82
|
map.delete(oldest.value);
|
|
74
83
|
}
|
|
75
84
|
},
|
|
85
|
+
// In place: `map.set` on a key the Map already holds keeps its position, and that position is
|
|
86
|
+
// the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
|
|
87
|
+
markStale: (path) => {
|
|
88
|
+
const entry = map.get(path);
|
|
89
|
+
if (entry === undefined) return false;
|
|
90
|
+
map.set(path, { ...entry, stale: true });
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
76
93
|
delete: (path) => {
|
|
77
94
|
map.delete(path);
|
|
78
95
|
},
|
|
@@ -81,20 +98,36 @@ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
/**
|
|
84
|
-
* The
|
|
101
|
+
* The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
|
|
102
|
+
* because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
|
|
103
|
+
* `descriptorFor` would answer `undefined` and a declared `revalidate: { ttl }` would silently
|
|
104
|
+
* become tag-only. Reserved spelling, so an app's own `?locale=` stays its own dimension.
|
|
105
|
+
*/
|
|
106
|
+
export const ISR_LOCALE_PARAM = '__x_locale';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The ISR store key for one request URL: pathname, the negotiated LOCALE, and the query, **params
|
|
110
|
+
* sorted**.
|
|
85
111
|
*
|
|
86
112
|
* Exported because deriving it is the caller's job and there may only be ONE derivation — a
|
|
87
113
|
* server that keyed on `url.pathname` while the store believed it held a whole URL is the shape
|
|
88
114
|
* of #171. Sorting makes `?a=1&b=2` and `?b=2&a=1` one entry rather than two renders of one page.
|
|
89
115
|
*
|
|
116
|
+
* The locale is REQUIRED, and required as an argument rather than read from the ambient context so
|
|
117
|
+
* that every call site has to answer: a document is rendered with `<html lang>` and every `t()` in
|
|
118
|
+
* the request's own locale, so one entry per path served visitor 2 the document negotiated for
|
|
119
|
+
* visitor 1 — for the whole TTL, and with `s-maxage` telling the CDN to do the same. The time zone
|
|
120
|
+
* is deliberately NOT a dimension: it is unbounded where a locale set is declared, and an `isr`
|
|
121
|
+
* page is a shared artifact, so a date on one belongs in an explicit zone the page itself names.
|
|
122
|
+
*
|
|
90
123
|
* A query-carrying URL therefore gets its own entry, which is correct and is not free: a crawler
|
|
91
124
|
* appending `?utm_source=…` mints one entry per value. That is bounded, not unbounded —
|
|
92
125
|
* `DEFAULT_ISR_MAX_ENTRIES` evicts least-recently-generated first — and a bounded cache that
|
|
93
126
|
* thrashes is the right failure next to an unbounded one that answers the wrong document.
|
|
94
127
|
*/
|
|
95
|
-
export function isrKey(url: URL): string {
|
|
96
|
-
if (url.search === '') return url.pathname;
|
|
128
|
+
export function isrKey(url: URL, locale: string): string {
|
|
97
129
|
const params = new URLSearchParams(url.search);
|
|
130
|
+
params.set(ISR_LOCALE_PARAM, locale);
|
|
98
131
|
params.sort();
|
|
99
132
|
return `${url.pathname}?${params.toString()}`;
|
|
100
133
|
}
|
|
@@ -213,6 +246,19 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
213
246
|
|
|
214
247
|
const descriptor = descriptorFor(path);
|
|
215
248
|
const work = (async (): Promise<IsrEntry> => {
|
|
249
|
+
// BEFORE the render, not after: `revalidateByTags` reads the graph, so a bust arriving while
|
|
250
|
+
// a cold path's first render was in flight could not see the page it was invalidating —
|
|
251
|
+
// which is the window in which the bust that matters most arrives.
|
|
252
|
+
registerPath(path, descriptor);
|
|
253
|
+
// Sampled before the render for the reason `@ultimat3/cache`'s read-through fill samples
|
|
254
|
+
// before its `load()` (`tiers.ts`): the HTML below is built from rows read in the past, and
|
|
255
|
+
// a `markStale` landing in between was then ERASED by `store.set({ stale: false })`. For a
|
|
256
|
+
// tag-only route `isFresh` is true forever, so the process went on serving pre-write HTML
|
|
257
|
+
// for the rest of its life. One mechanism, not a second one grown here.
|
|
258
|
+
const fence = sampleFence({
|
|
259
|
+
key: path,
|
|
260
|
+
tags: (descriptor?.revalidateTags ?? []).map(parseWireTag),
|
|
261
|
+
});
|
|
216
262
|
const html = await render(path);
|
|
217
263
|
const entry: IsrEntry = {
|
|
218
264
|
path,
|
|
@@ -222,8 +268,10 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
222
268
|
ttlMs: parseTtlMs(descriptor?.revalidateTtl),
|
|
223
269
|
stale: false,
|
|
224
270
|
};
|
|
225
|
-
|
|
226
|
-
|
|
271
|
+
// Refused, never published stale-flagged: the next request re-renders from rows that now
|
|
272
|
+
// include the write, where a stored-but-stale entry would serve this pre-write body once
|
|
273
|
+
// more before doing the same thing.
|
|
274
|
+
if (fence.isValid()) store.set(entry);
|
|
227
275
|
forgetEvictedPaths();
|
|
228
276
|
return entry;
|
|
229
277
|
})();
|
|
@@ -234,10 +282,11 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
234
282
|
}
|
|
235
283
|
|
|
236
284
|
function markStale(path: string): boolean {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
285
|
+
// The mark is recorded whether or not the store holds the page: a regeneration already in
|
|
286
|
+
// flight for a path this store has never held is exactly the case the fence above exists for,
|
|
287
|
+
// and `invalidateTags`' own fanout only marks the TAGS.
|
|
288
|
+
markInvalidated({ key: path });
|
|
289
|
+
return store.markStale(path);
|
|
241
290
|
}
|
|
242
291
|
|
|
243
292
|
return {
|
|
@@ -362,6 +411,11 @@ function toResult(entry: IsrEntry, buildId: string, servedStale = false): Render
|
|
|
362
411
|
const headers: Record<string, string> = {
|
|
363
412
|
...staticHeaders(entry.hash, buildId),
|
|
364
413
|
'cache-control': cacheControl(entry.ttlMs),
|
|
414
|
+
// The store keys on the locale; a shared cache in front of it has to as well, or the CDN
|
|
415
|
+
// repeats the bug this entry was split to fix. `ssrHeaders`' own line, for the same reason.
|
|
416
|
+
// The rest of the shared key — the cookie, the zone — is added by `@ultimat3/http`'s
|
|
417
|
+
// `cache-headers` stage, which sees the actor this function cannot.
|
|
418
|
+
vary: 'accept-language',
|
|
365
419
|
};
|
|
366
420
|
if (servedStale) headers['x-ultimate-isr'] = 'stale';
|
|
367
421
|
return { status: 200, headers, body: entry.html };
|