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.
@@ -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
  }
@@ -0,0 +1,76 @@
1
+ // Generator-tag decision module. ONE resolved decision { meta, header } made at
2
+ // build time (brust build / brust dev write generator.json into every jinja out
3
+ // dir); consumed by the jinja emitters (bake), the React stream injector, and
4
+ // the X-Powered-By napi thread. The name "brust" is mandatory; only the version
5
+ // substring is optional (--no-generator-version). Spec:
6
+ // docs/superpowers/specs/2026-06-12-generator-tag-design.md
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { readVersion } from './cli/help.ts'
10
+
11
+ export interface GeneratorStrings {
12
+ /** Full meta tag, e.g. `<meta name="generator" content="brust 0.1.48-alpha"/>` */
13
+ meta: string
14
+ /** X-Powered-By value, e.g. `brust/0.1.48-alpha` */
15
+ header: string
16
+ }
17
+
18
+ /** Build the resolved strings. Version comes from the brustjs package.json
19
+ * (readVersion never throws — "unknown" degrades to name-only, never a crash).
20
+ * The version is sanitized to attr/header-safe bytes; semver chars only. */
21
+ export function generatorStrings(versionOn: boolean): GeneratorStrings {
22
+ const raw = readVersion()
23
+ const v = raw === 'unknown' ? '' : raw.replace(/[^0-9A-Za-z.+-]/g, '')
24
+ const withVersion = versionOn && v.length > 0
25
+ return {
26
+ meta: `<meta name="generator" content="brust${withVersion ? ` ${v}` : ''}"/>`,
27
+ header: withVersion ? `brust/${v}` : 'brust',
28
+ }
29
+ }
30
+
31
+ /** The exact head literal the Rust compiler emits for every Document template
32
+ * (crates/jsx-rust-compiler/src/emit_jinja.rs:110). Compiler-owned and stable. */
33
+ const VIEWPORT_ANCHOR = '<meta name="viewport" content="width=device-width, initial-scale=1"/>'
34
+
35
+ /** Insert the generator meta immediately after the compiler-emitted viewport
36
+ * meta. Anchor missing (non-document template) → no-op, never an error.
37
+ * CALLER CONTRACT: pass fresh compiler output — this function does not check
38
+ * for an existing tag, so calling it twice on the same string duplicates.
39
+ * Every emit path recompiles from source each run, which keeps this safe. */
40
+ export function insertGeneratorMeta(jinja: string, metaTag: string): string {
41
+ const at = jinja.indexOf(VIEWPORT_ANCHOR)
42
+ if (at === -1) return jinja
43
+ const end = at + VIEWPORT_ANCHOR.length
44
+ return jinja.slice(0, end) + metaTag + jinja.slice(end)
45
+ }
46
+
47
+ /** Write the decision artifact into `dir` (a jinja out dir), creating it. */
48
+ export function writeGeneratorArtifact(dir: string, strings: GeneratorStrings): void {
49
+ mkdirSync(dir, { recursive: true })
50
+ writeFileSync(path.join(dir, 'generator.json'), JSON.stringify(strings))
51
+ }
52
+
53
+ /** Read the artifact; null on missing/malformed (caller decides the fallback). */
54
+ export function readGeneratorArtifact(dir: string): GeneratorStrings | null {
55
+ try {
56
+ const raw = readFileSync(path.join(dir, 'generator.json'), 'utf8')
57
+ // unknown + explicit narrowing (not a cast): a JSON `null`/non-object body
58
+ // must reach the `return null` below by DESIGN, not by riding the catch.
59
+ const p: unknown = JSON.parse(raw)
60
+ if (typeof p === 'object' && p !== null) {
61
+ const { meta, header } = p as Record<string, unknown>
62
+ if (typeof meta === 'string' && typeof header === 'string') {
63
+ return { meta, header }
64
+ }
65
+ }
66
+ return null
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ /** Artifact if present, else version-on defaults — the spec's fallback policy
73
+ * (an old dist with no artifact behaves as default = version on). */
74
+ export function resolveGenerator(dir: string): GeneratorStrings {
75
+ return readGeneratorArtifact(dir) ?? generatorStrings(true)
76
+ }
@@ -279,6 +279,12 @@ export interface ServeOptions {
279
279
  tuning?: ServeTuning
280
280
  /** Optional action prefix override. Defaults to `/_brust/action`. */
281
281
  actionPrefix?: string
282
+ /**
283
+ * `X-Powered-By` header value (e.g. `brust/0.1.48-alpha`). Single-line
284
+ * ASCII; omit to skip the header. The TS runtime always passes it (name
285
+ * mandatory, version per the build's generator.json).
286
+ */
287
+ generator?: string
282
288
  /**
283
289
  * Optional in-process TLS: PEM certificate (chain) path. When BOTH this and
284
290
  * `tls_key_path` are present, the server terminates TLS itself (ALPN