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

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 +89 -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 +114 -5
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +275 -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
@@ -0,0 +1,83 @@
1
+ // Static assets for Workers Static Assets (M4-CF R7): only files the native host would serve (`safe_rel` in
2
+ // `crates/brust-core/src/static_rules.rs`), laid out under the URL they are requested at, plus `_headers`.
3
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const SEG = /^[A-Za-z0-9_.-]+$/
7
+ const IMMUTABLE = 'public, max-age=31536000, immutable'
8
+ const PUBLIC_DEFAULT = 'public, max-age=3600'
9
+ const MAX_RULES = 100
10
+
11
+ /** `static_rules.rs` `is_hashed`: the stem ends in `-<hex6+>` (lowercase) with at least one digit. */
12
+ export function isHashed(file: string): boolean {
13
+ const base = file.slice(file.lastIndexOf('/') + 1)
14
+ const dot = base.lastIndexOf('.')
15
+ const stem = dot > 0 ? base.slice(0, dot) : base
16
+ const i = stem.lastIndexOf('-')
17
+ if (i < 0) return false
18
+ const h = stem.slice(i + 1)
19
+ return h.length >= 6 && /^[0-9a-f]+$/.test(h) && /\d/.test(h)
20
+ }
21
+
22
+ const segOk = (s: string) => s !== '' && !s.startsWith('.') && SEG.test(s)
23
+
24
+ /** `client/<file>.js|.css`: `is_safe_island_filename` / `is_safe_css_filename`, plus the fonts and images a
25
+ * stylesheet references (`is_safe_client_asset_filename`, L19). */
26
+ const CLIENT_EXT = /\.(js|css|woff2|woff|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)$/
27
+ const clientOk = (f: string) => segOk(f) && !f.includes('..') && CLIENT_EXT.test(f)
28
+
29
+ function walk(dir: string, rel = ''): string[] {
30
+ const out: string[] = []
31
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
32
+ const r = rel ? `${rel}/${e.name}` : e.name
33
+ if (e.isDirectory()) out.push(...walk(join(dir, e.name), r))
34
+ else if (e.isFile() || statSync(join(dir, e.name)).isFile()) out.push(r)
35
+ }
36
+ return out.sort()
37
+ }
38
+
39
+ /** Copies the admissible files of `<out>/client` and `<out>/public` into `<cfDir>/assets/...`; returns the
40
+ * asset-relative paths (`_brust/client/x.js`, `public/img/a.png`) and writes `assets/_headers`. */
41
+ export function layoutAssets(out: string, cfDir: string, version: string): { files: string[]; warnings: string[] } {
42
+ const assets = join(cfDir, 'assets')
43
+ mkdirSync(assets, { recursive: true })
44
+ const files: string[] = []
45
+ const warnings: string[] = []
46
+ const copy = (from: string, to: string) => {
47
+ mkdirSync(dirname(join(assets, to)), { recursive: true })
48
+ cpSync(from, join(assets, to))
49
+ files.push(to)
50
+ }
51
+ const client = join(out, 'client')
52
+ if (existsSync(client))
53
+ for (const f of walk(client)) {
54
+ if (!f.includes('/') && clientOk(f)) copy(join(client, f), `_brust/client/${f}`)
55
+ else warnings.push(`[brust] assets: client/${f} is not served natively; skipped`)
56
+ }
57
+ const pub = join(out, 'public')
58
+ if (existsSync(pub))
59
+ for (const f of walk(pub)) {
60
+ if (f.split('/').every(segOk)) copy(join(pub, f), `public/${f}`)
61
+ else warnings.push(`[brust] assets: public/${f} has a name the native host would not serve; skipped`)
62
+ }
63
+ const h = headersFile(files, version)
64
+ warnings.push(...h.warnings)
65
+ writeFileSync(join(assets, '_headers'), h.text)
66
+ return { files, warnings }
67
+ }
68
+
69
+ export function headersFile(files: string[], version: string): { text: string; warnings: string[] } {
70
+ const warnings: string[] = []
71
+ const rules: string[] = [
72
+ `/*\n X-Content-Type-Options: nosniff\n X-Powered-By: brust/${version}`,
73
+ `/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
74
+ ]
75
+ const pub = files.filter((f) => f.startsWith('public/'))
76
+ if (rules.length + pub.length <= MAX_RULES)
77
+ for (const f of pub) rules.push(`/${f}\n Cache-Control: ${isHashed(f) ? IMMUTABLE : PUBLIC_DEFAULT}`)
78
+ else if (pub.length > 0) {
79
+ rules.push(`/public/*\n Cache-Control: ${PUBLIC_DEFAULT}`)
80
+ warnings.push(`[brust] assets: ${pub.length} public files exceed the 100-rule _headers limit; one /public/* max-age=3600 rule (no per-file immutable)`)
81
+ }
82
+ return { text: `${rules.join('\n\n')}\n`, warnings }
83
+ }
@@ -0,0 +1,90 @@
1
+ // Locates `@brust/core-cf` (the wasm core + glue, built by lane M4-CF-3) and reads its `brust` metadata (M4-CF R3, R4).
2
+ // Order: `--wasm-from-source` > env BRUST_CORE_CF_DIR > the app's node_modules > next to @brust/core.
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { BuildError } from './errors'
6
+
7
+ export interface CoreCfMeta {
8
+ abi: number
9
+ /** worker-build entry, relative to `worker/` */
10
+ main: string
11
+ glue: string
12
+ /** dir under `worker/` that holds `brust-app.js` (next to the glue's `raw_module`) */
13
+ appModuleDir: string
14
+ compatibilityDate: string
15
+ }
16
+
17
+ export interface CoreCf {
18
+ dir: string
19
+ workerDir: string
20
+ meta: CoreCfMeta
21
+ version: string
22
+ warnings: string[]
23
+ }
24
+
25
+ export interface ResolveOpts {
26
+ appRoot: string
27
+ fromSource: boolean
28
+ env: Record<string, string | undefined>
29
+ /** `@brust/core`'s package dir (default: this package) */
30
+ corePkgDir?: string
31
+ run?: (cmd: string[], cwd: string) => number
32
+ which?: (bin: string) => string | null
33
+ }
34
+
35
+ const HINT = (v: string) => `install it with \`bun add -d @brust/core-cf@${v}\` or pass --wasm-from-source`
36
+
37
+ function readJson(file: string): Record<string, unknown> | null {
38
+ try {
39
+ return JSON.parse(readFileSync(file, 'utf8'))
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ function fromDir(dir: string, coreVersion: string): CoreCf {
46
+ const pkg = readJson(join(dir, 'package.json'))
47
+ const m = pkg?.brust as Partial<CoreCfMeta> | undefined
48
+ if (!pkg || !m) throw new BuildError('cf-wasm', `${dir}/package.json has no "brust" metadata (not a @brust/core-cf package?)`)
49
+ if (m.abi !== 1) throw new BuildError('cf-wasm', `@brust/core-cf has abi ${String(m.abi)}; this brust supports abi 1 — upgrade brust or core-cf`)
50
+ const meta: CoreCfMeta = {
51
+ abi: 1,
52
+ main: m.main ?? 'index.js',
53
+ glue: m.glue ?? m.main ?? 'index.js',
54
+ appModuleDir: m.appModuleDir ?? '.',
55
+ compatibilityDate: m.compatibilityDate ?? '2026-10-01',
56
+ }
57
+ const workerDir = join(dir, 'worker')
58
+ if (!existsSync(join(workerDir, meta.main)))
59
+ throw new BuildError('cf-wasm', `@brust/core-cf has no worker/${meta.main} (${dir}); ${HINT(coreVersion)}`)
60
+ const version = String(pkg.version ?? '')
61
+ const warnings: string[] = []
62
+ if (version !== coreVersion) warnings.push(`[brust] @brust/core-cf ${version} differs from @brust/core ${coreVersion}`)
63
+ return { dir, workerDir, meta, version, warnings }
64
+ }
65
+
66
+ export function resolveCoreCf(o: ResolveOpts): CoreCf {
67
+ const corePkgDir = o.corePkgDir ?? join(import.meta.dir, '..', '..')
68
+ const coreVersion = String(readJson(join(corePkgDir, 'package.json'))?.version ?? '0.0.0')
69
+ if (o.fromSource) {
70
+ const repo = join(corePkgDir, '..', '..')
71
+ if (!existsSync(join(repo, 'crates', 'brust-wasm'))) throw new BuildError('cf-wasm', 'cf-wasm needs crates/brust-wasm (lane M4-CF-3): --wasm-from-source requires a brust checkout')
72
+ const which = o.which ?? ((b: string) => Bun.which(b))
73
+ for (const bin of ['cargo']) if (!which(bin)) throw new BuildError('cf-wasm', `--wasm-from-source needs \`${bin}\` on PATH`)
74
+ const run = o.run ?? ((cmd, cwd) => Bun.spawnSync(cmd, { cwd, stdio: ['inherit', 'inherit', 'inherit'] }).exitCode)
75
+ // The build script writes npm/core-cf/worker/ (lane 3 owns it; never bare `worker-build`).
76
+ const code = run(['bun', join('crates', 'brust-wasm', 'scripts', 'build.ts')], repo)
77
+ if (code !== 0) throw new BuildError('cf-wasm', `crates/brust-wasm/scripts/build.ts failed (exit ${code})`)
78
+ return fromDir(join(repo, 'npm', 'core-cf'), coreVersion)
79
+ }
80
+ const envDir = o.env.BRUST_CORE_CF_DIR
81
+ if (envDir) return fromDir(envDir, coreVersion)
82
+ for (const from of [o.appRoot, corePkgDir]) {
83
+ try {
84
+ return fromDir(dirname(Bun.resolveSync('@brust/core-cf/package.json', from)), coreVersion)
85
+ } catch (e) {
86
+ if (e instanceof BuildError) throw e
87
+ }
88
+ }
89
+ throw new BuildError('cf-wasm', `@brust/core-cf is not installed; ${HINT(coreVersion)}`)
90
+ }
@@ -0,0 +1,76 @@
1
+ // The generated Cloudflare entry (M4-CF spec §4, R9, R13): ~25 lines that wire the user's routes, jobs and
2
+ // actions into the six exports the wasm glue imports from `./brust-app.js`. Pure: text in, text out.
3
+ export interface CfConfigJson {
4
+ abi: 1
5
+ generator: string
6
+ l1Capacity: number
7
+ jobCacheCapacity: number
8
+ stats: boolean
9
+ edgeCache: 'off'
10
+ cors?: unknown
11
+ actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes: number; crossSite: 'reject' | 'allow' }
12
+ /** The cache adapter (optional under abi 1, cache adapters R13). */
13
+ cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string; epochIntervalMs: number }
14
+ }
15
+
16
+ /** The cache adapter wiring of the entry: absent → the entry is byte-identical to the no-cache one (D3). */
17
+ export interface CfEntryCache {
18
+ /** The adapter module (an absolute path, or a built-in package spec). */
19
+ module: string
20
+ options: Record<string, unknown>
21
+ /** A composed built-in broadcast's package spec. */
22
+ broadcast?: string
23
+ broadcastOptions?: Record<string, unknown>
24
+ }
25
+
26
+ export interface CfEntryInput {
27
+ /** absolute paths; each goes through JSON.stringify */
28
+ entry: string
29
+ jobsEntry: string
30
+ actionsEntry: string | null
31
+ /** L16: no pages: the routes module and the jobs module are not imported; the handlers serve an empty route table. */
32
+ actionsOnly?: boolean
33
+ prefix: string
34
+ manifestPath: string
35
+ templatesPath: string
36
+ config: CfConfigJson
37
+ cache?: CfEntryCache | null
38
+ /** A Durable Object is configured: the entry re-exports `BrustCacheDO` (R1; `worker/main.js` re-exports it from this one module). */
39
+ doClass?: boolean
40
+ }
41
+
42
+ export function cfEntrySource(i: CfEntryInput): string {
43
+ const q = JSON.stringify
44
+ const actions = i.actionsEntry !== null
45
+ const c = i.cache ?? null
46
+ const pages = i.actionsOnly !== true
47
+ return `${[
48
+ '// generated by `brust build --target cloudflare` — do not edit (M4-CF spec §4)',
49
+ ...(pages ? [`import * as R from ${q(i.entry)}`, `import J, * as JM from ${q(i.jobsEntry)}`] : []),
50
+ ...(actions ? [`import A from ${q(i.actionsEntry)}`] : []),
51
+ `import M from ${q(i.manifestPath)}`,
52
+ `import T from ${q(i.templatesPath)}`,
53
+ `import { fetchInScope, flattenRoutes, jsonCall, makeHandlers, noActions } from "@brust/core/server-handlers"`,
54
+ ...(actions ? ['import { mountActions } from "@brust/core/server-handlers/actions"'] : []),
55
+ pages
56
+ ? 'const h = makeHandlers({ leaves: flattenRoutes(R.routes ?? R.default).leaves, jobs: J, manifest: M, stores: JM.stores ?? {} })'
57
+ : 'const h = makeHandlers({ leaves: [], jobs: {}, manifest: M, stores: {} })',
58
+ ...(actions ? [`const app = mountActions(A, ${q(i.prefix)}).compile() // startup-phase compile (R9)`] : []),
59
+ 'export const loader = jsonCall(h.loader)',
60
+ 'export const jobs = jsonCall(h.jobs)',
61
+ actions ? 'export const action = (req) => fetchInScope(app, req)' : 'export const action = noActions',
62
+ 'export const MANIFEST = JSON.stringify(M)',
63
+ 'export const TEMPLATES = T',
64
+ ...(c
65
+ ? [
66
+ `import CACHE from ${q(c.module)}`,
67
+ ...(c.broadcast ? [`import CACHE_BROADCAST from ${q(c.broadcast)}`] : []),
68
+ 'import { waitUntil } from "cloudflare:workers"',
69
+ 'import { install as installRuntime } from "@brust/core/server-handlers"',
70
+ `export const install = (rt) => installRuntime(rt, { adapter: CACHE, options: ${JSON.stringify(c.options)}, ${c.broadcast ? `broadcast: CACHE_BROADCAST, broadcastOptions: ${JSON.stringify(c.broadcastOptions ?? {})}, ` : ''}config: ${JSON.stringify(i.config.cache)}, waitUntil })`,
71
+ ]
72
+ : ['export { install } from "@brust/core/server-handlers"']),
73
+ ...(i.doClass ? ['export { BrustCacheDO } from "@brust/cache-cloudflare/do"'] : []),
74
+ `export const CONFIG = ${q(JSON.stringify(i.config))}`,
75
+ ].join('\n')}\n`
76
+ }
@@ -0,0 +1,169 @@
1
+ // `wrangler.jsonc` for `dist/cf/` (M4-CF R5, R6, R7): the user's app-root config merged with the keys brust owns.
2
+ // Pure apart from `readUserWrangler`. Never adds an `account_id`; copies one only if the user wrote it.
3
+ import { existsSync } from 'node:fs'
4
+ import { isAbsolute, join, relative } from 'node:path'
5
+ import { BuildError } from './errors'
6
+
7
+ type Json = Record<string, unknown>
8
+ export interface UserWrangler {
9
+ file: string | null
10
+ config: Json
11
+ }
12
+
13
+ export interface MergeOpts {
14
+ appRoot: string
15
+ cfDir: string
16
+ /** `[cloudflare] name` */
17
+ name?: string
18
+ pkgName?: string
19
+ compatibilityDate: string
20
+ /** worker-build entry, relative to `worker/` (core-cf `brust.main`) */
21
+ main: string
22
+ version: string
23
+ /** L16: an actions-only Worker has no Static Assets (and no ASSETS binding). */
24
+ noAssets?: boolean
25
+ }
26
+
27
+ const OWNED = ['main', 'assets', 'compatibility_date', 'no_bundle', 'find_additional_modules', 'base_dir', 'rules', 'build', 'site']
28
+ const CONFIG_FILES = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']
29
+
30
+ export async function readUserWrangler(appRoot: string): Promise<UserWrangler> {
31
+ const found = CONFIG_FILES.filter((f) => existsSync(join(appRoot, f)))
32
+ if (found.length > 1) throw new BuildError('cf-config', `more than one wrangler config in ${appRoot}: ${found.join(', ')} — keep one`)
33
+ const f = found[0]
34
+ if (!f) return { file: null, config: {} }
35
+ const text = await Bun.file(join(appRoot, f)).text()
36
+ try {
37
+ const parsed = f.endsWith('.toml') ? Bun.TOML.parse(text) : Bun.JSONC.parse(text)
38
+ return { file: f, config: (parsed ?? {}) as Json }
39
+ } catch (e) {
40
+ throw new BuildError('cf-config', `failed to parse ${join(appRoot, f)}: ${(e as Error).message}`)
41
+ }
42
+ }
43
+
44
+ const isObj = (v: unknown): v is Json => v !== null && typeof v === 'object' && !Array.isArray(v)
45
+
46
+ /** package.json `name` -> a Worker name: scope dropped, lowercased, non-[a-z0-9-] -> `-`, <= 63. */
47
+ export function workerNameOf(pkgName: string): string {
48
+ return pkgName.replace(/^@[^/]*\//, '').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 63)
49
+ }
50
+
51
+ function rebase(o: MergeOpts, p: string): string {
52
+ return isAbsolute(p) ? p : relative(o.cfDir, join(o.appRoot, p))
53
+ }
54
+
55
+ function assertNoAssetsBinding(v: unknown, path: string): void {
56
+ if (Array.isArray(v)) return v.forEach((x, i) => assertNoAssetsBinding(x, `${path}[${i}]`))
57
+ if (!isObj(v)) return
58
+ for (const [k, x] of Object.entries(v)) {
59
+ if (k === 'assets' && path === '') continue
60
+ if ((k === 'binding' || (k === 'name' && /durable_objects/.test(path))) && x === 'ASSETS') throw new BuildError('cf-config', `wrangler config: a binding named ASSETS (${path || 'root'}) clashes with brust's static-assets binding`)
61
+ if (k === 'vars' && isObj(x) && 'ASSETS' in x) throw new BuildError('cf-config', 'wrangler config: vars.ASSETS clashes with brust\'s static-assets binding')
62
+ assertNoAssetsBinding(x, `${path}.${k}`)
63
+ }
64
+ }
65
+
66
+ function rebasePaths(o: MergeOpts, block: Json): void {
67
+ if (Array.isArray(block.d1_databases))
68
+ for (const d of block.d1_databases)
69
+ if (isObj(d)) d.migrations_dir = rebase(o, typeof d.migrations_dir === 'string' ? d.migrations_dir : 'migrations')
70
+ if (typeof block.tsconfig === 'string') block.tsconfig = rebase(o, block.tsconfig)
71
+ if (isObj(block.alias))
72
+ for (const [k, v] of Object.entries(block.alias)) if (typeof v === 'string' && v.startsWith('.')) block.alias[k] = rebase(o, v)
73
+ }
74
+
75
+ function checkFlags(block: Json, where: string): void {
76
+ if (Array.isArray(block.compatibility_flags) && block.compatibility_flags.some((f) => typeof f === 'string' && /^no_nodejs_compat/.test(f)))
77
+ throw new BuildError('cf-config', `wrangler config${where}: compatibility_flags "no_nodejs_compat*" is not allowed (AsyncLocalStorage needs nodejs_compat)`)
78
+ }
79
+
80
+ export function mergeWrangler(user: UserWrangler, o: MergeOpts): { config: Json; warnings: string[] } {
81
+ const warnings: string[] = []
82
+ const src = structuredClone(user.config)
83
+ assertNoAssetsBinding(src, '')
84
+ checkFlags(src, '')
85
+ const owned = (k: string, where: string) => warnings.push(`[brust] wrangler: ${where}${k} is owned by brust build --target cloudflare; ignored`)
86
+ const rest: Json = {}
87
+ for (const [k, v] of Object.entries(src)) {
88
+ if (OWNED.includes(k)) owned(k, '')
89
+ else rest[k] = v
90
+ }
91
+ const userName = typeof rest.name === 'string' ? rest.name : undefined
92
+ if (o.name !== undefined && userName !== undefined && userName !== o.name)
93
+ warnings.push(`[brust] wrangler: name "${userName}" conflicts with [cloudflare] name "${o.name}"; brust.toml wins`)
94
+ const name = o.name ?? userName ?? (o.pkgName ? workerNameOf(o.pkgName) : undefined)
95
+ rebasePaths(o, rest)
96
+ if (isObj(rest.env))
97
+ for (const [en, eb] of Object.entries(rest.env)) {
98
+ if (!isObj(eb)) continue
99
+ checkFlags(eb, ` env.${en}`)
100
+ for (const k of OWNED)
101
+ if (k in eb) {
102
+ owned(k, `env.${en}.`)
103
+ delete eb[k]
104
+ }
105
+ rebasePaths(o, eb)
106
+ }
107
+ const config: Json = {
108
+ ...(name !== undefined ? { name } : {}),
109
+ main: `worker/${o.main}`,
110
+ compatibility_date: o.compatibilityDate,
111
+ ...(o.noAssets ? {} : { assets: { directory: 'assets', binding: 'ASSETS', run_worker_first: false, html_handling: 'none', not_found_handling: 'none' } }),
112
+ ...Object.fromEntries(Object.entries(rest).filter(([k]) => k !== 'name')),
113
+ }
114
+ return { config, warnings }
115
+ }
116
+
117
+ export interface CacheNeed {
118
+ kv?: string
119
+ do?: string
120
+ }
121
+
122
+ const KV_SNIPPET = (b: string) => `"kv_namespaces": [{ "binding": "${b}", "id": "<wrangler kv namespace create brust-cache>" }]`
123
+ const DO_SNIPPET = (b: string) =>
124
+ `"durable_objects": { "bindings": [{ "name": "${b}", "class_name": "BrustCacheDO" }] },\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`
125
+
126
+ /** Does `block` declare BrustCacheDO as a SQLite class (migrations or `exports`)? `'kv'` = declared only as a KV-backed `new_classes`. */
127
+ function declaresDoClass(block: Json): boolean | 'kv' {
128
+ if (Array.isArray(block.migrations)) {
129
+ let kvBacked = false
130
+ for (const m of block.migrations) {
131
+ if (!isObj(m)) continue
132
+ if (Array.isArray(m.new_sqlite_classes) && m.new_sqlite_classes.includes('BrustCacheDO')) return true
133
+ if (Array.isArray(m.new_classes) && m.new_classes.includes('BrustCacheDO')) kvBacked = true
134
+ }
135
+ if (kvBacked) return 'kv'
136
+ }
137
+ const e = isObj(block.exports) ? block.exports.BrustCacheDO : undefined
138
+ return isObj(e) && e.type === 'durable-object' && e.storage === 'sqlite'
139
+ }
140
+
141
+ /**
142
+ * R10: the user's wrangler config (top level and every `env.<name>`) must declare the bindings the configured Cloudflare cache needs. Nothing is
143
+ * added for them. A class declaration (`migrations` / `exports`) is also accepted from the top level for an env block. Pure; `config` is not mutated.
144
+ */
145
+ export function verifyCacheBindings(config: Json, need: CacheNeed): { ok: true } | { ok: false; message: string } {
146
+ const problems: string[] = []
147
+ const blocks: [string, Json][] = [['the top level', config]]
148
+ if (isObj(config.env)) for (const [n, b] of Object.entries(config.env)) if (isObj(b)) blocks.push([`env.${n}`, b])
149
+ for (const [where, b] of blocks) {
150
+ if (need.kv !== undefined) {
151
+ const ok = Array.isArray(b.kv_namespaces) && b.kv_namespaces.some((k) => isObj(k) && k.binding === need.kv)
152
+ if (!ok) problems.push(`wrangler config ${where}: no kv_namespaces binding "${need.kv}". Add:\n ${KV_SNIPPET(need.kv)}`)
153
+ }
154
+ if (need.do !== undefined) {
155
+ const bound = isObj(b.durable_objects) && Array.isArray(b.durable_objects.bindings) && b.durable_objects.bindings.some((d) => isObj(d) && d.name === need.do && d.class_name === 'BrustCacheDO' && d.script_name === undefined)
156
+ if (!bound) problems.push(`wrangler config ${where}: no durable_objects binding { "name": "${need.do}", "class_name": "BrustCacheDO" } (without script_name). Add:\n ${DO_SNIPPET(need.do)}`)
157
+ else {
158
+ const decl = declaresDoClass(b) || (where === 'the top level' ? false : declaresDoClass(config))
159
+ if (decl === 'kv') problems.push(`wrangler config ${where}: BrustCacheDO is declared with new_classes (KV-backed, no SQL API); use new_sqlite_classes:\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`)
160
+ else if (decl !== true) problems.push(`wrangler config ${where}: BrustCacheDO is not declared. Add:\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`)
161
+ }
162
+ }
163
+ }
164
+ return problems.length === 0 ? { ok: true } : { ok: false, message: problems.join('\n') }
165
+ }
166
+
167
+ export function renderWrangler(config: Json): string {
168
+ return `// generated by \`brust build --target cloudflare\` — edit the app-root wrangler config instead\n${JSON.stringify(config, null, 2)}\n`
169
+ }