brustjs 0.1.23-alpha → 0.1.24-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/runtime/routes.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  resolveComponentContext,
19
19
  } from './islands/native-render.ts'
20
20
  import type { IslandCache } from './islands/native-render.ts'
21
+ import { isActionError, type ActionError, type ActionErrorBody } from './action-error.ts'
21
22
  import type { EndpointDef } from './define-actions.ts'
22
23
  import { isRespondSentinel, makeRespond } from './define-actions.ts'
23
24
  import { validate } from './standard-schema.ts'
@@ -180,6 +181,54 @@ export function isNativeVerdict(x: unknown): x is NativeVerdict {
180
181
  )
181
182
  }
182
183
 
184
+ /** Loader context passed to native chain loaders. */
185
+ export interface NativeLoaderCtx {
186
+ params: Record<string, string>
187
+ path: string
188
+ req: BrustRequest
189
+ }
190
+
191
+ /** Result of running a native route's chain loaders top-down: either a merged
192
+ * flat context object (all loader results shallow-merged, child keys win), or
193
+ * the first verdict encountered (top-down) which short-circuits the chain. */
194
+ export type NativeChainResult = { data: Record<string, unknown> } | { verdict: NativeVerdict }
195
+
196
+ /** Run a native route's `flat.chain` loaders top-down (parent → leaf) and merge
197
+ * their results into ONE flat context object.
198
+ *
199
+ * Semantics (T3 — native <Outlet> loader chain):
200
+ * - Each chain node may lack a loader → skip it.
201
+ * - Results merge shallow: `merged = { ...merged, ...result }` — later (child)
202
+ * keys win over earlier (parent) keys.
203
+ * - First verdict wins: if any loader returns a `notFound()`/`redirect()`
204
+ * verdict (top-down), STOP — remaining loaders are NOT run — and return it.
205
+ * - `chain.length === 1` (or only the leaf has a loader) → behaves exactly as
206
+ * the old leaf-only read (no regression).
207
+ *
208
+ * The caller is responsible for wrapping this in a SINGLE `runInStoreContext`
209
+ * (one Map per request) so parent loader store writes are visible to child
210
+ * loaders — mirroring the React path. This helper itself does NOT open a store
211
+ * scope. */
212
+ export async function runNativeChainLoaders(
213
+ chain: Route[],
214
+ ctx: NativeLoaderCtx,
215
+ ): Promise<NativeChainResult> {
216
+ let merged: Record<string, unknown> = {}
217
+ for (const node of chain) {
218
+ if (!node.loader) continue
219
+ // `as never` bypasses per-route Params narrowing — NativeLoaderCtx is the
220
+ // default-instantiated loader ctx shape ({ params, path, req }).
221
+ const result = await node.loader(ctx as never)
222
+ if (isNativeVerdict(result)) {
223
+ return { verdict: result }
224
+ }
225
+ if (result && typeof result === 'object') {
226
+ merged = { ...merged, ...(result as Record<string, unknown>) }
227
+ }
228
+ }
229
+ return { data: merged }
230
+ }
231
+
183
232
  /** Middleware contract — Express/Koa-style chain. Receives a structured
184
233
  * request and a `next()` that runs the rest of the chain (eventually the
185
234
  * loader + render). Return a `RouteResponse` to short-circuit, or call
@@ -332,8 +381,12 @@ function validateRoute(r: Route, basePath: string): void {
332
381
  if (r.websocket !== undefined) {
333
382
  throw new Error(`Route ${where}: 'native: true' cannot coexist with 'websocket'`)
334
383
  }
384
+ // T2 — `native: true` MAY have children, but only if the ENTIRE subtree is
385
+ // also native. The build synthesizes a per-leaf wrapper that composes the
386
+ // whole route chain into one native template; a non-native node anywhere in
387
+ // that chain can't be inlined (it would render via React, breaking native).
335
388
  if (r.children !== undefined) {
336
- throw new Error(`Route ${where}: 'native: true' cannot have nested children`)
389
+ assertNativeSubtree(r.children, where)
337
390
  }
338
391
  if (r.cache !== undefined) {
339
392
  throw new Error(`Route ${where}: 'native: true' cannot coexist with 'cache' (deferred)`)
@@ -369,6 +422,24 @@ function validateRoute(r: Route, basePath: string): void {
369
422
  }
370
423
  }
371
424
 
425
+ /** T2 — recursively assert every node in a native subtree is also `native: true`.
426
+ * A native chain is composed into one native template at build time, so a
427
+ * non-native node anywhere in the subtree would have to render via React,
428
+ * breaking the native fast path. */
429
+ function assertNativeSubtree(children: Route[], where: string): void {
430
+ for (const child of children) {
431
+ if (child.native !== true) {
432
+ // Name the offending CHILD, not the native parent — `where` is the parent.
433
+ throw new Error(
434
+ `native route cannot mix native and non-native components in one chain (offending child: ${child.path ?? child.Component?.name ?? '(no path)'}, under: ${where})`,
435
+ )
436
+ }
437
+ if (child.children !== undefined) {
438
+ assertNativeSubtree(child.children, child.path ?? where)
439
+ }
440
+ }
441
+ }
442
+
372
443
  /** Walk the nested route tree, emitting one FlatRoute per leaf or index node.
373
444
  * Composes paths, middleware, errorBoundary, and cache per the rules in
374
445
  * the design spec (S3). */
