@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (68) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +91 -4
  3. package/native/index.js +60 -54
  4. package/package.json +25 -9
  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 +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +332 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +462 -52
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +320 -0
  19. package/src/build/compile.ts +116 -6
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +331 -0
  22. package/src/build/esm-resolve.ts +170 -0
  23. package/src/build/features.ts +114 -0
  24. package/src/build/index.ts +264 -20
  25. package/src/build/manifest.ts +85 -7
  26. package/src/build/pkg-src.ts +514 -0
  27. package/src/build/recognised.ts +19 -0
  28. package/src/build/scan.ts +71 -20
  29. package/src/build/server-only.ts +39 -0
  30. package/src/build/ssg-render.ts +65 -0
  31. package/src/build/ssg.ts +146 -0
  32. package/src/build/static-worker.ts +91 -0
  33. package/src/build/static.ts +258 -0
  34. package/src/build/tailwind.ts +117 -0
  35. package/src/build/tsconfig-paths.ts +67 -0
  36. package/src/build-time.ts +10 -0
  37. package/src/cache-adapter/bridge.ts +91 -0
  38. package/src/cache-adapter/conformance.ts +226 -0
  39. package/src/cache-adapter/index.ts +10 -0
  40. package/src/cache-adapter/load.ts +144 -0
  41. package/src/cache-adapter/memory.ts +229 -0
  42. package/src/cache-adapter/runtime.ts +124 -0
  43. package/src/cache-adapter/types.ts +96 -0
  44. package/src/cache-adapter/workers.ts +118 -0
  45. package/src/cache.ts +4 -1
  46. package/src/cf.ts +19 -0
  47. package/src/cli.ts +71 -2
  48. package/src/config.ts +127 -25
  49. package/src/css-modules.d.ts +8 -0
  50. package/src/dev/bun.ts +371 -0
  51. package/src/dev/client.ts +92 -0
  52. package/src/dev/devtools-data.ts +23 -0
  53. package/src/dev/devtools.ts +356 -0
  54. package/src/dev/diag.ts +30 -0
  55. package/src/dev/proxy.ts +236 -0
  56. package/src/dev/tui.ts +144 -0
  57. package/src/dev/watch.ts +130 -0
  58. package/src/dev.ts +97 -0
  59. package/src/handlers.ts +282 -0
  60. package/src/index.ts +3 -1
  61. package/src/native.ts +38 -1
  62. package/src/routes.ts +20 -1
  63. package/src/run.ts +59 -12
  64. package/src/server-handlers.ts +32 -0
  65. package/src/shadcn/init.ts +513 -0
  66. package/src/shadcn/jsonc.ts +129 -0
  67. package/src/shadcn/registry.ts +149 -0
  68. package/src/worker.ts +127 -245
