brustjs 0.1.46-alpha → 0.1.48-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.46-alpha",
3
+ "version": "0.1.48-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,12 +41,12 @@
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "optionalDependencies": {
44
- "brustjs-darwin-x64": "0.1.46-alpha",
45
- "brustjs-darwin-arm64": "0.1.46-alpha",
46
- "brustjs-linux-x64-gnu": "0.1.46-alpha",
47
- "brustjs-linux-arm64-gnu": "0.1.46-alpha",
48
- "brustjs-linux-x64-musl": "0.1.46-alpha",
49
- "brustjs-linux-arm64-musl": "0.1.46-alpha"
44
+ "brustjs-darwin-x64": "0.1.48-alpha",
45
+ "brustjs-darwin-arm64": "0.1.48-alpha",
46
+ "brustjs-linux-x64-gnu": "0.1.48-alpha",
47
+ "brustjs-linux-arm64-gnu": "0.1.48-alpha",
48
+ "brustjs-linux-x64-musl": "0.1.48-alpha",
49
+ "brustjs-linux-arm64-musl": "0.1.48-alpha"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^19.2.6",
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { copyFile, cp, mkdir, readdir, rm } from 'node:fs/promises'
2
+ import { copyFile, cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { createRequire } from 'node:module'
4
+ import { tmpdir } from 'node:os'
4
5
  import path, { isAbsolute, resolve } from 'node:path'
5
6
  import type { BunPlugin } from 'bun'
6
7
  import { emitNativeTemplates } from './native-routes-emit.ts'
@@ -144,6 +145,7 @@ export interface ParsedArgs {
144
145
  target: string // --target value (default 'auto')
145
146
  ssg: boolean // --ssg — prerender static routes after the build
146
147
  ssgOut: string | null // --ssg-out value (absolute); null → <outDir>/static computed later
148
+ generatorVersion: boolean // false ⇔ --no-generator-version (name-only generator tag)
147
149
  }
148
150
 
149
151
  /** Parse `brust build` argv. Pure (no fs access, no process.exit) so it's
@@ -154,6 +156,7 @@ export function parseArgs(args: string[]): ParsedArgs {
154
156
  let target = 'auto'
155
157
  let ssg = false
156
158
  let ssgOut: string | undefined
159
+ let generatorVersion = true
157
160
 
158
161
  for (let i = 0; i < args.length; i++) {
159
162
  const a = args[i]
@@ -176,6 +179,8 @@ export function parseArgs(args: string[]): ParsedArgs {
176
179
  }
177
180
  } else if (a === '--ssg') {
178
181
  ssg = true
182
+ } else if (a === '--no-generator-version') {
183
+ generatorVersion = false
179
184
  } else if (a === '--ssg-out') {
180
185
  ssgOut = args[++i]
181
186
  if (!ssgOut) {
@@ -213,7 +218,7 @@ export function parseArgs(args: string[]): ParsedArgs {
213
218
  }
214
219
  const ssgOutPath = ssgOut ? (isAbsolute(ssgOut) ? ssgOut : resolve(cwd, ssgOut)) : null
215
220
 
216
- return { entry: entryPath, outDir: outPath, target, ssg, ssgOut: ssgOutPath }
221
+ return { entry: entryPath, outDir: outPath, target, ssg, ssgOut: ssgOutPath, generatorVersion }
217
222
  }
218
223
 
219
224
  export async function runBuild(args: string[]): Promise<void> {
@@ -321,6 +326,22 @@ export async function runBuild(args: string[]): Promise<void> {
321
326
  // the same dual-emit the jinja mirror below does). Strict no-op without md
322
327
  // routes: no files, no dirs, byte-identical dist.
323
328
  const jinjaDir = path.join(outDir, 'jinja')
329
+
330
+ // Generator tag decision — baked at build time into BOTH jinja dirs so every
331
+ // runtime path (prebuilt dist reads <distDir>/jinja; dev/source reads
332
+ // .brust/jinja) picks up the same artifact. Runs unconditionally: even a
333
+ // React-only app with zero native/md routes needs the artifact so the React
334
+ // stream injector and X-Powered-By thread share the same decision.
335
+ // The .brust/jinja write is defense-in-depth: the 4.1 mirror cp below
336
+ // overwrites it with identical content, but this write must NOT be removed —
337
+ // it keeps the artifact correct even if that cp ever becomes conditional.
338
+ {
339
+ const { generatorStrings, writeGeneratorArtifact } = await import('../generator.ts')
340
+ const gen = generatorStrings(parsed.generatorVersion)
341
+ writeGeneratorArtifact(jinjaDir, gen)
342
+ writeGeneratorArtifact(path.join(process.cwd(), '.brust', 'jinja'), gen)
343
+ }
344
+
324
345
  let mdIslands = new Map<string, string>()
325
346
  if (existsSync(routesFile) && loadedRoutes !== undefined) {
326
347
  const { emitMdArtifacts } = await import('../md/emit.ts')
@@ -345,7 +366,17 @@ export async function runBuild(args: string[]): Promise<void> {
345
366
  const islandMap = existsSync(routesFile)
346
367
  ? scanIslandChunks(routesFile, mdIslands)
347
368
  : new Map<string, string>()
348
- if (islandMap.size > 0) {
369
+ // `fallback: 'client'` SSG routes need the islands RUNTIME (_bootstrap.js
370
+ // drives the client takeover; _react*.js back the fallback chunk's
371
+ // externals) even when the app ships ZERO islands — buildIslands with an
372
+ // empty map emits exactly those runtime files. Without this, the fallback
373
+ // shell references a /_brust/islands/_bootstrap.js that was never built.
374
+ const needsFallbackRuntime =
375
+ parsed.ssg &&
376
+ ((loadedRoutes ?? []) as { chain?: Array<{ ssg?: { fallback?: string } }> }[]).some(
377
+ (r) => r.chain?.at(-1)?.ssg?.fallback === 'client',
378
+ )
379
+ if (islandMap.size > 0 || needsFallbackRuntime) {
349
380
  const islandsOutDir = path.join(outDir, 'islands')
350
381
  const result = await buildIslands(islandMap, {
351
382
  outDir: islandsOutDir,
@@ -570,22 +601,108 @@ export async function runBuild(args: string[]): Promise<void> {
570
601
  console.log(`[brust build] native: ${name}`)
571
602
  }
572
603
 
604
+ // 7.5. SSG fallback chunks (--ssg only): for every route whose LEAF declares
605
+ // `ssg.fallback: 'client'`, bundle a browser chunk that re-exports the leaf
606
+ // component + its `clientLoader` (generated entry → islands buildOne, react
607
+ // externals, content-addressed `Fallback_<Name>_<hash>.js`). Constraint
608
+ // (documented in the spec): the leaf Component must be a DEFAULT import in
609
+ // routes.tsx (scanImports resolution), and its file must export clientLoader.
610
+ // Step 8 hands these to exportStatic (shell/payload crawl + manifest + 404).
611
+ const ssgFallbacks: Array<{ pattern: string; chunk: string }> = []
612
+ if (parsed.ssg) {
613
+ const fallbackRoutes = (
614
+ (loadedRoutes ?? []) as {
615
+ fullPath: string
616
+ chain?: Array<{ ssg?: { fallback?: string }; Component?: { name?: string } }>
617
+ }[]
618
+ ).filter((r) => r.chain?.at(-1)?.ssg?.fallback === 'client')
619
+ if (fallbackRoutes.length > 0) {
620
+ const { fallbackEntrySource, hasClientLoaderExport } = await import('./ssg.ts')
621
+ const { scanImports } = await import('./native-routes-emit.ts')
622
+ const { buildOne } = await import('../islands/build.ts')
623
+ const { islandChunkBasename } = await import('../islands/chunk-id.ts')
624
+ const importMap = scanImports(routesFile)
625
+ const islandsOutDir = path.join(outDir, 'islands')
626
+ await mkdir(islandsOutDir, { recursive: true })
627
+ // Temp dir for the generated entry modules; removed after the bundles land.
628
+ // Error paths THROW (not process.exit) so the finally's cleanup runs —
629
+ // exit(1) inside the try would skip it and leak the temp dir; the catch
630
+ // below owns stderr + exit.
631
+ const entryTmp = await mkdtemp(path.join(tmpdir(), 'brust-fallback-'))
632
+ try {
633
+ for (const [i, route] of fallbackRoutes.entries()) {
634
+ const name = route.chain?.at(-1)?.Component?.name
635
+ const source = name ? importMap.get(name) : undefined
636
+ if (!name || !source) {
637
+ throw new Error(
638
+ `[brust build] ssg: fallback 'client' on "${route.fullPath}": leaf Component must be a DEFAULT import in the routes file`,
639
+ )
640
+ }
641
+ const text = await readFile(source, 'utf8')
642
+ if (!hasClientLoaderExport(text)) {
643
+ throw new Error(
644
+ `[brust build] ssg: fallback 'client' on "${route.fullPath}": ` +
645
+ `${path.relative(process.cwd(), source)} must \`export const clientLoader\``,
646
+ )
647
+ }
648
+ const file = `Fallback_${islandChunkBasename(name, source)}.js`
649
+ // Index-suffixed entry name: two fallback routes may share a component
650
+ // NAME (different files) — the chunk name is already content-addressed.
651
+ const entryPath = path.join(entryTmp, `${name}_${i}.entry.ts`)
652
+ await writeFile(entryPath, fallbackEntrySource(source))
653
+ try {
654
+ await buildOne(
655
+ [entryPath],
656
+ islandsOutDir,
657
+ file,
658
+ ['react', 'react/jsx-runtime', 'react-dom/client'],
659
+ cssBuildPlugins,
660
+ )
661
+ } catch (err) {
662
+ // Browser-safety convention: server-only deps at the component file's
663
+ // top level surface here as bundle errors.
664
+ throw new Error(
665
+ `[brust build] ssg: fallback chunk for "${route.fullPath}": ${err instanceof Error ? err.message : String(err)}`,
666
+ )
667
+ }
668
+ ssgFallbacks.push({ pattern: route.fullPath, chunk: `/_brust/islands/${file}` })
669
+ }
670
+ } catch (err) {
671
+ await rm(entryTmp, { recursive: true, force: true })
672
+ console.error(err instanceof Error ? err.message : String(err))
673
+ process.exit(1)
674
+ }
675
+ await rm(entryTmp, { recursive: true, force: true })
676
+ }
677
+ }
678
+
573
679
  // 8. SSG export (--ssg): boot the just-built dist once and crawl every
574
680
  // statically-renderable route into <--ssg-out | outDir/static>. Reuses the
575
681
  // routes module ALREADY loaded for the MCP/css steps (loadedRoutes) — no
576
682
  // second import. Without the flag this is a strict no-op.
577
683
  if (parsed.ssg) {
578
- const { collectStaticPaths, exportStatic } = await import('./ssg.ts')
579
- const decisions = collectStaticPaths(
580
- (loadedRoutes ?? []) as Parameters<typeof collectStaticPaths>[0],
581
- )
684
+ const { collectStaticPaths, expandDynamicRoutes, exportStatic } = await import('./ssg.ts')
685
+ // Initialized to [] so the post-catch read type-checks even where
686
+ // process.exit isn't narrowed to `never` (exit(1) still prevents use).
687
+ let expanded: Awaited<ReturnType<typeof expandDynamicRoutes>> = []
688
+ try {
689
+ expanded = await expandDynamicRoutes(
690
+ (loadedRoutes ?? []) as Parameters<typeof expandDynamicRoutes>[0],
691
+ )
692
+ } catch (err) {
693
+ console.error(`[brust build] ssg: ${err instanceof Error ? err.message : String(err)}`)
694
+ process.exit(1)
695
+ }
696
+ const expandedCount = expanded.length - (loadedRoutes ?? []).length
697
+ const decisions = collectStaticPaths(expanded)
582
698
  const staticOut = parsed.ssgOut ?? path.join(outDir, 'static')
583
699
  try {
584
- const { written, navWritten, skipped } = await exportStatic({
700
+ const { written, navWritten, fallbackWritten, skipped } = await exportStatic({
585
701
  distDir: outDir,
586
702
  entryDir,
587
703
  staticOut,
588
704
  routes: decisions,
705
+ fallbacks: ssgFallbacks,
589
706
  })
590
707
  const counts = new Map<string, number>()
591
708
  for (const s of skipped) {
@@ -597,9 +714,17 @@ export async function runBuild(args: string[]): Promise<void> {
597
714
  .map((r) => `${r}=${counts.get(r)}`)
598
715
  .join(', ')
599
716
  const skippedDesc = skipped.length > 0 ? ` (skipped ${skipped.length}: ${reasons})` : ''
717
+ const expandedDesc = expandedCount > 0 ? `, expanded ${expandedCount} dynamic page(s)` : ''
718
+ const fallbackDesc =
719
+ fallbackWritten.length > 0 ? `, ${fallbackWritten.length} fallback file(s)` : ''
600
720
  console.log(
601
- `[brust build] ssg: ${written.length} pages + ${navWritten.length} spa payloads → ${staticOut}${skippedDesc}`,
721
+ `[brust build] ssg: ${written.length} pages + ${navWritten.length} spa payloads${expandedDesc}${fallbackDesc} → ${staticOut}${skippedDesc}`,
602
722
  )
723
+ for (const f of ssgFallbacks) {
724
+ console.log(
725
+ `[brust build] ssg: fallback chunk ${path.basename(f.chunk)} (${f.pattern})`,
726
+ )
727
+ }
603
728
  } catch (err) {
604
729
  console.error(`[brust build] ssg: ${err instanceof Error ? err.message : String(err)}`)
605
730
  process.exit(1)
@@ -10,11 +10,13 @@ const REPO_ROOT = path.resolve(import.meta.dir, '..', '..')
10
10
  interface ParsedArgs {
11
11
  entry: string
12
12
  port: number | undefined
13
+ generatorVersion: boolean
13
14
  }
14
15
 
15
16
  function parseArgs(args: string[]): ParsedArgs {
16
17
  let entry: string | undefined
17
18
  let port: number | undefined
19
+ let generatorVersion = true
18
20
  for (let i = 0; i < args.length; i++) {
19
21
  const a = args[i]
20
22
  if (a === '--port') {
@@ -28,6 +30,8 @@ function parseArgs(args: string[]): ParsedArgs {
28
30
  console.error(`brust dev: invalid port ${v}`)
29
31
  process.exit(1)
30
32
  }
33
+ } else if (a === '--no-generator-version') {
34
+ generatorVersion = false
31
35
  } else if (a.startsWith('--port=')) {
32
36
  port = parseInt(a.slice('--port='.length), 10)
33
37
  } else if (a.startsWith('-')) {
@@ -50,14 +54,22 @@ function parseArgs(args: string[]): ParsedArgs {
50
54
  console.error(`brust dev: no entry file at ${entryPath}; pass a path or create ./index.ts`)
51
55
  process.exit(1)
52
56
  }
53
- return { entry: entryPath, port }
57
+ return { entry: entryPath, port, generatorVersion }
54
58
  }
55
59
 
56
60
  export async function runDev(args: string[]): Promise<void> {
57
- const { entry, port } = parseArgs(args)
61
+ const { entry, port, generatorVersion } = parseArgs(args)
58
62
  process.env.BRUST_DEV = '1'
59
63
  if (port !== undefined) process.env.BRUST_PORT = String(port)
60
64
 
65
+ // Bake the generator decision BEFORE the first emit — emitters and the boot
66
+ // re-emit paths all resolve <cwd>/.brust/jinja/generator.json internally.
67
+ const { generatorStrings, writeGeneratorArtifact } = await import('../generator.ts')
68
+ writeGeneratorArtifact(
69
+ path.join(process.cwd(), '.brust', 'jinja'),
70
+ generatorStrings(generatorVersion),
71
+ )
72
+
61
73
  // Sub-project J — emit .brust/jinja/<Name>.jinja templates BEFORE handing
62
74
  // off to the user's entry. The runtime loads these on boot. Dev-mode HMR
63
75
  // on .tsx edit is deferred per spec S12 (restart-to-reload for v2).
@@ -61,6 +61,10 @@ export const COMMANDS: CommandDef[] = [
61
61
  flag: '--ssg-out <dir>',
62
62
  desc: 'Output directory for prerendered HTML (default <out-dir>/static)',
63
63
  },
64
+ {
65
+ flag: '--no-generator-version',
66
+ desc: 'Drop the version from the generator meta tag + X-Powered-By header (the name stays)',
67
+ },
64
68
  ],
65
69
  notes: [
66
70
  'Markdown pages: routes mounted with mdRoutes(<contentDir>) compile to native',
@@ -75,6 +79,10 @@ export const COMMANDS: CommandDef[] = [
75
79
  flags: [
76
80
  { flag: '[entry]', desc: 'Entry file (default ./index.ts)' },
77
81
  { flag: '--port <n>', desc: 'Port to listen on' },
82
+ {
83
+ flag: '--no-generator-version',
84
+ desc: 'Drop the version from the generator meta tag + X-Powered-By header (the name stays)',
85
+ },
78
86
  ],
79
87
  },
80
88
  {
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
2
2
  import { createRequire } from 'node:module'
3
3
  import { dirname, relative, resolve } from 'node:path'
4
4
  import { buildDevClientTag } from '../dev/client.ts'
5
+ import { insertGeneratorMeta, resolveGenerator } from '../generator.ts'
5
6
  import { islandChunkBasename } from '../islands/chunk-id.ts'
6
7
  import { DIRECTIVES_BOOTSTRAP, ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
7
8
 
@@ -527,6 +528,12 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
527
528
  nativeRoutes.length > 0 &&
528
529
  (await import('../native/build.ts')).scanDirectiveComponents(opts.entryFile).size > 0
529
530
 
531
+ // Generator meta: resolved INTERNALLY from the out dir's artifact (NOT a
532
+ // caller param) — emit re-runs from five call sites (build, dev, boot
533
+ // staleness, md boot re-emit, dev HMR) and a param would silently drop the
534
+ // tag on re-emit. Fallback (no artifact) = version-on defaults.
535
+ const generatorMeta = resolveGenerator(opts.outDir).meta
536
+
530
537
  const built: string[] = []
531
538
  for (const r of nativeRoutes) {
532
539
  const name = r.nativeTemplate!
@@ -632,8 +639,9 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
632
639
  // injection, so splice the /_brust/dev WS script in here. reEmitJinja() runs
633
640
  // this on every hot reload, so the script is always present in dev.
634
641
  const withDirectives = bakeDirectivesIfUsed(compiled.template, hasDirectives)
642
+ const withGenerator = insertGeneratorMeta(withDirectives, generatorMeta)
635
643
  const template =
636
- process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withDirectives) : withDirectives
644
+ process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withGenerator) : withGenerator
637
645
  writeFileSync(outPath, template)
638
646
  built.push(name)
639
647