brustjs 0.1.49-alpha → 0.1.51-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.
Files changed (93) hide show
  1. package/package.json +39 -15
  2. package/runtime/cache-sync.ts +291 -0
  3. package/runtime/cache.ts +4 -0
  4. package/runtime/cli/build.ts +7 -0
  5. package/runtime/cli/dev.ts +7 -0
  6. package/runtime/cli/native-routes-emit.ts +147 -1
  7. package/runtime/cli/ssg.ts +94 -23
  8. package/runtime/config.ts +42 -0
  9. package/runtime/index.d.ts +63 -0
  10. package/runtime/index.js +57 -52
  11. package/runtime/index.ts +114 -9
  12. package/runtime/islands/page-cache.ts +32 -2
  13. package/runtime/native/runtime.ts +220 -7
  14. package/runtime/render/fragment.ts +87 -0
  15. package/runtime/routes.ts +482 -95
  16. package/runtime/templates.ts +47 -0
  17. package/runtime/treaty.ts +24 -1
  18. package/types/action-error.d.ts +18 -0
  19. package/types/cache-sync.d.ts +42 -0
  20. package/types/cache.d.ts +20 -0
  21. package/types/cli/help.d.ts +28 -0
  22. package/types/cli/jinja-staleness.d.ts +14 -0
  23. package/types/cli/native-routes-emit.d.ts +217 -0
  24. package/types/cli/new.d.ts +30 -0
  25. package/types/cli/templates.d.ts +39 -0
  26. package/types/client/index.d.ts +14 -0
  27. package/types/config.d.ts +42 -0
  28. package/types/cookies.d.ts +25 -0
  29. package/types/create.d.ts +1 -0
  30. package/types/css/build.d.ts +11 -0
  31. package/types/css/component-build.d.ts +17 -0
  32. package/types/css/component-loader.d.ts +8 -0
  33. package/types/css/manifest.d.ts +21 -0
  34. package/types/css/process-modules.d.ts +31 -0
  35. package/types/css/route-deps.d.ts +20 -0
  36. package/types/css/scan-imports.d.ts +13 -0
  37. package/types/css.d.ts +16 -0
  38. package/types/define-actions.d.ts +133 -0
  39. package/types/dev/client.d.ts +8 -0
  40. package/types/dev/coordinator.d.ts +33 -0
  41. package/types/dev/inject.d.ts +6 -0
  42. package/types/dev/jinja-reload.d.ts +7 -0
  43. package/types/dev/tui.d.ts +35 -0
  44. package/types/dev/watcher.d.ts +34 -0
  45. package/types/dev/worker-registry.d.ts +17 -0
  46. package/types/dev/ws-channel.d.ts +39 -0
  47. package/types/generator.d.ts +23 -0
  48. package/types/index.d.ts +222 -0
  49. package/types/islands/brust-page.d.ts +74 -0
  50. package/types/islands/build.d.ts +49 -0
  51. package/types/islands/chunk-id.d.ts +10 -0
  52. package/types/islands/importmap.d.ts +2 -0
  53. package/types/islands/island.d.ts +65 -0
  54. package/types/islands/isr-jsx.d.ts +31 -0
  55. package/types/islands/native-render.d.ts +89 -0
  56. package/types/loader-cache.d.ts +18 -0
  57. package/types/mcp/extractor.d.ts +14 -0
  58. package/types/mcp/manifest.d.ts +23 -0
  59. package/types/mcp/schema.d.ts +19 -0
  60. package/types/mcp/server.d.ts +15 -0
  61. package/types/md/emit.d.ts +72 -0
  62. package/types/md/render.d.ts +80 -0
  63. package/types/md/routes.d.ts +119 -0
  64. package/types/md/scan.d.ts +34 -0
  65. package/types/md/slug.d.ts +1 -0
  66. package/types/native/build.d.ts +30 -0
  67. package/types/native/index.d.ts +2 -0
  68. package/types/native/runtime.d.ts +52 -0
  69. package/types/navigation/active-nav.d.ts +2 -0
  70. package/types/navigation/index.d.ts +5 -0
  71. package/types/navigation/navigate.d.ts +14 -0
  72. package/types/navigation/react.d.ts +15 -0
  73. package/types/navigation/store.d.ts +44 -0
  74. package/types/render/fragment.d.ts +20 -0
  75. package/types/render/inject-action-prefix.d.ts +9 -0
  76. package/types/render/inject-css-link.d.ts +8 -0
  77. package/types/render/inject-dev-client.d.ts +6 -0
  78. package/types/render/inject-generator.d.ts +7 -0
  79. package/types/render/inject-store.d.ts +9 -0
  80. package/types/render/stream.d.ts +45 -0
  81. package/types/request-context.d.ts +16 -0
  82. package/types/routes.d.ts +506 -0
  83. package/types/sse/handler.d.ts +22 -0
  84. package/types/standard-schema.d.ts +31 -0
  85. package/types/store/define-store.d.ts +31 -0
  86. package/types/store/index.d.ts +5 -0
  87. package/types/store/react.d.ts +2 -0
  88. package/types/store/serialize.d.ts +5 -0
  89. package/types/store/server-context.d.ts +4 -0
  90. package/types/store/signal.d.ts +18 -0
  91. package/types/templates.d.ts +18 -0
  92. package/types/treaty.d.ts +70 -0
  93. package/types/ws/handler.d.ts +26 -0
package/runtime/routes.ts CHANGED
@@ -1,15 +1,8 @@
1
1
  import { createContext, createElement, useContext, type ComponentType, type ReactNode } from 'react'
