@brust/core 2.0.0-alpha.33 → 2.0.0-alpha.35

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/bun.ts CHANGED
@@ -9,6 +9,7 @@ import { loadConfig } from '../config'
9
9
  import { type Diag, parseBuildError, parseWarnings } from './diag'
10
10
  import { tierCounts } from './devtools-data'
11
11
  import { type DevProxy, startProxy } from './proxy'
12
+ import { openCommand } from './open'
12
13
  import { HOTKEYS, Tui, type TuiOut, tuiMode } from './tui'
13
14
  import { loadBuildConfig } from '../build/config'
14
15
  import { broadRootWarnings, changeLine, defaultIgnored, watchLoop } from './watch'
@@ -76,9 +77,9 @@ function pumpLines(stream: ReadableStream<Uint8Array> | null | undefined, onLine
76
77
  }
77
78
 
78
79
  /** L19/L21 dev swap: the built stylesheets of the old and the new generation, paired by source stem
79
- * (`<stem>-<hex10>.css`), as `/_brust/client/<old>` → `/_brust/client/<new>`. `null` when the pairing is not
80
+ * (`<stem>-<hex10>.css`), as `<base>/_brust/client/<old>` → `<base>/_brust/client/<new>`. `null` when the pairing is not
80
81
  * one-to-one (a stylesheet was added or removed, or two sources share a stem): the page must reload. */
