@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.
- package/README.md +66 -6
- package/native/index.d.ts +91 -4
- package/native/index.js +60 -54
- package/package.json +25 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +24 -2
- package/src/actions-bridge.ts +29 -11
- package/src/browser.ts +3 -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 +462 -52
- 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 +320 -0
- package/src/build/compile.ts +116 -6
- package/src/build/config.ts +294 -11
- package/src/build/css.ts +331 -0
- package/src/build/esm-resolve.ts +170 -0
- package/src/build/features.ts +114 -0
- package/src/build/index.ts +264 -20
- package/src/build/manifest.ts +85 -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 +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 +71 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +371 -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 +130 -0
- package/src/dev.ts +97 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +38 -1
- package/src/routes.ts +20 -1
- package/src/run.ts +59 -12
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +513 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/shadcn/registry.ts +149 -0
- package/src/worker.ts +127 -245
package/src/dev/bun.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// `brust dev` for the Bun target (R13): a long-lived parent owns the port (proxy.ts), every successful
|
|
2
|
+
// build is booted as a NEW child (`brust start` on a loopback port) and swapped in blue/green. A failed
|
|
3
|
+
// build or a failed boot keeps the old child serving. Never an in-process reload: one SERVER per
|
|
4
|
+
// process, workers never restart, SABs are never freed (R13.1).
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { loadConfig } from '../config'
|
|
9
|
+
import { type Diag, parseBuildError, parseWarnings } from './diag'
|
|
10
|
+
import { tierCounts } from './devtools-data'
|
|
11
|
+
import { type DevProxy, startProxy } from './proxy'
|
|
12
|
+
import { HOTKEYS, Tui, type TuiOut, tuiMode } from './tui'
|
|
13
|
+
import { loadBuildConfig } from '../build/config'
|
|
14
|
+
import { broadRootWarnings, changeLine, defaultIgnored, watchLoop } from './watch'
|
|
15
|
+
|
|
16
|
+
export interface BunDevOptions {
|
|
17
|
+
appRoot: string
|
|
18
|
+
entry: string
|
|
19
|
+
port?: number
|
|
20
|
+
/** Bun workers of each child; default 1 (R13.4). */
|
|
21
|
+
workers?: number
|
|
22
|
+
out?: TuiOut
|
|
23
|
+
env?: Record<string, string | undefined>
|
|
24
|
+
/** Fires with the user-visible URL once the proxy listens (tests). */
|
|
25
|
+
onListening?: (url: string) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEBOUNCE_MS = 150
|
|
29
|
+
const BOOT_TIMEOUT_MS = 60_000
|
|
30
|
+
const TAIL_LINES = 40
|
|
31
|
+
const cliPath = join(import.meta.dir, '..', 'cli.ts')
|
|
32
|
+
|
|
33
|
+
/** A free loopback port (bind port 0, read it, release it). */
|
|
34
|
+
export function freePort(): number {
|
|
35
|
+
const s = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') })
|
|
36
|
+
const p = s.port as number
|
|
37
|
+
s.stop(true)
|
|
38
|
+
return p
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pumpLines(stream: ReadableStream<Uint8Array> | null | undefined, onLine: (l: string) => void): void {
|
|
42
|
+
if (!stream) return
|
|
43
|
+
void (async () => {
|
|
44
|
+
const dec = new TextDecoder()
|
|
45
|
+
let buf = ''
|
|
46
|
+
for await (const chunk of stream) {
|
|
47
|
+
buf += dec.decode(chunk, { stream: true })
|
|
48
|
+
let i = buf.indexOf('\n')
|
|
49
|
+
while (i >= 0) {
|
|
50
|
+
onLine(buf.slice(0, i).replace(/\r$/, ''))
|
|
51
|
+
buf = buf.slice(i + 1)
|
|
52
|
+
i = buf.indexOf('\n')
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (buf) onLine(buf)
|
|
56
|
+
})()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** L19/L21 dev swap: the built stylesheets of the old and the new generation, paired by source stem
|
|
60
|
+
* (`<stem>-<hex10>.css`), as `/_brust/client/<old>` → `/_brust/client/<new>`. `null` when the pairing is not
|
|
61
|
+
* one-to-one (a stylesheet was added or removed, or two sources share a stem): the page must reload. */
|
|
62
|
+
export function cssSwapMap(oldDir: string, newDir: string): Record<string, string> | null {
|
|
63
|
+
const byStem = (dir: string) => {
|
|
64
|
+
const m = new Map<string, string>()
|
|
65
|
+
let files: string[] = []
|
|
66
|
+
try {
|
|
67
|
+
files = readdirSync(join(dir, 'client'))
|
|
68
|
+
} catch {}
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
const s = /^(.*)-[0-9a-f]{10}\.css$/.exec(f)?.[1]
|
|
71
|
+
if (s === undefined) continue
|
|
72
|
+
if (m.has(s)) return null
|
|
73
|
+
m.set(s, f)
|
|
74
|
+
}
|
|
75
|
+
return m
|
|
76
|
+
}
|
|
77
|
+
const a = byStem(oldDir)
|
|
78
|
+
const b = byStem(newDir)
|
|
79
|
+
if (!a || !b || a.size !== b.size) return null
|
|
80
|
+
const map: Record<string, string> = {}
|
|
81
|
+
for (const [stem, f] of a) {
|
|
82
|
+
const g = b.get(stem)
|
|
83
|
+
if (g === undefined) return null
|
|
84
|
+
if (g !== f) map[`/_brust/client/${f}`] = `/_brust/client/${g}`
|
|
85
|
+
}
|
|
86
|
+
return map
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The child server's own tracing chatter is not forwarded; the app's console output is. */
|
|
90
|
+
const NOISE = /\b(INFO|DEBUG)\b|^\[brust\] (listening on|ready)|^\s*$/
|
|
91
|
+
|
|
92
|
+
interface Child {
|
|
93
|
+
proc: ReturnType<typeof Bun.spawn>
|
|
94
|
+
port: number
|
|
95
|
+
dir: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
99
|
+
const appRoot = resolve(o.appRoot)
|
|
100
|
+
const entry = resolve(appRoot, o.entry)
|
|
101
|
+
const out: TuiOut = o.out ?? (process.stdout as unknown as TuiOut)
|
|
102
|
+
const env = o.env ?? process.env
|
|
103
|
+
const mode = tuiMode(out, env)
|
|
104
|
+
const tui = new Tui(out, mode.interactive, mode.color)
|
|
105
|
+
const cfg = await loadConfig(appRoot, o.port !== undefined ? { port: o.port } : {})
|
|
106
|
+
const workers = o.workers ?? 1
|
|
107
|
+
const devRoot = join(appRoot, '.brust', 'dev')
|
|
108
|
+
rmSync(devRoot, { recursive: true, force: true })
|
|
109
|
+
mkdirSync(devRoot, { recursive: true })
|
|
110
|
+
|
|
111
|
+
// R14.1: only this process and its children know the token; the core traces a request only if it carries it.
|
|
112
|
+
const devToken = crypto.randomUUID()
|
|
113
|
+
let counter = 0
|
|
114
|
+
let gen = 0
|
|
115
|
+
let state: 'building' | 'ready' | 'error' = 'building'
|
|
116
|
+
let diag: Diag | null = null
|
|
117
|
+
let active: Child | null = null
|
|
118
|
+
let buildMs: number | undefined
|
|
119
|
+
let warnings: string[] = []
|
|
120
|
+
let last: { method: string; path: string; status: number; tiers?: { static: number; native: number; react: number } } | undefined
|
|
121
|
+
let stopping = false
|
|
122
|
+
let url = ''
|
|
123
|
+
/** Processes spawned by an in-flight cycle (a build, a booting child): `stop()` must reach them (R13.5, own PIDs only). */
|
|
124
|
+
const inflight = new Set<ReturnType<typeof Bun.spawn>>()
|
|
125
|
+
|
|
126
|
+
const publish = () =>
|
|
127
|
+
tui.setStatus({
|
|
128
|
+
state,
|
|
129
|
+
url,
|
|
130
|
+
gen,
|
|
131
|
+
buildMs,
|
|
132
|
+
last,
|
|
133
|
+
warnings,
|
|
134
|
+
error: diag && state === 'error' ? `${diag.rule} ${diag.file ? `${diag.file}${diag.line ? `:${diag.line}` : ''} ` : ''}${diag.message}`.trim() : undefined,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const devMeta = (): unknown | null => {
|
|
138
|
+
try {
|
|
139
|
+
return active ? JSON.parse(readFileSync(join(active.dir, 'dev.json'), 'utf8')) : null
|
|
140
|
+
} catch {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const proxy: DevProxy = startProxy(
|
|
146
|
+
{ hostname: cfg.host, port: cfg.port, token: devToken },
|
|
147
|
+
{
|
|
148
|
+
gen: () => gen,
|
|
149
|
+
state: () => state,
|
|
150
|
+
diag: () => diag,
|
|
151
|
+
upstream: () => active?.port ?? null,
|
|
152
|
+
meta: devMeta,
|
|
153
|
+
onRequest(r) {
|
|
154
|
+
tui.log(`${r.method} ${r.path} ${r.status} ${r.ms}ms${r.cache ? ` ${r.cache}` : ''}`)
|
|
155
|
+
if (!r.path.startsWith('/_brust/')) {
|
|
156
|
+
const rendered = (r.trace as { rendered?: string[] } | null)?.rendered
|
|
157
|
+
last = { method: r.method, path: r.path, status: r.status, ...(rendered ? { tiers: tierCounts(rendered, devMeta() as Parameters<typeof tierCounts>[1]) } : {}) }
|
|
158
|
+
publish()
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
url = `http://${cfg.host}:${proxy.port}`
|
|
164
|
+
o.onListening?.(url)
|
|
165
|
+
|
|
166
|
+
/** Build into `dir`; a child process, so module caches never leak between builds. */
|
|
167
|
+
async function build(dir: string): Promise<{ ok: boolean; text: string; ms: number }> {
|
|
168
|
+
const t0 = performance.now()
|
|
169
|
+
const p = Bun.spawn([process.execPath, cliPath, 'build', o.entry, '--out-dir', dir, '--dev'], {
|
|
170
|
+
cwd: appRoot,
|
|
171
|
+
env: { ...process.env, NODE_ENV: 'development', BRUST_BUILD_MODE: 'development' },
|
|
172
|
+
stdout: 'pipe',
|
|
173
|
+
stderr: 'pipe',
|
|
174
|
+
})
|
|
175
|
+
inflight.add(p)
|
|
176
|
+
const [so, se, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
177
|
+
inflight.delete(p)
|
|
178
|
+
return { ok: code === 0, text: `${so}\n${se}`, ms: Math.round(performance.now() - t0) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Boot `brust start` over `dir`; resolves once `[brust] ready`, rejects with the output tail. */
|
|
182
|
+
function boot(dir: string): Promise<Child> {
|
|
183
|
+
const port = freePort()
|
|
184
|
+
const proc = Bun.spawn([process.execPath, cliPath, 'start', '--dist-dir', dir, '--entry', entry, '--workers', String(workers)], {
|
|
185
|
+
cwd: appRoot,
|
|
186
|
+
env: {
|
|
187
|
+
...process.env,
|
|
188
|
+
NODE_ENV: 'development',
|
|
189
|
+
BRUST_PORT: String(port),
|
|
190
|
+
BRUST_ADDR: '127.0.0.1',
|
|
191
|
+
BRUST_WORKERS: String(workers),
|
|
192
|
+
BRUST_DIST_DIR: dir,
|
|
193
|
+
BRUST_APP_ENTRY: entry,
|
|
194
|
+
BRUST_DEV_TOKEN: devToken,
|
|
195
|
+
},
|
|
196
|
+
stdout: 'pipe',
|
|
197
|
+
stderr: 'pipe',
|
|
198
|
+
})
|
|
199
|
+
inflight.add(proc)
|
|
200
|
+
const tail: string[] = []
|
|
201
|
+
let ready = false
|
|
202
|
+
return new Promise<Child>((resolveBoot, reject) => {
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
proc.kill('SIGKILL')
|
|
205
|
+
reject(new Error(`the new build did not become ready within ${BOOT_TIMEOUT_MS / 1000}s\n${tail.join('\n')}`))
|
|
206
|
+
}, BOOT_TIMEOUT_MS)
|
|
207
|
+
const line = (l: string) => {
|
|
208
|
+
tail.push(l)
|
|
209
|
+
if (tail.length > TAIL_LINES) tail.shift()
|
|
210
|
+
if (!ready && /\[brust\] ready/.test(l)) {
|
|
211
|
+
ready = true
|
|
212
|
+
clearTimeout(timer)
|
|
213
|
+
resolveBoot({ proc, port, dir })
|
|
214
|
+
} else if (ready && !NOISE.test(l)) tui.log(l)
|
|
215
|
+
}
|
|
216
|
+
pumpLines(proc.stdout as ReadableStream<Uint8Array>, line)
|
|
217
|
+
pumpLines(proc.stderr as ReadableStream<Uint8Array>, line)
|
|
218
|
+
void proc.exited.then((code) => {
|
|
219
|
+
inflight.delete(proc)
|
|
220
|
+
clearTimeout(timer)
|
|
221
|
+
if (!ready) reject(new Error(`the new build exited with code ${code} before it was ready\n${tail.join('\n')}`))
|
|
222
|
+
else if (active?.proc === proc && !stopping) tui.log(`[brust] the server process exited (code ${code})`)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function stopChild(c: Child): Promise<void> {
|
|
228
|
+
c.proc.kill('SIGINT')
|
|
229
|
+
if ((await Promise.race([c.proc.exited, Bun.sleep(4000).then(() => undefined)])) === undefined) {
|
|
230
|
+
c.proc.kill('SIGKILL')
|
|
231
|
+
await c.proc.exited
|
|
232
|
+
}
|
|
233
|
+
rmSync(c.dir, { recursive: true, force: true })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function fail(d: Diag): void {
|
|
237
|
+
state = 'error'
|
|
238
|
+
diag = d
|
|
239
|
+
proxy.broadcast({ type: 'error', diag: d })
|
|
240
|
+
tui.log(`[brust] ${d.rule}${d.file ? ` ${d.file}${d.line ? `:${d.line}${d.col ? `:${d.col}` : ''}` : ''}` : ''} ${d.message}`)
|
|
241
|
+
publish()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One build -> boot -> swap cycle. */
|
|
245
|
+
async function cycle(cssOnly = false): Promise<void> {
|
|
246
|
+
state = 'building'
|
|
247
|
+
publish()
|
|
248
|
+
const g = ++counter
|
|
249
|
+
const dir = join(devRoot, `g${g}`)
|
|
250
|
+
const b = await build(dir)
|
|
251
|
+
if (stopping) {
|
|
252
|
+
rmSync(dir, { recursive: true, force: true })
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
warnings = parseWarnings(b.text)
|
|
256
|
+
if (!b.ok) {
|
|
257
|
+
rmSync(dir, { recursive: true, force: true })
|
|
258
|
+
fail(parseBuildError(b.text) ?? { rule: 'build', message: b.text.trim().split('\n').pop() ?? 'build failed', raw: b.text.trim() })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
let child: Child
|
|
262
|
+
try {
|
|
263
|
+
child = await boot(dir)
|
|
264
|
+
} catch (e) {
|
|
265
|
+
rmSync(dir, { recursive: true, force: true })
|
|
266
|
+
if (stopping) return
|
|
267
|
+
const raw = (e as Error).message
|
|
268
|
+
fail({ rule: 'boot', message: raw.split('\n')[0] ?? raw, raw })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
// Stopped while this child booted, or a newer generation already took over: this child must not be adopted.
|
|
272
|
+
if (stopping || g < gen) {
|
|
273
|
+
await stopChild(child)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
const old = active
|
|
277
|
+
active = child
|
|
278
|
+
gen = g
|
|
279
|
+
buildMs = b.ms
|
|
280
|
+
state = 'ready'
|
|
281
|
+
diag = null
|
|
282
|
+
// A stylesheet-only edit (L19/L21): the pages keep their DOM and swap their brust CSS links.
|
|
283
|
+
const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir) : null
|
|
284
|
+
if (swap) {
|
|
285
|
+
proxy.broadcast({ type: 'css-swap', gen, map: swap })
|
|
286
|
+
tui.log(`[brust] css rebuilt (${Object.keys(swap).length} stylesheet${Object.keys(swap).length === 1 ? '' : 's'} swapped, no reload)`)
|
|
287
|
+
} else proxy.broadcast({ type: 'reload', gen })
|
|
288
|
+
publish()
|
|
289
|
+
if (old) void stopChild(old)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const isCss = (rel: string) => rel.startsWith('public/') && rel.endsWith('.css')
|
|
293
|
+
|
|
294
|
+
// F273: `[build] source_roots` are watched too (a bad config is the build's error to report, not the watcher's)
|
|
295
|
+
const extraRoots = await loadBuildConfig(appRoot).then((c) => c.sourceRoots.map((r) => resolve(appRoot, r)), () => [] as string[])
|
|
296
|
+
for (const w of broadRootWarnings(extraRoots, appRoot)) tui.log(w)
|
|
297
|
+
const loop = watchLoop({
|
|
298
|
+
root: appRoot,
|
|
299
|
+
extraRoots,
|
|
300
|
+
ignored: (rel) => defaultIgnored(rel, 'dist'),
|
|
301
|
+
debounceMs: DEBOUNCE_MS,
|
|
302
|
+
async run(changed) {
|
|
303
|
+
// A change to public/*.css skips the build: copy it into the active dist and swap the <link> (R13.3).
|
|
304
|
+
if (active && state === 'ready' && [...changed].every((r) => isCss(r) && existsSync(join(appRoot, r)))) {
|
|
305
|
+
const v = Date.now()
|
|
306
|
+
for (const rel of changed) {
|
|
307
|
+
cpSync(join(appRoot, rel), join(active.dir, rel))
|
|
308
|
+
proxy.broadcast({ type: 'css', href: `/${rel}`, v })
|
|
309
|
+
}
|
|
310
|
+
tui.log(`[brust] css ${[...changed].join(', ')} (no reload)`)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const line = changeLine(changed)
|
|
314
|
+
if (line) tui.log(line)
|
|
315
|
+
// Only built stylesheets changed (L19/L21): rebuild, boot, then swap the links instead of reloading.
|
|
316
|
+
await cycle(active !== null && state === 'ready' && [...changed].every((r) => r.endsWith('.css') && !isCss(r)))
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
let exit: (code: number) => void = () => {}
|
|
321
|
+
const done = new Promise<number>((r) => {
|
|
322
|
+
exit = r
|
|
323
|
+
})
|
|
324
|
+
let stdinRaw = false
|
|
325
|
+
const stop = async () => {
|
|
326
|
+
if (stopping) return
|
|
327
|
+
stopping = true
|
|
328
|
+
loop.close()
|
|
329
|
+
proxy.stop()
|
|
330
|
+
if (stdinRaw) {
|
|
331
|
+
process.stdin.setRawMode(false)
|
|
332
|
+
process.stdin.pause()
|
|
333
|
+
}
|
|
334
|
+
tui.close()
|
|
335
|
+
// A build or a booting child of an in-flight cycle: SIGINT, then SIGKILL (own PIDs only).
|
|
336
|
+
const mid = [...inflight]
|
|
337
|
+
for (const p of mid) p.kill('SIGINT')
|
|
338
|
+
await Promise.all(mid.map(async (p) => {
|
|
339
|
+
if ((await Promise.race([p.exited, Bun.sleep(4000).then(() => undefined)])) === undefined) p.kill('SIGKILL')
|
|
340
|
+
}))
|
|
341
|
+
if (active) await stopChild(active)
|
|
342
|
+
rmSync(devRoot, { recursive: true, force: true })
|
|
343
|
+
exit(0)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (mode.interactive && process.stdin.isTTY) {
|
|
347
|
+
stdinRaw = true
|
|
348
|
+
process.stdin.setRawMode(true)
|
|
349
|
+
process.stdin.resume()
|
|
350
|
+
process.stdin.on('data', (b) => {
|
|
351
|
+
for (const k of b.toString()) {
|
|
352
|
+
if (k === 'q' || k === '\x03') void stop()
|
|
353
|
+
else if (k === 'r') proxy.broadcast({ type: 'reload' })
|
|
354
|
+
else if (k === 'o') Bun.spawn([process.platform === 'darwin' ? 'open' : 'xdg-open', url], { stdout: 'ignore', stderr: 'ignore' })
|
|
355
|
+
else if (k === 'w') for (const w of warnings.length ? warnings : ['no warnings']) tui.log(`warning ${w}`)
|
|
356
|
+
else if (k === 'c') tui.clear()
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
;(out as unknown as NodeJS.WriteStream).on?.('resize', () => tui.resize())
|
|
360
|
+
}
|
|
361
|
+
process.on('SIGINT', () => void stop())
|
|
362
|
+
process.on('SIGTERM', () => void stop())
|
|
363
|
+
|
|
364
|
+
tui.log(`[brust] dev server on ${url} — ${HOTKEYS}`)
|
|
365
|
+
publish()
|
|
366
|
+
// The first build goes through the loop too, so it is single-flight with every later one: an edit saved while it
|
|
367
|
+
// runs waits for it and then builds on top (never a second cycle racing the first).
|
|
368
|
+
loop.trigger('.')
|
|
369
|
+
void loop.idle()
|
|
370
|
+
return done
|
|
371
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The browser half of `brust dev`, served as `/_brust/dev/client.js?g=<gen>` and injected into every HTML
|
|
2
|
+
// response by the proxy. One WebSocket to the parent (`/_brust/dev/ws`):
|
|
3
|
+
// hello {gen, state, diag} on every (re)connect — a page older than `gen` reloads (the catch-up), a failing
|
|
4
|
+
// build shows the overlay above the stale page
|
|
5
|
+
// reload a new generation is serving
|
|
6
|
+
// error {diag} a build/boot failed
|
|
7
|
+
// css {href, v} a public/*.css changed: swap the <link>, keep the page
|
|
8
|
+
// css-swap {gen, map} only built stylesheets changed (L19/L21): a new generation serves; swap each
|
|
9
|
+
// data-brust-css link old href -> new href, keep the page (no reload)
|
|
10
|
+
// WS, not SSE: SSE holds one of the six per-host connections, so six tabs would stall (R13.2).
|
|
11
|
+
// The overlay lives in a shadow root, so page CSS cannot touch it and it cannot touch the page.
|
|
12
|
+
export const DEV_CLIENT_JS = String.raw`
|
|
13
|
+
let pageGen = Number(new URL(import.meta.url).searchParams.get('g') || 0)
|
|
14
|
+
let host = null
|
|
15
|
+
let delay = 150
|
|
16
|
+
|
|
17
|
+
function esc(s) {
|
|
18
|
+
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clearOverlay() {
|
|
22
|
+
host?.remove()
|
|
23
|
+
host = null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function showOverlay(diag) {
|
|
27
|
+
clearOverlay()
|
|
28
|
+
host = document.createElement('brust-dev-overlay')
|
|
29
|
+
const root = host.attachShadow({ mode: 'open' })
|
|
30
|
+
const where = diag.file ? esc(diag.file) + (diag.line ? ':' + diag.line + (diag.col ? ':' + diag.col : '') : '') : ''
|
|
31
|
+
root.innerHTML =
|
|
32
|
+
'<style>:host{all:initial}.box{position:fixed;inset:0;z-index:2147483647;overflow:auto;background:rgba(20,0,0,.88);color:#fdd;font:14px/1.5 ui-monospace,Menlo,monospace;padding:32px}' +
|
|
33
|
+
'h1{margin:0 0 8px;font-size:18px;color:#ff8a80}.where{color:#ffd180;margin-bottom:12px}pre{white-space:pre-wrap;margin:0;color:#fff}small{display:block;margin-top:24px;color:#bbb}</style>' +
|
|
34
|
+
'<div class="box" role="alert" data-testid="brust-dev-overlay"><h1>brust: ' + esc(diag.rule || 'error') + '</h1>' +
|
|
35
|
+
(where ? '<div class="where">' + where + '</div>' : '') +
|
|
36
|
+
'<pre>' + esc(diag.raw || diag.message || '') + '</pre><small>The page below is the last good build. Fix the error and save: this overlay goes away on its own.</small></div>'
|
|
37
|
+
document.documentElement.appendChild(host)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function swapCss(href, v) {
|
|
41
|
+
for (const old of document.querySelectorAll('link[rel~="stylesheet"]')) {
|
|
42
|
+
const u = new URL(old.href, location.href)
|
|
43
|
+
if (u.origin !== location.origin || u.pathname !== href) continue
|
|
44
|
+
const next = old.cloneNode()
|
|
45
|
+
next.href = href + '?v=' + v
|
|
46
|
+
next.onload = () => old.remove()
|
|
47
|
+
next.onerror = () => next.remove()
|
|
48
|
+
old.after(next)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function swapBuiltCss(map) {
|
|
53
|
+
for (const old of document.querySelectorAll('link[data-brust-css]')) {
|
|
54
|
+
const to = map[new URL(old.href, location.href).pathname]
|
|
55
|
+
if (!to) continue
|
|
56
|
+
const next = old.cloneNode()
|
|
57
|
+
next.href = to
|
|
58
|
+
next.onload = () => old.remove()
|
|
59
|
+
next.onerror = () => next.remove()
|
|
60
|
+
old.after(next)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function handle(m) {
|
|
65
|
+
if (m.type === 'hello') {
|
|
66
|
+
if (m.state === 'error' && m.diag) showOverlay(m.diag)
|
|
67
|
+
else {
|
|
68
|
+
clearOverlay()
|
|
69
|
+
if (m.gen > pageGen) location.reload()
|
|
70
|
+
}
|
|
71
|
+
} else if (m.type === 'reload') location.reload()
|
|
72
|
+
else if (m.type === 'error') showOverlay(m.diag)
|
|
73
|
+
else if (m.type === 'css') swapCss(m.href, m.v)
|
|
74
|
+
else if (m.type === 'css-swap') {
|
|
75
|
+
pageGen = m.gen
|
|
76
|
+
swapBuiltCss(m.map)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function connect() {
|
|
81
|
+
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/_brust/dev/ws')
|
|
82
|
+
ws.onopen = () => {
|
|
83
|
+
delay = 150
|
|
84
|
+
}
|
|
85
|
+
ws.onmessage = (e) => handle(JSON.parse(e.data))
|
|
86
|
+
ws.onclose = () => {
|
|
87
|
+
setTimeout(connect, delay)
|
|
88
|
+
delay = Math.min(delay * 2, 2000)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
connect()
|
|
92
|
+
`
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Pure data helpers of the devtools overlay (no DOM at import time: unit-testable under Bun).
|
|
2
|
+
export type Tier = 'static' | 'native' | 'react'
|
|
3
|
+
export interface DevMeta { components?: Record<string, { tier: Tier }> }
|
|
4
|
+
|
|
5
|
+
export function tierCounts(rendered: string[], meta: DevMeta | null): Record<Tier, number> {
|
|
6
|
+
const out = { static: 0, native: 0, react: 0 }
|
|
7
|
+
for (const id of rendered) {
|
|
8
|
+
const t = meta?.components?.[id]?.tier
|
|
9
|
+
if (t) out[t]++
|
|
10
|
+
}
|
|
11
|
+
return out
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Contrast ratio of two sRGB colours given as `[r, g, b]` 0..255 (WCAG 2.x). */
|
|
15
|
+
export function contrast(a: [number, number, number], b: [number, number, number]): number {
|
|
16
|
+
const lum = (c: [number, number, number]) => {
|
|
17
|
+
const [r, g, bl] = c.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }) as [number, number, number]
|
|
18
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * bl
|
|
19
|
+
}
|
|
20
|
+
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x) as [number, number]
|
|
21
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
22
|
+
}
|
|
23
|
+
|