@ultimat3/render 1.2.0 → 2.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 ADDED
@@ -0,0 +1,74 @@
1
+ # @ultimat3/render — boundary
2
+
3
+ Owns: the `route` primitive, the five render modes, the route table, the surface boundary,
4
+ islands + budgets, hydration directives, the vendored client router, `<head>` merge, **the server
5
+ JSX runtime and the two Bun loaders that make an app's `.tsx` and `.scss` runnable**.
6
+
7
+ `island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a
8
+ second render mode — the same rule `llm()` and `backfill()` follow. It adds no key to
9
+ `defineRoute`.
10
+
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
+ (sideways), never `ui`/`cli` (upward).
14
+
15
+ | Rule | Detail |
16
+ |---|---|
17
+ | `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
18
+ | `hydrate` | **optional since 1.2.0, derived from `island()`** — `'interaction'` when the module declared one, `'never'` when it did not. Declaring it still wins and is the only way to reach `idle` / `visible`. Not a widening of the contract: it is the one key the declaration above it already answers, and requiring it meant two failures (`X_ISLAND_NOT_HYDRATED`, and a `site/` route refused for a missing `budget.js`) for one omission. Never give the island its own strategy instead — `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`, and two islands wanting different timings would leave it with no true answer. |
19
+ | `defineRoute` shape | exactly the contract's 9 keys. New route *metadata* still goes inside `meta` — `load` is not metadata, it is the data `meta` already took a parameter for. |
20
+ | `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Two resolutions is a `<title>` describing content the body does not contain. Absent `load`, the context IS the data (`{ params, url }`), which is what `meta` received before the key existed — so no consumer branches on whether a route declared one. |
21
+ | `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter — `unknown` when `RouteContext` satisfies `TData`, a required `load` when it does not. That is what makes the no-`load` fallback true rather than asserted: it was `ctx as unknown as TData`, so a `meta` reading `data.post` off a route that loads nothing type-checked and rendered `undefined` in a `<title>`. `RouteContext` is a type ALIAS for the same reason — only an alias carries the implicit index signature that makes it a `RouteData`; as an `interface` the compiler cannot see it and the cast comes back. |
22
+ | A loader's own error | rethrown only when `isUltimateError` says so — core's brand, never a `code` property. Every `ENOENT` is an `Error` with a string `code`, and the duck-type that preceded this let all of them out of `routeDataFor` unwrapped: no `X_ROUTE_LOAD_FAILED`, no fix line, no route named. A tier-0 error (`@ultimat3/schema` cannot import core) is branded, not a subclass — never narrow this to `instanceof UltimateError`. |
23
+ | Type claims | `type-pins.tsx`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one. `.tsx` since 1.2.0: the island-as-JSX claim is only decidable by writing the JSX an author writes, checked against the same `solid-js` `JSX.Element` a page is. |
24
+ | Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
25
+ | Descriptor `budget` | always an object, `{}` when undeclared. Its *fields* stay optional — `budget.js === undefined` is the site/ hydration failure. |
26
+ | No `describe()` on a route | `describeRoutes()` is the one route list. A per-route projection would be a second one. |
27
+ | Mode invariants | `modes.ts` only. Never inline a mode check in a render-\* file. |
28
+ | Island declaration | `island({ src })` — a **specifier**, never an import. That is the whole boundary: a string has no scope to close over and no edge for a bundler or `checkSurfaceBoundary` to follow, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
29
+ | Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling, same rule as `page.tsx`: a module ships to the browser iff its name says so. Never widen it to accept a second, and never make it optional. |
30
+ | 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
+ | 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
+ | 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/` → `4kb`, `app/` → `18kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`). A declared `budget.js` wins; a `'never'` route gets none, so the contradiction stays visible. |
34
+ | `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
+ | 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
+ | 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. |
37
+ | 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. |
38
+ | 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)`. |
39
+ | 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. |
40
+ | Route truth | `registry.ts`. Never keep a second route list anywhere. |
41
+ | 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. |
42
+ | 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. |
43
+ | Descriptors | `describeRoutes()` must stay JSON-safe, sorted by path, deterministic. |
44
+ | Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
45
+ | Stream cancellation | the underlying source has a `cancel()`, and `write` is guarded on it. A client that disconnects mid-stream aborts `StreamHole.resolve(signal)` and every later `write`/`close` is a no-op — `settle()` on a cancelled controller threw out of a `void`ed promise, one unhandled rejection per response, while the resolved holes kept doing their database work with nowhere to write. |
46
+ | ISR detach | `attach()`'s returned function clears the revalidator as well as the dependents — and only if the slot is still its own, tracked in `installedRevalidator` because `@ultimat3/cache` holds ONE and offers no read back. Left installed, a detached controller and its whole store stayed reachable and kept receiving revalidations while the live one's pages never went stale. |
47
+ | ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first — a route table supports `:params` and `*`, so `/blog/:slug` retains one full HTML string per slug ever requested, 404-shaped ones included. |
48
+ | Route path from file | ONE reader of the surface segment: `locateSurface()` answers which surface AND where it starts. `registry.ts` sliced at `indexOf('app/')` instead, which matched inside `myapp/`, so every route under `apps/myapp/app/` served at `/app/…`. Never re-derive the offset from the surface NAME. |
49
+ | An undecodable path segment | not a match, never a throw. `decodeURIComponent('%zz')` is a bare `URIError` — a 500 for a typo — and `@ultimat3/http`'s router already answers "this branch does not match" for the same input. A literal route matching the same text still wins. |
50
+ | ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). The store is bounded and evicts silently; `registered` and the cache graph behind it only ever grew, one edge per slug ever requested. Never a store callback — a custom `IsrStore` has none. |
51
+ | Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15s), `holeTimeoutMs: null` to opt out. A hole is app code the framework `await`s and nothing else bounds it; one that never settles held the response and its whole closure open for the life of the process. A hole reveals exactly once — its promise, its rejection, or its deadline, whichever lands first. |
52
+ | Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
53
+ | Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
54
+ | Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
55
+ | Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. Inject primitives. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. |
56
+ | 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. |
57
+ | `<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. |
58
+ | Escaping | `html.ts` only. 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. |
59
+ | Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `&lt;` corrupts the code AND leaves the hole. |
60
+ | Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
61
+ | 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. |
62
+ | 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. |
63
+ | The global layer | this package may not import `@ultimat3/ui` (tier 5, upward), 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. |
64
+ | Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
65
+ | `<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. |
66
+
67
+ Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
68
+ Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
69
+
70
+ ```
71
+ bun test # from packages/render
72
+ bun run typecheck
73
+ bun run --cwd ../.. verify # the contract
74
+ ```
package/README.md CHANGED
@@ -18,17 +18,54 @@ export const config = defineRoute({
18
18
  offline: 'precache', // precache | runtime | network-only
19
19
  hydrate: 'visible', // idle | visible | interaction | never
20
20
  budget: { js: '40kb', lcp: 2000 },
21
- meta: ({ post }) => ({ title: post.title, description: post.excerpt,
22
- og: { image: post.cover }, ld: ld.Article(post) }),
21
+ load: ({ params }) => db.posts.bySlug(params.slug), // once per render
22
+ meta: ({ data, url }) => ({ title: data.title, description: data.excerpt,
23
+ og: { image: data.cover }, alternates: { canonical: url },
24
+ ld: ld.Article(data) }),
23
25
  });
26
+
27
+ export function Page(props: { data: Post }) { /* the SAME object meta was given */ }
28
+ ```
29
+
30
+ ## `load` is the one server-side data seam
31
+
32
+ Optional, and the only way a page gets data. It runs **once per render** and the result is handed
33
+ to both `meta` and the page component — the same object, never two resolutions, because a `<title>`
34
+ describing content the body does not contain is the failure this seam exists to prevent.
35
+
36
+ `meta` receives a context, not the bare data: `{ data, params, url, t }`. All four are needed for a
37
+ real `<head>` — the data for the content, `url` for the canonical, `t` because no user-facing
38
+ string may be hardcoded. A route that declares no `load` still gets `params` and `url` under the
39
+ same names it always had, so nothing that shipped has to change.
40
+
41
+ `As of 2026-07`, omitting `load` means the context IS the data: `meta` may read only what the
42
+ context supplies, and anything richer is a compile error naming the missing `load`.
43
+
44
+ ```text
45
+ defineRoute({ /* … */ meta: ({ data }) => ({ title: data.post.title }) });
46
+ // Property 'load' is missing in type … but required in type '{ readonly load: RouteLoadFn<…> }'
24
47
  ```
