@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11

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 (65) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +87 -5
  3. package/native/index.js +61 -54
  4. package/package.json +22 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +27 -3
  8. package/src/actions-bridge.ts +76 -19
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +330 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +341 -43
  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 +317 -0
  19. package/src/build/compile.ts +74 -4
  20. package/src/build/config.ts +274 -15
  21. package/src/build/css.ts +275 -0
  22. package/src/build/features.ts +110 -0
  23. package/src/build/index.ts +224 -20
  24. package/src/build/manifest.ts +69 -8
  25. package/src/build/pkg-src.ts +383 -0
  26. package/src/build/scan.ts +61 -20
  27. package/src/build/server-only.ts +39 -0
  28. package/src/build/ssg-render.ts +65 -0
  29. package/src/build/ssg.ts +146 -0
  30. package/src/build/static-worker.ts +91 -0
  31. package/src/build/static.ts +258 -0
  32. package/src/build/tailwind.ts +117 -0
  33. package/src/build/tsconfig-paths.ts +67 -0
  34. package/src/build-time.ts +10 -0
  35. package/src/cache-adapter/bridge.ts +91 -0
  36. package/src/cache-adapter/conformance.ts +226 -0
  37. package/src/cache-adapter/index.ts +10 -0
  38. package/src/cache-adapter/load.ts +144 -0
  39. package/src/cache-adapter/memory.ts +229 -0
  40. package/src/cache-adapter/runtime.ts +124 -0
  41. package/src/cache-adapter/types.ts +96 -0
  42. package/src/cache-adapter/workers.ts +118 -0
  43. package/src/cache.ts +4 -1
  44. package/src/cf.ts +19 -0
  45. package/src/cli.ts +70 -2
  46. package/src/config.ts +127 -25
  47. package/src/css-modules.d.ts +8 -0
  48. package/src/dev/bun.ts +365 -0
  49. package/src/dev/client.ts +92 -0
  50. package/src/dev/devtools-data.ts +23 -0
  51. package/src/dev/devtools.ts +356 -0
  52. package/src/dev/diag.ts +30 -0
  53. package/src/dev/proxy.ts +236 -0
  54. package/src/dev/tui.ts +144 -0
  55. package/src/dev/watch.ts +81 -0
  56. package/src/dev.ts +93 -0
  57. package/src/handlers.ts +282 -0
  58. package/src/index.ts +3 -1
  59. package/src/native.ts +49 -2
  60. package/src/routes.ts +20 -1
  61. package/src/run.ts +61 -14
  62. package/src/server-handlers.ts +32 -0
  63. package/src/shadcn/init.ts +213 -0
  64. package/src/shadcn/jsonc.ts +129 -0
  65. package/src/worker.ts +129 -245
