@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.
@@ -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 tagSeq = new Map<string, number>()
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 t of args.tags ?? []) raise(tagSeq, t, s)
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
- const stale = (m: Map<string, number>, k: string) => (m.get(k) ?? 0) > s
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 tags = new Set(args.tags ?? [])
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 `/_brust/client/<old>` → `/_brust/client/<new>`. `null` when the pairing is not
80
+ * (`<stem>-<hex10>.css`), as `<base>/_brust/client/<old>` → `<base>/_brust/client/<new>`. `null` when the pairing is not
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[`/_brust/client/${f}`] = `/_brust/client/${g}`
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('/_brust/')) {
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
- const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/_brust/dev/ws')
81
+ // F495: the socket lives next to this script (<base>/_brust/dev/ws), wherever the app is mounted.
82
+ const wsUrl = new URL('ws', import.meta.url)
83
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
84
+ const ws = new WebSocket(wsUrl)
82
85
  ws.onopen = () => {
83
86
  delay = 150
84
87
  }
@@ -109,17 +109,19 @@ class BrustDevtools extends HTMLElement {
109
109
  }
110
110
 
111
111
  private async loadMeta(): Promise<void> {
112
- try { this.meta = (await (await fetch('/_brust/dev/meta')).json()) as DevMeta } catch { this.meta = null }
112
+ try { this.meta = (await (await fetch(new URL('meta', import.meta.url))).json()) as DevMeta } catch { this.meta = null }
113
113
  this.paintIndicator()
114
114
  if (!this.panel.hidden) this.render()
115
115
  }
116
116
 
117
117
  private async loadEntries(): Promise<void> {
118
- try { this.entries = ((await (await fetch('/_brust/dev/traces')).json()) as { traces: Entry[] }).traces } catch { this.entries = [] }
118
+ try { this.entries = ((await (await fetch(new URL('traces', import.meta.url))).json()) as { traces: Entry[] }).traces } catch { this.entries = [] }
119
119
  }
120
120
 
121
121
  private connect(): void {
122
- const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_brust/dev/ws`)
122
+ const wsUrl = new URL('ws', import.meta.url)
123
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
124
+ const ws = new WebSocket(wsUrl)
123
125
  ws.onmessage = (e) => {
124
126
  const m = JSON.parse(e.data as string) as { type: string; gen?: number; state?: BuildState['state']; diag?: BuildState['diag'] }
125
127
  if (m.type === 'hello') this.build = { state: m.state ?? 'ready', gen: m.gen ?? 0, diag: m.diag ?? null }
package/src/dev/proxy.ts CHANGED
@@ -61,13 +61,15 @@ export const WS_PATH = '/_brust/dev/ws'
61
61
  export const DEVTOOLS_PATH = '/_brust/dev/devtools.js'
62
62
  export const TRACES_PATH = '/_brust/dev/traces'
63
63
  export const META_PATH = '/_brust/dev/meta'
64
- const scriptTag = (gen: number) => `<script type="module" src="${CLIENT_PATH}?g=${gen}"></script><script type="module" src="${DEVTOOLS_PATH}"></script>`
64
+ /** F495: the dev channel's URLs for an app mounted under `base` (`''` = the root): the page's scripts and the WebSocket connect under it. */
65
+ export const devPaths = (base = '') => ({ client: `${base}${CLIENT_PATH}`, ws: `${base}${WS_PATH}`, devtools: `${base}${DEVTOOLS_PATH}`, traces: `${base}${TRACES_PATH}`, meta: `${base}${META_PATH}` })
66
+ const scriptTag = (gen: number, base = '') => `<script type="module" src="${devPaths(base).client}?g=${gen}"></script><script type="module" src="${devPaths(base).devtools}"></script>`
65
67
 
66
68
  const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] as string)
67
69
 
68
70
  /** `html` with the dev client script before `</head>` (else at the end). */
69
- export function inject(html: string, gen: number, trace: unknown | null = null): string {
70
- const tag = scriptTag(gen) + (trace ? traceTag(trace) : '')
71
+ export function inject(html: string, gen: number, trace: unknown | null = null, base = ''): string {
72
+ const tag = scriptTag(gen, base) + (trace ? traceTag(trace) : '')
71
73
  const i = html.search(/<\/head\s*>/i)
72
74
  return i >= 0 ? html.slice(0, i) + tag + html.slice(i) : html + tag
73
75
  }
@@ -93,8 +95,8 @@ export function decodeTrace(h: string | null): unknown | null {
93
95
  }
94
96
 
95
97
  /** A small HTML page (so even a plain-text 5xx or "no child yet" carries the client script). */
96
- function htmlPage(status: number, title: string, body: string, gen: number, trace: unknown | null = null): Response {
97
- const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>${scriptTag(gen)}${trace ? traceTag(trace) : ''}</head><body style="font:14px ui-monospace,monospace;padding:24px"><h1>${esc(title)}</h1><pre style="white-space:pre-wrap">${esc(body)}</pre></body></html>`
98
+ function htmlPage(status: number, title: string, body: string, gen: number, trace: unknown | null = null, base = ''): Response {
99
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>${scriptTag(gen, base)}${trace ? traceTag(trace) : ''}</head><body style="font:14px ui-monospace,monospace;padding:24px"><h1>${esc(title)}</h1><pre style="white-space:pre-wrap">${esc(body)}</pre></body></html>`
98
100
  return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
99
101
  }
100
102
 
@@ -136,7 +138,9 @@ function devtoolsBundle(): Promise<string> {
136
138
  /** The trace a proxied response carried (read by `onRequest`; the header itself never reaches the browser). */
137
139
  const traceOf = new WeakMap<Response, unknown>()
138
140
 
139
- export function startProxy(o: { hostname: string; port: number; allowedHosts?: string; token: string }, view: DevView): DevProxy {
141
+ export function startProxy(o: { hostname: string; port: number; allowedHosts?: string; token: string; basePath?: string }, view: DevView): DevProxy {
142
+ const base = o.basePath ?? ''
143
+ const P = devPaths(base)
140
144
  const traces: TraceEntry[] = []
141
145
  let seq = 0
142
146
  const allowed = allowedHosts(o.hostname, o.allowedHosts ?? process.env.BRUST_DEV_ALLOWED_HOSTS)
@@ -155,7 +159,7 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
155
159
  const port = view.upstream()
156
160
  if (port === null) {
157
161
  const d = view.diag()
158
- return htmlPage(503, d ? `brust dev: ${d.rule}` : 'brust dev: building…', d?.raw ?? 'The first build is running. This page reloads when it is ready.', gen)
162
+ return htmlPage(503, d ? `brust dev: ${d.rule}` : 'brust dev: building…', d?.raw ?? 'The first build is running. This page reloads when it is ready.', gen, null, base)
159
163
  }
160
164
  const headers = new Headers(req.headers)
161
165
  // The token only this proxy knows: whatever a browser sent under that name is overwritten.
@@ -174,13 +178,13 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
174
178
  // biome-ignore lint/suspicious/noExplicitAny: Bun's fetch option
175
179
  } as any)
176
180
  } catch (e) {
177
- return htmlPage(502, 'brust dev: the server is not reachable', String((e as Error).message ?? e), gen)
181
+ return htmlPage(502, 'brust dev: the server is not reachable', String((e as Error).message ?? e), gen, null, base)
178
182
  }
179
183
  const out = new Headers(res.headers)
180
184
  // The trace never reaches the browser as a header: it travels in the page (`#__brust_trace`) and `/_brust/dev/traces`.
181
185
  const trace = decodeTrace(res.headers.get('x-brust-dev'))
182
186
  out.delete('x-brust-dev')
183
- const isAction = req.method !== 'GET' && req.method !== 'HEAD' ? true : url.pathname.startsWith('/_brust/action')
187
+ const isAction = req.method !== 'GET' && req.method !== 'HEAD' ? true : url.pathname.startsWith(`${base}/_brust/action`)
184
188
  if (trace || isAction) {
185
189
  traces.push({ id: ++seq, at: Date.now(), kind: trace ? 'page' : 'action', method: req.method, path: url.pathname + url.search, status: res.status, ms: Math.round(performance.now() - t0), trace })
186
190
  if (traces.length > MAX_TRACES) traces.shift()
@@ -195,9 +199,9 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
195
199
  if (req.method === 'HEAD' || res.status === 204 || res.status === 304) return new Response(null, { status: res.status, headers: out })
196
200
  if (/text\/html/i.test(type)) {
197
201
  out.set('content-type', type)
198
- return tag(new Response(inject(await res.text(), gen, trace), { status: res.status, headers: out }))
202
+ return tag(new Response(inject(await res.text(), gen, trace, base), { status: res.status, headers: out }))
199
203
  }
200
- if (doc && res.status >= 500) return tag(htmlPage(res.status, `${res.status} ${res.statusText || 'server error'}`, await res.text(), gen, trace))
204
+ if (doc && res.status >= 500) return tag(htmlPage(res.status, `${res.status} ${res.statusText || 'server error'}`, await res.text(), gen, trace, base))
201
205
  return tag(new Response(res.body, { status: res.status, headers: out }))
202
206
  }
203
207
 
@@ -216,17 +220,17 @@ export function startProxy(o: { hostname: string; port: number; allowedHosts?: s
216
220
  // DNS rebinding: a page on evil.example whose name resolves to 127.0.0.1 would otherwise read the app and the
217
221
  // source paths in build errors. Only the dev server's own names are answered.
218
222
  if (!allowed.has(hostName(req.headers.get('host') ?? ''))) return new Response('brust dev: unexpected Host header (set BRUST_DEV_ALLOWED_HOSTS to allow it)', { status: 421 })
219
- if (url.pathname === WS_PATH && !sameOriginOrNone(req.headers.get('origin'), req.headers.get('host'))) return new Response('brust dev: cross-origin WebSocket refused', { status: 403 })
220
- if (url.pathname === WS_PATH) return srv.upgrade(req, { data: undefined }) ? undefined : new Response('websocket expected', { status: 426 })
221
- if (url.pathname === TRACES_PATH) return Response.json({ traces }, { headers: { 'cache-control': 'no-store' } })
222
- if (url.pathname === META_PATH) return Response.json(view.meta() ?? {}, { headers: { 'cache-control': 'no-store' } })
223
- if (url.pathname === DEVTOOLS_PATH) {
223
+ if (url.pathname === P.ws && !sameOriginOrNone(req.headers.get('origin'), req.headers.get('host'))) return new Response('brust dev: cross-origin WebSocket refused', { status: 403 })
224
+ if (url.pathname === P.ws) return srv.upgrade(req, { data: undefined }) ? undefined : new Response('websocket expected', { status: 426 })
225
+ if (url.pathname === P.traces) return Response.json({ traces }, { headers: { 'cache-control': 'no-store' } })
226
+ if (url.pathname === P.meta) return Response.json(view.meta() ?? {}, { headers: { 'cache-control': 'no-store' } })
227
+ if (url.pathname === P.devtools) {
224
228
  return devtoolsBundle().then(
225
229
  (js) => new Response(js, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } }),
226
230
  (e: Error) => new Response(e.message, { status: 500 }),
227
231
  )
228
232
  }
229
- if (url.pathname === CLIENT_PATH) return new Response(DEV_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } })
233
+ if (url.pathname === P.client) return new Response(DEV_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } })
230
234
  const t0 = performance.now()
231
235
  const res = await forward(req)
232
236
  view.onRequest({ method: req.method, path: url.pathname + url.search, status: res.status, ms: Math.round(performance.now() - t0), cache: res.headers.get('x-brust-cache'), trace: traceOf.get(res) ?? null })
package/src/index.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  /// <reference path="./assets.d.ts" />
3
3
  // `@brust/core`: the app-facing API.
4
4
  export { buildTime } from './build-time'
5
+
6
+ /** F495 `[server] base_path`: the path the app is mounted under (`/portal`), `''` at the root. Read-only: build URLs with it
7
+ * (`${basePath}/_brust/…` is brust's own; every URL you write is already the real one). Set from the manifest (`brust start`) or the
8
+ * config (`brust build`) before the app's modules load. */
9
+ export const basePath: string = process.env.BRUST_BASE_PATH ?? ''
5
10
  export { clearNavigationCache, navigate } from './navigate'
6
11
  export type { NavErrorDetail, NavLoadDetail, NavigateOptions, NavSuccessDetail, NavTrigger } from './navigate'
7
12
  export { cache } from './cache'
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