@ultimat3/render 2.0.0 → 4.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 +9 -2
- package/README.md +1 -1
- package/package.json +5 -5
- package/src/css-modules.ts +5 -1
- package/src/duration.ts +27 -0
- package/src/head.ts +19 -5
- package/src/html.ts +35 -6
- package/src/hydrate.ts +13 -5
- package/src/index.ts +2 -1
- package/src/island-props.ts +14 -2
- package/src/modes.ts +24 -3
- package/src/module-loader.ts +5 -1
- package/src/registry.ts +5 -1
- package/src/render-html.ts +5 -4
- package/src/render-isr.ts +42 -24
- package/src/render-spa.ts +10 -5
- package/src/render-static.ts +6 -8
- package/src/render-stream.ts +24 -7
- package/src/route-data.ts +24 -4
- package/src/router-client.ts +13 -2
package/CLAUDE.md
CHANGED
|
@@ -33,6 +33,9 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
33
33
|
| Derived budget | `registry.ts`, not `defineRoute`: a ceiling is only meaningful against a surface baseline, and the surface is a fact of the file path the route table already reads. `site/` → `4kb`, `app/` → `18kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`). A declared `budget.js` wins; a `'never'` route gets none, so the contradiction stays visible. |
|
|
34
34
|
| `RouteEntry.islands` | filled from `config.islands` at registration, and from nothing else — `RegisterRouteInput` has no `islands` key. It was `input.islands ?? []`, undocumented and passed by nothing, so `routeJsBytes`'s "what registration declared" half read `[]` on every route in the framework's history; keeping it as a fallback would be a second answer to one question that can only ever weaken it, since a caller passing `[]` un-weighs a declared island. |
|
|
35
35
|
| Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` — `island-props.ts` is the one gate. A structural walk, never a `JSON.stringify` round trip: stringify drops a function and an `undefined` silently, which is the footgun rather than the check. |
|
|
36
|
+
| A prop lands via `Object.defineProperty` | never `out[key] = v`. For exactly one name — `__proto__`, which `JSON.parse` mints as a real OWN key off any request body — the assignment runs `Object.prototype`'s setter: the prop was DROPPED from the browser payload (the footgun the walk exists to prevent), the record handed back as `IslandProps` carried a prototype built from request data, so a later `bag.row.isAdmin` on the SERVER read attacker-chosen values, and `ISLAND_PROPS_MAX_BYTES` under-counted because `JSON.stringify` could not see it. Same shape `@ultimat3/mcp`'s `validate-args.ts` uses for the same class. |
|
|
37
|
+
| An attribute alias is a `Map` | never a record — an object lookup walks the prototype chain, so `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and `attribute.toLowerCase()` threw a bare `TypeError`: no code, no fix, the whole page 500s off a `load()` result. Same reason `MODE_SPECS[config.render]` in `modes.ts` is guarded by `Object.hasOwn`, where `render: 'constructor'` returned a frozen descriptor for a mode nothing implements. |
|
|
38
|
+
| Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four (`href`, `src`, `action`, `formaction`) plus `data`, `poster`, `ping` and `xlink:href`, each of which a browser FOLLOWS. `srcdoc` is refused outright: its value is entity-decoded and THEN parsed as HTML, so `escapeAttribute`'s `<script>` becomes a live `<script>` on this origin — escaping cannot make markup inert, so the attribute is never emitted, the same way `innerHTML` stays the one explicit escape hatch. |
|
|
36
39
|
| Island collection | per render, passed as `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context — two concurrent requests would bill one page for the other's JS, and `assertNoPerRequestState` refuses a live context under `static` anyway. |
|
|
37
40
|
| Island bytes | `routeJsBytes` unions `entry.islands` with the rendered directives' `moduleId`s. Reading either alone is a budget that counts the runtime and not the chunk. |
|
|
38
41
|
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`, so a document assembler has exactly one thing left to remember: `hydrateRuntime(directives)`. |
|
|
@@ -44,18 +47,22 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
44
47
|
| Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
|
|
45
48
|
| Stream cancellation | the underlying source has a `cancel()`, and `write` is guarded on it. A client that disconnects mid-stream aborts `StreamHole.resolve(signal)` and every later `write`/`close` is a no-op — `settle()` on a cancelled controller threw out of a `void`ed promise, one unhandled rejection per response, while the resolved holes kept doing their database work with nowhere to write. |
|
|
46
49
|
| ISR detach | `attach()`'s returned function clears the revalidator as well as the dependents — and only if the slot is still its own, tracked in `installedRevalidator` because `@ultimat3/cache` holds ONE and offers no read back. Left installed, a detached controller and its whole store stayed reachable and kept receiving revalidations while the live one's pages never went stale. |
|
|
50
|
+
| "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below both `modes.ts` and `render-isr.ts` (importing the latter from the former is a cycle through `registry.ts`). `hasRevalidateTrigger` accepted any non-empty string, so `revalidate: { ttl: '5 minutes' }` passed registration and parsed to `null` at serve time: generated ONCE, served for the life of the process, while the CDN was told `s-maxage=60` — the exact costume the `isr`-needs-a-trigger check refuses. |
|
|
51
|
+
| A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts` — never `error instanceof Error ? error.message : String(error)`. A component that throws `Object.create(null)` escaped as a bare `TypeError` and one whose `message` getter throws as a bare `Error`, where `X_PRERENDER_FAILED` naming the file belongs. `bun run error-render` does NOT see this shape: it follows a direct interpolation, not a value laundered through a file-local `describe()` helper. |
|
|
52
|
+
| `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `new URL(ctx.url)` inside the catch made a relative `ctx.url` — what a prerender pass, `x build` and every test harness hand in — throw a bare `TypeError` on the one path whose job is a coded error. `pathnameOf` never throws. |
|
|
47
53
|
| ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first — a route table supports `:params` and `*`, so `/blog/:slug` retains one full HTML string per slug ever requested, 404-shaped ones included. |
|
|
48
54
|
| Route path from file | ONE reader of the surface segment: `locateSurface()` answers which surface AND where it starts. `registry.ts` sliced at `indexOf('app/')` instead, which matched inside `myapp/`, so every route under `apps/myapp/app/` served at `/app/…`. Never re-derive the offset from the surface NAME. |
|
|
49
|
-
| An undecodable path segment | not a match, never a throw. `decodeURIComponent('%zz')` is a bare `URIError` — a 500 for a typo — and `@ultimat3/http`'s router already answers "this branch does not match" for the same input. A literal route matching the same text still wins. |
|
|
55
|
+
| An undecodable path segment | not a match, never a throw, on BOTH sides — `decodeSegment` in `registry.ts` is the one reader, imported by `router-client.ts`. The client half interpolated `decodeURIComponent` raw until 2026-08, and `resolve()` runs from the signal initialiser, from `navigate`, from a `<Link>` prefetch's `mouseenter` and from the popstate handler: `/blog/%zz` failed the SPA at BOOT where the server answers 404. `decodeURIComponent('%zz')` is a bare `URIError` — a 500 for a typo — and `@ultimat3/http`'s router already answers "this branch does not match" for the same input. A literal route matching the same text still wins. |
|
|
50
56
|
| ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). The store is bounded and evicts silently; `registered` and the cache graph behind it only ever grew, one edge per slug ever requested. Never a store callback — a custom `IsrStore` has none. |
|
|
51
57
|
| Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15s), `holeTimeoutMs: null` to opt out. A hole is app code the framework `await`s and nothing else bounds it; one that never settles held the response and its whole closure open for the life of the process. A hole reveals exactly once — its promise, its rejection, or its deadline, whichever lands first. |
|
|
52
58
|
| Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
|
|
53
59
|
| Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
|
|
60
|
+
| 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 fresh-and-gated, `spa` is a gated shell. |
|
|
54
61
|
| Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
|
|
55
62
|
| Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. Inject primitives. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. |
|
|
56
63
|
| The loaders | `module-loader.ts` installs them at `index.ts` module scope, once. A plugin only affects modules loaded after it, so a second install point is a page that renders in one entry point and not another. |
|
|
57
64
|
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can still override any of them. Absent until `As of 2026-08`, and the missing `viewport` is why every deployed app rendered zoomed-out on a phone whatever its CSS said. |
|
|
58
|
-
| Escaping | `html.ts` only. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `head.ts` and `hydrate.ts` each had a private copy; both now import. |
|
|
65
|
+
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest), `render-spa.ts` (`lang`, `dir`, `buildId`, `rootId`, every chunk url) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `lang` is safe on the framework's own path (`currentLocale()` normalises against the configured `supported` list) and is escaped anyway, because `renderSpaShell` is a public export and a caller supplies it directly. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
|
|
59
66
|
| Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `<` corrupts the code AND leaves the hole. |
|
|
60
67
|
| Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
|
|
61
68
|
| Stylesheets | compiled by `css-modules.ts` and served **inlined** per surface. `sass` is this package's only third-party dependency and its only reason to exist here. |
|
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ a hydrating `site/` route below.
|
|
|
101
101
|
| Mode | Invariant | Error if violated |
|
|
102
102
|
|---|---|---|
|
|
103
103
|
| `static` | no per-request state — no `policy`, no `revalidate` | `X_ROUTE_MODE_INVALID` |
|
|
104
|
-
| `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl` | `X_ROUTE_MODE_INVALID` |
|
|
104
|
+
| `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl`; **no `policy`** — one cached document per URL cannot answer two actors | `X_ROUTE_MODE_INVALID` |
|
|
105
105
|
| `ssr` | cannot be prerendered | `X_ROUTE_MODE_INVALID` |
|
|
106
106
|
| `stream` | at least one `<Suspense>` boundary | `X_ROUTE_MODE_INVALID` |
|
|
107
107
|
| `spa` | requires a `policy` (authed dashboards only) | `X_ROUTE_MODE_INVALID` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/i18n": "
|
|
37
|
-
"@ultimat3/seo": "
|
|
34
|
+
"@ultimat3/cache": "4.0.0",
|
|
35
|
+
"@ultimat3/core": "4.0.0",
|
|
36
|
+
"@ultimat3/i18n": "4.0.0",
|
|
37
|
+
"@ultimat3/seo": "4.0.0",
|
|
38
38
|
"sass": "1.102.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/css-modules.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { basename, dirname, resolve } from 'node:path';
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
10
11
|
import * as sass from 'sass';
|
|
11
12
|
import { PrerenderFailedError } from './errors';
|
|
12
13
|
import { contentHash } from './render-static';
|
|
@@ -124,7 +125,10 @@ export function compileStylesheet(file: string, source: string): CompiledStylesh
|
|
|
124
125
|
style: 'compressed',
|
|
125
126
|
}).css;
|
|
126
127
|
} catch (error) {
|
|
127
|
-
|
|
128
|
+
// `renderThrowable`, never `.message`/`String()`: an importer, a plugin or a future Sass
|
|
129
|
+
// release can throw a value whose own read raises, and this frame is what turns a failed
|
|
130
|
+
// compile into `X_PRERENDER_FAILED` naming the file — a laundered read leaves it a bare one.
|
|
131
|
+
const first = renderThrowable(error).split('\n')[0];
|
|
128
132
|
throw new PrerenderFailedError(
|
|
129
133
|
`${file} did not compile: ${first}`,
|
|
130
134
|
`edit ${file}: ${TOKEN_FIX}`,
|
package/src/duration.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one reader of a TTL string. It sits below both `modes.ts` (which asks "is this a TTL at
|
|
3
|
+
* all?" when it refuses an `isr` route with no regeneration trigger) and `render-isr.ts` (which
|
|
4
|
+
* asks "how many ms?"), because a second answer is what let `revalidate: { ttl: '5 minutes' }`
|
|
5
|
+
* pass registration and then parse to `null` — a page generated once and served for the life of
|
|
6
|
+
* the process while the CDN was told `s-maxage=60`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DURATION_UNITS: Readonly<Record<string, number>> = {
|
|
10
|
+
ms: 1,
|
|
11
|
+
s: 1_000,
|
|
12
|
+
m: 60_000,
|
|
13
|
+
h: 3_600_000,
|
|
14
|
+
d: 86_400_000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** `'5m'` → 300000. Numbers pass through as milliseconds. */
|
|
18
|
+
export function parseTtlMs(ttl: string | number | null | undefined): number | null {
|
|
19
|
+
if (ttl === null || ttl === undefined) return null;
|
|
20
|
+
if (typeof ttl === 'number') return Number.isFinite(ttl) && ttl > 0 ? ttl : null;
|
|
21
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(ttl.trim());
|
|
22
|
+
const amount = match?.[1];
|
|
23
|
+
const unit = match?.[2];
|
|
24
|
+
if (amount === undefined || unit === undefined) return null;
|
|
25
|
+
const factor = DURATION_UNITS[unit];
|
|
26
|
+
return factor === undefined ? null : Number(amount) * factor;
|
|
27
|
+
}
|
package/src/head.ts
CHANGED
|
@@ -134,10 +134,20 @@ function contentOf(tag: HeadTag, raw: string): string {
|
|
|
134
134
|
return carriesJson(tag) ? escapeJsonContent(raw) : escapeRawTextContent(raw);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* `application/ld+json`, `application/json`, any `…+json`: the body is data, not code.
|
|
139
|
+
*
|
|
140
|
+
* The MIME parameter is cut before the suffix test, because a real document writes
|
|
141
|
+
* `type="application/ld+json; charset=utf-8"` and that does not end in `json` — so the block built
|
|
142
|
+
* from route data, which is the path attacker text takes, silently took the raw-text escaper
|
|
143
|
+
* instead of the total one. `</` is escaped either way, so this was a weakened boundary rather than
|
|
144
|
+
* a break-out; the JSON rule is total on purpose, and a `charset` is not a reason to leave it.
|
|
145
|
+
*/
|
|
138
146
|
function carriesJson(tag: HeadTag): boolean {
|
|
139
|
-
const
|
|
140
|
-
|
|
147
|
+
const declared = tag.attrs?.['type'];
|
|
148
|
+
if (typeof declared !== 'string') return false;
|
|
149
|
+
const [type = ''] = declared.split(';');
|
|
150
|
+
return type.trim().toLowerCase().endsWith('json');
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
export interface ThemeScriptOptions {
|
|
@@ -158,10 +168,14 @@ export const THEME_SCRIPT_MAX_BYTES = 512;
|
|
|
158
168
|
export function themeScript(options: ThemeScriptOptions = {}): HeadTag {
|
|
159
169
|
const attribute = options.attribute ?? 'data-theme';
|
|
160
170
|
const storageKey = options.storageKey ?? 'x-theme';
|
|
171
|
+
// `JSON.stringify`, never a value pasted between two quotes: both options land INSIDE a JS
|
|
172
|
+
// string in a `<script>` body, where one `"` ends the string and the rest is code the page runs.
|
|
173
|
+
// Author-supplied today — the same status every `emitIslandAttributes` value had before it was
|
|
174
|
+
// routed through `html.ts`. The element's own raw-text rule is applied by `renderTag` below.
|
|
161
175
|
const source =
|
|
162
|
-
`try{var t=localStorage.getItem(
|
|
176
|
+
`try{var t=localStorage.getItem(${JSON.stringify(storageKey)})||` +
|
|
163
177
|
`(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");` +
|
|
164
|
-
`document.documentElement.setAttribute(
|
|
178
|
+
`document.documentElement.setAttribute(${JSON.stringify(attribute)},t)}catch(e){}`;
|
|
165
179
|
|
|
166
180
|
const bytes = new TextEncoder().encode(source).byteLength;
|
|
167
181
|
const cap = options.maxBytes ?? THEME_SCRIPT_MAX_BYTES;
|
package/src/html.ts
CHANGED
|
@@ -72,11 +72,15 @@ export function escapeJsonContent(json: string): string {
|
|
|
72
72
|
* JSX prop name → attribute name. Solid authors write the HTML spelling (`class`, `for`), but the
|
|
73
73
|
* React spellings compile too, and an author who writes one and gets no attribute has a bug with
|
|
74
74
|
* no error message.
|
|
75
|
+
*
|
|
76
|
+
* A `Map` like the two sets above, never a record: an object lookup walks the prototype chain, so
|
|
77
|
+
* `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and the URL
|
|
78
|
+
* check below called `.toLowerCase()` on it — a bare TypeError, no code, no fix, and the page 500s.
|
|
75
79
|
*/
|
|
76
|
-
const ATTRIBUTE_ALIASES:
|
|
77
|
-
className
|
|
78
|
-
htmlFor
|
|
79
|
-
|
|
80
|
+
const ATTRIBUTE_ALIASES: ReadonlyMap<string, string> = new Map([
|
|
81
|
+
['className', 'class'],
|
|
82
|
+
['htmlFor', 'for'],
|
|
83
|
+
]);
|
|
80
84
|
|
|
81
85
|
/** Props the tree consumes rather than emits. `innerHTML` is emitted as content, not an attribute. */
|
|
82
86
|
const NON_ATTRIBUTES: ReadonlySet<string> = new Set([
|
|
@@ -87,6 +91,30 @@ const NON_ATTRIBUTES: ReadonlySet<string> = new Set([
|
|
|
87
91
|
'textContent',
|
|
88
92
|
]);
|
|
89
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Attributes a browser FOLLOWS, beyond the four `@ultimat3/core` names for the anchor case. Kept
|
|
96
|
+
* here rather than in `safe-url.ts` only because that file is another package's; the scheme check
|
|
97
|
+
* is one rule and every attribute a scheme can execute from belongs under it. `data` is
|
|
98
|
+
* `<object data>`, `poster` is `<video poster>`, `xlink:href` executes on click inside inline SVG,
|
|
99
|
+
* and `ping` is a URL the browser POSTs to on activation.
|
|
100
|
+
*/
|
|
101
|
+
const URL_BEARING_ATTRIBUTES: ReadonlySet<string> = new Set([
|
|
102
|
+
...URL_ATTRIBUTES,
|
|
103
|
+
'data',
|
|
104
|
+
'ping',
|
|
105
|
+
'poster',
|
|
106
|
+
'xlink:href',
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Attributes that carry MARKUP, not text or a URL, and are therefore never emitted. `srcdoc` is
|
|
111
|
+
* entity-DECODED and then parsed as HTML, so `escapeAttribute`'s `<script>` becomes a live
|
|
112
|
+
* `<script>` inside the iframe — on this origin, with this session's cookie. Escaping cannot make
|
|
113
|
+
* it inert, so the attribute is refused instead, the same way `innerHTML` above stays the one
|
|
114
|
+
* explicit escape hatch rather than a prop anyone can spread in from a row.
|
|
115
|
+
*/
|
|
116
|
+
const REFUSED_ATTRIBUTES: ReadonlySet<string> = new Set(['srcdoc']);
|
|
117
|
+
|
|
90
118
|
const cssProperty = (name: string): string =>
|
|
91
119
|
name.startsWith('--') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
92
120
|
|
|
@@ -111,7 +139,8 @@ export function attributePair(name: string, value: unknown): string | null {
|
|
|
111
139
|
if (typeof value === 'function') return null;
|
|
112
140
|
if (name.startsWith('on') && name.length > 2) return null;
|
|
113
141
|
|
|
114
|
-
const attribute = ATTRIBUTE_ALIASES
|
|
142
|
+
const attribute = ATTRIBUTE_ALIASES.get(name) ?? name;
|
|
143
|
+
if (REFUSED_ATTRIBUTES.has(attribute.toLowerCase())) return null;
|
|
115
144
|
if (value === true) return attribute;
|
|
116
145
|
if (attribute === 'style') {
|
|
117
146
|
const style = styleValue(value);
|
|
@@ -123,7 +152,7 @@ export function attributePair(name: string, value: unknown): string | null {
|
|
|
123
152
|
// module is the single place injection is prevented and an href off a database row is the shape
|
|
124
153
|
// every app writes. A refused URL emits no attribute at all — an anchor with no `href` is inert
|
|
125
154
|
// and still renders its text, where a blanked one is a live link nobody checked.
|
|
126
|
-
if (
|
|
155
|
+
if (URL_BEARING_ATTRIBUTES.has(attribute.toLowerCase())) {
|
|
127
156
|
const url = safeUrl(text, attribute.toLowerCase());
|
|
128
157
|
if (url === null) return null;
|
|
129
158
|
return `${attribute}="${escapeAttribute(url)}"`;
|
package/src/hydrate.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* answered instead of swallowed.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { escapeJsonContent } from './html';
|
|
8
|
+
import { escapeAttribute, escapeJsonContent } from './html';
|
|
9
9
|
import type { HydrateStrategy } from './route';
|
|
10
10
|
|
|
11
11
|
export interface IslandDirective {
|
|
@@ -34,14 +34,22 @@ export const DEFAULT_REPLAY_EVENTS = ['click', 'input', 'change', 'submit', 'key
|
|
|
34
34
|
* runtime below is never emitted for that island.
|
|
35
35
|
*/
|
|
36
36
|
export function emitIslandAttributes(directive: IslandDirective): string {
|
|
37
|
-
|
|
37
|
+
// `html.ts`'s escaper, not raw interpolation: a `"` in any of these values closes the attribute
|
|
38
|
+
// and the rest of the string is markup. Author-controlled today — which is why it costs nothing
|
|
39
|
+
// to route through the ONE escaper now, rather than after a build id or a prop-derived margin
|
|
40
|
+
// starts carrying something the author did not type.
|
|
41
|
+
const attr = (name: string, value: string): string => `${name}="${escapeAttribute(value)}"`;
|
|
42
|
+
const attrs = [
|
|
43
|
+
attr('data-x-island', directive.islandId),
|
|
44
|
+
attr('data-x-hydrate', directive.strategy),
|
|
45
|
+
];
|
|
38
46
|
if (directive.strategy !== 'never') {
|
|
39
|
-
attrs.push(
|
|
47
|
+
attrs.push(attr('data-x-entry', directive.entry));
|
|
40
48
|
if (directive.rootMargin !== undefined) {
|
|
41
|
-
attrs.push(
|
|
49
|
+
attrs.push(attr('data-x-margin', directive.rootMargin));
|
|
42
50
|
}
|
|
43
51
|
if (directive.events !== undefined && directive.events.length > 0) {
|
|
44
|
-
attrs.push(
|
|
52
|
+
attrs.push(attr('data-x-events', directive.events.join(' ')));
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
return attrs.join(' ');
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ installRenderLoader();
|
|
|
11
11
|
|
|
12
12
|
export type { CompiledStylesheet } from './css-modules';
|
|
13
13
|
export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
|
|
14
|
+
export { parseTtlMs } from './duration';
|
|
14
15
|
export type { RenderErrorCode } from './errors';
|
|
15
16
|
export {
|
|
16
17
|
BudgetExceededError,
|
|
@@ -134,8 +135,8 @@ export {
|
|
|
134
135
|
createIsrController,
|
|
135
136
|
DEFAULT_ISR_MAX_ENTRIES,
|
|
136
137
|
invalidateAndRevalidate,
|
|
138
|
+
isrKey,
|
|
137
139
|
memoryIsrStore,
|
|
138
|
-
parseTtlMs,
|
|
139
140
|
} from './render-isr';
|
|
140
141
|
export type { SpaShell, SpaShellInput } from './render-spa';
|
|
141
142
|
export { renderSpa, renderSpaShell, SPA_ROOT_ID } from './render-spa';
|
package/src/island-props.ts
CHANGED
|
@@ -68,7 +68,7 @@ function assertJsonSafe(value: unknown, path: string, seen: Set<object>, file: s
|
|
|
68
68
|
guardCycle(value, path, seen, file);
|
|
69
69
|
const out: Record<string, JsonValue> = {};
|
|
70
70
|
for (const [key, item] of Object.entries(value)) {
|
|
71
|
-
out
|
|
71
|
+
put(out, key, assertJsonSafe(item, `${path}.${key}`, seen, file));
|
|
72
72
|
}
|
|
73
73
|
seen.delete(value);
|
|
74
74
|
return out;
|
|
@@ -82,6 +82,18 @@ function assertJsonSafe(value: unknown, path: string, seen: Set<object>, file: s
|
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One walked value onto the bag. `out[key] = value` is not an assignment for exactly one name:
|
|
87
|
+
* `__proto__` runs `Object.prototype`'s setter and REPLACES the prototype instead of adding a key,
|
|
88
|
+
* so the prop never reaches the browser — the exact footgun the walk above exists to prevent — and
|
|
89
|
+
* the record the server keeps reading answers whatever the request body chose. `JSON.parse` mints a
|
|
90
|
+
* real own `__proto__` key, so a row off the wire is enough. Same shape as `validate-args.ts`'s
|
|
91
|
+
* `put` in `@ultimat3/mcp`; `defineProperty` writes a plain own data property whatever the name is.
|
|
92
|
+
*/
|
|
93
|
+
function put(out: Record<string, JsonValue>, key: string, value: JsonValue): void {
|
|
94
|
+
Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
86
98
|
if (typeof value !== 'object' || value === null) return false;
|
|
87
99
|
const proto: unknown = Object.getPrototypeOf(value);
|
|
@@ -127,7 +139,7 @@ export function checkIslandProps(
|
|
|
127
139
|
const bag: Record<string, JsonValue> = {};
|
|
128
140
|
const seen = new Set<object>();
|
|
129
141
|
for (const key of passed) {
|
|
130
|
-
bag
|
|
142
|
+
put(bag, key, assertJsonSafe(props[key], `props.${key}`, seen, file));
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
const bytes = new TextEncoder().encode(JSON.stringify(bag)).byteLength;
|
package/src/modes.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* invariant is only documented is a mode that silently degrades in production.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { parseTtlMs } from './duration';
|
|
8
9
|
import { RouteModeInvalidError } from './errors';
|
|
9
10
|
import type { HydrateStrategy, RenderMode, RouteConfig } from './route';
|
|
10
11
|
import { HYDRATE_STRATEGIES } from './route';
|
|
@@ -89,8 +90,12 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
|
|
|
89
90
|
|
|
90
91
|
/** Mode-local checks that need nothing but the config. Called by `defineRoute`. */
|
|
91
92
|
export function assertModeShape(config: RouteShape): void {
|
|
92
|
-
// Widened on purpose: JS callers reach `defineRoute` with unvalidated strings
|
|
93
|
-
|
|
93
|
+
// Widened on purpose: JS callers reach `defineRoute` with unvalidated strings — and an object
|
|
94
|
+
// lookup walks the prototype chain, so `render: 'constructor'` used to find a `ModeSpec` that
|
|
95
|
+
// does not exist and hand back a frozen descriptor for a mode nothing implements.
|
|
96
|
+
const spec: ModeSpec | undefined = Object.hasOwn(MODE_SPECS, config.render)
|
|
97
|
+
? MODE_SPECS[config.render]
|
|
98
|
+
: undefined;
|
|
94
99
|
if (spec === undefined) {
|
|
95
100
|
throw new RouteModeInvalidError(
|
|
96
101
|
`render: ${JSON.stringify(config.render)} is not a render mode`,
|
|
@@ -121,6 +126,19 @@ export function assertModeShape(config: RouteShape): void {
|
|
|
121
126
|
);
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
// isr: a CACHED document, so it may not be gated. The symmetry with `static` above is the
|
|
130
|
+
// point — an ISR route resolves `load` with the request's own `Ctx`, renders that actor's
|
|
131
|
+
// document, and the cache stores it under the pathname alone. Every later actor who passes the
|
|
132
|
+
// same policy is then served the first actor's HTML. Keying the cache on more is a trap, not a
|
|
133
|
+
// fix: the key would have to enumerate everything a policy and a `load` can read.
|
|
134
|
+
if (config.render === 'isr' && config.policy !== undefined) {
|
|
135
|
+
throw new RouteModeInvalidError(
|
|
136
|
+
"render: 'isr' caches one document per URL and cannot be gated, but a `policy` was " +
|
|
137
|
+
`declared (${config.policy.permission} varies the answer per actor)`,
|
|
138
|
+
"change render to 'ssr' (fresh, gated) or 'spa' (gated shell), or drop the policy",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
124
142
|
// isr: needs a trigger, otherwise it is `static` wearing a costume.
|
|
125
143
|
if (config.render === 'isr' && !hasRevalidateTrigger(config)) {
|
|
126
144
|
throw new RouteModeInvalidError(
|
|
@@ -150,7 +168,10 @@ function hasRevalidateTrigger(config: RouteShape): boolean {
|
|
|
150
168
|
const revalidate = config.revalidate;
|
|
151
169
|
if (revalidate === undefined) return false;
|
|
152
170
|
const hasTags = revalidate.tags !== undefined && revalidate.tags.length > 0;
|
|
153
|
-
|
|
171
|
+
// `parseTtlMs`, never "a non-empty string": the ISR clock is the one that has to read this value,
|
|
172
|
+
// and `ttl: '5 minutes'` passed here and parsed to `null` there — a page generated once and
|
|
173
|
+
// served for the life of the process, which is the exact costume this check refuses below.
|
|
174
|
+
const hasTtl = parseTtlMs(revalidate.ttl) !== null;
|
|
154
175
|
return hasTags || hasTtl;
|
|
155
176
|
}
|
|
156
177
|
|
package/src/module-loader.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* `bun test` all load a component the same way and there is no separate "bundled" behaviour.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
8
|
import { compileStylesheet, isGlobalStylesheet } from './css-modules';
|
|
8
9
|
import { PrerenderFailedError } from './errors';
|
|
9
10
|
import type { Surface } from './surfaces';
|
|
@@ -134,7 +135,10 @@ export function installRenderLoader(): void {
|
|
|
134
135
|
} catch (error) {
|
|
135
136
|
if (error instanceof PrerenderFailedError) throw error;
|
|
136
137
|
throw new PrerenderFailedError(
|
|
137
|
-
|
|
138
|
+
// `renderThrowable` from core, never `.message`/`String()`: this catch is the loader's
|
|
139
|
+
// last frame, and a value that fights being read would replace the coded failure with
|
|
140
|
+
// a bare throw out of a Bun plugin — where no route and no file name survives.
|
|
141
|
+
`${path} could not be loaded: ${renderThrowable(error)}`,
|
|
138
142
|
`open ${path} and fix the stylesheet it @use-s`,
|
|
139
143
|
);
|
|
140
144
|
}
|
package/src/registry.ts
CHANGED
|
@@ -369,8 +369,12 @@ export function matchRoute(pathname: string): RouteMatch | null {
|
|
|
369
369
|
* `undefined` for a malformed percent-escape. A pathname is whatever the client typed, and
|
|
370
370
|
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — which escaped
|
|
371
371
|
* `matchRoute` as a 500 and an error-monitor page for somebody's typo.
|
|
372
|
+
*
|
|
373
|
+
* Exported for `router-client.ts`, which answers the same question about the same pathname on the
|
|
374
|
+
* other side of the wire: two decoders is how the client throws out of a popstate listener for an
|
|
375
|
+
* address the server 404s.
|
|
372
376
|
*/
|
|
373
|
-
function decodeSegment(value: string): string | undefined {
|
|
377
|
+
export function decodeSegment(value: string): string | undefined {
|
|
374
378
|
try {
|
|
375
379
|
return decodeURIComponent(value);
|
|
376
380
|
} catch {
|
package/src/render-html.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* component — `static` at build time, `ssr`/`stream` per request, all through `renderToHtml`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
8
|
import {
|
|
8
9
|
IslandInvalidError,
|
|
9
10
|
IslandNotHydratedError,
|
|
@@ -30,9 +31,6 @@ export interface RenderHtmlOptions {
|
|
|
30
31
|
readonly islands?: IslandCollector;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const describe = (error: unknown): string =>
|
|
34
|
-
error instanceof Error ? error.message : String(error);
|
|
35
|
-
|
|
36
34
|
/**
|
|
37
35
|
* A thunk is called, not stringified. Solid's reactive reads are accessors (`count()`), and a
|
|
38
36
|
* `children` prop is routinely a function — evaluating it once is exactly the server's job.
|
|
@@ -142,7 +140,10 @@ export async function renderComponent(
|
|
|
142
140
|
if (error instanceof IslandPropsInvalidError) throw error;
|
|
143
141
|
if (error instanceof IslandNotHydratedError) throw error;
|
|
144
142
|
throw new PrerenderFailedError(
|
|
145
|
-
`
|
|
143
|
+
// `renderThrowable`, never `.message`/`String()`: a component is app code and may throw a
|
|
144
|
+
// null-prototype object (`String()` raises) or an Error whose `message` getter does — and
|
|
145
|
+
// this frame is the last thing between that and a build failure with no code at all.
|
|
146
|
+
`rendering the component in ${file} threw: ${renderThrowable(error)}`,
|
|
146
147
|
`run \`bun test ${file.replace(/\.tsx?$/, '.test.ts')}\` to reproduce, then fix ${file}`,
|
|
147
148
|
);
|
|
148
149
|
}
|
package/src/render-isr.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
registerRevalidator,
|
|
14
14
|
unregisterDependent,
|
|
15
15
|
} from '@ultimat3/cache';
|
|
16
|
-
import { logger } from '@ultimat3/core';
|
|
16
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
17
|
+
import { parseTtlMs } from './duration';
|
|
17
18
|
import type { RouteDescriptor } from './registry';
|
|
18
19
|
import { describeRoutes } from './registry';
|
|
19
20
|
import { contentHash, staticHeaders } from './render-static';
|
|
@@ -22,6 +23,11 @@ import type { RenderResult } from './route';
|
|
|
22
23
|
export type IsrState = 'miss' | 'hit' | 'stale';
|
|
23
24
|
|
|
24
25
|
export interface IsrEntry {
|
|
26
|
+
/**
|
|
27
|
+
* The store key: the request's pathname AND its query, params sorted. Not the route's pattern
|
|
28
|
+
* and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
|
|
29
|
+
* keying both as `/blog` served the second visitor the first one's HTML (#171).
|
|
30
|
+
*/
|
|
25
31
|
readonly path: string;
|
|
26
32
|
readonly html: string;
|
|
27
33
|
readonly hash: string;
|
|
@@ -74,24 +80,35 @@ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The ISR store key for one request URL: pathname plus the query, **params sorted**.
|
|
85
|
+
*
|
|
86
|
+
* Exported because deriving it is the caller's job and there may only be ONE derivation — a
|
|
87
|
+
* server that keyed on `url.pathname` while the store believed it held a whole URL is the shape
|
|
88
|
+
* of #171. Sorting makes `?a=1&b=2` and `?b=2&a=1` one entry rather than two renders of one page.
|
|
89
|
+
*
|
|
90
|
+
* A query-carrying URL therefore gets its own entry, which is correct and is not free: a crawler
|
|
91
|
+
* appending `?utm_source=…` mints one entry per value. That is bounded, not unbounded —
|
|
92
|
+
* `DEFAULT_ISR_MAX_ENTRIES` evicts least-recently-generated first — and a bounded cache that
|
|
93
|
+
* thrashes is the right failure next to an unbounded one that answers the wrong document.
|
|
94
|
+
*/
|
|
95
|
+
export function isrKey(url: URL): string {
|
|
96
|
+
if (url.search === '') return url.pathname;
|
|
97
|
+
const params = new URLSearchParams(url.search);
|
|
98
|
+
params.sort();
|
|
99
|
+
return `${url.pathname}?${params.toString()}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The route pattern a key belongs to. Every lookup that asks the ROUTE TABLE a question — the
|
|
104
|
+
* descriptor, and therefore the TTL — has to strip the query first: `descriptorFor('/blog?page=2')`
|
|
105
|
+
* matches no route, so the entry would silently fall back to `ttlMs: null` and a declared
|
|
106
|
+
* `revalidate: { ttl: '5m' }` would become tag-only. Keying without this split is a half-fix that
|
|
107
|
+
* trades a leak for a wrong TTL.
|
|
108
|
+
*/
|
|
109
|
+
function routePathOf(key: string): string {
|
|
110
|
+
const query = key.indexOf('?');
|
|
111
|
+
return query === -1 ? key : key.slice(0, query);
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
export type IsrRenderFn = (path: string) => string | Promise<string>;
|
|
@@ -150,7 +167,8 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
150
167
|
const pending = new Map<string, Promise<IsrEntry>>();
|
|
151
168
|
const registered = new Set<string>();
|
|
152
169
|
|
|
153
|
-
function descriptorFor(
|
|
170
|
+
function descriptorFor(key: string): RouteDescriptor | undefined {
|
|
171
|
+
const path = routePathOf(key);
|
|
154
172
|
const table = routes();
|
|
155
173
|
return table.find((r) => r.path === path) ?? table.find((r) => matchesRoute(path, r.path));
|
|
156
174
|
}
|
|
@@ -248,10 +266,10 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
248
266
|
// stale-while-revalidate: answer from the stale copy now, refresh behind the request.
|
|
249
267
|
const already = pending.has(path);
|
|
250
268
|
void regenerate(path, render).catch((error: unknown) => {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
});
|
|
269
|
+
// `renderThrowable`, never `.message`/`String()`: this `.catch` is the last frame under a
|
|
270
|
+
// route's own render function, and `String()` raises on a null-prototype object — the
|
|
271
|
+
// handler that exists to REPORT the failure became a second, unhandled rejection.
|
|
272
|
+
logger.warn('isr.regenerate.failed', { path, error: renderThrowable(error) });
|
|
255
273
|
});
|
|
256
274
|
return {
|
|
257
275
|
state: 'stale',
|
package/src/render-spa.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { RouteModeInvalidError } from './errors';
|
|
9
|
+
import { escapeAttribute } from './html';
|
|
9
10
|
import type { RouteEntry } from './registry';
|
|
10
11
|
import { contentHash } from './render-static';
|
|
11
12
|
import type { RenderResult } from './route';
|
|
@@ -37,18 +38,22 @@ export function renderSpaShell(input: SpaShellInput): SpaShell {
|
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
// Every attribute value through `html.ts`'s ONE escaper — the package's own escaping rule, and
|
|
42
|
+
// the same repair `emitIslandAttributes` took. `head` is the exception by construction: it is
|
|
43
|
+
// already-merged markup from `head.ts`, which escaped it on the way in.
|
|
44
|
+
const rootId = escapeAttribute(input.rootId ?? SPA_ROOT_ID);
|
|
41
45
|
const preloads = input.chunks
|
|
42
|
-
.map((chunk) => `<link rel="modulepreload" href="${chunk}">`)
|
|
46
|
+
.map((chunk) => `<link rel="modulepreload" href="${escapeAttribute(chunk)}">`)
|
|
43
47
|
.join('');
|
|
44
48
|
const scripts = input.chunks
|
|
45
|
-
.map((chunk) => `<script type="module" src="${chunk}"></script>`)
|
|
49
|
+
.map((chunk) => `<script type="module" src="${escapeAttribute(chunk)}"></script>`)
|
|
46
50
|
.join('');
|
|
47
51
|
|
|
48
52
|
const html =
|
|
49
|
-
`<!doctype html><html lang="${input.lang}"
|
|
53
|
+
`<!doctype html><html lang="${escapeAttribute(input.lang)}" ` +
|
|
54
|
+
`dir="${escapeAttribute(input.dir ?? 'ltr')}">` +
|
|
50
55
|
`<head>${input.head}${preloads}` +
|
|
51
|
-
`<meta name="x-ultimate-build" content="${input.buildId}">` +
|
|
56
|
+
`<meta name="x-ultimate-build" content="${escapeAttribute(input.buildId)}">` +
|
|
52
57
|
`</head><body><div id="${rootId}"></div>${scripts}</body></html>`;
|
|
53
58
|
|
|
54
59
|
return { html, hash: contentHash(html) };
|
package/src/render-static.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* the precache revision in `sw.js`, and the asset filename suffix.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { useContext } from '@ultimat3/core';
|
|
7
|
+
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';
|
|
@@ -69,7 +69,9 @@ export async function enumeratePrerender(entry: RouteEntry): Promise<readonly Ro
|
|
|
69
69
|
produced = await prerender();
|
|
70
70
|
} catch (error) {
|
|
71
71
|
throw new PrerenderFailedError(
|
|
72
|
-
`
|
|
72
|
+
// `renderThrowable`, never `.message`/`String()`: `prerender` is app code and may throw a
|
|
73
|
+
// value whose read raises in turn — this frame is what makes the build failure a coded one.
|
|
74
|
+
`prerender() for ${entry.path} threw: ${renderThrowable(error)}`,
|
|
73
75
|
`fix prerender in ${entry.file} — it runs at build time with no request context`,
|
|
74
76
|
);
|
|
75
77
|
}
|
|
@@ -119,8 +121,8 @@ export async function renderStatic(
|
|
|
119
121
|
html = await render({ path, params });
|
|
120
122
|
} catch (error) {
|
|
121
123
|
throw new PrerenderFailedError(
|
|
122
|
-
`rendering ${path} failed: ${
|
|
123
|
-
`
|
|
124
|
+
`rendering ${path} failed: ${renderThrowable(error)}`,
|
|
125
|
+
`x build --target static --json # reproduces ${path}, then fix ${entry.file}`,
|
|
124
126
|
);
|
|
125
127
|
}
|
|
126
128
|
const hash = contentHash(html);
|
|
@@ -164,7 +166,3 @@ export function fillPath(pattern: string, params: RouteParams): string {
|
|
|
164
166
|
.replace(/\/+$/, '') || '/'
|
|
165
167
|
);
|
|
166
168
|
}
|
|
167
|
-
|
|
168
|
-
function describe(error: unknown): string {
|
|
169
|
-
return error instanceof Error ? error.message : String(error);
|
|
170
|
-
}
|
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 } from '@ultimat3/core';
|
|
14
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
15
|
+
import { escapeAttribute, escapeRawTextContent } from './html';
|
|
15
16
|
import type { RenderResult } from './route';
|
|
16
17
|
|
|
17
18
|
export interface StreamHole {
|
|
@@ -42,9 +43,16 @@ export function holeId(id: string): string {
|
|
|
42
43
|
return `${HOLE_PREFIX}${id}`;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The placeholder that sits in the first flush, holding the fallback markup.
|
|
48
|
+
*
|
|
49
|
+
* `html.ts`'s escaper, not raw interpolation, for the reason `emitIslandAttributes` states: a `"`
|
|
50
|
+
* in the id closes the attribute and the rest is markup. Author-controlled today — which is why it
|
|
51
|
+
* costs nothing to route through the ONE escaper now, rather than after an id starts being derived
|
|
52
|
+
* from a param. `fallback` is already-rendered HTML and stays verbatim.
|
|
53
|
+
*/
|
|
46
54
|
export function holeMarker(id: string, fallback: string): string {
|
|
47
|
-
return `<x-hole id="${holeId(id)}">${fallback}</x-hole>`;
|
|
55
|
+
return `<x-hole id="${escapeAttribute(holeId(id))}">${fallback}</x-hole>`;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
/**
|
|
@@ -57,7 +65,15 @@ export const REVEAL_SCRIPT =
|
|
|
57
65
|
|
|
58
66
|
export function revealChunk(id: string, html: string): string {
|
|
59
67
|
const key = holeId(id);
|
|
60
|
-
|
|
68
|
+
// Two contexts, two encoders — the attribute takes `escapeAttribute`, and the script argument is
|
|
69
|
+
// built by `JSON.stringify` so the id is a JS string LITERAL rather than text pasted between two
|
|
70
|
+
// quotes: `a");alert(1);//` closed the call and ran on the page's own origin. `</script` inside
|
|
71
|
+
// it would still end the element, so the raw-text rule applies over the top, as `html.ts` says.
|
|
72
|
+
const argument = escapeRawTextContent(JSON.stringify(key));
|
|
73
|
+
return (
|
|
74
|
+
`<template data-x-hole="${escapeAttribute(key)}">${html}</template>` +
|
|
75
|
+
`<script>$X(${argument})</script>`
|
|
76
|
+
);
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
/**
|
|
@@ -166,9 +182,10 @@ export function renderStreamHtml(
|
|
|
166
182
|
reveal(html);
|
|
167
183
|
},
|
|
168
184
|
(error: unknown) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
// `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
|
|
186
|
+
// and a read that raises here skips the `reveal` below — the hole never fills and the
|
|
187
|
+
// response is held to its deadline for a failure that was already handled.
|
|
188
|
+
logger.warn(`stream hole ${hole.id} rejected: ${renderThrowable(error)}`);
|
|
172
189
|
reveal(errorFallback(hole.id));
|
|
173
190
|
},
|
|
174
191
|
);
|
package/src/route-data.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* the body does not contain.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { isUltimateError } from '@ultimat3/core';
|
|
8
|
+
import { isUltimateError, renderThrowable } from '@ultimat3/core';
|
|
9
9
|
import { useI18n } from '@ultimat3/i18n';
|
|
10
10
|
import { RouteLoadFailedError } from './errors';
|
|
11
11
|
import type { RouteConfig, RouteContext, RouteData, RouteMetaContext } from './route';
|
|
@@ -26,6 +26,11 @@ export async function routeDataFor<TData = RouteData>(
|
|
|
26
26
|
// `RouteContext` satisfies `TData` on this line. One narrowing assertion backed by a build
|
|
27
27
|
// error, in place of the `as unknown as` that used to hand back any shape a caller named.
|
|
28
28
|
if (config.load === undefined) return ctx as RouteContext & TData;
|
|
29
|
+
// Read BEFORE the try, and never inside the catch: `new URL('/posts/7')` is a bare `TypeError`,
|
|
30
|
+
// and `ctx.url` is relative on every path that builds one by hand — a prerender pass, `x build`,
|
|
31
|
+
// a test harness. Computing it in the catch replaced `X_ROUTE_LOAD_FAILED` with that TypeError
|
|
32
|
+
// on the ONE path whose job is to produce a coded error.
|
|
33
|
+
const path = pathnameOf(ctx.url);
|
|
29
34
|
try {
|
|
30
35
|
return await config.load(ctx);
|
|
31
36
|
} catch (cause) {
|
|
@@ -35,14 +40,29 @@ export async function routeDataFor<TData = RouteData>(
|
|
|
35
40
|
// `code` property: an ENOENT off `Bun.file` carries `code: 'ENOENT'`, and the duck-type let
|
|
36
41
|
// every one of them out of here unwrapped — no fix line, and no mention of the route to fix.
|
|
37
42
|
if (isUltimateError(cause)) throw cause;
|
|
38
|
-
|
|
43
|
+
// `renderThrowable`, not `.message`/`String()`: `load` is app code and may throw a value
|
|
44
|
+
// whose `message` getter or `toString` throws in turn — this frame is the last thing between
|
|
45
|
+
// that and a page with no error at all.
|
|
46
|
+
const detail = renderThrowable(cause);
|
|
39
47
|
throw new RouteLoadFailedError(
|
|
40
|
-
`load() threw while rendering ${
|
|
41
|
-
`fix the load function for ${
|
|
48
|
+
`load() threw while rendering ${path}: ${detail}`,
|
|
49
|
+
`fix the load function for ${path}, or return a fallback so the page can render`,
|
|
42
50
|
);
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The pathname of a route URL, absolute or relative, and never a throw: this value only ever
|
|
56
|
+
* appears in an error message, so a reader that raises would cost the caller the whole error.
|
|
57
|
+
*/
|
|
58
|
+
function pathnameOf(url: string): string {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(url).pathname;
|
|
61
|
+
} catch {
|
|
62
|
+
return url.split(/[?#]/)[0] ?? url;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* The argument `meta` is called with. Built here rather than at each call site so `x dev` and the
|
|
48
68
|
* build's prerenderer cannot disagree about what `meta` receives — two builders is a `<title>`
|
package/src/router-client.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CompiledPattern } from './registry';
|
|
10
|
-
import { compilePattern } from './registry';
|
|
10
|
+
import { compilePattern, decodeSegment } from './registry';
|
|
11
11
|
import type { RouteParams } from './route';
|
|
12
12
|
|
|
13
13
|
export interface RouterRoute {
|
|
@@ -108,10 +108,21 @@ export function createRouter(options: RouterOptions): Router {
|
|
|
108
108
|
const match = candidate.pattern.regex.exec(rawPath);
|
|
109
109
|
if (match === null) continue;
|
|
110
110
|
const params: Record<string, string> = {};
|
|
111
|
+
let undecodable = false;
|
|
111
112
|
candidate.pattern.keys.forEach((key, index) => {
|
|
112
113
|
const value = match[index + 1];
|
|
113
|
-
if (value
|
|
114
|
+
if (value === undefined) return;
|
|
115
|
+
// `decodeSegment`, the server router's own reader: `decodeURIComponent('%zz')` is a bare
|
|
116
|
+
// `URIError`, and `resolve` is called from the signal initialiser, from `navigate`, from a
|
|
117
|
+
// `mouseenter` prefetch and from the popstate handler — so a typo in the address bar took
|
|
118
|
+
// the whole SPA down at boot instead of failing this one branch.
|
|
119
|
+
const decoded = decodeSegment(value);
|
|
120
|
+
if (decoded === undefined) undecodable = true;
|
|
121
|
+
else params[key] = decoded;
|
|
114
122
|
});
|
|
123
|
+
// Fails only the branch that would have decoded it, exactly as `matchRoute` answers: a
|
|
124
|
+
// literal route matching the same text still wins, and an unclaimed pathname is a non-match.
|
|
125
|
+
if (undecodable) continue;
|
|
115
126
|
return {
|
|
116
127
|
route: candidate.route,
|
|
117
128
|
params,
|