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

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 (68) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +91 -4
  3. package/native/index.js +60 -54
  4. package/package.json +25 -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 +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 +332 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +462 -52
  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 +320 -0
  19. package/src/build/compile.ts +116 -6
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +331 -0
  22. package/src/build/esm-resolve.ts +170 -0
  23. package/src/build/features.ts +114 -0
  24. package/src/build/index.ts +264 -20
  25. package/src/build/manifest.ts +85 -7
  26. package/src/build/pkg-src.ts +514 -0
  27. package/src/build/recognised.ts +19 -0
  28. package/src/build/scan.ts +71 -20
  29. package/src/build/server-only.ts +39 -0
  30. package/src/build/ssg-render.ts +65 -0
  31. package/src/build/ssg.ts +146 -0
  32. package/src/build/static-worker.ts +91 -0
  33. package/src/build/static.ts +258 -0
  34. package/src/build/tailwind.ts +117 -0
  35. package/src/build/tsconfig-paths.ts +67 -0
  36. package/src/build-time.ts +10 -0
  37. package/src/cache-adapter/bridge.ts +91 -0
  38. package/src/cache-adapter/conformance.ts +226 -0
  39. package/src/cache-adapter/index.ts +10 -0
  40. package/src/cache-adapter/load.ts +144 -0
  41. package/src/cache-adapter/memory.ts +229 -0
  42. package/src/cache-adapter/runtime.ts +124 -0
  43. package/src/cache-adapter/types.ts +96 -0
  44. package/src/cache-adapter/workers.ts +118 -0
  45. package/src/cache.ts +4 -1
  46. package/src/cf.ts +19 -0
  47. package/src/cli.ts +71 -2
  48. package/src/config.ts +127 -25
  49. package/src/css-modules.d.ts +8 -0
  50. package/src/dev/bun.ts +371 -0
  51. package/src/dev/client.ts +92 -0
  52. package/src/dev/devtools-data.ts +23 -0
  53. package/src/dev/devtools.ts +356 -0
  54. package/src/dev/diag.ts +30 -0
  55. package/src/dev/proxy.ts +236 -0
  56. package/src/dev/tui.ts +144 -0
  57. package/src/dev/watch.ts +130 -0
  58. package/src/dev.ts +97 -0
  59. package/src/handlers.ts +282 -0
  60. package/src/index.ts +3 -1
  61. package/src/native.ts +38 -1
  62. package/src/routes.ts +20 -1
  63. package/src/run.ts +59 -12
  64. package/src/server-handlers.ts +32 -0
  65. package/src/shadcn/init.ts +513 -0
  66. package/src/shadcn/jsonc.ts +129 -0
  67. package/src/shadcn/registry.ts +149 -0
  68. package/src/worker.ts +127 -245
@@ -1,15 +1,21 @@
1
1
  // Build-time settings from `brust.toml` `[build]` (the runtime sections are `../config.ts`):
2
2
  // [build]
3
3
  // server_only = ["lib/server", "@acme/db"] # import prefixes / app-root-relative paths
4
+ // source_roots = ["../shared"] # F256: directories besides the app a relative import may reach (monorepo)
4
5
  // `server_only` is the app config `serverOnly` of the compiler spec (§8.2): the compiler refuses
5
6
  // such an import in client code, and every browser bundle refuses to include it.
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 { statSync } from 'node:fs'
8
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
9
+ import { ACTION_CREDENTIALS, type ActionCredentials, DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, baseUrlError, prefixError } from '../actions/prefix'
10
+ import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
8
11
  import { BuildError } from './errors'
9
12
 
10
13
  // [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
14
  // [actions]
12
15
  // entry = "actions.ts" prefix = "/_brust/action" max_body_bytes = 1048576 max_response_bytes = 8388608 cross_site = "reject" | "allow" (F87)
16
+ // base_url = "https://api.example.com" credentials = "same-origin" | "include" | "omit" (L16: the browser client's target; runtime settings, validated here too)
17
+ // `prefix = "/"` is accepted only for an actions-only build (L16 R16.4).
18
+ // `ACTION_INLINE_BODY_BYTES` (Rust): the inline/heap threshold of an action body, independent of the response slot size (`slotBytes`).
13
19
  const MIN_RESPONSE_BYTES = 262_144