@@ -0,0 +1,149 @@
1
+ // F228: a registry URL given to `brust shadcn init` names a third-party item that declares its own file targets, known
2
+ // only once the item is fetched. This fetches the item JSON BEFORE the shadcn CLI is spawned (https only, a size cap, a
3
+ // timeout, redirects followed by hand and only to https) and checks every path it can make the CLI write. The command
4
+ // then hands the CLI the file it checked, not the URL, so the item cannot change between the check and the write.
5
+
6
+ /** The most bytes of item JSON read; a bigger answer is refused. */
7
+ export const ITEM_MAX_BYTES = 1024 * 1024
8
+ /** The whole fetch (redirects included) gets this long. */
9
+ export const ITEM_TIMEOUT_MS = 15_000
10
+ /** Redirects followed by hand; each target must be https. */
11
+ export const MAX_REDIRECTS = 3
12
+
13
+ export interface FetchOptions {
14
+ /** URL schemes allowed for the request and every redirect. Default `https:` only; a test passes `http:` for a local server. */
15
+ schemes?: string[]
16
+ maxBytes?: number
17
+ timeoutMs?: number
18
+ }
19
+
20
+ /** Fetches `url` and returns the body text, or throws an `Error` whose message is the reason it was refused. */
21
+ export async function fetchRegistryItem(url: string, o: FetchOptions = {}): Promise<string> {
22
+ const schemes = o.schemes ?? ['https:']
23
+ const maxBytes = o.maxBytes ?? ITEM_MAX_BYTES
24
+ const signal = AbortSignal.timeout(o.timeoutMs ?? ITEM_TIMEOUT_MS)
25
+ let cur = url
26
+ try {
27
+ for (let hop = 0; ; hop++) {
28
+ let u: URL
29
+ try {
30
+ u = new URL(cur)
31
+ } catch {
32
+ throw new Error(`${cur} is not a URL`)
33
+ }
34
+ if (!schemes.includes(u.protocol)) throw new Error(`${cur} is not an ${schemes.join(' or ').replace(/:/g, '')} URL`)
35
+ if (u.username !== '' || u.password !== '') throw new Error(`${u.origin}${u.pathname} carries credentials in the URL`)
36
+ const res = await fetch(u, { redirect: 'manual', signal, headers: { accept: 'application/json' } })
37
+ if (res.status >= 300 && res.status < 400) {
38
+ const next = res.headers.get('location')
39
+ await res.body?.cancel()
40
+ if (!next) throw new Error(`${cur} answered ${res.status} with no Location`)
41
+ if (hop >= MAX_REDIRECTS) throw new Error(`${url} redirects more than ${MAX_REDIRECTS} times`)
42
+ cur = new URL(next, u).toString() // the next loop turn refuses a non-https target
43
+ continue
44
+ }
45
+ if (!res.ok) {
46
+ await res.body?.cancel()
47
+ throw new Error(`${cur} answered HTTP ${res.status}`)
48
+ }
49
+ const declared = Number(res.headers.get('content-length'))
50
+ if (Number.isFinite(declared) && declared > maxBytes) {
51
+ await res.body?.cancel()
52
+ throw new Error(`${cur} is ${declared} bytes; the limit is ${maxBytes}`)
53
+ }
54
+ const reader = res.body?.getReader()
55
+ if (!reader) return ''
56
+ const chunks: Uint8Array[] = []
57
+ let size = 0
58
+ for (;;) {
59
+ const { done, value } = await reader.read()
60
+ if (done) break
61
+ size += value.length
62
+ if (size > maxBytes) {
63
+ await reader.cancel()
64
+ throw new Error(`${cur} is bigger than the limit of ${maxBytes} bytes`)
65
+ }
66
+ chunks.push(value)
67
+ }
68
+ return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
69
+ }
70
+ } catch (e) {
71
+ if (signal.aborted) throw new Error(`${url} did not answer within ${(o.timeoutMs ?? ITEM_TIMEOUT_MS) / 1000} s`)
72
+ throw e instanceof Error ? e : new Error(String(e))
73
+ }
74
+ }
75
+
76
+ /** True for a path that is not a plain relative path: absolute, a drive, a backslash, a NUL, a control character or `..`. */
77
+ export function unsafeRel(p: string): boolean {
78
+ return p === '' || p.startsWith('/') || /^[a-zA-Z]:/.test(p) || p.includes('\\') || /[\x00-\x1f]/.test(p) || p.split('/').some((s) => s === '..') || (p.includes('%') && /%2e|%2f|%5c|%00/i.test(p))
79
+ }
80
+
81
+ export interface VettedItem {
82
+ /** Project-relative paths the CLI may write for this item, beyond the alias directories (checked for symlinks). */
83
+ paths: string[]
84
+ }
85
+
86
+ const PLAIN_NAME = /^[a-z0-9][a-z0-9-]*$/
87
+
88
+ /** The top-level keys of an item in the pinned registry schema (`@shadcn/registry` 0.1.3). Allowlist, not denylist: a key
89
+ * this does not name (`font`, which makes the CLI write the CSS entry and a layout file, or one a later schema adds) is a
90
+ * refusal, because the CLI could write something this never judged. */
91
+ const ITEM_KEYS = new Set(['$schema', 'extends', 'name', 'type', 'title', 'author', 'description', 'dependencies', 'devDependencies', 'registryDependencies', 'files', 'tailwind', 'cssVars', 'css', 'envVars', 'meta', 'docs', 'categories'])
92
+ /** Item types whose files the CLI rewrites to a framework path (`registry:page`) or whose write set is not judged (`registry:font`). */
93
+ const REFUSED_TYPES = new Set(['registry:font', 'registry:page'])
94
+
95
+ /**
96
+ * Judges item JSON: the file targets it declares and the other things it makes the CLI write or fetch. Returns the
97
+ * paths to check for symlinks, or the reason it is refused. Fail closed: anything it cannot judge is a refusal.
98
+ */
99
+ export function vetItem(text: string, cssEntry: string): VettedItem | { error: string } {
100
+ let item: unknown
101
+ try {
102
+ item = JSON.parse(text)
103
+ } catch {
104
+ return { error: 'is not valid JSON' }
105
+ }
106
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) return { error: 'is not a JSON object' }
107
+ const o = item as Record<string, unknown>
108
+ if (o.type !== undefined && (typeof o.type !== 'string' || REFUSED_TYPES.has(o.type))) return { error: `is of type ${JSON.stringify(o.type)}, whose files the CLI places by rules this check does not model` }
109
+ const unknown = Object.keys(o).find((k) => !ITEM_KEYS.has(k))
110
+ if (unknown !== undefined) return { error: `has the key ${JSON.stringify(unknown)}, which this check does not know; it may make the CLI write files that cannot be checked` }
111
+ const paths: string[] = []
112
+ if (o.files !== undefined) {
113
+ if (!Array.isArray(o.files)) return { error: '"files" is not an array' }
114
+ for (const f of o.files) {
115
+ if (typeof f !== 'object' || f === null) return { error: 'has a file entry that is not an object' }
116
+ const { path, target } = f as { path?: unknown; target?: unknown }
117
+ if (path !== undefined && typeof path !== 'string') return { error: 'has a file with a "path" that is not a string' }
118
+ if (target !== undefined && typeof target !== 'string') return { error: 'has a file with a "target" that is not a string' }
119
+ if (typeof path === 'string' && unsafeRel(path)) return { error: `declares the file path ${JSON.stringify(path)}, which is not a plain path inside the project` }
120
+ if (typeof target === 'string' && target !== '') {
121
+ // The CLI's own rules (resolveFilePath): `~/x` is project-relative; `@components|ui|lib|hooks/x` is inside that alias
122
+ // directory (scanned already); any other target is joined to the project root, once as written and, in a `src/`
123
+ // project, under `src/` (it drops one `src/` from the target either way). All of them are checked.
124
+ const t = target.startsWith('~/') ? target.slice(2) : target
125
+ const alias = /^@(components|ui|lib|hooks)\/(.+)$/.exec(t)
126
+ if (alias) {
127
+ if (unsafeRel(alias[2]!)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
128
+ } else {
129
+ const rel = t.replace(/^@([^/]+)\//, '$1/')
130
+ if (unsafeRel(rel)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
131
+ const bare = rel.replace('src/', '')
132
+ paths.push(rel, bare, `src/${bare}`)
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // what else the CLI writes: the Tailwind CSS entry (css, cssVars), env files (envVars)
138
+ if (o.css !== undefined || o.cssVars !== undefined || o.tailwind !== undefined) paths.push(cssEntry)
139
+ if (o.envVars !== undefined) paths.push('.env', '.env.local')
140
+ // dependencies on other items the CLI would fetch after this check (a `config` that would merge into components.json is an unknown key, refused above)
141
+ const deps = [...(Array.isArray(o.registryDependencies) ? o.registryDependencies : o.registryDependencies === undefined ? [] : [null]), ...(o.extends === undefined ? [] : [o.extends])]
142
+ for (const d of deps) {
143
+ if (typeof d !== 'string') return { error: 'has a registryDependencies / extends entry that is not a string' }
144
+ if (!PLAIN_NAME.test(d)) {
145
+ return { error: `depends on ${JSON.stringify(d)}, which the CLI would fetch after this check; pass that URL to the command as its own argument so it is checked too` }
146
+ }
147
+ }
148
+ return { paths }
149
+ }
package/src/worker.ts CHANGED
@@ -1,274 +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 { join, resolve } from 'node:path'
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, setActionsLimits } from './actions-bridge'
7
+ import { type HeapIo, runFetch, setActionsApp, setActionsLimits } from './actions-bridge'
7
8
  import { DEFAULT_MAX_RESPONSE_BYTES } from './actions/prefix'
8
- import { planJobs, registerWorker } from './native'
9
- import { installServerResolver, type StoreSnapshot } from './store/core-adapter'
10
- import { collectSnapshot, getServerInstance, runInStoreContext, touchStores } from './store/server-context'
11
- import { type FlatRoute, flattenRoutes, isHttpErrorTrigger, isVerdict, type LoaderReq, type Route, type Verdict } from './routes'
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'
12
15
 
13
- installServerResolver(getServerInstance)
16
+ export * from './handlers'
14
17
 
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 }
18
+ // ---- SAB slot writer ----
53
19
 
54
- export interface JobsResponse {
55
- results: JobResult[]
56
- }
20
+ const enc = new TextEncoder()
57
21
 
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> }>
22
+ /** L10: the default `maxLoaderResponseBytes` (config.ts keeps the range). */
23
+ export const DEFAULT_MAX_LOADER_RESPONSE_BYTES = 8_388_608
61
24
 
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
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
76
34
  }
77
-
78
- // ---- SAB slot writer ----
79
-
80
- const enc = new TextEncoder()
35
+ const nativeHeap: HeapReply = { io: cacheHeapIo(), planBuf: (slot, buf) => planJobsBuf(slot, buf) }
36
+ let loaderCap = DEFAULT_MAX_LOADER_RESPONSE_BYTES
81
37
 
82
38
  /** Writes `json` as UTF-8 at the start of `slot`'s sub-region `[slot*sub, slot*sub+sub)`,
83
39
  * `sub = floor(view.byteLength / slots)`; returns the byte length (> 0, ≤ sub). ONE copy: the
84
40
  * encoder writes straight into the slot (M3-P P1); it stops at the last whole code point that
85
41
  * fits, so `read < json.length` means "did not fit" and the neighbouring slot is never touched.
86
- * A response that does not fit is replaced by a small `{"error":"response too large: <n> > <sub>"}`
87
- * (`n` = its exact UTF-8 length, computed only on this path). */
88
- export function writeSlot(view: Uint8Array, slot: number, slots: number, json: string): number {
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 {
89
47
  const sub = Math.floor(view.byteLength / Math.max(1, slots))
90
48
  const dst = view.subarray(slot * sub, slot * sub + sub)
91
49
  const r = enc.encodeInto(json, dst)
92
50
  if (r.read === json.length) return r.written
93
- const err = JSON.stringify({ error: `response too large: ${Buffer.byteLength(json)} > ${sub}` })
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 })
94
60
  const e = enc.encodeInto(err, dst)
95
61
  if (e.read === err.length) return e.written
96
62
  // Not even the message fits: the first `sub` bytes of a fixed one (ASCII, so byte = char).
97
63
  return enc.encodeInto('{"error":"too large"}', dst).written
98
64
  }
99
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
+
100
74
  /** `planJobs` (napi): the misses' JobsRequest JSON, '' (every job hit) or PLAN_DECLINED. */
101
75
  export type PlanJobs = (slot: number, len: number) => string
102
76
  /** `planJobs` declined: the loader response already in the slot is the answer (Rust plans after the call). */
103
77
  export const PLAN_DECLINED = '!'
104
78
  const PLANNED = { planned: true } as const
105
79
 
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
80
  /** The tsfn callback for `registerWorker`. Never rejects: a parse error, an unknown kind or a
266
81
  * throwing handler is written to the slot as `{error}`. A loader request with `plan: true` whose
267
82
  * chain answered `ok` is planned in the same call (`plan`, the napi `planJobs` by default — the
268
- * addon is touched only then). */
269
- export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan: PlanJobs = planJobs) {
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
270
87
  return async (kind: string, requestJson: string, slot: number): Promise<number> => {
271
88
  if (kind === 'fetch') return runFetch(requestJson, view, slot, slots)
89
+ if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo(), cacheDeps)
272
90
  let out: unknown
273
91
  try {
274
92
  const req = JSON.parse(requestJson)
@@ -278,9 +96,20 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
278
96
  // One round trip (M3-P P6): the ctx goes to Rust through this slot, synchronously, and only
279
97
  // the jobs Rust found missing run here; the final answer replaces it in the same slot.
280
98
  const json = toJson(out)
281
- const n = writeSlot(view, slot, slots, json)
282
- const misses = plan(slot, n)
283
- if (misses === PLAN_DECLINED) return n
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
+ }
284
113
  out = misses === '' ? PLANNED : { planned: true, results: (await h.jobs(JSON.parse(misses))).results }
285
114
  }
