@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/native/index.d.ts +47 -1
- package/native/index.js +59 -54
- package/package.json +14 -8
- package/src/actions/prefix.ts +3 -1
- package/src/actions-bridge.ts +76 -19
- 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 +234 -11
- package/src/build/index.ts +76 -8
- package/src/build/manifest.ts +21 -1
- 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 +31 -2
- package/src/run.ts +8 -3
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +45 -231
package/src/build/index.ts
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
// edited by hand. An out dir that could hold the app's own sources, or a non-empty one that is not
|
|
5
5
|
// a previous brust dist (no `manifest.json` / `.brust` marker; `--force` overrides), is refused
|
|
6
6
|
// before anything is written or removed.
|
|
7
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
|
|
10
10
|
import { BrustRouteError, validateRoutes } from '../routes'
|
|
11
|
-
import { type BrowserCtx, buildActions, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
|
|
11
|
+
import { type BrowserCtx, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
|
|
12
|
+
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
12
13
|
import { compileApp } from './compile'
|
|
13
|
-
import { type ActionsBuildConfig, loadBuildConfig } from './config'
|
|
14
|
+
import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
|
|
14
15
|
import { BuildError } from './errors'
|
|
15
|
-
import { writeManifest } from './manifest'
|
|
16
|
+
import { type CacheRecord, type ManifestJson, writeManifest } from './manifest'
|
|
16
17
|
import { scanRoutes } from './scan'
|
|
17
18
|
|
|
18
19
|
export { BuildError } from './errors'
|
|
@@ -79,6 +80,10 @@ export async function runBuild(opts: {
|
|
|
79
80
|
outDir: string
|
|
80
81
|
/** Replace a non-empty out dir even without a previous build's marker. */
|
|
81
82
|
force?: boolean
|
|
83
|
+
/** `cloudflare` also writes `<out>/cf/` (M4-CF); default `bun` = the unchanged build. */
|
|
84
|
+
target?: 'bun' | 'cloudflare'
|
|
85
|
+
/** `--wasm-from-source` (cloudflare only). */
|
|
86
|
+
wasmFromSource?: boolean
|
|
82
87
|
log: (s: string) => void
|
|
83
88
|
}): Promise<void> {
|
|
84
89
|
const appRoot = resolve(opts.appRoot)
|
|
@@ -86,7 +91,7 @@ export async function runBuild(opts: {
|
|
|
86
91
|
const dist = resolve(appRoot, opts.outDir)
|
|
87
92
|
assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
|
|
88
93
|
assertReplaceable(dist, opts.force)
|
|
89
|
-
const { serverOnly, actions } = await loadBuildConfig(appRoot)
|
|
94
|
+
const { serverOnly, actions, cloudflare, cache } = await loadBuildConfig(appRoot)
|
|
90
95
|
|
|
91
96
|
// Everything that can fail on the app's own input runs before a byte is written.
|
|
92
97
|
const { routes, componentFile } = await scanRoutes(entry)
|
|
@@ -114,7 +119,18 @@ export async function runBuild(opts: {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
|
-
|
|
122
|
+
let cacheModule: string | null = null
|
|
123
|
+
if (cache?.module !== undefined) {
|
|
124
|
+
cacheModule = resolve(appRoot, cache.module)
|
|
125
|
+
const rel = relative(appRoot, cacheModule)
|
|
126
|
+
if (isAbsolute(cache.module) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(cacheModule)) {
|
|
127
|
+
throw new BuildError('cache', `cache.module ${cache.module} is not a file inside the app`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : []), ...(cacheModule ? [cacheModule] : [])])
|
|
131
|
+
|
|
132
|
+
// Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
|
|
133
|
+
const cf = opts.target === 'cloudflare' ? await prepareCloudflare(appRoot, cloudflare, { fromSource: opts.wasmFromSource === true, env: process.env }) : null
|
|
118
134
|
|
|
119
135
|
// Sibling of `dist` (same filesystem: the final rename is atomic), fixed name (no random bytes in
|
|
120
136
|
// anything the bundler sees).
|
|
@@ -122,7 +138,7 @@ export async function runBuild(opts: {
|
|
|
122
138
|
rmSync(tmp, { recursive: true, force: true })
|
|
123
139
|
mkdirSync(tmp, { recursive: true })
|
|
124
140
|
try {
|
|
125
|
-
await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, log: opts.log })
|
|
141
|
+
await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, cache, cacheModule, cf, log: opts.log })
|
|
126
142
|
rmSync(join(tmp, '.stage'), { recursive: true, force: true })
|
|
127
143
|
if (existsSync(dist)) {
|
|
128
144
|
const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
|
|
@@ -150,6 +166,10 @@ async function buildInto(
|
|
|
150
166
|
actions: ActionsBuildConfig | null
|
|
151
167
|
/** Absolute path of the actions entry (null when `[actions]` is off). */
|
|
152
168
|
actionsEntry: string | null
|
|
169
|
+
cache: CacheBuildConfig | null
|
|
170
|
+
/** Absolute path of a custom cache adapter module. */
|
|
171
|
+
cacheModule: string | null
|
|
172
|
+
cf: CfPrepared | null
|
|
153
173
|
log: (s: string) => void
|
|
154
174
|
},
|
|
155
175
|
): Promise<void> {
|
|
@@ -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
|
+
}
|
package/src/build/manifest.ts
CHANGED
|
@@ -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`
|
|
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
|
+
}
|