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,252 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { copyFile, mkdir, readdir, rm } from 'node:fs/promises'
3
+ import path, { isAbsolute, resolve } from 'node:path'
4
+ import {
5
+ actionsPrebuiltPlugin,
6
+ writePrebuiltActionsFileWithMap,
7
+ } from './actions-prebuilt-plugin.ts'
8
+ import { emitNativeTemplates } from './native-routes-emit.ts'
9
+ import { nativeShimPlugin } from './native-shim-plugin.ts'
10
+
11
+ /** repoRoot = the directory that contains runtime/. This file lives at
12
+ * runtime/cli/build.ts so two dirname() steps get us there. */
13
+ const REPO_ROOT = path.resolve(import.meta.dir, '..', '..')
14
+
15
+ interface ParsedArgs {
16
+ entry: string // absolute path to the entry file
17
+ outDir: string // absolute path to the output dir
18
+ }
19
+
20
+ function parseArgs(args: string[]): ParsedArgs {
21
+ let entry: string | undefined
22
+ let outDir: string | undefined
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ const a = args[i]
26
+ if (a === '--out-dir') {
27
+ outDir = args[++i]
28
+ if (!outDir) {
29
+ console.error('brust build: --out-dir requires a value')
30
+ process.exit(1)
31
+ }
32
+ } else if (a.startsWith('--out-dir=')) {
33
+ outDir = a.slice('--out-dir='.length)
34
+ } else if (a.startsWith('-')) {
35
+ console.error(`brust build: unknown flag "${a}"`)
36
+ process.exit(1)
37
+ } else if (entry === undefined) {
38
+ entry = a
39
+ } else {
40
+ console.error(`brust build: unexpected positional argument "${a}"`)
41
+ process.exit(1)
42
+ }
43
+ }
44
+
45
+ const cwd = process.cwd()
46
+ const entryPath = entry
47
+ ? isAbsolute(entry)
48
+ ? entry
49
+ : resolve(cwd, entry)
50
+ : resolve(cwd, 'index.ts')
51
+
52
+ if (!existsSync(entryPath)) {
53
+ console.error(`brust build: no entry file at ${entryPath}; pass a path or create ./index.ts`)
54
+ process.exit(1)
55
+ }
56
+
57
+ const outPath = outDir
58
+ ? isAbsolute(outDir)
59
+ ? outDir
60
+ : resolve(cwd, outDir)
61
+ : resolve(cwd, 'dist')
62
+
63
+ return { entry: entryPath, outDir: outPath }
64
+ }
65
+
66
+ export async function runBuild(args: string[]): Promise<void> {
67
+ const { entry, outDir } = parseArgs(args)
68
+ const entryDir = path.dirname(entry)
69
+
70
+ console.log(`[brust build] entry: ${entry}`)
71
+ console.log(`[brust build] outDir: ${outDir}`)
72
+
73
+ // 1. Clobber outDir.
74
+ await rm(outDir, { recursive: true, force: true })
75
+ await mkdir(outDir, { recursive: true })
76
+
77
+ // 2. Scan actions + rediscover id→source mapping for the prebuilt plugin.
78
+ const { scanActions, collectExports } = await import('../scan-actions.ts')
79
+ const scan = await scanActions({ roots: [entryDir] })
80
+ console.log(
81
+ `[brust build] actions: discovered ${scan.actions.length} (${scan.actions.map((a) => a.id).join(', ') || '(none)'})`,
82
+ )
83
+
84
+ const idToSource = new Map<string, string>()
85
+ for (const file of scan.sourceFiles) {
86
+ const defs = await collectExports(file)
87
+ for (const def of defs) idToSource.set(def.id, file)
88
+ }
89
+
90
+ // routes.tsx is the scan target for both islands (§3) and the MCP manifest
91
+ // (§4). Computed once here and reused below.
92
+ const routesFile = path.join(entryDir, 'routes.tsx')
93
+
94
+ // 3. Build islands (if any <Island> usage is found in the routes graph).
95
+ const { scanIslandChunks, buildIslands } = await import('../islands/build.ts')
96
+ const islandMap = existsSync(routesFile)
97
+ ? scanIslandChunks(routesFile)
98
+ : new Map<string, string>()
99
+ if (islandMap.size > 0) {
100
+ const islandsOutDir = path.join(outDir, 'islands')
101
+ const result = await buildIslands(islandMap, { outDir: islandsOutDir })
102
+ console.log(`[brust build] islands: ${result.islandCount} chunk(s) → ${islandsOutDir}`)
103
+ } else {
104
+ console.log('[brust build] islands: skipped (no <Island> usage)')
105
+ }
106
+
107
+ // 4. MCP manifest (if routes.tsx exists).
108
+ let loadedRoutes: any[] | undefined
109
+ if (existsSync(routesFile)) {
110
+ const { extractMcpManifest } = await import('../mcp/extractor.ts')
111
+ const { routes } = await import(routesFile)
112
+ loadedRoutes = routes
113
+ const manifest = await extractMcpManifest({
114
+ serverFiles: scan.sourceFiles,
115
+ routesFile,
116
+ sourceRoots: [entryDir],
117
+ actions: scan.actions,
118
+ routes,
119
+ })
120
+ const manifestPath = path.join(outDir, 'mcp-manifest.json')
121
+ await Bun.write(manifestPath, JSON.stringify(manifest, null, 2))
122
+ console.log(
123
+ `[brust build] mcp: ${manifest.tools.length} tools + ${manifest.resources.length} resources → ${manifestPath}`,
124
+ )
125
+ } else {
126
+ console.log(`[brust build] mcp: skipped (no routes.tsx)`)
127
+ }
128
+
129
+ // 4.1. Sub-project J — emit .brust/jinja/<Name>.jinja templates for every
130
+ // native: true route. Pipeline runs even if no native routes exist (writes
131
+ // an empty manifest) so consumers can rely on the output dir's presence.
132
+ {
133
+ // outDir must align with the runtime's loadJinjaOnce which reads from
134
+ // `process.cwd() + '.brust/jinja'`. Existing CSS pipeline uses cwd too
135
+ // (see boot log: "built CSS → <cwd>/.brust/css/app.css"). entryDir
136
+ // diverges when user runs `bun run dev <entry>` from a different dir;
137
+ // cwd is the single source of truth for both pipelines.
138
+ const jinjaDir = path.join(process.cwd(), '.brust/jinja')
139
+ // Spec §7 Component-source resolution: scan the routes module's source for
140
+ // ImportDeclarations, NOT the app entry's. The app entry only imports the
141
+ // routes module + brust; the page components are imported by routes.tsx.
142
+ // If routes.tsx doesn't exist (no routes module), we still write an empty
143
+ // manifest below — scanner falls back to passing a dummy path that produces
144
+ // an empty importMap (no native routes to emit anyway).
145
+ await emitNativeTemplates({
146
+ entryFile: existsSync(routesFile) ? routesFile : entry,
147
+ flatRoutes: (loadedRoutes ?? []) as { nativeTemplate?: string }[],
148
+ outDir: jinjaDir,
149
+ repoRoot: REPO_ROOT,
150
+ })
151
+ const nativeCount = (loadedRoutes ?? []).filter((r: any) => r?.nativeTemplate).length
152
+ console.log(`[brust build] jinja: ${nativeCount} template(s) → ${jinjaDir}`)
153
+ }
154
+
155
+ // 4.5. CSS — Tailwind v4 if app.css is present.
156
+ const appCssPath = path.join(entryDir, 'app.css')
157
+ if (existsSync(appCssPath)) {
158
+ const { buildCss } = await import('../css/build.ts')
159
+ const cssOutDir = path.join(outDir, 'css')
160
+ await buildCss({ entry: appCssPath, outDir: cssOutDir })
161
+ console.log(`[brust build] css: ${cssOutDir}/app.css`)
162
+ } else {
163
+ console.log(`[brust build] css: skipped (no app.css)`)
164
+ }
165
+
166
+ // 4.6. Component CSS — Lightning CSS + Modules.
167
+ {
168
+ const { scanCssImports } = await import('../css/scan-imports.ts')
169
+ const scan = await scanCssImports(entryDir)
170
+ if (scan.size > 0) {
171
+ const { buildComponentCss } = await import('../css/component-build.ts')
172
+ const routesFile = path.join(entryDir, 'routes.tsx')
173
+ let routeForCss: { fullPath: string; componentSource: string }[] = []
174
+ if (existsSync(routesFile)) {
175
+ try {
176
+ const { routes } = await import(routesFile)
177
+ routeForCss = (routes as any[]).map((r) => ({
178
+ fullPath: r.fullPath,
179
+ componentSource: routesFile,
180
+ }))
181
+ } catch {
182
+ /* if routes import fails, skip — manifest still emits modules */
183
+ }
184
+ }
185
+ const cssOutDir = path.join(outDir, 'css')
186
+ const manifest = await buildComponentCss({
187
+ scanRoot: entryDir,
188
+ outDir: cssOutDir,
189
+ tailwindCompile: null,
190
+ routes: routeForCss,
191
+ })
192
+ console.log(
193
+ `[brust build] css-mod: ${Object.keys(manifest.modules).length} chunk(s) → ${cssOutDir}/components/`,
194
+ )
195
+ } else {
196
+ console.log(`[brust build] css-mod: skipped (no component CSS imports)`)
197
+ }
198
+ }
199
+
200
+ // 5. Generate the prebuilt-actions file (always — empty list if no actions).
201
+ const prebuiltActionsPath = path.join(outDir, '_actions-prebuilt.ts')
202
+ await writePrebuiltActionsFileWithMap(prebuiltActionsPath, idToSource, REPO_ROOT)
203
+
204
+ // 6. Bun.build the server bundle with both plugins + banner.
205
+ const banner =
206
+ `process.env.BRUST_PREBUILT = '1';\n` + `process.env.BRUST_DIST_DIR = import.meta.dir;\n`
207
+
208
+ const result = await Bun.build({
209
+ entrypoints: [entry],
210
+ outdir: outDir,
211
+ naming: 'index.js',
212
+ target: 'bun',
213
+ format: 'esm',
214
+ // Preserve function/class identifiers. The Island component's chunk-id
215
+ // marker on the React path IS `Component.name`, so mangled names would
216
+ // point at non-existent files. Whitespace + syntax minification still apply.
217
+ minify: { whitespace: true, syntax: true, identifiers: false },
218
+ banner,
219
+ plugins: [nativeShimPlugin(REPO_ROOT), actionsPrebuiltPlugin(prebuiltActionsPath, REPO_ROOT)],
220
+ })
221
+
222
+ if (!result.success) {
223
+ console.error('brust build: Bun.build failed')
224
+ for (const m of result.logs) console.error(String(m))
225
+ process.exit(1)
226
+ }
227
+ console.log(`[brust build] bundle: ${path.join(outDir, 'index.js')}`)
228
+
229
+ // 7. Copy the current-platform native binary.
230
+ const nativeDir = path.join(outDir, 'native')
231
+ await mkdir(nativeDir, { recursive: true })
232
+
233
+ // napi-rs emits `runtime/brust.<platform>-<arch>[-libc].node` (binaryName
234
+ // "brust", from the root napi config). We copy every `brust.*.node` we find
235
+ // in runtime/ so a multi-platform pre-build (CI matrix) Just Works without
236
+ // further wiring; in single-platform local builds this is just one file.
237
+ const runtimeDir = path.join(REPO_ROOT, 'runtime')
238
+ const nodeFiles = (await readdir(runtimeDir)).filter((f) => /^brust\..+\.node$/.test(f))
239
+ if (nodeFiles.length === 0) {
240
+ console.error(
241
+ `brust build: no native binary found in ${runtimeDir}. ` +
242
+ `Run \`bun --filter runtime run build\` (or :debug) first.`,
243
+ )
244
+ process.exit(1)
245
+ }
246
+ for (const f of nodeFiles) {
247
+ await copyFile(path.join(runtimeDir, f), path.join(nativeDir, f))
248
+ console.log(`[brust build] native: ${f}`)
249
+ }
250
+
251
+ console.log(`[brust build] done.`)
252
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path, { dirname, isAbsolute, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { emitNativeTemplates } from './native-routes-emit.ts'
5
+
6
+ /** repoRoot = the directory that contains runtime/. This file lives at
7
+ * runtime/cli/dev.ts so two dirname() steps get us there. */
8
+ const REPO_ROOT = path.resolve(import.meta.dir, '..', '..')
9
+
10
+ interface ParsedArgs {
11
+ entry: string
12
+ port: number | undefined
13
+ }
14
+
15
+ function parseArgs(args: string[]): ParsedArgs {
16
+ let entry: string | undefined
17
+ let port: number | undefined
18
+ for (let i = 0; i < args.length; i++) {
19
+ const a = args[i]
20
+ if (a === '--port') {
21
+ const v = args[++i]
22
+ if (!v) {
23
+ console.error('brust dev: --port requires a value')
24
+ process.exit(1)
25
+ }
26
+ port = parseInt(v, 10)
27
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
28
+ console.error(`brust dev: invalid port ${v}`)
29
+ process.exit(1)
30
+ }
31
+ } else if (a.startsWith('--port=')) {
32
+ port = parseInt(a.slice('--port='.length), 10)
33
+ } else if (a.startsWith('-')) {
34
+ console.error(`brust dev: unknown flag ${a}`)
35
+ process.exit(1)
36
+ } else if (entry === undefined) {
37
+ entry = a
38
+ } else {
39
+ console.error(`brust dev: unexpected positional argument ${a}`)
40
+ process.exit(1)
41
+ }
42
+ }
43
+ const cwd = process.cwd()
44
+ const entryPath = entry
45
+ ? isAbsolute(entry)
46
+ ? entry
47
+ : resolve(cwd, entry)
48
+ : resolve(cwd, 'index.ts')
49
+ if (!existsSync(entryPath)) {
50
+ console.error(`brust dev: no entry file at ${entryPath}; pass a path or create ./index.ts`)
51
+ process.exit(1)
52
+ }
53
+ return { entry: entryPath, port }
54
+ }
55
+
56
+ export async function runDev(args: string[]): Promise<void> {
57
+ const { entry, port } = parseArgs(args)
58
+ process.env.BRUST_DEV = '1'
59
+ if (port !== undefined) process.env.BRUST_PORT = String(port)
60
+
61
+ // Sub-project J — emit .brust/jinja/<Name>.jinja templates BEFORE handing
62
+ // off to the user's entry. The runtime loads these on boot. Dev-mode HMR
63
+ // on .tsx edit is deferred per spec §12 (restart-to-reload for v2).
64
+ const entryDir = dirname(entry)
65
+ const routesFile = path.join(entryDir, 'routes.tsx')
66
+ let loadedRoutes: any[] = []
67
+ if (existsSync(routesFile)) {
68
+ try {
69
+ const mod = await import(routesFile)
70
+ loadedRoutes = mod.routes ?? []
71
+ } catch (err) {
72
+ console.warn(
73
+ `[brust dev] failed to pre-load routes for jinja emit: ${(err as Error).message}`,
74
+ )
75
+ }
76
+ }
77
+ // Spec §7 — scan routes.tsx (where page imports live), not the app entry.
78
+ // outDir = process.cwd() to align with the runtime's loadJinjaOnce which
79
+ // reads from `cwd + '.brust/jinja'`. When user runs `bun run dev
80
+ // example/hello-world/index.ts` from repo root, cwd != entryDir; writing
81
+ // to entryDir would put templates somewhere the runtime never looks.
82
+ await emitNativeTemplates({
83
+ entryFile: existsSync(routesFile) ? routesFile : entry,
84
+ flatRoutes: loadedRoutes as { nativeTemplate?: string }[],
85
+ outDir: path.join(process.cwd(), '.brust/jinja'),
86
+ repoRoot: REPO_ROOT,
87
+ })
88
+
89
+ // Hand off to the user's entry. It calls brust.run() which, with
90
+ // BRUST_DEV=1, enables dev wiring without requiring user edits.
91
+ await import(pathToFileURL(entry).href)
92
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bun
2
+ const [, , subcommand, ...rest] = process.argv
3
+
4
+ switch (subcommand) {
5
+ case 'build': {
6
+ const { runBuild } = await import('./build.ts')
7
+ await runBuild(rest)
8
+ break
9
+ }
10
+ case 'dev': {
11
+ const { runDev } = await import('./dev.ts')
12
+ await runDev(rest)
13
+ break
14
+ }
15
+ case 'new': {
16
+ const { runNew } = await import('./new.ts')
17
+ await runNew(rest)
18
+ break
19
+ }
20
+ default: {
21
+ if (!subcommand) {
22
+ console.error('brust: missing subcommand. Try: brust build | brust dev | brust new')
23
+ } else {
24
+ console.error(
25
+ `brust: unknown subcommand "${subcommand}". Try: brust build | brust dev | brust new`,
26
+ )
27
+ }
28
+ process.exit(1)
29
+ }
30
+ }
@@ -0,0 +1,171 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
4
+
5
+ /** Sub-project J — build pass that turns user's `pages/<Name>.tsx` files into
6
+ * `.brust/jinja/<Name>.jinja` templates. Invoked from `brust build` and
7
+ * `brust dev` after the user's routes are flattened.
8
+ *
9
+ * Limitations (spec §7 + §13.10):
10
+ * - Regex-based import scanner — handles `import Name from './path'` only.
11
+ * Full swc AST + re-export chain support deferred to v2.x.
12
+ * - Dev mode does NOT hot-reload templates on .tsx edit. Boot-only; restart
13
+ * required. Deferred per spec §12.
14
+ */
15
+
16
+ export interface NativeRouteEmitOpts {
17
+ /** User's routes entry file (absolute path). Scanned for ImportDeclarations
18
+ * to resolve each native: true route's Component to its source .tsx. */
19
+ entryFile: string
20
+ /** Flat routes array; only entries with `nativeTemplate` are emitted. */
21
+ flatRoutes: { nativeTemplate?: string }[]
22
+ /** `.brust/jinja` absolute output dir. Created if missing. */
23
+ outDir: string
24
+ /** Repo root — used to resolve the `jsx-rustc` binary. */
25
+ repoRoot: string
26
+ }
27
+
28
+ /** One entry in a `<Name>.islands.json` as emitted by `jsx-rustc` (camelCase,
29
+ * see crates/jsx-rust-compiler/src/lib.rs). Enriched with `sourcePath`. */
30
+ interface RawIslandEntry {
31
+ component: string
32
+ instance: number
33
+ propsPath: string
34
+ ssr: boolean
35
+ hydrate: string
36
+ }
37
+ interface EnrichedIslandEntry extends RawIslandEntry {
38
+ /** Absolute path to the island's client source, resolved from the page's
39
+ * own `import <component> from "..."` declaration. */
40
+ sourcePath: string
41
+ }
42
+
43
+ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<void> {
44
+ mkdirSync(opts.outDir, { recursive: true })
45
+
46
+ const nativeRoutes = opts.flatRoutes.filter((r) => r.nativeTemplate)
47
+
48
+ // Resolve jsx-rustc binary: prefer release, fall back to debug.
49
+ const jsxRustcRelease = resolve(opts.repoRoot, 'target/release/jsx-rustc')
50
+ const jsxRustcDebug = resolve(opts.repoRoot, 'target/debug/jsx-rustc')
51
+ const jsxRustc = existsSync(jsxRustcRelease)
52
+ ? jsxRustcRelease
53
+ : existsSync(jsxRustcDebug)
54
+ ? jsxRustcDebug
55
+ : null
56
+
57
+ if (!jsxRustc && nativeRoutes.length > 0) {
58
+ throw new Error(
59
+ 'jsx-rustc binary not found in target/{release,debug}/; ' +
60
+ 'run `cargo build -p jsx-rust-compiler --bin jsx-rustc`',
61
+ )
62
+ }
63
+
64
+ const importMap =
65
+ nativeRoutes.length > 0 ? scanImports(opts.entryFile) : new Map<string, string>()
66
+
67
+ const built: string[] = []
68
+ for (const r of nativeRoutes) {
69
+ const name = r.nativeTemplate!
70
+ const sourcePath = importMap.get(name)
71
+ if (!sourcePath) {
72
+ console.warn(
73
+ `[brust build] no import for native route "${name}" in ${opts.entryFile}; skipping`,
74
+ )
75
+ continue
76
+ }
77
+ const outPath = resolve(opts.outDir, `${name}.jinja`)
78
+ const result = Bun.spawnSync({
79
+ cmd: [jsxRustc!, sourcePath, '-o', outPath],
80
+ stdout: 'pipe',
81
+ stderr: 'pipe',
82
+ })
83
+ if (result.exitCode !== 0) {
84
+ const stderr = result.stderr ? new TextDecoder().decode(result.stderr) : ''
85
+ const stdout = result.stdout ? new TextDecoder().decode(result.stdout) : ''
86
+ throw new Error(`jsx-rustc failed for ${sourcePath}:\n${stdout}\n${stderr}`)
87
+ }
88
+ built.push(name)
89
+
90
+ // Islands post-processing. jsx-rustc writes `<Name>.islands.json` ONLY when
91
+ // the route uses <Island>; absent file ⇒ no islands ⇒ leave the .jinja
92
+ // byte-identical (no-island regression).
93
+ const islandsJsonPath = resolve(opts.outDir, `${name}.islands.json`)
94
+ if (existsSync(islandsJsonPath)) {
95
+ // Island source paths resolve from the PAGE file's own imports.
96
+ const pageImports = scanImports(sourcePath)
97
+ reconcileIslandManifest(outPath, islandsJsonPath, pageImports, name)
98
+ }
99
+ }
100
+
101
+ writeFileSync(
102
+ resolve(opts.outDir, '_manifest.json'),
103
+ JSON.stringify({ templates: built, generatedAt: new Date().toISOString() }, null, 2),
104
+ )
105
+ }
106
+
107
+ /** Scan the entry file's `import Name from './path'` declarations and build a
108
+ * map of localName -> resolved absolute path. Extension resolution tries
109
+ * `.tsx`, `.ts`, `/index.tsx`, `/index.ts` in order. */
110
+ export function scanImports(entryFile: string): Map<string, string> {
111
+ const source = readFileSync(entryFile, 'utf8')
112
+ const map = new Map<string, string>()
113
+ // Regex-based scanner; full swc AST scan deferred per spec §7 + §13.10.
114
+ const re = /^import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/gm
115
+ for (let m = re.exec(source); m !== null; m = re.exec(source)) {
116
+ const localName = m[1]!
117
+ const importPath = m[2]!
118
+ if (!importPath.startsWith('.')) continue // skip package imports
119
+ const baseDir = dirname(entryFile)
120
+ const resolved = resolve(baseDir, importPath)
121
+ const candidates = [
122
+ `${resolved}.tsx`,
123
+ `${resolved}.ts`,
124
+ `${resolved}/index.tsx`,
125
+ `${resolved}/index.ts`,
126
+ ]
127
+ const found = candidates.find((p) => existsSync(p))
128
+ if (found) map.set(localName, found)
129
+ }
130
+ return map
131
+ }
132
+
133
+ /** Reconcile the raw `<Name>.islands.json` jsx-rustc emitted against the page's
134
+ * own imports, then bake the importmap+bootstrap into the `.jinja`.
135
+ *
136
+ * Pure-ish & synchronous (fs only) so it unit-tests deterministically:
137
+ * 1. If `islandsJsonPath` is absent → no-op (the route has no islands; the
138
+ * `.jinja` stays byte-identical).
139
+ * 2. Resolve every entry's `sourcePath` from the page's `import <component>
140
+ * from "..."` (else throw).
141
+ * 3. Enrich each entry with that absolute `sourcePath` and rewrite the
142
+ * `.islands.json`.
143
+ * 4. Append `{% raw %}…{% endraw %}`-wrapped bootstrap to the `.jinja`. The raw
144
+ * block keeps the importmap's literal `}}`/`{{` inert through minijinja's
145
+ * boot-time compile.
146
+ */
147
+ export function reconcileIslandManifest(
148
+ jinjaPath: string,
149
+ islandsJsonPath: string,
150
+ pageImports: Map<string, string>,
151
+ routeName: string,
152
+ ): void {
153
+ if (!existsSync(islandsJsonPath)) return
154
+
155
+ const raw = JSON.parse(readFileSync(islandsJsonPath, 'utf8')) as RawIslandEntry[]
156
+
157
+ const enriched: EnrichedIslandEntry[] = raw.map((entry) => {
158
+ const sourcePath = pageImports.get(entry.component)
159
+ if (!sourcePath) {
160
+ throw new Error(
161
+ `island component "${entry.component}" in native route "${routeName}" has no matching import in the page source (expected \`import ${entry.component} from "..."\`)`,
162
+ )
163
+ }
164
+ return { ...entry, sourcePath }
165
+ })
166
+
167
+ writeFileSync(islandsJsonPath, JSON.stringify(enriched))
168
+
169
+ const baked = `{% raw %}${ISLANDS_IMPORTMAP_AND_BOOTSTRAP}{% endraw %}`
170
+ writeFileSync(jinjaPath, readFileSync(jinjaPath, 'utf8') + baked)
171
+ }
@@ -0,0 +1,85 @@
1
+ import { resolve } from 'node:path'
2
+ import type { BunPlugin } from 'bun'
3
+
4
+ /** Bun.build plugin that replaces `runtime/index.js` (the napi-rs platform
5
+ * shim, 469 lines of conditional require()s) with a single shim that resolves
6
+ * the native binary from `BRUST_DIST_DIR/native/brust.<platform>-<arch>[-libc].node`.
7
+ *
8
+ * The shim relies on the bundle banner having set BRUST_DIST_DIR; if that env
9
+ * is missing (shouldn't happen post-build) it falls back to import.meta.dir.
10
+ *
11
+ * Linux: napi emits a libc-suffixed name (brust.linux-x64-gnu.node / -musl);
12
+ * darwin/win have no suffix. The shim detects musl-vs-glibc (via process.report,
13
+ * the same signal the full napi loader uses) to order candidates, then falls
14
+ * back to the other libc so a single-platform dist still loads even if detection
15
+ * is off. */
16
+ export function nativeShimPlugin(repoRoot: string): BunPlugin {
17
+ const targetPath = resolve(repoRoot, 'runtime/index.js')
18
+
19
+ const SHIM = `
20
+ import { createRequire } from 'node:module'
21
+ import { join } from 'node:path'
22
+
23
+ const require_ = createRequire(import.meta.url)
24
+ const { platform, arch } = process
25
+ const dir = process.env.BRUST_DIST_DIR ?? import.meta.dir
26
+
27
+ // Detect musl via process.report: glibc runtimes report glibcVersionRuntime in
28
+ // the report header, musl does not. Mirrors the full napi loader's signal.
29
+ function linuxIsMusl() {
30
+ try {
31
+ if (typeof process.report?.getReport === 'function') {
32
+ process.report.excludeNetwork = true
33
+ const report = process.report.getReport()
34
+ return !(report && report.header && report.header.glibcVersionRuntime)
35
+ }
36
+ } catch {}
37
+ return false
38
+ }
39
+
40
+ // Candidate binary names in load order. Linux is libc-suffixed (detected libc
41
+ // first, the other as fallback); darwin/win are bare platform-arch.
42
+ let candidates
43
+ if (platform === 'linux') {
44
+ const libc = linuxIsMusl() ? 'musl' : 'gnu'
45
+ const other = libc === 'musl' ? 'gnu' : 'musl'
46
+ candidates = [\`brust.linux-\${arch}-\${libc}.node\`, \`brust.linux-\${arch}-\${other}.node\`]
47
+ } else {
48
+ candidates = [\`brust.\${platform}-\${arch}.node\`]
49
+ }
50
+
51
+ let nativeBinding
52
+ const tried = []
53
+ for (const name of candidates) {
54
+ const absPath = join(dir, 'native', name)
55
+ try {
56
+ nativeBinding = require_(absPath)
57
+ break
58
+ } catch (cause) {
59
+ tried.push(\`\${absPath} (\${cause && cause.message ? cause.message : cause})\`)
60
+ }
61
+ }
62
+ if (!nativeBinding) {
63
+ throw new Error(
64
+ \`brust: no native binary for \${platform}-\${arch}. Tried:\\n \` +
65
+ tried.join('\\n ') +
66
+ \`\\nRun \\\`brust build\\\` on the target platform.\`,
67
+ )
68
+ }
69
+
70
+ module.exports = nativeBinding
71
+ `.trim()
72
+
73
+ return {
74
+ name: 'brust-native-shim',
75
+ setup(build) {
76
+ build.onLoad({ filter: /.*runtime[\\/]index\.js$/ }, (args) => {
77
+ // Only rewrite the canonical napi-rs shim; ignore any same-named file
78
+ // elsewhere in node_modules (Bun resolves real paths, so this guard
79
+ // is just belt-and-braces).
80
+ if (args.path !== targetPath) return undefined
81
+ return { contents: SHIM, loader: 'js' }
82
+ })
83
+ },
84
+ }
85
+ }