@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.
@@ -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
- assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : [])])
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> {
@@ -175,13 +195,22 @@ async function buildInto(
175
195
  await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest))
176
196
  manifest.actions =
177
197
  o.actions && o.actionsEntry
178
- ? { entry: await buildActions(out, o.actionsEntry), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes }
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
+ }
@@ -51,11 +51,13 @@ export interface RouteRecord {
51
51
  catch_all: boolean
52
52
  }
53
53
 
54
- /** `manifest.actions` (A3): `entry` is relative to dist; `maxBodyBytes` is camelCase by contract. */
54
+ /** `manifest.actions` (A3): `entry` is relative to dist; `maxBodyBytes`/`maxResponseBytes` are camelCase by contract. */
55
55
  export interface ActionsRecord {
56
56
  entry: string
57
57
  prefix: string
58
58
  maxBodyBytes: number
59
+ maxResponseBytes: number
60
+ crossSite: 'reject' | 'allow'
59
61
  }
60
62
 
61
63
  export interface ManifestJson {
@@ -65,6 +67,24 @@ export interface ManifestJson {
65
67
  assets: { runtime: string }
66
68
  jobs_module: string
67
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
68
88
  }
69
89
 
70
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
+ }
@@ -0,0 +1,226 @@
1
+ // `@brust/core/cache-adapter/conformance`: the cases every cache adapter must pass (spec §9),
2
+ // registered with bun:test. Built-ins (lanes B/C) and custom adapters run the same suite:
3
+ //
4
+ // import { runConformance } from '@brust/core/cache-adapter/conformance'
5
+ // runConformance(() => myAdapter(opts), { push: true, guard: true })
6
+ //
7
+ // Each case builds a fresh adapter with `make()` and closes it afterwards. Keys are unique per case.
8
+ import { afterEach, describe, expect, test } from 'bun:test'
9
+ import type { CacheAdapter, InvalidationEvent, SetMeta, StoredPage } from './types'
10
+
11
+ export interface ConformanceCaps {
12
+ /** The broadcast is push (`subscribe`). */
13
+ push?: boolean
14
+ /** The broadcast is pull (`since`). */
15
+ pull?: boolean
16
+ /** The store refuses stale writes exactly (Redis Lua, DO, memory). */
17
+ guard?: boolean
18
+ /** The store invalidates (default true); `false` skips the tag/path invalidation cases (Cloudflare KV alone never invalidates). */
19
+ invalidate?: boolean
20
+ }
21
+
22
+ const enc = new TextEncoder()
23
+ const page = (body: string | Uint8Array, headers: [string, string][] = [['content-type', 'text/html']]): StoredPage => ({
24
+ status: 200,
25
+ headers,
26
+ body: typeof body === 'string' ? enc.encode(body) : body,
27
+ })
28
+ const meta = (o: Partial<SetMeta> = {}): SetMeta => ({ ttlMs: 60_000, tags: [], method: 'GET', path: '/', stamp: null, ...o })
29
+
30
+ async function until(what: string, f: () => boolean | Promise<boolean>, ms = 2000): Promise<void> {
31
+ const t0 = Date.now()
32
+ while (!(await f())) {
33
+ if (Date.now() - t0 > ms) throw new Error(`timed out: ${what}`)
34
+ await new Promise(r => setTimeout(r, 10))
35
+ }
36
+ }
37
+
38
+ export function runConformance(make: () => CacheAdapter | Promise<CacheAdapter>, caps: ConformanceCaps = {}): void {
39
+ let open: CacheAdapter[] = []
40
+ let n = 0
41
+ const key = () => `conf:${Date.now().toString(36)}:${n++}`
42
+ const fresh = async () => {
43
+ const a = await make()
44
+ await a.init?.({ namespace: 'conformance', buildId: 'b0', runtime: 'bun', workerId: 0 })
45
+ open.push(a)
46
+ return a
47
+ }
48
+ const store = async () => {
49
+ const a = await fresh()
50
+ if (!a.store) throw new Error(`adapter ${a.name} has no store`)
51
+ return { a, s: a.store }
52
+ }
53
+ afterEach(async () => {
54
+ const all = open
55
+ open = []
56
+ await Promise.allSettled(all.map(a => a.close?.()))
57
+ })
58
+
59
+ describe('cache adapter conformance', () => {
60
+ test('get of an absent key is a miss', async () => {
61
+ const { s } = await store()
62
+ const r = await s.get(key())
63
+ expect(r.page).toBeNull()
64
+ expect(r.entryStamp).toBeNull()
65
+ })
66
+
67
+ test('set then get round-trips status, headers (order + repeats) and body', async () => {
68
+ const { s } = await store()
69
+ const k = key()
70
+ const headers: [string, string][] = [
71
+ ['content-type', 'text/html; charset=utf-8'],
72
+ ['x-a', '1'],
73
+ ['x-a', '2'],
74
+ ['cache-control', 'no-store'],
75
+ ]
76
+ expect(await s.set(k, { status: 203, headers, body: enc.encode('<p>hi</p>') }, meta())).toBe(true)
77
+ const r = await s.get(k)
78
+ expect(r.page?.status).toBe(203)
79
+ expect(r.page?.headers).toEqual(headers)
80
+ expect(new TextDecoder().decode(r.page!.body)).toBe('<p>hi</p>')
81
+ expect(typeof r.storedAt === 'number' || r.storedAt === null).toBe(true)
82
+ })
83
+
84
+ test('bodies are binary-safe (all 256 byte values)', async () => {
85
+ const { s } = await store()
86
+ const k = key()
87
+ const body = Uint8Array.from({ length: 512 }, (_, i) => i % 256)
88
+ expect(await s.set(k, page(body), meta())).toBe(true)
89
+ expect(Array.from((await s.get(k)).page!.body)).toEqual(Array.from(body))
90
+ })
91
+
92
+ test('entryStamp round-trips the set stamp', async () => {
93
+ const { a, s } = await store()
94
+ const k = key()
95
+ const g = await s.get(k)
96
+ if (a.broadcast) expect(typeof g.stamp).toBe('number')
97
+ expect(await s.set(k, page('x'), meta({ stamp: g.stamp }))).toBe(true)
98
+ expect((await s.get(k)).entryStamp).toBe(g.stamp)
99
+ })
100
+
101
+ test('entries expire after ttlMs', async () => {
102
+ const { s } = await store()
103
+ const k = key()
104
+ expect(await s.set(k, page('x'), meta({ ttlMs: 1 }))).toBe(true)
105
+ await until('expiry', async () => (await s.get(k)).page === null)
106
+ })
107
+
108
+ if (caps.invalidate !== false) test('tag invalidation drops tagged pages and keeps unrelated ones', async () => {
109
+ const { s } = await store()
110
+ const [k1, k2, k3] = [key(), key(), key()]
111
+ await s.set(k1, page('1'), meta({ tags: ['items'], path: '/a' }))
112
+ await s.set(k2, page('2'), meta({ tags: ['other', 'items'], path: '/b' }))
113
+ await s.set(k3, page('3'), meta({ tags: ['other'], path: '/c' }))
114
+ await s.invalidate({ tags: ['items'] })
115
+ expect((await s.get(k1)).page).toBeNull()
116
+ expect((await s.get(k2)).page).toBeNull()
117
+ expect((await s.get(k3)).page).not.toBeNull()
118
+ })
119
+
120
+ if (caps.invalidate !== false) test('path + method invalidation drops that page only', async () => {
121
+ const { s } = await store()
122
+ const [k1, k2] = [key(), key()]
123
+ await s.set(k1, page('1'), meta({ path: '/x' }))
124
+ await s.set(k2, page('2'), meta({ path: '/y' }))
125
+ await s.invalidate({ path: '/x', method: 'POST' })
126
+ expect((await s.get(k1)).page).not.toBeNull()
127
+ await s.invalidate({ path: '/x' })
128
+ expect((await s.get(k1)).page).toBeNull()
129
+ expect((await s.get(k2)).page).not.toBeNull()
130
+ })
131
+
132
+ test('key invalidation is a store no-op', async () => {
133
+ const { s } = await store()
134
+ const k = key()
135
+ await s.set(k, page('1'), meta({ tags: ['t'] }))
136
+ await s.invalidate({ key: 'job-key' })
137
+ expect((await s.get(k)).page).not.toBeNull()
138
+ })
139
+
140
+ test('a body over maxBodyBytes is refused', async () => {
141
+ const { s } = await store()
142
+ const k = key()
143
+ expect(await s.set(k, page(new Uint8Array(s.maxBodyBytes + 1)), meta())).toBe(false)
144
+ expect((await s.get(k)).page).toBeNull()
145
+ })
146
+
147
+ if (caps.guard) {
148
+ test('a set whose stamp predates an invalidation of its tag is refused', async () => {
149
+ const { a, s } = await store()
150
+ const k = key()
151
+ const g = await s.get(k)
152
+ await s.invalidate({ tags: ['items'] })
153
+ await a.broadcast?.publish({ tags: ['items'] }, 'conf')
154
+ expect(await s.set(k, page('stale'), meta({ tags: ['items'], stamp: g.stamp }))).toBe(false)
155
+ expect((await s.get(k)).page).toBeNull()
156
+ const g2 = await s.get(k)
157
+ expect(await s.set(k, page('fresh'), meta({ tags: ['items'], stamp: g2.stamp }))).toBe(true)
158
+ })
159
+
160
+ test('L7b: a stale set racing invalidate (before its publish) never lands', async () => {
161
+ const { s } = await store()
162
+ const k = key()
163
+ const g = await s.get(k)
164
+ await s.invalidate({ tags: ['items'], path: '/p' })
165
+ expect(await s.set(k, page('stale'), meta({ tags: ['items'], stamp: g.stamp }))).toBe(false)
166
+ expect(await s.set(k, page('stale'), meta({ path: '/p', stamp: g.stamp }))).toBe(false)
167
+ expect((await s.get(k)).page).toBeNull()
168
+ })
169
+ }
170
+
171
+ test('publish seqs strictly increase; get().stamp is the last seq (one space)', async () => {
172
+ const a = await fresh()
173
+ if (!a.broadcast) return
174
+ const s1 = await a.broadcast.publish({ tags: ['a'] }, 'conf')
175
+ const s2 = await a.broadcast.publish({ path: '/b' }, 'conf')
176
+ expect(s2).toBeGreaterThan(s1)
177
+ if (a.store) expect((await a.store.get(key())).stamp).toBe(s2)
178
+ })
179
+
180
+ if (caps.push) {
181
+ test('push: in-order delivery with origin and args; unsubscribe stops it', async () => {
182
+ const a = await fresh()
183
+ const got: InvalidationEvent[] = []
184
+ const stop = await a.broadcast!.subscribe!(e => got.push(e), () => {})
185
+ const s1 = await a.broadcast!.publish({ tags: ['a'] }, 'o1')
186
+ const s2 = await a.broadcast!.publish({ path: '/b', method: 'GET' }, 'o2')
187
+ await until('delivery', () => got.length === 2)
188
+ expect(got).toEqual([
189
+ { seq: s1, origin: 'o1', args: { tags: ['a'] } },
190
+ { seq: s2, origin: 'o2', args: { path: '/b', method: 'GET' } },
191
+ ])
192
+ await stop()
193
+ await a.broadcast!.publish({ tags: ['c'] }, 'o1')
194
+ await new Promise(r => setTimeout(r, 50))
195
+ expect(got.length).toBe(2)
196
+ })
197
+ }
198
+
199
+ if (caps.pull) {
200
+ test('pull: since(0) after three publishes is contiguous', async () => {
201
+ const a = await fresh()
202
+ const b = a.broadcast!
203
+ const before = await b.since!(0)
204
+ const base = before.seq
205
+ for (const t of ['a', 'b', 'c']) await b.publish({ tags: [t] }, 'o')
206
+ const r = await b.since!(base)
207
+ expect(r.behind).toBe(false)
208
+ expect(r.seq).toBe(base + 3)
209
+ expect(r.events.map(e => e.seq)).toEqual([base + 1, base + 2, base + 3])
210
+ expect(r.events.map(e => e.args.tags?.[0])).toEqual(['a', 'b', 'c'])
211
+ expect(r.events[0]!.origin).toBe('o')
212
+ expect((await b.since!(r.seq)).events).toEqual([])
213
+ expect(typeof r.gen).toBe('string')
214
+ })
215
+
216
+ test('pull: a reader past the retained history is behind', async () => {
217
+ const a = await fresh()
218
+ const b = a.broadcast!
219
+ const base = (await b.since!(0)).seq
220
+ // 1025 events is beyond the spec's 1024-event ring.
221
+ for (let i = 0; i < 1025; i++) await b.publish({ tags: [`t${i}`] }, 'o')
222
+ expect((await b.since!(base)).behind).toBe(true)
223
+ })
224
+ }
225
+ })
226
+ }
@@ -0,0 +1,10 @@
1
+ // `@brust/core/cache-adapter`: the contract, the in-memory adapter and `defineCacheAdapter`.
2
+ // No `bun`/`bun:*` import here (the Cloudflare bundle imports it); the conformance suite is its own
3
+ // subpath, `@brust/core/cache-adapter/conformance`.
4
+ import type { CacheAdapter, CacheAdapterFactory } from './types'
5
+
6
+ export type * from './types'
7
+ export { type MemoryAdapter, type MemoryAdapterOptions, type MemoryAdapterTest, memoryAdapter } from './memory'
8
+
9
+ /** Identity, for type inference: `export default defineCacheAdapter((opts) => ({ … }))`. */
10
+ export const defineCacheAdapter = <T extends CacheAdapter | CacheAdapterFactory>(a: T): T => a
@@ -0,0 +1,144 @@
1
+ // Resolving and validating the configured adapter (cache-adapter R1, R8, R14). The build checks
2
+ // that a built-in package is installed; this runs where the adapter lives (each Bun worker, or the
3
+ // Workers isolate's `install`). No `bun` import: resolution is the caller's (`resolve`).
4
+ import type { CacheAdapter, CacheAdapterFactory } from './types'
5
+
6
+ /** What `brust build` writes to `manifest.cache` / `CONFIG.cache`. */
7
+ export interface CacheManifest {
8
+ adapter: string
9
+ /** `custom`: the bundled module, relative to dist (native) — Workers import it in the entry. */
10
+ module?: string
11
+ /** A built-in broadcast composed onto the adapter (`broadcast = "cloudflare-do"`). */
12
+ broadcast?: string
13
+ options: Record<string, unknown>
14
+ /** Options of the composed broadcast's factory. */
15
+ broadcastOptions?: Record<string, unknown>
16
+ namespace: string
17
+ getTimeoutMs: number
18
+ maxBodyBytes: number
19
+ epochIntervalMs: number
20
+ buildId: string
21
+ }
22
+
23
+ /** The built-in adapters: each package subpath's default export is a factory (the lanes B/C contract). */
24
+ export const BUILTINS: Readonly<Record<string, string>> = {
25
+ redis: '@brust/cache-redis',
26
+ dragonfly: '@brust/cache-redis/dragonfly',
27
+ 'cloudflare-kv': '@brust/cache-cloudflare/kv',
28
+ 'cloudflare-do': '@brust/cache-cloudflare/do',
29
+ }
30
+
31
+ export const ADAPTER_NAMES = ['redis', 'dragonfly', 'cloudflare-kv', 'cloudflare-do', 'custom'] as const
32
+
33
+ /** A built-in name's install hint (the build's error). */
34
+ export function installHint(name: string): string {
35
+ const spec = BUILTINS[name] ?? name
36
+ const pkg = spec.split('/').slice(0, 2).join('/')
37
+ return `adapter "${name}" needs ${pkg} (bun add ${pkg})`
38
+ }
39
+
40
+ /** `env:NAME` strings of a built-in's option table → `env[NAME]` (missing → a boot error naming NAME only). */
41
+ export function resolveEnvOptions(o: Record<string, unknown>, env: Record<string, string | undefined>): Record<string, unknown> {
42
+ const out: Record<string, unknown> = {}
43
+ for (const [k, v] of Object.entries(o)) {
44
+ if (typeof v === 'string' && v.startsWith('env:')) {
45
+ const name = v.slice(4)
46
+ const val = env[name]
47
+ if (val === undefined || val === '') throw new Error(`cache adapter option ${k}: environment variable ${name} is not set`)
48
+ out[k] = val
49
+ } else out[k] = v
50
+ }
51
+ return out
52
+ }
53
+
54
+ /** A module's default export → the adapter (a factory is called with `options`). */
55
+ export function instantiate(mod: unknown, options: Record<string, unknown>, what: string): CacheAdapter {
56
+ const d = (mod as { default?: unknown } | null)?.default ?? mod
57
+ const a = typeof d === 'function' ? (d as CacheAdapterFactory)(options) : d
58
+ return validateAdapter(a, what)
59
+ }
60
+
61
+ const fn = (o: unknown, k: string) => typeof (o as Record<string, unknown> | undefined)?.[k] === 'function'
62
+
63
+ /** The shape check (TypeError naming what is wrong). */
64
+ export function validateAdapter(a: unknown, what: string): CacheAdapter {
65
+ const bad = (m: string): never => {
66
+ throw new TypeError(`cache adapter ${what}: ${m}`)
67
+ }
68
+ if (!a || typeof a !== 'object') bad('the default export is not an adapter or a factory returning one')
69
+ const x = a as CacheAdapter
70
+ if (typeof x.name !== 'string' || !x.name) bad('no name')
71
+ if (!Array.isArray(x.runtimes) || x.runtimes.some(r => r !== 'bun' && r !== 'workers')) bad('runtimes must list "bun" and/or "workers"')
72
+ if (x.store && !(fn(x.store, 'get') && fn(x.store, 'set') && fn(x.store, 'invalidate') && typeof x.store.maxBodyBytes === 'number'))
73
+ bad('store needs get, set, invalidate and maxBodyBytes')
74
+ if (x.broadcast) {
75
+ if (!fn(x.broadcast, 'publish')) bad('broadcast needs publish')
76
+ const push = fn(x.broadcast, 'subscribe')
77
+ const pull = fn(x.broadcast, 'since')
78
+ if (push === pull) bad('broadcast needs exactly one of subscribe (push) or since (pull)')
79
+ }
80
+ if (!x.store && !x.broadcast) bad('neither a store nor a broadcast')
81
+ return x
82
+ }
83
+
84
+ /** R1 (native) / R14 (Workers): the runtime and the broadcast mode must fit where it runs. */
85
+ export function checkRuntime(a: CacheAdapter, runtime: 'bun' | 'workers'): void {
86
+ if (!a.runtimes.includes(runtime))
87
+ throw new TypeError(`cache adapter "${a.name}" does not run on ${runtime === 'bun' ? 'Bun' : 'Cloudflare Workers'} (runtimes: ${a.runtimes.join(', ')})`)
88
+ if (!a.broadcast) return
89
+ const push = typeof a.broadcast.subscribe === 'function'
90
+ if (runtime === 'bun' && !push) throw new TypeError(`cache adapter "${a.name}": native needs a push broadcast (subscribe); pull (since) is Workers-only in v1`)
91
+ if (runtime === 'workers' && push) throw new TypeError(`cache adapter "${a.name}": push-only broadcast (subscribe) cannot run on Workers; it needs since (pull)`)
92
+ }
93
+
94
+ /**
95
+ * The configured adapter. `load(spec, builtin)` imports a module (the caller resolves it: a custom
96
+ * `module` relative to dist natively, a built-in package from the app root). `env` resolves
97
+ * built-in `env:` options.
98
+ */
99
+ export async function loadAdapter(
100
+ m: CacheManifest,
101
+ runtime: 'bun' | 'workers',
102
+ load: (spec: string, builtin: boolean) => Promise<unknown>,
103
+ env: Record<string, string | undefined>,
104
+ ): Promise<CacheAdapter> {
105
+ let adapter: CacheAdapter
106
+ if (m.adapter === 'custom') {
107
+ if (!m.module) throw new Error('cache: adapter "custom" without a module')
108
+ adapter = instantiate(await load(m.module, false), m.options, `"custom" (${m.module})`)
109
+ } else {
110
+ const spec = BUILTINS[m.adapter]
111
+ if (!spec) throw new Error(`cache: unknown adapter "${m.adapter}"`)
112
+ adapter = instantiate(await load(spec, true), resolveEnvOptions(m.options, env), `"${m.adapter}"`)
113
+ }
114
+ if (m.broadcast) {
115
+ const spec = BUILTINS[m.broadcast]
116
+ if (!spec) throw new Error(`cache: unknown broadcast "${m.broadcast}"`)
117
+ const other = instantiate(await load(spec, true), resolveEnvOptions(m.broadcastOptions ?? {}, env), `"${m.broadcast}" (broadcast)`)
118
+ if (!other.broadcast) throw new TypeError(`cache adapter "${m.broadcast}" has no broadcast to compose`)
119
+ adapter = compose(adapter, other)
120
+ }
121
+ checkRuntime(adapter, runtime)
122
+ return adapter
123
+ }
124
+
125
+ /** `base`'s store with `other`'s broadcast; both are initialised and closed. Built explicitly (not a
126
+ * spread) so a class-based adapter keeps its prototype methods. */
127
+ export function compose(base: CacheAdapter, other: CacheAdapter): CacheAdapter {
128
+ // A broadcast that knows its store partner (the Cloudflare DO + KV floors) composes itself.
129
+ const own = (other as { composeWith?: (store: CacheAdapter) => CacheAdapter }).composeWith
130
+ if (typeof own === 'function') return own.call(other, base)
131
+ return {
132
+ name: base.name,
133
+ runtimes: base.runtimes.filter(r => other.runtimes.includes(r)),
134
+ ...(base.store ? { store: base.store } : {}),
135
+ broadcast: other.broadcast,
136
+ async init(ctx) {
137
+ await base.init?.(ctx)
138
+ await other.init?.(ctx)
139
+ },
140
+ async close() {
141
+ await Promise.allSettled([base.close?.(), other.close?.()])
142
+ },
143
+ }
144
+ }