brustjs 0.1.48-alpha → 0.1.50-alpha
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/package.json +7 -7
- package/runtime/cli/build.ts +7 -0
- package/runtime/cli/ssg.ts +94 -23
- package/runtime/index.js +52 -52
- package/runtime/index.ts +6 -0
- package/runtime/islands/bootstrap.ts +37 -22
- package/runtime/islands/page-cache.ts +32 -2
- package/runtime/islands/view-transition.ts +50 -0
- package/runtime/routes.ts +257 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brustjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.50-alpha",
|
|
4
4
|
"description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"typescript": "^6.0.3"
|
|
42
42
|
},
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"brustjs-darwin-x64": "0.1.
|
|
45
|
-
"brustjs-darwin-arm64": "0.1.
|
|
46
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
48
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
49
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
44
|
+
"brustjs-darwin-x64": "0.1.50-alpha",
|
|
45
|
+
"brustjs-darwin-arm64": "0.1.50-alpha",
|
|
46
|
+
"brustjs-linux-x64-gnu": "0.1.50-alpha",
|
|
47
|
+
"brustjs-linux-arm64-gnu": "0.1.50-alpha",
|
|
48
|
+
"brustjs-linux-x64-musl": "0.1.50-alpha",
|
|
49
|
+
"brustjs-linux-arm64-musl": "0.1.50-alpha"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "^19.2.6",
|
package/runtime/cli/build.ts
CHANGED
|
@@ -696,6 +696,12 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
696
696
|
const expandedCount = expanded.length - (loadedRoutes ?? []).length
|
|
697
697
|
const decisions = collectStaticPaths(expanded)
|
|
698
698
|
const staticOut = parsed.ssgOut ?? path.join(outDir, 'static')
|
|
699
|
+
// A GLOBAL catch-all (notFound flag + empty prefix) gets rendered into
|
|
700
|
+
// 404.html. Nested per-prefix catch-alls are live-only on static hosts
|
|
701
|
+
// (the spec's documented SSG limitation).
|
|
702
|
+
const globalNotFound = (
|
|
703
|
+
(loadedRoutes ?? []) as { notFound?: boolean; notFoundPrefix?: string }[]
|
|
704
|
+
).some((r) => r.notFound === true && (r.notFoundPrefix ?? '') === '')
|
|
699
705
|
try {
|
|
700
706
|
const { written, navWritten, fallbackWritten, skipped } = await exportStatic({
|
|
701
707
|
distDir: outDir,
|
|
@@ -703,6 +709,7 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
703
709
|
staticOut,
|
|
704
710
|
routes: decisions,
|
|
705
711
|
fallbacks: ssgFallbacks,
|
|
712
|
+
globalNotFound,
|
|
706
713
|
})
|
|
707
714
|
const counts = new Map<string, number>()
|
|
708
715
|
for (const s of skipped) {
|
package/runtime/cli/ssg.ts
CHANGED
|
@@ -147,6 +147,14 @@ export function collectStaticPaths(flatRoutes: FlatRouteLike[]): SsgRouteDecisio
|
|
|
147
147
|
/** Reserved sentinel param value (Phase B fallback shell crawl). */
|
|
148
148
|
export const SSG_FALLBACK_SENTINEL = '__brust_fallback__'
|
|
149
149
|
|
|
150
|
+
/** Definitely-unmatched URL path used to crawl the GLOBAL catch-all into
|
|
151
|
+
* 404.html: the dist server's NotFound tier renders the global catch-all at
|
|
152
|
+
* status 404 for any path no real route matches. Deliberately bogus — a
|
|
153
|
+
* double-underscore-namespaced segment no app route declares. NOT `/_brust/`-
|
|
154
|
+
* prefixed: those resolve to brust-internal handlers BEFORE route matching, so
|
|
155
|
+
* the catch-all tier would never see them. */
|
|
156
|
+
export const SSG_NOT_FOUND_SENTINEL_PATH = '/__brust_not_found_sentinel__'
|
|
157
|
+
|
|
150
158
|
/** Structural view of the leaf's ssg config (mirrors RouteSsgConfig). */
|
|
151
159
|
export interface RouteSsgLike {
|
|
152
160
|
params?: () => Array<Record<string, string>> | Promise<Array<Record<string, string>>>
|
|
@@ -263,24 +271,24 @@ export function hasClientLoaderExport(source: string): boolean {
|
|
|
263
271
|
return /export\s*\{[^}]*\bclientLoader\b[^}]*\}/.test(code)
|
|
264
272
|
}
|
|
265
273
|
|
|
266
|
-
/**
|
|
267
|
-
* [{pattern, doc}] manifest pairs; a path matching a
|
|
268
|
-
* the REAL url in sessionStorage (the takeover runtime
|
|
269
|
-
* history.replaceState) and redirects to the prerendered
|
|
270
|
-
* No match →
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
|
|
274
|
+
/** The redirect-only `<script>` for `fallback: 'client'` routes (no surrounding
|
|
275
|
+
* document): inlines the [{pattern, doc}] manifest pairs; a path matching a
|
|
276
|
+
* fallback pattern stashes the REAL url in sessionStorage (the takeover runtime
|
|
277
|
+
* restores it via history.replaceState) and redirects to the prerendered
|
|
278
|
+
* fallback shell. No match → no-op (the surrounding document is the 404 body).
|
|
279
|
+
* Extracted so it can be wrapped in the minimal `fallback404Html` shell OR
|
|
280
|
+
* injected into a crawled global-404 page (compose404Html). Pure string fn so
|
|
281
|
+
* the script/inline-JSON contract is unit-testable. Escapes for the <script>
|
|
282
|
+
* context: `<`/`>` (no `</script>`/`<!--`/`-->` sequences) and U+2028/U+2029
|
|
283
|
+
* (legal in JSON, illegal in pre-ES2019-parsed JS string literals). Patterns
|
|
284
|
+
* are author-controlled — belt-and-braces, not a trust boundary. */
|
|
285
|
+
export function fallback404Script(pairs: Array<{ pattern: string; doc: string }>): string {
|
|
276
286
|
const inlineJson = JSON.stringify(pairs)
|
|
277
287
|
.replace(/</g, '\\u003c')
|
|
278
288
|
.replace(/>/g, '\\u003e')
|
|
279
289
|
.replace(/\u2028/g, '\\u2028')
|
|
280
290
|
.replace(/\u2029/g, '\\u2029')
|
|
281
|
-
return
|
|
282
|
-
<p>Not found.</p>
|
|
283
|
-
<script>
|
|
291
|
+
return `<script>
|
|
284
292
|
(function () {
|
|
285
293
|
var MANIFEST = ${inlineJson};
|
|
286
294
|
function match(pattern, path) {
|
|
@@ -300,10 +308,43 @@ export function fallback404Html(pairs: Array<{ pattern: string; doc: string }>):
|
|
|
300
308
|
}
|
|
301
309
|
}
|
|
302
310
|
})()
|
|
303
|
-
</script
|
|
311
|
+
</script>`
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Static-host 404 document for `fallback: 'client'` routes when NO global
|
|
315
|
+
* catch-all page exists: the minimal shell (`<p>Not found.</p>`) wrapping the
|
|
316
|
+
* redirect `<script>` from `fallback404Script`. When a global catch-all DOES
|
|
317
|
+
* exist its crawled page is the document and the script is injected instead
|
|
318
|
+
* (compose404Html) — this shell is unused in that case. */
|
|
319
|
+
export function fallback404Html(pairs: Array<{ pattern: string; doc: string }>): string {
|
|
320
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>404</title></head><body>
|
|
321
|
+
<p>Not found.</p>
|
|
322
|
+
${fallback404Script(pairs)}</body></html>
|
|
304
323
|
`
|
|
305
324
|
}
|
|
306
325
|
|
|
326
|
+
/** Inject `<script>…</script>` into `html` just before the closing `</body>`
|
|
327
|
+
* (last occurrence, case-insensitive). If the document has no `</body>` the
|
|
328
|
+
* script is appended — a static host still parses a trailing script. Used to
|
|
329
|
+
* compose the fallback redirect into a crawled global-404 page. */
|
|
330
|
+
export function injectBeforeBodyClose(html: string, script: string): string {
|
|
331
|
+
const idx = html.toLowerCase().lastIndexOf('</body>')
|
|
332
|
+
if (idx === -1) return `${html}\n${script}`
|
|
333
|
+
return `${html.slice(0, idx)}${script}\n${html.slice(idx)}`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Compose the final SSG `404.html` from a crawled global-catch-all page and,
|
|
337
|
+
* when `fallback: 'client'` routes also exist, the fallback redirect `<script>`
|
|
338
|
+
* injected before `</body>`. With no fallback pairs the crawled page is
|
|
339
|
+
* returned verbatim (pure rendered 404 page). */
|
|
340
|
+
export function compose404Html(
|
|
341
|
+
crawled: string,
|
|
342
|
+
fallbackPairs: Array<{ pattern: string; doc: string }>,
|
|
343
|
+
): string {
|
|
344
|
+
if (fallbackPairs.length === 0) return crawled
|
|
345
|
+
return injectBeforeBodyClose(crawled, fallback404Script(fallbackPairs))
|
|
346
|
+
}
|
|
347
|
+
|
|
307
348
|
// ----- static export -----
|
|
308
349
|
|
|
309
350
|
const READY_LINE = '[brust] listening on' // println! in brust-core server/mod.rs
|
|
@@ -415,13 +456,19 @@ export async function exportStatic(opts: {
|
|
|
415
456
|
routes: SsgRouteDecision[]
|
|
416
457
|
/** `fallback: 'client'` routes (pattern + built chunk URL) to emit shells for. */
|
|
417
458
|
fallbacks?: Array<{ pattern: string; chunk: string }>
|
|
459
|
+
/** True when the app declares a GLOBAL catch-all (notFoundPrefix === ''). The
|
|
460
|
+
* crawler fetches an unmatched sentinel path (→ NotFound tier renders the
|
|
461
|
+
* catch-all at 404) and writes its HTML to staticOut/404.html, composing the
|
|
462
|
+
* fallback redirect script when fallbacks also exist. An app public/404.html
|
|
463
|
+
* still wins. */
|
|
464
|
+
globalNotFound?: boolean
|
|
418
465
|
}): Promise<{
|
|
419
466
|
written: string[]
|
|
420
467
|
navWritten: string[]
|
|
421
468
|
fallbackWritten: string[]
|
|
422
469
|
skipped: SsgRouteDecision[]
|
|
423
470
|
}> {
|
|
424
|
-
const { distDir, entryDir, staticOut, routes, fallbacks = [] } = opts
|
|
471
|
+
const { distDir, entryDir, staticOut, routes, fallbacks = [], globalNotFound = false } = opts
|
|
425
472
|
const included = routes.filter((r) => r.include)
|
|
426
473
|
const skipped = routes.filter((r) => !r.include)
|
|
427
474
|
|
|
@@ -431,7 +478,7 @@ export async function exportStatic(opts: {
|
|
|
431
478
|
const written: string[] = []
|
|
432
479
|
const navWritten: string[] = []
|
|
433
480
|
const fallbackWritten: string[] = []
|
|
434
|
-
if (included.length > 0 || fallbacks.length > 0) {
|
|
481
|
+
if (included.length > 0 || fallbacks.length > 0 || globalNotFound) {
|
|
435
482
|
const port = await freePort()
|
|
436
483
|
const proc = Bun.spawn(['bun', join(distDir, 'index.js')], {
|
|
437
484
|
env: { ...process.env, BRUST_PORT: String(port), BRUST_WORKERS: '1' },
|
|
@@ -541,6 +588,13 @@ export async function exportStatic(opts: {
|
|
|
541
588
|
fallbackWritten.push(payloadFile)
|
|
542
589
|
}
|
|
543
590
|
|
|
591
|
+
// Fallback {pattern, doc} pairs for the 404.html redirect script — also
|
|
592
|
+
// inlined in the routes.json manifest. Empty when no fallback routes.
|
|
593
|
+
const fallbackPairs = fallbacks.map((f) => ({
|
|
594
|
+
pattern: f.pattern,
|
|
595
|
+
doc: `/_brust/fallback/${fallbackDiskPath(f.pattern)}/`,
|
|
596
|
+
}))
|
|
597
|
+
|
|
544
598
|
if (fallbacks.length > 0) {
|
|
545
599
|
// Manifest the client takeover runtime fetches to map a 404'd path to
|
|
546
600
|
// its fallback shell. doc/payload are directory-index URLs (trailing
|
|
@@ -557,19 +611,36 @@ export async function exportStatic(opts: {
|
|
|
557
611
|
const manifestPath = join(staticOut, '_brust', 'routes.json')
|
|
558
612
|
await mkdir(dirname(manifestPath), { recursive: true })
|
|
559
613
|
await Bun.write(manifestPath, JSON.stringify(manifest))
|
|
614
|
+
}
|
|
560
615
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
616
|
+
// 404.html, single static-host slot. Resolution:
|
|
617
|
+
// - app public/404.html present → author owns it (lands via the public/
|
|
618
|
+
// copy below); never overwrite, warn only when it would have mattered.
|
|
619
|
+
// - GLOBAL catch-all → crawl an unmatched sentinel (NotFound tier renders
|
|
620
|
+
// the catch-all at 404), use its HTML as the document; inject the
|
|
621
|
+
// fallback redirect <script> when fallbacks ALSO exist (compose404Html).
|
|
622
|
+
// - fallbacks only → the minimal fallback404Html shell (unchanged).
|
|
623
|
+
// - neither → no framework 404.html (byte-identical-today).
|
|
624
|
+
if (globalNotFound || fallbacks.length > 0) {
|
|
564
625
|
if (existsSync(join(entryDir, 'public', '404.html'))) {
|
|
565
626
|
console.warn(
|
|
566
627
|
'[brust build] ssg: public/404.html exists — NOT overwriting; your 404 page must redirect fallback routes itself (see docs)',
|
|
567
628
|
)
|
|
629
|
+
} else if (globalNotFound) {
|
|
630
|
+
// Crawl the global catch-all via an unmatched path: the dist server's
|
|
631
|
+
// NotFound tier renders it at status 404. A non-404 here means the
|
|
632
|
+
// catch-all isn't wired — fail the export rather than ship a wrong
|
|
633
|
+
// (or 200) page as the 404.
|
|
634
|
+
const resp = await fetch(`http://127.0.0.1:${port}${SSG_NOT_FOUND_SENTINEL_PATH}`)
|
|
635
|
+
const body = await resp.text()
|
|
636
|
+
if (resp.status !== 404) {
|
|
637
|
+
throw new Error(
|
|
638
|
+
`global catch-all crawl GET ${SSG_NOT_FOUND_SENTINEL_PATH} → ${resp.status} (expected 404)\n${body.slice(0, 500)}`,
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
await Bun.write(join(staticOut, '404.html'), compose404Html(body, fallbackPairs))
|
|
568
642
|
} else {
|
|
569
|
-
await Bun.write(
|
|
570
|
-
join(staticOut, '404.html'),
|
|
571
|
-
fallback404Html(manifest.fallbacks.map(({ pattern, doc }) => ({ pattern, doc }))),
|
|
572
|
-
)
|
|
643
|
+
await Bun.write(join(staticOut, '404.html'), fallback404Html(fallbackPairs))
|
|
573
644
|
}
|
|
574
645
|
}
|
|
575
646
|
} catch (err) {
|
package/runtime/index.js
CHANGED
|
@@ -77,8 +77,8 @@ function requireNative() {
|
|
|
77
77
|
try {
|
|
78
78
|
const binding = require('brustjs-android-arm64')
|
|
79
79
|
const bindingPackageVersion = require('brustjs-android-arm64/package.json').version
|
|
80
|
-
if (bindingPackageVersion !== '0.1.
|
|
81
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
80
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
81
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
82
82
|
}
|
|
83
83
|
return binding
|
|
84
84
|
} catch (e) {
|
|
@@ -93,8 +93,8 @@ function requireNative() {
|
|
|
93
93
|
try {
|
|
94
94
|
const binding = require('brustjs-android-arm-eabi')
|
|
95
95
|
const bindingPackageVersion = require('brustjs-android-arm-eabi/package.json').version
|
|
96
|
-
if (bindingPackageVersion !== '0.1.
|
|
97
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
96
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
97
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
98
98
|
}
|
|
99
99
|
return binding
|
|
100
100
|
} catch (e) {
|
|
@@ -114,8 +114,8 @@ function requireNative() {
|
|
|
114
114
|
try {
|
|
115
115
|
const binding = require('brustjs-win32-x64-gnu')
|
|
116
116
|
const bindingPackageVersion = require('brustjs-win32-x64-gnu/package.json').version
|
|
117
|
-
if (bindingPackageVersion !== '0.1.
|
|
118
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
117
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
118
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
119
119
|
}
|
|
120
120
|
return binding
|
|
121
121
|
} catch (e) {
|
|
@@ -130,8 +130,8 @@ function requireNative() {
|
|
|
130
130
|
try {
|
|
131
131
|
const binding = require('brustjs-win32-x64-msvc')
|
|
132
132
|
const bindingPackageVersion = require('brustjs-win32-x64-msvc/package.json').version
|
|
133
|
-
if (bindingPackageVersion !== '0.1.
|
|
134
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
133
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
134
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
135
135
|
}
|
|
136
136
|
return binding
|
|
137
137
|
} catch (e) {
|
|
@@ -147,8 +147,8 @@ function requireNative() {
|
|
|
147
147
|
try {
|
|
148
148
|
const binding = require('brustjs-win32-ia32-msvc')
|
|
149
149
|
const bindingPackageVersion = require('brustjs-win32-ia32-msvc/package.json').version
|
|
150
|
-
if (bindingPackageVersion !== '0.1.
|
|
151
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
150
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
151
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
152
152
|
}
|
|
153
153
|
return binding
|
|
154
154
|
} catch (e) {
|
|
@@ -163,8 +163,8 @@ function requireNative() {
|
|
|
163
163
|
try {
|
|
164
164
|
const binding = require('brustjs-win32-arm64-msvc')
|
|
165
165
|
const bindingPackageVersion = require('brustjs-win32-arm64-msvc/package.json').version
|
|
166
|
-
if (bindingPackageVersion !== '0.1.
|
|
167
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
166
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
167
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
168
168
|
}
|
|
169
169
|
return binding
|
|
170
170
|
} catch (e) {
|
|
@@ -182,8 +182,8 @@ function requireNative() {
|
|
|
182
182
|
try {
|
|
183
183
|
const binding = require('brustjs-darwin-universal')
|
|
184
184
|
const bindingPackageVersion = require('brustjs-darwin-universal/package.json').version
|
|
185
|
-
if (bindingPackageVersion !== '0.1.
|
|
186
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
185
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
186
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
187
187
|
}
|
|
188
188
|
return binding
|
|
189
189
|
} catch (e) {
|
|
@@ -198,8 +198,8 @@ function requireNative() {
|
|
|
198
198
|
try {
|
|
199
199
|
const binding = require('brustjs-darwin-x64')
|
|
200
200
|
const bindingPackageVersion = require('brustjs-darwin-x64/package.json').version
|
|
201
|
-
if (bindingPackageVersion !== '0.1.
|
|
202
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
201
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
202
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
203
203
|
}
|
|
204
204
|
return binding
|
|
205
205
|
} catch (e) {
|
|
@@ -214,8 +214,8 @@ function requireNative() {
|
|
|
214
214
|
try {
|
|
215
215
|
const binding = require('brustjs-darwin-arm64')
|
|
216
216
|
const bindingPackageVersion = require('brustjs-darwin-arm64/package.json').version
|
|
217
|
-
if (bindingPackageVersion !== '0.1.
|
|
218
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
217
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
218
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
219
219
|
}
|
|
220
220
|
return binding
|
|
221
221
|
} catch (e) {
|
|
@@ -234,8 +234,8 @@ function requireNative() {
|
|
|
234
234
|
try {
|
|
235
235
|
const binding = require('brustjs-freebsd-x64')
|
|
236
236
|
const bindingPackageVersion = require('brustjs-freebsd-x64/package.json').version
|
|
237
|
-
if (bindingPackageVersion !== '0.1.
|
|
238
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
237
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
238
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
239
239
|
}
|
|
240
240
|
return binding
|
|
241
241
|
} catch (e) {
|
|
@@ -250,8 +250,8 @@ function requireNative() {
|
|
|
250
250
|
try {
|
|
251
251
|
const binding = require('brustjs-freebsd-arm64')
|
|
252
252
|
const bindingPackageVersion = require('brustjs-freebsd-arm64/package.json').version
|
|
253
|
-
if (bindingPackageVersion !== '0.1.
|
|
254
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
253
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
254
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
255
255
|
}
|
|
256
256
|
return binding
|
|
257
257
|
} catch (e) {
|
|
@@ -271,8 +271,8 @@ function requireNative() {
|
|
|
271
271
|
try {
|
|
272
272
|
const binding = require('brustjs-linux-x64-musl')
|
|
273
273
|
const bindingPackageVersion = require('brustjs-linux-x64-musl/package.json').version
|
|
274
|
-
if (bindingPackageVersion !== '0.1.
|
|
275
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
274
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
275
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
276
276
|
}
|
|
277
277
|
return binding
|
|
278
278
|
} catch (e) {
|
|
@@ -287,8 +287,8 @@ function requireNative() {
|
|
|
287
287
|
try {
|
|
288
288
|
const binding = require('brustjs-linux-x64-gnu')
|
|
289
289
|
const bindingPackageVersion = require('brustjs-linux-x64-gnu/package.json').version
|
|
290
|
-
if (bindingPackageVersion !== '0.1.
|
|
291
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
290
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
291
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
292
292
|
}
|
|
293
293
|
return binding
|
|
294
294
|
} catch (e) {
|
|
@@ -305,8 +305,8 @@ function requireNative() {
|
|
|
305
305
|
try {
|
|
306
306
|
const binding = require('brustjs-linux-arm64-musl')
|
|
307
307
|
const bindingPackageVersion = require('brustjs-linux-arm64-musl/package.json').version
|
|
308
|
-
if (bindingPackageVersion !== '0.1.
|
|
309
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
308
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
309
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
310
310
|
}
|
|
311
311
|
return binding
|
|
312
312
|
} catch (e) {
|
|
@@ -321,8 +321,8 @@ function requireNative() {
|
|
|
321
321
|
try {
|
|
322
322
|
const binding = require('brustjs-linux-arm64-gnu')
|
|
323
323
|
const bindingPackageVersion = require('brustjs-linux-arm64-gnu/package.json').version
|
|
324
|
-
if (bindingPackageVersion !== '0.1.
|
|
325
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
324
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
325
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
326
326
|
}
|
|
327
327
|
return binding
|
|
328
328
|
} catch (e) {
|
|
@@ -339,8 +339,8 @@ function requireNative() {
|
|
|
339
339
|
try {
|
|
340
340
|
const binding = require('brustjs-linux-arm-musleabihf')
|
|
341
341
|
const bindingPackageVersion = require('brustjs-linux-arm-musleabihf/package.json').version
|
|
342
|
-
if (bindingPackageVersion !== '0.1.
|
|
343
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
342
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
343
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
344
344
|
}
|
|
345
345
|
return binding
|
|
346
346
|
} catch (e) {
|
|
@@ -355,8 +355,8 @@ function requireNative() {
|
|
|
355
355
|
try {
|
|
356
356
|
const binding = require('brustjs-linux-arm-gnueabihf')
|
|
357
357
|
const bindingPackageVersion = require('brustjs-linux-arm-gnueabihf/package.json').version
|
|
358
|
-
if (bindingPackageVersion !== '0.1.
|
|
359
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
358
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
359
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
360
360
|
}
|
|
361
361
|
return binding
|
|
362
362
|
} catch (e) {
|
|
@@ -373,8 +373,8 @@ function requireNative() {
|
|
|
373
373
|
try {
|
|
374
374
|
const binding = require('brustjs-linux-loong64-musl')
|
|
375
375
|
const bindingPackageVersion = require('brustjs-linux-loong64-musl/package.json').version
|
|
376
|
-
if (bindingPackageVersion !== '0.1.
|
|
377
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
376
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
377
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
378
378
|
}
|
|
379
379
|
return binding
|
|
380
380
|
} catch (e) {
|
|
@@ -389,8 +389,8 @@ function requireNative() {
|
|
|
389
389
|
try {
|
|
390
390
|
const binding = require('brustjs-linux-loong64-gnu')
|
|
391
391
|
const bindingPackageVersion = require('brustjs-linux-loong64-gnu/package.json').version
|
|
392
|
-
if (bindingPackageVersion !== '0.1.
|
|
393
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
392
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
393
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
394
394
|
}
|
|
395
395
|
return binding
|
|
396
396
|
} catch (e) {
|
|
@@ -407,8 +407,8 @@ function requireNative() {
|
|
|
407
407
|
try {
|
|
408
408
|
const binding = require('brustjs-linux-riscv64-musl')
|
|
409
409
|
const bindingPackageVersion = require('brustjs-linux-riscv64-musl/package.json').version
|
|
410
|
-
if (bindingPackageVersion !== '0.1.
|
|
411
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
410
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
411
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
412
412
|
}
|
|
413
413
|
return binding
|
|
414
414
|
} catch (e) {
|
|
@@ -423,8 +423,8 @@ function requireNative() {
|
|
|
423
423
|
try {
|
|
424
424
|
const binding = require('brustjs-linux-riscv64-gnu')
|
|
425
425
|
const bindingPackageVersion = require('brustjs-linux-riscv64-gnu/package.json').version
|
|
426
|
-
if (bindingPackageVersion !== '0.1.
|
|
427
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
426
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
427
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
428
428
|
}
|
|
429
429
|
return binding
|
|
430
430
|
} catch (e) {
|
|
@@ -440,8 +440,8 @@ function requireNative() {
|
|
|
440
440
|
try {
|
|
441
441
|
const binding = require('brustjs-linux-ppc64-gnu')
|
|
442
442
|
const bindingPackageVersion = require('brustjs-linux-ppc64-gnu/package.json').version
|
|
443
|
-
if (bindingPackageVersion !== '0.1.
|
|
444
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
443
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
444
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
445
445
|
}
|
|
446
446
|
return binding
|
|
447
447
|
} catch (e) {
|
|
@@ -456,8 +456,8 @@ function requireNative() {
|
|
|
456
456
|
try {
|
|
457
457
|
const binding = require('brustjs-linux-s390x-gnu')
|
|
458
458
|
const bindingPackageVersion = require('brustjs-linux-s390x-gnu/package.json').version
|
|
459
|
-
if (bindingPackageVersion !== '0.1.
|
|
460
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
459
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
460
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
461
461
|
}
|
|
462
462
|
return binding
|
|
463
463
|
} catch (e) {
|
|
@@ -476,8 +476,8 @@ function requireNative() {
|
|
|
476
476
|
try {
|
|
477
477
|
const binding = require('brustjs-openharmony-arm64')
|
|
478
478
|
const bindingPackageVersion = require('brustjs-openharmony-arm64/package.json').version
|
|
479
|
-
if (bindingPackageVersion !== '0.1.
|
|
480
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
479
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
480
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
481
481
|
}
|
|
482
482
|
return binding
|
|
483
483
|
} catch (e) {
|
|
@@ -492,8 +492,8 @@ function requireNative() {
|
|
|
492
492
|
try {
|
|
493
493
|
const binding = require('brustjs-openharmony-x64')
|
|
494
494
|
const bindingPackageVersion = require('brustjs-openharmony-x64/package.json').version
|
|
495
|
-
if (bindingPackageVersion !== '0.1.
|
|
496
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
495
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
496
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
497
497
|
}
|
|
498
498
|
return binding
|
|
499
499
|
} catch (e) {
|
|
@@ -508,8 +508,8 @@ function requireNative() {
|
|
|
508
508
|
try {
|
|
509
509
|
const binding = require('brustjs-openharmony-arm')
|
|
510
510
|
const bindingPackageVersion = require('brustjs-openharmony-arm/package.json').version
|
|
511
|
-
if (bindingPackageVersion !== '0.1.
|
|
512
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
511
|
+
if (bindingPackageVersion !== '0.1.50-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
512
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.50-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
513
513
|
}
|
|
514
514
|
return binding
|
|
515
515
|
} catch (e) {
|
package/runtime/index.ts
CHANGED
|
@@ -245,6 +245,12 @@ export const brust = {
|
|
|
245
245
|
}
|
|
246
246
|
: null,
|
|
247
247
|
nativeTemplate: r.nativeTemplate ?? null,
|
|
248
|
+
// Catch-all (`{ path: '*' }`) markers. Rust reads these via serde
|
|
249
|
+
// rename (`notFound` / `notFoundPrefix`) in a later task to skip the
|
|
250
|
+
// matchit insert + register the not-found fallback table entry.
|
|
251
|
+
// Emitting them now is harmless before Rust consumes them.
|
|
252
|
+
notFound: r.notFound === true,
|
|
253
|
+
notFoundPrefix: r.notFoundPrefix ?? '',
|
|
248
254
|
}),
|
|
249
255
|
)
|
|
250
256
|
const result = (native as any).registerRoutes(configs)
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
takeover,
|
|
39
39
|
unmountFallbackRootsIn,
|
|
40
40
|
} from './fallback.ts'
|
|
41
|
+
import { withViewTransition } from './view-transition.ts'
|
|
41
42
|
|
|
42
43
|
// Track React roots created by hydrateOne so we can unmount them before
|
|
43
44
|
// removing their DOM in swapMainContent. Without this, removing the DOM
|
|
@@ -321,16 +322,21 @@ async function attemptClientFallback(
|
|
|
321
322
|
if (!main) return false
|
|
322
323
|
// From here down: the SAME post-swap sequence navigate()'s normal path runs.
|
|
323
324
|
scrollPositions.set(currentPageKey, window.scrollY)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
325
|
+
await withViewTransition(document, () => {
|
|
326
|
+
unmountIslandsIn(main as HTMLElement)
|
|
327
|
+
swapMainContent(main as HTMLElement, html)
|
|
328
|
+
if (title) document.title = title
|
|
329
|
+
// History BEFORE takeover: takeover derives params from location.pathname,
|
|
330
|
+
// so the URL bar must already show the destination.
|
|
331
|
+
if (mode === 'push') history.pushState({}, '', url.href)
|
|
332
|
+
else if (mode === 'replace') history.replaceState({}, '', url.href)
|
|
333
|
+
if (mode === 'none') window.scrollTo(0, scrollPositions.get(pageCacheKey(url)) ?? 0)
|
|
334
|
+
else window.scrollTo(0, 0)
|
|
335
|
+
currentPageKey = pageCacheKey(url)
|
|
336
|
+
})
|
|
337
|
+
// Superseded across the view-transition await → the newer navigation owns
|
|
338
|
+
// the DOM; don't run takeover/hydrate/commit on a soon-to-be-replaced shell.
|
|
339
|
+
if (signal?.aborted) return true
|
|
334
340
|
const container = main.querySelector<HTMLElement>('[data-brust-fallback-root]')
|
|
335
341
|
if (!container) {
|
|
336
342
|
// A fallback payload without its marker is a build bug — log it, but
|
|
@@ -423,19 +429,28 @@ export async function navigate(url: URL, mode: 'push' | 'replace' | 'none'): Pro
|
|
|
423
429
|
}
|
|
424
430
|
const main = document.querySelector('main')
|
|
425
431
|
if (!main) throw new Error('navigation: no <main> element')
|
|
432
|
+
// scrollY of the LEAVING page is read before the transition (it must see
|
|
433
|
+
// the old page's position under the old key).
|
|
426
434
|
scrollPositions.set(currentPageKey, window.scrollY)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
435
|
+
await withViewTransition(document, () => {
|
|
436
|
+
unmountIslandsIn(main as HTMLElement)
|
|
437
|
+
swapMainContent(main as HTMLElement, html)
|
|
438
|
+
// Only a FRESH payload re-applies the server store snapshot: replaying a
|
|
439
|
+
// cached (stale) snapshot would roll back live client store state the
|
|
440
|
+
// user changed since the page was first fetched.
|
|
441
|
+
if (!cached && store) applyStoreSnapshot(store)
|
|
442
|
+
if (title) document.title = title
|
|
443
|
+
if (mode === 'push') history.pushState({}, '', url.href)
|
|
444
|
+
else if (mode === 'replace') history.replaceState({}, '', url.href)
|
|
445
|
+
if (mode === 'none') window.scrollTo(0, scrollPositions.get(key) ?? 0)
|
|
446
|
+
else window.scrollTo(0, 0)
|
|
447
|
+
hydrateMarkersIn(main as HTMLElement)
|
|
448
|
+
})
|
|
449
|
+
// The view-transition await is a new suspension point: a newer navigation
|
|
450
|
+
// could have aborted us between the swap and here. The DOM is already
|
|
451
|
+
// committed (irreversible), but skip the bookkeeping so we don't advance
|
|
452
|
+
// the nav store to this stale destination — the newer navigation owns it.
|
|
453
|
+
if (ac.signal.aborted) return
|
|
439
454
|
currentPageKey = key
|
|
440
455
|
__navCommit(url.pathname, url.search)
|
|
441
456
|
} catch (err) {
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
export interface PagePayload {
|
|
10
10
|
html: string
|
|
11
11
|
title: string
|
|
12
|
-
store?: Record<string, Record<string, unknown>>
|
|
12
|
+
store?: Record<string, Record<string, unknown>> | null
|
|
13
|
+
/** True when this payload was delivered at HTTP 404 (a rendered catch-all
|
|
14
|
+
* page, NOT a transport error). The caller swaps it in-place like any other
|
|
15
|
+
* payload but must NOT cache it — a 404 is not a stable, re-servable entry,
|
|
16
|
+
* and caching it would poison a later real navigation to the same path. */
|
|
17
|
+
notFound?: boolean
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/** Bound the cache so a long session can't grow memory unbounded (LRU evict). */
|
|
@@ -60,7 +65,32 @@ export async function fetchPagePayload(url: URL, signal?: AbortSignal): Promise<
|
|
|
60
65
|
signal,
|
|
61
66
|
headers: { Accept: 'application/json' },
|
|
62
67
|
})
|
|
63
|
-
|
|
68
|
+
// A 404 carrying a rendered page payload (the framework's catch-all 404 page)
|
|
69
|
+
// is renderable, NOT a transport error: parse it, tag it `notFound`, and hand
|
|
70
|
+
// it to the caller to swap in-place. Anything else that isn't 2xx (5xx, other
|
|
71
|
+
// statuses) is a real transport failure → throw so the caller full-reloads or
|
|
72
|
+
// tries the `fallback:'client'` takeover. A 404 whose body is empty / not a
|
|
73
|
+
// valid `{ html }` payload is ALSO a transport failure (no page to render):
|
|
74
|
+
// the json()/shape check below throws and is caught here.
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
if (resp.status === 404) {
|
|
77
|
+
try {
|
|
78
|
+
const payload = (await resp.json()) as PagePayload
|
|
79
|
+
// `html` is the SOLE discriminator: its presence means "a renderable
|
|
80
|
+
// page came back" (vs a bare transport 404). `title`/`store` are NOT
|
|
81
|
+
// required — title may legitimately be empty, and `store` is null for a
|
|
82
|
+
// native catch-all; both have downstream guards (bootstrap applies the
|
|
83
|
+
// snapshot only when truthy, same contract as the 200 path). Requiring
|
|
84
|
+
// them here would spuriously full-reload valid 404 pages.
|
|
85
|
+
if (payload && typeof payload.html === 'string') {
|
|
86
|
+
return { ...payload, notFound: true }
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// empty / non-JSON 404 body → fall through to the transport-error throw
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`navigation: status ${resp.status}`)
|
|
93
|
+
}
|
|
64
94
|
const payload = (await resp.json()) as PagePayload
|
|
65
95
|
setCachedPage(pageCacheKey(url), payload)
|
|
66
96
|
return payload
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Opt-in View Transitions for SPA navigation. The framework default is an
|
|
2
|
+
// INSTANT swap; an app opts in by putting `data-brust-view-transitions` on
|
|
3
|
+
// <html> and shipping the `::view-transition-*(root)` CSS itself. The helper is
|
|
4
|
+
// pure (takes `doc`) so it unit-tests without a real browser. Spec:
|
|
5
|
+
// docs/superpowers/specs/2026-06-12-view-transitions-design.md
|
|
6
|
+
|
|
7
|
+
/** True iff this navigation should animate: the browser supports the View
|
|
8
|
+
* Transitions API AND the app opted in with the <html> marker. */
|
|
9
|
+
export function viewTransitionsEnabled(doc: Document): boolean {
|
|
10
|
+
return (
|
|
11
|
+
typeof (doc as { startViewTransition?: unknown }).startViewTransition === 'function' &&
|
|
12
|
+
doc.documentElement.hasAttribute('data-brust-view-transitions')
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Run the synchronous navigation `commit` inside a view transition when
|
|
17
|
+
* enabled, else directly. Resolves once the DOM is committed (NOT when the
|
|
18
|
+
* animation finishes) so caller ordering is preserved. `commit` runs EXACTLY
|
|
19
|
+
* once on every path:
|
|
20
|
+
* - disabled/unsupported → direct call
|
|
21
|
+
* - startViewTransition throws synchronously (before the callback) → direct
|
|
22
|
+
* call (the swap never happened — losing it would blank the page, B2)
|
|
23
|
+
* - updateCallbackDone rejects (the callback ran-and-threw) → NOT re-run; the
|
|
24
|
+
* rejection PROPAGATES so the caller can run its full-reload recovery. */
|
|
25
|
+
export async function withViewTransition(doc: Document, commit: () => void): Promise<void> {
|
|
26
|
+
if (!viewTransitionsEnabled(doc)) {
|
|
27
|
+
commit()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
const start = (
|
|
31
|
+
doc as Document & {
|
|
32
|
+
startViewTransition: (cb: () => void) => { updateCallbackDone: Promise<void> }
|
|
33
|
+
}
|
|
34
|
+
).startViewTransition
|
|
35
|
+
let tr: { updateCallbackDone: Promise<void> }
|
|
36
|
+
try {
|
|
37
|
+
tr = start.call(doc, commit)
|
|
38
|
+
} catch {
|
|
39
|
+
commit()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
// `updateCallbackDone` rejects ONLY when the update callback (`commit`) threw
|
|
43
|
+
// — animation failures surface on `.finished`, which we never await. So a
|
|
44
|
+
// rejection means the DOM may be half-committed: propagate it (do NOT re-run
|
|
45
|
+
// `commit`) so the caller's catch runs its error path (`__navError` +
|
|
46
|
+
// full-reload), exactly as the synchronous direct-commit branch does. A
|
|
47
|
+
// transition that is merely SKIPPED (a newer startViewTransition) still
|
|
48
|
+
// resolves `updateCallbackDone`, so this never throws on supersession.
|
|
49
|
+
await tr.updateCallbackDone
|
|
50
|
+
}
|
package/runtime/routes.ts
CHANGED
|
@@ -235,8 +235,25 @@ export interface NativeVerdict {
|
|
|
235
235
|
readonly headers?: Record<string, string>
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/**
|
|
239
|
-
*
|
|
238
|
+
/** Signal "not found" from a route loader. ONE helper, two call shapes —
|
|
239
|
+
* matching how each render path consumes a loader result:
|
|
240
|
+
*
|
|
241
|
+
* - NATIVE route loader: `return notFound(data?)`. The returned verdict is
|
|
242
|
+
* inspected by `runNativeChainLoaders` and renders the route's OWN template
|
|
243
|
+
* at HTTP 404 (`data` default `{}` becomes the template context).
|
|
244
|
+
*
|
|
245
|
+
* - REACT route loader: `throw notFound()`. A React loader's RETURN value is
|
|
246
|
+
* the component's `data` prop (never inspected as a verdict), so the only
|
|
247
|
+
* way to signal not-found is to throw. The render dispatch discriminates the
|
|
248
|
+
* thrown verdict (it is symbol-tagged — `isNativeVerdict`) BEFORE the generic
|
|
249
|
+
* 500/errorBoundary handler and renders the NEAREST catch-all (`path: '*'`)
|
|
250
|
+
* for the route's prefix at HTTP 404 — NOT the route's own Component, NOT a
|
|
251
|
+
* 500. If no catch-all is registered for the prefix, a framework default 404
|
|
252
|
+
* body is rendered. `data` is ignored on the React throw path (the catch-all
|
|
253
|
+
* runs its own loader chain).
|
|
254
|
+
*
|
|
255
|
+
* Same value type (`NativeVerdict`) either way — native returns it, React
|
|
256
|
+
* throws it — so there is a single public `notFound()` API. */
|
|
240
257
|
export function notFound(data?: unknown): NativeVerdict {
|
|
241
258
|
return { [BRUST_VERDICT]: true, status: 404, render: true, data: data ?? {} }
|
|
242
259
|
}
|
|
@@ -257,6 +274,45 @@ export function isNativeVerdict(x: unknown): x is NativeVerdict {
|
|
|
257
274
|
)
|
|
258
275
|
}
|
|
259
276
|
|
|
277
|
+
/** Framework default 404 body, served when a React `notFound()` fires but no
|
|
278
|
+
* catch-all (`path: '*'`) is registered for the route's prefix — so the response
|
|
279
|
+
* is still HTTP 404 with a body (never a 500, never a crash). */
|
|
280
|
+
const DEFAULT_NOT_FOUND_BODY =
|
|
281
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 Not Found</title></head><body><main><h1>404</h1><p>Not found.</p></main></body></html>'
|
|
282
|
+
|
|
283
|
+
/** True when a thrown value is the React `notFound()` trigger: a NativeVerdict
|
|
284
|
+
* that renders at HTTP 404 (vs a thrown `redirect()`, which is `render: false`).
|
|
285
|
+
* Distinct from ActionError (a different Symbol) and from real Errors, so the
|
|
286
|
+
* render-dispatch catch can re-render the catch-all ONLY for this case and let
|
|
287
|
+
* everything else fall through to the 500/errorBoundary path. */
|
|
288
|
+
function isNotFoundTrigger(x: unknown): x is NativeVerdict {
|
|
289
|
+
return isNativeVerdict(x) && x.render === true && x.status === 404
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Select the nearest catch-all (`path: '*'`) FlatRoute for an unmatched path —
|
|
293
|
+
* the JS-side mirror of Rust's `select_not_found` (routing/routes.rs). Returns
|
|
294
|
+
* the catch-all's route_id (array index) or `undefined` if none covers the path.
|
|
295
|
+
*
|
|
296
|
+
* Longest segment-prefix wins: a `notFoundPrefix` of `/docs` covers `/docs` and
|
|
297
|
+
* `/docs/...` but NOT `/docsearch`; the root catch-all (`''`) covers everything
|
|
298
|
+
* as the last resort. Identical precedence to the Rust unmatched-path tier, so a
|
|
299
|
+
* React `notFound()` selects the SAME catch-all a genuinely-unmatched path would. */
|
|
300
|
+
function selectNotFound(routes: FlatRoute[], path: string): number | undefined {
|
|
301
|
+
let bestId: number | undefined
|
|
302
|
+
let bestLen = -1
|
|
303
|
+
for (let i = 0; i < routes.length; i++) {
|
|
304
|
+
const r = routes[i]
|
|
305
|
+
if (r.notFound !== true) continue
|
|
306
|
+
const p = r.notFoundPrefix ?? ''
|
|
307
|
+
const covers = p === '' || path === p || path.startsWith(`${p}/`)
|
|
308
|
+
if (covers && p.length > bestLen) {
|
|
309
|
+
bestLen = p.length
|
|
310
|
+
bestId = i
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return bestId
|
|
314
|
+
}
|
|
315
|
+
|
|
260
316
|
/** Loader context passed to native chain loaders. */
|
|
261
317
|
export interface NativeLoaderCtx {
|
|
262
318
|
params: Record<string, string>
|
|
@@ -409,6 +465,22 @@ export interface FlatRoute {
|
|
|
409
465
|
/** Sub-project J — Component.name when leaf had `native: true`. Captured
|
|
410
466
|
* at flatten time (build-time AST identifier), so minifier-safe. */
|
|
411
467
|
nativeTemplate?: string
|
|
468
|
+
/** Catch-all (`{ path: '*' }`) marker. When true this FlatRoute is a
|
|
469
|
+
* "not found" fallback: it stays in the array at its natural index (route_id
|
|
470
|
+
* stable) but install SKIPS the matchit insert for it — it only renders when
|
|
471
|
+
* matchit returns NoMatch under `notFoundPrefix`. NEVER remove a flagged
|
|
472
|
+
* entry from the flat array (that would shift every later route_id).
|
|
473
|
+
*
|
|
474
|
+
* A catch-all render is stamped HTTP 404 UNCONDITIONALLY (spec invariant 4:
|
|
475
|
+
* "never 200"). A `redirect()` from the catch-all's OWN loader still wins —
|
|
476
|
+
* redirect verdicts short-circuit and return before the 404 stamp on every
|
|
477
|
+
* path. A non-redirect verdict status, however, is overridden by 404 (a 404
|
|
478
|
+
* page is a 404, by definition). */
|
|
479
|
+
notFound?: boolean
|
|
480
|
+
/** Parent layout's path prefix this catch-all covers. Root catch-all → `''`
|
|
481
|
+
* (matches everything as last resort). Longest segment-prefix wins at match
|
|
482
|
+
* time. Never contains `*` (it's the parent prefix, not the catch-all path). */
|
|
483
|
+
notFoundPrefix?: string
|
|
412
484
|
}
|
|
413
485
|
|
|
414
486
|
/** Compose a child's relative path onto a parent's base path.
|
|
@@ -530,7 +602,10 @@ function assertNativeSubtree(children: Route[], where: string): void {
|
|
|
530
602
|
* the design spec (S3). */
|
|
531
603
|
export function flattenRoutes(routes: Route[]): FlatRoute[] {
|
|
532
604
|
const out: FlatRoute[] = []
|
|
533
|
-
|
|
605
|
+
// Tracks `notFoundPrefix` values already claimed by a catch-all so a second
|
|
606
|
+
// catch-all under the same prefix throws instead of silently shadowing.
|
|
607
|
+
const seenNotFoundPrefixes = new Set<string>()
|
|
608
|
+
walkRoutes(routes, [], '', out, seenNotFoundPrefixes)
|
|
534
609
|
return out
|
|
535
610
|
}
|
|
536
611
|
|
|
@@ -539,6 +614,7 @@ function walkRoutes(
|
|
|
539
614
|
parentChain: Route[],
|
|
540
615
|
basePath: string,
|
|
541
616
|
out: FlatRoute[],
|
|
617
|
+
seenNotFoundPrefixes: Set<string>,
|
|
542
618
|
): void {
|
|
543
619
|
for (const r of routes) {
|
|
544
620
|
validateRoute(r, basePath)
|
|
@@ -549,11 +625,36 @@ function walkRoutes(
|
|
|
549
625
|
continue
|
|
550
626
|
}
|
|
551
627
|
|
|
628
|
+
// Catch-all (`{ path: '*' }`) — a "not found" fallback for the `basePath`
|
|
629
|
+
// subtree. It is a LEAF (no children/index) and is KEPT in the flat array
|
|
630
|
+
// at its natural index (route_id stable) flagged `notFound`. Its fullPath
|
|
631
|
+
// is set to the parent prefix (never a `*` matchit pattern) so a later
|
|
632
|
+
// install step can skip the matchit insert and rely on the flag.
|
|
633
|
+
if (r.path === '*') {
|
|
634
|
+
if (r.children && r.children.length > 0) {
|
|
635
|
+
throw new Error(`catch-all route "*" must be a leaf (no children) under "${basePath}"`)
|
|
636
|
+
}
|
|
637
|
+
// (index is already mutually exclusive with path via validateRoute.)
|
|
638
|
+
const prefix = basePath
|
|
639
|
+
if (seenNotFoundPrefixes.has(prefix)) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`duplicate catch-all route "*": only one catch-all allowed per prefix "${prefix}"`,
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
seenNotFoundPrefixes.add(prefix)
|
|
645
|
+
// Construct atomically (no post-hoc mutation) so the flag + prefix are
|
|
646
|
+
// never observable half-set. `fullPath === notFoundPrefix` by design; the
|
|
647
|
+
// install step gates the matchit-insert skip on the `notFound` flag, not
|
|
648
|
+
// on fullPath (see runtime/index.ts registerRoutes).
|
|
649
|
+
out.push({ ...makeFlat(chain, prefix), notFound: true, notFoundPrefix: prefix })
|
|
650
|
+
continue
|
|
651
|
+
}
|
|
652
|
+
|
|
552
653
|
const ownPath = r.path ?? ''
|
|
553
654
|
const myPath = joinPath(basePath, ownPath)
|
|
554
655
|
|
|
555
656
|
if (r.children && r.children.length > 0) {
|
|
556
|
-
walkRoutes(r.children, chain, myPath, out)
|
|
657
|
+
walkRoutes(r.children, chain, myPath, out, seenNotFoundPrefixes)
|
|
557
658
|
} else {
|
|
558
659
|
// Leaf with a path (validated above).
|
|
559
660
|
out.push(makeFlat(chain, myPath))
|
|
@@ -991,6 +1092,12 @@ export function makeRenderer(
|
|
|
991
1092
|
} else {
|
|
992
1093
|
data = chainResult.data
|
|
993
1094
|
}
|
|
1095
|
+
// Catch-all (`path: '*'`) leaf rendered on an unmatched path: stamp HTTP
|
|
1096
|
+
// 404 unconditionally, regardless of any verdict status. The loader does
|
|
1097
|
+
// NOT need to call notFound() — being a catch-all IS the 404 signal.
|
|
1098
|
+
if (flat.notFound === true) {
|
|
1099
|
+
renderStatus = 404
|
|
1100
|
+
}
|
|
994
1101
|
// B7 — native store-snapshot SSR. Fill the framework-owned
|
|
995
1102
|
// `{{ __brust_store__ | safe }}` slot (emitted into every native
|
|
996
1103
|
// full-document <head>) with the defineStore SSR snapshot collected
|
|
@@ -1127,32 +1234,89 @@ export function makeRenderer(
|
|
|
1127
1234
|
return await runInRequestContext(call.req?.cookies ?? {}, async () => {
|
|
1128
1235
|
// Computed ONCE and shared by buildRenderElement (leaf swap) and
|
|
1129
1236
|
// renderBranchStreaming (forceIslands) so the two can never diverge.
|
|
1130
|
-
|
|
1131
|
-
let element: ReactNode
|
|
1132
|
-
|
|
1237
|
+
let shellMode = wantsSsgFallbackShell(flat, call)
|
|
1238
|
+
let element: ReactNode = null
|
|
1239
|
+
// The route actually rendered + its HTTP status. Normally the matched
|
|
1240
|
+
// `flat` at the verdict status (404 when the matched route IS a
|
|
1241
|
+
// catch-all). A React loader that `throw`s `notFound()` swaps both to
|
|
1242
|
+
// the nearest catch-all at 404 below.
|
|
1243
|
+
let renderFlat = flat
|
|
1244
|
+
let renderStatus = flat.notFound === true ? 404 : verdict.status
|
|
1133
1245
|
try {
|
|
1134
1246
|
element = await buildRenderElement(call, flat, opts.getWorkerId, shellMode)
|
|
1135
|
-
errorBoundary =
|
|
1136
|
-
flat.errorBoundary ??
|
|
1137
|
-
(({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
|
|
1138
1247
|
} catch (err) {
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1248
|
+
if (isNotFoundTrigger(err)) {
|
|
1249
|
+
// React `notFound()` trigger: abandon the matched route and render
|
|
1250
|
+
// the NEAREST catch-all (same selection as a Rust-unmatched path)
|
|
1251
|
+
// at HTTP 404 — NOT the route's own Component, NOT a 500. Reuse the
|
|
1252
|
+
// existing 404-render machinery by re-running buildRenderElement
|
|
1253
|
+
// against the catch-all's chain.
|
|
1254
|
+
// nfId is the array index, which IS the route_id (catch-alls keep
|
|
1255
|
+
// their natural slot — see FlatRoute.notFound), so byRouteId resolves
|
|
1256
|
+
// the same flat route the Rust tier picks for an unmatched path.
|
|
1257
|
+
const nfId = selectNotFound(routes, call.path)
|
|
1258
|
+
const nfFlat = nfId !== undefined ? byRouteId.get(nfId) : undefined
|
|
1259
|
+
renderStatus = 404
|
|
1260
|
+
if (nfFlat) {
|
|
1261
|
+
renderFlat = nfFlat
|
|
1262
|
+
shellMode = wantsSsgFallbackShell(nfFlat, call)
|
|
1263
|
+
try {
|
|
1264
|
+
element = await buildRenderElement(call, nfFlat, opts.getWorkerId, shellMode)
|
|
1265
|
+
} catch (nfErr) {
|
|
1266
|
+
// The catch-all's OWN loader/render setup failed — don't loop;
|
|
1267
|
+
// fall back to the framework default 404 body at 404.
|
|
1268
|
+
console.error(`[brust] catch-all render setup failed:`, nfErr)
|
|
1269
|
+
return await emitSingleChunkResponse(
|
|
1270
|
+
slotView,
|
|
1271
|
+
napi,
|
|
1272
|
+
workerId,
|
|
1273
|
+
encoder,
|
|
1274
|
+
{
|
|
1275
|
+
status: 404,
|
|
1276
|
+
contentType: 'text/html; charset=utf-8',
|
|
1277
|
+
body: DEFAULT_NOT_FOUND_BODY,
|
|
1278
|
+
},
|
|
1279
|
+
slot,
|
|
1280
|
+
)
|
|
1281
|
+
}
|
|
1282
|
+
} else {
|
|
1283
|
+
// No catch-all registered for this prefix → framework default 404
|
|
1284
|
+
// body at status 404 (don't crash, don't 500).
|
|
1285
|
+
return await emitSingleChunkResponse(
|
|
1286
|
+
slotView,
|
|
1287
|
+
napi,
|
|
1288
|
+
workerId,
|
|
1289
|
+
encoder,
|
|
1290
|
+
{
|
|
1291
|
+
status: 404,
|
|
1292
|
+
contentType: 'text/html; charset=utf-8',
|
|
1293
|
+
body: DEFAULT_NOT_FOUND_BODY,
|
|
1294
|
+
},
|
|
1295
|
+
slot,
|
|
1296
|
+
)
|
|
1297
|
+
}
|
|
1298
|
+
} else {
|
|
1299
|
+
// Setup failure BEFORE renderToPipeableStream — loader throw, params
|
|
1300
|
+
// bind throw. Shape matches the legacy "internal error" path so
|
|
1301
|
+
// existing integration tests stay green.
|
|
1302
|
+
console.error(`[brust] render setup failed:`, err)
|
|
1303
|
+
return await emitSingleChunkResponse(
|
|
1304
|
+
slotView,
|
|
1305
|
+
napi,
|
|
1306
|
+
workerId,
|
|
1307
|
+
encoder,
|
|
1308
|
+
{
|
|
1309
|
+
status: 500,
|
|
1310
|
+
contentType: 'text/html; charset=utf-8',
|
|
1311
|
+
body: 'internal error',
|
|
1312
|
+
},
|
|
1313
|
+
slot,
|
|
1314
|
+
)
|
|
1315
|
+
}
|
|
1155
1316
|
}
|
|
1317
|
+
const errorBoundary: ComponentType<{ error: Error }> =
|
|
1318
|
+
renderFlat.errorBoundary ??
|
|
1319
|
+
(({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
|
|
1156
1320
|
const storeSnapshot = collectSnapshot()
|
|
1157
1321
|
await renderBranchStreaming({
|
|
1158
1322
|
element,
|
|
@@ -1161,9 +1325,11 @@ export function makeRenderer(
|
|
|
1161
1325
|
workerId,
|
|
1162
1326
|
napi,
|
|
1163
1327
|
errorBoundary,
|
|
1164
|
-
|
|
1328
|
+
// Catch-all (`path: '*'`) leaf rendered on an unmatched path OR a
|
|
1329
|
+
// React `notFound()` swap: stamp HTTP 404 (mirrors the native path).
|
|
1330
|
+
status: renderStatus,
|
|
1165
1331
|
headers: flushSetCookie(verdict.headers),
|
|
1166
|
-
routePath:
|
|
1332
|
+
routePath: renderFlat.fullPath,
|
|
1167
1333
|
storeSnapshot,
|
|
1168
1334
|
// SSG fallback shells have zero islands on the page but the
|
|
1169
1335
|
// client-loader runtime still needs the importmap + bootstrap.
|
|
@@ -1174,7 +1340,7 @@ export function makeRenderer(
|
|
|
1174
1340
|
})
|
|
1175
1341
|
}
|
|
1176
1342
|
if (call.kind === 'navigation') {
|
|
1177
|
-
await navigationBranch(call, byRouteId, slotView, encoder, opts.getWorkerId, slot)
|
|
1343
|
+
await navigationBranch(call, byRouteId, routes, slotView, encoder, opts.getWorkerId, slot)
|
|
1178
1344
|
return 0
|
|
1179
1345
|
}
|
|
1180
1346
|
if (call.kind === 'action') {
|
|
@@ -1268,6 +1434,7 @@ function renderToAwaitedString(element: ReactNode): Promise<string> {
|
|
|
1268
1434
|
async function navigationBranch(
|
|
1269
1435
|
call: Extract<RouteCall, { kind: 'navigation' }>,
|
|
1270
1436
|
byRouteId: Map<number, FlatRoute>,
|
|
1437
|
+
routes: FlatRoute[],
|
|
1271
1438
|
view: Uint8Array,
|
|
1272
1439
|
encoder: TextEncoder,
|
|
1273
1440
|
getWorkerId: (() => number | null) | undefined,
|
|
@@ -1384,6 +1551,14 @@ async function navigationBranch(
|
|
|
1384
1551
|
// with no headers param, so staged cookies are dropped there (same as the
|
|
1385
1552
|
// full-document native render path).
|
|
1386
1553
|
let navHeaders: Record<string, string> | undefined
|
|
1554
|
+
// The nav-payload HTTP status. Normally 200, or 404 when the matched route
|
|
1555
|
+
// IS a catch-all (rendered on a genuinely-unmatched `/_brust/page/*` path).
|
|
1556
|
+
// A React loader `throw notFound()` (caught below) swaps the rendered route
|
|
1557
|
+
// to the nearest catch-all and forces this to 404 — mirroring the
|
|
1558
|
+
// full-document render path. NOTE: in the trigger case `flat` is the MATCHED
|
|
1559
|
+
// route (notFound === false), so the status must be forced to 404 explicitly,
|
|
1560
|
+
// not derived from `flat.notFound`.
|
|
1561
|
+
let navStatus = flat.notFound === true ? 404 : 200
|
|
1387
1562
|
if (flat.nativeTemplate !== undefined) {
|
|
1388
1563
|
fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId, slot)
|
|
1389
1564
|
} else {
|
|
@@ -1393,23 +1568,54 @@ async function navigationBranch(
|
|
|
1393
1568
|
// injection is needed in a NAV payload: the payload is swapped into a
|
|
1394
1569
|
// document that already booted the bootstrap (the navigator IS the
|
|
1395
1570
|
// bootstrap), and the takeover runtime imports its chunk itself.
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1571
|
+
const renderFlatToHtml = (target: FlatRoute): Promise<string> =>
|
|
1572
|
+
runInRequestContext(call.req?.cookies ?? {}, async () => {
|
|
1573
|
+
const element = await buildRenderElement(
|
|
1574
|
+
call as any,
|
|
1575
|
+
target,
|
|
1576
|
+
getWorkerId,
|
|
1577
|
+
wantsSsgFallbackShell(target, call as any),
|
|
1578
|
+
)
|
|
1579
|
+
if (!element) throw new Error('render setup failed')
|
|
1580
|
+
// Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
|
|
1581
|
+
// their RESOLVED markup, not the fallback. renderToString would only
|
|
1582
|
+
// capture the shell — navigating SPA-style to a Suspense-using route
|
|
1583
|
+
// would otherwise ship "loading…" and never recover.
|
|
1584
|
+
const html = await renderToAwaitedString(element)
|
|
1585
|
+
store = collectSnapshot()
|
|
1586
|
+
navHeaders = flushSetCookie(undefined)
|
|
1587
|
+
return html
|
|
1588
|
+
})
|
|
1589
|
+
try {
|
|
1590
|
+
fullHtml = await renderFlatToHtml(flat)
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
if (!isNotFoundTrigger(err)) throw err
|
|
1593
|
+
// React `notFound()` trigger on the SPA-nav path: abandon the matched
|
|
1594
|
+
// route and render the NEAREST catch-all (same selection as a
|
|
1595
|
+
// Rust-unmatched path) as the nav payload at HTTP 404 — NOT a 500
|
|
1596
|
+
// `{"error":"render failed"}`. Mirrors the full-document render path.
|
|
1597
|
+
navStatus = 404
|
|
1598
|
+
// nfId is the array index == route_id (catch-alls keep their slot).
|
|
1599
|
+
const nfId = selectNotFound(routes, call.path)
|
|
1600
|
+
const nfFlat = nfId !== undefined ? byRouteId.get(nfId) : undefined
|
|
1601
|
+
if (nfFlat) {
|
|
1602
|
+
try {
|
|
1603
|
+
fullHtml = await renderFlatToHtml(nfFlat)
|
|
1604
|
+
} catch (nfErr) {
|
|
1605
|
+
// The catch-all's OWN loader/render threw (notFound or a real error):
|
|
1606
|
+
// ship the framework default 404 body — don't recurse into another
|
|
1607
|
+
// catch-all selection.
|
|
1608
|
+
console.error('[brust] catch-all nav render failed:', nfErr)
|
|
1609
|
+
fullHtml = DEFAULT_NOT_FOUND_BODY
|
|
1610
|
+
store = null
|
|
1611
|
+
}
|
|
1612
|
+
} else {
|
|
1613
|
+
// No catch-all registered for this prefix → framework default 404 body
|
|
1614
|
+
// shipped as a nav payload (so the client swaps it in), at 404.
|
|
1615
|
+
fullHtml = DEFAULT_NOT_FOUND_BODY
|
|
1616
|
+
store = null
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1413
1619
|
}
|
|
1414
1620
|
|
|
1415
1621
|
// Extract <main> inner content. If the page didn't render a <main>,
|
|
@@ -1436,7 +1642,11 @@ async function navigationBranch(
|
|
|
1436
1642
|
workerId,
|
|
1437
1643
|
encoder,
|
|
1438
1644
|
{
|
|
1439
|
-
|
|
1645
|
+
// Catch-all (`path: '*'`) leaf rendered as a SPA-nav payload on an
|
|
1646
|
+
// unmatched path — OR a React `throw notFound()` swapped to the nearest
|
|
1647
|
+
// catch-all — stamps HTTP 404 while still shipping the rendered body so
|
|
1648
|
+
// the client swaps it in (vs the bare `{"error":"not found"}`).
|
|
1649
|
+
status: navStatus,
|
|
1440
1650
|
contentType: 'application/json; charset=utf-8',
|
|
1441
1651
|
body,
|
|
1442
1652
|
headers: navHeaders,
|