@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +91 -4
  3. package/native/index.js +60 -54
  4. package/package.json +25 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +332 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +462 -52
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +320 -0
  19. package/src/build/compile.ts +116 -6
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +331 -0
  22. package/src/build/esm-resolve.ts +170 -0
  23. package/src/build/features.ts +114 -0
  24. package/src/build/index.ts +264 -20
  25. package/src/build/manifest.ts +85 -7
  26. package/src/build/pkg-src.ts +514 -0
  27. package/src/build/recognised.ts +19 -0
  28. package/src/build/scan.ts +71 -20
  29. package/src/build/server-only.ts +39 -0
  30. package/src/build/ssg-render.ts +65 -0
  31. package/src/build/ssg.ts +146 -0
  32. package/src/build/static-worker.ts +91 -0
  33. package/src/build/static.ts +258 -0
  34. package/src/build/tailwind.ts +117 -0
  35. package/src/build/tsconfig-paths.ts +67 -0
  36. package/src/build-time.ts +10 -0
  37. package/src/cache-adapter/bridge.ts +91 -0
  38. package/src/cache-adapter/conformance.ts +226 -0
  39. package/src/cache-adapter/index.ts +10 -0
  40. package/src/cache-adapter/load.ts +144 -0
  41. package/src/cache-adapter/memory.ts +229 -0
  42. package/src/cache-adapter/runtime.ts +124 -0
  43. package/src/cache-adapter/types.ts +96 -0
  44. package/src/cache-adapter/workers.ts +118 -0
  45. package/src/cache.ts +4 -1
  46. package/src/cf.ts +19 -0
  47. package/src/cli.ts +71 -2
  48. package/src/config.ts +127 -25
  49. package/src/css-modules.d.ts +8 -0
  50. package/src/dev/bun.ts +371 -0
  51. package/src/dev/client.ts +92 -0
  52. package/src/dev/devtools-data.ts +23 -0
  53. package/src/dev/devtools.ts +356 -0
  54. package/src/dev/diag.ts +30 -0
  55. package/src/dev/proxy.ts +236 -0
  56. package/src/dev/tui.ts +144 -0
  57. package/src/dev/watch.ts +130 -0
  58. package/src/dev.ts +97 -0
  59. package/src/handlers.ts +282 -0
  60. package/src/index.ts +3 -1
  61. package/src/native.ts +38 -1
  62. package/src/routes.ts +20 -1
  63. package/src/run.ts +59 -12
  64. package/src/server-handlers.ts +32 -0
  65. package/src/shadcn/init.ts +513 -0
  66. package/src/shadcn/jsonc.ts +129 -0
  67. package/src/shadcn/registry.ts +149 -0
  68. package/src/worker.ts +127 -245
package/src/dev/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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
+