@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.
- package/native/index.d.ts +47 -1
- package/native/index.js +59 -54
- package/package.json +14 -8
- package/src/actions/prefix.ts +3 -1
- package/src/actions-bridge.ts +76 -19
- package/src/build/bundle.ts +12 -2
- package/src/build/cf-assets.ts +81 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +72 -0
- package/src/build/cf-wrangler.ts +167 -0
- package/src/build/cloudflare.ts +310 -0
- package/src/build/config.ts +234 -11
- package/src/build/index.ts +76 -8
- package/src/build/manifest.ts +21 -1
- package/src/cache-adapter/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +18 -0
- package/src/cli.ts +35 -2
- package/src/config.ts +16 -0
- package/src/dev.ts +110 -0
- package/src/handlers.ts +243 -0
- package/src/native.ts +31 -2
- package/src/run.ts +8 -3
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +45 -231
|
@@ -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
|
+
}
|
package/src/build/config.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
13
|
-
const
|
|
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 >
|
|
45
|
-
throw new BuildError('config', `${file}: actions.max_body_bytes must be an integer in 1..${
|
|
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
|
-
|
|
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
|
}
|