@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.10

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 (63) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +87 -5
  3. package/native/index.js +61 -54
  4. package/package.json +22 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +27 -3
  8. package/src/actions-bridge.ts +76 -19
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +319 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +278 -43
  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 +317 -0
  19. package/src/build/compile.ts +72 -4
  20. package/src/build/config.ts +274 -15
  21. package/src/build/css.ts +275 -0
  22. package/src/build/features.ts +110 -0
  23. package/src/build/index.ts +223 -20
  24. package/src/build/manifest.ts +59 -8
  25. package/src/build/pkg-src.ts +383 -0
  26. package/src/build/scan.ts +61 -20
  27. package/src/build/server-only.ts +39 -0
  28. package/src/build/ssg-render.ts +65 -0
  29. package/src/build/ssg.ts +146 -0
  30. package/src/build/static-worker.ts +91 -0
  31. package/src/build/static.ts +258 -0
  32. package/src/build/tailwind.ts +117 -0
  33. package/src/build/tsconfig-paths.ts +67 -0
  34. package/src/build-time.ts +10 -0
  35. package/src/cache-adapter/bridge.ts +91 -0
  36. package/src/cache-adapter/conformance.ts +226 -0
  37. package/src/cache-adapter/index.ts +10 -0
  38. package/src/cache-adapter/load.ts +144 -0
  39. package/src/cache-adapter/memory.ts +229 -0
  40. package/src/cache-adapter/runtime.ts +124 -0
  41. package/src/cache-adapter/types.ts +96 -0
  42. package/src/cache-adapter/workers.ts +118 -0
  43. package/src/cache.ts +4 -1
  44. package/src/cf.ts +19 -0
  45. package/src/cli.ts +50 -2
  46. package/src/config.ts +127 -25
  47. package/src/css-modules.d.ts +8 -0
  48. package/src/dev/bun.ts +365 -0
  49. package/src/dev/client.ts +92 -0
  50. package/src/dev/devtools-data.ts +23 -0
  51. package/src/dev/devtools.ts +356 -0
  52. package/src/dev/diag.ts +30 -0
  53. package/src/dev/proxy.ts +236 -0
  54. package/src/dev/tui.ts +144 -0
  55. package/src/dev/watch.ts +81 -0
  56. package/src/dev.ts +93 -0
  57. package/src/handlers.ts +282 -0
  58. package/src/index.ts +3 -1
  59. package/src/native.ts +49 -2
  60. package/src/routes.ts +20 -1
  61. package/src/run.ts +48 -11
  62. package/src/server-handlers.ts +32 -0
  63. package/src/worker.ts +129 -245
@@ -4,16 +4,25 @@
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, nestedOf, 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 { preparePackageSources } from './pkg-src'
17
+ import { type Compiled, compileApp } from './compile'
18
+ import { buildCss, CssGraph, directCss, resolveGlobals, routeCssSources } from './css'
19
+ import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
14
20
  import { BuildError } from './errors'
15
- import { writeManifest } from './manifest'
21
+ import { importsStore } from './features'
22
+ import { type CacheRecord, type ManifestJson, tierOf, writeManifest } from './manifest'
16
23
  import { scanRoutes } from './scan'
24
+ import { type ExprReads, prepareStatic, writeStatic } from './static'
25
+ import { writeStaticWorker } from './static-worker'
17
26
 
18
27
  export { BuildError } from './errors'
19
28
 
