@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.4
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 +64 -6
- package/native/index.d.ts +37 -2
- package/native/index.js +56 -54
- package/package.json +11 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +24 -2
- package/src/actions-bridge.ts +2 -2
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +199 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +39 -13
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +7 -3
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +14 -9
- package/src/build/compile.ts +41 -4
- package/src/build/config.ts +46 -10
- package/src/build/css.ts +286 -0
- package/src/build/index.ts +135 -20
- package/src/build/manifest.ts +23 -4
- package/src/build/scan.ts +59 -20
- 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-time.ts +10 -0
- package/src/cf.ts +2 -1
- package/src/cli.ts +19 -4
- package/src/config.ts +111 -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 +11 -28
- package/src/handlers.ts +41 -2
- package/src/index.ts +3 -1
- package/src/native.ts +18 -0
- package/src/routes.ts +20 -1
- package/src/run.ts +40 -8
- 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 {
|
|
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?:
|
|
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
|
-
|
|
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(
|
|
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
|
|
25
|
-
*
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
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 (`
|
|
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
|
-
|
|
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(
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
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') {
|