@brust/core 2.0.0-alpha.34 → 2.0.0-alpha.36

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/src/dev/proxy.ts CHANGED
@@ -61,13 +61,15 @@ export const WS_PATH = '/_brust/dev/ws'
61
61
  export const DEVTOOLS_PATH = '/_brust/dev/devtools.js'
62
62
  export const TRACES_PATH = '/_brust/dev/traces'
63
63
  export const META_PATH = '/_brust/dev/meta'
64
- const scriptTag = (gen: number) => `<script type="module" src="${CLIENT_PATH}?g=${gen}"></script><script type="module" src="${DEVTOOLS_PATH}"></script>`
64
+ /** F495: the dev channel's URLs for an app mounted under `base` (`''` = the root): the page's scripts and the WebSocket connect under it. */
65
+ export const devPaths = (base = '') => ({ client: `${base}${CLIENT_PATH}`, ws: `${base}${WS_PATH}`, devtools: `${base}${DEVTOOLS_PATH}`, traces: `${base}${TRACES_PATH}`, meta: `${base}${META_PATH}` })
66
+ const scriptTag = (gen: number, base = '') => `<script type="module" src="${devPaths(base).client}?g=${gen}"></script><script type="module" src="${devPaths(base).devtools}"></script>`
65
67
 
66
68
  const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] as string)
67
69
 
68
70
  /** `html` with the dev client script before `</head>` (else at the end). */
