@brust/core 2.0.0-alpha.33 → 2.0.0-alpha.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,9 @@ export interface ChildRecord {
36
36
  exprs?: Record<string, string>
37
37
  /** F142 R34.43: the list of a `per-row-literal` instance (a constant the compiler read). */
38
38
  rows?: unknown[]
39
+ /** F502: the branch condition (`{truthy}`/`{eq}`/`{not}`/`{all}`/`{any}` over props paths) the server checks per instance before
40
+ * it runs the child's job: a row whose branch does not render the child gets no job. */
41
+ when?: unknown
39
42
  }
40
43
 
41
44
  export interface ComponentRecord {
@@ -97,6 +100,8 @@ export interface ManifestJson {
97
100
  actions: ActionsRecord | null
98
101
  /** L16 R16.3: an actions-only build (no routes, no templates); everything but the prefix is a 404 problem+json. */
99
102
  actions_only?: true
103
+ /** F495 `[server] base_path`: set only for an app mounted under a path. The server answers under it alone and every URL brust owns lies below it. */
104
+ base_path?: string
100
105
  /** L16 R16.4b: a page-only build: no local actions app, `base_url` points at the deployment that has it. */
101
106
  actions_remote?: { prefix: string }
102
107
  /** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
@@ -315,6 +320,7 @@ export function childRecords(c: Compiled, clientOnly: string[] = []): ChildRecor
315
320
  if (inst.literals && Object.keys(inst.literals).length > 0) rec.literals = { ...inst.literals }
316
321
  if (inst.exprs && Object.keys(inst.exprs).length > 0) rec.exprs = { ...inst.exprs }
317
322
  if (literal) rec.rows = inst.rows_literal
323
+ if (inst.when !== undefined && inst.when !== null) rec.when = inst.when
318
324
  return rec
319
325
  })
320
326
  for (const id of clientOnly) out.push({ id, instances: 'static', props: {} })
@@ -0,0 +1,35 @@
1
+ // F485 (G5): the one choke point for path spelling. A path that leaves the filesystem layer (handed to the native
2
+ // compiler, used as a Map/manifest key, matched against config prefixes, written into dist/*.js, dev.json or a wrangler
3
+ // config) is `/`-separated; a path used only for fs calls stays native. Every helper takes the separator as a parameter
4
+ // (default: the platform's), so a unit test can feed `C:\app\pages\X.tsx` on any OS and POSIX output stays byte-identical.
5
+ import { isAbsolute, relative, sep } from 'node:path'
6
+
7
+ /** `p` with the platform separator spelled `/`. A no-op on POSIX (a `\` there is a legal file name character). */
8
+ export function posixOf(p: string, s: string = sep): string {
9
+ return s === '/' ? p : p.split(s).join('/')
10
+ }
11
+
12
+ /** The reverse of {@link posixOf}: a `/`-separated path (the compiler's spelling) as the platform spells it. */
13
+ export function nativeOf(p: string, s: string = sep): string {
14
+ return s === '/' ? p : p.split('/').join(s)
15
+ }
16
+
17
+ /** `relative(base, file)` spelled with `/`. */
18
+ export function relPosix(base: string, file: string, s: string = sep): string {
19
+ return posixOf(relative(base, file), s)
20
+ }
21
+
22
+ /** A `relative()` result that does not lie inside its base: `..`, `../x`, or an absolute path (another drive on Windows). */
23
+ export function escapes(rel: string, s: string = sep): boolean {
24
+ const p = posixOf(rel, s)
25
+ return p === '..' || p.startsWith('../') || isAbsolute(rel) || (s !== '/' && /^[A-Za-z]:/.test(p)) // `c:x` is a legal POSIX dir name
26
+ }
27
+
28
+ /** `[build] server_only` (spec §8.2): is the app-root-relative `rel` inside a listed prefix? Prefixes are `/`-spelled
29
+ * (config.ts normalises them); `rel` is spelled with `s` and normalised here, so a Windows `lib\server\db.ts` matches
30
+ * `lib/server`. A path outside the app root never matches. */
31
+ export function inServerOnlyPrefix(rel: string, prefixes: string[], s: string = sep): boolean {
32
+ if (escapes(rel, s)) return false
33
+ const p = posixOf(rel, s)
34
+ return prefixes.some((pre) => p.startsWith(pre))
35
+ }
@@ -0,0 +1,39 @@
1
+ // F496: the server's router refuses some pattern sets (`/{*rest}` next to `/{slug}`); without this the build passes and
2
+ // the first `brust start` fails. The check is the router's own insertion (`routing/routes.rs` `find_pattern_conflict`,
3
+ // through the addon the build already loads): there is no second matcher here.
4
+ import type { FlatRoute } from '../routes'
5
+ import { BuildError } from './errors'
6
+
7
+ export interface PatternConflict {
8
+ /** Index into the patterns given of the pattern the router refused. */
9
+ index: number
10
+ /** Index of the earlier pattern it conflicts with, when there is one. */
11
+ withIndex?: number | null
12
+ /** The router's refusal, as `brust start` would print it. */
13
+ message: string
14
+ }
15
+
16
+ export type CheckPatterns = (patterns: string[]) => PatternConflict | null
17
+
18
+ /** The addon's `checkRoutePatterns` (crates/brust-napi/src/compile.rs `check_route_patterns`). */
19
+ export async function nativeCheckPatterns(): Promise<CheckPatterns> {
20
+ const { native } = await import('../native')
21
+ const b = native() as unknown as { checkRoutePatterns?: CheckPatterns }
22
+ if (typeof b.checkRoutePatterns !== 'function')
23
+ throw new BuildError('route-conflict', 'the native addon is older than this build (no checkRoutePatterns): rebuild it')
24
+ return b.checkRoutePatterns.bind(b)
25
+ }
26
+
27
+ /** Fails the build when the server's router would refuse the leaves' patterns. Catch-all (`*`) leaves never enter the
28
+ * router, so they are left out, as the server leaves them out. The error names the route ids. */
29
+ export function assertRoutesInstallable(leaves: FlatRoute[], check: CheckPatterns): void {
30
+ const routed = leaves.filter((l) => !l.catchAll)
31
+ const found = check(routed.map((l) => l.pattern))
32
+ if (!found) return
33
+ const at = routed[found.index]
34
+ const partner = found.withIndex != null ? routed[found.withIndex] : undefined
35
+ const who = partner
36
+ ? `routes ${partner.id} (${partner.pattern}) and ${at?.id} (${at?.pattern}) cannot be installed together`
37
+ : `route ${at?.id} (${at?.pattern}) cannot be installed`
38
+ throw new BuildError('route-conflict', `${who}: ${found.message}`)
39
+ }
package/src/build/ssg.ts CHANGED
@@ -77,7 +77,8 @@ function checkValue(leaf: FlatRoute, i: number, name: string, rest: boolean, v:
77
77
  }
78
78
 
79
79
  /** Plans every page. `params` holds each dynamic route's resolved `ssg.params()` by route id. */
80
- export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>): StaticPlan {
80
+ /** F495: under `base` (`[server] base_path`) the not-found probe is a path under it, so the app's `*` route answers it rather than the server's plain 404. */
81
+ export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>, base = ''): StaticPlan {
81
82
  const pages: PlannedPage[] = []
82
83
  const skipped: StaticPlan['skipped'] = []
83
84
  let catchAll: FlatRoute | undefined
@@ -133,7 +134,7 @@ export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>): S
133
134
  seen.set(key, p)
134
135
  }
