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

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 (65) 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 +330 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +341 -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 +74 -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 +224 -20
  24. package/src/build/manifest.ts +69 -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 +70 -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 +61 -14
  62. package/src/server-handlers.ts +32 -0
  63. package/src/shadcn/init.ts +213 -0
  64. package/src/shadcn/jsonc.ts +129 -0
  65. package/src/worker.ts +129 -245
@@ -2,10 +2,13 @@
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'
10
+ import { checkAliasAgreement, readPathAliases } from './tsconfig-paths'
11
+ import type { PackageSources } from './pkg-src'
9
12
 
10
13
  /** `ComponentIR` as the compiler serialises it (snake_case), with the fields that are absent
11
14
  * when default (`ir/mod.rs`) normalised. The build reads it; it never rewrites it. */
@@ -17,13 +20,41 @@ export type ComponentIR = Record<string, any> & {
17
20
  tier: any
18
21
  // biome-ignore lint/suspicious/noExplicitAny: IR JSON
19
22
  jobs: any[]
20
- instances: { child_id: string; k: number; loops: (string | null)[]; props: Record<string, string | null> }[]
23
+ instances: {
24
+ child_id: string
25
+ k: number
26
+ loops: (string | null)[]
27
+ props: Record<string, string | null>
28
+ literals?: Record<string, unknown>
29
+ exprs?: Record<string, string>
30
+ /** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
31
+ rows_literal?: unknown[]
32
+ }[]
33
+ /** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
34
+ build_eval_instances?: {
35
+ key: string
36
+ child_id: string
37
+ k: number
38
+ inputs: Record<string, unknown>
39
+ outputs: string[]
40
+ stubs?: { source: string; code: string }[]
41
+ /** R34.96: the job modules fused into the child's job, which it imports (`FusedEvalJob`). */
42
+ fused?: { child_id: string; stubs?: { source: string; code: string }[] }[]
43
+ line: number
44
+ col: number
45
+ }[]
21
46
  use_id_slots: number
22
47
  uses_outlet: boolean
23
48
  needs_worker: boolean
24
49
  /** `useStore(handle)` bindings (`decls.rs` `StoreUse`); `resolved` = `StoreRef`, `null` when
25
50
  * unresolved (the component falls back). `module` is root-relative (C3). */
26
51
  stores?: { handle: string; resolved: { name: string; module: string } | null }[]
52
+ /** A package export rendered as a React island (L22): `source` is then not a file. */
53
+ external?: { source: string; export: string; member?: string[] }
54
+ /** The binding a React island imports this component by from `source` (absent: the default export). */
55
+ export?: string
56
+ /** Child components; `nested` is set when this file passes lowered JSX children to that child, an island (shadcn S1). */
57
+ children?: { id: string | null; nested?: string[]; x?: string[]; holes?: string[]; [field: string]: unknown }[]
27
58
  }
28
59
 
29
60
  export interface Compiled {
@@ -33,7 +64,11 @@ export interface Compiled {
33
64
  ir: ComponentIR
34
65
  jinja: string
35
66
  serverTs?: string
67
+ /** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
68
+ evalTs?: string
36
69
  clientJs?: string
70
+ /** The compiler's diagnostics for this component (`dev.json`, L14). */
71
+ diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string }[]
37
72
  }
38
73
 
39
74
  type Diag = NonNullable<CompiledTree['error']>
@@ -78,11 +113,31 @@ export function compileApp(opts: {
78
113
  appRoot: string
79
114
  leaves: FlatRoute[]
80
115
  componentFile: Map<Function, string>
116
+ /** Why a Component the scan saw has no file (named export only, outside the root); optional. */
117
+ componentIssue?: Map<Function, string>
81
118
  runtimeImport: string
82
119
  serverOnly: string[]
120
+ /** L20: every CSS Module's class map by absolute source path; `styles.x` folds to the literal class. */
121
+ cssMaps?: Map<string, Record<string, string>>
83
122
  log: (s: string) => void
123
+ /** SVG markup the build rendered for lucide icon requests (L23), by `ir.icon_requests[].key`. */
124
+ iconSvgs?: Record<string, { svg: string; base: string }>
125
+ /** The compile after icons were rendered (even if none could be): icons still components are judged as islands. */
126
+ iconsFinal?: boolean
127
+ /** Values the build computed for `ir.build_eval` slots, by key (L2): the slots become literals. */
128
+ slotValues?: Record<string, unknown>
129
+ /** F153c: the package sources this build wrote (`pkg-src.ts`); absent = package imports are islands. */
130
+ packages?: PackageSources
84
131
  }): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
