@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.
@@ -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, ...rest] = argv
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)