@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The cache adapter at run time, on both hosts: the per-process (native) / per-isolate (Workers)
|
|
2
|
+
// adapter, what `cache.invalidate` does after the tier-1 primitive, and the native worker-0
|
|
3
|
+
// subscriber that replays other processes' invalidations. No `bun` import (CF-bundle safe).
|
|
4
|
+
//
|
|
5
|
+
// L3: a replayed event is applied through the host's REPLAY primitive (napi `cacheReplay`, wasm
|
|
6
|
+
// `core.l2_replay`), never through `cache.invalidate`, so it is never published again.
|
|
7
|
+
import type { CacheAdapter, InvalidateArgs, InvalidationEvent } from './types'
|
|
8
|
+
|
|
9
|
+
export interface CacheRuntime {
|
|
10
|
+
adapter: CacheAdapter
|
|
11
|
+
/** This process / isolate: events it published carry it and are not replayed here. */
|
|
12
|
+
origin: string
|
|
13
|
+
/** Keeps a background promise alive (Workers: `waitUntil`; native: a swallowed floating promise). */
|
|
14
|
+
defer: (p: Promise<unknown>) => void
|
|
15
|
+
/** Bumps a `stats.l2` counter (napi `cacheL2Count` / wasm `rt.l2Count`). */
|
|
16
|
+
count: (name: string) => void
|
|
17
|
+
/** Resolves once the adapter's `init` ran (Workers defer it to the first use). */
|
|
18
|
+
ready?: () => Promise<void>
|
|
19
|
+
/**
|
|
20
|
+
* The host's replay primitive (napi `cacheReplay` / wasm `rt.replay` → `Core::l2_replay`): tier 1
|
|
21
|
+
* again plus the floor at `seq` (`null` = tier 1 only). Never publishes.
|
|
22
|
+
*/
|
|
23
|
+
replay?: (args: InvalidateArgs, seq: number | null) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let current: CacheRuntime | null = null
|
|
27
|
+
|
|
28
|
+
/** Installed at worker boot (native) / `install` (Workers); `null` uninstalls (tests). */
|
|
29
|
+
export function setCacheRuntime(r: CacheRuntime | null): void {
|
|
30
|
+
current = r
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function cacheRuntime(): CacheRuntime | null {
|
|
34
|
+
return current
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const describe = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* After `cache.invalidate`'s tier-1 primitive: the shared-side delete, THEN the publish (ruling
|
|
41
|
+
* L7a: other instances replay only once the store no longer holds the bodies), deferred off the
|
|
42
|
+
* request path; THEN this instance replays its own invalidation once (review F1): a request in the
|
|
43
|
+
* window before the delete landed could re-fill tier 1 from the old L2 body, and its own echo is
|
|
44
|
+
* skipped, so the replay re-clears tier 1 and raises the floor to the publish seq (a re-filled
|
|
45
|
+
* body is older). Never throws, never leaves a rejected promise (a worker-side unhandled rejection
|
|
46
|
+
* is fatal on native). Option values are never logged.
|
|
47
|
+
*/
|
|
48
|
+
export function afterInvalidate(args: InvalidateArgs): void {
|
|
49
|
+
const r = current
|
|
50
|
+
if (!r || (!r.adapter.store && !r.adapter.broadcast)) return
|
|
51
|
+
const { adapter, origin, count } = r
|
|
52
|
+
const task = (async () => {
|
|
53
|
+
await r.ready?.()
|
|
54
|
+
if (adapter.store) {
|
|
55
|
+
try {
|
|
56
|
+
await adapter.store.invalidate(args)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
count('errors')
|
|
59
|
+
console.error(`[brust] cache adapter "${adapter.name}": store.invalidate failed: ${describe(e)}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let seq: number | null = null
|
|
63
|
+
if (adapter.broadcast) {
|
|
64
|
+
try {
|
|
65
|
+
seq = await adapter.broadcast.publish(args, origin)
|
|
66
|
+
count('published')
|
|
67
|
+
} catch (e) {
|
|
68
|
+
count('publish_errors')
|
|
69
|
+
console.error(`[brust] cache adapter "${adapter.name}": publish failed: ${describe(e)}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
r.replay?.(args, typeof seq === 'number' && Number.isSafeInteger(seq) && seq >= 0 ? seq : null)
|
|
74
|
+
} catch (e) {
|
|
75
|
+
count('errors')
|
|
76
|
+
console.error(`[brust] cache adapter "${adapter.name}": own replay failed: ${describe(e)}`)
|
|
77
|
+
}
|
|
78
|
+
})()
|
|
79
|
+
r.defer(task.catch(() => {}))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SubscriberDeps {
|
|
83
|
+
/** napi `cacheReplay(args, seq)`: tier 1 + the floor, never re-published. */
|
|
84
|
+
replay: (args: InvalidateArgs, seq: number) => void
|
|
85
|
+
/** napi `cacheClear()`: both tier-1 caches dropped (a gap). */
|
|
86
|
+
clear: () => void
|
|
87
|
+
count: (name: string) => void
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Native worker 0's push subscriber (R10): first event adopts its seq; `seq <= last` is ignored;
|
|
92
|
+
* `seq > last + 1` clears tier 1 then adopts; `onGap` clears and forgets. Own events advance the
|
|
93
|
+
* seq but are not replayed (tier 1 is already invalidated). Resolves to the unsubscribe.
|
|
94
|
+
*/
|
|
95
|
+
export async function startSubscriber(adapter: CacheAdapter, origin: string, deps: SubscriberDeps): Promise<(() => Promise<void>) | null> {
|
|
96
|
+
const sub = adapter.broadcast?.subscribe
|
|
97
|
+
if (!sub) return null
|
|
98
|
+
let last: number | null = null
|
|
99
|
+
const on = (e: InvalidationEvent) => {
|
|
100
|
+
try {
|
|
101
|
+
if (last !== null && e.seq <= last) return
|
|
102
|
+
if (last !== null && e.seq > last + 1) deps.clear()
|
|
103
|
+
last = e.seq
|
|
104
|
+
if (e.origin === origin) {
|
|
105
|
+
deps.count('skipped_own')
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
deps.replay(e.args, e.seq)
|
|
109
|
+
deps.count('received')
|
|
110
|
+
} catch (err) {
|
|
111
|
+
deps.count('errors')
|
|
112
|
+
console.error(`[brust] cache adapter "${adapter.name}": replay failed: ${describe(err)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const onGap = () => {
|
|
116
|
+
try {
|
|
117
|
+
deps.clear()
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error(`[brust] cache adapter "${adapter.name}": clear failed: ${describe(err)}`)
|
|
120
|
+
}
|
|
121
|
+
last = null
|
|
122
|
+
}
|
|
123
|
+
return sub.call(adapter.broadcast, on, onGap)
|
|
124
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The cache adapter contract (docs/design/2026-10-12-cache-adapter-design.md §3.1): a shared second
|
|
2
|
+
// tier behind brust's in-process cache. An adapter has two optional capabilities — a `store` of
|
|
3
|
+
// rendered pages and a `broadcast` of invalidations (push `subscribe` or pull `since`) — so a store
|
|
4
|
+
// without a channel (KV) can be composed with another adapter's broadcast (DO).
|
|
5
|
+
//
|
|
6
|
+
// One sequence space per adapter: every `stamp`, `entryStamp` and event `seq` is a number in its
|
|
7
|
+
// broadcast's sequence (a store without a broadcast reports `null` stamps and its writes are unguarded).
|
|
8
|
+
import type { InvalidateArgs } from '../native'
|
|
9
|
+
|
|
10
|
+
export type { InvalidateArgs }
|
|
11
|
+
|
|
12
|
+
/** A rendered page: the identity body and its validated headers, in order (repeats kept). */
|
|
13
|
+
export interface StoredPage {
|
|
14
|
+
status: number
|
|
15
|
+
headers: [string, string][]
|
|
16
|
+
body: Uint8Array
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SetMeta {
|
|
20
|
+
ttlMs: number
|
|
21
|
+
tags: string[]
|
|
22
|
+
method: string
|
|
23
|
+
path: string
|
|
24
|
+
/** The `stamp` the lookup returned (read before the render); `null` = unguarded write. */
|
|
25
|
+
stamp: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GetResult {
|
|
29
|
+
page: StoredPage | null
|
|
30
|
+
/** The broadcast seq NOW: the write-guard base of the set that may follow this miss. */
|
|
31
|
+
stamp: number | null
|
|
32
|
+
/** The stamp the entry was written under (`SetMeta.stamp`); the invalidation floor compares it. */
|
|
33
|
+
entryStamp: number | null
|
|
34
|
+
/** When the entry was written (ms since the epoch); `null` = unknown (full ttl). */
|
|
35
|
+
storedAt: number | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CacheStore {
|
|
39
|
+
get(key: string): Promise<GetResult>
|
|
40
|
+
/**
|
|
41
|
+
* `false` = refused: the stamp is older than an invalidation of one of `meta.tags` or of
|
|
42
|
+
* `meta.method meta.path` (a store with atomic writes MUST refuse it), or the body is over `maxBodyBytes`.
|
|
43
|
+
*/
|
|
44
|
+
set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean>
|
|
45
|
+
/**
|
|
46
|
+
* Shared-side delete by tags/path (`key` is a no-op: the store holds pages only). Records the
|
|
47
|
+
* invalidation marker BEFORE deleting, so a concurrent stale `set` is refused even before the
|
|
48
|
+
* publish (ruling L7b).
|
|
49
|
+
*/
|
|
50
|
+
invalidate(args: InvalidateArgs): Promise<void>
|
|
51
|
+
readonly maxBodyBytes: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InvalidationEvent {
|
|
55
|
+
seq: number
|
|
56
|
+
origin: string
|
|
57
|
+
args: InvalidateArgs
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EpochSince {
|
|
61
|
+
/** The broadcast's generation: a change means its history is gone (every reader clears). */
|
|
62
|
+
gen: string
|
|
63
|
+
/** The latest seq. */
|
|
64
|
+
seq: number
|
|
65
|
+
/** The reader is further behind than the retained history: it must clear. */
|
|
66
|
+
behind: boolean
|
|
67
|
+
/** Every retained event with a seq above the one asked for, in order. */
|
|
68
|
+
events: InvalidationEvent[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CacheBroadcast {
|
|
72
|
+
/** Append an invalidation; resolves to the seq it was assigned. */
|
|
73
|
+
publish(args: InvalidateArgs, origin: string): Promise<number>
|
|
74
|
+
/** Push: events in order; `onGap` when delivery may have lost some. Resolves to an unsubscribe. */
|
|
75
|
+
subscribe?(on: (e: InvalidationEvent) => void, onGap: () => void): Promise<() => Promise<void>>
|
|
76
|
+
/** Pull: the events after `seq` (Workers: the only mode, no connection outlives a request). */
|
|
77
|
+
since?(seq: number): Promise<EpochSince>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CacheAdapterInit {
|
|
81
|
+
namespace: string
|
|
82
|
+
buildId: string
|
|
83
|
+
runtime: 'bun' | 'workers'
|
|
84
|
+
workerId: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CacheAdapter {
|
|
88
|
+
readonly name: string
|
|
89
|
+
readonly runtimes: ReadonlyArray<'bun' | 'workers'>
|
|
90
|
+
store?: CacheStore
|
|
91
|
+
broadcast?: CacheBroadcast
|
|
92
|
+
init?(ctx: CacheAdapterInit): Promise<void>
|
|
93
|
+
close?(): Promise<void>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type CacheAdapterFactory = (options: Record<string, unknown>) => CacheAdapter
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The cache adapter inside a Workers isolate (cache adapters R11, R14): `install(rt, ext)` builds the
|
|
2
|
+
// adapter, wires `cache.invalidate`'s publish through `waitUntil`, and answers the wasm side's bridge
|
|
3
|
+
// (`crates/brust-wasm/src/host.rs` `CacheBridge`). No `bun` import (it is bundled into brust-app.js).
|
|
4
|
+
import { checkRuntime, compose, instantiate } from './load'
|
|
5
|
+
import { setCacheRuntime } from './runtime'
|
|
6
|
+
import type { CacheAdapter, InvalidateArgs, SetMeta } from './types'
|
|
7
|
+
|
|
8
|
+
/** What the wasm side passes to `install` (`bridge.rs`). */
|
|
9
|
+
export interface CfRuntimeBase {
|
|
10
|
+
l2Count?(name: string): boolean
|
|
11
|
+
/** `Core::l2_replay`: tier 1 + the floor at `seq`, never published (review F1's own replay). */
|
|
12
|
+
replay?(args: InvalidateArgs, seq: number | null): unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `CONFIG.cache` (build/cf-entry.ts), as the entry passes it. */
|
|
16
|
+
export interface CfCacheConfig {
|
|
17
|
+
adapter: string
|
|
18
|
+
getTimeoutMs: number
|
|
19
|
+
maxBodyBytes: number
|
|
20
|
+
namespace: string
|
|
21
|
+
buildId: string
|
|
22
|
+
epochIntervalMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The generated entry's second `install` argument. */
|
|
26
|
+
export interface CfInstallExt {
|
|
27
|
+
/** The adapter module's namespace, default export, or factory. */
|
|
28
|
+
adapter: unknown
|
|
29
|
+
options: Record<string, unknown>
|
|
30
|
+
/** A composed built-in broadcast (`[cache] broadcast`), same forms as `adapter`. */
|
|
31
|
+
broadcast?: unknown
|
|
32
|
+
broadcastOptions?: Record<string, unknown>
|
|
33
|
+
config: CfCacheConfig
|
|
34
|
+
waitUntil: (p: Promise<unknown>) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The wasm side's view: `get`/`set` only with a store, `since` only with a pull broadcast. */
|
|
38
|
+
export interface CfCacheBridge {
|
|
39
|
+
origin: string
|
|
40
|
+
get?: (key: string) => Promise<{ json: string; body: Uint8Array | null }>
|
|
41
|
+
set?: (key: string, json: string, body: Uint8Array) => void
|
|
42
|
+
since?: (seq: number) => Promise<string>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const describe = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
|
46
|
+
|
|
47
|
+
/** Throws (a TypeError → the Worker refuses to boot, lane-3 R11) on a bad adapter or a push-only broadcast. */
|
|
48
|
+
export function installCache(rt: CfRuntimeBase, ext: CfInstallExt): CfCacheBridge {
|
|
49
|
+
const { config: c, waitUntil } = ext
|
|
50
|
+
let a: CacheAdapter = instantiate(ext.adapter, ext.options, `"${c.adapter}"`)
|
|
51
|
+
if (ext.broadcast !== undefined) {
|
|
52
|
+
const other = instantiate(ext.broadcast, ext.broadcastOptions ?? {}, 'broadcast')
|
|
53
|
+
if (!other.broadcast) throw new TypeError(`cache adapter "${other.name}" has no broadcast to compose`)
|
|
54
|
+
a = compose(a, other)
|
|
55
|
+
}
|
|
56
|
+
checkRuntime(a, 'workers')
|
|
57
|
+
const count = (name: string) => void rt.l2Count?.(name)
|
|
58
|
+
// `init` may do I/O, which a Worker refuses at start: it runs on the first use, once.
|
|
59
|
+
let ready: Promise<void> | null = null
|
|
60
|
+
const ensure = () => (ready ??= Promise.resolve(a.init?.({ namespace: c.namespace, buildId: c.buildId, runtime: 'workers', workerId: 0 })))
|
|
61
|
+
// A Worker may not draw random values at start (global scope): the origin is made on first read.
|
|
62
|
+
let origin: string | null = null
|
|
63
|
+
const getOrigin = () => (origin ??= crypto.randomUUID())
|
|
64
|
+
setCacheRuntime({
|
|
65
|
+
adapter: a,
|
|
66
|
+
get origin() {
|
|
67
|
+
return getOrigin()
|
|
68
|
+
},
|
|
69
|
+
defer: (p) => waitUntil(p),
|
|
70
|
+
count,
|
|
71
|
+
ready: ensure,
|
|
72
|
+
replay: (args, seq) => void rt.replay?.(args, seq),
|
|
73
|
+
})
|
|
74
|
+
const store = a.store
|
|
75
|
+
const since = a.broadcast?.since?.bind(a.broadcast)
|
|
76
|
+
return {
|
|
77
|
+
get origin() {
|
|
78
|
+
return getOrigin()
|
|
79
|
+
},
|
|
80
|
+
...(store
|
|
81
|
+
? {
|
|
82
|
+
async get(key: string) {
|
|
83
|
+
try {
|
|
84
|
+
await ensure()
|
|
85
|
+
const r = await store.get(key)
|
|
86
|
+
if (!r.page || r.page.body.length > c.maxBodyBytes) {
|
|
87
|
+
if (r.page) count('errors')
|
|
88
|
+
return { json: JSON.stringify({ hit: false, stamp: r.stamp }), body: null }
|
|
89
|
+
}
|
|
90
|
+
const p = r.page
|
|
91
|
+
return { json: JSON.stringify({ hit: true, status: p.status, headers: p.headers, stamp: r.stamp, entryStamp: r.entryStamp, storedAt: r.storedAt }), body: p.body }
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { json: JSON.stringify({ error: describe(e) }), body: null }
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
set(key: string, json: string, body: Uint8Array) {
|
|
97
|
+
const task = (async () => {
|
|
98
|
+
const { status, headers, meta } = JSON.parse(json) as { status: number; headers: [string, string][]; meta: SetMeta }
|
|
99
|
+
await ensure()
|
|
100
|
+
count((await store.set(key, { status, headers, body }, meta)) ? 'sets' : 'set_refused')
|
|
101
|
+
})().catch((e) => {
|
|
102
|
+
count('errors')
|
|
103
|
+
console.error(`[brust] cache adapter "${a.name}": set failed: ${describe(e)}`)
|
|
104
|
+
})
|
|
105
|
+
waitUntil(task)
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
109
|
+
...(since
|
|
110
|
+
? {
|
|
111
|
+
async since(seq: number) {
|
|
112
|
+
await ensure()
|
|
113
|
+
return JSON.stringify(await since(seq))
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/cache.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Component-level cache declaration (S4 / §5). The compiler reads `cache(Comp, opts)` statically;
|
|
2
2
|
// at runtime it is identity. `cache.invalidate` drops L1 pages and job-cache entries in the
|
|
3
|
-
// running server (napi `cacheInvalidate`)
|
|
3
|
+
// running server (napi `cacheInvalidate`), then — with a cache adapter — the shared store and the
|
|
4
|
+
// broadcast to every other instance (deferred, `cache-adapter/runtime.ts`).
|
|
5
|
+
import { afterInvalidate } from './cache-adapter/runtime'
|
|
4
6
|
import { cacheComponent } from './cache-core'
|
|
5
7
|
import { type InvalidateArgs, type InvalidateResult, cacheInvalidate } from './native'
|
|
6
8
|
|
|
@@ -19,6 +21,7 @@ function invalidate(args: InvalidateArgs): InvalidateResult {
|
|
|
19
21
|
throw new Error(`${NO_SERVER}: ${msg}`)
|
|
20
22
|
throw e
|
|
21
23
|
}
|
|
24
|
+
afterInvalidate(args)
|
|
22
25
|
return { l1Removed: r.l1Removed, jobRemoved: r.jobRemoved }
|
|
23
26
|
}
|
|
24
27
|
|
package/src/cf.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// `@brust/core` under the `workerd` export condition (M4-CF, ruling c30e2633): the app-facing API a Worker can run.
|
|
2
|
+
// Same names as `index.ts` minus what needs a host process: no `run`, no `loadConfig`. `cache.invalidate` goes through
|
|
3
|
+
// the CF bundle's napi stub to the runtime the wasm glue installed at start (`server-handlers` `install`).
|
|
4
|
+
export { cache } from './cache'
|
|
5
|
+
export type { ComponentCacheOptions, InvalidateArgs, InvalidateResult } from './cache'
|
|
6
|
+
export {
|
|
7
|
+
ALLOWED_ROUTE_FIELDS,
|
|
8
|
+
BrustRouteError,
|
|
9
|
+
defineRoutes,
|
|
10
|
+
flattenRoutes,
|
|
11
|
+
httpError,
|
|
12
|
+
isHttpErrorTrigger,
|
|
13
|
+
isVerdict,
|
|
14
|
+
notFound,
|
|
15
|
+
Outlet,
|
|
16
|
+
redirect,
|
|
17
|
+
} from './routes'
|
|
18
|
+
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
|
package/src/cli.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// Exit codes: 0 ok, 1 build/boot error (`error <rule> <message>` / `[brust] …`), 2 usage.
|
|
4
4
|
import { parseArgs } from 'node:util'
|
|
5
5
|
import { BuildError, runBuild } from './build'
|
|
6
|
+
import { runDev } from './dev'
|
|
6
7
|
import { run } from './run'
|
|
7
8
|
|
|
8
9
|
const USAGE = `usage:
|
|
9
|
-
brust build [entry=routes.tsx] [--out-dir dist] [--force]
|
|
10
|
+
brust build [entry=routes.tsx] [--out-dir dist] [--force] [--target bun|cloudflare] [--wasm-from-source]
|
|
11
|
+
brust dev [entry=routes.tsx] --target cloudflare [--out-dir dist] [--port N] [--wasm-from-source] [-- <wrangler args>]
|
|
10
12
|
brust start [--port N] [--workers N] [--dist-dir dist] [--entry routes.tsx]
|
|
11
13
|
|
|
12
14
|
Config precedence for start: env (BRUST_ADDR, BRUST_PORT, BRUST_WORKERS, BRUST_RENDER_SLOTS,
|
|
@@ -29,7 +31,10 @@ function intFlag(name: string, v: string | undefined, min: number): number | und
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export async function main(argv: string[]): Promise<void> {
|
|
32
|
-
const [cmd, ...
|
|
34
|
+
const [cmd, ...all] = argv
|
|
35
|
+
const dd = cmd === 'dev' ? all.indexOf('--') : -1
|
|
36
|
+
const rest = dd < 0 ? all : all.slice(0, dd)
|
|
37
|
+
const passthrough = dd < 0 ? [] : all.slice(dd + 1)
|
|
33
38
|
if (cmd === undefined || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
34
39
|
process.stdout.write(USAGE)
|
|
35
40
|
process.exit(cmd === undefined ? 2 : 0)
|
|
@@ -46,6 +51,8 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
46
51
|
port: { type: 'string' },
|
|
47
52
|
workers: { type: 'string' },
|
|
48
53
|
force: { type: 'boolean' },
|
|
54
|
+
target: { type: 'string' },
|
|
55
|
+
'wasm-from-source': { type: 'boolean' },
|
|
49
56
|
help: { type: 'boolean', short: 'h' },
|
|
50
57
|
},
|
|
51
58
|
})
|
|
@@ -58,6 +65,8 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
58
65
|
process.exit(0)
|
|
59
66
|
}
|
|
60
67
|
const str = (k: string) => values[k] as string | undefined
|
|
68
|
+
const target = str('target')
|
|
69
|
+
if (target !== undefined && target !== 'bun' && target !== 'cloudflare') usage(`--target must be bun or cloudflare (got ${JSON.stringify(target)})`)
|
|
61
70
|
|
|
62
71
|
if (cmd === 'build') {
|
|
63
72
|
if (positionals.length > 1) usage(`build takes one entry (got ${positionals.join(' ')})`)
|
|
@@ -68,6 +77,8 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
68
77
|
entry: positionals[0] ?? 'routes.tsx',
|
|
69
78
|
outDir: str('out-dir') ?? 'dist',
|
|
70
79
|
force: values.force === true,
|
|
80
|
+
target: target as 'bun' | 'cloudflare' | undefined,
|
|
81
|
+
wasmFromSource: values['wasm-from-source'] === true,
|
|
71
82
|
log: (s) => console.log(s),
|
|
72
83
|
})
|
|
73
84
|
} catch (e) {
|
|
@@ -79,6 +90,28 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
79
90
|
console.log(`[brust] built ${str('out-dir') ?? 'dist'} in ${Math.round(performance.now() - t0)}ms`)
|
|
80
91
|
process.exit(0)
|
|
81
92
|
}
|
|
93
|
+
if (cmd === 'dev') {
|
|
94
|
+
if (positionals.length > 1) usage(`dev takes one entry (got ${positionals.join(' ')})`)
|
|
95
|
+
if (target !== 'cloudflare') usage('brust dev: only --target cloudflare in this release')
|
|
96
|
+
const port = intFlag('port', str('port'), 0)
|
|
97
|
+
try {
|
|
98
|
+
process.exit(
|
|
99
|
+
await runDev({
|
|
100
|
+
appRoot: process.cwd(),
|
|
101
|
+
entry: positionals[0] ?? 'routes.tsx',
|
|
102
|
+
outDir: str('out-dir') ?? 'dist',
|
|
103
|
+
...(port !== undefined && { port }),
|
|
104
|
+
wranglerArgs: passthrough,
|
|
105
|
+
fromSource: values['wasm-from-source'] === true,
|
|
106
|
+
log: (s) => console.log(s),
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e instanceof BuildError) console.error(`error ${e.rule} ${e.message}`)
|
|
111
|
+
else console.error(`error dev ${(e as Error).stack ?? String(e)}`)
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
82
115
|
if (cmd === 'start') {
|
|
83
116
|
if (positionals.length > 0) usage(`start takes no positional arguments (got ${positionals.join(' ')})`)
|
|
84
117
|
const port = intFlag('port', str('port'), 0)
|