25
48
 
26
- ## `offline`, `hydrate` and `meta` are required by the type
49
+ A `text` fence, not a `ts` one, because it is the one example in this file that must **not**
50
+ compile — that is the whole claim. Without the rule the route compiled and rendered `undefined`
51
+ in a `<title>`.
52
+
53
+ A loader that throws is `X_ROUTE_LOAD_FAILED`, naming the path to fix — unless it threw an
54
+ `UltimateError` of its own, which passes through untouched: a policy denial or a missing row
55
+ already carries a better code and a better fix than any wrapper could. Membership is the framework's
56
+ brand, not a `code` property: an `ENOENT` is an `Error` with a string `code` too, and it gets
57
+ wrapped like any other loader failure.
58
+
59
+ ## `offline` and `meta` are required by the type
27
60
 
28
61
  Not by a lint rule, not by a doc — by `RouteDefinition`. Axiom 3 lives in the type system:
29
62
  a route that forgets its offline strategy or its `<head>` does not compile. `defineRoute`
30
- re-checks the same three at runtime (`X_ROUTE_OFFLINE_MISSING`, `X_ROUTE_META_MISSING`) for
31
- JS callers and generators.
63
+ re-checks both at runtime (`X_ROUTE_OFFLINE_MISSING`, `X_ROUTE_META_MISSING`) for JS callers
64
+ and generators.
65
+
66
+ `hydrate` was the third, and is not, `As of 2026-08`. It is the one key the framework can work
67
+ out from the page's own declarations — see the island section — and requiring a value it already
68
+ knows is not enforcement, it is a second place to get one thing wrong.
32
69
 
