@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/src/worker.ts CHANGED
@@ -1,78 +1,17 @@
1
1
  // The Bun side of contract 7 (`crates/brust-server/src/protocol.rs`): requests arrive as inline
2
2
  // JSON, responses go into this worker's SharedArrayBuffer slot (SAB rule, `dispatch.rs`). A
3
3
  // handler NEVER rejects across the tsfn: every failure becomes `{error}` / `{id, error}`.
4
- import { join, resolve } from 'node:path'
4
+ import { dirname, join, resolve } from 'node:path'
5
5
  import { workerData } from 'node:worker_threads'
6
- import { runFetch, setActionsApp } from './actions-bridge'
7
- import { planJobs, registerWorker } from './native'
8
- import { installServerResolver, type StoreSnapshot } from './store/core-adapter'
9
- import { collectSnapshot, getServerInstance, runInStoreContext, touchStores } from './store/server-context'
10
- import { type FlatRoute, flattenRoutes, isHttpErrorTrigger, isVerdict, type LoaderReq, type Route, type Verdict } from './routes'
6
+ import { type HeapIo, runFetch, setActionsApp, setActionsLimits } from './actions-bridge'
7
+ import { DEFAULT_MAX_RESPONSE_BYTES } from './actions/prefix'
8
+ import { type CacheBridgeDeps, runCache } from './cache-adapter/bridge'
9
+ import type { CacheManifest } from './cache-adapter/load'
10
+ import { type Handlers, type JobsModule, type ManifestLike, makeHandlers, toJson } from './handlers'
11
+ import { cacheClear, cacheL2Count, cacheReplay, planJobs, putFetchReply, registerWorker, takeFetchBody } from './native'
12
+ import { flattenRoutes, type Route } from './routes'
11
13
 
12
- installServerResolver(getServerInstance)
13
-
14
- // ---- wire shapes (protocol.rs, camelCase) ----
15
-
16
- export interface LoaderRequest {
17
- routeId: string
18
- params: Record<string, string>
19
- path: string
20
- req: LoaderReq
21
- /** M3-P P6: the chain has jobs — plan them in this call (`planJobs`). Optional to honour. */
22
- plan?: boolean
23
- }
24
-
25
- export type LoaderResponse =
26
- | { ok: true; data: Record<string, unknown>; headers?: Record<string, string>; store?: StoreSnapshot | null }
27
- | { verdict: 'notFound'; data: unknown; store?: StoreSnapshot | null }
28
- | { verdict: 'redirect'; location: string; status: number }
29
- | { verdict: 'httpError'; status: number; body: string }
30
- | { error: string }
31
-
32
- export type JobKind = 'precompute' | 'ssr'
33
-
34
- export interface JobCall {
35
- /** Opaque, echoed back: `<componentId>/<jobId>[/<row>]` for a chain job,
36
- * `<parentId>/<childId>_<k>/<jobId>[/<row>]` for an inlined child instance (pipeline.rs `plan_one`). */
37
- id: string
38
- componentId: string
39
- kind: JobKind
40
- // biome-ignore lint/suspicious/noExplicitAny: job inputs are arbitrary JSON
41
- inputs: any
42
- /** ssr: the react component to render (D6). */
43
- target?: string
44
- row?: number
45
- }
46
-
47
- export interface JobsRequest {
48
- jobs: JobCall[]
49
- }
50
-
51
- export type JobResult = { id: string; value: unknown } | { id: string; error: string }
52
-
53
- export interface JobsResponse {
54
- results: JobResult[]
55
- }
56
-
57
- /** The default export of `<dist>/<manifest.jobs_module>` (built by Task 6): component id → job fns. */
58
- // biome-ignore lint/suspicious/noExplicitAny: props are whatever the component takes
59
- export type JobsModule = Record<string, { precompute?: (props: any) => unknown; ssr?: (props: any) => string | Promise<string> }>
60
-
61
- /** The slice of `manifest.json` the worker reads: ssr job `literals` (S6 amendment). */
62
- export interface ManifestLike {
63
- routes?: { id: string; chain: string[] }[]
64
- components: Record<
65
- string,
66
- {
67
- jobs: { id: string; kind?: string; target?: string; literals?: Record<string, unknown> }[]
68
- children?: { id: string }[]
69
- stores?: string[]
70
- }
71
- >
72
- jobs_module?: string
73
- /** `brust build` (M3-F A3): the actions entry, relative to dist. Absent/null = no actions. */
74
- actions?: { entry: string; prefix: string; maxBodyBytes: number } | null
75
- }
14
+ export * from './handlers'
76
15
 
