brustjs 0.1.50-alpha → 0.1.52-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/dev.ts +7 -0
- package/runtime/cli/native-routes-emit.ts +147 -1
- package/runtime/config.ts +42 -0
- package/runtime/index.d.ts +63 -0
- package/runtime/index.js +57 -52
- package/runtime/index.ts +108 -9
- package/runtime/native/runtime.ts +220 -7
- package/runtime/render/fragment.ts +87 -0
- package/runtime/routes.ts +225 -48
- 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.
|
|
@@ -274,6 +292,76 @@ export function isNativeVerdict(x: unknown): x is NativeVerdict {
|
|
|
274
292
|
)
|
|
275
293
|
}
|
|
276
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
|
+
|
|
277
365
|
/** Framework default 404 body, served when a React `notFound()` fires but no
|
|
278
366
|
* catch-all (`path: '*'`) is registered for the route's prefix — so the response
|
|
279
367
|
* is still HTTP 404 with a body (never a 500, never a crash). */
|
|
@@ -320,10 +408,15 @@ export interface NativeLoaderCtx {
|
|
|
320
408
|
req: BrustRequest
|
|
321
409
|
}
|
|
322
410
|
|
|
323
|
-
/** Result of running a native route's chain loaders top-down:
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
|
|
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 }
|
|
327
420
|
|
|
328
421
|
/** Run a native route's `flat.chain` loaders top-down (parent → leaf) and merge
|
|
329
422
|
* their results into ONE flat context object.
|
|
@@ -350,7 +443,16 @@ export async function runNativeChainLoaders(
|
|
|
350
443
|
if (!node.loader) continue
|
|
351
444
|
// `as never` bypasses per-route Params narrowing — NativeLoaderCtx is the
|
|
352
445
|
// default-instantiated loader ctx shape ({ params, path, req }).
|
|
353
|
-
|
|
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
|
+
}
|
|
354
456
|
if (isNativeVerdict(result)) {
|
|
355
457
|
return { verdict: result }
|
|
356
458
|
}
|
|
@@ -916,6 +1018,9 @@ export function makeRenderer(
|
|
|
916
1018
|
// have a per-conn AbortController. Middleware/loaders/components
|
|
917
1019
|
// can still read req.signal.aborted (always false).
|
|
918
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)
|
|
919
1024
|
|
|
920
1025
|
// Run the middleware chain against a streaming-marker terminal.
|
|
921
1026
|
// Middleware that short-circuits (returns without calling next())
|
|
@@ -936,6 +1041,16 @@ export function makeRenderer(
|
|
|
936
1041
|
try {
|
|
937
1042
|
verdict = (await chain()) as StreamMarkerResponse
|
|
938
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
|
+
}
|
|
939
1054
|
console.error(`[brust] middleware/render uncaught:`, err)
|
|
940
1055
|
// FAST LANE: single-chunk error. Works for both React (big dispatch
|
|
941
1056
|
// reads the SAB via its fast-lane arm) and native routes (which take
|
|
@@ -1073,6 +1188,18 @@ export function makeRenderer(
|
|
|
1073
1188
|
body: 'internal error',
|
|
1074
1189
|
})
|
|
1075
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
|
+
}
|
|
1076
1203
|
let renderStatus: number | undefined
|
|
1077
1204
|
if ('verdict' in chainResult) {
|
|
1078
1205
|
// First verdict wins (top-down). Remaining loaders already skipped.
|
|
@@ -1245,6 +1372,24 @@ export function makeRenderer(
|
|
|
1245
1372
|
try {
|
|
1246
1373
|
element = await buildRenderElement(call, flat, opts.getWorkerId, shellMode)
|
|
1247
1374
|
} catch (err) {
|
|
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
|
+
}
|
|
1248
1393
|
if (isNotFoundTrigger(err)) {
|
|
1249
1394
|
// React `notFound()` trigger: abandon the matched route and render
|
|
1250
1395
|
// the NEAREST catch-all (same selection as a Rust-unmatched path)
|
|
@@ -1395,41 +1540,8 @@ export function makeRenderer(
|
|
|
1395
1540
|
}
|
|
1396
1541
|
}
|
|
1397
1542
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
* would only capture the shell + fallbacks, while renderBranchStreaming
|
|
1401
|
-
* is for the streaming render path. */
|
|
1402
|
-
function renderToAwaitedString(element: ReactNode): Promise<string> {
|
|
1403
|
-
return new Promise<string>((resolve, reject) => {
|
|
1404
|
-
const chunks: Buffer[] = []
|
|
1405
|
-
const sink = new Writable({
|
|
1406
|
-
write(chunk, _enc, cb) {
|
|
1407
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
1408
|
-
cb()
|
|
1409
|
-
},
|
|
1410
|
-
})
|
|
1411
|
-
sink.on('finish', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
1412
|
-
sink.on('error', reject)
|
|
1413
|
-
|
|
1414
|
-
let stream: ReturnType<typeof renderToPipeableStream>
|
|
1415
|
-
stream = renderToPipeableStream(element, {
|
|
1416
|
-
onAllReady() {
|
|
1417
|
-
try {
|
|
1418
|
-
stream.pipe(sink)
|
|
1419
|
-
} catch (e) {
|
|
1420
|
-
reject(e)
|
|
1421
|
-
}
|
|
1422
|
-
},
|
|
1423
|
-
onShellError(err) {
|
|
1424
|
-
reject(err)
|
|
1425
|
-
},
|
|
1426
|
-
onError(err) {
|
|
1427
|
-
// Logged at the navigationBranch level; let onAllReady drive completion.
|
|
1428
|
-
console.error('[brust] navigation render onError:', err)
|
|
1429
|
-
},
|
|
1430
|
-
})
|
|
1431
|
-
})
|
|
1432
|
-
}
|
|
1543
|
+
// renderToAwaitedString moved to render/fragment.ts (shared with the public
|
|
1544
|
+
// renderFragment API) — imported above; navigationBranch behavior unchanged.
|
|
1433
1545
|
|
|
1434
1546
|
async function navigationBranch(
|
|
1435
1547
|
call: Extract<RouteCall, { kind: 'navigation' }>,
|
|
@@ -1484,6 +1596,10 @@ async function navigationBranch(
|
|
|
1484
1596
|
_brustStream: NAV_MARKER,
|
|
1485
1597
|
})
|
|
1486
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
|
+
|
|
1487
1603
|
// navigationBranch receives a 'navigation' call, but composeChain only
|
|
1488
1604
|
// needs req + middleware — cast to satisfy the type.
|
|
1489
1605
|
const navChain = composeChain(
|
|
@@ -1496,6 +1612,17 @@ async function navigationBranch(
|
|
|
1496
1612
|
try {
|
|
1497
1613
|
navVerdict = (await navChain()) as NavMarkerResponse
|
|
1498
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
|
+
}
|
|
1499
1626
|
console.error('[brust] navigation middleware threw:', err)
|
|
1500
1627
|
await emitSingleChunkResponse(
|
|
1501
1628
|
view,
|
|
@@ -1654,6 +1781,28 @@ async function navigationBranch(
|
|
|
1654
1781
|
slot,
|
|
1655
1782
|
)
|
|
1656
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
|
+
}
|
|
1657
1806
|
console.error('[brust] navigation render failed:', err)
|
|
1658
1807
|
await emitSingleChunkResponse(
|
|
1659
1808
|
view,
|
|
@@ -1703,6 +1852,12 @@ async function renderNativeRouteToHtml(
|
|
|
1703
1852
|
}),
|
|
1704
1853
|
)
|
|
1705
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
|
+
}
|
|
1706
1861
|
if ('verdict' in chainResult) {
|
|
1707
1862
|
// SPA nav can't emit a redirect/404 in-place; force the client's full-reload
|
|
1708
1863
|
// fallback so the document path produces the authoritative status.
|
|
@@ -2070,6 +2225,10 @@ export async function dispatchAction(
|
|
|
2070
2225
|
}
|
|
2071
2226
|
}
|
|
2072
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)
|
|
2073
2232
|
|
|
2074
2233
|
// Body decode — dispatch by content-type (JSON / urlencoded / multipart).
|
|
2075
2234
|
let rawBody: unknown
|
|
@@ -2158,6 +2317,15 @@ export async function dispatchAction(
|
|
|
2158
2317
|
} catch (err) {
|
|
2159
2318
|
if (isActionError(err)) {
|
|
2160
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
|
+
}
|
|
2161
2329
|
} else {
|
|
2162
2330
|
console.error('[brust] action middleware uncaught:', err)
|
|
2163
2331
|
response = {
|
|
@@ -2191,6 +2359,9 @@ async function mcpBranchToResponse(
|
|
|
2191
2359
|
// reading req.signal.aborted always sees false; the SSE branch is where
|
|
2192
2360
|
// real disconnect lives.
|
|
2193
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)
|
|
2194
2365
|
const responseJson = await mcp.handleRequest(call.body_text, call.req)
|
|
2195
2366
|
if (responseJson === '') {
|
|
2196
2367
|
// Notification — no response body. Return 204 No Content.
|
|
@@ -2264,6 +2435,9 @@ async function sseBranch(
|
|
|
2264
2435
|
// Inject NEVER_ABORTS into req for the middleware run. handleSseStream
|
|
2265
2436
|
// will overwrite with a per-conn AbortController.signal afterward.
|
|
2266
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)
|
|
2267
2441
|
|
|
2268
2442
|
// Run middleware chain with a 200 placeholder terminal. The terminal
|
|
2269
2443
|
// does NOT invoke leaf.sse — handleSseStream does that after middleware
|
|
@@ -2370,6 +2544,9 @@ async function wsBranch(
|
|
|
2370
2544
|
// lifecycle is via registered onMessage/onClose callbacks rather than
|
|
2371
2545
|
// awaiting on a request signal.
|
|
2372
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)
|
|
2373
2550
|
|
|
2374
2551
|
// Run middleware chain with a 101 placeholder terminal that signals
|
|
2375
2552
|
// "ready to upgrade". The terminal does NOT call route.websocket —
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// R1 dynamic template registry — runtime registration of minijinja templates
|
|
2
|
+
// (per-tenant sections etc.). Thin TS over NAPI; the Rust env is process-global
|
|
3
|
+
// so registrations are visible to every worker isolate immediately.
|
|
4
|
+
import * as native from './index.js'
|
|
5
|
+
|
|
6
|
+
export const templates = {
|
|
7
|
+
/** Register (or replace) a runtime template under `name`. Names are opaque
|
|
8
|
+
* keys (`shop/42/section/7@v3` is fine). Throws on jinja syntax errors
|
|
9
|
+
* (message includes line info). Replacement is atomic: concurrent renders
|
|
10
|
+
* see old or new, never missing. */
|
|
11
|
+
register(name: string, jinjaSource: string): void {
|
|
12
|
+
// napi-rs v3 hands back the `Error` as the RETURN VALUE for sync
|
|
13
|
+
// fallible bindings under Bun instead of throwing (verified empirically
|
|
14
|
+
// for both `Result<()>` and `Result<String>`) — normalize to the
|
|
15
|
+
// documented throw.
|
|
16
|
+
const err = (native as any).napiRegisterTemplate(name, jinjaSource)
|
|
17
|
+
if (err instanceof Error) throw err
|
|
18
|
+
},
|
|
19
|
+
/** Remove a runtime-registered template. Returns whether it existed.
|
|
20
|
+
* Boot-tier templates (compiled from routes) are not removable. */
|
|
21
|
+
remove(name: string): boolean {
|
|
22
|
+
return (native as any).napiRemoveTemplate(name)
|
|
23
|
+
},
|
|
24
|
+
/** True when `name` resolves in either tier (dynamic first, then boot). */
|
|
25
|
+
has(name: string): boolean {
|
|
26
|
+
return (native as any).napiHasTemplate(name)
|
|
27
|
+
},
|
|
28
|
+
/** Names of runtime-registered templates (dynamic tier only). */
|
|
29
|
+
list(): string[] {
|
|
30
|
+
return (native as any).napiListDynamicTemplates() ?? []
|
|
31
|
+
},
|
|
32
|
+
/** Render a template (either tier) to an HTML string. Pure (name, data) →
|
|
33
|
+
* html — no request/store context. NOT the request fast lane; intended for
|
|
34
|
+
* handlers/loaders/tooling (draft canvases, section previews). */
|
|
35
|
+
render(name: string, data?: unknown): string {
|
|
36
|
+
// `data == null` (undefined OR explicit null) renders against an empty
|
|
37
|
+
// context — JSON.stringify(null) would hand minijinja a null root where
|
|
38
|
+
// every variable lookup goes Undefined.
|
|
39
|
+
const json = JSON.stringify(data == null ? {} : data)
|
|
40
|
+
// Same napi-rs v3 Bun quirk as register(): the Error comes back as the
|
|
41
|
+
// return value, not a throw — without this guard a consumer would
|
|
42
|
+
// interpolate "Error: ..." into HTML instead of catching.
|
|
43
|
+
const out = (native as any).napiRenderTemplate(name, json)
|
|
44
|
+
if (out instanceof Error) throw out
|
|
45
|
+
return out
|
|
46
|
+
},
|
|
47
|
+
}
|
package/runtime/treaty.ts
CHANGED
|
@@ -11,6 +11,14 @@ export interface TreatyResponse<Data = unknown, Err = unknown> {
|
|
|
11
11
|
}
|
|
12
12
|
export interface ClientOptions {
|
|
13
13
|
prefix?: string
|
|
14
|
+
/** Absolute origin of ANOTHER brust deployment to target, e.g.
|
|
15
|
+
* `https://api.example.com` (a path suffix like `/v2` composes by
|
|
16
|
+
* concatenation). Must match `^https?://`; a trailing slash is stripped.
|
|
17
|
+
* When set, the action prefix is `prefix ?? '/_brust/action'` — the global
|
|
18
|
+
* `__BRUST_ACTION_PREFIX__` belongs to the SERVING app and is never
|
|
19
|
+
* consulted. Cross-origin cookies: pass a `fetch` override that sets
|
|
20
|
+
* `credentials: 'include'` (and configure `cors.credentials` server-side). */
|
|
21
|
+
baseUrl?: string
|
|
14
22
|
headers?: Record<string, string> | (() => Record<string, string>)
|
|
15
23
|
fetch?: typeof fetch
|
|
16
24
|
}
|
|
@@ -77,15 +85,30 @@ export type Treaty<App> =
|
|
|
77
85
|
|
|
78
86
|
function resolvePrefix(opts?: ClientOptions): string {
|
|
79
87
|
if (opts?.prefix) return opts.prefix
|
|
88
|
+
// The global __BRUST_ACTION_PREFIX__ is injected by the SERVING app's pages;
|
|
89
|
+
// it describes THIS origin's mount point, never a cross-origin target's.
|
|
90
|
+
if (opts?.baseUrl !== undefined) return '/_brust/action'
|
|
80
91
|
const g = (globalThis as { __BRUST_ACTION_PREFIX__?: string }).__BRUST_ACTION_PREFIX__
|
|
81
92
|
return g ?? '/_brust/action'
|
|
82
93
|
}
|
|
83
94
|
|
|
95
|
+
/** Validate + normalize `baseUrl`: absolute http(s) origin, trailing slash(es)
|
|
96
|
+
* stripped. Absent → '' (same-origin, byte-identical legacy behavior). */
|
|
97
|
+
function resolveBaseUrl(opts?: ClientOptions): string {
|
|
98
|
+
const b = opts?.baseUrl
|
|
99
|
+
if (b === undefined) return ''
|
|
100
|
+
if (!/^https?:\/\//.test(b)) {
|
|
101
|
+
throw new Error(`treaty baseUrl must be an absolute http(s) origin (got ${JSON.stringify(b)})`)
|
|
102
|
+
}
|
|
103
|
+
return b.replace(/\/+$/, '')
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
/** Build a treaty proxy. Static segments accumulate as a path; a function call
|
|
85
107
|
* with an object fills the next {param}(s) positionally (in insertion order); a
|
|
86
108
|
* terminal method key (.get/.post/…) performs the request. URL is composed from
|
|
87
109
|
* the literal accumulated segments — never from any inferred type. */
|
|
88
110
|
export function client<App = unknown>(opts?: ClientOptions): Treaty<App> {
|
|
111
|
+
const baseUrl = resolveBaseUrl(opts)
|
|
89
112
|
const prefix = resolvePrefix(opts)
|
|
90
113
|
const doFetch = opts?.fetch ?? fetch
|
|
91
114
|
function make(segments: string[]): any {
|
|
@@ -98,7 +121,7 @@ export function client<App = unknown>(opts?: ClientOptions): Treaty<App> {
|
|
|
98
121
|
| { query?: Record<string, string>; headers?: Record<string, string> }
|
|
99
122
|
| undefined
|
|
100
123
|
const body = isBodyless ? undefined : arg1
|
|
101
|
-
let url = prefix + '/' + segments.join('/')
|
|
124
|
+
let url = baseUrl + prefix + '/' + segments.join('/')
|
|
102
125
|
if (options?.query) {
|
|
103
126
|
const qs = new URLSearchParams(options.query as Record<string, string>).toString()
|
|
104
127
|
if (qs) url += '?' + qs
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
declare const ACTION_ERROR: unique symbol;
|
|
2
|
+
export interface ActionErrorBody {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
data?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export declare class ActionError extends Error {
|
|
8
|
+
readonly [ACTION_ERROR]: true;
|
|
9
|
+
readonly status: number;
|
|
10
|
+
readonly code: string;
|
|
11
|
+
readonly data?: unknown;
|
|
12
|
+
constructor(status: number, code: string, opts?: {
|
|
13
|
+
message?: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export declare function isActionError(v: unknown): v is ActionError;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { InvalidateArgs } from './cache.ts';
|
|
2
|
+
/** Public wire contract (documented for external publishers like studio):
|
|
3
|
+
* `{ "v": 1, "sender": "<uuid>", "key": "...", "tags": ["..."],
|
|
4
|
+
* "path": "...", "method": "GET" }` — all invalidation fields optional,
|
|
5
|
+
* `sender` may be omitted (never matches our token, always applied). */
|
|
6
|
+
export interface CacheSyncMessage {
|
|
7
|
+
v: 1;
|
|
8
|
+
sender?: string;
|
|
9
|
+
key?: string;
|
|
10
|
+
tags?: string[];
|
|
11
|
+
path?: string;
|
|
12
|
+
method?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const CHANNEL_DEFAULT = "brust:cache:invalidate";
|
|
15
|
+
/** Injectable publish seam so unit tests never construct a RedisClient. */
|
|
16
|
+
export interface CacheSyncTransport {
|
|
17
|
+
publish(channel: string, message: string): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
export declare function __setTransportForTest(t: CacheSyncTransport | null): void;
|
|
20
|
+
/** The redis URL may carry credentials (`redis://:pass@host:port/db`). Every
|
|
21
|
+
* log line that mentions the target MUST go through this — host:port only. */
|
|
22
|
+
export declare function redactUrl(url: string): string;
|
|
23
|
+
/** Apply a parsed peer message to the local NAPI caches. Direct fan-out —
|
|
24
|
+
* NEVER via cache.invalidate, so a received message can never re-publish
|
|
25
|
+
* (no loop, even across misconfigured channels). Exported for tests. */
|
|
26
|
+
export declare function applyCacheSyncMessage(msg: CacheSyncMessage): void;
|
|
27
|
+
/** Publish an invalidation to peers. No-op when sync is not configured
|
|
28
|
+
* (BRUST_CACHE_SYNC_URL absent). Fire-and-forget: synchronous from the
|
|
29
|
+
* caller's view, failures warn (throttled) and never propagate — local
|
|
30
|
+
* invalidation must never depend on redis state. */
|
|
31
|
+
export declare function publishCacheSync(args: InvalidateArgs): void;
|
|
32
|
+
/** Start the subscriber (main isolate only — run() guards; workers never call
|
|
33
|
+
* this). Idempotent; never throws. A down redis at boot logs a redacted warn
|
|
34
|
+
* and retries with capped exponential backoff — the server boots regardless. */
|
|
35
|
+
export declare function startCacheSync(opts: {
|
|
36
|
+
url: string;
|
|
37
|
+
channel?: string;
|
|
38
|
+
}): void;
|
|
39
|
+
/** Shutdown/test hook: stop retries, close both clients, reset state so tests
|
|
40
|
+
* can restart. Wired into gracefulExit so backoff timers can't fire between
|
|
41
|
+
* drain and exit. */
|
|
42
|
+
export declare function stopCacheSync(): void;
|
package/types/cache.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as native from './index.js';
|
|
2
|
+
export interface InvalidateArgs {
|
|
3
|
+
key?: string;
|
|
4
|
+
tags?: string[];
|
|
5
|
+
/** L1 response-cache: evict all entries for this request path (any prefix /
|
|
6
|
+
* query variant). Independent of `tags`. */
|
|
7
|
+
path?: string;
|
|
8
|
+
/** HTTP method for the `path` eviction (defaults to GET in Rust). */
|
|
9
|
+
method?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const cache: {
|
|
12
|
+
/** Evict across all three caches: islands, L2 page cache, and L1 response
|
|
13
|
+
* cache. `key`/`tags` hit islands + L2; the L1 response cache is reached by
|
|
14
|
+
* `tags` (the ROUTE must declare static `cache.tags` for its L1 entries to
|
|
15
|
+
* carry them) and by `path` (evicts every L1 entry for that request path,
|
|
16
|
+
* any prefix/query variant). All fields optional; `invalidate({})` is a
|
|
17
|
+
* deliberate no-op. The `?.` guards against a stale addon built before a
|
|
18
|
+
* given binding existed (degrades to no-op). */
|
|
19
|
+
invalidate(args: InvalidateArgs): void;
|
|
20
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Read the brustjs package.json version. `help.ts` lives at
|
|
2
|
+
* <root>/runtime/cli/, so ../../package.json is <root>/package.json in both the
|
|
3
|
+
* source tree and an installed node_modules/brustjs layout. Never throws —
|
|
4
|
+
* returns "unknown" on any failure (version must not crash the CLI). */
|
|
5
|
+
export declare function readVersion(): string;
|
|
6
|
+
export declare const style: {
|
|
7
|
+
bold: (s: string) => string;
|
|
8
|
+
dim: (s: string) => string;
|
|
9
|
+
cyan: (s: string) => string;
|
|
10
|
+
green: (s: string) => string;
|
|
11
|
+
red: (s: string) => string;
|
|
12
|
+
};
|
|
13
|
+
interface CommandDef {
|
|
14
|
+
name: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
usage: string;
|
|
17
|
+
flags: {
|
|
18
|
+
flag: string;
|
|
19
|
+
desc: string;
|
|
20
|
+
}[];
|
|
21
|
+
/** Free-form lines rendered after the Options block (one paragraph per entry). */
|
|
22
|
+
notes?: string[];
|
|
23
|
+
}
|
|
24
|
+
export declare const COMMANDS: CommandDef[];
|
|
25
|
+
export declare function renderVersion(): string;
|
|
26
|
+
export declare function renderRootHelp(): string;
|
|
27
|
+
export declare function renderCommandHelp(name: string): string | null;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** True when the emitted native templates in `jinjaDir` are missing or older
|
|
2
|
+
* than the authored `.tsx` sources under `scanRoot` — i.e. a boot-time
|
|
3
|
+
* recompile is warranted so `bun run <entry>` (source mode) doesn't require a
|
|
4
|
+
* prior `brust build`, and an edited page is picked up without a stale render.
|
|
5
|
+
*
|
|
6
|
+
* Staleness = the build marker (`_manifest.json`, written last by
|
|
7
|
+
* `emitNativeTemplates`) is absent, OR any source `.tsx` is newer than it.
|
|
8
|
+
*
|
|
9
|
+
* `manifestDir` is where `md-manifest.json` lives. It defaults to
|
|
10
|
+
* `dirname(jinjaDir)` — the layout contract is `jinjaDir == <x>/jinja` with
|
|
11
|
+
* the md manifest written next to it in `<x>` (both `emitMdArtifacts` callers
|
|
12
|
+
* pass `manifestDirs: [<x>]`). A caller that already holds the manifest dir
|
|
13
|
+
* should pass it explicitly instead of relying on that positional contract. */
|
|
14
|
+
export declare function isJinjaStale(scanRoot: string, jinjaDir: string, manifestDir?: string): boolean;
|