@@ -416,6 +487,17 @@ function makeFlat(chain: Route[], fullPath: string): FlatRoute {
416
487
  }
417
488
  const leaf = chain[chain.length - 1]
418
489
  const cache = leaf.cache
490
+ // T2 — a native leaf demands an all-native chain (the build composes the
491
+ // whole chain into one native template). Reject a native leaf reached through
492
+ // a non-native ancestor. NOTE: this is the PRIMARY (not redundant) guard for the
493
+ // non-native-parent → native-child direction — `assertNativeSubtree` only runs
494
+ // from a native PARENT (validateRoute's `if (r.native)` block), so a non-native
495
+ // parent never triggers it; this check is what catches that case.
496
+ if (leaf.native === true && chain.some((node) => node.native !== true)) {
497
+ throw new Error(
498
+ `native route cannot mix native and non-native components in one chain (route: ${leaf.path ?? fullPath})`,
499
+ )
500
+ }
419
501
  const nativeTemplate = leaf.native === true && leaf.Component ? leaf.Component.name : undefined
420
502
  return { fullPath, chain, middleware, errorBoundary, cache, nativeTemplate }
421
503
  }
@@ -632,43 +714,48 @@ export function makeRenderer(
632
714
  // deferred to v2.x. If your middleware needs to mutate, use a React
633
715
  // route for now.
634
716
  if (flat.nativeTemplate !== undefined) {
635
- let data: unknown = {}
636
- const leaf = flat.chain[flat.chain.length - 1]
637
- if (leaf.loader) {
638
- const ctx = { params: call.params, path: call.path, req: call.req }
639
- try {
640
- // Native loaders may write to a defineStore; run them in a per-request
641
- // store scope so those writes are isolated per request. No snapshot is
642
- // collected and no <script> is injected on native paths Spec B owns
643
- // native store delivery (hard non-goal here).
644
- data = await runInStoreContext(() => leaf.loader!(ctx as any))
645
- } catch (err) {
646
- console.error(`[brust] loader failed for native route ${flat.fullPath}:`, err)
647
- // FAST LANE: native routes take dispatch_single_chunk (no chunk
648
- // channel), so every native fallback MUST pack + return a length.
649
- return packSingleChunkResponse(view, encoder, {
650
- status: 500,
651
- contentType: 'text/html; charset=utf-8',
652
- body: 'internal error',
653
- })
654
- }
717
+ let data: Record<string, unknown>
718
+ const ctx = { params: call.params, path: call.path, req: call.req }
719
+ let chainResult: NativeChainResult
720
+ try {
721
+ // Run the WHOLE route chain's loaders top-down (parent → leaf) and
722
+ // merge into ONE flat context (child keys win), mirroring the React
723
+ // chain-loader path. Wrap the entire loop in a SINGLE per-request
724
+ // store scope so a parent loader's defineStore writes are visible to
725
+ // child loaders (one Map per request — NOT one per loader). No
726
+ // snapshot is collected and no <script> is injected on native paths —
727
+ // Spec B owns native store delivery (hard non-goal here).
728
+ chainResult = await runInStoreContext(() => runNativeChainLoaders(flat.chain, ctx))
729
+ } catch (err) {
730
+ console.error(`[brust] loader failed for native route ${flat.fullPath}:`, err)
731
+ // FAST LANE: native routes take dispatch_single_chunk (no chunk
732
+ // channel), so every native fallback MUST pack + return a length.
733
+ return packSingleChunkResponse(view, encoder, {
734
+ status: 500,
735
+ contentType: 'text/html; charset=utf-8',
736
+ body: 'internal error',
737
+ })
655
738
  }
656
739
  let renderStatus: number | undefined
657
- if (isNativeVerdict(data)) {
658
- if (!data.render) {
740
+ if ('verdict' in chainResult) {
741
+ // First verdict wins (top-down). Remaining loaders already skipped.
742
+ const verdict = chainResult.verdict
743
+ if (!verdict.render) {
659
744
  // redirect — no template render; fast-lane packed response with Location.
660
745
  return packSingleChunkResponse(view, encoder, {
661
- status: data.status,
746
+ status: verdict.status,
662
747
  contentType: 'text/html; charset=utf-8',
663
748
  body: '',
664
- headers: data.headers,
749
+ headers: verdict.headers,
665
750
  })
666
751
  }
667
752
  // notFound — render the route's own template, but with the verdict's status.
668
- renderStatus = data.status
669
- data = data.data ?? {}
753
+ renderStatus = verdict.status
754
+ data = (verdict.data ?? {}) as Record<string, unknown>
755
+ } else {
756
+ data = chainResult.data
670
757
  }
671
- const json = JSON.stringify(data ?? {})
758
+ const json = JSON.stringify(data)
672
759
  // Sub-project J — islands + components. If this template has an enriched
673
760
  // islands manifest or a components manifest, merge per-island context vars
674
761
  // (island_<id>_props, plus island_<id>_html for ssr entries) and per-component
@@ -1013,7 +1100,8 @@ async function navigationBranch(
1013
1100
 
1014
1101
  /** Render a native (jinja) route to its full HTML document for a SPA navigation.
1015
1102
  *
1016
- * Mirrors the render-branch native path: run the leaf loader, merge island /
1103
+ * Mirrors the render-branch native path: run the whole route chain's loaders
1104
+ * (top-down, merged), merge island /
1017
1105
  * component manifest context, then call the SYNC `napiRenderJinja`, which writes
1018
1106
  * a framed `[meta_len u16 BE][meta JSON][body]` response into the SAB and returns
1019
1107
  * its length. Here — unlike the render branch, which returns that length so Rust
@@ -1030,24 +1118,25 @@ async function renderNativeRouteToHtml(
1030
1118
  workerId: bigint,
1031
1119
  ): Promise<string> {
1032
1120
  const templateName = flat.nativeTemplate as string
1033
- const leaf = flat.chain[flat.chain.length - 1]
1034
-
1035
- let data: unknown = {}
1036
- if (leaf.loader) {
1037
- // Per-request store scope for native loader writes (isolation only). No
1038
- // snapshot collected / no <script> injected — Spec B owns native delivery.
1039
- data = await runInStoreContext(() =>
1040
- leaf.loader!({ params: call.params, path: call.path, req: call.req } as any),
1041
- )
1042
- }
1043
1121
 
1044
- if (isNativeVerdict(data)) {
1122
+ // Run the WHOLE route chain's loaders top-down and merge into ONE flat context
1123
+ // (child keys win), mirroring the full-render branch. One per-request store
1124
+ // scope wraps the entire loop (isolation only — no snapshot / no <script>).
1125
+ const chainResult = await runInStoreContext(() =>
1126
+ runNativeChainLoaders(flat.chain, {
1127
+ params: call.params,
1128
+ path: call.path,
1129
+ req: call.req,
1130
+ }),
1131
+ )
1132
+
1133
+ if ('verdict' in chainResult) {
1045
1134
  // SPA nav can't emit a redirect/404 in-place; force the client's full-reload
1046
1135
  // fallback so the document path produces the authoritative status.
1047
1136
  throw new Error('native verdict on SPA navigation — falling back to full reload')
1048
1137
  }
1049
1138
 
1050
- let ctx = (data ?? {}) as Record<string, unknown>
1139
+ let ctx = chainResult.data
1051
1140
  const manifest = loadIslandManifest(templateName)
1052
1141
  const compManifest = loadComponentManifest(templateName)
1053
1142
  if ((manifest && manifest.length > 0) || (compManifest && compManifest.length > 0)) {
@@ -1295,6 +1384,19 @@ async function decodeActionBody(
1295
1384
  }
1296
1385
  }
1297
1386
 
1387
+ function actionErrorResponse(err: ActionError): RouteResponse {
1388
+ // Flat domain-error body `{ code, message, data? }` — `code` is the client-side
1389
+ // discriminator (vs the framework's enveloped `{ error: { … } }`). `data` is
1390
+ // included only when present so the wire shape omits the key entirely otherwise.
1391
+ const body: ActionErrorBody = { code: err.code, message: err.message }
1392
+ if (err.data !== undefined) body.data = err.data
1393
+ return {
1394
+ status: err.status,
1395
+ body: JSON.stringify(body),
1396
+ contentType: 'application/json; charset=utf-8',
1397
+ }
1398
+ }
1399
+
1298
1400
  export async function dispatchAction(
1299
1401
  call: Extract<RouteCall, { kind: 'action' }>,
1300
1402
  byId: Map<string, EndpointDef>,
@@ -1374,6 +1476,7 @@ export async function dispatchAction(
1374
1476
  contentType: 'application/json; charset=utf-8',
1375
1477
  }
1376
1478
  } catch (err) {
1479
+ if (isActionError(err)) return actionErrorResponse(err)
1377
1480
  const e = err instanceof Error ? err : new Error(String(err))
1378
1481
  console.error(`[brust] action ${def.method} ${def.path} threw:`, err)
1379
1482
  return {
@@ -1389,11 +1492,15 @@ export async function dispatchAction(
1389
1492
  try {
1390
1493
  response = await chain()
1391
1494
  } catch (err) {
1392
- console.error('[brust] action middleware uncaught:', err)
1393
- response = {
1394
- status: 500,
1395
- body: '{"error":{"message":"internal error"}}',
1396
- contentType: 'application/json; charset=utf-8',
1495
+ if (isActionError(err)) {
1496
+ response = actionErrorResponse(err)
1497
+ } else {
1498
+ console.error('[brust] action middleware uncaught:', err)
1499
+ response = {
1500
+ status: 500,
1501
+ body: '{"error":{"message":"internal error"}}',
1502
+ contentType: 'application/json; charset=utf-8',
1503
+ }
1397
1504
  }
1398
1505
  }
1399
1506
  return {