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.
- package/package.json +39 -15
- package/runtime/cache-sync.ts +291 -0
- package/runtime/cache.ts +4 -0
- package/runtime/cli/build.ts +7 -0
- package/runtime/cli/dev.ts +7 -0
- package/runtime/cli/native-routes-emit.ts +147 -1
- package/runtime/cli/ssg.ts +94 -23
- package/runtime/config.ts +42 -0
- package/runtime/index.d.ts +63 -0
- package/runtime/index.js +57 -52
- package/runtime/index.ts +114 -9
- package/runtime/islands/page-cache.ts +32 -2
- package/runtime/native/runtime.ts +220 -7
- package/runtime/render/fragment.ts +87 -0
- package/runtime/routes.ts +482 -95
- package/runtime/templates.ts +47 -0
- package/runtime/treaty.ts +24 -1
- package/types/action-error.d.ts +18 -0
- package/types/cache-sync.d.ts +42 -0
- package/types/cache.d.ts +20 -0
- package/types/cli/help.d.ts +28 -0
- package/types/cli/jinja-staleness.d.ts +14 -0
- package/types/cli/native-routes-emit.d.ts +217 -0
- package/types/cli/new.d.ts +30 -0
- package/types/cli/templates.d.ts +39 -0
- package/types/client/index.d.ts +14 -0
- package/types/config.d.ts +42 -0
- package/types/cookies.d.ts +25 -0
- package/types/create.d.ts +1 -0
- package/types/css/build.d.ts +11 -0
- package/types/css/component-build.d.ts +17 -0
- package/types/css/component-loader.d.ts +8 -0
- package/types/css/manifest.d.ts +21 -0
- package/types/css/process-modules.d.ts +31 -0
- package/types/css/route-deps.d.ts +20 -0
- package/types/css/scan-imports.d.ts +13 -0
- package/types/css.d.ts +16 -0
- package/types/define-actions.d.ts +133 -0
- package/types/dev/client.d.ts +8 -0
- package/types/dev/coordinator.d.ts +33 -0
- package/types/dev/inject.d.ts +6 -0
- package/types/dev/jinja-reload.d.ts +7 -0
- package/types/dev/tui.d.ts +35 -0
- package/types/dev/watcher.d.ts +34 -0
- package/types/dev/worker-registry.d.ts +17 -0
- package/types/dev/ws-channel.d.ts +39 -0
- package/types/generator.d.ts +23 -0
- package/types/index.d.ts +222 -0
- package/types/islands/brust-page.d.ts +74 -0
- package/types/islands/build.d.ts +49 -0
- package/types/islands/chunk-id.d.ts +10 -0
- package/types/islands/importmap.d.ts +2 -0
- package/types/islands/island.d.ts +65 -0
- package/types/islands/isr-jsx.d.ts +31 -0
- package/types/islands/native-render.d.ts +89 -0
- package/types/loader-cache.d.ts +18 -0
- package/types/mcp/extractor.d.ts +14 -0
- package/types/mcp/manifest.d.ts +23 -0
- package/types/mcp/schema.d.ts +19 -0
- package/types/mcp/server.d.ts +15 -0
- package/types/md/emit.d.ts +72 -0
- package/types/md/render.d.ts +80 -0
- package/types/md/routes.d.ts +119 -0
- package/types/md/scan.d.ts +34 -0
- package/types/md/slug.d.ts +1 -0
- package/types/native/build.d.ts +30 -0
- package/types/native/index.d.ts +2 -0
- package/types/native/runtime.d.ts +52 -0
- package/types/navigation/active-nav.d.ts +2 -0
- package/types/navigation/index.d.ts +5 -0
- package/types/navigation/navigate.d.ts +14 -0
- package/types/navigation/react.d.ts +15 -0
- package/types/navigation/store.d.ts +44 -0
- package/types/render/fragment.d.ts +20 -0
- package/types/render/inject-action-prefix.d.ts +9 -0
- package/types/render/inject-css-link.d.ts +8 -0
- package/types/render/inject-dev-client.d.ts +6 -0
- package/types/render/inject-generator.d.ts +7 -0
- package/types/render/inject-store.d.ts +9 -0
- package/types/render/stream.d.ts +45 -0
- package/types/request-context.d.ts +16 -0
- package/types/routes.d.ts +506 -0
- package/types/sse/handler.d.ts +22 -0
- package/types/standard-schema.d.ts +31 -0
- package/types/store/define-store.d.ts +31 -0
- package/types/store/index.d.ts +5 -0
- package/types/store/react.d.ts +2 -0
- package/types/store/serialize.d.ts +5 -0
- package/types/store/server-context.d.ts +4 -0
- package/types/store/signal.d.ts +18 -0
- package/types/templates.d.ts +18 -0
- package/types/treaty.d.ts +70 -0
- 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
|
-
/**
|
|
239
|
-
*
|
|
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:
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
-
let element: ReactNode
|
|
1132
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
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 —
|