@@ -79,17 +88,40 @@ export async function runBuild(opts: {
79
88
  outDir: string
80
89
  /** Replace a non-empty out dir even without a previous build's marker. */
81
90
  force?: boolean
91
+ /** `cloudflare` also writes `<out>/cf/` (M4-CF); `static` also writes `<out>/static/` (gaps round
92
+ * L9); default `bun` = the unchanged build. */
93
+ target?: 'bun' | 'cloudflare' | 'static'
94
+ /** `--strict` (static): a loader that reads the request fails the build. */
95
+ strict?: boolean
96
+ /** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
97
+ exprReads?: ExprReads
98
+ /** `--wasm-from-source` (cloudflare only). */
99
+ wasmFromSource?: boolean
100
+ /** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
101
+ actionsOnly?: boolean
102
+ /** `brust build --dev` (L14, used by `brust dev`): also writes `dev.json` and builds the client runtime with `__BRUST_DEV__`. */
103
+ dev?: boolean
82
104
  log: (s: string) => void
83
105
  }): Promise<void> {
106
+ setDevBuild(opts.dev === true)
84
107
  const appRoot = resolve(opts.appRoot)
85
108
  const entry = resolve(appRoot, opts.entry)
86
109
  const dist = resolve(appRoot, opts.outDir)
87
110
  assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
88
111
  assertReplaceable(dist, opts.force)
89
- const { serverOnly, actions } = await loadBuildConfig(appRoot)
112
+ const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
113
+ if (opts.actionsOnly)
114
+ return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
115
+ const { serverOnly, cloudflare, cache, cssGlobal } = built
116
+ // L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
117
+ // is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
118
+ const remote =
119
+ built.actions !== null && built.actions.baseUrl !== undefined && !built.actions.entryExplicit && !statOk(resolve(appRoot, built.actions.entry)) ? built.actions : null
120
+ const actions = remote ? null : built.actions
121
+ 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
122
 
91
123
  // Everything that can fail on the app's own input runs before a byte is written.
92
- const { routes, componentFile } = await scanRoutes(entry)
124
+ const { routes, componentFile, componentIssue } = await scanRoutes(entry, appRoot)
93
125
  let leaves: ReturnType<typeof validateRoutes>
94
126
  try {
95
127
  leaves = validateRoutes(routes)
@@ -114,7 +146,26 @@ export async function runBuild(opts: {
114
146
  }
115
147
  }
116
148
  }
117
- assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : [])])
149
+ let cacheModule: string | null = null
150
+ if (cache?.module !== undefined) {
151
+ cacheModule = resolve(appRoot, cache.module)
152
+ const rel = relative(appRoot, cacheModule)
153
+ if (isAbsolute(cache.module) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(cacheModule)) {
154
+ throw new BuildError('cache', `cache.module ${cache.module} is not a file inside the app`)
155
+ }
156
+ }
157
+ assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : []), ...(cacheModule ? [cacheModule] : [])])
158
+
159
+ // R9.3/R9.4: every reason to refuse a static build, before any write.
160
+ const ssgPlan =
161
+ opts.target === 'static'
162
+ ? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache)
163
+ : null
164
+
165
+ // Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
166
+ // L12: a static build with `[actions]` also builds the Cloudflare worker (its Pages `_worker.js`).
167
+ const hybrid = ssgPlan !== null && actions !== null
168
+ const cf = opts.target === 'cloudflare' || hybrid ? await prepareCloudflare(appRoot, cloudflare, { fromSource: opts.wasmFromSource === true, env: process.env }) : null
118
169
 
119
170
  // Sibling of `dist` (same filesystem: the final rename is atomic), fixed name (no random bytes in
120
171
  // anything the bundler sees).
@@ -122,7 +173,13 @@ export async function runBuild(opts: {
122
173
  rmSync(tmp, { recursive: true, force: true })
123
174
  mkdirSync(tmp, { recursive: true })
124
175
  try {
125
- await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, log: opts.log })
176
+ 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 })
177
+ if (ssgPlan) {
178
+ // Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
179
+ const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
180
+ await writeStatic(tmp, { entry, appRoot, plan: ssgPlan, version, strict: opts.strict === true, log: opts.log, shownDir: join(dist, 'static') })
181
+ if (hybrid && cf && actions) writeStaticWorker(tmp, actions, cf, opts.log)
182
+ }
126
183
  rmSync(join(tmp, '.stage'), { recursive: true, force: true })
