@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.5
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/README.md +64 -6
- package/native/index.d.ts +39 -4
- package/native/index.js +56 -54
- package/package.json +17 -10
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +24 -2
- package/src/actions-bridge.ts +2 -2
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +199 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +39 -13
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +7 -3
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +14 -9
- package/src/build/compile.ts +41 -4
- package/src/build/config.ts +46 -10
- package/src/build/css.ts +286 -0
- package/src/build/index.ts +135 -20
- package/src/build/manifest.ts +23 -4
- package/src/build/scan.ts +59 -20
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build-time.ts +10 -0
- package/src/cf.ts +2 -1
- package/src/cli.ts +19 -4
- package/src/config.ts +111 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +11 -28
- package/src/handlers.ts +41 -2
- package/src/index.ts +3 -1
- package/src/native.ts +18 -0
- package/src/routes.ts +20 -1
- package/src/run.ts +40 -8
- package/src/worker.ts +88 -20
package/src/build/index.ts
CHANGED
|
@@ -7,14 +7,20 @@
|
|
|
7
7
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
|
|
10
|
-
import { BrustRouteError, validateRoutes } from '../routes'
|
|
11
|
-
import { type BrowserCtx, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
|
|
10
|
+
import { BrustRouteError, type FlatRoute, validateRoutes } from '../routes'
|
|
11
|
+
import { type BrowserCtx, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
|
|
12
|
+
import { runActionsOnly } from './actions-only'
|
|
12
13
|
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
13
|
-
import {
|
|
14
|
+
import { renderIcons } from './build-icons'
|
|
15
|
+
import { evaluateBuildSlots } from './build-eval'
|
|
16
|
+
import { type Compiled, compileApp } from './compile'
|
|
17
|
+
import { buildCss, CssGraph, directCss, resolveGlobals, routeCssSources } from './css'
|
|
14
18
|
import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
|
|
15
19
|
import { BuildError } from './errors'
|
|
16
|
-
import { type CacheRecord, type ManifestJson, writeManifest } from './manifest'
|
|
20
|
+
import { type CacheRecord, type ManifestJson, tierOf, writeManifest } from './manifest'
|
|
17
21
|
import { scanRoutes } from './scan'
|
|
22
|
+
import { type ExprReads, prepareStatic, writeStatic } from './static'
|
|
23
|
+
import { writeStaticWorker } from './static-worker'
|
|
18
24
|
|
|
19
25
|
export { BuildError } from './errors'
|
|
20
26
|
|
|
@@ -80,21 +86,40 @@ export async function runBuild(opts: {
|
|
|
80
86
|
outDir: string
|
|
81
87
|
/** Replace a non-empty out dir even without a previous build's marker. */
|
|
82
88
|
force?: boolean
|
|
83
|
-
/** `cloudflare` also writes `<out>/cf/` (M4-CF);
|
|
84
|
-
|
|
89
|
+
/** `cloudflare` also writes `<out>/cf/` (M4-CF); `static` also writes `<out>/static/` (gaps round
|
|
90
|
+
* L9); default `bun` = the unchanged build. */
|
|
91
|
+
target?: 'bun' | 'cloudflare' | 'static'
|
|
92
|
+
/** `--strict` (static): a loader that reads the request fails the build. */
|
|
93
|
+
strict?: boolean
|
|
94
|
+
/** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
|
|
95
|
+
exprReads?: ExprReads
|
|
85
96
|
/** `--wasm-from-source` (cloudflare only). */
|
|
86
97
|
wasmFromSource?: boolean
|
|
98
|
+
/** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
|
|
99
|
+
actionsOnly?: boolean
|
|
100
|
+
/** `brust build --dev` (L14, used by `brust dev`): also writes `dev.json` and builds the client runtime with `__BRUST_DEV__`. */
|
|
101
|
+
dev?: boolean
|
|
87
102
|
log: (s: string) => void
|
|
88
103
|
}): Promise<void> {
|
|
104
|
+
setDevBuild(opts.dev === true)
|
|
89
105
|
const appRoot = resolve(opts.appRoot)
|
|
90
106
|
const entry = resolve(appRoot, opts.entry)
|
|
91
107
|
const dist = resolve(appRoot, opts.outDir)
|
|
92
108
|
assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
|
|
93
109
|
assertReplaceable(dist, opts.force)
|
|
94
|
-
const
|
|
110
|
+
const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
|
|
111
|
+
if (opts.actionsOnly)
|
|
112
|
+
return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
|
|
113
|
+
const { serverOnly, cloudflare, cache, cssGlobal } = built
|
|
114
|
+
// L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
|
|
115
|
+
// is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
|
|
116
|
+
const remote =
|
|
117
|
+
built.actions !== null && built.actions.baseUrl !== undefined && !built.actions.entryExplicit && !statOk(resolve(appRoot, built.actions.entry)) ? built.actions : null
|
|
118
|
+
const actions = remote ? null : built.actions
|
|
119
|
+
if (actions?.prefix === '/') throw new BuildError('config', 'actions.prefix "/" is only for an actions-only build (brust build --actions-only) or a page-only build that points at one')
|
|
95
120
|
|
|
96
121
|
// Everything that can fail on the app's own input runs before a byte is written.
|
|
97
|
-
const { routes, componentFile } = await scanRoutes(entry)
|
|
122
|
+
const { routes, componentFile, componentIssue } = await scanRoutes(entry, appRoot)
|
|
98
123
|
let leaves: ReturnType<typeof validateRoutes>
|
|
99
124
|
try {
|
|
100
125
|
leaves = validateRoutes(routes)
|
|
@@ -129,8 +154,16 @@ export async function runBuild(opts: {
|
|
|
129
154
|
}
|
|
130
155
|
assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : []), ...(cacheModule ? [cacheModule] : [])])
|
|
131
156
|
|
|
157
|
+
// R9.3/R9.4: every reason to refuse a static build, before any write.
|
|
158
|
+
const ssgPlan =
|
|
159
|
+
opts.target === 'static'
|
|
160
|
+
? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache)
|
|
161
|
+
: null
|
|
162
|
+
|
|
132
163
|
// Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
|
|
133
|
-
|
|
164
|
+
// L12: a static build with `[actions]` also builds the Cloudflare worker (its Pages `_worker.js`).
|
|
165
|
+
const hybrid = ssgPlan !== null && actions !== null
|
|
166
|
+
const cf = opts.target === 'cloudflare' || hybrid ? await prepareCloudflare(appRoot, cloudflare, { fromSource: opts.wasmFromSource === true, env: process.env }) : null
|
|
134
167
|
|
|
135
168
|
// Sibling of `dist` (same filesystem: the final rename is atomic), fixed name (no random bytes in
|
|
136
169
|
// anything the bundler sees).
|
|
@@ -138,7 +171,13 @@ export async function runBuild(opts: {
|
|
|
138
171
|
rmSync(tmp, { recursive: true, force: true })
|
|
139
172
|
mkdirSync(tmp, { recursive: true })
|
|
140
173
|
try {
|
|
141
|
-
await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, cache, cacheModule, cf, log: opts.log })
|
|
174
|
+
await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, actionsRemote: remote?.prefix ?? null, log: opts.log })
|
|
175
|
+
if (ssgPlan) {
|
|
176
|
+
// Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
|
|
177
|
+
const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
|
|
178
|
+
await writeStatic(tmp, { entry, appRoot, plan: ssgPlan, version, strict: opts.strict === true, log: opts.log, shownDir: join(dist, 'static') })
|
|
179
|
+
if (hybrid && cf && actions) writeStaticWorker(tmp, actions, cf, opts.log)
|
|
180
|
+
}
|
|
142
181
|
rmSync(join(tmp, '.stage'), { recursive: true, force: true })
|
|
143
182
|
if (existsSync(dist)) {
|
|
144
183
|
const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
|
|
@@ -156,12 +195,15 @@ export async function runBuild(opts: {
|
|
|
156
195
|
async function buildInto(
|
|
157
196
|
out: string,
|
|
158
197
|
o: {
|
|
198
|
+
/** Write `dev.json` (L14). */
|
|
199
|
+
dev: boolean
|
|
159
200
|
appRoot: string
|
|
160
201
|
entry: string
|
|
161
202
|
/** The final dist dir (`index.js` points at the entry relative to it). */
|
|
162
203
|
dist: string
|
|
163
204
|
leaves: ReturnType<typeof validateRoutes>
|
|
164
205
|
componentFile: Map<Function, string>
|
|
206
|
+
componentIssue: Map<Function, string>
|
|
165
207
|
serverOnly: string[]
|
|
166
208
|
actions: ActionsBuildConfig | null
|
|
167
209
|
/** Absolute path of the actions entry (null when `[actions]` is off). */
|
|
@@ -170,21 +212,52 @@ async function buildInto(
|
|
|
170
212
|
/** Absolute path of a custom cache adapter module. */
|
|
171
213
|
cacheModule: string | null
|
|
172
214
|
cf: CfPrepared | null
|
|
215
|
+
/** `[css] global` as written (app-root relative). */
|
|
216
|
+
cssGlobal: string[]
|
|
217
|
+
/** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
|
|
218
|
+
actionsRemote: string | null
|
|
219
|
+
/** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
|
|
220
|
+
hybrid: boolean
|
|
173
221
|
log: (s: string) => void
|
|
174
222
|
},
|
|
175
223
|
): Promise<void> {
|
|
176
224
|
const { appRoot, entry, leaves } = o
|
|
177
|
-
|
|
225
|
+
// L19: every stylesheet first, so each JS build below reads one set of CSS Module class maps.
|
|
226
|
+
const graph = new CssGraph()
|
|
227
|
+
const chainFiles = (l: FlatRoute) => l.chain.flatMap((n) => (n.Component && o.componentFile.has(n.Component) ? [o.componentFile.get(n.Component)!] : []))
|
|
228
|
+
const routeCss = routeCssSources(leaves, chainFiles, resolveGlobals(appRoot, o.cssGlobal), graph)
|
|
229
|
+
const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
|
|
230
|
+
mkdirSync(join(out, 'client'), { recursive: true })
|
|
231
|
+
// Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
|
|
232
|
+
const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
|
|
233
|
+
for (const c of directCss(entry))
|
|
234
|
+
o.log(`[brust] warning css ${relative(appRoot, entry)} imports ${c}: the route entry belongs to no route, so it is linked nowhere; import it from a layout, or list it in [css] global`)
|
|
235
|
+
const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps }
|
|
178
236
|
// The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
|
|
179
237
|
const runtime = await buildRuntime(out, ctx)
|
|
180
|
-
const
|
|
238
|
+
const compileOpts = {
|
|
181
239
|
appRoot,
|
|
182
240
|
leaves,
|
|
183
241
|
componentFile: o.componentFile,
|
|
242
|
+
componentIssue: o.componentIssue,
|
|
184
243
|
runtimeImport: `/_brust/${runtime}`,
|
|
185
244
|
serverOnly: o.serverOnly,
|
|
186
|
-
|
|
187
|
-
}
|
|
245
|
+
cssMaps: css.maps,
|
|
246
|
+
}
|
|
247
|
+
const firstLog: string[] = []
|
|
248
|
+
let { compiled, routeComponent } = compileApp({ ...compileOpts, log: (l) => firstLog.push(l) })
|
|
249
|
+
// L23: lucide icons the compiler could inline are rendered here (react-dom/server); L2: slots that read
|
|
250
|
+
// nothing request- or time-dependent are computed once, here. The app is compiled again with both (an icon with
|
|
251
|
+
// no markup stays the L22 island; a slot with a value is a literal: no job, no worker). The first compile's
|
|
252
|
+
// warnings describe icons that are inline now, so when icons were rendered only the last compile's are shown.
|
|
253
|
+
const hasIcons = [...compiled.values()].some((c) => (c.ir.icon_requests ?? []).length > 0)
|
|
254
|
+
const iconSvgs = hasIcons ? await renderIcons(compiled, appRoot, o.log) : undefined
|
|
255
|
+
const slotValues = await evaluateBuildSlots(out, compiled, ctx.sourceOf, appRoot, o.log)
|
|
256
|
+
const hasSlots = Object.keys(slotValues).length > 0
|
|
257
|
+
if (hasIcons || hasSlots) {
|
|
258
|
+
if (!hasIcons) firstLog.forEach((l) => o.log(l))
|
|
259
|
+
;({ compiled, routeComponent } = compileApp({ ...compileOpts, iconSvgs, iconsFinal: hasIcons, slotValues, log: hasIcons ? o.log : () => {} }))
|
|
260
|
+
} else firstLog.forEach((l) => o.log(l))
|
|
188
261
|
|
|
189
262
|
mkdirSync(join(out, 'jinja'), { recursive: true })
|
|
190
263
|
for (const c of compiled.values()) writeFileSync(join(out, 'jinja', `${c.id}.jinja`), c.jinja)
|
|
@@ -192,13 +265,19 @@ async function buildInto(
|
|
|
192
265
|
const runtimeUrl = `/_brust/${runtime}`
|
|
193
266
|
const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrl)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrl))])
|
|
194
267
|
const manifest = writeManifest({ leaves, routeComponent, compiled, runtime, chunks })
|
|
195
|
-
|
|
268
|
+
for (const r of manifest.routes) {
|
|
269
|
+
const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
|
|
270
|
+
if (files.length > 0) r.css = files
|
|
271
|
+
}
|
|
272
|
+
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps)
|
|
196
273
|
manifest.actions =
|
|
197
274
|
o.actions && o.actionsEntry
|
|
198
|
-
? { entry: await buildActions(out, o.actionsEntry), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite }
|
|
275
|
+
? { entry: await buildActions(out, o.actionsEntry, css.maps), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite }
|
|
199
276
|
: null
|
|
200
|
-
if (o.
|
|
277
|
+
if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
|
|
278
|
+
if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
|
|
201
279
|
writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
280
|
+
if (o.dev) writeFileSync(join(out, 'dev.json'), `${JSON.stringify(devJson(appRoot, compiled), null, 2)}\n`)
|
|
202
281
|
writeFileSync(join(out, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
|
|
203
282
|
|
|
204
283
|
const pub = join(appRoot, 'public')
|
|
@@ -206,7 +285,19 @@ async function buildInto(
|
|
|
206
285
|
if (o.cf) {
|
|
207
286
|
const warnings = await buildCloudflare(
|
|
208
287
|
out,
|
|
209
|
-
{
|
|
288
|
+
{
|
|
289
|
+
appRoot,
|
|
290
|
+
entry,
|
|
291
|
+
dist: o.dist,
|
|
292
|
+
compiled,
|
|
293
|
+
manifest: o.hybrid ? { ...manifest, cache: undefined } : manifest,
|
|
294
|
+
sourceOf: ctx.sourceOf,
|
|
295
|
+
actionsEntry: o.actionsEntry,
|
|
296
|
+
cacheModule: o.hybrid ? null : o.cacheModule,
|
|
297
|
+
cacheGetTimeoutMs: o.hybrid ? undefined : o.cache?.getTimeoutMs,
|
|
298
|
+
cssMaps: css.maps,
|
|
299
|
+
log: o.log,
|
|
300
|
+
},
|
|
210
301
|
o.cf,
|
|
211
302
|
)
|
|
212
303
|
for (const w of warnings) o.log(w)
|
|
@@ -227,10 +318,34 @@ async function buildInto(
|
|
|
227
318
|
)
|
|
228
319
|
}
|
|
229
320
|
|
|
321
|
+
/** `dev.json` (L14, R14.2): component id → source file, tier, the react reason, diagnostics. Only `brust build --dev` writes it. */
|
|
322
|
+
export function devJson(appRoot: string, compiled: Map<string, Compiled>): { version: 1; components: Record<string, DevComponent> } {
|
|
323
|
+
const components: Record<string, DevComponent> = {}
|
|
324
|
+
for (const c of compiled.values()) {
|
|
325
|
+
const tier = tierOf(c.ir)
|
|
326
|
+
const t = c.ir.tier
|
|
327
|
+
components[c.id] = {
|
|
328
|
+
file: relative(appRoot, c.file),
|
|
329
|
+
tier,
|
|
330
|
+
...(tier === 'react' && t && typeof t === 'object' && 'React' in t ? { reason: String((t as { React: { reason: string } }).React.reason) } : {}),
|
|
331
|
+
diagnostics: (c.diagnostics ?? []).map((d) => ({ class: d.class, rule: d.rule, line: d.line, col: d.col, message: d.message, remediation: d.remediation })),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { version: 1, components }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface DevComponent {
|
|
338
|
+
file: string
|
|
339
|
+
tier: 'static' | 'native' | 'react'
|
|
340
|
+
/** Why the compiler chose the react tier (the IR's `React.reason`). */
|
|
341
|
+
reason?: string
|
|
342
|
+
diagnostics: { class: string; rule: string; line: number; col: number; message: string; remediation: string }[]
|
|
343
|
+
}
|
|
344
|
+
|
|
230
345
|
/** `manifest.cache` (R7, R8): the custom module bundled for the worker, the native defaults, and the
|
|
231
346
|
* build id every L2 key carries. */
|
|
232
|
-
async function cacheRecord(out: string, manifest: ManifestJson, c: CacheBuildConfig, moduleAbs: string | null): Promise<CacheRecord> {
|
|
233
|
-
const module = moduleAbs ? await buildCacheAdapter(out, moduleAbs) : undefined
|
|
347
|
+
async function cacheRecord(out: string, manifest: ManifestJson, c: CacheBuildConfig, moduleAbs: string | null, cssMaps: Map<string, Record<string, string>>): Promise<CacheRecord> {
|
|
348
|
+
const module = moduleAbs ? await buildCacheAdapter(out, moduleAbs, cssMaps) : undefined
|
|
234
349
|
return {
|
|
235
350
|
adapter: c.adapter,
|
|
236
351
|
...(module ? { module } : {}),
|
package/src/build/manifest.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface ChildRecord {
|
|
|
29
29
|
id: string
|
|
30
30
|
instances: string
|
|
31
31
|
props: Record<string, string>
|
|
32
|
+
/** F105: constant props; part of the child's job keys. */
|
|
33
|
+
literals?: Record<string, unknown>
|
|
34
|
+
/** F105: jinja over `__props` (and `__row`/`__idx` per row) the server evaluates. */
|
|
35
|
+
exprs?: Record<string, string>
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export interface ComponentRecord {
|
|
@@ -49,6 +53,8 @@ export interface RouteRecord {
|
|
|
49
53
|
loaders: string[]
|
|
50
54
|
cache: { ttl_seconds: number; prefix: string | null; bypass: true | string | null; tags: string[] } | null
|
|
51
55
|
catch_all: boolean
|
|
56
|
+
/** L19 (R19.3): `client/<file>.css` in cascade order; absent when the route has none. */
|
|
57
|
+
css?: string[]
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/** `manifest.actions` (A3): `entry` is relative to dist; `maxBodyBytes`/`maxResponseBytes` are camelCase by contract. */
|
|
@@ -60,13 +66,20 @@ export interface ActionsRecord {
|
|
|
60
66
|
crossSite: 'reject' | 'allow'
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/** The manifest format the Rust loader accepts (`MANIFEST_VERSION` in brust-core `manifest.rs`): the one place TS names it. */
|
|
70
|
+
export const MANIFEST_VERSION = 2 as const
|
|
71
|
+
|
|
63
72
|
export interface ManifestJson {
|
|
64
|
-
version:
|
|
73
|
+
version: typeof MANIFEST_VERSION
|
|
65
74
|
routes: RouteRecord[]
|
|
66
75
|
components: Record<string, ComponentRecord>
|
|
67
76
|
assets: { runtime: string }
|
|
68
77
|
jobs_module: string
|
|
69
78
|
actions: ActionsRecord | null
|
|
79
|
+
/** L16 R16.3: an actions-only build (no routes, no templates); everything but the prefix is a 404 problem+json. */
|
|
80
|
+
actions_only?: true
|
|
81
|
+
/** L16 R16.4b: a page-only build: no local actions app, `base_url` points at the deployment that has it. */
|
|
82
|
+
actions_remote?: { prefix: string }
|
|
70
83
|
/** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
|
|
71
84
|
cache?: CacheRecord
|
|
72
85
|
}
|
|
@@ -231,10 +244,16 @@ export function childRecords(c: Compiled, clientOnly: string[] = []): ChildRecor
|
|
|
231
244
|
const props: Record<string, string> = {}
|
|
232
245
|
for (const [name, path] of Object.entries(inst.props)) {
|
|
233
246
|
if (path === null)
|
|
234
|
-
throw new BuildError(
|
|
247
|
+
throw new BuildError(
|
|
248
|
+
'instance-prop-path',
|
|
249
|
+
`${ir.id}: child ${inst.child_id} prop ${name} is not a props path, a JSON literal or a template expression over props; compute it in a loader, or wrap the child's derived value in cache()`,
|
|
250
|
+
)
|
|
235
251
|
props[name] = path
|
|
236
252
|
}
|
|
237
|
-
|
|
253
|
+
const rec: ChildRecord = { id: inst.child_id, instances: list === undefined ? 'static' : `per-row:${list}`, props }
|
|
254
|
+
if (inst.literals && Object.keys(inst.literals).length > 0) rec.literals = { ...inst.literals }
|
|
255
|
+
if (inst.exprs && Object.keys(inst.exprs).length > 0) rec.exprs = { ...inst.exprs }
|
|
256
|
+
return rec
|
|
238
257
|
})
|
|
239
258
|
for (const id of clientOnly) out.push({ id, instances: 'static', props: {} })
|
|
240
259
|
return out
|
|
@@ -302,7 +321,7 @@ export function writeManifest(opts: {
|
|
|
302
321
|
}
|
|
303
322
|
})
|
|
304
323
|
linkNativeChunks(routes, compiled, components)
|
|
305
|
-
return { version:
|
|
324
|
+
return { version: MANIFEST_VERSION, routes, components, assets: { runtime: opts.runtime }, jobs_module: 'jobs.js', actions: opts.actions ?? null }
|
|
306
325
|
}
|
|
307
326
|
|
|
308
327
|
/** S6 amendment (F66): the server's asset injection walks only each chain component's direct
|
package/src/build/scan.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// Route scan (plan T5): import the route entry, then
|
|
2
|
-
// file it is the default export of. The compiler only ever sees files, never functions.
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { dirname } from 'node:path'
|
|
1
|
+
// Route scan (plan T5, gaps L7): import the route entry, then walk its static import graph (inside the app root,
|
|
2
|
+
// outside node_modules) and map every Component to the `.tsx` file it is the default export of. The compiler only ever sees files, never functions.
|
|
3
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
4
|
+
import { dirname, resolve, sep } from 'node:path'
|
|
5
5
|
import { BrustRouteError, type Route } from '../routes'
|
|
6
6
|
import { BuildError } from './errors'
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/** `componentIssue` explains a Component the walk saw but could not map (named-only, outside the root, in node_modules). */
|
|
9
|
+
export async function scanRoutes(
|
|
10
|
+
entryFile: string,
|
|
11
|
+
appRoot?: string,
|
|
12
|
+
): Promise<{ routes: Route[]; componentFile: Map<Function, string>; componentIssue: Map<Function, string> }> {
|
|
11
13
|
// biome-ignore lint/suspicious/noExplicitAny: an arbitrary user module
|
|
12
14
|
let mod: any
|
|
13
15
|
try {
|
|
@@ -21,22 +23,59 @@ export async function scanRoutes(entryFile: string): Promise<{ routes: Route[];
|
|
|
21
23
|
if (!Array.isArray(routes))
|
|
22
24
|
throw new BuildError('route-entry', `${entryFile} exports neither \`routes\` nor a default route array`)
|
|
23
25
|
|
|
24
|
-
const src = readFileSync(entryFile, 'utf8')
|
|
25
|
-
const real = new Set(new Bun.Transpiler({ loader: 'tsx' }).scanImports(src).map((i) => i.path))
|
|
26
26
|
const componentFile = new Map<Function, string>()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
const componentIssue = new Map<Function, string>()
|
|
28
|
+
const root = realpathSync(resolve(appRoot ?? dirname(entryFile)))
|
|
29
|
+
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
|
|
30
|
+
const inRoot = (f: string) => f.startsWith(root + sep) && !f.split(sep).includes('node_modules')
|
|
31
|
+
const visited = new Set<string>()
|
|
32
|
+
// Post-order: a file's imports are mapped before the file itself, and the first file to claim a function
|
|
33
|
+
// keeps it, so the module that defines a Component beats a barrel that re-exports it.
|
|
34
|
+
const visit = async (file: string): Promise<void> => {
|
|
35
|
+
if (visited.has(file)) return
|
|
36
|
+
visited.add(file)
|
|
37
|
+
let src: string
|
|
31
38
|
try {
|
|
32
|
-
|
|
39
|
+
src = readFileSync(file, 'utf8')
|
|
33
40
|
} catch {
|
|
34
|
-
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const loader = file.endsWith('.tsx') ? transpiler : file.endsWith('.ts') ? new Bun.Transpiler({ loader: 'ts' }) : null
|
|
44
|
+
if (!loader) return
|
|
45
|
+
const deps: string[] = []
|
|
46
|
+
// Only static imports are followed: a dynamic import() is not part of the route table's static graph.
|
|
47
|
+
for (const imp of loader.scanImports(src)) {
|
|
48
|
+
if (imp.kind !== 'import-statement') continue
|
|
49
|
+
let dep: string
|
|
50
|
+
try {
|
|
51
|
+
dep = Bun.resolveSync(imp.path, dirname(file))
|
|
52
|
+
} catch {
|
|
53
|
+
continue // a bare specifier that does not resolve; a route using it fails in compileApp
|
|
54
|
+
}
|
|
55
|
+
if (!dep.endsWith('.tsx') && !dep.endsWith('.ts')) continue
|
|
56
|
+
if (inRoot(dep)) deps.push(dep)
|
|
57
|
+
else if (dep.endsWith('.tsx')) await note(dep, dep.split(sep).includes('node_modules') ? 'in node_modules' : `outside the app root ${root}`)
|
|
58
|
+
}
|
|
59
|
+
for (const dep of deps) await visit(dep)
|
|
60
|
+
if (!file.endsWith('.tsx')) return
|
|
61
|
+
const m = await import(file)
|
|
62
|
+
for (const [name, v] of Object.entries(m)) {
|
|
63
|
+
if (typeof v !== 'function') continue
|
|
64
|
+
if (name === 'default') {
|
|
65
|
+
if (!componentFile.has(v)) componentFile.set(v, file)
|
|
66
|
+
} else if (!componentIssue.has(v)) {
|
|
67
|
+
componentIssue.set(v, `${v.name || name} is only a named export of ${file} (export ${name}): export it as default`)
|
|
68
|
+
}
|
|
35
69
|
}
|
|
36
|
-
if (!file.endsWith('.tsx')) continue // not a component source; a route using it fails in compileApp
|
|
37
|
-
const def = (await import(file)).default
|
|
38
|
-
// Bun's module cache: the same function object the routes tree holds.
|
|
39
|
-
if (typeof def === 'function') componentFile.set(def, file)
|
|
40
70
|
}
|
|
41
|
-
|
|
71
|
+
const note = async (file: string, why: string) => {
|
|
72
|
+
try {
|
|
73
|
+
const def = (await import(file)).default
|
|
74
|
+
if (typeof def === 'function' && !componentIssue.has(def)) componentIssue.set(def, `${def.name || '(anonymous)'} lives in ${file}, which is ${why}`)
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
await visit(entryFile)
|
|
78
|
+
// A function reachable as a default somewhere is fine; only an unmapped one keeps its explanation.
|
|
79
|
+
for (const f of componentFile.keys()) componentIssue.delete(f)
|
|
80
|
+
return { routes, componentFile, componentIssue }
|
|
42
81
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The child process `brust build --target static` renders in (gaps round L9): boots the real server
|
|
2
|
+
// on loopback with the normal workers (`run()`, so every byte is what `brust start` sends), fetches
|
|
3
|
+
// each planned path, writes the responses to `BRUST_SSG_OUT`, then drains. A child process, because
|
|
4
|
+
// the napi server is a per-process singleton. Input: `BRUST_SSG_PLAN` (a JSON list of paths).
|
|
5
|
+
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { run } from '../run'
|
|
7
|
+
|
|
8
|
+
export interface Rendered {
|
|
9
|
+
path: string
|
|
10
|
+
status: number
|
|
11
|
+
headers: Record<string, string>
|
|
12
|
+
/** base64 */
|
|
13
|
+
body: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A page that did not answer within the per-page limit (review N3). */
|
|
17
|
+
export class PageTimeout extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
readonly path: string,
|
|
20
|
+
readonly ms: number,
|
|
21
|
+
) {
|
|
22
|
+
super(`GET ${path} did not answer within ${ms} ms`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The seam an in-process host can replace later: GET each path from `base`, uncompressed, no redirect
|
|
27
|
+
* following, each within `pageMs` (`PageTimeout`). `started` hears each path before it is requested. */
|
|
28
|
+
export async function renderPaths(base: string, paths: string[], pageMs = 30_000, started: (path: string) => void = () => {}): Promise<Rendered[]> {
|
|
29
|
+
const out: Rendered[] = []
|
|
30
|
+
for (const path of paths) {
|
|
31
|
+
started(path)
|
|
32
|
+
const signal = AbortSignal.timeout(pageMs)
|
|
33
|
+
try {
|
|
34
|
+
const r = await fetch(base + path, { redirect: 'manual', headers: { 'accept-encoding': 'identity' }, signal })
|
|
35
|
+
out.push({ path, status: r.status, headers: Object.fromEntries(r.headers), body: Buffer.from(await r.arrayBuffer()).toString('base64') })
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (signal.aborted) throw new PageTimeout(path, pageMs)
|
|
38
|
+
throw e
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (import.meta.main) {
|
|
45
|
+
const env = process.env
|
|
46
|
+
const paths = JSON.parse(readFileSync(env.BRUST_SSG_PLAN!, 'utf8')) as string[]
|
|
47
|
+
await run({
|
|
48
|
+
distDir: env.BRUST_DIST_DIR,
|
|
49
|
+
entry: env.BRUST_APP_ENTRY,
|
|
50
|
+
config: { host: '127.0.0.1', port: 0 },
|
|
51
|
+
onReady: async (addr) => {
|
|
52
|
+
const pageMs = Number(env.BRUST_SSG_PAGE_TIMEOUT_MS) || 30_000
|
|
53
|
+
const progress = env.BRUST_SSG_PROGRESS
|
|
54
|
+
try {
|
|
55
|
+
const pages = await renderPaths(`http://${addr}`, paths, pageMs, (p) => progress && appendFileSync(progress, `${p}\n`))
|
|
56
|
+
writeFileSync(env.BRUST_SSG_OUT!, JSON.stringify({ addr, pages }))
|
|
57
|
+
return 0
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (!(e instanceof PageTimeout)) throw e
|
|
60
|
+
writeFileSync(env.BRUST_SSG_OUT!, JSON.stringify({ addr, timeout: { path: e.path, ms: e.ms } }))
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
}
|
package/src/build/ssg.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// `brust build --target static` planner (gaps round L9, R9.2-R9.3): which paths to render and the
|
|
2
|
+
// file each one is written to. Pure: no server, no filesystem. `params()` results come in resolved.
|
|
3
|
+
import type { FlatRoute, RouteSsgConfig } from '../routes'
|
|
4
|
+
import { BuildError } from './errors'
|
|
5
|
+
|
|
6
|
+
/** The path the `*` route is rendered at: no route matches it, so the server answers the 404 page. */
|
|
7
|
+
export const NOT_FOUND_PATH = '/__brust_ssg_not_found__'
|
|
8
|
+
|
|
9
|
+
export interface PlannedPage {
|
|
10
|
+
/** The request path (each `{name}` value percent-encoded). */
|
|
11
|
+
path: string
|
|
12
|
+
/** Relative to the static dir: `index.html`, `docs.html`, `docs/x.html`, `404.html`. */
|
|
13
|
+
file: string
|
|
14
|
+
routeId: string
|
|
15
|
+
pattern: string
|
|
16
|
+
/** 200, or 404 for the `*` route. */
|
|
17
|
+
expect: 200 | 404
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StaticPlan {
|
|
21
|
+
pages: PlannedPage[]
|
|
22
|
+
/** Routes `ssg: false` left out (listed in the report). */
|
|
23
|
+
skipped: { routeId: string; pattern: string }[]
|
|
24
|
+
/** No `*` route: the build writes a default 404.html. */
|
|
25
|
+
defaultNotFound: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A leaf's own `ssg` field (the last node of its chain). */
|
|
29
|
+
export function ssgOf(leaf: FlatRoute): RouteSsgConfig | undefined {
|
|
30
|
+
return leaf.chain[leaf.chain.length - 1]?.ssg
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SEGMENT = /\{(\*?)([^}]*)\}/g
|
|
34
|
+
|
|
35
|
+
/** The `{name}` / `{*name}` segment names of a pattern, in order. */
|
|
36
|
+
export function patternParams(pattern: string): { name: string; rest: boolean }[] {
|
|
37
|
+
return [...pattern.matchAll(SEGMENT)].map((m) => ({ name: m[2]!, rest: m[1] === '*' }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The file a request path is written to: `/` → `index.html`, `/a/b` → `a/b.html` (decoded, so a
|
|
41
|
+
* host that decodes the URL finds it). A segment that is not valid percent-encoding (a stray `%`)
|
|
42
|
+
* is a `ssg-param-encoding` error naming `where` (review N1), never a URIError. */
|
|
43
|
+
export function fileFor(path: string, where = path): string {
|
|
44
|
+
if (path === '/') return 'index.html'
|
|
45
|
+
const decoded = path
|
|
46
|
+
.slice(1)
|
|
47
|
+
.split('/')
|
|
48
|
+
.map((s) => {
|
|
49
|
+
try {
|
|
50
|
+
return decodeURIComponent(s)
|
|
51
|
+
} catch {
|
|
52
|
+
throw new BuildError('ssg-param-encoding', `${where}: path segment ${JSON.stringify(s)} is not valid percent-encoding (write a literal % as %25)`)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.join('/')
|
|
56
|
+
return `${decoded}.html`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `encodeURIComponent`, as an `ssg-param-encoding` error naming `where` for a lone surrogate (review N1). */
|
|
60
|
+
function encodeSegment(v: string, where: string): string {
|
|
61
|
+
try {
|
|
62
|
+
return encodeURIComponent(v)
|
|
63
|
+
} catch {
|
|
64
|
+
throw new BuildError('ssg-param-encoding', `${where} = ${JSON.stringify(v)} is not encodable (a lone UTF-16 surrogate)`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One `{name}` value: a non-empty string; `/` only in a `{*rest}` value, and never an empty, `.`
|
|
69
|
+
* or `..` segment (the file name would leave its directory). */
|
|
70
|
+
function checkValue(leaf: FlatRoute, i: number, name: string, rest: boolean, v: unknown): string {
|
|
71
|
+
const where = `route ${leaf.id} (${leaf.pattern}) ssg.params()[${i}].${name}`
|
|
72
|
+
if (typeof v !== 'string' || v === '') throw new BuildError('ssg-params', `${where} is ${v === '' ? 'empty' : 'missing'}: every {${name}} needs a non-empty string`)
|
|
73
|
+
if (!rest && v.includes('/')) throw new BuildError('ssg-params', `${where} = ${JSON.stringify(v)} contains '/': a {${name}} value is one path segment`)
|
|
74
|
+
for (const seg of v.split('/'))
|
|
75
|
+
if (seg === '' || seg === '.' || seg === '..') throw new BuildError('ssg-params', `${where} = ${JSON.stringify(v)} has an empty, '.' or '..' segment`)
|
|
76
|
+
return v
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Plans every page. `params` holds each dynamic route's resolved `ssg.params()` by route id. */
|
|
80
|
+
export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>): StaticPlan {
|
|
81
|
+
const pages: PlannedPage[] = []
|
|
82
|
+
const skipped: StaticPlan['skipped'] = []
|
|
83
|
+
let catchAll: FlatRoute | undefined
|
|
84
|
+
for (const leaf of leaves) {
|
|
85
|
+
const ssg = ssgOf(leaf)
|
|
86
|
+
if (ssg === false) {
|
|
87
|
+
skipped.push({ routeId: leaf.id, pattern: leaf.pattern })
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (leaf.catchAll) {
|
|
91
|
+
catchAll = leaf
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const names = patternParams(leaf.pattern)
|
|
95
|
+
if (names.length === 0) {
|
|
96
|
+
pages.push({ path: leaf.pattern, file: fileFor(leaf.pattern, `route ${leaf.id} (${leaf.pattern})`), routeId: leaf.id, pattern: leaf.pattern, expect: 200 })
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (!ssg || ssg.params === undefined)
|
|
100
|
+
throw new BuildError(
|
|
101
|
+
'ssg-params-missing',
|
|
102
|
+
`route ${leaf.id} (${leaf.pattern}) is dynamic: add ssg: { params: () => [{ ${names.map((n) => `${n.name}: '…'`).join(', ')} }] } or ssg: false`,
|
|
103
|
+
)
|
|
104
|
+
const recs = params.get(leaf.id)
|
|
105
|
+
if (!Array.isArray(recs)) throw new BuildError('ssg-params', `route ${leaf.id} (${leaf.pattern}) ssg.params() must return an array of records`)
|
|
106
|
+
recs.forEach((rec, i) => {
|
|
107
|
+
if (typeof rec !== 'object' || rec === null || Array.isArray(rec)) throw new BuildError('ssg-params', `route ${leaf.id} (${leaf.pattern}) ssg.params()[${i}] is not a record`)
|
|
108
|
+
const r = rec as Record<string, unknown>
|
|
109
|
+
let k = 0
|
|
110
|
+
const path = leaf.pattern.replace(SEGMENT, () => {
|
|
111
|
+
const { name, rest } = names[k++]!
|
|
112
|
+
const v = checkValue(leaf, i, name, rest, r[name])
|
|
113
|
+
return v
|
|
114
|
+
.split('/')
|
|
115
|
+
.map((seg) => encodeSegment(seg, `route ${leaf.id} (${leaf.pattern}) ssg.params()[${i}].${name}`))
|
|
116
|
+
.join('/')
|
|
117
|
+
})
|
|
118
|
+
pages.push({ path, file: fileFor(path, `route ${leaf.id} (${leaf.pattern})`), routeId: leaf.id, pattern: leaf.pattern, expect: 200 })
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
const seen = new Map<string, PlannedPage>()
|
|
122
|
+
// Keyed case-folded (review N2): macOS and Windows file systems are case-insensitive, so `/A` and
|
|
123
|
+
// `/a` would overwrite each other's file.
|
|
124
|
+
for (const p of pages) {
|
|
125
|
+
const key = p.file.toLowerCase()
|
|
126
|
+
const prev = seen.get(key)
|
|
127
|
+
if (prev && prev.file === p.file) throw new BuildError('ssg-params', `${prev.path} (route ${prev.routeId}) and ${p.path} (route ${p.routeId}) both write ${p.file}`)
|
|
128
|
+
if (prev)
|
|
129
|
+
throw new BuildError(
|
|
130
|
+
'ssg-path-collision',
|
|
131
|
+
`${prev.path} (route ${prev.routeId}, ${prev.file}) and ${p.path} (route ${p.routeId}, ${p.file}) differ only by case: on a case-insensitive file system one file overwrites the other`,
|
|
132
|
+
)
|
|
133
|
+
seen.set(key, p)
|
|
134
|
+
}
|
|
135
|
+
if (seen.has('404.html')) throw new BuildError('ssg-params', `${seen.get('404.html')!.path} would overwrite 404.html`)
|
|
136
|
+
if (catchAll) pages.push({ path: NOT_FOUND_PATH, file: '404.html', routeId: catchAll.id, pattern: catchAll.pattern, expect: 404 })
|
|
137
|
+
return { pages, skipped, defaultNotFound: catchAll === undefined }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The output paths `ssg.params()` must be resolved for: dynamic leaves with `ssg.params`. */
|
|
141
|
+
export function paramRoutes(leaves: FlatRoute[]): FlatRoute[] {
|
|
142
|
+
return leaves.filter((l) => {
|
|
143
|
+
const ssg = ssgOf(l)
|
|
144
|
+
return !l.catchAll && ssg && ssg.params !== undefined && patternParams(l.pattern).length > 0
|
|
145
|
+
})
|
|
146
|
+
}
|