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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -89,6 +89,22 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
89
89
  }
90
90
  }
91
91
 
92
+ /** `[cors]` of brust.toml and nothing else (no env, no cli): what the Cloudflare build ships in CONFIG.
93
+ * Throws `BrustConfigError` on a bad table, exactly like `loadConfig`. */
94
+ export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
95
+ const tomlPath = join(cwd, 'brust.toml')
96
+ const file = Bun.file(tomlPath)
97
+ if (!(await file.exists())) return undefined
98
+ let parsed: unknown
99
+ try {
100
+ parsed = Bun.TOML.parse(await file.text())
101
+ } catch (e) {
102
+ throw new BrustConfigError(`failed to parse ${tomlPath}: ${(e as Error).message}`, tomlPath)
103
+ }
104
+ const root = (parsed ?? {}) as Record<string, unknown>
105
+ return 'cors' in root ? parseCors(root.cors, tomlPath) : undefined
106
+ }
107
+
92
108
  /** CLI / programmatic overrides get the same bounds as env (a CLI `--port 0` = any free port). */
93
109
  function checkCli(cli: Partial<BrustConfig>): void {
94
110
  const check = (name: string, v: number | undefined, min: number, max: number) => {
package/src/dev.ts ADDED
@@ -0,0 +1,110 @@
1
+ // `brust dev [entry] --target cloudflare [-- <wrangler args>]` (M4-CF R10): build, run `wrangler dev` on the
2
+ // generated project, rebuild + restart wrangler on a source change. No HMR. Each build runs in a child
3
+ // process: a failed build keeps the old wrangler, and module caches never leak between builds.
4
+ import { type FSWatcher, watch } from 'node:fs'
5
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
6
+ import { BuildError } from './build/errors'
7
+
8
+ export interface DevOptions {
9
+ appRoot: string
10
+ entry: string
11
+ outDir: string
12
+ port?: number
13
+ wranglerArgs: string[]
14
+ fromSource: boolean
15
+ log: (s: string) => void
16
+ }
17
+
18
+ const IGNORED = new Set(['node_modules', '.wrangler', '.git'])
19
+ const DEBOUNCE_MS = 300
20
+
21
+ function wranglerBin(appRoot: string): string {
22
+ const fake = process.env.BRUST_WRANGLER
23
+ if (fake) return fake
24
+ const found = Bun.which('wrangler', { PATH: `${join(appRoot, 'node_modules', '.bin')}:${process.env.PATH ?? ''}` })
25
+ if (!found) throw new BuildError('cf-dev', 'wrangler not found (bun add -d wrangler)')
26
+ return found
27
+ }
28
+
29
+ /** Resolves with the exit code once the session ends (SIGINT/SIGTERM -> 0, or the child's own exit code). */
30
+ export async function runDev(o: DevOptions): Promise<number> {
31
+ const appRoot = resolve(o.appRoot)
32
+ const dist = resolve(appRoot, o.outDir)
33
+ const bin = wranglerBin(appRoot)
34
+ const build = (): Promise<number> => {
35
+ const p = Bun.spawn([process.execPath, join(import.meta.dir, 'cli.ts'), 'build', o.entry, '--target', 'cloudflare', '--out-dir', o.outDir, ...(o.fromSource ? ['--wasm-from-source'] : [])], {
36
+ cwd: appRoot,
37
+ stdout: 'inherit',
38
+ stderr: 'inherit',
39
+ })
40
+ return p.exited
41
+ }
42
+ if ((await build()) !== 0) return 1
43
+
44
+ const args = ['dev', '-c', join(dist, 'cf', 'wrangler.jsonc'), '--persist-to', join(appRoot, '.wrangler', 'state'), ...(o.port !== undefined ? ['--port', String(o.port)] : []), ...o.wranglerArgs]
45
+ let child: ReturnType<typeof Bun.spawn> | null = null
46
+ let stopping = false
47
+ let restarting = false
48
+ let exitCode: (c: number) => void = () => {}
49
+ const done = new Promise<number>((r) => {
50
+ exitCode = r
51
+ })
52
+ const spawn = () => {
53
+ const c = Bun.spawn([bin, ...args], { cwd: appRoot, stdio: ['inherit', 'inherit', 'inherit'] })
54
+ child = c
55
+ c.exited.then((code) => {
56
+ if (child !== c || stopping || restarting) return
57
+ watcher?.close()
58
+ exitCode(code)
59
+ })
60
+ }
61
+ const stopChild = async () => {
62
+ const c = child
63
+ if (!c) return
64
+ c.kill('SIGTERM')
65
+ await c.exited
66
+ }
67
+
68
+ let timer: ReturnType<typeof setTimeout> | undefined
69
+ let building = false
70
+ let again = false
71
+ const rebuild = async () => {
72
+ if (building) {
73
+ again = true
74
+ return
75
+ }
76
+ building = true
77
+ do {
78
+ again = false
79
+ o.log('[brust] change detected; rebuilding')
80
+ if ((await build()) === 0) {
81
+ restarting = true
82
+ await stopChild()
83
+ restarting = false
84
+ if (!stopping) spawn()
85
+ } else o.log('[brust] rebuild failed; keeping the running wrangler')
86
+ } while (again && !stopping)
87
+ building = false
88
+ }
89
+ const ignored = (rel: string) => {
90
+ const abs = resolve(appRoot, rel)
91
+ return rel.split(sep).some((s) => IGNORED.has(s)) || abs === dist || abs.startsWith(dist + sep) || abs.startsWith(`${dist}.`) || abs.startsWith(join(dirname(dist), `.${basename(dist)}.brust-`)) || relative(dist, abs) === ''
92
+ }
93
+ let watcher: FSWatcher | null = watch(appRoot, { recursive: true }, (_ev, name) => {
94
+ if (!name || ignored(String(name))) return
95
+ clearTimeout(timer)
96
+ timer = setTimeout(() => void rebuild(), DEBOUNCE_MS)
97
+ })
98
+
99
+ spawn()
100
+ const stop = () => {
101
+ if (stopping) return
102
+ stopping = true
103
+ clearTimeout(timer)
104
+ watcher?.close()
105
+ void stopChild().then(() => exitCode(0))
106
+ }
107
+ process.on('SIGINT', stop)
108
+ process.on('SIGTERM', stop)
109
+ return done
110
+ }
@@ -0,0 +1,243 @@
1
+ // The host-agnostic half of the worker (M4-CF lane 4): wire shapes + `makeHandlers`. No Bun, no
2
+ // napi, no `node:*` — the Cloudflare entry imports it through `@brust/core/server-handlers`.
3
+ // `worker.ts` re-exports everything here, so existing importers are unchanged.
4
+ import { installServerResolver, type StoreSnapshot } from './store/core-adapter'
5
+ import { collectSnapshot, getServerInstance, runInStoreContext, touchStores } from './store/server-context'
6
+ import { type FlatRoute, isHttpErrorTrigger, isVerdict, type LoaderReq, type Route, type Verdict } from './routes'
7
+
8
+ installServerResolver(getServerInstance)
9
+
10
+
11
+ // ---- wire shapes (protocol.rs, camelCase) ----
12
+
13
+ export interface LoaderRequest {
14
+ routeId: string
15
+ params: Record<string, string>
16
+ path: string
17
+ req: LoaderReq
18
+ /** M3-P P6: the chain has jobs — plan them in this call (`planJobs`). Optional to honour. */
19
+ plan?: boolean
20
+ }
21
+
22
+ export type LoaderResponse =
23
+ | { ok: true; data: Record<string, unknown>; headers?: Record<string, string>; store?: StoreSnapshot | null }
24
+ | { verdict: 'notFound'; data: unknown; store?: StoreSnapshot | null }
25
+ | { verdict: 'redirect'; location: string; status: number }
26
+ | { verdict: 'httpError'; status: number; body: string }
27
+ | { error: string }
28
+
29
+ export type JobKind = 'precompute' | 'ssr'
30
+
31
+ export interface JobCall {
32
+ /** Opaque, echoed back: `<componentId>/<jobId>[/<row>]` for a chain job,
33
+ * `<parentId>/<childId>_<k>/<jobId>[/<row>]` for an inlined child instance (pipeline.rs `plan_one`). */
34
+ id: string
35
+ componentId: string
36
+ kind: JobKind
37
+ // biome-ignore lint/suspicious/noExplicitAny: job inputs are arbitrary JSON
38
+ inputs: any
39
+ /** ssr: the react component to render (D6). */
40
+ target?: string
41
+ row?: number
42
+ }
43
+
44
+ export interface JobsRequest {
45
+ jobs: JobCall[]
46
+ }
47
+
48
+ export type JobResult = { id: string; value: unknown } | { id: string; error: string }
49
+
50
+ export interface JobsResponse {
51
+ results: JobResult[]
52
+ }
53
+
54
+ /** The default export of `<dist>/<manifest.jobs_module>` (built by Task 6): component id → job fns. */
55
+ // biome-ignore lint/suspicious/noExplicitAny: props are whatever the component takes
56
+ export type JobsModule = Record<string, { precompute?: (props: any) => unknown; ssr?: (props: any) => string | Promise<string> }>
57
+
58
+ /** The slice of `manifest.json` the worker reads: ssr job `literals` (S6 amendment). */
59
+ export interface ManifestLike {
60
+ routes?: { id: string; chain: string[] }[]
61
+ components: Record<
62
+ string,
63
+ {
64
+ jobs: { id: string; kind?: string; target?: string; literals?: Record<string, unknown> }[]
65
+ children?: { id: string }[]
66
+ stores?: string[]
67
+ }
68
+ >
69
+ jobs_module?: string
70
+ /** `brust build` (M3-F A3): the actions entry, relative to dist. Absent/null = no actions. */
71
+ actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number } | null
72
+ }
73
+
74
+ export function toJson(out: unknown): string {
75
+ try {
76
+ return JSON.stringify(out)
77
+ } catch (e) {
78
+ return JSON.stringify({ error: `response not serialisable: ${String(e)}` })
79
+ }
80
+ }
81
+
82
+ // ---- literals (S6 amendment: the worker merges an ssr job's `literals` over `call.inputs`) ----
83
+
84
+ /** `"<componentId>/<jobId>"` → that job record's `literals` (only records that carry some). */
85
+ export function literalsIndex(manifest: ManifestLike | undefined): Map<string, Record<string, unknown>> {
86
+ const out = new Map<string, Record<string, unknown>>()
87
+ for (const [cid, c] of Object.entries(manifest?.components ?? {}))
88
+ for (const j of c.jobs ?? []) if (j.literals && Object.keys(j.literals).length > 0) out.set(`${cid}/${j.id}`, j.literals)
89
+ return out
90
+ }
91
+
92
+ /** The manifest job id of a call: the segment after `componentId` in a chain id
93
+ * (`<cid>/<jobId>[/<row>]`), else the third segment of a child-instance id
94
+ * (`<parentId>/<cid>_<k>/<jobId>[/<row>]`). */
95
+ export function jobIdOf(call: Pick<JobCall, 'id' | 'componentId'>): string | undefined {
96
+ const parts = call.id.split('/')
97
+ return parts[0] === call.componentId ? parts[1] : parts[2]
98
+ }
99
+
100
+ // ---- touched stores (M3-F D4) ----
101
+
102
+ /** route id -> sorted unique union of `stores` over the route's chain, walking each component's
103
+ * `children[].id` and `jobs[].target` transitively. Routes with an empty union are absent. */
104
+ export function routeStoresIndex(manifest: ManifestLike | undefined): Map<string, string[]> {
105
+ const out = new Map<string, string[]>()
106
+ const comps = manifest?.components ?? {}
107
+ for (const r of manifest?.routes ?? []) {
108
+ const acc = new Set<string>()
109
+ const seen = new Set<string>()
110
+ const walk = (id: string) => {
111
+ if (seen.has(id)) return
112
+ seen.add(id)
113
+ const c = comps[id]
114
+ if (!c) return
115
+ for (const p of c.stores ?? []) acc.add(p)
116
+ for (const k of c.children ?? []) walk(k.id)
117
+ for (const j of c.jobs ?? []) if (j.target) walk(j.target)
118
+ }
119
+ for (const id of r.chain ?? []) walk(id)
120
+ if (acc.size > 0) out.set(r.id, [...acc].sort())
121
+ }
122
+ return out
123
+ }
124
+
125
+ /** R1: `store` is assigned only when a store was touched; absent otherwise. */
126
+ function withStore<T extends LoaderResponse>(r: T): T {
127
+ const s = collectSnapshot()
128
+ if (s) (r as { store?: StoreSnapshot | null }).store = s
129
+ return r
130
+ }
131
+
132
+ // ---- handlers ----
133
+
134
+ /** `parent`: the data the chain merged before the verdict. A notFound renders the route's
135
+ * template (layouts included), so the layouts' data stays under the verdict's own keys. */
136
+ function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderResponse {
137
+ if (v.status === 404) {
138
+ const own = v.data ?? {}
139
+ return { verdict: 'notFound', data: typeof own === 'object' && !Array.isArray(own) ? { ...parent, ...own } : own }
140
+ }
141
+ return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
142
+ }
143
+
144
+ export interface Handlers {
145
+ loader(req: LoaderRequest): Promise<LoaderResponse>
146
+ jobs(req: JobsRequest): Promise<JobsResponse>
147
+ }
148
+
149
+ /** `leaves` from `flattenRoutes` (ids must match the manifest's route ids); `jobs` the jobs
150
+ * module; `manifest` supplies ssr `literals` (omit → no merge). */
151
+ export function makeHandlers(opts: {
152
+ leaves: FlatRoute[]
153
+ jobs: JobsModule
154
+ manifest?: ManifestLike
155
+ /** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
156
+ stores?: Record<string, object>
157
+ }): Handlers {
158
+ const byId = new Map(opts.leaves.map((l) => [l.id, l]))
159
+ const literals = literalsIndex(opts.manifest)
160
+ const jobs = opts.jobs
161
+ const storeIdx = routeStoresIndex(opts.manifest)
162
+ const storeTable = opts.stores ?? {}
163
+
164
+ async function runJob(call: JobCall): Promise<JobResult> {
165
+ const owner = call.kind === 'ssr' ? (call.target ?? call.componentId) : call.componentId // D6
166
+ const fn = jobs[owner]?.[call.kind]
167
+ if (typeof fn !== 'function') return { id: call.id, error: `no ${call.kind} job for component ${owner}` }
168
+ try {
169
+ let props = call.inputs
170
+ if (call.kind === 'ssr') {
171
+ const jid = jobIdOf(call)
172
+ const lit = jid === undefined ? undefined : literals.get(`${call.componentId}/${jid}`)
173
+ if (lit) props = { ...(props ?? {}), ...lit }
174
+ // Precompute runs outside any scope (D8); ssr runs in a scope seeded from `__store`, which props never see.
175
+ const seed = (props?.__store ?? null) as StoreSnapshot | null
176
+ if (props && '__store' in props) {
177
+ const { __store: _, ...rest } = props
178
+ props = rest
179
+ }
180
+ const p = props
181
+ return { id: call.id, value: await runInStoreContext(() => fn(p), seed) }
182
+ }
183
+ return { id: call.id, value: await fn(props) }
184
+ } catch (e) {
185
+ return { id: call.id, error: String(e) }
186
+ }
187
+ }
188
+
189
+ return {
190
+ // 0.1.x `runNativeChainLoaders`: root → leaf, flat merge (later keys win), first verdict stops
191
+ // (a notFound keeps what the chain merged so far: verdictJson).
192
+ async loader(req) {
193
+ const leaf = byId.get(req.routeId)
194
+ if (!leaf) return { error: `unknown routeId ${req.routeId}` }
195
+ // One object for the whole chain: each level's keys are assigned INTO it (later keys win,
196
+ // first-seen key order — what `{...merged, ...r}` produced) instead of re-spreading every
197
+ // key once per level (M3-P P1). `merged` is fresh per call; a loader's own object is never
198
+ // written to. Null prototype: with no `__proto__` setter to hit, an own "__proto__" key in a
199
+ // loader's data is assigned as data, as the spread copied it.
200
+ return runInStoreContext(async () => {
201
+ const merged: Record<string, unknown> = Object.create(null)
202
+ try {
203
+ const touch = storeIdx.get(req.routeId)
204
+ if (touch) touchStores(touch, storeTable)
205
+ for (const node of leaf.chain) {
206
+ if (!node.loader) continue
207
+ let r: unknown
208
+ try {
209
+ r = await node.loader({ params: req.params, path: req.path, req: req.req })
210
+ } catch (e) {
211
+ if (isHttpErrorTrigger(e)) return { verdict: 'httpError', status: e.status, body: e.body } as LoaderResponse
212
+ throw e
213
+ }
214
+ if (isVerdict(r)) {
215
+ const v = verdictJson(r, merged)
216
+ return 'verdict' in v && v.verdict === 'notFound' ? withStore(v) : v
217
+ }
218
+ if (r && typeof r === 'object') Object.assign(merged, r)
219
+ }
220
+ return withStore({ ok: true, data: merged } as LoaderResponse)
221
+ } catch (e) {
222
+ return { error: String(e) } as LoaderResponse
223
+ }
224
+ })
225
+ },
226
+ // One request carries every job of the page; a per-job failure keeps the other results.
227
+ async jobs(req) {
228
+ return { results: await Promise.all((req.jobs ?? []).map(runJob)) }
229
+ },
230
+ }
231
+ }
232
+
233
+ /** Wraps an async handler for a JSON-string boundary: parses, calls, serialises; NEVER rejects
234
+ * (a parse error or a throwing handler resolves `{"error":…}`, like `makeDispatch`). */
235
+ export function jsonCall<Q, R>(fn: (q: Q) => Promise<R>): (json: string) => Promise<string> {
236
+ return async (json) => {
237
+ try {
238
+ return toJson(await fn(JSON.parse(json)))
239
+ } catch (e) {
240
+ return toJson({ error: String(e) })
241
+ }
242
+ }
243
+ }
package/src/native.ts CHANGED
@@ -31,6 +31,16 @@ export interface StartOptions {
31
31
  cors?: CorsOptions
32
32
  /** The actions subtree (M3-F A3); absent = no actions. Boot error `actions: …` when invalid. */
33
33
  actions?: ActionsOptions
34
+ /** The cache adapter (`manifest.cache`); absent = none (byte-identical server). */
35
+ l2?: L2Options
36
+ }
37
+ /** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
38
+ export interface L2Options {
39
+ adapter: string
40
+ getTimeoutMs?: number
41
+ maxBodyBytes?: number
42
+ namespace: string
43
+ buildId: string
34
44
  }
35
45
  /** napi `NapiCorsOptions` (camelCase keys; snake_case keys are silently dropped). */
36
46
  export interface CorsOptions {
@@ -41,12 +51,16 @@ export interface CorsOptions {
41
51
  credentials?: boolean
42
52
  maxAgeSeconds?: number
43
53
  }
44
- /** napi `NapiActionsOptions`; defaults `/_brust/action`, 262144. */
54
+ /** napi `NapiActionsOptions`; defaults `/_brust/action`, 1048576, 8388608. */
45
55
  export interface ActionsOptions {
46
56
  prefix?: string
47
57
  maxBodyBytes?: number
58
+ /** F82: response body cap (bytes). Default 8388608. */
59
+ maxResponseBytes?: number
60
+ /** F87: cross-site mutating requests. Default `reject`. */
61
+ crossSite?: 'reject' | 'allow'
48
62
  }
49
- export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch', requestJson: string, slot: number) => Promise<number>
63
+ export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch' | 'cache', requestJson: string, slot: number) => Promise<number>
50
64
  export interface NativeBinding {
51
65
  startServer(opts: StartOptions): void
52
66
  registerWorker(buf: Uint8Array, slots: number, f: WorkerFn): number
@@ -57,6 +71,16 @@ export interface NativeBinding {
57
71
  localAddr(): string
58
72
  /** M3-P P6 — see crates/brust-napi/src/server.rs `plan_jobs`. Never throws. */
59
73
  planJobs(slot: number, len: number): string
74
+ /** F82 — the heap request body of the in-flight `fetch` call, once; null if none. Never throws. */
75
+ takeFetchBody(slot: number): Uint8Array | null
76
+ /** F82 — hand Rust a response body too large for the slot; false = declined. Never throws. */
77
+ putFetchReply(slot: number, body: Uint8Array): boolean
78
+ /** cache adapter R10 — a foreign broadcast event: tier 1 + the floor at `seq`; never re-published. */
79
+ cacheReplay(args: InvalidateArgs, seq: number | null): InvalidateResult
80
+ /** cache adapter R9 — bump `stats.l2[name]`; false = unknown name / no adapter / no server. Never throws. */
81
+ cacheL2Count(name: string): boolean
82
+ /** cache adapter R10 — drop both tier-1 caches (a broadcast gap); the L1 entries removed. */
83
+ cacheClear(): number
60
84
  }
61
85
 
62
86
  let binding: NativeBinding | null = null
@@ -83,3 +107,8 @@ export const cacheInvalidate = (args: InvalidateArgs): InvalidateResult => nativ
83
107
  export const cacheStats = (): string => native().cacheStats()
84
108
  export const localAddr = (): string => native().localAddr()
85
109
  export const planJobs = (slot: number, len: number): string => native().planJobs(slot, len)
110
+ export const takeFetchBody = (slot: number): Uint8Array | null => native().takeFetchBody(slot)
111
+ export const putFetchReply = (slot: number, body: Uint8Array): boolean => native().putFetchReply(slot, body)
112
+ export const cacheReplay = (args: InvalidateArgs, seq: number | null): InvalidateResult => native().cacheReplay(args, seq)
113
+ export const cacheL2Count = (name: string): boolean => native().cacheL2Count(name)
114
+ export const cacheClear = (): number => native().cacheClear()
package/src/run.ts CHANGED
@@ -22,10 +22,12 @@ export interface RunOptions {
22
22
 
23
23
  /** The manifest fields `run` needs (build/manifest.ts owns the full shape). */
24
24
  export interface RunManifest {
25
- actions?: { entry: string; prefix: string; maxBodyBytes: number } | null
25
+ actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null
26
+ /** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
27
+ cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string } | null
26
28
  }
27
29
 
28
- /** Pure: the napi `StartOptions`. No actions/cors → exactly the pre-actions keys. */
30
+ /** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
29
31
  export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string): StartOptions {
30
32
  return {
31
33
  host: cfg.host,
@@ -35,8 +37,9 @@ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest,
35
37
  callTimeoutMs: cfg.callTimeoutMs,
36
38
  workerThreads: cfg.ioThreads,
37
39
  generator: `brust/${version}`,
38
- ...(m.actions ? { actions: { prefix: m.actions.prefix, maxBodyBytes: m.actions.maxBodyBytes } } : {}),
40
+ ...(m.actions ? { actions: { prefix: m.actions.prefix, maxBodyBytes: m.actions.maxBodyBytes, ...(m.actions.maxResponseBytes !== undefined ? { maxResponseBytes: m.actions.maxResponseBytes } : {}), ...(m.actions.crossSite ? { crossSite: m.actions.crossSite } : {}) } } : {}),
39
41
  ...(cfg.cors ? { cors: cfg.cors } : {}),
42
+ ...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
40
43
  }
41
44
  }
42
45
 
@@ -86,6 +89,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
86
89
  BRUST_RENDER_SLOTS: String(cfg.renderSlots),
87
90
  BRUST_DIST_DIR: distDir,
88
91
  BRUST_APP_ENTRY: entry,
92
+ // One cache-adapter origin per process: its workers' published events are not replayed here.
93
+ BRUST_CACHE_ORIGIN: crypto.randomUUID(),
89
94
  }
90
95
 
91
96
  let ready = false
@@ -0,0 +1,32 @@
1
+ // What the generated Cloudflare entry (`brust-app.js`) imports: the host-agnostic handler code and the
2
+ // store-scoped action fetch. No Bun / worker_threads here; `actions-bridge`'s napi import is stubbed by the
3
+ // CF bundle (M4-CF R8).
4
+ import { type CfCacheBridge, type CfInstallExt, installCache } from './cache-adapter/workers'
5
+ import type { InvalidateArgs, InvalidateResult } from './native'
6
+
7
+ export { fetchInScope, noActions } from './actions-bridge'
8
+ export { jsonCall, makeHandlers } from './handlers'
9
+ export { flattenRoutes } from './routes'
10
+
11
+ /** What the wasm side hands over at start (`#[event(start]` -> `install({ invalidate, l2Count })`, ruling 19753649). */
12
+ export interface CfRuntime {
13
+ invalidate(args: InvalidateArgs): InvalidateResult
14
+ /** Bumps a `stats.l2` counter (cache adapters R9). */
15
+ l2Count?(name: string): boolean
16
+ }
17
+ export type { CfCacheBridge, CfInstallExt }
18
+
19
+ let rt: CfRuntime | null = null
20
+
21
+ /** Called by the wasm glue once at Worker start; the CF bundle's napi stub forwards `cacheInvalidate` to it.
22
+ * With a cache adapter (`ext`, from the generated entry) it answers the adapter bridge (R11, R14). */
23
+ export function install(runtime: CfRuntime, ext?: CfInstallExt): { cache: CfCacheBridge | null } {
24
+ rt = runtime
25
+ return { cache: ext ? installCache(runtime, ext) : null }
26
+ }
27
+
28
+ /** The installed runtime; throws before `install` (a request cannot arrive earlier). */
29
+ export function installedRuntime(): CfRuntime {
30
+ if (!rt) throw new Error('brust: the Cloudflare runtime is not installed (install(rt) was not called at start)')
31
+ return rt
32
+ }