69
- export function inject(html: string, gen: number, trace: unknown | null = null): string {
70
- const tag = scriptTag(gen) + (trace ? traceTag(trace) : '')
71
+ export function inject(html: string, gen: number, trace: unknown | null = null, base = ''): string {
72
+ const tag = scriptTag(gen, base) + (trace ? traceTag(trace) : '')
71
73
  const i = html.search(/<\/head\s*>/i)
72
74
  return i >= 0 ? html.slice(0, i) + tag + html.slice(i) : html + tag
73
75
  }
@@ -93,8 +95,8 @@ export function decodeTrace(h: string | null): unknown | null {
93
95
  }
94
96
 
95
97
  /** A small HTML page (so even a plain-text 5xx or "no child yet" carries the client script). */
96
- function htmlPage(status: number, title: string, body: string, gen: number, trace: unknown | null = null): Response {
97
- const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>${scriptTag(gen)}${trace ? traceTag(trace) : ''}</head><body style="font:14px ui-monospace,monospace;padding:24px"><h1>${esc(title)}</h1><pre style="white-space:pre-wrap">${esc(body)}</pre></body></html>`
98
+ function htmlPage(status: number, title: string, body: string, gen: number, trace: unknown | null = null, base = ''): Response {
99
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>${scriptTag(gen, base)}${trace ? traceTag(trace) : ''}</head><body style="font:14px ui-monospace,monospace;padding:24px"><h1>${esc(title)}</h1><pre style="white-space:pre-wrap">${esc(body)}</pre></body></html>`
98
100
  return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
99
101
  }
100
102
 
@@ -136,7 +138,9 @@ function devtoolsBundle(): Promise<string> {
136
138
  /** The trace a proxied response carried (read by `onRequest`; the header itself never reaches the browser). */
137
139
  const traceOf = new WeakMap<Response, unknown>()
138
140
 
139
- export function startProxy(o: { hostname: string; port: number; allowedHosts?: string; token: string }, view: DevView): DevProxy {
141
+ export function startProxy(o: { hostname: string; port: number; allowedHosts?: string; token: string; basePath?: string }, view: DevView): DevProxy {
142
+ const base = o.basePath ?? ''
143
+ const P = devPaths(base)
140
144
  const traces: TraceEntry[] = []
141
145
  let seq = 0
142
146
  const allowed = allowedHosts(o.hostname, o.allowedHosts ?? process.env.BRUST_DEV_ALLOWED_HOSTS)
@@ -155,7 +159,7 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
155
159
  const port = view.upstream()
156
160
  if (port === null) {
157
161
  const d = view.diag()
158
- return htmlPage(503, d ? `brust dev: ${d.rule}` : 'brust dev: building…', d?.raw ?? 'The first build is running. This page reloads when it is ready.', gen)
162
+ return htmlPage(503, d ? `brust dev: ${d.rule}` : 'brust dev: building…', d?.raw ?? 'The first build is running. This page reloads when it is ready.', gen, null, base)
159
163
  }
160
164
  const headers = new Headers(req.headers)
161
165
  // The token only this proxy knows: whatever a browser sent under that name is overwritten.
@@ -174,13 +178,13 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
174
178
  // biome-ignore lint/suspicious/noExplicitAny: Bun's fetch option
175
179
  } as any)
176
180
  } catch (e) {
177
- return htmlPage(502, 'brust dev: the server is not reachable', String((e as Error).message ?? e), gen)
181
+ return htmlPage(502, 'brust dev: the server is not reachable', String((e as Error).message ?? e), gen, null, base)
178
182
  }
179
183
  const out = new Headers(res.headers)
180
184
  // The trace never reaches the browser as a header: it travels in the page (`#__brust_trace`) and `/_brust/dev/traces`.
181
185
  const trace = decodeTrace(res.headers.get('x-brust-dev'))
182
186
  out.delete('x-brust-dev')
183
- const isAction = req.method !== 'GET' && req.method !== 'HEAD' ? true : url.pathname.startsWith('/_brust/action')
187
+ const isAction = req.method !== 'GET' && req.method !== 'HEAD' ? true : url.pathname.startsWith(`${base}/_brust/action`)
184
188
  if (trace || isAction) {
185
189
  traces.push({ id: ++seq, at: Date.now(), kind: trace ? 'page' : 'action', method: req.method, path: url.pathname + url.search, status: res.status, ms: Math.round(performance.now() - t0), trace })
186
190
  if (traces.length > MAX_TRACES) traces.shift()
@@ -195,9 +199,9 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
195
199
  if (req.method === 'HEAD' || res.status === 204 || res.status === 304) return new Response(null, { status: res.status, headers: out })
196
200
  if (/text\/html/i.test(type)) {
197
201
  out.set('content-type', type)
198
- return tag(new Response(inject(await res.text(), gen, trace), { status: res.status, headers: out }))
202
+ return tag(new Response(inject(await res.text(), gen, trace, base), { status: res.status, headers: out }))
199
203
  }
200
- if (doc && res.status >= 500) return tag(htmlPage(res.status, `${res.status} ${res.statusText || 'server error'}`, await res.text(), gen, trace))
204
+ if (doc && res.status >= 500) return tag(htmlPage(res.status, `${res.status} ${res.statusText || 'server error'}`, await res.text(), gen, trace, base))
201
205
  return tag(new Response(res.body, { status: res.status, headers: out }))
202
206
  }
203
207
 
@@ -216,17 +220,17 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
216
220
  // DNS rebinding: a page on evil.example whose name resolves to 127.0.0.1 would otherwise read the app and the
217
221
  // source paths in build errors. Only the dev server's own names are answered.
218
222
  if (!allowed.has(hostName(req.headers.get('host') ?? ''))) return new Response('brust dev: unexpected Host header (set BRUST_DEV_ALLOWED_HOSTS to allow it)', { status: 421 })
219
- if (url.pathname === WS_PATH && !sameOriginOrNone(req.headers.get('origin'), req.headers.get('host'))) return new Response('brust dev: cross-origin WebSocket refused', { status: 403 })
220
- if (url.pathname === WS_PATH) return srv.upgrade(req, { data: undefined }) ? undefined : new Response('websocket expected', { status: 426 })
221
- if (url.pathname === TRACES_PATH) return Response.json({ traces }, { headers: { 'cache-control': 'no-store' } })
222
- if (url.pathname === META_PATH) return Response.json(view.meta() ?? {}, { headers: { 'cache-control': 'no-store' } })
223
- if (url.pathname === DEVTOOLS_PATH) {
223
+ if (url.pathname === P.ws && !sameOriginOrNone(req.headers.get('origin'), req.headers.get('host'))) return new Response('brust dev: cross-origin WebSocket refused', { status: 403 })
224
+ if (url.pathname === P.ws) return srv.upgrade(req, { data: undefined }) ? undefined : new Response('websocket expected', { status: 426 })
225
+ if (url.pathname === P.traces) return Response.json({ traces }, { headers: { 'cache-control': 'no-store' } })
226
+ if (url.pathname === P.meta) return Response.json(view.meta() ?? {}, { headers: { 'cache-control': 'no-store' } })
227
+ if (url.pathname === P.devtools) {
224
228
  return devtoolsBundle().then(
225
229
  (js) => new Response(js, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } }),
226
230
  (e: Error) => new Response(e.message, { status: 500 }),
227
231
  )
228
232
  }