2
- // React 19 split react-dom/server by runtime: under Bun the bare 'react-dom/server'
3
- // resolves to the web-streams build (server.bun.js), which only exports
4
- // renderToReadableStream. renderToPipeableStream (Node streams — what our Writable
5
- // sink + onAllReady/onShellReady path uses) lives in the .node build. Import it
6
- // explicitly so SSR works on the react@19 we declare as a peer (React 18 shipped
7
- // renderToPipeableStream in the bun build, which is why this was silently fine).
8
- import { renderToPipeableStream } from 'react-dom/server.node'
9
- import { Writable } from 'node:stream'
10
2
  import { Buffer } from 'node:buffer'
11
3
  import * as native from './index.js'
12
4
  import { renderBranchStreaming } from './render/stream.ts'
5
+ import { renderToAwaitedString } from './render/fragment.ts'
13
6
  import { runInStoreContext, collectSnapshot } from './store/server-context.ts'
14
7
  import { buildStoreScripts } from './render/inject-store.ts'
15
8
  import { getCssHrefsForRoute } from './css.ts'
@@ -98,6 +91,31 @@ export interface BrustRequest {
98
91
  * unaborted shared sentinel; signal.aborted === false forever). Real
99
92
  * disconnect detection for render/action is a follow-up. */
100
93
  signal: AbortSignal
94
+ /** Matched route params (percent-decoded) — the SAME values the loader ctx
95
+ * receives. Populated by the TS dispatch layer (`prepReq`) BEFORE the
96
+ * middleware chain runs, so middleware can authorize against `{id}` without
97
+ * re-parsing the raw URL. Empty object for routes without params — and for
98
+ * SSE/WS requests (their envelope carries no params in v1). */
99
+ params: Record<string, string>
100
+ /** Request-scoped bag middleware writes and loaders/handlers read. The same
101
+ * object identity flows through the whole chain (middleware → loader →
102
+ * component/handler). Locals written before `next()` are visible downstream;
103
+ * writes AFTER `next()` returns happen after the loader already ran. Reset
104
+ * to a fresh `{}` per request. */
105
+ locals: Record<string, unknown>
106
+ }
107
+
108
+ /** Populate the TS-owned `BrustRequest` fields (`params`, `locals`) on a
109
+ * request that just arrived in the Rust envelope. Rust knows nothing of these
110
+ * fields, so every dispatch branch calls this ONCE, before composeChain /
111
+ * loaders run — the single population point. Mutates and returns `req`. */
112
+ export function prepReq(
113
+ req: BrustRequest,
114
+ params: Record<string, string> | undefined,
115
+ ): BrustRequest {
116
+ req.params = params ?? {}
117
+ req.locals = {}
118
+ return req
101
119
  }
102
120
 
103
121
  /** Handler module shape — what `() => Promise<WsHandlers>` resolves to.
@@ -235,8 +253,25 @@ export interface NativeVerdict {
235
253
  readonly headers?: Record<string, string>
236
254
  }
237
255
 
238
- /** Return from a native route loader to render the route's OWN template with
239
- * HTTP 404. `data` (default `{}`) becomes the template context. */
256
+ /** Signal "not found" from a route loader. ONE helper, two call shapes
257
+ * matching how each render path consumes a loader result:
258
+ *
259
+ * - NATIVE route loader: `return notFound(data?)`. The returned verdict is
260
+ * inspected by `runNativeChainLoaders` and renders the route's OWN template
261
+ * at HTTP 404 (`data` default `{}` becomes the template context).
262
+ *
263
+ * - REACT route loader: `throw notFound()`. A React loader's RETURN value is
264
+ * the component's `data` prop (never inspected as a verdict), so the only
265
+ * way to signal not-found is to throw. The render dispatch discriminates the
266
+ * thrown verdict (it is symbol-tagged — `isNativeVerdict`) BEFORE the generic
267
+ * 500/errorBoundary handler and renders the NEAREST catch-all (`path: '*'`)
268
+ * for the route's prefix at HTTP 404 — NOT the route's own Component, NOT a
269
+ * 500. If no catch-all is registered for the prefix, a framework default 404
270
+ * body is rendered. `data` is ignored on the React throw path (the catch-all
271
+ * runs its own loader chain).
272
+ *
273
+ * Same value type (`NativeVerdict`) either way — native returns it, React
274
+ * throws it — so there is a single public `notFound()` API. */
240
275
  export function notFound(data?: unknown): NativeVerdict {
241
276
  return { [BRUST_VERDICT]: true, status: 404, render: true, data: data ?? {} }
242
277
  }
@@ -257,6 +292,115 @@ export function isNativeVerdict(x: unknown): x is NativeVerdict {
257
292
  )
258
293
  }
259
294
 
