@ultimat3/render 6.0.0 → 8.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 +13 -6
- package/README.md +58 -7
- package/package.json +9 -5
- package/src/head.ts +11 -1
- package/src/html.ts +27 -4
- package/src/hydrate.ts +34 -4
- package/src/index.ts +16 -15
- package/src/island-collector.ts +1 -1
- package/src/islands.ts +4 -6
- package/src/modes.ts +40 -11
- package/src/registry.ts +28 -51
- package/src/route.ts +2 -7
- package/src/surfaces.ts +2 -2
package/CLAUDE.md
CHANGED
|
@@ -9,8 +9,11 @@ second render mode — the same rule `llm()` and `backfill()` follow. It adds no
|
|
|
9
9
|
`defineRoute`.
|
|
10
10
|
|
|
11
11
|
Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cache`, `seo`,
|
|
12
|
-
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`
|
|
13
|
-
|
|
12
|
+
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`, `ui`
|
|
13
|
+
— all tier 4, so **sideways**, and an undeclared sideways edge is a build error. `ui` moved 5 → 4
|
|
14
|
+
in 2026-08 and is held level with this package deliberately (`FLOOR_ABOVE` in
|
|
15
|
+
`scripts/lib/tiers.ts`), so `render → ui` stays refused; this package sits above its own floor of 2
|
|
16
|
+
for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upward).
|
|
14
17
|
|
|
15
18
|
| Rule | Detail |
|
|
16
19
|
|---|---|
|
|
@@ -30,17 +33,21 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
30
33
|
| 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. |
|
|
31
34
|
| 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. |
|
|
32
35
|
| 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. |
|
|
33
|
-
| 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/` → `
|
|
36
|
+
| 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. |
|
|
34
37
|
| `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-weighs a declared island. |
|
|
35
38
|
| 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. |
|
|
36
39
|
| 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. |
|
|
37
40
|
| 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. |
|
|
41
|
+
| 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. |
|
|
38
42
|
| 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. |
|
|
39
43
|
| 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. |
|
|
44
|
+
| 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. |
|
|
40
45
|
| Island bytes | `routeJsBytes` unions `entry.islands` with the rendered directives' `moduleId`s. Reading either alone is a budget that counts the runtime and not the chunk. |
|
|
41
46
|
| 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)`. |
|
|
42
47
|
| 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. |
|
|
43
|
-
|
|
|
48
|
+
| 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` 744, `visible` 816, `interaction` 1,010 (`As of 2026-08-21`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from). |
|
|
49
|
+
| `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. |
|
|
50
|
+
| 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. |
|
|
44
51
|
| 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. |
|
|
45
52
|
| 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. |
|
|
46
53
|
| Descriptors | `describeRoutes()` must stay JSON-safe, sorted by path, deterministic. |
|
|
@@ -63,12 +70,12 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
63
70
|
| 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. |
|
|
64
71
|
| The loaders | `module-loader.ts` installs them at `index.ts` module scope, once. 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. |
|
|
65
72
|
| `<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. |
|
|
66
|
-
| 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. `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. |
|
|
73
|
+
| 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. |
|
|
67
74
|
| 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 `<` corrupts the code AND leaves the hole. |
|
|
68
75
|
| Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
|
|
69
76
|
| Stylesheets | compiled by `css-modules.ts` and served **inlined** per surface. `sass` is this package's only third-party dependency and its only reason to exist here. |
|
|
70
77
|
| 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. |
|
|
71
|
-
| The global layer | this package may not import `@ultimat3/ui` (tier 5,
|
|
78
|
+
| 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. |
|
|
72
79
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
|
73
80
|
| `<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. |
|
|
74
81
|
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ export const config = defineRoute({
|
|
|
19
19
|
budget: { js: '40kb', lcp: 2000 },
|
|
20
20
|
load: ({ params }) => db.posts.bySlug(params.slug), // once per render
|
|
21
21
|
meta: ({ data, url }) => ({ title: data.title, description: data.excerpt,
|
|
22
|
-
og: { image: data.cover },
|
|
22
|
+
og: { image: data.cover }, canonical: url,
|
|
23
23
|
ld: ld.Article(data) }),
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -184,15 +184,45 @@ out three things. `hydrate` and `budget.js` are **derived from `island()`**, `As
|
|
|
184
184
|
| Omitted | Derived | Overridden by |
|
|
185
185
|
|---|---|---|
|
|
186
186
|
| `hydrate` | `'interaction'` when the module declared an island, `'never'` when it did not | stating `hydrate` — the only way to say `idle` or `visible` |
|
|
187
|
-
| `budget.js` | the surface baseline +
|
|
187
|
+
| `budget.js` | the surface baseline + 20kb (`site/` → `20kb`, `app/` → `34kb`) | stating `budget: { js }` |
|
|
188
188
|
|
|
189
189
|
Both were required and both were punished: an island on a route still at `'never'` is
|
|
190
190
|
`X_ISLAND_NOT_HYDRATED`, and a `site/` route off `'never'` with no `budget.js` is refused at
|
|
191
191
|
registration. Two failures for one omission the `island()` call above had already answered.
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
20kb because a Solid island cannot cost less. Measured through `buildIslands`, minified, against
|
|
194
|
+
production Solid, `As of 2026-08`:
|
|
195
|
+
|
|
196
|
+
| Island | Bytes |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `render(() => <p>hello</p>, el)` — the floor, before an author writes a line | 12,588 |
|
|
199
|
+
| a signal, a button and reactive text | 13,663 |
|
|
200
|
+
| `settings.island.tsx`, the heaviest island this repo ships | 17,797 |
|
|
201
|
+
| one directive's hydration runtime at `hydrate: 'idle'` | 615 |
|
|
202
|
+
| the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 881 |
|
|
203
|
+
|
|
204
|
+
17,797 + 881 = **18,678** — the heaviest island this repo ships, plus the runtime an app pays
|
|
205
|
+
without writing a number down. `DEFAULT_ISLAND_HYDRATE` is `'interaction'`
|
|
206
|
+
([`route.ts:33`](src/route.ts)), applied at `:253` to any island route that states no `hydrate`, so
|
|
207
|
+
`idle`'s 615 is the cheaper case and not the one a budget has to clear.
|
|
208
|
+
|
|
209
|
+
The default is **20,480** (20kb), which is not that number rounded: the next whole kilobyte above
|
|
210
|
+
it is 19,456, and clearing today's worst island by 778 bytes is a ceiling the next line anyone
|
|
211
|
+
writes breaks. 20kb leaves 1,802 B, and stays under 2× 18,678 — so a route that bundles the same
|
|
212
|
+
island twice is still refused. All three clauses are assertions in
|
|
213
|
+
[`modes.test.ts`](src/modes.test.ts)'s `DEFAULT_ISLAND_JS_BYTES` block, against the measured
|
|
214
|
+
table above; a default that stopped clearing the floor, or stopped being a ceiling, is red.
|
|
215
|
+
|
|
216
|
+
It was **4kb** until `As of 2026-08`, sized from `contact-sales.island.tsx` — 875 B of chunk, and
|
|
217
|
+
no `solid-js` import anywhere in it. Calibrating a JSX budget on the one island shape that does not
|
|
218
|
+
pay the JSX runtime put the default a factor of three below the floor of every island that does:
|
|
219
|
+
no `budget.js` under 4096 was reachable on any surface, because the allowance is measured ABOVE the
|
|
220
|
+
baseline and not against it. (Its second number was wrong too — one directive's hydration runtime
|
|
221
|
+
is 615 B at `idle` and 881 B at `interaction`, never 1,019.)
|
|
222
|
+
|
|
223
|
+
Still a ceiling and not a pass: exceeding it is `X_BUDGET_EXCEEDED`, naming the island. An island
|
|
224
|
+
that pulls a design system in — `@ultimat3/ui`'s `<Switch>` measures 36,335 B — writes its own
|
|
225
|
+
number down, which is the point of the field.
|
|
196
226
|
|
|
197
227
|
`island()` goes **above** `defineRoute`, where JavaScript already puts a `const` the page uses:
|
|
198
228
|
`defineRoute` drains the declarations made before it. Below it, the route resolves to `'never'` and
|
|
@@ -205,7 +235,7 @@ route's — because two islands wanting different timings would leave `RouteDesc
|
|
|
205
235
|
| The route says | The island says |
|
|
206
236
|
|---|---|
|
|
207
237
|
| `hydrate` — WHEN it wakes, once, for the whole route, and only when the default is wrong | `src` — WHICH module, and `props` — what it may receive |
|
|
208
|
-
| `budget.js` — how many bytes that is allowed to cost, when
|
|
238
|
+
| `budget.js` — how many bytes that is allowed to cost, when 20kb is not the number | `tag`, `events`, `rootMargin` — how the wrapper behaves |
|
|
209
239
|
|
|
210
240
|
### An island node is a JSX child
|
|
211
241
|
|
|
@@ -271,6 +301,26 @@ island (remove it), or the `island()` call sits below the `defineRoute` that wou
|
|
|
271
301
|
be drained. A `'never'` route is also left with no derived budget, deliberately — a ceiling there
|
|
272
302
|
would paper over the contradiction.
|
|
273
303
|
|
|
304
|
+
### "Booted" and "mounted" are different facts, and the DOM says which
|
|
305
|
+
|
|
306
|
+
| In the DOM | Means |
|
|
307
|
+
|---|---|
|
|
308
|
+
| `data-x-island`, `data-x-hydrate` | **declared** — emitted for every island, `'never'` included |
|
|
309
|
+
| `el.__x` set, neither marker | **importing** — the chunk was requested, `mount()` has not settled |
|
|
310
|
+
| `data-x-mounted=""` | **running** — `mount()` resolved |
|
|
311
|
+
| `data-x-failed="<message>"` | **threw** — `mount()` rejected, and this is why |
|
|
312
|
+
|
|
313
|
+
`el.__x` is assigned when `import()` is *called*, so on its own it cannot tell a chunk still
|
|
314
|
+
downloading from one whose `mount()` threw — and the second is the half that gates a deploy. Both
|
|
315
|
+
markers are set by the runtime, never by `emitIslandAttributes`: the server does not know the
|
|
316
|
+
answer. A rejection still rethrows, so `el.__x` stays rejected and the `interaction` replay queue is
|
|
317
|
+
never flushed into an island that did not mount. `ISLAND_MOUNTED_ATTRIBUTE` and
|
|
318
|
+
`ISLAND_FAILED_ATTRIBUTE` are exported so a reader (`x shot`, an app's own test) names them once.
|
|
319
|
+
|
|
320
|
+
`IDLE_HYDRATE_TIMEOUT_MS` (2000) is the `requestIdleCallback` deadline the `idle` runtime is built
|
|
321
|
+
from, exported for the same reason: anything waiting for hydration has to wait at least this long,
|
|
322
|
+
and a second copy of the number is a settle that shoots early and calls a healthy page broken.
|
|
323
|
+
|
|
274
324
|
## Public API
|
|
275
325
|
|
|
276
326
|
| Export | Owns |
|
|
@@ -278,12 +328,13 @@ would paper over the contradiction.
|
|
|
278
328
|
| `defineRoute` | the `route` primitive |
|
|
279
329
|
| `island`, `createIslandCollector` | one interactive component on a static page |
|
|
280
330
|
| `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
|
|
281
|
-
| `registerRoute`, `describeRoutes`, `
|
|
331
|
+
| `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
|
|
282
332
|
| `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
|
|
283
333
|
| `renderStatic`, `enumeratePrerender` | build-time render, content hashing |
|
|
284
334
|
| `createIsrController`, `invalidateAndRevalidate` | SWR + single-flight + tag triggers |
|
|
285
335
|
| `renderSsr`, `streamResult` | the per-request modes |
|
|
286
336
|
| `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
|
|
337
|
+
| `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
|
|
287
338
|
| `graphFor`, `checkBudget`, `assertBudget` | two bundle graphs, per-route budgets |
|
|
288
339
|
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
|
|
289
340
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.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",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/errors.ts",
|
|
9
|
+
"./src/index.ts"
|
|
10
|
+
],
|
|
7
11
|
"repository": {
|
|
8
12
|
"type": "git",
|
|
9
13
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
@@ -31,10 +35,10 @@
|
|
|
31
35
|
"test": "bun test"
|
|
32
36
|
},
|
|
33
37
|
"dependencies": {
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/i18n": "
|
|
37
|
-
"@ultimat3/seo": "
|
|
38
|
+
"@ultimat3/cache": "8.0.0",
|
|
39
|
+
"@ultimat3/core": "8.0.0",
|
|
40
|
+
"@ultimat3/i18n": "8.0.0",
|
|
41
|
+
"@ultimat3/seo": "8.0.0",
|
|
38
42
|
"sass": "1.102.0"
|
|
39
43
|
}
|
|
40
44
|
}
|
package/src/head.ts
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
11
11
|
import { BudgetExceededError } from './errors';
|
|
12
12
|
// `html.ts` is this package's one escaper — a second one is how a character ends up missing.
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
escapeAttribute,
|
|
15
|
+
escapeJsonContent,
|
|
16
|
+
escapeRawTextContent,
|
|
17
|
+
escapeText,
|
|
18
|
+
isAttributeName,
|
|
19
|
+
} from './html';
|
|
14
20
|
|
|
15
21
|
export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
|
|
16
22
|
|
|
@@ -112,7 +118,11 @@ export function renderHead(tags: readonly HeadTag[]): string {
|
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
function renderTag(tag: HeadTag): string {
|
|
121
|
+
// The NAME is emitted verbatim before the `=` and is escaped nowhere, so a key carrying a space
|
|
122
|
+
// carries a whole second attribute with it — an app spreading a row into `attrs` put a live
|
|
123
|
+
// event handler in `<head>`. Same predicate `attributePair` uses, never a second copy.
|
|
115
124
|
const attrs = Object.entries(tag.attrs ?? {})
|
|
125
|
+
.filter(([name]) => isAttributeName(name))
|
|
116
126
|
.map(([name, value]) =>
|
|
117
127
|
value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`,
|
|
118
128
|
)
|
package/src/html.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
|
|
8
|
+
import { escapeAttribute } from '@ultimat3/seo';
|
|
8
9
|
import type { JsxProps } from './jsx';
|
|
9
10
|
|
|
10
11
|
/** Elements that never carry children, so the writer must not emit a closing tag. */
|
|
@@ -29,9 +30,13 @@ export function escapeText(value: string): string {
|
|
|
29
30
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Re-exported, never re-implemented: `@ultimat3/seo` (tier 1) owns the one attribute escaper, and
|
|
35
|
+
* the copy that lived here was a second place for a character to go missing. It stays reachable
|
|
36
|
+
* from `./html` so this module remains the single import site every render-* file already uses —
|
|
37
|
+
* one implementation, one place to look.
|
|
38
|
+
*/
|
|
39
|
+
export { escapeAttribute };
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* `<script>` and `<style>` hold RAW TEXT: a character reference is not decoded inside them, so
|
|
@@ -115,6 +120,20 @@ const URL_BEARING_ATTRIBUTES: ReadonlySet<string> = new Set([
|
|
|
115
120
|
*/
|
|
116
121
|
const REFUSED_ATTRIBUTES: ReadonlySet<string> = new Set(['srcdoc']);
|
|
117
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The only shape that can be written between `<div` and `>` without ending the attribute. A name
|
|
125
|
+
* is not escaped anywhere — it is emitted verbatim before the `=` — so a key carrying a space
|
|
126
|
+
* carries a whole second attribute with it: `{ 'x onmouseover=alert(1) y': 'ok' }` emitted a live
|
|
127
|
+
* event handler out of an object KEY, which is `<div {...row} />` over a JSON body or a JSONB
|
|
128
|
+
* column. Narrower than the HTML spec's name production on purpose: everything an app actually
|
|
129
|
+
* writes (`data-*`, `aria-*`, `xlink:href`, a `__proto__`-named column) matches, and the refusal
|
|
130
|
+
* is `null` — the same "emit nothing" this function already answers for a refused URL.
|
|
131
|
+
*/
|
|
132
|
+
const ATTRIBUTE_NAME = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/;
|
|
133
|
+
|
|
134
|
+
/** Exported for `head.ts`, the package's other attribute sink. One predicate, never two. */
|
|
135
|
+
export const isAttributeName = (name: string): boolean => ATTRIBUTE_NAME.test(name);
|
|
136
|
+
|
|
118
137
|
const cssProperty = (name: string): string =>
|
|
119
138
|
name.startsWith('--') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
120
139
|
|
|
@@ -137,7 +156,11 @@ export function attributePair(name: string, value: unknown): string | null {
|
|
|
137
156
|
if (NON_ATTRIBUTES.has(name)) return null;
|
|
138
157
|
if (value === undefined || value === null || value === false) return null;
|
|
139
158
|
if (typeof value === 'function') return null;
|
|
140
|
-
if (
|
|
159
|
+
if (!isAttributeName(name)) return null;
|
|
160
|
+
// Folded, because HTML attribute names are case-insensitive and the two checks below already
|
|
161
|
+
// fold: `ONERROR="alert(1)"` went out on the wire off a spread row for as long as this line was
|
|
162
|
+
// `name.startsWith('on')`.
|
|
163
|
+
if (name.toLowerCase().startsWith('on') && name.length > 2) return null;
|
|
141
164
|
|
|
142
165
|
const attribute = ATTRIBUTE_ALIASES.get(name) ?? name;
|
|
143
166
|
if (REFUSED_ATTRIBUTES.has(attribute.toLowerCase())) return null;
|
package/src/hydrate.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* answered instead of swallowed.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { HydrateStrategy } from '@ultimat3/core';
|
|
8
9
|
import { escapeAttribute, escapeJsonContent } from './html';
|
|
9
|
-
import type { HydrateStrategy } from './route';
|
|
10
10
|
|
|
11
11
|
export interface IslandDirective {
|
|
12
12
|
/** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */
|
|
@@ -29,6 +29,28 @@ export interface IslandDirective {
|
|
|
29
29
|
|
|
30
30
|
export const DEFAULT_REPLAY_EVENTS = ['click', 'input', 'change', 'submit', 'keydown'] as const;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* How long `idle` waits before hydrating anyway. Exported because a second reader exists and it
|
|
34
|
+
* has to agree: `x shot` leaves the page alone for this long before it photographs it, and a
|
|
35
|
+
* settle shorter than this timeout reports an unhydrated page for one that hydrates perfectly.
|
|
36
|
+
* The runtime string below interpolates it — two copies of one number is the drift axiom 2 refuses.
|
|
37
|
+
*/
|
|
38
|
+
export const IDLE_HYDRATE_TIMEOUT_MS = 2_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set by the runtime when the island's `mount()` RESOLVED. `el.__x` is set when `import()` is
|
|
42
|
+
* called, so it answers "the chunk was requested" and nothing more — three facts (declared,
|
|
43
|
+
* importing, running) had two observables between them, and the missing one is the half that gates.
|
|
44
|
+
*/
|
|
45
|
+
export const ISLAND_MOUNTED_ATTRIBUTE = 'data-x-mounted';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Set by the runtime when `mount()` REJECTED, to the error's message. A failed island and one still
|
|
49
|
+
* loading are the two states an agent most needs to tell apart, and with a success marker alone
|
|
50
|
+
* they are the same absence.
|
|
51
|
+
*/
|
|
52
|
+
export const ISLAND_FAILED_ATTRIBUTE = 'data-x-failed';
|
|
53
|
+
|
|
32
54
|
/**
|
|
33
55
|
* The island's markup wrapper. `never` gets attributes only, so the HTML is inert and the
|
|
34
56
|
* runtime below is never emitted for that island.
|
|
@@ -84,20 +106,28 @@ export function requiredStrategies(
|
|
|
84
106
|
// resolved promise. As a flag, a second click during the chunk's load short-circuited to
|
|
85
107
|
// `Promise.resolve()`, and the interaction runtime flushed its replay queue into an island that
|
|
86
108
|
// had not mounted — the events went to nothing and the listeners were already removed.
|
|
109
|
+
//
|
|
110
|
+
// The mount markers are what make the boot promise's OUTCOME readable from the DOM — by `x shot`,
|
|
111
|
+
// by an app's own test, by a human in devtools. `el.__x` exists from the moment `import()` is
|
|
112
|
+
// called, so it cannot tell a chunk that is still downloading from one whose `mount()` threw.
|
|
113
|
+
// The rejection handler rethrows: swallowing it would resolve `el.__x`, and the interaction
|
|
114
|
+
// runtime below would then flush its replay queue into an island that never mounted — the bug
|
|
115
|
+
// `el.__x`-as-a-promise was introduced to fix, reintroduced one layer further out.
|
|
87
116
|
const RUNTIME_PRELUDE = `
|
|
88
|
-
var Q={};
|
|
89
117
|
function boot(el){var e=el.getAttribute('data-x-entry');
|
|
90
118
|
if(!e)return Promise.resolve();if(el.__x)return el.__x;
|
|
91
119
|
var p=document.querySelector('script[data-x-props="'+el.getAttribute('data-x-island')+'"]');
|
|
92
120
|
var props=p?JSON.parse(p.textContent||'{}'):{};
|
|
93
|
-
return el.__x=import(e).then(function(m){return m.mount(el,props)})
|
|
121
|
+
return el.__x=import(e).then(function(m){return m.mount(el,props)}).then(
|
|
122
|
+
function(r){el.setAttribute('${ISLAND_MOUNTED_ATTRIBUTE}','');return r},
|
|
123
|
+
function(x){el.setAttribute('${ISLAND_FAILED_ATTRIBUTE}',x&&x.message||'1');throw x})}
|
|
94
124
|
function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
|
|
95
125
|
`.trim();
|
|
96
126
|
|
|
97
127
|
const RUNTIME_IDLE = `
|
|
98
128
|
each('[data-x-hydrate="idle"]',function(el){
|
|
99
129
|
var go=function(){boot(el)};
|
|
100
|
-
if('requestIdleCallback'in window)requestIdleCallback(go,{timeout
|
|
130
|
+
if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:${IDLE_HYDRATE_TIMEOUT_MS}});else setTimeout(go,1)})
|
|
101
131
|
`.trim();
|
|
102
132
|
|
|
103
133
|
const RUNTIME_VISIBLE = `
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,18 @@ import { installRenderLoader } from './module-loader';
|
|
|
9
9
|
// places one fact can be wrong instead of none.
|
|
10
10
|
installRenderLoader();
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The route vocabulary is declared once, at tier 0 (`@ultimat3/core`), and re-exported here
|
|
14
|
+
* because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor` all take these types
|
|
15
|
+
* in their signatures: a consumer calling this package's API should not need a second import to
|
|
16
|
+
* name its arguments. A re-export is not a declaration — `scripts/render-modes.test.ts` refuses a
|
|
17
|
+
* second declaration, which is what makes re-exporting safe where copying was not.
|
|
18
|
+
*/
|
|
19
|
+
export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
20
|
+
// `formatBytes` moved to `@ultimat3/core` (one formatter, with the `mb` branch this package's copy
|
|
21
|
+
// never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
|
|
22
|
+
// table it prints against.
|
|
23
|
+
export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
|
|
12
24
|
export type { CompiledStylesheet } from './css-modules';
|
|
13
25
|
export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
|
|
14
26
|
export { parseTtlMs } from './duration';
|
|
@@ -54,6 +66,9 @@ export {
|
|
|
54
66
|
emitIslandProps,
|
|
55
67
|
hydrateRuntime,
|
|
56
68
|
hydrateRuntimeBytes,
|
|
69
|
+
IDLE_HYDRATE_TIMEOUT_MS,
|
|
70
|
+
ISLAND_FAILED_ATTRIBUTE,
|
|
71
|
+
ISLAND_MOUNTED_ATTRIBUTE,
|
|
57
72
|
requiredStrategies,
|
|
58
73
|
} from './hydrate';
|
|
59
74
|
export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
|
|
@@ -74,7 +89,6 @@ export {
|
|
|
74
89
|
assertBudget,
|
|
75
90
|
checkBudget,
|
|
76
91
|
checkBudgets,
|
|
77
|
-
formatBytes,
|
|
78
92
|
graphFor,
|
|
79
93
|
parseByteBudget,
|
|
80
94
|
routeJsBytes,
|
|
@@ -89,7 +103,6 @@ export {
|
|
|
89
103
|
defaultHydrate,
|
|
90
104
|
defaultIslandBudget,
|
|
91
105
|
MODE_SPECS,
|
|
92
|
-
RENDER_MODES,
|
|
93
106
|
} from './modes';
|
|
94
107
|
export type { Stylesheet } from './module-loader';
|
|
95
108
|
export {
|
|
@@ -105,13 +118,11 @@ export type {
|
|
|
105
118
|
RegisterRouteInput,
|
|
106
119
|
RouteDescriptor,
|
|
107
120
|
RouteEntry,
|
|
108
|
-
RouteMatch,
|
|
109
121
|
} from './registry';
|
|
110
122
|
export {
|
|
111
123
|
clearRoutes,
|
|
112
124
|
compilePattern,
|
|
113
125
|
describeRoutes,
|
|
114
|
-
matchRoute,
|
|
115
126
|
ROUTE_FILENAME,
|
|
116
127
|
registerRoute,
|
|
117
128
|
routeCount,
|
|
@@ -162,11 +173,8 @@ export {
|
|
|
162
173
|
streamResult,
|
|
163
174
|
} from './render-stream';
|
|
164
175
|
export type {
|
|
165
|
-
HydrateStrategy,
|
|
166
176
|
LoadRequirement,
|
|
167
|
-
OfflineStrategy,
|
|
168
177
|
PrerenderFn,
|
|
169
|
-
RenderMode,
|
|
170
178
|
RenderResult,
|
|
171
179
|
RevalidateConfig,
|
|
172
180
|
RouteBudget,
|
|
@@ -182,14 +190,7 @@ export type {
|
|
|
182
190
|
RouteMetaFn,
|
|
183
191
|
RouteParams,
|
|
184
192
|
} from './route';
|
|
185
|
-
export {
|
|
186
|
-
DEFAULT_ISLAND_HYDRATE,
|
|
187
|
-
defineRoute,
|
|
188
|
-
HYDRATE_STRATEGIES,
|
|
189
|
-
isRouteConfig,
|
|
190
|
-
OFFLINE_STRATEGIES,
|
|
191
|
-
tagKeys,
|
|
192
|
-
} from './route';
|
|
193
|
+
export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './route';
|
|
193
194
|
export type { RouteComponent } from './route-component';
|
|
194
195
|
export { pageComponentOf } from './route-component';
|
|
195
196
|
export { metaContextFor, routeDataFor } from './route-data';
|
package/src/island-collector.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* one place a route says it ships JavaScript, and the directives stay a per-render fact.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { HydrateStrategy } from '@ultimat3/core';
|
|
7
8
|
import { IslandInvalidError, IslandNotHydratedError } from './errors';
|
|
8
9
|
import type { IslandDirective } from './hydrate';
|
|
9
10
|
import { DEFAULT_REPLAY_EVENTS } from './hydrate';
|
|
@@ -12,7 +13,6 @@ import { isEmittableSpecifier, islandNeverDrained } from './island';
|
|
|
12
13
|
import type { IslandProps } from './island-props';
|
|
13
14
|
import { checkIslandProps } from './island-props';
|
|
14
15
|
import type { JsxProps } from './jsx';
|
|
15
|
-
import type { HydrateStrategy } from './route';
|
|
16
16
|
|
|
17
17
|
/** The distinct client entries a rendered page pulled in — one per module, however many instances. */
|
|
18
18
|
export function islandModuleIds(directives: readonly IslandDirective[]): readonly string[] {
|
package/src/islands.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* opt-in, budgeted island.
|
|
6
6
|
*/
|
|
7
7
|
|
|
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';
|
|
8
12
|
import { BudgetExceededError } from './errors';
|
|
9
13
|
import type { IslandDirective } from './hydrate';
|
|
10
14
|
import { hydrateRuntimeBytes } from './hydrate';
|
|
11
15
|
import type { RouteEntry } from './registry';
|
|
12
|
-
import type { HydrateStrategy } from './route';
|
|
13
16
|
import type { Surface } from './surfaces';
|
|
14
17
|
import { SURFACE_SPECS } from './surfaces';
|
|
15
18
|
|
|
@@ -55,11 +58,6 @@ export function parseByteBudget(budget: string | undefined): number | null {
|
|
|
55
58
|
return factor === undefined ? null : Math.round(Number(amount) * factor);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
export function formatBytes(bytes: number): string {
|
|
59
|
-
if (bytes < 1024) return `${bytes}b`;
|
|
60
|
-
return `${Math.round((bytes / 1024) * 10) / 10}kb`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
61
|
export interface RouteBytes {
|
|
64
62
|
readonly total: number;
|
|
65
63
|
readonly baseline: number;
|
package/src/modes.ts
CHANGED
|
@@ -5,15 +5,14 @@
|
|
|
5
5
|
* invariant is only documented is a mode that silently degrades in production.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { HydrateStrategy, RenderMode } from '@ultimat3/core';
|
|
9
|
+
import { HYDRATE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
|
|
8
10
|
import { parseTtlMs } from './duration';
|
|
9
11
|
import { RouteModeInvalidError } from './errors';
|
|
10
|
-
import type {
|
|
11
|
-
import { HYDRATE_STRATEGIES } from './route';
|
|
12
|
+
import type { RouteConfig } from './route';
|
|
12
13
|
import type { Surface } from './surfaces';
|
|
13
14
|
import { SURFACE_SPECS, surfaceAllows } from './surfaces';
|
|
14
15
|
|
|
15
|
-
export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream'] as const;
|
|
16
|
-
|
|
17
16
|
/**
|
|
18
17
|
* Everything about a route except the two keys carrying its data generic. Mode invariants never
|
|
19
18
|
* read metadata and never load anything, and omitting both keeps these checks free of `TData`.
|
|
@@ -38,7 +37,9 @@ export interface ModeSpec {
|
|
|
38
37
|
readonly description: string;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
|
|
40
|
+
/** Keyed with an explicit type argument for the reason `MODE_STRATEGY` in `@ultimat3/pwa`
|
|
41
|
+
* gives: a `const X: Record<…> = Object.freeze({…})` accepts an extra key in silence. */
|
|
42
|
+
export const MODE_SPECS = Object.freeze<Record<RenderMode, ModeSpec>>({
|
|
42
43
|
static: {
|
|
43
44
|
mode: 'static',
|
|
44
45
|
perRequestState: false,
|
|
@@ -211,14 +212,42 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
/**
|
|
214
|
-
* What an island route may spend ABOVE its surface's baseline when it declares no `budget.js
|
|
215
|
+
* What an island route may spend ABOVE its surface's baseline when it declares no `budget.js` —
|
|
216
|
+
* the island chunk plus the hydration runtime, which is what `routeJsBytes` adds to the baseline.
|
|
217
|
+
*
|
|
218
|
+
* **20kb, and it is calibrated on a Solid island** (`As of 2026-08-21`). It was 4096, justified in
|
|
219
|
+
* this comment as "twice what the reference app's real island costs (875 B of chunk plus a 1,019 B
|
|
220
|
+
* runtime)" — and that island, `contact-sales.island.tsx`, imports no `solid-js` at all. Calibrating
|
|
221
|
+
* a JSX budget on the one island shape that does not pay the JSX runtime made the default
|
|
222
|
+
* unreachable for every island that does: measured with `buildIslands`, minified, production Solid,
|
|
223
|
+
* `render(() => <p>hello</p>, el)` is **12,588 B** — the floor, before an author writes anything —
|
|
224
|
+
* a signal + a button + reactive text is 13,663 B, and the reference app's real Solid island
|
|
225
|
+
* (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
|
|
226
|
+
* any surface, because the allowance is measured above the baseline and not against it.
|
|
227
|
+
*
|
|
228
|
+
* The number: 17,797 (the heaviest island this repo actually ships) + 1,010 (`hydrateRuntimeBytes`
|
|
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,807**. That is the worst case an
|
|
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,673 B of headroom and still
|
|
233
|
+
* under 2x 18,807, so a route bundling the same island twice is refused. `island-budget.test.ts`
|
|
234
|
+
* asserts all three. `idle` costs 744 and `visible` 816, so an island route that declares its
|
|
235
|
+
* strategy pays less; the default is what the budget has to clear.
|
|
236
|
+
*
|
|
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. The
|
|
239
|
+
* headroom absorbed it and the conclusion is unchanged — which is the point of stating the
|
|
240
|
+
* arithmetic here rather than the answer alone. It is not
|
|
241
|
+
* derived from Solid's own size on purpose — this package may not import or name `solid-js`
|
|
242
|
+
* (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
|
|
215
243
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* which is
|
|
219
|
-
*
|
|
244
|
+
* Still a real ceiling, still real bytes on disk: an island that pulls a design system in
|
|
245
|
+
* (`@ultimat3/ui`'s `<Switch>` measures 36,335 B) or ships an engine twice must write its own
|
|
246
|
+
* number down, which is the whole point. Two defaults keyed on "does the graph reach Solid?" is
|
|
247
|
+
* not an option even setting axiom 1 aside: `registry.ts` reads this at REGISTRATION, before any
|
|
248
|
+
* bundle exists, so the answer is not available where the constant is used.
|
|
220
249
|
*/
|
|
221
|
-
export const DEFAULT_ISLAND_JS_BYTES =
|
|
250
|
+
export const DEFAULT_ISLAND_JS_BYTES = 20_480;
|
|
222
251
|
|
|
223
252
|
/**
|
|
224
253
|
* The derived ceiling for a route whose hydration came from an island. Relative to the surface
|
package/src/registry.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* from. Nothing downstream may keep its own list of routes.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
8
9
|
import {
|
|
9
10
|
RouteDuplicateError,
|
|
10
11
|
RouteFileInvalidError,
|
|
@@ -12,24 +13,22 @@ import {
|
|
|
12
13
|
SurfaceBoundaryError,
|
|
13
14
|
} from './errors';
|
|
14
15
|
import { assertModeInvariants, defaultIslandBudget } from './modes';
|
|
15
|
-
import type {
|
|
16
|
-
HydrateStrategy,
|
|
17
|
-
OfflineStrategy,
|
|
18
|
-
RenderMode,
|
|
19
|
-
RouteConfig,
|
|
20
|
-
RouteData,
|
|
21
|
-
RouteParams,
|
|
22
|
-
} from './route';
|
|
16
|
+
import type { RouteConfig, RouteData } from './route';
|
|
23
17
|
import { isRouteConfig, tagKeys } from './route';
|
|
24
18
|
import type { RouteComponent } from './route-component';
|
|
25
19
|
import type { Surface } from './surfaces';
|
|
26
20
|
import { locateSurface } from './surfaces';
|
|
27
21
|
|
|
28
22
|
/**
|
|
29
|
-
* The one filename a route may carry, per surface. `shared/` is
|
|
30
|
-
* of helpers with no URL, so a route file there has nowhere to resolve to
|
|
23
|
+
* The one filename a route may carry, per surface. `shared/` is `Exclude`d rather than merely
|
|
24
|
+
* absent: it is a leaf of helpers with no URL, so a route file there has nowhere to resolve to —
|
|
25
|
+
* and stating that in the key type makes the other three MANDATORY. `Partial<Record<Surface, …>>`
|
|
26
|
+
* said the same thing about `shared` and let any of the three go missing, which only three
|
|
27
|
+
* registration tests would have caught. A dropped `api` row is not a crash: `assertRouteFilename`
|
|
28
|
+
* reads `undefined` as "this file is under shared/", so every `api/` route author would have been
|
|
29
|
+
* told their file is a leaf of helpers.
|
|
31
30
|
*/
|
|
32
|
-
export const ROUTE_FILENAME
|
|
31
|
+
export const ROUTE_FILENAME = Object.freeze<Record<Exclude<Surface, 'shared'>, string>>({
|
|
33
32
|
site: 'page.tsx',
|
|
34
33
|
app: 'page.tsx',
|
|
35
34
|
api: 'route.ts',
|
|
@@ -137,7 +136,9 @@ const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")
|
|
|
137
136
|
* author already meant, plus the one filename that surface accepts.
|
|
138
137
|
*/
|
|
139
138
|
function assertRouteFilename(file: string, surface: Surface, basename: string | undefined): void {
|
|
140
|
-
|
|
139
|
+
// `shared` is the one surface with no filename, and the key type now says so — which is why
|
|
140
|
+
// this is a comparison rather than an `undefined` check on the lookup.
|
|
141
|
+
const expected = surface === 'shared' ? undefined : ROUTE_FILENAME[surface];
|
|
141
142
|
if (expected === undefined) {
|
|
142
143
|
throw new RouteFileInvalidError(
|
|
143
144
|
`${file} is under shared/, which is a leaf of helpers with no URL — a route cannot live there`,
|
|
@@ -154,12 +155,20 @@ function assertRouteFilename(file: string, surface: Surface, basename: string |
|
|
|
154
155
|
const dir = stem.slice(0, stem.lastIndexOf('/'));
|
|
155
156
|
const inPlace = DIRECTORY_STEMS.has(stem.slice(dir.length + 1));
|
|
156
157
|
const target = inPlace ? `${dir}/${expected}` : `${stem}/${expected}`;
|
|
158
|
+
// Plain `mv`, never `git mv`. `x new --no-git` scaffolds a tree with no repository, and there the
|
|
159
|
+
// shipped `git mv` answered `fatal: not a git repository` — a fix line that fails is worse than
|
|
160
|
+
// no fix line, because the reader debugs git instead of moving the file. `mv` works in both
|
|
161
|
+
// cases: git detects the rename at `git add` time, so the only thing given up is a nicety, and
|
|
162
|
+
// the instruction is the same one either way. `-n` keeps the one guarantee `git mv` did carry:
|
|
163
|
+
// an author who adds the correct `site/pricing/page.tsx` and leaves `site/pricing.tsx` behind is
|
|
164
|
+
// reported against the stale file, and `target` is then the GOOD file — a clobber deletes a
|
|
165
|
+
// working route, and this fix line is pasted unread.
|
|
157
166
|
throw new RouteFileInvalidError(
|
|
158
167
|
`${file} is a route on the ${surface} surface, so it must be named ${expected}: the URL is the ` +
|
|
159
168
|
'directory path and the filename names the kind of file',
|
|
160
169
|
inPlace
|
|
161
|
-
? `
|
|
162
|
-
: `mkdir -p -- ${shellQuote(stem)} &&
|
|
170
|
+
? `mv -n -- ${shellQuote(file)} ${shellQuote(target)}`
|
|
171
|
+
: `mkdir -p -- ${shellQuote(stem)} && mv -n -- ${shellQuote(file)} ${shellQuote(target)}`,
|
|
163
172
|
);
|
|
164
173
|
}
|
|
165
174
|
|
|
@@ -333,46 +342,14 @@ export function describeRoutes(): readonly RouteDescriptor[] {
|
|
|
333
342
|
}));
|
|
334
343
|
}
|
|
335
344
|
|
|
336
|
-
export interface RouteMatch {
|
|
337
|
-
readonly entry: RouteEntry;
|
|
338
|
-
readonly params: RouteParams;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Most specific pattern wins: static segments > dynamic > catch-all. */
|
|
342
|
-
export function matchRoute(pathname: string): RouteMatch | null {
|
|
343
|
-
const candidates = routeEntries()
|
|
344
|
-
.slice()
|
|
345
|
-
.sort((a, b) => b.pattern.specificity - a.pattern.specificity);
|
|
346
|
-
|
|
347
|
-
for (const entry of candidates) {
|
|
348
|
-
const match = entry.pattern.regex.exec(pathname);
|
|
349
|
-
if (match === null) continue;
|
|
350
|
-
const params: Record<string, string> = {};
|
|
351
|
-
let undecodable = false;
|
|
352
|
-
entry.pattern.keys.forEach((key, index) => {
|
|
353
|
-
const value = match[index + 1];
|
|
354
|
-
if (value === undefined) return;
|
|
355
|
-
const decoded = decodeSegment(value);
|
|
356
|
-
if (decoded === undefined) undecodable = true;
|
|
357
|
-
else params[key] = decoded;
|
|
358
|
-
});
|
|
359
|
-
// A segment that will not decode fails only the branch that would have decoded it, exactly as
|
|
360
|
-
// `@ultimat3/http`'s router already answers: a literal route matching the same text still wins,
|
|
361
|
-
// and a pathname nothing else claims is the 404 it always was.
|
|
362
|
-
if (undecodable) continue;
|
|
363
|
-
return { entry, params };
|
|
364
|
-
}
|
|
365
|
-
return null;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
345
|
/**
|
|
369
346
|
* `undefined` for a malformed percent-escape. A pathname is whatever the client typed, and
|
|
370
|
-
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line —
|
|
371
|
-
*
|
|
347
|
+
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — where a router
|
|
348
|
+
* already has an answer for "this branch does not match".
|
|
372
349
|
*
|
|
373
|
-
* Still exported after
|
|
374
|
-
* this segment decodable?" on this side of the wire,
|
|
375
|
-
* ends up throwing where the other 404s.
|
|
350
|
+
* Still exported after this package's own `matchRoute` was deleted for `@ultimat3/http`'s trie
|
|
351
|
+
* (`stages.ts`): it is the one answer to "is this segment decodable?" on this side of the wire,
|
|
352
|
+
* and a second copy of it is how one of the two ends up throwing where the other 404s.
|
|
376
353
|
*/
|
|
377
354
|
export function decodeSegment(value: string): string | undefined {
|
|
378
355
|
try {
|
package/src/route.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import type { CacheTag } from '@ultimat3/cache';
|
|
16
16
|
import { serializeTags } from '@ultimat3/cache';
|
|
17
|
+
import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
18
|
+
import { OFFLINE_STRATEGIES } from '@ultimat3/core';
|
|
17
19
|
import type { Translator } from '@ultimat3/i18n';
|
|
18
20
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
19
21
|
import { RouteLoadInvalidError, RouteMetaMissingError, RouteOfflineMissingError } from './errors';
|
|
@@ -21,13 +23,6 @@ import type { IslandSpec } from './island';
|
|
|
21
23
|
import { drainDeclaredIslands } from './island';
|
|
22
24
|
import { assertModeShape } from './modes';
|
|
23
25
|
|
|
24
|
-
export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream';
|
|
25
|
-
export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
|
|
26
|
-
export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never';
|
|
27
|
-
|
|
28
|
-
export const OFFLINE_STRATEGIES = ['precache', 'runtime', 'network-only'] as const;
|
|
29
|
-
export const HYDRATE_STRATEGIES = ['idle', 'visible', 'interaction', 'never'] as const;
|
|
30
|
-
|
|
31
26
|
/**
|
|
32
27
|
* What a page that declares an island hydrates as when it says nothing. The most conservative of
|
|
33
28
|
* the three that ship JavaScript: nothing runs until the visitor acts, and `interaction` is the
|
package/src/surfaces.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* aspirational. Violations are build errors, resolved through the whole chain.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { RenderMode } from '@ultimat3/core';
|
|
8
9
|
import { SurfaceBoundaryError } from './errors';
|
|
9
|
-
import type { RenderMode } from './route';
|
|
10
10
|
|
|
11
11
|
export type Surface = 'site' | 'app' | 'api' | 'shared';
|
|
12
12
|
|
|
@@ -24,7 +24,7 @@ export interface SurfaceSpec {
|
|
|
24
24
|
readonly mayImportTypes: readonly Surface[];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export const SURFACE_SPECS
|
|
27
|
+
export const SURFACE_SPECS = Object.freeze<Record<Surface, SurfaceSpec>>({
|
|
28
28
|
site: {
|
|
29
29
|
surface: 'site',
|
|
30
30
|
defaultMode: 'static',
|