77
16
  // ---- SAB slot writer ----
78
17
 
@@ -102,165 +41,6 @@ export type PlanJobs = (slot: number, len: number) => string
102
41
  export const PLAN_DECLINED = '!'
103
42
  const PLANNED = { planned: true } as const
104
43
 
105
- function toJson(out: unknown): string {
106
- try {
107
- return JSON.stringify(out)
108
- } catch (e) {
109
- return JSON.stringify({ error: `response not serialisable: ${String(e)}` })
110
- }
111
- }
112
-
113
- // ---- literals (S6 amendment: the worker merges an ssr job's `literals` over `call.inputs`) ----
114
-
115
- /** `"<componentId>/<jobId>"` → that job record's `literals` (only records that carry some). */
116
- export function literalsIndex(manifest: ManifestLike | undefined): Map<string, Record<string, unknown>> {
117
- const out = new Map<string, Record<string, unknown>>()
118
- for (const [cid, c] of Object.entries(manifest?.components ?? {}))
119
- for (const j of c.jobs ?? []) if (j.literals && Object.keys(j.literals).length > 0) out.set(`${cid}/${j.id}`, j.literals)
120
- return out
121
- }
122
-
123
- /** The manifest job id of a call: the segment after `componentId` in a chain id
124
- * (`<cid>/<jobId>[/<row>]`), else the third segment of a child-instance id
125
- * (`<parentId>/<cid>_<k>/<jobId>[/<row>]`). */
126
- export function jobIdOf(call: Pick<JobCall, 'id' | 'componentId'>): string | undefined {
127
- const parts = call.id.split('/')
128
- return parts[0] === call.componentId ? parts[1] : parts[2]
129
- }
130
-
131
- // ---- touched stores (M3-F D4) ----
132
-
133
- /** route id -> sorted unique union of `stores` over the route's chain, walking each component's
134
- * `children[].id` and `jobs[].target` transitively. Routes with an empty union are absent. */
135
- export function routeStoresIndex(manifest: ManifestLike | undefined): Map<string, string[]> {
136
- const out = new Map<string, string[]>()
137
- const comps = manifest?.components ?? {}
138
- for (const r of manifest?.routes ?? []) {
139
- const acc = new Set<string>()
140
- const seen = new Set<string>()
141
- const walk = (id: string) => {
142
- if (seen.has(id)) return
143
- seen.add(id)
144
- const c = comps[id]
145
- if (!c) return
146
- for (const p of c.stores ?? []) acc.add(p)
147
- for (const k of c.children ?? []) walk(k.id)
148
- for (const j of c.jobs ?? []) if (j.target) walk(j.target)
149
- }
150
- for (const id of r.chain ?? []) walk(id)
151
- if (acc.size > 0) out.set(r.id, [...acc].sort())
152
- }
153
- return out
154
- }
155
-
156
- /** R1: `store` is assigned only when a store was touched; absent otherwise. */
157
- function withStore<T extends LoaderResponse>(r: T): T {
158
- const s = collectSnapshot()
159
- if (s) (r as { store?: StoreSnapshot | null }).store = s
160
- return r
161
- }
162
-
163
- // ---- handlers ----
164
-
165
- /** `parent`: the data the chain merged before the verdict. A notFound renders the route's
166
- * template (layouts included), so the layouts' data stays under the verdict's own keys. */
167
- function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderResponse {
168
- if (v.status === 404) {
169
- const own = v.data ?? {}
170
- return { verdict: 'notFound', data: typeof own === 'object' && !Array.isArray(own) ? { ...parent, ...own } : own }
171
- }
172
- return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
173
- }
174
-
175
- export interface Handlers {
176
- loader(req: LoaderRequest): Promise<LoaderResponse>
177
- jobs(req: JobsRequest): Promise<JobsResponse>
178
- }
179
-
180
- /** `leaves` from `flattenRoutes` (ids must match the manifest's route ids); `jobs` the jobs
181
- * module; `manifest` supplies ssr `literals` (omit → no merge). */
182
- export function makeHandlers(opts: {
183
- leaves: FlatRoute[]
184
- jobs: JobsModule
185
- manifest?: ManifestLike
186
- /** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
187
- stores?: Record<string, object>
188
- }): Handlers {
189
- const byId = new Map(opts.leaves.map((l) => [l.id, l]))
190
- const literals = literalsIndex(opts.manifest)
191
- const jobs = opts.jobs
192
- const storeIdx = routeStoresIndex(opts.manifest)
193
- const storeTable = opts.stores ?? {}
194
-
195
- async function runJob(call: JobCall): Promise<JobResult> {
196
- const owner = call.kind === 'ssr' ? (call.target ?? call.componentId) : call.componentId // D6
197
- const fn = jobs[owner]?.[call.kind]
198
- if (typeof fn !== 'function') return { id: call.id, error: `no ${call.kind} job for component ${owner}` }
199
- try {
200
- let props = call.inputs
201
- if (call.kind === 'ssr') {
202
- const jid = jobIdOf(call)
203
- const lit = jid === undefined ? undefined : literals.get(`${call.componentId}/${jid}`)
204
- if (lit) props = { ...(props ?? {}), ...lit }
205
- // Precompute runs outside any scope (D8); ssr runs in a scope seeded from `__store`, which props never see.
206
- const seed = (props?.__store ?? null) as StoreSnapshot | null
207
- if (props && '__store' in props) {
208
- const { __store: _, ...rest } = props
209
- props = rest
210
- }
211
- const p = props
212
- return { id: call.id, value: await runInStoreContext(() => fn(p), seed) }
213
- }
214
- return { id: call.id, value: await fn(props) }
215
- } catch (e) {
216
- return { id: call.id, error: String(e) }
217
- }
218
- }
219
-
220
- return {
221
- // 0.1.x `runNativeChainLoaders`: root → leaf, flat merge (later keys win), first verdict stops
222
- // (a notFound keeps what the chain merged so far: verdictJson).
223
- async loader(req) {
224
- const leaf = byId.get(req.routeId)
225
- if (!leaf) return { error: `unknown routeId ${req.routeId}` }
226
- // One object for the whole chain: each level's keys are assigned INTO it (later keys win,
227
- // first-seen key order — what `{...merged, ...r}` produced) instead of re-spreading every
228
- // key once per level (M3-P P1). `merged` is fresh per call; a loader's own object is never
229
- // written to. Null prototype: with no `__proto__` setter to hit, an own "__proto__" key in a
230
- // loader's data is assigned as data, as the spread copied it.
231
- return runInStoreContext(async () => {
232
- const merged: Record<string, unknown> = Object.create(null)
233
- try {
234
- const touch = storeIdx.get(req.routeId)
235
- if (touch) touchStores(touch, storeTable)
236
- for (const node of leaf.chain) {
237
- if (!node.loader) continue
238
- let r: unknown
239
- try {
240
- r = await node.loader({ params: req.params, path: req.path, req: req.req })
241
- } catch (e) {
242
- if (isHttpErrorTrigger(e)) return { verdict: 'httpError', status: e.status, body: e.body } as LoaderResponse
243
- throw e
244
- }
245
- if (isVerdict(r)) {
246
- const v = verdictJson(r, merged)
247
- return 'verdict' in v && v.verdict === 'notFound' ? withStore(v) : v
248
- }
249
- if (r && typeof r === 'object') Object.assign(merged, r)
250
- }
251
- return withStore({ ok: true, data: merged } as LoaderResponse)
252
- } catch (e) {
253
- return { error: String(e) } as LoaderResponse
254
- }
255
- })
256
- },
257
- // One request carries every job of the page; a per-job failure keeps the other results.
258
- async jobs(req) {
259
- return { results: await Promise.all((req.jobs ?? []).map(runJob)) }
260
- },
261
- }
262
- }
263
-
264
44
  /** The tsfn callback for `registerWorker`. Never rejects: a parse error, an unknown kind or a
265
45
  * throwing handler is written to the slot as `{error}`. A loader request with `plan: true` whose
266
46
  * chain answered `ok` is planned in the same call (`plan`, the napi `planJobs` by default — the
@@ -268,6 +48,7 @@ export function makeHandlers(opts: {
268
48
  export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan: PlanJobs = planJobs) {
269
49
  return async (kind: string, requestJson: string, slot: number): Promise<number> => {
270
50
  if (kind === 'fetch') return runFetch(requestJson, view, slot, slots)
51
+ if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo, cacheDeps)
271
52
  let out: unknown
272
53
  try {
273
54
  const req = JSON.parse(requestJson)
@@ -291,6 +72,31 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
291
72
  }
292
73
  }
293
74
 
75
+ // ---- cache adapter (the `cache` call kind) ----
76
+
77
+ const cacheHeapIo: HeapIo = { takeBody: (slot) => takeFetchBody(slot), putReply: (slot, body) => putFetchReply(slot, body) }
78
+ const count = (name: string) => void cacheL2Count(name)
79
+ let cacheDeps: CacheBridgeDeps = { store: undefined, getTimeoutMs: 50, maxBodyBytes: 1_048_576, count }
80
+
81
+ /** The `cache` call's store (tests; `startWorker` sets it from `manifest.cache`). */
82
+ export function setCacheBridge(d: CacheBridgeDeps): void {
83
+ cacheDeps = d
84
+ }
85
+
86
+ /** Load `manifest.cache`'s adapter in this worker (R8): a custom module from dist, a built-in from the
87
+ * app root; `init`, then `cache.invalidate` publishes through it. Worker 0 also subscribes after
88
+ * registering (R10). */
89
+ async function startCacheAdapter(m: CacheManifest, dist: string, appDir: string, workerId: number) {
90
+ const { loadAdapter } = await import('./cache-adapter/load')
91
+ const { setCacheRuntime } = await import('./cache-adapter/runtime')
92
+ const adapter = await loadAdapter(m, 'bun', (spec, builtin) => import(builtin ? Bun.resolveSync(spec, appDir) : join(dist, spec)), process.env)
93
+ await adapter.init?.({ namespace: m.namespace, buildId: m.buildId, runtime: 'bun', workerId })
94
+ const origin = process.env.BRUST_CACHE_ORIGIN || crypto.randomUUID()
95
+ setCacheRuntime({ adapter, origin, defer: (p) => void p.catch(() => {}), count, replay: (args, seq) => void cacheReplay(args, seq) })
96
+ setCacheBridge({ store: adapter.store, getTimeoutMs: m.getTimeoutMs, maxBodyBytes: m.maxBodyBytes, count })
97
+ return { adapter, origin }
98
+ }
99
+
294
100
  // ---- worker entry ----
295
101
 
296
102
  export const SLOT_BYTES = 256 * 1024
@@ -317,7 +123,7 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
317
123
  const dist = resolve(env.BRUST_DIST_DIR ?? 'dist')
318
124
  const entry = env.BRUST_APP_ENTRY
319
125
  if (!entry) throw new Error('BRUST_APP_ENTRY is not set')
320
- const manifest = (await Bun.file(join(dist, 'manifest.json')).json()) as ManifestLike
126
+ const manifest = (await Bun.file(join(dist, 'manifest.json')).json()) as ManifestLike & { cache?: CacheManifest | null }
321
127
  const app = await import(resolve(entry))
322
128
  const routes = (app.routes ?? app.default) as Route[] | undefined
323
129
  if (!Array.isArray(routes)) throw new Error(`${entry} exports no routes (export const routes or default)`)
@@ -327,7 +133,10 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
327
133
  // Lazy: elysia is an optional peer, so an app without [actions] must boot without it installed (R7).
328
134
  const { loadActionsApp } = await import('./actions/load')
329
135
  setActionsApp(await loadActionsApp(join(dist, manifest.actions.entry), manifest.actions.prefix))
136
+ setActionsLimits({ maxResponseBytes: manifest.actions.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES })
330
137
  }
138
+ const workerId = Number.parseInt(env.BRUST_WORKER_ID ?? '0', 10) || 0
139
+ const l2 = manifest.cache ? await startCacheAdapter(manifest.cache, dist, dirname(resolve(entry)), workerId) : null
331
140
  sab = opts.sab ?? new SharedArrayBuffer(SLOT_BYTES * slots)
332
141
  view = new Uint8Array(sab)
333
142
  const handlers = makeHandlers({
@@ -336,7 +145,12 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
336
145
  manifest,
337
146
  stores: (jobsMod.stores ?? {}) as Record<string, object>,
338
147
  })
339
- return registerWorker(view, slots, makeDispatch(handlers, view, slots))
148
+ const id = registerWorker(view, slots, makeDispatch(handlers, view, slots))
149
+ if (l2 && env.BRUST_WORKER_ID === '0') {
150
+ const { startSubscriber } = await import('./cache-adapter/runtime')
151
+ await startSubscriber(l2.adapter, l2.origin, { replay: (args, seq) => void cacheReplay(args, seq), clear: () => void cacheClear(), count })
152
+ }
153
+ return id
340
154
  }
341
155
 
342
156
  // Auto-start only inside a Bun `Worker` spawned by `run()` (BRUST_WORKER_ID set). Not