@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/native/index.d.ts +19 -1
- package/native/index.js +55 -54
- package/package.json +12 -10
- package/src/actions/client.ts +3 -4
- package/src/browser.ts +3 -0
- package/src/build/actions-only.ts +7 -10
- package/src/build/bundle.ts +21 -14
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cf-wrangler.ts +3 -2
- package/src/build/cloudflare.ts +10 -6
- package/src/build/compile.ts +5 -2
- package/src/build/config.ts +26 -7
- package/src/build/css.ts +53 -12
- package/src/build/esm-resolve.ts +7 -2
- package/src/build/index.ts +44 -26
- package/src/build/manifest.ts +6 -0
- package/src/build/posix.ts +35 -0
- package/src/build/route-conflicts.ts +39 -0
- package/src/build/ssg.ts +3 -2
- package/src/build/static.ts +3 -2
- package/src/build/swap.ts +47 -0
- package/src/build/tailwind.ts +13 -3
- package/src/build/tsconfig-paths.ts +31 -3
- package/src/cache-adapter/conformance.ts +77 -0
- package/src/cache-adapter/memory.ts +44 -12
- package/src/cache-adapter/types.ts +7 -1
- package/src/cache.ts +11 -0
- package/src/cf.ts +3 -0
- package/src/config.ts +29 -1
- package/src/dev/bun.ts +11 -7
- package/src/dev/client.ts +4 -1
- package/src/dev/devtools.ts +5 -3
- package/src/dev/open.ts +7 -0
- package/src/dev/proxy.ts +21 -17
- package/src/dev.ts +5 -3
- package/src/index.ts +5 -0
- package/src/launcher-watch.ts +45 -0
- package/src/native.ts +7 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +27 -0
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
|
|
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[
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/dev/devtools.ts
CHANGED
|
@@ -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('
|
|
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('
|
|
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
|
|
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 }
|
package/src/dev/open.ts
ADDED
|
@@ -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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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(
|
|
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 ===
|
|
220
|
-
if (url.pathname ===
|
|
221
|
-
if (url.pathname ===
|
|
222
|
-
if (url.pathname ===
|
|
223
|
-
if (url.pathname ===
|
|
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 ===
|
|
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')}
|
|
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) {
|