@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,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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The in-memory cache adapter: the reference implementation of the contract (exact stale-write
|
|
2
|
+
// guard, push or pull broadcast with a bounded ring) and the test double for brust's own suites.
|
|
3
|
+
// Not a production backend: it shares nothing across processes. No `bun` import (CF-bundle safe).
|
|
4
|
+
import type {
|
|
5
|
+
CacheAdapter,
|
|
6
|
+
CacheBroadcast,
|
|
7
|
+
CacheStore,
|
|
8
|
+
EpochSince,
|
|
9
|
+
GetResult,
|
|
10
|
+
InvalidateArgs,
|
|
11
|
+
InvalidationEvent,
|
|
12
|
+
SetMeta,
|
|
13
|
+
StoredPage,
|
|
14
|
+
} from './types'
|
|
15
|
+
|
|
16
|
+
export interface MemoryAdapterOptions {
|
|
17
|
+
broadcast?: 'push' | 'pull' | 'none'
|
|
18
|
+
/** Ring capacity in events (pull). Default 1024. */
|
|
19
|
+
ring?: number
|
|
20
|
+
/** Ring capacity in serialized bytes (pull). Default 1 MiB. */
|
|
21
|
+
ringBytes?: number
|
|
22
|
+
/** Default 8 MiB. */
|
|
23
|
+
maxBodyBytes?: number
|
|
24
|
+
now?: () => number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One event above this many serialized bytes is kept as a gap marker (readers clear). */
|
|
28
|
+
const OVERSIZE_EVENT = 64 * 1024
|
|
29
|
+
|
|
30
|
+
interface Entry {
|
|
31
|
+
page: StoredPage
|
|
32
|
+
tags: string[]
|
|
33
|
+
method: string
|
|
34
|
+
path: string
|
|
35
|
+
entryStamp: number | null
|
|
36
|
+
storedAt: number
|
|
37
|
+
expiresAt: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Slot = { seq: number; bytes: number; event: InvalidationEvent | null }
|
|
41
|
+
|
|
42
|
+
export interface MemoryAdapterTest {
|
|
43
|
+
/** A new generation (a restarted broadcast): readers see `gen` change. */
|
|
44
|
+
regen(): void
|
|
45
|
+
/** The next `get` settles as a miss after `ms` (default 2000). */
|
|
46
|
+
hangNextGet(ms?: number): void
|
|
47
|
+
/** The next store/broadcast call rejects. */
|
|
48
|
+
failNext(): void
|
|
49
|
+
/** The next `store.invalidate` waits `ms` before it marks and deletes (a slow networked store). */
|
|
50
|
+
delayNextInvalidate(ms: number): void
|
|
51
|
+
publishCount(): number
|
|
52
|
+
/** Stored entries (expired ones included). */
|
|
53
|
+
size(): number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type MemoryAdapter = CacheAdapter & { store: CacheStore; __test: MemoryAdapterTest }
|
|
57
|
+
|
|
58
|
+
const pathName = (method: string | undefined, path: string) => `${method ?? 'GET'} ${path}`
|
|
59
|
+
|
|
60
|
+
export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
61
|
+
const mode = o.broadcast ?? 'push'
|
|
62
|
+
const ringCap = o.ring ?? 1024
|
|
63
|
+
const ringBytes = o.ringBytes ?? 1_048_576
|
|
64
|
+
const maxBodyBytes = o.maxBodyBytes ?? 8_388_608
|
|
65
|
+
const now = o.now ?? Date.now
|
|
66
|
+
const entries = new Map<string, Entry>()
|
|
67
|
+
const tagSeq = new Map<string, number>()
|
|
68
|
+
const pathSeq = new Map<string, number>()
|
|
69
|
+
let seq = 0
|
|
70
|
+
// Drawn on first use: a Workers module may not make random values at global scope.
|
|
71
|
+
let genValue: string | null = null
|
|
72
|
+
const gen = () => (genValue ??= randomGen())
|
|
73
|
+
const ring: Slot[] = []
|
|
74
|
+
let ringUsed = 0
|
|
75
|
+
/** The highest seq no longer in the ring (a reader at or below seq-1 of it is behind). */
|
|
76
|
+
let dropped = 0
|
|
77
|
+
let publishes = 0
|
|
78
|
+
let hang: number | null = null
|
|
79
|
+
let fail = false
|
|
80
|
+
let invalidateDelay = 0
|
|
81
|
+
const subscribers = new Set<{ on: (e: InvalidationEvent) => void; onGap: () => void }>()
|
|
82
|
+
|
|
83
|
+
const guarded = mode !== 'none'
|
|
84
|
+
const failing = () => {
|
|
85
|
+
if (!fail) return false
|
|
86
|
+
fail = false
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
const raise = (m: Map<string, number>, k: string, s: number) => m.set(k, Math.max(m.get(k) ?? 0, s))
|
|
90
|
+
const mark = (args: InvalidateArgs, s: number) => {
|
|
91
|
+
for (const t of args.tags ?? []) raise(tagSeq, t, s)
|
|
92
|
+
if (args.path !== undefined) raise(pathSeq, pathName(args.method, args.path), s)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const store: CacheStore = {
|
|
96
|
+
maxBodyBytes,
|
|
97
|
+
async get(key: string): Promise<GetResult> {
|
|
98
|
+
if (failing()) throw new Error('memory adapter: injected failure')
|
|
99
|
+
if (hang !== null) {
|
|
100
|
+
const ms = hang
|
|
101
|
+
hang = null
|
|
102
|
+
await new Promise(r => setTimeout(r, ms))
|
|
103
|
+
return { page: null, stamp: guarded ? seq : null, entryStamp: null, storedAt: null }
|
|
104
|
+
}
|
|
105
|
+
const e = entries.get(key)
|
|
106
|
+
const stamp = guarded ? seq : null
|
|
107
|
+
if (!e || e.expiresAt <= now()) {
|
|
108
|
+
if (e) entries.delete(key)
|
|
109
|
+
return { page: null, stamp, entryStamp: null, storedAt: null }
|
|
110
|
+
}
|
|
111
|
+
return { page: copyPage(e.page), stamp, entryStamp: e.entryStamp, storedAt: e.storedAt }
|
|
112
|
+
},
|
|
113
|
+
async set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean> {
|
|
114
|
+
if (failing()) throw new Error('memory adapter: injected failure')
|
|
115
|
+
if (page.body.length > maxBodyBytes) return false
|
|
116
|
+
if (meta.stamp !== null) {
|
|
117
|
+
const s = meta.stamp
|
|
118
|
+
const stale = (m: Map<string, number>, k: string) => (m.get(k) ?? 0) > s
|
|
119
|
+
if (meta.tags.some(t => stale(tagSeq, t)) || stale(pathSeq, pathName(meta.method, meta.path))) return false
|
|
120
|
+
}
|
|
121
|
+
const at = now()
|
|
122
|
+
entries.set(key, {
|
|
123
|
+
page: copyPage(page),
|
|
124
|
+
tags: [...meta.tags],
|
|
125
|
+
method: meta.method,
|
|
126
|
+
path: meta.path,
|
|
127
|
+
entryStamp: meta.stamp,
|
|
128
|
+
storedAt: at,
|
|
129
|
+
expiresAt: at + meta.ttlMs,
|
|
130
|
+
})
|
|
131
|
+
return true
|
|
132
|
+
},
|
|
133
|
+
async invalidate(args: InvalidateArgs): Promise<void> {
|
|
134
|
+
if (failing()) throw new Error('memory adapter: injected failure')
|
|
135
|
+
if (invalidateDelay > 0) {
|
|
136
|
+
const ms = invalidateDelay
|
|
137
|
+
invalidateDelay = 0
|
|
138
|
+
await new Promise(r => setTimeout(r, ms))
|
|
139
|
+
}
|
|
140
|
+
// L7b: the marker first, at the seq the matching publish will take (seq + 1), so a set whose
|
|
141
|
+
// stamp was read before this call is refused from now on; then the delete.
|
|
142
|
+
mark(args, seq + 1)
|
|
143
|
+
const tags = new Set(args.tags ?? [])
|
|
144
|
+
const p = args.path === undefined ? null : pathName(args.method, args.path)
|
|
145
|
+
for (const [k, e] of entries) {
|
|
146
|
+
if (e.tags.some(t => tags.has(t)) || (p !== null && pathName(e.method, e.path) === p)) entries.delete(k)
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const push = (event: InvalidationEvent) => {
|
|
152
|
+
const bytes = JSON.stringify(event).length
|
|
153
|
+
const slot: Slot = { seq: event.seq, bytes: Math.min(bytes, OVERSIZE_EVENT), event: bytes > OVERSIZE_EVENT ? null : event }
|
|
154
|
+
ring.push(slot)
|
|
155
|
+
ringUsed += slot.bytes
|
|
156
|
+
while (ring.length > ringCap || ringUsed > ringBytes) {
|
|
157
|
+
const old = ring.shift()!
|
|
158
|
+
ringUsed -= old.bytes
|
|
159
|
+
dropped = old.seq
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const broadcast: CacheBroadcast = {
|
|
164
|
+
async publish(args: InvalidateArgs, origin: string): Promise<number> {
|
|
165
|
+
if (failing()) throw new Error('memory adapter: injected failure')
|
|
166
|
+
seq += 1
|
|
167
|
+
publishes += 1
|
|
168
|
+
mark(args, seq)
|
|
169
|
+
const event: InvalidationEvent = { seq, origin, args: structuredClone(args) }
|
|
170
|
+
push(event)
|
|
171
|
+
for (const s of subscribers) queueMicrotask(() => s.on(structuredClone(event)))
|
|
172
|
+
return seq
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
if (mode === 'push') {
|
|
176
|
+
broadcast.subscribe = async (on, onGap) => {
|
|
177
|
+
const sub = { on, onGap }
|
|
178
|
+
subscribers.add(sub)
|
|
179
|
+
return async () => {
|
|
180
|
+
subscribers.delete(sub)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (mode === 'pull') {
|
|
185
|
+
broadcast.since = async (from: number): Promise<EpochSince> => {
|
|
186
|
+
if (failing()) throw new Error('memory adapter: injected failure')
|
|
187
|
+
const after = ring.filter(s => s.seq > from)
|
|
188
|
+
const behind = from < dropped || after.some(s => s.event === null)
|
|
189
|
+
return { gen: gen(), seq, behind, events: behind ? [] : after.map(s => structuredClone(s.event!)) }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
name: 'memory',
|
|
195
|
+
runtimes: ['bun', 'workers'],
|
|
196
|
+
store,
|
|
197
|
+
...(mode === 'none' ? {} : { broadcast }),
|
|
198
|
+
__test: {
|
|
199
|
+
regen() {
|
|
200
|
+
genValue = randomGen()
|
|
201
|
+
ring.length = 0
|
|
202
|
+
ringUsed = 0
|
|
203
|
+
dropped = seq
|
|
204
|
+
for (const s of subscribers) queueMicrotask(() => s.onGap())
|
|
205
|
+
},
|
|
206
|
+
hangNextGet(ms = 2000) {
|
|
207
|
+
hang = ms
|
|
208
|
+
},
|
|
209
|
+
failNext() {
|
|
210
|
+
fail = true
|
|
211
|
+
},
|
|
212
|
+
delayNextInvalidate(ms: number) {
|
|
213
|
+
invalidateDelay = ms
|
|
214
|
+
},
|
|
215
|
+
publishCount: () => publishes,
|
|
216
|
+
size: () => entries.size,
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function copyPage(p: StoredPage): StoredPage {
|
|
222
|
+
return { status: p.status, headers: p.headers.map(([k, v]) => [k, v] as [string, string]), body: p.body.slice() }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function randomGen(): string {
|
|
226
|
+
const b = new Uint8Array(8)
|
|
227
|
+
crypto.getRandomValues(b)
|
|
228
|
+
return Array.from(b, x => x.toString(16).padStart(2, '0')).join('')
|
|
229
|
+
}
|