33
70
  ## `defineRoute` returns a descriptor, not the object you passed
34
71
 
@@ -36,12 +73,22 @@ Two fields come back narrower than they went in, so nothing downstream branches
36
73
 
37
74
  | Field | The declaration accepts | The descriptor always is |
38
75
  |---|---|---|
39
- | `meta` | `(data) => RouteMeta \| Promise<RouteMeta>` | `(data) => Promise<RouteMeta>` |
76
+ | `meta` | `(ctx: RouteMetaContext) => RouteMeta \| Promise<RouteMeta>` | `(ctx) => Promise<RouteMeta>` |
40
77
  | `budget` | omitted, or a `RouteBudget` | a `RouteBudget` — `{}` when undeclared |
78
+ | `hydrate` | omitted, or a `HydrateStrategy` | a `HydrateStrategy` — derived from the islands |
79
+ | `islands` | never written | the `IslandSpec`s this module declared, in order |
80
+
81
+ `meta` takes the **context**, never the bare data — the same `{ data, params, url, t }` the
82
+ declaration receives, all four required:
41
83
 
42
84
  ```ts
43
- const meta = await config.meta({ post }); // always. sync or async declaration, one call
44
- const js = config.budget.js ?? null; // never config.budget?.js
85
+ import type { RouteConfig, RouteMetaContext } from '@ultimat3/render';
86
+
87
+ declare const config: RouteConfig<Post>;
88
+ declare const ctx: RouteMetaContext<Post>; // { data, params, url, t }
89
+
90
+ const meta = await config.meta(ctx); // always. sync or async declaration, one call
91
+ const js = config.budget.js ?? null; // never config.budget?.js
45
92
  ```
46
93
 
47
94
  No author is forced to write `async`, and a `meta` that throws synchronously comes back as
@@ -111,11 +158,127 @@ The import that costs you is three hops from the file anyone reviewed, so
111
158
  at build time and therefore never carry the boundary. `shared/` is a leaf; `app/ → api/` is
112
159
  types-only.
113
160
 
