brustjs 0.1.0-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 (63) hide show
  1. package/README.md +110 -0
  2. package/package.json +92 -0
  3. package/runtime/actions.ts +65 -0
  4. package/runtime/bun.lock +236 -0
  5. package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
  6. package/runtime/cli/build.ts +252 -0
  7. package/runtime/cli/dev.ts +92 -0
  8. package/runtime/cli/index.ts +30 -0
  9. package/runtime/cli/native-routes-emit.ts +171 -0
  10. package/runtime/cli/native-shim-plugin.ts +85 -0
  11. package/runtime/cli/new.ts +208 -0
  12. package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
  13. package/runtime/cli/templates/minimal/_gitignore +4 -0
  14. package/runtime/cli/templates/minimal/app.css +6 -0
  15. package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
  16. package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
  17. package/runtime/cli/templates/minimal/index.ts +4 -0
  18. package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
  19. package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
  20. package/runtime/cli/templates/minimal/routes.tsx +6 -0
  21. package/runtime/cli/templates/minimal/tsconfig.json +20 -0
  22. package/runtime/client/index.ts +121 -0
  23. package/runtime/config.ts +148 -0
  24. package/runtime/css/build.ts +54 -0
  25. package/runtime/css/component-build.ts +78 -0
  26. package/runtime/css/component-loader.ts +27 -0
  27. package/runtime/css/manifest.ts +51 -0
  28. package/runtime/css/process-modules.ts +56 -0
  29. package/runtime/css/route-deps.ts +33 -0
  30. package/runtime/css/scan-imports.ts +79 -0
  31. package/runtime/css.ts +39 -0
  32. package/runtime/dev/client.ts +49 -0
  33. package/runtime/dev/coordinator.ts +127 -0
  34. package/runtime/dev/inject.ts +17 -0
  35. package/runtime/dev/tui.ts +109 -0
  36. package/runtime/dev/watcher.ts +109 -0
  37. package/runtime/dev/worker-registry.ts +96 -0
  38. package/runtime/dev/ws-channel.ts +99 -0
  39. package/runtime/index.d.ts +199 -0
  40. package/runtime/index.js +604 -0
  41. package/runtime/index.ts +618 -0
  42. package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
  43. package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
  44. package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
  45. package/runtime/islands/_entries/react-dom.ts +7 -0
  46. package/runtime/islands/_entries/react.ts +11 -0
  47. package/runtime/islands/bootstrap.ts +241 -0
  48. package/runtime/islands/build.ts +141 -0
  49. package/runtime/islands/importmap.ts +17 -0
  50. package/runtime/islands/island.tsx +58 -0
  51. package/runtime/islands/native-render.ts +153 -0
  52. package/runtime/mcp/extractor.ts +160 -0
  53. package/runtime/mcp/manifest.ts +50 -0
  54. package/runtime/mcp/schema.ts +124 -0
  55. package/runtime/mcp/server.ts +250 -0
  56. package/runtime/render/inject-css-link.ts +59 -0
  57. package/runtime/render/inject-dev-client.ts +49 -0
  58. package/runtime/render/stream.ts +304 -0
  59. package/runtime/routes.ts +1406 -0
  60. package/runtime/scan-actions.ts +172 -0
  61. package/runtime/sse/handler.ts +85 -0
  62. package/runtime/tsconfig.json +14 -0
  63. package/runtime/ws/handler.ts +151 -0
