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.
Files changed (90) hide show
  1. package/package.json +39 -15
  2. package/runtime/cache-sync.ts +291 -0
  3. package/runtime/cache.ts +4 -0
  4. package/runtime/cli/dev.ts +7 -0
  5. package/runtime/cli/native-routes-emit.ts +147 -1
  6. package/runtime/config.ts +42 -0
  7. package/runtime/index.d.ts +63 -0
  8. package/runtime/index.js +57 -52
  9. package/runtime/index.ts +108 -9
  10. package/runtime/native/runtime.ts +220 -7
  11. package/runtime/render/fragment.ts +87 -0
  12. package/runtime/routes.ts +225 -48
  13. package/runtime/templates.ts +47 -0
  14. package/runtime/treaty.ts +24 -1
  15. package/types/action-error.d.ts +18 -0
  16. package/types/cache-sync.d.ts +42 -0
  17. package/types/cache.d.ts +20 -0
  18. package/types/cli/help.d.ts +28 -0
  19. package/types/cli/jinja-staleness.d.ts +14 -0
  20. package/types/cli/native-routes-emit.d.ts +217 -0
  21. package/types/cli/new.d.ts +30 -0
  22. package/types/cli/templates.d.ts +39 -0
  23. package/types/client/index.d.ts +14 -0
  24. package/types/config.d.ts +42 -0
  25. package/types/cookies.d.ts +25 -0
  26. package/types/create.d.ts +1 -0
  27. package/types/css/build.d.ts +11 -0
  28. package/types/css/component-build.d.ts +17 -0
  29. package/types/css/component-loader.d.ts +8 -0
  30. package/types/css/manifest.d.ts +21 -0
  31. package/types/css/process-modules.d.ts +31 -0
  32. package/types/css/route-deps.d.ts +20 -0
  33. package/types/css/scan-imports.d.ts +13 -0
  34. package/types/css.d.ts +16 -0
  35. package/types/define-actions.d.ts +133 -0
  36. package/types/dev/client.d.ts +8 -0
  37. package/types/dev/coordinator.d.ts +33 -0
  38. package/types/dev/inject.d.ts +6 -0
  39. package/types/dev/jinja-reload.d.ts +7 -0
  40. package/types/dev/tui.d.ts +35 -0
  41. package/types/dev/watcher.d.ts +34 -0
  42. package/types/dev/worker-registry.d.ts +17 -0
  43. package/types/dev/ws-channel.d.ts +39 -0
  44. package/types/generator.d.ts +23 -0
  45. package/types/index.d.ts +222 -0
  46. package/types/islands/brust-page.d.ts +74 -0
  47. package/types/islands/build.d.ts +49 -0
  48. package/types/islands/chunk-id.d.ts +10 -0
  49. package/types/islands/importmap.d.ts +2 -0
  50. package/types/islands/island.d.ts +65 -0
  51. package/types/islands/isr-jsx.d.ts +31 -0
  52. package/types/islands/native-render.d.ts +89 -0
  53. package/types/loader-cache.d.ts +18 -0
  54. package/types/mcp/extractor.d.ts +14 -0
  55. package/types/mcp/manifest.d.ts +23 -0
  56. package/types/mcp/schema.d.ts +19 -0
  57. package/types/mcp/server.d.ts +15 -0
  58. package/types/md/emit.d.ts +72 -0
  59. package/types/md/render.d.ts +80 -0
  60. package/types/md/routes.d.ts +119 -0
  61. package/types/md/scan.d.ts +34 -0
  62. package/types/md/slug.d.ts +1 -0
  63. package/types/native/build.d.ts +30 -0
  64. package/types/native/index.d.ts +2 -0
  65. package/types/native/runtime.d.ts +52 -0
  66. package/types/navigation/active-nav.d.ts +2 -0
  67. package/types/navigation/index.d.ts +5 -0
  68. package/types/navigation/navigate.d.ts +14 -0
  69. package/types/navigation/react.d.ts +15 -0
  70. package/types/navigation/store.d.ts +44 -0
  71. package/types/render/fragment.d.ts +20 -0
  72. package/types/render/inject-action-prefix.d.ts +9 -0
  73. package/types/render/inject-css-link.d.ts +8 -0
  74. package/types/render/inject-dev-client.d.ts +6 -0
  75. package/types/render/inject-generator.d.ts +7 -0
  76. package/types/render/inject-store.d.ts +9 -0
  77. package/types/render/stream.d.ts +45 -0
  78. package/types/request-context.d.ts +16 -0
  79. package/types/routes.d.ts +506 -0
  80. package/types/sse/handler.d.ts +22 -0
  81. package/types/standard-schema.d.ts +31 -0
  82. package/types/store/define-store.d.ts +31 -0
  83. package/types/store/index.d.ts +5 -0
  84. package/types/store/react.d.ts +2 -0
  85. package/types/store/serialize.d.ts +5 -0
  86. package/types/store/server-context.d.ts +4 -0
  87. package/types/store/signal.d.ts +18 -0
  88. package/types/templates.d.ts +18 -0
  89. package/types/treaty.d.ts +70 -0
  90. 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: either a merged
324
- * flat context object (all loader results shallow-merged, child keys win), or
325
- * the first verdict encountered (top-down) which short-circuits the chain. */
326
- export type NativeChainResult = { data: Record<string, unknown> } | { verdict: NativeVerdict }
411
+ /** Result of running a native route's chain loaders top-down: a merged flat
412
+ * context object (all loader results shallow-merged, child keys win), the
413
+ * first verdict encountered (top-down) which short-circuits the chain, or an
414
+ * `httpError()` trigger THROWN by a loader (intercepted per-loader so it never
415
+ * reaches the caller's generic 500 catch). */
416
+ export type NativeChainResult =
417
+ | { data: Record<string, unknown> }
418
+ | { verdict: NativeVerdict }
419
+ | { httpError: HttpErrorTrigger }
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
- const result = await node.loader(ctx as never)
446
+ let result: unknown
447
+ try {
448
+ result = await node.loader(ctx as never)
449
+ } catch (err) {
450
+ // httpError() short-circuit — intercept PER-LOADER so the trigger never
451
+ // reaches the caller's generic loader catch (which logs + 500s). Any
452
+ // other throw keeps the existing 500 contract.
453
+ if (isHttpErrorTrigger(err)) return { httpError: err }
454
+ throw err
455
+ }
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
- /** Render a React element to a single HTML string, awaiting all Suspense
1399
- * boundaries via onAllReady. Used by navigationBranch renderToString
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;
@@ -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;