161
+ ## One interactive component on a static page
162
+
163
+ `island()` — a marketing page with a contact modal, a docs page with a search box, a pricing page
164
+ with a plan toggle. The page stays `render: 'static'`; the modal is the only JavaScript on it.
165
+
166
+ ```tsx
167
+ const ContactModal = island({ src: './contact-modal.island.tsx', props: ['subject'] });
168
+
169
+ export const config = defineRoute({
170
+ render: 'static', offline: 'precache',
171
+ meta: ({ t }) => ({ title: t('pricing.title'), description: t('pricing.description') }),
172
+ });
173
+
174
+ export function Page() {
175
+ return <main><h1>Pricing</h1>
176
+ <ContactModal subject="pricing"><button>Contact us</button></ContactModal>
177
+ </main>;
178
+ }
179
+ ```
180
+
181
+ ### Declaring the island is the whole declaration
182
+
183
+ That route says `render` and `meta` and nothing else, and it is the same route that used to spell
184
+ out three things. `hydrate` and `budget.js` are **derived from `island()`**, `As of 2026-08`:
185
+
186
+ | Omitted | Derived | Overridden by |
187
+ |---|---|---|
188
+ | `hydrate` | `'interaction'` when the module declared an island, `'never'` when it did not | stating `hydrate` — the only way to say `idle` or `visible` |
189
+ | `budget.js` | the surface baseline + 4kb (`site/` → `4kb`, `app/` → `18kb`) | stating `budget: { js }` |
190
+
191
+ Both were required and both were punished: an island on a route still at `'never'` is
192
+ `X_ISLAND_NOT_HYDRATED`, and a `site/` route off `'never'` with no `budget.js` is refused at
193
+ registration. Two failures for one omission the `island()` call above had already answered.
194
+
195
+ 4kb because the reference app's real island is 875 B of chunk plus a 1,019 B runtime — room for a
196
+ second small island, not enough to hide a library. It is a ceiling, not a pass: exceeding it is
197
+ `X_BUDGET_EXCEEDED`, naming the island.
198
+
199
+ `island()` goes **above** `defineRoute`, where JavaScript already puts a `const` the page uses:
200
+ `defineRoute` drains the declarations made before it. Below it, the route resolves to `'never'` and
201
+ the render fails loudly with `X_ISLAND_NOT_HYDRATED`, whose fix names both repairs.
202
+
203
+ An island still declares no strategy of its own. One spelling for "this route hydrates" — the
204
+ route's — because two islands wanting different timings would leave `RouteDescriptor.hydrate`, which
205
+ `sw.js`, the web manifest and `x routes` all read, with no single true answer.
206
+
207
+ | The route says | The island says |
208
+ |---|---|
209
+ | `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 |
210
+ | `budget.js` — how many bytes that is allowed to cost, when 4kb is not the number | `tag`, `events`, `rootMargin` — how the wrapper behaves |
211
+
212
+ ### An island node is a JSX child
213
+
214
+ `island()` returns a component, and `<ContactModal />` is an ordinary element call. That took work:
215
+ an app types its JSX with `jsxImportSource: solid-js`, whose `JSX.Element` is a type **alias** —
216
+ unaugmentable — and whose only object-shaped member is `ArrayElement`. `IslandNode` is therefore a
217
+ branded (empty) array, which is what makes it assignable. Until `As of 2026-08` it was a plain
218
+ object and every `<ContactModal />` in an app was **TS2786**, so the feature was reachable only
219
+ through `h(ContactModal, …)` — which is exactly what the framework's own tests used, and why
220
+ nobody saw it. `type-pins.tsx` holds the claim now: it writes the JSX an author writes, and `tsc`
221
+ reads it.
222
+
223
+ ### Declared by specifier, never by import
224
+
225
+ `src` is a string. The page never imports the client module, so:
226
+
227
+ - **there is no import edge** for the bundler or `checkSurfaceBoundary()` to follow from
228
+ `page.tsx` into the island — the static page's graph cannot grow the island's dependencies;
229
+ - **the component cannot close over anything.** A string does not capture a database handle,
230
+ a request, an actor or a row. There is no scope to leak.
231
+
232
+ `.island.tsx` is the one spelling, for the reason `page.tsx` is: a file ships to the browser if
233
+ and only if its name says so, decidable by `grep` and by the bundler without opening it. Anything
234
+ else is `X_ISLAND_INVALID`, and the fix is the `git mv`.
235
+
236
+ ### Props are the only channel, and they are checked
237
+
238
+ | Rule | Failure |
239
+ |---|---|
240
+ | every prop is declared in `props: [...]` | `X_ISLAND_PROPS_INVALID`, naming each undeclared key |
241
+ | every value is JSON — no function, `Date`, class instance, `bigint`, `undefined`, cycle | `X_ISLAND_PROPS_INVALID`, naming the path and the type |
242
+ | serialized props ≤ `ISLAND_PROPS_MAX_BYTES` (4096) | `X_ISLAND_PROPS_INVALID`, naming the measured size |
243
+
244
+ `<ContactModal {...post} />` fails and names `email`, `passwordHash` — every column the spread
245
+ would have shipped. The type refuses it first (`type-pins.tsx` pins that); the render refuses it
246
+ second, which for `static` and `isr` is build time. `children` are the server-rendered shell and
247
+ are never serialized.
248
+
249
+ ### It counts against the route's budget
250
+
251
+ ```ts
252
+ const collector = createIslandCollector({ file, hydrate: config.hydrate, resolve });
253
+ const html = await renderToHtml(page, { islands: collector });
254
+ document.body += hydrateRuntime(collector.directives); // the one thing left to remember
255
+ assertBudget(entry, measuredIslands, collector.directives);
256
+ ```
257
+
258
+ `routeJsBytes` reads **both** sources — what registration declared in `entry.islands` and what the
259
+ render actually pulled in. Reading only the first is how a page could be charged for the hydration
260
+ runtime and not for the chunk it boots: a budget that counts the wrapper and not the code.
261
+
262
+ `entry.islands` is filled from `config.islands` at registration and from nothing else, so a declared
263
+ island is weighed even on a route no render has touched. It was `input.islands ?? []` and nothing
264
+ ever passed `islands`, which left that half of the union reading nothing at all; `RegisterRouteInput`
265
+ no longer carries the key, because the only thing a caller could do with it was un-weigh a
266
+ declaration.
267
+
268
+ An island on a route that resolves to `hydrate: 'never'`, or rendered with no collector, is
269
+ `X_ISLAND_NOT_HYDRATED` — inert markup either way. With `hydrate` derived it means one of exactly
270
+ two things, and the `fix:` names **the one that is yours**: the route stated `'never'` next to an
271
+ island (remove it), or the `island()` call sits below the `defineRoute` that would have drained it
272
+ (move it up). The throw site tells them apart by asking whether that declaration is still waiting to
273
+ be drained. A `'never'` route is also left with no derived budget, deliberately — a ceiling there
274
+ would paper over the contradiction.
275
+
114
276
  ## Public API
115
277
 
116
278
  | Export | Owns |
117
279
  |---|---|
118
280
  | `defineRoute` | the `route` primitive |
281
+ | `island`, `createIslandCollector` | one interactive component on a static page |
119
282
  | `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
