@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.10
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/README.md +66 -6
- package/native/index.d.ts +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +319 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +278 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +72 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +223 -20
- package/src/build/manifest.ts +59 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -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 +19 -0
- package/src/cli.ts +50 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +48 -11
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +129 -245
package/src/worker.ts
CHANGED
|
@@ -1,273 +1,92 @@
|
|
|
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 {
|
|
4
|
+
import { appendFileSync } from 'node:fs'
|
|
5
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
6
|
import { workerData } from 'node:worker_threads'
|
|
6
|
-
import { runFetch, setActionsApp } from './actions-bridge'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { type
|
|
7
|
+
import { type HeapIo, runFetch, setActionsApp, setActionsLimits } from './actions-bridge'
|
|
8
|
+
import { DEFAULT_MAX_RESPONSE_BYTES } from './actions/prefix'
|
|
9
|
+
import { type CacheBridgeDeps, runCache } from './cache-adapter/bridge'
|
|
10
|
+
import type { CacheManifest } from './cache-adapter/load'
|
|
11
|
+
import { type Handlers, type JobsModule, type ManifestLike, makeHandlers, type SsgRecord, toJson } from './handlers'
|
|
12
|
+
import { cacheClear, cacheL2Count, cacheReplay, planJobs, planJobsBuf, putFetchReply, registerWorker, takeFetchBody } from './native'
|
|
13
|
+
import { installServerOnlyRuntime } from './build/server-only'
|
|
14
|
+
import { flattenRoutes, type Route } from './routes'
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
export * from './handlers'
|
|
13
17
|
|
|
14
|
-
// ----
|
|
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 }
|
|
18
|
+
// ---- SAB slot writer ----
|
|
52
19
|
|
|
53
|
-
|
|
54
|
-
results: JobResult[]
|
|
55
|
-
}
|
|
20
|
+
const enc = new TextEncoder()
|
|
56
21
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
export type JobsModule = Record<string, { precompute?: (props: any) => unknown; ssr?: (props: any) => string | Promise<string> }>
|
|
22
|
+
/** L10: the default `maxLoaderResponseBytes` (config.ts keeps the range). */
|
|
23
|
+
export const DEFAULT_MAX_LOADER_RESPONSE_BYTES = 8_388_608
|
|
60
24
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
25
|
+
/** L10: a reply too large for the slot crosses by heap: `HeapIo.putReply` hands Rust the bytes, and the slot holds
|
|
26
|
+
* the 5-byte marker `[0x00][u32 LE n]` (JSON never starts with 0x00). `cap` = `maxLoaderResponseBytes`. */
|
|
27
|
+
export const HEAP_MARKER_BYTES = 5
|
|
28
|
+
export interface HeapReply {
|
|
29
|
+
io: HeapIo
|
|
30
|
+
/** `planJobsBuf` (napi): plan a loader response handed over directly. */
|
|
31
|
+
planBuf: (slot: number, buf: Uint8Array) => string
|
|
32
|
+
/** Default: the worker's `maxLoaderResponseBytes` (env `BRUST_MAX_LOADER_RESPONSE_BYTES`, 8 MiB). */
|
|
33
|
+
cap?: number
|
|
75
34
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const enc = new TextEncoder()
|
|
35
|
+
const nativeHeap: HeapReply = { io: cacheHeapIo(), planBuf: (slot, buf) => planJobsBuf(slot, buf) }
|
|
36
|
+
let loaderCap = DEFAULT_MAX_LOADER_RESPONSE_BYTES
|
|
80
37
|
|
|
81
38
|
/** Writes `json` as UTF-8 at the start of `slot`'s sub-region `[slot*sub, slot*sub+sub)`,
|
|
82
39
|
* `sub = floor(view.byteLength / slots)`; returns the byte length (> 0, ≤ sub). ONE copy: the
|
|
83
40
|
* encoder writes straight into the slot (M3-P P1); it stops at the last whole code point that
|
|
84
41
|
* fits, so `read < json.length` means "did not fit" and the neighbouring slot is never touched.
|
|
85
|
-
* A response that does not fit
|
|
86
|
-
*
|
|
87
|
-
|
|
42
|
+
* A response that does not fit goes by heap when `io` and `cap` are given (L10): encoded once, handed to
|
|
43
|
+
* Rust with `io.putReply`, and the slot holds the 5-byte marker. Over `cap` (or declined), or with no `io`, it is
|
|
44
|
+
* replaced by a small `{"error":"response too large: <n> > <limit>"}` (`n` = its exact UTF-8 length, computed
|
|
45
|
+
* only on this path). */
|
|
46
|
+
export function writeSlot(view: Uint8Array, slot: number, slots: number, json: string, io?: HeapIo, cap?: number): number {
|
|
88
47
|
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
89
48
|
const dst = view.subarray(slot * sub, slot * sub + sub)
|
|
90
49
|
const r = enc.encodeInto(json, dst)
|
|
91
50
|
if (r.read === json.length) return r.written
|
|
92
|
-
|
|
51
|
+
let why = `response too large: ${Buffer.byteLength(json)} > ${sub}`
|
|
52
|
+
if (io && cap !== undefined && sub >= HEAP_MARKER_BYTES) {
|
|
53
|
+
const bytes = enc.encode(json)
|
|
54
|
+
const n = heapMarker(dst, slot, bytes, io, cap)
|
|
55
|
+
if (n > 0) return n
|
|
56
|
+
// Name the real reason: over the cap, or a put Rust declined while under it (idle slot, a second put).
|
|
57
|
+
why = bytes.length > cap ? `response too large: ${bytes.length} > ${cap} (maxLoaderResponseBytes)` : `heap reply declined: ${bytes.length} bytes did not fit the ${sub}-byte slot and Rust refused the handover`
|
|
58
|
+
}
|
|
59
|
+
const err = JSON.stringify({ error: why })
|
|
93
60
|
const e = enc.encodeInto(err, dst)
|
|
94
61
|
if (e.read === err.length) return e.written
|
|
95
62
|
// Not even the message fits: the first `sub` bytes of a fixed one (ASCII, so byte = char).
|
|
96
63
|
return enc.encodeInto('{"error":"too large"}', dst).written
|
|
97
64
|
}
|
|
98
65
|
|
|
66
|
+
/** Hand `bytes` to Rust and write the marker at `dst`; 0 when over `cap` or Rust declined. */
|
|
67
|
+
function heapMarker(dst: Uint8Array, slot: number, bytes: Uint8Array, io: HeapIo, cap: number): number {
|
|
68
|
+
if (bytes.length > cap || !io.putReply(slot, bytes)) return 0
|
|
69
|
+
dst[0] = 0
|
|
70
|
+
new DataView(dst.buffer, dst.byteOffset + 1, 4).setUint32(0, bytes.length, true)
|
|
71
|
+
return HEAP_MARKER_BYTES
|
|
72
|
+
}
|
|
73
|
+
|
|
99
74
|
/** `planJobs` (napi): the misses' JobsRequest JSON, '' (every job hit) or PLAN_DECLINED. */
|
|
100
75
|
export type PlanJobs = (slot: number, len: number) => string
|
|
101
76
|
/** `planJobs` declined: the loader response already in the slot is the answer (Rust plans after the call). */
|
|
102
77
|
export const PLAN_DECLINED = '!'
|
|
103
78
|
const PLANNED = { planned: true } as const
|
|
104
79
|
|
|
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
80
|
/** The tsfn callback for `registerWorker`. Never rejects: a parse error, an unknown kind or a
|
|
265
81
|
* throwing handler is written to the slot as `{error}`. A loader request with `plan: true` whose
|
|
266
82
|
* chain answered `ok` is planned in the same call (`plan`, the napi `planJobs` by default — the
|
|
267
|
-
* addon is touched only then).
|
|
268
|
-
|
|
83
|
+
* addon is touched only then). A loader/jobs/planned reply that does not fit the slot crosses by heap
|
|
84
|
+
* (`heap`, the napi addon by default; L10). */
|
|
85
|
+
export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan: PlanJobs = planJobs, heap: HeapReply = nativeHeap) {
|
|
86
|
+
const cap = () => heap.cap ?? loaderCap
|
|
269
87
|
return async (kind: string, requestJson: string, slot: number): Promise<number> => {
|
|
270
88
|
if (kind === 'fetch') return runFetch(requestJson, view, slot, slots)
|
|
89
|
+
if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo(), cacheDeps)
|
|
271
90
|
let out: unknown
|
|
272
91
|
try {
|
|
273
92
|
const req = JSON.parse(requestJson)
|
|
@@ -277,9 +96,20 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
|
|
|
277
96
|
// One round trip (M3-P P6): the ctx goes to Rust through this slot, synchronously, and only
|
|
278
97
|
// the jobs Rust found missing run here; the final answer replaces it in the same slot.
|
|
279
98
|
const json = toJson(out)
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
99
|
+
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
100
|
+
const dst = view.subarray(slot * sub, slot * sub + sub)
|
|
101
|
+
const w = enc.encodeInto(json, dst)
|
|
102
|
+
let misses: string
|
|
103
|
+
if (w.read === json.length) {
|
|
104
|
+
misses = plan(slot, w.written)
|
|
105
|
+
if (misses === PLAN_DECLINED) return w.written
|
|
106
|
+
} else {
|
|
107
|
+
// L10: too large for the slot — plan from the bytes directly; a declined plan hands them over as the answer.
|
|
108
|
+
const bytes = enc.encode(json)
|
|
109
|
+
if (bytes.length > cap()) return writeSlot(view, slot, slots, json, heap.io, cap())
|
|
110
|
+
misses = heap.planBuf(slot, bytes)
|
|
111
|
+
if (misses === PLAN_DECLINED) return writeSlot(view, slot, slots, json, heap.io, cap())
|
|
112
|
+
}
|
|
283
113
|
out = misses === '' ? PLANNED : { planned: true, results: (await h.jobs(JSON.parse(misses))).results }
|
|
284
114
|
}
|
|
285
115
|
} else if (kind === 'jobs') out = await h.jobs(req)
|
|
@@ -287,16 +117,51 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
|
|
|
287
117
|
} catch (e) {
|
|
288
118
|
out = { error: String(e) }
|
|
289
119
|
}
|
|
290
|
-
return writeSlot(view, slot, slots, toJson(out))
|
|
120
|
+
return writeSlot(view, slot, slots, toJson(out), heap.io, cap())
|
|
291
121
|
}
|
|
292
122
|
}
|
|
293
123
|
|
|
124
|
+
// ---- cache adapter (the `cache` call kind) ----
|
|
125
|
+
|
|
126
|
+
function cacheHeapIo(): HeapIo {
|
|
127
|
+
return { takeBody: (slot) => takeFetchBody(slot), putReply: (slot, body) => putFetchReply(slot, body) }
|
|
128
|
+
}
|
|
129
|
+
const count = (name: string) => void cacheL2Count(name)
|
|
130
|
+
let cacheDeps: CacheBridgeDeps = { store: undefined, getTimeoutMs: 50, maxBodyBytes: 1_048_576, count }
|
|
131
|
+
|
|
132
|
+
/** The `cache` call's store (tests; `startWorker` sets it from `manifest.cache`). */
|
|
133
|
+
export function setCacheBridge(d: CacheBridgeDeps): void {
|
|
134
|
+
cacheDeps = d
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Load `manifest.cache`'s adapter in this worker (R8): a custom module from dist, a built-in from the
|
|
138
|
+
* app root; `init`, then `cache.invalidate` publishes through it. Worker 0 also subscribes after
|
|
139
|
+
* registering (R10). */
|
|
140
|
+
async function startCacheAdapter(m: CacheManifest, dist: string, appDir: string, workerId: number) {
|
|
141
|
+
const { loadAdapter } = await import('./cache-adapter/load')
|
|
142
|
+
const { setCacheRuntime } = await import('./cache-adapter/runtime')
|
|
143
|
+
const adapter = await loadAdapter(m, 'bun', (spec, builtin) => import(builtin ? Bun.resolveSync(spec, appDir) : join(dist, spec)), process.env)
|
|
144
|
+
await adapter.init?.({ namespace: m.namespace, buildId: m.buildId, runtime: 'bun', workerId })
|
|
145
|
+
const origin = process.env.BRUST_CACHE_ORIGIN || crypto.randomUUID()
|
|
146
|
+
setCacheRuntime({ adapter, origin, defer: (p) => void p.catch(() => {}), count, replay: (args, seq) => void cacheReplay(args, seq) })
|
|
147
|
+
setCacheBridge({ store: adapter.store, getTimeoutMs: m.getTimeoutMs, maxBodyBytes: m.maxBodyBytes, count })
|
|
148
|
+
return { adapter, origin }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- static output (gaps round R9.5) ----
|
|
152
|
+
|
|
153
|
+
/** Appends each record as one JSON line to `file` (every worker of the render appends to it). */
|
|
154
|
+
function ssgLog(file: string): (r: SsgRecord) => void {
|
|
155
|
+
return (r) => appendFileSync(file, `${JSON.stringify(r)}\n`)
|
|
156
|
+
}
|
|
157
|
+
|
|
294
158
|
// ---- worker entry ----
|
|
295
159
|
|
|
160
|
+
/** The default response slot (`slotBytes`, config.ts: 64 KiB..16 MiB). */
|
|
296
161
|
export const SLOT_BYTES = 256 * 1024
|
|
297
162
|
|
|
298
163
|
export interface StartWorkerOptions {
|
|
299
|
-
/** The response buffer to register (`
|
|
164
|
+
/** The response buffer to register (`slotBytes * slots` bytes). `run()` allocates it on the
|
|
300
165
|
* MAIN thread and keeps it for the process lifetime, passing it as `workerData.sab`: the Rust
|
|
301
166
|
* dispatcher holds a raw pointer into it until the process exits, so its backing store must not
|
|
302
167
|
* die with this worker (`crates/brust-napi/src/dispatch.rs` BufPtr). Absent (tests, a worker
|
|
@@ -315,11 +180,19 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
|
|
|
315
180
|
const env = process.env
|
|
316
181
|
const slots = Math.max(1, Number.parseInt(env.BRUST_RENDER_SLOTS ?? '1', 10) || 1)
|
|
317
182
|
const dist = resolve(env.BRUST_DIST_DIR ?? 'dist')
|
|
318
|
-
|
|
183
|
+
// L16 R16.3: an actions-only dist has no routes module (so no BRUST_APP_ENTRY either).
|
|
184
|
+
const manifestFile = join(dist, 'manifest.json')
|
|
185
|
+
const hasManifest = await Bun.file(manifestFile).exists()
|
|
186
|
+
const actionsOnly = hasManifest && !env.BRUST_APP_ENTRY && ((await Bun.file(manifestFile).json()) as { actions_only?: boolean }).actions_only === true
|
|
187
|
+
const entry = env.BRUST_APP_ENTRY ?? (actionsOnly ? join(dist, 'index.js') : undefined)
|
|
319
188
|
if (!entry) throw new Error('BRUST_APP_ENTRY is not set')
|
|
320
|
-
const manifest = (await Bun.file(
|
|
321
|
-
|
|
322
|
-
|
|
189
|
+
const manifest = (await Bun.file(manifestFile).json()) as ManifestLike & { cache?: CacheManifest | null; actions_only?: boolean }
|
|
190
|
+
let routes: Route[] | undefined = []
|
|
191
|
+
if (!manifest.actions_only) {
|
|
192
|
+
installServerOnlyRuntime()
|
|
193
|
+
const app = await import(resolve(entry))
|
|
194
|
+
routes = (app.routes ?? app.default) as Route[] | undefined
|
|
195
|
+
}
|
|
323
196
|
if (!Array.isArray(routes)) throw new Error(`${entry} exports no routes (export const routes or default)`)
|
|
324
197
|
const jobsMod = await import(join(dist, manifest.jobs_module ?? 'jobs.js'))
|
|
325
198
|
const jobs = (jobsMod.default ?? jobsMod) as JobsModule
|
|
@@ -327,16 +200,27 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
|
|
|
327
200
|
// Lazy: elysia is an optional peer, so an app without [actions] must boot without it installed (R7).
|
|
328
201
|
const { loadActionsApp } = await import('./actions/load')
|
|
329
202
|
setActionsApp(await loadActionsApp(join(dist, manifest.actions.entry), manifest.actions.prefix))
|
|
203
|
+
setActionsLimits({ maxResponseBytes: manifest.actions.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES })
|
|
330
204
|
}
|
|
331
|
-
|
|
205
|
+
loaderCap = Number.parseInt(env.BRUST_MAX_LOADER_RESPONSE_BYTES ?? '', 10) || DEFAULT_MAX_LOADER_RESPONSE_BYTES
|
|
206
|
+
const slotBytes = Number.parseInt(env.BRUST_SLOT_BYTES ?? '', 10) || SLOT_BYTES
|
|
207
|
+
const workerId = Number.parseInt(env.BRUST_WORKER_ID ?? '0', 10) || 0
|
|
208
|
+
const l2 = manifest.cache ? await startCacheAdapter(manifest.cache, dist, dirname(resolve(entry)), workerId) : null
|
|
209
|
+
sab = opts.sab ?? new SharedArrayBuffer(slotBytes * slots)
|
|
332
210
|
view = new Uint8Array(sab)
|
|
333
211
|
const handlers = makeHandlers({
|
|
334
212
|
leaves: flattenRoutes(routes).leaves,
|
|
335
213
|
jobs,
|
|
336
214
|
manifest,
|
|
337
215
|
stores: (jobsMod.stores ?? {}) as Record<string, object>,
|
|
216
|
+
...(env.BRUST_SSG === '1' && env.BRUST_SSG_LOG ? { ssg: ssgLog(env.BRUST_SSG_LOG) } : {}),
|
|
338
217
|
})
|
|
339
|
-
|
|
218
|
+
const id = registerWorker(view, slots, makeDispatch(handlers, view, slots))
|
|
219
|
+
if (l2 && env.BRUST_WORKER_ID === '0') {
|
|
220
|
+
const { startSubscriber } = await import('./cache-adapter/runtime')
|
|
221
|
+
await startSubscriber(l2.adapter, l2.origin, { replay: (args, seq) => void cacheReplay(args, seq), clear: () => void cacheClear(), count })
|
|
222
|
+
}
|
|
223
|
+
return id
|
|
340
224
|
}
|
|
341
225
|
|
|
342
226
|
// Auto-start only inside a Bun `Worker` spawned by `run()` (BRUST_WORKER_ID set). Not
|