@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.
@@ -37,8 +37,10 @@ function walk(dir: string, rel = ''): string[] {
37
37
  }
38
38
 
39
39
  /** Copies the admissible files of `<out>/client` and `<out>/public` into `<cfDir>/assets/...`; returns the
40
- * asset-relative paths (`_brust/client/x.js`, `public/img/a.png`) and writes `assets/_headers`. */
41
- export function layoutAssets(out: string, cfDir: string, version: string): { files: string[]; warnings: string[] } {
40
+ * asset-relative paths (`_brust/client/x.js`, `public/img/a.png`) and writes `assets/_headers`. F495: an app under a base path
41
+ * (`/portal`) lays them out under it (`portal/_brust/client/x.js`), so the platform serves the very URLs the pages name. */
42
+ export function layoutAssets(out: string, cfDir: string, version: string, base = ''): { files: string[]; warnings: string[] } {
43
+ const under = base === '' ? '' : `${base.slice(1)}/`
42
44
  const assets = join(cfDir, 'assets')
43
45
  mkdirSync(assets, { recursive: true })
44
46
  const files: string[] = []
@@ -51,32 +53,32 @@ export function layoutAssets(out: string, cfDir: string, version: string): { fil
51
53
  const client = join(out, 'client')
52
54
  if (existsSync(client))
53
55
  for (const f of walk(client)) {
54
- if (!f.includes('/') && clientOk(f)) copy(join(client, f), `_brust/client/${f}`)
56
+ if (!f.includes('/') && clientOk(f)) copy(join(client, f), `${under}_brust/client/${f}`)
55
57
  else warnings.push(`[brust] assets: client/${f} is not served natively; skipped`)
56
58
  }
57
59
  const pub = join(out, 'public')
58
60
  if (existsSync(pub))
59
61
  for (const f of walk(pub)) {
60
- if (f.split('/').every(segOk)) copy(join(pub, f), `public/${f}`)
62
+ if (f.split('/').every(segOk)) copy(join(pub, f), `${under}public/${f}`)
61
63
  else warnings.push(`[brust] assets: public/${f} has a name the native host would not serve; skipped`)
62
64
  }
63
- const h = headersFile(files, version)
65
+ const h = headersFile(files, version, base)
64
66
  warnings.push(...h.warnings)
65
67
  writeFileSync(join(assets, '_headers'), h.text)
66
68
  return { files, warnings }
67
69
  }
68
70
 
69
- export function headersFile(files: string[], version: string): { text: string; warnings: string[] } {
71
+ export function headersFile(files: string[], version: string, base = ''): { text: string; warnings: string[] } {
70
72
  const warnings: string[] = []
71
73
  const rules: string[] = [
72
74
  `/*\n X-Content-Type-Options: nosniff\n X-Powered-By: brust/${version}`,
73
- `/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
75
+ `${base}/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
74
76
  ]
75
- const pub = files.filter((f) => f.startsWith('public/'))
77
+ const pub = files.filter((f) => f.startsWith(`${base === '' ? '' : `${base.slice(1)}/`}public/`))
76
78
  if (rules.length + pub.length <= MAX_RULES)
77
79
  for (const f of pub) rules.push(`/${f}\n Cache-Control: ${isHashed(f) ? IMMUTABLE : PUBLIC_DEFAULT}`)
78
80
  else if (pub.length > 0) {
79
- rules.push(`/public/*\n Cache-Control: ${PUBLIC_DEFAULT}`)
81
+ rules.push(`${base}/public/*\n Cache-Control: ${PUBLIC_DEFAULT}`)
80
82
  warnings.push(`[brust] assets: ${pub.length} public files exceed the 100-rule _headers limit; one /public/* max-age=3600 rule (no per-file immutable)`)
81
83
  }
82
84
  return { text: `${rules.join('\n\n')}\n`, warnings }
@@ -1,8 +1,9 @@
1
1
  // `wrangler.jsonc` for `dist/cf/` (M4-CF R5, R6, R7): the user's app-root config merged with the keys brust owns.
2
2
  // Pure apart from `readUserWrangler`. Never adds an `account_id`; copies one only if the user wrote it.
3
3
  import { existsSync } from 'node:fs'
4
- import { isAbsolute, join, relative } from 'node:path'
4
+ import { isAbsolute, join } from 'node:path'
5
5
  import { BuildError } from './errors'
6
+ import { relPosix } from './posix'
6
7
 
7
8
  type Json = Record<string, unknown>
8
9
  export interface UserWrangler {
@@ -49,7 +50,7 @@ export function workerNameOf(pkgName: string): string {
49
50
  }
50
51
 
51
52
  function rebase(o: MergeOpts, p: string): string {
52
- return isAbsolute(p) ? p : relative(o.cfDir, join(o.appRoot, p))
53
+ return isAbsolute(p) ? p : relPosix(o.cfDir, join(o.appRoot, p))
53
54
  }
54
55
 
55
56
  function assertNoAssetsBinding(v: unknown, path: string): void {
@@ -5,6 +5,7 @@
5
5
  import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
6
6
  import { builtinModules } from 'node:module'
7
7
  import { dirname, join, relative } from 'node:path'
8
+ import { relPosix } from './posix'
8
9
  import type { BunPlugin } from 'bun'
9
10
  import { BrustConfigError, type CorsConfig, loadCors } from '../config'
10
11
  import { layoutAssets } from './cf-assets'
@@ -76,7 +77,7 @@ export interface CfBuildCtx {
76
77
  /** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
77
78
  dist: string
78
79
  compiled: Map<string, Compiled>
79
- 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 }
80
81
  sourceOf: SourceOf
81
82
  actionsEntry: string | null
82
83
  /** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
@@ -90,6 +91,9 @@ export interface CfBuildCtx {
90
91
  log: (s: string) => void
91
92
  }
92
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
+
93
97
  /** The resolution conditions of the Worker bundle; the package-resolver probe (`sourceDirPlugin`) must carry the same (F223). */
94
98
  export const CF_CONDITIONS = ['workerd', 'worker', 'browser']
95
99
 
@@ -102,7 +106,7 @@ function cfGuard(appRoot: string, violations: string[]): BunPlugin {
102
106
  name: 'brust-cf-guard',
103
107
  setup(b) {
104
108
  b.onResolve({ filter: /^bun(:.*)?$/ }, (a) => {
105
- violations.push(`${relative(appRoot, a.importer) || a.importer} imports ${a.path}`)
109
+ violations.push(`${relPosix(appRoot, a.importer) || a.importer} imports ${a.path}`)
106
110
  return { path: a.path, external: true }
107
111
  })
108
112
  b.onResolve({ filter: /^[a-z_]+(\/[a-z_/]+)?$/ }, (a) => (BUILTINS.has(a.path) ? { path: `node:${a.path}`, external: true } : undefined))
@@ -117,7 +121,7 @@ function cfAlias(alias: Record<string, string>, sourceOf: SourceOf): BunPlugin {
117
121
  setup(b) {
118
122
  if (Object.keys(alias).length === 0) return
119
123
  b.onResolve({ filter: /.*/ }, (a) => {
120
- if (a.importer === '' || a.importer.includes('/node_modules/')) return undefined
124
+ if (a.importer === '' || /[\\/]node_modules[\\/]/.test(a.importer)) return undefined
121
125
  let abs: string
122
126
  try {
123
127
  abs = Bun.resolveSync(a.path, dirname(sourceOf.get(a.importer) ?? a.importer))
@@ -287,10 +291,10 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
287
291
  target: 'browser',
288
292
  format: 'esm',
289
293
  conditions: CF_CONDITIONS,
290
- define: PRODUCTION,
294
+ define: workerDefine(ctx.manifest.base_path),
291
295
  minify: false,
292
296
  external: ['node:*', 'cloudflare:*', glueSpec],
293
- 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)],
294
298
  })
295
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`)
296
300
  if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
@@ -302,7 +306,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
302
306
  writeFileSync(appFile, await js[0]!.text())
303
307
 
304
308
  // An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
305
- 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)
306
310
  const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
307
311
  if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
308
312
  if (doClass) {
@@ -4,6 +4,7 @@
4
4
  // A component several trees reach keeps one compile, chosen by role, not by route order (F75).
5
5
  import { readFileSync, realpathSync } from 'node:fs'
6
6
  import { dirname, relative, sep } from 'node:path'
7
+ import { nativeOf, relPosix } from './posix'
7
8
  import { type CompiledComponent, type CompiledTree, compileTree, scanLoadWrites } from '../../native/index.js'
8
9
  import type { FlatRoute, Route } from '../routes'
9
10
  import { BuildError } from './errors'
@@ -29,6 +30,8 @@ export type ComponentIR = Record<string, any> & {
29
30
  exprs?: Record<string, string>
30
31
  /** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
31
32
  rows_literal?: unknown[]
33
+ /** F502: the branch condition the server checks per instance before it runs the child's job. */
34
+ when?: unknown
32
35
  }[]
33
36
  /** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
34
37
  build_eval_instances?: {
@@ -196,7 +199,7 @@ export function compileApp(opts: {
196
199
  )
197
200
  let rootId = rootIdOfFile.get(file)
198
201
  if (!rootId) {
199
- const rel = relative(appRoot, file)
202
+ const rel = relPosix(appRoot, file) // G5: the Rust resolver assumes `/`
200
203
  const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson, sourceRoots, opts.loadWrites)
201
204
  if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
202
205
  for (const [i, c] of tree.components.entries()) {
@@ -226,7 +229,7 @@ export function compileApp(opts: {
226
229
  ir.stores ??= []
227
230
  compiled.set(c.id, {
228
231
  id: c.id,
229
- file: `${appRoot}/${c.source}`,
232
+ file: nativeOf(`${appRoot}/${c.source}`), // the spelling Bun.resolveSync returns: these are Map keys and `===` operands
230
233
  ir,
231
234
  jinja: c.jinja,
232
235
  serverTs: c.serverTs,
@@ -22,7 +22,9 @@ 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'
27
+ import { posixOf } from './posix'
26
28
 
27
29
  // [actions] (opt-in, ruling R6): present table => actions on. Both byte caps cross napi as u32 (F82: 1..64 MiB / 256 KiB..64 MiB).
28
30
  // [actions]
@@ -107,6 +109,8 @@ export interface BuildConfig {
107
109
  cssGlobal: string[]
108
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. */
109
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
110
114
  }
111
115
 
112
116
  const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -273,7 +277,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
273
277
  }
274
278
  }
275
279
 
276
- function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
280
+ function parseActions(file: string, raw: unknown, actionsOnly: boolean, basePath = ''): ActionsBuildConfig | null {
277
281
  if (raw === undefined) return null
278
282
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
279
283
  const t = raw as Record<string, unknown>
@@ -282,12 +286,16 @@ function parseActions(file: string, raw: unknown, actionsOnly: boolean): Actions
282
286
  if (typeof t.entry !== 'string' || t.entry.trim() === '') throw new BuildError('config', `${file}: actions.entry must be a non-empty string`)
283
287
  entry = t.entry.trim().replace(/^\.\//, '')
284
288
  }
285
- 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}`
286
291
  if (t.prefix !== undefined) {
287
292
  // `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
288
293
  const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
289
294
  if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
290
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)}`)
291
299
  }
292
300
  let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
293
301
  if (t.max_body_bytes !== undefined) {
@@ -396,10 +404,20 @@ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): s
396
404
  return [...out].sort()
397
405
  }
398
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
+
399
417
  export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
400
418
  const file = join(appRoot, 'brust.toml')
401
419
  const f = Bun.file(file)
402
- 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: '' }
403
421
  let parsed: unknown
404
422
  try {
405
423
  parsed = Bun.TOML.parse(await f.text())
@@ -407,17 +425,18 @@ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolea
407
425
  throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
408
426
  }
409
427
  const root = (parsed ?? {}) as Record<string, unknown>
410
- 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)
411
430
  const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
412
431
  const cache = await parseCache(file, appRoot, root.cache)
413
432
  const cssGlobal = parseCss(file, root.css)
414
433
  const build = root.build
415
- if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
434
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [], basePath }
416
435
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
417
436
  const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
418
437
  const so = (build as Record<string, unknown>).server_only
419
- if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
438
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
420
439
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
421
440
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
422
- return { serverOnly: so.map((p: string) => 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 }
423
442
  }
package/src/build/css.ts CHANGED
@@ -122,9 +122,27 @@ const ASSET_FILE = /\.(?:woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)$/i
122
122
 
123
123
  const SOURCE_URL = /url\(\s*(["']?)([^"')]+?)\1\s*\)/g
124
124
 
125
+ /** A `url()` value as the file system spells it: CSS escapes undone (Bun prints a Windows path as `C:\\Users\\…`). */
126
+ /** CSS Syntax 4.3.7: an escaped code point that is zero, a surrogate or above U+10FFFF is U+FFFD (never a RangeError). */
127
+ const cssCodePoint = (n: number): number => (n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? 0xfffd : n)
128
+
129
+ export function cssUnescape(s: string): string {
130
+ return s.replace(/\\(?:([0-9a-fA-F]{1,6})\s?|([^\n0-9a-fA-F]))/g, (_m, h: string | undefined, c: string | undefined) =>
131
+ h !== undefined ? String.fromCodePoint(cssCodePoint(Number.parseInt(h, 16))) : c!,
132
+ )
133
+ }
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
+
139
+ /** F490: an absolute local path in either spelling: POSIX `/…`, a Windows drive `C:\…` / `C:/…`, or a UNC `\\host\…`. */
140
+ export const localAbsolute = (p: string): boolean => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
141
+
125
142
  /** F393: a `url()` that is the absolute path of a file on disk (see the `loader` in `buildCss`) becomes a
126
- * hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written. */
127
- function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>): string {
143
+ * hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written.
144
+ * F490: on Windows that path is `C:\\…` (CSS-escaped), so the value is unescaped and both spellings are taken. */
145
+ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>, base = ''): string {
128
146
  const emit = (path: string, suffix: string) => {
129
147
  const { name, bytes } = hashedAsset(path)
130
148
  if (!assets.some((a) => a.name === name)) assets.push({ name, bytes })
@@ -133,12 +151,29 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
133
151
  return text.replace(SOURCE_URL, (whole, _q: string, path: string) => {
134
152
  const abs = suffixed.get(path)
135
153
  if (abs !== undefined) return emit(abs, path.slice(path.search(/[?#]/)))
136
- const ext = extname(path).toLowerCase()
137
- if (!path.startsWith('/') || !SERVED_ASSET_EXT.includes(ext) || path.startsWith('/public/') || path.startsWith('/_brust/') || !existsSync(path)) return whole
138
- return emit(path, '')
154
+ const file = cssUnescape(path)
155
+ const ext = extname(file).toLowerCase()
156
+ if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || brustUrl(file, base) || !existsSync(file)) return whole
157
+ return emit(file, '')
139
158
  })
140
159
  }
141
160
 
161
+ /** F490 fail closed: a `url()` left in the built CSS that still names a file on this machine would ship the machine's
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
163
+ * the app root or the dist dir (`roots`). Any other root URL passes, even one that happens to exist on the build
164
+ * machine (`url(/tmp/x)`, `url(/fonts/x.woff2)`): only the app's own files are known to be local. */
165
+ export function leakedLocalPaths(text: string, roots: string[], base = ''): string[] {
166
+ const out: string[] = []
167
+ for (const m of text.matchAll(SOURCE_URL)) {
168
+ const v = cssUnescape(m[2]!)
169
+ if (brustUrl(v, base) || !localAbsolute(v)) continue
170
+ const posix = v.replaceAll('\\', '/')
171
+ const local = !v.startsWith('/') || roots.some((r) => posix === r || posix.startsWith(`${r}/`))
172
+ if (local && !out.includes(v)) out.push(v)
173
+ }
174
+ return out
175
+ }
176
+
142
177
  /** F393: a `url()` asset with a `?query` / `#fragment` (`?v=3`, `?#iefix`, an SVG font's `#id`) is not given
143
178
  * Bun's path form: a plugin-resolved specifier is printed as written. This plugin resolves such a specifier
144
179
  * itself and records the file it names, so `emitSourceAssets` can copy it and keep the suffix verbatim. The
@@ -172,7 +207,7 @@ export async function buildCss(
172
207
  dist: string,
173
208
  sources: string[],
174
209
  minify = true,
175
- o: { appRoot?: string; log?: (s: string) => void } = {},
210
+ o: { appRoot?: string; log?: (s: string) => void; basePath?: string; ownOutput?: string[] } = {},
176
211
  ): Promise<CssBuild> {
177
212
  const file = new Map<string, string>()
178
213
  const maps = new Map<string, Record<string, string>>()
@@ -189,7 +224,9 @@ export async function buildCss(
189
224
  if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
190
225
  return hit
191
226
  }
192
- 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)]))
193
230
  if (tw.imports.length > 1)
194
231
  o.log?.(
195
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`,
@@ -210,7 +247,7 @@ export async function buildCss(
210
247
  format: 'esm',
211
248
  minify,
212
249
  naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
213
- external: ['/public/*', '/_brust/*'],
250
+ external: ['/public/*', '/_brust/*', ...(o.basePath ? [`${o.basePath}/public/*`, `${o.basePath}/_brust/*`] : [])],
214
251
  root: appRoot,
215
252
  plugins,
216
253
  // F393, verified on Bun 1.4.4 only: its CSS bundler turns an asset under the size threshold into a `data:`
@@ -258,7 +295,11 @@ export async function buildCss(
258
295
  `${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
259
296
  )
260
297
  }
261
- 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))
299
+ const roots = [appRoot, dist].flatMap((r) => [r, real(r)]).map((r) => r.replaceAll('\\', '/').replace(/\/+$/, '')).filter((r) => r !== '')
300
+ const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots, o.basePath).map((p) => `url(${p}) in ${sources[i]}`))
301
+ if (leaks.length > 0)
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/…`)
262
303
  for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
263
304
  for (const [i, src] of sources.entries()) {
264
305
  let text = css.get(i)
@@ -316,9 +357,9 @@ export function tailwindUse(sources: string[]): { using: string[]; imports: stri
316
357
  * (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
317
358
  * stylesheet itself is linked by the server (R19.3), never bundled into JS.
318
359
  * F411: with `clientDir` (`<dist>/client`), an import of a served asset (`SERVED_ASSET_EXT`: a font, an image) is
319
- * 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
320
361
  * file is written there, so the URL can be preloaded (`<link rel="preload" as="font" href={font}>`). */
321
- 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 {
322
363
  return {
323
364
  name: 'brust-css',
324
365
  setup(b) {
@@ -336,7 +377,7 @@ export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?:
336
377
  mkdirSync(clientDir, { recursive: true })
337
378
  writeFileSync(to, bytes)
338
379
  }
339
- 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' }
340
381
  })
341
382
  },
342
383
  }
@@ -10,6 +10,7 @@ import { createHash } from 'node:crypto'
10
10
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
11
11
  import { dirname, join, sep } from 'node:path'
12
12
  import { BuildError } from './errors'
13
+ import { nativeOf } from './posix'
13
14
 
14
15
  /** A specifier relative to its importer: `./x`, `../x` and the bare `.` / `..` (a directory). The one test every resolver path uses (F218, F244). */
15
16
  export const RELATIVE_SPEC = /^\.\.?(\/|$)/
@@ -46,11 +47,14 @@ export function clearResolveCache(): void {
46
47
  const PROBE = `
47
48
  const [target, entry, spec, define, conditions] = process.argv.slice(1)
48
49
  const source = 'import ' + JSON.stringify(spec) + '\\n'
50
+ // F490: on Windows the virtual entry comes back to onLoad as C:/…/__brust_resolve__.ts while argv spells it C:\\…, so
51
+ // the two compare in one spelling; else the entry itself was taken for the answer.
52
+ const entryPosix = entry.replaceAll('\\\\', '/')
49
53
  let found
50
54
  await Bun.build({
51
55
  entrypoints: [entry], target, write: false, define: JSON.parse(define), conditions: JSON.parse(conditions), files: { [entry]: source },
52
56
  plugins: [{ name: 'o', setup(b) { b.onLoad({ filter: /./ }, (a) => {
53
- if (a.path === entry) return { contents: source, loader: 'ts' }
57
+ if (a.path.replaceAll('\\\\', '/') === entryPosix) return { contents: source, loader: 'ts' }
54
58
  found ??= a.path
55
59
  return { contents: '', loader: 'js' }
56
60
  }) } }],
@@ -77,7 +81,8 @@ export function resolveNative(spec: string, from: string, target: ResolveTarget,
77
81
  const timeout = opts.timeoutMs ?? RESOLVE_TIMEOUT_MS
78
82
  const r = Bun.spawnSync([process.execPath, '-e', PROBE, target, join(from, '__brust_resolve__.ts'), spec, defs, conds], { cwd: opts.appRoot ?? from, stdout: 'pipe', stderr: 'pipe', timeout, killSignal: 'SIGKILL' })
79
83
  const out = r.exitCode === 0 ? r.stdout.toString() : ''
80
- let path = out
84
+ // F490: one spelling out of the resolver, the platform's (a file system path; POSIX: unchanged).
85
+ let path = nativeOf(out)
81
86
  if (path === '') {
82
87
  // The probe did not answer: today's answer (`Bun.resolveSync`) stands, and the build output says why.
83
88
  const why = r.exitCode === null || r.signalCode ? `the probe did not answer within ${timeout} ms` : r.exitCode !== 0 ? `the probe failed (${r.stderr.toString().trim().split('\n')[0] || `exit ${r.exitCode}`})` : 'the probe resolved nothing'
@@ -5,11 +5,12 @@
5
5
  // a previous brust dist (no `manifest.json` / `.brust` marker; `--force` overrides), is refused
6
6
  // before anything is written or removed.
7
7
  import { createHash } from 'node:crypto'
8
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
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'
@@ -19,6 +20,8 @@ import { type Compiled, compileApp, scanAppWrites } from './compile'
19
20
  import { buildCss, CssGraph, directCss, resolveGlobals, routeCssSources } from './css'
20
21
  import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
21
22
  import { BuildError } from './errors'
23
+ import { escapes, relPosix } from './posix'
24
+ import { swapDist } from './swap'
22
25
  import { importsStore } from './features'
23
26
  import { type CacheRecord, type ManifestJson, needsWorkers, reachedJobs, tierOf, writeManifest } from './manifest'
24
27
  import { scanRoutes } from './scan'
@@ -83,6 +86,14 @@ function statOk(p: string): boolean {
83
86
  }
84
87
  }
85
88
 
89
+ function realRoot(p: string): string {
90
+ try {
91
+ return realpathSync(p)
92
+ } catch {
93
+ return p
94
+ }
95
+ }
96
+
86
97
  export async function runBuild(opts: {
87
98
  appRoot: string
88
99
  entry: string
@@ -96,6 +107,8 @@ export async function runBuild(opts: {
96
107
  strict?: boolean
97
108
  /** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
98
109
  exprReads?: ExprReads
110
+ /** The router's refusal of a pattern set (F496; tests swap it). Default: the addon. */
111
+ checkPatterns?: CheckPatterns
99
112
  /** `--wasm-from-source` (cloudflare only). */
100
113
  wasmFromSource?: boolean
101
114
  /** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
@@ -105,15 +118,19 @@ export async function runBuild(opts: {
105
118
  log: (s: string) => void
106
119
  }): Promise<void> {
107
120
  setDevBuild(opts.dev === true)
108
- const appRoot = resolve(opts.appRoot)
121
+ // The app root is the real path: Windows keeps a symlinked cwd as the link, and `relative(link, file)` across drives is
122
+ // an absolute path that every `join(appRoot, rel)` then glues onto the link (N2). POSIX cwd is already real.
123
+ const appRoot = realRoot(resolve(opts.appRoot))
109
124
  const entry = resolve(appRoot, opts.entry)
110
125
  const dist = resolve(appRoot, opts.outDir)
111
126
  assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
112
127
  assertReplaceable(dist, opts.force)
113
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
114
131
  if (opts.actionsOnly)
115
132
  return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
116
- const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots } = built
133
+ const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots, basePath } = built
117
134
  // L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
118
135
  // is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
119
136
  const remote =
@@ -126,15 +143,18 @@ export async function runBuild(opts: {
126
143
  let leaves: ReturnType<typeof validateRoutes>
127
144
  try {
128
145
  leaves = validateRoutes(routes)
146
+ checkBasePath(leaves, basePath)
129
147
  } catch (e) {
130
148
  if (e instanceof BrustRouteError) throw new BuildError(e.rule, e.message)
131
149
  throw e
132
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()))
133
153
  let actionsEntry: string | null = null
134
154
  if (actions) {
135
155
  actionsEntry = resolve(appRoot, actions.entry)
136
156
  const rel = relative(appRoot, actionsEntry)
137
- if (isAbsolute(actions.entry) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(actionsEntry)) {
157
+ if (isAbsolute(actions.entry) || escapes(rel) || !statOk(actionsEntry)) {
138
158
  throw new BuildError('actions-entry', `actions.entry ${actions.entry} is not a file inside the app`)
139
159
  }
140
160
  // F470: a route under the prefix answers GET and HEAD of its own paths, the actions app everything else there. The
@@ -155,7 +175,7 @@ export async function runBuild(opts: {
155
175
  if (cache?.module !== undefined) {
156
176
  cacheModule = resolve(appRoot, cache.module)
157
177
  const rel = relative(appRoot, cacheModule)
158
- if (isAbsolute(cache.module) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(cacheModule)) {
178
+ if (isAbsolute(cache.module) || escapes(rel) || !statOk(cacheModule)) {
159
179
  throw new BuildError('cache', `cache.module ${cache.module} is not a file inside the app`)
160
180
  }
161
181
  }
@@ -164,7 +184,7 @@ export async function runBuild(opts: {
164
184
  // R9.3/R9.4: every reason to refuse a static build, before any write.
165
185
  const ssgPlan =
166
186
  opts.target === 'static'
167
- ? 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)
168
188
  : null
169
189
 
170
190
  // Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
@@ -178,7 +198,7 @@ export async function runBuild(opts: {
178
198
  rmSync(tmp, { recursive: true, force: true })
179
199
  mkdirSync(tmp, { recursive: true })
180
200
  try {
181
- 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 })
182
202
  if (ssgPlan) {
183
203
  // Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
184
204
  const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
@@ -186,13 +206,7 @@ export async function runBuild(opts: {
186
206
  if (hybrid && cf && actions) writeStaticWorker(tmp, actions, cf, opts.log)
187
207
  }
188
208
  rmSync(join(tmp, '.stage'), { recursive: true, force: true })
189
- if (existsSync(dist)) {
190
- const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
191
- rmSync(old, { recursive: true, force: true })
192
- renameSync(dist, old)
193
- renameSync(tmp, dist)
194
- rmSync(old, { recursive: true, force: true })
195
- } else renameSync(tmp, dist)
209
+ swapDist(tmp, dist)
196
210
  } catch (e) {
197
211
  rmSync(tmp, { recursive: true, force: true })
198
212
  throw e
@@ -227,6 +241,8 @@ async function buildInto(
227
241
  cssGlobal: string[]
228
242
  /** `[build] source_roots` (F256): normalised, app-root-relative directories outside the app a relative import may reach. */
229
243
  sourceRoots: string[]
244
+ /** `[server] base_path` (F495), `''` = the root. */
245
+ basePath: string
230
246
  /** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
231
247
  actionsRemote: string | null
232
248
  /** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
@@ -242,13 +258,13 @@ async function buildInto(
242
258
  const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
243
259
  mkdirSync(join(out, 'client'), { recursive: true })
244
260
  // Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
245
- 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')] })
246
262
  for (const c of directCss(entry))
247
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`)
248
- 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 }
249
265
  // The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
250
266
  const runtime = await buildRuntime(out, ctx)
251
- const runtimeUrls = { core: `/_brust/${runtime.core}`, store: `/_brust/${runtime.store}` }
267
+ const runtimeUrls = { core: `${brustRoot(ctx)}/${runtime.core}`, store: `${brustRoot(ctx)}/${runtime.store}` }
252
268
  // F230: one scan per build for load-time writes into another app module's exports; every compile reads it.
253
269
  const loadWrites = scanAppWrites(appRoot, o.appModules, o.log, o.sourceRoots)
254
270
  // Where a React island renders, the load-time writers run first: every one in the SSR jobs bundle (server code the
@@ -277,12 +293,12 @@ async function buildInto(
277
293
  }
278
294
  const out = writes.filter((w) => [...w.mods].some((m) => reached.has(m)))
279
295
  for (const w of out) {
280
- const mods = [...w.mods].filter((m) => reached.has(m)).map((m) => relative(appRoot, m)).join(', ')
281
- o.log(`[brust] warning load-writer-in-client ${relative(appRoot, w.file)} is imported into the browser chunk of the React island ${c.id}: it changes ${mods} when it loads, and the island reads that module (F230). Its code ships to the browser: keep secrets and server-only code out of it, or write the value in the module that declares it`)
296
+ const mods = [...w.mods].filter((m) => reached.has(m)).map((m) => relPosix(appRoot, m)).join(', ')
297
+ o.log(`[brust] warning load-writer-in-client ${relPosix(appRoot, w.file)} is imported into the browser chunk of the React island ${c.id}: it changes ${mods} when it loads, and the island reads that module (F230). Its code ships to the browser: keep secrets and server-only code out of it, or write the value in the module that declares it`)
282
298
  // F480: a server-only import reached through this writer names the chain in `server-only-in-client`.
283
299
  ctx.loadWriters ??= new Map()
284
300
  if (!ctx.loadWriters.has(w.file))
285
- ctx.loadWriters.set(w.file, `${relative(appRoot, w.file)} is loaded into the React island ${c.id} because it is judged to write ${mods} when it loads: F230 load writes`)
301
+ ctx.loadWriters.set(w.file, `${relPosix(appRoot, w.file)} is loaded into the React island ${c.id} because it is judged to write ${mods} when it loads: F230 load writes`)
286
302
  }
287
303
  return [...new Set(out.map((w) => w.file))]
288
304
  }
@@ -340,10 +356,12 @@ async function buildInto(
340
356
  const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
341
357
  if (files.length > 0) r.css = files
342
358
  }
343
- 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
344
362
  manifest.actions =
345
363
  o.actions && o.actionsEntry
346
- ? { 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 }
347
365
  : null
348
366
  if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
349
367
  if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
@@ -376,7 +394,7 @@ async function buildInto(
376
394
  )
377
395
  for (const w of warnings) o.log(w)
378
396
  }
379
- const entryRel = relative(o.dist, entry)
397
+ const entryRel = relPosix(o.dist, entry) // `/`-spelled: a dist built on Windows runs on Linux (join() below re-spells it)
380
398
  writeFileSync(
381
399
  join(out, 'index.js'),
382
400
  [
@@ -399,7 +417,7 @@ export function devJson(appRoot: string, compiled: Map<string, Compiled>): { ver
399
417
  const tier = tierOf(c.ir)
400
418
  const t = c.ir.tier
401
419
  components[c.id] = {
402
- file: relative(appRoot, c.file),
420
+ file: relPosix(appRoot, c.file),
403
421
  tier,
404
422
  ...(tier === 'react' && t && typeof t === 'object' && 'React' in t ? { reason: String((t as { React: { reason: string } }).React.reason) } : {}),
405
423
  diagnostics: (c.diagnostics ?? []).map((d) => ({ class: d.class, rule: d.rule, line: d.line, col: d.col, message: d.message, remediation: d.remediation })),