brustjs 0.1.49-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.49-alpha",
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.49-alpha",
45
- "brustjs-darwin-arm64": "0.1.49-alpha",
46
- "brustjs-linux-x64-gnu": "0.1.49-alpha",
47
- "brustjs-linux-arm64-gnu": "0.1.49-alpha",
48
- "brustjs-linux-x64-musl": "0.1.49-alpha",
49
- "brustjs-linux-arm64-musl": "0.1.49-alpha"
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",
@@ -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) {
@@ -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
- /** Static-host 404 document for `fallback: 'client'` routes: inlines the
267
- * [{pattern, doc}] manifest pairs; a path matching a fallback pattern stashes
268
- * the REAL url in sessionStorage (the takeover runtime restores it via
269
- * history.replaceState) and redirects to the prerendered fallback shell.
270
- * No match → plain 404 text. Pure string fn so the script/inline-JSON
271
- * contract is unit-testable. Escapes for the <script> context: `<`/`>` (no
272
- * `</script>`/`<!--`/`-->` sequences) and U+2028/U+2029 (legal in JSON,
273
- * illegal in pre-ES2019-parsed JS string literals). Patterns are
274
- * author-controlled belt-and-braces, not a trust boundary. */
275
- export function fallback404Html(pairs: Array<{ pattern: string; doc: string }>): string {
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 `<!doctype html><html><head><meta charset="utf-8"><title>404</title></head><body>
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></body></html>
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
- // 404.html redirects non-prerendered fallback paths to their shell.
562
- // An app-authored public/404.html wins it lands via the public/
563
- // copy below, and the author owns the redirect contract.
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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.49-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.49-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
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)
@@ -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
- if (!resp.ok) throw new Error(`navigation: status ${resp.status}`)
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
package/runtime/routes.ts CHANGED
@@ -235,8 +235,25 @@ export interface NativeVerdict {
235
235
  readonly headers?: Record<string, string>
236
236
  }
237
237
 
238
- /** Return from a native route loader to render the route's OWN template with
239
- * HTTP 404. `data` (default `{}`) becomes the template context. */
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
- walkRoutes(routes, [], '', out)
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
- const shellMode = wantsSsgFallbackShell(flat, call)
1131
- let element: ReactNode
1132
- let errorBoundary: ComponentType<{ error: Error }>
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
- // Setup failure BEFORE renderToPipeableStream — loader throw, params
1140
- // bind throw. Shape matches the legacy "internal error" path so
1141
- // existing integration tests stay green.
1142
- console.error(`[brust] render setup failed:`, err)
1143
- return await emitSingleChunkResponse(
1144
- slotView,
1145
- napi,
1146
- workerId,
1147
- encoder,
1148
- {
1149
- status: 500,
1150
- contentType: 'text/html; charset=utf-8',
1151
- body: 'internal error',
1152
- },
1153
- slot,
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
- status: verdict.status,
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: flat.fullPath,
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
- fullHtml = await runInRequestContext(call.req?.cookies ?? {}, async () => {
1397
- const element = await buildRenderElement(
1398
- call as any,
1399
- flat,
1400
- getWorkerId,
1401
- wantsSsgFallbackShell(flat, call as any),
1402
- )
1403
- if (!element) throw new Error('render setup failed')
1404
- // Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
1405
- // their RESOLVED markup, not the fallback. renderToString would only
1406
- // capture the shell navigating SPA-style to a Suspense-using route
1407
- // would otherwise ship "loading…" and never recover.
1408
- const html = await renderToAwaitedString(element)
1409
- store = collectSnapshot()
1410
- navHeaders = flushSetCookie(undefined)
1411
- return html
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
- status: 200,
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,