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
|
@@ -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
|
+
}
|