@@ -0,0 +1,1406 @@
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
+ import { Buffer } from 'node:buffer'
11
+ // @ts-expect-error - index.js is generated by napi-rs at build time
12
+ import * as native from './index.js'
13
+ import { renderBranchStreaming } from './render/stream.ts'
14
+ import { loadIslandManifest, resolveIslandContext } from './islands/native-render.ts'
15
+ import type { ActionDef } from './actions.ts'
16
+
17
+ // Permanently-unaborted AbortSignal sentinel for non-SSE routes.
18
+ // The controller is held in module scope and never .abort()-ed, keeping
19
+ // the signal alive in the unaborted state. Do NOT use AbortSignal.abort()
20
+ // — that creates an already-aborted signal which would fire any
21
+ // addEventListener('abort') listener synchronously and break defensive
22
+ // cleanup code.
23
+ const _neverAbortsController = new AbortController()
24
+ export const NEVER_ABORTS: AbortSignal = _neverAbortsController.signal
25
+
26
+ /** Structured view of the request, parsed once in Rust and shipped in the
27
+ * JSON envelope. Header names are lower-cased. Cookies are parsed from the
28
+ * Cookie header. `search` is the query string parsed as key→value (last
29
+ * occurrence wins on duplicates). */
30
+ export interface BrustRequest {
31
+ method: string
32
+ /** Full request URL path including query string, e.g. `/foo?bar=1`. */
33
+ url: string
34
+ headers: Record<string, string>
35
+ cookies: Record<string, string>
36
+ search: Record<string, string>
37
+ /** AbortSignal that fires when the client disconnects mid-request.
38
+ * SSE-only in MVP — non-SSE routes receive NEVER_ABORTS (a permanently-
39
+ * unaborted shared sentinel; signal.aborted === false forever). Real
40
+ * disconnect detection for render/action is a follow-up. */
41
+ signal: AbortSignal
42
+ }
43
+
44
+ /** Handler module shape — what `() => Promise<WsHandlers>` resolves to.
45
+ * open/message/close are all OPTIONAL — a no-op WebSocket (handshake
46
+ * only, e.g. liveness probe) is a valid use case. */
47
+ export interface WsHandlers {
48
+ /** Called once after the 101 handshake completes. Use this to record
49
+ * the socket in your in-memory map, send a hello frame, etc.
50
+ * Throwing here closes the conn with 1011 (Internal Error); on_close
51
+ * does NOT fire (we never reached steady state). */
52
+ open?: (
53
+ socket: WsSocket,
54
+ ctx: { req: BrustRequest; subprotocol: string | null },
55
+ ) => void | Promise<void>
56
+ /** Called per incoming message frame. data is string for Text frames,
57
+ * Uint8Array for Binary. Throwing here is logged but the conn stays
58
+ * open — one bad message shouldn't kill the conn; wrap in try/catch
59
+ * for strict-close semantics. */
60
+ message?: (socket: WsSocket, data: string | Uint8Array) => void | Promise<void>
61
+ /** Called exactly ONCE when the conn closes EXCEPT when the author
62
+ * called socket.close themselves. Code/reason from the RFC 6455
63
+ * close frame; 1006 for abnormal (RST), 1011 for pong timeout,
64
+ * 1001 for server shutdown. */
65
+ close?: (socket: WsSocket, code: number, reason: string) => void
66
+ }
67
+
68
+ /** The only handle the author touches inside a WsHandlers callback. */
69
+ export interface WsSocket {
70
+ /** Send a frame. Text if data is string, Binary if Uint8Array. Returns
71
+ * a Promise that resolves when the TCP write completes (cooperative
72
+ * backpressure, same model as SSE napi.write). Rejects with a clear
73
+ * error if the conn is already closed. */
74
+ send(data: string | Uint8Array): Promise<void>
75
+ /** Initiate close with optional code (default 1000) and reason (default
76
+ * ''). Idempotent — second call is a no-op. on_close does NOT fire
77
+ * after this call. */
78
+ close(code?: number, reason?: string): void
79
+ /** Stable per-conn identifier. Useful as a Map key in author's
80
+ * in-memory connection registry. */
81
+ readonly id: bigint
82
+ }
83
+
84
+ export interface RouteContext<Params = Record<string, string>, Data = unknown> {
85
+ params: Params
86
+ path: string
87
+ /** Value returned by `route.loader`. Undefined if the route has no loader. */
88
+ data: Data
89
+ /** Bun Worker id rendering this request. null before the first registerRenderer
90
+ * return resolves (a brief window during boot). */
91
+ workerId: number | null
92
+ /** Structured request shape. Available to components for read-only inspection. */
93
+ req: BrustRequest
94
+ }
95
+
96
+ export interface ErrorBoundaryProps {
97
+ error: Error
98
+ }
99
+
100
+ export interface RouteCacheConfig {
101
+ /** Time-to-live in seconds. */
102
+ ttl_seconds: number
103
+ /** Request headers that affect content. Each becomes part of the cache key. */
104
+ vary?: string[]
105
+ }
106
+
107
+ /** Shape returned by a middleware or by the terminal `next()` (loader + render).
108
+ * Middleware can short-circuit by returning a RouteResponse without calling next,
109
+ * or call next() and mutate the returned response (status, headers). */
110
+ export interface RouteResponse {
111
+ status: number
112
+ body: string
113
+ /** Extra response headers. Names are case-insensitive on the wire; Rust
114
+ * deduplicates by lower-casing internally. Skips collisions with the fixed
115
+ * Content-Type / Content-Length / Connection lines. */
116
+ headers?: Record<string, string>
117
+ /** Override the Content-Type emitted by Rust. Action returns set this to
118
+ * 'application/json; charset=utf-8'; middleware short-circuits with a
119
+ * raw string body can set 'text/plain'. Falls back to 'text/html' (render)
120
+ * or 'application/json' (action) when omitted. */
121
+ contentType?: string
122
+ }
123
+
124
+ /** Middleware contract — Express/Koa-style chain. Receives a structured
125
+ * request and a `next()` that runs the rest of the chain (eventually the
126
+ * loader + render). Return a `RouteResponse` to short-circuit, or call
127
+ * `await next()` and return its (possibly mutated) result. */
128
+ export type Middleware = (
129
+ req: BrustRequest,
130
+ next: () => Promise<RouteResponse>,
131
+ ) => Promise<RouteResponse>
132
+
133
+ export interface Route<Params = Record<string, string>, Data = unknown> {
134
+ /** Relative path segment (matchit syntax). Empty string `''` = layout-only
135
+ * (this node contributes nothing to the path; children attach to ancestors).
136
+ * Mutually exclusive with `index: true`. */
137
+ path?: string
138
+ /** Index route — matches the parent path exactly. Must be a leaf (no
139
+ * `children`, no `path`). Mutually exclusive with `path`. */
140
+ index?: boolean
141
+ /** TypeScript can't infer per-entry generics inside a `defineRoutes([...])`
142
+ * literal, so the array would force every Component to accept the default
143
+ * `RouteContext<Record<string, string>, unknown>` shape — components that
144
+ * narrow `Params` / `Data` would fail to type-check. Keep the field
145
+ * permissive here; each component self-declares its expected props. */
146
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
147
+ Component?: ComponentType<any>
148
+ /** Optional async function that runs in the worker before rendering. Its
149
+ * return value becomes the component's `data` prop. Exceptions are caught
150
+ * by `errorBoundary` if declared (inherited from closest ancestor). */
151
+ loader?: (ctx: { params: Params; path: string; req: BrustRequest }) => Promise<Data>
152
+ /** Optional component invoked when Component or loader throws. Inherited
153
+ * by descendants when they don't define their own. */
154
+ errorBoundary?: ComponentType<ErrorBoundaryProps>
155
+ /** Opt-in cache. Cache config from the leaf only — parent's cache is
156
+ * ignored when the route is reached as part of a chain. */
157
+ cache?: RouteCacheConfig
158
+ /** Per-route middleware chain. Runs in declaration order; concatenated
159
+ * with parent middlewares (parent runs before child). Cache lookup
160
+ * still happens BEFORE any middleware (existing rule). */
161
+ middleware?: Middleware[]
162
+ /** Nested children. Each child's path is composed with this node's path
163
+ * via `joinPath` (see flattenRoutes). */
164
+ children?: Route[]
165
+ /** When set, this route streams a text/event-stream response. Cannot
166
+ * coexist with Component, loader, or children (validated at defineRoutes
167
+ * time). The framework auto-sends Content-Type, Cache-Control,
168
+ * X-Accel-Buffering, plus a `: ping\n\n` heartbeat every 15s (opt-out
169
+ * via sseOptions). The author returns ReadableStream<Uint8Array | string>;
170
+ * string chunks are UTF-8 encoded. Listen on req.signal for disconnect. */
171
+ sse?: (
172
+ req: BrustRequest,
173
+ ) => ReadableStream<Uint8Array | string> | Promise<ReadableStream<Uint8Array | string>>
174
+ sseOptions?: {
175
+ /** Auto-emit `: ping\n\n` every N ms. Default 15000. Set 0 to disable. */
176
+ heartbeatMs?: number
177
+ }
178
+ /** When set, this route accepts WebSocket upgrades. Cannot coexist
179
+ * with Component, loader, sse, or children (validated at defineRoutes
180
+ * time). The factory is invoked lazily by the WS dispatch path and
181
+ * cached per worker. The handler module may export `WsHandlers`
182
+ * directly OR as a `default` (the common `() => import('./ws-foo')`
183
+ * pattern returns a module namespace `{ default: WsHandlers }`);
184
+ * `handleWsConn` unwraps `.default` defensively at call time. */
185
+ websocket?: () => Promise<WsHandlers | { default: WsHandlers }>
186
+ wsOptions?: {
187
+ /** Server-initiated ping interval in ms. Default 30000. Set 0 to disable.
188
+ * Pong timeout = 2× pingMs; conn closes with code 1011 if no pong by then. */
189
+ pingMs?: number
190
+ /** Max message size in bytes. Default 1 048 576 (1 MB). Larger frames
191
+ * close the conn with 1009 (Message Too Big). */
192
+ maxMessageBytes?: number
193
+ /** Subprotocols the route supports (Sec-WebSocket-Protocol). Client's
194
+ * requested list is intersected with this; first match (in this declared
195
+ * order) wins and is reflected in Sec-WebSocket-Protocol response. */
196
+ subprotocols?: string[]
197
+ }
198
+ /** Sub-project J — render this route via the native (jinja) engine, not React.
199
+ * `Component` is REQUIRED (the JSX file is the source jsx-rustc compiles
200
+ * into `.brust/jinja/<Component.name>.jinja`). Loader-friendly: the loader's
201
+ * return value becomes the template context. */
202
+ native?: boolean
203
+ }
204
+
205
+ /** Internal post-flatten representation. Each FlatRoute is a single leaf or
206
+ * index route in the user's nested tree. Indexed by Rust's route_id (array
207
+ * position). Consumed by `makeRenderer` and structurally compatible with
208
+ * `brust.registerRoutes` (reads only `fullPath` and `cache`). */
209
+ export interface FlatRoute {
210
+ /** Full path Rust matches against. Composed from the chain via joinPath. */
211
+ fullPath: string
212
+ /** Chain of Route nodes from root to leaf, inclusive. Renderer walks
213
+ * this top-down. */
214
+ chain: Route[]
215
+ /** Concatenated middleware from root → leaf. composeChain wraps right-to-left,
216
+ * so the first element here becomes the outermost wrap (runs first). */
217
+ middleware: Middleware[]
218
+ /** Closest errorBoundary in the chain (leaf wins; falls back up the chain). */
219
+ errorBoundary?: ComponentType<ErrorBoundaryProps>
220
+ /** Cache from the leaf only — no parent inheritance. */
221
+ cache?: RouteCacheConfig
222
+ /** Sub-project J — Component.name when leaf had `native: true`. Captured
223
+ * at flatten time (build-time AST identifier), so minifier-safe. */
224
+ nativeTemplate?: string
225
+ }
226
+
227
+ /** Compose a child's relative path onto a parent's base path.
228
+ * - `rel === ''` (layout-only child) → returns `base` unchanged
229
+ * - `rel` starts with `/` (absolute) → returns `rel` (only valid when base === '';
230
+ * flattenRoutes rejects this case otherwise)
231
+ * - otherwise → strip any trailing `/` from base, append `/${rel}` */
232
+ export function joinPath(base: string, rel: string): string {
233
+ if (rel === '') return base
234
+ if (rel.startsWith('/')) return rel
235
+ const trimmedBase = base.endsWith('/') ? base.slice(0, -1) : base
236
+ return `${trimmedBase}/${rel}`
237
+ }
238
+
239
+ /** Validate a Route node's structural invariants in the context of its
240
+ * parent's basePath. Throws with a useful message at module top-level —
241
+ * route bugs fail loudly before brust.serve binds. */
242
+ function validateRoute(r: Route, basePath: string): void {
243
+ if (r.index === true && r.path !== undefined) {
244
+ throw new Error(`route under "${basePath}": cannot set both index and path`)
245
+ }
246
+ if (r.index === true && r.children && r.children.length > 0) {
247
+ throw new Error(`route under "${basePath}": index route cannot have children`)
248
+ }
249
+ if (!r.index && r.path === undefined && !(r.children && r.children.length > 0)) {
250
+ throw new Error(`route under "${basePath}": must have path, index, or children`)
251
+ }
252
+ if (r.path?.startsWith('/') && basePath !== '') {
253
+ // Absolute children under a non-empty parent are a footgun (the child
254
+ // escapes the parent's URL space). Only allowed when the parent is
255
+ // layout-only (basePath === '').
256
+ throw new Error(
257
+ `route under "${basePath}": absolute child path "${r.path}" must be under a pathless ('') parent`,
258
+ )
259
+ }
260
+ if (r.native === true) {
261
+ const where = r.path ?? '(no path)'
262
+ if (r.Component === undefined) {
263
+ throw new Error(`Route ${where}: 'native: true' requires 'Component'`)
264
+ }
265
+ if (!r.Component.name || r.Component.name.length === 0) {
266
+ throw new Error(
267
+ `Route ${where}: 'native: true' Component must be a named function (got anonymous)`,
268
+ )
269
+ }
270
+ if (r.sse !== undefined) {
271
+ throw new Error(`Route ${where}: 'native: true' cannot coexist with 'sse'`)
272
+ }
273
+ if (r.websocket !== undefined) {
274
+ throw new Error(`Route ${where}: 'native: true' cannot coexist with 'websocket'`)
275
+ }
276
+ if (r.children !== undefined) {
277
+ throw new Error(`Route ${where}: 'native: true' cannot have nested children`)
278
+ }
279
+ if (r.cache !== undefined) {
280
+ throw new Error(`Route ${where}: 'native: true' cannot coexist with 'cache' (deferred)`)
281
+ }
282
+ // loader + middleware are EXPLICITLY allowed.
283
+ }
284
+ if (r.sse) {
285
+ const where = r.path ?? '(no path)'
286
+ if (r.Component !== undefined) {
287
+ throw new Error(`Route ${where}: 'sse' cannot coexist with 'Component'`)
288
+ }
289
+ if (r.loader !== undefined) {
290
+ throw new Error(`Route ${where}: 'sse' cannot coexist with 'loader'`)
291
+ }
292
+ if (r.children !== undefined) {
293
+ throw new Error(`Route ${where}: 'sse' cannot have nested children`)
294
+ }
295
+ }
296
+ if (r.websocket) {
297
+ const where = r.path ?? '(no path)'
298
+ if (r.Component !== undefined) {
299
+ throw new Error(`Route ${where}: 'websocket' cannot coexist with 'Component'`)
300
+ }
301
+ if (r.loader !== undefined) {
302
+ throw new Error(`Route ${where}: 'websocket' cannot coexist with 'loader'`)
303
+ }
304
+ if (r.sse !== undefined) {
305
+ throw new Error(`Route ${where}: 'websocket' cannot coexist with 'sse'`)
306
+ }
307
+ if (r.children !== undefined) {
308
+ throw new Error(`Route ${where}: 'websocket' cannot have nested children`)
309
+ }
310
+ }
311
+ }
312
+
313
+ /** Walk the nested route tree, emitting one FlatRoute per leaf or index node.
314
+ * Composes paths, middleware, errorBoundary, and cache per the rules in
315
+ * the design spec (§3). */
316
+ export function flattenRoutes(routes: Route[]): FlatRoute[] {
317
+ const out: FlatRoute[] = []
318
+ walkRoutes(routes, [], '', out)
319
+ return out
320
+ }
321
+
322
+ function walkRoutes(
323
+ routes: Route[],
324
+ parentChain: Route[],
325
+ basePath: string,
326
+ out: FlatRoute[],
327
+ ): void {
328
+ for (const r of routes) {
329
+ validateRoute(r, basePath)
330
+ const chain = [...parentChain, r]
331
+
332
+ if (r.index === true) {
333
+ out.push(makeFlat(chain, basePath))
334
+ continue
335
+ }
336
+
337
+ const ownPath = r.path ?? ''
338
+ const myPath = joinPath(basePath, ownPath)
339
+
340
+ if (r.children && r.children.length > 0) {
341
+ walkRoutes(r.children, chain, myPath, out)
342
+ } else {
343
+ // Leaf with a path (validated above).
344
+ out.push(makeFlat(chain, myPath))
345
+ }
346
+ }
347
+ }
348
+
349
+ function makeFlat(chain: Route[], fullPath: string): FlatRoute {
350
+ const middleware: Middleware[] = []
351
+ for (const r of chain) {
352
+ if (r.middleware) middleware.push(...r.middleware)
353
+ }
354
+ let errorBoundary: ComponentType<ErrorBoundaryProps> | undefined
355
+ for (const r of chain) {
356
+ if (r.errorBoundary) errorBoundary = r.errorBoundary
357
+ }
358
+ const leaf = chain[chain.length - 1]
359
+ const cache = leaf.cache
360
+ const nativeTemplate = leaf.native === true && leaf.Component ? leaf.Component.name : undefined
361
+ return { fullPath, chain, middleware, errorBoundary, cache, nativeTemplate }
362
+ }
363
+
364
+ /** Internal React context that carries the next-deeper rendered element to
365
+ * the parent's <Outlet />. Default `null` means "no child to render" —
366
+ * `<Outlet />` from a leaf route or a flat route renders nothing. */
367
+ export const OutletContext = createContext<ReactNode>(null)
368
+
369
+ /** Renders the matched child route inside a parent layout. Read via
370
+ * React context; falls back to null at the leaf or in a flat (non-nested)
371
+ * route. Use inside a parent Component:
372
+ *
373
+ * function AdminLayout() {
374
+ * return <div><nav>…</nav><main><Outlet /></main></div>
375
+ * }
376
+ */
377
+ export function Outlet(): ReactNode {
378
+ return useContext(OutletContext)
379
+ }
380
+
381
+ /** Process the user's nested route tree into a flat array for the renderer
382
+ * and Rust route table. Each leaf/index node becomes one FlatRoute. Indices
383
+ * are stable across worker reloads (= array position), matching Rust's
384
+ * route_id semantics. */
385
+ export function defineRoutes(routes: Route[]): FlatRoute[] {
386
+ return flattenRoutes(routes)
387
+ }
388
+
389
+ /** Wire-level shape of the JSON envelope produced by Rust. Discriminated
390
+ * union: render path (matched against a route) vs action path
391
+ * (POST /_brust/action/<id>). Keep these in sync with src/routes.rs
392
+ * RouteEnvelope / ActionEnvelope.
393
+ */
394
+ export type RouteCall =
395
+ | {
396
+ kind: 'render'
397
+ route_id: number
398
+ path: string
399
+ params: Record<string, string>
400
+ req: BrustRequest
401
+ }
402
+ | {
403
+ kind: 'navigation'
404
+ route_id: number
405
+ path: string
406
+ params: Record<string, string>
407
+ req: BrustRequest
408
+ }
409
+ | {
410
+ kind: 'action'
411
+ action_id: string
412
+ /** Request's Content-Type, whitespace-trimmed (case preserved by Rust;
413
+ * the dispatch points below lowercase defensively). '' means the header
414
+ * was missing. */
415
+ content_type: string
416
+ /** UTF-8 text body — present for application/json and
417
+ * application/x-www-form-urlencoded. Mutually exclusive with body_b64. */
418
+ body_text?: string
419
+ /** Base64-encoded binary body — present for multipart/form-data.
420
+ * JS decodes via Buffer.from(s, 'base64') before parsing. */
421
+ body_b64?: string
422
+ req: BrustRequest
423
+ }
424
+ | {
425
+ kind: 'mcp'
426
+ body_text: string
427
+ req: BrustRequest
428
+ }
429
+ | {
430
+ kind: 'sse'
431
+ conn_id: bigint
432
+ req: BrustRequest
433
+ }
434
+ | {
435
+ kind: 'ws'
436
+ conn_id: bigint
437
+ client_subprotocols: string[]
438
+ req: BrustRequest
439
+ }
440
+
441
+ /**
442
+ * Build a render callback for a given routes table. The returned function is
443
+ * what gets passed to `brust.registerRenderer(view, fn)` on the worker side.
444
+ *
445
+ * Wire format written to the SAB: [meta_len: u16 BE][meta JSON UTF-8][body bytes].
446
+ * meta = { status: number, headers?: Record<string, string> }.
447
+ */
448
+ export interface MakeRendererOptions {
449
+ /** Lazy getter for the Bun Worker id. Called per-render so the value can be
450
+ * resolved after `registerRenderer` returns. Returns null before that. */
451
+ getWorkerId?: () => number | null
452
+ /** Action table the worker dispatches to when envelope.kind === 'action'.
453
+ * Both the main process and each worker call `brust.scanActions(...)` at
454
+ * module top-level and pass the resulting array here — the wire keys (ids)
455
+ * and the handler functions (fn) must agree across both ends. */
456
+ actions?: ActionDef[]
457
+ /** MCP server instance — built once per worker at module top-level. */
458
+ mcp?: import('./mcp/server.ts').McpServer
459
+ }
460
+
461
+ export function makeRenderer(
462
+ routes: FlatRoute[],
463
+ view: Uint8Array,
464
+ opts: MakeRendererOptions = {},
465
+ ): (envelopeJsonOrLen: number | string) => Promise<number> {
466
+ const encoder = new TextEncoder()
467
+ const decoder = new TextDecoder()
468
+ const byRouteId = new Map<number, FlatRoute>()
469
+ routes.forEach((r, i) => {
470
+ byRouteId.set(i, r)
471
+ })
472
+ const byActionId = new Map<string, ActionDef>()
473
+ for (const a of opts.actions ?? []) byActionId.set(a.id, a)
474
+
475
+ // napi shim for the chunk channel. The sabBytes arg is ignored by the
476
+ // native fn (Rust reads from the pre-registered BufPtr) — the call sites
477
+ // still pass it so renderBranchStreaming can be unit-tested against a
478
+ // mock that captures the bytes.
479
+ const napi = {
480
+ renderChunk: async (workerId: bigint, len: number, _sabBytes: Uint8Array): Promise<void> => {
481
+ await (native as any).napiRenderChunk(Number(workerId), len)
482
+ },
483
+ renderChunkFinal: async (
484
+ workerId: bigint,
485
+ len: number,
486
+ _sabBytes: Uint8Array,
487
+ ): Promise<void> => {
488
+ await (native as any).napiRenderChunkFinal(Number(workerId), len)
489
+ },
490
+ }
491
+
492
+ return async (envelopeJsonOrLen: number | string): Promise<number> => {
493
+ const envelopeJson =
494
+ typeof envelopeJsonOrLen === 'number'
495
+ ? decoder.decode(view.subarray(0, envelopeJsonOrLen))
496
+ : envelopeJsonOrLen
497
+ const call = JSON.parse(envelopeJson) as RouteCall
498
+ const wid = opts.getWorkerId?.() ?? 0
499
+ const workerId = BigInt(wid)
500
+
501
+ if (call.kind === 'render') {
502
+ const flat = byRouteId.get(call.route_id)
503
+ if (!flat) {
504
+ console.error(`[brust] unknown route_id=${call.route_id} for path=${call.path}`)
505
+ await emitSingleChunkResponse(view, napi, workerId, encoder, {
506
+ status: 404,
507
+ contentType: 'text/plain; charset=utf-8',
508
+ body: 'not found',
509
+ })
510
+ return 0
511
+ }
512
+ // Inject the permanently-unaborted signal — non-SSE routes don't
513
+ // have a per-conn AbortController. Middleware/loaders/components
514
+ // can still read req.signal.aborted (always false).
515
+ call.req.signal = NEVER_ABORTS
516
+
517
+ // Run the middleware chain against a streaming-marker terminal.
518
+ // Middleware that short-circuits (returns without calling next())
519
+ // wins as a plain single-chunk response. Middleware that calls next()
520
+ // gets the marker back and can wrap/mutate `headers` and `status`
521
+ // before we hand control to renderBranchStreaming.
522
+ const STREAM_MARKER: unique symbol = Symbol.for('brust.streamRender')
523
+ type StreamMarkerResponse = RouteResponse & { _brustStream?: typeof STREAM_MARKER }
524
+ const streamTerminal: () => Promise<StreamMarkerResponse> = async () => ({
525
+ status: 200,
526
+ body: '',
527
+ contentType: 'text/html; charset=utf-8',
528
+ _brustStream: STREAM_MARKER,
529
+ })
530
+ const chain = composeChain(call.req, flat.middleware, streamTerminal)
531
+
532
+ let verdict: StreamMarkerResponse
533
+ try {
534
+ verdict = (await chain()) as StreamMarkerResponse
535
+ } catch (err) {
536
+ console.error(`[brust] middleware/render uncaught:`, err)
537
+ // FAST LANE: single-chunk error. Works for both React (big dispatch
538
+ // reads the SAB via its fast-lane arm) and native routes (which take
539
+ // the channel-free dispatch_single_chunk).
540
+ return packSingleChunkResponse(view, encoder, {
541
+ status: 500,
542
+ contentType: 'text/html; charset=utf-8',
543
+ body: 'internal error',
544
+ })
545
+ }
546
+
547
+ if (verdict._brustStream !== STREAM_MARKER) {
548
+ // Middleware short-circuited with a concrete response. FAST LANE —
549
+ // single-chunk; same dual-dispatch safety as the error path above.
550
+ return packSingleChunkResponse(view, encoder, {
551
+ status: verdict.status,
552
+ contentType: verdict.contentType ?? 'text/html; charset=utf-8',
553
+ body: verdict.body,
554
+ headers: verdict.headers,
555
+ })
556
+ }
557
+
558
+ // Sub-project J — native: true branch. Runs the leaf's loader (if any),
559
+ // JSON-encodes the result into the SAB, then invokes napiRenderJinja
560
+ // which performs the minijinja render Rust-side and emits a 200 chunk
561
+ // through the same render-chunk channel as the React path.
562
+ //
563
+ // KNOWN LIMITATION: middleware can short-circuit by returning a
564
+ // RouteResponse without next() — that's the verdict._brustStream / status
565
+ // path above and works for native routes. But middleware that calls
566
+ // next() then mutates status/headers (e.g. adds Cache-Control) is NOT
567
+ // forwarded; napi_render_jinja hardcodes status: 200 and empty headers.
568
+ // Spec §4 doesn't define post-next() mutation semantics for native;
569
+ // deferred to v2.x. If your middleware needs to mutate, use a React
570
+ // route for now.
571
+ if (flat.nativeTemplate !== undefined) {
572
+ let data: unknown = {}
573
+ const leaf = flat.chain[flat.chain.length - 1]
574
+ if (leaf.loader) {
575
+ const ctx = { params: call.params, path: call.path, req: call.req }
576
+ try {
577
+ data = await leaf.loader(ctx as any)
578
+ } catch (err) {
579
+ console.error(`[brust] loader failed for native route ${flat.fullPath}:`, err)
580
+ // FAST LANE: native routes take dispatch_single_chunk (no chunk
581
+ // channel), so every native fallback MUST pack + return a length.
582
+ return packSingleChunkResponse(view, encoder, {
583
+ status: 500,
584
+ contentType: 'text/html; charset=utf-8',
585
+ body: 'internal error',
586
+ })
587
+ }
588
+ }
589
+ const json = JSON.stringify(data ?? {})
590
+ // Sub-project J — islands. If this template has an enriched islands
591
+ // manifest, merge per-island context vars (island_<id>_props, plus
592
+ // island_<id>_html for ssr entries) into the loader data before
593
+ // shipping it. resolveIslandContext is async (T9: it awaits the dynamic
594
+ // import + renderToString of each ssr island source).
595
+ const manifest = loadIslandManifest(flat.nativeTemplate)
596
+ if (manifest && manifest.length > 0) {
597
+ const rt = JSON.parse(json) // roundtrip ONCE; props read from rt
598
+ const extra = await resolveIslandContext(manifest, rt)
599
+ const ctx = { ...rt, ...extra }
600
+ const finalBytes = encoder.encode(JSON.stringify(ctx))
601
+ // The original size check guarded the pre-island bytes; the merged
602
+ // context (with island props) can be larger. Re-check on finalBytes.
603
+ if (finalBytes.length > view.length) {
604
+ return packSingleChunkResponse(view, encoder, {
605
+ status: 413,
606
+ contentType: 'text/plain; charset=utf-8',
607
+ body: 'loader data too large for SAB',
608
+ })
609
+ }
610
+ view.set(finalBytes, 0)
611
+ try {
612
+ return (native as any).napiRenderJinja(
613
+ Number(workerId),
614
+ finalBytes.length,
615
+ flat.nativeTemplate,
616
+ )
617
+ } catch (err) {
618
+ console.error(`[brust] napiRenderJinja failed for "${flat.nativeTemplate}":`, err)
619
+ return packSingleChunkResponse(view, encoder, {
620
+ status: 500,
621
+ contentType: 'text/html; charset=utf-8',
622
+ body: 'internal error',
623
+ })
624
+ }
625
+ }
626
+ const dataBytes = encoder.encode(json)
627
+ if (dataBytes.length > view.length) {
628
+ return packSingleChunkResponse(view, encoder, {
629
+ status: 413,
630
+ contentType: 'text/plain; charset=utf-8',
631
+ body: 'loader data too large for SAB',
632
+ })
633
+ }
634
+ view.set(dataBytes, 0)
635
+ try {
636
+ // FAST LANE: napiRenderJinja is a SYNC napi call — renders Rust-side,
637
+ // writes the framed response into the SAB, and returns its length
638
+ // directly (no Promise round-trip). Return it up to the tsfn; Rust's
639
+ // fast-lane arm reads the SAB directly (no chunk channel).
640
+ return (native as any).napiRenderJinja(
641
+ Number(workerId),
642
+ dataBytes.length,
643
+ flat.nativeTemplate,
644
+ )
645
+ } catch (err) {
646
+ console.error(`[brust] napiRenderJinja failed for "${flat.nativeTemplate}":`, err)
647
+ return packSingleChunkResponse(view, encoder, {
648
+ status: 500,
649
+ contentType: 'text/html; charset=utf-8',
650
+ body: 'internal error',
651
+ })
652
+ }
653
+ }
654
+
655
+ let element: ReactNode
656
+ let errorBoundary: ComponentType<{ error: Error }>
657
+ try {
658
+ element = await buildRenderElement(call, flat, opts.getWorkerId)
659
+ errorBoundary =
660
+ flat.errorBoundary ??
661
+ (({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
662
+ } catch (err) {
663
+ // Setup failure BEFORE renderToPipeableStream — loader throw, params
664
+ // bind throw. Shape matches the legacy "internal error" path so
665
+ // existing integration tests stay green.
666
+ console.error(`[brust] render setup failed:`, err)
667
+ return await emitSingleChunkResponse(view, napi, workerId, encoder, {
668
+ status: 500,
669
+ contentType: 'text/html; charset=utf-8',
670
+ body: 'internal error',
671
+ })
672
+ }
673
+ await renderBranchStreaming({
674
+ element,
675
+ view,
676
+ workerId,
677
+ napi,
678
+ errorBoundary,
679
+ status: verdict.status,
680
+ headers: verdict.headers,
681
+ routePath: flat.fullPath,
682
+ })
683
+ // renderBranchStreaming wrote via the chunk channel.
684
+ return 0
685
+ }
686
+ if (call.kind === 'navigation') {
687
+ await navigationBranch(call, byRouteId, view, encoder, opts.getWorkerId)
688
+ return 0
689
+ }
690
+ if (call.kind === 'action') {
691
+ // FAST LANE: pack the framed response into the SAB and return its length.
692
+ // Rust reads it directly after the Promise settles — no chunk channel.
693
+ const resp = await actionBranchToResponse(call, byActionId)
694
+ return packSingleChunkResponse(view, encoder, resp)
695
+ }
696
+ if (call.kind === 'mcp') {
697
+ const resp = await mcpBranchToResponse(call, opts.mcp)
698
+ return await emitSingleChunkResponse(view, napi, workerId, encoder, resp)
699
+ }
700
+ if (call.kind === 'sse') {
701
+ try {
702
+ await sseBranch(call, view, encoder, routes)
703
+ } catch (err) {
704
+ // Setup-time errors only (BigInt coerce, dynamic import resolve,
705
+ // napi shim build). Once handleSseStream has started streaming,
706
+ // Rust already wrote 200 headers and frames — this 500 is irrelevant.
707
+ console.error('[brust] sseBranch uncaught:', err)
708
+ }
709
+ // SSE bypasses the chunk channel entirely — handleSseStream owns the
710
+ // socket via napiSse* fns. No renderChunk calls here.
711
+ return 0
712
+ }
713
+ if (call.kind === 'ws') {
714
+ try {
715
+ await wsBranch(call, view, encoder, routes)
716
+ } catch (err) {
717
+ // Setup-time errors only — same reasoning as sseBranch above.
718
+ console.error('[brust] wsBranch uncaught:', err)
719
+ }
720
+ // WS bypasses the chunk channel entirely — handleWsConn owns the
721
+ // socket via napiWs* fns. No renderChunk calls here.
722
+ return 0
723
+ }
724
+ // Unknown kind — log and 500. Shouldn't happen unless Rust ships
725
+ // something out of band.
726
+ console.error(`[brust] unknown envelope kind in worker:`, (call as { kind?: string }).kind)
727
+ return await emitSingleChunkResponse(view, napi, workerId, encoder, {
728
+ status: 500,
729
+ contentType: 'text/plain; charset=utf-8',
730
+ body: 'invalid envelope kind',
731
+ })
732
+ }
733
+ }
734
+
735
+ /** Render a React element to a single HTML string, awaiting all Suspense
736
+ * boundaries via onAllReady. Used by navigationBranch — renderToString
737
+ * would only capture the shell + fallbacks, while renderBranchStreaming
738
+ * is for the streaming render path. */
739
+ function renderToAwaitedString(element: ReactNode): Promise<string> {
740
+ return new Promise<string>((resolve, reject) => {
741
+ const chunks: Buffer[] = []
742
+ const sink = new Writable({
743
+ write(chunk, _enc, cb) {
744
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
745
+ cb()
746
+ },
747
+ })
748
+ sink.on('finish', () => resolve(Buffer.concat(chunks).toString('utf8')))
749
+ sink.on('error', reject)
750
+
751
+ let stream: ReturnType<typeof renderToPipeableStream>
752
+ stream = renderToPipeableStream(element, {
753
+ onAllReady() {
754
+ try {
755
+ stream.pipe(sink)
756
+ } catch (e) {
757
+ reject(e)
758
+ }
759
+ },
760
+ onShellError(err) {
761
+ reject(err)
762
+ },
763
+ onError(err) {
764
+ // Logged at the navigationBranch level; let onAllReady drive completion.
765
+ console.error('[brust] navigation render onError:', err)
766
+ },
767
+ })
768
+ })
769
+ }
770
+
771
+ async function navigationBranch(
772
+ call: Extract<RouteCall, { kind: 'navigation' }>,
773
+ byRouteId: Map<number, FlatRoute>,
774
+ view: Uint8Array,
775
+ encoder: TextEncoder,
776
+ getWorkerId: (() => number | null) | undefined,
777
+ ): Promise<void> {
778
+ const workerId = BigInt(getWorkerId?.() ?? 0)
779
+ const napi = {
780
+ renderChunk: async (wid: bigint, len: number, _view: Uint8Array): Promise<void> => {
781
+ await (native as any).napiRenderChunk(Number(wid), len)
782
+ },
783
+ renderChunkFinal: async (wid: bigint, len: number, _view: Uint8Array): Promise<void> => {
784
+ await (native as any).napiRenderChunkFinal(Number(wid), len)
785
+ },
786
+ }
787
+
788
+ const flat = byRouteId.get(call.route_id)
789
+ if (!flat) {
790
+ await emitSingleChunkResponse(view, napi, workerId, encoder, {
791
+ status: 404,
792
+ contentType: 'application/json; charset=utf-8',
793
+ body: '{"error":"not found"}',
794
+ })
795
+ return
796
+ }
797
+
798
+ // Run the middleware chain before rendering — mirrors the render branch's
799
+ // middleware pattern. A short-circuit verdict is emitted as the navigation
800
+ // response (client treats any non-2xx as a fallback trigger → full reload).
801
+ const NAV_MARKER: unique symbol = Symbol.for('brust.streamRender')
802
+ type NavMarkerResponse = RouteResponse & { _brustStream?: typeof NAV_MARKER }
803
+ const navTerminal: () => Promise<NavMarkerResponse> = async () => ({
804
+ status: 200,
805
+ body: '',
806
+ contentType: 'application/json; charset=utf-8',
807
+ _brustStream: NAV_MARKER,
808
+ })
809
+
810
+ // navigationBranch receives a 'navigation' call, but composeChain only
811
+ // needs req + middleware — cast to satisfy the type.
812
+ const navChain = composeChain(
813
+ (call as unknown as Extract<RouteCall, { kind: 'render' }>).req,
814
+ flat.middleware,
815
+ navTerminal,
816
+ )
817
+
818
+ let navVerdict: NavMarkerResponse
819
+ try {
820
+ navVerdict = (await navChain()) as NavMarkerResponse
821
+ } catch (err) {
822
+ console.error('[brust] navigation middleware threw:', err)
823
+ await emitSingleChunkResponse(view, napi, workerId, encoder, {
824
+ status: 500,
825
+ contentType: 'application/json; charset=utf-8',
826
+ body: '{"error":"middleware threw"}',
827
+ })
828
+ return
829
+ }
830
+
831
+ if (navVerdict._brustStream !== NAV_MARKER) {
832
+ // Middleware short-circuited — emit the verdict. Client's non-2xx check
833
+ // triggers the full-reload fallback so the user hits the real route and
834
+ // sees the middleware's challenge page.
835
+ await emitSingleChunkResponse(view, napi, workerId, encoder, {
836
+ status: navVerdict.status,
837
+ contentType: navVerdict.contentType ?? 'application/json; charset=utf-8',
838
+ body: navVerdict.body,
839
+ headers: navVerdict.headers,
840
+ })
841
+ return
842
+ }
843
+
844
+ try {
845
+ const element = await buildRenderElement(call as any, flat, getWorkerId)
846
+ if (!element) throw new Error('render setup failed')
847
+ // Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
848
+ // their RESOLVED markup, not the fallback. renderToString would only
849
+ // capture the shell — navigating SPA-style to a Suspense-using route
850
+ // would otherwise ship "loading…" and never recover.
851
+ const fullHtml = await renderToAwaitedString(element)
852
+
853
+ // Extract <main> inner content. If the page didn't render a <main>,
854
+ // ship the full HTML — the client's no-main check will fire its
855
+ // full-reload fallback.
856
+ //
857
+ // Known limitation: the lazy regex truncates at the first </main>, so
858
+ // a page that renders nested <main> elements (invalid per the HTML
859
+ // spec but allowed by React) would lose content after the inner
860
+ // </main>. A future task can replace this with stack-counting
861
+ // extraction or DOMParser if real apps hit it.
862
+ const mainMatch = fullHtml.match(/<main[^>]*>([\s\S]*?)<\/main>/i)
863
+ const innerHtml = mainMatch ? mainMatch[1] : fullHtml
864
+
865
+ // Extract <title> text. React 18 inserts <!-- --> markers between
866
+ // adjacent text nodes inside <title>; strip those before serialising.
867
+ const titleMatch = fullHtml.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
868
+ const title = titleMatch ? titleMatch[1].replace(/<!--.*?-->/g, '').trim() : ''
869
+
870
+ const body = JSON.stringify({ html: innerHtml, title })
871
+ await emitSingleChunkResponse(view, napi, workerId, encoder, {
872
+ status: 200,
873
+ contentType: 'application/json; charset=utf-8',
874
+ body,
875
+ })
876
+ } catch (err) {
877
+ console.error('[brust] navigation render failed:', err)
878
+ await emitSingleChunkResponse(view, napi, workerId, encoder, {
879
+ status: 500,
880
+ contentType: 'application/json; charset=utf-8',
881
+ body: '{"error":"render failed"}',
882
+ })
883
+ }
884
+ }
885
+
886
+ /** Build the React element for a render or navigation call: runs loaders
887
+ * top-down, builds the element bottom-up wrapping in OutletContext.Provider
888
+ * so nested routes receive the deeper element via <Outlet />. The caller
889
+ * (render branch or navigationBranch) is responsible for running the
890
+ * middleware chain BEFORE calling this — this helper assumes middleware
891
+ * has already passed.
892
+ *
893
+ * Throws on setup failure (loader throw, etc.). The caller synthesises a 500
894
+ * in that case.
895
+ */
896
+ async function buildRenderElement(
897
+ call: Extract<RouteCall, { kind: 'render' }>,
898
+ flat: FlatRoute,
899
+ getWorkerId?: () => number | null,
900
+ ): Promise<ReactNode> {
901
+ call.req.signal = NEVER_ABORTS
902
+ const workerId = getWorkerId ? getWorkerId() : null
903
+ const chainNodes = flat.chain
904
+
905
+ // 1. Run loaders top-down (parent → leaf). Each Component receives ONLY
906
+ // its own loader's data — no merge, no inheritance.
907
+ const datas: unknown[] = new Array(chainNodes.length)
908
+ for (let i = 0; i < chainNodes.length; i++) {
909
+ const r = chainNodes[i]
910
+ datas[i] = r.loader
911
+ ? await r.loader({ params: call.params, path: call.path, req: call.req })
912
+ : undefined
913
+ }
914
+
915
+ // 2. Build the React element bottom-up. Each level wraps the deeper level
916
+ // via <OutletContext.Provider value={renderedChild}>; <Outlet /> in any
917
+ // parent Component returns that value.
918
+ let element: ReactNode = null
919
+ for (let i = chainNodes.length - 1; i >= 0; i--) {
920
+ const r = chainNodes[i]
921
+ const node = createElement(r.Component!, {
922
+ params: call.params,
923
+ path: call.path,
924
+ data: datas[i],
925
+ workerId,
926
+ req: call.req,
927
+ })
928
+ element = createElement(OutletContext.Provider, { value: element }, node)
929
+ }
930
+ return element
931
+ }
932
+
933
+ /** Pack a framed single-chunk response `[meta_len: u16 BE][meta JSON][body]`
934
+ * into the SAB and return its total byte length — the FAST LANE. The worker
935
+ * resolves its render Promise with this length; Rust reads the framed bytes
936
+ * directly from the SAB after the Promise settles, bypassing the chunk channel
937
+ * (no napiRenderChunk call, no per-chunk ack round-trip). Returns the length so
938
+ * the caller can `return` it up to the tsfn.
939
+ *
940
+ * On SAB overflow, packs a small 500 instead and returns ITS length — the
941
+ * response always fits, so the client never hangs waiting for a body. */
942
+ function packSingleChunkResponse(
943
+ view: Uint8Array,
944
+ encoder: TextEncoder,
945
+ resp: {
946
+ status: number
947
+ contentType: string
948
+ body: string | Uint8Array
949
+ headers?: Record<string, string>
950
+ },
951
+ ): number {
952
+ const bodyBytes = typeof resp.body === 'string' ? encoder.encode(resp.body) : resp.body
953
+ const meta = JSON.stringify({
954
+ status: resp.status,
955
+ contentType: resp.contentType,
956
+ headers: resp.headers ?? {},
957
+ streaming: false,
958
+ })
959
+ const metaBytes = encoder.encode(meta)
960
+ const total = 2 + metaBytes.length + bodyBytes.length
961
+ if (total > view.length) {
962
+ console.error(`[brust] fast-lane response ${total}b exceeds SAB ${view.length}b — emitting 500`)
963
+ const errBody = encoder.encode('response body too large')
964
+ const errMeta = JSON.stringify({
965
+ status: 500,
966
+ contentType: 'text/plain; charset=utf-8',
967
+ headers: {},
968
+ streaming: false,
969
+ })
970
+ const errMetaBytes = encoder.encode(errMeta)
971
+ const errTotal = 2 + errMetaBytes.length + errBody.length
972
+ view[0] = (errMetaBytes.length >> 8) & 0xff
973
+ view[1] = errMetaBytes.length & 0xff
974
+ view.set(errMetaBytes, 2)
975
+ view.set(errBody, 2 + errMetaBytes.length)
976
+ return errTotal
977
+ }
978
+ view[0] = (metaBytes.length >> 8) & 0xff
979
+ view[1] = metaBytes.length & 0xff
980
+ view.set(metaBytes, 2)
981
+ view.set(bodyBytes, 2 + metaBytes.length)
982
+ return total
983
+ }
984
+
985
+ /** Emit a single-chunk response through the chunk channel — wire shape
986
+ * matches what dispatch_to_worker_and_stream_chunks expects (one Bytes
987
+ * chunk with `[meta_len][meta][body]`, then Final). Used by mcp branch
988
+ * and by setup-failure fallbacks in the render branch. Returns 0 (the
989
+ * "used the chunk channel" sentinel) so callers can `return` it up to the
990
+ * tsfn. */
991
+ async function emitSingleChunkResponse(
992
+ view: Uint8Array,
993
+ napi: {
994
+ renderChunk: (w: bigint, len: number, view: Uint8Array) => Promise<void>
995
+ renderChunkFinal: (w: bigint, len: number, view: Uint8Array) => Promise<void>
996
+ },
997
+ workerId: bigint,
998
+ encoder: TextEncoder,
999
+ resp: {
1000
+ status: number
1001
+ contentType: string
1002
+ body: string | Uint8Array
1003
+ headers?: Record<string, string>
1004
+ },
1005
+ ): Promise<number> {
1006
+ const bodyBytes = typeof resp.body === 'string' ? encoder.encode(resp.body) : resp.body
1007
+ const meta = JSON.stringify({
1008
+ status: resp.status,
1009
+ contentType: resp.contentType,
1010
+ headers: resp.headers ?? {},
1011
+ streaming: false,
1012
+ })
1013
+ const metaBytes = encoder.encode(meta)
1014
+ const total = 2 + metaBytes.length + bodyBytes.length
1015
+ if (total > view.length) {
1016
+ console.error(
1017
+ `[brust] single-chunk response ${total}b exceeds SAB ${view.length}b — emitting 500`,
1018
+ )
1019
+ // Emit a proper 500 plain-text response so the client doesn't hang on
1020
+ // TCP timeout waiting for a body that never comes. The fallback body is
1021
+ // small enough to always fit in the SAB.
1022
+ const errBody = encoder.encode('response body too large')
1023
+ const errMeta = JSON.stringify({
1024
+ status: 500,
1025
+ contentType: 'text/plain; charset=utf-8',
1026
+ headers: {},
1027
+ streaming: false,
1028
+ })
1029
+ const errMetaBytes = encoder.encode(errMeta)
1030
+ const errTotal = 2 + errMetaBytes.length + errBody.length
1031
+ view[0] = (errMetaBytes.length >> 8) & 0xff
1032
+ view[1] = errMetaBytes.length & 0xff
1033
+ view.set(errMetaBytes, 2)
1034
+ view.set(errBody, 2 + errMetaBytes.length)
1035
+ await napi.renderChunkFinal(workerId, errTotal, view)
1036
+ return 0
1037
+ }
1038
+ view[0] = (metaBytes.length >> 8) & 0xff
1039
+ view[1] = metaBytes.length & 0xff
1040
+ view.set(metaBytes, 2)
1041
+ view.set(bodyBytes, 2 + metaBytes.length)
1042
+ await napi.renderChunkFinal(workerId, total, view)
1043
+ return 0
1044
+ }
1045
+
1046
+ /** Plain response object shape returned by action/mcp branches and consumed
1047
+ * by `emitSingleChunkResponse`. The branches no longer write to the SAB
1048
+ * directly — they hand back a JS-side response and the caller packs +
1049
+ * dispatches it through the chunk channel. */
1050
+ interface BranchResponse {
1051
+ status: number
1052
+ contentType: string
1053
+ body: string | Uint8Array
1054
+ headers?: Record<string, string>
1055
+ }
1056
+
1057
+ async function actionBranchToResponse(
1058
+ call: Extract<RouteCall, { kind: 'action' }>,
1059
+ byId: Map<string, ActionDef>,
1060
+ ): Promise<BranchResponse> {
1061
+ const def = byId.get(call.action_id)
1062
+ if (!def) {
1063
+ // Rust already 404s when the id isn't registered, but a race during
1064
+ // hot-reload (or a desynced worker) could land here. Log and 404.
1065
+ // Action clients always expect JSON, so ship a JSON envelope even
1066
+ // when Rust would have 404'd first.
1067
+ console.error(`[brust] unknown action_id=${call.action_id}`)
1068
+ return {
1069
+ status: 404,
1070
+ body: '{"error":{"message":"unknown action"}}',
1071
+ contentType: 'application/json; charset=utf-8',
1072
+ }
1073
+ }
1074
+ // Populate req.signal with the permanently-unaborted sentinel. Action
1075
+ // handlers reading req.signal.aborted always see false; the SSE branch
1076
+ // is where real disconnect lives.
1077
+ call.req.signal = NEVER_ABORTS
1078
+
1079
+ // Decode the body into the args array that will be spread into the handler.
1080
+ // Three paths: multipart (body_b64), form-urlencoded (body_text), or JSON (body_text).
1081
+ // Body decode happens BEFORE middleware so a malformed body 400s without running
1082
+ // any user code.
1083
+ let args: unknown[]
1084
+ try {
1085
+ if (call.body_b64 !== undefined) {
1086
+ // Multipart path — base64 → bytes → Web Request.formData()
1087
+ const bytes = Buffer.from(call.body_b64, 'base64')
1088
+ const synthReq = new Request('http://x', {
1089
+ method: 'POST',
1090
+ headers: { 'Content-Type': call.content_type },
1091
+ body: bytes,
1092
+ })
1093
+ const fd = await synthReq.formData()
1094
+ args = [fd]
1095
+ } else if (call.content_type.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
1096
+ // Form-urlencoded path — URLSearchParams → FormData
1097
+ const params = new URLSearchParams(call.body_text ?? '')
1098
+ const fd = new FormData()
1099
+ for (const [k, v] of params) fd.append(k, v)
1100
+ args = [fd]
1101
+ } else {
1102
+ // JSON path (default — empty or application/json content type).
1103
+ const decoded = JSON.parse(call.body_text ?? '') as unknown
1104
+ if (!Array.isArray(decoded)) {
1105
+ return {
1106
+ status: 400,
1107
+ body: '{"error":{"message":"args must be a JSON array"}}',
1108
+ contentType: 'application/json; charset=utf-8',
1109
+ }
1110
+ }
1111
+ args = decoded
1112
+ }
1113
+ } catch (err) {
1114
+ const msg = err instanceof Error ? err.message : String(err)
1115
+ return {
1116
+ status: 400,
1117
+ body: JSON.stringify({ error: { message: `invalid request body: ${msg}` } }),
1118
+ contentType: 'application/json; charset=utf-8',
1119
+ }
1120
+ }
1121
+
1122
+ const terminal = async (): Promise<RouteResponse> => {
1123
+ try {
1124
+ const result = await def.fn(call.req, ...args)
1125
+ return {
1126
+ status: 200,
1127
+ body: result === undefined ? '' : JSON.stringify(result),
1128
+ contentType: 'application/json; charset=utf-8',
1129
+ }
1130
+ } catch (err) {
1131
+ console.error(`[brust] action ${def.id} threw:`, err)
1132
+ const e = err instanceof Error ? err : new Error(String(err))
1133
+ return {
1134
+ status: 500,
1135
+ body: JSON.stringify({ error: { message: e.message, name: e.name } }),
1136
+ contentType: 'application/json; charset=utf-8',
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ const chain = composeChain(call.req, def.middleware, terminal)
1142
+
1143
+ let response: RouteResponse
1144
+ try {
1145
+ response = await chain()
1146
+ } catch (err) {
1147
+ console.error(`[brust] action middleware uncaught:`, err)
1148
+ response = {
1149
+ status: 500,
1150
+ body: JSON.stringify({ error: { message: 'internal error' } }),
1151
+ contentType: 'application/json; charset=utf-8',
1152
+ }
1153
+ }
1154
+ return {
1155
+ status: response.status,
1156
+ body: response.body,
1157
+ contentType: response.contentType ?? 'application/json; charset=utf-8',
1158
+ headers: response.headers,
1159
+ }
1160
+ }
1161
+
1162
+ async function mcpBranchToResponse(
1163
+ call: Extract<RouteCall, { kind: 'mcp' }>,
1164
+ mcp: import('./mcp/server.ts').McpServer | undefined,
1165
+ ): Promise<BranchResponse> {
1166
+ if (!mcp) {
1167
+ return {
1168
+ status: 501,
1169
+ body: '{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"mcp not configured"}}',
1170
+ contentType: 'application/json; charset=utf-8',
1171
+ }
1172
+ }
1173
+ // Populate req.signal with the permanently-unaborted sentinel. MCP handler
1174
+ // reading req.signal.aborted always sees false; the SSE branch is where
1175
+ // real disconnect lives.
1176
+ call.req.signal = NEVER_ABORTS
1177
+ const responseJson = await mcp.handleRequest(call.body_text, call.req)
1178
+ if (responseJson === '') {
1179
+ // Notification — no response body. Return 204 No Content.
1180
+ return {
1181
+ status: 204,
1182
+ body: '',
1183
+ contentType: 'application/json; charset=utf-8',
1184
+ }
1185
+ }
1186
+ return {
1187
+ status: 200,
1188
+ body: responseJson,
1189
+ contentType: 'application/json; charset=utf-8',
1190
+ }
1191
+ }
1192
+
1193
+ async function sseBranch(
1194
+ call: Extract<RouteCall, { kind: 'sse' }>,
1195
+ _view: Uint8Array,
1196
+ encoder: TextEncoder,
1197
+ routes: FlatRoute[],
1198
+ ): Promise<void> {
1199
+ // conn_id crosses the boundary as a JSON number (u64) but napiSse* fns
1200
+ // require BigInt. Coerce once here; reassign on the call object so
1201
+ // handleSseStream (which reads call.conn_id directly) also gets bigint.
1202
+ ;(call as any).conn_id = BigInt((call as any).conn_id)
1203
+
1204
+ // Find matching FlatRoute by literal path (MVP — exact match; Rust's
1205
+ // path_is_sse gates dispatch so we only see registered paths). Strip
1206
+ // query string before matching.
1207
+ // Rust's path_is_sse uses the same literal-match registration as this
1208
+ // find — trailing-slash divergence (or any other normalization) fails
1209
+ // in lockstep on both sides, never producing silent half-state.
1210
+ const pathOnly = call.req.url.split('?')[0]
1211
+ const flat = routes.find((r) => r.fullPath === pathOnly)
1212
+ const leaf = flat?.chain[flat.chain.length - 1]
1213
+
1214
+ // Build napi shim around the four napiSse* native fns. signalOpen
1215
+ // wraps the body string in a Buffer (the Rust side takes Buffer).
1216
+ // Dynamic import is consistent with mcpBranch/actionBranch — defers
1217
+ // loading the native binding until the dispatch path actually fires
1218
+ // and avoids any circular-import risk during module init.
1219
+ const native = await import('./index.js')
1220
+ const napi = {
1221
+ write: (conn_id: bigint, bytes: Uint8Array) =>
1222
+ (native as any).napiSseWrite(
1223
+ conn_id,
1224
+ Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength),
1225
+ ),
1226
+ close: (conn_id: bigint) => (native as any).napiSseClose(conn_id),
1227
+ registerAbort: (conn_id: bigint, cb: () => void) =>
1228
+ (native as any).napiSseRegisterAbort(conn_id, cb),
1229
+ signalOpen: (conn_id: bigint, status: number, body: string, ct: string) => {
1230
+ const bodyBytes = encoder.encode(body)
1231
+ ;(native as any).napiSseSignalOpen(
1232
+ conn_id,
1233
+ status,
1234
+ Buffer.from(bodyBytes.buffer, bodyBytes.byteOffset, bodyBytes.byteLength),
1235
+ ct,
1236
+ )
1237
+ },
1238
+ }
1239
+
1240
+ if (!flat || !leaf?.sse) {
1241
+ // Defensive — shouldn't reach here since Rust gates by path_is_sse.
1242
+ napi.signalOpen(call.conn_id, 404, 'not found', 'text/plain; charset=utf-8')
1243
+ napi.close(call.conn_id)
1244
+ return
1245
+ }
1246
+
1247
+ // Inject NEVER_ABORTS into req for the middleware run. handleSseStream
1248
+ // will overwrite with a per-conn AbortController.signal afterward.
1249
+ call.req.signal = NEVER_ABORTS
1250
+
1251
+ // Run middleware chain with a 200 placeholder terminal. The terminal
1252
+ // does NOT invoke leaf.sse — handleSseStream does that after middleware
1253
+ // approves. This keeps middleware semantics identical to action/render.
1254
+ const placeholderTerminal: () => Promise<RouteResponse> = async () => ({
1255
+ status: 200,
1256
+ body: '',
1257
+ contentType: 'text/event-stream',
1258
+ })
1259
+ const chain = composeChain(call.req, flat.middleware, placeholderTerminal)
1260
+ const verdict = await chain()
1261
+
1262
+ if (verdict.status >= 400) {
1263
+ napi.signalOpen(
1264
+ call.conn_id,
1265
+ verdict.status,
1266
+ verdict.body,
1267
+ verdict.contentType ?? 'text/plain; charset=utf-8',
1268
+ )
1269
+ napi.close(call.conn_id)
1270
+ return
1271
+ }
1272
+
1273
+ // Middleware OK — invoke handleSseStream which signals open 200 itself
1274
+ // and runs the reader loop until done or aborted.
1275
+ const { handleSseStream } = await import('./sse/handler.ts')
1276
+ const routeShim: Route = {
1277
+ path: flat.fullPath,
1278
+ sse: leaf.sse,
1279
+ sseOptions: leaf.sseOptions,
1280
+ }
1281
+ await handleSseStream(call, routeShim, napi)
1282
+ }
1283
+
1284
+ async function wsBranch(
1285
+ call: Extract<RouteCall, { kind: 'ws' }>,
1286
+ _view: Uint8Array,
1287
+ encoder: TextEncoder,
1288
+ routes: FlatRoute[],
1289
+ ): Promise<void> {
1290
+ // Coerce conn_id from JSON.parse number → BigInt (same fix as sseBranch
1291
+ // Task 12; native binding requires BigInt since conn_ids are u64).
1292
+ ;(call as any).conn_id = BigInt(call.conn_id)
1293
+
1294
+ // Find matching FlatRoute by literal fullPath (Rust path_is_ws gates
1295
+ // dispatch — same literal-match contract as SSE).
1296
+ const pathOnly = call.req.url.split('?')[0]
1297
+ const flat = routes.find((r) => r.fullPath === pathOnly)
1298
+ const leaf = flat?.chain[flat.chain.length - 1]
1299
+
1300
+ // Build napi shim around the 4 napiWs* native fns. The registerHandlers
1301
+ // shim adapts from struct-arg callbacks (WsMessageArg / WsCloseArg —
1302
+ // Task 4 deviation: napi-rs rejected tuple Function args) to the
1303
+ // positional-arg handleWsConn API.
1304
+ const native = await import('./index.js')
1305
+ const napi = {
1306
+ send: (conn_id: bigint, bytes: Uint8Array, isBinary: boolean) =>
1307
+ (native as any).napiWsSend(
1308
+ conn_id,
1309
+ Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength),
1310
+ isBinary,
1311
+ ),
1312
+ close: (conn_id: bigint, code: number, reason: string) =>
1313
+ (native as any).napiWsClose(conn_id, code, reason),
1314
+ signalOpen: (
1315
+ conn_id: bigint,
1316
+ status: number,
1317
+ body: string,
1318
+ ct: string,
1319
+ subprotocol: string,
1320
+ ) => {
1321
+ const bodyBytes = encoder.encode(body)
1322
+ ;(native as any).napiWsSignalOpen(
1323
+ conn_id,
1324
+ status,
1325
+ Buffer.from(bodyBytes.buffer, bodyBytes.byteOffset, bodyBytes.byteLength),
1326
+ ct,
1327
+ subprotocol,
1328
+ )
1329
+ },
1330
+ registerHandlers: (
1331
+ conn_id: bigint,
1332
+ onMessage: (data: Uint8Array, isBinary: boolean) => void,
1333
+ onClose: (code: number, reason: string) => void,
1334
+ ) => {
1335
+ // napi-rs delivers struct args (WsMessageArg / WsCloseArg) — adapt
1336
+ // back to positional for handleWsConn's contract.
1337
+ ;(native as any).napiWsRegisterHandlers(
1338
+ conn_id,
1339
+ (arg: { data: Uint8Array; isBinary: boolean }) => onMessage(arg.data, arg.isBinary),
1340
+ (arg: { code: number; reason: string }) => onClose(arg.code, arg.reason),
1341
+ )
1342
+ },
1343
+ }
1344
+
1345
+ if (!flat || !leaf?.websocket) {
1346
+ // Defensive — Rust path_is_ws gates dispatch.
1347
+ napi.signalOpen(call.conn_id, 404, 'not found', 'text/plain; charset=utf-8', '')
1348
+ return
1349
+ }
1350
+
1351
+ // Inject NEVER_ABORTS into req for middleware. There's no per-conn
1352
+ // AbortController for WS — handleWsConn doesn't create one because
1353
+ // lifecycle is via registered onMessage/onClose callbacks rather than
1354
+ // awaiting on a request signal.
1355
+ call.req.signal = NEVER_ABORTS
1356
+
1357
+ // Run middleware chain with a 101 placeholder terminal that signals
1358
+ // "ready to upgrade". The terminal does NOT call route.websocket —
1359
+ // handleWsConn does that after middleware approves.
1360
+ const placeholderTerminal: () => Promise<RouteResponse> = async () => ({
1361
+ status: 101,
1362
+ body: '',
1363
+ contentType: 'application/octet-stream',
1364
+ })
1365
+ const chain = composeChain(call.req, flat.middleware, placeholderTerminal)
1366
+ const verdict = await chain()
1367
+
1368
+ if (verdict.status >= 400) {
1369
+ napi.signalOpen(
1370
+ call.conn_id,
1371
+ verdict.status,
1372
+ verdict.body,
1373
+ verdict.contentType ?? 'text/plain; charset=utf-8',
1374
+ '',
1375
+ )
1376
+ return
1377
+ }
1378
+
1379
+ // Middleware OK — invoke handleWsConn. It signals open 101 itself
1380
+ // (with the chosen subprotocol) and registers handlers.
1381
+ const { handleWsConn } = await import('./ws/handler.ts')
1382
+ const routeShim: Route = {
1383
+ path: flat.fullPath,
1384
+ websocket: leaf.websocket,
1385
+ wsOptions: leaf.wsOptions,
1386
+ }
1387
+ await handleWsConn(call, routeShim, napi)
1388
+ }
1389
+
1390
+ /** Right-to-left compose a middleware chain. Each middleware wraps the next;
1391
+ * the terminal step ends up at the innermost call. Returning without calling
1392
+ * next() short-circuits. Used identically by render + action branches. */
1393
+ export function composeChain(
1394
+ req: BrustRequest,
1395
+ mws: Middleware[] | undefined,
1396
+ terminal: () => Promise<RouteResponse>,
1397
+ ): () => Promise<RouteResponse> {
1398
+ if (!mws || mws.length === 0) return terminal
1399
+ let chain = terminal
1400
+ for (let i = mws.length - 1; i >= 0; i--) {
1401
+ const mw = mws[i]
1402
+ const next = chain
1403
+ chain = () => mw(req, next)
1404
+ }
1405
+ return chain
1406
+ }