295
+ const HTTP_ERROR: unique symbol = Symbol.for('brust.httpError')
296
+
297
+ export interface HttpErrorOpts {
298
+ /** Override the Content-Type. Defaults: string body → `text/plain;
299
+ * charset=utf-8`, object body → `application/json; charset=utf-8`. */
300
+ contentType?: string
301
+ /** Extra response headers (e.g. `WWW-Authenticate`). */
302
+ headers?: Record<string, string>
303
+ }
304
+
305
+ /** The symbol-keyed value `httpError()` throws. Body/contentType are resolved
306
+ * at construction so every catch site emits the same wire shape. Mirrors the
307
+ * NativeVerdict / ActionError branding (Symbol.for survives the trigger being
308
+ * duplicated across bundles). */
309
+ export interface HttpErrorTrigger {
310
+ readonly [HTTP_ERROR]: true
311
+ readonly status: number
312
+ readonly body: string
313
+ readonly contentType: string
314
+ readonly headers?: Record<string, string>
315
+ }
316
+
317
+ /** Throw from a route loader (React or native) to short-circuit the response
318
+ * with an arbitrary error status — the loader-side analogue of a middleware
319
+ * short-circuit:
320
+ *
321
+ * ```ts
322
+ * loader: async ({ req }) => {
323
+ * if (!req.locals.user) throw httpError(403, 'no entry')
324
+ * …
325
+ * }
326
+ * ```
327
+ *
328
+ * THROW-only (it never returns) — one usage form on both render paths, unlike
329
+ * `notFound()` which native loaders RETURN. `body`: string → text/plain (or
330
+ * `opts.contentType`), object → JSON, omitted → empty. Status must be an
331
+ * integer in 400-599: 3xx is `redirect()`'s job, a 404 that renders a page is
332
+ * `notFound()`'s. The response is a plain short-circuit — it never reaches the
333
+ * errorBoundary and never logs as a 500. */
334
+ export function httpError(status: number, body?: string | object, opts?: HttpErrorOpts): never {
335
+ if (!Number.isInteger(status) || status < 400 || status > 599) {
336
+ throw new Error(
337
+ `httpError(status) must be an integer in 400-599, got ${status} — use redirect() for 3xx and notFound() for a rendered 404`,
338
+ )
339
+ }
340
+ let bodyStr: string
341
+ let contentType: string
342
+ if (body === undefined || typeof body === 'string') {
343
+ bodyStr = body ?? ''
344
+ contentType = opts?.contentType ?? 'text/plain; charset=utf-8'
345
+ } else {
346
+ bodyStr = JSON.stringify(body)
347
+ contentType = opts?.contentType ?? 'application/json; charset=utf-8'
348
+ }
349
+ const trigger: HttpErrorTrigger = {
350
+ [HTTP_ERROR]: true,
351
+ status,
352
+ body: bodyStr,
353
+ contentType,
354
+ headers: opts?.headers,
355
+ }
356
+ throw trigger
357
+ }
358
+
359
+ /** True when a thrown value is the `httpError()` trigger (symbol-keyed — a
360
+ * plain object with a `status` property is NOT mistaken for one). */
361
+ export function isHttpErrorTrigger(x: unknown): x is HttpErrorTrigger {
362
+ return typeof x === 'object' && x !== null && (x as Record<symbol, unknown>)[HTTP_ERROR] === true
363
+ }
364
+
365
+ /** Framework default 404 body, served when a React `notFound()` fires but no
366
+ * catch-all (`path: '*'`) is registered for the route's prefix — so the response
367
+ * is still HTTP 404 with a body (never a 500, never a crash). */
368
+ const DEFAULT_NOT_FOUND_BODY =
369
+ '<!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>'
370
+
371
+ /** True when a thrown value is the React `notFound()` trigger: a NativeVerdict
372
+ * that renders at HTTP 404 (vs a thrown `redirect()`, which is `render: false`).
373
+ * Distinct from ActionError (a different Symbol) and from real Errors, so the
374
+ * render-dispatch catch can re-render the catch-all ONLY for this case and let
375
+ * everything else fall through to the 500/errorBoundary path. */
376
+ function isNotFoundTrigger(x: unknown): x is NativeVerdict {
377
+ return isNativeVerdict(x) && x.render === true && x.status === 404
378
+ }
379
+
380
+ /** Select the nearest catch-all (`path: '*'`) FlatRoute for an unmatched path —
381
+ * the JS-side mirror of Rust's `select_not_found` (routing/routes.rs). Returns
382
+ * the catch-all's route_id (array index) or `undefined` if none covers the path.
383
+ *
384
+ * Longest segment-prefix wins: a `notFoundPrefix` of `/docs` covers `/docs` and
385
+ * `/docs/...` but NOT `/docsearch`; the root catch-all (`''`) covers everything
386
+ * as the last resort. Identical precedence to the Rust unmatched-path tier, so a
387
+ * React `notFound()` selects the SAME catch-all a genuinely-unmatched path would. */
388
+ function selectNotFound(routes: FlatRoute[], path: string): number | undefined {
389
+ let bestId: number | undefined
390
+ let bestLen = -1
391
+ for (let i = 0; i < routes.length; i++) {
392
+ const r = routes[i]
393
+ if (r.notFound !== true) continue
394
+ const p = r.notFoundPrefix ?? ''
395
+ const covers = p === '' || path === p || path.startsWith(`${p}/`)
396
+ if (covers && p.length > bestLen) {
397
+ bestLen = p.length
398
+ bestId = i
399
+ }
400
+ }
401
+ return bestId
402
+ }
403
+
260
404
  /** Loader context passed to native chain loaders. */
