@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.31
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 +82 -4
- package/native/index.js +58 -54
- package/package.json +29 -15
- package/src/actions/client.ts +53 -7
- package/src/actions/elysia.ts +1 -1
- package/src/actions/load.ts +82 -1
- package/src/actions/prefix.ts +44 -2
- package/src/actions-bridge.ts +44 -9
- package/src/assets.d.ts +55 -0
- package/src/browser.ts +6 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +332 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +466 -54
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +9 -4
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +34 -13
- package/src/build/compile.ts +263 -6
- package/src/build/config.ts +138 -11
- package/src/build/css.ts +353 -0
- package/src/build/esm-resolve.ts +170 -0
- package/src/build/features.ts +119 -0
- package/src/build/index.ts +210 -27
- package/src/build/manifest.ts +94 -7
- package/src/build/pkg-src.ts +514 -0
- package/src/build/recognised.ts +19 -0
- package/src/build/scan.ts +71 -20
- package/src/build/server-only.ts +62 -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 +259 -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/types.ts +1 -1
- package/src/cf.ts +4 -1
- package/src/cli.ts +40 -4
- package/src/config.ts +111 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +378 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +358 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +256 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +130 -0
- package/src/dev.ts +15 -28
- package/src/handlers.ts +116 -10
- package/src/index.ts +6 -1
- package/src/native.ts +32 -1
- package/src/navigate-browser.ts +19 -0
- package/src/navigate.ts +19 -0
- package/src/navigation.ts +91 -0
- package/src/routes.ts +31 -1
- package/src/run.ts +65 -13
- package/src/shadcn/init.ts +513 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/shadcn/registry.ts +149 -0
- package/src/store/builtin.ts +4 -0
- package/src/worker.ts +105 -22
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,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
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// `brust dev [entry] --target cloudflare [-- <wrangler args>]` (M4-CF R10): build, run `wrangler dev` on the
|
|
2
2
|
// generated project, rebuild + restart wrangler on a source change. No HMR. Each build runs in a child
|
|
3
3
|
// process: a failed build keeps the old wrangler, and module caches never leak between builds.
|
|
4
|
-
import {
|
|
5
|
-
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { join, relative, resolve } from 'node:path'
|
|
6
5
|
import { BuildError } from './build/errors'
|
|
6
|
+
import { loadBuildConfig } from './build/config'
|
|
7
|
+
import { broadRootWarnings, defaultIgnored, type WatchLoop, watchLoop } from './dev/watch'
|
|
7
8
|
|
|
8
9
|
export interface DevOptions {
|
|
9
10
|
appRoot: string
|
|
@@ -15,7 +16,6 @@ export interface DevOptions {
|
|
|
15
16
|
log: (s: string) => void
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const IGNORED = new Set(['node_modules', '.wrangler', '.git'])
|
|
19
19
|
const DEBOUNCE_MS = 300
|
|
20
20
|
|
|
21
21
|
function wranglerBin(appRoot: string): string {
|
|
@@ -54,7 +54,7 @@ export async function runDev(o: DevOptions): Promise<number> {
|
|
|
54
54
|
child = c
|
|
55
55
|
c.exited.then((code) => {
|
|
56
56
|
if (child !== c || stopping || restarting) return
|
|
57
|
-
|
|
57
|
+
loop.close()
|
|
58
58
|
exitCode(code)
|
|
59
59
|
})
|
|
60
60
|
}
|
|
@@ -65,17 +65,15 @@ export async function runDev(o: DevOptions): Promise<number> {
|
|
|
65
65
|
await c.exited
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
do {
|
|
78
|
-
again = false
|
|
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() {
|
|
79
77
|
o.log('[brust] change detected; rebuilding')
|
|
80
78
|
if ((await build()) === 0) {
|
|
81
79
|
restarting = true
|
|
@@ -83,25 +81,14 @@ export async function runDev(o: DevOptions): Promise<number> {
|
|
|
83
81
|
restarting = false
|
|
84
82
|
if (!stopping) spawn()
|
|
85
83
|
} else o.log('[brust] rebuild failed; keeping the running wrangler')
|
|
86
|
-
}
|
|
87
|
-
building = false
|
|
88
|
-
}
|
|
89
|
-
const ignored = (rel: string) => {
|
|
90
|
-
const abs = resolve(appRoot, rel)
|
|
91
|
-
return rel.split(sep).some((s) => IGNORED.has(s)) || abs === dist || abs.startsWith(dist + sep) || abs.startsWith(`${dist}.`) || abs.startsWith(join(dirname(dist), `.${basename(dist)}.brust-`)) || relative(dist, abs) === ''
|
|
92
|
-
}
|
|
93
|
-
let watcher: FSWatcher | null = watch(appRoot, { recursive: true }, (_ev, name) => {
|
|
94
|
-
if (!name || ignored(String(name))) return
|
|
95
|
-
clearTimeout(timer)
|
|
96
|
-
timer = setTimeout(() => void rebuild(), DEBOUNCE_MS)
|
|
84
|
+
},
|
|
97
85
|
})
|
|
98
86
|
|
|
99
87
|
spawn()
|
|
100
88
|
const stop = () => {
|
|
101
89
|
if (stopping) return
|
|
102
90
|
stopping = true
|
|
103
|
-
|
|
104
|
-
watcher?.close()
|
|
91
|
+
loop.close()
|
|
105
92
|
void stopChild().then(() => exitCode(0))
|
|
106
93
|
}
|
|
107
94
|
process.on('SIGINT', stop)
|
package/src/handlers.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `worker.ts` re-exports everything here, so existing importers are unchanged.
|
|
4
4
|
import { installServerResolver, type StoreSnapshot } from './store/core-adapter'
|
|
5
5
|
import { collectSnapshot, getServerInstance, runInStoreContext, touchStores } from './store/server-context'
|
|
6
|
+
import { NAVIGATION_MODULE } from './store/builtin'
|
|
6
7
|
import { type FlatRoute, isHttpErrorTrigger, isVerdict, type LoaderReq, type Route, type Verdict } from './routes'
|
|
7
8
|
|
|
8
9
|
installServerResolver(getServerInstance)
|
|
@@ -19,11 +20,14 @@ export interface LoaderRequest {
|
|
|
19
20
|
plan?: boolean
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** Response headers as `[name, value]` pairs: a repeated name (`set-cookie`) stays repeated. */
|
|
24
|
+
export type HeaderPairs = [string, string][]
|
|
25
|
+
|
|
22
26
|
export type LoaderResponse =
|
|
23
|
-
| { ok: true; data: Record<string, unknown>; headers?:
|
|
24
|
-
| { verdict: 'notFound'; data: unknown; store?: StoreSnapshot | null }
|
|
25
|
-
| { verdict: 'redirect'; location: string; status: number }
|
|
26
|
-
| { verdict: 'httpError'; status: number; body: string }
|
|
27
|
+
| { ok: true; data: Record<string, unknown>; headers?: HeaderPairs; store?: StoreSnapshot | null }
|
|
28
|
+
| { verdict: 'notFound'; data: unknown; headers?: HeaderPairs; store?: StoreSnapshot | null }
|
|
29
|
+
| { verdict: 'redirect'; location: string; status: number; headers?: HeaderPairs }
|
|
30
|
+
| { verdict: 'httpError'; status: number; body: string; contentType: string; headers?: HeaderPairs }
|
|
27
31
|
| { error: string }
|
|
28
32
|
|
|
29
33
|
export type JobKind = 'precompute' | 'ssr'
|
|
@@ -68,7 +72,7 @@ export interface ManifestLike {
|
|
|
68
72
|
>
|
|
69
73
|
jobs_module?: string
|
|
70
74
|
/** `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
|
|
75
|
+
actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number; stream?: string[] } | null
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export function toJson(out: unknown): string {
|
|
@@ -131,6 +135,50 @@ function withStore<T extends LoaderResponse>(r: T): T {
|
|
|
131
135
|
|
|
132
136
|
// ---- handlers ----
|
|
133
137
|
|
|
138
|
+
// ---- response headers (F464) ----
|
|
139
|
+
|
|
140
|
+
/** Names the server owns: the framing, the page's type, and a redirect's target (`redirect()`). */
|
|
141
|
+
const REFUSED_HEADERS = new Set(['content-type', 'content-length', 'transfer-encoding', 'connection', 'location'])
|
|
142
|
+
|
|
143
|
+
const refusedMessage = (name: string) =>
|
|
144
|
+
`a loader cannot set "${name}" (the server sets it${name.trim().toLowerCase() === 'location' ? '; use redirect()' : ''})`
|
|
145
|
+
|
|
146
|
+
/** `res.headers`: a `Headers` that refuses the server's names, by a throw in dev (the loader fails with a 500 that
|
|
147
|
+
* names it) and by a warning in production (the call does nothing). */
|
|
148
|
+
class LoaderHeaders extends Headers {
|
|
149
|
+
constructor(private readonly dev: boolean) {
|
|
150
|
+
super()
|
|
151
|
+
}
|
|
152
|
+
private allowed(name: string): boolean {
|
|
153
|
+
if (!REFUSED_HEADERS.has(name.trim().toLowerCase())) return true
|
|
154
|
+
const msg = `res.headers: ${refusedMessage(name)}`
|
|
155
|
+
if (this.dev) throw new Error(msg)
|
|
156
|
+
console.warn(`[brust] warning ${msg}; dropped`)
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
override set(name: string, value: string): void {
|
|
160
|
+
if (this.allowed(name)) super.set(name, value)
|
|
161
|
+
}
|
|
162
|
+
override append(name: string, value: string): void {
|
|
163
|
+
if (this.allowed(name)) super.append(name, value)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The wire pairs, absent when empty. `Set-Cookie` one pair per cookie (iteration would join them). */
|
|
168
|
+
function headerPairs(h: Headers): HeaderPairs | undefined {
|
|
169
|
+
const out: HeaderPairs = []
|
|
170
|
+
for (const [k, v] of h) if (k !== 'set-cookie') out.push([k, v])
|
|
171
|
+
for (const c of h.getSetCookie()) out.push(['set-cookie', c])
|
|
172
|
+
return out.length > 0 ? out : undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `r` with `headers` set when there are any. */
|
|
176
|
+
function withHeaders<T extends LoaderResponse>(r: T, h: Headers): T {
|
|
177
|
+
const pairs = headerPairs(h)
|
|
178
|
+
if (pairs) (r as { headers?: HeaderPairs }).headers = pairs
|
|
179
|
+
return r
|
|
180
|
+
}
|
|
181
|
+
|
|
134
182
|
/** `parent`: the data the chain merged before the verdict. A notFound renders the route's
|
|
135
183
|
* template (layouts included), so the layouts' data stays under the verdict's own keys. */
|
|
136
184
|
function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderResponse {
|
|
@@ -141,6 +189,39 @@ function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderRespons
|
|
|
141
189
|
return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
|
|
142
190
|
}
|
|
143
191
|
|
|
192
|
+
/** What a loader did during a static render (gaps round R9.5) that a static file cannot honour. */
|
|
193
|
+
export type SsgRecord =
|
|
194
|
+
| { kind: 'read'; routeId: string; path: string; field: 'headers' | 'cookies' | 'search'; key: string }
|
|
195
|
+
| { kind: 'headers'; routeId: string; path: string; names: string[] }
|
|
196
|
+
|
|
197
|
+
/** R9.5: the request a loader sees while rendering static output: a visitor with no headers, cookies
|
|
198
|
+
* or query. Every read of `headers` / `cookies` / `search` (a key, `in`, or a listing: key `*`) is
|
|
199
|
+
* recorded, so the build can warn that the page written ignores it. */
|
|
200
|
+
export function ssgRequest(req: LoaderReq, routeId: string, path: string, record: (r: SsgRecord) => void): LoaderReq {
|
|
201
|
+
const watched = (field: 'headers' | 'cookies' | 'search') => {
|
|
202
|
+
const read = (key: string) => record({ kind: 'read', routeId, path, field, key })
|
|
203
|
+
return new Proxy(Object.create(null) as Record<string, string>, {
|
|
204
|
+
get: (t, k) => {
|
|
205
|
+
if (typeof k === 'string') read(k)
|
|
206
|
+
return Reflect.get(t, k)
|
|
207
|
+
},
|
|
208
|
+
has: (t, k) => {
|
|
209
|
+
if (typeof k === 'string') read(k)
|
|
210
|
+
return Reflect.has(t, k)
|
|
211
|
+
},
|
|
212
|
+
ownKeys: (t) => {
|
|
213
|
+
read('*')
|
|
214
|
+
return Reflect.ownKeys(t)
|
|
215
|
+
},
|
|
216
|
+
getOwnPropertyDescriptor: (t, k) => {
|
|
217
|
+
if (typeof k === 'string') read(k)
|
|
218
|
+
return Reflect.getOwnPropertyDescriptor(t, k)
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
return { method: 'GET', url: req.url, headers: watched('headers'), cookies: watched('cookies'), search: watched('search') }
|
|
223
|
+
}
|
|
224
|
+
|
|
144
225
|
export interface Handlers {
|
|
145
226
|
loader(req: LoaderRequest): Promise<LoaderResponse>
|
|
146
227
|
jobs(req: JobsRequest): Promise<JobsResponse>
|
|
@@ -154,6 +235,10 @@ export function makeHandlers(opts: {
|
|
|
154
235
|
manifest?: ManifestLike
|
|
155
236
|
/** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
|
|
156
237
|
stores?: Record<string, object>
|
|
238
|
+
/** Static output (R9.5): loaders see `ssgRequest`, and what a static file drops is recorded. */
|
|
239
|
+
ssg?: (r: SsgRecord) => void
|
|
240
|
+
/** `brust dev`: a refused `res.headers` name throws instead of being dropped with a warning (F464). */
|
|
241
|
+
dev?: boolean
|
|
157
242
|
}): Handlers {
|
|
158
243
|
const byId = new Map(opts.leaves.map((l) => [l.id, l]))
|
|
159
244
|
const literals = literalsIndex(opts.manifest)
|
|
@@ -199,25 +284,46 @@ export function makeHandlers(opts: {
|
|
|
199
284
|
// loader's data is assigned as data, as the spread copied it.
|
|
200
285
|
return runInStoreContext(async () => {
|
|
201
286
|
const merged: Record<string, unknown> = Object.create(null)
|
|
287
|
+
const res = { headers: new LoaderHeaders(opts.dev === true) }
|
|
288
|
+
// R9.5: a static file carries no headers; the build warns, naming them.
|
|
289
|
+
const out = <T extends LoaderResponse>(r: T): T => {
|
|
290
|
+
withHeaders(r, res.headers)
|
|
291
|
+
const names = (r as { headers?: HeaderPairs }).headers?.map(([n]) => n)
|
|
292
|
+
if (opts.ssg && names) opts.ssg({ kind: 'headers', routeId: req.routeId, path: req.path, names: [...new Set(names)] })
|
|
293
|
+
return r
|
|
294
|
+
}
|
|
202
295
|
try {
|
|
203
296
|
const touch = storeIdx.get(req.routeId)
|
|
204
|
-
if (touch)
|
|
297
|
+
if (touch) {
|
|
298
|
+
touchStores(touch, storeTable)
|
|
299
|
+
// The built-in `navigation` store (design §11) paints the request's path + search on first paint.
|
|
300
|
+
if (touch.includes(NAVIGATION_MODULE)) (storeTable[NAVIGATION_MODULE] as { navigation?: { hydrate(s: object): void } } | undefined)?.navigation?.hydrate({ url: req.req.url })
|
|
301
|
+
}
|
|
205
302
|
for (const node of leaf.chain) {
|
|
206
303
|
if (!node.loader) continue
|
|
207
304
|
let r: unknown
|
|
208
305
|
try {
|
|
209
|
-
|
|
306
|
+
const lreq = opts.ssg ? ssgRequest(req.req, req.routeId, req.path, opts.ssg) : req.req
|
|
307
|
+
r = await node.loader({ params: req.params, path: req.path, req: lreq, res })
|
|
210
308
|
} catch (e) {
|
|
211
|
-
if (isHttpErrorTrigger(e))
|
|
309
|
+
if (isHttpErrorTrigger(e)) {
|
|
310
|
+
// httpError's own `headers` option adds to what the chain set. A refused name is dropped with a warning
|
|
311
|
+
// in dev too: a throw here would hide the error the loader meant to send (F466).
|
|
312
|
+
for (const [k, v] of Object.entries(e.headers ?? {})) {
|
|
313
|
+
if (REFUSED_HEADERS.has(k.trim().toLowerCase())) console.warn(`[brust] warning httpError headers: ${refusedMessage(k)}; dropped`)
|
|
314
|
+
else res.headers.append(k, v)
|
|
315
|
+
}
|
|
316
|
+
return out({ verdict: 'httpError', status: e.status, body: e.body, contentType: e.contentType } as LoaderResponse)
|
|
317
|
+
}
|
|
212
318
|
throw e
|
|
213
319
|
}
|
|
214
320
|
if (isVerdict(r)) {
|
|
215
|
-
const v = verdictJson(r, merged)
|
|
321
|
+
const v = out(verdictJson(r, merged))
|
|
216
322
|
return 'verdict' in v && v.verdict === 'notFound' ? withStore(v) : v
|
|
217
323
|
}
|
|
218
324
|
if (r && typeof r === 'object') Object.assign(merged, r)
|
|
219
325
|
}
|
|
220
|
-
return withStore({ ok: true, data: merged } as LoaderResponse)
|
|
326
|
+
return out(withStore({ ok: true, data: merged } as LoaderResponse))
|
|
221
327
|
} catch (e) {
|
|
222
328
|
return { error: String(e) } as LoaderResponse
|
|
223
329
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
/// <reference path="./css-modules.d.ts" />
|
|
2
|
+
/// <reference path="./assets.d.ts" />
|
|
1
3
|
// `@brust/core`: the app-facing API.
|
|
4
|
+
export { buildTime } from './build-time'
|
|
5
|
+
export { clearNavigationCache, navigate } from './navigate'
|
|
6
|
+
export type { NavErrorDetail, NavLoadDetail, NavigateOptions, NavSuccessDetail, NavTrigger } from './navigate'
|
|
2
7
|
export { cache } from './cache'
|
|
3
8
|
export { type BrustConfig, BrustConfigError, loadConfig } from './config'
|
|
4
9
|
export { type RunOptions, run } from './run'
|
|
@@ -15,4 +20,4 @@ export {
|
|
|
15
20
|
Outlet,
|
|
16
21
|
redirect,
|
|
17
22
|
} from './routes'
|
|
18
|
-
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
|
|
23
|
+
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, RouteSsgConfig, Verdict } from './routes'
|
package/src/native.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface StartOptions {
|
|
|
22
22
|
workers: number
|
|
23
23
|
claimTimeoutMs?: number
|
|
24
24
|
callTimeoutMs?: number
|
|
25
|
+
/** L10: the biggest loader/jobs reply that may cross the heap when it does not fit the slot; absent = 8388608. */
|
|
26
|
+
maxLoaderResponseBytes?: number
|
|
25
27
|
/** tokio I/O threads; absent = one per core. */
|
|
26
28
|
workerThreads?: number
|
|
27
29
|
l1Capacity?: number
|
|
@@ -31,8 +33,12 @@ export interface StartOptions {
|
|
|
31
33
|
cors?: CorsOptions
|
|
32
34
|
/** The actions subtree (M3-F A3); absent = no actions. Boot error `actions: …` when invalid. */
|
|
33
35
|
actions?: ActionsOptions
|
|
36
|
+
/** L16: what the page tells the browser `client()` (base URL, credentials, a non-default prefix); absent = from `actions` alone. */
|
|
37
|
+
actionsClient?: ActionsClientOptions
|
|
34
38
|
/** The cache adapter (`manifest.cache`); absent = none (byte-identical server). */
|
|
35
39
|
l2?: L2Options
|
|
40
|
+
/** `brust dev` only (L14): a request carrying this token (`x-brust-dev-token`) is traced; absent in production. */
|
|
41
|
+
devToken?: string
|
|
36
42
|
}
|
|
37
43
|
/** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
|
|
38
44
|
export interface L2Options {
|
|
@@ -51,8 +57,14 @@ export interface CorsOptions {
|
|
|
51
57
|
credentials?: boolean
|
|
52
58
|
maxAgeSeconds?: number
|
|
53
59
|
}
|
|
60
|
+
/** napi `NapiActionsClient`. */
|
|
61
|
+
export interface ActionsClientOptions {
|
|
62
|
+
prefix?: string
|
|
63
|
+
baseUrl?: string
|
|
64
|
+
credentials?: 'same-origin' | 'include' | 'omit'
|
|
65
|
+
}
|
|
54
66
|
/** napi `NapiActionsOptions`; defaults `/_brust/action`, 1048576, 8388608. */
|
|
55
|
-
export interface ActionsOptions {
|
|
67
|
+
export interface ActionsOptions extends StreamOptions {
|
|
56
68
|
prefix?: string
|
|
57
69
|
maxBodyBytes?: number
|
|
58
70
|
/** F82: response body cap (bytes). Default 8388608. */
|
|
@@ -60,6 +72,15 @@ export interface ActionsOptions {
|
|
|
60
72
|
/** F87: cross-site mutating requests. Default `reject`. */
|
|
61
73
|
crossSite?: 'reject' | 'allow'
|
|
62
74
|
}
|
|
75
|
+
/** F475: the streamed action bodies (napi `NapiActionsOptions`): `["POST /upload"]` relative to the prefix, and the bounds
|
|
76
|
+
* (defaults 268435456 bytes, 30000 ms idle, 900000 ms in all, a quarter of the worker slots at once). */
|
|
77
|
+
export interface StreamOptions {
|
|
78
|
+
stream?: string[]
|
|
79
|
+
maxStreamBytes?: number
|
|
80
|
+
streamIdleMs?: number
|
|
81
|
+
streamTotalMs?: number
|
|
82
|
+
maxStreams?: number
|
|
83
|
+
}
|
|
63
84
|
export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch' | 'cache', requestJson: string, slot: number) => Promise<number>
|
|
64
85
|
export interface NativeBinding {
|
|
65
86
|
startServer(opts: StartOptions): void
|
|
@@ -71,16 +92,23 @@ export interface NativeBinding {
|
|
|
71
92
|
localAddr(): string
|
|
72
93
|
/** M3-P P6 — see crates/brust-napi/src/server.rs `plan_jobs`. Never throws. */
|
|
73
94
|
planJobs(slot: number, len: number): string
|
|
95
|
+
/** L10 — `planJobs` for a loader response too large for the slot: the bytes are handed over directly. Never throws. */
|
|
96
|
+
planJobsBuf(slot: number, buf: Uint8Array): string
|
|
74
97
|
/** F82 — the heap request body of the in-flight `fetch` call, once; null if none. Never throws. */
|
|
75
98
|
takeFetchBody(slot: number): Uint8Array | null
|
|
76
99
|
/** F82 — hand Rust a response body too large for the slot; false = declined. Never throws. */
|
|
77
100
|
putFetchReply(slot: number, body: Uint8Array): boolean
|
|
101
|
+
/** F475 — the next chunk of the in-flight `fetch` call's streamed body (A1 `{stream: {token}}`): bytes, `null` at the end,
|
|
102
|
+
* or why it failed (`max_stream_bytes` | `idle` | `io`). Never rejects. */
|
|
103
|
+
pullFetchBody(slot: number, token: number): Promise<Uint8Array | string | null>
|
|
78
104
|
/** cache adapter R10 — a foreign broadcast event: tier 1 + the floor at `seq`; never re-published. */
|
|
79
105
|
cacheReplay(args: InvalidateArgs, seq: number | null): InvalidateResult
|
|
80
106
|
/** cache adapter R9 — bump `stats.l2[name]`; false = unknown name / no adapter / no server. Never throws. */
|
|
81
107
|
cacheL2Count(name: string): boolean
|
|
82
108
|
/** cache adapter R10 — drop both tier-1 caches (a broadcast gap); the L1 entries removed. */
|
|
83
109
|
cacheClear(): number
|
|
110
|
+
/** Static output (gaps round R9.4): the request reads of a cache key expression; throws when it does not parse. */
|
|
111
|
+
cacheExprReads(src: string): string[]
|
|
84
112
|
}
|
|
85
113
|
|
|
86
114
|
let binding: NativeBinding | null = null
|
|
@@ -107,8 +135,11 @@ export const cacheInvalidate = (args: InvalidateArgs): InvalidateResult => nativ
|
|
|
107
135
|
export const cacheStats = (): string => native().cacheStats()
|
|
108
136
|
export const localAddr = (): string => native().localAddr()
|
|
109
137
|
export const planJobs = (slot: number, len: number): string => native().planJobs(slot, len)
|
|
138
|
+
export const planJobsBuf = (slot: number, buf: Uint8Array): string => native().planJobsBuf(slot, buf)
|
|
110
139
|
export const takeFetchBody = (slot: number): Uint8Array | null => native().takeFetchBody(slot)
|
|
111
140
|
export const putFetchReply = (slot: number, body: Uint8Array): boolean => native().putFetchReply(slot, body)
|
|
141
|
+
export const pullFetchBody = (slot: number, token: number): Promise<Uint8Array | string | null> => native().pullFetchBody(slot, token)
|
|
112
142
|
export const cacheReplay = (args: InvalidateArgs, seq: number | null): InvalidateResult => native().cacheReplay(args, seq)
|
|
113
143
|
export const cacheL2Count = (name: string): boolean => native().cacheL2Count(name)
|
|
114
144
|
export const cacheClear = (): number => native().cacheClear()
|
|
145
|
+
export const cacheExprReads = (src: string): string[] => native().cacheExprReads(src)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// `navigate()` in the browser: runs the SPA flow through the `nav` module (`Symbol.for('brust.nav')`, the same
|
|
2
|
+
// cross-chunk pattern as `brust.stores`) and resolves after `success` or `error`. Without `nav` it is a full load.
|
|
3
|
+
import type { NavigateOptions } from './navigate'
|
|
4
|
+
|
|
5
|
+
type Bridge = { navigate(url: string, opts?: NavigateOptions): Promise<void>; clearCache(): void }
|
|
6
|
+
const bridge = () => (globalThis as { [k: symbol]: Bridge | undefined })[Symbol.for('brust.nav')]
|
|
7
|
+
|
|
8
|
+
export function navigate(url: string, opts?: NavigateOptions): Promise<void> {
|
|
9
|
+
const b = bridge()
|
|
10
|
+
if (b) return b.navigate(url, opts)
|
|
11
|
+
if (opts?.replace) location.replace(url)
|
|
12
|
+
else location.assign(url)
|
|
13
|
+
return Promise.resolve()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Drop every page the SPA navigation cache holds (design §10.1), e.g. after a mutation made outside `client()`. */
|
|
17
|
+
export function clearNavigationCache(): void {
|
|
18
|
+
bridge()?.clearCache()
|
|
19
|
+
}
|