@ultimat3/render 24.0.0 → 25.1.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 +11 -10
- package/README.md +20 -11
- package/package.json +8 -8
- package/src/client-path-style-tag.ts +0 -3
- package/src/client-scope-tag.ts +0 -3
- package/src/client-sync-tags.ts +0 -3
- package/src/code-unit-order.ts +1 -1
- package/src/css-modules.ts +3 -2
- package/src/errors.ts +1 -1
- package/src/head.ts +4 -11
- package/src/html.ts +3 -9
- package/src/hydrate.ts +32 -11
- package/src/index.ts +10 -23
- package/src/island-collector.ts +11 -4
- package/src/island-hold.ts +56 -0
- package/src/island-props.ts +1 -1
- package/src/island.ts +5 -1
- package/src/module-loader.ts +19 -14
- package/src/navigation-fetch.ts +24 -14
- package/src/navigation-rules.ts +15 -21
- package/src/navigation.ts +19 -10
- package/src/registry.ts +7 -7
- package/src/render-html.ts +5 -3
- package/src/render-isr.ts +7 -7
- package/src/render-ssr.ts +2 -1
- package/src/render-static.ts +2 -2
- package/src/render-stream.ts +2 -3
- package/src/route.ts +3 -1
- package/src/server.ts +1 -1
- package/src/stylesheet-path.ts +34 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// A HELD island (#506): server markup that must not paint before the island mounts, because what
|
|
2
|
+
// it shows can be older than what the page will show — an offline reload's cached count, under a
|
|
3
|
+
// queued write the mount rebuilds. Hidden from its first byte, booted at once, revealed when the
|
|
4
|
+
// mount settles or at the cap. Who is held is the renderer's caller's call (`hold` on the collector).
|
|
5
|
+
|
|
6
|
+
/** On a held island's wrapper until the runtime reveals it; what the runtime finds it by. */
|
|
7
|
+
export const ISLAND_HOLD_ATTRIBUTE = 'data-x-hold';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The longest a held island stays hidden, whatever its chunk does — a download on a slow network,
|
|
11
|
+
* a mount that never settles. Above `@ultimat3/realtime`'s `FIRST_PAINT_HOLD_MS` (1 s), the cap on
|
|
12
|
+
* the disk restore a realtime island waits for, so a slow disk meets that cap and still mounts
|
|
13
|
+
* before this one shows the server's markup. Interpolated into the runtime: one number, one copy.
|
|
14
|
+
*/
|
|
15
|
+
export const ISLAND_HOLD_MS = 3_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The keyframes that reveal a held island with no script at all — JS off, the runtime blocked, a
|
|
19
|
+
* strategy part that threw before the hold could run. Defined in `@ultimat3/ui`'s `global.scss`
|
|
20
|
+
* (this package may not import `ui`; `island-hold-replay.test.ts` holds the two spellings equal).
|
|
21
|
+
*/
|
|
22
|
+
export const ISLAND_HOLD_REVEAL = 'ultimate-hold-reveal';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The wrapper's attributes. An inline `style` and not a stylesheet rule: it applies from the byte
|
|
26
|
+
* the parser reads (no sheet can have loaded yet), and `@ultimat3/http`'s policy admits a style
|
|
27
|
+
* ATTRIBUTE (`style-src-attr`) where an inline `<style>` would need a hash. `visibility`, never
|
|
28
|
+
* `display`: the island keeps its box, so the reveal shifts no layout. The animation is the reveal
|
|
29
|
+
* that needs no script: a zero-length step to `visible` after the cap, held by `forwards` (reduced
|
|
30
|
+
* motion shortens durations, never delays). The runtime takes both properties off when it reveals.
|
|
31
|
+
*/
|
|
32
|
+
export const ISLAND_HOLD_ATTRIBUTES =
|
|
33
|
+
`${ISLAND_HOLD_ATTRIBUTE} ` +
|
|
34
|
+
`style="visibility:hidden;animation:${ISLAND_HOLD_REVEAL} 0s ${ISLAND_HOLD_MS}ms forwards"`;
|
|
35
|
+
|
|
36
|
+
// Booted at once, whatever the route's strategy: a hidden island receives no press and sits in no
|
|
37
|
+
// idle slot worth waiting for, so `interaction` would hold it until the cap and show the stale
|
|
38
|
+
// markup anyway. `boot` is the prelude's, so the strategy below chains on the SAME `el.__x` and
|
|
39
|
+
// mounts nothing twice. `el.__h`, not `each`'s `el.__v`: `each` would mark the root visited and
|
|
40
|
+
// the strategy's own pass would then skip it. Emitted FIRST after the prelude (`hydrate.ts`), so
|
|
41
|
+
// a strategy part that throws — an old WebView with no IntersectionObserver — cannot strand it.
|
|
42
|
+
// The strategy's capture listeners let go on the first event after the mount (`catchUp`'s `on`),
|
|
43
|
+
// since the hold, not a caught event, is what booted the island.
|
|
44
|
+
//
|
|
45
|
+
// Revealed TOGETHER, once every held island of this pass has settled (or at the cap): one record
|
|
46
|
+
// shown in two islands is rebuilt by whichever island holds its writer — a queued write's overlay
|
|
47
|
+
// is restored inside THAT island's mount — so a reader revealed at its own mount painted the
|
|
48
|
+
// record without it (#506, measured in `offline-like.e2e.test.ts`). A failed mount counts as
|
|
49
|
+
// settled (`hush`): its server markup is shown rather than hidden forever.
|
|
50
|
+
export const RUNTIME_HOLD = `
|
|
51
|
+
var hs=[];Array.prototype.forEach.call(document.querySelectorAll('[${ISLAND_HOLD_ATTRIBUTE}]'),function(el){
|
|
52
|
+
if(!el.__h){el.__h=1;hs.push(el)}});
|
|
53
|
+
if(hs.length){var t,r=function(){clearTimeout(t);hs.forEach(function(el){el.removeAttribute('${ISLAND_HOLD_ATTRIBUTE}');
|
|
54
|
+
el.style.removeProperty('visibility');el.style.removeProperty('animation')})};
|
|
55
|
+
t=setTimeout(r,${ISLAND_HOLD_MS});Promise.all(hs.map(function(el){return boot(el).then(null,hush)})).then(r)}
|
|
56
|
+
`.trim();
|
package/src/island-props.ts
CHANGED
|
@@ -173,7 +173,7 @@ export function checkIslandProps(
|
|
|
173
173
|
'and every one of them ships inside the HTML on every request — ' +
|
|
174
174
|
heaviest.map(([key, size]) => `props.${key} is ${size} B of the ${bytes}`).join(', '),
|
|
175
175
|
`in ${file}, pass ${heaviest.map(([key]) => `\`${key}: []\``).join(' and ')} beside ` +
|
|
176
|
-
`\`${first}Endpoint:
|
|
176
|
+
`\`${first}Endpoint: queryPath('<queryName>')\` (@ultimat3/core) and fetch the rows inside ` +
|
|
177
177
|
'the island after mount — a list that is the same on every request is a dataset, not a ' +
|
|
178
178
|
'prop; an id, a count and a URL are',
|
|
179
179
|
);
|
package/src/island.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* graph (axiom 6). WHEN it wakes is the route's `hydrate`, never a second declaration here.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
9
10
|
import { IslandInvalidError } from './errors';
|
|
10
11
|
import type { JsonValue } from './island-props';
|
|
11
12
|
import type { JsxProps } from './jsx';
|
|
@@ -195,7 +196,10 @@ function normalizeIsland(declaration: IslandDeclaration): IslandSpec {
|
|
|
195
196
|
throw new IslandInvalidError(
|
|
196
197
|
`island src ${JSON.stringify(src)} is not an island file: a module ships to the browser ` +
|
|
197
198
|
`only if its name says so, and ${ISLAND_EXTENSION} is the one spelling that says it`,
|
|
198
|
-
|
|
199
|
+
// Screened (security audit of plan 101 sweep 1c, M1): `UNSAFE_SPECIFIER` lets `$(…)`, `;`,
|
|
200
|
+
// `|` and `&` through. A leading `./` is dropped first — the same path to a shell, and
|
|
201
|
+
// `renderFixShellArg` refuses a leading `.` — so the common specifier stays pasteable.
|
|
202
|
+
`git mv -- ${renderFixShellArg(src.replace(/^\.\//, ''), "'<module>'")} ${renderFixShellArg(`${stem.replace(/^\.\//, '')}${ISLAND_EXTENSION}`, `'<name>${ISLAND_EXTENSION}'`)}, then pass src: '${stem}${ISLAND_EXTENSION}'`,
|
|
199
203
|
);
|
|
200
204
|
}
|
|
201
205
|
|
package/src/module-loader.ts
CHANGED
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
* `bun test` all load a component the same way and there is no separate "bundled" behaviour.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
// why: Bun ships no path API, and a relative app root has to be resolved against the working
|
|
8
|
-
// directory before it can be compared with the absolute paths the Bun plugin hands this loader.
|
|
9
|
-
import { resolve } from 'node:path';
|
|
10
7
|
import { renderThrowable } from '@ultimat3/core';
|
|
11
8
|
import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules';
|
|
12
9
|
import { PrerenderFailedError } from './errors';
|
|
10
|
+
import { comparablePath, isPathBelow, pathBelow } from './stylesheet-path';
|
|
13
11
|
import type { Surface } from './surfaces';
|
|
14
12
|
import { surfaceUnder } from './surfaces';
|
|
15
13
|
|
|
@@ -42,7 +40,8 @@ const RENDER_PACKAGE = '@ultimat3/render';
|
|
|
42
40
|
* `Bun.plugin` ignores `onResolve` outright — the callback never fires, from module scope or from
|
|
43
41
|
* a `--preload`, and a virtual namespace never resolves — and ignores `resolveDir` on an `onLoad`
|
|
44
42
|
* result. Neither seam can carry the resolution, so the specifier must. Revisit if Bun wires
|
|
45
|
-
* either: an `onResolve` hook would leave every compiled module byte-identical.
|
|
43
|
+
* either: an `onResolve` hook would leave every compiled module byte-identical. Upstream:
|
|
44
|
+
* https://github.com/oven-sh/bun/issues/40579.
|
|
46
45
|
*
|
|
47
46
|
* The fallback is the status quo, for the one frame that cannot answer: inside a
|
|
48
47
|
* `bun build --compile` binary `import.meta.url` is `file:///$bunfs/root/<name>` and
|
|
@@ -120,9 +119,12 @@ export function registeredStylesheets(): readonly Stylesheet[] {
|
|
|
120
119
|
/**
|
|
121
120
|
* The directory a sheet's surface is read below. Absent, the process's working directory — which
|
|
122
121
|
* is the app root in the container (`WORKDIR /app`) and under every `x` command run from one.
|
|
122
|
+
* Held as a `comparablePath`, like every claim: on Windows each arrives as `D:\app\…`.
|
|
123
123
|
*/
|
|
124
124
|
let stylesheetRoot: string | undefined;
|
|
125
125
|
|
|
126
|
+
const rootOfSheets = (): string => stylesheetRoot ?? comparablePath(process.cwd());
|
|
127
|
+
|
|
126
128
|
/**
|
|
127
129
|
* Directories a package claimed for ONE surface, longest first. A package's sheet has no surface
|
|
128
130
|
* of its own and rides both graphs — right for a design system, and the reason an app's static
|
|
@@ -130,11 +132,13 @@ let stylesheetRoot: string | undefined;
|
|
|
130
132
|
*/
|
|
131
133
|
const claims: { readonly dir: string; readonly surface: Surface }[] = [];
|
|
132
134
|
|
|
133
|
-
const claimedSurface = (path: string): Surface | undefined =>
|
|
134
|
-
|
|
135
|
+
const claimedSurface = (path: string): Surface | undefined => {
|
|
136
|
+
const comparable = comparablePath(path);
|
|
137
|
+
return claims.find((claim) => isPathBelow(comparable, claim.dir))?.surface;
|
|
138
|
+
};
|
|
135
139
|
|
|
136
140
|
const surfaceOfSheet = (path: string): Surface | null =>
|
|
137
|
-
claimedSurface(path) ?? surfaceUnder(
|
|
141
|
+
claimedSurface(path) ?? surfaceUnder(rootOfSheets(), comparablePath(path));
|
|
138
142
|
|
|
139
143
|
/** Every registered sheet, classified again; the revision moves only when an answer does. */
|
|
140
144
|
const reclassify = (): void => {
|
|
@@ -157,7 +161,7 @@ const reclassify = (): void => {
|
|
|
157
161
|
* it standing, because the module that made it is cached and will not make it again.
|
|
158
162
|
*/
|
|
159
163
|
export function claimStylesheets(dir: string, surface: Surface): void {
|
|
160
|
-
const claimed =
|
|
164
|
+
const claimed = comparablePath(dir);
|
|
161
165
|
if (claims.some((claim) => claim.dir === claimed && claim.surface === surface)) return;
|
|
162
166
|
claims.push({ dir: claimed, surface });
|
|
163
167
|
claims.sort((a, b) => b.dir.length - a.dir.length);
|
|
@@ -174,7 +178,7 @@ export function claimStylesheets(dir: string, surface: Surface): void {
|
|
|
174
178
|
*/
|
|
175
179
|
export function setStylesheetRoot(root: string | undefined): void {
|
|
176
180
|
// Resolved: `loadApp('.')` is a legal call, and `.` is a prefix of no absolute path.
|
|
177
|
-
stylesheetRoot = root === undefined ? undefined :
|
|
181
|
+
stylesheetRoot = root === undefined ? undefined : comparablePath(root);
|
|
178
182
|
reclassify();
|
|
179
183
|
}
|
|
180
184
|
|
|
@@ -221,11 +225,12 @@ const OWNER_RANK = (sheet: Stylesheet): number =>
|
|
|
221
225
|
// A claimed sheet is a package's whichever surface carries it: library first.
|
|
222
226
|
sheet.surface === null || sheet.claimed ? 0 : sheet.surface === 'shared' ? 1 : 2;
|
|
223
227
|
|
|
224
|
-
/**
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Relative to the app root and `/`-separated, so a container at `/app`, a laptop and a Windows
|
|
230
|
+
* checkout at `D:\app` order one app alike — and so mint one stylesheet hash.
|
|
231
|
+
*/
|
|
232
|
+
const orderPath = (sheet: Stylesheet): string =>
|
|
233
|
+
pathBelow(comparablePath(sheet.file), rootOfSheets());
|
|
229
234
|
|
|
230
235
|
/** See `stylesFor`: global layer, then owner, then path. Exported for the tests that pin it. */
|
|
231
236
|
export function stylesheetOrder(a: Stylesheet, b: Stylesheet): number {
|
package/src/navigation-fetch.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The client router's one request: the headers the server's navigation gate reads (purpose, the
|
|
3
3
|
* document's `<app>:<surface>`, its principal, its build on a GET), `redirect: 'manual'` so no
|
|
4
|
-
* redirect is followed behind the router's back,
|
|
5
|
-
* page's text, or a non-page's
|
|
4
|
+
* redirect is followed behind the router's back, `cache: 'no-cache'` so the browser's HTTP cache
|
|
5
|
+
* never answers, and the answer read ONCE into an `Answer` — the page's text, or a non-page's
|
|
6
|
+
* bytes to hand over (a prefetch's are dropped unread).
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
CLIENT_BUILD_META,
|
|
11
|
+
CLIENT_NAVIGATION_HEADER,
|
|
12
|
+
CLIENT_NAVIGATION_LOCATION_HEADER,
|
|
13
|
+
CLIENT_NAVIGATION_SCOPE_HEADER,
|
|
14
|
+
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
15
|
+
CLIENT_SCOPE_META,
|
|
16
|
+
} from '@ultimat3/core/page';
|
|
17
|
+
import { NAVIGATION_META } from './navigation-rules';
|
|
16
18
|
|
|
17
19
|
/** One fetched answer, read once — a prefetch and the click after it share it. */
|
|
18
20
|
export interface Answer {
|
|
@@ -41,16 +43,24 @@ export function fetchDocument(
|
|
|
41
43
|
): Promise<Answer> {
|
|
42
44
|
const headers: Record<string, string> = {
|
|
43
45
|
accept: 'text/html,application/xhtml+xml',
|
|
44
|
-
[
|
|
45
|
-
[
|
|
46
|
+
[CLIENT_NAVIGATION_HEADER]: purpose,
|
|
47
|
+
[CLIENT_NAVIGATION_SURFACE_HEADER]: metaOf(doc, NAVIGATION_META) ?? '',
|
|
46
48
|
};
|
|
47
49
|
const scope = metaOf(doc, CLIENT_SCOPE_META);
|
|
48
|
-
if (scope !== null) headers[
|
|
50
|
+
if (scope !== null) headers[CLIENT_NAVIGATION_SCOPE_HEADER] = scope;
|
|
49
51
|
// GET only: a skewed GET is refused before any route runs; a POST is never refused mid-submit.
|
|
50
52
|
const build = metaOf(doc, CLIENT_BUILD_META);
|
|
51
53
|
if ((init.method ?? 'GET') === 'GET' && build !== null) headers[CLIENT_BUILD_META] = build;
|
|
54
|
+
// `no-cache`: the browser's HTTP cache never answers — a `stale-while-revalidate` page would be
|
|
55
|
+
// swapped in stale after a write, a sign-out or a deploy (#693); the router's cache is the only one.
|
|
52
56
|
return win
|
|
53
|
-
.fetch(url, {
|
|
57
|
+
.fetch(url, {
|
|
58
|
+
...init,
|
|
59
|
+
cache: 'no-cache',
|
|
60
|
+
credentials: 'same-origin',
|
|
61
|
+
redirect: 'manual',
|
|
62
|
+
headers,
|
|
63
|
+
})
|
|
54
64
|
.then(async (response) => {
|
|
55
65
|
const contentType = response.headers.get('content-type') ?? '';
|
|
56
66
|
const opaqueRedirect = response.type === 'opaqueredirect';
|
|
@@ -61,7 +71,7 @@ export function fetchDocument(
|
|
|
61
71
|
if (purpose === 'prefetch') await response.body?.cancel();
|
|
62
72
|
else body = await response.blob();
|
|
63
73
|
}
|
|
64
|
-
const location = response.headers.get(
|
|
74
|
+
const location = response.headers.get(CLIENT_NAVIGATION_LOCATION_HEADER);
|
|
65
75
|
return {
|
|
66
76
|
status: response.status,
|
|
67
77
|
opaqueRedirect,
|
package/src/navigation-rules.ts
CHANGED
|
@@ -1,33 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which clicks and submits the client router takes, and which it leaves to the browser — as pure
|
|
3
3
|
* functions over plain facts, so every rule is testable without a DOM and the router
|
|
4
|
-
* (`navigation.ts`) only gathers the facts. Also the one declaration of every attribute
|
|
5
|
-
*
|
|
4
|
+
* (`navigation.ts`) only gathers the facts. Also the one declaration of every attribute and event
|
|
5
|
+
* name the router shares with the documents the server renders and the apps that listen; the
|
|
6
|
+
* router's headers are core's (`CLIENT_NAVIGATION_*_HEADER`), imported where they are sent.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
-
CLIENT_NAVIGATION_HEADER,
|
|
10
|
-
CLIENT_NAVIGATION_LOCATION_HEADER,
|
|
11
|
-
CLIENT_NAVIGATION_SCOPE_HEADER,
|
|
12
|
-
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
13
|
-
} from '@ultimat3/core/page';
|
|
14
|
-
|
|
15
9
|
/**
|
|
16
10
|
* `<meta name="ultimate-navigation" content="<app>:<surface>">` — present only on an opted-in
|
|
17
11
|
* surface. The app's name is part of it, so two apps on one origin (web and admin) are two routers.
|
|
18
12
|
*/
|
|
19
13
|
export const NAVIGATION_META = 'ultimate-navigation';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
export const NAVIGATION_HEADER = CLIENT_NAVIGATION_HEADER;
|
|
25
|
-
/** The `<app>:<surface>` of the document the router runs in, on every router fetch. */
|
|
26
|
-
export const NAVIGATION_SURFACE_HEADER = CLIENT_NAVIGATION_SURFACE_HEADER;
|
|
27
|
-
/** The document's principal (`ultimate-scope`), when it carries one. */
|
|
28
|
-
export const NAVIGATION_SCOPE_HEADER = CLIENT_NAVIGATION_SCOPE_HEADER;
|
|
29
|
-
/** On a `204`: load this URL with a real navigation — the server's hand-over. */
|
|
30
|
-
export const NAVIGATION_LOCATION_HEADER = CLIENT_NAVIGATION_LOCATION_HEADER;
|
|
14
|
+
// The four router HEADERS are core's (`CLIENT_NAVIGATION_HEADER`, `…_SURFACE_HEADER`,
|
|
15
|
+
// `…_SCOPE_HEADER`, `…_LOCATION_HEADER`), imported by the fetcher and by `@ultimat3/http`'s gate —
|
|
16
|
+
// 25.0.0 deleted the `NAVIGATION_*_HEADER` aliases this file published for them (`X_HELPER_COPY`).
|
|
31
17
|
/** On a link or form: always a full document load. */
|
|
32
18
|
export const NAVIGATION_RELOAD_ATTRIBUTE = 'data-x-reload';
|
|
33
19
|
/** On a link: followed softly, never fetched before it is clicked. */
|
|
@@ -275,7 +261,15 @@ export function responseVerdict(facts: ResponseFacts): ResponseVerdict {
|
|
|
275
261
|
facts.build !== null && facts.nextBuild !== null && facts.build !== facts.nextBuild;
|
|
276
262
|
// A GET refused for skew (409 before any route ran) or rendered by another build: load it.
|
|
277
263
|
if (get && skewed) return load(facts.requested, 'another build');
|
|
278
|
-
|
|
264
|
+
const html = /^\s*text\/html\b/i.test(facts.contentType);
|
|
265
|
+
// An error that is not a page (an offline service worker's empty 503, a JSON 404) is nothing to
|
|
266
|
+
// show — handed over, it became a `blob:` URL in the address bar. A GET is the browser's to
|
|
267
|
+
// load (a worker answers a real navigation with its offline page); a POST is never re-sent.
|
|
268
|
+
if (facts.status >= 400 && !html) {
|
|
269
|
+
const reason = 'an error answer that is not a page';
|
|
270
|
+
return get ? load(facts.requested, reason) : { kind: 'failed', reason };
|
|
271
|
+
}
|
|
272
|
+
if (!html) return { kind: 'hand-over' };
|
|
279
273
|
// A POST answered in place IS the page the browser would have shown, whatever rendered it —
|
|
280
274
|
// and when that is another principal's or build's, the caller stops trusting this tab
|
|
281
275
|
// (`answerMovesTab`): every later navigation is a real load.
|
package/src/navigation.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
NAVIGATED_EVENT,
|
|
47
47
|
NAVIGATING_ATTRIBUTE,
|
|
48
48
|
NAVIGATION_ERROR_EVENT,
|
|
49
|
+
NAVIGATION_MAX_HOPS,
|
|
49
50
|
NAVIGATION_META,
|
|
50
51
|
NAVIGATION_NO_PREFETCH_ATTRIBUTE,
|
|
51
52
|
NAVIGATION_PREFETCH_DELAY_MS,
|
|
@@ -103,7 +104,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
103
104
|
let rendered = withoutFragment(win.location.href);
|
|
104
105
|
/** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
|
|
105
106
|
let navigatingTo: string | undefined;
|
|
106
|
-
/** The view transition still animating
|
|
107
|
+
/** The view transition still animating — skipped only for a click it swallowed (`onClick`). */
|
|
107
108
|
let animating: RunningTransition | undefined;
|
|
108
109
|
/** Between a press and its click: the focus the press gives a link is not a hover. */
|
|
109
110
|
let pressed = false;
|
|
@@ -172,6 +173,12 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
172
173
|
};
|
|
173
174
|
|
|
174
175
|
const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
|
|
176
|
+
// The redirect chain's only exit is `hops >= NAVIGATION_MAX_HOPS`, which `NaN` never meets.
|
|
177
|
+
// `hops` is this router's own recursion counter (nothing outside it can reach `navigate`), so a
|
|
178
|
+
// non-count is a broken caller, not input: it fails CLOSED as an exhausted chain — one request,
|
|
179
|
+
// then the browser loads the page — instead of shipping the error renderer (~2 KB) to every page.
|
|
180
|
+
const asked = options.hops ?? 0;
|
|
181
|
+
const hops = Number.isSafeInteger(asked) && asked >= 0 ? asked : NAVIGATION_MAX_HOPS;
|
|
175
182
|
const method = options.method ?? 'GET';
|
|
176
183
|
// After an answer for another principal or build was shown in place, this tab is no longer
|
|
177
184
|
// trusted to swap: every navigation is a real load (`answerMovesTab`).
|
|
@@ -212,7 +219,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
212
219
|
opaqueRedirect: answer.opaqueRedirect,
|
|
213
220
|
location: answer.location,
|
|
214
221
|
contentType: answer.contentType,
|
|
215
|
-
hops
|
|
222
|
+
hops,
|
|
216
223
|
surface: metaOf(doc, NAVIGATION_META),
|
|
217
224
|
nextSurface: next === null ? null : metaOf(next, NAVIGATION_META),
|
|
218
225
|
build: metaOf(doc, CLIENT_BUILD_META),
|
|
@@ -236,7 +243,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
236
243
|
case 'follow':
|
|
237
244
|
await navigate(verdict.url, {
|
|
238
245
|
history: options.history === 'none' ? 'replace' : 'push',
|
|
239
|
-
hops:
|
|
246
|
+
hops: hops + 1,
|
|
240
247
|
});
|
|
241
248
|
return;
|
|
242
249
|
case 'hand-over':
|
|
@@ -315,9 +322,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
315
322
|
pressed = true;
|
|
316
323
|
pressedOverTransition = animating !== undefined;
|
|
317
324
|
// A press is not a hover: the click it becomes navigates, and a guess on its heels is a
|
|
318
|
-
// second request for the same page.
|
|
325
|
+
// second request for the same page. A press alone never skips the running transition — that
|
|
326
|
+
// snapped every named element to its end mid-glide (#621); only the click below may.
|
|
319
327
|
win.clearTimeout(intent);
|
|
320
|
-
animating?.skipTransition?.();
|
|
321
328
|
};
|
|
322
329
|
const onRelease = (): void => {
|
|
323
330
|
pressed = false;
|
|
@@ -325,13 +332,15 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
325
332
|
|
|
326
333
|
const onClick = (event: MouseEvent): void => {
|
|
327
334
|
win.clearTimeout(intent);
|
|
328
|
-
// While a view transition paints, the browser hit-tests every press to `<html
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
335
|
+
// While a view transition paints, the browser hit-tests every press to `<html>` — Chrome does
|
|
336
|
+
// so whatever `pointer-events` the `::view-transition` overlay has — so the click reached
|
|
337
|
+
// nothing: the visitor's first click after a swap was lost. It is given to the element under
|
|
338
|
+
// the pointer, whatever it is: a link, a submit button, an island's own control. Only a
|
|
339
|
+
// skipped transition hit-tests the page, so the skip happens HERE, at the click, never at
|
|
340
|
+
// the press; when the animation already ended between the two, there is nothing to skip.
|
|
333
341
|
if (pressedOverTransition && event.target === doc.documentElement) {
|
|
334
342
|
pressedOverTransition = false;
|
|
343
|
+
animating?.skipTransition?.();
|
|
335
344
|
const hit = doc.elementFromPoint(event.clientX, event.clientY);
|
|
336
345
|
if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
|
|
337
346
|
return;
|
package/src/registry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The route table — the single source of route truth. File path → URL conventions for
|
|
3
|
-
* `site/`, `app/` and `api/`, plus `
|
|
3
|
+
* `site/`, `app/` and `api/`, plus `describePages()`, the serializable projection that
|
|
4
4
|
* `x.manifest.json`, the `/_x` routes panel, the sitemap and `sw.js` are all generated
|
|
5
5
|
* from. Nothing downstream may keep its own list of routes.
|
|
6
6
|
*/
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
setMountedRoutes,
|
|
28
28
|
} from './mounted-routes';
|
|
29
29
|
import type { RouteConfig, RouteData } from './route';
|
|
30
|
-
import { isRouteConfig,
|
|
30
|
+
import { isRouteConfig, routeTagKeys } from './route';
|
|
31
31
|
import type { RouteComponent } from './route-component';
|
|
32
32
|
import type { CompiledPattern } from './route-pattern';
|
|
33
33
|
import { compilePattern } from './route-pattern';
|
|
@@ -206,7 +206,7 @@ function toUrlSegment(segment: string): string {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
const routes = new Map<string, RouteEntry>();
|
|
209
|
-
/** The table `
|
|
209
|
+
/** The table `describePages()` last built, dropped whenever a route registers or the registry clears. */
|
|
210
210
|
let described: readonly RouteDescriptor[] | undefined;
|
|
211
211
|
|
|
212
212
|
export interface RegisterRouteInput<TData = RouteData> {
|
|
@@ -250,7 +250,7 @@ export function registerRoute<TData = RouteData>(
|
|
|
250
250
|
input: RegisterRouteInput<TData>,
|
|
251
251
|
): RouteEntry<TData> {
|
|
252
252
|
// The type already refuses a declaration; this catches the JS caller and the cast. Without it a
|
|
253
|
-
// raw declaration registers, and `
|
|
253
|
+
// raw declaration registers, and `describePages()` is where it surfaces — as a bare TypeError
|
|
254
254
|
// on `config.budget.js`, one build step away from the file that caused it.
|
|
255
255
|
if (!isRouteConfig(input.config)) {
|
|
256
256
|
throw new RouteUnnormalizedError(
|
|
@@ -367,7 +367,7 @@ export function routeCount(): number {
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
export function routeEntries(): readonly RouteEntry[] {
|
|
370
|
-
// Code units, never `localeCompare` — `
|
|
370
|
+
// Code units, never `localeCompare` — `describePages()` below promises an order "identical for
|
|
371
371
|
// identical input", and `localeCompare` with no locale argument reads the runtime's ICU default.
|
|
372
372
|
return [...routes.values()].sort((a, b) => byCodeUnit(a.path, b.path));
|
|
373
373
|
}
|
|
@@ -380,7 +380,7 @@ export function routeFor(path: string): RouteEntry | undefined {
|
|
|
380
380
|
* The manifest projection: JSON-safe, sorted by path, identical for identical input.
|
|
381
381
|
* Determinism matters because `sw.js` and the sitemap are diffed across deploys.
|
|
382
382
|
*/
|
|
383
|
-
export function
|
|
383
|
+
export function describePages(): readonly RouteDescriptor[] {
|
|
384
384
|
// Built once per registry change and handed out as the SAME frozen array: an ISR regeneration
|
|
385
385
|
// looked its route up through this on every request, re-sorting the whole table each time, and
|
|
386
386
|
// a stable identity is what lets `render-isr.ts` compile its matchers once per table.
|
|
@@ -422,7 +422,7 @@ const descriptorOf = (
|
|
|
422
422
|
mode: config.render,
|
|
423
423
|
offline: config.offline,
|
|
424
424
|
hydrate: config.hydrate,
|
|
425
|
-
revalidateTags:
|
|
425
|
+
revalidateTags: routeTagKeys(config.revalidate?.tags),
|
|
426
426
|
revalidateTtl: config.revalidate?.ttl ?? null,
|
|
427
427
|
prerenderable: config.prerender !== undefined,
|
|
428
428
|
dynamic: params > 0,
|
package/src/render-html.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
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
|
+
import { escapeHtml, renderFixShellArg, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import {
|
|
9
9
|
IslandInvalidError,
|
|
10
10
|
IslandNotHydratedError,
|
|
11
11
|
IslandPropsInvalidError,
|
|
12
12
|
PrerenderFailedError,
|
|
13
13
|
} from './errors';
|
|
14
|
-
import {
|
|
14
|
+
import { renderAttributes, VOID_ELEMENTS } from './html';
|
|
15
15
|
import { emitIslandAttributes, emitIslandProps } from './hydrate';
|
|
16
16
|
import type { IslandNode } from './island';
|
|
17
17
|
import { isIslandNode } from './island';
|
|
@@ -164,7 +164,9 @@ export async function renderComponent(
|
|
|
164
164
|
// null-prototype object (`String()` raises) or an Error whose `message` getter does — and
|
|
165
165
|
// this frame is the last thing between that and a build failure with no code at all.
|
|
166
166
|
`rendering the component in ${file} threw: ${renderThrowable(error)}`,
|
|
167
|
-
|
|
167
|
+
// The route file is author text and `$(…)` is a legal name: screened where it enters the
|
|
168
|
+
// reproduce command (security audit of plan 101 sweep 1c).
|
|
169
|
+
`run \`bun test ${renderFixShellArg(file.replace(/\.tsx?$/, '.test.ts'), "'<the route's test file>'")}\` to reproduce, then fix ${file}`,
|
|
168
170
|
);
|
|
169
171
|
}
|
|
170
172
|
}
|
package/src/render-isr.ts
CHANGED
|
@@ -17,11 +17,11 @@ import {
|
|
|
17
17
|
unregisterDependent,
|
|
18
18
|
} from '@ultimat3/cache';
|
|
19
19
|
import type { Scheduler } from '@ultimat3/core';
|
|
20
|
-
import {
|
|
20
|
+
import { finiteCount, logger, renderThrowable, singleFlight } from '@ultimat3/core';
|
|
21
21
|
import { parseTtlMs } from './duration';
|
|
22
22
|
import { finiteStatus, isRenderStatus } from './finite-status';
|
|
23
23
|
import type { RouteDescriptor } from './registry';
|
|
24
|
-
import {
|
|
24
|
+
import { describePages } from './registry';
|
|
25
25
|
import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
|
|
26
26
|
import { memoryIsrStore } from './render-isr-store';
|
|
27
27
|
import { contentHash, staticHeaders } from './render-static';
|
|
@@ -153,16 +153,16 @@ let installedRevalidator: Revalidator | undefined;
|
|
|
153
153
|
/** What `registerRevalidator` is handed on detach: the framework's "nothing to revalidate". */
|
|
154
154
|
const NO_REVALIDATION: Revalidator = () => undefined;
|
|
155
155
|
|
|
156
|
-
export function
|
|
156
|
+
export function isrController(options: IsrControllerOptions = {}): IsrController {
|
|
157
157
|
const store = options.store ?? memoryIsrStore();
|
|
158
158
|
const now = options.now ?? (() => Date.now());
|
|
159
|
-
const routes = options.routes ??
|
|
159
|
+
const routes = options.routes ?? describePages;
|
|
160
160
|
const isrDependents =
|
|
161
161
|
options.isrDependents ?? ((tags: readonly CacheTag[]) => dependentsOfKind(tags, 'isr-route'));
|
|
162
162
|
const buildId = options.buildId ?? 'dev';
|
|
163
|
-
const flight =
|
|
163
|
+
const flight = singleFlight({
|
|
164
164
|
deadlineMs: finiteCount(
|
|
165
|
-
'
|
|
165
|
+
'isrController',
|
|
166
166
|
'regenerateDeadlineMs',
|
|
167
167
|
options.regenerateDeadlineMs ?? DEFAULT_ISR_REGENERATE_DEADLINE_MS,
|
|
168
168
|
1,
|
|
@@ -380,7 +380,7 @@ interface RouteMatcher {
|
|
|
380
380
|
test(storedPath: string): boolean;
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
-
/** One compiled set per route TABLE — `
|
|
383
|
+
/** One compiled set per route TABLE — `describePages()` hands out one array per registry change. */
|
|
384
384
|
const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
|
|
385
385
|
|
|
386
386
|
/**
|
package/src/render-ssr.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { BUILD_ID_HEADER } from '@ultimat3/core';
|
|
8
9
|
import { cacheControl } from '@ultimat3/http';
|
|
9
10
|
import { finiteStatus } from './finite-status';
|
|
10
11
|
import type { RouteEntry } from './registry';
|
|
@@ -81,6 +82,6 @@ export function ssrHeaders(
|
|
|
81
82
|
? PRIVATE_NO_STORE
|
|
82
83
|
: 'public, max-age=0, s-maxage=30, stale-while-revalidate=300',
|
|
83
84
|
vary: [...vary].sort().join(', '),
|
|
84
|
-
|
|
85
|
+
[BUILD_ID_HEADER]: options.buildId,
|
|
85
86
|
};
|
|
86
87
|
}
|
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 { renderCauseValue, renderThrowable, useContext } from '@ultimat3/core';
|
|
7
|
+
import { BUILD_ID_HEADER, renderCauseValue, 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';
|
|
@@ -161,7 +161,7 @@ export function staticHeaders(hash: string, buildId: string): Readonly<Record<st
|
|
|
161
161
|
// Revalidate cheaply: the HTML URL is stable, its content hash is not.
|
|
162
162
|
'cache-control': 'public, max-age=0, must-revalidate',
|
|
163
163
|
etag: `"${hash}"`,
|
|
164
|
-
|
|
164
|
+
[BUILD_ID_HEADER]: buildId,
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
package/src/render-stream.ts
CHANGED
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
* contains no interactive island costs literally zero JS.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
14
|
+
import { BUILD_ID_HEADER, escapeHtml, finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
15
15
|
import { finiteStatus } from './finite-status';
|
|
16
|
-
import { escapeHtml } from './html';
|
|
17
16
|
import type { RenderResult } from './route';
|
|
18
17
|
import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
|
|
19
18
|
|
|
@@ -225,7 +224,7 @@ export function streamResult(plan: StreamPlan, options: StreamOptions, status =
|
|
|
225
224
|
// Proxies that buffer defeat the entire mode.
|
|
226
225
|
'x-accel-buffering': 'no',
|
|
227
226
|
'transfer-encoding': 'chunked',
|
|
228
|
-
|
|
227
|
+
[BUILD_ID_HEADER]: options.buildId,
|
|
229
228
|
},
|
|
230
229
|
body: renderStreamHtml(plan, options),
|
|
231
230
|
};
|
package/src/route.ts
CHANGED
|
@@ -265,8 +265,10 @@ export interface RenderResult {
|
|
|
265
265
|
/**
|
|
266
266
|
* Route descriptors carry tags in `@ultimat3/cache`'s wire form (`post`, `post:123`), the
|
|
267
267
|
* same strings every cache tier speaks. Render never invents a key convention of its own.
|
|
268
|
+
* Declaration order is kept — unlike `@ultimat3/cache`'s `tagKeys`, the sorted identity form —
|
|
269
|
+
* so the name says which list this is, and it stays out of the public barrel.
|
|
268
270
|
*/
|
|
269
|
-
export function
|
|
271
|
+
export function routeTagKeys(tags: readonly CacheTag[] | undefined): readonly string[] {
|
|
270
272
|
return tags === undefined ? [] : serializeTags(tags);
|
|
271
273
|
}
|
|
272
274
|
|
package/src/server.ts
CHANGED
|
@@ -47,10 +47,10 @@ export type {
|
|
|
47
47
|
IsrServeResult,
|
|
48
48
|
} from './render-isr';
|
|
49
49
|
export {
|
|
50
|
-
createIsrController,
|
|
51
50
|
DEFAULT_ISR_REGENERATE_DEADLINE_MS,
|
|
52
51
|
ISR_LOCALE_PARAM,
|
|
53
52
|
invalidateAndRevalidate,
|
|
53
|
+
isrController,
|
|
54
54
|
isrKey,
|
|
55
55
|
} from './render-isr';
|
|
56
56
|
export type { IsrEntry, IsrState, IsrStore, MemoryIsrStoreOptions } from './render-isr-store';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One responsibility: the ONE spelling of a filesystem path the stylesheet registry compares — `/`
|
|
2
|
+
// separators, `..` folded, a Windows drive letter upper-cased — so "is this sheet below that
|
|
3
|
+
// directory" means the same on Windows, where Bun hands every path over as `D:\app\…`, as on POSIX.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path API, and a relative root must be resolved against the working directory.
|
|
6
|
+
import { posix, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** POSIX-absolute (`/srv/app`), drive-absolute (`D:\app`, `d:/app`) or UNC (`\\host\share`). */
|
|
9
|
+
const ABSOLUTE = /^(?:[A-Za-z]:)?[\\/]/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Comparable, never displayed: the registry keeps every sheet under the path it was handed and only
|
|
13
|
+
* COMPARES through this. An absolute path is not re-resolved, because `resolve('/app')` on Windows
|
|
14
|
+
* is `D:\app` — a drive the sheets a caller handed as `/app/…` never carry.
|
|
15
|
+
*/
|
|
16
|
+
export function comparablePath(path: string): string {
|
|
17
|
+
const absolute = ABSOLUTE.test(path) ? path : resolve(path);
|
|
18
|
+
const slashed = posix.normalize(absolute.replaceAll('\\', '/'));
|
|
19
|
+
const drive = slashed.replace(/^([a-z]):/, (_, letter: string) => `${letter.toUpperCase()}:`);
|
|
20
|
+
// A trailing separator is dropped so `${dir}/` is one prefix — except the filesystem root itself.
|
|
21
|
+
const trimmed = drive.replace(/\/+$/, '');
|
|
22
|
+
return trimmed === '' || trimmed.endsWith(':') ? `${trimmed}/` : trimmed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `path` sits below `dir` — both already `comparablePath`. A sibling sharing a prefix does not. */
|
|
26
|
+
export function isPathBelow(path: string, dir: string): boolean {
|
|
27
|
+
return path.startsWith(dir.endsWith('/') ? dir : `${dir}/`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `path` relative to `dir` when it sits below it, else `path` — both already `comparablePath`. */
|
|
31
|
+
export function pathBelow(path: string, dir: string): string {
|
|
32
|
+
if (!isPathBelow(path, dir)) return path;
|
|
33
|
+
return path.slice(dir.endsWith('/') ? dir.length : dir.length + 1);
|
|
34
|
+
}
|