@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,12 +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
+ import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
8
9
  import { BuildError } from './errors'
9
10
 
10
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).
11
12
  // [actions]
12
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`).
13
17
  const MIN_RESPONSE_BYTES = 262_144
14
18
 
15
19
  export interface ActionsBuildConfig {
@@ -18,14 +22,227 @@ export interface ActionsBuildConfig {
18
22
  maxBodyBytes: number
19
23
  maxResponseBytes: number
20
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
30
+ }
31
+
32
+ // [cloudflare] (M4-CF; only read by `--target cloudflare`, validated always so errors surface early):
33
+ // name = "my-worker" compatibility_date = "2026-10-01" l1_capacity = 256 job_cache_capacity = 512 stats = false
34
+ // edge_cache = "off" [cloudflare.alias] "lib/db.ts" = "lib/db.cf.ts" # app-root-relative module swaps (spec R12)
35
+ const CF_KEYS = new Set(['name', 'compatibility_date', 'l1_capacity', 'job_cache_capacity', 'stats', 'edge_cache', 'alias'])
36
+ export const CF_MIN_COMPAT_DATE = '2026-08-04'
37
+ const U32_MAX = 4_294_967_295
38
+
39
+ export interface CloudflareBuildConfig {
40
+ name: string | undefined
41
+ compatibilityDate: string | undefined
42
+ l1Capacity: number
43
+ jobCacheCapacity: number
44
+ stats: boolean
45
+ edgeCache: 'off'
46
+ /** absolute module path -> absolute replacement path */
47
+ alias: Record<string, string>
48
+ }
49
+
50
+ // [cache] (cache adapters, opt-in; spec §6): a shared second tier behind the in-process cache.
51
+ // adapter = "redis" | "dragonfly" | "cloudflare-kv" | "cloudflare-do" | "custom" module = "lib/cache.ts" (custom)
52
+ // broadcast = "<built-in>" namespace = "<package name>" get_timeout_ms = 50 (native) / 100 (Workers)
53
+ // max_body_bytes = 1048576 epoch_interval_ms = 1000 [cache.redis] url = "env:REDIS_URL" [cache.cloudflare] [cache.options]
54
+ const CACHE_KEYS = new Set(['adapter', 'broadcast', 'module', 'namespace', 'get_timeout_ms', 'max_body_bytes', 'epoch_interval_ms', 'redis', 'cloudflare', 'options'])
55
+ export const MAX_L2_BODY_BYTES = 67_108_864
56
+
57
+ export interface CacheBuildConfig {
58
+ adapter: (typeof ADAPTER_NAMES)[number]
59
+ /** `custom`: the module, app-root-relative as written. */
60
+ module: string | undefined
61
+ broadcast: string | undefined
62
+ /** The factory's options: `[cache.redis]` / `[cache.cloudflare]` for built-ins, `[cache.options]` for custom. */
63
+ options: Record<string, unknown>
64
+ broadcastOptions: Record<string, unknown> | undefined
65
+ namespace: string
66
+ /** Absent = the runtime default (native 50, Workers 100). */
67
+ getTimeoutMs: number | undefined
68
+ maxBodyBytes: number
69
+ epochIntervalMs: number
21
70
  }
22
71
 
23
72
  export interface BuildConfig {
24
73
  serverOnly: string[]
25
74
  actions: ActionsBuildConfig | null
75
+ cloudflare: CloudflareBuildConfig | null
76
+ cache: CacheBuildConfig | null
77
+ /** `[css] global` (L19, R19.1): app-root-relative stylesheets every route links first, in order. */
78
+ cssGlobal: string[]
79
+ }
80
+
81
+ const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
82
+
83
+ function rangeInt(file: string, key: string, v: unknown, lo: number, hi: number, dflt: number): number {
84
+ if (v === undefined) return dflt
85
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < lo || v > hi)
86
+ throw new BuildError('cache', `${file}: cache.${key} must be an integer in ${lo}..${hi} (got ${String(v)})`)
87
+ return v
26
88
  }
27
89
 
28
- function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
90
+ /** `[cache.redis]`'s keys and ranges (cache-redis R10); `@brust/cache-redis` validates the same list at worker boot. */
91
+ const REDIS_INT_KEYS: ReadonlyArray<readonly [string, number, number]> = [
92
+ ['connect_timeout_ms', 100, 60_000],
93
+ ['max_body_bytes', 1, MAX_L2_BODY_BYTES],
94
+ ['marker_ttl_ms', 60_000, 604_800_000],
95
+ ['unlink_batch', 1, 10_000],
96
+ ['reconnect_min_ms', 10, 60_000],
97
+ ['reconnect_max_ms', 10, 600_000],
98
+ ]
99
+ const REDIS_TLS_KEYS: Record<string, string> = { reject_unauthorized: 'boolean', server_name: 'string', ca_file: 'string' }
100
+
101
+ function checkRedisTable(file: string, r: Record<string, unknown>): void {
102
+ const known = new Set(['url', 'tls', ...REDIS_INT_KEYS.map(([k]) => k)])
103
+ for (const k of Object.keys(r)) if (!known.has(k)) throw new BuildError('cache', `${file}: cache.redis.${k} is not a known key`)
104
+ for (const [k, lo, hi] of REDIS_INT_KEYS) rangeInt(file, `redis.${k}`, r[k], lo, hi, lo)
105
+ if (typeof r.reconnect_min_ms === 'number' && typeof r.reconnect_max_ms === 'number' && r.reconnect_max_ms < r.reconnect_min_ms)
106
+ throw new BuildError('cache', `${file}: cache.redis.reconnect_max_ms must be >= cache.redis.reconnect_min_ms`)
107
+ const tls = r.tls
108
+ if (tls === undefined || typeof tls === 'boolean') return
109
+ if (!isTable(tls)) throw new BuildError('cache', `${file}: cache.redis.tls must be a boolean or a table`)
110
+ for (const [k, v] of Object.entries(tls)) {
111
+ const want = REDIS_TLS_KEYS[k]
112
+ if (!want) throw new BuildError('cache', `${file}: cache.redis.tls.${k} is not a known key`)
113
+ if (typeof v !== want || v === '') throw new BuildError('cache', `${file}: cache.redis.tls.${k} must be a ${want === 'string' ? 'non-empty string' : want}`)
114
+ }
115
+ }
116
+
117
+ /** The option table a built-in reads. */
118
+ function builtinOptions(name: string, t: Record<string, unknown>): Record<string, unknown> {
119
+ const key = name === 'redis' || name === 'dragonfly' ? 'redis' : 'cloudflare'
120
+ return { ...((t[key] as Record<string, unknown> | undefined) ?? {}) }
121
+ }
122
+
123
+ async function packageName(appRoot: string): Promise<string | undefined> {
124
+ const f = Bun.file(join(appRoot, 'package.json'))
125
+ if (!(await f.exists())) return undefined
126
+ try {
127
+ const n = ((await f.json()) as { name?: unknown }).name
128
+ return typeof n === 'string' && n.trim() ? n.trim() : undefined
129
+ } catch {
130
+ return undefined
131
+ }
132
+ }
133
+
134
+ async function parseCache(file: string, appRoot: string, raw: unknown): Promise<CacheBuildConfig | null> {
135
+ if (raw === undefined) return null
136
+ if (!isTable(raw)) throw new BuildError('cache', `${file}: [cache] must be a table`)
137
+ const t = raw
138
+ for (const k of Object.keys(t)) if (!CACHE_KEYS.has(k)) throw new BuildError('cache', `${file}: cache.${k} is not a known key`)
139
+ for (const k of ['redis', 'cloudflare', 'options'] as const)
140
+ if (t[k] !== undefined && !isTable(t[k])) throw new BuildError('cache', `${file}: [cache.${k}] must be a table`)
141
+ const adapter = t.adapter
142
+ if (typeof adapter !== 'string' || !(ADAPTER_NAMES as readonly string[]).includes(adapter))
143
+ throw new BuildError('cache', `${file}: cache.adapter must be one of ${ADAPTER_NAMES.map((n) => `"${n}"`).join(', ')} (got ${JSON.stringify(adapter)})`)
144
+ const name = adapter as CacheBuildConfig['adapter']
145
+ let module: string | undefined
146
+ if (name === 'custom') {
147
+ if (typeof t.module !== 'string' || t.module.trim() === '') throw new BuildError('cache', `${file}: cache.module is required for adapter "custom"`)
148
+ module = t.module.trim()
149
+ } else if (t.module !== undefined) throw new BuildError('cache', `${file}: cache.module is only for adapter "custom"`)
150
+ let broadcast: string | undefined
151
+ if (t.broadcast !== undefined) {
152
+ if (typeof t.broadcast !== 'string' || !(t.broadcast in BUILTINS))
153
+ throw new BuildError('cache', `${file}: cache.broadcast must name a built-in adapter (${Object.keys(BUILTINS).map((n) => `"${n}"`).join(', ')})`)
154
+ broadcast = t.broadcast
155
+ }
156
+ // `env:` indirection only: a literal URL may carry a password, so the error never echoes the value.
157
+ const redis = t.redis as Record<string, unknown> | undefined
158
+ if (redis?.url !== undefined && (typeof redis.url !== 'string' || !/^env:[A-Za-z_][A-Za-z0-9_]*$/.test(redis.url)))
159
+ throw new BuildError('cache', `${file}: cache.redis.url must be "env:NAME" (a literal URL is refused: it may hold a password)`)
160
+ if (redis) checkRedisTable(file, redis)
161
+ for (const n of [name, broadcast]) {
162
+ if (n === undefined || n === 'custom') continue
163
+ try {
164
+ Bun.resolveSync(BUILTINS[n]!, appRoot)
165
+ } catch {
166
+ throw new BuildError('cache', `${file}: ${installHint(n)}`)
167
+ }
168
+ }
169
+ const options = name === 'custom' ? { ...((t.options as Record<string, unknown> | undefined) ?? {}) } : builtinOptions(name, t)
170
+ let namespace = (await packageName(appRoot)) ?? 'brust'
171
+ if (t.namespace !== undefined) {
172
+ if (typeof t.namespace !== 'string' || !/^[A-Za-z0-9@._/-]{1,64}$/.test(t.namespace))
173
+ throw new BuildError('cache', `${file}: cache.namespace must be 1..64 of [A-Za-z0-9@._/-]`)
174
+ namespace = t.namespace
175
+ }
176
+ return {
177
+ adapter: name,
178
+ module,
179
+ broadcast,
180
+ options,
181
+ broadcastOptions: broadcast ? builtinOptions(broadcast, t) : undefined,
182
+ namespace,
183
+ getTimeoutMs: t.get_timeout_ms === undefined ? undefined : rangeInt(file, 'get_timeout_ms', t.get_timeout_ms, 1, 1000, 50),
184
+ maxBodyBytes: rangeInt(file, 'max_body_bytes', t.max_body_bytes, 1, MAX_L2_BODY_BYTES, 1_048_576),
185
+ epochIntervalMs: rangeInt(file, 'epoch_interval_ms', t.epoch_interval_ms, 250, 60_000, 1000),
186
+ }
187
+ }
188
+
189
+ function cfCapacity(file: string, key: string, v: unknown, dflt: number): number {
190
+ if (v === undefined) return dflt
191
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > U32_MAX)
192
+ throw new BuildError('config', `${file}: cloudflare.${key} must be an integer in 1..${U32_MAX} (got ${String(v)})`)
193
+ return v
194
+ }
195
+
196
+ function parseCloudflare(file: string, appRoot: string, raw: unknown): CloudflareBuildConfig | null {
197
+ if (raw === undefined) return null
198
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [cloudflare] must be a table`)
199
+ const t = raw as Record<string, unknown>
200
+ for (const k of Object.keys(t)) if (!CF_KEYS.has(k)) throw new BuildError('config', `${file}: cloudflare.${k} is not a known key`)
201
+ if (t.name !== undefined && (typeof t.name !== 'string' || t.name.trim() === ''))
202
+ throw new BuildError('config', `${file}: cloudflare.name must be a non-empty string`)
203
+ let compatibilityDate: string | undefined
204
+ if (t.compatibility_date !== undefined) {
205
+ const d = t.compatibility_date
206
+ if (typeof d !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(d)) throw new BuildError('config', `${file}: cloudflare.compatibility_date must be YYYY-MM-DD`)
207
+ if (d < CF_MIN_COMPAT_DATE)
208
+ throw new BuildError('config', `${file}: cloudflare.compatibility_date must be >= ${CF_MIN_COMPAT_DATE} (nodejs_compat is default from then)`)
209
+ compatibilityDate = d
210
+ }
211
+ if (t.stats !== undefined && typeof t.stats !== 'boolean') throw new BuildError('config', `${file}: cloudflare.stats must be a boolean`)
212
+ if (t.edge_cache !== undefined) {
213
+ if (t.edge_cache === 'workers-cache') throw new BuildError('config', `${file}: cloudflare.edge_cache = "workers-cache" lands with M4-CF-7`)
214
+ if (t.edge_cache !== 'off') throw new BuildError('config', `${file}: cloudflare.edge_cache must be "off"`)
215
+ }
216
+ const alias: Record<string, string> = {}
217
+ if (t.alias !== undefined) {
218
+ if (t.alias === null || typeof t.alias !== 'object' || Array.isArray(t.alias))
219
+ throw new BuildError('config', `${file}: cloudflare.alias must be a table of module paths`)
220
+ for (const [from, to] of Object.entries(t.alias as Record<string, unknown>)) {
221
+ if (typeof to !== 'string' || to.trim() === '') throw new BuildError('config', `${file}: cloudflare.alias["${from}"] must be a non-empty string`)
222
+ alias[resolveAlias(file, appRoot, from)] = resolveAlias(file, appRoot, to)
223
+ }
224
+ }
225
+ return {
226
+ name: t.name === undefined ? undefined : (t.name as string).trim(),
227
+ compatibilityDate,
228
+ l1Capacity: cfCapacity(file, 'l1_capacity', t.l1_capacity, 256),
229
+ jobCacheCapacity: cfCapacity(file, 'job_cache_capacity', t.job_cache_capacity, 512),
230
+ stats: (t.stats as boolean | undefined) ?? false,
231
+ edgeCache: 'off',
232
+ alias,
233
+ }
234
+ }
235
+
236
+ function resolveAlias(file: string, appRoot: string, p: string): string {
237
+ const rel = p.trim().replace(/^\.\//, '')
238
+ try {
239
+ return Bun.resolveSync(`./${rel}`, appRoot)
240
+ } catch {
241
+ throw new BuildError('config', `${file}: cloudflare.alias: cannot resolve "${p}" from the app root`)
242
+ }
243
+ }
244
+
245
+ function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
29
246
  if (raw === undefined) return null
30
247
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
31
248
  const t = raw as Record<string, unknown>
@@ -36,7 +253,8 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
36
253
  }
