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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,7 +77,7 @@ export interface CfBuildCtx {
77
77
  /** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
78
78
  dist: string
79
79
  compiled: Map<string, Compiled>
80
- manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | null; cache?: CacheRecord }
80
+ manifest: { components: Record<string, { template: string }>; /** F495 `[server] base_path`: the asset tree is laid out under it. */ base_path?: string; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | null; cache?: CacheRecord }
81
81
  sourceOf: SourceOf
82
82
  actionsEntry: string | null
83
83
  /** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
@@ -91,6 +91,9 @@ export interface CfBuildCtx {
91
91
  log: (s: string) => void
92
92
  }
93
93
 
94
+ /** F495: the Worker bundle's `define`: production, and the app's base path baked in for `basePath` of `@brust/core` (cf.ts reads it; a Worker has no build-time env). */
95
+ export const workerDefine = (basePath = ''): Record<string, string> => ({ ...PRODUCTION, 'process.env.BRUST_BASE_PATH': JSON.stringify(basePath) })
96
+
94
97
  /** The resolution conditions of the Worker bundle; the package-resolver probe (`sourceDirPlugin`) must carry the same (F223). */
95
98
  export const CF_CONDITIONS = ['workerd', 'worker', 'browser']
96
99
 
@@ -288,10 +291,10 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
288
291
  target: 'browser',
289
292
  format: 'esm',
290
293
  conditions: CF_CONDITIONS,
291
- define: PRODUCTION,
294
+ define: workerDefine(ctx.manifest.base_path),
292
295
  minify: false,
293
296
  external: ['node:*', 'cloudflare:*', glueSpec],
294
- plugins: [serverOnlyEmptyPlugin(), cssPlugin(ctx.cssMaps ?? new Map(), join(out, 'client')), cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'], 'browser', CF_CONDITIONS), exposePlugin(ctx.compiled)],
297
+ plugins: [serverOnlyEmptyPlugin(), cssPlugin(ctx.cssMaps ?? new Map(), join(out, 'client'), ctx.manifest.base_path ?? ''), cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'], 'browser', CF_CONDITIONS), exposePlugin(ctx.compiled)],
295
298
  })
296
299
  if (violations.length > 0) throw new BuildError('cf-unsupported', `${[...new Set(violations)].join('\n')}\nthese modules do not exist on Cloudflare Workers; swap them with [cloudflare] alias`)
297
300
  if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
@@ -303,7 +306,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
303
306
  writeFileSync(appFile, await js[0]!.text())
304
307
 
305
308
  // An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
306
- if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
309
+ if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion, ctx.manifest.base_path ?? '').warnings)
307
310
  const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
308
311
  if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
309
312
  if (doClass) {
@@ -30,6 +30,8 @@ export type ComponentIR = Record<string, any> & {
30
30
  exprs?: Record<string, string>
31
31
  /** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
32
32
  rows_literal?: unknown[]
33
+ /** F502: the branch condition the server checks per instance before it runs the child's job. */
34
+ when?: unknown
33
35
  }[]
34
36
  /** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
35
37
  build_eval_instances?: {
@@ -22,6 +22,7 @@ import {
22
22
  streamEntryError,
23
23
  } from '../actions/prefix'
24
24
  import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
25
+ import { normalizeBasePath } from '../config'
25
26
  import { BuildError } from './errors'
26
27
  import { posixOf } from './posix'
27
28
 
@@ -108,6 +109,8 @@ export interface BuildConfig {
108
109
  cssGlobal: string[]
109
110
  /** `[build] source_roots` (F256): directories outside the app a relative import may reach, normalised and relative to the app root (`../shared`). Empty = the app only. */
110
111
  sourceRoots: string[]
112
+ /** `[server] base_path` (F495), normalised (`/portal`); `''` = the app is mounted at the root. Baked into every URL brust emits. */
113
+ basePath: string
111
114
  }
112
115
 
113
116
  const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -274,7 +277,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
274
277
  }
275
278
  }
276
279
 
