@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.5

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 (47) hide show
  1. package/README.md +64 -6
  2. package/native/index.d.ts +39 -4
  3. package/native/index.js +56 -54
  4. package/package.json +17 -10
  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 +2 -2
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +199 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +39 -13
  14. package/src/build/cf-assets.ts +4 -2
  15. package/src/build/cf-entry.ts +7 -3
  16. package/src/build/cf-wrangler.ts +3 -1
  17. package/src/build/cloudflare.ts +14 -9
  18. package/src/build/compile.ts +41 -4
  19. package/src/build/config.ts +46 -10
  20. package/src/build/css.ts +286 -0
  21. package/src/build/index.ts +135 -20
  22. package/src/build/manifest.ts +23 -4
  23. package/src/build/scan.ts +59 -20
  24. package/src/build/ssg-render.ts +65 -0
  25. package/src/build/ssg.ts +146 -0
  26. package/src/build/static-worker.ts +91 -0
  27. package/src/build/static.ts +258 -0
  28. package/src/build-time.ts +10 -0
  29. package/src/cf.ts +2 -1
  30. package/src/cli.ts +19 -4
  31. package/src/config.ts +111 -25
  32. package/src/css-modules.d.ts +8 -0
  33. package/src/dev/bun.ts +365 -0
  34. package/src/dev/client.ts +92 -0
  35. package/src/dev/devtools-data.ts +23 -0
  36. package/src/dev/devtools.ts +356 -0
  37. package/src/dev/diag.ts +30 -0
  38. package/src/dev/proxy.ts +236 -0
  39. package/src/dev/tui.ts +144 -0
  40. package/src/dev/watch.ts +81 -0
  41. package/src/dev.ts +11 -28
  42. package/src/handlers.ts +41 -2
  43. package/src/index.ts +3 -1
  44. package/src/native.ts +18 -0
  45. package/src/routes.ts +20 -1
  46. package/src/run.ts +40 -8
  47. package/src/worker.ts +88 -20
package/src/config.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  // Runtime configuration (spec §8). Precedence, high → low:
2
2
  // env (BRUST_ADDR / BRUST_PORT / BRUST_WORKERS / BRUST_RENDER_SLOTS / BRUST_DRAIN_TIMEOUT_MS /
3
- // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS)
4
- // > CLI flags (`brust start --port/--workers`)
5
- // > brust.toml ([server] address / port, [workers] count)
6
- // > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000).
3
+ // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES)
4
+ // > CLI flags (`brust start --port/--workers`) and `run({ config })`
5
+ // > brust.toml ([server] address / port / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
6
+ // > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000, 262144, 8388608).
7
+ // L16 R16.1: `actionsBaseUrl` follows env (BRUST_ACTIONS_BASE_URL) > run({ config }) > toml `[actions] base_url`.
8
+ // `cors` has no env layer; `run({ config: { cors } })` replaces a toml `[cors]` as a whole (never merged), and
9
+ // `cors: null` turns a toml `[cors]` off.
7
10
  // `brust start`'s BRUST_DIST_DIR / BRUST_APP_ENTRY follow the same env > flag rule (run.ts).
8
11
  // A missing brust.toml is fine; a present one with the wrong shape is an error. Messages follow
9
12
  // 0.1.x `runtime/config.ts`.
10
13
  import { availableParallelism } from 'node:os'
11
14
  import { join } from 'node:path'
15
+ import { ACTION_CREDENTIALS, type ActionCredentials, baseUrlError } from './actions/prefix'
12
16
 
13
17
  /** `[cors]` in brust.toml; same shape and validation as the Rust `CorsConfig` (crates/brust-server/src/config.rs). */
