@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.4

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.
Files changed (58) hide show
  1. package/README.md +64 -6
  2. package/native/index.d.ts +65 -2
  3. package/native/index.js +59 -54
  4. package/package.json +16 -8
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +199 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +48 -12
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +315 -0
  19. package/src/build/compile.ts +41 -4
  20. package/src/build/config.ts +255 -10
  21. package/src/build/css.ts +286 -0
  22. package/src/build/index.ts +199 -16
  23. package/src/build/manifest.ts +41 -4
  24. package/src/build/scan.ts +59 -20
  25. package/src/build/ssg-render.ts +65 -0
  26. package/src/build/ssg.ts +146 -0
  27. package/src/build/static-worker.ts +91 -0
  28. package/src/build/static.ts +258 -0
  29. package/src/build-time.ts +10 -0
  30. package/src/cache-adapter/bridge.ts +91 -0
  31. package/src/cache-adapter/conformance.ts +226 -0
  32. package/src/cache-adapter/index.ts +10 -0
  33. package/src/cache-adapter/load.ts +144 -0
  34. package/src/cache-adapter/memory.ts +229 -0
  35. package/src/cache-adapter/runtime.ts +124 -0
  36. package/src/cache-adapter/types.ts +96 -0
  37. package/src/cache-adapter/workers.ts +118 -0
  38. package/src/cache.ts +4 -1
  39. package/src/cf.ts +19 -0
  40. package/src/cli.ts +50 -2
  41. package/src/config.ts +127 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +365 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +356 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +236 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +81 -0
  51. package/src/dev.ts +93 -0
  52. package/src/handlers.ts +282 -0
  53. package/src/index.ts +3 -1
  54. package/src/native.ts +38 -1
  55. package/src/routes.ts +20 -1
  56. package/src/run.ts +46 -9
  57. package/src/server-handlers.ts +32 -0
  58. package/src/worker.ts +125 -245
@@ -4,16 +4,23 @@
4
4
  // edited by hand. An out dir that could hold the app's own sources, or a non-empty one that is not
5
5
  // a previous brust dist (no `manifest.json` / `.brust` marker; `--force` overrides), is refused
6
6
  // before anything is written or removed.
7
- import { cpSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
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, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
12
- import { compileApp } from './compile'
13
- import { type ActionsBuildConfig, loadBuildConfig } from './config'
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'
13
+ import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
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'
18
+ import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
14
19
  import { BuildError } from './errors'
15
- import { writeManifest } from './manifest'
20
+ import { type CacheRecord, type ManifestJson, tierOf, writeManifest } from './manifest'
16
21
  import { scanRoutes } from './scan'
22
+ import { type ExprReads, prepareStatic, writeStatic } from './static'
23
+ import { writeStaticWorker } from './static-worker'
17
24
 
18
25
  export { BuildError } from './errors'
19
26
 
@@ -79,17 +86,40 @@ export async function runBuild(opts: {
79
86
  outDir: string
80
87
  /** Replace a non-empty out dir even without a previous build's marker. */
81
88
  force?: boolean
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
96
+ /** `--wasm-from-source` (cloudflare only). */
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
82
102
  log: (s: string) => void
83
103
  }): Promise<void> {
104
+ setDevBuild(opts.dev === true)
84
105
  const appRoot = resolve(opts.appRoot)
85
106
  const entry = resolve(appRoot, opts.entry)
86
107
  const dist = resolve(appRoot, opts.outDir)
87
108
  assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
88
109
  assertReplaceable(dist, opts.force)
89
- const { serverOnly, actions } = await loadBuildConfig(appRoot)
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')
90
120
 
91
121
  // Everything that can fail on the app's own input runs before a byte is written.
92
- const { routes, componentFile } = await scanRoutes(entry)
122
+ const { routes, componentFile, componentIssue } = await scanRoutes(entry, appRoot)
93
123
  let leaves: ReturnType<typeof validateRoutes>
94
124
  try {
95
125
  leaves = validateRoutes(routes)
@@ -114,7 +144,26 @@ export async function runBuild(opts: {
114
144
  }
115
145
  }
116
146
  }
117
- assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : [])])
147
+ let cacheModule: string | null = null
148
+ if (cache?.module !== undefined) {
149
+ cacheModule = resolve(appRoot, cache.module)
150
+ const rel = relative(appRoot, cacheModule)
151
+ if (isAbsolute(cache.module) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(cacheModule)) {
152
+ throw new BuildError('cache', `cache.module ${cache.module} is not a file inside the app`)
153
+ }
154
+ }
155
+ assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : []), ...(cacheModule ? [cacheModule] : [])])
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
+
163
+ // Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
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
118
167
 