14
20
 
15
21
  export interface ActionsBuildConfig {
@@ -18,14 +24,229 @@ export interface ActionsBuildConfig {
18
24
  maxBodyBytes: number
19
25
  maxResponseBytes: number
20
26
  crossSite: 'reject' | 'allow'
27
+ /** `[actions] base_url`; with no actions file present the build is page-only (L16 R16.4b). */
28
+ baseUrl: string | undefined
29
+ credentials: ActionCredentials
30
+ /** `entry` was written in brust.toml (a missing file is then an error, never a page-only build). */
31
+ entryExplicit: boolean
32
+ }
33
+
34
+ // [cloudflare] (M4-CF; only read by `--target cloudflare`, validated always so errors surface early):
35
+ // name = "my-worker" compatibility_date = "2026-10-01" l1_capacity = 256 job_cache_capacity = 512 stats = false
36
+ // edge_cache = "off" [cloudflare.alias] "lib/db.ts" = "lib/db.cf.ts" # app-root-relative module swaps (spec R12)
37
+ const CF_KEYS = new Set(['name', 'compatibility_date', 'l1_capacity', 'job_cache_capacity', 'stats', 'edge_cache', 'alias'])
38
+ export const CF_MIN_COMPAT_DATE = '2026-08-04'
39
+ const U32_MAX = 4_294_967_295
40
+
41
+ export interface CloudflareBuildConfig {
42
+ name: string | undefined
43
+ compatibilityDate: string | undefined
44
+ l1Capacity: number
45
+ jobCacheCapacity: number
46
+ stats: boolean
47
+ edgeCache: 'off'
48
+ /** absolute module path -> absolute replacement path */
49
+ alias: Record<string, string>
50
+ }
51
+
52
+ // [cache] (cache adapters, opt-in; spec §6): a shared second tier behind the in-process cache.
53
+ // adapter = "redis" | "dragonfly" | "cloudflare-kv" | "cloudflare-do" | "custom" module = "lib/cache.ts" (custom)
54
+ // broadcast = "<built-in>" namespace = "<package name>" get_timeout_ms = 50 (native) / 100 (Workers)
55
+ // max_body_bytes = 1048576 epoch_interval_ms = 1000 [cache.redis] url = "env:REDIS_URL" [cache.cloudflare] [cache.options]
56
+ const CACHE_KEYS = new Set(['adapter', 'broadcast', 'module', 'namespace', 'get_timeout_ms', 'max_body_bytes', 'epoch_interval_ms', 'redis', 'cloudflare', 'options'])
57
+ export const MAX_L2_BODY_BYTES = 67_108_864
58
+
59
+ export interface CacheBuildConfig {
60
+ adapter: (typeof ADAPTER_NAMES)[number]
61
+ /** `custom`: the module, app-root-relative as written. */
62
+ module: string | undefined
63
+ broadcast: string | undefined
64
+ /** The factory's options: `[cache.redis]` / `[cache.cloudflare]` for built-ins, `[cache.options]` for custom. */
65
+ options: Record<string, unknown>
66
+ broadcastOptions: Record<string, unknown> | undefined
67
+ namespace: string
68
+ /** Absent = the runtime default (native 50, Workers 100). */
69
+ getTimeoutMs: number | undefined
70
+ maxBodyBytes: number
71
+ epochIntervalMs: number
21
72
  }
22
73
 
23
74
  export interface BuildConfig {
24
75
  serverOnly: string[]
25
76
  actions: ActionsBuildConfig | null
77
+ cloudflare: CloudflareBuildConfig | null
78
+ cache: CacheBuildConfig | null
79
+ /** `[css] global` (L19, R19.1): app-root-relative stylesheets every route links first, in order. */
80
+ cssGlobal: string[]
81
+ /** `[build] source_roots` (F256): directories outside the app a relative import may reach, normalised and relative to the app root (`../shared`). Empty = the app only. */
82
+ sourceRoots: string[]
83
+ }
84
+
85
+ const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
86
+
87
+ function rangeInt(file: string, key: string, v: unknown, lo: number, hi: number, dflt: number): number {
88
+ if (v === undefined) return dflt
89
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < lo || v > hi)
90
+ throw new BuildError('cache', `${file}: cache.${key} must be an integer in ${lo}..${hi} (got ${String(v)})`)
91
+ return v
92
+ }
93
+
94
+ /** `[cache.redis]`'s keys and ranges (cache-redis R10); `@brust/cache-redis` validates the same list at worker boot. */
95
+ const REDIS_INT_KEYS: ReadonlyArray<readonly [string, number, number]> = [
96
+ ['connect_timeout_ms', 100, 60_000],
97
+ ['max_body_bytes', 1, MAX_L2_BODY_BYTES],
98
+ ['marker_ttl_ms', 60_000, 604_800_000],
99
+ ['unlink_batch', 1, 10_000],
100
+ ['reconnect_min_ms', 10, 60_000],
101
+ ['reconnect_max_ms', 10, 600_000],
102
+ ]
103
+ const REDIS_TLS_KEYS: Record<string, string> = { reject_unauthorized: 'boolean', server_name: 'string', ca_file: 'string' }
104
+
105
+ function checkRedisTable(file: string, r: Record<string, unknown>): void {
106
+ const known = new Set(['url', 'tls', ...REDIS_INT_KEYS.map(([k]) => k)])
107
+ for (const k of Object.keys(r)) if (!known.has(k)) throw new BuildError('cache', `${file}: cache.redis.${k} is not a known key`)
108
+ for (const [k, lo, hi] of REDIS_INT_KEYS) rangeInt(file, `redis.${k}`, r[k], lo, hi, lo)
109
+ if (typeof r.reconnect_min_ms === 'number' && typeof r.reconnect_max_ms === 'number' && r.reconnect_max_ms < r.reconnect_min_ms)
110
+ throw new BuildError('cache', `${file}: cache.redis.reconnect_max_ms must be >= cache.redis.reconnect_min_ms`)
111
+ const tls = r.tls
112
+ if (tls === undefined || typeof tls === 'boolean') return
113
+ if (!isTable(tls)) throw new BuildError('cache', `${file}: cache.redis.tls must be a boolean or a table`)
114
+ for (const [k, v] of Object.entries(tls)) {
115
+ const want = REDIS_TLS_KEYS[k]
116
+ if (!want) throw new BuildError('cache', `${file}: cache.redis.tls.${k} is not a known key`)
117
+ if (typeof v !== want || v === '') throw new BuildError('cache', `${file}: cache.redis.tls.${k} must be a ${want === 'string' ? 'non-empty string' : want}`)
118
+ }
119
+ }
120
+
121
+ /** The option table a built-in reads. */
122
+ function builtinOptions(name: string, t: Record<string, unknown>): Record<string, unknown> {
123
+ const key = name === 'redis' || name === 'dragonfly' ? 'redis' : 'cloudflare'
124
+ return { ...((t[key] as Record<string, unknown> | undefined) ?? {}) }
125
+ }
126
+
127
+ async function packageName(appRoot: string): Promise<string | undefined> {
128
+ const f = Bun.file(join(appRoot, 'package.json'))
129
+ if (!(await f.exists())) return undefined
130
+ try {
131
+ const n = ((await f.json()) as { name?: unknown }).name
132
+ return typeof n === 'string' && n.trim() ? n.trim() : undefined
133
+ } catch {
134
+ return undefined
135
+ }
136
+ }
137
+
138
+ async function parseCache(file: string, appRoot: string, raw: unknown): Promise<CacheBuildConfig | null> {
139
+ if (raw === undefined) return null
140
+ if (!isTable(raw)) throw new BuildError('cache', `${file}: [cache] must be a table`)
141
+ const t = raw
142
+ for (const k of Object.keys(t)) if (!CACHE_KEYS.has(k)) throw new BuildError('cache', `${file}: cache.${k} is not a known key`)
143
+ for (const k of ['redis', 'cloudflare', 'options'] as const)
144
+ if (t[k] !== undefined && !isTable(t[k])) throw new BuildError('cache', `${file}: [cache.${k}] must be a table`)
145
+ const adapter = t.adapter
146
+ if (typeof adapter !== 'string' || !(ADAPTER_NAMES as readonly string[]).includes(adapter))
147
+ throw new BuildError('cache', `${file}: cache.adapter must be one of ${ADAPTER_NAMES.map((n) => `"${n}"`).join(', ')} (got ${JSON.stringify(adapter)})`)
148
+ const name = adapter as CacheBuildConfig['adapter']
149
+ let module: string | undefined
150
+ if (name === 'custom') {
151
+ if (typeof t.module !== 'string' || t.module.trim() === '') throw new BuildError('cache', `${file}: cache.module is required for adapter "custom"`)
152
+ module = t.module.trim()
153
+ } else if (t.module !== undefined) throw new BuildError('cache', `${file}: cache.module is only for adapter "custom"`)
154
+ let broadcast: string | undefined
155
+ if (t.broadcast !== undefined) {
156
+ if (typeof t.broadcast !== 'string' || !(t.broadcast in BUILTINS))
157
+ throw new BuildError('cache', `${file}: cache.broadcast must name a built-in adapter (${Object.keys(BUILTINS).map((n) => `"${n}"`).join(', ')})`)
158
+ broadcast = t.broadcast
159
+ }
160
+ // `env:` indirection only: a literal URL may carry a password, so the error never echoes the value.
161
+ const redis = t.redis as Record<string, unknown> | undefined
162
+ if (redis?.url !== undefined && (typeof redis.url !== 'string' || !/^env:[A-Za-z_][A-Za-z0-9_]*$/.test(redis.url)))
163
+ throw new BuildError('cache', `${file}: cache.redis.url must be "env:NAME" (a literal URL is refused: it may hold a password)`)
164
+ if (redis) checkRedisTable(file, redis)
165
+ for (const n of [name, broadcast]) {
166
+ if (n === undefined || n === 'custom') continue
167
+ try {
168
+ Bun.resolveSync(BUILTINS[n]!, appRoot)
169
+ } catch {
170
+ throw new BuildError('cache', `${file}: ${installHint(n)}`)
171
+ }
172
+ }
173
+ const options = name === 'custom' ? { ...((t.options as Record<string, unknown> | undefined) ?? {}) } : builtinOptions(name, t)
174
+ let namespace = (await packageName(appRoot)) ?? 'brust'
175
+ if (t.namespace !== undefined) {
176
+ if (typeof t.namespace !== 'string' || !/^[A-Za-z0-9@._/-]{1,64}$/.test(t.namespace))
177
+ throw new BuildError('cache', `${file}: cache.namespace must be 1..64 of [A-Za-z0-9@._/-]`)
178
+ namespace = t.namespace
179
+ }
180
+ return {
181
+ adapter: name,
182
+ module,
183
+ broadcast,
184
+ options,
185
+ broadcastOptions: broadcast ? builtinOptions(broadcast, t) : undefined,
186
+ namespace,
187
+ getTimeoutMs: t.get_timeout_ms === undefined ? undefined : rangeInt(file, 'get_timeout_ms', t.get_timeout_ms, 1, 1000, 50),
188
+ maxBodyBytes: rangeInt(file, 'max_body_bytes', t.max_body_bytes, 1, MAX_L2_BODY_BYTES, 1_048_576),
189
+ epochIntervalMs: rangeInt(file, 'epoch_interval_ms', t.epoch_interval_ms, 250, 60_000, 1000),
190
+ }
191
+ }
192
+
193
+ function cfCapacity(file: string, key: string, v: unknown, dflt: number): number {
194
+ if (v === undefined) return dflt
195
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > U32_MAX)
196
+ throw new BuildError('config', `${file}: cloudflare.${key} must be an integer in 1..${U32_MAX} (got ${String(v)})`)
197
+ return v
198
+ }
199
+
200
+ function parseCloudflare(file: string, appRoot: string, raw: unknown): CloudflareBuildConfig | null {
201
+ if (raw === undefined) return null
202
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [cloudflare] must be a table`)
203
+ const t = raw as Record<string, unknown>
204
+ for (const k of Object.keys(t)) if (!CF_KEYS.has(k)) throw new BuildError('config', `${file}: cloudflare.${k} is not a known key`)
205
+ if (t.name !== undefined && (typeof t.name !== 'string' || t.name.trim() === ''))
206
+ throw new BuildError('config', `${file}: cloudflare.name must be a non-empty string`)
207
+ let compatibilityDate: string | undefined
208
+ if (t.compatibility_date !== undefined) {
209
+ const d = t.compatibility_date
210
+ 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`)
211
+ if (d < CF_MIN_COMPAT_DATE)
212
+ throw new BuildError('config', `${file}: cloudflare.compatibility_date must be >= ${CF_MIN_COMPAT_DATE} (nodejs_compat is default from then)`)
213
+ compatibilityDate = d
214
+ }
215
+ if (t.stats !== undefined && typeof t.stats !== 'boolean') throw new BuildError('config', `${file}: cloudflare.stats must be a boolean`)
216
+ if (t.edge_cache !== undefined) {
217
+ if (t.edge_cache === 'workers-cache') throw new BuildError('config', `${file}: cloudflare.edge_cache = "workers-cache" lands with M4-CF-7`)
218
+ if (t.edge_cache !== 'off') throw new BuildError('config', `${file}: cloudflare.edge_cache must be "off"`)
219
+ }
220
+ const alias: Record<string, string> = {}
221
+ if (t.alias !== undefined) {
222
+ if (t.alias === null || typeof t.alias !== 'object' || Array.isArray(t.alias))
223
+ throw new BuildError('config', `${file}: cloudflare.alias must be a table of module paths`)
224
+ for (const [from, to] of Object.entries(t.alias as Record<string, unknown>)) {
225
+ if (typeof to !== 'string' || to.trim() === '') throw new BuildError('config', `${file}: cloudflare.alias["${from}"] must be a non-empty string`)
226
+ alias[resolveAlias(file, appRoot, from)] = resolveAlias(file, appRoot, to)
227
+ }
228
+ }
229
+ return {
230
+ name: t.name === undefined ? undefined : (t.name as string).trim(),
231
+ compatibilityDate,
232
+ l1Capacity: cfCapacity(file, 'l1_capacity', t.l1_capacity, 256),
233
+ jobCacheCapacity: cfCapacity(file, 'job_cache_capacity', t.job_cache_capacity, 512),
234
+ stats: (t.stats as boolean | undefined) ?? false,
235
+ edgeCache: 'off',
236
+ alias,
237
+ }
238
+ }
239
+
240
+ function resolveAlias(file: string, appRoot: string, p: string): string {
241
+ const rel = p.trim().replace(/^\.\//, '')
242
+ try {
243
+ return Bun.resolveSync(`./${rel}`, appRoot)
244
+ } catch {
245
+ throw new BuildError('config', `${file}: cloudflare.alias: cannot resolve "${p}" from the app root`)
246
+ }
26
247
  }
27
248
 
28
- function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
249
+ function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
29
250
  if (raw === undefined) return null
30
251
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
31
252
  const t = raw as Record<string, unknown>
@@ -36,7 +257,8 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
36
257
  }
