@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.5

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.
Files changed (47) hide show
  1. package/README.md +64 -6
  2. package/native/index.d.ts +39 -4
  3. package/native/index.js +56 -54
  4. package/package.json +17 -10
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +2 -2
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +199 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +39 -13
  14. package/src/build/cf-assets.ts +4 -2
  15. package/src/build/cf-entry.ts +7 -3
  16. package/src/build/cf-wrangler.ts +3 -1
  17. package/src/build/cloudflare.ts +14 -9
  18. package/src/build/compile.ts +41 -4
  19. package/src/build/config.ts +46 -10
  20. package/src/build/css.ts +286 -0
  21. package/src/build/index.ts +135 -20
  22. package/src/build/manifest.ts +23 -4
  23. package/src/build/scan.ts +59 -20
  24. package/src/build/ssg-render.ts +65 -0
  25. package/src/build/ssg.ts +146 -0
  26. package/src/build/static-worker.ts +91 -0
  27. package/src/build/static.ts +258 -0
  28. package/src/build-time.ts +10 -0
  29. package/src/cf.ts +2 -1
  30. package/src/cli.ts +19 -4
  31. package/src/config.ts +111 -25
  32. package/src/css-modules.d.ts +8 -0
  33. package/src/dev/bun.ts +365 -0
  34. package/src/dev/client.ts +92 -0
  35. package/src/dev/devtools-data.ts +23 -0
  36. package/src/dev/devtools.ts +356 -0
  37. package/src/dev/diag.ts +30 -0
  38. package/src/dev/proxy.ts +236 -0
  39. package/src/dev/tui.ts +144 -0
  40. package/src/dev/watch.ts +81 -0
  41. package/src/dev.ts +11 -28
  42. package/src/handlers.ts +41 -2
  43. package/src/index.ts +3 -1
  44. package/src/native.ts +18 -0
  45. package/src/routes.ts +20 -1
  46. package/src/run.ts +40 -8
  47. package/src/worker.ts +88 -20