81
- export function cssSwapMap(oldDir: string, newDir: string): Record<string, string> | null {
82
+ export function cssSwapMap(oldDir: string, newDir: string, base = ''): Record<string, string> | null {
82
83
  const byStem = (dir: string) => {
83
84
  const m = new Map<string, string>()
84
85
  let files: string[] = []
@@ -100,7 +101,7 @@ export function cssSwapMap(oldDir: string, newDir: string): Record<string, strin
100
101
  for (const [stem, f] of a) {
101
102
  const g = b.get(stem)
102
103
  if (g === undefined) return null
103
- if (g !== f) map[`/_brust/client/${f}`] = `/_brust/client/${g}`
104
+ if (g !== f) map[`${base}/_brust/client/${f}`] = `${base}/_brust/client/${g}`
104
105
  }
105
106
  return map
106
107
  }
@@ -122,6 +123,8 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
122
123
  const mode = tuiMode(out, env)
123
124
  const tui = new Tui(out, mode.interactive, mode.color)
124
125
  const cfg = await loadConfig(appRoot, o.port !== undefined ? { port: o.port } : {})
126
+ // F495: `[server] base_path`: the dev channel (`<base>/_brust/dev/*`) and the app's own URLs live under it, so two apps behind one path proxy can both run.
127
+ const base = cfg.basePath ?? ''
125
128
  const workers = o.workers ?? 1
126
129
  const devRoot = join(appRoot, '.brust', 'dev')
127
130
  rmSync(devRoot, { recursive: true, force: true })
@@ -164,7 +167,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
164
167
  }
165
168
 
166
169
  const proxy: DevProxy = startProxy(
167
- { hostname: cfg.host, port: cfg.port, token: devToken },
170
+ { hostname: cfg.host, port: cfg.port, token: devToken, basePath: base },
168
171
  {
169
172
  gen: () => gen,
170
173
  state: () => state,
@@ -174,7 +177,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
174
177
  actionsPrefix: () => prefixOf(active),
175
178
  onRequest(r) {
176
179
  tui.log(`${r.method} ${r.path} ${r.status} ${r.ms}ms${r.cache ? ` ${r.cache}` : ''}`)
177
- if (!r.path.startsWith('/_brust/')) {
180
+ if (!r.path.startsWith(`${base}/_brust/`)) {
178
181
  const rendered = (r.trace as { rendered?: string[] } | null)?.rendered
179
182
  last = { method: r.method, path: r.path, status: r.status, ...(rendered ? { tiers: tierCounts(rendered, devMeta() as Parameters<typeof tierCounts>[1]) } : {}) }
180
183
  publish()
@@ -302,7 +305,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
302
305
  state = 'ready'
303
306
  diag = null
304
307
  // A stylesheet-only edit (L19/L21): the pages keep their DOM and swap their brust CSS links.
305
- const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir) : null
308
+ const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir, base) : null
306
309
  if (swap) {
307
310
  proxy.broadcast({ type: 'css-swap', gen, map: swap })
308
311
  tui.log(`[brust] css rebuilt (${Object.keys(swap).length} stylesheet${Object.keys(swap).length === 1 ? '' : 's'} swapped, no reload)`)
@@ -373,13 +376,14 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
373
376
  for (const k of b.toString()) {
374
377
  if (k === 'q' || k === '\x03') void stop()
375
378
  else if (k === 'r') proxy.broadcast({ type: 'reload' })
376
- else if (k === 'o') Bun.spawn([process.platform === 'darwin' ? 'open' : 'xdg-open', url], { stdout: 'ignore', stderr: 'ignore' })
379
+ else if (k === 'o') Bun.spawn(openCommand(url), { stdout: 'ignore', stderr: 'ignore' })
377
380
  else if (k === 'w') for (const w of warnings.length ? warnings : ['no warnings']) tui.log(`warning ${w}`)
378
381
  else if (k === 'c') tui.clear()
379
382
  }
380
383
  })
381
384
  ;(out as unknown as NodeJS.WriteStream).on?.('resize', () => tui.resize())
382
385
  }
386
+ // F485 P12: on Windows Ctrl+C (SIGINT) reaches this handler; SIGTERM handlers rarely fire and a kill ends the process without a drain.
383
387
  process.on('SIGINT', () => void stop())
384
388
  process.on('SIGTERM', () => void stop())
385
389
 
package/src/dev/client.ts CHANGED
@@ -78,7 +78,10 @@ function handle(m) {
78
78
  }
79
79
 
80
80
  function connect() {
81
- const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/_brust/dev/ws')
81
+ // F495: the socket lives next to this script (<base>/_brust/dev/ws), wherever the app is mounted.
82
+ const wsUrl = new URL('ws', import.meta.url)
83
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
84
+ const ws = new WebSocket(wsUrl)
82
85
  ws.onopen = () => {
83
86
  delay = 150
84
87
  }
@@ -109,17 +109,19 @@ class BrustDevtools extends HTMLElement {
109
109
  }
110
110
 
111
111
  private async loadMeta(): Promise<void> {
112
- try { this.meta = (await (await fetch('/_brust/dev/meta')).json()) as DevMeta } catch { this.meta = null }
112
+ try { this.meta = (await (await fetch(new URL('meta', import.meta.url))).json()) as DevMeta } catch { this.meta = null }
113
113
  this.paintIndicator()
114
114
  if (!this.panel.hidden) this.render()
115
115
  }
116
116
 
117
117
  private async loadEntries(): Promise<void> {
118
- try { this.entries = ((await (await fetch('/_brust/dev/traces')).json()) as { traces: Entry[] }).traces } catch { this.entries = [] }
118
+ try { this.entries = ((await (await fetch(new URL('traces', import.meta.url))).json()) as { traces: Entry[] }).traces } catch { this.entries = [] }
119
119
  }
120
120
 
121
121
  private connect(): void {
122
- const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_brust/dev/ws`)
122
+ const wsUrl = new URL('ws', import.meta.url)
123
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
124
+ const ws = new WebSocket(wsUrl)
123
125
  ws.onmessage = (e) => {
124
126
  const m = JSON.parse(e.data as string) as { type: string; gen?: number; state?: BuildState['state']; diag?: BuildState['diag'] }
125
127
  if (m.type === 'hello') this.build = { state: m.state ?? 'ready', gen: m.gen ?? 0, diag: m.diag ?? null }
@@ -0,0 +1,7 @@
1
+ // F485 P7: the command that opens `url` in the default browser (dev hotkey `o`). Argv array, no shell string.
2
+ export function openCommand(url: string, platform: string = process.platform): string[] {
3
+ if (platform === 'darwin') return ['open', url]
4
+ // cmd parses `&` in a URL as a separator: fine for the internal localhost URL this is called with.
5
+ if (platform === 'win32') return ['cmd', '/c', 'start', '', url] // the empty string is `start`'s window title slot
6
+ return ['xdg-open', url]
7
+ }
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/dev.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // `brust dev [entry] --target cloudflare [-- <wrangler args>]` (M4-CF R10): build, run `wrangler dev` on the
2
2
  // generated project, rebuild + restart wrangler on a source change. No HMR. Each build runs in a child
3
3
  // process: a failed build keeps the old wrangler, and module caches never leak between builds.
4
- import { join, relative, resolve } from 'node:path'
4
+ import { delimiter, join, relative, resolve } from 'node:path'
5
+ import { posixOf } from './build/posix'
5
6
  import { BuildError } from './build/errors'
6
7
  import { loadBuildConfig } from './build/config'
7
8
  import { broadRootWarnings, defaultIgnored, type WatchLoop, watchLoop } from './dev/watch'
@@ -21,7 +22,7 @@ const DEBOUNCE_MS = 300
21
22
  function wranglerBin(appRoot: string): string {
22
23
  const fake = process.env.BRUST_WRANGLER
23
24
  if (fake) return fake
24
- const found = Bun.which('wrangler', { PATH: `${join(appRoot, 'node_modules', '.bin')}:${process.env.PATH ?? ''}` })
25
+ const found = Bun.which('wrangler', { PATH: `${join(appRoot, 'node_modules', '.bin')}${delimiter}${process.env.PATH ?? ''}` })
25
26
  if (!found) throw new BuildError('cf-dev', 'wrangler not found (bun add -d wrangler)')
26
27
  return found
27
28
  }
@@ -71,7 +72,7 @@ export async function runDev(o: DevOptions): Promise<number> {
71
72
  const loop: WatchLoop = watchLoop({
72
73
  root: appRoot,
73
74
  extraRoots,
74
- ignored: (rel) => defaultIgnored(rel, relative(appRoot, dist)),
75
+ ignored: (rel) => defaultIgnored(rel, posixOf(relative(appRoot, dist))),
75
76
  debounceMs: DEBOUNCE_MS,
76
77
  async run() {
77
78
  o.log('[brust] change detected; rebuilding')
@@ -91,6 +92,7 @@ export async function runDev(o: DevOptions): Promise<number> {
91
92
  loop.close()
92
93
  void stopChild().then(() => exitCode(0))
93
94
  }
95
+ // F485 P12: on Windows Ctrl+C (SIGINT) reaches this handler; SIGTERM handlers rarely fire and a kill ends the process without a drain.
94
96
  process.on('SIGINT', stop)
95
97
  process.on('SIGTERM', stop)
96
98
  return done
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'
@@ -0,0 +1,45 @@
1
+ // F488: on Windows a kill of the `bun run start` wrapper (TerminateProcess; no process groups) takes the `.bin/brust.exe` shim down with it
2
+ // but not the server below: it keeps the port, the app dir and the inherited pipes. Probe on windows-latest (PR #428): wrapper -> shim ->
3
+ // server, and after a plain wrapper kill the shim is gone while the server lives. The shim is the server's direct parent, so the server
4
+ // watches `process.ppid`: when that dies it drains like Ctrl+C. Started at boot, synchronously: no process table, nothing awaited.
5
+ // (A PowerShell table read awaited before `ready` cost seconds per start and broke 5 s static builds; the lead ruled it out.)
6
+ // POSIX never watches (platform gate): a POSIX supervisor signals the server directly.
7
+
8
+ /** `process.kill(pid, 0)` throws ESRCH when the process is gone; EPERM means it exists. */
9
+ export function pidAlive(pid: number): boolean {
10
+ try {
11
+ process.kill(pid, 0)
12
+ return true
13
+ } catch (e) {
14
+ return (e as NodeJS.ErrnoException).code === 'EPERM'
15
+ }
16
+ }
17
+
18
+ export interface LauncherWatchOptions {
19
+ /** Runs once, when the parent is gone. */
20
+ onGone: (pid: number) => void
21
+ platform?: NodeJS.Platform
22
+ ppid?: number
23
+ alive?: (pid: number) => boolean
24
+ intervalMs?: number
25
+ log?: (line: string) => void
26
+ }
27
+
28
+ /** Windows only. Returns the watched pid (undefined: nothing watched) and a `stop`. The timer is unref'd: it never holds the process open. */
29
+ export function watchLauncher(opts: LauncherWatchOptions): { pid: number | undefined; stop: () => void } {
30
+ const none = { pid: undefined, stop: () => {} }
31
+ if ((opts.platform ?? process.platform) !== 'win32') return none
32
+ const ppid = opts.ppid ?? process.ppid
33
+ const alive = opts.alive ?? pidAlive
34
+ if (!ppid || !alive(ppid)) {
35
+ ;(opts.log ?? console.error)(`[brust] launcher (pid ${ppid}) is already gone at boot: not watching it`)
36
+ return none
37
+ }
38
+ const timer = setInterval(() => {
39
+ if (alive(ppid)) return
40
+ clearInterval(timer)
41
+ opts.onGone(ppid)
42
+ }, opts.intervalMs ?? 1000)
43
+ timer.unref()
44
+ return { pid: ppid, stop: () => clearInterval(timer) }
45
+ }
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
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
@@ -11,6 +11,7 @@
11
11
  import { existsSync, readFileSync } from 'node:fs'
12
12
  import { join, resolve } from 'node:path'
13
13
  import { type BrustConfig, type ConfigOverrides, loadConfig } from './config'
14
+ import { watchLauncher } from './launcher-watch'
14
15
  import { beginDrain, localAddr, type StartOptions, type StreamOptions, startServer, untilReady } from './native'
15
16
  import { DEFAULT_MAX_LOADER_RESPONSE_BYTES } from './worker'
16
17
 
@@ -34,6 +35,8 @@ export interface RunManifest {
34
35
  actions_remote?: { prefix: string } | null
35
36
  /** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
36
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
37
40
  /** F165: `false` = no route can call a worker; absent (older manifests) = the pool is needed. */
38
41
  needs_workers?: boolean
39
42
  }
@@ -80,6 +83,15 @@ function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptio
80
83
  return { ...(prefix ? { prefix } : {}), ...(cfg.actionsBaseUrl !== undefined ? { baseUrl: cfg.actionsBaseUrl } : {}), ...(credentials ? { credentials } : {}) }
81
84
  }
82
85
 
86
+ /** F495: the message when the config names a base path that is not the one the manifest was built for (`base-path-mismatch`), else
87
+ * null. A config that sets none is fine: the manifest's own is what the server answers under. */
88
+ export function basePathMismatch(cfg: Pick<BrustConfig, 'basePath'>, m: Pick<RunManifest, 'base_path'>): string | null {
89
+ const built = m.base_path ?? ''
90
+ if (cfg.basePath === undefined || cfg.basePath === built) return null
91
+ const show = (p: string) => (p === '' ? 'the root' : JSON.stringify(p))
92
+ 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)`
93
+ }
94
+
83
95
  /** Every worker's response buffer, kept reachable until the process exits (see run()). */
84
96
  const workerBuffers: SharedArrayBuffer[] = []
85
97
 
@@ -110,6 +122,10 @@ export async function run(opts: RunOptions = {}): Promise<void> {
110
122
  fail((e as Error).message)
111
123
  }
112
124
 
125
+ const mismatch = basePathMismatch(cfg, m)
126
+ if (mismatch) fail(mismatch)
127
+ // `basePath` of `@brust/core` in the app's modules (the workers inherit the env).
128
+ process.env.BRUST_BASE_PATH = m.base_path ?? ''
113
129
  if (m.actions_remote && cfg.actionsBaseUrl === undefined)
114
130
  fail('this build has no local actions app: set [actions] base_url (or BRUST_ACTIONS_BASE_URL) to the deployment that serves them')
115
131
  const version: string = (await Bun.file(join(import.meta.dir, '../package.json')).json()).version
@@ -191,9 +207,20 @@ export async function run(opts: RunOptions = {}): Promise<void> {
191
207
  }
192
208
  process.on('uncaughtException', onFatal('uncaught exception'))
193
209
  process.on('unhandledRejection', onFatal('unhandled rejection'))
210
+ // F485 P12: on Windows Ctrl+C (SIGINT) drains; SIGTERM handlers rarely fire and a kill ends the process without a drain (POSIX-only).
194
211
  process.on('SIGINT', () => onSignal(130))
195
212
  process.on('SIGTERM', () => onSignal(143))
196
213
 
214
+ // F488 (Windows only): a killed `bun run start` wrapper takes the shim (our parent) down but leaves this process running; drain when the
215
+ // parent is gone (same path as Ctrl+C). Synchronous, nothing awaited. Not for a one-shot (`opts.onReady`: a static render that exits itself).
216
+ if (!opts.onReady)
217
+ watchLauncher({
218
+ onGone: (pid) => {
219
+ if (exiting) return
220
+ console.error(`[brust] launcher (pid ${pid}) exited: shutting down`)
221
+ onSignal(130)
222
+ },
223
+ })
197
224
  try {
198
225
  await untilReady(cfg.bootTimeoutMs)
199
226
  } catch (e) {