@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
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,130 @@
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, readdirSync, statSync, watch } from 'node:fs'
6
+ import { join, relative, resolve, 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
+ /** F273: a source root holding more files than this gets a warning (a root that broad slows every rebuild check). */
18
+ export const ROOT_FILE_WARN = 5_000
19
+
20
+ /** Files under `dir` (skipping dependency/VCS dirs), counting stops once past `limit`. */
21
+ export function countFiles(dir: string, limit: number): number {
22
+ let n = 0
23
+ const walk = (d: string): void => {
24
+ let ents: import('node:fs').Dirent[]
25
+ try {
26
+ ents = readdirSync(d, { withFileTypes: true })
27
+ } catch {
28
+ return
29
+ }
30
+ for (const e of ents) {
31
+ if (n > limit) return
32
+ if (e.isDirectory()) {
33
+ if (!SKIP_DIRS.has(e.name)) walk(join(d, e.name))
34
+ } else n++
35
+ }
36
+ }
37
+ walk(dir)
38
+ return n
39
+ }
40
+
41
+ /** Warning lines for source roots broader than `ROOT_FILE_WARN` files; `roots` are absolute. */
42
+ export function broadRootWarnings(roots: string[], appRoot: string, limit = ROOT_FILE_WARN): string[] {
43
+ return roots.flatMap((r) => (countFiles(r, limit) > limit ? [`[brust] warning: source root ${relative(appRoot, r) || r} holds more than ${limit} files; watching it slows every change check. Point source_roots at a narrower directory`] : []))
44
+ }
45
+
46
+ export function watchLoop(o: { root: string; /** F273: `[build] source_roots`, absolute: watched too; their changes are reported app-relative (`../shared/x.ts`). */ extraRoots?: string[]; ignored: (rel: string) => boolean; debounceMs: number; run: (changed: Set<string>) => Promise<void> }): WatchLoop {
47
+ let pending = new Set<string>()
48
+ let timer: ReturnType<typeof setTimeout> | undefined
49
+ let running = false
50
+ let closed = false
51
+ let idleWaiters: (() => void)[] = []
52
+ const pump = async () => {
53
+ if (running) return // the running loop picks `pending` up when it finishes
54
+ running = true
55
+ try {
56
+ while (pending.size > 0 && !closed) {
57
+ const batch = pending
58
+ pending = new Set()
59
+ try {
60
+ await o.run(batch)
61
+ } catch (e) {
62
+ console.error(`[brust] dev run failed: ${(e as Error).stack ?? String(e)}`)
63
+ }
64
+ }
65
+ } finally {
66
+ running = false
67
+ if (pending.size === 0) for (const w of idleWaiters.splice(0)) w()
68
+ }
69
+ }
70
+ const queue = (rel: string) => {
71
+ if (closed) return
72
+ pending.add(rel)
73
+ clearTimeout(timer)
74
+ timer = setTimeout(() => void pump(), o.debounceMs)
75
+ }
76
+ const onEvent = (base: string) => (_ev: string, name: string | null) => {
77
+ if (!name) return
78
+ const abs = join(base, String(name))
79
+ const rel = (base === o.root ? String(name) : relative(o.root, abs)).split(sep).join('/')
80
+ if (o.ignored(rel)) return
81
+ // A directory's own event is metadata noise (reading a dir bumps its atime, which FSEvents reports): a real
82
+ // change inside it reports the file itself. A path that no longer exists is a delete, which counts.
83
+ try {
84
+ if (statSync(abs).isDirectory()) return
85
+ } catch {}
86
+ queue(rel)
87
+ }
88
+ const watchers: FSWatcher[] = [watch(o.root, { recursive: true }, onEvent(o.root))]
89
+ for (const r of o.extraRoots ?? []) {
90
+ const abs = resolve(r)
91
+ // a root inside the app is already watched; one that cannot be watched must not stop the session
92
+ if (abs === o.root || abs.startsWith(o.root + sep)) continue
93
+ try {
94
+ watchers.push(watch(abs, { recursive: true }, onEvent(abs)))
95
+ } catch (e) {
96
+ console.error(`[brust] cannot watch source root ${abs}: ${(e as Error).message}`)
97
+ }
98
+ }
99
+ return {
100
+ trigger: (rel = '.') => queue(rel),
101
+ close() {
102
+ closed = true
103
+ clearTimeout(timer)
104
+ pending.clear()
105
+ for (const w of watchers) w.close()
106
+ for (const w of idleWaiters.splice(0)) w()
107
+ },
108
+ idle: () => (!running && pending.size === 0 ? Promise.resolve() : new Promise<void>((r) => idleWaiters.push(r))),
109
+ }
110
+ }
111
+
112
+ /** F133: the line `brust dev` logs for a batch of changed paths, or null when the batch is only the boot trigger (`.`,
113
+ * the app root itself: the first build goes through the loop, and a watcher can report the root too). */
114
+ export function changeLine(changed: Iterable<string>): string | null {
115
+ const paths = [...changed].filter((r) => r !== '.')
116
+ if (paths.length === 0) return null
117
+ return `[brust] change detected: ${paths.slice(0, 3).join(', ')}${paths.length > 3 ? ` +${paths.length - 3}` : ''}`
118
+ }
119
+
120
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.brust'])
121
+ const SKIP_FILE = /(^|\/)(\.DS_Store|4913)$|~$|\.(swp|swx|tmp|log)$|\.(sqlite|db)(-wal|-shm|-journal)?$/
122
+
123
+ /** Paths a dev session never rebuilds for: dependency/VCS dirs, brust's own output, editor temp files, databases. */
124
+ export function defaultIgnored(rel: string, outDirRel: string): boolean {
125
+ const segs = rel.split('/')
126
+ if (segs.some((s) => SKIP_DIRS.has(s))) return true
127
+ const out = outDirRel.replace(/\/+$/, '')
128
+ if (rel === out || rel.startsWith(`${out}/`) || rel.startsWith(`${out}.`) || rel.startsWith(`.${out}.brust-`)) return true
129
+ return SKIP_FILE.test(rel)
130
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,97 @@
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 { loadBuildConfig } from './build/config'
7
+ import { broadRootWarnings, defaultIgnored, type WatchLoop, watchLoop } from './dev/watch'
8
+
9
+ export interface DevOptions {
10
+ appRoot: string
11
+ entry: string
12
+ outDir: string
13
+ port?: number
14
+ wranglerArgs: string[]
15
+ fromSource: boolean
16
+ log: (s: string) => void
17
+ }
18
+
19
+ const DEBOUNCE_MS = 300
20
+
21
+ function wranglerBin(appRoot: string): string {
22
+ const fake = process.env.BRUST_WRANGLER
23
+ if (fake) return fake
24
+ const found = Bun.which('wrangler', { PATH: `${join(appRoot, 'node_modules', '.bin')}:${process.env.PATH ?? ''}` })
25
+ if (!found) throw new BuildError('cf-dev', 'wrangler not found (bun add -d wrangler)')
26
+ return found
27
+ }
28
+
29
+ /** Resolves with the exit code once the session ends (SIGINT/SIGTERM -> 0, or the child's own exit code). */
30
+ export async function runDev(o: DevOptions): Promise<number> {
31
+ const appRoot = resolve(o.appRoot)
32
+ const dist = resolve(appRoot, o.outDir)
33
+ const bin = wranglerBin(appRoot)
34
+ const build = (): Promise<number> => {
35
+ 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'] : [])], {
36
+ cwd: appRoot,
37
+ stdout: 'inherit',
38
+ stderr: 'inherit',
39
+ })
40
+ return p.exited
41
+ }
42
+ if ((await build()) !== 0) return 1
43
+
44
+ const args = ['dev', '-c', join(dist, 'cf', 'wrangler.jsonc'), '--persist-to', join(appRoot, '.wrangler', 'state'), ...(o.port !== undefined ? ['--port', String(o.port)] : []), ...o.wranglerArgs]
45
+ let child: ReturnType<typeof Bun.spawn> | null = null
46
+ let stopping = false
47
+ let restarting = false
48
+ let exitCode: (c: number) => void = () => {}
49
+ const done = new Promise<number>((r) => {
50
+ exitCode = r
51
+ })
52
+ const spawn = () => {
53
+ const c = Bun.spawn([bin, ...args], { cwd: appRoot, stdio: ['inherit', 'inherit', 'inherit'] })
54
+ child = c
55
+ c.exited.then((code) => {
56
+ if (child !== c || stopping || restarting) return
57
+ loop.close()
58
+ exitCode(code)
59
+ })
60
+ }
61
+ const stopChild = async () => {
62
+ const c = child
63
+ if (!c) return
64
+ c.kill('SIGTERM')
65
+ await c.exited
66
+ }
67
+
68
+ // The shared watch/build loop (dev/watch.ts): one rebuild at a time, edits during a rebuild are kept for the next.
69
+ const extraRoots = await loadBuildConfig(appRoot).then((c) => c.sourceRoots.map((r) => resolve(appRoot, r)), () => [] as string[])
70
+ for (const w of broadRootWarnings(extraRoots, appRoot)) o.log(w)
71
+ const loop: WatchLoop = watchLoop({
72
+ root: appRoot,
73
+ extraRoots,
74
+ ignored: (rel) => defaultIgnored(rel, relative(appRoot, dist)),
75
+ debounceMs: DEBOUNCE_MS,
76
+ async run() {
77
+ o.log('[brust] change detected; rebuilding')
78
+ if ((await build()) === 0) {
79
+ restarting = true
80
+ await stopChild()
81
+ restarting = false
82
+ if (!stopping) spawn()
83
+ } else o.log('[brust] rebuild failed; keeping the running wrangler')
84
+ },
85
+ })
86
+
87
+ spawn()
88
+ const stop = () => {
89
+ if (stopping) return
90
+ stopping = true
91
+ loop.close()
92
+ void stopChild().then(() => exitCode(0))
93
+ }
94
+ process.on('SIGINT', stop)
95
+ process.on('SIGTERM', stop)
96
+ return done
97
+ }
@@ -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'