14
18
  export interface CorsConfig {
@@ -32,10 +36,22 @@ export interface BrustConfig {
32
36
  callTimeoutMs: number
33
37
  /** tokio threads of the HTTP runtime (renders every page); absent = one per core (server default). */
34
38
  ioThreads?: number
35
- /** From brust.toml `[cors]` only (runtime config: no env / CLI layer); absent = CORS off. */
39
+ /** The SAB response slot of one render slot, bytes (`slot_bytes`, env BRUST_SLOT_BYTES); 64 KiB..16 MiB. */
40
+ slotBytes: number
41
+ /** The biggest loader/jobs reply that crosses by heap when it does not fit the slot (`max_loader_response_bytes`,
42
+ * env BRUST_MAX_LOADER_RESPONSE_BYTES); 256 KiB..64 MiB. */
43
+ maxLoaderResponseBytes: number
44
+ /** `run({ config: { cors } })` over brust.toml `[cors]` (a whole-object replace); absent = CORS off. No env layer. */
36
45
  cors?: CorsConfig
46
+ /** L16 R16.1: the origin the browser `client()` calls (page script `__BRUST_ACTION_BASE__`); absent = same origin. */
47
+ actionsBaseUrl?: string
48
+ /** L16 R16.2: the client's default `RequestInit.credentials` (`__BRUST_ACTION_CREDENTIALS__`); absent = `same-origin`. */
49
+ actionsCredentials?: ActionCredentials
37
50
  }
38
51
 
52
+ /** What `run({ config })` / `loadConfig`'s `cli` accepts: any setting, and `cors: null` to turn a toml `[cors]` off. */
53
+ export type ConfigOverrides = Omit<Partial<BrustConfig>, 'cors'> & { cors?: (Omit<CorsConfig, 'credentials'> & { credentials?: boolean }) | null }
54
+
39
55
  export class BrustConfigError extends Error {
40
56
  override name = 'BrustConfigError'
41
57
  constructor(
@@ -46,11 +62,11 @@ export class BrustConfigError extends Error {
46
62
  }
47
63
  }
48
64
 
49
- const DEFAULTS = { host: 'localhost', port: 1337, drainTimeoutMs: 10_000, bootTimeoutMs: 30_000, callTimeoutMs: 30_000 }
65
+ const DEFAULTS = { host: 'localhost', port: 1337, drainTimeoutMs: 10_000, bootTimeoutMs: 30_000, callTimeoutMs: 30_000, slotBytes: 256 * 1024, maxLoaderResponseBytes: 8_388_608 }
50
66
 
51
67
  /** Ceilings: ms values cross napi as `u32` (a larger number would wrap), more than 1024 Bun
52
- * workers is a misconfiguration, not a deployment, and each render slot is a 256 KiB shared
53
- * response buffer per worker (more than 64 would only exhaust memory). */
68
+ * workers is a misconfiguration, not a deployment, and each render slot is a `slotBytes` (default 256 KiB)
69
+ * shared response buffer per worker (more than 64 would only exhaust memory). */
54
70
  export const MAX_MS = 0xffff_ffff
55
71
  /** `max_age_seconds` crosses napi as `u32`, like the ms values. */
56
72
  const MAX_U32 = 0xffff_ffff
@@ -58,8 +74,14 @@ const MAX_U32 = 0xffff_ffff
58
74
  export const MAX_IO_THREADS = 256
59
75
  export const MAX_WORKERS = 1024
60
76
  export const MAX_RENDER_SLOTS = 64
77
+ /** `slotBytes` (L10): 64 KiB..16 MiB. */
78
+ export const MIN_SLOT_BYTES = 64 * 1024
79
+ export const MAX_SLOT_BYTES = 16 * 1024 * 1024
80
+ /** `maxLoaderResponseBytes` (L10): 256 KiB..64 MiB, like the actions limits. */
81
+ export const MIN_LOADER_RESPONSE_BYTES = 256 * 1024
82
+ export const MAX_LOADER_RESPONSE_BYTES = 64 * 1024 * 1024
61
83
 
62
- export async function loadConfig(cwd: string = process.cwd(), cli: Partial<BrustConfig> = {}): Promise<BrustConfig> {
84
+ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrides = {}): Promise<BrustConfig> {
63
85
  const tomlPath = join(cwd, 'brust.toml')
64
86
  let fromToml: Partial<BrustConfig> = {}
65
87
  const file = Bun.file(tomlPath)
@@ -75,6 +97,8 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
75
97
  }
76
98
  const env = fromEnv()
77
99
  checkCli(cli)
100
+ // cli (validated, normalised) over toml as a whole object (R10.2); `null` = off.
101
+ const cors = cli.cors === undefined ? fromToml.cors : cli.cors === null ? undefined : checkCors(cli.cors, 'config.cors', null, false)
78
102
  return {
79
103
  host: env.host ?? cli.host ?? fromToml.host ?? DEFAULTS.host,
80
104
  port: env.port ?? cli.port ?? fromToml.port ?? DEFAULTS.port,
@@ -84,11 +108,17 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
84
108
  drainTimeoutMs: env.drainTimeoutMs ?? cli.drainTimeoutMs ?? DEFAULTS.drainTimeoutMs,
85
109
  bootTimeoutMs: env.bootTimeoutMs ?? cli.bootTimeoutMs ?? DEFAULTS.bootTimeoutMs,
86
110
  callTimeoutMs: env.callTimeoutMs ?? cli.callTimeoutMs ?? DEFAULTS.callTimeoutMs,
111
+ slotBytes: env.slotBytes ?? cli.slotBytes ?? fromToml.slotBytes ?? DEFAULTS.slotBytes,
112
+ maxLoaderResponseBytes: env.maxLoaderResponseBytes ?? cli.maxLoaderResponseBytes ?? fromToml.maxLoaderResponseBytes ?? DEFAULTS.maxLoaderResponseBytes,
87
113
  ioThreads: env.ioThreads ?? cli.ioThreads,
88
- ...(fromToml.cors !== undefined ? { cors: fromToml.cors } : {}),
114
+ ...(cors ? { cors } : {}),
115
+ ...pick('actionsBaseUrl', env.actionsBaseUrl ?? cli.actionsBaseUrl ?? fromToml.actionsBaseUrl),
116
+ ...pick('actionsCredentials', cli.actionsCredentials ?? fromToml.actionsCredentials),
89
117
  }
90
118
  }
91
119
 
120
+ const pick = <K extends string, V>(k: K, v: V | undefined) => (v === undefined ? {} : ({ [k]: v } as { [P in K]: V }))
121
+
92
122
  /** `[cors]` of brust.toml and nothing else (no env, no cli): what the Cloudflare build ships in CONFIG.
93
123
  * Throws `BrustConfigError` on a bad table, exactly like `loadConfig`. */
94
124
  export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
@@ -106,7 +136,7 @@ export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
106
136
  }