120
283
  | `registerRoute`, `describeRoutes`, `matchRoute`, `routePathFromFile` | the route table |
121
284
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
@@ -135,14 +298,19 @@ types-only.
135
298
  `action({ cache: { invalidates: [tag.post] } })` reaches ISR in the same hop as memo,
136
299
  LRU, Redis and the CDN, and regenerates exactly the dependent pages — nobody lists
137
300
  pages by hand, so nobody forgets one. `controller.attach()` installs it as the
138
- framework's `Revalidator`.
301
+ framework's `Revalidator`, and the function it returns releases **both** halves — the
302
+ dependents and the revalidator slot, the latter only while it is still this controller's.
303
+ The default store (`memoryIsrStore`) is capped at `DEFAULT_ISR_MAX_ENTRIES` (1,000) pages,
304
+ least recently generated evicted first.
139
305
  - **`stream`** flushes the shell first, then reveals holes in completion order with a
140
- ~200-byte inline script. Solid's compiled templates and signals mean the shell costs zero
306
+ ~200-byte inline script. A client that disconnects mid-stream cancels it: `StreamHole.resolve`
307
+ is handed an `AbortSignal` so the work stops, and nothing more is enqueued. Solid's compiled templates and signals mean the shell costs zero
141
308
  hydration work, so streaming buys TTFB *and* TBT here, not just TTFB.
142
309
  - **`hydrate: 'interaction'`** replays the event that woke the island; without replay the
143
310
  first click on a cold island is silently lost.
144
311
  - **`hydrate: 'never'`** emits no attributes beyond the marker and no runtime — the `site/`
145
- 0kb default is mechanical, not aspirational.
312
+ 0kb default is mechanical, not aspirational. A page that renders an island anyway is
313
+ `X_ISLAND_NOT_HYDRATED`, not a silently dead button.
146
314
  - **The client router is vendored** rather than depending on a moving SolidStart alpha. It
147
315
  imports no `solid-js`: reactive primitives and the DOM host are injected.