229
- if (url.pathname === CLIENT_PATH) return new Response(DEV_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } })
233
+ if (url.pathname === P.client) return new Response(DEV_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } })
230
234
  const t0 = performance.now()
231
235
  const res = await forward(req)
232
236
  view.onRequest({ method: req.method, path: url.pathname + url.search, status: res.status, ms: Math.round(performance.now() - t0), cache: res.headers.get('x-brust-cache'), trace: traceOf.get(res) ?? null })
package/src/index.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  /// <reference path="./assets.d.ts" />
3
3
  // `@brust/core`: the app-facing API.
4
4
  export { buildTime } from './build-time'
5
+
6
+ /** F495 `[server] base_path`: the path the app is mounted under (`/portal`), `''` at the root. Read-only: build URLs with it
7
+ * (`${basePath}/_brust/…` is brust's own; every URL you write is already the real one). Set from the manifest (`brust start`) or the
8
+ * config (`brust build`) before the app's modules load. */
9
+ export const basePath: string = process.env.BRUST_BASE_PATH ?? ''
5
10
  export { clearNavigationCache, navigate } from './navigate'
6
11
  export type { NavErrorDetail, NavLoadDetail, NavigateOptions, NavSuccessDetail, NavTrigger } from './navigate'
7
12
  export { cache } from './cache'
@@ -15,12 +15,63 @@ export function pidAlive(pid: number): boolean {
15
15
  }
16
16
  }
17
17
 
