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

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.
@@ -0,0 +1,310 @@
1
+ // `brust build --target cloudflare` (M4-CF spec §4): after the normal build, writes `<out>/cf/`:
2
+ // worker/ the @brust/core-cf glue + wasm (verbatim) + <appModuleDir>/brust-app.js (ONE Bun bundle, R2)
3
+ // assets/ Static Assets tree + _headers (R7) wrangler.jsonc (user config merged with brust's keys, R5)
4
+ // .stage/ the generated entry + the smoke harness (kept for inspection)
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
6
+ import { builtinModules } from 'node:module'
7
+ import { dirname, join, relative } from 'node:path'
8
+ import type { BunPlugin } from 'bun'
9
+ import { BrustConfigError, type CorsConfig, loadCors } from '../config'
10
+ import { layoutAssets } from './cf-assets'
11
+ import { type CfConfigJson, cfEntrySource } from './cf-entry'
12
+ import { type CoreCf, resolveCoreCf } from './cf-core'
13
+ import { type CacheNeed, mergeWrangler, readUserWrangler, renderWrangler, type UserWrangler, verifyCacheBindings } from './cf-wrangler'
14
+ import { type CloudflareBuildConfig, CF_MIN_COMPAT_DATE } from './config'
15
+ import { PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
16
+ import type { Compiled } from './compile'
17
+ import { BuildError } from './errors'
18
+ import type { CacheRecord } from './manifest'
19
+ import { BUILTINS as CACHE_BUILTINS } from '../cache-adapter/load'
20
+
21
+ export interface CfFlags {
22
+ fromSource: boolean
23
+ env: Record<string, string | undefined>
24
+ }
25
+
26
+ export interface CfPrepared {
27
+ cfg: CloudflareBuildConfig
28
+ core: CoreCf
29
+ user: UserWrangler
30
+ coreVersion: string
31
+ pkgName: string | undefined
32
+ /** brust.toml `[cors]`, validated before any write */
33
+ cors: CorsConfig | undefined
34
+ warnings: string[]
35
+ }
36
+
37
+ const DEFAULT_CFG: CloudflareBuildConfig = { name: undefined, compatibilityDate: undefined, l1Capacity: 256, jobCacheCapacity: 512, stats: false, edgeCache: 'off', alias: {} }
38
+
39
+ /** Everything that can fail on the app's own input or install, before a byte is written (index.ts rule). */
40
+ export async function prepareCloudflare(appRoot: string, cfg: CloudflareBuildConfig | null, flags: CfFlags): Promise<CfPrepared> {
41
+ const core = resolveCoreCf({ appRoot, fromSource: flags.fromSource, env: flags.env })
42
+ const user = await readUserWrangler(appRoot)
43
+ const warnings = [...core.warnings]
44
+ try {
45
+ const toml = Bun.file(join(appRoot, 'brust.toml'))
46
+ if (await toml.exists()) {
47
+ const root = Bun.TOML.parse(await toml.text()) as Record<string, unknown>
48
+ for (const k of ['server', 'workers']) if (k in root) warnings.push(`[brust] brust.toml [${k}] is ignored on this target`)
49
+ }
50
+ } catch {
51
+ // loadBuildConfig already reported a malformed brust.toml
52
+ }
53
+ let pkgName: string | undefined
54
+ try {
55
+ pkgName = (JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')) as { name?: string }).name
56
+ } catch {
57
+ pkgName = undefined
58
+ }
59
+ const coreVersion = String((JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as { version?: string }).version ?? '0.0.0')
60
+ let cors: CorsConfig | undefined
61
+ try {
62
+ cors = await loadCors(appRoot)
63
+ } catch (e) {
64
+ if (e instanceof BrustConfigError) throw new BuildError('config', e.message)
65
+ throw e
66
+ }
67
+ return { cfg: cfg ?? DEFAULT_CFG, core, user, coreVersion, pkgName, cors, warnings }
68
+ }
69
+
70
+ export interface CfBuildCtx {
71
+ appRoot: string
72
+ entry: string
73
+ /** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
74
+ dist: string
75
+ compiled: Map<string, Compiled>
76
+ manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null; cache?: CacheRecord }
77
+ sourceOf: SourceOf
78
+ actionsEntry: string | null
79
+ /** A custom cache adapter's source module (absolute). */
80
+ cacheModule?: string | null
81
+ /** `[cache] get_timeout_ms` as written (absent → the Workers default, 100). */
82
+ cacheGetTimeoutMs?: number
83
+ log: (s: string) => void
84
+ }
85
+
86
+ const CORE_SRC = join(import.meta.dir, '..')
87
+ const BUILTINS = new Set(builtinModules.filter((m) => !m.startsWith('_')))
88
+
89
+ /** Violations of the Workers host surface: `bun`/`bun:*` anywhere in the graph (R2 f). */
90
+ function cfGuard(appRoot: string, violations: string[]): BunPlugin {
91
+ return {
92
+ name: 'brust-cf-guard',
93
+ setup(b) {
94
+ b.onResolve({ filter: /^bun(:.*)?$/ }, (a) => {
95
+ violations.push(`${relative(appRoot, a.importer) || a.importer} imports ${a.path}`)
96
+ return { path: a.path, external: true }
97
+ })
98
+ b.onResolve({ filter: /^[a-z_]+(\/[a-z_/]+)?$/ }, (a) => (BUILTINS.has(a.path) ? { path: `node:${a.path}`, external: true } : undefined))
99
+ },
100
+ }
101
+ }
102
+
103
+ /** `[cloudflare] alias` (spec R12): a module whose resolved path is a key is replaced by its value. */
104
+ function cfAlias(alias: Record<string, string>, sourceOf: SourceOf): BunPlugin {
105
+ return {
106
+ name: 'brust-cf-alias',
107
+ setup(b) {
108
+ if (Object.keys(alias).length === 0) return
109
+ b.onResolve({ filter: /.*/ }, (a) => {
110
+ if (a.importer === '' || a.importer.includes('/node_modules/')) return undefined
111
+ let abs: string
112
+ try {
113
+ abs = Bun.resolveSync(a.path, dirname(sourceOf.get(a.importer) ?? a.importer))
114
+ } catch {
115
+ return undefined
116
+ }
117
+ const to = alias[abs]
118
+ return to === undefined ? undefined : { path: to }
119
+ })
120
+ },
121
+ }
122
+ }
123
+
124
+ /** The CF bundle's stand-in for the napi addon (`native.ts` `require('../native/index.js')`, R8): every call
125
+ * fails loudly, since a Worker has no addon. */
126
+ function cfNativeStub(handlersPath: string): BunPlugin {
127
+ const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
128
+ return {
129
+ name: 'brust-cf-native',
130
+ setup(b) {
131
+ b.onResolve({ filter: /^\.\.\/native\/index\.js$/ }, (a) => (/[\\/]src[\\/]native\.ts$/.test(a.importer) ? { path: 'brust-cf-native', namespace: 'brust-cf' } : undefined))
132
+ b.onLoad({ filter: /.*/, namespace: 'brust-cf' }, () => ({
133
+ loader: 'js',
134
+ // cacheInvalidate -> the `rt.invalidate` the glue installed at start (ruling 19753649); the rest has no Worker meaning.
135
+ contents: `import { installedRuntime } from ${JSON.stringify(handlersPath)}\nconst no = (n) => () => { throw new Error(n + ' is not available on Cloudflare Workers') }\nmodule.exports = {${names.map((n) => (n === 'cacheInvalidate' ? 'cacheInvalidate: (a) => installedRuntime().invalidate(a)' : `${n}: no(${JSON.stringify(n)})`)).join(', ')}}\n`,
136
+ }))
137
+ },
138
+ }
139
+ }
140
+
141
+ /** The @brust/core `workerStorePlugin` twin for a bundle that inlines `@brust/core` (not external). */
142
+ function cfStorePlugin(appRoot: string): BunPlugin {
143
+ return {
144
+ name: 'brust-cf-store',
145
+ setup(b) {
146
+ // The core's own re-export files (`src/store/{index,react}.ts`) must keep their real target, else they loop.
147
+ b.onResolve({ filter: /^@brust\/runtime-dom\/store(\/react)?$/ }, (a) =>
148
+ a.importer.startsWith(CORE_SRC) ? undefined : { path: Bun.resolveSync(a.path.replace('@brust/runtime-dom', '@brust/core'), appRoot) },
149
+ )
150
+ },
151
+ }
152
+ }
153
+
154
+ /** The entry's cache wiring (R8 on Workers): the custom source module or the built-in spec; an `env:`
155
+ * option cannot be resolved inside a Worker (no process env at bundle time) and is refused. */
156
+ function cfEntryCache(c: CacheRecord, customModule: string | null) {
157
+ const noEnv = (o: Record<string, unknown> | undefined) => {
158
+ for (const [k, v] of Object.entries(o ?? {}))
159
+ if (typeof v === 'string' && v.startsWith('env:')) throw new BuildError('cache', `cache option ${k}: "env:" values are not available on Cloudflare Workers (use a binding)`)
160
+ }
161
+ noEnv(c.options)
162
+ noEnv(c.broadcastOptions)
163
+ const module = c.adapter === 'custom' ? customModule : CACHE_BUILTINS[c.adapter]
164
+ if (!module) throw new BuildError('cache', `cache adapter "${c.adapter}" has no module to bundle`)
165
+ return { module, options: c.options, ...(c.broadcast ? { broadcast: CACHE_BUILTINS[c.broadcast]!, broadcastOptions: c.broadcastOptions ?? {} } : {}) }
166
+ }
167
+
168
+ /** R2/R10: which Cloudflare bindings the configured cache needs (`[cache.cloudflare]` keys land in the adapter's options), or null. */
169
+ export function cacheNeeds(c: CacheRecord | undefined): CacheNeed | null {
170
+ if (!c) return null
171
+ const opt = (o: Record<string, unknown> | undefined, k: string) => (typeof o?.[k] === 'string' ? (o[k] as string) : undefined)
172
+ const all = [c.options, c.broadcastOptions]
173
+ const names = [c.adapter, c.broadcast]
174
+ const kv = names.includes('cloudflare-kv') ? (all.map((o) => opt(o, 'kv_binding')).find((b) => b) ?? 'BRUST_CACHE') : undefined
175
+ const explicitDo = all.map((o) => opt(o, 'do_binding')).find((b) => b)
176
+ const dobind = names.includes('cloudflare-do') || explicitDo !== undefined ? (explicitDo ?? 'BRUST_CACHE_DO') : undefined
177
+ return kv === undefined && dobind === undefined ? null : { ...(kv !== undefined ? { kv } : {}), ...(dobind !== undefined ? { do: dobind } : {}) }
178
+ }
179
+
180
+ function smokeFiles(app: string, cache = false): { preload: string; smoke: string } {
181
+ const preload = `// generated: stubs the Workers-only modules so brust-app.js can be imported under Bun
182
+ import { plugin } from 'bun'
183
+ plugin({
184
+ name: 'brust-cf-smoke',
185
+ setup(b) {
186
+ b.module('cloudflare:workers', () => ({ exports: { env: {}, waitUntil() {}, DurableObject: class {} }, loader: 'object' }))
187
+ },
188
+ })
189
+ `
190
+ const smoke = `// generated: build-time smoke check of the CF bundle (child process; mock leaks are process-wide)
191
+ const m = await import(${JSON.stringify(app)})
192
+ const bad = []
193
+ for (const k of ['loader', 'jobs', 'action', 'install']) if (typeof m[k] !== 'function') bad.push(k + ' is not a function')
194
+ if (typeof m.MANIFEST !== 'string' || JSON.parse(m.MANIFEST).version !== 1) bad.push('MANIFEST.version !== 1')
195
+ if (typeof m.CONFIG !== 'string' || JSON.parse(m.CONFIG).abi !== 1) bad.push('CONFIG.abi !== 1')
196
+ if (typeof m.TEMPLATES !== 'object' || m.TEMPLATES === null) bad.push('TEMPLATES is not an object')
197
+ const r = await m.loader('{}')
198
+ if (typeof r !== 'string' || !r.includes('error')) bad.push('loader({}) did not resolve an error string: ' + String(r))${cache ? `
199
+ try {
200
+ const out = m.install({ invalidate: () => ({ l1Removed: 0, jobRemoved: 0 }), l2Count: () => true })
201
+ if (!out || !out.cache || typeof out.cache.origin !== 'string') bad.push('install() returned no cache bridge')
202
+ } catch (e) { bad.push('cache adapter: ' + (e && e.message ? e.message : String(e))) }` : ''}
203
+ if (bad.length) { console.error(bad.join('\\n')); process.exit(1) }
204
+ `
205
+ return { preload, smoke }
206
+ }
207
+
208
+ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepared): Promise<string[]> {
209
+ const cfDir = join(out, 'cf')
210
+ const stage = join(cfDir, '.stage')
211
+ const warnings = [...p.warnings]
212
+ // R10: a Cloudflare cache built-in needs the user's own bindings; verify (never add) before anything is bundled.
213
+ const need = cacheNeeds(ctx.manifest.cache)
214
+ if (need) {
215
+ const v = verifyCacheBindings(p.user.config, need)
216
+ if (!v.ok) throw new BuildError('cf-config', v.message)
217
+ }
218
+ const doClass = need?.do !== undefined
219
+ if (doClass && existsSync(join(p.core.workerDir, 'main.js'))) throw new BuildError('cf-config', `@brust/core-cf ships its own worker/main.js; brust cannot add the BrustCacheDO re-export (R1)`)
220
+ mkdirSync(stage, { recursive: true })
221
+ cpSync(p.core.workerDir, join(cfDir, 'worker'), { recursive: true })
222
+
223
+ const templates: Record<string, string> = {}
224
+ for (const [id, c] of Object.entries(ctx.manifest.components)) {
225
+ const comp = ctx.compiled.get(id)
226
+ if (comp) templates[c.template] = comp.jinja
227
+ }
228
+ const templatesPath = join(stage, 'templates.json')
229
+ writeFileSync(templatesPath, JSON.stringify(templates))
230
+
231
+ const cors = p.cors
232
+ const a = ctx.manifest.actions
233
+ const cache = ctx.manifest.cache ? { ...ctx.manifest.cache, getTimeoutMs: ctx.cacheGetTimeoutMs ?? 100 } : null
234
+ const entryCache = cache ? cfEntryCache(cache, ctx.cacheModule ?? null) : null
235
+ const config: CfConfigJson = {
236
+ abi: 1,
237
+ generator: `brust/${p.coreVersion}`,
238
+ l1Capacity: p.cfg.l1Capacity,
239
+ jobCacheCapacity: p.cfg.jobCacheCapacity,
240
+ stats: p.cfg.stats,
241
+ edgeCache: p.cfg.edgeCache,
242
+ ...(cors ? { cors } : {}),
243
+ ...(a ? { actions: { prefix: a.prefix, maxBodyBytes: a.maxBodyBytes, maxResponseBytes: a.maxResponseBytes ?? 8_388_608, crossSite: a.crossSite ?? 'reject' } } : {}),
244
+ ...(cache ? { cache: { adapter: cache.adapter, getTimeoutMs: cache.getTimeoutMs, maxBodyBytes: cache.maxBodyBytes, namespace: cache.namespace, buildId: cache.buildId, epochIntervalMs: cache.epochIntervalMs } } : {}),
245
+ }
246
+ const entryPath = join(stage, 'brust-app.entry.js')
247
+ const jobsEntry = join(out, '.stage', 'jobs.ts')
248
+ writeFileSync(
249
+ entryPath,
250
+ cfEntrySource({ entry: ctx.entry, jobsEntry, actionsEntry: a ? ctx.actionsEntry : null, prefix: a?.prefix ?? '', manifestPath: join(out, 'manifest.json'), templatesPath, config, cache: entryCache, doClass }),
251
+ )
252
+ // Bare imports of the generated files resolve from the app root (an --out-dir outside the app has no node_modules above it).
253
+ // (registered by absolute AND real path: the bundler reports the real one, e.g. /private/var on macOS)
254
+ for (const f of [entryPath, jobsEntry]) {
255
+ ctx.sourceOf.set(f, ctx.entry)
256
+ ctx.sourceOf.set(realpathSync(f), ctx.entry)
257
+ }
258
+
259
+ const glue = p.core.meta.glue
260
+ const appDir = join(cfDir, 'worker', p.core.meta.appModuleDir)
261
+ const glueRel = relative(appDir, join(cfDir, 'worker', glue)).split('\\').join('/')
262
+ const glueSpec = glueRel.startsWith('.') ? glueRel : `./${glueRel}`
263
+ const violations: string[] = []
264
+ const res = await Bun.build({
265
+ entrypoints: [entryPath],
266
+ target: 'browser',
267
+ format: 'esm',
268
+ conditions: ['workerd', 'worker', 'browser'],
269
+ define: PRODUCTION,
270
+ minify: false,
271
+ external: ['node:*', 'cloudflare:*', glueSpec],
272
+ plugins: [cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'])],
273
+ })
274
+ if (violations.length > 0) throw new BuildError('cf-unsupported', `${[...new Set(violations)].join('\n')}\nthese modules do not exist on Cloudflare Workers; swap them with [cloudflare] alias`)
275
+ if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
276
+ const js = res.outputs.filter((o) => o.kind === 'entry-point')
277
+ if (js.length !== 1) throw new BuildError('bundle', `cloudflare: expected one entry-point output, got ${js.length}`)
278
+ for (const o of res.outputs) if (o.kind !== 'entry-point') warnings.push(`[brust] cloudflare bundle: dropped non-JS output ${o.path}`)
279
+ const appFile = join(appDir, 'brust-app.js')
280
+ mkdirSync(appDir, { recursive: true })
281
+ writeFileSync(appFile, await js[0]!.text())
282
+
283
+ const assets = layoutAssets(out, cfDir, p.coreVersion)
284
+ warnings.push(...assets.warnings)
285
+ const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
286
+ if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
287
+ if (doClass) {
288
+ // R1: the Durable Object class must be exported by the deployed main module; the glue already imports ./brust-app.js, so it is one module copy.
289
+ const appRel = relative(join(cfDir, 'worker'), appFile).split('\\').join('/')
290
+ writeFileSync(
291
+ join(cfDir, 'worker', 'main.js'),
292
+ `// generated by brust build --target cloudflare (do not edit)\nexport * from "./${p.core.meta.main}"\nexport { default } from "./${p.core.meta.main}"\nexport { BrustCacheDO } from "./${appRel}"\n`,
293
+ )
294
+ }
295
+ const merged = mergeWrangler(p.user, { appRoot: ctx.appRoot, cfDir: join(ctx.dist, 'cf'), name: p.cfg.name, pkgName: p.pkgName, compatibilityDate, main: doClass ? 'main.js' : p.core.meta.main, version: p.coreVersion })
296
+ warnings.push(...merged.warnings)
297
+ writeFileSync(join(cfDir, 'wrangler.jsonc'), renderWrangler(merged.config))
298
+
299
+ const { preload, smoke } = smokeFiles(appFile, cache !== null)
300
+ writeFileSync(join(stage, 'smoke-preload.ts'), preload)
301
+ writeFileSync(join(stage, 'smoke.ts'), smoke)
302
+ const r = Bun.spawnSync([process.execPath, '--preload', join(stage, 'smoke-preload.ts'), join(stage, 'smoke.ts')], {
303
+ cwd: ctx.appRoot,
304
+ env: { ...process.env, NODE_ENV: 'production' },
305
+ stdout: 'pipe',
306
+ stderr: 'pipe',
307
+ })
308
+ if (r.exitCode !== 0) throw new BuildError('cf-smoke', `${r.stderr.toString().trim() || r.stdout.toString().trim()}\n(open bindings lazily: module top level runs at build time with env = {})`)
309
+ return warnings
310
+ }
@@ -4,23 +4,232 @@
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 { DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, 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
+ const MIN_RESPONSE_BYTES = 262_144
14
15
 
15
16
  export interface ActionsBuildConfig {
16
17
  entry: string
17
18
  prefix: string
18
19
  maxBodyBytes: number
20
+ maxResponseBytes: number
21
+ crossSite: 'reject' | 'allow'
22
+ }
23
+
24
+ // [cloudflare] (M4-CF; only read by `--target cloudflare`, validated always so errors surface early):
25
+ // name = "my-worker" compatibility_date = "2026-10-01" l1_capacity = 256 job_cache_capacity = 512 stats = false
26
+ // edge_cache = "off" [cloudflare.alias] "lib/db.ts" = "lib/db.cf.ts" # app-root-relative module swaps (spec R12)
27
+ const CF_KEYS = new Set(['name', 'compatibility_date', 'l1_capacity', 'job_cache_capacity', 'stats', 'edge_cache', 'alias'])
28
+ export const CF_MIN_COMPAT_DATE = '2026-08-04'
29
+ const U32_MAX = 4_294_967_295
30
+
31
+ export interface CloudflareBuildConfig {
32
+ name: string | undefined
33
+ compatibilityDate: string | undefined
34
+ l1Capacity: number
35
+ jobCacheCapacity: number
36
+ stats: boolean
37
+ edgeCache: 'off'
38
+ /** absolute module path -> absolute replacement path */
39
+ alias: Record<string, string>
40
+ }
41
+
42
+ // [cache] (cache adapters, opt-in; spec §6): a shared second tier behind the in-process cache.
43
+ // adapter = "redis" | "dragonfly" | "cloudflare-kv" | "cloudflare-do" | "custom" module = "lib/cache.ts" (custom)
44
+ // broadcast = "<built-in>" namespace = "<package name>" get_timeout_ms = 50 (native) / 100 (Workers)
45
+ // max_body_bytes = 1048576 epoch_interval_ms = 1000 [cache.redis] url = "env:REDIS_URL" [cache.cloudflare] [cache.options]
46
+ const CACHE_KEYS = new Set(['adapter', 'broadcast', 'module', 'namespace', 'get_timeout_ms', 'max_body_bytes', 'epoch_interval_ms', 'redis', 'cloudflare', 'options'])
47
+ export const MAX_L2_BODY_BYTES = 67_108_864
48
+
49
+ export interface CacheBuildConfig {
50
+ adapter: (typeof ADAPTER_NAMES)[number]
51
+ /** `custom`: the module, app-root-relative as written. */
52
+ module: string | undefined
53
+ broadcast: string | undefined
54
+ /** The factory's options: `[cache.redis]` / `[cache.cloudflare]` for built-ins, `[cache.options]` for custom. */
55
+ options: Record<string, unknown>
56
+ broadcastOptions: Record<string, unknown> | undefined
57
+ namespace: string
58
+ /** Absent = the runtime default (native 50, Workers 100). */
59
+ getTimeoutMs: number | undefined
60
+ maxBodyBytes: number
61
+ epochIntervalMs: number
19
62
  }
20
63
 
21
64
  export interface BuildConfig {
22
65
  serverOnly: string[]
23
66
  actions: ActionsBuildConfig | null
67
+ cloudflare: CloudflareBuildConfig | null
68
+ cache: CacheBuildConfig | null
69
+ }
70
+
71
+ const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
72
+
73
+ function rangeInt(file: string, key: string, v: unknown, lo: number, hi: number, dflt: number): number {
74
+ if (v === undefined) return dflt
75
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < lo || v > hi)
76
+ throw new BuildError('cache', `${file}: cache.${key} must be an integer in ${lo}..${hi} (got ${String(v)})`)
77
+ return v
78
+ }
79
+
80
+ /** `[cache.redis]`'s keys and ranges (cache-redis R10); `@brust/cache-redis` validates the same list at worker boot. */
81
+ const REDIS_INT_KEYS: ReadonlyArray<readonly [string, number, number]> = [
82
+ ['connect_timeout_ms', 100, 60_000],
83
+ ['max_body_bytes', 1, MAX_L2_BODY_BYTES],
84
+ ['marker_ttl_ms', 60_000, 604_800_000],
85
+ ['unlink_batch', 1, 10_000],
86
+ ['reconnect_min_ms', 10, 60_000],
87
+ ['reconnect_max_ms', 10, 600_000],
88
+ ]
89
+ const REDIS_TLS_KEYS: Record<string, string> = { reject_unauthorized: 'boolean', server_name: 'string', ca_file: 'string' }
90
+
91
+ function checkRedisTable(file: string, r: Record<string, unknown>): void {
92
+ const known = new Set(['url', 'tls', ...REDIS_INT_KEYS.map(([k]) => k)])
93
+ for (const k of Object.keys(r)) if (!known.has(k)) throw new BuildError('cache', `${file}: cache.redis.${k} is not a known key`)
94
+ for (const [k, lo, hi] of REDIS_INT_KEYS) rangeInt(file, `redis.${k}`, r[k], lo, hi, lo)
95
+ if (typeof r.reconnect_min_ms === 'number' && typeof r.reconnect_max_ms === 'number' && r.reconnect_max_ms < r.reconnect_min_ms)
96
+ throw new BuildError('cache', `${file}: cache.redis.reconnect_max_ms must be >= cache.redis.reconnect_min_ms`)
97
+ const tls = r.tls
98
+ if (tls === undefined || typeof tls === 'boolean') return
99
+ if (!isTable(tls)) throw new BuildError('cache', `${file}: cache.redis.tls must be a boolean or a table`)
100
+ for (const [k, v] of Object.entries(tls)) {
101
+ const want = REDIS_TLS_KEYS[k]
102
+ if (!want) throw new BuildError('cache', `${file}: cache.redis.tls.${k} is not a known key`)
103
+ if (typeof v !== want || v === '') throw new BuildError('cache', `${file}: cache.redis.tls.${k} must be a ${want === 'string' ? 'non-empty string' : want}`)
104
+ }
105
+ }
106
+
107
+ /** The option table a built-in reads. */
108
+ function builtinOptions(name: string, t: Record<string, unknown>): Record<string, unknown> {
109
+ const key = name === 'redis' || name === 'dragonfly' ? 'redis' : 'cloudflare'
110
+ return { ...((t[key] as Record<string, unknown> | undefined) ?? {}) }
111
+ }
112
+
113
+ async function packageName(appRoot: string): Promise<string | undefined> {
114
+ const f = Bun.file(join(appRoot, 'package.json'))
115
+ if (!(await f.exists())) return undefined
116
+ try {
117
+ const n = ((await f.json()) as { name?: unknown }).name
118
+ return typeof n === 'string' && n.trim() ? n.trim() : undefined
119
+ } catch {
120
+ return undefined
121
+ }
122
+ }
123
+
124
+ async function parseCache(file: string, appRoot: string, raw: unknown): Promise<CacheBuildConfig | null> {
125
+ if (raw === undefined) return null
126
+ if (!isTable(raw)) throw new BuildError('cache', `${file}: [cache] must be a table`)
127
+ const t = raw
128
+ for (const k of Object.keys(t)) if (!CACHE_KEYS.has(k)) throw new BuildError('cache', `${file}: cache.${k} is not a known key`)
129
+ for (const k of ['redis', 'cloudflare', 'options'] as const)
130
+ if (t[k] !== undefined && !isTable(t[k])) throw new BuildError('cache', `${file}: [cache.${k}] must be a table`)
131
+ const adapter = t.adapter
132
+ if (typeof adapter !== 'string' || !(ADAPTER_NAMES as readonly string[]).includes(adapter))
133
+ throw new BuildError('cache', `${file}: cache.adapter must be one of ${ADAPTER_NAMES.map((n) => `"${n}"`).join(', ')} (got ${JSON.stringify(adapter)})`)
134
+ const name = adapter as CacheBuildConfig['adapter']
135
+ let module: string | undefined
136
+ if (name === 'custom') {
137
+ if (typeof t.module !== 'string' || t.module.trim() === '') throw new BuildError('cache', `${file}: cache.module is required for adapter "custom"`)
138
+ module = t.module.trim()
139
+ } else if (t.module !== undefined) throw new BuildError('cache', `${file}: cache.module is only for adapter "custom"`)
140
+ let broadcast: string | undefined
141
+ if (t.broadcast !== undefined) {
142
+ if (typeof t.broadcast !== 'string' || !(t.broadcast in BUILTINS))
143
+ throw new BuildError('cache', `${file}: cache.broadcast must name a built-in adapter (${Object.keys(BUILTINS).map((n) => `"${n}"`).join(', ')})`)
144
+ broadcast = t.broadcast
145
+ }
146
+ // `env:` indirection only: a literal URL may carry a password, so the error never echoes the value.
147
+ const redis = t.redis as Record<string, unknown> | undefined
148
+ if (redis?.url !== undefined && (typeof redis.url !== 'string' || !/^env:[A-Za-z_][A-Za-z0-9_]*$/.test(redis.url)))
149
+ throw new BuildError('cache', `${file}: cache.redis.url must be "env:NAME" (a literal URL is refused: it may hold a password)`)
150
+ if (redis) checkRedisTable(file, redis)
151
+ for (const n of [name, broadcast]) {
152
+ if (n === undefined || n === 'custom') continue
153
+ try {
154
+ Bun.resolveSync(BUILTINS[n]!, appRoot)
155
+ } catch {
156
+ throw new BuildError('cache', `${file}: ${installHint(n)}`)
157
+ }
158
+ }
159
+ const options = name === 'custom' ? { ...((t.options as Record<string, unknown> | undefined) ?? {}) } : builtinOptions(name, t)
160
+ let namespace = (await packageName(appRoot)) ?? 'brust'
161
+ if (t.namespace !== undefined) {
162
+ if (typeof t.namespace !== 'string' || !/^[A-Za-z0-9@._/-]{1,64}$/.test(t.namespace))
163
+ throw new BuildError('cache', `${file}: cache.namespace must be 1..64 of [A-Za-z0-9@._/-]`)
164
+ namespace = t.namespace
165
+ }
166
+ return {
167
+ adapter: name,
168
+ module,
169
+ broadcast,
170
+ options,
171
+ broadcastOptions: broadcast ? builtinOptions(broadcast, t) : undefined,
172
+ namespace,
173
+ getTimeoutMs: t.get_timeout_ms === undefined ? undefined : rangeInt(file, 'get_timeout_ms', t.get_timeout_ms, 1, 1000, 50),
174
+ maxBodyBytes: rangeInt(file, 'max_body_bytes', t.max_body_bytes, 1, MAX_L2_BODY_BYTES, 1_048_576),
175
+ epochIntervalMs: rangeInt(file, 'epoch_interval_ms', t.epoch_interval_ms, 250, 60_000, 1000),
176
+ }
177
+ }
178
+
179
+ function cfCapacity(file: string, key: string, v: unknown, dflt: number): number {
180
+ if (v === undefined) return dflt
181
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > U32_MAX)
182
+ throw new BuildError('config', `${file}: cloudflare.${key} must be an integer in 1..${U32_MAX} (got ${String(v)})`)
183
+ return v
184
+ }
185
+
186
+ function parseCloudflare(file: string, appRoot: string, raw: unknown): CloudflareBuildConfig | null {
187
+ if (raw === undefined) return null
188
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [cloudflare] must be a table`)
189
+ const t = raw as Record<string, unknown>
190
+ for (const k of Object.keys(t)) if (!CF_KEYS.has(k)) throw new BuildError('config', `${file}: cloudflare.${k} is not a known key`)
191
+ if (t.name !== undefined && (typeof t.name !== 'string' || t.name.trim() === ''))
192
+ throw new BuildError('config', `${file}: cloudflare.name must be a non-empty string`)
193
+ let compatibilityDate: string | undefined
194
+ if (t.compatibility_date !== undefined) {
195
+ const d = t.compatibility_date
196
+ 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`)
197
+ if (d < CF_MIN_COMPAT_DATE)
198
+ throw new BuildError('config', `${file}: cloudflare.compatibility_date must be >= ${CF_MIN_COMPAT_DATE} (nodejs_compat is default from then)`)
199
+ compatibilityDate = d
200
+ }
201
+ if (t.stats !== undefined && typeof t.stats !== 'boolean') throw new BuildError('config', `${file}: cloudflare.stats must be a boolean`)
202
+ if (t.edge_cache !== undefined) {
203
+ if (t.edge_cache === 'workers-cache') throw new BuildError('config', `${file}: cloudflare.edge_cache = "workers-cache" lands with M4-CF-7`)
204
+ if (t.edge_cache !== 'off') throw new BuildError('config', `${file}: cloudflare.edge_cache must be "off"`)
205
+ }
206
+ const alias: Record<string, string> = {}
207
+ if (t.alias !== undefined) {
208
+ if (t.alias === null || typeof t.alias !== 'object' || Array.isArray(t.alias))
209
+ throw new BuildError('config', `${file}: cloudflare.alias must be a table of module paths`)
210
+ for (const [from, to] of Object.entries(t.alias as Record<string, unknown>)) {
211
+ if (typeof to !== 'string' || to.trim() === '') throw new BuildError('config', `${file}: cloudflare.alias["${from}"] must be a non-empty string`)
212
+ alias[resolveAlias(file, appRoot, from)] = resolveAlias(file, appRoot, to)
213
+ }
214
+ }
215
+ return {
216
+ name: t.name === undefined ? undefined : (t.name as string).trim(),
217
+ compatibilityDate,
218
+ l1Capacity: cfCapacity(file, 'l1_capacity', t.l1_capacity, 256),
219
+ jobCacheCapacity: cfCapacity(file, 'job_cache_capacity', t.job_cache_capacity, 512),
220
+ stats: (t.stats as boolean | undefined) ?? false,
221
+ edgeCache: 'off',
222
+ alias,
223
+ }
224
+ }
225
+
226
+ function resolveAlias(file: string, appRoot: string, p: string): string {
227
+ const rel = p.trim().replace(/^\.\//, '')
228
+ try {
229
+ return Bun.resolveSync(`./${rel}`, appRoot)
230
+ } catch {
231
+ throw new BuildError('config', `${file}: cloudflare.alias: cannot resolve "${p}" from the app root`)
232
+ }
24
233
  }
25
234
 
26
235
  function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
@@ -41,11 +250,23 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
41
250
  let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
42
251
  if (t.max_body_bytes !== undefined) {
43
252
  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)})`)
253
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > MAX_ACTION_BYTES)
254
+ throw new BuildError('config', `${file}: actions.max_body_bytes must be an integer in 1..${MAX_ACTION_BYTES} (got ${String(n)})`)
46
255
  maxBodyBytes = n
47
256
  }
48
- return { entry, prefix, maxBodyBytes }
257
+ let maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES
258
+ if (t.max_response_bytes !== undefined) {
259
+ const n = t.max_response_bytes
260
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < MIN_RESPONSE_BYTES || n > MAX_ACTION_BYTES)
261
+ throw new BuildError('config', `${file}: actions.max_response_bytes must be an integer in ${MIN_RESPONSE_BYTES}..${MAX_ACTION_BYTES} (got ${String(n)})`)
262
+ maxResponseBytes = n
263
+ }
264
+ let crossSite: 'reject' | 'allow' = 'reject'
265
+ if (t.cross_site !== undefined) {
266
+ if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
267
+ crossSite = t.cross_site
268
+ }
269
+ return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite }
49
270
  }
50
271
 
51
272
  const DEFAULT_ENTRY = 'actions.ts'
@@ -53,7 +274,7 @@ const DEFAULT_ENTRY = 'actions.ts'
53
274
  export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
54
275
  const file = join(appRoot, 'brust.toml')
55
276
  const f = Bun.file(file)
56
- if (!(await f.exists())) return { serverOnly: [], actions: null }
277
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null }
57
278
  let parsed: unknown
58
279
  try {
59
280
  parsed = Bun.TOML.parse(await f.text())
@@ -62,12 +283,14 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
62
283
  }
63
284
  const root = (parsed ?? {}) as Record<string, unknown>
64
285
  const actions = parseActions(file, root.actions)
286
+ const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
287
+ const cache = await parseCache(file, appRoot, root.cache)
65
288
  const build = root.build
66
- if (build === undefined) return { serverOnly: [], actions }
289
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache }
67
290
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
68
291
  const so = (build as Record<string, unknown>).server_only
69
- if (so === undefined) return { serverOnly: [], actions }
292
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache }
70
293
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
71
294
  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 }
295
+ return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache }
73
296
  }