@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.
@@ -28,6 +28,7 @@ export async function prepareStatic(
28
28
  reads: ExprReads,
29
29
  log: (s: string) => void,
30
30
  cache?: Parameters<typeof assertNoCacheBindings>[0],
31
+ base = '',
31
32
  ): Promise<StaticPlan> {
32
33
  // L12: `[actions]` makes the output hybrid (static pages + a Pages Worker for the action prefix).
33
34
  if (actions) assertNoCacheBindings(cache)
@@ -65,7 +66,7 @@ export async function prepareStatic(
65
66
  throw new BuildError('ssg-params', `route ${leaf.id} (${leaf.pattern}) ssg.params() threw: ${(e as Error).message ?? String(e)}`)
66
67
  }
67
68
  }
68
- return planStatic(leaves, params)
69
+ return planStatic(leaves, params, base)
69
70
  }
70
71
 
71
72
  export interface StaticReport {
@@ -167,7 +168,7 @@ export async function writeStatic(
167
168
  const dir = join(dist, 'static')
168
169
  rmSync(dir, { recursive: true, force: true })
169
170
  const assetsStage = join(dist, '.ssg-assets')
170
- const assets = layoutAssets(dist, assetsStage, o.version)
171
+ const assets = layoutAssets(dist, assetsStage, o.version, (JSON.parse(readFileSync(join(dist, 'manifest.json'), 'utf8')) as { base_path?: string }).base_path ?? '')
171
172
  renameSync(join(assetsStage, 'assets'), dir)
172
173
  rmSync(assetsStage, { recursive: true, force: true })
173
174
  const warnings = [...assets.warnings]
@@ -176,6 +177,10 @@ export async function writeStatic(
176
177
  o.log(w)
177
178
  }
178
179
  for (const w of assets.warnings) o.log(w)
180
+ // F506: a static host serves the files as it likes; brust's trailing-slash policy applies to `brust start` only.
181
+ const ts = (JSON.parse(readFileSync(join(dist, 'manifest.json'), 'utf8')) as { trailing_slash?: string }).trailing_slash
182
+ if (ts !== undefined && ts !== 'strict')
183
+ warn(`warning trailing-slash-static server.trailing_slash = ${JSON.stringify(ts)} does not apply to --target static: the static host decides how a path ending in '/' is served`)
179
184
 
180
185
  // R9.4: [cache] does not apply to static files, so the render must not need its adapter (a Redis
181
186
  // that is not running at build time): the render reads a manifest without `cache`, and the dist
@@ -68,8 +68,14 @@ const addCommand = (v: string) => `bun add -d --exact ${TAILWIND_PACKAGES.map((p
68
68
  /** The Tailwind plugin of the CSS build, resolved from the app. Missing packages → `tailwind-missing` with the
69
69
  * exact install command; versions that differ → one warning through `log` with the command that aligns them.
70
70
  * `uses(file)` says whether a stylesheet needs the compiler (it, or a relative `@import` of it, uses Tailwind);
71
- * other stylesheets stay Bun's. */
72
- export async function loadTailwind(appRoot: string, usedBy: string, uses: (file: string) => boolean, log?: (s: string) => void): Promise<BunPlugin> {
71
+ * other stylesheets stay Bun's. `ownOutput`: absolute directories of brust's own output that no scan may read (F501, F505). */
72
+ export async function loadTailwind(
73
+ appRoot: string,
74
+ usedBy: string,
75
+ uses: (file: string) => boolean,
76
+ log?: (s: string) => void,
77
+ ownOutput: string[] = [],
78
+ ): Promise<BunPlugin> {
73
79
  const v = tailwindVersions(appRoot)
74
80
  const missing = TAILWIND_PACKAGES.filter((p) => versionFrom(p, appRoot) === undefined)
75
81
  if (missing.length > 0)
@@ -105,7 +111,13 @@ export async function loadTailwind(appRoot: string, usedBy: string, uses: (file:
105
111
  const compiler = await node.compile(css, { base: dirname(a.path), from: a.path, shouldRewriteUrls: true, onDependency: () => {} })
106
112
  const root: SourceEntry[] =
107
113
  compiler.root === 'none' ? [] : compiler.root === null ? [{ base: appRoot, pattern: '**/*', negated: false }] : [{ ...compiler.root, negated: false }]
108
- const scanner = new oxide.Scanner({ sources: [...root, ...compiler.sources] })
114
+ // F501: brust's own output (the dist dirs, `.brust`) is never a source, whatever the ignore files say and whatever
115
+ // the stylesheet's `source(...)` and `@source` lines add: last, so a stale build can never feed the next one.
116
+ // F505: each directory is the BASE of its own negation (verified: oxide honours a negated entry whose base differs
117
+ // from the scanned root, a base outside the root, a base that does not exist yet, and a name with glob characters
118
+ // such as `dist[1]`, none of which a pattern relative to the app root would survive).
119
+ const own: SourceEntry[] = ownOutput.map((dir) => ({ base: dir, pattern: '**/*', negated: true }))
120
+ const scanner = new oxide.Scanner({ sources: [...root, ...compiler.sources, ...own] })
109
121
  let out = compiler.build(scanner.scan())
110
122
  // `@reference "./app.css"` pulls in Tailwind's license banner too; only the file that imports Tailwind ships it
111
123
  // (one banner per page, as before)
@@ -3,7 +3,7 @@
3
3
  // this module reads out of the app's own `tsconfig.json` / `jsconfig.json`. v1 reads that one file: no `extends`, no
4
4
  // `references` (a named fallback below); exact and single-`*` patterns only. The bundler resolves the same specifiers
5
5
  // natively; `checkAliasAgreement` fails the build if the two ever disagree.
6
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
6
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
7
7
  import { dirname, isAbsolute, join, normalize, relative, sep } from 'node:path'
8
8
  import { BuildError } from './errors'
9
9
 
@@ -26,10 +26,21 @@ export function readPathAliases(appRoot: string): { aliases: PathAlias[]; notes:
26
26
  }
27
27
  const notes: string[] = []
28
28
  const paths = json?.compilerOptions?.paths
29
- if (json?.extends !== undefined || json?.references !== undefined)
29
+ const name = relative(appRoot, file)
30
+ const hasPaths = !!paths && typeof paths === 'object'
31
+ // TS semantics: a local `compilerOptions.paths` replaces any inherited `paths`, so beside a local `paths` an `extends`
32
+ // can only matter through the `baseUrl` it supplies (F497). Without local `paths` the aliases live elsewhere.
33
+ if (hasPaths ? json?.references !== undefined : json?.extends !== undefined || json?.references !== undefined)
30
34
  notes.push(
31
- `${relative(appRoot, file)} uses \`${json.extends !== undefined ? 'extends' : 'references'}\`, which brust does not follow: \`paths\` aliases written in another tsconfig stay package imports (put \`paths\` in ${relative(appRoot, file)})`,
35
+ `${name} uses \`${json.extends !== undefined && !hasPaths ? 'extends' : 'references'}\`, which brust does not follow: \`paths\` aliases written in another tsconfig stay package imports (put \`paths\` in ${name})`,
32
36
  )
37
+ if (hasPaths && json?.extends !== undefined && typeof json.compilerOptions?.baseUrl !== 'string') {
38
+ const from = inheritedBaseUrlFile(file, json.extends)
39
+ if (from)
40
+ notes.push(
41
+ `\`baseUrl\` comes from ${relative(appRoot, from).split(sep).join('/')}, which brust does not follow: aliases resolve from ${name}'s directory (set \`baseUrl\` in ${name})`,
42
+ )
43
+ }
33
44
  if (!paths || typeof paths !== 'object') return { aliases: [], notes }
34
45
  const base = typeof json.compilerOptions?.baseUrl === 'string' ? json.compilerOptions.baseUrl : '.'
35
46
  const aliases: PathAlias[] = []
@@ -52,6 +63,23 @@ export function readPathAliases(appRoot: string): { aliases: PathAlias[]; notes:
52
63
  return { aliases, notes }
53
64
  }
54
65
 
66
+ /** The extended tsconfig that sets `baseUrl`, when `extends` names a relative file (one level, JSONC; a package specifier,
67
+ * an unreadable file or a file without `baseUrl` is none). */
68
+ function inheritedBaseUrlFile(file: string, extendsValue: unknown): string | null {
69
+ const refs = Array.isArray(extendsValue) ? extendsValue : [extendsValue]
70
+ for (const ref of refs) {
71
+ if (typeof ref !== 'string' || !(ref.startsWith('./') || ref.startsWith('../'))) continue
72
+ const base = join(dirname(file), ref)
73
+ const target = [base, `${base}.json`].find((f) => existsSync(f) && statSync(f).isFile())
74
+ if (!target) continue
75
+ try {
76
+ const ext = Bun.JSONC.parse(readFileSync(target, 'utf8')) as { compilerOptions?: { baseUrl?: unknown } } | null
77
+ if (typeof ext?.compilerOptions?.baseUrl === 'string') return target
78
+ } catch {}
79
+ }
80
+ return null
81
+ }
82
+
55
83
  /** The compiler's answer for every alias import must be the file the bundler resolves the same specifier to. */
56
84
  export function checkAliasAgreement(appRoot: string, imports: { from: string; spec: string; target: string }[]): void {
57
85
  for (const { from, spec, target } of imports) {
@@ -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
@@ -1,8 +1,8 @@
1
1
  // Runtime configuration (spec §8). Precedence, high → low:
2
2
  // env (BRUST_ADDR / BRUST_PORT / BRUST_WORKERS / BRUST_RENDER_SLOTS / BRUST_DRAIN_TIMEOUT_MS /
3
- // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES)
3
+ // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES / BRUST_STATS)
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 / stats / stats_token / 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
@@ -24,6 +24,20 @@ export interface CorsConfig {
24
24
  maxAgeSeconds?: number
25
25
  }
26
26
 
27
+ /** F507 `[server] stats`: who may read `/_brust/cache/stats`. */
28
+ export const STATS_MODES = ['off', 'public', 'token'] as const
29
+ export type StatsMode = (typeof STATS_MODES)[number]
30
+ /** F507 `[server] stats_token`: an env reference only, never the secret itself (like `[cache.redis] url`). */
31
+ const STATS_TOKEN_REF = /^env:[A-Za-z_][A-Za-z0-9_]*$/
32
+
33
+ /** Why `[server] stats` / `stats_token` (or `run({ config })`) are wrong, or `undefined`. The secret never appears in the message. */
34
+ function statsError(mode: unknown, token: unknown, where: string): string | undefined {
35
+ if (mode !== undefined && !STATS_MODES.includes(mode as StatsMode)) return `${where}stats must be one of ${STATS_MODES.join(', ')} (got ${JSON.stringify(mode)})`
36
+ if (token !== undefined && (typeof token !== 'string' || !STATS_TOKEN_REF.test(token)))
37
+ return `${where}stats_token must be "env:NAME" (a literal token is refused: it would sit in a file)`
38
+ return undefined
39
+ }
40
+
27
41
  export interface BrustConfig {
28
42
  host: string
29
43
  port: number
@@ -43,6 +57,19 @@ export interface BrustConfig {
43
57
  maxLoaderResponseBytes: number
44
58
  /** `run({ config: { cors } })` over brust.toml `[cors]` (a whole-object replace); absent = CORS off. No env layer. */
45
59
  cors?: CorsConfig
60
+ /** F495 `[server] base_path` / `run({ config: { basePath } })`: the path the app is mounted under on a shared origin (`/portal`; `''` = the
61
+ * root), absent when nothing set it. A build setting too (`brust build` bakes it into the manifest): `brust start` refuses a manifest
62
+ * built for another one, and with none set serves the one the manifest names. */
63
+ basePath?: string
64
+ /** F506 `[server] trailing_slash` / `run({ config: { trailingSlash } })`: what a page request whose path ends in `/` gets — `strict` (matched
65
+ * as written, the default), `redirect` (GET/HEAD answer 308 to the path without it, query kept) or `ignore` (the slash is dropped before
66
+ * matching). A build setting too (baked into the manifest): `brust start` refuses a manifest built with another one. */
67
+ trailingSlash?: TrailingSlash
68
+ /** F507 `[server] stats` / `run({ config: { stats } })`: who may read `/_brust/cache/stats` (`off` = 404, `public`, `token` = a bearer
69
+ * token). Absent = `off` for `brust start`, `public` under `brust dev`. */
70
+ stats?: StatsMode
71
+ /** F507 `[server] stats_token` / `run({ config: { statsToken } })`: `"env:NAME"`, the variable that holds the bearer token (`stats = "token"` only). */
72
+ statsToken?: string
46
73
  /** L16 R16.1: the origin the browser `client()` calls (page script `__BRUST_ACTION_BASE__`); absent = same origin. */
47
74
  actionsBaseUrl?: string
48
75
  /** L16 R16.2: the client's default `RequestInit.credentials` (`__BRUST_ACTION_CREDENTIALS__`); absent = `same-origin`. */
@@ -52,6 +79,29 @@ export interface BrustConfig {
52
79
  /** What `run({ config })` / `loadConfig`'s `cli` accepts: any setting, and `cors: null` to turn a toml `[cors]` off. */
53
80
  export type ConfigOverrides = Omit<Partial<BrustConfig>, 'cors'> & { cors?: (Omit<CorsConfig, 'credentials'> & { credentials?: boolean }) | null }
54
81
 
82
+ /** F506: the `[server] trailing_slash` values. */
83
+ export const TRAILING_SLASH = ['strict', 'redirect', 'ignore'] as const
84
+ export type TrailingSlash = (typeof TRAILING_SLASH)[number]
85
+
86
+ /** F506: `[server] trailing_slash`, validated; the reason it is refused otherwise. */
87
+ export function checkTrailingSlash(raw: unknown): { ok: true; value: TrailingSlash } | { ok: false; why: string } {
88
+ if (typeof raw === 'string' && (TRAILING_SLASH as readonly string[]).includes(raw)) return { ok: true, value: raw as TrailingSlash }
89
+ return { ok: false, why: `must be one of ${TRAILING_SLASH.map((v) => JSON.stringify(v)).join(', ')} (got ${JSON.stringify(raw)})` }
90
+ }
91
+
92
+ /** F495: `[server] base_path`, validated: `/seg[/seg…]`, no trailing `/`, each segment `[A-Za-z0-9._~-]+` (not `.`/`..`), the
93
+ * same alphabet as the actions prefix, so it needs no percent-encoding anywhere it is printed. `""` and `"/"` mean the
94
+ * root (`""`). Returns the value, or the reason it is refused. */
95
+ export function normalizeBasePath(raw: unknown): { ok: true; value: string } | { ok: false; why: string } {
96
+ if (typeof raw !== 'string') return { ok: false, why: `must be a string (got ${JSON.stringify(raw)})` }
97
+ if (raw === '' || raw === '/') return { ok: true, value: '' }
98
+ if (!raw.startsWith('/')) return { ok: false, why: `must start with '/' (got ${JSON.stringify(raw)})` }
99
+ if (raw.endsWith('/')) return { ok: false, why: `must not end with '/' (got ${JSON.stringify(raw)})` }
100
+ if (raw.slice(1).split('/').some((seg) => !/^[A-Za-z0-9._~-]+$/.test(seg) || seg === '.' || seg === '..'))
101
+ return { ok: false, why: `segments must be [A-Za-z0-9._~-]+ and not '.'/'..' (got ${JSON.stringify(raw)})` }
102
+ return { ok: true, value: raw }
103
+ }
104
+
55
105
  export class BrustConfigError extends Error {
56
106
  override name = 'BrustConfigError'
57
107
  constructor(
@@ -99,6 +149,11 @@ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrid
99
149
  checkCli(cli)
100
150
  // cli (validated, normalised) over toml as a whole object (R10.2); `null` = off.
101
151
  const cors = cli.cors === undefined ? fromToml.cors : cli.cors === null ? undefined : checkCors(cli.cors, 'config.cors', null, false)
152
+ // F507: the two keys belong together, wherever each one came from.
153
+ const stats = env.stats ?? cli.stats ?? fromToml.stats
154
+ const statsToken = cli.statsToken ?? fromToml.statsToken
155
+ if (stats === 'token' && statsToken === undefined) throw new BrustConfigError('stats = "token" needs stats_token = "env:NAME" (the variable that holds the bearer token)', null)
156
+ if (statsToken !== undefined && stats !== 'token') throw new BrustConfigError('stats_token is used only with stats = "token"', null)
102
157
  return {
103
158
  host: env.host ?? cli.host ?? fromToml.host ?? DEFAULTS.host,
104
159
  port: env.port ?? cli.port ?? fromToml.port ?? DEFAULTS.port,
@@ -112,6 +167,10 @@ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrid
112
167
  maxLoaderResponseBytes: env.maxLoaderResponseBytes ?? cli.maxLoaderResponseBytes ?? fromToml.maxLoaderResponseBytes ?? DEFAULTS.maxLoaderResponseBytes,
113
168
  ioThreads: env.ioThreads ?? cli.ioThreads,
114
169
  ...(cors ? { cors } : {}),
170
+ ...pick('basePath', cli.basePath ?? fromToml.basePath),
171
+ ...pick('trailingSlash', cli.trailingSlash ?? fromToml.trailingSlash),
172
+ ...pick('stats', stats),
173
+ ...pick('statsToken', statsToken),
115
174
  ...pick('actionsBaseUrl', env.actionsBaseUrl ?? cli.actionsBaseUrl ?? fromToml.actionsBaseUrl),
116
175
  ...pick('actionsCredentials', cli.actionsCredentials ?? fromToml.actionsCredentials),
117
176
  }
@@ -150,6 +209,17 @@ function checkCli(cli: ConfigOverrides): void {
150
209
  check('ioThreads', cli.ioThreads, 1, MAX_IO_THREADS)
151
210
  check('slotBytes', cli.slotBytes, MIN_SLOT_BYTES, MAX_SLOT_BYTES)
152
211
  check('maxLoaderResponseBytes', cli.maxLoaderResponseBytes, MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES)
212
+ if (cli.basePath !== undefined) {
213
+ const r = normalizeBasePath(cli.basePath)
214
+ if (!r.ok) throw new BrustConfigError(`config.basePath ${r.why}`, null)
215
+ cli.basePath = r.value
216
+ }
217
+ if (cli.trailingSlash !== undefined) {
218
+ const r = checkTrailingSlash(cli.trailingSlash)
219
+ if (!r.ok) throw new BrustConfigError(`config.trailingSlash ${r.why}`, null)
220
+ }
221
+ const badStats = statsError(cli.stats, cli.statsToken, 'config.')
222
+ if (badStats) throw new BrustConfigError(badStats, null)
153
223
  if (cli.actionsBaseUrl !== undefined) {
154
224
  const why = baseUrlError(cli.actionsBaseUrl)
155
225
  if (why) throw new BrustConfigError(`config.actionsBaseUrl ${why}`, null)
@@ -176,6 +246,20 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
176
246
  if ('server' in root) {
177
247
  const server = table(root.server, '[server]', file)
178
248
  const { port, address } = server
249
+ if (server.base_path !== undefined) {
250
+ const r = normalizeBasePath(server.base_path)
251
+ if (!r.ok) throw new BrustConfigError(`${file}: server.base_path ${r.why}`, file)
252
+ out.basePath = r.value
253
+ }
254
+ if (server.trailing_slash !== undefined) {
255
+ const r = checkTrailingSlash(server.trailing_slash)
256
+ if (!r.ok) throw new BrustConfigError(`${file}: server.trailing_slash ${r.why}`, file)
257
+ out.trailingSlash = r.value
258
+ }
259
+ const bad = statsError(server.stats, server.stats_token, `${file}: server.`)
260
+ if (bad) throw new BrustConfigError(bad, file)
261
+ if (server.stats !== undefined) out.stats = server.stats as StatsMode
262
+ if (server.stats_token !== undefined) out.statsToken = server.stats_token as string
179
263
  const cap = tomlInt(server.max_loader_response_bytes, 'server.max_loader_response_bytes', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, file)
180
264
  if (cap !== undefined) out.maxLoaderResponseBytes = cap
181
265
  if (port !== undefined) {
@@ -292,6 +376,11 @@ function fromEnv(): Partial<BrustConfig> {
292
376
  out.slotBytes = envInt('BRUST_SLOT_BYTES', MIN_SLOT_BYTES, MAX_SLOT_BYTES, `an integer in ${MIN_SLOT_BYTES}..${MAX_SLOT_BYTES}`)
293
377
  out.maxLoaderResponseBytes = envInt('BRUST_MAX_LOADER_RESPONSE_BYTES', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, `an integer in ${MIN_LOADER_RESPONSE_BYTES}..${MAX_LOADER_RESPONSE_BYTES}`)
294
378
  out.ioThreads = envInt('BRUST_WORKER_THREADS', 1, MAX_IO_THREADS, `an integer in 1..${MAX_IO_THREADS}`)
379
+ const stats = process.env.BRUST_STATS
380
+ if (stats !== undefined && stats !== '') {
381
+ if (!STATS_MODES.includes(stats as StatsMode)) throw new BrustConfigError(`BRUST_STATS must be one of ${STATS_MODES.join(', ')} (got ${JSON.stringify(stats)})`, null)
382
+ out.stats = stats as StatsMode
383
+ }
295
384
  const base = process.env.BRUST_ACTIONS_BASE_URL
296
385
  if (base !== undefined && base !== '') {
297
386
  const why = baseUrlError(base)
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 }