261
405
  export interface NativeLoaderCtx {
262
406
  params: Record<string, string>
@@ -264,10 +408,15 @@ export interface NativeLoaderCtx {
264
408
  req: BrustRequest
265
409
  }
266
410
 
267
- /** Result of running a native route's chain loaders top-down: either a merged
268
- * flat context object (all loader results shallow-merged, child keys win), or
269
- * the first verdict encountered (top-down) which short-circuits the chain. */
270
- export type NativeChainResult = { data: Record<string, unknown> } | { verdict: NativeVerdict }
411
+ /** Result of running a native route's chain loaders top-down: a merged flat
412
+ * context object (all loader results shallow-merged, child keys win), the
413
+ * first verdict encountered (top-down) which short-circuits the chain, or an
414
+ * `httpError()` trigger THROWN by a loader (intercepted per-loader so it never
415
+ * reaches the caller's generic 500 catch). */
416
+ export type NativeChainResult =
417
+ | { data: Record<string, unknown> }
418
+ | { verdict: NativeVerdict }
419
+ | { httpError: HttpErrorTrigger }
271
420
 
272
421
  /** Run a native route's `flat.chain` loaders top-down (parent → leaf) and merge
273
422
  * their results into ONE flat context object.
@@ -294,7 +443,16 @@ export async function runNativeChainLoaders(
294
443
  if (!node.loader) continue
295
444
  // `as never` bypasses per-route Params narrowing — NativeLoaderCtx is the
296
445
  // default-instantiated loader ctx shape ({ params, path, req }).
297
- const result = await node.loader(ctx as never)
446
+ let result: unknown
447
+ try {
448
+ result = await node.loader(ctx as never)
449
+ } catch (err) {
450
+ // httpError() short-circuit — intercept PER-LOADER so the trigger never
451
+ // reaches the caller's generic loader catch (which logs + 500s). Any
452
+ // other throw keeps the existing 500 contract.
453
+ if (isHttpErrorTrigger(err)) return { httpError: err }
454
+ throw err
455
+ }
298
456
  if (isNativeVerdict(result)) {
299
457
  return { verdict: result }
300
458
  }
@@ -409,6 +567,22 @@ export interface FlatRoute {
409
567
  /** Sub-project J — Component.name when leaf had `native: true`. Captured
410
568
  * at flatten time (build-time AST identifier), so minifier-safe. */
411
569
  nativeTemplate?: string
570
+ /** Catch-all (`{ path: '*' }`) marker. When true this FlatRoute is a
571
+ * "not found" fallback: it stays in the array at its natural index (route_id
572
+ * stable) but install SKIPS the matchit insert for it — it only renders when
573
+ * matchit returns NoMatch under `notFoundPrefix`. NEVER remove a flagged
574
+ * entry from the flat array (that would shift every later route_id).
575
+ *
576
+ * A catch-all render is stamped HTTP 404 UNCONDITIONALLY (spec invariant 4:
577
+ * "never 200"). A `redirect()` from the catch-all's OWN loader still wins —
578
+ * redirect verdicts short-circuit and return before the 404 stamp on every
579
+ * path. A non-redirect verdict status, however, is overridden by 404 (a 404
580
+ * page is a 404, by definition). */
581
+ notFound?: boolean
582
+ /** Parent layout's path prefix this catch-all covers. Root catch-all → `''`
583
+ * (matches everything as last resort). Longest segment-prefix wins at match
584
+ * time. Never contains `*` (it's the parent prefix, not the catch-all path). */
585
+ notFoundPrefix?: string
412
586
  }
413
587
 
414
588
  /** Compose a child's relative path onto a parent's base path.
@@ -530,7 +704,10 @@ function assertNativeSubtree(children: Route[], where: string): void {
530
704
  * the design spec (S3). */
531
705
  export function flattenRoutes(routes: Route[]): FlatRoute[] {
532
706
  const out: FlatRoute[] = []
533
- walkRoutes(routes, [], '', out)
707
+ // Tracks `notFoundPrefix` values already claimed by a catch-all so a second
708
+ // catch-all under the same prefix throws instead of silently shadowing.
709
+ const seenNotFoundPrefixes = new Set<string>()
710
+ walkRoutes(routes, [], '', out, seenNotFoundPrefixes)
534
711
  return out
535
712
  }
536
713
 