277
- function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
280
+ function parseActions(file: string, raw: unknown, actionsOnly: boolean, basePath = ''): ActionsBuildConfig | null {
278
281
  if (raw === undefined) return null
279
282
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
280
283
  const t = raw as Record<string, unknown>
@@ -283,12 +286,16 @@ function parseActions(file: string, raw: unknown, actionsOnly: boolean): Actions
283
286
  if (typeof t.entry !== 'string' || t.entry.trim() === '') throw new BuildError('config', `${file}: actions.entry must be a non-empty string`)
284
287
  entry = t.entry.trim().replace(/^\.\//, '')
285
288
  }
286
- let prefix = DEFAULT_ACTION_PREFIX
289
+ // F495: the default prefix lives under the base path, like every URL brust owns.
290
+ let prefix = `${basePath}${DEFAULT_ACTION_PREFIX}`
287
291
  if (t.prefix !== undefined) {
288
292
  // `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
289
293
  const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
290
294
  if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
291
295
  prefix = t.prefix as string
296
+ // F495: an explicit prefix is used as written, and must itself be under the base path.
297
+ if (basePath !== '' && prefix !== basePath && !prefix.startsWith(`${basePath}/`))
298
+ throw new BuildError('config', `${file}: actions.prefix ${JSON.stringify(prefix)} must lie under server.base_path ${JSON.stringify(basePath)}`)
292
299
  }
293
300
  let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
294
301
  if (t.max_body_bytes !== undefined) {
@@ -397,10 +404,20 @@ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): s
397
404
  return [...out].sort()
398
405
  }
399
406
 
407
+ /** `[server] base_path` (F495): the one validator is config.ts's; a bad value is a build error here too. */
408
+ function parseBasePath(file: string, server: unknown): string {
409
+ if (server === undefined) return ''
410
+ if (!isTable(server)) throw new BuildError('config', `${file}: [server] must be a table`)
411
+ if (server.base_path === undefined) return ''
412
+ const r = normalizeBasePath(server.base_path)
413
+ if (!r.ok) throw new BuildError('config', `${file}: server.base_path ${r.why}`)
414
+ return r.value
415
+ }
416
+
400
417
  export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
401
418
  const file = join(appRoot, 'brust.toml')
402
419
  const f = Bun.file(file)
403
- if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [] }
420
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [], basePath: '' }
404
421
  let parsed: unknown
405
422
  try {
406
423
  parsed = Bun.TOML.parse(await f.text())
@@ -408,17 +425,18 @@ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolea
408
425
  throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
409
426
  }
410
427
  const root = (parsed ?? {}) as Record<string, unknown>
411
- const actions = parseActions(file, root.actions, o.actionsOnly === true)
428
+ const basePath = parseBasePath(file, root.server)
429
+ const actions = parseActions(file, root.actions, o.actionsOnly === true, basePath)
412
430
  const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
413
431
  const cache = await parseCache(file, appRoot, root.cache)
414
432
  const cssGlobal = parseCss(file, root.css)
415
433
  const build = root.build
416
- if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
434
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [], basePath }
417
435
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
418
436
  const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
419
437
  const so = (build as Record<string, unknown>).server_only
420
- if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
438
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
421
439
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
422
440
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
423
- return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots }
441
+ return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
424
442
  }
package/src/build/css.ts CHANGED
@@ -132,13 +132,17 @@ export function cssUnescape(s: string): string {
132
132
  )
133
133
  }
134
134
 
135
+ /** F495: a URL brust serves itself, `/public/…` or `/_brust/…`, under the app's base path (`<base>/public/…`) or at the root:
136
+ * written as it is, never a file on this machine. */
137
+ export const brustUrl = (v: string, base = ''): boolean => ['/public/', '/_brust/'].some((p) => v.startsWith(p) || (base !== '' && v.startsWith(`${base}${p}`)))
138
+
135
139
  /** F490: an absolute local path in either spelling: POSIX `/…`, a Windows drive `C:\…` / `C:/…`, or a UNC `\\host\…`. */
136
140
  export const localAbsolute = (p: string): boolean => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
137
141
 