135
136
  if (seen.has('404.html')) throw new BuildError('ssg-params', `${seen.get('404.html')!.path} would overwrite 404.html`)
136
- if (catchAll) pages.push({ path: NOT_FOUND_PATH, file: '404.html', routeId: catchAll.id, pattern: catchAll.pattern, expect: 404 })
137
+ if (catchAll) pages.push({ path: `${base}${NOT_FOUND_PATH}`, file: '404.html', routeId: catchAll.id, pattern: catchAll.pattern, expect: 404 })
137
138
  return { pages, skipped, defaultNotFound: catchAll === undefined }
138
139
  }
139
140
 
@@ -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]
@@ -0,0 +1,47 @@
1
+ // F485 P8: replacing `dist` by the finished temp dir. On Windows a rename of a directory fails (EPERM / EBUSY /
2
+ // EACCES) while anything holds a handle inside it (a running `brust start`, an indexer, an antivirus scan), so the
3
+ // rename is retried briefly and then fails with a message that says what to stop. On POSIX the first try succeeds.
4
+ import { existsSync, renameSync, rmSync } from 'node:fs'
5
+ import { basename, dirname, join } from 'node:path'
6
+ import { BuildError } from './errors'
7
+
8
+ const BUSY = new Set(['EPERM', 'EBUSY', 'EACCES'])
9
+ export const RENAME_TRIES = 5
10
+ export const RENAME_WAIT_MS = 100
11
+
12
+ export interface SwapOps {
13
+ rename: (from: string, to: string) => void
14
+ sleep: (ms: number) => void
15
+ }
16
+
17
+ const real: SwapOps = {
18
+ rename: renameSync,
19
+ sleep: (ms) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
20
+ }
21
+
22
+ /** `renameSync` that retries a held-handle failure `RENAME_TRIES` times, `RENAME_WAIT_MS` apart. */
23
+ export function renameRetry(from: string, to: string, dist: string, ops: SwapOps = real): void {
24
+ for (let i = 1; ; i++) {
25
+ try {
26
+ ops.rename(from, to)
27
+ return
28
+ } catch (e) {
29
+ const code = (e as NodeJS.ErrnoException).code
30
+ if (code === undefined || !BUSY.has(code)) throw e
31
+ if (i >= RENAME_TRIES)
32
+ throw new BuildError('dist-busy', `cannot replace ${dist} (${code}): stop a running \`brust start\` or anything holding files in it, then build again`)
33
+ ops.sleep(RENAME_WAIT_MS)
34
+ }
35
+ }
36
+ }
37
+
38
+ /** Puts `tmp` in place of `dist`; the previous dist goes through `.<name>.brust-old` and is removed after. */
39
+ export function swapDist(tmp: string, dist: string, ops: SwapOps = real): void {
40
+ if (existsSync(dist)) {
41
+ const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
42
+ rmSync(old, { recursive: true, force: true })
43
+ renameRetry(dist, old, dist, ops)
44
+ renameRetry(tmp, dist, dist, ops)
45
+ rmSync(old, { recursive: true, force: true })
46
+ } else renameRetry(tmp, dist, dist, ops)
47
+ }
@@ -68,8 +68,15 @@ 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`: app-root-relative, `/`-separated directories of brust's own output that no
72
+ * scan may read (F501). */
73
+ export async function loadTailwind(
74
+ appRoot: string,
75
+ usedBy: string,
76
+ uses: (file: string) => boolean,
77
+ log?: (s: string) => void,
78
+ ownOutput: string[] = [],
79
+ ): Promise<BunPlugin> {
73
80
  const v = tailwindVersions(appRoot)
74
81
  const missing = TAILWIND_PACKAGES.filter((p) => versionFrom(p, appRoot) === undefined)
75
82
  if (missing.length > 0)
@@ -105,7 +112,10 @@ export async function loadTailwind(appRoot: string, usedBy: string, uses: (file:
105
112
  const compiler = await node.compile(css, { base: dirname(a.path), from: a.path, shouldRewriteUrls: true, onDependency: () => {} })
106
113
  const root: SourceEntry[] =
107
114
  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] })
115
+ // F501: brust's own output (the dist dir, `.brust`) is never a source, whatever the ignore files say and whatever
116
+ // the stylesheet's `source(...)` and `@source` lines add: last, so a stale build can never feed the next one.
117
+ const own: SourceEntry[] = ownOutput.map((dir) => ({ base: appRoot, pattern: `${dir}/**`, negated: true }))
118
+ const scanner = new oxide.Scanner({ sources: [...root, ...compiler.sources, ...own] })
109
119
  let out = compiler.build(scanner.scan())
110
120
  // `@reference "./app.css"` pulls in Tailwind's license banner too; only the file that imports Tailwind ships it
111
121
  // (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
@@ -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) {