@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/README.md +2 -1
- package/native/index.d.ts +26 -1
- package/native/index.js +55 -54
- package/package.json +10 -10
- package/src/actions/client.ts +3 -4
- package/src/browser.ts +3 -0
- package/src/build/actions-only.ts +2 -1
- package/src/build/bundle.ts +58 -19
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cf-workerd.ts +63 -0
- package/src/build/cloudflare.ts +26 -6
- package/src/build/compile.ts +2 -0
- package/src/build/config.ts +36 -7
- package/src/build/css.ts +18 -12
- package/src/build/index.ts +30 -12
- package/src/build/manifest.ts +8 -0
- package/src/build/route-conflicts.ts +39 -0
- package/src/build/ssg.ts +3 -2
- package/src/build/static.ts +7 -2
- package/src/build/tailwind.ts +15 -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 +91 -2
- package/src/dev/bun.ts +8 -6
- package/src/dev/client.ts +4 -1
- package/src/dev/devtools.ts +5 -3
- package/src/dev/proxy.ts +21 -17
- package/src/index.ts +5 -0
- package/src/launcher-watch.ts +54 -1
- package/src/native.ts +11 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +39 -1
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/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'
|
package/src/launcher-watch.ts
CHANGED
|
@@ -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).
|