@ultimat3/render 19.3.1 → 19.3.3
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 +3 -1
- package/README.md +12 -0
- package/package.json +5 -5
- package/src/css-modules.ts +38 -6
- package/src/errors.ts +19 -0
- package/src/finite-status.ts +10 -1
- package/src/hydrate.ts +4 -0
- package/src/index.ts +2 -0
- package/src/module-loader.ts +14 -4
- package/src/render-isr.ts +42 -3
- package/src/route-status.ts +72 -0
- package/src/route.ts +13 -1
- package/src/server.ts +8 -1
package/CLAUDE.md
CHANGED
|
@@ -37,6 +37,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
37
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. 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. |
|
|
38
38
|
| `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. |
|
|
39
39
|
| 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`. |
|
|
40
|
+
| A loader's own STATUS | `withStatus(status, data)` in `route-status.ts` — the ONE way a page answers 404 (or 410, or 503) while still rendering its own component inside the app's shell. Measured in ai-maxxing 2026-09-07: the only route to a status was a throw, which is the framework's error page OUTSIDE the shell, so `/fleet/nope` rendered the right page and answered 200. The status rides on the data by IDENTITY, in a `WeakMap` — the same object comes back, so `load`'s type, `routeDataFor`'s signature and every consumer that never asks are untouched, and a frozen or class-instance result is never written into. Never a `RouteContext` method: every builder of a context (`x dev`, the prerenderer, the SEO scan, both scaffold templates) would have to supply it, and an optional method is a second way. `routeStatusOf(data)` is the reader, TOTAL, 200 when nothing asked. A 3xx is `X_ROUTE_STATUS_INVALID` — a redirect is a `Location` and no body — and the range is `finiteStatus`'s. A 4xx/5xx is `robots.index = false` BY CONSTRUCTION in `defineRoute`'s `meta` wrapper, the one function every `<head>` renderer calls; a 200 hands the author's `meta` object back by reference. The response status itself is minted by `@ultimat3/cli`'s `dev-render.ts` (`resultFor`), which reads `routeStatusOf(data)` once and hands it to the mode — this package owns the seam, never the `Response`. |
|
|
40
41
|
| 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. |
|
|
41
42
|
| Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
|
|
42
43
|
| Descriptor `budget` | always an object, `{}` when undeclared. Its *fields* stay optional — `budget.js === undefined` is the site/ hydration failure. |
|
|
@@ -80,7 +81,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
80
81
|
| A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set` — the same mechanism `CacheStack`'s read-through fill uses, never a second one grown here. `regenerate` rendered and then wrote `{ stale: false }` unconditionally, so a `markStale` arriving in between was ERASED by HTML built from pre-write rows; for a tag-only route `isFresh` is then true forever and the process serves it for the rest of its life. `registerPath` runs BEFORE the render for the other half: `revalidateByTags` reads the graph, so a bust could not see a cold path whose first render was still in flight. |
|
|
81
82
|
| Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })`. `set` means "this page was just generated" and the default store orders eviction by exactly that, so the read-modify-write made the STALEST page the newest: a tag bust protected the pages that most needed regenerating and evicted the freshest one instead. **Breaking**: `markStale` is a required member of `IsrStore`. |
|
|
82
83
|
| 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. |
|
|
83
|
-
| A `RenderResult.status` | `finiteStatus(subject, status)` in `finite-status.ts`, at
|
|
84
|
+
| A `RenderResult.status` | `finiteStatus(subject, status)` in `finite-status.ts`, at every site that takes one from a caller (`renderSsr`'s `options.status`, `streamResult`'s third argument, `withStatus`'s first, and an `IsrRenderFn` answering `{ html, status }` at generation — `isRenderStatus`, the same range as a predicate, is the TOTAL read of a stored `IsrEntry.status` on the request path, because a custom store may JSON-round-trip one and a throw there is a 500 for the page's whole TTL) — 200–599, whole. It reaches `new Response(body, { status })`, which answers a bare `RangeError` for anything else: `NaN` arrives there as `The status provided (-9223372036854775808)`, no code, no fix, and the render fails two frames above the route that set it. The screen is NARROWER than the boundary on purpose — `new Response` also takes `101`, and no rendered document is a protocol switch. The name carries `finite` because `bun run finite-bounds` recognises a repair by the shape of the CALL; spelled `renderStatus` it read as no screen at all. |
|
|
84
85
|
| `IsrEntry.ttlMs` off a store | normalised by `entryTtlMs`, TOTAL, never a throw — a ttl that is not a POSITIVE FINITE number of ms is the tag-only `null` `parseTtlMs` would have answered. `IsrStore` is a driver seam, and one backed by Redis round-trips the entry through JSON where a `ttlMs` nobody wrote reads back as `undefined`, so `entry.ttlMs === null` is false. Two failures, neither raising: `now - generatedAt < NaN` is false so the page is NEVER fresh and every request regenerates it, and the CDN is handed `s-maxage=NaN` — a directive a conforming cache IGNORES, dropping the page to heuristic caching. Read on the request path, so a refusal would turn a bad stored entry into a 500; the `isr.entry_ttl_invalid` warning is what keeps it from being silent. |
|
|
85
86
|
| 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. |
|
|
86
87
|
| An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts` is the one reader. `decodeURIComponent('%zz')` is a bare `URIError`, so a typo in a path segment was a 500 where `@ultimat3/http`'s router already answers "this branch does not match". A literal route matching the same text still wins. `router-client.ts` was the second reader and went with `createRouter`. |
|
|
@@ -102,6 +103,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
102
103
|
| 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. |
|
|
103
104
|
| The global layer | this package may not import `@ultimat3/ui` (tier 4, the same tier — sideways, not upward: `ui` moved 5 → 4 in 2026-08 and `render → ui` stays forbidden because a same-tier edge has to be declared in `scripts/lib/tiers.ts`, and this one deliberately is not — the static bundle graph may not reach the design system, axiom 6), so the app's source graph carries it: one `shared/global.scss` that `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts`. One file, because each stylesheet is its own Sass compilation — a token file `@use`d per module duplicates its `:root` block per module. `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
|
|
104
105
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
|
106
|
+
| An island's `mount` | may return `() => void`, its disposer — `return render(…)`. The runtime resolves `el.__x` with it and never calls it; `@ultimat3/testing`'s `mountIsland` does, on dispose. |
|
|
105
107
|
| `<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. |
|
|
106
108
|
|
|
107
109
|
Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
|
package/README.md
CHANGED
|
@@ -55,6 +55,17 @@ already carries a better code and a better fix than any wrapper could. Membershi
|
|
|
55
55
|
brand, not a `code` property: an `ENOENT` is an `Error` with a string `code` too, and it gets
|
|
56
56
|
wrapped like any other loader failure.
|
|
57
57
|
|
|
58
|
+
A loader that wants the page to answer a **status** — a row the URL names and the table lacks —
|
|
59
|
+
returns its data through `withStatus(404, data)`. The same object comes back, so nothing about
|
|
60
|
+
`load`'s type, `meta`'s `data` or the page's props changes; every render mode reads the status off
|
|
61
|
+
it (`routeStatusOf`), and a 4xx or 5xx is `robots: noindex` by construction, applied by the
|
|
62
|
+
descriptor's `meta` after the route's own ran. Not a throw: a throw is the framework's error page,
|
|
63
|
+
outside the app's shell, and `As of 2026-09-07` that was the only way to a 404 — ai-maxxing's
|
|
64
|
+
`/fleet/nope` rendered the right page and answered 200. A 3xx is `X_ROUTE_STATUS_INVALID`; a
|
|
65
|
+
redirect is `@ultimat3/http`'s `redirect()`. The static export writes the document whatever the
|
|
66
|
+
loader said — a file has no status — and the build's measurer, which renders with `params: {}`,
|
|
67
|
+
never fails on a loader answering 404.
|
|
68
|
+
|
|
58
69
|
## `offline` and `meta` are required by the type
|
|
59
70
|
|
|
60
71
|
Not by a lint rule, not by a doc — by `RouteDefinition`. Axiom 3 lives in the type system:
|
|
@@ -429,6 +440,7 @@ side effect. Anything that loads an app's source — `x dev`, `x build`, `server
|
|
|
429
440
|
| Export | Owns |
|
|
430
441
|
|---|---|
|
|
431
442
|
| `defineRoute` | the `route` primitive |
|
|
443
|
+
| `withStatus`, `routeStatusOf` | the status a loader answers, carried on its data; 200 when nothing asked |
|
|
432
444
|
| `island`, `createIslandCollector` | one interactive component on a static page |
|
|
433
445
|
| `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
|
|
434
446
|
| `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "19.3.
|
|
3
|
+
"version": "19.3.3",
|
|
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": "19.3.
|
|
40
|
-
"@ultimat3/core": "19.3.
|
|
41
|
-
"@ultimat3/i18n": "19.3.
|
|
42
|
-
"@ultimat3/seo": "19.3.
|
|
39
|
+
"@ultimat3/cache": "19.3.3",
|
|
40
|
+
"@ultimat3/core": "19.3.3",
|
|
41
|
+
"@ultimat3/i18n": "19.3.3",
|
|
42
|
+
"@ultimat3/seo": "19.3.3",
|
|
43
43
|
"sass": "1.102.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/css-modules.ts
CHANGED
|
@@ -115,15 +115,47 @@ const TOKEN_FIX =
|
|
|
115
115
|
"@ultimat3/ui/tokens exports functions and mixins — space(4), radius(md), role('surface-raised'), " +
|
|
116
116
|
'text(sm) — and no $variables';
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A leading byte-order mark or `@charset` rule: the encoding claim Sass writes at the head of any
|
|
120
|
+
* output holding a non-ASCII character. Anchored to the START — a `@charset` anywhere else is
|
|
121
|
+
* already invalid CSS and not this function's to repair.
|
|
122
|
+
*/
|
|
123
|
+
const CHARSET_HEAD = /^\uFEFF?(?:@charset\s+"[^"]*"\s*;\s*)?/u;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Drop the encoding claim from the head of one compiled sheet. Sass emits it for the FILE it
|
|
127
|
+
* believes it is writing, and it is right about a file: a stylesheet that begins with U+FEFF is
|
|
128
|
+
* decoded as UTF-8 by every browser. It is wrong about a fragment. The registry concatenates
|
|
129
|
+
* modules verbatim, so every module after the first that holds a `content: '·'` began with a BOM
|
|
130
|
+
* glued to its first selector — `\uFEFF.dashboard_256ee8e0{display:grid}` — which the browser reads
|
|
131
|
+
* as an unparseable selector and drops with its whole rule. Measured on ai-maxxing's home
|
|
132
|
+
* stylesheet, 2026-09-06: seven modules, seven first rules gone, the dashboard's grid container
|
|
133
|
+
* painting `display: block` with only the UA rule in Chrome's matched styles. The bundle is served
|
|
134
|
+
* `text/css; charset=utf-8` by the route, so no sheet needs to claim its encoding at all.
|
|
135
|
+
*
|
|
136
|
+
* Applied at BOTH seams: on the compile, where `charset: false` already asks Sass not to write it,
|
|
137
|
+
* and again where the sheets are joined, so a future Sass that ignores the option — or a sheet
|
|
138
|
+
* that reached the registry by another road — still cannot put a BOM mid-file.
|
|
139
|
+
*/
|
|
140
|
+
export function stripCharset(css: string): string {
|
|
141
|
+
return css.replace(CHARSET_HEAD, '');
|
|
142
|
+
}
|
|
143
|
+
|
|
118
144
|
export function compileStylesheet(file: string, source: string): CompiledStylesheet {
|
|
119
145
|
let css: string;
|
|
120
146
|
try {
|
|
121
|
-
css =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
147
|
+
css = stripCharset(
|
|
148
|
+
sass.compileString(source, {
|
|
149
|
+
url: pathToFileURL(file),
|
|
150
|
+
loadPaths: [dirname(file)],
|
|
151
|
+
importers: [packageImporter(dirname(file))],
|
|
152
|
+
style: 'compressed',
|
|
153
|
+
// No `@charset`, no BOM — see `stripCharset`. Dart Sass writes one for any compressed
|
|
154
|
+
// output holding a non-ASCII character, and re-emits an escaped `\\00b7` as the literal
|
|
155
|
+
// character, so escaping in the app cannot avoid it.
|
|
156
|
+
charset: false,
|
|
157
|
+
}).css,
|
|
158
|
+
);
|
|
127
159
|
} catch (error) {
|
|
128
160
|
// `renderThrowable`, never `.message`/`String()`: an importer, a plugin or a future Sass
|
|
129
161
|
// release can throw a value whose own read raises, and this frame is what turns a failed
|
package/src/errors.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const RENDER_ERROR_CODES = [
|
|
|
14
14
|
'X_ROUTE_FILE_INVALID',
|
|
15
15
|
'X_ROUTE_LOAD_INVALID',
|
|
16
16
|
'X_ROUTE_LOAD_FAILED',
|
|
17
|
+
'X_ROUTE_STATUS_INVALID',
|
|
17
18
|
'X_SURFACE_BOUNDARY',
|
|
18
19
|
'X_BUDGET_EXCEEDED',
|
|
19
20
|
'X_PRERENDER_FAILED',
|
|
@@ -37,6 +38,7 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
|
|
|
37
38
|
X_ROUTE_FILE_INVALID: 'a route file is not named for its surface',
|
|
38
39
|
X_ROUTE_LOAD_INVALID: 'a route declared a load that is not a function',
|
|
39
40
|
X_ROUTE_LOAD_FAILED: "a route's load threw while resolving its data",
|
|
41
|
+
X_ROUTE_STATUS_INVALID: 'a route answered a status a rendered page cannot carry',
|
|
40
42
|
X_SURFACE_BOUNDARY: 'a surface imported across the hard boundary',
|
|
41
43
|
X_BUDGET_EXCEEDED: 'a route blew its JS or LCP budget',
|
|
42
44
|
X_PRERENDER_FAILED: 'a prerendered path threw during build',
|
|
@@ -254,3 +256,20 @@ export class RouteLoadFailedError extends UltimateError {
|
|
|
254
256
|
});
|
|
255
257
|
}
|
|
256
258
|
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A loader answered a status through `withStatus` that no rendered document can carry: a 3xx. A
|
|
262
|
+
* redirect is a `Location` and no body, and this seam only ever produces a body — so the answer
|
|
263
|
+
* is refused where it was written, with the redirect the author meant named as the fix. The
|
|
264
|
+
* out-of-range half (`NaN`, `199`, `600`) is `finiteStatus`'s, the same screen every mode uses.
|
|
265
|
+
*/
|
|
266
|
+
export class RouteStatusInvalidError extends UltimateError {
|
|
267
|
+
static readonly code = 'X_ROUTE_STATUS_INVALID' as const;
|
|
268
|
+
constructor(cause: string, fix: string) {
|
|
269
|
+
super({
|
|
270
|
+
code: RouteStatusInvalidError.code,
|
|
271
|
+
cause,
|
|
272
|
+
fix,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
package/src/finite-status.ts
CHANGED
|
@@ -14,6 +14,15 @@ import { assert, finiteCount } from '@ultimat3/core';
|
|
|
14
14
|
const MIN_RENDER_STATUS = 200;
|
|
15
15
|
const MAX_RENDER_STATUS = 599;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The one range, as a predicate, for the request-path reader that must stay TOTAL: an `IsrEntry`
|
|
19
|
+
* a custom store round-tripped through JSON is read on every hit, and a throw there turns one bad
|
|
20
|
+
* stored number into a 500 for the page's whole TTL. `finiteStatus` below is the throwing form
|
|
21
|
+
* and reads the same two bounds, so the two cannot disagree about what a rendered status is.
|
|
22
|
+
*/
|
|
23
|
+
export const isRenderStatus = (status: number): boolean =>
|
|
24
|
+
Number.isSafeInteger(status) && status >= MIN_RENDER_STATUS && status <= MAX_RENDER_STATUS;
|
|
25
|
+
|
|
17
26
|
/**
|
|
18
27
|
* `NaN` is the value that gets here: `??` guards NULLISH, so a status read from a config, a JSON
|
|
19
28
|
* body or `Number(process.env.X)` walks past its default intact — and the boundary then reports it
|
|
@@ -22,7 +31,7 @@ const MAX_RENDER_STATUS = 599;
|
|
|
22
31
|
export function finiteStatus(subject: string, status: number): number {
|
|
23
32
|
finiteCount(subject, 'status', status, 0);
|
|
24
33
|
assert(
|
|
25
|
-
status
|
|
34
|
+
isRenderStatus(status),
|
|
26
35
|
`${subject} status is ${String(status)}, which new Response() refuses with a RangeError instead of returning a document`,
|
|
27
36
|
`pass a status between ${String(MIN_RENDER_STATUS)} and ${String(MAX_RENDER_STATUS)} to ${subject}, or omit it and take 200`,
|
|
28
37
|
);
|
package/src/hydrate.ts
CHANGED
|
@@ -117,6 +117,10 @@ export function requiredStrategies(
|
|
|
117
117
|
// The rejection handler rethrows: swallowing it would resolve `el.__x`, and the interaction
|
|
118
118
|
// runtime below would then flush its replay queue into an island that never mounted — the bug
|
|
119
119
|
// `el.__x`-as-a-promise was introduced to fix, reintroduced one layer further out.
|
|
120
|
+
//
|
|
121
|
+
// `el.__x` resolves to whatever `mount()` returned. An island's `mount` may return `() => void`,
|
|
122
|
+
// its disposer (Solid's `render` answers one); the runtime keeps it there and
|
|
123
|
+
// `@ultimat3/testing`'s `mountIsland` calls it on dispose. Nothing here ever calls it.
|
|
120
124
|
const RUNTIME_PRELUDE = `
|
|
121
125
|
function boot(el){var e=el.getAttribute('data-x-entry');
|
|
122
126
|
if(!e)return Promise.resolve();if(el.__x)return el.__x;
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
RouteMetaMissingError,
|
|
34
34
|
RouteModeInvalidError,
|
|
35
35
|
RouteOfflineMissingError,
|
|
36
|
+
RouteStatusInvalidError,
|
|
36
37
|
SurfaceBoundaryError,
|
|
37
38
|
} from './errors';
|
|
38
39
|
export type {
|
|
@@ -129,6 +130,7 @@ export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './r
|
|
|
129
130
|
export type { RouteComponent } from './route-component';
|
|
130
131
|
export { pageComponentOf } from './route-component';
|
|
131
132
|
export { metaContextFor, routeDataFor } from './route-data';
|
|
133
|
+
export { DEFAULT_ROUTE_STATUS, isErrorStatus, routeStatusOf, withStatus } from './route-status';
|
|
132
134
|
export type {
|
|
133
135
|
BoundaryRule,
|
|
134
136
|
BoundaryViolation,
|
package/src/module-loader.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { renderThrowable } from '@ultimat3/core';
|
|
8
|
-
import { compileStylesheet, isGlobalStylesheet } from './css-modules';
|
|
8
|
+
import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules';
|
|
9
9
|
import { PrerenderFailedError } from './errors';
|
|
10
10
|
import type { Surface } from './surfaces';
|
|
11
11
|
import { surfaceOf } from './surfaces';
|
|
@@ -125,8 +125,10 @@ export function stylesFor(surface: Surface | null): string {
|
|
|
125
125
|
const carried = [...stylesheets.values()].filter(
|
|
126
126
|
(sheet) => sheet.surface === null || sheet.surface === 'shared' || sheet.surface === surface,
|
|
127
127
|
);
|
|
128
|
+
// `stripCharset` on every sheet, not only the first: a `@charset` or a BOM is legal at byte 0 of
|
|
129
|
+
// a FILE and nowhere else, and this join is what turns seven files into one.
|
|
128
130
|
return [...carried.filter((sheet) => sheet.global), ...carried.filter((sheet) => !sheet.global)]
|
|
129
|
-
.map((sheet) => sheet.css)
|
|
131
|
+
.map((sheet) => stripCharset(sheet.css))
|
|
130
132
|
.join('');
|
|
131
133
|
}
|
|
132
134
|
|
|
@@ -169,6 +171,9 @@ let installed = false;
|
|
|
169
171
|
* placement that covers `x dev`, `x build`, the production `server.ts` and `bun test` without each
|
|
170
172
|
* of them remembering to. A plugin only affects modules loaded AFTER it, and every route module is.
|
|
171
173
|
*/
|
|
174
|
+
/** `/a/page.tsx?x-reload=3` → `/a/page.tsx`: the file on disk, which is what the hook reads. */
|
|
175
|
+
const withoutQuery = (path: string): string => path.replace(/\?[^/]*$/, '');
|
|
176
|
+
|
|
172
177
|
export function installRenderLoader(): void {
|
|
173
178
|
if (installed) return;
|
|
174
179
|
installed = true;
|
|
@@ -176,8 +181,13 @@ export function installRenderLoader(): void {
|
|
|
176
181
|
Bun.plugin({
|
|
177
182
|
name: 'ultimate-render',
|
|
178
183
|
setup(build): void {
|
|
179
|
-
|
|
180
|
-
|
|
184
|
+
// The query is admitted and then stripped. `x dev` re-imports an edited route module as
|
|
185
|
+
// `<path>?x-reload=<hash>` — the only cache key Bun honours — and Bun hands this hook the
|
|
186
|
+
// specifier QUERY INCLUDED. Anchored on `.tsx$` the filter let that import fall through to
|
|
187
|
+
// Bun's own loader, which compiles JSX to `React.createElement`: every reloaded page then
|
|
188
|
+
// died on its first render with `__xh is not defined`.
|
|
189
|
+
build.onLoad({ filter: /\.tsx(?:\?[^/]*)?$/ }, async ({ path }) => ({
|
|
190
|
+
contents: transformTsx(await Bun.file(withoutQuery(path)).text()),
|
|
181
191
|
loader: 'js',
|
|
182
192
|
}));
|
|
183
193
|
|
package/src/render-isr.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '@ultimat3/cache';
|
|
18
18
|
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
19
19
|
import { parseTtlMs } from './duration';
|
|
20
|
+
import { finiteStatus, isRenderStatus } from './finite-status';
|
|
20
21
|
import type { RouteDescriptor } from './registry';
|
|
21
22
|
import { describeRoutes } from './registry';
|
|
22
23
|
import { contentHash, staticHeaders } from './render-static';
|
|
@@ -37,6 +38,12 @@ export interface IsrEntry {
|
|
|
37
38
|
readonly ttlMs: number | null;
|
|
38
39
|
/** Set by a tag invalidation; independent of the TTL clock. */
|
|
39
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;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export interface IsrStore {
|
|
@@ -150,7 +157,22 @@ function routePathOf(key: string): string {
|
|
|
150
157
|
return query === -1 ? key : key.slice(0, query);
|
|
151
158
|
}
|
|
152
159
|
|
|
153
|
-
|
|
160
|
+
/**
|
|
161
|
+
* A render that also answers a status — what a loader's `withStatus(404, …)` becomes once the
|
|
162
|
+
* document is built. A bare string is the 200 every render before this one was: the union is
|
|
163
|
+
* additive, and a render function that never learned the object shape keeps compiling.
|
|
164
|
+
*/
|
|
165
|
+
export interface IsrRendered {
|
|
166
|
+
readonly html: string;
|
|
167
|
+
readonly status: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type IsrRenderFn = (path: string) => string | IsrRendered | Promise<string | IsrRendered>;
|
|
171
|
+
|
|
172
|
+
/** One shape for the generator, so nothing below branches on what the render handed back. */
|
|
173
|
+
function renderedOf(rendered: string | IsrRendered): IsrRendered {
|
|
174
|
+
return typeof rendered === 'string' ? { html: rendered, status: 200 } : rendered;
|
|
175
|
+
}
|
|
154
176
|
|
|
155
177
|
export interface IsrServeResult {
|
|
156
178
|
readonly state: IsrState;
|
|
@@ -266,7 +288,7 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
266
288
|
key: path,
|
|
267
289
|
tags: (descriptor?.revalidateTags ?? []).map(parseWireTag),
|
|
268
290
|
});
|
|
269
|
-
const html = await render(path);
|
|
291
|
+
const { html, status } = renderedOf(await render(path));
|
|
270
292
|
const entry: IsrEntry = {
|
|
271
293
|
path,
|
|
272
294
|
html,
|
|
@@ -274,6 +296,9 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
274
296
|
generatedAt: now(),
|
|
275
297
|
ttlMs: parseTtlMs(descriptor?.revalidateTtl),
|
|
276
298
|
stale: false,
|
|
299
|
+
// Screened at generation, the one place a status enters the store: a `NaN` written here
|
|
300
|
+
// would be served for the whole TTL as a `RangeError` on every hit.
|
|
301
|
+
status: finiteStatus('IsrRenderFn', status),
|
|
277
302
|
};
|
|
278
303
|
// Refused, never published stale-flagged: the next request re-renders from rows that now
|
|
279
304
|
// include the write, where a stored-but-stale entry would serve this pre-write body once
|
|
@@ -432,6 +457,20 @@ function cacheControl(ttlMs: number | null): string {
|
|
|
432
457
|
return `public, max-age=0, s-maxage=${sMaxAge}, stale-while-revalidate=86400`;
|
|
433
458
|
}
|
|
434
459
|
|
|
460
|
+
/**
|
|
461
|
+
* `entryTtlMs`'s reason, one field over: a store may hand back an entry with no `status`, or one
|
|
462
|
+
* that JSON turned into something else, on the request path. Absent is 200 — the only value any
|
|
463
|
+
* entry carried before the field existed — and anything the range refuses is 200 with a warning,
|
|
464
|
+
* because a stored number must not 500 the page for its whole TTL.
|
|
465
|
+
*/
|
|
466
|
+
function entryStatus(entry: IsrEntry): number {
|
|
467
|
+
const status = entry.status;
|
|
468
|
+
if (status === undefined) return 200;
|
|
469
|
+
if (isRenderStatus(status)) return status;
|
|
470
|
+
logger.warn('isr.entry_status_invalid', { path: entry.path, status: String(status) });
|
|
471
|
+
return 200;
|
|
472
|
+
}
|
|
473
|
+
|
|
435
474
|
function toResult(entry: IsrEntry, buildId: string, servedStale = false): RenderResult {
|
|
436
475
|
const headers: Record<string, string> = {
|
|
437
476
|
...staticHeaders(entry.hash, buildId),
|
|
@@ -443,5 +482,5 @@ function toResult(entry: IsrEntry, buildId: string, servedStale = false): Render
|
|
|
443
482
|
vary: 'accept-language',
|
|
444
483
|
};
|
|
445
484
|
if (servedStale) headers['x-ultimate-isr'] = 'stale';
|
|
446
|
-
return { status:
|
|
485
|
+
return { status: entryStatus(entry), headers, body: entry.html };
|
|
447
486
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The one way a loader answers a response STATUS while still rendering the route's own page.
|
|
2
|
+
//
|
|
3
|
+
// Measured in ai-maxxing, 2026-09-07: `/fleet/nope` — a host id the fleet does not have — rendered
|
|
4
|
+
// the app's own "Not found" page inside its shell, the right page, and answered **200**. The only
|
|
5
|
+
// route to a 404 was throwing, and a throw renders the framework's error page OUTSIDE the shell.
|
|
6
|
+
// So an app that did the right thing for its visitor could not do the right thing for a crawler,
|
|
7
|
+
// a CDN or a monitor, and one that did the right thing for those lost its shell.
|
|
8
|
+
//
|
|
9
|
+
// The status rides ON the data, by identity: `withStatus(404, data)` hands the same object back,
|
|
10
|
+
// so `load`'s return type is untouched, `routeDataFor` still hands ONE object to `meta` and the
|
|
11
|
+
// page, and every consumer that never asks reads 200. A `WeakMap` and not a symbol property — the
|
|
12
|
+
// data may be frozen, may be a class instance, and is the author's; nothing here writes into it.
|
|
13
|
+
// A `RouteContext` method (`ctx.notFound()`) was the alternative and was refused: every builder of
|
|
14
|
+
// a context — `x dev`, the prerenderer, the SEO scan, both scaffold templates — would have had to
|
|
15
|
+
// learn to supply it, and an optional method is a second way.
|
|
16
|
+
|
|
17
|
+
import { RouteStatusInvalidError } from './errors';
|
|
18
|
+
import { finiteStatus } from './finite-status';
|
|
19
|
+
|
|
20
|
+
/** What every render answers when the loader said nothing, and what a no-`load` route answers. */
|
|
21
|
+
export const DEFAULT_ROUTE_STATUS = 200;
|
|
22
|
+
|
|
23
|
+
const STATUSES = new WeakMap<object, number>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What `withStatus` can mark, read back off `unknown`: exactly TypeScript's `object` — a non-null
|
|
27
|
+
* object OR a function. Both are `WeakMap` keys and both satisfy `withStatus`'s constraint, so a
|
|
28
|
+
* loader answering `withStatus(404, () => …)` — data that is a function, which `load`'s type
|
|
29
|
+
* allows — must read back as 404 and not as the default. The two sides of the seam share this
|
|
30
|
+
* one predicate so they cannot disagree about what carries a status.
|
|
31
|
+
*/
|
|
32
|
+
const canCarryStatus = (data: unknown): data is object =>
|
|
33
|
+
(typeof data === 'object' && data !== null) || typeof data === 'function';
|
|
34
|
+
|
|
35
|
+
const isRedirect = (status: number): boolean => status >= 300 && status < 400;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Answer `status` for this render, and render the page with `data` all the same.
|
|
39
|
+
*
|
|
40
|
+
* Any 2xx, 4xx or 5xx. A 3xx is refused by name: a redirect is a `Location` and no body, which is
|
|
41
|
+
* `@ultimat3/http`'s `redirect()` and never a page. Out of range is `finiteStatus`'s refusal, the
|
|
42
|
+
* same screen `renderSsr` and `streamResult` apply — `NaN` reaching `new Response` is a bare
|
|
43
|
+
* `RangeError` two frames above the loader that set it.
|
|
44
|
+
*
|
|
45
|
+
* A 4xx or 5xx is `robots: noindex` BY CONSTRUCTION — `defineRoute`'s `meta` wrapper applies it —
|
|
46
|
+
* so a page that does not exist is never indexed however its `meta` was written.
|
|
47
|
+
*/
|
|
48
|
+
export function withStatus<TData extends object>(status: number, data: TData): TData {
|
|
49
|
+
const screened = finiteStatus('withStatus', status);
|
|
50
|
+
if (isRedirect(screened)) {
|
|
51
|
+
throw new RouteStatusInvalidError(
|
|
52
|
+
`withStatus(${String(screened)}, …) asks a page to be a redirect, and a rendered document has no Location to send`,
|
|
53
|
+
"answer 2xx, 4xx or 5xx from load; a redirect is `redirect(location)` from '@ultimat3/http', thrown or returned by the handler",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
STATUSES.set(data, screened);
|
|
57
|
+
return data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The status a render of `data` answers: what `withStatus` recorded, else 200. TOTAL, and asked
|
|
62
|
+
* of `unknown` on purpose — the callers are the render modes, and the data is whatever the app's
|
|
63
|
+
* loader returned, an object or not.
|
|
64
|
+
*/
|
|
65
|
+
export function routeStatusOf(data: unknown): number {
|
|
66
|
+
if (!canCarryStatus(data)) return DEFAULT_ROUTE_STATUS;
|
|
67
|
+
const status = STATUSES.get(data);
|
|
68
|
+
return status === undefined ? DEFAULT_ROUTE_STATUS : status;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A 4xx or 5xx: a document a crawler must forget, whatever its `meta` said. */
|
|
72
|
+
export const isErrorStatus = (status: number): boolean => status >= 400;
|
package/src/route.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { RouteLoadInvalidError, RouteMetaMissingError, RouteOfflineMissingError
|
|
|
22
22
|
import type { IslandSpec } from './island';
|
|
23
23
|
import { drainDeclaredIslands } from './island';
|
|
24
24
|
import { assertModeShape } from './modes';
|
|
25
|
+
import { isErrorStatus, routeStatusOf } from './route-status';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* What a page that declares an island hydrates as when it says nothing. The most conservative of
|
|
@@ -274,7 +275,12 @@ export function defineRoute<TData = RouteData>(
|
|
|
274
275
|
// Wrapped rather than stored: the declaration may be sync, the descriptor never is.
|
|
275
276
|
// A meta that throws synchronously becomes a rejection here, so `await config.meta(d)`
|
|
276
277
|
// is the one way to fail as well as the one way to succeed.
|
|
277
|
-
|
|
278
|
+
// And the one place a 4xx/5xx becomes `noindex`: every consumer that renders a `<head>` —
|
|
279
|
+
// `x dev`, the prerenderer, the SEO scan — calls THIS function, so a page whose loader said
|
|
280
|
+
// 404 is never indexed however its `meta` was written. A 200 hands the author's object back
|
|
281
|
+
// untouched, so an app that never sets a status is byte-identical.
|
|
282
|
+
meta: async (metaCtx: RouteMetaContext<TData>) =>
|
|
283
|
+
noindexOnError(await declaredMeta(metaCtx), routeStatusOf(metaCtx.data)),
|
|
278
284
|
// Always an object. `budget.js` is the only reach a consumer needs, so an undeclared
|
|
279
285
|
// budget is `{}` instead of a second undefined-check at every call site.
|
|
280
286
|
budget: def.budget ?? {},
|
|
@@ -290,6 +296,12 @@ export function defineRoute<TData = RouteData>(
|
|
|
290
296
|
return Object.freeze(config);
|
|
291
297
|
}
|
|
292
298
|
|
|
299
|
+
/** Only `index` is decided here; `follow` and the rest stay the author's. */
|
|
300
|
+
function noindexOnError(meta: RouteMeta, status: number): RouteMeta {
|
|
301
|
+
if (!isErrorStatus(status)) return meta;
|
|
302
|
+
return { ...meta, robots: { ...meta.robots, index: false } };
|
|
303
|
+
}
|
|
304
|
+
|
|
293
305
|
export function isRouteConfig(value: unknown): value is RouteConfig {
|
|
294
306
|
return typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'route';
|
|
295
307
|
}
|
package/src/server.ts
CHANGED
|
@@ -16,7 +16,13 @@ installRenderLoader();
|
|
|
16
16
|
|
|
17
17
|
// ---- scss → css, and the scoped class map every `import styles from` receives -------------------
|
|
18
18
|
export type { CompiledStylesheet } from './css-modules';
|
|
19
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
compileStylesheet,
|
|
21
|
+
isCssModule,
|
|
22
|
+
isGlobalStylesheet,
|
|
23
|
+
scopeClasses,
|
|
24
|
+
stripCharset,
|
|
25
|
+
} from './css-modules';
|
|
20
26
|
// ---- the two Bun loaders: `.tsx` → the server JSX factory, `.scss` → css + a class map ----------
|
|
21
27
|
export type { Stylesheet } from './module-loader';
|
|
22
28
|
export {
|
|
@@ -35,6 +41,7 @@ export type {
|
|
|
35
41
|
IsrController,
|
|
36
42
|
IsrControllerOptions,
|
|
37
43
|
IsrEntry,
|
|
44
|
+
IsrRendered,
|
|
38
45
|
IsrRenderFn,
|
|
39
46
|
IsrServeResult,
|
|
40
47
|
IsrState,
|