@brust/core 2.0.0-alpha.3 → 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 (47) hide show
  1. package/README.md +64 -6
  2. package/native/index.d.ts +37 -2
  3. package/native/index.js +56 -54
  4. package/package.json +11 -9
  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 +2 -2
  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 +39 -13
  14. package/src/build/cf-assets.ts +4 -2
  15. package/src/build/cf-entry.ts +7 -3
  16. package/src/build/cf-wrangler.ts +3 -1
  17. package/src/build/cloudflare.ts +14 -9
  18. package/src/build/compile.ts +41 -4
  19. package/src/build/config.ts +46 -10
  20. package/src/build/css.ts +286 -0
  21. package/src/build/index.ts +135 -20
  22. package/src/build/manifest.ts +23 -4
  23. package/src/build/scan.ts +59 -20
  24. package/src/build/ssg-render.ts +65 -0
  25. package/src/build/ssg.ts +146 -0
  26. package/src/build/static-worker.ts +91 -0
  27. package/src/build/static.ts +258 -0
  28. package/src/build-time.ts +10 -0
  29. package/src/cf.ts +2 -1
  30. package/src/cli.ts +19 -4
  31. package/src/config.ts +111 -25
  32. package/src/css-modules.d.ts +8 -0
  33. package/src/dev/bun.ts +365 -0
  34. package/src/dev/client.ts +92 -0
  35. package/src/dev/devtools-data.ts +23 -0
  36. package/src/dev/devtools.ts +356 -0
  37. package/src/dev/diag.ts +30 -0
  38. package/src/dev/proxy.ts +236 -0
  39. package/src/dev/tui.ts +144 -0
  40. package/src/dev/watch.ts +81 -0
  41. package/src/dev.ts +11 -28
  42. package/src/handlers.ts +41 -2
  43. package/src/index.ts +3 -1
  44. package/src/native.ts +18 -0
  45. package/src/routes.ts +20 -1
  46. package/src/run.ts +40 -8
  47. package/src/worker.ts +88 -20