127
184
  if (existsSync(dist)) {
128
185
  const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
@@ -140,48 +197,131 @@ export async function runBuild(opts: {
140
197
  async function buildInto(
141
198
  out: string,
142
199
  o: {
200
+ /** Write `dev.json` (L14). */
201
+ dev: boolean
143
202
  appRoot: string
144
203
  entry: string
145
204
  /** The final dist dir (`index.js` points at the entry relative to it). */
146
205
  dist: string
147
206
  leaves: ReturnType<typeof validateRoutes>
148
207
  componentFile: Map<Function, string>
208
+ componentIssue: Map<Function, string>
149
209
  serverOnly: string[]
150
210
  actions: ActionsBuildConfig | null
151
211
  /** Absolute path of the actions entry (null when `[actions]` is off). */
152
212
  actionsEntry: string | null
213
+ cache: CacheBuildConfig | null
214
+ /** Absolute path of a custom cache adapter module. */
215
+ cacheModule: string | null
216
+ cf: CfPrepared | null
217
+ /** `[css] global` as written (app-root relative). */
218
+ cssGlobal: string[]
219
+ /** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
220
+ actionsRemote: string | null
221
+ /** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
222
+ hybrid: boolean
153
223
  log: (s: string) => void
154
224
  },
155
225
  ): Promise<void> {
156
226
  const { appRoot, entry, leaves } = o
157
- const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [] }
227
+ // L19: every stylesheet first, so each JS build below reads one set of CSS Module class maps.
228
+ const graph = new CssGraph()
229
+ const chainFiles = (l: FlatRoute) => l.chain.flatMap((n) => (n.Component && o.componentFile.has(n.Component) ? [o.componentFile.get(n.Component)!] : []))
230
+ const routeCss = routeCssSources(leaves, chainFiles, resolveGlobals(appRoot, o.cssGlobal), graph)
231
+ const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
232
+ mkdirSync(join(out, 'client'), { recursive: true })
233
+ // Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
234
+ const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
235
+ for (const c of directCss(entry))
236
+ 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`)
237
+ const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps }
158
238
  // The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
159
239
  const runtime = await buildRuntime(out, ctx)
160
- const { compiled, routeComponent } = compileApp({
240
+ const runtimeUrls = { core: `/_brust/${runtime.core}`, store: `/_brust/${runtime.store}` }
241
+ const compileOpts = {
161
242
  appRoot,
162
243
  leaves,
163
244
  componentFile: o.componentFile,
164
- runtimeImport: `/_brust/${runtime}`,
245
+ componentIssue: o.componentIssue,
246
+ runtimeImport: runtimeUrls.core,
165
247
  serverOnly: o.serverOnly,
166
- log: o.log,
167
- })
248
+ cssMaps: css.maps,
249
+ }
250
+ const firstLog: string[] = []
251
+ let { compiled, routeComponent } = compileApp({ ...compileOpts, log: (l) => firstLog.push(l) })
252
+ // F153c: the packages the app imports components from are compiled from their source when they publish it (and
253
+ // it matches what they publish); the app is compiled again with those sources. A refused package stays the island.
254
+ const external = [...new Set([...compiled.values()].flatMap((c) => (c.ir.external ? [c.ir.external.source] : [])))]
255
+ const packages = preparePackageSources(appRoot, external)
256
+ if (Object.keys(packages.scopes).length > 0) {
257
+ firstLog.length = 0
258
+ ;({ compiled, routeComponent } = compileApp({ ...compileOpts, packages, log: (l) => firstLog.push(l) }))
259
+ }
260
+ // L23: lucide icons the compiler could inline are rendered here (react-dom/server); L2: slots that read
261
+ // nothing request- or time-dependent are computed once, here. The app is compiled again with both (an icon with
262
+ // no markup stays the L22 island; a slot with a value is a literal: no job, no worker). The first compile's
263
+ // warnings describe icons that are inline now, so when icons were rendered only the last compile's are shown.
264
+ const hasIcons = [...compiled.values()].some((c) => (c.ir.icon_requests ?? []).length > 0)
265
+ const iconSvgs = hasIcons ? await renderIcons(compiled, appRoot, o.log) : undefined
266
+ const slotValues = await evaluateBuildSlots(out, compiled, ctx.sourceOf, appRoot, o.log)
267
+ const hasSlots = Object.keys(slotValues).length > 0
268
+ if (hasIcons || hasSlots) {
269
+ if (!hasIcons) firstLog.forEach((l) => o.log(l))
270
+ ;({ compiled, routeComponent } = compileApp({ ...compileOpts, packages, iconSvgs, iconsFinal: hasIcons, slotValues, log: hasIcons ? o.log : () => {} }))
271
+ } else firstLog.forEach((l) => o.log(l))
272
+
273
+ // F158 (R34.69/70): the compiler makes a caller of a route component fall back to React, so none is a revive host;
274
+ // if one is, the compiler missed it, and the build stops rather than let request data near the revive.
275
+ const routeRoots: ReadonlySet<string> = new Set(routeComponent.values())
276
+ for (const id of routeRoots) {
277
+ if (nestedOf(compiled, id) !== undefined) throw new BuildError('revive-host-route-root', `${id} is a page or layout and also receives lowered JSX children from a caller; this is a compiler bug (F158), please report it`)
278
+ }
168
279
 
169
280
  mkdirSync(join(out, 'jinja'), { recursive: true })
170
281
  for (const c of compiled.values()) writeFileSync(join(out, 'jinja', `${c.id}.jinja`), c.jinja)
171
282
 
172
- const runtimeUrl = `/_brust/${runtime}`
173
- const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrl)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrl))])
174
- const manifest = writeManifest({ leaves, routeComponent, compiled, runtime, chunks })
175
- await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest))
283
+ const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrls)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrls, routeRoots))])
284
+ const manifest = writeManifest({ leaves, routeComponent, compiled, runtime: { core: runtime.core, features: { ...runtime.features, store: runtime.store } }, chunks })
285
+ // L33: a component whose chunk imports the store module (directly or through a shared chunk) has the page preload it.
286
+ const readClient = (rel: string) => (existsSync(join(out, rel)) ? readFileSync(join(out, rel), 'utf8') : undefined)
287
+ for (const rec of Object.values(manifest.components)) if (rec.client && importsStore(readClient, rec.client, runtime.store)) rec.features.push('store')
288
+ for (const r of manifest.routes) {
289
+ const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
290
+ if (files.length > 0) r.css = files
291
+ }
292
+ await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots)
176
293
  manifest.actions =
177
294
  o.actions && o.actionsEntry
178
- ? { entry: await buildActions(out, o.actionsEntry), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes }
295
+ ? { 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
296
  : null
297
+ if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
298
+ if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
180
299
  writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
300
+ if (o.dev) writeFileSync(join(out, 'dev.json'), `${JSON.stringify(devJson(appRoot, compiled), null, 2)}\n`)
181
301
  writeFileSync(join(out, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
182
302
 
183
303
  const pub = join(appRoot, 'public')
184
304
  if (existsSync(pub)) cpSync(pub, join(out, 'public'), { recursive: true })
305
+ if (o.cf) {
306
+ const warnings = await buildCloudflare(
307
+ out,
308
+ {
309
+ appRoot,
310
+ entry,
311
+ dist: o.dist,
312
+ compiled,
313
+ manifest: o.hybrid ? { ...manifest, cache: undefined } : manifest,
314
+ sourceOf: ctx.sourceOf,
315
+ actionsEntry: o.actionsEntry,
316
+ cacheModule: o.hybrid ? null : o.cacheModule,
317
+ cacheGetTimeoutMs: o.hybrid ? undefined : o.cache?.getTimeoutMs,
318
+ cssMaps: css.maps,
319
+ log: o.log,
320
+ },
321
+ o.cf,
322
+ )
323
+ for (const w of warnings) o.log(w)
324
+ }
185
325
  const entryRel = relative(o.dist, entry)
186
326
  writeFileSync(
187
327
  join(out, 'index.js'),
@@ -197,3 +337,66 @@ async function buildInto(
197
337
  ].join('\n'),
198
338
  )
199
339
  }
340
+
341
+ /** `dev.json` (L14, R14.2): component id → source file, tier, the react reason, diagnostics. Only `brust build --dev` writes it. */
342
+ export function devJson(appRoot: string, compiled: Map<string, Compiled>): { version: 1; components: Record<string, DevComponent> } {
343
+ const components: Record<string, DevComponent> = {}
344
+ for (const c of compiled.values()) {
345
+ const tier = tierOf(c.ir)
346
+ const t = c.ir.tier
347
+ components[c.id] = {
348
+ file: relative(appRoot, c.file),
349
+ tier,
350
+ ...(tier === 'react' && t && typeof t === 'object' && 'React' in t ? { reason: String((t as { React: { reason: string } }).React.reason) } : {}),
351
+ diagnostics: (c.diagnostics ?? []).map((d) => ({ class: d.class, rule: d.rule, line: d.line, col: d.col, message: d.message, remediation: d.remediation })),
352
+ }
353
+ }
354
+ return { version: 1, components }
355
+ }
356
+
357
+ export interface DevComponent {
358
+ file: string
359
+ tier: 'static' | 'native' | 'react'
360
+ /** Why the compiler chose the react tier (the IR's `React.reason`). */
361
+ reason?: string
362
+ diagnostics: { class: string; rule: string; line: number; col: number; message: string; remediation: string }[]
363
+ }
364
+
365
+ /** `manifest.cache` (R7, R8): the custom module bundled for the worker, the native defaults, and the
366
+ * build id every L2 key carries. */
367
+ async function cacheRecord(out: string, manifest: ManifestJson, c: CacheBuildConfig, moduleAbs: string | null, cssMaps: Map<string, Record<string, string>>): Promise<CacheRecord> {
368
+ const module = moduleAbs ? await buildCacheAdapter(out, moduleAbs, cssMaps) : undefined
369
+ return {
370
+ adapter: c.adapter,
371
+ ...(module ? { module } : {}),
372
+ ...(c.broadcast ? { broadcast: c.broadcast, broadcastOptions: c.broadcastOptions ?? {} } : {}),
373
+ options: c.options,
374
+ namespace: c.namespace,
375
+ getTimeoutMs: c.getTimeoutMs ?? 50,
376
+ maxBodyBytes: c.maxBodyBytes,
377
+ epochIntervalMs: c.epochIntervalMs,
378
+ buildId: computeBuildId(out, manifest),
379
+ }
380
+ }
381
+
382
+ /** R7: the first 16 hex chars of sha256 over the manifest (without `cache`), every `jinja/*.jinja`
383
+ * (by name), `jobs.js` and `actions.js` when present: a rebuild of the same app keeps it, any change
384
+ * to what renders a page moves it (a deploy never serves an older build's L2 bodies). */
385
+ export function computeBuildId(out: string, manifest: ManifestJson): string {
386
+ const h = new Bun.CryptoHasher('sha256')
387
+ const { cache: _, ...rest } = manifest
388
+ h.update(JSON.stringify(rest))
389
+ const jinja = join(out, 'jinja')
390
+ for (const f of (existsSync(jinja) ? readdirSync(jinja) : []).filter((f) => f.endsWith('.jinja')).sort()) {
391
+ h.update(`\0${f}\0`)
392
+ h.update(readFileSync(join(jinja, f)))
393
+ }
394
+ for (const f of ['jobs.js', 'actions.js']) {
395
+ const p = join(out, f)
396
+ if (existsSync(p)) {
397
+ h.update(`\0${f}\0`)
398
+ h.update(readFileSync(p))
399
+ }
400
+ }
401
+ return h.digest('hex').slice(0, 16)
402
+ }
@@ -6,6 +6,7 @@
6
6
  import type { FlatRoute } from '../routes'
7
7
  import type { Compiled, ComponentIR } from './compile'
8
8
  import { BuildError } from './errors'
9
+ import { featuresOf } from './features'
9
10
 
10
11
  export interface JobCache {
11
12
  key: string | null
@@ -29,6 +30,12 @@ export interface ChildRecord {
29
30
  id: string
30
31
  instances: string
31
32
  props: Record<string, string>
33
+ /** F105: constant props; part of the child's job keys. */
34
+ literals?: Record<string, unknown>
35
+ /** F105: jinja over `__props` (and `__row`/`__idx` per row) the server evaluates. */
36
+ exprs?: Record<string, string>
37
+ /** F142 R34.43: the list of a `per-row-literal` instance (a constant the compiler read). */
38
+ rows?: unknown[]
32
39
  }
33
40
 
34
41
  export interface ComponentRecord {
@@ -40,6 +47,8 @@ export interface ComponentRecord {
40
47
  needs_worker: boolean
41
48
  use_id_slots: number
42
49
  stores: string[]
50
+ /** L33: the client runtime feature modules (`assets.features` keys) this component's output needs, in load order. */
51
+ features: string[]
43
52
  }
44
53
 
45
54
  export interface RouteRecord {
@@ -49,22 +58,52 @@ export interface RouteRecord {
49
58
  loaders: string[]
50
59
  cache: { ttl_seconds: number; prefix: string | null; bypass: true | string | null; tags: string[] } | null
51
60
  catch_all: boolean
61
+ /** L19 (R19.3): `client/<file>.css` in cascade order; absent when the route has none. */
62
+ css?: string[]
52
63
  }
53
64
 
54
- /** `manifest.actions` (A3): `entry` is relative to dist; `maxBodyBytes` is camelCase by contract. */
65
+ /** `manifest.actions` (A3): `entry` is relative to dist; `maxBodyBytes`/`maxResponseBytes` are camelCase by contract. */
55
66
  export interface ActionsRecord {
56
67
  entry: string
57
68
  prefix: string
58
69
  maxBodyBytes: number
70
+ maxResponseBytes: number
71
+ crossSite: 'reject' | 'allow'
59
72
  }
60
73
 
74
+ /** The manifest format the Rust loader accepts (`MANIFEST_VERSION` in brust-core `manifest.rs`): the one place TS names it. */
75
+ export const MANIFEST_VERSION = 3 as const
76
+
61
77
  export interface ManifestJson {
62
- version: 1
78
+ version: typeof MANIFEST_VERSION
63
79
  routes: RouteRecord[]
64
80
  components: Record<string, ComponentRecord>
65
- assets: { runtime: string }
81
+ /** `runtime` is the core; `features` maps each feature module name to its file (L33). */
82
+ assets: { runtime: string; features: Record<string, string> }
66
83
  jobs_module: string
67
84
  actions: ActionsRecord | null
85
+ /** L16 R16.3: an actions-only build (no routes, no templates); everything but the prefix is a 404 problem+json. */
86
+ actions_only?: true
87
+ /** L16 R16.4b: a page-only build: no local actions app, `base_url` points at the deployment that has it. */
88
+ actions_remote?: { prefix: string }
89
+ /** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
90
+ cache?: CacheRecord
91
+ }
92
+
93
+ /** `manifest.cache` / `CONFIG.cache`: the adapter the runtime loads (`cache-adapter/load.ts`). */
94
+ export interface CacheRecord {
95
+ adapter: string
96
+ /** `custom`: the bundled module, relative to dist. */
97
+ module?: string
98
+ broadcast?: string
99
+ options: Record<string, unknown>
100
+ broadcastOptions?: Record<string, unknown>
101
+ namespace: string
102
+ getTimeoutMs: number
103
+ maxBodyBytes: number
104
+ epochIntervalMs: number
105
+ /** 16 hex chars of sha256 over the build output (R7): every L2 key carries it. */
106
+ buildId: string
68
107
  }
69
108
 
70
109
  // biome-ignore lint/suspicious/noExplicitAny: IR JSON (externally tagged enums)
@@ -206,15 +245,23 @@ export function childRecords(c: Compiled, clientOnly: string[] = []): ChildRecor
206
245
  if (inst.loops.length > 1)
207
246
  throw new BuildError('nested-instance', `${ir.id}: child ${inst.child_id} sits in nested lists (M3)`)
208
247
  const list = inst.loops[0]
209
- if (list === null)
248
+ const literal = list === null && Array.isArray(inst.rows_literal)
249
+ if (list === null && !literal)
210
250
  throw new BuildError('instance-list-path', `${ir.id}: child ${inst.child_id} sits in a list that is not a props path`)
211
251
  const props: Record<string, string> = {}
212
252
  for (const [name, path] of Object.entries(inst.props)) {
213
253
  if (path === null)
214
- throw new BuildError('instance-prop-path', `${ir.id}: child ${inst.child_id} prop ${name} is not a props path`)
254
+ throw new BuildError(
255
+ 'instance-prop-path',
256
+ `${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()`,
257
+ )
215
258
  props[name] = path
216
259
  }
217
- return { id: inst.child_id, instances: list === undefined ? 'static' : `per-row:${list}`, props }
260
+ const rec: ChildRecord = { id: inst.child_id, instances: literal ? 'per-row-literal' : list === undefined ? 'static' : `per-row:${list}`, props }
261
+ if (inst.literals && Object.keys(inst.literals).length > 0) rec.literals = { ...inst.literals }
262
+ if (inst.exprs && Object.keys(inst.exprs).length > 0) rec.exprs = { ...inst.exprs }
263
+ if (literal) rec.rows = inst.rows_literal
264
+ return rec
218
265
  })
219
266
  for (const id of clientOnly) out.push({ id, instances: 'static', props: {} })
220
267
  return out
@@ -241,7 +288,8 @@ export function writeManifest(opts: {
241
288
  leaves: FlatRoute[]
242
289
  routeComponent: Map<string, string>
243
290
  compiled: Map<string, Compiled>
244
- runtime: string
291
+ /** The runtime files `buildRuntime` wrote: the core and the feature modules. */
292
+ runtime: { core: string; features: Record<string, string> }
245
293
  /** component id → `client/<file>` (client chunk or react island chunk). */
246
294
  chunks: Map<string, string>
247
295
  actions?: ActionsRecord | null
@@ -260,7 +308,10 @@ export function writeManifest(opts: {
260
308
  needs_worker: jobs.length > 0,
261
309
  use_id_slots: c.ir.use_id_slots ?? 0,
262
310
  stores: storesOf(compiled, c.id),
311
+ features: featuresOf(c.id, c.jinja, tierOf(c.ir) === 'react'),
263
312
  }
313
+ for (const f of components[c.id]!.features)
314
+ if (!(f in opts.runtime.features)) throw new BuildError('runtime-feature-missing', `${c.id}: needs the client runtime feature ${f}, which the build did not ship`)
264
315
  }
265
316
  const routes: RouteRecord[] = opts.leaves.map((l) => {
266
317
  // A node without a Component (a pure path/loader group) renders nothing: not a chain entry.
@@ -282,7 +333,7 @@ export function writeManifest(opts: {
282
333
  }
283
334
  })
284
335
  linkNativeChunks(routes, compiled, components)
285
- return { version: 1, routes, components, assets: { runtime: opts.runtime }, jobs_module: 'jobs.js', actions: opts.actions ?? null }
336
+ return { version: MANIFEST_VERSION, routes, components, assets: { runtime: opts.runtime.core, features: opts.runtime.features }, jobs_module: 'jobs.js', actions: opts.actions ?? null }
286
337
  }
287
338
 
288
339
  /** S6 amendment (F66): the server's asset injection walks only each chain component's direct