107
137
 
108
138
  /** CLI / programmatic overrides get the same bounds as env (a CLI `--port 0` = any free port). */
109
- function checkCli(cli: Partial<BrustConfig>): void {
139
+ function checkCli(cli: ConfigOverrides): void {
110
140
  const check = (name: string, v: number | undefined, min: number, max: number) => {
111
141
  if (v !== undefined && (!Number.isInteger(v) || v < min || v > max))
112
142
  throw new BrustConfigError(`${name} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`, null)
@@ -118,6 +148,14 @@ function checkCli(cli: Partial<BrustConfig>): void {
118
148
  check('bootTimeoutMs', cli.bootTimeoutMs, 1, MAX_MS)
119
149
  check('callTimeoutMs', cli.callTimeoutMs, 1, MAX_MS)
120
150
  check('ioThreads', cli.ioThreads, 1, MAX_IO_THREADS)
151
+ check('slotBytes', cli.slotBytes, MIN_SLOT_BYTES, MAX_SLOT_BYTES)
152
+ check('maxLoaderResponseBytes', cli.maxLoaderResponseBytes, MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES)
153
+ if (cli.actionsBaseUrl !== undefined) {
154
+ const why = baseUrlError(cli.actionsBaseUrl)
155
+ if (why) throw new BrustConfigError(`config.actionsBaseUrl ${why}`, null)
156
+ }
157
+ if (cli.actionsCredentials !== undefined && !ACTION_CREDENTIALS.includes(cli.actionsCredentials))
158
+ throw new BrustConfigError(`config.actionsCredentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(cli.actionsCredentials)})`, null)
121
159
  }
122
160
 
123
161
  function table(v: unknown, what: string, file: string): Record<string, unknown> {
@@ -125,12 +163,21 @@ function table(v: unknown, what: string, file: string): Record<string, unknown>
125
163
  return v as Record<string, unknown>
126
164
  }
127
165
 
166
+ function tomlInt(v: unknown, name: string, min: number, max: number, file: string): number | undefined {
167
+ if (v === undefined) return undefined
168
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max)
169
+ throw new BrustConfigError(`${file}: ${name} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`, file)
170
+ return v
171
+ }
172
+
128
173
  function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
129
174
  const root = table(parsed, 'top level', file)
130
175
  const out: Partial<BrustConfig> = {}
131
176
  if ('server' in root) {
132
177
  const server = table(root.server, '[server]', file)
133
178
  const { port, address } = server
179
+ const cap = tomlInt(server.max_loader_response_bytes, 'server.max_loader_response_bytes', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, file)
180
+ if (cap !== undefined) out.maxLoaderResponseBytes = cap
134
181
  if (port !== undefined) {
135
182
  if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)
136
183
  throw new BrustConfigError(`${file}: server.port must be an integer in 1..65535 (got ${JSON.stringify(port)})`, file)
@@ -143,7 +190,10 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
143
190
  }
144
191
  }
145
192
  if ('workers' in root) {
146
- const { count } = table(root.workers, '[workers]', file)
193
+ const w = table(root.workers, '[workers]', file)
194
+ const { count } = w
195
+ const slot = tomlInt(w.slot_bytes, 'workers.slot_bytes', MIN_SLOT_BYTES, MAX_SLOT_BYTES, file)
196
+ if (slot !== undefined) out.slotBytes = slot
147
197
  if (count !== undefined) {
148
198
  if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > MAX_WORKERS)
149
199
  throw new BrustConfigError(`${file}: workers.count must be an integer in 1..${MAX_WORKERS} (got ${JSON.stringify(count)})`, file)
@@ -151,34 +201,62 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
151
201
  }
152
202
  }
153
203
  if ('cors' in root) out.cors = parseCors(root.cors, file)