37
258
  let prefix = DEFAULT_ACTION_PREFIX
38
259
  if (t.prefix !== undefined) {
39
- const why = prefixError(t.prefix)
260
+ // `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
261
+ const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
40
262
  if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
41
263
  prefix = t.prefix as string
42
264
  }
@@ -59,15 +281,72 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
59
281
  if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
60
282
  crossSite = t.cross_site
61
283
  }
62
- return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite }
284
+ let baseUrl: string | undefined
285
+ if (t.base_url !== undefined) {
286
+ const why = baseUrlError(t.base_url)
287
+ if (why) throw new BuildError('config', `${file}: actions.base_url ${why}`)
288
+ baseUrl = t.base_url as string
289
+ }
290
+ let credentials: ActionCredentials = 'same-origin'
291
+ if (t.credentials !== undefined) {
292
+ if (typeof t.credentials !== 'string' || !ACTION_CREDENTIALS.includes(t.credentials as ActionCredentials))
293
+ throw new BuildError('config', `${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(t.credentials)})`)
294
+ credentials = t.credentials as ActionCredentials
295
+ }
296
+ return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite, baseUrl, credentials, entryExplicit: t.entry !== undefined }
63
297
  }
64
298
 
65
299
  const DEFAULT_ENTRY = 'actions.ts'
