@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.
- package/README.md +66 -6
- package/native/index.d.ts +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +70 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- 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
|
+
}
|
package/src/dev/watch.ts
ADDED
|
@@ -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
|
+
}
|
package/src/handlers.ts
ADDED
|
@@ -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'
|