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/example/pokedex/actions.ts +5 -7
- package/example/pokedex/components/AddToTeamButton.tsx +9 -4
- package/example/pokedex/components/{PageLayout.tsx → AppLayout.tsx} +17 -11
- package/example/pokedex/lib/loaders.ts +17 -2
- package/example/pokedex/lib/types.ts +18 -7
- package/example/pokedex/pages/DetailPage.tsx +9 -9
- package/example/pokedex/pages/ListPage.tsx +7 -14
- package/example/pokedex/pages/TypeChart.tsx +9 -14
- package/example/pokedex/routes.tsx +21 -6
- package/package.json +7 -7
- package/runtime/action-error.ts +31 -0
- package/runtime/cli/build.ts +4 -1
- package/runtime/cli/dev.ts +4 -1
- package/runtime/cli/native-routes-emit.ts +180 -8
- package/runtime/index.js +52 -52
- package/runtime/index.ts +3 -0
- package/runtime/routes.ts +153 -46
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
|
-
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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 (
|
|
658
|
-
|
|
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:
|
|
746
|
+
status: verdict.status,
|
|
662
747
|
contentType: 'text/html; charset=utf-8',
|
|
663
748
|
body: '',
|
|
664
|
-
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 =
|
|
669
|
-
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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 {
|