66
300
 
67
- export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
301
+ /** `[css]`: only `global`, a list of `.css` files relative to the app root (checked to exist by the build). */
302
+ function parseCss(file: string, raw: unknown): string[] {
303
+ if (raw === undefined) return []
304
+ if (!isTable(raw)) throw new BuildError('config', `${file}: [css] must be a table`)
305
+ for (const k of Object.keys(raw)) if (k !== 'global') throw new BuildError('config', `${file}: unknown key css.${k} (allowed: global)`)
306
+ const g = raw.global
307
+ if (g === undefined) return []
308
+ if (!Array.isArray(g) || g.some((p) => typeof p !== 'string' || !p.trim().endsWith('.css')))
309
+ throw new BuildError('config', `${file}: css.global must be an array of .css paths (relative to the app root)`)
310
+ return g.map((p: string) => p.trim())
311
+ }
312
+
313
+ /**
314
+ * `[build] source_roots` (F256): extra directories, relative to the app root, that a relative import may reach besides
315
+ * the app itself (`../shared` in a monorepo). Each is normalised (`../a/../shared` = `../shared`), must exist and be a
316
+ * directory, and confines imports the way the app root does: lexically, so `../shared/../secret` is outside it.
317
+ * An entry inside the app adds nothing and is dropped; the app root itself or an ancestor of it is refused (F272). Returns the roots that lie outside, `/`-separated.
318
+ */
319
+ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): string[] {
320
+ if (raw === undefined) return []
321
+ if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string' || p.trim() === '' || p.includes('\0')))
322
+ throw new BuildError('config', `${file}: build.source_roots must be an array of non-empty strings (paths relative to the app root)`)
323
+ const out = new Set<string>()
324
+ for (const p of raw as string[]) {
325
+ if (isAbsolute(p.trim())) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} must be relative to the app root`)
326
+ const abs = resolve(appRoot, p.trim())
327
+ let dir = false
328
+ try {
329
+ dir = statSync(abs).isDirectory()
330
+ } catch {}
331
+ if (!dir) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} (${abs}) is not an existing directory`)
332
+ if (abs === resolve(abs, '..')) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} is the filesystem root`)
333
+ const rel = relative(appRoot, abs).split(sep).join('/')
334
+ // F272: the app root or an ancestor of it (`.`, `..`, `../..`) contains every import the app can make, so it would
335
+ // switch F243's confinement off for that whole level; name the directory that is actually shared instead.
336
+ if (rel === '' || rel.split('/').every((seg) => seg === '..'))
337
+ throw new BuildError(
338
+ 'config',
339
+ `${file}: build.source_roots entry ${JSON.stringify(p)} is the app root or a directory that contains it, which would allow every relative import; list the shared directory itself (e.g. "../shared")`,
340
+ )
341
+ if (rel === '..' || rel.startsWith('../')) out.add(rel)
342
+ }
343
+ return [...out].sort()
344
+ }
345
+
346
+ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
68
347
  const file = join(appRoot, 'brust.toml')
69
348
  const f = Bun.file(file)
70
- if (!(await f.exists())) return { serverOnly: [], actions: null }
349
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [] }
71
350
  let parsed: unknown
72
351
  try {
73
352
  parsed = Bun.TOML.parse(await f.text())
@@ -75,13 +354,17 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
75
354
  throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
76
355
  }
77
356
  const root = (parsed ?? {}) as Record<string, unknown>
78
- const actions = parseActions(file, root.actions)
357
+ const actions = parseActions(file, root.actions, o.actionsOnly === true)
358
+ const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
359
+ const cache = await parseCache(file, appRoot, root.cache)
360
+ const cssGlobal = parseCss(file, root.css)
79
361
  const build = root.build
80
- if (build === undefined) return { serverOnly: [], actions }
362
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
81
363
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
364
+ const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
82
365
  const so = (build as Record<string, unknown>).server_only
83
- if (so === undefined) return { serverOnly: [], actions }
366
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
84
367
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
85
368
  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 }
369
+ return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots }
87
370
  }