18
+ /** F492 (PID reuse): the identity of a process that outlives a PID: its creation time (a FILETIME, 100 ns ticks since 1601), read once
19
+ * through kernel32 `GetProcessTimes` (`OpenProcess` with the query-limited right: no elevation, no process table, no PowerShell). A PID
20
+ * that is reused by another process has another creation time. `undefined` when it cannot be read (not Windows, the process is gone, no
21
+ * access, `bun:ffi` missing): the watch then keeps its pid-only behaviour. Synchronous, a few microseconds, nothing awaited. */
22
+ export function processIdentity(pid: number): string | undefined {
23
+ if (process.platform !== 'win32') return undefined
24
+ const k = kernel32()
25
+ if (!k) return undefined
26
+ try {
27
+ const h = k.OpenProcess(0x1000 /* PROCESS_QUERY_LIMITED_INFORMATION */, 0, pid)
28
+ if (!h) return undefined
29
+ try {
30
+ const t = new BigUint64Array(4) // creation, exit, kernel, user
31
+ const base = k.ptr(t)
32
+ if (!k.GetProcessTimes(h, base, k.ptr(t, 8), k.ptr(t, 16), k.ptr(t, 24))) return undefined
33
+ return t[0]!.toString()
34
+ } finally {
35
+ k.CloseHandle(h)
36
+ }
37
+ } catch {
38
+ return undefined
39
+ }
40
+ }
41
+
42
+ type Kernel32 = {
43
+ OpenProcess(access: number, inherit: number, pid: number): unknown
44
+ GetProcessTimes(h: unknown, a: unknown, b: unknown, c: unknown, d: unknown): number
45
+ CloseHandle(h: unknown): number
46
+ ptr: (view: ArrayBufferView, offset?: number) => never
47
+ }
48
+ let k32: Kernel32 | null | undefined
49
+ /** The three kernel32 bindings, opened ONCE for the life of the process: `dlopen` compiles a binding per symbol and holds it until
50
+ * the library is closed, and the watch reads the identity every tick of a long-running server. `null` = unavailable (kept). */
51
+ function kernel32(): Kernel32 | null {
52
+ if (k32 !== undefined) return k32
53
+ try {
54
+ const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
55
+ const s = dlopen('kernel32.dll', {
56
+ OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.ptr },
57
+ GetProcessTimes: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
58
+ CloseHandle: { args: [FFIType.ptr], returns: FFIType.i32 },
59
+ }).symbols
60
+ k32 = { ...(s as unknown as Omit<Kernel32, 'ptr'>), ptr: ptr as Kernel32['ptr'] }
61
+ } catch {
62
+ k32 = null
63
+ }
64
+ return k32
65
+ }
66
+
18
67
  export interface LauncherWatchOptions {
19
68
  /** Runs once, when the parent is gone. */
20
69
  onGone: (pid: number) => void
21
70
  platform?: NodeJS.Platform
22
71
  ppid?: number
23
72
  alive?: (pid: number) => boolean
73
+ /** The parent's identity (default: its creation time, win32). Read once at boot and again on each tick while the pid answers alive: a different value means the pid was reused, so the launcher is gone. */
74
+ identity?: (pid: number) => string | undefined
24
75
  intervalMs?: number
25
76
  log?: (line: string) => void
26
77
  }
@@ -35,8 +86,10 @@ export function watchLauncher(opts: LauncherWatchOptions): { pid: number | undef
35
86
  ;(opts.log ?? console.error)(`[brust] launcher (pid ${ppid}) is already gone at boot: not watching it`)
36
87
  return none
37
88
  }
89
+ const identity = opts.identity ?? processIdentity
90
+ const born = identity(ppid)
38
91
  const timer = setInterval(() => {
39
- if (alive(ppid)) return
92
+ if (alive(ppid) && (born === undefined || identity(ppid) === born)) return
40
93
  clearInterval(timer)
41
94
  opts.onGone(ppid)
42
95
  }, opts.intervalMs ?? 1000)
package/src/native.ts CHANGED
@@ -10,6 +10,13 @@ export interface InvalidateArgs {
10
10
  tags?: string[]
11
11
  path?: string
12
12
  method?: string
13
+ /**
14
+ * F493: narrow the page invalidation to entries whose evaluated route-cache `prefix` equals this
15
+ * string exactly (`''` = routes with no prefix). With `path`: that path of that prefix; with
16
+ * `tags`: that prefix's tagged pages (the job cache is not touched); alone: every page of that
17
+ * prefix. Not with `key`.
18
+ */
19
+ prefix?: string
13
20
  }