package/src/dev/tui.ts ADDED
@@ -0,0 +1,144 @@
1
+ // The inline terminal UI of `brust dev` (R13.5). NEVER the alternate screen: request log lines scroll
2
+ // normally, and a status block of at most 6 lines is redrawn at the bottom (cursor-up + clear, wrapped
3
+ // lines counted, resize aware). Not a TTY, `CI` or `NO_COLOR` -> plain line logs, no redraw, no raw mode.
4
+
5
+ export type DevState = 'building' | 'ready' | 'error'
6
+
7
+ export interface TuiStatus {
8
+ state: DevState
9
+ url: string
10
+ gen: number
11
+ buildMs?: number
12
+ /** The last page request (route + status) until L14's per-page trace lands. */
13
+ last?: { method: string; path: string; status: number; /** Components of the last page by tier (from its trace + `dev.json`). */ tiers?: { static: number; native: number; react: number } }
14
+ warnings: string[]
15
+ /** One line shown under the state while `error` (the first line of the diagnostic). */
16
+ error?: string
17
+ }
18
+
19
+ export interface TuiOut {
20
+ write(s: string): unknown
21
+ columns?: number
22
+ isTTY?: boolean
23
+ }
24
+
25
+ const STATE_DOT: Record<DevState, string> = { building: '◌', ready: '●', error: '✖' }
26
+ const STATE_COLOR: Record<DevState, string> = { building: '33', ready: '32', error: '31' }
27
+ export const HOTKEYS = '[r]eload [o]pen [w]arnings [c]lear [q]uit'
28
+ export const MAX_STATUS_LINES = 6
29
+
30
+ /** Visible length (ANSI escapes do not count). */
31
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escapes
32
+ const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g
33
+ export const visibleLen = (s: string): number => [...s.replace(ANSI, '')].length
34
+
35
+ /** `s` cut to `width` visible columns, with a trailing `…` when it was cut (escapes are not split: input is plain here). */
36
+ function fit(s: string, width: number): string {
37
+ const cs = [...s]
38
+ return cs.length <= width ? s : `${cs.slice(0, Math.max(0, width - 1)).join('')}…`
39
+ }
40
+
41
+ /** The status block, plain text (colour is added by the caller), every line at most `width` columns. */
42
+ export function renderStatus(s: TuiStatus, width: number): string[] {
43
+ const w = Math.max(20, width - 1)
44
+ const head = `${STATE_DOT[s.state]} ${s.state} ${s.url} gen ${s.gen}${s.buildMs !== undefined ? ` · built in ${s.buildMs}ms` : ''}`
45
+ const lines = [head]
46
+ if (s.state === 'error' && s.error) lines.push(` error: ${s.error}`)
47
+ else if (s.last) lines.push(` last: ${s.last.method} ${s.last.path} → ${s.last.status}${s.last.tiers ? ` · ${s.last.tiers.static} static ${s.last.tiers.native} native ${s.last.tiers.react} react` : ''}`)
48
+ else lines.push(' last: (no request yet)')
49
+ if (s.warnings.length > 0) lines.push(` warnings: ${s.warnings.length} — ${s.warnings[0]}`)
50
+ lines.push(` ${HOTKEYS}`)
51
+ return lines.slice(0, MAX_STATUS_LINES).map((l) => fit(l, w))
52
+ }
53
+
54
+ /** Rows `lines` occupy on a terminal `width` columns wide (a line wider than the terminal wraps). */
55
+ export function rowsFor(lines: string[], width: number): number {
56
+ const w = Math.max(1, width)
57
+ return lines.reduce((n, l) => n + Math.max(1, Math.ceil(visibleLen(l) / w)), 0)
58
+ }
59
+
60
+ /** Colour is on for a TTY unless NO_COLOR; the redraw is on only for an interactive TTY outside CI. */
61
+ export function tuiMode(out: TuiOut, env: Record<string, string | undefined>): { interactive: boolean; color: boolean } {
62
+ const tty = out.isTTY === true
63
+ const interactive = tty && !env.CI && !env.NO_COLOR
64
+ return { interactive, color: interactive }
65
+ }
66
+
67
+ export class Tui {
68
+ private status: TuiStatus | null = null
69
+ private drawn: string[] = []
70
+ private closed = false
71
+ private lastPlain = ''
72
+
73
+ constructor(
74
+ private readonly out: TuiOut,
75
+ private readonly interactive: boolean,
76
+ private readonly color = interactive,
77
+ ) {}
78
+
79
+ private width(): number {
80
+ return this.out.columns && this.out.columns > 0 ? this.out.columns : 80
81
+ }
82
+
83
+ private paint(l: string, i: number): string {
84
+ if (!this.color || !this.status) return l
85
+ if (i === 0) return `\x1b[${STATE_COLOR[this.status.state]}m${l}\x1b[0m`
86
+ return `\x1b[2m${l}\x1b[0m`
87
+ }
88
+
89
+ private erase(): string {
90
+ if (this.drawn.length === 0) return ''
91
+ const rows = rowsFor(this.drawn, this.width())
92
+ this.drawn = []
93
+ return `\r\x1b[${rows}A\x1b[J`
94
+ }
95
+
96
+ private draw(): string {
97
+ if (!this.status) return ''
98
+ const lines = renderStatus(this.status, this.width())
99
+ this.drawn = lines
100
+ return `${lines.map((l, i) => this.paint(l, i)).join('\n')}\n`
101
+ }
102
+
103
+ /** One scrolling log line (a request, a warning, a message). */
104
+ log(raw: string): void {
105
+ if (this.closed) return
106
+ // Colour only where colour is on: a child's ANSI-coloured tracing line is plain text in plain mode.
107
+ const line = this.color ? raw : raw.replace(ANSI, '')
108
+ if (!this.interactive) {
109
+ this.out.write(`${line}\n`)
110
+ return
111
+ }
112
+ this.out.write(`${this.erase()}${line}\n${this.draw()}`)
113
+ }
114
+
115
+ /** Replace the status block. Plain mode prints a line only when the state line changes. */
116
+ setStatus(s: TuiStatus): void {
117
+ if (this.closed) return
118
+ this.status = s
119
+ if (!this.interactive) {
120
+ const l = `[brust] ${s.state} ${s.url} gen ${s.gen}${s.buildMs !== undefined ? ` (${s.buildMs}ms)` : ''}${s.error ? ` — ${s.error}` : ''}`
121
+ if (l !== this.lastPlain) this.out.write(`${l}\n`)
122
+ this.lastPlain = l
123
+ return
124
+ }
125
+ this.out.write(`${this.erase()}${this.draw()}`)
126
+ }
127
+
128
+ /** Redraw after a terminal resize (the rows the old block took are recounted at the new width). */
129
+ resize(): void {
130
+ if (this.interactive && !this.closed) this.out.write(`${this.erase()}${this.draw()}`)
131
+ }
132
+
133
+ /** The `c` hotkey: clear the screen and redraw the block. */
134
+ clear(): void {
135
+ if (!this.interactive) return
136
+ this.drawn = []
137
+ this.out.write(`\x1b[2J\x1b[H${this.draw()}`)
138
+ }
139
+
140
+ /** Leave the final block on screen and stop drawing. */
141
+ close(): void {
142
+ this.closed = true
143
+ }
144
+ }
@@ -0,0 +1,81 @@
1
+ // The shared watch/build loop of `brust dev` (bun and cloudflare targets): debounced file events,
2
+ // ONE run at a time, and every change that arrives while a run is in flight is kept for the next
3
+ // run (post-mortem 8f29c26: an edit during a rebuild was dropped). `run` receives the app-relative
4
+ // paths that changed since the previous run started.
5
+ import { type FSWatcher, statSync, watch } from 'node:fs'
6
+ import { join, sep } from 'node:path'
7
+
8
+ export interface WatchLoop {
9
+ /** Queue a run as if `rel` changed (the `r` hotkey, tests). */
10
+ trigger(rel?: string): void
11
+ /** Stop watching and drop queued changes; an in-flight run is left to finish. */
12
+ close(): void
13
+ /** Resolves when no run is in flight and nothing is queued. */
14
+ idle(): Promise<void>
15
+ }
16
+
17
+ export function watchLoop(o: { root: string; ignored: (rel: string) => boolean; debounceMs: number; run: (changed: Set<string>) => Promise<void> }): WatchLoop {
18
+ let pending = new Set<string>()
19
+ let timer: ReturnType<typeof setTimeout> | undefined
20
+ let running = false
21
+ let closed = false
22
+ let idleWaiters: (() => void)[] = []
23
+ const pump = async () => {
24
+ if (running) return // the running loop picks `pending` up when it finishes
25
+ running = true
26
+ try {
27
+ while (pending.size > 0 && !closed) {
28
+ const batch = pending
29
+ pending = new Set()
30
+ try {
31
+ await o.run(batch)
32
+ } catch (e) {
33
+ console.error(`[brust] dev run failed: ${(e as Error).stack ?? String(e)}`)
34
+ }
35
+ }
36
+ } finally {
37
+ running = false
38
+ if (pending.size === 0) for (const w of idleWaiters.splice(0)) w()
39
+ }
40
+ }
41
+ const queue = (rel: string) => {
42
+ if (closed) return
43
+ pending.add(rel)
44
+ clearTimeout(timer)
45
+ timer = setTimeout(() => void pump(), o.debounceMs)
46
+ }
47
+ const watcher: FSWatcher = watch(o.root, { recursive: true }, (_ev, name) => {
48
+ if (!name) return
49
+ const rel = String(name).split(sep).join('/')
50
+ if (o.ignored(rel)) return
51
+ // A directory's own event is metadata noise (reading a dir bumps its atime, which FSEvents reports): a real
52
+ // change inside it reports the file itself. A path that no longer exists is a delete, which counts.
53
+ try {
54
+ if (statSync(join(o.root, rel)).isDirectory()) return
55
+ } catch {}
56
+ queue(rel)
57
+ })
58
+ return {
59
+ trigger: (rel = '.') => queue(rel),
60
+ close() {
61
+ closed = true
62
+ clearTimeout(timer)
63
+ pending.clear()
64
+ watcher.close()
65
+ for (const w of idleWaiters.splice(0)) w()
66
+ },
67
+ idle: () => (!running && pending.size === 0 ? Promise.resolve() : new Promise<void>((r) => idleWaiters.push(r))),
68
+ }
69
+ }
70
+
71
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.brust'])
72
+ const SKIP_FILE = /(^|\/)(\.DS_Store|4913)$|~$|\.(swp|swx|tmp|log)$|\.(sqlite|db)(-wal|-shm|-journal)?$/
73
+
74
+ /** Paths a dev session never rebuilds for: dependency/VCS dirs, brust's own output, editor temp files, databases. */
75
+ export function defaultIgnored(rel: string, outDirRel: string): boolean {
76
+ const segs = rel.split('/')
77
+ if (segs.some((s) => SKIP_DIRS.has(s))) return true
78
+ const out = outDirRel.replace(/\/+$/, '')
79
+ if (rel === out || rel.startsWith(`${out}/`) || rel.startsWith(`${out}.`) || rel.startsWith(`.${out}.brust-`)) return true
80
+ return SKIP_FILE.test(rel)
81
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,93 @@
1
+ // `brust dev [entry] --target cloudflare [-- <wrangler args>]` (M4-CF R10): build, run `wrangler dev` on the
2
+ // generated project, rebuild + restart wrangler on a source change. No HMR. Each build runs in a child
3
+ // process: a failed build keeps the old wrangler, and module caches never leak between builds.
4
+ import { join, relative, resolve } from 'node:path'
5
+ import { BuildError } from './build/errors'
6
+ import { defaultIgnored, type WatchLoop, watchLoop } from './dev/watch'
7
+
8
+ export interface DevOptions {
9
+ appRoot: string
10
+ entry: string
11
+ outDir: string
12
+ port?: number
13
+ wranglerArgs: string[]
14
+ fromSource: boolean
15
+ log: (s: string) => void
16
+ }
17
+
18
+ const DEBOUNCE_MS = 300
19
+
20
+ function wranglerBin(appRoot: string): string {
21
+ const fake = process.env.BRUST_WRANGLER
22
+ if (fake) return fake
23
+ const found = Bun.which('wrangler', { PATH: `${join(appRoot, 'node_modules', '.bin')}:${process.env.PATH ?? ''}` })
24
+ if (!found) throw new BuildError('cf-dev', 'wrangler not found (bun add -d wrangler)')
25
+ return found
26
+ }
27
+
28
+ /** Resolves with the exit code once the session ends (SIGINT/SIGTERM -> 0, or the child's own exit code). */
29
+ export async function runDev(o: DevOptions): Promise<number> {
30
+ const appRoot = resolve(o.appRoot)
31
+ const dist = resolve(appRoot, o.outDir)
32
+ const bin = wranglerBin(appRoot)
33
+ const build = (): Promise<number> => {
34
+ const p = Bun.spawn([process.execPath, join(import.meta.dir, 'cli.ts'), 'build', o.entry, '--target', 'cloudflare', '--out-dir', o.outDir, ...(o.fromSource ? ['--wasm-from-source'] : [])], {
35
+ cwd: appRoot,
36
+ stdout: 'inherit',
37
+ stderr: 'inherit',
38
+ })
39
+ return p.exited
40
+ }
41
+ if ((await build()) !== 0) return 1
42
+
43
+ const args = ['dev', '-c', join(dist, 'cf', 'wrangler.jsonc'), '--persist-to', join(appRoot, '.wrangler', 'state'), ...(o.port !== undefined ? ['--port', String(o.port)] : []), ...o.wranglerArgs]
44
+ let child: ReturnType<typeof Bun.spawn> | null = null
45
+ let stopping = false
46
+ let restarting = false
47
+ let exitCode: (c: number) => void = () => {}
48
+ const done = new Promise<number>((r) => {
49
+ exitCode = r
50
+ })
51
+ const spawn = () => {
52
+ const c = Bun.spawn([bin, ...args], { cwd: appRoot, stdio: ['inherit', 'inherit', 'inherit'] })
53
+ child = c
54
+ c.exited.then((code) => {
55
+ if (child !== c || stopping || restarting) return
56
+ loop.close()
57
+ exitCode(code)
58
+ })
59
+ }
60
+ const stopChild = async () => {
61
+ const c = child
62
+ if (!c) return
63
+ c.kill('SIGTERM')
64
+ await c.exited
65
+ }
66
+
67
+ // The shared watch/build loop (dev/watch.ts): one rebuild at a time, edits during a rebuild are kept for the next.
68
+ const loop: WatchLoop = watchLoop({
69
+ root: appRoot,
70
+ ignored: (rel) => defaultIgnored(rel, relative(appRoot, dist)),
71
+ debounceMs: DEBOUNCE_MS,
72
+ async run() {
73
+ o.log('[brust] change detected; rebuilding')
74
+ if ((await build()) === 0) {
75
+ restarting = true
76
+ await stopChild()
77
+ restarting = false
78
+ if (!stopping) spawn()
79
+ } else o.log('[brust] rebuild failed; keeping the running wrangler')
80
+ },
81
+ })
82
+
83
+ spawn()
84
+ const stop = () => {
85
+ if (stopping) return
86
+ stopping = true
87
+ loop.close()
88
+ void stopChild().then(() => exitCode(0))
89
+ }
90
+ process.on('SIGINT', stop)
91
+ process.on('SIGTERM', stop)
92
+ return done
93
+ }
@@ -0,0 +1,282 @@
1
+ // The host-agnostic half of the worker (M4-CF lane 4): wire shapes + `makeHandlers`. No Bun, no
2
+ // napi, no `node:*` — the Cloudflare entry imports it through `@brust/core/server-handlers`.
3
+ // `worker.ts` re-exports everything here, so existing importers are unchanged.
4
+ import { installServerResolver, type StoreSnapshot } from './store/core-adapter'
5
+ import { collectSnapshot, getServerInstance, runInStoreContext, touchStores } from './store/server-context'
6
+ import { type FlatRoute, isHttpErrorTrigger, isVerdict, type LoaderReq, type Route, type Verdict } from './routes'
7
+
8
+ installServerResolver(getServerInstance)
9
+
10
+
11
+ // ---- wire shapes (protocol.rs, camelCase) ----
12
+
13
+ export interface LoaderRequest {
14
+ routeId: string
15
+ params: Record<string, string>
16
+ path: string
17
+ req: LoaderReq
18
+ /** M3-P P6: the chain has jobs — plan them in this call (`planJobs`). Optional to honour. */
19
+ plan?: boolean
20
+ }
21
+
22
+ export type LoaderResponse =
23
+ | { ok: true; data: Record<string, unknown>; headers?: Record<string, string>; store?: StoreSnapshot | null }
24
+ | { verdict: 'notFound'; data: unknown; store?: StoreSnapshot | null }
25
+ | { verdict: 'redirect'; location: string; status: number }
26
+ | { verdict: 'httpError'; status: number; body: string }
27
+ | { error: string }
28
+
29
+ export type JobKind = 'precompute' | 'ssr'
30
+
31
+ export interface JobCall {
32
+ /** Opaque, echoed back: `<componentId>/<jobId>[/<row>]` for a chain job,
33
+ * `<parentId>/<childId>_<k>/<jobId>[/<row>]` for an inlined child instance (pipeline.rs `plan_one`). */
34
+ id: string
35
+ componentId: string
36
+ kind: JobKind
37
+ // biome-ignore lint/suspicious/noExplicitAny: job inputs are arbitrary JSON
38
+ inputs: any
39
+ /** ssr: the react component to render (D6). */
40
+ target?: string
41
+ row?: number
42
+ }
43
+
44
+ export interface JobsRequest {
45
+ jobs: JobCall[]
46
+ }
47
+
48
+ export type JobResult = { id: string; value: unknown } | { id: string; error: string }
49
+
50
+ export interface JobsResponse {
51
+ results: JobResult[]
52
+ }
53
+
54
+ /** The default export of `<dist>/<manifest.jobs_module>` (built by Task 6): component id → job fns. */
55
+ // biome-ignore lint/suspicious/noExplicitAny: props are whatever the component takes
56
+ export type JobsModule = Record<string, { precompute?: (props: any) => unknown; ssr?: (props: any) => string | Promise<string> }>
57
+
58
+ /** The slice of `manifest.json` the worker reads: ssr job `literals` (S6 amendment). */
59
+ export interface ManifestLike {
60
+ routes?: { id: string; chain: string[] }[]
61
+ components: Record<
62
+ string,
63
+ {
64
+ jobs: { id: string; kind?: string; target?: string; literals?: Record<string, unknown> }[]
65
+ children?: { id: string }[]
66
+ stores?: string[]
67
+ }
68
+ >
69
+ jobs_module?: string
70
+ /** `brust build` (M3-F A3): the actions entry, relative to dist. Absent/null = no actions. */
71
+ actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number } | null
72
+ }
73
+
74
+ export function toJson(out: unknown): string {
75
+ try {
76
+ return JSON.stringify(out)
77
+ } catch (e) {
78
+ return JSON.stringify({ error: `response not serialisable: ${String(e)}` })
79
+ }
80
+ }
81
+
82
+ // ---- literals (S6 amendment: the worker merges an ssr job's `literals` over `call.inputs`) ----
83
+
84
+ /** `"<componentId>/<jobId>"` → that job record's `literals` (only records that carry some). */
85
+ export function literalsIndex(manifest: ManifestLike | undefined): Map<string, Record<string, unknown>> {
86
+ const out = new Map<string, Record<string, unknown>>()
87
+ for (const [cid, c] of Object.entries(manifest?.components ?? {}))
88
+ for (const j of c.jobs ?? []) if (j.literals && Object.keys(j.literals).length > 0) out.set(`${cid}/${j.id}`, j.literals)
89
+ return out
90
+ }
91
+
92
+ /** The manifest job id of a call: the segment after `componentId` in a chain id
93
+ * (`<cid>/<jobId>[/<row>]`), else the third segment of a child-instance id
94
+ * (`<parentId>/<cid>_<k>/<jobId>[/<row>]`). */
95
+ export function jobIdOf(call: Pick<JobCall, 'id' | 'componentId'>): string | undefined {
96
+ const parts = call.id.split('/')
97
+ return parts[0] === call.componentId ? parts[1] : parts[2]
98
+ }
99
+
100
+ // ---- touched stores (M3-F D4) ----
101
+
102
+ /** route id -> sorted unique union of `stores` over the route's chain, walking each component's
103
+ * `children[].id` and `jobs[].target` transitively. Routes with an empty union are absent. */
104
+ export function routeStoresIndex(manifest: ManifestLike | undefined): Map<string, string[]> {
105
+ const out = new Map<string, string[]>()
106
+ const comps = manifest?.components ?? {}
107
+ for (const r of manifest?.routes ?? []) {
108
+ const acc = new Set<string>()
109
+ const seen = new Set<string>()
110
+ const walk = (id: string) => {
111
+ if (seen.has(id)) return
112
+ seen.add(id)
113
+ const c = comps[id]
114
+ if (!c) return
115
+ for (const p of c.stores ?? []) acc.add(p)
116
+ for (const k of c.children ?? []) walk(k.id)
117
+ for (const j of c.jobs ?? []) if (j.target) walk(j.target)
118
+ }
119
+ for (const id of r.chain ?? []) walk(id)
120
+ if (acc.size > 0) out.set(r.id, [...acc].sort())
121
+ }
122
+ return out
123
+ }
124
+
125
+ /** R1: `store` is assigned only when a store was touched; absent otherwise. */
126
+ function withStore<T extends LoaderResponse>(r: T): T {
127
+ const s = collectSnapshot()
128
+ if (s) (r as { store?: StoreSnapshot | null }).store = s
129
+ return r
130
+ }
131
+
132
+ // ---- handlers ----
133
+
134
+ /** `parent`: the data the chain merged before the verdict. A notFound renders the route's
135
+ * template (layouts included), so the layouts' data stays under the verdict's own keys. */
136
+ function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderResponse {
137
+ if (v.status === 404) {
138
+ const own = v.data ?? {}
139
+ return { verdict: 'notFound', data: typeof own === 'object' && !Array.isArray(own) ? { ...parent, ...own } : own }
140
+ }
141
+ return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
142
+ }
143
+
144
+ /** What a loader did during a static render (gaps round R9.5) that a static file cannot honour. */
145
+ export type SsgRecord =
146
+ | { kind: 'read'; routeId: string; path: string; field: 'headers' | 'cookies' | 'search'; key: string }
147
+ | { kind: 'headers'; routeId: string; path: string; names: string[] }
148
+
149
+ /** R9.5: the request a loader sees while rendering static output: a visitor with no headers, cookies
150
+ * or query. Every read of `headers` / `cookies` / `search` (a key, `in`, or a listing: key `*`) is
151
+ * recorded, so the build can warn that the page written ignores it. */
152
+ export function ssgRequest(req: LoaderReq, routeId: string, path: string, record: (r: SsgRecord) => void): LoaderReq {
153
+ const watched = (field: 'headers' | 'cookies' | 'search') => {
154
+ const read = (key: string) => record({ kind: 'read', routeId, path, field, key })
155
+ return new Proxy(Object.create(null) as Record<string, string>, {
156
+ get: (t, k) => {
157
+ if (typeof k === 'string') read(k)
158
+ return Reflect.get(t, k)
159
+ },
160
+ has: (t, k) => {
161
+ if (typeof k === 'string') read(k)
162
+ return Reflect.has(t, k)
163
+ },
164
+ ownKeys: (t) => {
165
+ read('*')
166
+ return Reflect.ownKeys(t)
167
+ },
168
+ getOwnPropertyDescriptor: (t, k) => {
169
+ if (typeof k === 'string') read(k)
170
+ return Reflect.getOwnPropertyDescriptor(t, k)
171
+ },
172
+ })
173
+ }
174
+ return { method: 'GET', url: req.url, headers: watched('headers'), cookies: watched('cookies'), search: watched('search') }
175
+ }
176
+
177
+ export interface Handlers {
178
+ loader(req: LoaderRequest): Promise<LoaderResponse>
179
+ jobs(req: JobsRequest): Promise<JobsResponse>
180
+ }
181
+
182
+ /** `leaves` from `flattenRoutes` (ids must match the manifest's route ids); `jobs` the jobs
183
+ * module; `manifest` supplies ssr `literals` (omit → no merge). */
184
+ export function makeHandlers(opts: {
185
+ leaves: FlatRoute[]
186
+ jobs: JobsModule
187
+ manifest?: ManifestLike
188
+ /** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
189
+ stores?: Record<string, object>
190
+ /** Static output (R9.5): loaders see `ssgRequest`, and what a static file drops is recorded. */
191
+ ssg?: (r: SsgRecord) => void
192
+ }): Handlers {
193
+ const byId = new Map(opts.leaves.map((l) => [l.id, l]))
194
+ const literals = literalsIndex(opts.manifest)
195
+ const jobs = opts.jobs
196
+ const storeIdx = routeStoresIndex(opts.manifest)
197
+ const storeTable = opts.stores ?? {}
198
+
199
+ async function runJob(call: JobCall): Promise<JobResult> {
200
+ const owner = call.kind === 'ssr' ? (call.target ?? call.componentId) : call.componentId // D6
201
+ const fn = jobs[owner]?.[call.kind]
202
+ if (typeof fn !== 'function') return { id: call.id, error: `no ${call.kind} job for component ${owner}` }
203
+ try {
204
+ let props = call.inputs
205
+ if (call.kind === 'ssr') {
206
+ const jid = jobIdOf(call)
207
+ const lit = jid === undefined ? undefined : literals.get(`${call.componentId}/${jid}`)
208
+ if (lit) props = { ...(props ?? {}), ...lit }
209
+ // Precompute runs outside any scope (D8); ssr runs in a scope seeded from `__store`, which props never see.
210
+ const seed = (props?.__store ?? null) as StoreSnapshot | null
211
+ if (props && '__store' in props) {
212
+ const { __store: _, ...rest } = props
213
+ props = rest
214
+ }
215
+ const p = props
216
+ return { id: call.id, value: await runInStoreContext(() => fn(p), seed) }
217
+ }
218
+ return { id: call.id, value: await fn(props) }
219
+ } catch (e) {
220
+ return { id: call.id, error: String(e) }
221
+ }
222
+ }
223
+
224
+ return {
225
+ // 0.1.x `runNativeChainLoaders`: root → leaf, flat merge (later keys win), first verdict stops
226
+ // (a notFound keeps what the chain merged so far: verdictJson).
227
+ async loader(req) {
228
+ const leaf = byId.get(req.routeId)
229
+ if (!leaf) return { error: `unknown routeId ${req.routeId}` }
230
+ // One object for the whole chain: each level's keys are assigned INTO it (later keys win,
231
+ // first-seen key order — what `{...merged, ...r}` produced) instead of re-spreading every
232
+ // key once per level (M3-P P1). `merged` is fresh per call; a loader's own object is never
233
+ // written to. Null prototype: with no `__proto__` setter to hit, an own "__proto__" key in a
234
+ // loader's data is assigned as data, as the spread copied it.
235
+ return runInStoreContext(async () => {
236
+ const merged: Record<string, unknown> = Object.create(null)
237
+ try {
238
+ const touch = storeIdx.get(req.routeId)
239
+ if (touch) touchStores(touch, storeTable)
240
+ for (const node of leaf.chain) {
241
+ if (!node.loader) continue
242
+ let r: unknown
243
+ try {
244
+ const lreq = opts.ssg ? ssgRequest(req.req, req.routeId, req.path, opts.ssg) : req.req
245
+ r = await node.loader({ params: req.params, path: req.path, req: lreq })
246
+ } catch (e) {
247
+ if (isHttpErrorTrigger(e)) return { verdict: 'httpError', status: e.status, body: e.body } as LoaderResponse
248
+ throw e
249
+ }
250
+ if (isVerdict(r)) {
251
+ const v = verdictJson(r, merged)
252
+ return 'verdict' in v && v.verdict === 'notFound' ? withStore(v) : v
253
+ }
254
+ if (r && typeof r === 'object') Object.assign(merged, r)
255
+ }
256
+ const ok = withStore({ ok: true, data: merged } as LoaderResponse)
257
+ const headers = (ok as { headers?: Record<string, string> }).headers
258
+ if (opts.ssg && headers && Object.keys(headers).length > 0) opts.ssg({ kind: 'headers', routeId: req.routeId, path: req.path, names: Object.keys(headers) })
259
+ return ok
260
+ } catch (e) {
261
+ return { error: String(e) } as LoaderResponse
262
+ }
263
+ })
264
+ },
265
+ // One request carries every job of the page; a per-job failure keeps the other results.
266
+ async jobs(req) {
267
+ return { results: await Promise.all((req.jobs ?? []).map(runJob)) }
268
+ },
269
+ }
270
+ }
271
+
272
+ /** Wraps an async handler for a JSON-string boundary: parses, calls, serialises; NEVER rejects
273
+ * (a parse error or a throwing handler resolves `{"error":…}`, like `makeDispatch`). */
274
+ export function jsonCall<Q, R>(fn: (q: Q) => Promise<R>): (json: string) => Promise<string> {
275
+ return async (json) => {
276
+ try {
277
+ return toJson(await fn(JSON.parse(json)))
278
+ } catch (e) {
279
+ return toJson({ error: String(e) })
280
+ }
281
+ }
282
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
+ /// <reference path="./css-modules.d.ts" />
1
2
  // `@brust/core`: the app-facing API.
3
+ export { buildTime } from './build-time'
2
4
  export { cache } from './cache'
3
5
  export { type BrustConfig, BrustConfigError, loadConfig } from './config'
4
6
  export { type RunOptions, run } from './run'
@@ -15,4 +17,4 @@ export {
15
17
  Outlet,
16
18
  redirect,
17
19
  } from './routes'
18
- export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
20
+ export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, RouteSsgConfig, Verdict } from './routes'