@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.
- package/native/index.d.ts +19 -1
- package/native/index.js +55 -54
- package/package.json +12 -10
- package/src/actions/client.ts +3 -4
- package/src/browser.ts +3 -0
- package/src/build/actions-only.ts +7 -10
- package/src/build/bundle.ts +21 -14
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cf-wrangler.ts +3 -2
- package/src/build/cloudflare.ts +10 -6
- package/src/build/compile.ts +5 -2
- package/src/build/config.ts +26 -7
- package/src/build/css.ts +53 -12
- package/src/build/esm-resolve.ts +7 -2
- package/src/build/index.ts +44 -26
- package/src/build/manifest.ts +6 -0
- package/src/build/posix.ts +35 -0
- package/src/build/route-conflicts.ts +39 -0
- package/src/build/ssg.ts +3 -2
- package/src/build/static.ts +3 -2
- package/src/build/swap.ts +47 -0
- package/src/build/tailwind.ts +13 -3
- package/src/build/tsconfig-paths.ts +31 -3
- package/src/cache-adapter/conformance.ts +77 -0
- package/src/cache-adapter/memory.ts +44 -12
- package/src/cache-adapter/types.ts +7 -1
- package/src/cache.ts +11 -0
- package/src/cf.ts +3 -0
- package/src/config.ts +29 -1
- package/src/dev/bun.ts +11 -7
- package/src/dev/client.ts +4 -1
- package/src/dev/devtools.ts +5 -3
- package/src/dev/open.ts +7 -0
- package/src/dev/proxy.ts +21 -17
- package/src/dev.ts +5 -3
- package/src/index.ts +5 -0
- package/src/launcher-watch.ts +45 -0
- package/src/native.ts +7 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +27 -0
package/src/build/manifest.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/src/build/static.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/build/tailwind.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
|
68
|
-
const pathSeq = new Map<string, number>()
|
|
105
|
+
const nameSeq = new Map<string, number>()
|
|
69
106
|
let seq = 0
|
|
70
107
|
// Drawn on first use: a Workers module may not make random values at global scope.
|
|
71
108
|
let genValue: string | null = null
|
|
@@ -86,10 +123,8 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
86
123
|
fail = false
|
|
87
124
|
return true
|
|
88
125
|
}
|
|
89
|
-
const raise = (m: Map<string, number>, k: string, s: number) => m.set(k, Math.max(m.get(k) ?? 0, s))
|
|
90
126
|
const mark = (args: InvalidateArgs, s: number) => {
|
|
91
|
-
for (const
|
|
92
|
-
if (args.path !== undefined) raise(pathSeq, pathName(args.method, args.path), s)
|
|
127
|
+
for (const n of invalidationNames(args)) nameSeq.set(n, Math.max(nameSeq.get(n) ?? 0, s))
|
|
93
128
|
}
|
|
94
129
|
|
|
95
130
|
const store: CacheStore = {
|
|
@@ -113,10 +148,10 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
113
148
|
async set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean> {
|
|
114
149
|
if (failing()) throw new Error('memory adapter: injected failure')
|
|
115
150
|
if (page.body.length > maxBodyBytes) return false
|
|
151
|
+
const prefix = meta.prefix ?? ''
|
|
116
152
|
if (meta.stamp !== null) {
|
|
117
153
|
const s = meta.stamp
|
|
118
|
-
|
|
119
|
-
if (meta.tags.some(t => stale(tagSeq, t)) || stale(pathSeq, pathName(meta.method, meta.path))) return false
|
|
154
|
+
if (entryNames({ ...meta, prefix }).some(n => (nameSeq.get(n) ?? 0) > s)) return false
|
|
120
155
|
}
|
|
121
156
|
const at = now()
|
|
122
157
|
entries.set(key, {
|
|
@@ -124,6 +159,7 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
124
159
|
tags: [...meta.tags],
|
|
125
160
|
method: meta.method,
|
|
126
161
|
path: meta.path,
|
|
162
|
+
prefix,
|
|
127
163
|
entryStamp: meta.stamp,
|
|
128
164
|
storedAt: at,
|
|
129
165
|
expiresAt: at + meta.ttlMs,
|
|
@@ -140,11 +176,7 @@ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
|
|
|
140
176
|
// L7b: the marker first, at the seq the matching publish will take (seq + 1), so a set whose
|
|
141
177
|
// stamp was read before this call is refused from now on; then the delete.
|
|
142
178
|
mark(args, seq + 1)
|
|
143
|
-
const
|
|
144
|
-
const p = args.path === undefined ? null : pathName(args.method, args.path)
|
|
145
|
-
for (const [k, e] of entries) {
|
|
146
|
-
if (e.tags.some(t => tags.has(t)) || (p !== null && pathName(e.method, e.path) === p)) entries.delete(k)
|
|
147
|
-
}
|
|
179
|
+
for (const [k, e] of entries) if (covers(args, e)) entries.delete(k)
|
|
148
180
|
},
|
|
149
181
|
}
|
|
150
182
|
|
|
@@ -21,6 +21,11 @@ export interface SetMeta {
|
|
|
21
21
|
tags: string[]
|
|
22
22
|
method: string
|
|
23
23
|
path: string
|
|
24
|
+
/**
|
|
25
|
+
* The entry's evaluated route-cache key prefix (`''` = none), for `invalidate({ prefix })` (F493).
|
|
26
|
+
* Absent only from callers older than F493: treat it as `''`.
|
|
27
|
+
*/
|
|
28
|
+
prefix?: string
|
|
24
29
|
/** The `stamp` the lookup returned (read before the render); `null` = unguarded write. */
|
|
25
30
|
stamp: number | null
|
|
26
31
|
}
|
|
@@ -45,7 +50,8 @@ export interface CacheStore {
|
|
|
45
50
|
/**
|
|
46
51
|
* Shared-side delete by tags/path (`key` is a no-op: the store holds pages only). Records the
|
|
47
52
|
* invalidation marker BEFORE deleting, so a concurrent stale `set` is refused even before the
|
|
48
|
-
* publish (ruling L7b).
|
|
53
|
+
* publish (ruling L7b). With `prefix` (F493) only entries whose `SetMeta.prefix` equals it are
|
|
54
|
+
* deleted (`{ prefix }` alone: every page of that prefix); entries of other prefixes stay.
|
|
49
55
|
*/
|
|
50
56
|
invalidate(args: InvalidateArgs): Promise<void>
|
|
51
57
|
readonly maxBodyBytes: number
|
package/src/cache.ts
CHANGED
|
@@ -9,8 +9,19 @@ import { type InvalidateArgs, type InvalidateResult, cacheInvalidate } from './n
|
|
|
9
9
|
export type { ComponentCacheOptions } from './cache-core'
|
|
10
10
|
|
|
11
11
|
const NO_SERVER = 'cache.invalidate needs a running server (call it inside `brust start`)'
|
|
12
|
+
/** Same text as brust-core `PREFIX_WITH_KEY` (the Rust hosts refuse it too). */
|
|
13
|
+
export const PREFIX_WITH_KEY = 'cache.invalidate: `prefix` narrows page entries; `key` names a job entry — call them separately'
|
|
14
|
+
|
|
15
|
+
/** F493: `prefix` is a string and never comes with `key`. Throws a TypeError otherwise. */
|
|
16
|
+
export function checkInvalidateArgs(args: InvalidateArgs): void {
|
|
17
|
+
const prefix = (args as { prefix?: unknown } | null | undefined)?.prefix
|
|
18
|
+
if (prefix === undefined) return
|
|
19
|
+
if (typeof prefix !== 'string') throw new TypeError(`cache.invalidate: \`prefix\` must be a string (got ${prefix === null ? 'null' : typeof prefix})`)
|
|
20
|
+
if (args.key !== undefined) throw new TypeError(PREFIX_WITH_KEY)
|
|
21
|
+
}
|
|
12
22
|
|
|
13
23
|
function invalidate(args: InvalidateArgs): InvalidateResult {
|
|
24
|
+
checkInvalidateArgs(args)
|
|
14
25
|
let r: InvalidateResult
|
|
15
26
|
try {
|
|
16
27
|
r = cacheInvalidate(args)
|
package/src/cf.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Same names as `index.ts` minus what needs a host process: no `run`, no `loadConfig`. `cache.invalidate` goes through
|
|
3
3
|
// the CF bundle's napi stub to the runtime the wasm glue installed at start (`server-handlers` `install`).
|
|
4
4
|
export { buildTime } from './build-time'
|
|
5
|
+
|
|
6
|
+
/** F495 `[server] base_path` (see index.ts). The Cloudflare build bakes the manifest's value into the Worker bundle (`process.env.BRUST_BASE_PATH` is a `define`), `''` at the root. */
|
|
7
|
+
export const basePath: string = typeof process === 'undefined' ? '' : (process.env.BRUST_BASE_PATH ?? '')
|
|
5
8
|
export { navigate } from './navigate'
|
|
6
9
|
export type { NavErrorDetail, NavLoadDetail, NavigateOptions, NavSuccessDetail, NavTrigger } from './navigate'
|
|
7
10
|
export { cache } from './cache'
|
package/src/config.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// env (BRUST_ADDR / BRUST_PORT / BRUST_WORKERS / BRUST_RENDER_SLOTS / BRUST_DRAIN_TIMEOUT_MS /
|
|
3
3
|
// BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES)
|
|
4
4
|
// > CLI flags (`brust start --port/--workers`) and `run({ config })`
|
|
5
|
-
// > brust.toml ([server] address / port / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
|
|
5
|
+
// > brust.toml ([server] address / port / base_path / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
|
|
6
6
|
// > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000, 262144, 8388608).
|
|
7
7
|
// L16 R16.1: `actionsBaseUrl` follows env (BRUST_ACTIONS_BASE_URL) > run({ config }) > toml `[actions] base_url`.
|
|
8
8
|
// `cors` has no env layer; `run({ config: { cors } })` replaces a toml `[cors]` as a whole (never merged), and
|
|
@@ -43,6 +43,10 @@ export interface BrustConfig {
|
|
|
43
43
|
maxLoaderResponseBytes: number
|
|
44
44
|
/** `run({ config: { cors } })` over brust.toml `[cors]` (a whole-object replace); absent = CORS off. No env layer. */
|
|
45
45
|
cors?: CorsConfig
|
|
46
|
+
/** F495 `[server] base_path` / `run({ config: { basePath } })`: the path the app is mounted under on a shared origin (`/portal`; `''` = the
|
|
47
|
+
* root), absent when nothing set it. A build setting too (`brust build` bakes it into the manifest): `brust start` refuses a manifest
|
|
48
|
+
* built for another one, and with none set serves the one the manifest names. */
|
|
49
|
+
basePath?: string
|
|
46
50
|
/** L16 R16.1: the origin the browser `client()` calls (page script `__BRUST_ACTION_BASE__`); absent = same origin. */
|
|
47
51
|
actionsBaseUrl?: string
|
|
48
52
|
/** L16 R16.2: the client's default `RequestInit.credentials` (`__BRUST_ACTION_CREDENTIALS__`); absent = `same-origin`. */
|
|
@@ -52,6 +56,19 @@ export interface BrustConfig {
|
|
|
52
56
|
/** What `run({ config })` / `loadConfig`'s `cli` accepts: any setting, and `cors: null` to turn a toml `[cors]` off. */
|
|
53
57
|
export type ConfigOverrides = Omit<Partial<BrustConfig>, 'cors'> & { cors?: (Omit<CorsConfig, 'credentials'> & { credentials?: boolean }) | null }
|
|
54
58
|
|
|
59
|
+
/** F495: `[server] base_path`, validated: `/seg[/seg…]`, no trailing `/`, each segment `[A-Za-z0-9._~-]+` (not `.`/`..`), the
|
|
60
|
+
* same alphabet as the actions prefix, so it needs no percent-encoding anywhere it is printed. `""` and `"/"` mean the
|
|
61
|
+
* root (`""`). Returns the value, or the reason it is refused. */
|
|
62
|
+
export function normalizeBasePath(raw: unknown): { ok: true; value: string } | { ok: false; why: string } {
|
|
63
|
+
if (typeof raw !== 'string') return { ok: false, why: `must be a string (got ${JSON.stringify(raw)})` }
|
|
64
|
+
if (raw === '' || raw === '/') return { ok: true, value: '' }
|
|
65
|
+
if (!raw.startsWith('/')) return { ok: false, why: `must start with '/' (got ${JSON.stringify(raw)})` }
|
|
66
|
+
if (raw.endsWith('/')) return { ok: false, why: `must not end with '/' (got ${JSON.stringify(raw)})` }
|
|
67
|
+
if (raw.slice(1).split('/').some((seg) => !/^[A-Za-z0-9._~-]+$/.test(seg) || seg === '.' || seg === '..'))
|
|
68
|
+
return { ok: false, why: `segments must be [A-Za-z0-9._~-]+ and not '.'/'..' (got ${JSON.stringify(raw)})` }
|
|
69
|
+
return { ok: true, value: raw }
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
export class BrustConfigError extends Error {
|
|
56
73
|
override name = 'BrustConfigError'
|
|
57
74
|
constructor(
|
|
@@ -112,6 +129,7 @@ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrid
|
|
|
112
129
|
maxLoaderResponseBytes: env.maxLoaderResponseBytes ?? cli.maxLoaderResponseBytes ?? fromToml.maxLoaderResponseBytes ?? DEFAULTS.maxLoaderResponseBytes,
|
|
113
130
|
ioThreads: env.ioThreads ?? cli.ioThreads,
|
|
114
131
|
...(cors ? { cors } : {}),
|
|
132
|
+
...pick('basePath', cli.basePath ?? fromToml.basePath),
|
|
115
133
|
...pick('actionsBaseUrl', env.actionsBaseUrl ?? cli.actionsBaseUrl ?? fromToml.actionsBaseUrl),
|
|
116
134
|
...pick('actionsCredentials', cli.actionsCredentials ?? fromToml.actionsCredentials),
|
|
117
135
|
}
|
|
@@ -150,6 +168,11 @@ function checkCli(cli: ConfigOverrides): void {
|
|
|
150
168
|
check('ioThreads', cli.ioThreads, 1, MAX_IO_THREADS)
|
|
151
169
|
check('slotBytes', cli.slotBytes, MIN_SLOT_BYTES, MAX_SLOT_BYTES)
|
|
152
170
|
check('maxLoaderResponseBytes', cli.maxLoaderResponseBytes, MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES)
|
|
171
|
+
if (cli.basePath !== undefined) {
|
|
172
|
+
const r = normalizeBasePath(cli.basePath)
|
|
173
|
+
if (!r.ok) throw new BrustConfigError(`config.basePath ${r.why}`, null)
|
|
174
|
+
cli.basePath = r.value
|
|
175
|
+
}
|
|
153
176
|
if (cli.actionsBaseUrl !== undefined) {
|
|
154
177
|
const why = baseUrlError(cli.actionsBaseUrl)
|
|
155
178
|
if (why) throw new BrustConfigError(`config.actionsBaseUrl ${why}`, null)
|
|
@@ -176,6 +199,11 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
|
|
|
176
199
|
if ('server' in root) {
|
|
177
200
|
const server = table(root.server, '[server]', file)
|
|
178
201
|
const { port, address } = server
|
|
202
|
+
if (server.base_path !== undefined) {
|
|
203
|
+
const r = normalizeBasePath(server.base_path)
|
|
204
|
+
if (!r.ok) throw new BrustConfigError(`${file}: server.base_path ${r.why}`, file)
|
|
205
|
+
out.basePath = r.value
|
|
206
|
+
}
|
|
179
207
|
const cap = tomlInt(server.max_loader_response_bytes, 'server.max_loader_response_bytes', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, file)
|
|
180
208
|
if (cap !== undefined) out.maxLoaderResponseBytes = cap
|
|
181
209
|
if (port !== undefined) {
|