148
316
  - `@ultimat3/http`'s `html()` / `stream()` turn a `RenderResult` into a `Response`; render
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "1.2.0",
3
+ "version": "2.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,8 +31,10 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/cache": "1.2.0",
34
- "@ultimat3/core": "1.2.0",
35
- "@ultimat3/seo": "1.2.0"
34
+ "@ultimat3/cache": "2.0.0",
35
+ "@ultimat3/core": "2.0.0",
36
+ "@ultimat3/i18n": "2.0.0",
37
+ "@ultimat3/seo": "2.0.0",
38
+ "sass": "1.102.0"
36
39
  }
37
40
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * SCSS → CSS, plus the scoped class-name map every `import styles from './x.module.scss'` already
3
+ * assumes exists. Scoping is content-addressed, so the same source compiles to the same class names
4
+ * on every machine and the output diffs across deploys the way the HTML does.
5
+ */
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import { basename, dirname, resolve } from 'node:path';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+ import * as sass from 'sass';
11
+ import { PrerenderFailedError } from './errors';
12
+ import { contentHash } from './render-static';
13
+
14
+ export interface CompiledStylesheet {
15
+ readonly css: string;
16
+ /** `hero` → `hero_1f2e3d4c`. Empty for a plain (non-module) stylesheet. */
17
+ readonly classes: Readonly<Record<string, string>>;
18
+ }
19
+
20
+ /** A file is a CSS module when its name says so — one spelling, per the `.module.scss` convention. */
21
+ export function isCssModule(file: string): boolean {
22
+ return file.endsWith('.module.scss') || file.endsWith('.module.css');
23
+ }
24
+
25
+ /**
26
+ * The complement, named rather than spelled `!isCssModule(…)` at the call site, because the
27
+ * registry ORDERS on it: a plain stylesheet is the global layer — the `:root` custom properties
28
+ * every module rule reads through `var(--…)`, and the element reset — and it has to reach the
29
+ * document before the modules do. A cascade rule that only exists as a negation at one call site
30
+ * is a cascade rule the next reader inverts by accident.
31
+ */
32
+ export function isGlobalStylesheet(file: string): boolean {
33
+ return !isCssModule(file);
34
+ }
35
+
36
+ /**
37
+ * Sass resolves relative `@use` itself; a bare specifier is Bun's job, because `@ultimat3/ui/tokens`
38
+ * is an `exports` entry and only the module resolver knows where that lands.
39
+ */
40
+ const packageImporter = (from: string): sass.FileImporter<'sync'> => ({
41
+ findFileUrl(url: string, context: { readonly containingUrl?: URL | null }): URL | null {
42
+ // Sass routes every load inside a file THIS importer supplied back to this importer, including
43
+ // `_index.scss`'s own relative `@forward`s — so the filesystem lookup has to live here too, or
44
+ // a package entry point resolves and every partial it forwards does not.
45
+ const base =
46
+ context.containingUrl === undefined || context.containingUrl === null
47
+ ? from
48
+ : dirname(fileURLToPath(context.containingUrl));
49
+ const local = partialCandidates(resolve(base, url)).find((candidate) => existsSync(candidate));
50
+ if (local !== undefined) return pathToFileURL(local);
51
+ try {
52
+ return pathToFileURL(Bun.resolveSync(url, base));
53
+ } catch {
54
+ return null;
55
+ }
56
+ },
57
+ });
58
+
59
+ /** Sass's own load order for a bare name: the file, its partial, then the directory's index. */
60
+ const partialCandidates = (target: string): readonly string[] => {
61
+ const dir = dirname(target);
62
+ const name = basename(target);
63
+ return [
64
+ `${dir}/${name}.scss`,
65
+ `${dir}/_${name}.scss`,
66
+ `${target}/_index.scss`,
67
+ `${target}/index.scss`,
68
+ `${dir}/${name}.css`,
69
+ ];
70
+ };
71
+
72
+ /** Strings and `url()` payloads may contain a `.` that is not a class selector. */
73
+ const PROTECTED = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|url\([^)]*\)/g;
74
+ /** A class selector: a dot followed by an identifier start. `0.5rem` cannot match — `5` is not one. */
75
+ const CLASS_SELECTOR = /\.(-?[A-Za-z_][\w-]*)/g;
76
+ /**
77
+ * The mask delimiter. NUL is the one byte CSS cannot contain, so the restore pass cannot mistake
78
+ * a real declaration for a placeholder — a bare numeric marker would collide with `flex:1 1 0`.
79
+ */
80
+ const NUL = '\u0000';
81
+ const MASKED = new RegExp(`${NUL}(\\d+)${NUL}`, 'g');
82
+
83
+ /**
84
+ * Rewrite every class selector to its scoped name and report the map. Done on the compiled CSS
85
+ * rather than the SCSS source so mixins, `@extend` and interpolation have already produced their
86
+ * final selectors — a rewrite before Sass runs would miss every class a mixin generates.
87
+ */
88
+ export function scopeClasses(
89
+ css: string,
90
+ suffix: string,
91
+ ): { readonly css: string; readonly classes: Record<string, string> } {
92
+ const classes: Record<string, string> = {};
93
+ const literals: string[] = [];
94
+ const masked = css.replace(PROTECTED, (match) => {
95
+ literals.push(match);
96
+ return `${NUL}${literals.length - 1}${NUL}`;
97
+ });
98
+ const scoped = masked.replace(CLASS_SELECTOR, (_match, name: string) => {
99
+ const local = `${name}_${suffix}`;
100
+ classes[name] = local;
101
+ return `.${local}`;
102
+ });
103
+ const restored = scoped.replace(
104
+ MASKED,
105
+ // The mask is dense and index-addressed, so a miss is impossible; `??` only keeps
106
+ // `noUncheckedIndexedAccess` honest.
107
+ (_match, index: string) => literals[Number(index)] ?? '',
108
+ );
109
+ return { css: restored, classes };
110
+ }
111
+
112
+ /** The fix line for a stylesheet that names tokens `@ultimat3/ui/tokens` does not export. */
113
+ const TOKEN_FIX =
114
+ "@ultimat3/ui/tokens exports functions and mixins — space(4), radius(md), role('surface-raised'), " +
115
+ 'text(sm) — and no $variables';
116
+
117
+ export function compileStylesheet(file: string, source: string): CompiledStylesheet {
118
+ let css: string;
119
+ try {
120
+ css = sass.compileString(source, {
121
+ url: pathToFileURL(file),
122
+ loadPaths: [dirname(file)],
123
+ importers: [packageImporter(dirname(file))],
124
+ style: 'compressed',
125
+ }).css;
126
+ } catch (error) {
127
+ const first = error instanceof Error ? error.message.split('\n')[0] : String(error);
128
+ throw new PrerenderFailedError(
129
+ `${file} did not compile: ${first}`,
130
+ `edit ${file}: ${TOKEN_FIX}`,
131
+ );
132
+ }
133
+ if (!isCssModule(file)) return { css, classes: {} };
134
+ // Content-addressed, not path-addressed: a checkout at a different absolute path must produce
135
+ // byte-identical CSS, which a hash over the absolute filename would not.
136
+ const scoped = scopeClasses(css, contentHash(`${file.split('/').pop() ?? file} ${source}`));
137
+ return { css: scoped.css, classes: scoped.classes };
138
+ }
package/src/errors.ts CHANGED
@@ -12,9 +12,18 @@ export const RENDER_ERROR_CODES = [
12
12
  'X_ROUTE_UNNORMALIZED',
13
13
  'X_ROUTE_DUPLICATE',
14
14
  'X_ROUTE_FILE_INVALID',
15
+ 'X_ROUTE_LOAD_INVALID',
16
+ 'X_ROUTE_LOAD_FAILED',
15
17
  'X_SURFACE_BOUNDARY',
16
18
  'X_BUDGET_EXCEEDED',
17
19
  'X_PRERENDER_FAILED',
20
+ 'X_ISLAND_INVALID',
21
+ 'X_ISLAND_PROPS_INVALID',
22
+ 'X_ISLAND_NOT_HYDRATED',
23
+ // Render's, not the CLI's, for the same reason X_BUDGET_EXCEEDED is: this package owns the
24
+ // stylesheet registry and `stylesFor`, so "the CSS a document on this surface carries" is a fact
25
+ // about render's own output. `x verify` is only the surface that reports it.
26
+ 'X_STYLES_GLOBAL_MISSING',
18
27
  ] as const;
