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 +7 -7
- package/runtime/cli/build.ts +134 -9
- package/runtime/cli/dev.ts +14 -2
- package/runtime/cli/help.ts +8 -0
- package/runtime/cli/native-routes-emit.ts +9 -1
- package/runtime/cli/ssg.ts +321 -10
- package/runtime/generator.ts +76 -0
- package/runtime/index.d.ts +6 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +27 -0
- package/runtime/islands/bootstrap.ts +170 -9
- package/runtime/islands/build.ts +1 -1
- package/runtime/islands/fallback.ts +198 -0
- package/runtime/md/emit.ts +11 -1
- package/runtime/render/inject-generator.ts +71 -0
- package/runtime/render/stream.ts +11 -3
- package/runtime/routes.ts +97 -13
package/runtime/cli/ssg.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/runtime/index.d.ts
CHANGED
|
@@ -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
|