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.
- package/README.md +110 -0
- package/package.json +92 -0
- package/runtime/actions.ts +65 -0
- package/runtime/bun.lock +236 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
- package/runtime/cli/build.ts +252 -0
- package/runtime/cli/dev.ts +92 -0
- package/runtime/cli/index.ts +30 -0
- package/runtime/cli/native-routes-emit.ts +171 -0
- package/runtime/cli/native-shim-plugin.ts +85 -0
- package/runtime/cli/new.ts +208 -0
- package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
- package/runtime/cli/templates/minimal/_gitignore +4 -0
- package/runtime/cli/templates/minimal/app.css +6 -0
- package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
- package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
- package/runtime/cli/templates/minimal/index.ts +4 -0
- package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
- package/runtime/cli/templates/minimal/routes.tsx +6 -0
- package/runtime/cli/templates/minimal/tsconfig.json +20 -0
- package/runtime/client/index.ts +121 -0
- package/runtime/config.ts +148 -0
- package/runtime/css/build.ts +54 -0
- package/runtime/css/component-build.ts +78 -0
- package/runtime/css/component-loader.ts +27 -0
- package/runtime/css/manifest.ts +51 -0
- package/runtime/css/process-modules.ts +56 -0
- package/runtime/css/route-deps.ts +33 -0
- package/runtime/css/scan-imports.ts +79 -0
- package/runtime/css.ts +39 -0
- package/runtime/dev/client.ts +49 -0
- package/runtime/dev/coordinator.ts +127 -0
- package/runtime/dev/inject.ts +17 -0
- package/runtime/dev/tui.ts +109 -0
- package/runtime/dev/watcher.ts +109 -0
- package/runtime/dev/worker-registry.ts +96 -0
- package/runtime/dev/ws-channel.ts +99 -0
- package/runtime/index.d.ts +199 -0
- package/runtime/index.js +604 -0
- package/runtime/index.ts +618 -0
- package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
- package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
- package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
- package/runtime/islands/_entries/react-dom.ts +7 -0
- package/runtime/islands/_entries/react.ts +11 -0
- package/runtime/islands/bootstrap.ts +241 -0
- package/runtime/islands/build.ts +141 -0
- package/runtime/islands/importmap.ts +17 -0
- package/runtime/islands/island.tsx +58 -0
- package/runtime/islands/native-render.ts +153 -0
- package/runtime/mcp/extractor.ts +160 -0
- package/runtime/mcp/manifest.ts +50 -0
- package/runtime/mcp/schema.ts +124 -0
- package/runtime/mcp/server.ts +250 -0
- package/runtime/render/inject-css-link.ts +59 -0
- package/runtime/render/inject-dev-client.ts +49 -0
- package/runtime/render/stream.ts +304 -0
- package/runtime/routes.ts +1406 -0
- package/runtime/scan-actions.ts +172 -0
- package/runtime/sse/handler.ts +85 -0
- package/runtime/tsconfig.json +14 -0
- package/runtime/ws/handler.ts +151 -0
package/runtime/index.ts
ADDED
|
@@ -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,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'
|