286
115
  } else if (kind === 'jobs') out = await h.jobs(req)
@@ -288,16 +117,51 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
288
117
  } catch (e) {
289
118
  out = { error: String(e) }
290
119
  }
291
- return writeSlot(view, slot, slots, toJson(out))
120
+ return writeSlot(view, slot, slots, toJson(out), heap.io, cap())
292
121
  }
293
122
  }
294
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
+
295
158
  // ---- worker entry ----
296
159
 
160
+ /** The default response slot (`slotBytes`, config.ts: 64 KiB..16 MiB). */
297
161
  export const SLOT_BYTES = 256 * 1024
298
162
 
299
163
  export interface StartWorkerOptions {
300
- /** The response buffer to register (`SLOT_BYTES * slots` bytes). `run()` allocates it on the
164
+ /** The response buffer to register (`slotBytes * slots` bytes). `run()` allocates it on the
301
165
  * MAIN thread and keeps it for the process lifetime, passing it as `workerData.sab`: the Rust
302
166
  * dispatcher holds a raw pointer into it until the process exits, so its backing store must not
303
167
  * die with this worker (`crates/brust-napi/src/dispatch.rs` BufPtr). Absent (tests, a worker
@@ -316,11 +180,19 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
316
180
  const env = process.env
317
181
  const slots = Math.max(1, Number.parseInt(env.BRUST_RENDER_SLOTS ?? '1', 10) || 1)
318
182
  const dist = resolve(env.BRUST_DIST_DIR ?? 'dist')
319
- const entry = env.BRUST_APP_ENTRY
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)
320
188
  if (!entry) throw new Error('BRUST_APP_ENTRY is not set')
321
- const manifest = (await Bun.file(join(dist, 'manifest.json')).json()) as ManifestLike
322
- const app = await import(resolve(entry))
323
- const routes = (app.routes ?? app.default) as Route[] | undefined
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
+ }
324
196
  if (!Array.isArray(routes)) throw new Error(`${entry} exports no routes (export const routes or default)`)
325
197
  const jobsMod = await import(join(dist, manifest.jobs_module ?? 'jobs.js'))
326
198
  const jobs = (jobsMod.default ?? jobsMod) as JobsModule
@@ -330,15 +202,25 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
330
202
  setActionsApp(await loadActionsApp(join(dist, manifest.actions.entry), manifest.actions.prefix))
331
203
  setActionsLimits({ maxResponseBytes: manifest.actions.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES })
332
204
  }
333
- sab = opts.sab ?? new SharedArrayBuffer(SLOT_BYTES * slots)
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)
334
210
  view = new Uint8Array(sab)
335
211
  const handlers = makeHandlers({
336
212
  leaves: flattenRoutes(routes).leaves,
337
213
  jobs,
338
214
  manifest,
339
215
  stores: (jobsMod.stores ?? {}) as Record<string, object>,
216
+ ...(env.BRUST_SSG === '1' && env.BRUST_SSG_LOG ? { ssg: ssgLog(env.BRUST_SSG_LOG) } : {}),
340
217
  })
341
- return registerWorker(view, slots, makeDispatch(handlers, view, slots))
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
342
224
  }
343
225
 
344
226
  // Auto-start only inside a Bun `Worker` spawned by `run()` (BRUST_WORKER_ID set). Not