@ultimat3/render 21.0.0 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +85 -91
- package/README.md +22 -0
- package/package.json +5 -5
- package/src/css-modules.ts +16 -3
- package/src/duration.ts +9 -8
- package/src/index.ts +4 -9
- 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,110 +4,102 @@ Owns: the `route` primitive, the four render modes, the route table, the surface
|
|
|
4
4
|
islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
|
|
5
5
|
loaders that make an app's `.tsx` and `.scss` runnable**.
|
|
6
6
|
|
|
7
|
-
**Two entry points, disjoint
|
|
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
|
-
| `idle` replays too |
|
|
68
|
-
| Where `interaction` replays | `aim(el, ev)
|
|
69
|
-
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string
|
|
70
|
-
| Route truth | `registry.ts`. Never
|
|
71
|
-
| Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME
|
|
72
|
-
| Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED
|
|
73
|
-
| Descriptors | `describeRoutes()`
|
|
74
|
-
| Every string order
|
|
35
|
+
| Descriptor `budget` | always an object, `{}` when undeclared. `budget.js === undefined` is the site/ hydration failure. |
|
|
36
|
+
| `RouteBudget` keys | **`js` and `lcp` only, and every key is PROJECTED** (`budgetJs`, `budgetLcp`). `type-pins.tsx` derives the allowed set from `RouteDescriptor`'s `budget*` keys, so an unprojected key is a build error; `scripts/declaration-readers.ts` finds the class across every declaration. |
|
|
37
|
+
| No `describe()` on a route | `describeRoutes()` is the one route list. |
|
|
38
|
+
| Mode invariants | `modes.ts` only; never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component.** A fifth mode has to name the function that renders it. |
|
|
39
|
+
| Island declaration | `island({ src })` — a **specifier**, never an import, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
|
|
40
|
+
| Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling. Never widen it. |
|
|
41
|
+
| Island timing | the route's `hydrate` and nothing else. `hydrate: 'never'` + an island, or an `island()` call below the `defineRoute` that drains it, is `X_ISLAND_NOT_HYDRATED`; `islandNeverDrained(spec)` tells the two causes apart so the `fix:` names exactly one edit. |
|
|
42
|
+
| Island node shape | a **branded array** (`IslandNode extends Array<never>`); every walker tests `isIslandNode` BEFORE `Array.isArray`. Solid's `JSX.Element` is an unaugmentable alias whose only object member is `ArrayElement`. Never import solid's union. |
|
|
43
|
+
| Island declaration order | `island()` above `defineRoute`, drained by it (`drainDeclaredIslands`), reachable from `./island` and never from `src/index.ts`. Per MODULE (evaluated once), unlike the per-render collector. |
|
|
44
|
+
| Derived budget | `registry.ts`, from the surface: `site/` → `20kb`, `app/` → `34kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`), calibrated on a Solid island. A declared `budget.js` wins; a `'never'` route gets none. |
|
|
45
|
+
| `RouteEntry.islands` | filled from `config.islands` at registration and nothing else — `RegisterRouteInput` has no `islands` key. |
|
|
46
|
+
| Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` (16 KiB; the doc comment carries the arithmetic) — `island-props.ts` is the one gate, a structural walk, never a `JSON.stringify` round trip. Over the cap the cause names the heaviest props and the fix names the endpoint pattern, never "raise the cap". `X_ISLAND_PROPS_INVALID` has a row in `@ultimat3/http`'s table (500). |
|
|
47
|
+
| A prop lands via `Object.defineProperty` | never `out[key] = v` — `__proto__` (a real own key off `JSON.parse`) would run the prototype setter. |
|
|
48
|
+
| An attribute alias is a `Map` | never a record (`toString` resolved to a function). `MODE_SPECS[config.render]` is guarded by `Object.hasOwn` for the same reason. |
|
|
49
|
+
| An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` (`/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`), refused as `null`; the `on*` handler check folds case. `head.ts`'s `renderTag` shares the predicate (`isAttributeName`). One predicate, never two. |
|
|
50
|
+
| Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four plus `data`, `poster`, `ping`, `xlink:href`. `srcdoc` is refused outright (entity-decoded, then parsed as HTML). |
|
|
51
|
+
| Island collection | per render, `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context. |
|
|
52
|
+
| A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. |
|
|
53
|
+
| Island bytes | **not this package's**: `@ultimat3/cli`'s `packages/cli/src/budgets.ts` measures the EMITTED document and is the gate. What stays here is the budget grammar (`parseByteBudget`) and `defaultIslandBudget`. |
|
|
54
|
+
| The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets). It is inline in every document with an island and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot. `runtimeBody` is the one place the served and hashed text are one string. **Uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>` (unreachable today; the first real hole needs a nonce). |
|
|
55
|
+
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`; an assembler only adds `hydrateRuntime(directives)`. |
|
|
56
|
+
| Island boot | `el.__x` holds the boot PROMISE, never a boolean. |
|
|
57
|
+
| Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-09-22`: `idle` 1,744, `visible` 846, `interaction` 1,629 B (what `DEFAULT_ISLAND_JS_BYTES` derives from). |
|
|
58
|
+
| A runtime that calls `boot` | TERMINATES the chain: `idle`/`visible` end in `.catch(hush)`, `interaction` passes `off` as the rejection arm. `hydrate-runtime.test.ts` reds on an unhandled rejection. |
|
|
59
|
+
| `idle` replays too | `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` (capture listeners for `data-x-events`, default `click`; a queue; `aim`; one flush). A caught event wakes an `idle` island early; each event replays once. `hydrate-replay.test.ts`. |
|
|
60
|
+
| Where `interaction` replays | `aim(el, ev)`, never `ev.target` — `mount` usually clears the root, detaching the pressed node. Kept → original target; replaced → `path(el, target)`'s structural walk (same tag, same place), then `document.elementFromPoint(ev.clientX, ev.clientY)` (unless `ev.detail === 0`), then the island root. A hit outside the island falls back to the root. `hydrate-replay.test.ts`. |
|
|
61
|
+
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
|
|
62
|
+
| Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
|
|
63
|
+
| Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`. Anything else is `X_ROUTE_FILE_INVALID`. |
|
|
64
|
+
| Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED`; it fills in only the island budget. |
|
|
65
|
+
| Descriptors | `describeRoutes()` stays JSON-safe, sorted by path, deterministic. |
|
|
66
|
+
| Every string order | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare` (ICU default locale and collation version vary by machine). |
|
|
75
67
|
| Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
|
|
76
|
-
| Stream cancellation | the
|
|
77
|
-
| ISR detach | `attach()`'s returned function clears the revalidator
|
|
78
|
-
| "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below
|
|
79
|
-
| A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts
|
|
80
|
-
| `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `
|
|
81
|
-
| The ISR key | `isrKey(url, locale)` — pathname, the negotiated
|
|
82
|
-
| A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set
|
|
83
|
-
| Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })
|
|
84
|
-
| ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first
|
|
85
|
-
| A `RenderResult.status` | `finiteStatus(subject, status)`
|
|
86
|
-
| `IsrEntry.ttlMs` off a store | normalised by `entryTtlMs`, TOTAL
|
|
87
|
-
| Route path from file |
|
|
88
|
-
| An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts
|
|
89
|
-
| ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`).
|
|
90
|
-
| 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. |
|
|
91
83
|
| Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
|
|
92
84
|
| Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
|
|
93
|
-
| A gated route is never a cached one | `modes.ts` refuses `policy` on
|
|
85
|
+
| A gated route is never a cached one | `modes.ts` refuses `policy` on both `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `ssr` is the one gated mode. |
|
|
94
86
|
| Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
|
|
95
|
-
| Who owns `cache-control` | a
|
|
96
|
-
| Solid | no `solid-js` import anywhere in this package
|
|
97
|
-
| Root element | `ROOT_ELEMENT_ID` (`render-html.ts`)
|
|
98
|
-
| The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once
|
|
99
|
-
| Client sync tags | `client-sync-tags.ts` — `ultimate-sync
|
|
100
|
-
| Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope"
|
|
101
|
-
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can
|
|
102
|
-
| Escaping | `html.ts` only
|
|
103
|
-
| Script and style CONTENT | never
|
|
104
|
-
| Which export is the page | `route-component.ts
|
|
105
|
-
| Stylesheets | compiled by `css-modules.ts`,
|
|
106
|
-
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`)
|
|
107
|
-
| The global layer |
|
|
87
|
+
| Who owns `cache-control` | a mode states intent; `@ultimat3/http`'s `cache-headers` stage decides and may overrule. This package cannot see the actor and never tries. |
|
|
88
|
+
| Solid | no `solid-js` import anywhere in this package; `type-pins.tsx` satisfies `JSX.Element` structurally. `jsx.ts` builds inert nodes. Islands are compiled by `@ultimat3/cli`'s `solid-loader.ts` with `babel-preset-solid`. |
|
|
89
|
+
| Root element | `ROOT_ELEMENT_ID` (`render-html.ts`). |
|
|
90
|
+
| The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once. `packages/cli/src/app-load.ts` imports `@ultimat3/render/server` for that side effect. |
|
|
91
|
+
| Client sync tags | `client-sync-tags.ts` — `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker`. Principal-free, on every document. |
|
|
92
|
+
| Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope">`, read by core's `pageClient()`. `documentCarriesScope(headers)` is the one rule: only a `private` document carries it; absent means "not rendered for anyone". The literal is duplicated in core until core exports a constant. |
|
|
93
|
+
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can override any of them. |
|
|
94
|
+
| Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeAttribute` is `@ultimat3/seo`'s, re-exported by `html.ts`. |
|
|
95
|
+
| Script and style CONTENT | never raw: `escapeText`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
|
|
96
|
+
| Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
|
|
97
|
+
| Stylesheets | compiled by `css-modules.ts`, grouped per surface, served by the CLI as one content-hashed file per surface (`@ultimat3/cli`'s `style-bundle.ts`) from `stylesFor`. `sass` is this package's only third-party dependency. |
|
|
98
|
+
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`). `shared/` is carried by both graphs. |
|
|
99
|
+
| The global layer | the app's `shared/global.scss` `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts` (this package may not import `ui`). `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
|
|
108
100
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
|
109
|
-
| An island's `mount` | may return `() => void`, its disposer
|
|
110
|
-
| `<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`. |
|
|
111
103
|
|
|
112
104
|
Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
|
|
113
105
|
Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
|
|
@@ -117,3 +109,5 @@ bun test # from packages/render
|
|
|
117
109
|
bun run typecheck
|
|
118
110
|
bun run --cwd ../.. verify # the contract
|
|
119
111
|
```
|
|
112
|
+
|
|
113
|
+
Why each rule above is shaped the way it is: [`docs/history/render.md`](../../docs/history/render.md).
|
package/README.md
CHANGED
|
@@ -433,6 +433,28 @@ does.
|
|
|
433
433
|
side effect. Anything that loads an app's source — `x dev`, `x build`, `server.ts`, a test that
|
|
434
434
|
`await import()`s a `page.tsx` — reaches it before the module it loads.
|
|
435
435
|
|
|
436
|
+
## Error classes
|
|
437
|
+
|
|
438
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
439
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
440
|
+
|
|
441
|
+
| Class | Code | Declared in |
|
|
442
|
+
|---|---|---|
|
|
443
|
+
| `BudgetExceededError` | `X_BUDGET_EXCEEDED` | `src/errors.ts` |
|
|
444
|
+
| `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
|
|
445
|
+
| `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
|
|
446
|
+
| `IslandPropsInvalidError` | `X_ISLAND_PROPS_INVALID` | `src/errors.ts` |
|
|
447
|
+
| `PrerenderFailedError` | `X_PRERENDER_FAILED` | `src/errors.ts` |
|
|
448
|
+
| `RouteDuplicateError` | `X_ROUTE_DUPLICATE` | `src/errors.ts` |
|
|
449
|
+
| `RouteFileInvalidError` | `X_ROUTE_FILE_INVALID` | `src/errors.ts` |
|
|
450
|
+
| `RouteLoadFailedError` | `X_ROUTE_LOAD_FAILED` | `src/errors.ts` |
|
|
451
|
+
| `RouteLoadInvalidError` | `X_ROUTE_LOAD_INVALID` | `src/errors.ts` |
|
|
452
|
+
| `RouteMetaMissingError` | `X_ROUTE_META_MISSING` | `src/errors.ts` |
|
|
453
|
+
| `RouteModeInvalidError` | `X_ROUTE_MODE_INVALID` | `src/errors.ts` |
|
|
454
|
+
| `RouteOfflineMissingError` | `X_ROUTE_OFFLINE_MISSING` | `src/errors.ts` |
|
|
455
|
+
| `RouteStatusInvalidError` | `X_ROUTE_STATUS_INVALID` | `src/errors.ts` |
|
|
456
|
+
| `SurfaceBoundaryError` | `X_SURFACE_BOUNDARY` | `src/errors.ts` |
|
|
457
|
+
|
|
436
458
|
## Public API
|
|
437
459
|
|
|
438
460
|
`†` marks a name on `@ultimat3/render/server`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
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": "
|
|
39
|
+
"@ultimat3/cache": "22.0.0",
|
|
40
|
+
"@ultimat3/core": "22.0.0",
|
|
41
|
+
"@ultimat3/i18n": "22.0.0",
|
|
42
|
+
"@ultimat3/seo": "22.0.0",
|
|
43
43
|
"sass": "1.104.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
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
|
}
|
package/src/index.ts
CHANGED
|
@@ -67,15 +67,13 @@ export {
|
|
|
67
67
|
headFromMeta,
|
|
68
68
|
mergeHead,
|
|
69
69
|
renderHead,
|
|
70
|
-
THEME_SCRIPT_MAX_BYTES,
|
|
71
70
|
THEME_STORAGE_KEY,
|
|
72
71
|
themeScript,
|
|
73
72
|
themeScriptBody,
|
|
74
73
|
} from './head';
|
|
75
|
-
export {
|
|
74
|
+
export { seoRenderers } from './head-seo';
|
|
76
75
|
export type { IslandDirective } from './hydrate';
|
|
77
76
|
export {
|
|
78
|
-
DEFAULT_REPLAY_EVENTS,
|
|
79
77
|
emitIslandAttributes,
|
|
80
78
|
emitIslandProps,
|
|
81
79
|
HYDRATE_RUNTIME_BODIES,
|
|
@@ -84,13 +82,10 @@ export {
|
|
|
84
82
|
IDLE_HYDRATE_TIMEOUT_MS,
|
|
85
83
|
ISLAND_FAILED_ATTRIBUTE,
|
|
86
84
|
ISLAND_MOUNTED_ATTRIBUTE,
|
|
87
|
-
requiredStrategies,
|
|
88
85
|
} from './hydrate';
|
|
89
86
|
export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
|
|
90
87
|
export {
|
|
91
88
|
ISLAND_EXTENSION,
|
|
92
|
-
ISLAND_NODE,
|
|
93
|
-
isEmittableSpecifier,
|
|
94
89
|
isIslandNode,
|
|
95
90
|
island,
|
|
96
91
|
islandModuleId,
|
|
@@ -98,7 +93,7 @@ export {
|
|
|
98
93
|
export type { IslandCollector, IslandCollectorInput } from './island-collector';
|
|
99
94
|
export { createIslandCollector, islandModuleIds } from './island-collector';
|
|
100
95
|
export type { IslandProps, JsonValue } from './island-props';
|
|
101
|
-
export {
|
|
96
|
+
export { ISLAND_PROPS_MAX_BYTES } from './island-props';
|
|
102
97
|
export { parseByteBudget } from './islands';
|
|
103
98
|
export type { JsxComponent, JsxNode, JsxProps } from './jsx';
|
|
104
99
|
export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
|
|
@@ -123,7 +118,6 @@ export {
|
|
|
123
118
|
describeRoutes,
|
|
124
119
|
ROUTE_FILENAME,
|
|
125
120
|
registerRoute,
|
|
126
|
-
routeCount,
|
|
127
121
|
routeEntries,
|
|
128
122
|
routeFor,
|
|
129
123
|
routePathFromFile,
|
|
@@ -150,7 +144,8 @@ export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './r
|
|
|
150
144
|
export type { RouteComponent } from './route-component';
|
|
151
145
|
export { pageComponentOf } from './route-component';
|
|
152
146
|
export { metaContextFor, routeDataFor } from './route-data';
|
|
153
|
-
export {
|
|
147
|
+
export { routeStatusOf, withStatus } from './route-status';
|
|
148
|
+
export { STREAM_REVEAL_BODIES } from './stream-scripts';
|
|
154
149
|
export type {
|
|
155
150
|
BoundaryRule,
|
|
156
151
|
BoundaryViolation,
|
package/src/module-loader.ts
CHANGED
|
@@ -152,6 +152,9 @@ export function transformTsx(source: string): string {
|
|
|
152
152
|
*/
|
|
153
153
|
export function loadStylesheet(path: string, source: string): string {
|
|
154
154
|
const compiled = compileStylesheet(path, source);
|
|
155
|
+
// An EMPTY compile unregisters: under `x dev` an edit that deleted every rule left the old entry
|
|
156
|
+
// in place, serving rules the file no longer had until the process restarted.
|
|
157
|
+
if (compiled.css.length === 0 && stylesheets.delete(path)) revision += 1;
|
|
155
158
|
if (compiled.css.length > 0) {
|
|
156
159
|
if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
|
|
157
160
|
stylesheets.set(path, {
|
package/src/registry.ts
CHANGED
|
@@ -211,6 +211,8 @@ export function compilePattern(path: string): CompiledPattern {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
const routes = new Map<string, RouteEntry>();
|
|
214
|
+
/** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
|
|
215
|
+
let described: readonly RouteDescriptor[] | undefined;
|
|
214
216
|
|
|
215
217
|
export interface RegisterRouteInput<TData = RouteData> {
|
|
216
218
|
readonly file: string;
|
|
@@ -290,6 +292,7 @@ export function registerRoute<TData = RouteData>(
|
|
|
290
292
|
...(input.component === undefined ? {} : { component: input.component }),
|
|
291
293
|
};
|
|
292
294
|
routes.set(path, entry as RouteEntry);
|
|
295
|
+
described = undefined;
|
|
293
296
|
return entry;
|
|
294
297
|
}
|
|
295
298
|
|
|
@@ -316,6 +319,7 @@ function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface):
|
|
|
316
319
|
|
|
317
320
|
export function clearRoutes(): void {
|
|
318
321
|
routes.clear();
|
|
322
|
+
described = undefined;
|
|
319
323
|
}
|
|
320
324
|
|
|
321
325
|
export function routeCount(): number {
|
|
@@ -337,6 +341,14 @@ export function routeFor(path: string): RouteEntry | undefined {
|
|
|
337
341
|
* Determinism matters because `sw.js` and the sitemap are diffed across deploys.
|
|
338
342
|
*/
|
|
339
343
|
export function describeRoutes(): readonly RouteDescriptor[] {
|
|
344
|
+
// Built once per registry change and handed out as the SAME frozen array: an ISR regeneration
|
|
345
|
+
// looked its route up through this on every request, re-sorting the whole table each time, and
|
|
346
|
+
// a stable identity is what lets `render-isr.ts` compile its matchers once per table.
|
|
347
|
+
described ??= Object.freeze(buildDescriptors());
|
|
348
|
+
return described;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function buildDescriptors(): RouteDescriptor[] {
|
|
340
352
|
return routeEntries().map((entry) => ({
|
|
341
353
|
path: entry.path,
|
|
342
354
|
file: entry.file,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ISR store: what an entry is, the driver seam an app may back with its own storage, and the
|
|
3
|
+
* bounded in-memory default. Split from `render-isr.ts`, which is the controller that reads it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { finiteCount } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
export type IsrState = 'miss' | 'hit' | 'stale';
|
|
9
|
+
|
|
10
|
+
export interface IsrEntry {
|
|
11
|
+
/**
|
|
12
|
+
* The store key: the request's pathname AND its query, params sorted. Not the route's pattern
|
|
13
|
+
* and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
|
|
14
|
+
* keying both as `/blog` served the second visitor the first one's HTML (#171).
|
|
15
|
+
*/
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly html: string;
|
|
18
|
+
readonly hash: string;
|
|
19
|
+
readonly generatedAt: number;
|
|
20
|
+
readonly ttlMs: number | null;
|
|
21
|
+
/** Set by a tag invalidation; independent of the TTL clock. */
|
|
22
|
+
readonly stale: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* What the page answers, 200–599. Optional because an entry can come back from an app's own
|
|
25
|
+
* store, written before this field existed or JSON-round-tripped without it; absent reads as
|
|
26
|
+
* 200, the only status an entry ever had until `withStatus`.
|
|
27
|
+
*/
|
|
28
|
+
readonly status?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IsrStore {
|
|
32
|
+
get(path: string): IsrEntry | undefined;
|
|
33
|
+
set(entry: IsrEntry): void;
|
|
34
|
+
/**
|
|
35
|
+
* Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
|
|
36
|
+
* not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
|
|
37
|
+
* store is entitled to order its eviction by that: the read-modify-write made the STALEST page
|
|
38
|
+
* the newest, so a tag bust protected exactly the pages that most needed regenerating.
|
|
39
|
+
*/
|
|
40
|
+
markStale(path: string): boolean;
|
|
41
|
+
delete(path: string): void;
|
|
42
|
+
paths(): readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How many rendered pages the default store holds. A route table supports `:params` and `*`, so
|
|
47
|
+
`/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
|
|
48
|
+
* included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
|
|
49
|
+
* the process.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
|
|
52
|
+
|
|
53
|
+
export interface MemoryIsrStoreOptions {
|
|
54
|
+
/** Pages retained. The least recently generated goes first. */
|
|
55
|
+
readonly maxEntries?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
59
|
+
// `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
|
|
60
|
+
// cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
|
|
61
|
+
const maxEntries = finiteCount(
|
|
62
|
+
'memoryIsrStore',
|
|
63
|
+
'maxEntries',
|
|
64
|
+
options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
|
|
65
|
+
);
|
|
66
|
+
const map = new Map<string, IsrEntry>();
|
|
67
|
+
return {
|
|
68
|
+
get: (path) => map.get(path),
|
|
69
|
+
set: (entry) => {
|
|
70
|
+
// Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
|
|
71
|
+
// the first key is the least recently generated page.
|
|
72
|
+
map.delete(entry.path);
|
|
73
|
+
map.set(entry.path, entry);
|
|
74
|
+
while (map.size > maxEntries) {
|
|
75
|
+
const oldest = map.keys().next();
|
|
76
|
+
if (oldest.done === true) break;
|
|
77
|
+
map.delete(oldest.value);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
// In place: `map.set` on a key the Map already holds keeps its position, and that position is
|
|
81
|
+
// the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
|
|
82
|
+
markStale: (path) => {
|
|
83
|
+
const entry = map.get(path);
|
|
84
|
+
if (entry === undefined) return false;
|
|
85
|
+
map.set(path, { ...entry, stale: true });
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
delete: (path) => {
|
|
89
|
+
map.delete(path);
|
|
90
|
+
},
|
|
91
|
+
paths: () => [...map.keys()].sort(),
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/render-isr.ts
CHANGED
|
@@ -15,101 +15,16 @@ import {
|
|
|
15
15
|
sampleFence,
|
|
16
16
|
unregisterDependent,
|
|
17
17
|
} from '@ultimat3/cache';
|
|
18
|
-
import {
|
|
18
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
19
19
|
import { parseTtlMs } from './duration';
|
|
20
20
|
import { finiteStatus, isRenderStatus } from './finite-status';
|
|
21
21
|
import type { RouteDescriptor } from './registry';
|
|
22
22
|
import { describeRoutes } from './registry';
|
|
23
|
+
import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
|
|
24
|
+
import { memoryIsrStore } from './render-isr-store';
|
|
23
25
|
import { contentHash, staticHeaders } from './render-static';
|
|
24
26
|
import type { RenderResult } from './route';
|
|
25
27
|
|
|
26
|
-
export type IsrState = 'miss' | 'hit' | 'stale';
|
|
27
|
-
|
|
28
|
-
export interface IsrEntry {
|
|
29
|
-
/**
|
|
30
|
-
* The store key: the request's pathname AND its query, params sorted. Not the route's pattern
|
|
31
|
-
* and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
|
|
32
|
-
* keying both as `/blog` served the second visitor the first one's HTML (#171).
|
|
33
|
-
*/
|
|
34
|
-
readonly path: string;
|
|
35
|
-
readonly html: string;
|
|
36
|
-
readonly hash: string;
|
|
37
|
-
readonly generatedAt: number;
|
|
38
|
-
readonly ttlMs: number | null;
|
|
39
|
-
/** Set by a tag invalidation; independent of the TTL clock. */
|
|
40
|
-
readonly stale: boolean;
|
|
41
|
-
/**
|
|
42
|
-
* What the page answers, 200–599. Optional because an entry can come back from an app's own
|
|
43
|
-
* store, written before this field existed or JSON-round-tripped without it; absent reads as
|
|
44
|
-
* 200, the only status an entry ever had until `withStatus`.
|
|
45
|
-
*/
|
|
46
|
-
readonly status?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface IsrStore {
|
|
50
|
-
get(path: string): IsrEntry | undefined;
|
|
51
|
-
set(entry: IsrEntry): void;
|
|
52
|
-
/**
|
|
53
|
-
* Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
|
|
54
|
-
* not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
|
|
55
|
-
* store is entitled to order its eviction by that: the read-modify-write made the STALEST page
|
|
56
|
-
* the newest, so a tag bust protected exactly the pages that most needed regenerating.
|
|
57
|
-
*/
|
|
58
|
-
markStale(path: string): boolean;
|
|
59
|
-
delete(path: string): void;
|
|
60
|
-
paths(): readonly string[];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* How many rendered pages the default store holds. A route table supports `:params` and `*`, so
|
|
65
|
-
`/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
|
|
66
|
-
* included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
|
|
67
|
-
* the process.
|
|
68
|
-
*/
|
|
69
|
-
export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
|
|
70
|
-
|
|
71
|
-
export interface MemoryIsrStoreOptions {
|
|
72
|
-
/** Pages retained. The least recently generated goes first. */
|
|
73
|
-
readonly maxEntries?: number;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
77
|
-
// `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
|
|
78
|
-
// cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
|
|
79
|
-
const maxEntries = finiteCount(
|
|
80
|
-
'memoryIsrStore',
|
|
81
|
-
'maxEntries',
|
|
82
|
-
options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
|
|
83
|
-
);
|
|
84
|
-
const map = new Map<string, IsrEntry>();
|
|
85
|
-
return {
|
|
86
|
-
get: (path) => map.get(path),
|
|
87
|
-
set: (entry) => {
|
|
88
|
-
// Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
|
|
89
|
-
// the first key is the least recently generated page.
|
|
90
|
-
map.delete(entry.path);
|
|
91
|
-
map.set(entry.path, entry);
|
|
92
|
-
while (map.size > maxEntries) {
|
|
93
|
-
const oldest = map.keys().next();
|
|
94
|
-
if (oldest.done === true) break;
|
|
95
|
-
map.delete(oldest.value);
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
// In place: `map.set` on a key the Map already holds keeps its position, and that position is
|
|
99
|
-
// the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
|
|
100
|
-
markStale: (path) => {
|
|
101
|
-
const entry = map.get(path);
|
|
102
|
-
if (entry === undefined) return false;
|
|
103
|
-
map.set(path, { ...entry, stale: true });
|
|
104
|
-
return true;
|
|
105
|
-
},
|
|
106
|
-
delete: (path) => {
|
|
107
|
-
map.delete(path);
|
|
108
|
-
},
|
|
109
|
-
paths: () => [...map.keys()].sort(),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
28
|
/**
|
|
114
29
|
* The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
|
|
115
30
|
* because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
|
|
@@ -231,7 +146,7 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
231
146
|
function descriptorFor(key: string): RouteDescriptor | undefined {
|
|
232
147
|
const path = routePathOf(key);
|
|
233
148
|
const table = routes();
|
|
234
|
-
return table.find((r) => r.path === path) ?? table.find((
|
|
149
|
+
return table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
|
|
235
150
|
}
|
|
236
151
|
|
|
237
152
|
/**
|
|
@@ -413,11 +328,27 @@ function parseWireTag(wire: string): CacheTag {
|
|
|
413
328
|
return { entity: wire.slice(0, split), id: wire.slice(split + 1) };
|
|
414
329
|
}
|
|
415
330
|
|
|
331
|
+
interface RouteMatcher {
|
|
332
|
+
readonly route: RouteDescriptor;
|
|
333
|
+
test(storedPath: string): boolean;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** One compiled set per route TABLE — `describeRoutes()` hands out one array per registry change. */
|
|
337
|
+
const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
|
|
338
|
+
|
|
416
339
|
/** A stored path belongs to a route when the route's pattern matches it. */
|
|
417
|
-
function
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
340
|
+
function matchersOf(table: readonly RouteDescriptor[]): readonly RouteMatcher[] {
|
|
341
|
+
const cached = compiledTables.get(table);
|
|
342
|
+
if (cached !== undefined) return cached;
|
|
343
|
+
const compiled = table.map((route): RouteMatcher => {
|
|
344
|
+
if (!route.path.includes(':') && !route.path.includes('*')) {
|
|
345
|
+
return { route, test: (storedPath) => storedPath === route.path };
|
|
346
|
+
}
|
|
347
|
+
const pattern = new RegExp(`^${route.path.split('/').map(segmentPattern).join('/')}/?$`);
|
|
348
|
+
return { route, test: (storedPath) => pattern.test(storedPath) };
|
|
349
|
+
});
|
|
350
|
+
compiledTables.set(table, compiled);
|
|
351
|
+
return compiled;
|
|
421
352
|
}
|
|
422
353
|
|
|
423
354
|
function segmentPattern(segment: string): string {
|
package/src/render-static.ts
CHANGED
|
@@ -8,12 +8,13 @@ import { renderThrowable, useContext } from '@ultimat3/core';
|
|
|
8
8
|
import { PrerenderFailedError, RouteModeInvalidError } from './errors';
|
|
9
9
|
import type { RouteEntry } from './registry';
|
|
10
10
|
import type { RenderResult, RouteParams } from './route';
|
|
11
|
+
import { filePathOf, filledSegments, urlPathOf } from './static-path';
|
|
11
12
|
|
|
12
13
|
export interface StaticArtifact {
|
|
13
14
|
readonly path: string;
|
|
14
15
|
readonly params: RouteParams;
|
|
15
16
|
readonly html: string;
|
|
16
|
-
/**
|
|
17
|
+
/** `contentHash` (xxHash32) of the HTML. Stable across machines and across Bun versions. */
|
|
17
18
|
readonly hash: string;
|
|
18
19
|
/** Where the file lands on disk, relative to the build output root. */
|
|
19
20
|
readonly outputPath: string;
|
|
@@ -25,14 +26,15 @@ export type StaticRenderFn = (input: {
|
|
|
25
26
|
readonly params: RouteParams;
|
|
26
27
|
}) => string | Promise<string>;
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* xxHash32 (seed 0) of the UTF-8 bytes, 8 hex characters. Native: FNV-1a in JS measured 134 µs on a
|
|
31
|
+
* 96 kB document against 21 µs here, and every static page, ISR regeneration and CSS module hashes
|
|
32
|
+
* through it. xxHash32 is a SPECIFIED algorithm — the test pins its reference vectors — so the value
|
|
33
|
+
* is stable across machines and Bun versions, as the FNV one was. Switching was a one-time cache
|
|
34
|
+
* bust: every ETag and every scoped CSS class name changed once, in 22.0.0.
|
|
35
|
+
*/
|
|
29
36
|
export function contentHash(input: string): string {
|
|
30
|
-
|
|
31
|
-
for (let i = 0; i < input.length; i += 1) {
|
|
32
|
-
hash ^= input.charCodeAt(i);
|
|
33
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
34
|
-
}
|
|
35
|
-
return hash.toString(16).padStart(8, '0');
|
|
37
|
+
return Bun.hash.xxHash32(input).toString(16).padStart(8, '0');
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -115,7 +117,8 @@ export async function renderStatic(
|
|
|
115
117
|
|
|
116
118
|
const artifacts: StaticArtifact[] = [];
|
|
117
119
|
for (const params of paramSets) {
|
|
118
|
-
const
|
|
120
|
+
const segments = filledSegments(entry.pattern.source, params);
|
|
121
|
+
const path = urlPathOf(segments);
|
|
119
122
|
let html: string;
|
|
120
123
|
try {
|
|
121
124
|
html = await render({ path, params });
|
|
@@ -131,7 +134,7 @@ export async function renderStatic(
|
|
|
131
134
|
params,
|
|
132
135
|
html,
|
|
133
136
|
hash,
|
|
134
|
-
outputPath:
|
|
137
|
+
outputPath: filePathOf(segments, indexFile),
|
|
135
138
|
headers: staticHeaders(hash, options.buildId),
|
|
136
139
|
});
|
|
137
140
|
}
|
|
@@ -152,17 +155,11 @@ export function staticResult(artifact: StaticArtifact): RenderResult {
|
|
|
152
155
|
return { status: 200, headers: artifact.headers, body: artifact.html };
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* `/blog/:slug` + `{ slug: 'hello' }` → `/blog/hello`, each segment percent-encoded. A missing
|
|
160
|
+
* param, a dot segment, a separator inside a `:param`, NUL, `?` and `#` are `X_PRERENDER_FAILED`
|
|
161
|
+
* (`static-path.ts`) — they wrote a `:slug` directory, or a file outside the build output.
|
|
162
|
+
*/
|
|
156
163
|
export function fillPath(pattern: string, params: RouteParams): string {
|
|
157
|
-
return (
|
|
158
|
-
pattern
|
|
159
|
-
.split('/')
|
|
160
|
-
.map((segment) => {
|
|
161
|
-
if (segment.startsWith(':')) return params[segment.slice(1)] ?? segment;
|
|
162
|
-
if (segment.startsWith('*')) return params[segment.slice(1)] ?? '';
|
|
163
|
-
return segment;
|
|
164
|
-
})
|
|
165
|
-
.join('/')
|
|
166
|
-
.replace(/\/+$/, '') || '/'
|
|
167
|
-
);
|
|
164
|
+
return urlPathOf(filledSegments(pattern, params));
|
|
168
165
|
}
|
package/src/render-stream.ts
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
15
15
|
import { finiteStatus } from './finite-status';
|
|
16
|
-
import { escapeAttribute
|
|
16
|
+
import { escapeAttribute } from './html';
|
|
17
17
|
import type { RenderResult } from './route';
|
|
18
|
+
import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
|
|
18
19
|
|
|
19
20
|
export interface StreamHole {
|
|
20
21
|
/** Stable within a response; becomes the DOM id, so keep it short. */
|
|
@@ -60,20 +61,14 @@ export function holeMarker(id: string, fallback: string): string {
|
|
|
60
61
|
* The entire client half of out-of-order streaming. Inline, uncompressed, ~200 bytes; it
|
|
61
62
|
* moves a late `<template>`'s content into the placeholder that is already on screen.
|
|
62
63
|
*/
|
|
63
|
-
export const REVEAL_SCRIPT =
|
|
64
|
-
"<script>window.$X=function(i){var t=document.querySelector('template[data-x-hole=\"'+i+'\"]')," +
|
|
65
|
-
's=document.getElementById(i);if(t&&s){s.replaceWith(t.content);t.remove()}}</script>';
|
|
64
|
+
export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
|
|
66
65
|
|
|
67
66
|
export function revealChunk(id: string, html: string): string {
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
// built by `JSON.stringify` so the id is a JS string LITERAL rather than text pasted between two
|
|
71
|
-
// quotes: `a");alert(1);//` closed the call and ran on the page's own origin. `</script` inside
|
|
72
|
-
// it would still end the element, so the raw-text rule applies over the top, as `html.ts` says.
|
|
73
|
-
const argument = escapeRawTextContent(JSON.stringify(key));
|
|
67
|
+
// The id reaches markup ONLY through `escapeAttribute` — never a script — so a quote in it
|
|
68
|
+
// cannot close anything, and no per-hole body exists for a CSP to fail to list.
|
|
74
69
|
return (
|
|
75
|
-
`<template data-x-hole="${escapeAttribute(
|
|
76
|
-
`<script>$
|
|
70
|
+
`<template data-x-hole="${escapeAttribute(holeId(id))}">${html}</template>` +
|
|
71
|
+
`<script>${REVEAL_CALL}</script>`
|
|
77
72
|
);
|
|
78
73
|
}
|
|
79
74
|
|
|
@@ -186,7 +181,7 @@ export function renderStreamHtml(
|
|
|
186
181
|
timeoutMs === null
|
|
187
182
|
? undefined
|
|
188
183
|
: setTimeout(() => {
|
|
189
|
-
logger.warn(
|
|
184
|
+
logger.warn('render.stream.hole_deadline', { hole: hole.id, timeoutMs });
|
|
190
185
|
reveal(errorFallback(hole.id));
|
|
191
186
|
}, timeoutMs);
|
|
192
187
|
// A response nobody is reading must not hold the process open until its deadline.
|
|
@@ -200,7 +195,12 @@ export function renderStreamHtml(
|
|
|
200
195
|
// `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
|
|
201
196
|
// and a read that raises here skips the `reveal` below — the hole never fills and the
|
|
202
197
|
// response is held to its deadline for a failure that was already handled.
|
|
203
|
-
logger
|
|
198
|
+
// A FIELD, never the message: `logger` redacts fields and never `msg`, so a hole's
|
|
199
|
+
// failure text interpolated into the message reached the log past every redactor.
|
|
200
|
+
logger.warn('render.stream.hole_rejected', {
|
|
201
|
+
hole: hole.id,
|
|
202
|
+
error: renderThrowable(error),
|
|
203
|
+
});
|
|
204
204
|
reveal(errorFallback(hole.id));
|
|
205
205
|
},
|
|
206
206
|
);
|
package/src/server.ts
CHANGED
|
@@ -40,22 +40,18 @@ export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
|
|
|
40
40
|
export type {
|
|
41
41
|
IsrController,
|
|
42
42
|
IsrControllerOptions,
|
|
43
|
-
IsrEntry,
|
|
44
43
|
IsrRendered,
|
|
45
44
|
IsrRenderFn,
|
|
46
45
|
IsrServeResult,
|
|
47
|
-
IsrState,
|
|
48
|
-
IsrStore,
|
|
49
|
-
MemoryIsrStoreOptions,
|
|
50
46
|
} from './render-isr';
|
|
51
47
|
export {
|
|
52
48
|
createIsrController,
|
|
53
|
-
DEFAULT_ISR_MAX_ENTRIES,
|
|
54
49
|
ISR_LOCALE_PARAM,
|
|
55
50
|
invalidateAndRevalidate,
|
|
56
51
|
isrKey,
|
|
57
|
-
memoryIsrStore,
|
|
58
52
|
} from './render-isr';
|
|
53
|
+
export type { IsrEntry, IsrState, IsrStore, MemoryIsrStoreOptions } from './render-isr-store';
|
|
54
|
+
export { DEFAULT_ISR_MAX_ENTRIES, memoryIsrStore } from './render-isr-store';
|
|
59
55
|
export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
|
|
60
56
|
export { renderSsr, ssrHeaders } from './render-ssr';
|
|
61
57
|
export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: a route pattern plus `prerender()` params, as the segments a static build
|
|
2
|
+
// writes. `prerender()` returns APP data, and each value becomes a directory on disk: a raw `..`
|
|
3
|
+
// wrote outside the build output, and a missing param wrote a directory literally named `:slug`.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { PrerenderFailedError } from './errors';
|
|
7
|
+
import type { RouteParams } from './route';
|
|
8
|
+
|
|
9
|
+
/** Anything a single path segment may not carry: separators, `?` and `#` (controls: `hasControl`). */
|
|
10
|
+
const UNSAFE = /[/\\?#]/;
|
|
11
|
+
|
|
12
|
+
/** NUL and every C0 control, plus DEL — by code point, so no control character sits in a regex. */
|
|
13
|
+
const hasControl = (text: string): boolean => {
|
|
14
|
+
for (const char of text) {
|
|
15
|
+
const code = char.codePointAt(0) ?? 0;
|
|
16
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const refuse = (pattern: string, param: string, value: unknown, why: string): never => {
|
|
22
|
+
throw new PrerenderFailedError(
|
|
23
|
+
`prerender() gave ${pattern} the ${param} ${renderCauseValue(value)}, which ${why}`,
|
|
24
|
+
`return a value for every param of ${pattern} from prerender(), each one plain path text — slugify it (lowercase, [a-z0-9-]) where it comes from a title or a user`,
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const checked = (pattern: string, param: string, segment: string): string => {
|
|
29
|
+
if (segment === '.' || segment === '..') {
|
|
30
|
+
return refuse(pattern, param, segment, 'is a dot segment — it would write outside its route');
|
|
31
|
+
}
|
|
32
|
+
if (UNSAFE.test(segment) || hasControl(segment)) {
|
|
33
|
+
return refuse(pattern, param, segment, 'carries a separator, a control character, ? or #');
|
|
34
|
+
}
|
|
35
|
+
return segment;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The raw (decoded) segments, validated. `:name` must be present and one segment; `*name` may span
|
|
40
|
+
* several (`a/b`) and may be absent, but none of its parts may be a dot segment or unsafe.
|
|
41
|
+
*/
|
|
42
|
+
export function filledSegments(pattern: string, params: RouteParams): readonly string[] {
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
for (const segment of pattern.split('/')) {
|
|
45
|
+
if (segment === '') continue;
|
|
46
|
+
if (segment.startsWith(':')) {
|
|
47
|
+
const name = segment.slice(1);
|
|
48
|
+
const value = Object.hasOwn(params, name) ? params[name] : undefined;
|
|
49
|
+
if (value === undefined || value === '') {
|
|
50
|
+
return refuse(
|
|
51
|
+
pattern,
|
|
52
|
+
name,
|
|
53
|
+
value,
|
|
54
|
+
'is missing — the file would be named after the pattern',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
out.push(checked(pattern, name, value));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (segment.startsWith('*')) {
|
|
61
|
+
const name = segment.slice(1);
|
|
62
|
+
const value = Object.hasOwn(params, name) ? (params[name] ?? '') : '';
|
|
63
|
+
for (const part of value.split('/')) if (part !== '') out.push(checked(pattern, name, part));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
out.push(segment);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The URL form: each segment percent-encoded, as a browser's `pathname` spells it. */
|
|
72
|
+
export const urlPathOf = (segments: readonly string[]): string =>
|
|
73
|
+
segments.length === 0 ? '/' : `/${segments.map(encodeURIComponent).join('/')}`;
|
|
74
|
+
|
|
75
|
+
/** The file form: the DECODED segments, which is what a static server maps a URL back onto. */
|
|
76
|
+
export const filePathOf = (segments: readonly string[], indexFile: string): string =>
|
|
77
|
+
[...segments, indexFile].join('/');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Single responsibility: the inline script bodies out-of-order streaming emits, as constants. Its own
|
|
2
|
+
// module so a CSP builder can import the list without importing the stream renderer and its logger.
|
|
3
|
+
|
|
4
|
+
export const REVEAL_BODY =
|
|
5
|
+
"window.$X=function(){document.querySelectorAll('template[data-x-hole]').forEach(function(t){" +
|
|
6
|
+
"var s=document.getElementById(t.getAttribute('data-x-hole'));if(s){s.replaceWith(t.content);t.remove()}})}";
|
|
7
|
+
|
|
8
|
+
/** The one call every reveal makes. Constant, so a hash-based CSP can admit it. */
|
|
9
|
+
export const REVEAL_CALL = '$X()';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every inline script body a streamed document can carry — two, whatever the holes. A production
|
|
13
|
+
* policy admits inline script by HASH (a `render: 'stream'` response gets no nonce), and the reveal
|
|
14
|
+
* used to be one `$X("<id>")` per hole: a body per id that no policy could list, so every reveal
|
|
15
|
+
* was blocked. The id now rides only in the escaped `data-x-hole` attribute, and `$X()` reveals
|
|
16
|
+
* every template that has arrived.
|
|
17
|
+
*/
|
|
18
|
+
export const STREAM_REVEAL_BODIES: readonly string[] = Object.freeze([REVEAL_BODY, REVEAL_CALL]);
|
package/src/surfaces.ts
CHANGED
|
@@ -47,7 +47,10 @@ export const SURFACE_SPECS = Object.freeze<Record<Surface, SurfaceSpec>>({
|
|
|
47
47
|
defaultMode: null,
|
|
48
48
|
allowedModes: [],
|
|
49
49
|
jsBaselineBytes: 0,
|
|
50
|
-
|
|
50
|
+
// `app` too, measured the day this table became the rule: both tracked apps' `api/index.ts`
|
|
51
|
+
// and `api/tasks.ts` import the app slices' actions and jobs to register them. Both surfaces
|
|
52
|
+
// are server-only, so no browser bundle pays for the edge; `site` stays out.
|
|
53
|
+
mayImport: ['shared', 'app'],
|
|
51
54
|
mayImportTypes: ['shared'],
|
|
52
55
|
},
|
|
53
56
|
shared: {
|
|
@@ -118,7 +121,11 @@ export function importGraph(
|
|
|
118
121
|
return graph;
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
export type BoundaryRule =
|
|
124
|
+
export type BoundaryRule =
|
|
125
|
+
| 'site-imports-app'
|
|
126
|
+
| 'shared-is-a-leaf'
|
|
127
|
+
| 'app-imports-api-at-runtime'
|
|
128
|
+
| 'surface-imports-surface';
|
|
122
129
|
|
|
123
130
|
export interface BoundaryViolation {
|
|
124
131
|
readonly rule: BoundaryRule;
|
|
@@ -210,50 +217,57 @@ interface ClassifyInput {
|
|
|
210
217
|
readonly chain: readonly string[];
|
|
211
218
|
}
|
|
212
219
|
|
|
220
|
+
/**
|
|
221
|
+
* One edge against `SURFACE_SPECS` — the table IS the rule. `mayImport` and `mayImportTypes` were
|
|
222
|
+
* read by nothing, so an `api → site`, `site → api` or `app → site` value import classified as no
|
|
223
|
+
* violation at all. The three pairs that had rules of their own keep them (and their codes); every
|
|
224
|
+
* other crossing the table does not allow is `surface-imports-surface`.
|
|
225
|
+
*/
|
|
213
226
|
function classify(i: ClassifyInput): BoundaryViolation | null {
|
|
214
227
|
const chainText = i.chain.join(' → ');
|
|
228
|
+
const base = { entry: i.entry, importer: i.importer, imported: i.imported, chain: i.chain };
|
|
215
229
|
|
|
230
|
+
// Transitive, from the ENTRY: a site page reaching app/ through any number of hops.
|
|
216
231
|
if (i.entrySurface === 'site' && i.importedSurface === 'app' && !i.typeOnly) {
|
|
217
232
|
return {
|
|
233
|
+
...base,
|
|
218
234
|
rule: 'site-imports-app',
|
|
219
|
-
entry: i.entry,
|
|
220
|
-
importer: i.importer,
|
|
221
|
-
imported: i.imported,
|
|
222
|
-
chain: i.chain,
|
|
223
235
|
cause: chainText,
|
|
224
236
|
fix: `x fix boundary ${i.entry} (or move ${i.imported} out of the shared graph)`,
|
|
225
237
|
};
|
|
226
238
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
239
|
+
const from = i.importerSurface;
|
|
240
|
+
const to = i.importedSurface;
|
|
241
|
+
if (from === null || to === null || from === to) return null;
|
|
242
|
+
const spec = SURFACE_SPECS[from];
|
|
243
|
+
if (spec.mayImport.includes(to)) return null;
|
|
244
|
+
if (i.typeOnly && spec.mayImportTypes.includes(to)) return null;
|
|
245
|
+
// Reported above, from the site entry that reaches it — one crossing, one finding.
|
|
246
|
+
if (from === 'site' && to === 'app' && !i.typeOnly) return null;
|
|
247
|
+
|
|
248
|
+
if (from === 'shared' && !i.typeOnly) {
|
|
233
249
|
return {
|
|
250
|
+
...base,
|
|
234
251
|
rule: 'shared-is-a-leaf',
|
|
235
|
-
entry: i.entry,
|
|
236
|
-
importer: i.importer,
|
|
237
|
-
imported: i.imported,
|
|
238
|
-
chain: i.chain,
|
|
239
252
|
cause: `${chainText} (shared/ is a leaf — it may not import a surface)`,
|
|
240
253
|
fix: `move the shared part of ${i.imported} into shared/ and import it from ${i.importer}`,
|
|
241
254
|
};
|
|
242
255
|
}
|
|
243
|
-
|
|
244
|
-
if (i.importerSurface === 'app' && i.importedSurface === 'api' && !i.typeOnly) {
|
|
256
|
+
if (from === 'app' && to === 'api' && !i.typeOnly) {
|
|
245
257
|
return {
|
|
258
|
+
...base,
|
|
246
259
|
rule: 'app-imports-api-at-runtime',
|
|
247
|
-
entry: i.entry,
|
|
248
|
-
importer: i.importer,
|
|
249
|
-
imported: i.imported,
|
|
250
|
-
chain: i.chain,
|
|
251
260
|
cause: `${chainText} (app/ → api/ is types-only)`,
|
|
252
261
|
fix: `change to \`import type\` in ${i.importer} and call the typed client instead`,
|
|
253
262
|
};
|
|
254
263
|
}
|
|
255
|
-
|
|
256
|
-
return
|
|
264
|
+
const field = i.typeOnly ? 'mayImportTypes' : 'mayImport';
|
|
265
|
+
return {
|
|
266
|
+
...base,
|
|
267
|
+
rule: 'surface-imports-surface',
|
|
268
|
+
cause: `${chainText} (${from}/ may not import ${to}/${i.typeOnly ? ', even as a type' : ''}: SURFACE_SPECS.${from}.${field} is [${spec[field].join(', ')}])`,
|
|
269
|
+
fix: `move what ${i.importer} needs from ${i.imported} into shared/ and import it from there`,
|
|
270
|
+
};
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
/** Build-time gate. `x verify` and the dev server both call this. */
|