119
168
  // Sibling of `dist` (same filesystem: the final rename is atomic), fixed name (no random bytes in
120
169
  // anything the bundler sees).
@@ -122,7 +171,13 @@ export async function runBuild(opts: {
122
171
  rmSync(tmp, { recursive: true, force: true })
123
172
  mkdirSync(tmp, { recursive: true })
124
173
  try {
125
- await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, 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
+ }
126
181
  rmSync(join(tmp, '.stage'), { recursive: true, force: true })
127
182
  if (existsSync(dist)) {
128
183
  const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
@@ -140,31 +195,69 @@ export async function runBuild(opts: {
140
195
  async function buildInto(
141
196
  out: string,
142
197
  o: {
198
+ /** Write `dev.json` (L14). */
199
+ dev: boolean
143
200
  appRoot: string
144
201
  entry: string
145
202
  /** The final dist dir (`index.js` points at the entry relative to it). */
146
203
  dist: string
147
204
  leaves: ReturnType<typeof validateRoutes>
148
205
  componentFile: Map<Function, string>
206
+ componentIssue: Map<Function, string>
149
207
  serverOnly: string[]
150
208
  actions: ActionsBuildConfig | null
151
209
  /** Absolute path of the actions entry (null when `[actions]` is off). */
152
210
  actionsEntry: string | null
211
+ cache: CacheBuildConfig | null
212
+ /** Absolute path of a custom cache adapter module. */
213
+ cacheModule: string | null
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
153
221
  log: (s: string) => void
154
222
  },
155
223
  ): Promise<void> {
156
224
  const { appRoot, entry, leaves } = o
157
- const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [] }
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 }
158
236
  // The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
159
237
  const runtime = await buildRuntime(out, ctx)
160
- const { compiled, routeComponent } = compileApp({
238
+ const compileOpts = {
161
239
  appRoot,
162
240
  leaves,
163
241
  componentFile: o.componentFile,
242
+ componentIssue: o.componentIssue,
164
243
  runtimeImport: `/_brust/${runtime}`,
165
244
  serverOnly: o.serverOnly,
166
- log: o.log,
167
- })
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))
168
261
 
169
262
  mkdirSync(join(out, 'jinja'), { recursive: true })
170
263
  for (const c of compiled.values()) writeFileSync(join(out, 'jinja', `${c.id}.jinja`), c.jinja)
@@ -172,16 +265,43 @@ async function buildInto(
172
265
  const runtimeUrl = `/_brust/${runtime}`
173
266
  const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrl)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrl))])
174
267
  const manifest = writeManifest({ leaves, routeComponent, compiled, runtime, chunks })
175
- await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest))
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)
176
273
  manifest.actions =
177
274
  o.actions && o.actionsEntry