204
+ if ('actions' in root) {
205
+ const a = table(root.actions, '[actions]', file)
206
+ if (a.base_url !== undefined) {
207
+ const why = baseUrlError(a.base_url)
208
+ if (why) throw new BrustConfigError(`${file}: actions.base_url ${why}`, file)
209
+ out.actionsBaseUrl = a.base_url as string
210
+ }
211
+ if (a.credentials !== undefined) {
212
+ if (typeof a.credentials !== 'string' || !ACTION_CREDENTIALS.includes(a.credentials as ActionCredentials))
213
+ throw new BrustConfigError(`${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(a.credentials)})`, file)
214
+ out.actionsCredentials = a.credentials as ActionCredentials
215
+ }
216
+ }
154
217
  return out
155
218
  }
156
219
 
157
220
  function parseCors(v: unknown, file: string): CorsConfig {
158
- const t = table(v, 'cors', file)
221
+ return checkCors(corsFromToml(table(v, 'cors', file)), `${file}: cors`, file, true)
222
+ }
223
+
224
+ /** brust.toml `[cors]` (snake_case keys) → the camelCase shape [`checkCors`] validates. Unknown toml keys are ignored, as always. */
225
+ function corsFromToml(t: Record<string, unknown>): Record<string, unknown> {
226
+ return { origins: t.origins, methods: t.methods, headers: t.headers, exposeHeaders: t.expose_headers, credentials: t.credentials, maxAgeSeconds: t.max_age_seconds }
227
+ }
228
+
229
+ const CORS_KEYS = ['origins', 'methods', 'headers', 'exposeHeaders', 'credentials', 'maxAgeSeconds']
230
+
231
+ /** The one `[cors]` validator (Rust `CorsConfig::validate`, verbatim), for toml (`where` = `<file>: cors`, key names in
232
+ * snake_case) and for `run({ config: { cors } })` (`where` = `config.cors`, camelCase; an unknown key is an error). */
233
+ function checkCors(c: unknown, where: string, file: string | null, snake: boolean): CorsConfig {
234
+ const fail = (m: string) => new BrustConfigError(m, file)
235
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) throw fail(`${where} must be ${file === null ? 'an object (or null)' : 'a table'} (got ${JSON.stringify(c)})`)
236
+ const t = c as Record<string, unknown>
237
+ if (!snake) {
238
+ const bad = Object.keys(t).find((k) => !CORS_KEYS.includes(k))
239
+ if (bad !== undefined) throw fail(`${where}.${bad} is not a cors option (expected ${CORS_KEYS.join(', ')})`)
240
+ }
241
+ const name = (k: string) => (snake ? k.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`) : k)
159
242
  const strings = (val: unknown, key: string): string[] | undefined => {
160
243
  if (val === undefined) return undefined
161
- if (!Array.isArray(val) || val.some((x) => typeof x !== 'string'))
162
- throw new BrustConfigError(`${file}: cors.${key} must be an array of strings (got ${JSON.stringify(val)})`, file)
244
+ if (!Array.isArray(val) || val.some((x) => typeof x !== 'string')) throw fail(`${where}.${name(key)} must be an array of strings (got ${JSON.stringify(val)})`)
163
245
  return val as string[]
164
246
  }
165
247
  const origins = strings(t.origins, 'origins') ?? []
166
248
  const methods = strings(t.methods, 'methods')
167
249
  const headers = strings(t.headers, 'headers')
168
- const exposeHeaders = strings(t.expose_headers, 'expose_headers')
169
- if (t.credentials !== undefined && typeof t.credentials !== 'boolean')
170
- throw new BrustConfigError(`${file}: cors.credentials must be a boolean (got ${JSON.stringify(t.credentials)})`, file)
250
+ const exposeHeaders = strings(t.exposeHeaders, 'exposeHeaders')
251
+ if (t.credentials !== undefined && typeof t.credentials !== 'boolean') throw fail(`${where}.credentials must be a boolean (got ${JSON.stringify(t.credentials)})`)
171
252
  const credentials = t.credentials === true
172
- const age = t.max_age_seconds
253
+ const age = t.maxAgeSeconds
173
254
  if (age !== undefined && (typeof age !== 'number' || !Number.isInteger(age) || age < 0 || age > MAX_U32))
174
- throw new BrustConfigError(`${file}: cors.max_age_seconds must be an integer in 0..${MAX_U32} (got ${JSON.stringify(age)})`, file)
255
+ throw fail(`${where}.${name('maxAgeSeconds')} must be an integer in 0..${MAX_U32} (got ${JSON.stringify(age)})`)
175
256
  // Rust CorsConfig::validate, verbatim.
176
- if (origins.length === 0) throw new BrustConfigError(`${file}: cors.origins must be non-empty`, file)
257
+ if (origins.length === 0) throw fail(`${where}.origins must be non-empty`)
177
258
  if (credentials && origins.includes('*'))
178
- throw new BrustConfigError(
179
- `${file}: cors.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`,
180
- file,
181
- )
259
+ throw fail(`${where}.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`)
182
260
  return {
183
261
  origins,
184
262
  ...(methods ? { methods } : {}),
@@ -211,6 +289,14 @@ function fromEnv(): Partial<BrustConfig> {
211
289
  out.drainTimeoutMs = envInt('BRUST_DRAIN_TIMEOUT_MS', 0, MAX_MS, `an integer in 0..${MAX_MS}`)
212
290
  out.bootTimeoutMs = envInt('BRUST_BOOT_TIMEOUT_MS', 1, MAX_MS, `an integer in 1..${MAX_MS}`)
213
291
  out.callTimeoutMs = envInt('BRUST_CALL_TIMEOUT_MS', 1, MAX_MS, `an integer in 1..${MAX_MS}`)
292
+ out.slotBytes = envInt('BRUST_SLOT_BYTES', MIN_SLOT_BYTES, MAX_SLOT_BYTES, `an integer in ${MIN_SLOT_BYTES}..${MAX_SLOT_BYTES}`)
293
+ out.maxLoaderResponseBytes = envInt('BRUST_MAX_LOADER_RESPONSE_BYTES', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, `an integer in ${MIN_LOADER_RESPONSE_BYTES}..${MAX_LOADER_RESPONSE_BYTES}`)
214
294
  out.ioThreads = envInt('BRUST_WORKER_THREADS', 1, MAX_IO_THREADS, `an integer in 1..${MAX_IO_THREADS}`)
295
+ const base = process.env.BRUST_ACTIONS_BASE_URL
296
+ if (base !== undefined && base !== '') {
297
+ const why = baseUrlError(base)
298
+ if (why) throw new BrustConfigError(`BRUST_ACTIONS_BASE_URL ${why}`, null)
299
+ out.actionsBaseUrl = base
300
+ }
215
301
  return out
216
302
  }
@@ -0,0 +1,8 @@
1
+ // L19 (R19.4): a `.module.css` import is its class map (the build's `cssPlugin`); any other `.css`
2
+ // import is a side-effect import the server links (R19.3). Pulled in by a `/// <reference>` from the
3
+ // entries apps import, so both forms typecheck.
4
+ declare module '*.module.css' {
5
+ const classes: { readonly [name: string]: string }
6
+ export default classes
7
+ }
8
+ declare module '*.css' {}
package/src/dev/bun.ts ADDED
@@ -0,0 +1,365 @@
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 { defaultIgnored, watchLoop } from './watch'
14
+
15
+ export interface BunDevOptions {
16
+ appRoot: string
17
+ entry: string
18
+ port?: number
19
+ /** Bun workers of each child; default 1 (R13.4). */
20
+ workers?: number
21
+ out?: TuiOut
22
+ env?: Record<string, string | undefined>
23
+ /** Fires with the user-visible URL once the proxy listens (tests). */
24
+ onListening?: (url: string) => void
25
+ }
26
+
27
+ const DEBOUNCE_MS = 150
28
+ const BOOT_TIMEOUT_MS = 60_000
29
+ const TAIL_LINES = 40
30
+ const cliPath = join(import.meta.dir, '..', 'cli.ts')
31
+
32
+ /** A free loopback port (bind port 0, read it, release it). */
33
+ export function freePort(): number {
34
+ const s = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') })
35
+ const p = s.port as number
36
+ s.stop(true)
37
+ return p
38
+ }
39
+
40
+ function pumpLines(stream: ReadableStream<Uint8Array> | null | undefined, onLine: (l: string) => void): void {
41
+ if (!stream) return
42
+ void (async () => {
43
+ const dec = new TextDecoder()
44
+ let buf = ''
45
+ for await (const chunk of stream) {
46
+ buf += dec.decode(chunk, { stream: true })
47
+ let i = buf.indexOf('\n')
48
+ while (i >= 0) {
49
+ onLine(buf.slice(0, i).replace(/\r$/, ''))
50
+ buf = buf.slice(i + 1)
51
+ i = buf.indexOf('\n')
52
+ }
53
+ }
54
+ if (buf) onLine(buf)
55
+ })()
56
+ }
57
+
58
+ /** L19/L21 dev swap: the built stylesheets of the old and the new generation, paired by source stem
59
+ * (`<stem>-<hex10>.css`), as `/_brust/client/<old>` → `/_brust/client/<new>`. `null` when the pairing is not
60
+ * one-to-one (a stylesheet was added or removed, or two sources share a stem): the page must reload. */
61
+ export function cssSwapMap(oldDir: string, newDir: string): Record<string, string> | null {
62
+ const byStem = (dir: string) => {
63
+ const m = new Map<string, string>()
64
+ let files: string[] = []
65
+ try {
66
+ files = readdirSync(join(dir, 'client'))
67
+ } catch {}
68
+ for (const f of files) {
69
+ const s = /^(.*)-[0-9a-f]{10}\.css$/.exec(f)?.[1]
70
+ if (s === undefined) continue
71
+ if (m.has(s)) return null
72
+ m.set(s, f)
73
+ }
74
+ return m
75
+ }
76
+ const a = byStem(oldDir)
77
+ const b = byStem(newDir)
78
+ if (!a || !b || a.size !== b.size) return null
79
+ const map: Record<string, string> = {}
80
+ for (const [stem, f] of a) {
81
+ const g = b.get(stem)
82
+ if (g === undefined) return null
83
+ if (g !== f) map[`/_brust/client/${f}`] = `/_brust/client/${g}`
84
+ }
85
+ return map
86
+ }
87
+
88
+ /** The child server's own tracing chatter is not forwarded; the app's console output is. */
89
+ const NOISE = /\b(INFO|DEBUG)\b|^\[brust\] (listening on|ready)|^\s*$/
90
+
91
+ interface Child {
92
+ proc: ReturnType<typeof Bun.spawn>
93
+ port: number
94
+ dir: string
95
+ }
96
+
97
+ export async function runBunDev(o: BunDevOptions): Promise<number> {
98
+ const appRoot = resolve(o.appRoot)
99
+ const entry = resolve(appRoot, o.entry)
100
+ const out: TuiOut = o.out ?? (process.stdout as unknown as TuiOut)
101
+ const env = o.env ?? process.env
102
+ const mode = tuiMode(out, env)
103
+ const tui = new Tui(out, mode.interactive, mode.color)
104
+ const cfg = await loadConfig(appRoot, o.port !== undefined ? { port: o.port } : {})
105
+ const workers = o.workers ?? 1
106
+ const devRoot = join(appRoot, '.brust', 'dev')
107
+ rmSync(devRoot, { recursive: true, force: true })
108
+ mkdirSync(devRoot, { recursive: true })
109
+
110
+ // R14.1: only this process and its children know the token; the core traces a request only if it carries it.
111
+ const devToken = crypto.randomUUID()
112
+ let counter = 0
113
+ let gen = 0
114
+ let state: 'building' | 'ready' | 'error' = 'building'
115
+ let diag: Diag | null = null
116
+ let active: Child | null = null
117
+ let buildMs: number | undefined
118
+ let warnings: string[] = []
119
+ let last: { method: string; path: string; status: number; tiers?: { static: number; native: number; react: number } } | undefined
120
+ let stopping = false
121
+ let url = ''
122
+ /** Processes spawned by an in-flight cycle (a build, a booting child): `stop()` must reach them (R13.5, own PIDs only). */
123
+ const inflight = new Set<ReturnType<typeof Bun.spawn>>()
124
+
125
+ const publish = () =>
126
+ tui.setStatus({
127
+ state,
128
+ url,
129
+ gen,
130
+ buildMs,
131
+ last,
132
+ warnings,
133
+ error: diag && state === 'error' ? `${diag.rule} ${diag.file ? `${diag.file}${diag.line ? `:${diag.line}` : ''} ` : ''}${diag.message}`.trim() : undefined,
134
+ })
135
+
136
+ const devMeta = (): unknown | null => {
137
+ try {
138
+ return active ? JSON.parse(readFileSync(join(active.dir, 'dev.json'), 'utf8')) : null
139
+ } catch {
140
+ return null
141
+ }
142
+ }
143
+
144
+ const proxy: DevProxy = startProxy(
145
+ { hostname: cfg.host, port: cfg.port, token: devToken },
146
+ {
147
+ gen: () => gen,
148
+ state: () => state,
149
+ diag: () => diag,
150
+ upstream: () => active?.port ?? null,
151
+ meta: devMeta,
152
+ onRequest(r) {
153
+ tui.log(`${r.method} ${r.path} ${r.status} ${r.ms}ms${r.cache ? ` ${r.cache}` : ''}`)
154
+ if (!r.path.startsWith('/_brust/')) {
155
+ const rendered = (r.trace as { rendered?: string[] } | null)?.rendered
156
+ last = { method: r.method, path: r.path, status: r.status, ...(rendered ? { tiers: tierCounts(rendered, devMeta() as Parameters<typeof tierCounts>[1]) } : {}) }
157
+ publish()
158
+ }
159
+ },
160
+ },
161
+ )
162
+ url = `http://${cfg.host}:${proxy.port}`
163
+ o.onListening?.(url)
164
+
165
+ /** Build into `dir`; a child process, so module caches never leak between builds. */
166
+ async function build(dir: string): Promise<{ ok: boolean; text: string; ms: number }> {
167
+ const t0 = performance.now()
168
+ const p = Bun.spawn([process.execPath, cliPath, 'build', o.entry, '--out-dir', dir, '--dev'], {
169
+ cwd: appRoot,
170
+ env: { ...process.env, NODE_ENV: 'development', BRUST_BUILD_MODE: 'development' },
171
+ stdout: 'pipe',
172
+ stderr: 'pipe',
173
+ })
174
+ inflight.add(p)
175
+ const [so, se, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
176
+ inflight.delete(p)
177
+ return { ok: code === 0, text: `${so}\n${se}`, ms: Math.round(performance.now() - t0) }
178
+ }
179
+
180
+ /** Boot `brust start` over `dir`; resolves once `[brust] ready`, rejects with the output tail. */
181
+ function boot(dir: string): Promise<Child> {
182
+ const port = freePort()
183
+ const proc = Bun.spawn([process.execPath, cliPath, 'start', '--dist-dir', dir, '--entry', entry, '--workers', String(workers)], {
184
+ cwd: appRoot,
185
+ env: {
186
+ ...process.env,
187
+ NODE_ENV: 'development',
188
+ BRUST_PORT: String(port),
189
+ BRUST_ADDR: '127.0.0.1',
190
+ BRUST_WORKERS: String(workers),
191
+ BRUST_DIST_DIR: dir,
192
+ BRUST_APP_ENTRY: entry,
193
+ BRUST_DEV_TOKEN: devToken,
194
+ },
195
+ stdout: 'pipe',
196
+ stderr: 'pipe',
197
+ })
198
+ inflight.add(proc)
199
+ const tail: string[] = []
200
+ let ready = false
201
+ return new Promise<Child>((resolveBoot, reject) => {
202
+ const timer = setTimeout(() => {
203
+ proc.kill('SIGKILL')
204
+ reject(new Error(`the new build did not become ready within ${BOOT_TIMEOUT_MS / 1000}s\n${tail.join('\n')}`))
205
+ }, BOOT_TIMEOUT_MS)
206
+ const line = (l: string) => {
207
+ tail.push(l)
208
+ if (tail.length > TAIL_LINES) tail.shift()
209
+ if (!ready && /\[brust\] ready/.test(l)) {
210
+ ready = true
211
+ clearTimeout(timer)
212
+ resolveBoot({ proc, port, dir })
213
+ } else if (ready && !NOISE.test(l)) tui.log(l)
214
+ }
215
+ pumpLines(proc.stdout as ReadableStream<Uint8Array>, line)
216
+ pumpLines(proc.stderr as ReadableStream<Uint8Array>, line)
217
+ void proc.exited.then((code) => {
218
+ inflight.delete(proc)
219
+ clearTimeout(timer)
220
+ if (!ready) reject(new Error(`the new build exited with code ${code} before it was ready\n${tail.join('\n')}`))
221
+ else if (active?.proc === proc && !stopping) tui.log(`[brust] the server process exited (code ${code})`)
222
+ })
223
+ })
224
+ }
225
+
226
+ async function stopChild(c: Child): Promise<void> {
227
+ c.proc.kill('SIGINT')
228
+ if ((await Promise.race([c.proc.exited, Bun.sleep(4000).then(() => undefined)])) === undefined) {
229
+ c.proc.kill('SIGKILL')
230
+ await c.proc.exited
231
+ }
232
+ rmSync(c.dir, { recursive: true, force: true })
233
+ }
234
+
235
+ function fail(d: Diag): void {
236
+ state = 'error'
237
+ diag = d
238
+ proxy.broadcast({ type: 'error', diag: d })
239
+ tui.log(`[brust] ${d.rule}${d.file ? ` ${d.file}${d.line ? `:${d.line}${d.col ? `:${d.col}` : ''}` : ''}` : ''} ${d.message}`)
240
+ publish()
241
+ }
242
+
243
+ /** One build -> boot -> swap cycle. */
244
+ async function cycle(cssOnly = false): Promise<void> {
245
+ state = 'building'
246
+ publish()
247
+ const g = ++counter
248
+ const dir = join(devRoot, `g${g}`)
249
+ const b = await build(dir)
250
+ if (stopping) {
251
+ rmSync(dir, { recursive: true, force: true })
252
+ return
253
+ }
254
+ warnings = parseWarnings(b.text)
255
+ if (!b.ok) {
256
+ rmSync(dir, { recursive: true, force: true })
257
+ fail(parseBuildError(b.text) ?? { rule: 'build', message: b.text.trim().split('\n').pop() ?? 'build failed', raw: b.text.trim() })
258
+ return
259
+ }
260
+ let child: Child
261
+ try {
262
+ child = await boot(dir)
263
+ } catch (e) {
264
+ rmSync(dir, { recursive: true, force: true })
265
+ if (stopping) return
266
+ const raw = (e as Error).message
267
+ fail({ rule: 'boot', message: raw.split('\n')[0] ?? raw, raw })
268
+ return
269
+ }
270
+ // Stopped while this child booted, or a newer generation already took over: this child must not be adopted.
271
+ if (stopping || g < gen) {
272
+ await stopChild(child)
273
+ return
274
+ }
275
+ const old = active
276
+ active = child
277
+ gen = g
278
+ buildMs = b.ms
279
+ state = 'ready'
280
+ diag = null
281
+ // A stylesheet-only edit (L19/L21): the pages keep their DOM and swap their brust CSS links.
282
+ const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir) : null
283
+ if (swap) {
284
+ proxy.broadcast({ type: 'css-swap', gen, map: swap })
285
+ tui.log(`[brust] css rebuilt (${Object.keys(swap).length} stylesheet${Object.keys(swap).length === 1 ? '' : 's'} swapped, no reload)`)
286
+ } else proxy.broadcast({ type: 'reload', gen })
287
+ publish()
288
+ if (old) void stopChild(old)
289
+ }
290
+
291
+ const isCss = (rel: string) => rel.startsWith('public/') && rel.endsWith('.css')
292
+
293
+ const loop = watchLoop({
294
+ root: appRoot,
295
+ ignored: (rel) => defaultIgnored(rel, 'dist'),
296
+ debounceMs: DEBOUNCE_MS,
297
+ async run(changed) {
298
+ // A change to public/*.css skips the build: copy it into the active dist and swap the <link> (R13.3).
299
+ if (active && state === 'ready' && [...changed].every((r) => isCss(r) && existsSync(join(appRoot, r)))) {
300
+ const v = Date.now()
301
+ for (const rel of changed) {
302
+ cpSync(join(appRoot, rel), join(active.dir, rel))
303
+ proxy.broadcast({ type: 'css', href: `/${rel}`, v })
304
+ }
305
+ tui.log(`[brust] css ${[...changed].join(', ')} (no reload)`)
306
+ return
307
+ }
308
+ tui.log(`[brust] change detected: ${[...changed].slice(0, 3).join(', ')}${changed.size > 3 ? ` +${changed.size - 3}` : ''}`)
309
+ // Only built stylesheets changed (L19/L21): rebuild, boot, then swap the links instead of reloading.
310
+ await cycle(active !== null && state === 'ready' && [...changed].every((r) => r.endsWith('.css') && !isCss(r)))
311
+ },
312
+ })
313
+
314
+ let exit: (code: number) => void = () => {}
315
+ const done = new Promise<number>((r) => {
316
+ exit = r
317
+ })
318
+ let stdinRaw = false
319
+ const stop = async () => {
320
+ if (stopping) return
321
+ stopping = true
322
+ loop.close()
323
+ proxy.stop()
324
+ if (stdinRaw) {
325
+ process.stdin.setRawMode(false)
326
+ process.stdin.pause()
327
+ }
328
+ tui.close()
329
+ // A build or a booting child of an in-flight cycle: SIGINT, then SIGKILL (own PIDs only).
330
+ const mid = [...inflight]
331
+ for (const p of mid) p.kill('SIGINT')
332
+ await Promise.all(mid.map(async (p) => {
333
+ if ((await Promise.race([p.exited, Bun.sleep(4000).then(() => undefined)])) === undefined) p.kill('SIGKILL')
334
+ }))
335
+ if (active) await stopChild(active)
336
+ rmSync(devRoot, { recursive: true, force: true })
337
+ exit(0)
338
+ }
339
+
340
+ if (mode.interactive && process.stdin.isTTY) {
341
+ stdinRaw = true
342
+ process.stdin.setRawMode(true)
343
+ process.stdin.resume()
344
+ process.stdin.on('data', (b) => {
345
+ for (const k of b.toString()) {
346
+ if (k === 'q' || k === '\x03') void stop()
347
+ else if (k === 'r') proxy.broadcast({ type: 'reload' })
348
+ else if (k === 'o') Bun.spawn([process.platform === 'darwin' ? 'open' : 'xdg-open', url], { stdout: 'ignore', stderr: 'ignore' })
349
+ else if (k === 'w') for (const w of warnings.length ? warnings : ['no warnings']) tui.log(`warning ${w}`)
350
+ else if (k === 'c') tui.clear()
351
+ }
352
+ })
353
+ ;(out as unknown as NodeJS.WriteStream).on?.('resize', () => tui.resize())
354
+ }
355
+ process.on('SIGINT', () => void stop())
356
+ process.on('SIGTERM', () => void stop())
357
+
358
+ tui.log(`[brust] dev server on ${url} — ${HOTKEYS}`)
359
+ publish()
360
+ // The first build goes through the loop too, so it is single-flight with every later one: an edit saved while it
361
+ // runs waits for it and then builds on top (never a second cycle racing the first).
362
+ loop.trigger('.')
363
+ void loop.idle()
364
+ return done
365
+ }