brustjs 0.1.45-alpha → 0.1.47-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.45-alpha",
3
+ "version": "0.1.47-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.45-alpha",
45
- "brustjs-darwin-arm64": "0.1.45-alpha",
46
- "brustjs-linux-x64-gnu": "0.1.45-alpha",
47
- "brustjs-linux-arm64-gnu": "0.1.45-alpha",
48
- "brustjs-linux-x64-musl": "0.1.45-alpha",
49
- "brustjs-linux-arm64-musl": "0.1.45-alpha"
44
+ "brustjs-darwin-x64": "0.1.47-alpha",
45
+ "brustjs-darwin-arm64": "0.1.47-alpha",
46
+ "brustjs-linux-x64-gnu": "0.1.47-alpha",
47
+ "brustjs-linux-arm64-gnu": "0.1.47-alpha",
48
+ "brustjs-linux-x64-musl": "0.1.47-alpha",
49
+ "brustjs-linux-arm64-musl": "0.1.47-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'
@@ -345,7 +346,17 @@ export async function runBuild(args: string[]): Promise<void> {
345
346
  const islandMap = existsSync(routesFile)
346
347
  ? scanIslandChunks(routesFile, mdIslands)
347
348
  : new Map<string, string>()
348
- if (islandMap.size > 0) {
349
+ // `fallback: 'client'` SSG routes need the islands RUNTIME (_bootstrap.js
350
+ // drives the client takeover; _react*.js back the fallback chunk's
351
+ // externals) even when the app ships ZERO islands — buildIslands with an
352
+ // empty map emits exactly those runtime files. Without this, the fallback
353
+ // shell references a /_brust/islands/_bootstrap.js that was never built.
354
+ const needsFallbackRuntime =
355
+ parsed.ssg &&
356
+ ((loadedRoutes ?? []) as { chain?: Array<{ ssg?: { fallback?: string } }> }[]).some(
357
+ (r) => r.chain?.at(-1)?.ssg?.fallback === 'client',
358
+ )
359
+ if (islandMap.size > 0 || needsFallbackRuntime) {
349
360
  const islandsOutDir = path.join(outDir, 'islands')
350
361
  const result = await buildIslands(islandMap, {
351
362
  outDir: islandsOutDir,
@@ -570,22 +581,108 @@ export async function runBuild(args: string[]): Promise<void> {
570
581
  console.log(`[brust build] native: ${name}`)
571
582
  }
572
583
 
584
+ // 7.5. SSG fallback chunks (--ssg only): for every route whose LEAF declares
585
+ // `ssg.fallback: 'client'`, bundle a browser chunk that re-exports the leaf
586
+ // component + its `clientLoader` (generated entry → islands buildOne, react
587
+ // externals, content-addressed `Fallback_<Name>_<hash>.js`). Constraint
588
+ // (documented in the spec): the leaf Component must be a DEFAULT import in
589
+ // routes.tsx (scanImports resolution), and its file must export clientLoader.
590
+ // Step 8 hands these to exportStatic (shell/payload crawl + manifest + 404).
591
+ const ssgFallbacks: Array<{ pattern: string; chunk: string }> = []
592
+ if (parsed.ssg) {
593
+ const fallbackRoutes = (
594
+ (loadedRoutes ?? []) as {
595
+ fullPath: string
596
+ chain?: Array<{ ssg?: { fallback?: string }; Component?: { name?: string } }>
597
+ }[]
598
+ ).filter((r) => r.chain?.at(-1)?.ssg?.fallback === 'client')
599
+ if (fallbackRoutes.length > 0) {
600
+ const { fallbackEntrySource, hasClientLoaderExport } = await import('./ssg.ts')
601
+ const { scanImports } = await import('./native-routes-emit.ts')
602
+ const { buildOne } = await import('../islands/build.ts')
603
+ const { islandChunkBasename } = await import('../islands/chunk-id.ts')
604
+ const importMap = scanImports(routesFile)
605
+ const islandsOutDir = path.join(outDir, 'islands')
606
+ await mkdir(islandsOutDir, { recursive: true })
607
+ // Temp dir for the generated entry modules; removed after the bundles land.
608
+ // Error paths THROW (not process.exit) so the finally's cleanup runs —
609
+ // exit(1) inside the try would skip it and leak the temp dir; the catch
610
+ // below owns stderr + exit.
611
+ const entryTmp = await mkdtemp(path.join(tmpdir(), 'brust-fallback-'))
612
+ try {
613
+ for (const [i, route] of fallbackRoutes.entries()) {
614
+ const name = route.chain?.at(-1)?.Component?.name
615
+ const source = name ? importMap.get(name) : undefined
616
+ if (!name || !source) {
617
+ throw new Error(
618
+ `[brust build] ssg: fallback 'client' on "${route.fullPath}": leaf Component must be a DEFAULT import in the routes file`,
619
+ )
620
+ }
621
+ const text = await readFile(source, 'utf8')
622
+ if (!hasClientLoaderExport(text)) {
623
+ throw new Error(
624
+ `[brust build] ssg: fallback 'client' on "${route.fullPath}": ` +
625
+ `${path.relative(process.cwd(), source)} must \`export const clientLoader\``,
626
+ )
627
+ }
628
+ const file = `Fallback_${islandChunkBasename(name, source)}.js`
629
+ // Index-suffixed entry name: two fallback routes may share a component
630
+ // NAME (different files) — the chunk name is already content-addressed.
631
+ const entryPath = path.join(entryTmp, `${name}_${i}.entry.ts`)
632
+ await writeFile(entryPath, fallbackEntrySource(source))
633
+ try {
634
+ await buildOne(
635
+ [entryPath],
636
+ islandsOutDir,
637
+ file,
638
+ ['react', 'react/jsx-runtime', 'react-dom/client'],
639
+ cssBuildPlugins,
640
+ )
641
+ } catch (err) {
642
+ // Browser-safety convention: server-only deps at the component file's
643
+ // top level surface here as bundle errors.
644
+ throw new Error(
645
+ `[brust build] ssg: fallback chunk for "${route.fullPath}": ${err instanceof Error ? err.message : String(err)}`,
646
+ )
647
+ }
648
+ ssgFallbacks.push({ pattern: route.fullPath, chunk: `/_brust/islands/${file}` })
649
+ }
650
+ } catch (err) {
651
+ await rm(entryTmp, { recursive: true, force: true })
652
+ console.error(err instanceof Error ? err.message : String(err))
653
+ process.exit(1)
654
+ }
655
+ await rm(entryTmp, { recursive: true, force: true })
656
+ }
657
+ }
658
+
573
659
  // 8. SSG export (--ssg): boot the just-built dist once and crawl every
574
660
  // statically-renderable route into <--ssg-out | outDir/static>. Reuses the
575
661
  // routes module ALREADY loaded for the MCP/css steps (loadedRoutes) — no
576
662
  // second import. Without the flag this is a strict no-op.
577
663
  if (parsed.ssg) {
578
- const { collectStaticPaths, exportStatic } = await import('./ssg.ts')
579
- const decisions = collectStaticPaths(
580
- (loadedRoutes ?? []) as Parameters<typeof collectStaticPaths>[0],
581
- )
664
+ const { collectStaticPaths, expandDynamicRoutes, exportStatic } = await import('./ssg.ts')
665
+ // Initialized to [] so the post-catch read type-checks even where
666
+ // process.exit isn't narrowed to `never` (exit(1) still prevents use).
667
+ let expanded: Awaited<ReturnType<typeof expandDynamicRoutes>> = []
668
+ try {
669
+ expanded = await expandDynamicRoutes(
670
+ (loadedRoutes ?? []) as Parameters<typeof expandDynamicRoutes>[0],
671
+ )
672
+ } catch (err) {
673
+ console.error(`[brust build] ssg: ${err instanceof Error ? err.message : String(err)}`)
674
+ process.exit(1)
675
+ }
676
+ const expandedCount = expanded.length - (loadedRoutes ?? []).length
677
+ const decisions = collectStaticPaths(expanded)
582
678
  const staticOut = parsed.ssgOut ?? path.join(outDir, 'static')
583
679
  try {
584
- const { written, navWritten, skipped } = await exportStatic({
680
+ const { written, navWritten, fallbackWritten, skipped } = await exportStatic({
585
681
  distDir: outDir,
586
682
  entryDir,
587
683
  staticOut,
588
684
  routes: decisions,
685
+ fallbacks: ssgFallbacks,
589
686
  })
590
687
  const counts = new Map<string, number>()
591
688
  for (const s of skipped) {
@@ -597,9 +694,17 @@ export async function runBuild(args: string[]): Promise<void> {
597
694
  .map((r) => `${r}=${counts.get(r)}`)
598
695
  .join(', ')
599
696
  const skippedDesc = skipped.length > 0 ? ` (skipped ${skipped.length}: ${reasons})` : ''
697
+ const expandedDesc = expandedCount > 0 ? `, expanded ${expandedCount} dynamic page(s)` : ''
698
+ const fallbackDesc =
699
+ fallbackWritten.length > 0 ? `, ${fallbackWritten.length} fallback file(s)` : ''
600
700
  console.log(
601
- `[brust build] ssg: ${written.length} pages + ${navWritten.length} spa payloads → ${staticOut}${skippedDesc}`,
701
+ `[brust build] ssg: ${written.length} pages + ${navWritten.length} spa payloads${expandedDesc}${fallbackDesc} → ${staticOut}${skippedDesc}`,
602
702
  )
703
+ for (const f of ssgFallbacks) {
704
+ console.log(
705
+ `[brust build] ssg: fallback chunk ${path.basename(f.chunk)} (${f.pattern})`,
706
+ )
707
+ }
603
708
  } catch (err) {
604
709
  console.error(`[brust build] ssg: ${err instanceof Error ? err.message : String(err)}`)
605
710
  process.exit(1)
@@ -15,8 +15,9 @@ export interface FlatRouteLike {
15
15
  /** Full path Rust matches against (e.g. '/', '/docs/intro', '/pokemon/{name}'). */
16
16
  fullPath: string
17
17
  /** Chain of Route nodes from root to leaf, inclusive. Only the LEAF node
18
- * can carry sse/websocket (defineRoutes forbids children on those). */
19
- chain: { sse?: unknown; websocket?: unknown }[]
18
+ * can carry sse/websocket (defineRoutes forbids children on those) — and
19
+ * only the leaf's `ssg`/`native` are consulted by expandDynamicRoutes. */
20
+ chain: { sse?: unknown; websocket?: unknown; native?: unknown; ssg?: RouteSsgLike }[]
20
21
  }
21
22
 
22
23
  export interface SsgRouteDecision {
@@ -27,6 +28,56 @@ export interface SsgRouteDecision {
27
28
  outFile: string // 'index.html' | 'docs/intro/index.html' …
28
29
  }
29
30
 
31
+ /** Decode a single URL path segment for on-disk use. Static hosts decode the
32
+ * request URL before file lookup, so the file must use the decoded form. We
33
+ * decode per-segment (not the whole path) so that a literal '/' or '\' inside
34
+ * a segment (%2F / %5C) cannot create directory traversal — those are
35
+ * re-encoded after decoding. Malformed percent sequences that would cause
36
+ * decodeURIComponent to throw are decoded triplet-by-triplet: each valid
37
+ * triplet is decoded, each invalid one is left as-is. */
38
+ function decodeSegment(seg: string): string {
39
+ // Fast path: nothing to decode.
40
+ if (!seg.includes('%')) return seg
41
+
42
+ // Try the whole segment first (common case: all triplets valid).
43
+ try {
44
+ const decoded = decodeURIComponent(seg)
45
+ // Re-encode decoded path separators to prevent directory traversal.
46
+ return decoded.replace(/\//g, '%2F').replace(/\\/g, '%5C')
47
+ } catch {
48
+ // Fallback: decode each /%[0-9A-Fa-f]{2}/ triplet individually.
49
+ const result = seg.replace(/%[0-9A-Fa-f]{2}/g, (triplet) => {
50
+ try {
51
+ const decoded = decodeURIComponent(triplet)
52
+ // Re-encode path separators even in the per-triplet pass.
53
+ if (decoded === '/') return '%2F'
54
+ if (decoded === '\\') return '%5C'
55
+ return decoded
56
+ } catch {
57
+ return triplet
58
+ }
59
+ })
60
+ return result
61
+ }
62
+ }
63
+
64
+ /** Produce the decoded on-disk path from a normalised URL path. Each segment
65
+ * is decoded independently so separator characters cannot escape; `.`/`..`
66
+ * segments (raw or decoded — encodeURIComponent leaves dots alone) are
67
+ * percent-encoded so a hostile param value can never traverse out of the
68
+ * static output directory. */
69
+ function decodePathForDisk(normalized: string): string {
70
+ return normalized
71
+ .split('/')
72
+ .map((seg) => {
73
+ const decoded = decodeSegment(seg)
74
+ if (decoded === '.') return '%2E'
75
+ if (decoded === '..') return '%2E%2E'
76
+ return decoded
77
+ })
78
+ .join('/')
79
+ }
80
+
30
81
  /** Strip trailing slashes ('/docs/intro/' → '/docs/intro'); root stays '/'. */
31
82
  function normalizePath(p: string): string {
32
83
  let s = p.startsWith('/') ? p : `/${p}`
@@ -35,20 +86,23 @@ function normalizePath(p: string): string {
35
86
  }
36
87
 
37
88
  /** '/' → 'index.html'; '/docs/intro' → 'docs/intro/index.html'. Input must be
38
- * normalized (no trailing slash). */
89
+ * normalized (no trailing slash). On-disk names use the decoded form because
90
+ * static hosts decode the request URL before file lookup. */
39
91
  function outFileFor(normalized: string): string {
40
92
  if (normalized === '/') return 'index.html'
41
- return `${normalized.slice(1)}/index.html`
93
+ return `${decodePathForDisk(normalized.slice(1))}/index.html`
42
94
  }
43
95
 
44
96
  /** Where a route's SPA navigation payload lands on disk. The client navigator
45
97
  * fetches `/_brust/page${pathname}` (bootstrap.ts navigate()), so the payload
46
98
  * must be reachable at that exact URL on a dumb static host — which means
47
99
  * `<url>/index.html`, the same directory-index shape the pages use:
48
- * '/' → '_brust/page/index.html'; '/docs/intro' → '_brust/page/docs/intro/index.html'. */
100
+ * '/' → '_brust/page/index.html'; '/docs/intro' → '_brust/page/docs/intro/index.html'.
101
+ * On-disk names use the decoded form for the same reason as outFileFor. */
49
102
  export function navPayloadFileFor(normalized: string): string {
50
103
  if (normalized === '/') return join('_brust', 'page', 'index.html')
51
- return join('_brust', 'page', normalized.slice(1), 'index.html')
104
+ const decoded = decodePathForDisk(normalized.slice(1))
105
+ return join('_brust', 'page', decoded, 'index.html')
52
106
  }
53
107
 
54
108
  /** Decide, for every flattened route, whether it can be statically prerendered
@@ -88,6 +142,168 @@ export function collectStaticPaths(flatRoutes: FlatRouteLike[]): SsgRouteDecisio
88
142
  return decisions
89
143
  }
90
144
 
145
+ // ----- expandDynamicRoutes -----
146
+
147
+ /** Reserved sentinel param value (Phase B fallback shell crawl). */
148
+ export const SSG_FALLBACK_SENTINEL = '__brust_fallback__'
149
+
150
+ /** Structural view of the leaf's ssg config (mirrors RouteSsgConfig). */
151
+ export interface RouteSsgLike {
152
+ params?: () => Array<Record<string, string>> | Promise<Array<Record<string, string>>>
153
+ fallback?: 'none' | 'client'
154
+ }
155
+ /** Unique `{name}`s in declaration order. A repeated name (`/x/{id}/y/{id}`)
156
+ * validates once and substitutes ALL occurrences via replaceAll below. The
157
+ * regex is function-local: a module-level /g regex is a stateful-lastIndex
158
+ * trap for any future exec/test caller. */
159
+ function paramNames(fullPath: string): string[] {
160
+ return [...new Set([...fullPath.matchAll(/\{([^/}]+)\}/g)].map((m) => m[1]!))]
161
+ }
162
+
163
+ /** Expand `ssg.params()` routes into concrete prerenderable paths. The
164
+ * pattern route stays in its ORIGINAL list position (never re-appended);
165
+ * concrete entries are appended sharing the same chain reference. Throws on
166
+ * any validation error — build must exit 1, never a silent partial export. */
167
+ export async function expandDynamicRoutes(flatRoutes: FlatRouteLike[]): Promise<FlatRouteLike[]> {
168
+ const out = [...flatRoutes]
169
+ for (const route of flatRoutes) {
170
+ const leaf = route.chain[route.chain.length - 1]
171
+ const ssg = leaf?.ssg
172
+ if (!ssg) continue
173
+ const names = paramNames(route.fullPath)
174
+ if (names.length === 0) {
175
+ throw new Error(
176
+ `ssg config on "${route.fullPath}": route has no {param} segment — remove the dead config`,
177
+ )
178
+ }
179
+ if (ssg.fallback === 'client' && leaf?.native) {
180
+ throw new Error(
181
+ `ssg.fallback 'client' on "${route.fullPath}": native (jinja) routes cannot client-render — use the island-fetch pattern instead`,
182
+ )
183
+ }
184
+ if (!ssg.params) continue
185
+ let records: Array<Record<string, string>>
186
+ try {
187
+ records = await ssg.params()
188
+ } catch (err) {
189
+ throw new Error(
190
+ `ssg.params for "${route.fullPath}" threw: ${err instanceof Error ? err.message : String(err)}`,
191
+ )
192
+ }
193
+ if (!Array.isArray(records)) {
194
+ throw new Error(`ssg.params for "${route.fullPath}": expected an array of records`)
195
+ }
196
+ const seen = new Set<string>()
197
+ for (const [i, record] of records.entries()) {
198
+ let concrete = route.fullPath
199
+ for (const name of names) {
200
+ const v = record?.[name]
201
+ if (typeof v !== 'string' || v === '') {
202
+ throw new Error(
203
+ `ssg.params for "${route.fullPath}": record #${i + 1} missing non-empty '${name}'`,
204
+ )
205
+ }
206
+ if (v === SSG_FALLBACK_SENTINEL) {
207
+ throw new Error(
208
+ `ssg.params for "${route.fullPath}": record #${i + 1} uses the reserved value ${SSG_FALLBACK_SENTINEL}`,
209
+ )
210
+ }
211
+ // encodeURIComponent leaves dots alone, so '.'/'..' would survive into
212
+ // the crawl path (where fetch normalizes them away — the crawl would
213
+ // silently hit a DIFFERENT route) and into the on-disk path.
214
+ if (v === '.' || v === '..') {
215
+ throw new Error(
216
+ `ssg.params for "${route.fullPath}": record #${i + 1} value '${v}' for '${name}' is not a valid path segment`,
217
+ )
218
+ }
219
+ concrete = concrete.replaceAll(`{${name}}`, encodeURIComponent(v))
220
+ }
221
+ if (seen.has(concrete)) continue
222
+ seen.add(concrete)
223
+ out.push({ fullPath: concrete, chain: route.chain })
224
+ }
225
+ }
226
+ return out
227
+ }
228
+
229
+ // ----- fallback chunk helpers (Phase B) -----
230
+
231
+ /** On-disk directory for a pattern's fallback artifacts: `{param}` → `__param__`
232
+ * (curly braces are hostile to static hosts / shells), leading slash stripped.
233
+ * '/blog/{slug}' → 'blog/__slug__'. Pure string fn. */
234
+ export function fallbackDiskPath(pattern: string): string {
235
+ return pattern.replace(/^\//, '').replace(/\{([^/}]+)\}/g, '__$1__')
236
+ }
237
+
238
+ /** The URL the build crawler requests for a fallback shell: every `{param}`
239
+ * replaced by the reserved sentinel. '/d/{a}' → '/d/__brust_fallback__'. */
240
+ export function fallbackSentinelPath(pattern: string): string {
241
+ return pattern.replace(/\{[^/}]+\}/g, SSG_FALLBACK_SENTINEL)
242
+ }
243
+
244
+ /** Generated entry module for a route's fallback chunk: re-exports the leaf
245
+ * component (default export) and its `clientLoader` under the names the client
246
+ * takeover runtime imports. */
247
+ export function fallbackEntrySource(componentSourcePath: string): string {
248
+ // JSON.stringify the specifier so quotes/backslashes in the path can never
249
+ // break the generated module syntax.
250
+ const spec = JSON.stringify(componentSourcePath)
251
+ return `import C, { clientLoader } from ${spec}\nexport { C as Component, clientLoader }\n`
252
+ }
253
+
254
+ /** Does the component source `export` a `clientLoader`? Covers const / let /
255
+ * function / async-function declarations AND the `export { clientLoader }` /
256
+ * `export { x as clientLoader }` re-export forms. Line + block comments are
257
+ * stripped first so a commented-out export doesn't count (naive strip — a
258
+ * `//` inside a string literal on the same line as the export is the known
259
+ * residual; failure mode is a clear build error, not a silent miss). */
260
+ export function hasClientLoaderExport(source: string): boolean {
261
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
262
+ if (/export\s+(const|let|async\s+function|function)\s+clientLoader\b/.test(code)) return true
263
+ return /export\s*\{[^}]*\bclientLoader\b[^}]*\}/.test(code)
264
+ }
265
+
266
+ /** Static-host 404 document for `fallback: 'client'` routes: inlines the
267
+ * [{pattern, doc}] manifest pairs; a path matching a fallback pattern stashes
268
+ * the REAL url in sessionStorage (the takeover runtime restores it via
269
+ * history.replaceState) and redirects to the prerendered fallback shell.
270
+ * No match → plain 404 text. Pure string fn so the script/inline-JSON
271
+ * contract is unit-testable. Escapes for the <script> context: `<`/`>` (no
272
+ * `</script>`/`<!--`/`-->` sequences) and U+2028/U+2029 (legal in JSON,
273
+ * illegal in pre-ES2019-parsed JS string literals). Patterns are
274
+ * author-controlled — belt-and-braces, not a trust boundary. */
275
+ export function fallback404Html(pairs: Array<{ pattern: string; doc: string }>): string {
276
+ const inlineJson = JSON.stringify(pairs)
277
+ .replace(/</g, '\\u003c')
278
+ .replace(/>/g, '\\u003e')
279
+ .replace(/\u2028/g, '\\u2028')
280
+ .replace(/\u2029/g, '\\u2029')
281
+ return `<!doctype html><html><head><meta charset="utf-8"><title>404</title></head><body>
282
+ <p>Not found.</p>
283
+ <script>
284
+ (function () {
285
+ var MANIFEST = ${inlineJson};
286
+ function match(pattern, path) {
287
+ var p = pattern.split('/'), u = path.split('/')
288
+ if (p.length !== u.length) return false
289
+ for (var i = 0; i < p.length; i++) {
290
+ if (p[i].charAt(0) === '{') { if (!u[i]) return false }
291
+ else if (p[i] !== u[i]) return false
292
+ }
293
+ return true
294
+ }
295
+ for (var i = 0; i < MANIFEST.length; i++) {
296
+ if (match(MANIFEST[i].pattern, location.pathname)) {
297
+ try { sessionStorage.setItem('brust:fallback-path', location.pathname + location.search) } catch (e) {}
298
+ location.replace(MANIFEST[i].doc)
299
+ return
300
+ }
301
+ }
302
+ })()
303
+ </script></body></html>
304
+ `
305
+ }
306
+
91
307
  // ----- static export -----
92
308
 
93
309
  const READY_LINE = '[brust] listening on' // println! in brust-core server/mod.rs
@@ -183,6 +399,13 @@ async function waitForListening(
183
399
  * the static site navigate SPA-style instead of full-reloading; any host
184
400
  * 404/redirect-to-HTML still lands in the navigator's full-reload fallback.
185
401
  *
402
+ * `fallback: 'client'` routes additionally get their sentinel SHELL crawled
403
+ * (header `x-brust-ssg: 1` — the worker renders the placeholder shell only
404
+ * for that header + all-sentinel params) into `_brust/fallback{,-page}/…`,
405
+ * plus a `_brust/routes.json` manifest and a redirecting `404.html` (skipped
406
+ * with a warning when the app ships its own `public/404.html`). Without
407
+ * fallbacks the output is byte-identical to before this feature existed.
408
+ *
186
409
  * Asset copy preserves the live server's URL shape: islands + css under
187
410
  * /_brust/, public/ root-mapped (runtime/index.ts configurePublicDir). */
188
411
  export async function exportStatic(opts: {
@@ -190,8 +413,15 @@ export async function exportStatic(opts: {
190
413
  entryDir: string // app dir (for public/)
191
414
  staticOut: string // e.g. dist/static (clobbered first)
192
415
  routes: SsgRouteDecision[]
193
- }): Promise<{ written: string[]; navWritten: string[]; skipped: SsgRouteDecision[] }> {
194
- const { distDir, entryDir, staticOut, routes } = opts
416
+ /** `fallback: 'client'` routes (pattern + built chunk URL) to emit shells for. */
417
+ fallbacks?: Array<{ pattern: string; chunk: string }>
418
+ }): Promise<{
419
+ written: string[]
420
+ navWritten: string[]
421
+ fallbackWritten: string[]
422
+ skipped: SsgRouteDecision[]
423
+ }> {
424
+ const { distDir, entryDir, staticOut, routes, fallbacks = [] } = opts
195
425
  const included = routes.filter((r) => r.include)
196
426
  const skipped = routes.filter((r) => !r.include)
197
427
 
@@ -200,7 +430,8 @@ export async function exportStatic(opts: {
200
430
 
201
431
  const written: string[] = []
202
432
  const navWritten: string[] = []
203
- if (included.length > 0) {
433
+ const fallbackWritten: string[] = []
434
+ if (included.length > 0 || fallbacks.length > 0) {
204
435
  const port = await freePort()
205
436
  const proc = Bun.spawn(['bun', join(distDir, 'index.js')], {
206
437
  env: { ...process.env, BRUST_PORT: String(port), BRUST_WORKERS: '1' },
@@ -262,6 +493,85 @@ export async function exportStatic(opts: {
262
493
  const settled = await Promise.allSettled(workers)
263
494
  const failed = settled.find((s): s is PromiseRejectedResult => s.status === 'rejected')
264
495
  if (failed) throw failed.reason
496
+
497
+ // Fallback shells: crawl the sentinel path (every {param} →
498
+ // __brust_fallback__) with the build-internal header that unlocks the
499
+ // shell render. Same no-partial rule as the page crawl: any non-200
500
+ // fails the whole export.
501
+ for (const f of fallbacks) {
502
+ const sentinel = fallbackSentinelPath(f.pattern)
503
+ const resp = await fetch(`http://127.0.0.1:${port}${sentinel}`, {
504
+ headers: { 'x-brust-ssg': '1' },
505
+ })
506
+ const body = await resp.text()
507
+ if (resp.status !== 200) {
508
+ throw new Error(`GET ${sentinel} → ${resp.status}\n${body.slice(0, 500)}`)
509
+ }
510
+ const docFile = join('_brust', 'fallback', fallbackDiskPath(f.pattern), 'index.html')
511
+ const docPath = join(staticOut, docFile)
512
+ await mkdir(dirname(docPath), { recursive: true })
513
+ await Bun.write(docPath, body)
514
+ fallbackWritten.push(docFile)
515
+
516
+ // SPA payload of the shell — same {html,...} contract the client
517
+ // navigator parses (attemptClientFallback swaps it into <main>).
518
+ const payloadUrl = `/_brust/page${sentinel}`
519
+ const payloadResp = await fetch(`http://127.0.0.1:${port}${payloadUrl}`, {
520
+ headers: { 'x-brust-ssg': '1', Accept: 'application/json' },
521
+ })
522
+ const payloadBody = await payloadResp.text()
523
+ if (payloadResp.status !== 200) {
524
+ throw new Error(`GET ${payloadUrl} → ${payloadResp.status}\n${payloadBody.slice(0, 500)}`)
525
+ }
526
+ try {
527
+ const parsed = JSON.parse(payloadBody) as { html?: unknown }
528
+ if (typeof parsed.html !== 'string') throw new Error('missing "html" field')
529
+ } catch (e) {
530
+ throw new Error(`GET ${payloadUrl} → invalid SPA payload: ${(e as Error).message}`)
531
+ }
532
+ const payloadFile = join(
533
+ '_brust',
534
+ 'fallback-page',
535
+ fallbackDiskPath(f.pattern),
536
+ 'index.html',
537
+ )
538
+ const payloadPath = join(staticOut, payloadFile)
539
+ await mkdir(dirname(payloadPath), { recursive: true })
540
+ await Bun.write(payloadPath, payloadBody)
541
+ fallbackWritten.push(payloadFile)
542
+ }
543
+
544
+ if (fallbacks.length > 0) {
545
+ // Manifest the client takeover runtime fetches to map a 404'd path to
546
+ // its fallback shell. doc/payload are directory-index URLs (trailing
547
+ // slash) so a dumb static host serves the index.html written above.
548
+ const manifest = {
549
+ version: 1,
550
+ fallbacks: fallbacks.map((f) => ({
551
+ pattern: f.pattern,
552
+ doc: `/_brust/fallback/${fallbackDiskPath(f.pattern)}/`,
553
+ payload: `/_brust/fallback-page/${fallbackDiskPath(f.pattern)}/`,
554
+ chunk: f.chunk,
555
+ })),
556
+ }
557
+ const manifestPath = join(staticOut, '_brust', 'routes.json')
558
+ await mkdir(dirname(manifestPath), { recursive: true })
559
+ await Bun.write(manifestPath, JSON.stringify(manifest))
560
+
561
+ // 404.html redirects non-prerendered fallback paths to their shell.
562
+ // An app-authored public/404.html wins — it lands via the public/
563
+ // copy below, and the author owns the redirect contract.
564
+ if (existsSync(join(entryDir, 'public', '404.html'))) {
565
+ console.warn(
566
+ '[brust build] ssg: public/404.html exists — NOT overwriting; your 404 page must redirect fallback routes itself (see docs)',
567
+ )
568
+ } else {
569
+ await Bun.write(
570
+ join(staticOut, '404.html'),
571
+ fallback404Html(manifest.fallbacks.map(({ pattern, doc }) => ({ pattern, doc }))),
572
+ )
573
+ }
574
+ }
265
575
  } catch (err) {
266
576
  // No partial site: a failed crawl removes everything it wrote.
267
577
  await rm(staticOut, { recursive: true, force: true }).catch(() => {})
@@ -298,5 +608,6 @@ export async function exportStatic(opts: {
298
608
 
299
609
  written.sort()
300
610
  navWritten.sort()
301
- return { written, navWritten, skipped }
611
+ fallbackWritten.sort()
612
+ return { written, navWritten, fallbackWritten, skipped }
302
613
  }