37
254
  let prefix = DEFAULT_ACTION_PREFIX
38
255
  if (t.prefix !== undefined) {
39
- 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)
40
258
  if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
41
259
  prefix = t.prefix as string
42
260
  }
@@ -59,15 +277,39 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
59
277
  if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
60
278
  crossSite = t.cross_site
61
279
  }
62
- 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 }
63
293
  }
64
294
 
65
295
  const DEFAULT_ENTRY = 'actions.ts'
66
296
 
67
- 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> {
68
310
  const file = join(appRoot, 'brust.toml')
69
311
  const f = Bun.file(file)
70
- if (!(await f.exists())) return { serverOnly: [], actions: null }
312
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [] }
71
313
  let parsed: unknown
72
314
  try {
73
315
  parsed = Bun.TOML.parse(await f.text())
@@ -75,13 +317,16 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
75
317
  throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
76
318
  }
77
319
  const root = (parsed ?? {}) as Record<string, unknown>
78
- const actions = parseActions(file, root.actions)
320
+ const actions = parseActions(file, root.actions, o.actionsOnly === true)
321
+ const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
322
+ const cache = await parseCache(file, appRoot, root.cache)
323
+ const cssGlobal = parseCss(file, root.css)
79
324
  const build = root.build
80
- if (build === undefined) return { serverOnly: [], actions }
325
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
81
326
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
82
327
  const so = (build as Record<string, unknown>).server_only
83
- if (so === undefined) return { serverOnly: [], actions }
328
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
84
329
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
85
330
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
86
- return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions }
331
+ return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal }
87
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
+ }