178
- ? { 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 }
179
276
  : null
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)
180
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`)
181
281
  writeFileSync(join(out, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
182
282
 
183
283
  const pub = join(appRoot, 'public')
184
284
  if (existsSync(pub)) cpSync(pub, join(out, 'public'), { recursive: true })
285
+ if (o.cf) {
286
+ const warnings = await buildCloudflare(
287
+ out,
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
+ },
301
+ o.cf,
302
+ )
303
+ for (const w of warnings) o.log(w)
304
+ }
185
305
  const entryRel = relative(o.dist, entry)
186
306
  writeFileSync(
187
307
  join(out, 'index.js'),
@@ -197,3 +317,66 @@ async function buildInto(
197
317
  ].join('\n'),
198
318
  )
199
319
  }
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
+
345
+ /** `manifest.cache` (R7, R8): the custom module bundled for the worker, the native defaults, and the
346
+ * build id every L2 key carries. */
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
349
+ return {
350
+ adapter: c.adapter,
351
+ ...(module ? { module } : {}),
352
+ ...(c.broadcast ? { broadcast: c.broadcast, broadcastOptions: c.broadcastOptions ?? {} } : {}),
353
+ options: c.options,
354
+ namespace: c.namespace,
355
+ getTimeoutMs: c.getTimeoutMs ?? 50,
356
+ maxBodyBytes: c.maxBodyBytes,
357
+ epochIntervalMs: c.epochIntervalMs,
358
+ buildId: computeBuildId(out, manifest),
359
+ }
360
+ }
361
+
362
+ /** R7: the first 16 hex chars of sha256 over the manifest (without `cache`), every `jinja/*.jinja`
363
+ * (by name), `jobs.js` and `actions.js` when present: a rebuild of the same app keeps it, any change
364
+ * to what renders a page moves it (a deploy never serves an older build's L2 bodies). */
365
+ export function computeBuildId(out: string, manifest: ManifestJson): string {
366
+ const h = new Bun.CryptoHasher('sha256')
367
+ const { cache: _, ...rest } = manifest
368
+ h.update(JSON.stringify(rest))
369
+ const jinja = join(out, 'jinja')
370
+ for (const f of (existsSync(jinja) ? readdirSync(jinja) : []).filter((f) => f.endsWith('.jinja')).sort()) {
371
+ h.update(`\0${f}\0`)
372
+ h.update(readFileSync(join(jinja, f)))
373
+ }
374
+ for (const f of ['jobs.js', 'actions.js']) {
375
+ const p = join(out, f)
376
+ if (existsSync(p)) {
377
+ h.update(`\0${f}\0`)
378
+ h.update(readFileSync(p))
379
+ }
380
+ }
381
+ return h.digest('hex').slice(0, 16)
382
+ }
@@ -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,38 @@ 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: 1
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 }
83
+ /** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
84
+ cache?: CacheRecord
85
+ }
86
+
87
+ /** `manifest.cache` / `CONFIG.cache`: the adapter the runtime loads (`cache-adapter/load.ts`). */
88
+ export interface CacheRecord {
89
+ adapter: string
90
+ /** `custom`: the bundled module, relative to dist. */
91
+ module?: string
92
+ broadcast?: string
93
+ options: Record<string, unknown>
94
+ broadcastOptions?: Record<string, unknown>
95
+ namespace: string
96
+ getTimeoutMs: number
97
+ maxBodyBytes: number
98
+ epochIntervalMs: number
99
+ /** 16 hex chars of sha256 over the build output (R7): every L2 key carries it. */
100
+ buildId: string
70
101
  }
71
102
 
72
103
  // biome-ignore lint/suspicious/noExplicitAny: IR JSON (externally tagged enums)
@@ -213,10 +244,16 @@ export function childRecords(c: Compiled, clientOnly: string[] = []): ChildRecor
213
244
  const props: Record<string, string> = {}
214
245
  for (const [name, path] of Object.entries(inst.props)) {
215
246
  if (path === null)
216
- throw new BuildError('instance-prop-path', `${ir.id}: child ${inst.child_id} prop ${name} is not a props path`)
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
+ )
217
251
  props[name] = path
218
252
  }
219
- return { id: inst.child_id, instances: list === undefined ? 'static' : `per-row:${list}`, props }
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
220
257
  })
221
258
  for (const id of clientOnly) out.push({ id, instances: 'static', props: {} })
222
259
  return out
@@ -284,7 +321,7 @@ export function writeManifest(opts: {
284
321
  }
285
322
  })
286
323
  linkNativeChunks(routes, compiled, components)
287
- return { version: 1, routes, components, assets: { runtime: opts.runtime }, jobs_module: 'jobs.js', actions: opts.actions ?? null }
324
+ return { version: MANIFEST_VERSION, routes, components, assets: { runtime: opts.runtime }, jobs_module: 'jobs.js', actions: opts.actions ?? null }
288
325
  }
289
326
 
290
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 map every route Component to the `.tsx`
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
- const DEFAULT_IMPORT = /^\s*import\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s+from\s+['"]([^'"]+)['"]/gm
9
-
10
- export async function scanRoutes(entryFile: string): Promise<{ routes: Route[]; componentFile: Map<Function, string> }> {
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
- for (const m of src.matchAll(DEFAULT_IMPORT)) {
28
- const spec = m[2]!
29
- if (!real.has(spec)) continue // inside a comment or string: not an import
30
- let file: string
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
- file = Bun.resolveSync(spec, dirname(entryFile))
39
+ src = readFileSync(file, 'utf8')
33
40
  } catch {
34
- continue // a bare specifier the route file never uses as a Component; checked below
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
- return { routes, componentFile }
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
+ }