19
28
 
20
29
  export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
@@ -26,9 +35,16 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
26
35
  X_ROUTE_UNNORMALIZED: 'a route was registered without defineRoute',
27
36
  X_ROUTE_DUPLICATE: 'two route files resolve to one URL',
28
37
  X_ROUTE_FILE_INVALID: 'a route file is not named for its surface',
38
+ X_ROUTE_LOAD_INVALID: 'a route declared a load that is not a function',
39
+ X_ROUTE_LOAD_FAILED: "a route's load threw while resolving its data",
29
40
  X_SURFACE_BOUNDARY: 'a surface imported across the hard boundary',
30
41
  X_BUDGET_EXCEEDED: 'a route blew its JS or LCP budget',
31
42
  X_PRERENDER_FAILED: 'a prerendered path threw during build',
43
+ X_ISLAND_INVALID: 'an island declaration cannot become a client entry',
44
+ X_ISLAND_PROPS_INVALID: 'an island was passed props it cannot carry to the browser',
45
+ X_ISLAND_NOT_HYDRATED: 'a page renders an island that nothing would ever boot',
46
+ X_STYLES_GLOBAL_MISSING:
47
+ 'a surface renders documents whose CSS defines no :root custom properties',
32
48
  };
33
49
 
34
50
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -165,3 +181,85 @@ export class PrerenderFailedError extends UltimateError {
165
181
  });