@@ -539,6 +716,7 @@ function walkRoutes(
539
716
  parentChain: Route[],
540
717
  basePath: string,
541
718
  out: FlatRoute[],
719
+ seenNotFoundPrefixes: Set<string>,
542
720
  ): void {
543
721
  for (const r of routes) {
544
722
  validateRoute(r, basePath)
@@ -549,11 +727,36 @@ function walkRoutes(
549
727
  continue
550
728
  }
551
729
 
730
+ // Catch-all (`{ path: '*' }`) — a "not found" fallback for the `basePath`
731
+ // subtree. It is a LEAF (no children/index) and is KEPT in the flat array
732
+ // at its natural index (route_id stable) flagged `notFound`. Its fullPath
733
+ // is set to the parent prefix (never a `*` matchit pattern) so a later
734
+ // install step can skip the matchit insert and rely on the flag.
735
+ if (r.path === '*') {
736
+ if (r.children && r.children.length > 0) {
737
+ throw new Error(`catch-all route "*" must be a leaf (no children) under "${basePath}"`)
738
+ }
739
+ // (index is already mutually exclusive with path via validateRoute.)
740
+ const prefix = basePath
741
+ if (seenNotFoundPrefixes.has(prefix)) {
742
+ throw new Error(
743
+ `duplicate catch-all route "*": only one catch-all allowed per prefix "${prefix}"`,
744
+ )
745
+ }
746
+ seenNotFoundPrefixes.add(prefix)
747
+ // Construct atomically (no post-hoc mutation) so the flag + prefix are
748
+ // never observable half-set. `fullPath === notFoundPrefix` by design; the
749
+ // install step gates the matchit-insert skip on the `notFound` flag, not
750
+ // on fullPath (see runtime/index.ts registerRoutes).
751
+ out.push({ ...makeFlat(chain, prefix), notFound: true, notFoundPrefix: prefix })
752
+ continue
753
+ }
754
+
552
755
  const ownPath = r.path ?? ''
553
756
  const myPath = joinPath(basePath, ownPath)
554
757
 
555
758
  if (r.children && r.children.length > 0) {
556
- walkRoutes(r.children, chain, myPath, out)
759
+ walkRoutes(r.children, chain, myPath, out, seenNotFoundPrefixes)
557
760
  } else {
558
761
  // Leaf with a path (validated above).
559
762
  out.push(makeFlat(chain, myPath))
@@ -815,6 +1018,9 @@ export function makeRenderer(
815
1018
  // have a per-conn AbortController. Middleware/loaders/components
816
1019
  // can still read req.signal.aborted (always false).
817
1020
  call.req.signal = NEVER_ABORTS
1021
+ // Populate the TS-owned req fields (matched params + a fresh locals bag)
1022
+ // BEFORE the middleware chain runs.
1023
+ prepReq(call.req, call.params)
818
1024
 
819
1025
  // Run the middleware chain against a streaming-marker terminal.
820
1026
  // Middleware that short-circuits (returns without calling next())
@@ -835,6 +1041,16 @@ export function makeRenderer(
835
1041
  try {
836
1042
  verdict = (await chain()) as StreamMarkerResponse
837
1043
  } catch (err) {
1044
+ // `throw httpError(...)` belongs in loaders, but a middleware that
1045
+ // throws it still deserves the intended status, not a 500 + log dump.
1046
+ if (isHttpErrorTrigger(err)) {
1047
+ return packSingleChunkResponse(slotView, encoder, {
1048
+ status: err.status,
1049
+ contentType: err.contentType,
1050
+ body: err.body,
1051
+ headers: err.headers,
1052
+ })
1053
+ }
838
1054
  console.error(`[brust] middleware/render uncaught:`, err)
839
1055
  // FAST LANE: single-chunk error. Works for both React (big dispatch
840
1056
  // reads the SAB via its fast-lane arm) and native routes (which take
@@ -972,6 +1188,18 @@ export function makeRenderer(
972
1188
  body: 'internal error',
973
1189
  })
974
1190
  }
1191
+ if ('httpError' in chainResult) {
1192
+ // A native loader threw httpError() — short-circuit with the
1193
+ // trigger's response (same fast-lane mechanism as a middleware
1194
+ // short-circuit, which already carries arbitrary statuses).
1195
+ const he = chainResult.httpError
1196
+ return packSingleChunkResponse(slotView, encoder, {
1197
+ status: he.status,
1198
+ contentType: he.contentType,
1199
+ body: he.body,
1200
+ headers: he.headers,
1201
+ })
1202
+ }
975
1203
  let renderStatus: number | undefined
976
1204
  if ('verdict' in chainResult) {
977
1205
  // First verdict wins (top-down). Remaining loaders already skipped.
@@ -991,6 +1219,12 @@ export function makeRenderer(
991
1219
  } else {
992
1220
  data = chainResult.data
993
1221
  }
1222
+ // Catch-all (`path: '*'`) leaf rendered on an unmatched path: stamp HTTP
1223
+ // 404 unconditionally, regardless of any verdict status. The loader does
1224
+ // NOT need to call notFound() — being a catch-all IS the 404 signal.
1225
+ if (flat.notFound === true) {
1226
+ renderStatus = 404
1227
+ }
994
1228
  // B7 — native store-snapshot SSR. Fill the framework-owned
995
1229
  // `{{ __brust_store__ | safe }}` slot (emitted into every native
996
1230
  // full-document <head>) with the defineStore SSR snapshot collected
@@ -1127,32 +1361,107 @@ export function makeRenderer(
1127
1361
  return await runInRequestContext(call.req?.cookies ?? {}, async () => {
1128
1362
  // Computed ONCE and shared by buildRenderElement (leaf swap) and
1129
1363
  // renderBranchStreaming (forceIslands) so the two can never diverge.
1130
- const shellMode = wantsSsgFallbackShell(flat, call)
1131
- let element: ReactNode
1132
- let errorBoundary: ComponentType<{ error: Error }>
1364
+ let shellMode = wantsSsgFallbackShell(flat, call)
1365
+ let element: ReactNode = null
1366
+ // The route actually rendered + its HTTP status. Normally the matched
1367
+ // `flat` at the verdict status (404 when the matched route IS a
1368
+ // catch-all). A React loader that `throw`s `notFound()` swaps both to
1369
+ // the nearest catch-all at 404 below.
1370
+ let renderFlat = flat
1371
+ let renderStatus = flat.notFound === true ? 404 : verdict.status
1133
1372
  try {
1134
1373
  element = await buildRenderElement(call, flat, opts.getWorkerId, shellMode)
1135
- errorBoundary =
1136
- flat.errorBoundary ??
1137
- (({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
1138
1374
  } 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
- )
1375
+ if (isHttpErrorTrigger(err)) {
1376
+ // React loader threw httpError() short-circuit with the trigger's
1377
+ // response. NOT the errorBoundary, NOT the 500 path below: this is
1378
+ // a deliberate response, like a middleware short-circuit.
1379
+ return await emitSingleChunkResponse(
1380
+ slotView,
1381
+ napi,
1382
+ workerId,
1383
+ encoder,
1384
+ {
1385
+ status: err.status,
1386
+ contentType: err.contentType,
1387
+ body: err.body,
1388
+ headers: err.headers,
1389
+ },
1390
+ slot,
1391
+ )
1392
+ }
1393
+ if (isNotFoundTrigger(err)) {
1394
+ // React `notFound()` trigger: abandon the matched route and render
1395
+ // the NEAREST catch-all (same selection as a Rust-unmatched path)
1396
+ // at HTTP 404 — NOT the route's own Component, NOT a 500. Reuse the
1397
+ // existing 404-render machinery by re-running buildRenderElement
1398
+ // against the catch-all's chain.
1399
+ // nfId is the array index, which IS the route_id (catch-alls keep
1400
+ // their natural slot — see FlatRoute.notFound), so byRouteId resolves
1401
+ // the same flat route the Rust tier picks for an unmatched path.
1402
+ const nfId = selectNotFound(routes, call.path)
1403
+ const nfFlat = nfId !== undefined ? byRouteId.get(nfId) : undefined
1404
+ renderStatus = 404
1405
+ if (nfFlat) {
1406
+ renderFlat = nfFlat
1407
+ shellMode = wantsSsgFallbackShell(nfFlat, call)
1408
+ try {
1409
+ element = await buildRenderElement(call, nfFlat, opts.getWorkerId, shellMode)
1410
+ } catch (nfErr) {
1411
+ // The catch-all's OWN loader/render setup failed — don't loop;
1412
+ // fall back to the framework default 404 body at 404.
1413
+ console.error(`[brust] catch-all render setup failed:`, nfErr)
1414
+ return await emitSingleChunkResponse(
1415
+ slotView,
1416
+ napi,
1417
+ workerId,
1418
+ encoder,
1419
+ {
1420
+ status: 404,
1421
+ contentType: 'text/html; charset=utf-8',
1422
+ body: DEFAULT_NOT_FOUND_BODY,
1423
+ },
1424
+ slot,
1425
+ )
1426
+ }
1427
+ } else {
1428
+ // No catch-all registered for this prefix → framework default 404
1429
+ // body at status 404 (don't crash, don't 500).
1430
+ return await emitSingleChunkResponse(
1431
+ slotView,
1432
+ napi,
1433
+ workerId,
1434
+ encoder,
1435
+ {
1436
+ status: 404,
1437
+ contentType: 'text/html; charset=utf-8',
1438
+ body: DEFAULT_NOT_FOUND_BODY,
1439
+ },
1440
+ slot,
1441
+ )
1442
+ }
1443
+ } else {
1444
+ // Setup failure BEFORE renderToPipeableStream — loader throw, params
1445
+ // bind throw. Shape matches the legacy "internal error" path so
1446
+ // existing integration tests stay green.
1447
+ console.error(`[brust] render setup failed:`, err)
1448
+ return await emitSingleChunkResponse(
1449
+ slotView,
1450
+ napi,
1451
+ workerId,
1452
+ encoder,
1453
+ {
1454
+ status: 500,
1455
+ contentType: 'text/html; charset=utf-8',
1456
+ body: 'internal error',
1457
+ },
1458
+ slot,
1459
+ )
1460
+ }
1155
1461
  }
1462
+ const errorBoundary: ComponentType<{ error: Error }> =
1463
+ renderFlat.errorBoundary ??
1464
+ (({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
1156
1465
  const storeSnapshot = collectSnapshot()
1157
1466
  await renderBranchStreaming({
1158
1467
  element,
@@ -1161,9 +1470,11 @@ export function makeRenderer(
1161
1470
  workerId,
1162
1471
  napi,
1163
1472
  errorBoundary,
1164
- status: verdict.status,
1473
+ // Catch-all (`path: '*'`) leaf rendered on an unmatched path OR a
1474
+ // React `notFound()` swap: stamp HTTP 404 (mirrors the native path).
1475
+ status: renderStatus,
1165
1476
  headers: flushSetCookie(verdict.headers),
1166
- routePath: flat.fullPath,
1477
+ routePath: renderFlat.fullPath,
1167
1478
  storeSnapshot,
1168
1479
  // SSG fallback shells have zero islands on the page but the
1169
1480
  // client-loader runtime still needs the importmap + bootstrap.
@@ -1174,7 +1485,7 @@ export function makeRenderer(
1174
1485
  })
1175
1486
  }
1176
1487
  if (call.kind === 'navigation') {
1177
- await navigationBranch(call, byRouteId, slotView, encoder, opts.getWorkerId, slot)
1488
+ await navigationBranch(call, byRouteId, routes, slotView, encoder, opts.getWorkerId, slot)
1178
1489
  return 0
1179
1490
  }
1180
1491
  if (call.kind === 'action') {
@@ -1229,45 +1540,13 @@ export function makeRenderer(
1229
1540
  }
1230
1541
  }
1231
1542
 
1232
- /** Render a React element to a single HTML string, awaiting all Suspense
1233
- * boundaries via onAllReady. Used by navigationBranch renderToString
1234
- * would only capture the shell + fallbacks, while renderBranchStreaming
1235
- * is for the streaming render path. */
1236
- function renderToAwaitedString(element: ReactNode): Promise<string> {
1237
- return new Promise<string>((resolve, reject) => {
1238
- const chunks: Buffer[] = []
1239
- const sink = new Writable({
1240
- write(chunk, _enc, cb) {
1241
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
1242
- cb()
1243
- },
1244
- })
1245
- sink.on('finish', () => resolve(Buffer.concat(chunks).toString('utf8')))
1246
- sink.on('error', reject)
1247
-
1248
- let stream: ReturnType<typeof renderToPipeableStream>
1249
- stream = renderToPipeableStream(element, {
1250
- onAllReady() {
1251
- try {
1252
- stream.pipe(sink)
1253
- } catch (e) {
1254
- reject(e)
1255
- }
1256
- },
1257
- onShellError(err) {
1258
- reject(err)
1259
- },
1260
- onError(err) {
1261
- // Logged at the navigationBranch level; let onAllReady drive completion.
1262
- console.error('[brust] navigation render onError:', err)
1263
- },
1264
- })
1265
- })
1266
- }
1543
+ // renderToAwaitedString moved to render/fragment.ts (shared with the public
1544
+ // renderFragment API) imported above; navigationBranch behavior unchanged.
1267
1545
 
1268
1546
  async function navigationBranch(
1269
1547
  call: Extract<RouteCall, { kind: 'navigation' }>,
1270
1548
  byRouteId: Map<number, FlatRoute>,
1549
+ routes: FlatRoute[],
1271
1550
  view: Uint8Array,
1272
1551
  encoder: TextEncoder,
1273
1552
  getWorkerId: (() => number | null) | undefined,
@@ -1317,6 +1596,10 @@ async function navigationBranch(
1317
1596
  _brustStream: NAV_MARKER,
1318
1597
  })
1319
1598
 
1599
+ // Populate the TS-owned req fields (matched params + a fresh locals bag)
1600
+ // BEFORE the middleware chain runs — mirrors the render branch.
1601
+ prepReq(call.req, call.params)
1602
+
1320
1603
  // navigationBranch receives a 'navigation' call, but composeChain only
1321
1604
  // needs req + middleware — cast to satisfy the type.
1322
1605
  const navChain = composeChain(
@@ -1329,6 +1612,17 @@ async function navigationBranch(
1329
1612
  try {
1330
1613
  navVerdict = (await navChain()) as NavMarkerResponse
1331
1614
  } catch (err) {
1615
+ // Same guard as the render path: an httpError thrown from middleware gets
1616
+ // its intended status (non-2xx nav → client full-reload contract).
1617
+ if (isHttpErrorTrigger(err)) {
1618
+ await emitSingleChunkResponse(view, napi, workerId, encoder, {
1619
+ status: err.status,
1620
+ contentType: err.contentType,
1621
+ body: err.body,
1622
+ headers: err.headers,
1623
+ })
1624
+ return
1625
+ }
1332
1626
  console.error('[brust] navigation middleware threw:', err)
1333
1627
  await emitSingleChunkResponse(
1334
1628
  view,
@@ -1384,6 +1678,14 @@ async function navigationBranch(
1384
1678
  // with no headers param, so staged cookies are dropped there (same as the
1385
1679
  // full-document native render path).
1386
1680
  let navHeaders: Record<string, string> | undefined
1681
+ // The nav-payload HTTP status. Normally 200, or 404 when the matched route
1682
+ // IS a catch-all (rendered on a genuinely-unmatched `/_brust/page/*` path).
1683
+ // A React loader `throw notFound()` (caught below) swaps the rendered route
1684
+ // to the nearest catch-all and forces this to 404 — mirroring the
1685
+ // full-document render path. NOTE: in the trigger case `flat` is the MATCHED
1686
+ // route (notFound === false), so the status must be forced to 404 explicitly,
1687
+ // not derived from `flat.notFound`.
1688
+ let navStatus = flat.notFound === true ? 404 : 200
1387
1689
  if (flat.nativeTemplate !== undefined) {
1388
1690
  fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId, slot)
1389
1691
  } else {
@@ -1393,23 +1695,54 @@ async function navigationBranch(
1393
1695
  // injection is needed in a NAV payload: the payload is swapped into a
1394
1696
  // document that already booted the bootstrap (the navigator IS the
1395
1697
  // 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
- })
1698
+ const renderFlatToHtml = (target: FlatRoute): Promise<string> =>
1699
+ runInRequestContext(call.req?.cookies ?? {}, async () => {
1700
+ const element = await buildRenderElement(
1701
+ call as any,
1702
+ target,
1703
+ getWorkerId,
1704
+ wantsSsgFallbackShell(target, call as any),
1705
+ )
1706
+ if (!element) throw new Error('render setup failed')
1707
+ // Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
1708
+ // their RESOLVED markup, not the fallback. renderToString would only
1709
+ // capture the shell navigating SPA-style to a Suspense-using route
1710
+ // would otherwise ship "loading…" and never recover.
1711
+ const html = await renderToAwaitedString(element)
1712
+ store = collectSnapshot()
1713
+ navHeaders = flushSetCookie(undefined)
1714
+ return html
1715
+ })
1716
+ try {
1717
+ fullHtml = await renderFlatToHtml(flat)
1718
+ } catch (err) {
1719
+ if (!isNotFoundTrigger(err)) throw err
1720
+ // React `notFound()` trigger on the SPA-nav path: abandon the matched
1721
+ // route and render the NEAREST catch-all (same selection as a
1722
+ // Rust-unmatched path) as the nav payload at HTTP 404 — NOT a 500
1723
+ // `{"error":"render failed"}`. Mirrors the full-document render path.
1724
+ navStatus = 404
1725
+ // nfId is the array index == route_id (catch-alls keep their slot).
1726
+ const nfId = selectNotFound(routes, call.path)
1727
+ const nfFlat = nfId !== undefined ? byRouteId.get(nfId) : undefined
1728
+ if (nfFlat) {
1729
+ try {
1730
+ fullHtml = await renderFlatToHtml(nfFlat)
1731
+ } catch (nfErr) {
1732
+ // The catch-all's OWN loader/render threw (notFound or a real error):
1733
+ // ship the framework default 404 body — don't recurse into another
1734
+ // catch-all selection.
1735
+ console.error('[brust] catch-all nav render failed:', nfErr)
1736
+ fullHtml = DEFAULT_NOT_FOUND_BODY
1737
+ store = null
1738
+ }
1739
+ } else {
1740
+ // No catch-all registered for this prefix → framework default 404 body
1741
+ // shipped as a nav payload (so the client swaps it in), at 404.
1742
+ fullHtml = DEFAULT_NOT_FOUND_BODY
1743
+ store = null
1744
+ }
1745
+ }
1413
1746
  }
1414
1747
 
1415
1748
  // Extract <main> inner content. If the page didn't render a <main>,
@@ -1436,7 +1769,11 @@ async function navigationBranch(
1436
1769
  workerId,
1437
1770
  encoder,
1438
1771
  {
1439
- status: 200,
1772
+ // Catch-all (`path: '*'`) leaf rendered as a SPA-nav payload on an
1773
+ // unmatched path — OR a React `throw notFound()` swapped to the nearest
1774
+ // catch-all — stamps HTTP 404 while still shipping the rendered body so
1775
+ // the client swaps it in (vs the bare `{"error":"not found"}`).
1776
+ status: navStatus,
1440
1777
  contentType: 'application/json; charset=utf-8',
1441
1778
  body,
1442
1779
  headers: navHeaders,
@@ -1444,6 +1781,28 @@ async function navigationBranch(
1444
1781
  slot,
1445
1782
  )
1446
1783
  } catch (err) {
1784
+ if (isHttpErrorTrigger(err)) {
1785
+ // A loader threw httpError() on the SPA-nav path (React render or the
1786
+ // native re-throw below). Mirror the existing non-2xx middleware
1787
+ // short-circuit semantics: emit the trigger's response as-is — the
1788
+ // client treats any non-2xx nav response as a fallback trigger (full
1789
+ // reload), so the user lands on the authoritative document response.
1790
+ // No new JSON error shape.
1791
+ await emitSingleChunkResponse(
1792
+ view,
1793
+ napi,
1794
+ workerId,
1795
+ encoder,
1796
+ {
1797
+ status: err.status,
1798
+ contentType: err.contentType,
1799
+ body: err.body,
1800
+ headers: err.headers,
1801
+ },
1802
+ slot,
1803
+ )
1804
+ return
1805
+ }
1447
1806
  console.error('[brust] navigation render failed:', err)
1448
1807
  await emitSingleChunkResponse(
1449
1808
  view,
@@ -1493,6 +1852,12 @@ async function renderNativeRouteToHtml(
1493
1852
  }),
1494
1853
  )
1495
1854
 
1855
+ if ('httpError' in chainResult) {
1856
+ // Re-throw the trigger — navigationBranch's catch emits it as the nav
1857
+ // response (non-2xx → client full-reload, mirroring a middleware
1858
+ // short-circuit).
1859
+ throw chainResult.httpError
1860
+ }
1496
1861
  if ('verdict' in chainResult) {
1497
1862
  // SPA nav can't emit a redirect/404 in-place; force the client's full-reload
1498
1863
  // fallback so the document path produces the authoritative status.
@@ -1860,6 +2225,10 @@ export async function dispatchAction(
1860
2225
  }
1861
2226
  }
1862
2227
  call.req.signal = NEVER_ABORTS
2228
+ // Populate the TS-owned req fields (matched params + a fresh locals bag)
2229
+ // BEFORE the middleware chain runs. ctx.req === call.req, so handler locals
2230
+ // reads see middleware writes with zero threading.
2231
+ prepReq(call.req, call.params)
1863
2232
 
1864
2233
  // Body decode — dispatch by content-type (JSON / urlencoded / multipart).
1865
2234
  let rawBody: unknown
@@ -1948,6 +2317,15 @@ export async function dispatchAction(
1948
2317
  } catch (err) {
1949
2318
  if (isActionError(err)) {
1950
2319
  response = actionErrorResponse(err)
2320
+ } else if (isHttpErrorTrigger(err)) {
2321
+ // ActionError is the idiomatic action-status tool, but an httpError
2322
+ // escaping a shared middleware/handler still gets its intended status.
2323
+ response = {
2324
+ status: err.status,
2325
+ body: err.body,
2326
+ contentType: err.contentType,
2327
+ headers: err.headers,
2328
+ }
1951
2329
  } else {
1952
2330
  console.error('[brust] action middleware uncaught:', err)
1953
2331
  response = {
@@ -1981,6 +2359,9 @@ async function mcpBranchToResponse(
1981
2359
  // reading req.signal.aborted always sees false; the SSE branch is where
1982
2360
  // real disconnect lives.
1983
2361
  call.req.signal = NEVER_ABORTS
2362
+ // No params in the MCP envelope and no middleware chain — but the
2363
+ // BrustRequest contract declares params/locals non-optional, so populate.
2364
+ prepReq(call.req, undefined)
1984
2365
  const responseJson = await mcp.handleRequest(call.body_text, call.req)
1985
2366
  if (responseJson === '') {
1986
2367
  // Notification — no response body. Return 204 No Content.
@@ -2054,6 +2435,9 @@ async function sseBranch(
2054
2435
  // Inject NEVER_ABORTS into req for the middleware run. handleSseStream
2055
2436
  // will overwrite with a per-conn AbortController.signal afterward.
2056
2437
  call.req.signal = NEVER_ABORTS
2438
+ // The SSE envelope carries NO params — middleware sees empty params in v1
2439
+ // (widening the Rust envelope is a separate change). Fresh locals bag.
2440
+ prepReq(call.req, undefined)
2057
2441
 
2058
2442
  // Run middleware chain with a 200 placeholder terminal. The terminal
2059
2443
  // does NOT invoke leaf.sse — handleSseStream does that after middleware
@@ -2160,6 +2544,9 @@ async function wsBranch(
2160
2544
  // lifecycle is via registered onMessage/onClose callbacks rather than
2161
2545
  // awaiting on a request signal.
2162
2546
  call.req.signal = NEVER_ABORTS
2547
+ // The WS envelope carries NO params — middleware sees empty params in v1
2548
+ // (same contract as SSE above). Fresh locals bag.
2549
+ prepReq(call.req, undefined)
2163
2550
 
2164
2551
  // Run middleware chain with a 101 placeholder terminal that signals
2165
2552
  // "ready to upgrade". The terminal does NOT call route.websocket —