@brust/core 2.0.0-alpha.2 → 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 +28 -0
- package/native/index.js +57 -54
- package/package.json +14 -8
- package/src/actions-bridge.ts +27 -9
- 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 +213 -4
- package/src/build/index.ts +75 -7
- package/src/build/manifest.ts +18 -0
- 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 +20 -1
- package/src/run.ts +6 -1
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +43 -231
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The host-agnostic half of the worker (M4-CF lane 4): wire shapes + `makeHandlers`. No Bun, no
|
|
2
|
+
// napi, no `node:*` — the Cloudflare entry imports it through `@brust/core/server-handlers`.
|
|
3
|
+
// `worker.ts` re-exports everything here, so existing importers are unchanged.
|
|
4
|
+
import { installServerResolver, type StoreSnapshot } from './store/core-adapter'
|
|
5
|
+
import { collectSnapshot, getServerInstance, runInStoreContext, touchStores } from './store/server-context'
|
|
6
|
+
import { type FlatRoute, isHttpErrorTrigger, isVerdict, type LoaderReq, type Route, type Verdict } from './routes'
|
|
7
|
+
|
|
8
|
+
installServerResolver(getServerInstance)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
// ---- wire shapes (protocol.rs, camelCase) ----
|
|
12
|
+
|
|
13
|
+
export interface LoaderRequest {
|
|
14
|
+
routeId: string
|
|
15
|
+
params: Record<string, string>
|
|
16
|
+
path: string
|
|
17
|
+
req: LoaderReq
|
|
18
|
+
/** M3-P P6: the chain has jobs — plan them in this call (`planJobs`). Optional to honour. */
|
|
19
|
+
plan?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type LoaderResponse =
|
|
23
|
+
| { ok: true; data: Record<string, unknown>; headers?: Record<string, string>; store?: StoreSnapshot | null }
|
|
24
|
+
| { verdict: 'notFound'; data: unknown; store?: StoreSnapshot | null }
|
|
25
|
+
| { verdict: 'redirect'; location: string; status: number }
|
|
26
|
+
| { verdict: 'httpError'; status: number; body: string }
|
|
27
|
+
| { error: string }
|
|
28
|
+
|
|
29
|
+
export type JobKind = 'precompute' | 'ssr'
|
|
30
|
+
|
|
31
|
+
export interface JobCall {
|
|
32
|
+
/** Opaque, echoed back: `<componentId>/<jobId>[/<row>]` for a chain job,
|
|
33
|
+
* `<parentId>/<childId>_<k>/<jobId>[/<row>]` for an inlined child instance (pipeline.rs `plan_one`). */
|
|
34
|
+
id: string
|
|
35
|
+
componentId: string
|
|
36
|
+
kind: JobKind
|
|
37
|
+
// biome-ignore lint/suspicious/noExplicitAny: job inputs are arbitrary JSON
|
|
38
|
+
inputs: any
|
|
39
|
+
/** ssr: the react component to render (D6). */
|
|
40
|
+
target?: string
|
|
41
|
+
row?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface JobsRequest {
|
|
45
|
+
jobs: JobCall[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type JobResult = { id: string; value: unknown } | { id: string; error: string }
|
|
49
|
+
|
|
50
|
+
export interface JobsResponse {
|
|
51
|
+
results: JobResult[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The default export of `<dist>/<manifest.jobs_module>` (built by Task 6): component id → job fns. */
|
|
55
|
+
// biome-ignore lint/suspicious/noExplicitAny: props are whatever the component takes
|
|
56
|
+
export type JobsModule = Record<string, { precompute?: (props: any) => unknown; ssr?: (props: any) => string | Promise<string> }>
|
|
57
|
+
|
|
58
|
+
/** The slice of `manifest.json` the worker reads: ssr job `literals` (S6 amendment). */
|
|
59
|
+
export interface ManifestLike {
|
|
60
|
+
routes?: { id: string; chain: string[] }[]
|
|
61
|
+
components: Record<
|
|
62
|
+
string,
|
|
63
|
+
{
|
|
64
|
+
jobs: { id: string; kind?: string; target?: string; literals?: Record<string, unknown> }[]
|
|
65
|
+
children?: { id: string }[]
|
|
66
|
+
stores?: string[]
|
|
67
|
+
}
|
|
68
|
+
>
|
|
69
|
+
jobs_module?: string
|
|
70
|
+
/** `brust build` (M3-F A3): the actions entry, relative to dist. Absent/null = no actions. */
|
|
71
|
+
actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number } | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function toJson(out: unknown): string {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.stringify(out)
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return JSON.stringify({ error: `response not serialisable: ${String(e)}` })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- literals (S6 amendment: the worker merges an ssr job's `literals` over `call.inputs`) ----
|
|
83
|
+
|
|
84
|
+
/** `"<componentId>/<jobId>"` → that job record's `literals` (only records that carry some). */
|
|
85
|
+
export function literalsIndex(manifest: ManifestLike | undefined): Map<string, Record<string, unknown>> {
|
|
86
|
+
const out = new Map<string, Record<string, unknown>>()
|
|
87
|
+
for (const [cid, c] of Object.entries(manifest?.components ?? {}))
|
|
88
|
+
for (const j of c.jobs ?? []) if (j.literals && Object.keys(j.literals).length > 0) out.set(`${cid}/${j.id}`, j.literals)
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The manifest job id of a call: the segment after `componentId` in a chain id
|
|
93
|
+
* (`<cid>/<jobId>[/<row>]`), else the third segment of a child-instance id
|
|
94
|
+
* (`<parentId>/<cid>_<k>/<jobId>[/<row>]`). */
|
|
95
|
+
export function jobIdOf(call: Pick<JobCall, 'id' | 'componentId'>): string | undefined {
|
|
96
|
+
const parts = call.id.split('/')
|
|
97
|
+
return parts[0] === call.componentId ? parts[1] : parts[2]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- touched stores (M3-F D4) ----
|
|
101
|
+
|
|
102
|
+
/** route id -> sorted unique union of `stores` over the route's chain, walking each component's
|
|
103
|
+
* `children[].id` and `jobs[].target` transitively. Routes with an empty union are absent. */
|
|
104
|
+
export function routeStoresIndex(manifest: ManifestLike | undefined): Map<string, string[]> {
|
|
105
|
+
const out = new Map<string, string[]>()
|
|
106
|
+
const comps = manifest?.components ?? {}
|
|
107
|
+
for (const r of manifest?.routes ?? []) {
|
|
108
|
+
const acc = new Set<string>()
|
|
109
|
+
const seen = new Set<string>()
|
|
110
|
+
const walk = (id: string) => {
|
|
111
|
+
if (seen.has(id)) return
|
|
112
|
+
seen.add(id)
|
|
113
|
+
const c = comps[id]
|
|
114
|
+
if (!c) return
|
|
115
|
+
for (const p of c.stores ?? []) acc.add(p)
|
|
116
|
+
for (const k of c.children ?? []) walk(k.id)
|
|
117
|
+
for (const j of c.jobs ?? []) if (j.target) walk(j.target)
|
|
118
|
+
}
|
|
119
|
+
for (const id of r.chain ?? []) walk(id)
|
|
120
|
+
if (acc.size > 0) out.set(r.id, [...acc].sort())
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** R1: `store` is assigned only when a store was touched; absent otherwise. */
|
|
126
|
+
function withStore<T extends LoaderResponse>(r: T): T {
|
|
127
|
+
const s = collectSnapshot()
|
|
128
|
+
if (s) (r as { store?: StoreSnapshot | null }).store = s
|
|
129
|
+
return r
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- handlers ----
|
|
133
|
+
|
|
134
|
+
/** `parent`: the data the chain merged before the verdict. A notFound renders the route's
|
|
135
|
+
* template (layouts included), so the layouts' data stays under the verdict's own keys. */
|
|
136
|
+
function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderResponse {
|
|
137
|
+
if (v.status === 404) {
|
|
138
|
+
const own = v.data ?? {}
|
|
139
|
+
return { verdict: 'notFound', data: typeof own === 'object' && !Array.isArray(own) ? { ...parent, ...own } : own }
|
|
140
|
+
}
|
|
141
|
+
return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface Handlers {
|
|
145
|
+
loader(req: LoaderRequest): Promise<LoaderResponse>
|
|
146
|
+
jobs(req: JobsRequest): Promise<JobsResponse>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `leaves` from `flattenRoutes` (ids must match the manifest's route ids); `jobs` the jobs
|
|
150
|
+
* module; `manifest` supplies ssr `literals` (omit → no merge). */
|
|
151
|
+
export function makeHandlers(opts: {
|
|
152
|
+
leaves: FlatRoute[]
|
|
153
|
+
jobs: JobsModule
|
|
154
|
+
manifest?: ManifestLike
|
|
155
|
+
/** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
|
|
156
|
+
stores?: Record<string, object>
|
|
157
|
+
}): Handlers {
|
|
158
|
+
const byId = new Map(opts.leaves.map((l) => [l.id, l]))
|
|
159
|
+
const literals = literalsIndex(opts.manifest)
|
|
160
|
+
const jobs = opts.jobs
|
|
161
|
+
const storeIdx = routeStoresIndex(opts.manifest)
|
|
162
|
+
const storeTable = opts.stores ?? {}
|
|
163
|
+
|
|
164
|
+
async function runJob(call: JobCall): Promise<JobResult> {
|
|
165
|
+
const owner = call.kind === 'ssr' ? (call.target ?? call.componentId) : call.componentId // D6
|
|
166
|
+
const fn = jobs[owner]?.[call.kind]
|
|
167
|
+
if (typeof fn !== 'function') return { id: call.id, error: `no ${call.kind} job for component ${owner}` }
|
|
168
|
+
try {
|
|
169
|
+
let props = call.inputs
|
|
170
|
+
if (call.kind === 'ssr') {
|
|
171
|
+
const jid = jobIdOf(call)
|
|
172
|
+
const lit = jid === undefined ? undefined : literals.get(`${call.componentId}/${jid}`)
|
|
173
|
+
if (lit) props = { ...(props ?? {}), ...lit }
|
|
174
|
+
// Precompute runs outside any scope (D8); ssr runs in a scope seeded from `__store`, which props never see.
|
|
175
|
+
const seed = (props?.__store ?? null) as StoreSnapshot | null
|
|
176
|
+
if (props && '__store' in props) {
|
|
177
|
+
const { __store: _, ...rest } = props
|
|
178
|
+
props = rest
|
|
179
|
+
}
|
|
180
|
+
const p = props
|
|
181
|
+
return { id: call.id, value: await runInStoreContext(() => fn(p), seed) }
|
|
182
|
+
}
|
|
183
|
+
return { id: call.id, value: await fn(props) }
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return { id: call.id, error: String(e) }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
// 0.1.x `runNativeChainLoaders`: root → leaf, flat merge (later keys win), first verdict stops
|
|
191
|
+
// (a notFound keeps what the chain merged so far: verdictJson).
|
|
192
|
+
async loader(req) {
|
|
193
|
+
const leaf = byId.get(req.routeId)
|
|
194
|
+
if (!leaf) return { error: `unknown routeId ${req.routeId}` }
|
|
195
|
+
// One object for the whole chain: each level's keys are assigned INTO it (later keys win,
|
|
196
|
+
// first-seen key order — what `{...merged, ...r}` produced) instead of re-spreading every
|
|
197
|
+
// key once per level (M3-P P1). `merged` is fresh per call; a loader's own object is never
|
|
198
|
+
// written to. Null prototype: with no `__proto__` setter to hit, an own "__proto__" key in a
|
|
199
|
+
// loader's data is assigned as data, as the spread copied it.
|
|
200
|
+
return runInStoreContext(async () => {
|
|
201
|
+
const merged: Record<string, unknown> = Object.create(null)
|
|
202
|
+
try {
|
|
203
|
+
const touch = storeIdx.get(req.routeId)
|
|
204
|
+
if (touch) touchStores(touch, storeTable)
|
|
205
|
+
for (const node of leaf.chain) {
|
|
206
|
+
if (!node.loader) continue
|
|
207
|
+
let r: unknown
|
|
208
|
+
try {
|
|
209
|
+
r = await node.loader({ params: req.params, path: req.path, req: req.req })
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (isHttpErrorTrigger(e)) return { verdict: 'httpError', status: e.status, body: e.body } as LoaderResponse
|
|
212
|
+
throw e
|
|
213
|
+
}
|
|
214
|
+
if (isVerdict(r)) {
|
|
215
|
+
const v = verdictJson(r, merged)
|
|
216
|
+
return 'verdict' in v && v.verdict === 'notFound' ? withStore(v) : v
|
|
217
|
+
}
|
|
218
|
+
if (r && typeof r === 'object') Object.assign(merged, r)
|
|
219
|
+
}
|
|
220
|
+
return withStore({ ok: true, data: merged } as LoaderResponse)
|
|
221
|
+
} catch (e) {
|
|
222
|
+
return { error: String(e) } as LoaderResponse
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
},
|
|
226
|
+
// One request carries every job of the page; a per-job failure keeps the other results.
|
|
227
|
+
async jobs(req) {
|
|
228
|
+
return { results: await Promise.all((req.jobs ?? []).map(runJob)) }
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Wraps an async handler for a JSON-string boundary: parses, calls, serialises; NEVER rejects
|
|
234
|
+
* (a parse error or a throwing handler resolves `{"error":…}`, like `makeDispatch`). */
|
|
235
|
+
export function jsonCall<Q, R>(fn: (q: Q) => Promise<R>): (json: string) => Promise<string> {
|
|
236
|
+
return async (json) => {
|
|
237
|
+
try {
|
|
238
|
+
return toJson(await fn(JSON.parse(json)))
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return toJson({ error: String(e) })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/native.ts
CHANGED
|
@@ -31,6 +31,16 @@ export interface StartOptions {
|
|
|
31
31
|
cors?: CorsOptions
|
|
32
32
|
/** The actions subtree (M3-F A3); absent = no actions. Boot error `actions: …` when invalid. */
|
|
33
33
|
actions?: ActionsOptions
|
|
34
|
+
/** The cache adapter (`manifest.cache`); absent = none (byte-identical server). */
|
|
35
|
+
l2?: L2Options
|
|
36
|
+
}
|
|
37
|
+
/** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
|
|
38
|
+
export interface L2Options {
|
|
39
|
+
adapter: string
|
|
40
|
+
getTimeoutMs?: number
|
|
41
|
+
maxBodyBytes?: number
|
|
42
|
+
namespace: string
|
|
43
|
+
buildId: string
|
|
34
44
|
}
|
|
35
45
|
/** napi `NapiCorsOptions` (camelCase keys; snake_case keys are silently dropped). */
|
|
36
46
|
export interface CorsOptions {
|
|
@@ -50,7 +60,7 @@ export interface ActionsOptions {
|
|
|
50
60
|
/** F87: cross-site mutating requests. Default `reject`. */
|
|
51
61
|
crossSite?: 'reject' | 'allow'
|
|
52
62
|
}
|
|
53
|
-
export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch', requestJson: string, slot: number) => Promise<number>
|
|
63
|
+
export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch' | 'cache', requestJson: string, slot: number) => Promise<number>
|
|
54
64
|
export interface NativeBinding {
|
|
55
65
|
startServer(opts: StartOptions): void
|
|
56
66
|
registerWorker(buf: Uint8Array, slots: number, f: WorkerFn): number
|
|
@@ -65,6 +75,12 @@ export interface NativeBinding {
|
|
|
65
75
|
takeFetchBody(slot: number): Uint8Array | null
|
|
66
76
|
/** F82 — hand Rust a response body too large for the slot; false = declined. Never throws. */
|
|
67
77
|
putFetchReply(slot: number, body: Uint8Array): boolean
|
|
78
|
+
/** cache adapter R10 — a foreign broadcast event: tier 1 + the floor at `seq`; never re-published. */
|
|
79
|
+
cacheReplay(args: InvalidateArgs, seq: number | null): InvalidateResult
|
|
80
|
+
/** cache adapter R9 — bump `stats.l2[name]`; false = unknown name / no adapter / no server. Never throws. */
|
|
81
|
+
cacheL2Count(name: string): boolean
|
|
82
|
+
/** cache adapter R10 — drop both tier-1 caches (a broadcast gap); the L1 entries removed. */
|
|
83
|
+
cacheClear(): number
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
let binding: NativeBinding | null = null
|
|
@@ -93,3 +109,6 @@ export const localAddr = (): string => native().localAddr()
|
|
|
93
109
|
export const planJobs = (slot: number, len: number): string => native().planJobs(slot, len)
|
|
94
110
|
export const takeFetchBody = (slot: number): Uint8Array | null => native().takeFetchBody(slot)
|
|
95
111
|
export const putFetchReply = (slot: number, body: Uint8Array): boolean => native().putFetchReply(slot, body)
|
|
112
|
+
export const cacheReplay = (args: InvalidateArgs, seq: number | null): InvalidateResult => native().cacheReplay(args, seq)
|
|
113
|
+
export const cacheL2Count = (name: string): boolean => native().cacheL2Count(name)
|
|
114
|
+
export const cacheClear = (): number => native().cacheClear()
|
package/src/run.ts
CHANGED
|
@@ -23,9 +23,11 @@ export interface RunOptions {
|
|
|
23
23
|
/** The manifest fields `run` needs (build/manifest.ts owns the full shape). */
|
|
24
24
|
export interface RunManifest {
|
|
25
25
|
actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null
|
|
26
|
+
/** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
|
|
27
|
+
cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string } | null
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
/** Pure: the napi `StartOptions`. No actions/cors → exactly the pre-actions keys. */
|
|
30
|
+
/** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
|
|
29
31
|
export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string): StartOptions {
|
|
30
32
|
return {
|
|
31
33
|
host: cfg.host,
|
|
@@ -37,6 +39,7 @@ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest,
|
|
|
37
39
|
generator: `brust/${version}`,
|
|
38
40
|
...(m.actions ? { actions: { prefix: m.actions.prefix, maxBodyBytes: m.actions.maxBodyBytes, ...(m.actions.maxResponseBytes !== undefined ? { maxResponseBytes: m.actions.maxResponseBytes } : {}), ...(m.actions.crossSite ? { crossSite: m.actions.crossSite } : {}) } } : {}),
|
|
39
41
|
...(cfg.cors ? { cors: cfg.cors } : {}),
|
|
42
|
+
...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -86,6 +89,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
|
|
|
86
89
|
BRUST_RENDER_SLOTS: String(cfg.renderSlots),
|
|
87
90
|
BRUST_DIST_DIR: distDir,
|
|
88
91
|
BRUST_APP_ENTRY: entry,
|
|
92
|
+
// One cache-adapter origin per process: its workers' published events are not replayed here.
|
|
93
|
+
BRUST_CACHE_ORIGIN: crypto.randomUUID(),
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
let ready = false
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// What the generated Cloudflare entry (`brust-app.js`) imports: the host-agnostic handler code and the
|
|
2
|
+
// store-scoped action fetch. No Bun / worker_threads here; `actions-bridge`'s napi import is stubbed by the
|
|
3
|
+
// CF bundle (M4-CF R8).
|
|
4
|
+
import { type CfCacheBridge, type CfInstallExt, installCache } from './cache-adapter/workers'
|
|
5
|
+
import type { InvalidateArgs, InvalidateResult } from './native'
|
|
6
|
+
|
|
7
|
+
export { fetchInScope, noActions } from './actions-bridge'
|
|
8
|
+
export { jsonCall, makeHandlers } from './handlers'
|
|
9
|
+
export { flattenRoutes } from './routes'
|
|
10
|
+
|
|
11
|
+
/** What the wasm side hands over at start (`#[event(start]` -> `install({ invalidate, l2Count })`, ruling 19753649). */
|
|
12
|
+
export interface CfRuntime {
|
|
13
|
+
invalidate(args: InvalidateArgs): InvalidateResult
|
|
14
|
+
/** Bumps a `stats.l2` counter (cache adapters R9). */
|
|
15
|
+
l2Count?(name: string): boolean
|
|
16
|
+
}
|
|
17
|
+
export type { CfCacheBridge, CfInstallExt }
|
|
18
|
+
|
|
19
|
+
let rt: CfRuntime | null = null
|
|
20
|
+
|
|
21
|
+
/** Called by the wasm glue once at Worker start; the CF bundle's napi stub forwards `cacheInvalidate` to it.
|
|
22
|
+
* With a cache adapter (`ext`, from the generated entry) it answers the adapter bridge (R11, R14). */
|
|
23
|
+
export function install(runtime: CfRuntime, ext?: CfInstallExt): { cache: CfCacheBridge | null } {
|
|
24
|
+
rt = runtime
|
|
25
|
+
return { cache: ext ? installCache(runtime, ext) : null }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The installed runtime; throws before `install` (a request cannot arrive earlier). */
|
|
29
|
+
export function installedRuntime(): CfRuntime {
|
|
30
|
+
if (!rt) throw new Error('brust: the Cloudflare runtime is not installed (install(rt) was not called at start)')
|
|
31
|
+
return rt
|
|
32
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -1,79 +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, setActionsLimits } from './actions-bridge'
|
|
6
|
+
import { type HeapIo, runFetch, setActionsApp, setActionsLimits } from './actions-bridge'
|
|
7
7
|
import { DEFAULT_MAX_RESPONSE_BYTES } from './actions/prefix'
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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'
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// ---- wire shapes (protocol.rs, camelCase) ----
|
|
16
|
-
|
|
17
|
-
export interface LoaderRequest {
|
|
18
|
-
routeId: string
|
|
19
|
-
params: Record<string, string>
|
|
20
|
-
path: string
|
|
21
|
-
req: LoaderReq
|
|
22
|
-
/** M3-P P6: the chain has jobs — plan them in this call (`planJobs`). Optional to honour. */
|
|
23
|
-
plan?: boolean
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export type LoaderResponse =
|
|
27
|
-
| { ok: true; data: Record<string, unknown>; headers?: Record<string, string>; store?: StoreSnapshot | null }
|
|
28
|
-
| { verdict: 'notFound'; data: unknown; store?: StoreSnapshot | null }
|
|
29
|
-
| { verdict: 'redirect'; location: string; status: number }
|
|
30
|
-
| { verdict: 'httpError'; status: number; body: string }
|
|
31
|
-
| { error: string }
|
|
32
|
-
|
|
33
|
-
export type JobKind = 'precompute' | 'ssr'
|
|
34
|
-
|
|
35
|
-
export interface JobCall {
|
|
36
|
-
/** Opaque, echoed back: `<componentId>/<jobId>[/<row>]` for a chain job,
|
|
37
|
-
* `<parentId>/<childId>_<k>/<jobId>[/<row>]` for an inlined child instance (pipeline.rs `plan_one`). */
|
|
38
|
-
id: string
|
|
39
|
-
componentId: string
|
|
40
|
-
kind: JobKind
|
|
41
|
-
// biome-ignore lint/suspicious/noExplicitAny: job inputs are arbitrary JSON
|
|
42
|
-
inputs: any
|
|
43
|
-
/** ssr: the react component to render (D6). */
|
|
44
|
-
target?: string
|
|
45
|
-
row?: number
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface JobsRequest {
|
|
49
|
-
jobs: JobCall[]
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export type JobResult = { id: string; value: unknown } | { id: string; error: string }
|
|
53
|
-
|
|
54
|
-
export interface JobsResponse {
|
|
55
|
-
results: JobResult[]
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The default export of `<dist>/<manifest.jobs_module>` (built by Task 6): component id → job fns. */
|
|
59
|
-
// biome-ignore lint/suspicious/noExplicitAny: props are whatever the component takes
|
|
60
|
-
export type JobsModule = Record<string, { precompute?: (props: any) => unknown; ssr?: (props: any) => string | Promise<string> }>
|
|
61
|
-
|
|
62
|
-
/** The slice of `manifest.json` the worker reads: ssr job `literals` (S6 amendment). */
|
|
63
|
-
export interface ManifestLike {
|
|
64
|
-
routes?: { id: string; chain: string[] }[]
|
|
65
|
-
components: Record<
|
|
66
|
-
string,
|
|
67
|
-
{
|
|
68
|
-
jobs: { id: string; kind?: string; target?: string; literals?: Record<string, unknown> }[]
|
|
69
|
-
children?: { id: string }[]
|
|
70
|
-
stores?: string[]
|
|
71
|
-
}
|
|
72
|
-
>
|
|
73
|
-
jobs_module?: string
|
|
74
|
-
/** `brust build` (M3-F A3): the actions entry, relative to dist. Absent/null = no actions. */
|
|
75
|
-
actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number } | null
|
|
76
|
-
}
|
|
14
|
+
export * from './handlers'
|
|
77
15
|
|
|
78
16
|
// ---- SAB slot writer ----
|
|
79
17
|
|
|
@@ -103,165 +41,6 @@ export type PlanJobs = (slot: number, len: number) => string
|
|
|
103
41
|
export const PLAN_DECLINED = '!'
|
|
104
42
|
const PLANNED = { planned: true } as const
|
|
105
43
|
|
|
106
|
-
function toJson(out: unknown): string {
|
|
107
|
-
try {
|
|
108
|
-
return JSON.stringify(out)
|
|
109
|
-
} catch (e) {
|
|
110
|
-
return JSON.stringify({ error: `response not serialisable: ${String(e)}` })
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ---- literals (S6 amendment: the worker merges an ssr job's `literals` over `call.inputs`) ----
|
|
115
|
-
|
|
116
|
-
/** `"<componentId>/<jobId>"` → that job record's `literals` (only records that carry some). */
|
|
117
|
-
export function literalsIndex(manifest: ManifestLike | undefined): Map<string, Record<string, unknown>> {
|
|
118
|
-
const out = new Map<string, Record<string, unknown>>()
|
|
119
|
-
for (const [cid, c] of Object.entries(manifest?.components ?? {}))
|
|
120
|
-
for (const j of c.jobs ?? []) if (j.literals && Object.keys(j.literals).length > 0) out.set(`${cid}/${j.id}`, j.literals)
|
|
121
|
-
return out
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** The manifest job id of a call: the segment after `componentId` in a chain id
|
|
125
|
-
* (`<cid>/<jobId>[/<row>]`), else the third segment of a child-instance id
|
|
126
|
-
* (`<parentId>/<cid>_<k>/<jobId>[/<row>]`). */
|
|
127
|
-
export function jobIdOf(call: Pick<JobCall, 'id' | 'componentId'>): string | undefined {
|
|
128
|
-
const parts = call.id.split('/')
|
|
129
|
-
return parts[0] === call.componentId ? parts[1] : parts[2]
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ---- touched stores (M3-F D4) ----
|
|
133
|
-
|
|
134
|
-
/** route id -> sorted unique union of `stores` over the route's chain, walking each component's
|
|
135
|
-
* `children[].id` and `jobs[].target` transitively. Routes with an empty union are absent. */
|
|
136
|
-
export function routeStoresIndex(manifest: ManifestLike | undefined): Map<string, string[]> {
|
|
137
|
-
const out = new Map<string, string[]>()
|
|
138
|
-
const comps = manifest?.components ?? {}
|
|
139
|
-
for (const r of manifest?.routes ?? []) {
|
|
140
|
-
const acc = new Set<string>()
|
|
141
|
-
const seen = new Set<string>()
|
|
142
|
-
const walk = (id: string) => {
|
|
143
|
-
if (seen.has(id)) return
|
|
144
|
-
seen.add(id)
|
|
145
|
-
const c = comps[id]
|
|
146
|
-
if (!c) return
|
|
147
|
-
for (const p of c.stores ?? []) acc.add(p)
|
|
148
|
-
for (const k of c.children ?? []) walk(k.id)
|
|
149
|
-
for (const j of c.jobs ?? []) if (j.target) walk(j.target)
|
|
150
|
-
}
|
|
151
|
-
for (const id of r.chain ?? []) walk(id)
|
|
152
|
-
if (acc.size > 0) out.set(r.id, [...acc].sort())
|
|
153
|
-
}
|
|
154
|
-
return out
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** R1: `store` is assigned only when a store was touched; absent otherwise. */
|
|
158
|
-
function withStore<T extends LoaderResponse>(r: T): T {
|
|
159
|
-
const s = collectSnapshot()
|
|
160
|
-
if (s) (r as { store?: StoreSnapshot | null }).store = s
|
|
161
|
-
return r
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ---- handlers ----
|
|
165
|
-
|
|
166
|
-
/** `parent`: the data the chain merged before the verdict. A notFound renders the route's
|
|
167
|
-
* template (layouts included), so the layouts' data stays under the verdict's own keys. */
|
|
168
|
-
function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderResponse {
|
|
169
|
-
if (v.status === 404) {
|
|
170
|
-
const own = v.data ?? {}
|
|
171
|
-
return { verdict: 'notFound', data: typeof own === 'object' && !Array.isArray(own) ? { ...parent, ...own } : own }
|
|
172
|
-
}
|
|
173
|
-
return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export interface Handlers {
|
|
177
|
-
loader(req: LoaderRequest): Promise<LoaderResponse>
|
|
178
|
-
jobs(req: JobsRequest): Promise<JobsResponse>
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** `leaves` from `flattenRoutes` (ids must match the manifest's route ids); `jobs` the jobs
|
|
182
|
-
* module; `manifest` supplies ssr `literals` (omit → no merge). */
|
|
183
|
-
export function makeHandlers(opts: {
|
|
184
|
-
leaves: FlatRoute[]
|
|
185
|
-
jobs: JobsModule
|
|
186
|
-
manifest?: ManifestLike
|
|
187
|
-
/** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
|
|
188
|
-
stores?: Record<string, object>
|
|
189
|
-
}): Handlers {
|
|
190
|
-
const byId = new Map(opts.leaves.map((l) => [l.id, l]))
|
|
191
|
-
const literals = literalsIndex(opts.manifest)
|
|
192
|
-
const jobs = opts.jobs
|
|
193
|
-
const storeIdx = routeStoresIndex(opts.manifest)
|
|
194
|
-
const storeTable = opts.stores ?? {}
|
|
195
|
-
|
|
196
|
-
async function runJob(call: JobCall): Promise<JobResult> {
|
|
197
|
-
const owner = call.kind === 'ssr' ? (call.target ?? call.componentId) : call.componentId // D6
|
|
198
|
-
const fn = jobs[owner]?.[call.kind]
|
|
199
|
-
if (typeof fn !== 'function') return { id: call.id, error: `no ${call.kind} job for component ${owner}` }
|
|
200
|
-
try {
|
|
201
|
-
let props = call.inputs
|
|
202
|
-
if (call.kind === 'ssr') {
|
|
203
|
-
const jid = jobIdOf(call)
|
|
204
|
-
const lit = jid === undefined ? undefined : literals.get(`${call.componentId}/${jid}`)
|
|
205
|
-
if (lit) props = { ...(props ?? {}), ...lit }
|
|
206
|
-
// Precompute runs outside any scope (D8); ssr runs in a scope seeded from `__store`, which props never see.
|
|
207
|
-
const seed = (props?.__store ?? null) as StoreSnapshot | null
|
|
208
|
-
if (props && '__store' in props) {
|
|
209
|
-
const { __store: _, ...rest } = props
|
|
210
|
-
props = rest
|
|
211
|
-
}
|
|
212
|
-
const p = props
|
|
213
|
-
return { id: call.id, value: await runInStoreContext(() => fn(p), seed) }
|
|
214
|
-
}
|
|
215
|
-
return { id: call.id, value: await fn(props) }
|
|
216
|
-
} catch (e) {
|
|
217
|
-
return { id: call.id, error: String(e) }
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return {
|
|
222
|
-
// 0.1.x `runNativeChainLoaders`: root → leaf, flat merge (later keys win), first verdict stops
|
|
223
|
-
// (a notFound keeps what the chain merged so far: verdictJson).
|
|
224
|
-
async loader(req) {
|
|
225
|
-
const leaf = byId.get(req.routeId)
|
|
226
|
-
if (!leaf) return { error: `unknown routeId ${req.routeId}` }
|
|
227
|
-
// One object for the whole chain: each level's keys are assigned INTO it (later keys win,
|
|
228
|
-
// first-seen key order — what `{...merged, ...r}` produced) instead of re-spreading every
|
|
229
|
-
// key once per level (M3-P P1). `merged` is fresh per call; a loader's own object is never
|
|
230
|
-
// written to. Null prototype: with no `__proto__` setter to hit, an own "__proto__" key in a
|
|
231
|
-
// loader's data is assigned as data, as the spread copied it.
|
|
232
|
-
return runInStoreContext(async () => {
|
|
233
|
-
const merged: Record<string, unknown> = Object.create(null)
|
|
234
|
-
try {
|
|
235
|
-
const touch = storeIdx.get(req.routeId)
|
|
236
|
-
if (touch) touchStores(touch, storeTable)
|
|
237
|
-
for (const node of leaf.chain) {
|
|
238
|
-
if (!node.loader) continue
|
|
239
|
-
let r: unknown
|
|
240
|
-
try {
|
|
241
|
-
r = await node.loader({ params: req.params, path: req.path, req: req.req })
|
|
242
|
-
} catch (e) {
|
|
243
|
-
if (isHttpErrorTrigger(e)) return { verdict: 'httpError', status: e.status, body: e.body } as LoaderResponse
|
|
244
|
-
throw e
|
|
245
|
-
}
|
|
246
|
-
if (isVerdict(r)) {
|
|
247
|
-
const v = verdictJson(r, merged)
|
|
248
|
-
return 'verdict' in v && v.verdict === 'notFound' ? withStore(v) : v
|
|
249
|
-
}
|
|
250
|
-
if (r && typeof r === 'object') Object.assign(merged, r)
|
|
251
|
-
}
|
|
252
|
-
return withStore({ ok: true, data: merged } as LoaderResponse)
|
|
253
|
-
} catch (e) {
|
|
254
|
-
return { error: String(e) } as LoaderResponse
|
|
255
|
-
}
|
|
256
|
-
})
|
|
257
|
-
},
|
|
258
|
-
// One request carries every job of the page; a per-job failure keeps the other results.
|
|
259
|
-
async jobs(req) {
|
|
260
|
-
return { results: await Promise.all((req.jobs ?? []).map(runJob)) }
|
|
261
|
-
},
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
44
|
/** The tsfn callback for `registerWorker`. Never rejects: a parse error, an unknown kind or a
|
|
266
45
|
* throwing handler is written to the slot as `{error}`. A loader request with `plan: true` whose
|
|
267
46
|
* chain answered `ok` is planned in the same call (`plan`, the napi `planJobs` by default — the
|
|
@@ -269,6 +48,7 @@ export function makeHandlers(opts: {
|
|
|
269
48
|
export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan: PlanJobs = planJobs) {
|
|
270
49
|
return async (kind: string, requestJson: string, slot: number): Promise<number> => {
|
|
271
50
|
if (kind === 'fetch') return runFetch(requestJson, view, slot, slots)
|
|
51
|
+
if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo, cacheDeps)
|
|
272
52
|
let out: unknown
|
|
273
53
|
try {
|
|
274
54
|
const req = JSON.parse(requestJson)
|
|
@@ -292,6 +72,31 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
|
|
|
292
72
|
}
|
|
293
73
|
}
|
|
294
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
|
+
|
|
295
100
|
// ---- worker entry ----
|
|
296
101
|
|
|
297
102
|
export const SLOT_BYTES = 256 * 1024
|
|
@@ -318,7 +123,7 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
|
|
|
318
123
|
const dist = resolve(env.BRUST_DIST_DIR ?? 'dist')
|
|
319
124
|
const entry = env.BRUST_APP_ENTRY
|
|
320
125
|
if (!entry) throw new Error('BRUST_APP_ENTRY is not set')
|
|
321
|
-
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 }
|
|
322
127
|
const app = await import(resolve(entry))
|
|
323
128
|
const routes = (app.routes ?? app.default) as Route[] | undefined
|
|
324
129
|
if (!Array.isArray(routes)) throw new Error(`${entry} exports no routes (export const routes or default)`)
|
|
@@ -330,6 +135,8 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
|
|
|
330
135
|
setActionsApp(await loadActionsApp(join(dist, manifest.actions.entry), manifest.actions.prefix))
|
|
331
136
|
setActionsLimits({ maxResponseBytes: manifest.actions.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES })
|
|
332
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
|
|
333
140
|
sab = opts.sab ?? new SharedArrayBuffer(SLOT_BYTES * slots)
|
|
334
141
|
view = new Uint8Array(sab)
|
|
335
142
|
const handlers = makeHandlers({
|
|
@@ -338,7 +145,12 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
|
|
|
338
145
|
manifest,
|
|
339
146
|
stores: (jobsMod.stores ?? {}) as Record<string, object>,
|
|
340
147
|
})
|
|
341
|
-
|
|
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
|
|
342
154
|
}
|
|
343
155
|
|
|
344
156
|
// Auto-start only inside a Bun `Worker` spawned by `run()` (BRUST_WORKER_ID set). Not
|