@brust/core 2.0.0-alpha.34 → 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 +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 +17 -10
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cloudflare.ts +7 -4
- package/src/build/compile.ts +2 -0
- package/src/build/config.ts +25 -7
- package/src/build/css.ts +18 -12
- package/src/build/index.ts +22 -10
- package/src/build/manifest.ts +6 -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/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 +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/native.ts +7 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +15 -0
|
@@ -129,6 +129,48 @@ export function runConformance(make: () => CacheAdapter | Promise<CacheAdapter>,
|
|
|
129
129
|
expect((await s.get(k2)).page).not.toBeNull()
|
|
130
130
|
})
|
|
131
131
|
|
|
132
|
+
if (caps.invalidate !== false) test('F493: prefix + path drops that prefix\'s page only', async () => {
|
|
133
|
+
const { s } = await store()
|
|
134
|
+
const [a, b, none] = [key(), key(), key()]
|
|
135
|
+
await s.set(a, page('a'), meta({ path: '/x', prefix: 'a.test' }))
|
|
136
|
+
await s.set(b, page('b'), meta({ path: '/x', prefix: 'b.test' }))
|
|
137
|
+
await s.set(none, page('n'), meta({ path: '/x', prefix: '' }))
|
|
138
|
+
await s.invalidate({ prefix: 'a.test', path: '/x' })
|
|
139
|
+
expect((await s.get(a)).page).toBeNull()
|
|
140
|
+
expect((await s.get(b)).page).not.toBeNull()
|
|
141
|
+
expect((await s.get(none)).page).not.toBeNull()
|
|
142
|
+
// `''` is exact: it matches the entry with no prefix only.
|
|
143
|
+
await s.invalidate({ prefix: '', path: '/x' })
|
|
144
|
+
expect((await s.get(none)).page).toBeNull()
|
|
145
|
+
expect((await s.get(b)).page).not.toBeNull()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (caps.invalidate !== false) test('F493: prefix + tags drops that prefix\'s tagged pages; the other prefix stays tagged', async () => {
|
|
149
|
+
const { s } = await store()
|
|
150
|
+
const [a, b, a2] = [key(), key(), key()]
|
|
151
|
+
await s.set(a, page('a'), meta({ tags: ['page'], path: '/p', prefix: 'a.test' }))
|
|
152
|
+
await s.set(b, page('b'), meta({ tags: ['page'], path: '/p', prefix: 'b.test' }))
|
|
153
|
+
await s.set(a2, page('a2'), meta({ tags: ['other'], path: '/q', prefix: 'a.test' }))
|
|
154
|
+
await s.invalidate({ prefix: 'a.test', tags: ['page'] })
|
|
155
|
+
expect((await s.get(a)).page).toBeNull()
|
|
156
|
+
expect((await s.get(b)).page).not.toBeNull()
|
|
157
|
+
expect((await s.get(a2)).page).not.toBeNull()
|
|
158
|
+
await s.invalidate({ tags: ['page'] })
|
|
159
|
+
expect((await s.get(b)).page).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (caps.invalidate !== false) test('F493: prefix alone drops every page of that prefix', async () => {
|
|
163
|
+
const { s } = await store()
|
|
164
|
+
const [a1, a2, b] = [key(), key(), key()]
|
|
165
|
+
await s.set(a1, page('1'), meta({ path: '/x', prefix: 'a.test' }))
|
|
166
|
+
await s.set(a2, page('2'), meta({ tags: ['t'], path: '/y', prefix: 'a.test' }))
|
|
167
|
+
await s.set(b, page('b'), meta({ path: '/x', prefix: 'b.test' }))
|
|
168
|
+
await s.invalidate({ prefix: 'a.test' })
|
|
169
|
+
expect((await s.get(a1)).page).toBeNull()
|
|
170
|
+
expect((await s.get(a2)).page).toBeNull()
|
|
171
|
+
expect((await s.get(b)).page).not.toBeNull()
|
|
172
|
+
})
|
|
173
|
+
|
|
132
174
|
test('key invalidation is a store no-op', async () => {
|
|
133
175
|
const { s } = await store()
|
|
134
176
|
const k = key()
|
|
@@ -168,6 +210,41 @@ export function runConformance(make: () => CacheAdapter | Promise<CacheAdapter>,
|
|
|
168
210
|
})
|
|
169
211
|
}
|
|
170
212
|
|
|
213
|
+
if (caps.guard) {
|
|
214
|
+
test('F493: a set whose stamp predates an invalidation of its prefix is refused; another prefix lands', async () => {
|
|
215
|
+
const { a, s } = await store()
|
|
216
|
+
const [k, other] = [key(), key()]
|
|
217
|
+
// A path of its own: a store's namespace may outlive one case (an earlier case's path marker must not refuse it).
|
|
218
|
+
const path = `/${k}`
|
|
219
|
+
const g = await s.get(k)
|
|
220
|
+
await s.invalidate({ prefix: 'a.test' })
|
|
221
|
+
await a.broadcast?.publish({ prefix: 'a.test' }, 'conf')
|
|
222
|
+
expect(await s.set(k, page('stale'), meta({ path, prefix: 'a.test', stamp: g.stamp }))).toBe(false)
|
|
223
|
+
expect((await s.get(k)).page).toBeNull()
|
|
224
|
+
expect(await s.set(other, page('b'), meta({ path, prefix: 'b.test', stamp: g.stamp }))).toBe(true)
|
|
225
|
+
const g2 = await s.get(k)
|
|
226
|
+
expect(await s.set(k, page('fresh'), meta({ path, prefix: 'a.test', stamp: g2.stamp }))).toBe(true)
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
test('F493: the broadcast carries prefix in its args', async () => {
|
|
231
|
+
const a = await fresh()
|
|
232
|
+
if (!a.broadcast?.subscribe && !a.broadcast?.since) return
|
|
233
|
+
const args = { prefix: 'a.test', path: '/b' }
|
|
234
|
+
if (a.broadcast.subscribe) {
|
|
235
|
+
const got: InvalidationEvent[] = []
|
|
236
|
+
const stop = await a.broadcast.subscribe(e => got.push(e), () => {})
|
|
237
|
+
const seq = await a.broadcast.publish(args, 'o1')
|
|
238
|
+
await until('delivery', () => got.length === 1)
|
|
239
|
+
expect(got[0]).toEqual({ seq, origin: 'o1', args })
|
|
240
|
+
await stop()
|
|
241
|
+
} else {
|
|
242
|
+
const base = (await a.broadcast.since!(0)).seq
|
|
243
|
+
await a.broadcast.publish(args, 'o1')
|
|
244
|
+
expect((await a.broadcast.since!(base)).events.map(e => e.args)).toEqual([args])
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
171
248
|
test('publish seqs strictly increase; get().stamp is the last seq (one space)', async () => {
|
|
172
249
|
const a = await fresh()
|
|
173
250
|
if (!a.broadcast) return
|
|
@@ -32,6 +32,8 @@ interface Entry {
|
|
|
32
32
|
tags: string[]
|
|
33
33
|
method: string
|
|
34
34
|
path: string
|
|
35
|
+
/** The route-cache key prefix (F493); `''` = none. */
|
|
36
|
+
prefix: string
|
|
35
37
|
entryStamp: number | null
|
|
36
38
|
storedAt: number
|
|
37
39
|
expiresAt: number
|
|
@@ -57,6 +59,42 @@ export type MemoryAdapter = CacheAdapter & { store: CacheStore; __test: MemoryAd
|
|
|
57
59
|
|
|
58
60
|
const pathName = (method: string | undefined, path: string) => `${method ?? 'GET'} ${path}`
|
|
59
61
|
|
|
62
|
+
// Marker names (F493: scoped by prefix, so one prefix's invalidation refuses only that prefix's stale
|
|
63
|
+
// writes). A prefix is JSON-quoted: self-delimiting, so names of different prefixes never meet.
|
|
64
|
+
const tagName = (t: string) => `t:${t}`
|
|
65
|
+
const pName = (m: string | undefined, p: string) => `p:${pathName(m, p)}`
|
|
66
|
+
const px = (prefix: string) => `px:${JSON.stringify(prefix)}`
|
|
67
|
+
const ptName = (prefix: string, t: string) => `pt:${JSON.stringify(prefix)}${t}`
|
|
68
|
+
const ppName = (prefix: string, m: string | undefined, p: string) => `pp:${JSON.stringify(prefix)}${pathName(m, p)}`
|
|
69
|
+
|
|
70
|
+
/** The marker names an invalidation raises. `{ prefix }` alone ignores `method` (conservative). */
|
|
71
|
+
function invalidationNames(a: InvalidateArgs): string[] {
|
|
72
|
+
const tags = a.tags ?? []
|
|
73
|
+
const pre = a.prefix
|
|
74
|
+
if (pre === undefined) return [...tags.map(tagName), ...(a.path !== undefined ? [pName(a.method, a.path)] : [])]
|
|
75
|
+
if (!tags.length && a.path === undefined) return [px(pre)]
|
|
76
|
+
return [...tags.map(t => ptName(pre, t)), ...(a.path !== undefined ? [ppName(pre, a.method, a.path)] : [])]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every marker name that covers an entry. */
|
|
80
|
+
function entryNames(e: { tags: string[]; method: string; path: string; prefix: string }): string[] {
|
|
81
|
+
return [
|
|
82
|
+
...e.tags.map(tagName),
|
|
83
|
+
pName(e.method, e.path),
|
|
84
|
+
px(e.prefix),
|
|
85
|
+
...e.tags.map(t => ptName(e.prefix, t)),
|
|
86
|
+
ppName(e.prefix, e.method, e.path),
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Does `a` delete `e`? (`key` alone deletes nothing: the store holds pages only.) */
|
|
91
|
+
function covers(a: InvalidateArgs, e: Entry): boolean {
|
|
92
|
+
if (a.prefix !== undefined && e.prefix !== a.prefix) return false
|
|
93
|
+
const tags = a.tags ?? []
|
|
94
|
+
if (!tags.length && a.path === undefined) return a.prefix !== undefined && (a.method === undefined || a.method === e.method)
|
|
95
|
+
return e.tags.some(t => tags.includes(t)) || (a.path !== undefined && pathName(e.method, e.path) === pathName(a.method, a.path))
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
61
99
|
const mode = o.broadcast ?? 'push'
|
|
62
100
|
const ringCap = o.ring ?? 1024
|
|
@@ -64,8 +102,7 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
64
102
|
const maxBodyBytes = o.maxBodyBytes ?? 8_388_608
|
|
65
103
|
const now = o.now ?? Date.now
|
|
66
104
|
const entries = new Map<string, Entry>()
|
|
67
|
-
const
|
|
68
|
-
const pathSeq = new Map<string, number>()
|
|
105
|
+
const nameSeq = new Map<string, number>()
|
|
69
106
|
let seq = 0
|
|
70
107
|
// Drawn on first use: a Workers module may not make random values at global scope.
|
|
71
108
|
let genValue: string | null = null
|
|
@@ -86,10 +123,8 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
86
123
|
fail = false
|
|
87
124
|
return true
|
|
88
125
|
}
|
|
89
|
-
const raise = (m: Map<string, number>, k: string, s: number) => m.set(k, Math.max(m.get(k) ?? 0, s))
|
|
90
126
|
const mark = (args: InvalidateArgs, s: number) => {
|
|
91
|
-
for (const
|
|
92
|
-
if (args.path !== undefined) raise(pathSeq, pathName(args.method, args.path), s)
|
|
127
|
+
for (const n of invalidationNames(args)) nameSeq.set(n, Math.max(nameSeq.get(n) ?? 0, s))
|
|
93
128
|
}
|
|
94
129
|
|
|
95
130
|
const store: CacheStore = {
|
|
@@ -113,10 +148,10 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
113
148
|
async set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean> {
|
|
114
149
|
if (failing()) throw new Error('memory adapter: injected failure')
|
|
115
150
|
if (page.body.length > maxBodyBytes) return false
|
|
151
|
+
const prefix = meta.prefix ?? ''
|
|
116
152
|
if (meta.stamp !== null) {
|
|
117
153
|
const s = meta.stamp
|
|
118
|
-
|
|
119
|
-
if (meta.tags.some(t => stale(tagSeq, t)) || stale(pathSeq, pathName(meta.method, meta.path))) return false
|
|
154
|
+
if (entryNames({ ...meta, prefix }).some(n => (nameSeq.get(n) ?? 0) > s)) return false
|
|
120
155
|
}
|
|
121
156
|
const at = now()
|
|
122
157
|
entries.set(key, {
|
|
@@ -124,6 +159,7 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
124
159
|
tags: [...meta.tags],
|
|
125
160
|
method: meta.method,
|
|
126
161
|
path: meta.path,
|
|
162
|
+
prefix,
|
|
127
163
|
entryStamp: meta.stamp,
|
|
128
164
|
storedAt: at,
|
|
129
165
|
expiresAt: at + meta.ttlMs,
|
|
@@ -140,11 +176,7 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
140
176
|
// L7b: the marker first, at the seq the matching publish will take (seq + 1), so a set whose
|
|
141
177
|
// stamp was read before this call is refused from now on; then the delete.
|
|
142
178
|
mark(args, seq + 1)
|
|
143
|
-
const
|
|
144
|
-
const p = args.path === undefined ? null : pathName(args.method, args.path)
|
|
145
|
-
for (const [k, e] of entries) {
|
|
146
|
-
if (e.tags.some(t => tags.has(t)) || (p !== null && pathName(e.method, e.path) === p)) entries.delete(k)
|
|
147
|
-
}
|
|
179
|
+
for (const [k, e] of entries) if (covers(args, e)) entries.delete(k)
|
|
148
180
|
},
|
|
149
181
|
}
|
|
150
182
|
|
|
@@ -21,6 +21,11 @@ export interface SetMeta {
|
|
|
21
21
|
tags: string[]
|
|
22
22
|
method: string
|
|
23
23
|
path: string
|
|
24
|
+
/**
|
|
25
|
+
* The entry's evaluated route-cache key prefix (`''` = none), for `invalidate({ prefix })` (F493).
|
|
26
|
+
* Absent only from callers older than F493: treat it as `''`.
|
|
27
|
+
*/
|
|
28
|
+
prefix?: string
|
|
24
29
|
/** The `stamp` the lookup returned (read before the render); `null` = unguarded write. */
|
|
25
30
|
stamp: number | null
|
|
26
31
|
}
|
|
@@ -45,7 +50,8 @@ export interface CacheStore {
|
|
|
45
50
|
/**
|
|
46
51
|
* Shared-side delete by tags/path (`key` is a no-op: the store holds pages only). Records the
|
|
47
52
|
* invalidation marker BEFORE deleting, so a concurrent stale `set` is refused even before the
|
|
48
|
-
* publish (ruling L7b).
|
|
53
|
+
* publish (ruling L7b). With `prefix` (F493) only entries whose `SetMeta.prefix` equals it are
|
|
54
|
+
* deleted (`{ prefix }` alone: every page of that prefix); entries of other prefixes stay.
|
|
49
55
|
*/
|
|
50
56
|
invalidate(args: InvalidateArgs): Promise<void>
|
|
51
57
|
readonly maxBodyBytes: number
|
package/src/cache.ts
CHANGED
|
@@ -9,8 +9,19 @@ import { type InvalidateArgs, type InvalidateResult, cacheInvalidate } from './n
|
|
|
9
9
|
export type { ComponentCacheOptions } from './cache-core'
|
|
10
10
|
|
|
11
11
|
const NO_SERVER = 'cache.invalidate needs a running server (call it inside `brust start`)'
|
|
12
|
+
/** Same text as brust-core `PREFIX_WITH_KEY` (the Rust hosts refuse it too). */
|
|
13
|
+
export const PREFIX_WITH_KEY = 'cache.invalidate: `prefix` narrows page entries; `key` names a job entry — call them separately'
|
|
14
|
+
|
|
15
|
+
/** F493: `prefix` is a string and never comes with `key`. Throws a TypeError otherwise. */
|
|
16
|
+
export function checkInvalidateArgs(args: InvalidateArgs): void {
|
|
17
|
+
const prefix = (args as { prefix?: unknown } | null | undefined)?.prefix
|
|
18
|
+
if (prefix === undefined) return
|
|
19
|
+
if (typeof prefix !== 'string') throw new TypeError(`cache.invalidate: \`prefix\` must be a string (got ${prefix === null ? 'null' : typeof prefix})`)
|
|
20
|
+
if (args.key !== undefined) throw new TypeError(PREFIX_WITH_KEY)
|
|
21
|
+
}
|
|
12
22
|
|
|
13
23
|
function invalidate(args: InvalidateArgs): InvalidateResult {
|
|
24
|
+
checkInvalidateArgs(args)
|
|
14
25
|
let r: InvalidateResult
|
|
15
26
|
try {
|
|
16
27
|
r = cacheInvalidate(args)
|
package/src/cf.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Same names as `index.ts` minus what needs a host process: no `run`, no `loadConfig`. `cache.invalidate` goes through
|
|
3
3
|
// the CF bundle's napi stub to the runtime the wasm glue installed at start (`server-handlers` `install`).
|
|
4
4
|
export { buildTime } from './build-time'
|
|
5
|
+
|
|
6
|
+
/** F495 `[server] base_path` (see index.ts). The Cloudflare build bakes the manifest's value into the Worker bundle (`process.env.BRUST_BASE_PATH` is a `define`), `''` at the root. */
|
|
7
|
+
export const basePath: string = typeof process === 'undefined' ? '' : (process.env.BRUST_BASE_PATH ?? '')
|
|
5
8
|
export { navigate } from './navigate'
|
|
6
9
|
export type { NavErrorDetail, NavLoadDetail, NavigateOptions, NavSuccessDetail, NavTrigger } from './navigate'
|
|
7
10
|
export { cache } from './cache'
|
package/src/config.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// env (BRUST_ADDR / BRUST_PORT / BRUST_WORKERS / BRUST_RENDER_SLOTS / BRUST_DRAIN_TIMEOUT_MS /
|
|
3
3
|
// BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES)
|
|
4
4
|
// > CLI flags (`brust start --port/--workers`) and `run({ config })`
|
|
5
|
-
// > brust.toml ([server] address / port / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
|
|
5
|
+
// > brust.toml ([server] address / port / base_path / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
|
|
6
6
|
// > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000, 262144, 8388608).
|
|
7
7
|
// L16 R16.1: `actionsBaseUrl` follows env (BRUST_ACTIONS_BASE_URL) > run({ config }) > toml `[actions] base_url`.
|
|
8
8
|
// `cors` has no env layer; `run({ config: { cors } })` replaces a toml `[cors]` as a whole (never merged), and
|
|
@@ -43,6 +43,10 @@ export interface BrustConfig {
|
|
|
43
43
|
maxLoaderResponseBytes: number
|
|
44
44
|
/** `run({ config: { cors } })` over brust.toml `[cors]` (a whole-object replace); absent = CORS off. No env layer. */
|
|
45
45
|
cors?: CorsConfig
|
|
46
|
+
/** F495 `[server] base_path` / `run({ config: { basePath } })`: the path the app is mounted under on a shared origin (`/portal`; `''` = the
|
|
47
|
+
* root), absent when nothing set it. A build setting too (`brust build` bakes it into the manifest): `brust start` refuses a manifest
|
|
48
|
+
* built for another one, and with none set serves the one the manifest names. */
|
|
49
|
+
basePath?: string
|
|
46
50
|
/** L16 R16.1: the origin the browser `client()` calls (page script `__BRUST_ACTION_BASE__`); absent = same origin. */
|
|
47
51
|
actionsBaseUrl?: string
|
|
48
52
|
/** L16 R16.2: the client's default `RequestInit.credentials` (`__BRUST_ACTION_CREDENTIALS__`); absent = `same-origin`. */
|
|
@@ -52,6 +56,19 @@ export interface BrustConfig {
|
|
|
52
56
|
/** What `run({ config })` / `loadConfig`'s `cli` accepts: any setting, and `cors: null` to turn a toml `[cors]` off. */
|
|
53
57
|
export type ConfigOverrides = Omit<Partial<BrustConfig>, 'cors'> & { cors?: (Omit<CorsConfig, 'credentials'> & { credentials?: boolean }) | null }
|
|
54
58
|
|
|
59
|
+
/** F495: `[server] base_path`, validated: `/seg[/seg…]`, no trailing `/`, each segment `[A-Za-z0-9._~-]+` (not `.`/`..`), the
|
|
60
|
+
* same alphabet as the actions prefix, so it needs no percent-encoding anywhere it is printed. `""` and `"/"` mean the
|
|
61
|
+
* root (`""`). Returns the value, or the reason it is refused. */
|
|
62
|
+
export function normalizeBasePath(raw: unknown): { ok: true; value: string } | { ok: false; why: string } {
|
|
63
|
+
if (typeof raw !== 'string') return { ok: false, why: `must be a string (got ${JSON.stringify(raw)})` }
|
|
64
|
+
if (raw === '' || raw === '/') return { ok: true, value: '' }
|
|
65
|
+
if (!raw.startsWith('/')) return { ok: false, why: `must start with '/' (got ${JSON.stringify(raw)})` }
|
|
66
|
+
if (raw.endsWith('/')) return { ok: false, why: `must not end with '/' (got ${JSON.stringify(raw)})` }
|
|
67
|
+
if (raw.slice(1).split('/').some((seg) => !/^[A-Za-z0-9._~-]+$/.test(seg) || seg === '.' || seg === '..'))
|
|
68
|
+
return { ok: false, why: `segments must be [A-Za-z0-9._~-]+ and not '.'/'..' (got ${JSON.stringify(raw)})` }
|
|
69
|
+
return { ok: true, value: raw }
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
export class BrustConfigError extends Error {
|
|
56
73
|
override name = 'BrustConfigError'
|
|
57
74
|
constructor(
|
|
@@ -112,6 +129,7 @@ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrid
|
|
|
112
129
|
maxLoaderResponseBytes: env.maxLoaderResponseBytes ?? cli.maxLoaderResponseBytes ?? fromToml.maxLoaderResponseBytes ?? DEFAULTS.maxLoaderResponseBytes,
|
|
113
130
|
ioThreads: env.ioThreads ?? cli.ioThreads,
|
|
114
131
|
...(cors ? { cors } : {}),
|
|
132
|
+
...pick('basePath', cli.basePath ?? fromToml.basePath),
|
|
115
133
|
...pick('actionsBaseUrl', env.actionsBaseUrl ?? cli.actionsBaseUrl ?? fromToml.actionsBaseUrl),
|
|
116
134
|
...pick('actionsCredentials', cli.actionsCredentials ?? fromToml.actionsCredentials),
|
|
117
135
|
}
|
|
@@ -150,6 +168,11 @@ function checkCli(cli: ConfigOverrides): void {
|
|
|
150
168
|
check('ioThreads', cli.ioThreads, 1, MAX_IO_THREADS)
|
|
151
169
|
check('slotBytes', cli.slotBytes, MIN_SLOT_BYTES, MAX_SLOT_BYTES)
|
|
152
170
|
check('maxLoaderResponseBytes', cli.maxLoaderResponseBytes, MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES)
|
|
171
|
+
if (cli.basePath !== undefined) {
|
|
172
|
+
const r = normalizeBasePath(cli.basePath)
|
|
173
|
+
if (!r.ok) throw new BrustConfigError(`config.basePath ${r.why}`, null)
|
|
174
|
+
cli.basePath = r.value
|
|
175
|
+
}
|
|
153
176
|
if (cli.actionsBaseUrl !== undefined) {
|
|
154
177
|
const why = baseUrlError(cli.actionsBaseUrl)
|
|
155
178
|
if (why) throw new BrustConfigError(`config.actionsBaseUrl ${why}`, null)
|
|
@@ -176,6 +199,11 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
|
|
|
176
199
|
if ('server' in root) {
|
|
177
200
|
const server = table(root.server, '[server]', file)
|
|
178
201
|
const { port, address } = server
|
|
202
|
+
if (server.base_path !== undefined) {
|
|
203
|
+
const r = normalizeBasePath(server.base_path)
|
|
204
|
+
if (!r.ok) throw new BrustConfigError(`${file}: server.base_path ${r.why}`, file)
|
|
205
|
+
out.basePath = r.value
|
|
206
|
+
}
|
|
179
207
|
const cap = tomlInt(server.max_loader_response_bytes, 'server.max_loader_response_bytes', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, file)
|
|
180
208
|
if (cap !== undefined) out.maxLoaderResponseBytes = cap
|
|
181
209
|
if (port !== undefined) {
|
package/src/dev/bun.ts
CHANGED
|
@@ -77,9 +77,9 @@ function pumpLines(stream: ReadableStream<Uint8Array> | null | undefined, onLine
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** L19/L21 dev swap: the built stylesheets of the old and the new generation, paired by source stem
|
|
80
|
-
* (`<stem>-<hex10>.css`), as
|
|
80
|
+
* (`<stem>-<hex10>.css`), as `<base>/_brust/client/<old>` → `<base>/_brust/client/<new>`. `null` when the pairing is not
|
|
81
81
|
* one-to-one (a stylesheet was added or removed, or two sources share a stem): the page must reload. */
|
|
82
|
-
export function cssSwapMap(oldDir: string, newDir: string): Record<string, string> | null {
|
|
82
|
+
export function cssSwapMap(oldDir: string, newDir: string, base = ''): Record<string, string> | null {
|
|
83
83
|
const byStem = (dir: string) => {
|
|
84
84
|
const m = new Map<string, string>()
|
|
85
85
|
let files: string[] = []
|
|
@@ -101,7 +101,7 @@ export function cssSwapMap(oldDir: string, newDir: string): Record<string, strin
|
|
|
101
101
|
for (const [stem, f] of a) {
|
|
102
102
|
const g = b.get(stem)
|
|
103
103
|
if (g === undefined) return null
|
|
104
|
-
if (g !== f) map[
|
|
104
|
+
if (g !== f) map[`${base}/_brust/client/${f}`] = `${base}/_brust/client/${g}`
|
|
105
105
|
}
|
|
106
106
|
return map
|
|
107
107
|
}
|
|
@@ -123,6 +123,8 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
123
123
|
const mode = tuiMode(out, env)
|
|
124
124
|
const tui = new Tui(out, mode.interactive, mode.color)
|
|
125
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 ?? ''
|
|
126
128
|
const workers = o.workers ?? 1
|
|
127
129
|
const devRoot = join(appRoot, '.brust', 'dev')
|
|
128
130
|
rmSync(devRoot, { recursive: true, force: true })
|
|
@@ -165,7 +167,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
const proxy: DevProxy = startProxy(
|
|
168
|
-
{ hostname: cfg.host, port: cfg.port, token: devToken },
|
|
170
|
+
{ hostname: cfg.host, port: cfg.port, token: devToken, basePath: base },
|
|
169
171
|
{
|
|
170
172
|
gen: () => gen,
|
|
171
173
|
state: () => state,
|
|
@@ -175,7 +177,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
175
177
|
actionsPrefix: () => prefixOf(active),
|
|
176
178
|
onRequest(r) {
|
|
177
179
|
tui.log(`${r.method} ${r.path} ${r.status} ${r.ms}ms${r.cache ? ` ${r.cache}` : ''}`)
|
|
178
|
-
if (!r.path.startsWith(
|
|
180
|
+
if (!r.path.startsWith(`${base}/_brust/`)) {
|
|
179
181
|
const rendered = (r.trace as { rendered?: string[] } | null)?.rendered
|
|
180
182
|
last = { method: r.method, path: r.path, status: r.status, ...(rendered ? { tiers: tierCounts(rendered, devMeta() as Parameters<typeof tierCounts>[1]) } : {}) }
|
|
181
183
|
publish()
|
|
@@ -303,7 +305,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
303
305
|
state = 'ready'
|
|
304
306
|
diag = null
|
|
305
307
|
// A stylesheet-only edit (L19/L21): the pages keep their DOM and swap their brust CSS links.
|
|
306
|
-
const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir) : null
|
|
308
|
+
const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir, base) : null
|
|
307
309
|
if (swap) {
|
|
308
310
|
proxy.broadcast({ type: 'css-swap', gen, map: swap })
|
|
309
311
|
tui.log(`[brust] css rebuilt (${Object.keys(swap).length} stylesheet${Object.keys(swap).length === 1 ? '' : 's'} swapped, no reload)`)
|
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/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/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
|
@@ -35,6 +35,8 @@ 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
|
|
38
40
|
/** F165: `false` = no route can call a worker; absent (older manifests) = the pool is needed. */
|
|
39
41
|
needs_workers?: boolean
|
|
40
42
|
}
|
|
@@ -81,6 +83,15 @@ function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptio
|
|
|
81
83
|
return { ...(prefix ? { prefix } : {}), ...(cfg.actionsBaseUrl !== undefined ? { baseUrl: cfg.actionsBaseUrl } : {}), ...(credentials ? { credentials } : {}) }
|
|
82
84
|
}
|
|
83
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
|
+
|
|
84
95
|
/** Every worker's response buffer, kept reachable until the process exits (see run()). */
|
|
85
96
|
const workerBuffers: SharedArrayBuffer[] = []
|
|
86
97
|
|
|
@@ -111,6 +122,10 @@ export async function run(opts: RunOptions = {}): Promise<void> {
|
|
|
111
122
|
fail((e as Error).message)
|
|
112
123
|
}
|
|
113
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 ?? ''
|
|
114
129
|
if (m.actions_remote && cfg.actionsBaseUrl === undefined)
|
|
115
130
|
fail('this build has no local actions app: set [actions] base_url (or BRUST_ACTIONS_BASE_URL) to the deployment that serves them')
|
|
116
131
|
const version: string = (await Bun.file(join(import.meta.dir, '../package.json')).json()).version
|