@brust/core 2.0.0-alpha.2 → 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 +28 -0
- package/native/index.js +57 -54
- package/package.json +14 -8
- package/src/actions-bridge.ts +27 -9
- 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 +213 -4
- package/src/build/index.ts +75 -7
- package/src/build/manifest.ts +18 -0
- 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 +20 -1
- package/src/run.ts +6 -1
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +43 -231
|
@@ -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,72 @@
|
|
|
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
|
+
prefix: string
|
|
32
|
+
manifestPath: string
|
|
33
|
+
templatesPath: string
|
|
34
|
+
config: CfConfigJson
|
|
35
|
+
cache?: CfEntryCache | null
|
|
36
|
+
/** A Durable Object is configured: the entry re-exports `BrustCacheDO` (R1; `worker/main.js` re-exports it from this one module). */
|
|
37
|
+
doClass?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function cfEntrySource(i: CfEntryInput): string {
|
|
41
|
+
const q = JSON.stringify
|
|
42
|
+
const actions = i.actionsEntry !== null
|
|
43
|
+
const c = i.cache ?? null
|
|
44
|
+
return `${[
|
|
45
|
+
'// generated by `brust build --target cloudflare` — do not edit (M4-CF spec §4)',
|
|
46
|
+
`import * as R from ${q(i.entry)}`,
|
|
47
|
+
`import J, * as JM from ${q(i.jobsEntry)}`,
|
|
48
|
+
...(actions ? [`import A from ${q(i.actionsEntry)}`] : []),
|
|
49
|
+
`import M from ${q(i.manifestPath)}`,
|
|
50
|
+
`import T from ${q(i.templatesPath)}`,
|
|
51
|
+
`import { fetchInScope, flattenRoutes, jsonCall, makeHandlers, noActions } from "@brust/core/server-handlers"`,
|
|
52
|
+
...(actions ? ['import { mountActions } from "@brust/core/server-handlers/actions"'] : []),
|
|
53
|
+
'const h = makeHandlers({ leaves: flattenRoutes(R.routes ?? R.default).leaves, jobs: J, manifest: M, stores: JM.stores ?? {} })',
|
|
54
|
+
...(actions ? [`const app = mountActions(A, ${q(i.prefix)}).compile() // startup-phase compile (R9)`] : []),
|
|
55
|
+
'export const loader = jsonCall(h.loader)',
|
|
56
|
+
'export const jobs = jsonCall(h.jobs)',
|
|
57
|
+
actions ? 'export const action = (req) => fetchInScope(app, req)' : 'export const action = noActions',
|
|
58
|
+
'export const MANIFEST = JSON.stringify(M)',
|
|
59
|
+
'export const TEMPLATES = T',
|
|
60
|
+
...(c
|
|
61
|
+
? [
|
|
62
|
+
`import CACHE from ${q(c.module)}`,
|
|
63
|
+
...(c.broadcast ? [`import CACHE_BROADCAST from ${q(c.broadcast)}`] : []),
|
|
64
|
+
'import { waitUntil } from "cloudflare:workers"',
|
|
65
|
+
'import { install as installRuntime } from "@brust/core/server-handlers"',
|
|
66
|
+
`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 })`,
|
|
67
|
+
]
|
|
68
|
+
: ['export { install } from "@brust/core/server-handlers"']),
|
|
69
|
+
...(i.doClass ? ['export { BrustCacheDO } from "@brust/cache-cloudflare/do"'] : []),
|
|
70
|
+
`export const CONFIG = ${q(JSON.stringify(i.config))}`,
|
|
71
|
+
].join('\n')}\n`
|
|
72
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
const OWNED = ['main', 'assets', 'compatibility_date', 'no_bundle', 'find_additional_modules', 'base_dir', 'rules', 'build', 'site']
|
|
26
|
+
const CONFIG_FILES = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']
|
|
27
|
+
|
|
28
|
+
export async function readUserWrangler(appRoot: string): Promise<UserWrangler> {
|
|
29
|
+
const found = CONFIG_FILES.filter((f) => existsSync(join(appRoot, f)))
|
|
30
|
+
if (found.length > 1) throw new BuildError('cf-config', `more than one wrangler config in ${appRoot}: ${found.join(', ')} — keep one`)
|
|
31
|
+
const f = found[0]
|
|
32
|
+
if (!f) return { file: null, config: {} }
|
|
33
|
+
const text = await Bun.file(join(appRoot, f)).text()
|
|
34
|
+
try {
|
|
35
|
+
const parsed = f.endsWith('.toml') ? Bun.TOML.parse(text) : Bun.JSONC.parse(text)
|
|
36
|
+
return { file: f, config: (parsed ?? {}) as Json }
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw new BuildError('cf-config', `failed to parse ${join(appRoot, f)}: ${(e as Error).message}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isObj = (v: unknown): v is Json => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
43
|
+
|
|
44
|
+
/** package.json `name` -> a Worker name: scope dropped, lowercased, non-[a-z0-9-] -> `-`, <= 63. */
|
|
45
|
+
export function workerNameOf(pkgName: string): string {
|
|
46
|
+
return pkgName.replace(/^@[^/]*\//, '').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 63)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rebase(o: MergeOpts, p: string): string {
|
|
50
|
+
return isAbsolute(p) ? p : relative(o.cfDir, join(o.appRoot, p))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertNoAssetsBinding(v: unknown, path: string): void {
|
|
54
|
+
if (Array.isArray(v)) return v.forEach((x, i) => assertNoAssetsBinding(x, `${path}[${i}]`))
|
|
55
|
+
if (!isObj(v)) return
|
|
56
|
+
for (const [k, x] of Object.entries(v)) {
|
|
57
|
+
if (k === 'assets' && path === '') continue
|
|
58
|
+
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`)
|
|
59
|
+
if (k === 'vars' && isObj(x) && 'ASSETS' in x) throw new BuildError('cf-config', 'wrangler config: vars.ASSETS clashes with brust\'s static-assets binding')
|
|
60
|
+
assertNoAssetsBinding(x, `${path}.${k}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function rebasePaths(o: MergeOpts, block: Json): void {
|
|
65
|
+
if (Array.isArray(block.d1_databases))
|
|
66
|
+
for (const d of block.d1_databases)
|
|
67
|
+
if (isObj(d)) d.migrations_dir = rebase(o, typeof d.migrations_dir === 'string' ? d.migrations_dir : 'migrations')
|
|
68
|
+
if (typeof block.tsconfig === 'string') block.tsconfig = rebase(o, block.tsconfig)
|
|
69
|
+
if (isObj(block.alias))
|
|
70
|
+
for (const [k, v] of Object.entries(block.alias)) if (typeof v === 'string' && v.startsWith('.')) block.alias[k] = rebase(o, v)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkFlags(block: Json, where: string): void {
|
|
74
|
+
if (Array.isArray(block.compatibility_flags) && block.compatibility_flags.some((f) => typeof f === 'string' && /^no_nodejs_compat/.test(f)))
|
|
75
|
+
throw new BuildError('cf-config', `wrangler config${where}: compatibility_flags "no_nodejs_compat*" is not allowed (AsyncLocalStorage needs nodejs_compat)`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function mergeWrangler(user: UserWrangler, o: MergeOpts): { config: Json; warnings: string[] } {
|
|
79
|
+
const warnings: string[] = []
|
|
80
|
+
const src = structuredClone(user.config)
|
|
81
|
+
assertNoAssetsBinding(src, '')
|
|
82
|
+
checkFlags(src, '')
|
|
83
|
+
const owned = (k: string, where: string) => warnings.push(`[brust] wrangler: ${where}${k} is owned by brust build --target cloudflare; ignored`)
|
|
84
|
+
const rest: Json = {}
|
|
85
|
+
for (const [k, v] of Object.entries(src)) {
|
|
86
|
+
if (OWNED.includes(k)) owned(k, '')
|
|
87
|
+
else rest[k] = v
|
|
88
|
+
}
|
|
89
|
+
const userName = typeof rest.name === 'string' ? rest.name : undefined
|
|
90
|
+
if (o.name !== undefined && userName !== undefined && userName !== o.name)
|
|
91
|
+
warnings.push(`[brust] wrangler: name "${userName}" conflicts with [cloudflare] name "${o.name}"; brust.toml wins`)
|
|
92
|
+
const name = o.name ?? userName ?? (o.pkgName ? workerNameOf(o.pkgName) : undefined)
|
|
93
|
+
rebasePaths(o, rest)
|
|
94
|
+
if (isObj(rest.env))
|
|
95
|
+
for (const [en, eb] of Object.entries(rest.env)) {
|
|
96
|
+
if (!isObj(eb)) continue
|
|
97
|
+
checkFlags(eb, ` env.${en}`)
|
|
98
|
+
for (const k of OWNED)
|
|
99
|
+
if (k in eb) {
|
|
100
|
+
owned(k, `env.${en}.`)
|
|
101
|
+
delete eb[k]
|
|
102
|
+
}
|
|
103
|
+
rebasePaths(o, eb)
|
|
104
|
+
}
|
|
105
|
+
const config: Json = {
|
|
106
|
+
...(name !== undefined ? { name } : {}),
|
|
107
|
+
main: `worker/${o.main}`,
|
|
108
|
+
compatibility_date: o.compatibilityDate,
|
|
109
|
+
assets: { directory: 'assets', binding: 'ASSETS', run_worker_first: false, html_handling: 'none', not_found_handling: 'none' },
|
|
110
|
+
...Object.fromEntries(Object.entries(rest).filter(([k]) => k !== 'name')),
|
|
111
|
+
}
|
|
112
|
+
return { config, warnings }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CacheNeed {
|
|
116
|
+
kv?: string
|
|
117
|
+
do?: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const KV_SNIPPET = (b: string) => `"kv_namespaces": [{ "binding": "${b}", "id": "<wrangler kv namespace create brust-cache>" }]`
|
|
121
|
+
const DO_SNIPPET = (b: string) =>
|
|
122
|
+
`"durable_objects": { "bindings": [{ "name": "${b}", "class_name": "BrustCacheDO" }] },\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`
|
|
123
|
+
|
|
124
|
+
/** Does `block` declare BrustCacheDO as a SQLite class (migrations or `exports`)? `'kv'` = declared only as a KV-backed `new_classes`. */
|
|
125
|
+
function declaresDoClass(block: Json): boolean | 'kv' {
|
|
126
|
+
if (Array.isArray(block.migrations)) {
|
|
127
|
+
let kvBacked = false
|
|
128
|
+
for (const m of block.migrations) {
|
|
129
|
+
if (!isObj(m)) continue
|
|
130
|
+
if (Array.isArray(m.new_sqlite_classes) && m.new_sqlite_classes.includes('BrustCacheDO')) return true
|
|
131
|
+
if (Array.isArray(m.new_classes) && m.new_classes.includes('BrustCacheDO')) kvBacked = true
|
|
132
|
+
}
|
|
133
|
+
if (kvBacked) return 'kv'
|
|
134
|
+
}
|
|
135
|
+
const e = isObj(block.exports) ? block.exports.BrustCacheDO : undefined
|
|
136
|
+
return isObj(e) && e.type === 'durable-object' && e.storage === 'sqlite'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* R10: the user's wrangler config (top level and every `env.<name>`) must declare the bindings the configured Cloudflare cache needs. Nothing is
|
|
141
|
+
* added for them. A class declaration (`migrations` / `exports`) is also accepted from the top level for an env block. Pure; `config` is not mutated.
|
|
142
|
+
*/
|
|
143
|
+
export function verifyCacheBindings(config: Json, need: CacheNeed): { ok: true } | { ok: false; message: string } {
|
|
144
|
+
const problems: string[] = []
|
|
145
|
+
const blocks: [string, Json][] = [['the top level', config]]
|
|
146
|
+
if (isObj(config.env)) for (const [n, b] of Object.entries(config.env)) if (isObj(b)) blocks.push([`env.${n}`, b])
|
|
147
|
+
for (const [where, b] of blocks) {
|
|
148
|
+
if (need.kv !== undefined) {
|
|
149
|
+
const ok = Array.isArray(b.kv_namespaces) && b.kv_namespaces.some((k) => isObj(k) && k.binding === need.kv)
|
|
150
|
+
if (!ok) problems.push(`wrangler config ${where}: no kv_namespaces binding "${need.kv}". Add:\n ${KV_SNIPPET(need.kv)}`)
|
|
151
|
+
}
|
|
152
|
+
if (need.do !== undefined) {
|
|
153
|
+
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)
|
|
154
|
+
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)}`)
|
|
155
|
+
else {
|
|
156
|
+
const decl = declaresDoClass(b) || (where === 'the top level' ? false : declaresDoClass(config))
|
|
157
|
+
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"] }]`)
|
|
158
|
+
else if (decl !== true) problems.push(`wrangler config ${where}: BrustCacheDO is not declared. Add:\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return problems.length === 0 ? { ok: true } : { ok: false, message: problems.join('\n') }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderWrangler(config: Json): string {
|
|
166
|
+
return `// generated by \`brust build --target cloudflare\` — edit the app-root wrangler config instead\n${JSON.stringify(config, null, 2)}\n`
|
|
167
|
+
}
|
|
@@ -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
|
+
}
|