@ultimat3/render 20.2.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +85 -88
- package/README.md +31 -9
- package/package.json +6 -6
- package/src/client-scope-tag.ts +49 -0
- package/src/client-sync-tags.ts +44 -0
- package/src/css-modules.ts +16 -3
- package/src/duration.ts +9 -8
- package/src/hydrate.ts +43 -12
- package/src/index.ts +21 -9
- package/src/modes.ts +11 -10
- package/src/module-loader.ts +3 -0
- package/src/registry.ts +12 -0
- package/src/render-isr-store.ts +93 -0
- package/src/render-isr.ts +24 -93
- package/src/render-static.ts +19 -22
- package/src/render-stream.ts +14 -14
- package/src/server.ts +2 -6
- package/src/static-path.ts +77 -0
- package/src/stream-scripts.ts +18 -0
- package/src/surfaces.ts +38 -24
package/CLAUDE.md
CHANGED
|
@@ -4,107 +4,102 @@ Owns: the `route` primitive, the four render modes, the route table, the surface
|
|
|
4
4
|
islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
|
|
5
5
|
loaders that make an app's `.tsx` and `.scss` runnable**.
|
|
6
6
|
|
|
7
|
-
**Two entry points, disjoint
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
`
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Measured: no `sideEffects` value repairs it (`false`, `[]`, an array naming only `errors.ts` — all
|
|
15
|
-
fail identically), which is where this split differs from realtime's, where the array alone was
|
|
16
|
-
enough. Only not importing the module does. Never re-export a name from both barrels: disjointness
|
|
17
|
-
is what makes "which half does this live in" a mechanical fact, and `index.test.ts` asserts both
|
|
18
|
-
the empty name intersection AND that `index.ts`'s transitive runtime import graph reaches none of
|
|
19
|
-
the seven modules above. `scripts/browser-barrel.test.ts` holds the end property, both directions.
|
|
7
|
+
**Two entry points, disjoint.** `"."` (`index.ts`) is the CLIENT half and bundles for the browser;
|
|
8
|
+
`"./server"` (`server.ts`) is the build-time half — `css-modules`, `module-loader`, `render-html`,
|
|
9
|
+
`render-isr`, `render-ssr`, `render-static`, `render-stream` — and does not (`css-modules.ts` needs
|
|
10
|
+
`node:url`, which a browser build cannot link; no `sideEffects` value repairs that). Never re-export
|
|
11
|
+
a name from both barrels: `index.test.ts` asserts the empty name intersection and that `index.ts`'s
|
|
12
|
+
runtime import graph reaches none of those seven modules; `scripts/browser-barrel.test.ts` holds
|
|
13
|
+
the end property.
|
|
20
14
|
|
|
21
|
-
`island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a
|
|
22
|
-
|
|
23
|
-
`defineRoute`.
|
|
15
|
+
`island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a second
|
|
16
|
+
render mode. It adds no key to `defineRoute`.
|
|
24
17
|
|
|
25
18
|
Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cache`, `seo`,
|
|
26
|
-
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`, `ui`
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upward).
|
|
19
|
+
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`, `ui` — all
|
|
20
|
+
tier 4, so sideways. This package sits above its floor of 2 so `render → pwa` stays refused, and
|
|
21
|
+
`ui` is held level with it so `render → ui` stays refused (`FLOOR_ABOVE` in `scripts/lib/tiers.ts`;
|
|
22
|
+
axiom 6). Never `cli` (upward).
|
|
31
23
|
|
|
32
24
|
| Rule | Detail |
|
|
33
25
|
|---|---|
|
|
34
26
|
| `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
|
|
35
|
-
| `hydrate` |
|
|
36
|
-
| `defineRoute` shape | exactly the contract's 9 keys. New route *metadata*
|
|
37
|
-
| `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component.
|
|
38
|
-
| `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter
|
|
39
|
-
| A loader's own error | rethrown only when `isUltimateError` says so
|
|
40
|
-
| A loader's own STATUS | `withStatus(status, data)`
|
|
41
|
-
| Type claims | `type-pins.tsx`, never a `.test.ts` — `tsconfig.json` excludes tests
|
|
27
|
+
| `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
|
|
28
|
+
| `defineRoute` shape | exactly the contract's 9 keys. New route *metadata* goes inside `meta`. |
|
|
29
|
+
| `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
|
|
30
|
+
| `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
|
|
31
|
+
| A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
|
|
32
|
+
| A loader's own STATUS | `withStatus(status, data)` (`route-status.ts`) — the ONE way a page answers 404/410/503 while rendering its own component in the app's shell. The status rides on the data by IDENTITY in a `WeakMap`; `routeStatusOf(data)` is the total reader (200 by default). A 3xx is `X_ROUTE_STATUS_INVALID`. A 4xx/5xx is `robots.index = false` by construction in `defineRoute`'s `meta` wrapper. The `Response` status is minted by `@ultimat3/cli`'s `dev-render.ts`. |
|
|
33
|
+
| Type claims | `type-pins.tsx`, never a `.test.ts` — `tsconfig.json` excludes tests. `.tsx` so the island-as-JSX claim is checked against the JSX an author writes. |
|
|
42
34
|
| Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
|
|
43
|
-
| Descriptor `budget` | always an object, `{}` when undeclared.
|
|
44
|
-
| `RouteBudget` keys | **`js` and `lcp
|
|
45
|
-
| No `describe()` on a route | `describeRoutes()` is the one route list.
|
|
46
|
-
| Mode invariants | `modes.ts` only
|
|
47
|
-
| Island declaration | `island({ src })` — a **specifier**, never an import
|
|
48
|
-
| Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling
|
|
49
|
-
| Island timing | the route's `hydrate` and nothing else
|
|
50
|
-
| Island node shape | a **branded array** (`IslandNode extends Array<never>`)
|
|
51
|
-
| Island declaration order | `island()` above `defineRoute`, drained by it (`drainDeclaredIslands`)
|
|
52
|
-
| Derived budget | `registry.ts`,
|
|
53
|
-
| `RouteEntry.islands` | filled from `config.islands` at registration
|
|
54
|
-
| Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` — `island-props.ts` is the one gate
|
|
55
|
-
| A prop lands via `Object.defineProperty` | never `out[key] = v
|
|
56
|
-
| An attribute alias is a `Map` | never a record
|
|
57
|
-
| An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts`
|
|
58
|
-
| Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four
|
|
59
|
-
| Island collection | per render,
|
|
60
|
-
| A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one.
|
|
61
|
-
| Island bytes | **not this package's
|
|
62
|
-
| The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit
|
|
63
|
-
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts
|
|
64
|
-
| Island boot | `el.__x` holds the boot PROMISE, never a boolean.
|
|
65
|
-
| Island mount markers | `data-x-mounted=""` when `mount()`
|
|
66
|
-
| A runtime that calls `boot` | TERMINATES the chain
|
|
67
|
-
|
|
|
68
|
-
| `
|
|
69
|
-
|
|
|
70
|
-
| Route
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
35
|
+
| Descriptor `budget` | always an object, `{}` when undeclared. `budget.js === undefined` is the site/ hydration failure. |
|
|
36
|
+
| `RouteBudget` keys | **`js` and `lcp` only, and every key is PROJECTED** (`budgetJs`, `budgetLcp`). `type-pins.tsx` derives the allowed set from `RouteDescriptor`'s `budget*` keys, so an unprojected key is a build error; `scripts/declaration-readers.ts` finds the class across every declaration. |
|
|
37
|
+
| No `describe()` on a route | `describeRoutes()` is the one route list. |
|
|
38
|
+
| Mode invariants | `modes.ts` only; never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component.** A fifth mode has to name the function that renders it. |
|
|
39
|
+
| Island declaration | `island({ src })` — a **specifier**, never an import, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
|
|
40
|
+
| Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling. Never widen it. |
|
|
41
|
+
| Island timing | the route's `hydrate` and nothing else. `hydrate: 'never'` + an island, or an `island()` call below the `defineRoute` that drains it, is `X_ISLAND_NOT_HYDRATED`; `islandNeverDrained(spec)` tells the two causes apart so the `fix:` names exactly one edit. |
|
|
42
|
+
| Island node shape | a **branded array** (`IslandNode extends Array<never>`); every walker tests `isIslandNode` BEFORE `Array.isArray`. Solid's `JSX.Element` is an unaugmentable alias whose only object member is `ArrayElement`. Never import solid's union. |
|
|
43
|
+
| Island declaration order | `island()` above `defineRoute`, drained by it (`drainDeclaredIslands`), reachable from `./island` and never from `src/index.ts`. Per MODULE (evaluated once), unlike the per-render collector. |
|
|
44
|
+
| Derived budget | `registry.ts`, from the surface: `site/` → `20kb`, `app/` → `34kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`), calibrated on a Solid island. A declared `budget.js` wins; a `'never'` route gets none. |
|
|
45
|
+
| `RouteEntry.islands` | filled from `config.islands` at registration and nothing else — `RegisterRouteInput` has no `islands` key. |
|
|
46
|
+
| Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` (16 KiB; the doc comment carries the arithmetic) — `island-props.ts` is the one gate, a structural walk, never a `JSON.stringify` round trip. Over the cap the cause names the heaviest props and the fix names the endpoint pattern, never "raise the cap". `X_ISLAND_PROPS_INVALID` has a row in `@ultimat3/http`'s table (500). |
|
|
47
|
+
| A prop lands via `Object.defineProperty` | never `out[key] = v` — `__proto__` (a real own key off `JSON.parse`) would run the prototype setter. |
|
|
48
|
+
| An attribute alias is a `Map` | never a record (`toString` resolved to a function). `MODE_SPECS[config.render]` is guarded by `Object.hasOwn` for the same reason. |
|
|
49
|
+
| An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` (`/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`), refused as `null`; the `on*` handler check folds case. `head.ts`'s `renderTag` shares the predicate (`isAttributeName`). One predicate, never two. |
|
|
50
|
+
| Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four plus `data`, `poster`, `ping`, `xlink:href`. `srcdoc` is refused outright (entity-decoded, then parsed as HTML). |
|
|
51
|
+
| Island collection | per render, `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context. |
|
|
52
|
+
| A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. |
|
|
53
|
+
| Island bytes | **not this package's**: `@ultimat3/cli`'s `packages/cli/src/budgets.ts` measures the EMITTED document and is the gate. What stays here is the budget grammar (`parseByteBudget`) and `defaultIslandBudget`. |
|
|
54
|
+
| The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets). It is inline in every document with an island and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot. `runtimeBody` is the one place the served and hashed text are one string. **Uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>` (unreachable today; the first real hole needs a nonce). |
|
|
55
|
+
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`; an assembler only adds `hydrateRuntime(directives)`. |
|
|
56
|
+
| Island boot | `el.__x` holds the boot PROMISE, never a boolean. |
|
|
57
|
+
| Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-09-22`: `idle` 1,744, `visible` 846, `interaction` 1,629 B (what `DEFAULT_ISLAND_JS_BYTES` derives from). |
|
|
58
|
+
| A runtime that calls `boot` | TERMINATES the chain: `idle`/`visible` end in `.catch(hush)`, `interaction` passes `off` as the rejection arm. `hydrate-runtime.test.ts` reds on an unhandled rejection. |
|
|
59
|
+
| `idle` replays too | `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` (capture listeners for `data-x-events`, default `click`; a queue; `aim`; one flush). A caught event wakes an `idle` island early; each event replays once. `hydrate-replay.test.ts`. |
|
|
60
|
+
| Where `interaction` replays | `aim(el, ev)`, never `ev.target` — `mount` usually clears the root, detaching the pressed node. Kept → original target; replaced → `path(el, target)`'s structural walk (same tag, same place), then `document.elementFromPoint(ev.clientX, ev.clientY)` (unless `ev.detail === 0`), then the island root. A hit outside the island falls back to the root. `hydrate-replay.test.ts`. |
|
|
61
|
+
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
|
|
62
|
+
| Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
|
|
63
|
+
| Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`. Anything else is `X_ROUTE_FILE_INVALID`. |
|
|
64
|
+
| Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED`; it fills in only the island budget. |
|
|
65
|
+
| Descriptors | `describeRoutes()` stays JSON-safe, sorted by path, deterministic. |
|
|
66
|
+
| Every string order | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare` (ICU default locale and collation version vary by machine). |
|
|
74
67
|
| Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
|
|
75
|
-
| Stream cancellation | the
|
|
76
|
-
| ISR detach | `attach()`'s returned function clears the revalidator
|
|
77
|
-
| "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below
|
|
78
|
-
| A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts
|
|
79
|
-
| `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `
|
|
80
|
-
| The ISR key | `isrKey(url, locale)` — pathname, the negotiated
|
|
81
|
-
| A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set
|
|
82
|
-
| Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })
|
|
83
|
-
| ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first
|
|
84
|
-
| A `RenderResult.status` | `finiteStatus(subject, status)`
|
|
85
|
-
| `IsrEntry.ttlMs` off a store | normalised by `entryTtlMs`, TOTAL
|
|
86
|
-
| Route path from file |
|
|
87
|
-
| An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts
|
|
88
|
-
| ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`).
|
|
89
|
-
| Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (
|
|
68
|
+
| Stream cancellation | the source has a `cancel()` and `write` is guarded on it; a disconnect aborts `StreamHole.resolve(signal)`. |
|
|
69
|
+
| ISR detach | `attach()`'s returned function clears the revalidator too — only if the slot is still its own (`installedRevalidator`). |
|
|
70
|
+
| "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below `modes.ts` and `render-isr.ts`. |
|
|
71
|
+
| A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts`. `bun run error-render` cannot see a value laundered through a local helper. |
|
|
72
|
+
| `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `pathnameOf` never throws on a relative `ctx.url`. |
|
|
73
|
+
| The ISR key | `isrKey(url, locale)` — pathname, the negotiated locale in the reserved `__x_locale` parameter, then the sorted query. The locale is REQUIRED. The time zone is deliberately not a dimension. `toResult` emits `vary: accept-language`; the rest of the shared key is `@ultimat3/http`'s `cache-headers` stage. |
|
|
74
|
+
| A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set`. `registerPath` runs BEFORE the render. |
|
|
75
|
+
| Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })` (`set` means "just generated" and orders eviction). |
|
|
76
|
+
| ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first. |
|
|
77
|
+
| A `RenderResult.status` | `finiteStatus(subject, status)` (`finite-status.ts`), 200–599 whole, at every site that takes one from a caller; `isRenderStatus` is the TOTAL read of a stored `IsrEntry.status`. The name carries `finite` so `bun run finite-bounds` recognises it. |
|
|
78
|
+
| `IsrEntry.ttlMs` off a store | normalised by `entryTtlMs`, TOTAL — anything not positive-finite is tag-only `null`, with an `isr.entry_ttl_invalid` warning. |
|
|
79
|
+
| Route path from file | `locateSurface()` answers which surface AND where it starts. Never re-derive the offset from the surface name. |
|
|
80
|
+
| An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts`. |
|
|
81
|
+
| ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). |
|
|
82
|
+
| Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15 s), `holeTimeoutMs: null` to opt out, otherwise a whole number ≥ 1. A hole reveals exactly once — promise, rejection or deadline. |
|
|
90
83
|
| Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
|
|
91
84
|
| Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
|
|
92
|
-
| A gated route is never a cached one | `modes.ts` refuses `policy` on
|
|
85
|
+
| A gated route is never a cached one | `modes.ts` refuses `policy` on both `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `ssr` is the one gated mode. |
|
|
93
86
|
| Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
|
|
94
|
-
| Who owns `cache-control` | a
|
|
95
|
-
| Solid | no `solid-js` import anywhere in this package
|
|
96
|
-
| Root element | `ROOT_ELEMENT_ID` (`render-html.ts`)
|
|
97
|
-
| The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
87
|
+
| Who owns `cache-control` | a mode states intent; `@ultimat3/http`'s `cache-headers` stage decides and may overrule. This package cannot see the actor and never tries. |
|
|
88
|
+
| Solid | no `solid-js` import anywhere in this package; `type-pins.tsx` satisfies `JSX.Element` structurally. `jsx.ts` builds inert nodes. Islands are compiled by `@ultimat3/cli`'s `solid-loader.ts` with `babel-preset-solid`. |
|
|
89
|
+
| Root element | `ROOT_ELEMENT_ID` (`render-html.ts`). |
|
|
90
|
+
| The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once. `packages/cli/src/app-load.ts` imports `@ultimat3/render/server` for that side effect. |
|
|
91
|
+
| Client sync tags | `client-sync-tags.ts` — `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker`. Principal-free, on every document. |
|
|
92
|
+
| Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope">`, read by core's `pageClient()`. `documentCarriesScope(headers)` is the one rule: only a `private` document carries it; absent means "not rendered for anyone". The literal is duplicated in core until core exports a constant. |
|
|
93
|
+
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can override any of them. |
|
|
94
|
+
| Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeAttribute` is `@ultimat3/seo`'s, re-exported by `html.ts`. |
|
|
95
|
+
| Script and style CONTENT | never raw: `escapeText`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
|
|
96
|
+
| Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
|
|
97
|
+
| Stylesheets | compiled by `css-modules.ts`, grouped per surface, served by the CLI as one content-hashed file per surface (`@ultimat3/cli`'s `style-bundle.ts`) from `stylesFor`. `sass` is this package's only third-party dependency. |
|
|
98
|
+
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`). `shared/` is carried by both graphs. |
|
|
99
|
+
| The global layer | the app's `shared/global.scss` `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts` (this package may not import `ui`). `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
|
|
105
100
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
|
106
|
-
| An island's `mount` | may return `() => void`, its disposer
|
|
107
|
-
| `<head>` binding | `head.ts` stays injection-only
|
|
101
|
+
| An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
|
|
102
|
+
| `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
|
|
108
103
|
|
|
109
104
|
Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
|
|
110
105
|
Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
|
|
@@ -114,3 +109,5 @@ bun test # from packages/render
|
|
|
114
109
|
bun run typecheck
|
|
115
110
|
bun run --cwd ../.. verify # the contract
|
|
116
111
|
```
|
|
112
|
+
|
|
113
|
+
Why each rule above is shaped the way it is: [`docs/history/render.md`](../../docs/history/render.md).
|
package/README.md
CHANGED
|
@@ -210,17 +210,17 @@ production Solid, `As of 2026-08`:
|
|
|
210
210
|
| `render(() => <p>hello</p>, el)` — the floor, before an author writes a line | 12,588 |
|
|
211
211
|
| a signal, a button and reactive text | 13,663 |
|
|
212
212
|
| `settings.island.tsx`, the heaviest island this repo ships | 17,797 |
|
|
213
|
-
| one directive's hydration runtime at `hydrate: 'idle'` |
|
|
214
|
-
| the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 1,
|
|
213
|
+
| one directive's hydration runtime at `hydrate: 'idle'`, what an `app/` island gets from `defaultHydrate` | 1,744 |
|
|
214
|
+
| the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 1,629 |
|
|
215
215
|
|
|
216
|
-
17,797 + 1,
|
|
217
|
-
without writing a number down. `
|
|
218
|
-
|
|
219
|
-
`
|
|
216
|
+
17,797 + 1,744 = **19,541** — the heaviest island this repo ships, plus the costlier of the two
|
|
217
|
+
runtimes an app pays without writing a number down. `idle` became the costlier on 2026-09-22, when
|
|
218
|
+
it learned to catch a press made before it mounted and replay it through the same `catchUp` as
|
|
219
|
+
`interaction`, and both learned to aim a keyboard press by the pressed node's path (774 -> 1,744);
|
|
220
|
+
`DEFAULT_ISLAND_HYDRATE` is `'interaction'` ([`route.ts:34`](src/route.ts)) at 1,629.
|
|
220
221
|
|
|
221
|
-
The default is **20,480** (20kb),
|
|
222
|
-
|
|
223
|
-
writes breaks. 20kb leaves 1,432 B, and stays under 2× 19,048 — so a route that bundles the same
|
|
222
|
+
The default is **20,480** (20kb), the next whole kilobyte above that number: 939 B of headroom,
|
|
223
|
+
and under 2× 19,541 — so a route that bundles the same
|
|
224
224
|
island twice is still refused. All three clauses are assertions in
|
|
225
225
|
[`island-budget.test.ts`](src/island-budget.test.ts)'s `DEFAULT_ISLAND_JS_BYTES` block, against the
|
|
226
226
|
measured table above; a default that stopped clearing the floor, or stopped being a ceiling, is red.
|
|
@@ -433,6 +433,28 @@ does.
|
|
|
433
433
|
side effect. Anything that loads an app's source — `x dev`, `x build`, `server.ts`, a test that
|
|
434
434
|
`await import()`s a `page.tsx` — reaches it before the module it loads.
|
|
435
435
|
|
|
436
|
+
## Error classes
|
|
437
|
+
|
|
438
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
439
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
440
|
+
|
|
441
|
+
| Class | Code | Declared in |
|
|
442
|
+
|---|---|---|
|
|
443
|
+
| `BudgetExceededError` | `X_BUDGET_EXCEEDED` | `src/errors.ts` |
|
|
444
|
+
| `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
|
|
445
|
+
| `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
|
|
446
|
+
| `IslandPropsInvalidError` | `X_ISLAND_PROPS_INVALID` | `src/errors.ts` |
|
|
447
|
+
| `PrerenderFailedError` | `X_PRERENDER_FAILED` | `src/errors.ts` |
|
|
448
|
+
| `RouteDuplicateError` | `X_ROUTE_DUPLICATE` | `src/errors.ts` |
|
|
449
|
+
| `RouteFileInvalidError` | `X_ROUTE_FILE_INVALID` | `src/errors.ts` |
|
|
450
|
+
| `RouteLoadFailedError` | `X_ROUTE_LOAD_FAILED` | `src/errors.ts` |
|
|
451
|
+
| `RouteLoadInvalidError` | `X_ROUTE_LOAD_INVALID` | `src/errors.ts` |
|
|
452
|
+
| `RouteMetaMissingError` | `X_ROUTE_META_MISSING` | `src/errors.ts` |
|
|
453
|
+
| `RouteModeInvalidError` | `X_ROUTE_MODE_INVALID` | `src/errors.ts` |
|
|
454
|
+
| `RouteOfflineMissingError` | `X_ROUTE_OFFLINE_MISSING` | `src/errors.ts` |
|
|
455
|
+
| `RouteStatusInvalidError` | `X_ROUTE_STATUS_INVALID` | `src/errors.ts` |
|
|
456
|
+
| `SurfaceBoundaryError` | `X_SURFACE_BOUNDARY` | `src/errors.ts` |
|
|
457
|
+
|
|
436
458
|
## Public API
|
|
437
459
|
|
|
438
460
|
`†` marks a name on `@ultimat3/render/server`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/i18n": "
|
|
42
|
-
"@ultimat3/seo": "
|
|
43
|
-
"sass": "1.
|
|
39
|
+
"@ultimat3/cache": "22.0.0",
|
|
40
|
+
"@ultimat3/core": "22.0.0",
|
|
41
|
+
"@ultimat3/i18n": "22.0.0",
|
|
42
|
+
"@ultimat3/seo": "22.0.0",
|
|
43
|
+
"sass": "1.104.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's client scope, as a `<head>` tag: `<meta name="ultimate-scope" content="…">`, read
|
|
3
|
+
* once by `@ultimat3/core`'s `pageClient()` when the page's client handle is created. Only a
|
|
4
|
+
* PRIVATE document carries one (`documentCarriesScope`): anything a shared cache may hold would
|
|
5
|
+
* serve one visitor's scope to the next, and a store fenced as the wrong principal is the leak the
|
|
6
|
+
* fence exists to stop.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CLIENT_PERSIST_META, CLIENT_SCOPE_META } from '@ultimat3/core';
|
|
10
|
+
import type { HeadTag } from './head';
|
|
11
|
+
|
|
12
|
+
/** The `name` core's reader matches — core's constant, so the writer and the reader are one literal. */
|
|
13
|
+
export { CLIENT_PERSIST_META, CLIENT_SCOPE_META };
|
|
14
|
+
|
|
15
|
+
/** `scope` is opaque (`@ultimat3/auth`'s `clientScopeOf`), and `''` is the anonymous page. */
|
|
16
|
+
export function clientScopeTag(scope: string): HeadTag {
|
|
17
|
+
return {
|
|
18
|
+
kind: 'meta',
|
|
19
|
+
key: `meta:${CLIENT_SCOPE_META}`,
|
|
20
|
+
attrs: { name: CLIENT_SCOPE_META, content: scope },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The record types this app keeps on disk (`entity(name, { persist: true })`), for the page's
|
|
26
|
+
* persister. Beside the scope and ONLY beside it: persistence is per principal, so a document with
|
|
27
|
+
* no scope has nothing to persist under. None persisted is no tag — absent already means "none".
|
|
28
|
+
*/
|
|
29
|
+
export function clientPersistTags(types: readonly string[]): readonly HeadTag[] {
|
|
30
|
+
if (types.length === 0) return [];
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
kind: 'meta',
|
|
34
|
+
key: `meta:${CLIENT_PERSIST_META}`,
|
|
35
|
+
attrs: { name: CLIENT_PERSIST_META, content: [...types].sort().join(',') },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a document with these response headers may carry a scope: its `cache-control` says
|
|
42
|
+
* `private`. Read off the headers the mode ALREADY decided — `ssrHeaders` makes a gated page
|
|
43
|
+
* private and an ungated one `public, s-maxage`, a stream is always private, `static`/`isr` never —
|
|
44
|
+
* so this is not a second opinion on what is shareable.
|
|
45
|
+
*/
|
|
46
|
+
export function documentCarriesScope(headers: Readonly<Record<string, string>>): boolean {
|
|
47
|
+
const cacheControl = headers['cache-control'] ?? '';
|
|
48
|
+
return cacheControl.split(',').some((directive) => directive.trim().toLowerCase() === 'private');
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's sync target as `<head>` tags, read by `@ultimat3/realtime`'s tab-side socket host:
|
|
3
|
+
* where the one socket dials, which build the page was rendered by, and which worker script hosts
|
|
4
|
+
* the socket. Principal-free, so a shareable document may carry them — unlike the scope tag.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META } from '@ultimat3/core';
|
|
8
|
+
import type { HeadTag } from './head';
|
|
9
|
+
|
|
10
|
+
/** Core's names (`page-meta.ts`), so the writer here and every reader share one literal each. */
|
|
11
|
+
export { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META };
|
|
12
|
+
|
|
13
|
+
export interface ClientSyncHead {
|
|
14
|
+
readonly syncUrl: string;
|
|
15
|
+
readonly buildId: string;
|
|
16
|
+
readonly workerUrl?: string | undefined;
|
|
17
|
+
/** `/_x/page-boot/<hash>.js` — realtime's page boot, when the app has realtime. */
|
|
18
|
+
readonly bootUrl?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function clientSyncTags(head: ClientSyncHead): readonly HeadTag[] {
|
|
22
|
+
return [
|
|
23
|
+
meta(CLIENT_SYNC_META, head.syncUrl),
|
|
24
|
+
meta(CLIENT_BUILD_META, head.buildId),
|
|
25
|
+
...(head.workerUrl === undefined ? [] : [meta(CLIENT_SYNC_WORKER_META, head.workerUrl)]),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const meta = (name: string, content: string): HeadTag => ({
|
|
30
|
+
kind: 'meta',
|
|
31
|
+
key: `meta:${name}`,
|
|
32
|
+
attrs: { name, content },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The page boot as one deferred classic script: it runs before every island module that follows it
|
|
37
|
+
* in the document, and ONCE per page — the disk restore and the outbox are the page's job, never
|
|
38
|
+
* each island's. Rendered only where there is a principal to restore for (`dev-render.ts`).
|
|
39
|
+
*/
|
|
40
|
+
export function clientBootTags(head: ClientSyncHead): readonly HeadTag[] {
|
|
41
|
+
return head.bootUrl === undefined
|
|
42
|
+
? []
|
|
43
|
+
: [{ kind: 'script', key: 'script:ultimate-boot', attrs: { src: head.bootUrl, defer: true } }];
|
|
44
|
+
}
|
package/src/css-modules.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
|
|
|
8
8
|
import { basename, dirname, resolve } from 'node:path';
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
10
|
import { renderThrowable } from '@ultimat3/core';
|
|
11
|
-
import * as
|
|
11
|
+
import type * as Sass from 'sass';
|
|
12
12
|
import { PrerenderFailedError } from './errors';
|
|
13
13
|
import { contentHash } from './render-static';
|
|
14
14
|
|
|
@@ -38,7 +38,7 @@ export function isGlobalStylesheet(file: string): boolean {
|
|
|
38
38
|
* Sass resolves relative `@use` itself; a bare specifier is Bun's job, because `@ultimat3/ui/tokens`
|
|
39
39
|
* is an `exports` entry and only the module resolver knows where that lands.
|
|
40
40
|
*/
|
|
41
|
-
const packageImporter = (from: string):
|
|
41
|
+
const packageImporter = (from: string): Sass.FileImporter<'sync'> => ({
|
|
42
42
|
findFileUrl(url: string, context: { readonly containingUrl?: URL | null }): URL | null {
|
|
43
43
|
// Sass routes every load inside a file THIS importer supplied back to this importer, including
|
|
44
44
|
// `_index.scss`'s own relative `@forward`s — so the filesystem lookup has to live here too, or
|
|
@@ -185,11 +185,24 @@ export function stripCharset(css: string): string {
|
|
|
185
185
|
return css.replace(CHARSET_HEAD, '');
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
let loadedSass: typeof Sass | undefined;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Dart Sass, loaded on the first compile and never at import: it is ~290ms of module evaluation,
|
|
192
|
+
* measured, and every process importing `@ultimat3/render/server` paid it — `x --help`, `x doctor`,
|
|
193
|
+
* a container role that renders no stylesheet. `require` and not `import()` because this function
|
|
194
|
+
* is synchronous: Bun's loader-plugin `onLoad` path in `module-loader.ts` calls it inline.
|
|
195
|
+
*/
|
|
196
|
+
const sassCompiler = (): typeof Sass => {
|
|
197
|
+
loadedSass ??= require('sass') as typeof Sass;
|
|
198
|
+
return loadedSass;
|
|
199
|
+
};
|
|
200
|
+
|
|
188
201
|
export function compileStylesheet(file: string, source: string): CompiledStylesheet {
|
|
189
202
|
let css: string;
|
|
190
203
|
try {
|
|
191
204
|
css = stripCharset(
|
|
192
|
-
|
|
205
|
+
sassCompiler().compileString(source, {
|
|
193
206
|
url: pathToFileURL(file),
|
|
194
207
|
loadPaths: [dirname(file)],
|
|
195
208
|
importers: [packageImporter(dirname(file))],
|
package/src/duration.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
* the process while the CDN was told `s-maxage=60`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
/** A `Map`, never an object literal: a unit is read by a string key, and a `Map` has no prototype chain to answer for it. */
|
|
10
|
+
const DURATION_UNITS: ReadonlyMap<string, number> = new Map([
|
|
11
|
+
['ms', 1],
|
|
12
|
+
['s', 1_000],
|
|
13
|
+
['m', 60_000],
|
|
14
|
+
['h', 3_600_000],
|
|
15
|
+
['d', 86_400_000],
|
|
16
|
+
]);
|
|
16
17
|
|
|
17
18
|
/** `'5m'` → 300000. Numbers pass through as milliseconds. */
|
|
18
19
|
export function parseTtlMs(ttl: string | number | null | undefined): number | null {
|
|
@@ -22,6 +23,6 @@ export function parseTtlMs(ttl: string | number | null | undefined): number | nu
|
|
|
22
23
|
const amount = match?.[1];
|
|
23
24
|
const unit = match?.[2];
|
|
24
25
|
if (amount === undefined || unit === undefined) return null;
|
|
25
|
-
const factor = DURATION_UNITS
|
|
26
|
+
const factor = DURATION_UNITS.get(unit);
|
|
26
27
|
return factor === undefined ? null : Number(amount) * factor;
|
|
27
28
|
}
|