85
132
  const { appRoot, leaves, componentFile } = opts
133
+ const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
134
+ const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
135
+ const packagesJson = opts.packages ? JSON.stringify(opts.packages) : undefined
136
+ // The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
137
+ const { aliases, notes } = readPathAliases(appRoot)
138
+ for (const n of notes) opts.log(`warning tsconfig-paths ${n}`)
139
+ const pathAliasesJson = aliases.length > 0 ? JSON.stringify(aliases) : undefined
140
+ const cssModules = Object.fromEntries([...(opts.cssMaps ?? [])].map(([abs, map]) => [relative(realpathSync(appRoot), abs).split(sep).join('/'), map]))
86
141
  const seen = new Map<string, Seen>()
87
142
  const rootIdOfFile = new Map<string, string>()
88
143
  const routeComponent = new Map<string, string>()
@@ -90,17 +145,22 @@ export function compileApp(opts: {
90
145
  const nodes = new Map<string, Route>()
91
146
  for (const l of leaves) l.chain.forEach((r, i) => r.Component && nodes.set(l.chainIds[i]!, r))
92
147
 
148
+ // F158 (R34.69): the route components (pages and layouts) take request data as props, so the compiler never hands
149
+ // them lowered JSX from a caller. Keyed like an import resolves: relative to the app root, `/`-separated.
150
+ const rootRel = (f: string) => relative(appRoot, f).split(sep).join('/')
151
+ const routeRoots = [...new Set([...nodes.values()].flatMap((r) => componentFile.get(r.Component!) ?? []).flatMap((f) => [rootRel(f), relative(realpathSync(appRoot), realpathSync(f)).split(sep).join('/')]))]
152
+
93
153
  for (const [routeId, route] of nodes) {
94
154
  const file = componentFile.get(route.Component!)
95
155
  if (!file)
96
156
  throw new BuildError(
97
157
  'component-source',
98
- `${route.Component!.name || '(anonymous)'} is not a default import of a .tsx file (route ${routeId})`,
158
+ `${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
159
  )
100
160
  let rootId = rootIdOfFile.get(file)
101
161
  if (!rootId) {
102
162
  const rel = relative(appRoot, file)
103
- const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly)
163
+ const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson)
104
164
  if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
105
165
  for (const [i, c] of tree.components.entries()) {
106
166
  // A lowering Error that came back as a diagnostic is a build error too (contract 9).
@@ -122,6 +182,7 @@ export function compileApp(opts: {
122
182
  for (const d of c.diagnostics)
123
183
  if (d.class === 'fallback' || d.class === 'warning') opts.log(`warning ${d.rule} ${where(c.source, d)} ${d.message}`)
124
184
  const ir = JSON.parse(c.ir) as ComponentIR
185
+ if (ir.alias_imports?.length) checkAliasAgreement(appRoot, ir.alias_imports)
125
186
  ir.use_id_slots ??= 0
126
187
  ir.uses_outlet ??= false
127
188
  ir.instances ??= []
@@ -132,9 +193,18 @@ export function compileApp(opts: {
132
193
  ir,
133
194
  jinja: c.jinja,
134
195
  serverTs: c.serverTs,
196
+ evalTs: c.evalTs,
135
197
  clientJs: c.clientJs,
198
+ diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
136
199
  })
137
200
  }
201
+ // A package export has no file of its own: bare imports in its island entries resolve from a
202
+ // component that uses it (the first one in build order).
203
+ for (const c of compiled.values()) {
204
+ if (!c.ir.external) continue
205
+ const user = [...compiled.values()].find((p) => !p.ir.external && (p.ir.children ?? []).some((ref: { id: string | null }) => ref.id === c.id))
206
+ c.file = user?.file ?? c.file
207
+ }
138
208
  for (const [routeId, route] of nodes) {
139
209
  const rootId = routeComponent.get(routeId)!
140
210
  if (compiled.get(rootId)!.ir.uses_outlet && !route.children)
@@ -4,26 +4,245 @@
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, 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
- // [actions] (opt-in, ruling R6): present table => actions on. Crosses napi as u32.
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
- // entry = "actions.ts" prefix = "/_brust/action" max_body_bytes = 262144
13
- const MAX_U32 = 0xffff_ffff
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`).
17
+ const MIN_RESPONSE_BYTES = 262_144
14
18
 
15
19
  export interface ActionsBuildConfig {
16
20
  entry: string
17
21
  prefix: string
18
22
  maxBodyBytes: number
23
+ maxResponseBytes: number
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
19
70
  }
20
71
 
21
72
  export interface BuildConfig {
22
73
  serverOnly: string[]
23
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
88
+ }
89
+
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
+ }
24
132
  }
25
133
 
26
- function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
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 {
27
246
  if (raw === undefined) return null
28
247
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
29
248
  const t = raw as Record<string, unknown>
@@ -34,26 +253,63 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
34
253
  }
35
254
  let prefix = DEFAULT_ACTION_PREFIX
36
255
  if (t.prefix !== undefined) {
37
- 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)
38
258
  if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
39
259
  prefix = t.prefix as string
40
260
  }