14
21
  export interface InvalidateResult {
15
22
  l1Removed: number
@@ -39,6 +46,10 @@ export interface StartOptions {
39
46
  l2?: L2Options
40
47
  /** `brust dev` only (L14): a request carrying this token (`x-brust-dev-token`) is traced; absent in production. */
41
48
  devToken?: string
49
+ /** F507: who may read `/_brust/cache/stats`; absent = `off` (404). Boot error `stats: …` for a mode and token that do not go together. */
50
+ stats?: 'off' | 'public' | 'token'
51
+ /** F507: the secret `stats: 'token'` compares `Authorization: Bearer` with, resolved from its env reference; never logged. */
52
+ statsToken?: string
42
53
  }
43
54
  /** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
44
55
  export interface L2Options {
package/src/routes.ts CHANGED
@@ -166,6 +166,19 @@ export function checkCatchAlls(leaves: FlatRoute[]): void {
166
166
  throw new BrustRouteError(`more than one '*' route (${all.map((l) => l.id).join(', ')}): M2 allows one root catch-all`, 'duplicate-catch-all')
167
167
  }
168
168
 
169
+ /** F495: with `[server] base_path` every route is a real URL path under it (`/portal`, `/portal/orders/{id}`): a pattern
170
+ * outside the base would never be reached, because the server answers 404 for any path outside it. The root `'*'` stays
171
+ * (it becomes "anything under the base no route matched"). Named error `route-outside-base-path`, listing the ids. */
172
+ export function checkBasePath(leaves: FlatRoute[], basePath: string): void {
173
+ if (basePath === '') return
174
+ const outside = leaves.filter((l) => !l.catchAll && l.pattern !== basePath && !l.pattern.startsWith(`${basePath}/`))
175
+ if (outside.length > 0)
176
+ throw new BrustRouteError(
177
+ `routes outside server.base_path ${JSON.stringify(basePath)}: ${outside.map((l) => `${l.id} (${l.pattern})`).join(', ')}; write every route with the base (nest them under { path: ${JSON.stringify(basePath)}, children })`,
178
+ 'route-outside-base-path',
179
+ )
180
+ }
181
+
169
182
  /** Placeholder for the child route slot inside a layout. The compiler replaces `<Outlet/>`
170
183
  * imported from `@brust/core/routes`; rendered by React directly it is empty. */
171
184
  export function Outlet(): null {
package/src/run.ts CHANGED
@@ -35,6 +35,10 @@ export interface RunManifest {
35
35
  actions_remote?: { prefix: string } | null
36
36
  /** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
37
37
  cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string } | null
38
+ /** F495: the `[server] base_path` the build baked in; absent = the app is at the root. */
39
+ base_path?: string
40
+ /** F506: the `[server] trailing_slash` the build baked in; absent = `strict`. */
41
+ trailing_slash?: string
38
42
  /** F165: `false` = no route can call a worker; absent (older manifests) = the pool is needed. */
39
43
  needs_workers?: boolean
40
44
  }
@@ -55,7 +59,9 @@ export function workerCount(cfg: BrustConfig, m: RunManifest): number {
55
59
  }
56
60
 
57
61
  /** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
58
- export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string): StartOptions {
62
+ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string, env: Record<string, string | undefined> = process.env): StartOptions {
63
+ // F507: `/_brust/cache/stats` is off unless set; `brust dev` (its server carries a dev token) defaults to public.
64
+ const stats = cfg.stats ?? (devToken ? 'public' : 'off')
59
65
  return {
60
66
  host: cfg.host,
61
67
  port: cfg.port,
@@ -69,10 +75,19 @@ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest,
69
75
  ...(actionsClient(cfg, m) ? { actionsClient: actionsClient(cfg, m) } : {}),
70
76
  ...(cfg.cors ? { cors: cfg.cors } : {}),
71
77
  ...(devToken ? { devToken } : {}),
78
+ ...(stats !== 'off' ? { stats, ...(stats === 'token' ? { statsToken: statsTokenOf(cfg.statsToken, env) } : {}) } : {}),
72
79
  ...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
73
80
  }
74
81
  }
75
82
 
83
+ /** F507: the bearer token `[server] stats_token = "env:NAME"` names; a boot error naming the variable (never a value) when it is unset or empty. */
84
+ function statsTokenOf(ref: string | undefined, env: Record<string, string | undefined>): string {
85
+ const name = (ref ?? '').replace(/^env:/, '')
86
+ const v = name ? env[name] : undefined
87
+ if (!v) throw new Error(`stats: [server] stats_token is "env:${name}" but ${name} is not set (or empty)`)
88
+ return v
89
+ }
90
+
76
91
  /** L16: what the page tells the browser `client()` (R16.1/R16.2); absent while it would only repeat the prefix script. */
77
92
  function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptions['actionsClient']> | undefined {
78
93
  const credentials = cfg.actionsCredentials !== undefined && cfg.actionsCredentials !== 'same-origin' ? cfg.actionsCredentials : undefined
@@ -81,6 +96,23 @@ function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptio
81
96
  return { ...(prefix ? { prefix } : {}), ...(cfg.actionsBaseUrl !== undefined ? { baseUrl: cfg.actionsBaseUrl } : {}), ...(credentials ? { credentials } : {}) }
82
97
  }
83
98
 
99
+ /** F495: the message when the config names a base path that is not the one the manifest was built for (`base-path-mismatch`), else
100
+ * null. A config that sets none is fine: the manifest's own is what the server answers under. */
101
+ export function basePathMismatch(cfg: Pick<BrustConfig, 'basePath'>, m: Pick<RunManifest, 'base_path'>): string | null {
102
+ const built = m.base_path ?? ''
103
+ if (cfg.basePath === undefined || cfg.basePath === built) return null
104
+ const show = (p: string) => (p === '' ? 'the root' : JSON.stringify(p))
105
+ return `base-path-mismatch: server.base_path is ${show(cfg.basePath)} but this build is for ${show(built)}; the base path is baked in at build time, so rebuild (brust build)`
106
+ }
107
+
108
+ /** F506: the message when the config names a trailing-slash policy other than the one the manifest was built with
109
+ * (`trailing-slash-mismatch`), else null. A config that sets none serves the manifest's. */
110
+ export function trailingSlashMismatch(cfg: Pick<BrustConfig, 'trailingSlash'>, m: Pick<RunManifest, 'trailing_slash'>): string | null {
111
+ const built = m.trailing_slash ?? 'strict'
112
+ if (cfg.trailingSlash === undefined || cfg.trailingSlash === built) return null
113
+ return `trailing-slash-mismatch: server.trailing_slash is ${JSON.stringify(cfg.trailingSlash)} but this build is for ${JSON.stringify(built)}; it is baked in at build time, so rebuild (brust build)`
114
+ }
115
+
84
116
  /** Every worker's response buffer, kept reachable until the process exits (see run()). */
85
117
  const workerBuffers: SharedArrayBuffer[] = []
86
118
 
@@ -111,6 +143,10 @@ export async function run(opts: RunOptions = {}): Promise<void> {
111
143
  fail((e as Error).message)
112
144
  }
113
145
 
146
+ const mismatch = basePathMismatch(cfg, m) ?? trailingSlashMismatch(cfg, m)
147
+ if (mismatch) fail(mismatch)
148
+ // `basePath` of `@brust/core` in the app's modules (the workers inherit the env).
149
+ process.env.BRUST_BASE_PATH = m.base_path ?? ''
114
150
  if (m.actions_remote && cfg.actionsBaseUrl === undefined)
115
151
  fail('this build has no local actions app: set [actions] base_url (or BRUST_ACTIONS_BASE_URL) to the deployment that serves them')
116
152
  const version: string = (await Bun.file(join(import.meta.dir, '../package.json')).json()).version
@@ -195,6 +231,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
195
231
  // F485 P12: on Windows Ctrl+C (SIGINT) drains; SIGTERM handlers rarely fire and a kill ends the process without a drain (POSIX-only).
196
232
  process.on('SIGINT', () => onSignal(130))
197
233
  process.on('SIGTERM', () => onSignal(143))
234
+ // F492: a console Ctrl+Break (and closing the console) reaches Bun as SIGBREAK on Windows: drain like Ctrl+C.
235
+ if (process.platform === 'win32') process.on('SIGBREAK', () => onSignal(149))
198
236
 
199
237
  // F488 (Windows only): a killed `bun run start` wrapper takes the shim (our parent) down but leaves this process running; drain when the
200
238
  // parent is gone (same path as Ctrl+C). Synchronous, nothing awaited. Not for a one-shot (`opts.onReady`: a static render that exits itself).