@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
package/src/build/config.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// such an import in client code, and every browser bundle refuses to include it.
|
|
6
6
|
import { join } from 'node:path'
|
|
7
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
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).
|
|
@@ -20,9 +21,215 @@ export interface ActionsBuildConfig {
|
|
|
20
21
|
crossSite: 'reject' | 'allow'
|
|
21
22
|
}
|
|
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
|
|
62
|
+
}
|
|
63
|
+
|
|
23
64
|
export interface BuildConfig {
|
|
24
65
|
serverOnly: string[]
|
|
25
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
|
+
}
|
|
26
233
|
}
|
|
27
234
|
|
|
28
235
|
function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
|
|
@@ -67,7 +274,7 @@ const DEFAULT_ENTRY = 'actions.ts'
|
|
|
67
274
|
export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
|
|
68
275
|
const file = join(appRoot, 'brust.toml')
|
|
69
276
|
const f = Bun.file(file)
|
|
70
|
-
if (!(await f.exists())) return { serverOnly: [], actions: null }
|
|
277
|
+
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null }
|
|
71
278
|
let parsed: unknown
|
|
72
279
|
try {
|
|
73
280
|
parsed = Bun.TOML.parse(await f.text())
|
|
@@ -76,12 +283,14 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
|
|
|
76
283
|
}
|
|
77
284
|
const root = (parsed ?? {}) as Record<string, unknown>
|
|
78
285
|
const actions = parseActions(file, root.actions)
|
|
286
|
+
const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
|
|
287
|
+
const cache = await parseCache(file, appRoot, root.cache)
|
|
79
288
|
const build = root.build
|
|
80
|
-
if (build === undefined) return { serverOnly: [], actions }
|
|
289
|
+
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache }
|
|
81
290
|
if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
|
|
82
291
|
const so = (build as Record<string, unknown>).server_only
|
|
83
|
-
if (so === undefined) return { serverOnly: [], actions }
|
|
292
|
+
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache }
|
|
84
293
|
if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
|
|
85
294
|
throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
|
|
86
|
-
return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions }
|
|
295
|
+
return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache }
|
|
87
296
|
}
|
package/src/build/index.ts
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
// edited by hand. An out dir that could hold the app's own sources, or a non-empty one that is not
|
|
5
5
|
// a previous brust dist (no `manifest.json` / `.brust` marker; `--force` overrides), is refused
|
|
6
6
|
// before anything is written or removed.
|
|
7
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
|
|
10
10
|
import { BrustRouteError, validateRoutes } from '../routes'
|
|
11
|
-
import { type BrowserCtx, buildActions, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
|
|
11
|
+
import { type BrowserCtx, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
|
|
12
|
+
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
12
13
|
import { compileApp } from './compile'
|
|
13
|
-
import { type ActionsBuildConfig, loadBuildConfig } from './config'
|
|
14
|
+
import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
|
|
14
15
|
import { BuildError } from './errors'
|
|
15
|
-
import { writeManifest } from './manifest'
|
|
16
|
+
import { type CacheRecord, type ManifestJson, writeManifest } from './manifest'
|
|
16
17
|
import { scanRoutes } from './scan'
|
|
17
18
|
|
|
18
19
|
export { BuildError } from './errors'
|
|
@@ -79,6 +80,10 @@ export async function runBuild(opts: {
|
|
|
79
80
|
outDir: string
|
|
80
81
|
/** Replace a non-empty out dir even without a previous build's marker. */
|
|
81
82
|
force?: boolean
|
|
83
|
+
/** `cloudflare` also writes `<out>/cf/` (M4-CF); default `bun` = the unchanged build. */
|
|
84
|
+
target?: 'bun' | 'cloudflare'
|
|
85
|
+
/** `--wasm-from-source` (cloudflare only). */
|
|
86
|
+
wasmFromSource?: boolean
|
|
82
87
|
log: (s: string) => void
|
|
83
88
|
}): Promise<void> {
|
|
84
89
|
const appRoot = resolve(opts.appRoot)
|
|
@@ -86,7 +91,7 @@ export async function runBuild(opts: {
|
|
|
86
91
|
const dist = resolve(appRoot, opts.outDir)
|
|
87
92
|
assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
|
|
88
93
|
assertReplaceable(dist, opts.force)
|
|
89
|
-
const { serverOnly, actions } = await loadBuildConfig(appRoot)
|
|
94
|
+
const { serverOnly, actions, cloudflare, cache } = await loadBuildConfig(appRoot)
|
|
90
95
|
|
|
91
96
|
// Everything that can fail on the app's own input runs before a byte is written.
|
|
92
97
|
const { routes, componentFile } = await scanRoutes(entry)
|
|
@@ -114,7 +119,18 @@ export async function runBuild(opts: {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
|
-
|
|
122
|
+
let cacheModule: string | null = null
|
|
123
|
+
if (cache?.module !== undefined) {
|
|
124
|
+
cacheModule = resolve(appRoot, cache.module)
|
|
125
|
+
const rel = relative(appRoot, cacheModule)
|
|
126
|
+
if (isAbsolute(cache.module) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(cacheModule)) {
|
|
127
|
+
throw new BuildError('cache', `cache.module ${cache.module} is not a file inside the app`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : []), ...(cacheModule ? [cacheModule] : [])])
|
|
131
|
+
|
|
132
|
+
// Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
|
|
133
|
+
const cf = opts.target === 'cloudflare' ? await prepareCloudflare(appRoot, cloudflare, { fromSource: opts.wasmFromSource === true, env: process.env }) : null
|
|
118
134
|
|
|
119
135
|
// Sibling of `dist` (same filesystem: the final rename is atomic), fixed name (no random bytes in
|
|
120
136
|
// anything the bundler sees).
|
|
@@ -122,7 +138,7 @@ export async function runBuild(opts: {
|
|
|
122
138
|
rmSync(tmp, { recursive: true, force: true })
|
|
123
139
|
mkdirSync(tmp, { recursive: true })
|
|
124
140
|
try {
|
|
125
|
-
await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, log: opts.log })
|
|
141
|
+
await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, cache, cacheModule, cf, log: opts.log })
|
|
126
142
|
rmSync(join(tmp, '.stage'), { recursive: true, force: true })
|
|
127
143
|
if (existsSync(dist)) {
|
|
128
144
|
const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
|
|
@@ -150,6 +166,10 @@ async function buildInto(
|
|
|
150
166
|
actions: ActionsBuildConfig | null
|
|
151
167
|
/** Absolute path of the actions entry (null when `[actions]` is off). */
|
|
152
168
|
actionsEntry: string | null
|
|
169
|
+
cache: CacheBuildConfig | null
|
|
170
|
+
/** Absolute path of a custom cache adapter module. */
|
|
171
|
+
cacheModule: string | null
|
|
172
|
+
cf: CfPrepared | null
|
|
153
173
|
log: (s: string) => void
|
|
154
174
|
},
|
|
155
175
|
): Promise<void> {
|
|
@@ -177,11 +197,20 @@ async function buildInto(
|
|
|
177
197
|
o.actions && o.actionsEntry
|
|
178
198
|
? { entry: await buildActions(out, o.actionsEntry), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite }
|
|
179
199
|
: null
|
|
200
|
+
if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule)
|
|
180
201
|
writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
181
202
|
writeFileSync(join(out, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
|
|
182
203
|
|
|
183
204
|
const pub = join(appRoot, 'public')
|
|
184
205
|
if (existsSync(pub)) cpSync(pub, join(out, 'public'), { recursive: true })
|
|
206
|
+
if (o.cf) {
|
|
207
|
+
const warnings = await buildCloudflare(
|
|
208
|
+
out,
|
|
209
|
+
{ appRoot, entry, dist: o.dist, compiled, manifest, sourceOf: ctx.sourceOf, actionsEntry: o.actionsEntry, cacheModule: o.cacheModule, cacheGetTimeoutMs: o.cache?.getTimeoutMs, log: o.log },
|
|
210
|
+
o.cf,
|
|
211
|
+
)
|
|
212
|
+
for (const w of warnings) o.log(w)
|
|
213
|
+
}
|
|
185
214
|
const entryRel = relative(o.dist, entry)
|
|
186
215
|
writeFileSync(
|
|
187
216
|
join(out, 'index.js'),
|
|
@@ -197,3 +226,42 @@ async function buildInto(
|
|
|
197
226
|
].join('\n'),
|
|
198
227
|
)
|
|
199
228
|
}
|
|
229
|
+
|
|
230
|
+
/** `manifest.cache` (R7, R8): the custom module bundled for the worker, the native defaults, and the
|
|
231
|
+
* build id every L2 key carries. */
|
|
232
|
+
async function cacheRecord(out: string, manifest: ManifestJson, c: CacheBuildConfig, moduleAbs: string | null): Promise<CacheRecord> {
|
|
233
|
+
const module = moduleAbs ? await buildCacheAdapter(out, moduleAbs) : undefined
|
|
234
|
+
return {
|
|
235
|
+
adapter: c.adapter,
|
|
236
|
+
...(module ? { module } : {}),
|
|
237
|
+
...(c.broadcast ? { broadcast: c.broadcast, broadcastOptions: c.broadcastOptions ?? {} } : {}),
|
|
238
|
+
options: c.options,
|
|
239
|
+
namespace: c.namespace,
|
|
240
|
+
getTimeoutMs: c.getTimeoutMs ?? 50,
|
|
241
|
+
maxBodyBytes: c.maxBodyBytes,
|
|
242
|
+
epochIntervalMs: c.epochIntervalMs,
|
|
243
|
+
buildId: computeBuildId(out, manifest),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** R7: the first 16 hex chars of sha256 over the manifest (without `cache`), every `jinja/*.jinja`
|
|
248
|
+
* (by name), `jobs.js` and `actions.js` when present: a rebuild of the same app keeps it, any change
|
|
249
|
+
* to what renders a page moves it (a deploy never serves an older build's L2 bodies). */
|
|
250
|
+
export function computeBuildId(out: string, manifest: ManifestJson): string {
|
|
251
|
+
const h = new Bun.CryptoHasher('sha256')
|
|
252
|
+
const { cache: _, ...rest } = manifest
|
|
253
|
+
h.update(JSON.stringify(rest))
|
|
254
|
+
const jinja = join(out, 'jinja')
|
|
255
|
+
for (const f of (existsSync(jinja) ? readdirSync(jinja) : []).filter((f) => f.endsWith('.jinja')).sort()) {
|
|
256
|
+
h.update(`\0${f}\0`)
|
|
257
|
+
h.update(readFileSync(join(jinja, f)))
|
|
258
|
+
}
|
|
259
|
+
for (const f of ['jobs.js', 'actions.js']) {
|
|
260
|
+
const p = join(out, f)
|
|
261
|
+
if (existsSync(p)) {
|
|
262
|
+
h.update(`\0${f}\0`)
|
|
263
|
+
h.update(readFileSync(p))
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return h.digest('hex').slice(0, 16)
|
|
267
|
+
}
|
package/src/build/manifest.ts
CHANGED
|
@@ -67,6 +67,24 @@ export interface ManifestJson {
|
|
|
67
67
|
assets: { runtime: string }
|
|
68
68
|
jobs_module: string
|
|
69
69
|
actions: ActionsRecord | null
|
|
70
|
+
/** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
|
|
71
|
+
cache?: CacheRecord
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `manifest.cache` / `CONFIG.cache`: the adapter the runtime loads (`cache-adapter/load.ts`). */
|
|
75
|
+
export interface CacheRecord {
|
|
76
|
+
adapter: string
|
|
77
|
+
/** `custom`: the bundled module, relative to dist. */
|
|
78
|
+
module?: string
|
|
79
|
+
broadcast?: string
|
|
80
|
+
options: Record<string, unknown>
|
|
81
|
+
broadcastOptions?: Record<string, unknown>
|
|
82
|
+
namespace: string
|
|
83
|
+
getTimeoutMs: number
|
|
84
|
+
maxBodyBytes: number
|
|
85
|
+
epochIntervalMs: number
|
|
86
|
+
/** 16 hex chars of sha256 over the build output (R7): every L2 key carries it. */
|
|
87
|
+
buildId: string
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
// biome-ignore lint/suspicious/noExplicitAny: IR JSON (externally tagged enums)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The worker side of the native `cache` call kind (cache-adapter R2/R3): Rust asks this worker's
|
|
2
|
+
// adapter store to `get` or `set` one page. Every outcome is an A2 frame `[u32 LE meta_len][meta
|
|
3
|
+
// JSON][body]` in the slot; a hit too large for the slot leaves by `putFetchReply` (meta `heap`), a
|
|
4
|
+
// set body arrives by `takeFetchBody`. Never rejects (Rust's own timer bounds the get; the races
|
|
5
|
+
// here guarantee the call settles, so a hung adapter never pins the slot).
|
|
6
|
+
import type { HeapIo } from '../actions-bridge'
|
|
7
|
+
import type { CacheStore, SetMeta, StoredPage } from './types'
|
|
8
|
+
|
|
9
|
+
export interface CacheBridgeDeps {
|
|
10
|
+
store: CacheStore | undefined
|
|
11
|
+
getTimeoutMs: number
|
|
12
|
+
maxBodyBytes: number
|
|
13
|
+
count: (name: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A get is answered `{error}` this long after Rust's `get_timeout` gave up on it (the two timers must not race). */
|
|
17
|
+
export const GET_SETTLE_SLACK_MS = 100
|
|
18
|
+
|
|
19
|
+
/** A set gets this long before it is answered `{error}` (the slot is then released). */
|
|
20
|
+
export const SET_TIMEOUT_MS = 5000
|
|
21
|
+
|
|
22
|
+
type GetReq = { op: 'get'; key: string }
|
|
23
|
+
type SetReq = { op: 'set'; key: string; status: number; headers: [string, string][]; meta: SetMeta; heap: number }
|
|
24
|
+
|
|
25
|
+
const enc = new TextEncoder()
|
|
26
|
+
const EMPTY = new Uint8Array(0)
|
|
27
|
+
|
|
28
|
+
function frame(view: Uint8Array, slot: number, slots: number, meta: object, body: Uint8Array = EMPTY): number {
|
|
29
|
+
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
30
|
+
const dst = view.subarray(slot * sub, slot * sub + sub)
|
|
31
|
+
let m = enc.encode(JSON.stringify(meta))
|
|
32
|
+
if (4 + m.length + body.length > sub) {
|
|
33
|
+
m = enc.encode(JSON.stringify({ error: 'cache reply does not fit the slot' }))
|
|
34
|
+
body = EMPTY
|
|
35
|
+
}
|
|
36
|
+
new DataView(dst.buffer, dst.byteOffset, 4).setUint32(0, m.length, true)
|
|
37
|
+
dst.set(m, 4)
|
|
38
|
+
dst.set(body, 4 + m.length)
|
|
39
|
+
return 4 + m.length + body.length
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TIMEOUT = Symbol('timeout')
|
|
43
|
+
function within<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMEOUT> {
|
|
44
|
+
let t: ReturnType<typeof setTimeout> | undefined
|
|
45
|
+
return Promise.race([p, new Promise<typeof TIMEOUT>(r => (t = setTimeout(() => r(TIMEOUT), ms)))]).finally(() => clearTimeout(t))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const describe = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
|
49
|
+
|
|
50
|
+
export async function runCache(requestJson: string, view: Uint8Array, slot: number, slots: number, io: HeapIo, deps: CacheBridgeDeps): Promise<number> {
|
|
51
|
+
const out = (meta: object, body?: Uint8Array) => frame(view, slot, slots, meta, body)
|
|
52
|
+
try {
|
|
53
|
+
const store = deps.store
|
|
54
|
+
if (!store) return out({ error: 'no cache adapter store in this worker' })
|
|
55
|
+
const req = JSON.parse(requestJson) as GetReq | SetReq
|
|
56
|
+
if (req.op === 'get') {
|
|
57
|
+
const pending = store.get(req.key)
|
|
58
|
+
pending.catch(() => {})
|
|
59
|
+
// Rust's own `get_timeout` answers the request (as a timeout); this later race only frees the slot.
|
|
60
|
+
const r = await within(pending, deps.getTimeoutMs + GET_SETTLE_SLACK_MS)
|
|
61
|
+
if (r === TIMEOUT) return out({ error: 'get timed out' })
|
|
62
|
+
if (!r.page) return out({ hit: false, stamp: r.stamp })
|
|
63
|
+
const p = r.page
|
|
64
|
+
if (p.body.length > deps.maxBodyBytes) {
|
|
65
|
+
deps.count('errors')
|
|
66
|
+
return out({ hit: false, stamp: r.stamp })
|
|
67
|
+
}
|
|
68
|
+
const meta = { hit: true, status: p.status, headers: p.headers, stamp: r.stamp, entryStamp: r.entryStamp, storedAt: r.storedAt }
|
|
69
|
+
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
70
|
+
if (4 + enc.encode(JSON.stringify(meta)).length + p.body.length <= sub) return out(meta, p.body)
|
|
71
|
+
if (!io.putReply(slot, p.body)) {
|
|
72
|
+
deps.count('errors')
|
|
73
|
+
return out({ hit: false, stamp: r.stamp })
|
|
74
|
+
}
|
|
75
|
+
return out({ ...meta, heap: p.body.length })
|
|
76
|
+
}
|
|
77
|
+
if (req.op === 'set') {
|
|
78
|
+
const body = io.takeBody(slot)
|
|
79
|
+
if (!body || body.length !== req.heap) return out({ error: `set body ${body ? `${body.length} != ${req.heap}` : 'missing'} bytes` })
|
|
80
|
+
const page: StoredPage = { status: req.status, headers: req.headers, body }
|
|
81
|
+
const pending = store.set(req.key, page, req.meta)
|
|
82
|
+
pending.catch(() => {})
|
|
83
|
+
const ok = await within(pending, SET_TIMEOUT_MS)
|
|
84
|
+
if (ok === TIMEOUT) return out({ error: 'set timed out' })
|
|
85
|
+
return out({ ok: ok === true })
|
|
86
|
+
}
|
|
87
|
+
return out({ error: `unknown cache op ${(req as { op?: unknown }).op}` })
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return out({ error: describe(e) })
|
|
90
|
+
}
|
|
91
|
+
}
|