138
142
  /** F393: a `url()` that is the absolute path of a file on disk (see the `loader` in `buildCss`) becomes a
139
143
  * hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written.
140
144
  * F490: on Windows that path is `C:\\…` (CSS-escaped), so the value is unescaped and both spellings are taken. */
141
- function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>): string {
145
+ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>, base = ''): string {
142
146
  const emit = (path: string, suffix: string) => {
143
147
  const { name, bytes } = hashedAsset(path)
144
148
  if (!assets.some((a) => a.name === name)) assets.push({ name, bytes })
@@ -149,7 +153,7 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
149
153
  if (abs !== undefined) return emit(abs, path.slice(path.search(/[?#]/)))
150
154
  const file = cssUnescape(path)
151
155
  const ext = extname(file).toLowerCase()
152
- if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || file.startsWith('/public/') || file.startsWith('/_brust/') || !existsSync(file)) return whole
156
+ if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || brustUrl(file, base) || !existsSync(file)) return whole
153
157
  return emit(file, '')
154
158
  })
155
159
  }
@@ -158,11 +162,11 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
158
162
  * path and 404 in the browser, so the build fails instead. A leak is a Windows drive or UNC path, or a `/…` path under
159
163
  * the app root or the dist dir (`roots`). Any other root URL passes, even one that happens to exist on the build
160
164
  * machine (`url(/tmp/x)`, `url(/fonts/x.woff2)`): only the app's own files are known to be local. */
161
- export function leakedLocalPaths(text: string, roots: string[]): string[] {
165
+ export function leakedLocalPaths(text: string, roots: string[], base = ''): string[] {
162
166
  const out: string[] = []
163
167
  for (const m of text.matchAll(SOURCE_URL)) {
164
168
  const v = cssUnescape(m[2]!)
165
- if (v.startsWith('/public/') || v.startsWith('/_brust/') || !localAbsolute(v)) continue
169
+ if (brustUrl(v, base) || !localAbsolute(v)) continue
166
170
  const posix = v.replaceAll('\\', '/')
167
171
  const local = !v.startsWith('/') || roots.some((r) => posix === r || posix.startsWith(`${r}/`))
168
172
  if (local && !out.includes(v)) out.push(v)
@@ -203,7 +207,7 @@ export async function buildCss(
203
207
  dist: string,
204
208
  sources: string[],
205
209
  minify = true,
206
- o: { appRoot?: string; log?: (s: string) => void } = {},
210
+ o: { appRoot?: string; log?: (s: string) => void; basePath?: string; ownOutput?: string[] } = {},
207
211
  ): Promise<CssBuild> {
208
212
  const file = new Map<string, string>()
209
213
  const maps = new Map<string, Record<string, string>>()
@@ -220,7 +224,9 @@ export async function buildCss(
220
224
  if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
221
225
  return hit
222
226
  }
223
- plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log))
227
+ // F501: the output dirs of this app that Tailwind must not scan, as app-root-relative patterns (only those under it).
228
+ const own = (o.ownOutput ?? []).map((d) => relative(real(appRoot), real(d)).split(sep).join('/')).filter((r) => r !== '' && r !== '..' && !r.startsWith('../') && !isAbsolute(r))
229
+ plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log, [...new Set(own)]))
224
230
  if (tw.imports.length > 1)
225
231
  o.log?.(
226
232
  `[brust] warning tailwind @import "tailwindcss" is in ${tw.imports.length} files (${tw.imports.map((f) => relative(appRoot, f)).join(', ')}): each one ships the whole of Tailwind; import it once (e.g. app.css) and use @reference elsewhere`,
@@ -241,7 +247,7 @@ export async function buildCss(
241
247
  format: 'esm',
242
248
  minify,
243
249
  naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
244
- external: ['/public/*', '/_brust/*'],
250
+ external: ['/public/*', '/_brust/*', ...(o.basePath ? [`${o.basePath}/public/*`, `${o.basePath}/_brust/*`] : [])],
245
251
  root: appRoot,
246
252
  plugins,
247
253
  // F393, verified on Bun 1.4.4 only: its CSS bundler turns an asset under the size threshold into a `data:`
@@ -289,9 +295,9 @@ export async function buildCss(
289
295
  `${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
290
296
  )
291
297
  }
292
- for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed))
298
+ for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed, o.basePath))
293
299
  const roots = [appRoot, dist].flatMap((r) => [r, real(r)]).map((r) => r.replaceAll('\\', '/').replace(/\/+$/, '')).filter((r) => r !== '')
294
- const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots).map((p) => `url(${p}) in ${sources[i]}`))
300
+ const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots, o.basePath).map((p) => `url(${p}) in ${sources[i]}`))
295
301
  if (leaks.length > 0)
296
302
  throw new BuildError('css-asset-path', `${leaks.join('; ')}: the built CSS would name a file on this machine; reference it relative to the stylesheet or as /public/…`)
297
303
  for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
@@ -351,9 +357,9 @@ export function tailwindUse(sources: string[]): { using: string[]; imports: stri
351
357
  * (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
352
358
  * stylesheet itself is linked by the server (R19.3), never bundled into JS.
353
359
  * F411: with `clientDir` (`<dist>/client`), an import of a served asset (`SERVED_ASSET_EXT`: a font, an image) is
354
- * the string `/_brust/client/<name>-<hex10>.<ext>`, the very file a stylesheet's `url()` of it is rewritten to; the
360
+ * the string `/_brust/client/<name>-<hex10>.<ext>` (under `basePath`, F495), the very file a stylesheet's `url()` of it is rewritten to; the
355
361
  * file is written there, so the URL can be preloaded (`<link rel="preload" as="font" href={font}>`). */
356
- export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?: string): BunPlugin {
362
+ export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?: string, basePath = ''): BunPlugin {
357
363
  return {
358
364
  name: 'brust-css',
359
365
  setup(b) {
@@ -371,7 +377,7 @@ export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?:
371
377
  mkdirSync(clientDir, { recursive: true })
372
378
  writeFileSync(to, bytes)
373
379
  }
374
- return { contents: `export default ${JSON.stringify(`/_brust/client/${name}`)}\n`, loader: 'js' }
380
+ return { contents: `export default ${JSON.stringify(`${basePath}/_brust/client/${name}`)}\n`, loader: 'js' }
375
381
  })
376
382
  },
377
383
  }
@@ -8,8 +8,9 @@ import { createHash } from 'node:crypto'
8
8
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
11
- import { BrustRouteError, type FlatRoute, validateRoutes } from '../routes'
12
- import { type BrowserCtx, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, nestedOf, storeModules } from './bundle'
11
+ import { BrustRouteError, checkBasePath, type FlatRoute, validateRoutes } from '../routes'
12
+ import { assertRoutesInstallable, type CheckPatterns, nativeCheckPatterns } from './route-conflicts'
13
+ import { type BrowserCtx, brustRoot, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, nestedOf, storeModules } from './bundle'
13
14
  import { runActionsOnly } from './actions-only'
14
15
  import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
15
16
  import { renderIcons } from './build-icons'
@@ -106,6 +107,8 @@ export async function runBuild(opts: {
106
107
  strict?: boolean
107
108
  /** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
108
109
  exprReads?: ExprReads
110
+ /** The router's refusal of a pattern set (F496; tests swap it). Default: the addon. */
111
+ checkPatterns?: CheckPatterns
109
112
  /** `--wasm-from-source` (cloudflare only). */
110
113
  wasmFromSource?: boolean
111
114
  /** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
@@ -123,9 +126,11 @@ export async function runBuild(opts: {
123
126
  assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
124
127
  assertReplaceable(dist, opts.force)
125
128
  const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
129
+ // F495: `basePath` of `@brust/core` while the build loads the app's modules (route scan, static render).
130
+ process.env.BRUST_BASE_PATH = built.basePath
126
131
  if (opts.actionsOnly)
127
132
  return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
128
- const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots } = built
133
+ const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots, basePath } = built
129
134
  // L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
130
135
  // is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
131
136
  const remote =
@@ -138,10 +143,13 @@ export async function runBuild(opts: {
138
143
  let leaves: ReturnType<typeof validateRoutes>
139
144
  try {
140
145
  leaves = validateRoutes(routes)
146
+ checkBasePath(leaves, basePath)
141
147
  } catch (e) {
142
148
  if (e instanceof BrustRouteError) throw new BuildError(e.rule, e.message)
143
149
  throw e
144
150
  }
151
+ // F496: what the server's router would refuse at `brust start` (a `{*rest}` next to a `{slug}`) fails here.
152
+ assertRoutesInstallable(leaves, opts.checkPatterns ?? (await nativeCheckPatterns()))
145
153
  let actionsEntry: string | null = null
146
154
  if (actions) {
147
155
  actionsEntry = resolve(appRoot, actions.entry)
@@ -176,7 +184,7 @@ export async function runBuild(opts: {
176
184
  // R9.3/R9.4: every reason to refuse a static build, before any write.
177
185
  const ssgPlan =
178
186
  opts.target === 'static'
179
- ? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache)
187
+ ? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache, basePath)
180
188
  : null
181
189
 
182
190
  // Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
@@ -190,7 +198,7 @@ export async function runBuild(opts: {
190
198
  rmSync(tmp, { recursive: true, force: true })
191
199
  mkdirSync(tmp, { recursive: true })
192
200
  try {
193
- await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, actionsRemote: remote?.prefix ?? null, log: opts.log })
201
+ await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, basePath, actionsRemote: remote?.prefix ?? null, log: opts.log })
194
202
  if (ssgPlan) {
195
203
  // Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
196
204
  const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
@@ -233,6 +241,8 @@ async function buildInto(
233
241
  cssGlobal: string[]
234
242
  /** `[build] source_roots` (F256): normalised, app-root-relative directories outside the app a relative import may reach. */
235
243
  sourceRoots: string[]
244
+ /** `[server] base_path` (F495), `''` = the root. */
245
+ basePath: string
236
246
  /** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
237
247
  actionsRemote: string | null
238
248
  /** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
@@ -248,13 +258,13 @@ async function buildInto(
248
258
  const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
249
259
  mkdirSync(join(out, 'client'), { recursive: true })
250
260
  // Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
251
- const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
261
+ const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log, basePath: o.basePath, ownOutput: [o.dist, join(appRoot, '.brust')] })
252
262
  for (const c of directCss(entry))
253
263
  o.log(`[brust] warning css ${relative(appRoot, entry)} imports ${c}: the route entry belongs to no route, so it is linked nowhere; import it from a layout, or list it in [css] global`)
254
- const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client') }
264
+ const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client'), basePath: o.basePath }
255
265
  // The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
256
266
  const runtime = await buildRuntime(out, ctx)
257
- const runtimeUrls = { core: `/_brust/${runtime.core}`, store: `/_brust/${runtime.store}` }
267
+ const runtimeUrls = { core: `${brustRoot(ctx)}/${runtime.core}`, store: `${brustRoot(ctx)}/${runtime.store}` }
258
268
  // F230: one scan per build for load-time writes into another app module's exports; every compile reads it.
259
269
  const loadWrites = scanAppWrites(appRoot, o.appModules, o.log, o.sourceRoots)
260
270
  // Where a React island renders, the load-time writers run first: every one in the SSR jobs bundle (server code the
@@ -346,10 +356,12 @@ async function buildInto(
346
356
  const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
347
357
  if (files.length > 0) r.css = files
348
358
  }
349
- await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers)
359
+ await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers, o.basePath)
360
+ // F495: absent without a base path, so an app at the root writes the manifest it always did.
361
+ if (o.basePath !== '') manifest.base_path = o.basePath
350
362
  manifest.actions =
351
363
  o.actions && o.actionsEntry
352
- ? { entry: await buildActions(out, o.actionsEntry, css.maps), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
364
+ ? { entry: await buildActions(out, o.actionsEntry, css.maps, o.basePath), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
353
365
  : null
354
366
  if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
355
367
  if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
@@ -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,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]
@@ -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) {