@ultimat3/render 15.0.0 → 17.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 +3 -1
- package/package.json +5 -5
- package/src/finite-status.ts +30 -0
- package/src/head.ts +4 -1
- package/src/registry.ts +9 -1
- package/src/render-isr.ts +30 -5
- package/src/render-ssr.ts +4 -1
- package/src/render-stream.ts +19 -4
package/CLAUDE.md
CHANGED
|
@@ -79,10 +79,12 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
79
79
|
| 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. |
|
|
80
80
|
| 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`. |
|
|
81
81
|
| 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. |
|
|
82
|
+
| A `RenderResult.status` | `finiteStatus(subject, status)` in `finite-status.ts`, at both sites that take one from a caller (`renderSsr`'s `options.status`, `streamResult`'s third argument) — 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. |
|
|
83
|
+
| `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. |
|
|
82
84
|
| 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. |
|
|
83
85
|
| 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`. |
|
|
84
86
|
| ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). The store is bounded and evicts silently; `registered` and the cache graph behind it only ever grew, one edge per slug ever requested. Never a store callback — a custom `IsrStore` has none. |
|
|
85
|
-
| Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15s), `holeTimeoutMs: null` to opt out. A hole is app code the framework `await`s and nothing else bounds it; one that never settles held the response and its whole closure open for the life of the process. A hole reveals exactly once — its promise, its rejection, or its deadline, whichever lands first. |
|
|
87
|
+
| Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15s), `holeTimeoutMs: null` to opt out, and a declared one is a whole number of at least **1** — a deadline is the bound a non-finite value does not disable but MOVES: `setTimeout(fn, NaN)` is `setTimeout(fn, 0)`, so every hole misses a deadline nobody set and the document is all fallbacks. There is no spelling of `holeTimeoutMs` that means "immediately". A hole is app code the framework `await`s and nothing else bounds it; one that never settles held the response and its whole closure open for the life of the process. A hole reveals exactly once — its promise, its rejection, or its deadline, whichever lands first. |
|
|
86
88
|
| Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
|
|
87
89
|
| Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
|
|
88
90
|
| A gated route is never a cached one | `modes.ts` refuses `policy` on BOTH `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `isr` was not refused until 2026-08 and `dev-render.ts` keys the cache on `url.pathname` alone — no actor, no query string — so a gated ISR route rendered the first actor's document and served it to every later actor who passed the same policy. Keying on more is a trap, not a fix: the key would have to enumerate everything a `policy` and a `load` can read. `ssr` is the one gated mode there is. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.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": "17.0.0",
|
|
40
|
+
"@ultimat3/core": "17.0.0",
|
|
41
|
+
"@ultimat3/i18n": "17.0.0",
|
|
42
|
+
"@ultimat3/seo": "17.0.0",
|
|
43
43
|
"sass": "1.102.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Single responsibility: the one screen a `RenderResult.status` passes before it reaches
|
|
2
|
+
// `new Response(body, { status })`, which answers a bare `RangeError` — no code, no fix, no
|
|
3
|
+
// `--json` — for anything outside the range it accepts. Its own module because two render modes
|
|
4
|
+
// take a status from their caller and a second copy of the rule is how the two drift apart.
|
|
5
|
+
// Named for `finiteOption`, and the name is load-bearing: `bun run finite-bounds` recognises a
|
|
6
|
+
// repair by the shape of the CALL, so a screen spelled `renderStatus` reads as no screen at all.
|
|
7
|
+
|
|
8
|
+
import { assert, finiteCount } from '@ultimat3/core';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What `new Response` accepts for a document. It also accepts `101`, which is a protocol switch
|
|
12
|
+
* and never a rendered page, so this range is the narrower of the two on purpose.
|
|
13
|
+
*/
|
|
14
|
+
const MIN_RENDER_STATUS = 200;
|
|
15
|
+
const MAX_RENDER_STATUS = 599;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `NaN` is the value that gets here: `??` guards NULLISH, so a status read from a config, a JSON
|
|
19
|
+
* body or `Number(process.env.X)` walks past its default intact — and the boundary then reports it
|
|
20
|
+
* as `The status provided (-9223372036854775808)`, which names nothing a caller can act on.
|
|
21
|
+
*/
|
|
22
|
+
export function finiteStatus(subject: string, status: number): number {
|
|
23
|
+
finiteCount(subject, 'status', status, 0);
|
|
24
|
+
assert(
|
|
25
|
+
status >= MIN_RENDER_STATUS && status <= MAX_RENDER_STATUS,
|
|
26
|
+
`${subject} status is ${String(status)}, which new Response() refuses with a RangeError instead of returning a document`,
|
|
27
|
+
`pass a status between ${String(MIN_RENDER_STATUS)} and ${String(MAX_RENDER_STATUS)} to ${subject}, or omit it and take 200`,
|
|
28
|
+
);
|
|
29
|
+
return status;
|
|
30
|
+
}
|
package/src/head.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* catalog and so the tag vocabulary keeps exactly one owner (`@ultimat3/seo`).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { finiteCount } from '@ultimat3/core';
|
|
10
11
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
11
12
|
import { BudgetExceededError } from './errors';
|
|
12
13
|
// `html.ts` is this package's one escaper — a second one is how a character ends up missing.
|
|
@@ -188,7 +189,9 @@ export function themeScript(options: ThemeScriptOptions = {}): HeadTag {
|
|
|
188
189
|
`document.documentElement.setAttribute(${JSON.stringify(attribute)},t)}catch(e){}`;
|
|
189
190
|
|
|
190
191
|
const bytes = new TextEncoder().encode(source).byteLength;
|
|
191
|
-
|
|
192
|
+
// `bytes > NaN` is false for every script, so a cap that arrived non-finite does not admit a
|
|
193
|
+
// bigger script — it removes the only budget a 0kb `site/` route has.
|
|
194
|
+
const cap = finiteCount('themeScript', 'maxBytes', options.maxBytes ?? THEME_SCRIPT_MAX_BYTES);
|
|
192
195
|
if (bytes > cap) {
|
|
193
196
|
throw new BudgetExceededError(
|
|
194
197
|
`the inlined theme script is ${bytes}b, over its ${cap}b cap — the only script a ` +
|
package/src/registry.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
9
|
+
import { finiteCount } from '@ultimat3/core';
|
|
9
10
|
import {
|
|
10
11
|
RouteDuplicateError,
|
|
11
12
|
RouteFileInvalidError,
|
|
@@ -242,7 +243,14 @@ export function registerRoute<TData = RouteData>(
|
|
|
242
243
|
|
|
243
244
|
const derived = routePathFromFile(input.file);
|
|
244
245
|
const path = input.path ?? derived.path;
|
|
245
|
-
|
|
246
|
+
// `ctx.suspenseBoundaries < 1` is the only thing between `render: 'stream'` and a route that
|
|
247
|
+
// streams nothing, and `NaN < 1` is false — a count that arrived non-finite does not report the
|
|
248
|
+
// route it counted, it stops reporting any route.
|
|
249
|
+
const suspenseBoundaries = finiteCount(
|
|
250
|
+
'registerRoute',
|
|
251
|
+
'suspenseBoundaries',
|
|
252
|
+
input.suspenseBoundaries ?? 0,
|
|
253
|
+
);
|
|
246
254
|
// Explicit `<TData>`: `isRouteConfig` is a guard over the default `RouteData`, so inference off
|
|
247
255
|
// the narrowed argument would resolve the route's own data generic away here.
|
|
248
256
|
const config = withIslandBudget<TData>(input.config, derived.surface);
|
package/src/render-isr.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
sampleFence,
|
|
16
16
|
unregisterDependent,
|
|
17
17
|
} from '@ultimat3/cache';
|
|
18
|
-
import { logger, renderThrowable } from '@ultimat3/core';
|
|
18
|
+
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
19
19
|
import { parseTtlMs } from './duration';
|
|
20
20
|
import type { RouteDescriptor } from './registry';
|
|
21
21
|
import { describeRoutes } from './registry';
|
|
@@ -67,7 +67,13 @@ export interface MemoryIsrStoreOptions {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
70
|
-
|
|
70
|
+
// `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
|
|
71
|
+
// cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
|
|
72
|
+
const maxEntries = finiteCount(
|
|
73
|
+
'memoryIsrStore',
|
|
74
|
+
'maxEntries',
|
|
75
|
+
options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
|
|
76
|
+
);
|
|
71
77
|
const map = new Map<string, IsrEntry>();
|
|
72
78
|
return {
|
|
73
79
|
get: (path) => map.get(path),
|
|
@@ -236,8 +242,9 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
236
242
|
|
|
237
243
|
function isFresh(entry: IsrEntry): boolean {
|
|
238
244
|
if (entry.stale) return false;
|
|
239
|
-
|
|
240
|
-
|
|
245
|
+
const ttlMs = entryTtlMs(entry);
|
|
246
|
+
if (ttlMs === null) return true; // tag-only revalidation: fresh until invalidated
|
|
247
|
+
return now() - entry.generatedAt < ttlMs;
|
|
241
248
|
}
|
|
242
249
|
|
|
243
250
|
function regenerate(path: string, render: IsrRenderFn): Promise<IsrEntry> {
|
|
@@ -397,6 +404,23 @@ function segmentPattern(segment: string): string {
|
|
|
397
404
|
/** Tag-only routes have no clock of their own; a tag bust reaches the CDN through the fanout. */
|
|
398
405
|
const TAG_ONLY_S_MAX_AGE_SECONDS = 60;
|
|
399
406
|
|
|
407
|
+
/**
|
|
408
|
+
* `IsrStore` is a driver seam, so an entry can come back from an app's own store — one backed by
|
|
409
|
+
* Redis round-trips it through JSON, where a `ttlMs` that was never written reads back as
|
|
410
|
+
* `undefined` and `entry.ttlMs === null` is then false. Two failures follow from that one value
|
|
411
|
+
* and neither raises: `now - generatedAt < NaN` is false, so the page is NEVER fresh and every
|
|
412
|
+
* request regenerates it, and the CDN is handed `s-maxage=NaN` — an unparseable directive a
|
|
413
|
+
* conforming cache IGNORES, dropping the page to heuristic caching rather than to the declared
|
|
414
|
+
* age. Read on the request path, so it is TOTAL rather than a throw: a ttl that is not a positive
|
|
415
|
+
* finite number of milliseconds is the tag-only `null` `parseTtlMs` would have answered for it.
|
|
416
|
+
*/
|
|
417
|
+
function entryTtlMs(entry: IsrEntry): number | null {
|
|
418
|
+
const ttlMs = entry.ttlMs;
|
|
419
|
+
if (ttlMs === null || (Number.isFinite(ttlMs) && ttlMs > 0)) return ttlMs;
|
|
420
|
+
logger.warn('isr.entry_ttl_invalid', { path: entry.path, ttlMs: String(ttlMs) });
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
400
424
|
/**
|
|
401
425
|
* The declared TTL is the route's own contract with the CDN: a shared cache must not hold the
|
|
402
426
|
* page longer than the app said it stays true. A flat `s-maxage=60` made `revalidate: { ttl:
|
|
@@ -404,13 +428,14 @@ const TAG_ONLY_S_MAX_AGE_SECONDS = 60;
|
|
|
404
428
|
*/
|
|
405
429
|
function cacheControl(ttlMs: number | null): string {
|
|
406
430
|
const sMaxAge = ttlMs === null ? TAG_ONLY_S_MAX_AGE_SECONDS : Math.round(ttlMs / 1_000);
|
|
431
|
+
|
|
407
432
|
return `public, max-age=0, s-maxage=${sMaxAge}, stale-while-revalidate=86400`;
|
|
408
433
|
}
|
|
409
434
|
|
|
410
435
|
function toResult(entry: IsrEntry, buildId: string, servedStale = false): RenderResult {
|
|
411
436
|
const headers: Record<string, string> = {
|
|
412
437
|
...staticHeaders(entry.hash, buildId),
|
|
413
|
-
'cache-control': cacheControl(entry
|
|
438
|
+
'cache-control': cacheControl(entryTtlMs(entry)),
|
|
414
439
|
// The store keys on the locale; a shared cache in front of it has to as well, or the CDN
|
|
415
440
|
// repeats the bug this entry was split to fix. `ssrHeaders`' own line, for the same reason.
|
|
416
441
|
// The rest of the shared key — the cookie, the zone — is added by `@ultimat3/http`'s
|
package/src/render-ssr.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { finiteStatus } from './finite-status';
|
|
8
9
|
import type { RouteEntry } from './registry';
|
|
9
10
|
import type { RenderResult, RouteParams } from './route';
|
|
10
11
|
|
|
@@ -31,7 +32,9 @@ export async function renderSsr(
|
|
|
31
32
|
): Promise<RenderResult> {
|
|
32
33
|
const html = await render(input);
|
|
33
34
|
return {
|
|
34
|
-
|
|
35
|
+
// Screened here and not at the Response boundary: `??` guards nullish, so a non-finite status
|
|
36
|
+
// reaches `new Response` intact and raises a RangeError two frames above the route that set it.
|
|
37
|
+
status: finiteStatus('renderSsr', options.status ?? 200),
|
|
35
38
|
headers: ssrHeaders(input.entry, options),
|
|
36
39
|
body: html,
|
|
37
40
|
};
|
package/src/render-stream.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* contains no interactive island costs literally zero JS.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { logger, renderThrowable } from '@ultimat3/core';
|
|
14
|
+
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
15
|
+
import { finiteStatus } from './finite-status';
|
|
15
16
|
import { escapeAttribute, escapeRawTextContent } from './html';
|
|
16
17
|
import type { RenderResult } from './route';
|
|
17
18
|
|
|
@@ -97,6 +98,18 @@ export interface StreamOptions {
|
|
|
97
98
|
readonly holeTimeoutMs?: number | null;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* `null` is the declared "this hole owns its own timeout" opt-out; every other value is a
|
|
103
|
+
* deadline, and a deadline of 0 is not one — `setTimeout(fn, 0)` fires on the next tick and the
|
|
104
|
+
* document becomes all fallbacks.
|
|
105
|
+
*/
|
|
106
|
+
const declaredTimeoutMs = (declared: number | null | undefined): number | null =>
|
|
107
|
+
declared === undefined
|
|
108
|
+
? DEFAULT_HOLE_TIMEOUT_MS
|
|
109
|
+
: declared === null
|
|
110
|
+
? null
|
|
111
|
+
: finiteCount('renderStreamHtml', 'holeTimeoutMs', declared, 1);
|
|
112
|
+
|
|
100
113
|
/**
|
|
101
114
|
* Flush order is completion order, not declaration order — a fast hole never waits behind
|
|
102
115
|
* a slow one. The stream closes only after every hole has settled, so a rejected boundary
|
|
@@ -110,8 +123,10 @@ export function renderStreamHtml(
|
|
|
110
123
|
const tail = plan.tail ?? '</body></html>';
|
|
111
124
|
const errorFallback =
|
|
112
125
|
options.errorFallback ?? ((id) => `<div data-x-hole-error="${id}" hidden></div>`);
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
// A deadline is the bound a non-finite value does not disable but MOVES: `setTimeout(fn, NaN)`
|
|
127
|
+
// is `setTimeout(fn, 0)`, so every hole would miss a deadline nobody set and the document would
|
|
128
|
+
// be all fallbacks. `null` is the declared opt-out; there is no spelling that means "immediately".
|
|
129
|
+
const timeoutMs = declaredTimeoutMs(options.holeTimeoutMs);
|
|
115
130
|
/**
|
|
116
131
|
* The response's own lifetime. A client that disconnects mid-stream cancels the stream, and
|
|
117
132
|
* both halves of that have to be honoured: nothing more may be enqueued — `settle`'s
|
|
@@ -202,7 +217,7 @@ export function renderStreamHtml(
|
|
|
202
217
|
|
|
203
218
|
export function streamResult(plan: StreamPlan, options: StreamOptions, status = 200): RenderResult {
|
|
204
219
|
return {
|
|
205
|
-
status,
|
|
220
|
+
status: finiteStatus('streamResult', status),
|
|
206
221
|
headers: {
|
|
207
222
|
'content-type': 'text/html; charset=utf-8',
|
|
208
223
|
'cache-control': 'private, no-store',
|