@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.
- package/README.md +2 -1
- package/native/index.d.ts +26 -1
- package/native/index.js +55 -54
- package/package.json +10 -10
- package/src/actions/client.ts +3 -4
- package/src/browser.ts +3 -0
- package/src/build/actions-only.ts +2 -1
- package/src/build/bundle.ts +58 -19
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cf-workerd.ts +63 -0
- package/src/build/cloudflare.ts +26 -6
- package/src/build/compile.ts +2 -0
- package/src/build/config.ts +36 -7
- package/src/build/css.ts +18 -12
- package/src/build/index.ts +30 -12
- package/src/build/manifest.ts +8 -0
- package/src/build/route-conflicts.ts +39 -0
- package/src/build/ssg.ts +3 -2
- package/src/build/static.ts +7 -2
- package/src/build/tailwind.ts +15 -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 +91 -2
- package/src/dev/bun.ts +8 -6
- package/src/dev/client.ts +4 -1
- package/src/dev/devtools.ts +5 -3
- package/src/dev/proxy.ts +21 -17
- package/src/index.ts +5 -0
- package/src/launcher-watch.ts +54 -1
- package/src/native.ts +11 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +39 -1
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]
|
|
@@ -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
|
package/src/build/tailwind.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
80
|
+
* (`<stem>-<hex10>.css`), as `<base>/_brust/client/<old>` → `<base>/_brust/client/<new>`. `null` when the pairing is not
|
|
81
81
|
* one-to-one (a stylesheet was added or removed, or two sources share a stem): the page must reload. */
|
|
82
|
-
export function cssSwapMap(oldDir: string, newDir: string): Record<string, string> | null {
|
|
82
|
+
export function cssSwapMap(oldDir: string, newDir: string, base = ''): Record<string, string> | null {
|
|
83
83
|
const byStem = (dir: string) => {
|
|
84
84
|
const m = new Map<string, string>()
|
|
85
85
|
let files: string[] = []
|
|
@@ -101,7 +101,7 @@ export function cssSwapMap(oldDir: string, newDir: string): Record<string, strin
|
|
|
101
101
|
for (const [stem, f] of a) {
|
|
102
102
|
const g = b.get(stem)
|
|
103
103
|
if (g === undefined) return null
|
|
104
|
-
if (g !== f) map[
|
|
104
|
+
if (g !== f) map[`${base}/_brust/client/${f}`] = `${base}/_brust/client/${g}`
|
|
105
105
|
}
|
|
106
106
|
return map
|
|
107
107
|
}
|
|
@@ -123,6 +123,8 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
123
123
|
const mode = tuiMode(out, env)
|
|
124
124
|
const tui = new Tui(out, mode.interactive, mode.color)
|
|
125
125
|
const cfg = await loadConfig(appRoot, o.port !== undefined ? { port: o.port } : {})
|
|
126
|
+
// F495: `[server] base_path`: the dev channel (`<base>/_brust/dev/*`) and the app's own URLs live under it, so two apps behind one path proxy can both run.
|
|
127
|
+
const base = cfg.basePath ?? ''
|
|
126
128
|
const workers = o.workers ?? 1
|
|
127
129
|
const devRoot = join(appRoot, '.brust', 'dev')
|
|
128
130
|
rmSync(devRoot, { recursive: true, force: true })
|
|
@@ -165,7 +167,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
const proxy: DevProxy = startProxy(
|
|
168
|
-
{ hostname: cfg.host, port: cfg.port, token: devToken },
|
|
170
|
+
{ hostname: cfg.host, port: cfg.port, token: devToken, basePath: base },
|
|
169
171
|
{
|
|
170
172
|
gen: () => gen,
|
|
171
173
|
state: () => state,
|
|
@@ -175,7 +177,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
175
177
|
actionsPrefix: () => prefixOf(active),
|
|
176
178
|
onRequest(r) {
|
|
177
179
|
tui.log(`${r.method} ${r.path} ${r.status} ${r.ms}ms${r.cache ? ` ${r.cache}` : ''}`)
|
|
178
|
-
if (!r.path.startsWith(
|
|
180
|
+
if (!r.path.startsWith(`${base}/_brust/`)) {
|
|
179
181
|
const rendered = (r.trace as { rendered?: string[] } | null)?.rendered
|
|
180
182
|
last = { method: r.method, path: r.path, status: r.status, ...(rendered ? { tiers: tierCounts(rendered, devMeta() as Parameters<typeof tierCounts>[1]) } : {}) }
|
|
181
183
|
publish()
|
|
@@ -303,7 +305,7 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
|
303
305
|
state = 'ready'
|
|
304
306
|
diag = null
|
|
305
307
|
// A stylesheet-only edit (L19/L21): the pages keep their DOM and swap their brust CSS links.
|
|
306
|
-
const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir) : null
|
|
308
|
+
const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir, base) : null
|
|
307
309
|
if (swap) {
|
|
308
310
|
proxy.broadcast({ type: 'css-swap', gen, map: swap })
|
|
309
311
|
tui.log(`[brust] css rebuilt (${Object.keys(swap).length} stylesheet${Object.keys(swap).length === 1 ? '' : 's'} swapped, no reload)`)
|
package/src/dev/client.ts
CHANGED
|
@@ -78,7 +78,10 @@ function handle(m) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function connect() {
|
|
81
|
-
|
|
81
|
+
// F495: the socket lives next to this script (<base>/_brust/dev/ws), wherever the app is mounted.
|
|
82
|
+
const wsUrl = new URL('ws', import.meta.url)
|
|
83
|
+
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
84
|
+
const ws = new WebSocket(wsUrl)
|
|
82
85
|
ws.onopen = () => {
|
|
83
86
|
delay = 150
|
|
84
87
|
}
|
package/src/dev/devtools.ts
CHANGED
|
@@ -109,17 +109,19 @@ class BrustDevtools extends HTMLElement {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
private async loadMeta(): Promise<void> {
|
|
112
|
-
try { this.meta = (await (await fetch('
|
|
112
|
+
try { this.meta = (await (await fetch(new URL('meta', import.meta.url))).json()) as DevMeta } catch { this.meta = null }
|
|
113
113
|
this.paintIndicator()
|
|
114
114
|
if (!this.panel.hidden) this.render()
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
private async loadEntries(): Promise<void> {
|
|
118
|
-
try { this.entries = ((await (await fetch('
|
|
118
|
+
try { this.entries = ((await (await fetch(new URL('traces', import.meta.url))).json()) as { traces: Entry[] }).traces } catch { this.entries = [] }
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
private connect(): void {
|
|
122
|
-
const
|
|
122
|
+
const wsUrl = new URL('ws', import.meta.url)
|
|
123
|
+
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
124
|
+
const ws = new WebSocket(wsUrl)
|
|
123
125
|
ws.onmessage = (e) => {
|
|
124
126
|
const m = JSON.parse(e.data as string) as { type: string; gen?: number; state?: BuildState['state']; diag?: BuildState['diag'] }
|
|
125
127
|
if (m.type === 'hello') this.build = { state: m.state ?? 'ready', gen: m.gen ?? 0, diag: m.diag ?? null }
|