Write your own
An adapter is a plain object with a store, a broadcast, or both. Import the types from @brust/core/cache-adapter.
That module has no bun import, so it bundles into a Worker. Set adapter = "custom" and module = "lib/cache.ts" in [cache] (see the overview).
1The adapter shape
export interface CacheAdapter {
readonly name: string
readonly runtimes: ReadonlyArray<'bun' | 'workers'>
store?: CacheStore
broadcast?: CacheBroadcast
init?(ctx: { namespace: string; buildId: string; runtime: 'bun' | 'workers'; workerId: number }): Promise<void>
close?(): Promise<void>
}An adapter needs a store, a broadcast, or both.
2Store: shared page bodies
interface CacheStore {
get(key: string): Promise<{ page: StoredPage | null; stamp: number | null; entryStamp: number | null; storedAt: number | null }>
set(key: string, page: StoredPage, meta: { ttlMs: number; tags: string[]; method: string; path: string; stamp: number | null }): Promise<boolean>
invalidate(args: InvalidateArgs): Promise<void>
readonly maxBodyBytes: number
}
interface StoredPage { status: number; headers: [string, string][]; body: Uint8Array } // identity bodygetreturns the page ornull.stampis the broadcast sequence number now;setlater passes it back asmeta.stamp.setreturnsfalseto refuse a write. A store that can do atomic writes must refuse whenmeta.stampis older than an invalidation of one ofmeta.tagsor ofmeta.method meta.path. This stops a page rendered before an invalidation from being stored after it. A store that cannot (a plain key-value service) returnsnullstamps and its writes are unguarded: stale entries then live up to their TTL.invalidatedeletes by tag or by path. It ignoreskey. Record the invalidation before deleting, so a racing stalesetis refused even before the broadcast.- Bodies are binary. Preserve every byte value, and the order and repeats of headers.
3Broadcast: invalidation events
Provide exactly one of subscribe or since:
interface CacheBroadcast {
publish(args: InvalidateArgs, origin: string): Promise<number> // resolves to the sequence number assigned
subscribe?(on: (e: InvalidationEvent) => void, onGap: () => void): Promise<() => Promise<void>> // push (native)
since?(seq: number): Promise<{ gen: string; seq: number; behind: boolean; events: InvalidationEvent[] }> // pull (Workers)
}
interface InvalidationEvent { seq: number; origin: string; args: InvalidateArgs }- Every
stamp,entryStampand eventseqis a number in one sequence space, the broadcast’s. - Push: call
onin order, and callonGapwhenever delivery may have lost an event; brust then clears tier 1. - Pull:
genidentifies the broadcast’s history. If it changes, orbehindis true, brust clears tier 1. - brust skips events whose
originis its own: it already cleared its tier 1.
4A minimal adapter
A store-only adapter over an HTTP key-value service. It works on both runtimes, has no broadcast, and so does not guard writes:
import { defineCacheAdapter, type StoredPage } from '@brust/core/cache-adapter'
// Chunked, so a large page body does not overflow the argument limit of String.fromCharCode.
const toBase64 = (bytes: Uint8Array): string => {
let s = ''
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
return btoa(s)
}
export default defineCacheAdapter((opts) => {
const base = String(opts.baseUrl)
const token = String(opts.token)
const call = (path: string, init: RequestInit = {}) =>
fetch(base + path, { ...init, headers: { authorization: `Bearer ${token}`, ...init.headers } })
return {
name: 'http-kv',
runtimes: ['bun', 'workers'],
store: {
maxBodyBytes: 1_048_576,
async get(key) {
const r = await call(`/get/${encodeURIComponent(key)}`)
if (r.status === 404) return { page: null, stamp: null, entryStamp: null, storedAt: null }
const j = (await r.json()) as { status: number; headers: [string, string][]; body: string; storedAt: number }
const page: StoredPage = { status: j.status, headers: j.headers, body: Uint8Array.from(atob(j.body), (c) => c.charCodeAt(0)) }
return { page, stamp: null, entryStamp: null, storedAt: j.storedAt }
},
async set(key, page, meta) {
const r = await call(`/set/${encodeURIComponent(key)}?ttl=${Math.ceil(meta.ttlMs / 1000)}`, {
method: 'POST',
body: JSON.stringify({ status: page.status, headers: page.headers, body: toBase64(page.body), storedAt: Date.now() }),
})
return r.ok
},
async invalidate() {
// A service with no tag index cannot delete by tag. Entries then expire by their TTL.
},
},
}
})[cache]
adapter = "custom"
module = "lib/cache.ts"
[cache.options]
baseUrl = "https://kv.example.com"
token = "set in the environment and read there, not written in brust.toml"This sketch skips error handling: a thrown error is counted and treated as a miss. It is a shape to copy from, not a tested client for any particular service.
5Test it
Test your adapter with the conformance suite brust uses for its own adapters:
import { runConformance } from '@brust/core/cache-adapter/conformance'
// bun:test cases: binary-safe bodies, header order, TTL expiry, tag and path invalidation,
// the stale-write guard, push/pull delivery, gaps. Pass the capabilities your adapter claims.
runConformance(() => myAdapter(), { push: true, guard: true })@brust/core/cache-adapter also exports memoryAdapter, the reference implementation. It shares nothing across processes, so it is for tests, not production.