package/src/native.ts CHANGED
@@ -22,6 +22,8 @@ export interface StartOptions {
22
22
  workers: number
23
23
  claimTimeoutMs?: number
24
24
  callTimeoutMs?: number
25
+ /** L10: the biggest loader/jobs reply that may cross the heap when it does not fit the slot; absent = 8388608. */
26
+ maxLoaderResponseBytes?: number
25
27
  /** tokio I/O threads; absent = one per core. */
26
28
  workerThreads?: number
27
29
  l1Capacity?: number
@@ -31,8 +33,12 @@ export interface StartOptions {
31
33
  cors?: CorsOptions
32
34
  /** The actions subtree (M3-F A3); absent = no actions. Boot error `actions: …` when invalid. */
33
35
  actions?: ActionsOptions
36
+ /** L16: what the page tells the browser `client()` (base URL, credentials, a non-default prefix); absent = from `actions` alone. */
37
+ actionsClient?: ActionsClientOptions
34
38
  /** The cache adapter (`manifest.cache`); absent = none (byte-identical server). */
35
39
  l2?: L2Options
40
+ /** `brust dev` only (L14): a request carrying this token (`x-brust-dev-token`) is traced; absent in production. */
41
+ devToken?: string
36
42
  }
37
43
  /** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
38
44
  export interface L2Options {
@@ -51,6 +57,12 @@ export interface CorsOptions {
51
57
  credentials?: boolean
52
58
  maxAgeSeconds?: number
53
59
  }
60
+ /** napi `NapiActionsClient`. */
61
+ export interface ActionsClientOptions {
62
+ prefix?: string
63
+ baseUrl?: string
64
+ credentials?: 'same-origin' | 'include' | 'omit'
65
+ }
54
66
  /** napi `NapiActionsOptions`; defaults `/_brust/action`, 1048576, 8388608. */
55
67
  export interface ActionsOptions {
56
68
  prefix?: string
@@ -71,6 +83,8 @@ export interface NativeBinding {
71
83
  localAddr(): string
72
84
  /** M3-P P6 — see crates/brust-napi/src/server.rs `plan_jobs`. Never throws. */
73
85
  planJobs(slot: number, len: number): string
86
+ /** L10 — `planJobs` for a loader response too large for the slot: the bytes are handed over directly. Never throws. */
87
+ planJobsBuf(slot: number, buf: Uint8Array): string
74
88
  /** F82 — the heap request body of the in-flight `fetch` call, once; null if none. Never throws. */
75
89
  takeFetchBody(slot: number): Uint8Array | null
76
90
  /** F82 — hand Rust a response body too large for the slot; false = declined. Never throws. */
@@ -81,6 +95,8 @@ export interface NativeBinding {
81
95
  cacheL2Count(name: string): boolean
82
96
  /** cache adapter R10 — drop both tier-1 caches (a broadcast gap); the L1 entries removed. */
83
97
  cacheClear(): number
98
+ /** Static output (gaps round R9.4): the request reads of a cache key expression; throws when it does not parse. */
99
+ cacheExprReads(src: string): string[]
84
100
  }
85
101
 
86
102
  let binding: NativeBinding | null = null
@@ -107,8 +123,10 @@ export const cacheInvalidate = (args: InvalidateArgs): InvalidateResult => nativ
107
123
  export const cacheStats = (): string => native().cacheStats()
108
124
  export const localAddr = (): string => native().localAddr()
109
125
  export const planJobs = (slot: number, len: number): string => native().planJobs(slot, len)
126
+ export const planJobsBuf = (slot: number, buf: Uint8Array): string => native().planJobsBuf(slot, buf)
110
127
  export const takeFetchBody = (slot: number): Uint8Array | null => native().takeFetchBody(slot)
111
128
  export const putFetchReply = (slot: number, body: Uint8Array): boolean => native().putFetchReply(slot, body)
112
129
  export const cacheReplay = (args: InvalidateArgs, seq: number | null): InvalidateResult => native().cacheReplay(args, seq)
113
130
  export const cacheL2Count = (name: string): boolean => native().cacheL2Count(name)
114
131
  export const cacheClear = (): number => native().cacheClear()
132
+ export const cacheExprReads = (src: string): string[] => native().cacheExprReads(src)
package/src/routes.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./css-modules.d.ts" />
1
2
  // Route tree, ids and loader verdicts (spec S4, S7 step 4). M2 routes carry ONLY
2
3
  // `path/Component/loader/cache/children`; every other 0.1.x field fails loudly (M3).
3
4
 
@@ -29,15 +30,21 @@ export interface LoaderCtx<P = Record<string, string>> {
29
30
  req: LoaderReq
30
31
  }
31
32
 
33
+ /** `brust build --target static` (gaps round R9.2): `params` lists the values of a dynamic route's
34
+ * `{name}` segments, one record per page; `false` leaves the route out of the static output. The
35
+ * server and Cloudflare targets ignore it. */
36
+ export type RouteSsgConfig<P = Record<string, string>> = { params?: () => P[] | Promise<P[]> } | false
37
+
32
38
  export interface Route<P = Record<string, string>, D = unknown> {
33
39
  path?: string
34
40
  Component?: ComponentType<any>
35
41
  loader?: (ctx: LoaderCtx<P>) => Promise<D> | D
36
42
  cache?: RouteCacheConfig
43
+ ssg?: RouteSsgConfig<P>
37
44
  children?: Route<any, any>[]
38
45
  }
39
46
 
40
- export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'children'] as const
47
+ export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'ssg', 'children'] as const
41
48
  const ALLOWED_CACHE_FIELDS = ['ttl_seconds', 'prefix', 'bypass', 'tags'] as const
42
49
  const M3_CACHE_FIELDS = ['key', 'key_ttl_seconds'] as const
43
50
 
@@ -61,6 +68,18 @@ function validate(node: Route, where: string): void {
61
68
  if (node.path !== undefined && typeof node.path !== 'string') throw new BrustRouteError(`route ${where}: path must be a string`)
62
69
  if (node.loader !== undefined && typeof node.loader !== 'function')
63
70
  throw new BrustRouteError(`route ${where}: loader must be a function`)
71
+ if (node.ssg !== undefined) {
72
+ const ssg = node.ssg as unknown
73
+ if (ssg !== false && (typeof ssg !== 'object' || ssg === null || Array.isArray(ssg)))
74
+ throw new BrustRouteError(`route ${where}: ssg must be false or { params }`)
75
+ if (ssg !== false) {
76
+ for (const k of Object.keys(ssg as object))
77
+ if (k !== 'params') throw new BrustRouteError(`route ${where}: unknown ssg field ssg.${k} (allowed: params)`)
78
+ const params = (ssg as { params?: unknown }).params
79
+ if (params !== undefined && typeof params !== 'function') throw new BrustRouteError(`route ${where}: ssg.params must be a function`)
80
+ }
81
+ if (node.children !== undefined) throw new BrustRouteError(`route ${where}: ssg on a layout route is not supported (put it on each leaf)`)
82
+ }
64
83
  if (node.cache !== undefined) {
65
84
  if (typeof node.cache !== 'object' || node.cache === null) throw new BrustRouteError(`route ${where}: cache must be an object`)
66
85
  for (const k of Object.keys(node.cache)) {
package/src/run.ts CHANGED
@@ -8,41 +8,59 @@
8
8
  // does not end it, so every exit is an explicit `process.exit`.
9
9
  import { existsSync, readFileSync } from 'node:fs'
10
10
  import { join, resolve } from 'node:path'
11
- import { type BrustConfig, loadConfig } from './config'
12
- import { beginDrain, type StartOptions, startServer, untilReady } from './native'
13
- import { SLOT_BYTES } from './worker'
11
+ import { type BrustConfig, type ConfigOverrides, loadConfig } from './config'
12
+ import { beginDrain, localAddr, type StartOptions, startServer, untilReady } from './native'
13
+ import { DEFAULT_MAX_LOADER_RESPONSE_BYTES } from './worker'
14
14
 
15
15
  export interface RunOptions {
16
16
  distDir?: string
17
17
  /** The routes module the workers import (`export const routes` or default). */
18
18
  entry?: string
19
19
  /** Overrides below env (CLI flags): see config.ts (incl. `bootTimeoutMs`, default 30000). */
20
- config?: Partial<BrustConfig>
20
+ config?: ConfigOverrides
21
+ /** Static output (gaps round L9): runs once every worker is ready, with the bound `host:port`;
22
+ * then the server drains and the process exits with the returned code. */
23
+ onReady?: (addr: string) => Promise<number>
21
24
  }
22
25
 
23
26
  /** The manifest fields `run` needs (build/manifest.ts owns the full shape). */
24
27
  export interface RunManifest {
25
28
  actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null
29
+ /** L16 R16.3: `brust build --actions-only`: no page routes and no routes entry. */
30
+ actions_only?: boolean
31
+ /** L16 R16.4b: a page-only build; the actions app is deployed elsewhere (`[actions] base_url`). */
32
+ actions_remote?: { prefix: string } | null
26
33
  /** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
27
34
  cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string } | null
28
35
  }
29
36
 
30
37
  /** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
31
- export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string): StartOptions {
38
+ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string): StartOptions {
32
39
  return {
33
40
  host: cfg.host,
34
41
  port: cfg.port,
35
42
  distDir,
36
43
  workers: cfg.workers,
37
44
  callTimeoutMs: cfg.callTimeoutMs,
45
+ ...(cfg.maxLoaderResponseBytes !== undefined && cfg.maxLoaderResponseBytes !== DEFAULT_MAX_LOADER_RESPONSE_BYTES ? { maxLoaderResponseBytes: cfg.maxLoaderResponseBytes } : {}),
38
46
  workerThreads: cfg.ioThreads,
39
47
  generator: `brust/${version}`,
40
48
  ...(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 } : {}) } } : {}),
49
+ ...(actionsClient(cfg, m) ? { actionsClient: actionsClient(cfg, m) } : {}),
41
50
  ...(cfg.cors ? { cors: cfg.cors } : {}),
51
+ ...(devToken ? { devToken } : {}),
42
52
  ...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
43
53
  }
44
54
  }
45
55
 
56
+ /** L16: what the page tells the browser `client()` (R16.1/R16.2); absent while it would only repeat the prefix script. */
57
+ function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptions['actionsClient']> | undefined {
58
+ const credentials = cfg.actionsCredentials !== undefined && cfg.actionsCredentials !== 'same-origin' ? cfg.actionsCredentials : undefined
59
+ if (cfg.actionsBaseUrl === undefined && credentials === undefined && !m.actions_remote) return undefined
60
+ const prefix = m.actions?.prefix ?? m.actions_remote?.prefix
61
+ return { ...(prefix ? { prefix } : {}), ...(cfg.actionsBaseUrl !== undefined ? { baseUrl: cfg.actionsBaseUrl } : {}), ...(credentials ? { credentials } : {}) }
62
+ }
63
+
46
64
  /** Every worker's response buffer, kept reachable until the process exits (see run()). */
47
65
  const workerBuffers: SharedArrayBuffer[] = []
48
66
 
@@ -63,7 +81,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
63
81
  fail(`bad manifest ${manifest}: ${(e as Error).message}`)
64
82
  }
65
83
  const entry = resolve(process.env.BRUST_APP_ENTRY || opts.entry || 'routes.tsx')
66
- if (!existsSync(entry)) fail(`routes entry ${entry} not found`)
84
+ // R16.3: an actions-only dist has no pages, so no routes module either.
85
+ if (!m.actions_only && !existsSync(entry)) fail(`routes entry ${entry} not found`)
67
86
 
68
87
  let cfg: BrustConfig
69
88
  try {
@@ -72,9 +91,11 @@ export async function run(opts: RunOptions = {}): Promise<void> {
72
91
  fail((e as Error).message)
73
92
  }
74
93
 
94
+ if (m.actions_remote && cfg.actionsBaseUrl === undefined)
95
+ fail('this build has no local actions app: set [actions] base_url (or BRUST_ACTIONS_BASE_URL) to the deployment that serves them')
75
96
  const version: string = (await Bun.file(join(import.meta.dir, '../package.json')).json()).version
76
97
  try {
77
- startServer(startOptions(cfg, distDir, m, version))
98
+ startServer(startOptions(cfg, distDir, m, version, process.env.BRUST_DEV_TOKEN || undefined))
78
99
  } catch (e) {
79
100
  fail((e as Error).message)
80
101
  }
@@ -87,6 +108,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
87
108
  ...process.env,
88
109
  NODE_ENV: process.env.NODE_ENV,
89
110
  BRUST_RENDER_SLOTS: String(cfg.renderSlots),
111
+ BRUST_SLOT_BYTES: String(cfg.slotBytes),
112
+ BRUST_MAX_LOADER_RESPONSE_BYTES: String(cfg.maxLoaderResponseBytes),
90
113
  BRUST_DIST_DIR: distDir,
91
114
  BRUST_APP_ENTRY: entry,
92
115
  // One cache-adapter origin per process: its workers' published events are not replayed here.
@@ -115,7 +138,7 @@ export async function run(opts: RunOptions = {}): Promise<void> {
115
138
  for (let i = 0; i < cfg.workers; i++) {
116
139
  // The response buffer is owned HERE, for the process lifetime: the Rust dispatcher keeps a raw
117
140
  // pointer into it, so it must not be freed with the worker (crates/brust-napi dispatch.rs).
118
- const sab = new SharedArrayBuffer(SLOT_BYTES * cfg.renderSlots)
141
+ const sab = new SharedArrayBuffer(cfg.slotBytes * cfg.renderSlots)
119
142
  workerBuffers.push(sab)
120
143
  const w = new Worker(url, { env: { ...env, BRUST_WORKER_ID: String(i) }, workerData: { sab } } as WorkerOptions)
121
144
  // No respawn in M2: a worker that fails to boot or dies later takes the process down loudly
@@ -158,5 +181,14 @@ export async function run(opts: RunOptions = {}): Promise<void> {
158
181
  }
159
182
  ready = true
160
183
  console.log(`[brust] ready (${cfg.workers} worker${cfg.workers === 1 ? '' : 's'})`)
184
+ if (opts.onReady) {
185
+ let code = 1
186
+ try {
187
+ code = await opts.onReady(localAddr())
188
+ } catch (e) {
189
+ console.error(`[brust] ${(e as Error).stack ?? String(e)}`)
190
+ }
191
+ if (!draining) drainThenExit(code)
192
+ }
161
193
  await new Promise<never>(() => {})
162
194
  }
package/src/worker.ts CHANGED
@@ -1,14 +1,15 @@
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 { appendFileSync } from 'node:fs'
4
5
  import { dirname, join, resolve } from 'node:path'
5
6
  import { workerData } from 'node:worker_threads'
6
7
  import { type HeapIo, runFetch, setActionsApp, setActionsLimits } from './actions-bridge'
7
8
  import { DEFAULT_MAX_RESPONSE_BYTES } from './actions/prefix'
8
9
  import { type CacheBridgeDeps, runCache } from './cache-adapter/bridge'
9
10
  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'
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'
12
13
  import { flattenRoutes, type Route } from './routes'
13
14
 
14
15
  export * from './handlers'
@@ -17,24 +18,58 @@ export * from './handlers'
17
18
 
18
19
  const enc = new TextEncoder()
19
20
 
21
+ /** L10: the default `maxLoaderResponseBytes` (config.ts keeps the range). */
22
+ export const DEFAULT_MAX_LOADER_RESPONSE_BYTES = 8_388_608
23
+
24
+ /** L10: a reply too large for the slot crosses by heap: `HeapIo.putReply` hands Rust the bytes, and the slot holds
25
+ * the 5-byte marker `[0x00][u32 LE n]` (JSON never starts with 0x00). `cap` = `maxLoaderResponseBytes`. */
26
+ export const HEAP_MARKER_BYTES = 5
27
+ export interface HeapReply {
28
+ io: HeapIo
29
+ /** `planJobsBuf` (napi): plan a loader response handed over directly. */
30
+ planBuf: (slot: number, buf: Uint8Array) => string
31
+ /** Default: the worker's `maxLoaderResponseBytes` (env `BRUST_MAX_LOADER_RESPONSE_BYTES`, 8 MiB). */
32
+ cap?: number
33
+ }
34
+ const nativeHeap: HeapReply = { io: cacheHeapIo(), planBuf: (slot, buf) => planJobsBuf(slot, buf) }
35
+ let loaderCap = DEFAULT_MAX_LOADER_RESPONSE_BYTES
36
+
20
37
  /** Writes `json` as UTF-8 at the start of `slot`'s sub-region `[slot*sub, slot*sub+sub)`,
21
38
  * `sub = floor(view.byteLength / slots)`; returns the byte length (> 0, ≤ sub). ONE copy: the
22
39
  * encoder writes straight into the slot (M3-P P1); it stops at the last whole code point that
23
40
  * fits, so `read < json.length` means "did not fit" and the neighbouring slot is never touched.
24
- * A response that does not fit is replaced by a small `{"error":"response too large: <n> > <sub>"}`
25
- * (`n` = its exact UTF-8 length, computed only on this path). */
26
- export function writeSlot(view: Uint8Array, slot: number, slots: number, json: string): number {
41
+ * A response that does not fit goes by heap when `io` and `cap` are given (L10): encoded once, handed to
42
+ * Rust with `io.putReply`, and the slot holds the 5-byte marker. Over `cap` (or declined), or with no `io`, it is
43
+ * replaced by a small `{"error":"response too large: <n> > <limit>"}` (`n` = its exact UTF-8 length, computed
44
+ * only on this path). */
45
+ export function writeSlot(view: Uint8Array, slot: number, slots: number, json: string, io?: HeapIo, cap?: number): number {
27
46
  const sub = Math.floor(view.byteLength / Math.max(1, slots))
28
47
  const dst = view.subarray(slot * sub, slot * sub + sub)
29
48
  const r = enc.encodeInto(json, dst)
30
49
  if (r.read === json.length) return r.written
31
- const err = JSON.stringify({ error: `response too large: ${Buffer.byteLength(json)} > ${sub}` })
50
+ let why = `response too large: ${Buffer.byteLength(json)} > ${sub}`
51
+ if (io && cap !== undefined && sub >= HEAP_MARKER_BYTES) {
52
+ const bytes = enc.encode(json)
53
+ const n = heapMarker(dst, slot, bytes, io, cap)
54
+ if (n > 0) return n
55
+ // Name the real reason: over the cap, or a put Rust declined while under it (idle slot, a second put).
56
+ 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`
57
+ }
58
+ const err = JSON.stringify({ error: why })
32
59
  const e = enc.encodeInto(err, dst)
33
60
  if (e.read === err.length) return e.written
34
61
  // Not even the message fits: the first `sub` bytes of a fixed one (ASCII, so byte = char).
35
62
  return enc.encodeInto('{"error":"too large"}', dst).written
36
63
  }
37
64
 
65
+ /** Hand `bytes` to Rust and write the marker at `dst`; 0 when over `cap` or Rust declined. */
66
+ function heapMarker(dst: Uint8Array, slot: number, bytes: Uint8Array, io: HeapIo, cap: number): number {
67
+ if (bytes.length > cap || !io.putReply(slot, bytes)) return 0
68
+ dst[0] = 0
69
+ new DataView(dst.buffer, dst.byteOffset + 1, 4).setUint32(0, bytes.length, true)
70
+ return HEAP_MARKER_BYTES
71
+ }
72
+
38
73
  /** `planJobs` (napi): the misses' JobsRequest JSON, '' (every job hit) or PLAN_DECLINED. */
39
74
  export type PlanJobs = (slot: number, len: number) => string
40
75
  /** `planJobs` declined: the loader response already in the slot is the answer (Rust plans after the call). */
@@ -44,11 +79,13 @@ const PLANNED = { planned: true } as const
44
79
  /** The tsfn callback for `registerWorker`. Never rejects: a parse error, an unknown kind or a
45
80
  * throwing handler is written to the slot as `{error}`. A loader request with `plan: true` whose
46
81
  * chain answered `ok` is planned in the same call (`plan`, the napi `planJobs` by default — the
47
- * addon is touched only then). */
48
- export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan: PlanJobs = planJobs) {
82
+ * addon is touched only then). A loader/jobs/planned reply that does not fit the slot crosses by heap
83
+ * (`heap`, the napi addon by default; L10). */
84
+ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan: PlanJobs = planJobs, heap: HeapReply = nativeHeap) {
85
+ const cap = () => heap.cap ?? loaderCap
49
86
  return async (kind: string, requestJson: string, slot: number): Promise<number> => {
50
87
  if (kind === 'fetch') return runFetch(requestJson, view, slot, slots)
51
- if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo, cacheDeps)
88
+ if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo(), cacheDeps)
52
89
  let out: unknown
53
90
  try {
54
91
  const req = JSON.parse(requestJson)
@@ -58,9 +95,20 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
58
95
  // One round trip (M3-P P6): the ctx goes to Rust through this slot, synchronously, and only
59
96
  // the jobs Rust found missing run here; the final answer replaces it in the same slot.
60
97
  const json = toJson(out)
61
- const n = writeSlot(view, slot, slots, json)
62
- const misses = plan(slot, n)
63
- if (misses === PLAN_DECLINED) return n
98
+ const sub = Math.floor(view.byteLength / Math.max(1, slots))
99
+ const dst = view.subarray(slot * sub, slot * sub + sub)
100
+ const w = enc.encodeInto(json, dst)
101
+ let misses: string
102
+ if (w.read === json.length) {
103
+ misses = plan(slot, w.written)
104
+ if (misses === PLAN_DECLINED) return w.written
105
+ } else {
106
+ // L10: too large for the slot — plan from the bytes directly; a declined plan hands them over as the answer.
107
+ const bytes = enc.encode(json)
108
+ if (bytes.length > cap()) return writeSlot(view, slot, slots, json, heap.io, cap())
109
+ misses = heap.planBuf(slot, bytes)
110
+ if (misses === PLAN_DECLINED) return writeSlot(view, slot, slots, json, heap.io, cap())
111
+ }
64
112
  out = misses === '' ? PLANNED : { planned: true, results: (await h.jobs(JSON.parse(misses))).results }
65
113
  }
66
114
  } else if (kind === 'jobs') out = await h.jobs(req)
@@ -68,13 +116,15 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
68
116
  } catch (e) {
69
117
  out = { error: String(e) }
70
118
  }
71
- return writeSlot(view, slot, slots, toJson(out))
119
+ return writeSlot(view, slot, slots, toJson(out), heap.io, cap())
72
120
  }
73
121
  }
74
122
 
75
123
  // ---- cache adapter (the `cache` call kind) ----
76
124
 
77
- const cacheHeapIo: HeapIo = { takeBody: (slot) => takeFetchBody(slot), putReply: (slot, body) => putFetchReply(slot, body) }
125
+ function cacheHeapIo(): HeapIo {
126
+ return { takeBody: (slot) => takeFetchBody(slot), putReply: (slot, body) => putFetchReply(slot, body) }
127
+ }
78
128
  const count = (name: string) => void cacheL2Count(name)
79
129
  let cacheDeps: CacheBridgeDeps = { store: undefined, getTimeoutMs: 50, maxBodyBytes: 1_048_576, count }
80
130
 
@@ -97,12 +147,20 @@ async function startCacheAdapter(m: CacheManifest, dist: string, appDir: string,
97
147
  return { adapter, origin }
98
148
  }
99
149
 
150
+ // ---- static output (gaps round R9.5) ----
151
+
152
+ /** Appends each record as one JSON line to `file` (every worker of the render appends to it). */
153
+ function ssgLog(file: string): (r: SsgRecord) => void {
154
+ return (r) => appendFileSync(file, `${JSON.stringify(r)}\n`)
155
+ }
156
+
100
157
  // ---- worker entry ----
101
158
 
159
+ /** The default response slot (`slotBytes`, config.ts: 64 KiB..16 MiB). */
102
160
  export const SLOT_BYTES = 256 * 1024
103
161
 
104
162
  export interface StartWorkerOptions {
105
- /** The response buffer to register (`SLOT_BYTES * slots` bytes). `run()` allocates it on the
163
+ /** The response buffer to register (`slotBytes * slots` bytes). `run()` allocates it on the
106
164
  * MAIN thread and keeps it for the process lifetime, passing it as `workerData.sab`: the Rust
107
165
  * dispatcher holds a raw pointer into it until the process exits, so its backing store must not
108
166
  * die with this worker (`crates/brust-napi/src/dispatch.rs` BufPtr). Absent (tests, a worker
@@ -121,11 +179,18 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
121
179
  const env = process.env
122
180
  const slots = Math.max(1, Number.parseInt(env.BRUST_RENDER_SLOTS ?? '1', 10) || 1)
123
181
  const dist = resolve(env.BRUST_DIST_DIR ?? 'dist')
124
- const entry = env.BRUST_APP_ENTRY
182
+ // L16 R16.3: an actions-only dist has no routes module (so no BRUST_APP_ENTRY either).
183
+ const manifestFile = join(dist, 'manifest.json')
184
+ const hasManifest = await Bun.file(manifestFile).exists()
185
+ const actionsOnly = hasManifest && !env.BRUST_APP_ENTRY && ((await Bun.file(manifestFile).json()) as { actions_only?: boolean }).actions_only === true
186
+ const entry = env.BRUST_APP_ENTRY ?? (actionsOnly ? join(dist, 'index.js') : undefined)
125
187
  if (!entry) throw new Error('BRUST_APP_ENTRY is not set')
126
- const manifest = (await Bun.file(join(dist, 'manifest.json')).json()) as ManifestLike & { cache?: CacheManifest | null }
127
- const app = await import(resolve(entry))
128
- const routes = (app.routes ?? app.default) as Route[] | undefined
188
+ const manifest = (await Bun.file(manifestFile).json()) as ManifestLike & { cache?: CacheManifest | null; actions_only?: boolean }
189
+ let routes: Route[] | undefined = []
190
+ if (!manifest.actions_only) {
191
+ const app = await import(resolve(entry))
192
+ routes = (app.routes ?? app.default) as Route[] | undefined
193
+ }
129
194
  if (!Array.isArray(routes)) throw new Error(`${entry} exports no routes (export const routes or default)`)
130
195
  const jobsMod = await import(join(dist, manifest.jobs_module ?? 'jobs.js'))
131
196
  const jobs = (jobsMod.default ?? jobsMod) as JobsModule
@@ -135,15 +200,18 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
135
200
  setActionsApp(await loadActionsApp(join(dist, manifest.actions.entry), manifest.actions.prefix))
136
201
  setActionsLimits({ maxResponseBytes: manifest.actions.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES })
137
202
  }
203
+ loaderCap = Number.parseInt(env.BRUST_MAX_LOADER_RESPONSE_BYTES ?? '', 10) || DEFAULT_MAX_LOADER_RESPONSE_BYTES
204
+ const slotBytes = Number.parseInt(env.BRUST_SLOT_BYTES ?? '', 10) || SLOT_BYTES
138
205
  const workerId = Number.parseInt(env.BRUST_WORKER_ID ?? '0', 10) || 0
139
206
  const l2 = manifest.cache ? await startCacheAdapter(manifest.cache, dist, dirname(resolve(entry)), workerId) : null
140
- sab = opts.sab ?? new SharedArrayBuffer(SLOT_BYTES * slots)
207
+ sab = opts.sab ?? new SharedArrayBuffer(slotBytes * slots)
141
208
  view = new Uint8Array(sab)
142
209
  const handlers = makeHandlers({
143
210
  leaves: flattenRoutes(routes).leaves,
144
211
  jobs,
145
212
  manifest,
146
213
  stores: (jobsMod.stores ?? {}) as Record<string, object>,
214
+ ...(env.BRUST_SSG === '1' && env.BRUST_SSG_LOG ? { ssg: ssgLog(env.BRUST_SSG_LOG) } : {}),
147
215
  })
148
216
  const id = registerWorker(view, slots, makeDispatch(handlers, view, slots))
149
217
  if (l2 && env.BRUST_WORKER_ID === '0') {