@@ -14,6 +14,7 @@ import { type CacheNeed, mergeWrangler, readUserWrangler, renderWrangler, type U
14
14
  import { type CloudflareBuildConfig, CF_MIN_COMPAT_DATE } from './config'
15
15
  import { PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
16
16
  import type { Compiled } from './compile'
17
+ import { cssPlugin } from './css'
17
18
  import { BuildError } from './errors'
18
19
  import type { CacheRecord } from './manifest'
19
20
  import { BUILTINS as CACHE_BUILTINS } from '../cache-adapter/load'
@@ -76,10 +77,14 @@ export interface CfBuildCtx {
76
77
  manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null; cache?: CacheRecord }
77
78
  sourceOf: SourceOf
78
79
  actionsEntry: string | null
80
+ /** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
81
+ actionsOnly?: boolean
79
82
  /** A custom cache adapter's source module (absolute). */
80
83
  cacheModule?: string | null
81
84
  /** `[cache] get_timeout_ms` as written (absent → the Workers default, 100). */
82
85
  cacheGetTimeoutMs?: number
86
+ /** L19: CSS Module class maps (the worker reads the same map as the browser). */
87
+ cssMaps?: Map<string, Record<string, string>>
83
88
  log: (s: string) => void
84
89
  }
85
90
 
@@ -124,7 +129,7 @@ function cfAlias(alias: Record<string, string>, sourceOf: SourceOf): BunPlugin {
124
129
  /** The CF bundle's stand-in for the napi addon (`native.ts` `require('../native/index.js')`, R8): every call
125
130
  * fails loudly, since a Worker has no addon. */
126
131
  function cfNativeStub(handlersPath: string): BunPlugin {
127
- const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
132
+ const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'planJobsBuf', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
128
133
  return {
129
134
  name: 'brust-cf-native',
130
135
  setup(b) {
@@ -166,7 +171,7 @@ function cfEntryCache(c: CacheRecord, customModule: string | null) {
166
171
  }
167
172
 
168
173
  /** R2/R10: which Cloudflare bindings the configured cache needs (`[cache.cloudflare]` keys land in the adapter's options), or null. */
169
- export function cacheNeeds(c: CacheRecord | undefined): CacheNeed | null {
174
+ export function cacheNeeds(c: Pick<CacheRecord, 'adapter' | 'broadcast' | 'options' | 'broadcastOptions'> | undefined): CacheNeed | null {
170
175
  if (!c) return null
171
176
  const opt = (o: Record<string, unknown> | undefined, k: string) => (typeof o?.[k] === 'string' ? (o[k] as string) : undefined)
172
177
  const all = [c.options, c.broadcastOptions]
@@ -191,7 +196,7 @@ plugin({
191
196
  const m = await import(${JSON.stringify(app)})
192
197
  const bad = []
193
198
  for (const k of ['loader', 'jobs', 'action', 'install']) if (typeof m[k] !== 'function') bad.push(k + ' is not a function')
194
- if (typeof m.MANIFEST !== 'string' || JSON.parse(m.MANIFEST).version !== 1) bad.push('MANIFEST.version !== 1')
199
+ if (typeof m.MANIFEST !== 'string' || JSON.parse(m.MANIFEST).version !== 2) bad.push('MANIFEST.version !== 2')
195
200
  if (typeof m.CONFIG !== 'string' || JSON.parse(m.CONFIG).abi !== 1) bad.push('CONFIG.abi !== 1')
196
201
  if (typeof m.TEMPLATES !== 'object' || m.TEMPLATES === null) bad.push('TEMPLATES is not an object')
197
202
  const r = await m.loader('{}')
@@ -247,11 +252,11 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
247
252
  const jobsEntry = join(out, '.stage', 'jobs.ts')
248
253
  writeFileSync(
249
254
  entryPath,
250
- cfEntrySource({ entry: ctx.entry, jobsEntry, actionsEntry: a ? ctx.actionsEntry : null, prefix: a?.prefix ?? '', manifestPath: join(out, 'manifest.json'), templatesPath, config, cache: entryCache, doClass }),
255
+ cfEntrySource({ entry: ctx.entry, jobsEntry, actionsOnly: ctx.actionsOnly === true, actionsEntry: a ? ctx.actionsEntry : null, prefix: a?.prefix ?? '', manifestPath: join(out, 'manifest.json'), templatesPath, config, cache: entryCache, doClass }),
251
256
  )
252
257
  // Bare imports of the generated files resolve from the app root (an --out-dir outside the app has no node_modules above it).
253
258
  // (registered by absolute AND real path: the bundler reports the real one, e.g. /private/var on macOS)
254
- for (const f of [entryPath, jobsEntry]) {
259
+ for (const f of ctx.actionsOnly ? [entryPath] : [entryPath, jobsEntry]) {
255
260
  ctx.sourceOf.set(f, ctx.entry)
256
261
  ctx.sourceOf.set(realpathSync(f), ctx.entry)
257
262
  }
@@ -269,7 +274,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
269
274
  define: PRODUCTION,
270
275
  minify: false,
271
276
  external: ['node:*', 'cloudflare:*', glueSpec],
272
- plugins: [cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'])],
277
+ plugins: [cssPlugin(ctx.cssMaps ?? new Map()), cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'])],
273
278
  })
274
279
  if (violations.length > 0) throw new BuildError('cf-unsupported', `${[...new Set(violations)].join('\n')}\nthese modules do not exist on Cloudflare Workers; swap them with [cloudflare] alias`)
275
280
  if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
@@ -280,8 +285,8 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
280
285
  mkdirSync(appDir, { recursive: true })
281
286
  writeFileSync(appFile, await js[0]!.text())
282
287
 
283
- const assets = layoutAssets(out, cfDir, p.coreVersion)
284
- warnings.push(...assets.warnings)
288
+ // An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
289
+ if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
285
290
  const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
286
291
  if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
287
292
  if (doClass) {
@@ -292,7 +297,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
292
297
  `// generated by brust build --target cloudflare (do not edit)\nexport * from "./${p.core.meta.main}"\nexport { default } from "./${p.core.meta.main}"\nexport { BrustCacheDO } from "./${appRel}"\n`,
293
298
  )
294
299
  }
295
- const merged = mergeWrangler(p.user, { appRoot: ctx.appRoot, cfDir: join(ctx.dist, 'cf'), name: p.cfg.name, pkgName: p.pkgName, compatibilityDate, main: doClass ? 'main.js' : p.core.meta.main, version: p.coreVersion })
300
+ const merged = mergeWrangler(p.user, { appRoot: ctx.appRoot, cfDir: join(ctx.dist, 'cf'), name: p.cfg.name, pkgName: p.pkgName, compatibilityDate, main: doClass ? 'main.js' : p.core.meta.main, version: p.coreVersion, ...(ctx.actionsOnly ? { noAssets: true } : {}) })
296
301
  warnings.push(...merged.warnings)
297
302
  writeFileSync(join(cfDir, 'wrangler.jsonc'), renderWrangler(merged.config))
298
303
 
@@ -2,7 +2,8 @@
2
2
  // `compileTree` (analysis + lowering of its whole tree). `Err` = build error; fallback and
3
3
  // warning diagnostics are logged; `outlet-outside-layout` is raised here (contract 5).
4
4
  // A component several trees reach keeps one compile, chosen by role, not by route order (F75).
5
- import { relative } from 'node:path'
5
+ import { realpathSync } from 'node:fs'
6
+ import { relative, sep } from 'node:path'
6
7
  import { type CompiledComponent, type CompiledTree, compileTree } from '../../native/index.js'
7
8
  import type { FlatRoute, Route } from '../routes'
8
9
  import { BuildError } from './errors'
@@ -17,13 +18,22 @@ export type ComponentIR = Record<string, any> & {
17
18
  tier: any
18
19
  // biome-ignore lint/suspicious/noExplicitAny: IR JSON
19
20
  jobs: any[]
20
- instances: { child_id: string; k: number; loops: (string | null)[]; props: Record<string, string | null> }[]
21
+ instances: {
22
+ child_id: string
23
+ k: number
24
+ loops: (string | null)[]
25
+ props: Record<string, string | null>
26
+ literals?: Record<string, unknown>
27
+ exprs?: Record<string, string>
28
+ }[]
21
29
  use_id_slots: number
22
30
  uses_outlet: boolean
23
31
  needs_worker: boolean
24
32
  /** `useStore(handle)` bindings (`decls.rs` `StoreUse`); `resolved` = `StoreRef`, `null` when
25
33
  * unresolved (the component falls back). `module` is root-relative (C3). */
26
34
  stores?: { handle: string; resolved: { name: string; module: string } | null }[]
35
+ /** A package export rendered as a React island (L22): `source` is then not a file. */
36
+ external?: { source: string; export: string }
27
37
  }
28
38
 
29
39
  export interface Compiled {
@@ -33,7 +43,11 @@ export interface Compiled {
33
43
  ir: ComponentIR
34
44
  jinja: string
35
45
  serverTs?: string
46
+ /** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
47
+ evalTs?: string
36
48
  clientJs?: string
49
+ /** The compiler's diagnostics for this component (`dev.json`, L14). */
50
+ diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string }[]
37
51
  }
38
52
 
39
53
  type Diag = NonNullable<CompiledTree['error']>
@@ -78,11 +92,25 @@ export function compileApp(opts: {
78
92
  appRoot: string
79
93
  leaves: FlatRoute[]
80
94
  componentFile: Map<Function, string>
95
+ /** Why a Component the scan saw has no file (named export only, outside the root); optional. */
96
+ componentIssue?: Map<Function, string>
81
97
  runtimeImport: string
82
98
  serverOnly: string[]
99
+ /** L20: every CSS Module's class map by absolute source path; `styles.x` folds to the literal class. */
100
+ cssMaps?: Map<string, Record<string, string>>
83
101
  log: (s: string) => void
102
+ /** SVG markup the build rendered for lucide icon requests (L23), by `ir.icon_requests[].key`. */
103
+ iconSvgs?: Record<string, { svg: string; base: string }>
104
+ /** The compile after icons were rendered (even if none could be): icons still components are judged as islands. */
105
+ iconsFinal?: boolean
106
+ /** Values the build computed for `ir.build_eval` slots, by key (L2): the slots become literals. */
107
+ slotValues?: Record<string, unknown>
84
108
  }): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
85
109
  const { appRoot, leaves, componentFile } = opts
110
+ const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
111
+ const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
112
+ // The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
113
+ const cssModules = Object.fromEntries([...(opts.cssMaps ?? [])].map(([abs, map]) => [relative(realpathSync(appRoot), abs).split(sep).join('/'), map]))
86
114
  const seen = new Map<string, Seen>()
87
115
  const rootIdOfFile = new Map<string, string>()
88
116
  const routeComponent = new Map<string, string>()
@@ -95,12 +123,12 @@ export function compileApp(opts: {
95
123
  if (!file)
96
124
  throw new BuildError(
97
125
  'component-source',
98
- `${route.Component!.name || '(anonymous)'} is not a default import of a .tsx file (route ${routeId})`,
126
+ `${opts.componentIssue?.get(route.Component!) ?? `${route.Component!.name || '(anonymous)'} is not the default export of a .tsx file in the import graph`} (route ${routeId})`,
99
127
  )
100
128
  let rootId = rootIdOfFile.get(file)
101
129
  if (!rootId) {
102
130
  const rel = relative(appRoot, file)
103
- const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly)
131
+ const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson)
104
132
  if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
105
133
  for (const [i, c] of tree.components.entries()) {
106
134
  // A lowering Error that came back as a diagnostic is a build error too (contract 9).
@@ -132,9 +160,18 @@ export function compileApp(opts: {
132
160
  ir,
133
161
  jinja: c.jinja,
134
162
  serverTs: c.serverTs,
163
+ evalTs: c.evalTs,
135
164
  clientJs: c.clientJs,
165
+ diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
136
166
  })
137
167
  }
168
+ // A package export has no file of its own: bare imports in its island entries resolve from a
169
+ // component that uses it (the first one in build order).
170
+ for (const c of compiled.values()) {
171
+ if (!c.ir.external) continue
172
+ const user = [...compiled.values()].find((p) => !p.ir.external && (p.ir.children ?? []).some((ref: { id: string | null }) => ref.id === c.id))
173
+ c.file = user?.file ?? c.file
174
+ }
138
175
  for (const [routeId, route] of nodes) {
139
176
  const rootId = routeComponent.get(routeId)!
140
177
  if (compiled.get(rootId)!.ir.uses_outlet && !route.children)
@@ -4,13 +4,16 @@
4
4
  // `server_only` is the app config `serverOnly` of the compiler spec (§8.2): the compiler refuses
5
5
  // such an import in client code, and every browser bundle refuses to include it.
6
6
  import { join } from 'node:path'
7
- import { DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, prefixError } from '../actions/prefix'
7
+ import { ACTION_CREDENTIALS, type ActionCredentials, DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, baseUrlError, prefixError } from '../actions/prefix'
8
8
  import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
9
9
  import { BuildError } from './errors'
10
10
 
11
11
  // [actions] (opt-in, ruling R6): present table => actions on. Both byte caps cross napi as u32 (F82: 1..64 MiB / 256 KiB..64 MiB).
12
12
  // [actions]
13
13
  // entry = "actions.ts" prefix = "/_brust/action" max_body_bytes = 1048576 max_response_bytes = 8388608 cross_site = "reject" | "allow" (F87)
14
+ // base_url = "https://api.example.com" credentials = "same-origin" | "include" | "omit" (L16: the browser client's target; runtime settings, validated here too)
15
+ // `prefix = "/"` is accepted only for an actions-only build (L16 R16.4).
16
+ // `ACTION_INLINE_BODY_BYTES` (Rust): the inline/heap threshold of an action body, independent of the response slot size (`slotBytes`).
14
17
  const MIN_RESPONSE_BYTES = 262_144
15
18
 
16
19
  export interface ActionsBuildConfig {
@@ -19,6 +22,11 @@ export interface ActionsBuildConfig {
19
22
  maxBodyBytes: number
20
23
  maxResponseBytes: number
21
24
  crossSite: 'reject' | 'allow'
25
+ /** `[actions] base_url`; with no actions file present the build is page-only (L16 R16.4b). */
26
+ baseUrl: string | undefined
27
+ credentials: ActionCredentials
28
+ /** `entry` was written in brust.toml (a missing file is then an error, never a page-only build). */
29
+ entryExplicit: boolean
22
30
  }
23
31
 
24
32
  // [cloudflare] (M4-CF; only read by `--target cloudflare`, validated always so errors surface early):
@@ -66,6 +74,8 @@ export interface BuildConfig {
66
74
  actions: ActionsBuildConfig | null
67
75
  cloudflare: CloudflareBuildConfig | null
68
76
  cache: CacheBuildConfig | null
77
+ /** `[css] global` (L19, R19.1): app-root-relative stylesheets every route links first, in order. */
78
+ cssGlobal: string[]
69
79
  }
70
80
 
71
81
  const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -232,7 +242,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
232
242
  }
233
243
  }
234
244
 
235
- function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
245
+ function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
236
246
  if (raw === undefined) return null
237
247
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
238
248
  const t = raw as Record<string, unknown>
@@ -243,7 +253,8 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
243
253
  }
244
254
  let prefix = DEFAULT_ACTION_PREFIX
245
255
  if (t.prefix !== undefined) {
246
- const why = prefixError(t.prefix)
256
+ // `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
257
+ const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
247
258
  if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
248
259
  prefix = t.prefix as string
249
260
  }
@@ -266,15 +277,39 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
266
277
  if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
267
278
  crossSite = t.cross_site
268
279
  }
269
- return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite }
280
+ let baseUrl: string | undefined
281
+ if (t.base_url !== undefined) {
282
+ const why = baseUrlError(t.base_url)
283
+ if (why) throw new BuildError('config', `${file}: actions.base_url ${why}`)
284
+ baseUrl = t.base_url as string
285
+ }
286
+ let credentials: ActionCredentials = 'same-origin'
287
+ if (t.credentials !== undefined) {
288
+ if (typeof t.credentials !== 'string' || !ACTION_CREDENTIALS.includes(t.credentials as ActionCredentials))
289
+ throw new BuildError('config', `${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(t.credentials)})`)
290
+ credentials = t.credentials as ActionCredentials
291
+ }
292
+ return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite, baseUrl, credentials, entryExplicit: t.entry !== undefined }
270
293
  }
271
294
 
272
295
  const DEFAULT_ENTRY = 'actions.ts'
273
296
 
274
- export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
297
+ /** `[css]`: only `global`, a list of `.css` files relative to the app root (checked to exist by the build). */
298
+ function parseCss(file: string, raw: unknown): string[] {
299
+ if (raw === undefined) return []
300
+ if (!isTable(raw)) throw new BuildError('config', `${file}: [css] must be a table`)
301
+ for (const k of Object.keys(raw)) if (k !== 'global') throw new BuildError('config', `${file}: unknown key css.${k} (allowed: global)`)
302
+ const g = raw.global
303
+ if (g === undefined) return []
304
+ if (!Array.isArray(g) || g.some((p) => typeof p !== 'string' || !p.trim().endsWith('.css')))
305
+ throw new BuildError('config', `${file}: css.global must be an array of .css paths (relative to the app root)`)
306
+ return g.map((p: string) => p.trim())
307
+ }
308
+
309
+ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
275
310
  const file = join(appRoot, 'brust.toml')
276
311
  const f = Bun.file(file)
277
- if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null }
312
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [] }
278
313
  let parsed: unknown
279
314
  try {
280
315
  parsed = Bun.TOML.parse(await f.text())
@@ -282,15 +317,16 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
282
317
  throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
283
318
  }
284
319
  const root = (parsed ?? {}) as Record<string, unknown>
285
- const actions = parseActions(file, root.actions)
320
+ const actions = parseActions(file, root.actions, o.actionsOnly === true)
286
321
  const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
287
322
  const cache = await parseCache(file, appRoot, root.cache)
323
+ const cssGlobal = parseCss(file, root.css)
288
324
  const build = root.build
289
- if (build === undefined) return { serverOnly: [], actions, cloudflare, cache }
325
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
290
326
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
291
327
  const so = (build as Record<string, unknown>).server_only
292
- if (so === undefined) return { serverOnly: [], actions, cloudflare, cache }
328
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
293
329
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
294
330
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
295
- return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache }
331
+ return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal }
296
332
  }