166
182
  }
167
183
  }
184
+
185
+ /** `load` is optional, so this catches a value that is present and not callable. */
186
+ export class RouteLoadInvalidError extends UltimateError {
187
+ static readonly code = 'X_ROUTE_LOAD_INVALID' as const;
188
+ constructor(cause: string, fix: string) {
189
+ super({
190
+ code: RouteLoadInvalidError.code,
191
+ cause,
192
+ fix,
193
+ docs: docsFor(RouteLoadInvalidError.code),
194
+ });
195
+ }
196
+ }
197
+
198
+ /**
199
+ * The declaration itself cannot become a client entry: no `src`, a remote URL, a name that is not
200
+ * `*.island.tsx`, or two islands on one page claiming one id. Thrown where `island()` is written,
201
+ * so the failure lands in the file the author is editing.
202
+ */
203
+ export class IslandInvalidError extends UltimateError {
204
+ static readonly code = 'X_ISLAND_INVALID' as const;
205
+ constructor(cause: string, fix: string) {
206
+ super({
207
+ code: IslandInvalidError.code,
208
+ cause,
209
+ fix,
210
+ docs: docsFor(IslandInvalidError.code),
211
+ });
212
+ }
213
+ }
214
+
215
+ /**
216
+ * An island is named by specifier, so it can close over nothing — its props are the only channel
217
+ * from the server, and this is what keeps that channel to declared, JSON-safe, budgeted values.
218
+ * The failure it exists for is `<Modal {...row} />`: a spread that ships a column nobody meant to.
219
+ */
220
+ export class IslandPropsInvalidError extends UltimateError {
221
+ static readonly code = 'X_ISLAND_PROPS_INVALID' as const;
222
+ constructor(cause: string, fix: string) {
223
+ super({
224
+ code: IslandPropsInvalidError.code,
225
+ cause,
226
+ fix,
227
+ docs: docsFor(IslandPropsInvalidError.code),
228
+ });
229
+ }
230
+ }
231
+
232
+ /**
233
+ * The page renders an island nothing would boot: the route says `hydrate: 'never'`, or the render
234
+ * collected no islands so no runtime is emitted. Both ship inert markup — and `hydrate: 'never'`
235
+ * is exactly what excuses a `site/` route from declaring `budget.js`, so silence here is how the
236
+ * budget stops meaning anything.
237
+ */
238
+ export class IslandNotHydratedError extends UltimateError {
239
+ static readonly code = 'X_ISLAND_NOT_HYDRATED' as const;
240
+ constructor(cause: string, fix: string) {
241
+ super({
242
+ code: IslandNotHydratedError.code,
243
+ cause,
244
+ fix,
245
+ docs: docsFor(IslandNotHydratedError.code),
246
+ });
247
+ }
248
+ }
249
+
250
+ /**
251
+ * A loader threw. Named separately from whatever it threw because the useful fact is WHICH route
252
+ * failed to load — a bare rejection surfaces the repo's own stack and not the URL an author has
253
+ * to go and fix.
254
+ */
255
+ export class RouteLoadFailedError extends UltimateError {
256
+ static readonly code = 'X_ROUTE_LOAD_FAILED' as const;
257
+ constructor(cause: string, fix: string) {
258
+ super({
259
+ code: RouteLoadFailedError.code,
260
+ cause,
261
+ fix,
262
+ docs: docsFor(RouteLoadFailedError.code),
263
+ });
264
+ }
265
+ }