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,618 @@
1
+ import * as native from './index.js'
2
+ import type { ActionDef } from './actions.ts'
3
+ import { isValidActionId } from './actions.ts'
4
+ import { loadConfig } from './config.ts'
5
+ import { configureCssEnabled, configureCssHrefsForRoute } from './css.ts'
6
+
7
+ export interface ServeOptions {
8
+ port: number
9
+ workers: number
10
+ entry: string
11
+ bootTimeoutMs?: number
12
+ /** Action definitions discovered by `brust.scanActions()`. When present,
13
+ * `serve` calls the internal action registry before the listener binds.
14
+ * Optional — omit if the app has no server actions. */
15
+ actions?: ActionDef[]
16
+ /** MCP support — pass a manifest built via brust.buildMcpManifest. brust.serve
17
+ * does NOT auto-wire MCP into workers; the worker branch of the entry file must
18
+ * call brust.loadMcpManifest() + makeMcpServer() itself and pass the McpServer
19
+ * to makeRenderer via `opts.mcp`. See example/hello-world/index.ts for the
20
+ * pattern. The field here is currently unused inside serve() and is reserved
21
+ * for future IPC-based propagation of the manifest. */
22
+ mcp?: { manifest: import('./mcp/manifest.ts').McpManifest }
23
+ }
24
+
25
+ // Render callback. Resolves with a framed-response length: `> 0` → FAST LANE
26
+ // (the worker wrote `[meta_len][meta][body]` into the SAB; Rust reads it
27
+ // directly), `0` → the worker used the chunk channel via
28
+ // `napi.renderChunk(workerId, len)` (React Suspense streaming) or owns the
29
+ // socket independently (SSE/WS). The argument is a JSON envelope
30
+ // `{ route_id, path, params }` produced by Rust's route table — see
31
+ // runtime/routes.ts::RouteCall.
32
+ export type RenderFn = (envelopeJsonOrLen: number | string) => Promise<number>
33
+
34
+ // Bun Workers run in the same OS process as the main thread; the `env` option
35
+ // only patches the JS-visible process.env, not the native OS environment that
36
+ // Rust reads via std::env::var. Read the flag here in JS instead.
37
+ export const isWorker: boolean = process.env.BRUST_WORKER_ID !== undefined
38
+
39
+ // native.workerId() reads a thread-local set by registerRenderer, so it must
40
+ // be re-evaluated at the point of use (not cached at module load).
41
+ export function workerId(): number | null {
42
+ return (native as any).workerId()
43
+ }
44
+
45
+ // Sub-project J — boot-time jinja loader. Idempotent: Rust holds the loaded
46
+ // templates in a OnceLock, so a second `set()` would panic. The flag guards
47
+ // repeated calls in both main and worker branches of `run()`, and from any
48
+ // future test/embedded entrypoint that calls `registerRoutes` directly.
49
+ let _jinjaLoaded = false
50
+ function loadJinjaOnce(dir: string): void {
51
+ if (_jinjaLoaded) return
52
+ ;(native as any).napiLoadJinjaTemplates(dir)
53
+ _jinjaLoaded = true
54
+ }
55
+
56
+ function registerActionsInternal(actions: Array<{ id: string }>): number {
57
+ const seen = new Set<string>()
58
+ for (const a of actions) {
59
+ if (!isValidActionId(a.id)) {
60
+ throw new Error(
61
+ `action id ${JSON.stringify(a.id)} contains invalid characters; ` +
62
+ `allowed: [A-Za-z0-9_-]+ (max 128 chars)`,
63
+ )
64
+ }
65
+ if (seen.has(a.id)) {
66
+ throw new Error(`action id ${JSON.stringify(a.id)} registered more than once`)
67
+ }
68
+ seen.add(a.id)
69
+ }
70
+ return (native as any).registerActions(actions.map((a) => a.id))
71
+ }
72
+
73
+ /** Read and schema-validate a prebuilt mcp-manifest.json at an absolute path.
74
+ * Returns null if the file does not exist. Throws on malformed JSON or version mismatch. */
75
+ async function readManifestFromPath(
76
+ absolutePath: string,
77
+ ): Promise<import('./mcp/manifest.ts').McpManifest | null> {
78
+ const f = Bun.file(absolutePath)
79
+ if (!(await f.exists())) return null
80
+ const text = await f.text()
81
+ let parsed: unknown
82
+ try {
83
+ parsed = JSON.parse(text)
84
+ } catch (e) {
85
+ throw new Error(`mcp-manifest.json is malformed: ${e instanceof Error ? e.message : String(e)}`)
86
+ }
87
+ if (!parsed || typeof parsed !== 'object' || (parsed as { version?: unknown }).version !== 1) {
88
+ throw new Error(`mcp-manifest.json version mismatch (expected 1)`)
89
+ }
90
+ return parsed as import('./mcp/manifest.ts').McpManifest
91
+ }
92
+
93
+ export const brust = {
94
+ async serve(opts: ServeOptions): Promise<void> {
95
+ if (opts.actions && opts.actions.length > 0) {
96
+ // Register action ids with Rust. registerActionsInternal validates
97
+ // charset + uniqueness; throws on either. Mirrors the previous
98
+ // `brust.registerActions` user-facing call exactly.
99
+ registerActionsInternal(opts.actions)
100
+ }
101
+ ;(native as any).beginServe({
102
+ port: opts.port,
103
+ workers: opts.workers,
104
+ entry: opts.entry,
105
+ })
106
+ const baseEnv = { ...process.env }
107
+ const workersArr: Worker[] = []
108
+ for (let i = 0; i < opts.workers; i++) {
109
+ // Bun.Worker requires the JS entry (post-bundling). For the skeleton,
110
+ // the entry is a TS file that Bun executes directly.
111
+ workersArr.push(
112
+ new Worker(opts.entry, {
113
+ env: { ...baseEnv, BRUST_WORKER_ID: String(i) },
114
+ }),
115
+ )
116
+ }
117
+ if (process.env.BRUST_DEV === '1') {
118
+ const { registerInitialPool } = await import('./dev/worker-registry.ts')
119
+ registerInitialPool(workersArr, opts.entry, opts.workers, baseEnv as Record<string, string>)
120
+ }
121
+ // Bun Workers intercept SIGINT before Rust's ctrl_c() handler fires.
122
+ // Install a JS-level handler so the process actually exits on SIGINT.
123
+ process.on('SIGINT', () => process.exit(0))
124
+ await (native as any).untilReady(opts.bootTimeoutMs ?? 5000)
125
+ await (native as any).untilShutdown()
126
+ },
127
+ /** Install the route table in Rust. MUST be called before `serve()`.
128
+ * Pass an array of FlatRoutes from `defineRoutes(...)` — each is JSON-encoded
129
+ * with its optional cache config. Rust matches against `fullPath`. */
130
+ registerRoutes(routes: import('./routes.ts').FlatRoute[]): number {
131
+ const configs = routes.map((r) =>
132
+ JSON.stringify({
133
+ path: r.fullPath,
134
+ cache: r.cache ?? null,
135
+ nativeTemplate: r.nativeTemplate ?? null,
136
+ }),
137
+ )
138
+ const result = (native as any).registerRoutes(configs)
139
+
140
+ // Sub-project J — startup validation. Every native: true route's
141
+ // Component.name must have a registered .jinja template, else 500 at
142
+ // request time. Warn here so boot logs surface the misconfiguration
143
+ // before any traffic hits.
144
+ const expected = routes.filter((r) => r.nativeTemplate).map((r) => r.nativeTemplate!)
145
+ if (expected.length > 0) {
146
+ const registered = new Set<string>((native as any).napiListNativeTemplates() ?? [])
147
+ for (const name of expected) {
148
+ if (!registered.has(name)) {
149
+ console.warn(
150
+ `[brust] native: true route expects template "${name}.jinja" but it's not registered (boot warning — request will 500)`,
151
+ )
152
+ }
153
+ }
154
+ }
155
+ return result
156
+ },
157
+ /** Register the list of literal route paths that should be dispatched as
158
+ * SSE (text/event-stream) instead of going through the render pipeline.
159
+ * Call from the main process after defineRoutes — the worker only needs
160
+ * the call if it also accepts SSE traffic, but in the current model the
161
+ * main accept loop owns dispatch so this is main-only. MVP supports only
162
+ * literal paths; parameterized routes (e.g. `/sse/{room}`) are a follow-up. */
163
+ registerSsePaths(paths: string[]): void {
164
+ ;(native as any).napiRegisterSsePaths(paths)
165
+ },
166
+ /** Register the list of literal route paths that should be dispatched as
167
+ * WebSocket upgrades. Call from the main process after defineRoutes.
168
+ * MVP supports only literal paths — parameterized routes (e.g.
169
+ * `/ws/chat/{room}`) are a follow-up. */
170
+ registerWsPaths(paths: string[]): void {
171
+ ;(native as any).napiRegisterWsPaths(paths)
172
+ },
173
+ /** Set the response cache capacity (entries). Default is 1000.
174
+ * Safe to call at any time; if shrinking below current size, excess
175
+ * LRU entries are evicted. */
176
+ configureCache(opts: { maxEntries: number }): void {
177
+ ;(native as any).configureCache(opts.maxEntries)
178
+ },
179
+ /** Tell Rust where to read `/_brust/islands/<file>` from. Called once at
180
+ * boot after buildIslands() emits chunks. Path must be absolute. */
181
+ configureIslandsDir(dir: string): void {
182
+ ;(native as any).configureIslandsDir(dir)
183
+ },
184
+ /** Tell Rust where to read `/_brust/css/<file>` from. Called from the
185
+ * main thread when CSS is configured. Path must be absolute. */
186
+ configureCssDir(dir: string): void {
187
+ ;(native as any).configureCssDir(dir)
188
+ },
189
+ /** Walk the project for files marked `'use server'`, import them, and
190
+ * return all named function exports as ActionDef[] plus the list of source
191
+ * files (needed by brust.buildMcpManifest). Both the main process and each
192
+ * worker should call this once at module top-level and pass `actions` to
193
+ * `brust.serve({ actions, ... })` (main) and `makeRenderer(..., { actions,
194
+ * ... })` (worker). See ScanOptions for roots / ignore overrides. */
195
+ async scanActions(
196
+ opts?: import('./scan-actions.ts').ScanOptions,
197
+ ): Promise<import('./scan-actions.ts').ScanActionsResult> {
198
+ const { scanActions } = await import('./scan-actions.ts')
199
+ return scanActions(opts)
200
+ },
201
+ /** Extract the MCP manifest from TypeScript source using the compiler API,
202
+ * write it to `.brust/mcp-manifest.json`, and return it. Call once in the
203
+ * main process after `brust.registerRoutes(routes)`. Workers must read the
204
+ * persisted manifest themselves via `brust.loadMcpManifest()` in the worker
205
+ * branch and pass an `McpServer` (built via `makeMcpServer`) to
206
+ * `makeRenderer` via `opts.mcp`. See example/hello-world/index.ts. */
207
+ async buildMcpManifest(opts: {
208
+ serverFiles: string[]
209
+ routesFile: string
210
+ sourceRoots: string[]
211
+ actions: import('./actions.ts').ActionDef[]
212
+ routes: import('./routes.ts').FlatRoute[]
213
+ cwd?: string
214
+ }): Promise<import('./mcp/manifest.ts').McpManifest> {
215
+ const { extractMcpManifest } = await import('./mcp/extractor.ts')
216
+ const { writeManifest } = await import('./mcp/manifest.ts')
217
+ const m = await extractMcpManifest(opts)
218
+ await writeManifest(opts.cwd ?? process.cwd(), m)
219
+ return m
220
+ },
221
+ /** Read the MCP manifest from `.brust/mcp-manifest.json`. Returns null if
222
+ * the file does not exist (i.e. the main process hasn't called
223
+ * `brust.buildMcpManifest` yet). Throws if the file is malformed. */
224
+ async loadMcpManifest(
225
+ cwd: string = process.cwd(),
226
+ ): Promise<import('./mcp/manifest.ts').McpManifest | null> {
227
+ const { readManifest } = await import('./mcp/manifest.ts')
228
+ return readManifest(cwd)
229
+ },
230
+ // Register a renderer for this Bun Worker. `buf` MUST be backed by a SharedArrayBuffer
231
+ // (or any ArrayBuffer the worker keeps rooted) — Rust captures the backing-store
232
+ // pointer once here and reuses it for every render call. The buffer is held alive
233
+ // by the worker's module scope; do not let it go out of scope.
234
+ registerRenderer(buf: Uint8Array, fn: RenderFn): number {
235
+ return (native as any).registerRenderer(buf, fn)
236
+ },
237
+
238
+ /**
239
+ * One-call lifecycle: scans actions, builds islands (if `routes.tsx` uses
240
+ * `<Island>`), registers routes + SSE/WS paths, builds the MCP manifest, then
241
+ * branches to `serve()` (main thread) or registers a renderer (worker
242
+ * thread). Replaces ~70 lines of boilerplate; the lower-level helpers
243
+ * (`scanActions`, `registerRoutes`, `makeRenderer`, etc.) are still exported
244
+ * for apps that need finer control.
245
+ *
246
+ * Conventions assumed (override via the corresponding option if your layout
247
+ * differs):
248
+ * - `<scanRoot>/routes.tsx` — scanned for `<Island>` usage (islands)
249
+ * and referenced by the MCP manifest builder
250
+ *
251
+ * `scanRoot` defaults to the directory of `entry` so the typical caller
252
+ * passes only `{ routes, entry: import.meta.url }`.
253
+ */
254
+ async run(opts: {
255
+ routes: import('./routes.ts').FlatRoute[]
256
+ entry: string
257
+ scanRoot?: string
258
+ /** Overrides merged into the underlying `serve()` call (main thread). */
259
+ serve?: Partial<Omit<ServeOptions, 'entry' | 'actions' | 'mcp'>>
260
+ /** Per-worker SAB size in bytes. Default 256 KB. */
261
+ sabBytes?: number
262
+ /** When true, prepend the dev WS route, install file watcher, set the
263
+ * dev-client snippet, and start the TUI. Default false.
264
+ * Also activated by BRUST_DEV=1 environment variable. */
265
+ dev?: boolean
266
+ }): Promise<void> {
267
+ const { existsSync } = await import('node:fs')
268
+ const { fileURLToPath } = await import('node:url')
269
+ const path = await import('node:path')
270
+
271
+ const prebuilt = process.env.BRUST_PREBUILT === '1'
272
+ const distDir = process.env.BRUST_DIST_DIR
273
+
274
+ const scanRoot = opts.scanRoot ?? path.dirname(fileURLToPath(opts.entry))
275
+
276
+ const dev = opts.dev === true || process.env.BRUST_DEV === '1'
277
+ let routes = opts.routes
278
+ if (dev) {
279
+ const { createDevWsRoute } = await import('./dev/ws-channel.ts')
280
+ const { buildDevClientTag } = await import('./dev/client.ts')
281
+ const { configureDevClientSnippet } = await import('./dev/inject.ts')
282
+ const devRoute = createDevWsRoute()
283
+ routes = [
284
+ { ...devRoute, fullPath: devRoute.path!, chain: [devRoute as any] } as any,
285
+ ...opts.routes,
286
+ ]
287
+ configureDevClientSnippet(buildDevClientTag())
288
+ }
289
+ // scanActions is plugin-aliased in prebuilt bundles → returns pre-baked
290
+ // list with sourceFiles=[]. In dev mode it walks the filesystem.
291
+ const { actions, sourceFiles } = await this.scanActions({ roots: [scanRoot] })
292
+
293
+ if (!isWorker) {
294
+ const { port, workers, cacheMaxEntries } = await loadConfig()
295
+ console.log(`[brust] main: spawning ${workers} worker threads`)
296
+ if (cacheMaxEntries !== undefined) this.configureCache({ maxEntries: cacheMaxEntries })
297
+
298
+ // Component CSS pipeline. Must run BEFORE buildIslands so the Bun.plugin
299
+ // is active during island bundling — otherwise Bun's default loader would
300
+ // see .module.css imports as separate asset outputs, producing duplicate-
301
+ // output-path errors when an island and its module share a basename
302
+ // (e.g. Counter.tsx + Counter.module.css → both want to emit Counter.js).
303
+ {
304
+ const { readComponentCssManifest } = await import('./css/manifest.ts')
305
+ const { cssLoaderPlugin } = await import('./css/component-loader.ts')
306
+ let manifest: import('./css/manifest.ts').ComponentCssManifest | null = null
307
+
308
+ if (prebuilt) {
309
+ const manifestPath = path.join(distDir!, 'css', 'component-manifest.json')
310
+ manifest = await readComponentCssManifest(manifestPath)
311
+ } else {
312
+ const { scanCssImports } = await import('./css/scan-imports.ts')
313
+ const scan = await scanCssImports(scanRoot)
314
+ if (scan.size > 0) {
315
+ const { buildComponentCss } = await import('./css/component-build.ts')
316
+ const routeForCss = opts.routes.map((r) => ({
317
+ fullPath: r.fullPath,
318
+ componentSource: path.join(scanRoot, 'routes.tsx'),
319
+ }))
320
+ const cssOutDir = path.join(process.cwd(), '.brust', 'css')
321
+ manifest = await buildComponentCss({
322
+ scanRoot,
323
+ outDir: cssOutDir,
324
+ tailwindCompile: null,
325
+ routes: routeForCss,
326
+ })
327
+ console.log(
328
+ `[brust] main: built ${Object.keys(manifest.modules).length} component CSS chunk(s)`,
329
+ )
330
+ }
331
+ }
332
+
333
+ if (manifest) {
334
+ Bun.plugin(cssLoaderPlugin(manifest))
335
+ for (const [routePath, hrefs] of Object.entries(manifest.routeChunks)) {
336
+ configureCssHrefsForRoute(routePath, hrefs)
337
+ }
338
+ }
339
+ }
340
+
341
+ if (prebuilt) {
342
+ // Pre-built islands live at <distDir>/islands.
343
+ const prebuiltIslandsDir = path.join(distDir!, 'islands')
344
+ if (existsSync(prebuiltIslandsDir)) {
345
+ this.configureIslandsDir(prebuiltIslandsDir)
346
+ console.log(`[brust] main: using pre-built islands at ${prebuiltIslandsDir}`)
347
+ }
348
+ } else {
349
+ const routesPath = path.join(scanRoot, 'routes.tsx')
350
+ if (existsSync(routesPath)) {
351
+ const { scanIslandChunks, buildIslands: build } = await import('./islands/build.ts')
352
+ const islandMap = scanIslandChunks(routesPath)
353
+ if (islandMap.size > 0) {
354
+ const islands = await build(islandMap)
355
+ this.configureIslandsDir(islands.outDir)
356
+ console.log(`[brust] main: built ${islands.islandCount} island chunk(s)`)
357
+ }
358
+ }
359
+ }
360
+
361
+ // CSS pipeline — opt-in via convention: <scanRoot>/app.css.
362
+ if (prebuilt) {
363
+ const prebuiltCssDir = path.join(distDir!, 'css')
364
+ if (existsSync(prebuiltCssDir)) {
365
+ this.configureCssDir(prebuiltCssDir)
366
+ configureCssEnabled(['/_brust/css/app.css'])
367
+ console.log(`[brust] main: using pre-built CSS at ${prebuiltCssDir}`)
368
+ }
369
+ } else {
370
+ const appCssPath = path.join(scanRoot, 'app.css')
371
+ if (existsSync(appCssPath)) {
372
+ const { buildCss } = await import('./css/build.ts')
373
+ const cssOutDir = path.join(process.cwd(), '.brust', 'css')
374
+ await buildCss({ entry: appCssPath, outDir: cssOutDir })
375
+ this.configureCssDir(cssOutDir)
376
+ configureCssEnabled(['/_brust/css/app.css'])
377
+ console.log(`[brust] main: built CSS → ${cssOutDir}/app.css`)
378
+ }
379
+ }
380
+
381
+ // Sub-project J — load .brust/jinja/*.jinja into the minijinja env so
382
+ // the startup-validation warning in registerRoutes can compare against
383
+ // a populated registry. Idempotent (Rust uses OnceLock).
384
+ loadJinjaOnce(path.resolve(process.cwd(), '.brust/jinja'))
385
+
386
+ this.registerRoutes(routes)
387
+ const ssePaths = routes
388
+ .filter((r) => r.chain[r.chain.length - 1].sse !== undefined)
389
+ .map((r) => r.fullPath)
390
+ if (ssePaths.length > 0) {
391
+ this.registerSsePaths(ssePaths)
392
+ console.log(
393
+ `[brust] main: registered ${ssePaths.length} sse path(s): ${ssePaths.join(', ')}`,
394
+ )
395
+ }
396
+ const wsPaths = routes
397
+ .filter((r) => r.chain[r.chain.length - 1].websocket !== undefined)
398
+ .map((r) => r.fullPath)
399
+ if (wsPaths.length > 0) {
400
+ this.registerWsPaths(wsPaths)
401
+ console.log(`[brust] main: registered ${wsPaths.length} ws path(s): ${wsPaths.join(', ')}`)
402
+ }
403
+ console.log(
404
+ `[brust] main: scanActions found ${actions.length} action(s): ${actions.map((a) => a.id).join(', ')}`,
405
+ )
406
+
407
+ if (dev) {
408
+ const { createWatcher } = await import('./dev/watcher.ts')
409
+ const { Coordinator } = await import('./dev/coordinator.ts')
410
+ const { broadcast } = await import('./dev/ws-channel.ts')
411
+ const { Tui } = await import('./dev/tui.ts')
412
+ const { terminateAll: termWorkers, spawnAll: spawnWorkers } = await import(
413
+ './dev/worker-registry.ts'
414
+ )
415
+ const fsModule = await import('node:fs')
416
+ const pathModule = await import('node:path')
417
+
418
+ const tui = new Tui({
419
+ isTty: process.stdout.isTTY === true,
420
+ write: (s: string) => process.stdout.write(s),
421
+ })
422
+ tui.updateStatus({ port, workers, watching: [scanRoot] })
423
+ tui.appendEvent(`▶ serving on http://127.0.0.1:${port}`)
424
+
425
+ const coordinator = new Coordinator({
426
+ workers: {
427
+ terminateAll: termWorkers,
428
+ spawnAll: async () => {
429
+ await spawnWorkers()
430
+ },
431
+ },
432
+ buildCss: async () => {
433
+ const appCss = pathModule.join(scanRoot, 'app.css')
434
+ if (fsModule.existsSync(appCss)) {
435
+ const { buildCss } = await import('./css/build.ts')
436
+ const outDir = pathModule.join(process.cwd(), '.brust', 'css')
437
+ await buildCss({ entry: appCss, outDir })
438
+ }
439
+ },
440
+ buildIslands: async () => {
441
+ const routesPath = pathModule.join(scanRoot, 'routes.tsx')
442
+ if (fsModule.existsSync(routesPath)) {
443
+ const { scanIslandChunks, buildIslands } = await import('./islands/build.ts')
444
+ const islandMap = scanIslandChunks(routesPath)
445
+ if (islandMap.size > 0) {
446
+ await buildIslands(islandMap)
447
+ }
448
+ }
449
+ },
450
+ buildComponentCss: async () => {
451
+ const { scanCssImports } = await import('./css/scan-imports.ts')
452
+ const scan = await scanCssImports(scanRoot)
453
+ if (scan.size === 0) return
454
+ const { buildComponentCss } = await import('./css/component-build.ts')
455
+ const { cssLoaderPlugin } = await import('./css/component-loader.ts')
456
+ const routeForCss = opts.routes.map((r) => ({
457
+ fullPath: r.fullPath,
458
+ componentSource: pathModule.join(scanRoot, 'routes.tsx'),
459
+ }))
460
+ const cssOutDir = pathModule.join(process.cwd(), '.brust', 'css')
461
+ const manifest = await buildComponentCss({
462
+ scanRoot,
463
+ outDir: cssOutDir,
464
+ tailwindCompile: null,
465
+ routes: routeForCss,
466
+ })
467
+ Bun.plugin(cssLoaderPlugin(manifest))
468
+ for (const [rp, hrefs] of Object.entries(manifest.routeChunks)) {
469
+ configureCssHrefsForRoute(rp, hrefs)
470
+ }
471
+ },
472
+ snapshotComponentCss: async () => {
473
+ const { readComponentCssManifest } = await import('./css/manifest.ts')
474
+ const p = pathModule.join(process.cwd(), '.brust', 'css', 'component-manifest.json')
475
+ return await readComponentCssManifest(p)
476
+ },
477
+ broadcast,
478
+ tui: { appendEvent: (l) => tui.appendEvent(l) },
479
+ })
480
+
481
+ createWatcher({
482
+ root: scanRoot,
483
+ onChange: (ev) => {
484
+ void coordinator.handleChange(ev)
485
+ },
486
+ })
487
+ }
488
+
489
+ let mcpManifest: import('./mcp/manifest.ts').McpManifest | null
490
+ if (prebuilt) {
491
+ const manifestPath = path.join(distDir!, 'mcp-manifest.json')
492
+ mcpManifest = await readManifestFromPath(manifestPath)
493
+ if (mcpManifest) {
494
+ console.log(
495
+ `[brust] main: loaded pre-built mcp manifest (${mcpManifest.tools.length} tools + ${mcpManifest.resources.length} resources)`,
496
+ )
497
+ }
498
+ } else {
499
+ mcpManifest = await this.buildMcpManifest({
500
+ serverFiles: sourceFiles,
501
+ routesFile: path.join(scanRoot, 'routes.tsx'),
502
+ sourceRoots: [scanRoot],
503
+ actions,
504
+ routes,
505
+ })
506
+ console.log(
507
+ `[brust] main: mcp manifest has ${mcpManifest.tools.length} tools + ${mcpManifest.resources.length} resources`,
508
+ )
509
+ }
510
+
511
+ await this.serve({
512
+ port,
513
+ workers,
514
+ entry: opts.entry,
515
+ actions,
516
+ ...(mcpManifest ? { mcp: { manifest: mcpManifest } } : {}),
517
+ ...opts.serve,
518
+ })
519
+ } else {
520
+ // Worker branch
521
+ let workerRoutes = opts.routes
522
+ if (dev) {
523
+ const { buildDevClientTag } = await import('./dev/client.ts')
524
+ const { configureDevClientSnippet } = await import('./dev/inject.ts')
525
+ configureDevClientSnippet(buildDevClientTag())
526
+ const { createDevWsRoute, installWorkerBroadcastListener } = await import(
527
+ './dev/ws-channel.ts'
528
+ )
529
+ installWorkerBroadcastListener()
530
+ const devRoute = createDevWsRoute()
531
+ workerRoutes = [
532
+ { ...devRoute, fullPath: devRoute.path!, chain: [devRoute as any] } as any,
533
+ ...opts.routes,
534
+ ]
535
+ }
536
+ // Worker: detect CSS the same way main did (no compile, no configureCssDir
537
+ // — Rust state is shared, but the per-worker renderer needs the hrefs).
538
+ if (prebuilt) {
539
+ if (existsSync(path.join(distDir!, 'css'))) {
540
+ configureCssEnabled(['/_brust/css/app.css'])
541
+ }
542
+ } else {
543
+ if (existsSync(path.join(scanRoot, 'app.css'))) {
544
+ configureCssEnabled(['/_brust/css/app.css'])
545
+ }
546
+ }
547
+
548
+ // Worker: register the component CSS Bun.plugin so .module.css imports
549
+ // resolve to the same hash map main saw. (Workers don't seed
550
+ // configureCssHrefsForRoute — that's a renderer concern only on the
551
+ // main thread.)
552
+ {
553
+ const { readComponentCssManifest } = await import('./css/manifest.ts')
554
+ const { cssLoaderPlugin } = await import('./css/component-loader.ts')
555
+ const manifestPath = prebuilt
556
+ ? path.join(distDir!, 'css', 'component-manifest.json')
557
+ : path.join(process.cwd(), '.brust', 'css', 'component-manifest.json')
558
+ const manifest = await readComponentCssManifest(manifestPath)
559
+ if (manifest) {
560
+ Bun.plugin(cssLoaderPlugin(manifest))
561
+ }
562
+ }
563
+
564
+ const sab = new SharedArrayBuffer(opts.sabBytes ?? 256 * 1024)
565
+ const view = new Uint8Array(sab)
566
+
567
+ let mcpManifest: import('./mcp/manifest.ts').McpManifest | null
568
+ if (prebuilt) {
569
+ const manifestPath = path.join(distDir!, 'mcp-manifest.json')
570
+ mcpManifest = await readManifestFromPath(manifestPath)
571
+ } else {
572
+ mcpManifest = await this.loadMcpManifest()
573
+ }
574
+ let mcpServer: import('./mcp/server.ts').McpServer | undefined
575
+ if (mcpManifest) {
576
+ const { makeMcpServer } = await import('./mcp/server.ts')
577
+ mcpServer = makeMcpServer({ manifest: mcpManifest, actions, routes: workerRoutes })
578
+ console.log(`[brust] worker: mcp server ready (${mcpManifest.tools.length} tools)`)
579
+ }
580
+
581
+ // Sub-project J note: jinja templates are loaded ONCE process-wide by the
582
+ // main branch's loadJinjaOnce call. Rust's ENV is a process-global
583
+ // OnceLock; Bun Workers share that process, so calling it from each
584
+ // worker would panic on second set(). The worker reads ENV.get() at
585
+ // napi_render_jinja time — no per-worker load needed.
586
+
587
+ const { makeRenderer: make } = await import('./routes.ts')
588
+ let wid: number | null = null
589
+ const renderer = make(workerRoutes, view, { actions, getWorkerId: () => wid, mcp: mcpServer })
590
+ wid = this.registerRenderer(view, renderer)
591
+ }
592
+ },
593
+ }
594
+
595
+ export { defineRoutes, makeRenderer, Outlet } from './routes.ts'
596
+ export type {
597
+ Route,
598
+ RouteCall,
599
+ RouteContext,
600
+ ErrorBoundaryProps,
601
+ RouteCacheConfig,
602
+ BrustRequest,
603
+ RouteResponse,
604
+ Middleware,
605
+ } from './routes.ts'
606
+
607
+ export { withMiddleware, isValidActionId } from './actions.ts'
608
+ export type { ActionDef, ActionFn } from './actions.ts'
609
+ export type { ScanOptions, ScanActionsResult } from './scan-actions.ts'
610
+
611
+ export { loadConfig, BrustConfigError } from './config.ts'
612
+ export type { BrustConfig } from './config.ts'
613
+
614
+ export { Island } from './islands/island.tsx'
615
+ export type { IslandProps, HydrateTrigger } from './islands/island.tsx'
616
+
617
+ export { buildIslands } from './islands/build.ts'
618
+ export type { IslandsBuildResult, BuildIslandsOptions } from './islands/build.ts'
@@ -0,0 +1,3 @@
1
+ // Test fixture for native-render T9 contained-failure: a module with NO
2
+ // default-exported component. Hits the `typeof Component !== 'function'` guard.
3
+ export const notDefault = 42
@@ -0,0 +1,7 @@
1
+ // Test fixture for native-render T9 ssr renderToString. A trivial island whose
2
+ // markup embeds its `n` prop, so a test can assert (a) it server-renders to
3
+ // `<span>N</span>` and (b) the value it received matches the roundtripped
4
+ // `_props`. createElement (no JSX) keeps the fixture transpile-trivial.
5
+ import { createElement } from 'react'
6
+
7
+ export default ({ n }: { n: number }) => createElement('span', null, String(n))
@@ -0,0 +1,9 @@
1
+ // Test fixture for native-render T9 contained-failure: a valid default-exported
2
+ // component that THROWS during render. This exercises the renderToString failure
3
+ // path (the production-realistic SSR failure), not just the
4
+ // `typeof Component !== 'function'` guard.
5
+ import type { createElement } from 'react'
6
+
7
+ export default function ThrowingIsland(): ReturnType<typeof createElement> {
8
+ throw new Error('boom in render')
9
+ }
@@ -0,0 +1,7 @@
1
+ // Bun 1.4 doesn't translate `export *` from a CommonJS module into ES named
2
+ // exports — the bundle ends up populating an internal object via __copyProps
3
+ // but emits no `export { ... }` statement, so the importmap-targeted browser
4
+ // import collapses to a SyntaxError. The CJS source (`node_modules/react-dom/
5
+ // client.js`) exposes only `createRoot` and `hydrateRoot`; name them
6
+ // explicitly so the bundler has a static export list to emit.
7
+ export { createRoot, hydrateRoot } from 'react-dom/client'
@@ -0,0 +1,11 @@
1
+ // Combined re-export. The browser's importmap maps BOTH `react` and
2
+ // `react/jsx-runtime` to the chunk built from this file. Browser fetches
3
+ // once; different import statements slice different named exports from
4
+ // the same module.
5
+ //
6
+ // `export *` from `react` includes Fragment; `react/jsx-runtime` also
7
+ // exports Fragment. We re-export only jsx + jsxs from jsx-runtime to
8
+ // avoid the name collision (Fragment from react wins, which is the
9
+ // same object).
10
+ export * from 'react'
11
+ export { jsx, jsxs } from 'react/jsx-runtime'