@@ -0,0 +1,286 @@
1
+ // CSS for `brust build` (gaps round L19, R19.1-R19.4): which stylesheets each route links, one
2
+ // minified content-hashed `client/<stem>-<hex10>.css` per source file, the class maps of CSS
3
+ // Modules, and the one plugin every JS build uses so no JS build ever emits CSS.
4
+ //
5
+ // - A route's CSS: `[css] global` first, then every `.css` import reachable from its chain files
6
+ // (root layout → page), depth-first in import order, first occurrence wins.
7
+ // - CSS Modules: one in-process Bun.build of every source (a JS entry per file), so one set of class
8
+ // names serves the server (jobs.js), the browser chunks and the Cloudflare worker alike.
9
+ // - Relative `url()` fonts and images are emitted next to the CSS as `client/<name>-<hex10>.<ext>`;
10
+ // absolute `/public/*` and `/_brust/*` stay as written.
11
+ import { createHash } from 'node:crypto'
12
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
13
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
14
+ import type { BunPlugin } from 'bun'
15
+ import type { FlatRoute } from '../routes'
16
+ import { BuildError } from './errors'
17
+
18
+ const hex = (b: string | Uint8Array) => createHash('sha256').update(b).digest('hex').slice(0, 10)
19
+ const real = (p: string) => {
20
+ try {
21
+ return realpathSync(p)
22
+ } catch {
23
+ return p
24
+ }
25
+ }
26
+ const isCss = (p: string) => p.endsWith('.css')
27
+ const isModule = (p: string) => p.endsWith('.module.css')
28
+ /** A name the static rules serve (`[A-Za-z0-9_.-]`, no leading dot). */
29
+ const safeName = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^\.+/, '_')
30
+
31
+ /** The stylesheets one source file reaches through static and dynamic imports, in order, real paths. Source
32
+ * files outside node_modules are followed; a `.css` import (an app file or a package's CSS) is
33
+ * collected, never followed (its `@import`s are the CSS bundler's). */
34
+ export class CssGraph {
35
+ private memo = new Map<string, string[]>()
36
+ private active = new Set<string>()
37
+ private transpilers = new Map<string, InstanceType<typeof Bun.Transpiler>>()
38
+
39
+ of(file: string): string[] {
40
+ const f = real(file)
41
+ const hit = this.memo.get(f)
42
+ if (hit) return hit
43
+ if (this.active.has(f)) return [] // an import cycle: the outer visit already lists what follows
44
+ this.active.add(f)
45
+ const out: string[] = []
46
+ const add = (c: string) => {
47
+ if (!out.includes(c)) out.push(c)
48
+ }
49
+ const loader = /\.(tsx|ts|jsx|js|mjs)$/.exec(f)?.[1]
50
+ let src = ''
51
+ try {
52
+ src = loader ? readFileSync(f, 'utf8') : ''
53
+ } catch {}
54
+ if (loader && src) {
55
+ const l = loader === 'mjs' ? 'js' : (loader as 'tsx' | 'ts' | 'jsx' | 'js')
56
+ let t = this.transpilers.get(l)
57
+ if (!t) this.transpilers.set(l, (t = new Bun.Transpiler({ loader: l })))
58
+ for (const imp of t.scanImports(src)) {
59
+ // A dynamic import() is walked too (review N4): its CSS is linked eagerly for the route, so a
60
+ // lazily loaded component is styled on first paint and its CSS Modules are built.
61
+ if (imp.kind !== 'import-statement' && imp.kind !== 'dynamic-import') continue
62
+ let dep: string
63
+ try {
64
+ dep = real(Bun.resolveSync(imp.path, dirname(f)))
65
+ } catch {
66
+ continue // unresolvable: the JS build reports it
67
+ }
68
+ if (isCss(dep)) add(dep)
69
+ else if (!dep.split(sep).includes('node_modules')) for (const c of this.of(dep)) add(c)
70
+ }
71
+ }
72
+ this.active.delete(f)
73
+ this.memo.set(f, out)
74
+ return out
75
+ }
76
+ }
77
+
78
+ /** R19.1: every route's ordered source stylesheets (real paths), by route id. `chainFiles` maps a leaf to
79
+ * its chain's component files, root layout first. */
80
+ export function routeCssSources(leaves: FlatRoute[], chainFiles: (l: FlatRoute) => string[], globals: string[], graph = new CssGraph()): Map<string, string[]> {
81
+ const out = new Map<string, string[]>()
82
+ for (const leaf of leaves) {
83
+ const list: string[] = [...globals]
84
+ for (const file of chainFiles(leaf)) for (const c of graph.of(file)) if (!list.includes(c)) list.push(c)
85
+ out.set(leaf.id, list)
86
+ }
87
+ return out
88
+ }
89
+
90
+ /** `[css] global` resolved: each a `.css` file inside the app. */
91
+ export function resolveGlobals(appRoot: string, globals: string[]): string[] {
92
+ return globals.map((g) => {
93
+ const abs = resolve(appRoot, g)
94
+ const rel = relative(appRoot, abs)
95
+ if (isAbsolute(g) || rel.startsWith('..') || isAbsolute(rel) || !existsSync(abs)) throw new BuildError('css', `css.global ${g} is not a file inside the app`)
96
+ return real(abs)
97
+ })
98
+ }
99
+
100
+ export interface CssBuild {
101
+ /** source (real path) → `client/<stem>-<hex10>.css`; absent when the file emits no CSS. */
102
+ file: Map<string, string>
103
+ /** `.module.css` source (real path) → its class map. */
104
+ maps: Map<string, Record<string, string>>
105
+ }
106
+
107
+ const CLASS = /\.(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g
108
+
109
+ /** The asset types `static_rules::is_safe_client_asset_filename` serves from `/_brust/client/` (review N1). */
110
+ export const SERVED_ASSET_EXT = ['.woff2', '.woff', '.ttf', '.otf', '.eot', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico']
111
+
112
+ /** R19.2/R19.4: builds every source stylesheet (one JS entry each) into `<dist>/client/`. `minify`
113
+ * is off only for a development build (`brust dev`, review N3). */
114
+ export async function buildCss(
115
+ dist: string,
116
+ sources: string[],
117
+ minify = true,
118
+ o: { appRoot?: string; log?: (s: string) => void } = {},
119
+ ): Promise<CssBuild> {
120
+ const file = new Map<string, string>()
121
+ const maps = new Map<string, Record<string, string>>()
122
+ if (sources.length === 0) return { file, maps }
123
+ const appRoot = o.appRoot ?? process.cwd()
124
+ const tw = tailwindUse(sources)
125
+ const plugins: BunPlugin[] = []
126
+ if (tw.using.length > 0) {
127
+ plugins.push(await loadTailwind(appRoot, tw.using[0]!))
128
+ if (tw.imports.length > 1)
129
+ o.log?.(
130
+ `[brust] warning tailwind @import "tailwindcss" is in ${tw.imports.length} files (${tw.imports.map((f) => relative(appRoot, f)).join(', ')}): each one ships the whole of Tailwind; import it once (e.g. app.css) and use @reference elsewhere`,
131
+ )
132
+ }
133
+ const stage = join(dist, '.stage', 'css')
134
+ mkdirSync(stage, { recursive: true })
135
+ const entries = sources.map((src, i) => {
136
+ const e = join(stage, `s${i}.js`)
137
+ writeFileSync(e, isModule(src) ? `export { default } from ${JSON.stringify(src)}\n` : `import ${JSON.stringify(src)}\n`)
138
+ return e
139
+ })
140
+ const res = await Bun.build({
141
+ entrypoints: entries,
142
+ target: 'browser',
143
+ format: 'esm',
144
+ minify,
145
+ naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
146
+ external: ['/public/*', '/_brust/*'],
147
+ // L21: Tailwind's content detection scans the app (bun-plugin-tailwind reads `root`).
148
+ root: appRoot,
149
+ plugins,
150
+ })
151
+ if (!res.success) throw new BuildError('css', res.logs.map(String).join('\n'))
152
+ // Assets a stylesheet references (fonts, images): `<name>-<hex10>.<ext>`, references rewritten.
153
+ const rename = new Map<string, string>()
154
+ const assets: { name: string; bytes: Uint8Array }[] = []
155
+ const css = new Map<number, string>()
156
+ const js = new Map<number, string>()
157
+ const unserved: string[] = []
158
+ for (const o of res.outputs) {
159
+ const name = basename(o.path)
160
+ const entry = /^s(\d+)-[^-.]+\.(css|js)$/.exec(name)
161
+ if (entry) {
162
+ ;(entry[2] === 'css' ? css : js).set(Number(entry[1]), await o.text())
163
+ continue
164
+ }
165
+ const m = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)
166
+ if (!m) throw new BuildError('css', `unexpected output ${name}`)
167
+ const ext = m[2]!.toLowerCase()
168
+ if (!SERVED_ASSET_EXT.includes(ext)) {
169
+ unserved.push(name)
170
+ continue
171
+ }
172
+ const bytes = new Uint8Array(await o.arrayBuffer())
173
+ const to = `${safeName(m[1]!)}-${hex(bytes)}${ext}`
174
+ rename.set(name, to)
175
+ assets.push({ name: to, bytes })
176
+ }
177
+ // Fail closed (review N1): an asset the server would not serve from /_brust/client would 404.
178
+ if (unserved.length > 0) {
179
+ const lines = unserved.map((name) => {
180
+ const by = sources.filter((_, i) => css.get(i)?.includes(name))
181
+ const [, stem, ext] = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)!
182
+ return `${stem}${ext} (url() in ${by.join(', ') || 'a stylesheet'})`
183
+ })
184
+ throw new BuildError(
185
+ 'css-asset-type',
186
+ `${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
187
+ )
188
+ }
189
+ for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
190
+ for (const [i, src] of sources.entries()) {
191
+ let text = css.get(i)
192
+ if (text !== undefined && text.trim() !== '') {
193
+ for (const [from, to] of rename) text = text.split(from).join(to)
194
+ const stem = safeName(basename(src).replace(/\.css$/, ''))
195
+ const out = `${stem}-${hex(text)}.css`
196
+ writeFileSync(join(dist, 'client', out), text)
197
+ file.set(src, `client/${out}`)
198
+ }
199
+ if (!isModule(src)) continue
200
+ const mod = join(stage, `map${i}.mjs`)
201
+ writeFileSync(mod, js.get(i) ?? 'export default {}')
202
+ const map = { ...((await import(mod)).default as Record<string, string>) }
203
+ // R19.4: every class the map hands out exists in the CSS it ships with.
204
+ const have = new Set([...(text ?? '').matchAll(CLASS)].map((x) => x[1]!))
205
+ for (const [k, v] of Object.entries(map))
206
+ for (const cls of v.split(/\s+/).filter(Boolean))
207
+ if (!have.has(cls)) throw new BuildError('css', `${src}: class ${k} maps to ${cls}, which is not in the emitted CSS`)
208
+ maps.set(src, map)
209
+ }
210
+ return { file, maps }
211
+ }
212
+
213
+ // ---- Tailwind CSS v4 (gaps round L21, R21.1) ----
214
+
215
+ /** A stylesheet that needs the Tailwind compiler. */
216
+ const TAILWIND_USE = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']|@apply\b|@reference\b|@theme\b|@plugin\b/
217
+ const TAILWIND_IMPORT = /@import\s+(?:url\(\s*)?["']tailwindcss["']/
218
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?["'](\.{1,2}\/[^"']+)["']/g
219
+
220
+ /** Which sources (or the relative `@import`s they pull in) use Tailwind, and which `@import "tailwindcss"`. */
221
+ export function tailwindUse(sources: string[]): { using: string[]; imports: string[] } {
222
+ const using: string[] = []
223
+ const imports: string[] = []
224
+ const seen = new Set<string>()
225
+ const visit = (f: string) => {
226
+ if (seen.has(f)) return
227
+ seen.add(f)
228
+ let text = ''
229
+ try {
230
+ text = readFileSync(f, 'utf8')
231
+ } catch {
232
+ return
233
+ }
234
+ if (TAILWIND_USE.test(text)) using.push(f)
235
+ if (TAILWIND_IMPORT.test(text)) imports.push(f)
236
+ for (const m of text.matchAll(CSS_IMPORT)) visit(resolve(dirname(f), m[1]!))
237
+ }
238
+ for (const s of sources) visit(s)
239
+ return { using, imports }
240
+ }
241
+
242
+ /** `bun-plugin-tailwind`, resolved from the app (R21.1: `tailwindcss@4` and the plugin are the app's own dev
243
+ * dependencies). Missing → `tailwind-missing` with the exact install command. */
244
+ export async function loadTailwind(appRoot: string, usedBy: string): Promise<BunPlugin> {
245
+ const missing = ['tailwindcss', 'bun-plugin-tailwind'].filter((p) => {
246
+ try {
247
+ Bun.resolveSync(`${p}/package.json`, appRoot)
248
+ return false
249
+ } catch {
250
+ return true
251
+ }
252
+ })
253
+ if (missing.length > 0)
254
+ throw new BuildError(
255
+ 'tailwind-missing',
256
+ `${relative(appRoot, usedBy)} uses Tailwind CSS, but ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not installed in the app: run \`bun add -d tailwindcss@4 bun-plugin-tailwind\``,
257
+ )
258
+ return (await import(Bun.resolveSync('bun-plugin-tailwind', appRoot))).default as BunPlugin
259
+ }
260
+
261
+ /** R19.4: the one CSS plugin of every JS build. A `.module.css` import is its class map
262
+ * (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
263
+ * stylesheet itself is linked by the server (R19.3), never bundled into JS. */
264
+ export function cssPlugin(maps: Map<string, Record<string, string>>): BunPlugin {
265
+ return {
266
+ name: 'brust-css',
267
+ setup(b) {
268
+ b.onLoad({ filter: /\.css$/ }, (a) => {
269
+ if (!isModule(a.path)) return { contents: '', loader: 'js' }
270
+ const map = maps.get(real(a.path))
271
+ if (!map) throw new Error(`${a.path} is imported by code the build did not reach from a route; import it from a route's component files`)
272
+ return { contents: `export default Object.freeze(${JSON.stringify(map)})\n`, loader: 'js' }
273
+ })
274
+ },
275
+ }
276
+ }
277
+
278
+ /** Direct `.css` imports of `file` (the route entry: linked to no route, so the build warns). */
279
+ export function directCss(file: string): string[] {
280
+ const src = readFileSync(file, 'utf8')
281
+ const t = new Bun.Transpiler({ loader: file.endsWith('.tsx') ? 'tsx' : 'ts' })
282
+ return t
283
+ .scanImports(src)
284
+ .filter((i) => i.kind === 'import-statement' && i.path.endsWith('.css'))
285
+ .map((i) => i.path)
286
+ }