41
261
  let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
42
262
  if (t.max_body_bytes !== undefined) {
43
263
  const n = t.max_body_bytes
44
- if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > MAX_U32)
45
- throw new BuildError('config', `${file}: actions.max_body_bytes must be an integer in 1..${MAX_U32} (got ${String(n)})`)
264
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > MAX_ACTION_BYTES)
265
+ throw new BuildError('config', `${file}: actions.max_body_bytes must be an integer in 1..${MAX_ACTION_BYTES} (got ${String(n)})`)
46
266
  maxBodyBytes = n
47
267
  }
48
- return { entry, prefix, maxBodyBytes }
268
+ let maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES
269
+ if (t.max_response_bytes !== undefined) {
270
+ const n = t.max_response_bytes
271
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < MIN_RESPONSE_BYTES || n > MAX_ACTION_BYTES)
272
+ throw new BuildError('config', `${file}: actions.max_response_bytes must be an integer in ${MIN_RESPONSE_BYTES}..${MAX_ACTION_BYTES} (got ${String(n)})`)
273
+ maxResponseBytes = n
274
+ }
275
+ let crossSite: 'reject' | 'allow' = 'reject'
276
+ if (t.cross_site !== undefined) {
277
+ if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
278
+ crossSite = t.cross_site
279
+ }
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 }
49
293
  }
50
294
 
51
295
  const DEFAULT_ENTRY = 'actions.ts'
52
296
 
53
- 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> {
54
310
  const file = join(appRoot, 'brust.toml')
55
311
  const f = Bun.file(file)
56
- if (!(await f.exists())) return { serverOnly: [], actions: null }
312
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [] }
57
313
  let parsed: unknown
58
314
  try {
59
315
  parsed = Bun.TOML.parse(await f.text())
@@ -61,13 +317,16 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
61
317
  throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
62
318
  }
63
319
  const root = (parsed ?? {}) as Record<string, unknown>
64
- 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)
65
324
  const build = root.build
66
- if (build === undefined) return { serverOnly: [], actions }
325
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
67
326
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
68
327
  const so = (build as Record<string, unknown>).server_only
69
- if (so === undefined) return { serverOnly: [], actions }
328
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
70
329
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
71
330
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
72
- return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions }
331
+ return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal }
73
332
  }