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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// runtime/islands/fallback.ts — client takeover runtime for `fallback: 'client'`
|
|
2
|
+
// SSG routes (Phase B of the ssg-dynamic-params design). A non-prerendered
|
|
3
|
+
// dynamic path serves a placeholder shell whose leaf is
|
|
4
|
+
// `<div data-brust-fallback-root data-brust-fallback="<pattern>">…</div>`;
|
|
5
|
+
// this module matches the pattern against the live pathname, loads the
|
|
6
|
+
// route's fallback chunk (`Component` + `clientLoader`), and client-renders
|
|
7
|
+
// the real page into the container.
|
|
8
|
+
//
|
|
9
|
+
// Imported BY bootstrap.ts (Task B6): react / react-dom/client are static
|
|
10
|
+
// imports on purpose — bootstrap bundles them as externals resolved via the
|
|
11
|
+
// importmap, and this module rides the same bundle. Import-safe without a
|
|
12
|
+
// DOM: nothing touches document/location at module top level.
|
|
13
|
+
|
|
14
|
+
import { createElement } from 'react'
|
|
15
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
16
|
+
|
|
17
|
+
export interface FallbackEntry {
|
|
18
|
+
pattern: string
|
|
19
|
+
doc: string
|
|
20
|
+
payload: string
|
|
21
|
+
chunk: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// React roots created by takeover, keyed by their container, so navigation
|
|
25
|
+
// can unmount them before replacing the DOM (a detached live root keeps
|
|
26
|
+
// React's scheduler posting work to dead nodes — hung tab). Same discipline
|
|
27
|
+
// as bootstrap's islandRoots.
|
|
28
|
+
const fallbackRoots = new WeakMap<HTMLElement, Root>()
|
|
29
|
+
|
|
30
|
+
/** Match a route pattern (`/blog/{slug}`) against a concrete pathname.
|
|
31
|
+
* Segment-wise: lengths must agree, `{name}` segments capture one
|
|
32
|
+
* decodeURIComponent-decoded segment (malformed percent-encoding falls back
|
|
33
|
+
* to the raw segment; an EMPTY segment never matches a capture), everything
|
|
34
|
+
* else must compare literally. Returns the captured params, or null. */
|
|
35
|
+
export function matchFallback(pattern: string, pathname: string): Record<string, string> | null {
|
|
36
|
+
const pat = pattern.split('/')
|
|
37
|
+
// Route-table patterns never carry a trailing slash; static hosts (CF Pages
|
|
38
|
+
// directory rewrites among them) may append one to the live pathname —
|
|
39
|
+
// normalize so '/blog/foo/' still matches '/blog/{slug}'.
|
|
40
|
+
const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname
|
|
41
|
+
const path = normalized.split('/')
|
|
42
|
+
if (pat.length !== path.length) return null
|
|
43
|
+
const params: Record<string, string> = {}
|
|
44
|
+
for (let i = 0; i < pat.length; i++) {
|
|
45
|
+
const p = pat[i] as string
|
|
46
|
+
const seg = path[i] as string
|
|
47
|
+
if (p.startsWith('{') && p.endsWith('}') && p.length > 2) {
|
|
48
|
+
if (seg === '') return null // empty capture → no match
|
|
49
|
+
let value: string
|
|
50
|
+
try {
|
|
51
|
+
value = decodeURIComponent(seg)
|
|
52
|
+
} catch {
|
|
53
|
+
value = seg // malformed percent-encoding → raw segment
|
|
54
|
+
}
|
|
55
|
+
params[p.slice(1, -1)] = value
|
|
56
|
+
} else if (p !== seg) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return params
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let manifestPromise: Promise<FallbackEntry[]> | null = null
|
|
64
|
+
|
|
65
|
+
function isFallbackEntry(e: unknown): e is FallbackEntry {
|
|
66
|
+
if (typeof e !== 'object' || e === null) return false
|
|
67
|
+
const r = e as Record<string, unknown>
|
|
68
|
+
return (
|
|
69
|
+
typeof r.pattern === 'string' && typeof r.payload === 'string' && typeof r.chunk === 'string'
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Fetch the static-export fallback manifest (`/_brust/routes.json`).
|
|
74
|
+
* Memoized for the document's lifetime — including the EMPTY result: a site
|
|
75
|
+
* with no fallback routes (404 / invalid / no `fallbacks` array) resolves []
|
|
76
|
+
* once and never refetches per navigation. Entries missing a string
|
|
77
|
+
* pattern/payload/chunk are filtered out. */
|
|
78
|
+
export function loadFallbackManifest(): Promise<FallbackEntry[]> {
|
|
79
|
+
if (!manifestPromise) {
|
|
80
|
+
manifestPromise = fetch('/_brust/routes.json')
|
|
81
|
+
.then(async (resp) => {
|
|
82
|
+
if (!resp.ok) return []
|
|
83
|
+
const json = (await resp.json()) as { fallbacks?: unknown }
|
|
84
|
+
if (!Array.isArray(json.fallbacks)) return []
|
|
85
|
+
return json.fallbacks.filter(isFallbackEntry)
|
|
86
|
+
})
|
|
87
|
+
.catch(() => [])
|
|
88
|
+
}
|
|
89
|
+
return manifestPromise
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function takeoverError(container: HTMLElement, message: string, err?: unknown): void {
|
|
93
|
+
if (err === undefined) console.error(`[brust] fallback: ${message}`)
|
|
94
|
+
else console.error(`[brust] fallback: ${message}`, err)
|
|
95
|
+
container.setAttribute('data-brust-fallback-error', '1')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Client-render a fallback route into its placeholder container.
|
|
99
|
+
*
|
|
100
|
+
* Reads the route pattern from `data-brust-fallback`, derives params from
|
|
101
|
+
* `location.pathname`, dynamic-imports the route's fallback chunk from the
|
|
102
|
+
* manifest, runs `clientLoader({ params, path })`, then replaces the
|
|
103
|
+
* placeholder children with `createRoot(container).render(<Component
|
|
104
|
+
* params path data />)` — the server prop shape minus `req`/`workerId`
|
|
105
|
+
* (those don't exist in a browser).
|
|
106
|
+
*
|
|
107
|
+
* On any failure (mismatched pattern, missing manifest entry, bad chunk,
|
|
108
|
+
* clientLoader throw) the container KEEPS its placeholder, gets a
|
|
109
|
+
* `data-brust-fallback-error` attribute, and the error is logged —
|
|
110
|
+
* author-level recovery is a catch inside clientLoader returning
|
|
111
|
+
* error-shaped data.
|
|
112
|
+
*
|
|
113
|
+
* CONTRACT: the caller must unmount any island roots inside the container
|
|
114
|
+
* FIRST (a placeholder may carry islands) — bootstrap's unmountIslandsIn
|
|
115
|
+
* does this before calling takeover. The call lives there, not here, to
|
|
116
|
+
* avoid a circular import between fallback.ts and bootstrap.ts.
|
|
117
|
+
*
|
|
118
|
+
* `signal`: a superseding navigation aborts it. Checked after every await and
|
|
119
|
+
* BEFORE createRoot — without that gate, a takeover resumed after the next
|
|
120
|
+
* navigation's swap would mount a React root into a DETACHED container that
|
|
121
|
+
* unmountFallbackRootsIn can never find again (hung-tab hazard). */
|
|
122
|
+
export async function takeover(container: HTMLElement, signal?: AbortSignal): Promise<void> {
|
|
123
|
+
const pattern = container.getAttribute('data-brust-fallback')
|
|
124
|
+
if (pattern === null) {
|
|
125
|
+
console.error('[brust] fallback: container has no data-brust-fallback attribute')
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const path = location.pathname
|
|
129
|
+
const params = matchFallback(pattern, path)
|
|
130
|
+
if (params === null) {
|
|
131
|
+
takeoverError(container, `pattern "${pattern}" does not match pathname "${path}"`)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const manifest = await loadFallbackManifest()
|
|
135
|
+
const entry = manifest.find((e) => e.pattern === pattern)
|
|
136
|
+
if (!entry) {
|
|
137
|
+
takeoverError(container, `no manifest entry for pattern "${pattern}"`)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
let Component: React.ComponentType<Record<string, unknown>>
|
|
141
|
+
let clientLoader: (args: { params: Record<string, string>; path: string }) => unknown
|
|
142
|
+
try {
|
|
143
|
+
// Variable specifier → stays a runtime dynamic import in the bundle
|
|
144
|
+
// (same trick as bootstrap's islandChunkMap).
|
|
145
|
+
const chunkUrl = entry.chunk
|
|
146
|
+
const mod = (await import(chunkUrl)) as Record<string, unknown>
|
|
147
|
+
if (typeof mod.Component !== 'function' || typeof mod.clientLoader !== 'function') {
|
|
148
|
+
takeoverError(container, `chunk "${entry.chunk}" must export Component and clientLoader`)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
Component = mod.Component as React.ComponentType<Record<string, unknown>>
|
|
152
|
+
clientLoader = mod.clientLoader as typeof clientLoader
|
|
153
|
+
} catch (e) {
|
|
154
|
+
takeoverError(container, `failed to load chunk "${entry.chunk}"`, e)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (signal?.aborted) return
|
|
158
|
+
let data: unknown
|
|
159
|
+
try {
|
|
160
|
+
data = await clientLoader({ params, path })
|
|
161
|
+
} catch (e) {
|
|
162
|
+
takeoverError(container, `clientLoader threw for "${pattern}"`, e)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
// Superseded while the data was loading → the newer navigation owns the
|
|
166
|
+
// DOM; mounting here would render into a detached container.
|
|
167
|
+
if (signal?.aborted) return
|
|
168
|
+
// Success: drop the placeholder, then mount a fresh client root.
|
|
169
|
+
while (container.firstChild) container.removeChild(container.firstChild)
|
|
170
|
+
const root = createRoot(container)
|
|
171
|
+
root.render(createElement(Component, { params, path, data }))
|
|
172
|
+
fallbackRoots.set(container, root)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Unmount any takeover roots inside `scope`. Must run BEFORE removing or
|
|
176
|
+
* replacing their DOM (same reason as bootstrap's unmountIslandsIn, which
|
|
177
|
+
* delegates here in Task B6 — islandRoots and fallbackRoots stay
|
|
178
|
+
* module-private to their owners). */
|
|
179
|
+
export function unmountFallbackRootsIn(scope: ParentNode): void {
|
|
180
|
+
const containers = scope.querySelectorAll<HTMLElement>('[data-brust-fallback-root]')
|
|
181
|
+
for (const el of Array.from(containers)) {
|
|
182
|
+
const root = fallbackRoots.get(el)
|
|
183
|
+
if (root) {
|
|
184
|
+
try {
|
|
185
|
+
root.unmount()
|
|
186
|
+
} catch (e) {
|
|
187
|
+
console.warn('[brust] fallback root unmount failed', e)
|
|
188
|
+
}
|
|
189
|
+
fallbackRoots.delete(el)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Test-only: drop the memoized manifest so the next loadFallbackManifest
|
|
195
|
+
// refetches. (fallbackRoots is a WeakMap — nothing to clear.)
|
|
196
|
+
export function __resetFallbackForTest(): void {
|
|
197
|
+
manifestPromise = null
|
|
198
|
+
}
|
package/runtime/md/emit.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import path from 'node:path'
|
|
15
|
+
import { insertGeneratorMeta, resolveGenerator } from '../generator.ts'
|
|
15
16
|
import {
|
|
16
17
|
bakeDirectivesIfUsed,
|
|
17
18
|
buildChainWrapperSource,
|
|
@@ -177,6 +178,12 @@ export async function emitMdTemplates(opts: MdEmitOpts): Promise<{
|
|
|
177
178
|
return files.get(src.relPath)
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
// Generator meta: resolved INTERNALLY from the out dir's artifact (NOT a
|
|
182
|
+
// caller param) — emit re-runs from five call sites (build, dev, boot
|
|
183
|
+
// staleness, md boot re-emit, dev HMR) and a param would silently drop the
|
|
184
|
+
// tag on re-emit. Fallback (no artifact) = version-on defaults.
|
|
185
|
+
const generatorMeta = resolveGenerator(opts.outDir).meta
|
|
186
|
+
|
|
180
187
|
// Tag-name → classification, shared across pages (one readFileSync per name).
|
|
181
188
|
const resolutionCache = new Map<string, { res: MdComponentResolution; absPath: string }>()
|
|
182
189
|
|
|
@@ -328,7 +335,10 @@ export async function emitMdTemplates(opts: MdEmitOpts): Promise<{
|
|
|
328
335
|
mdHtml = mdHtml.slice(0, at) + inlined + mdHtml.slice(at + use.marker.length)
|
|
329
336
|
}
|
|
330
337
|
|
|
331
|
-
const template =
|
|
338
|
+
const template = insertGeneratorMeta(
|
|
339
|
+
spliceMdSlot(compiled.template, name, mdHtml),
|
|
340
|
+
generatorMeta,
|
|
341
|
+
)
|
|
332
342
|
if (countMainTags(template) > 1) {
|
|
333
343
|
process.stderr.write(
|
|
334
344
|
`brust: md route "${name}" has more than one <main> after splice — SPA navigation extracts only the first <main>…</main>.\n`,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Render-time generator-meta injection for React-streamed HTML. The tag value
|
|
2
|
+
// comes from the build's generator.json (configured at boot by BOTH the main
|
|
3
|
+
// and worker isolates — module state is per-isolate, same trap as
|
|
4
|
+
// configureJinjaDir). Buffered branch: splice before </head> with a duplicate
|
|
5
|
+
// guard (a hand-authored generator meta wins). Streaming branch (stream.ts)
|
|
6
|
+
// prepends the raw tag with the other first-chunk tags instead — no guard
|
|
7
|
+
// possible there (head bytes arrive in later chunks); documented limitation.
|
|
8
|
+
const ENC = new TextEncoder()
|
|
9
|
+
|
|
10
|
+
let configured: string | null = null
|
|
11
|
+
|
|
12
|
+
/** Seed from generator.json at boot (main + worker). null → no injection. */
|
|
13
|
+
export function configureGeneratorMeta(meta: string | null): void {
|
|
14
|
+
configured = meta
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getGeneratorMeta(): string | null {
|
|
18
|
+
return configured
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const GUARD = ENC.encode('name="generator"')
|
|
22
|
+
|
|
23
|
+
/** Splice `metaTag` immediately before the first `</head>` (case-insensitive).
|
|
24
|
+
* No </head> in the chunk, empty tag, or an existing generator meta → body
|
|
25
|
+
* returned untouched. Byte-level (no decode) — safe with multibyte content. */
|
|
26
|
+
export function injectGeneratorMeta(body: Uint8Array, metaTag: string | null): Uint8Array {
|
|
27
|
+
if (!metaTag) return body
|
|
28
|
+
const pos = findHeadCloseTag(body)
|
|
29
|
+
if (pos < 0) return body
|
|
30
|
+
if (bytesInclude(body, GUARD, pos)) return body
|
|
31
|
+
const tagBytes = ENC.encode(metaTag)
|
|
32
|
+
const out = new Uint8Array(body.length + tagBytes.length)
|
|
33
|
+
out.set(body.subarray(0, pos), 0)
|
|
34
|
+
out.set(tagBytes, pos)
|
|
35
|
+
out.set(body.subarray(pos), pos + tagBytes.length)
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True if `needle` occurs in `hay[0..limit)`. Naive scan — head is small. */
|
|
40
|
+
function bytesInclude(hay: Uint8Array, needle: Uint8Array, limit: number): boolean {
|
|
41
|
+
const max = Math.min(limit, hay.length) - needle.length
|
|
42
|
+
outer: for (let i = 0; i <= max; i++) {
|
|
43
|
+
for (let j = 0; j < needle.length; j++) {
|
|
44
|
+
if (hay[i + j] !== needle[j]) continue outer
|
|
45
|
+
}
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Byte scan for `</head>` (letters case-insensitive) — same approach as
|
|
52
|
+
* inject-css-link.ts. Returns offset of `<` or -1. */
|
|
53
|
+
function findHeadCloseTag(body: Uint8Array): number {
|
|
54
|
+
const LT = 0x3c
|
|
55
|
+
const SL = 0x2f
|
|
56
|
+
const GT = 0x3e
|
|
57
|
+
for (let i = 0, max = body.length - 6; i < max; i++) {
|
|
58
|
+
if (body[i] !== LT || body[i + 1] !== SL) continue
|
|
59
|
+
if (!isLetter(body[i + 2], 0x48)) continue // H
|
|
60
|
+
if (!isLetter(body[i + 3], 0x45)) continue // E
|
|
61
|
+
if (!isLetter(body[i + 4], 0x41)) continue // A
|
|
62
|
+
if (!isLetter(body[i + 5], 0x44)) continue // D
|
|
63
|
+
if (body[i + 6] !== GT) continue
|
|
64
|
+
return i
|
|
65
|
+
}
|
|
66
|
+
return -1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isLetter(b: number, u: number): boolean {
|
|
70
|
+
return b === u || b === (u | 0x20)
|
|
71
|
+
}
|
package/runtime/render/stream.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { injectDevClient } from './inject-dev-client.ts'
|
|
|
12
12
|
import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-prefix.ts'
|
|
13
13
|
import { injectBrustStore, buildStoreScripts } from './inject-store.ts'
|
|
14
14
|
import { getDevClientSnippet } from '../dev/inject.ts'
|
|
15
|
+
import { getGeneratorMeta, injectGeneratorMeta } from './inject-generator.ts'
|
|
15
16
|
|
|
16
17
|
export interface RenderBranchStreamingArgs {
|
|
17
18
|
element: ReactNode
|
|
@@ -51,6 +52,10 @@ export interface RenderBranchStreamingArgs {
|
|
|
51
52
|
* `<script data-brust-store="…">` blob before `</head>` (buffering) or into
|
|
52
53
|
* the streaming first-chunk prepend. Null/undefined → no injection. */
|
|
53
54
|
storeSnapshot?: Record<string, Record<string, unknown>> | null
|
|
55
|
+
/** Inject the islands importmap + bootstrap even when no <Island> rendered.
|
|
56
|
+
* SSG fallback shells need the bootstrap (client takeover runtime) on pages
|
|
57
|
+
* that may have zero real islands. */
|
|
58
|
+
forceIslands?: boolean
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
const encoder = new TextEncoder()
|
|
@@ -167,10 +172,11 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
167
172
|
async final(cb: (e?: Error | null) => void) {
|
|
168
173
|
try {
|
|
169
174
|
if (mode === 'buffering') {
|
|
170
|
-
const islandsUsed = islandUsedBox.used
|
|
175
|
+
const islandsUsed = islandUsedBox.used || (args.forceIslands ?? false)
|
|
171
176
|
let body = concatBuffers(buffer, islandsUsed)
|
|
172
177
|
const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
|
|
173
178
|
body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
|
|
179
|
+
body = injectGeneratorMeta(body, getGeneratorMeta())
|
|
174
180
|
body = injectDevClient(body, getDevClientSnippet())
|
|
175
181
|
body = injectActionPrefix(body, getActionPrefixSnippet())
|
|
176
182
|
body = injectBrustStore(body, args.storeSnapshot ?? null)
|
|
@@ -236,13 +242,15 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
236
242
|
const devTag = getDevClientSnippet() ?? ''
|
|
237
243
|
const prefixTag = getActionPrefixSnippet() ?? ''
|
|
238
244
|
const storeTag = buildStoreScripts(args.storeSnapshot ?? null)
|
|
245
|
+
const genTag = getGeneratorMeta() ?? ''
|
|
239
246
|
if (
|
|
240
247
|
linkTagsStr.length > 0 ||
|
|
241
248
|
devTag.length > 0 ||
|
|
242
249
|
prefixTag.length > 0 ||
|
|
243
|
-
storeTag.length > 0
|
|
250
|
+
storeTag.length > 0 ||
|
|
251
|
+
genTag.length > 0
|
|
244
252
|
) {
|
|
245
|
-
const prepend = encoder.encode(linkTagsStr + prefixTag + devTag + storeTag)
|
|
253
|
+
const prepend = encoder.encode(genTag + linkTagsStr + prefixTag + devTag + storeTag)
|
|
246
254
|
const out = new Uint8Array(flushed.length + prepend.length)
|
|
247
255
|
out.set(flushed, 0)
|
|
248
256
|
out.set(prepend, flushed.length)
|
package/runtime/routes.ts
CHANGED
|
@@ -189,6 +189,23 @@ export interface RouteCacheConfig<Params = Record<string, string>> {
|
|
|
189
189
|
tags?: string[]
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/** SSG config — read ONLY by `brust build --ssg`. Live server / dev ignore it.
|
|
193
|
+
* See docs/superpowers/specs/2026-06-12-ssg-dynamic-params-design.md. */
|
|
194
|
+
export interface RouteSsgConfig {
|
|
195
|
+
/** generateStaticParams: concrete param records to prerender. Each record
|
|
196
|
+
* must cover every `{name}` in the route's full path with a non-empty
|
|
197
|
+
* string. Sync or async. Values are URL-encoded into the crawl path. */
|
|
198
|
+
params?: () => Array<Record<string, string>> | Promise<Array<Record<string, string>>>
|
|
199
|
+
/** What non-prerendered paths do on a static host. 'none' (default) = skip
|
|
200
|
+
* → host 404 (today's behavior). 'client' = client-loader takeover
|
|
201
|
+
* (Phase B; requires `export const clientLoader` in the leaf component
|
|
202
|
+
* file, leaf must be a DEFAULT import in routes.tsx, React routes only). */
|
|
203
|
+
fallback?: 'none' | 'client'
|
|
204
|
+
/** Server-rendered loading UI baked into the fallback shell (Phase B).
|
|
205
|
+
* Renders in the leaf position with NO data. */
|
|
206
|
+
placeholder?: ComponentType
|
|
207
|
+
}
|
|
208
|
+
|
|
192
209
|
/** Shape returned by a middleware or by the terminal `next()` (loader + render).
|
|
193
210
|
* Middleware can short-circuit by returning a RouteResponse without calling next,
|
|
194
211
|
* or call next() and mutate the returned response (status, headers). */
|
|
@@ -322,6 +339,9 @@ export interface Route<Params = Record<string, string>, Data = unknown> {
|
|
|
322
339
|
/** Opt-in cache. Cache config from the leaf only — parent's cache is
|
|
323
340
|
* ignored when the route is reached as part of a chain. */
|
|
324
341
|
cache?: RouteCacheConfig
|
|
342
|
+
/** Opt-in SSG behavior for dynamic-param routes (`/blog/{slug}`). Only
|
|
343
|
+
* consulted by `brust build --ssg`. */
|
|
344
|
+
ssg?: RouteSsgConfig
|
|
325
345
|
/** Per-route middleware chain. Runs in declaration order; concatenated
|
|
326
346
|
* with parent middlewares (parent runs before child). Cache lookup
|
|
327
347
|
* still happens BEFORE any middleware (existing rule). */
|
|
@@ -1105,10 +1125,13 @@ export function makeRenderer(
|
|
|
1105
1125
|
// after loaders (buildRenderElement resolved) — that's where Spec A stores
|
|
1106
1126
|
// are seeded — and threaded into the render for <script> injection.
|
|
1107
1127
|
return await runInRequestContext(call.req?.cookies ?? {}, async () => {
|
|
1128
|
+
// Computed ONCE and shared by buildRenderElement (leaf swap) and
|
|
1129
|
+
// renderBranchStreaming (forceIslands) so the two can never diverge.
|
|
1130
|
+
const shellMode = wantsSsgFallbackShell(flat, call)
|
|
1108
1131
|
let element: ReactNode
|
|
1109
1132
|
let errorBoundary: ComponentType<{ error: Error }>
|
|
1110
1133
|
try {
|
|
1111
|
-
element = await buildRenderElement(call, flat, opts.getWorkerId)
|
|
1134
|
+
element = await buildRenderElement(call, flat, opts.getWorkerId, shellMode)
|
|
1112
1135
|
errorBoundary =
|
|
1113
1136
|
flat.errorBoundary ??
|
|
1114
1137
|
(({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
|
|
@@ -1142,6 +1165,9 @@ export function makeRenderer(
|
|
|
1142
1165
|
headers: flushSetCookie(verdict.headers),
|
|
1143
1166
|
routePath: flat.fullPath,
|
|
1144
1167
|
storeSnapshot,
|
|
1168
|
+
// SSG fallback shells have zero islands on the page but the
|
|
1169
|
+
// client-loader runtime still needs the importmap + bootstrap.
|
|
1170
|
+
forceIslands: shellMode,
|
|
1145
1171
|
})
|
|
1146
1172
|
// renderBranchStreaming wrote via the chunk channel.
|
|
1147
1173
|
return 0
|
|
@@ -1363,8 +1389,17 @@ async function navigationBranch(
|
|
|
1363
1389
|
} else {
|
|
1364
1390
|
// Wrap loader run (inside buildRenderElement) + render in one store scope so
|
|
1365
1391
|
// store reads resolve the per-request instance; collect after render.
|
|
1392
|
+
// Shell mode here only swaps the leaf for the marker div — no bootstrap
|
|
1393
|
+
// injection is needed in a NAV payload: the payload is swapped into a
|
|
1394
|
+
// document that already booted the bootstrap (the navigator IS the
|
|
1395
|
+
// bootstrap), and the takeover runtime imports its chunk itself.
|
|
1366
1396
|
fullHtml = await runInRequestContext(call.req?.cookies ?? {}, async () => {
|
|
1367
|
-
const element = await buildRenderElement(
|
|
1397
|
+
const element = await buildRenderElement(
|
|
1398
|
+
call as any,
|
|
1399
|
+
flat,
|
|
1400
|
+
getWorkerId,
|
|
1401
|
+
wantsSsgFallbackShell(flat, call as any),
|
|
1402
|
+
)
|
|
1368
1403
|
if (!element) throw new Error('render setup failed')
|
|
1369
1404
|
// Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
|
|
1370
1405
|
// their RESOLVED markup, not the fallback. renderToString would only
|
|
@@ -1498,6 +1533,33 @@ async function renderNativeRouteToHtml(
|
|
|
1498
1533
|
return decoder.decode(view.subarray(2 + metaLen, len))
|
|
1499
1534
|
}
|
|
1500
1535
|
|
|
1536
|
+
/** Param value the SSG build crawler substitutes for every `{param}` when it
|
|
1537
|
+
* requests the fallback SHELL of an `ssg.fallback: 'client'` route. */
|
|
1538
|
+
const SSG_FALLBACK_SENTINEL = '__brust_fallback__'
|
|
1539
|
+
|
|
1540
|
+
/** True when this render/navigation call is the SSG build crawler asking for
|
|
1541
|
+
* the fallback shell: the leaf declares `ssg.fallback: 'client'`, the route
|
|
1542
|
+
* has ≥1 param and EVERY param value is the literal sentinel, AND the request
|
|
1543
|
+
* carries `x-brust-ssg: 1` (BrustRequest.headers is a plain lower-cased
|
|
1544
|
+
* Record — see the type doc). Live traffic never sends the header, so the
|
|
1545
|
+
* sentinel namespace is unreachable in production; a forged header yields a
|
|
1546
|
+
* shell that renders LESS than a normal request (leaf loader skipped). */
|
|
1547
|
+
function wantsSsgFallbackShell(
|
|
1548
|
+
flat: FlatRoute,
|
|
1549
|
+
call: { params: Record<string, string>; req: BrustRequest },
|
|
1550
|
+
): boolean {
|
|
1551
|
+
const leaf = flat.chain[flat.chain.length - 1]
|
|
1552
|
+
if (leaf?.ssg?.fallback !== 'client') return false
|
|
1553
|
+
const names = Object.keys(call.params ?? {})
|
|
1554
|
+
// Parameterless routes can never be in shell mode — intentional, not a
|
|
1555
|
+
// vacuous-truth bug: expandDynamicRoutes already rejects ssg config on
|
|
1556
|
+
// routes without {param}, and a no-param route is always fully
|
|
1557
|
+
// prerenderable.
|
|
1558
|
+
if (names.length === 0) return false
|
|
1559
|
+
if (!names.every((name) => call.params[name] === SSG_FALLBACK_SENTINEL)) return false
|
|
1560
|
+
return call.req?.headers?.['x-brust-ssg'] === '1'
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1501
1563
|
/** Build the React element for a render or navigation call: runs loaders
|
|
1502
1564
|
* top-down, builds the element bottom-up wrapping in OutletContext.Provider
|
|
1503
1565
|
* so nested routes receive the deeper element via <Outlet />. The caller
|
|
@@ -1505,6 +1567,12 @@ async function renderNativeRouteToHtml(
|
|
|
1505
1567
|
* middleware chain BEFORE calling this — this helper assumes middleware
|
|
1506
1568
|
* has already passed.
|
|
1507
1569
|
*
|
|
1570
|
+
* SSG fallback-shell mode (`wantsSsgFallbackShell`): parent loaders still run
|
|
1571
|
+
* (layouts need their data) but the LEAF loader is skipped and the leaf
|
|
1572
|
+
* Component is replaced by the marker div the client-loader runtime mounts
|
|
1573
|
+
* into. Both the render branch and navigationBranch flow through here, so the
|
|
1574
|
+
* shell swap covers full-document AND SPA-navigation payloads.
|
|
1575
|
+
*
|
|
1508
1576
|
* Throws on setup failure (loader throw, etc.). The caller synthesises a 500
|
|
1509
1577
|
* in that case.
|
|
1510
1578
|
*/
|
|
@@ -1512,19 +1580,25 @@ async function buildRenderElement(
|
|
|
1512
1580
|
call: Extract<RouteCall, { kind: 'render' }>,
|
|
1513
1581
|
flat: FlatRoute,
|
|
1514
1582
|
getWorkerId?: () => number | null,
|
|
1583
|
+
// Computed ONCE by the caller (and reused there for forceIslands) so the
|
|
1584
|
+
// shell decision and the bootstrap-injection decision can never diverge.
|
|
1585
|
+
shellMode = false,
|
|
1515
1586
|
): Promise<ReactNode> {
|
|
1516
1587
|
call.req.signal = NEVER_ABORTS
|
|
1517
1588
|
const workerId = getWorkerId ? getWorkerId() : null
|
|
1518
1589
|
const chainNodes = flat.chain
|
|
1590
|
+
const leafIdx = chainNodes.length - 1
|
|
1519
1591
|
|
|
1520
1592
|
// 1. Run loaders top-down (parent → leaf). Each Component receives ONLY
|
|
1521
|
-
// its own loader's data — no merge, no inheritance.
|
|
1593
|
+
// its own loader's data — no merge, no inheritance. Shell mode skips
|
|
1594
|
+
// the LEAF loader (its data comes from the clientLoader at runtime).
|
|
1522
1595
|
const datas: unknown[] = new Array(chainNodes.length)
|
|
1523
1596
|
for (let i = 0; i < chainNodes.length; i++) {
|
|
1524
1597
|
const r = chainNodes[i]
|
|
1525
|
-
datas[i] =
|
|
1526
|
-
|
|
1527
|
-
|
|
1598
|
+
datas[i] =
|
|
1599
|
+
r.loader && !(shellMode && i === leafIdx)
|
|
1600
|
+
? await r.loader({ params: call.params, path: call.path, req: call.req })
|
|
1601
|
+
: undefined
|
|
1528
1602
|
}
|
|
1529
1603
|
|
|
1530
1604
|
// 2. Build the React element bottom-up. Each level wraps the deeper level
|
|
@@ -1533,13 +1607,23 @@ async function buildRenderElement(
|
|
|
1533
1607
|
let element: ReactNode = null
|
|
1534
1608
|
for (let i = chainNodes.length - 1; i >= 0; i--) {
|
|
1535
1609
|
const r = chainNodes[i]
|
|
1536
|
-
const node =
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1610
|
+
const node =
|
|
1611
|
+
shellMode && i === leafIdx
|
|
1612
|
+
? createElement(
|
|
1613
|
+
'div',
|
|
1614
|
+
{
|
|
1615
|
+
'data-brust-fallback-root': '',
|
|
1616
|
+
'data-brust-fallback': flat.fullPath,
|
|
1617
|
+
},
|
|
1618
|
+
r.ssg?.placeholder ? createElement(r.ssg.placeholder) : null,
|
|
1619
|
+
)
|
|
1620
|
+
: createElement(r.Component!, {
|
|
1621
|
+
params: call.params,
|
|
1622
|
+
path: call.path,
|
|
1623
|
+
data: datas[i],
|
|
1624
|
+
workerId,
|
|
1625
|
+
req: call.req,
|
|
1626
|
+
})
|
|
1543
1627
|
element = createElement(OutletContext.Provider, { value: element }, node)
|
|
1544
1628
|
}
|
|
1545
1629
|
return element
|