@brust/core 2.0.0-alpha.14 → 2.0.0-alpha.16

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.
@@ -4,7 +4,7 @@
4
4
  // A component several trees reach keeps one compile, chosen by role, not by route order (F75).
5
5
  import { realpathSync } from 'node:fs'
6
6
  import { relative, sep } from 'node:path'
7
- import { type CompiledComponent, type CompiledTree, compileTree } from '../../native/index.js'
7
+ import { type CompiledComponent, type CompiledTree, compileTree, scanLoadWrites } from '../../native/index.js'
8
8
  import type { FlatRoute, Route } from '../routes'
9
9
  import { BuildError } from './errors'
10
10
  import { checkAliasAgreement, readPathAliases } from './tsconfig-paths'
@@ -51,6 +51,9 @@ export type ComponentIR = Record<string, any> & {
51
51
  stores?: { handle: string; resolved: { name: string; module: string } | null }[]
52
52
  /** A package export rendered as a React island (L22): `source` is then not a file. */
53
53
  external?: { source: string; export: string; member?: string[] }
54
+ /** A component compiled from a package source and written in app code as a package import (F188): the published
55
+ * export a React island or revive hook imports instead of the source. */
56
+ dist?: { source: string; export: string; member?: string[] }
54
57
  /** The binding a React island imports this component by from `source` (absent: the default export). */
55
58
  export?: string
56
59
  /** Child components; `nested` is set when this file passes lowered JSX children to that child, an island (shadcn S1). */
@@ -67,6 +70,8 @@ export interface Compiled {
67
70
  /** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
68
71
  evalTs?: string
69
72
  clientJs?: string
73
+ /** Modules the chunk imports that other chunks share (R34.124): `<name>.js` beside it. */
74
+ clientModules?: { name: string; code: string }[]
70
75
  /** The compiler's diagnostics for this component (`dev.json`, L14). */
71
76
  diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string }[]
72
77
  }
@@ -109,6 +114,25 @@ export function replaces(kept: Seen, next: Seen): boolean {
109
114
  return next.role > kept.role
110
115
  }
111
116
 
117
+ /**
118
+ * F230 (closes F219): scans the app's modules once for writes into another app module's exports (`LABELS.a = …`,
119
+ * `list.push(…)` through an import) and returns the JSON every `compileTree` of the build reads: a component whose
120
+ * compiled code reads such an export falls back to React (`module-written`). `modules` are absolute paths.
121
+ */
122
+ export function scanAppWrites(appRoot: string, modules: string[], log: (s: string) => void): string {
123
+ // Keyed as `compileApp` names a file to the compiler (relative to `appRoot`), plus the real-path spelling (as
124
+ // `routeRoots`), so an import resolves to the same key either way.
125
+ const real = realpathSync(appRoot)
126
+ const toKey = (base: string, f: string) => relative(base, f).split(sep).join('/')
127
+ const rel = [...new Set(modules.flatMap((f) => [toKey(appRoot, f), toKey(real, realpathSync(f))]))].filter((k) => !k.startsWith('..')).sort()
128
+ const { aliases } = readPathAliases(appRoot)
129
+ const t0 = performance.now()
130
+ const json = scanLoadWrites(appRoot, rel, aliases.length > 0 ? JSON.stringify(aliases) : undefined)
131
+ const ms = performance.now() - t0
132
+ if (process.env.BRUST_BUILD_TIMINGS) log(`[brust] scan load-time writes: ${rel.length} modules in ${ms.toFixed(1)} ms`)
133
+ return json
134
+ }
135
+
112
136
  export function compileApp(opts: {
113
137
  appRoot: string
114
138
  leaves: FlatRoute[]
@@ -128,11 +152,16 @@ export function compileApp(opts: {
128
152
  slotValues?: Record<string, unknown>
129
153
  /** F153c: the package sources this build wrote (`pkg-src.ts`); absent = package imports are islands. */
130
154
  packages?: PackageSources
155
+ /** F256: `[build] source_roots`, normalised and relative to the app root (`../shared`). */
156
+ sourceRoots?: string[]
157
+ /** F230: `scanAppWrites`'s result; absent = no module is refused for a load-time write. */
158
+ loadWrites?: string
131
159
  }): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
132
160
  const { appRoot, leaves, componentFile } = opts
133
161
  const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
134
162
  const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
135
163
  const packagesJson = opts.packages ? JSON.stringify(opts.packages) : undefined
164
+ const sourceRoots = opts.sourceRoots && opts.sourceRoots.length > 0 ? opts.sourceRoots : undefined
136
165
  // The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
137
166
  const { aliases, notes } = readPathAliases(appRoot)
138
167
  for (const n of notes) opts.log(`warning tsconfig-paths ${n}`)
@@ -160,7 +189,7 @@ export function compileApp(opts: {
160
189
  let rootId = rootIdOfFile.get(file)
161
190
  if (!rootId) {
162
191
  const rel = relative(appRoot, file)
163
- const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson)
192
+ const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson, sourceRoots, opts.loadWrites)
164
193
  if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
165
194
  for (const [i, c] of tree.components.entries()) {
166
195
  // A lowering Error that came back as a diagnostic is a build error too (contract 9).
@@ -195,6 +224,7 @@ export function compileApp(opts: {
195
224
  serverTs: c.serverTs,
196
225
  evalTs: c.evalTs,
197
226
  clientJs: c.clientJs,
227
+ clientModules: c.clientModules?.length ? c.clientModules : undefined,
198
228
  diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
199
229
  })
200
230
  }
@@ -1,9 +1,11 @@
1
1
  // Build-time settings from `brust.toml` `[build]` (the runtime sections are `../config.ts`):
2
2
  // [build]
3
3
  // server_only = ["lib/server", "@acme/db"] # import prefixes / app-root-relative paths
4
+ // source_roots = ["../shared"] # F256: directories besides the app a relative import may reach (monorepo)
4
5
  // `server_only` is the app config `serverOnly` of the compiler spec (§8.2): the compiler refuses
5
6
  // such an import in client code, and every browser bundle refuses to include it.
6
- import { join } from 'node:path'
7
+ import { statSync } from 'node:fs'
8
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
7
9
  import { ACTION_CREDENTIALS, type ActionCredentials, DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, baseUrlError, prefixError } from '../actions/prefix'
8
10
  import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
9
11
  import { BuildError } from './errors'
@@ -76,6 +78,8 @@ export interface BuildConfig {
76
78
  cache: CacheBuildConfig | null
77
79
  /** `[css] global` (L19, R19.1): app-root-relative stylesheets every route links first, in order. */
78
80
  cssGlobal: string[]
81
+ /** `[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. */
82
+ sourceRoots: string[]
79
83
  }
80
84
 
81
85
  const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -306,10 +310,43 @@ function parseCss(file: string, raw: unknown): string[] {
306
310
  return g.map((p: string) => p.trim())
307
311
  }
308
312
 
313
+ /**
314
+ * `[build] source_roots` (F256): extra directories, relative to the app root, that a relative import may reach besides
315
+ * the app itself (`../shared` in a monorepo). Each is normalised (`../a/../shared` = `../shared`), must exist and be a
316
+ * directory, and confines imports the way the app root does: lexically, so `../shared/../secret` is outside it.
317
+ * An entry inside the app adds nothing and is dropped; the app root itself or an ancestor of it is refused (F272). Returns the roots that lie outside, `/`-separated.
318
+ */
319
+ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): string[] {
320
+ if (raw === undefined) return []
321
+ if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string' || p.trim() === '' || p.includes('\0')))
322
+ throw new BuildError('config', `${file}: build.source_roots must be an array of non-empty strings (paths relative to the app root)`)
323
+ const out = new Set<string>()
324
+ for (const p of raw as string[]) {
325
+ if (isAbsolute(p.trim())) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} must be relative to the app root`)
326
+ const abs = resolve(appRoot, p.trim())
327
+ let dir = false
328
+ try {
329
+ dir = statSync(abs).isDirectory()
330
+ } catch {}
331
+ if (!dir) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} (${abs}) is not an existing directory`)
332
+ if (abs === resolve(abs, '..')) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} is the filesystem root`)
333
+ const rel = relative(appRoot, abs).split(sep).join('/')
334
+ // F272: the app root or an ancestor of it (`.`, `..`, `../..`) contains every import the app can make, so it would
335
+ // switch F243's confinement off for that whole level; name the directory that is actually shared instead.
336
+ if (rel === '' || rel.split('/').every((seg) => seg === '..'))
337
+ throw new BuildError(
338
+ 'config',
339
+ `${file}: build.source_roots entry ${JSON.stringify(p)} is the app root or a directory that contains it, which would allow every relative import; list the shared directory itself (e.g. "../shared")`,
340
+ )
341
+ if (rel === '..' || rel.startsWith('../')) out.add(rel)
342
+ }
343
+ return [...out].sort()
344
+ }
345
+
309
346
  export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
310
347
  const file = join(appRoot, 'brust.toml')
311
348
  const f = Bun.file(file)
312
- if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [] }
349
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [] }
313
350
  let parsed: unknown
314
351
  try {
315
352
  parsed = Bun.TOML.parse(await f.text())
@@ -322,11 +359,12 @@ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolea
322
359
  const cache = await parseCache(file, appRoot, root.cache)
323
360
  const cssGlobal = parseCss(file, root.css)
324
361
  const build = root.build
325
- if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
362
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
326
363
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
364
+ const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
327
365
  const so = (build as Record<string, unknown>).server_only
328
- if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
366
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
329
367
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
330
368
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
331
- return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal }
369
+ return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots }
332
370
  }
@@ -0,0 +1,170 @@
1
+ // F189: `Bun.resolveSync` answers the CJS `main` of a package that has both (recharts: `main` is a CJS build, `module`
2
+ // the ES build), so a package resolved from a directory the bundler does not walk (a package's own, for what its source
3
+ // imports) became a second, CJS copy beside the ESM one every other island imports. Re-implementing the bundler's
4
+ // choice drifts (conditions per target, the `browser` field, `development`, `module` before `import`), so this asks
5
+ // the bundler itself: a throwaway `Bun.build` of a virtual file placed in `from` that imports `spec`, with the same
6
+ // `target`; the first module the build loads from outside that file is the answer. Equal to the native pick by
7
+ // construction. It runs in a child process: a `Bun.build` started inside a plugin callback of another build deadlocks.
8
+ // Anything it cannot resolve falls back to `Bun.resolveSync`, which is today's answer, and says so (F222).
9
+ import { createHash } from 'node:crypto'
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join, sep } from 'node:path'
12
+ import { BuildError } from './errors'
13
+
14
+ /** A specifier relative to its importer: `./x`, `../x` and the bare `.` / `..` (a directory). The one test every resolver path uses (F218, F244). */
15
+ export const RELATIVE_SPEC = /^\.\.?(\/|$)/
16
+
17
+ export type ResolveTarget = 'bun' | 'browser'
18
+
19
+ /** What a build besides `target` and `define` tells the bundler about resolution, and where the probe runs. */
20
+ export interface ResolveOpts {
21
+ /** The build's `conditions` (`--target cloudflare`: workerd, worker, browser). Part of the answer, so part of the key. */
22
+ conditions?: string[]
23
+ /** The app root: the probe's working directory (F221) and the home of the cross-build cache (F224). */
24
+ appRoot?: string
25
+ /** Kills a probe that does not answer; default `RESOLVE_TIMEOUT_MS`. */
26
+ timeoutMs?: number
27
+ /** Where a fallback is named (F222); default: a `warning` line on stderr. */
28
+ warn?: (message: string) => void
29
+ }
30
+
31
+ /** `BRUST_RESOLVE_TIMEOUT_MS` or 30 s: a healthy probe answers in well under a second. */
32
+ export const RESOLVE_TIMEOUT_MS = Number(process.env.BRUST_RESOLVE_TIMEOUT_MS) > 0 ? Number(process.env.BRUST_RESOLVE_TIMEOUT_MS) : 30_000
33
+
34
+ const cache = new Map<string, string>()
35
+
36
+ /** Forgets the in-process answers and warnings (tests; the disk cache stays). */
37
+ export function clearResolveCache(): void {
38
+ cache.clear()
39
+ warned.clear()
40
+ disks.clear()
41
+ }
42
+
43
+ // argv: [target, entry, spec, define JSON, conditions JSON]; prints the resolved path. The define is the build's own:
44
+ // `NODE_ENV` flips Bun's `development` condition, so a probe without it picks another file than the build does. The
45
+ // same goes for `conditions`.
46
+ const PROBE = `
47
+ const [target, entry, spec, define, conditions] = process.argv.slice(1)
48
+ const source = 'import ' + JSON.stringify(spec) + '\\n'
49
+ let found
50
+ await Bun.build({
51
+ entrypoints: [entry], target, write: false, define: JSON.parse(define), conditions: JSON.parse(conditions), files: { [entry]: source },
52
+ plugins: [{ name: 'o', setup(b) { b.onLoad({ filter: /./ }, (a) => {
53
+ if (a.path === entry) return { contents: source, loader: 'ts' }
54
+ found ??= a.path
55
+ return { contents: '', loader: 'js' }
56
+ }) } }],
57
+ })
58
+ if (found) process.stdout.write(found)
59
+ `
60
+
61
+ const defaultWarn = (m: string) => console.warn(`warning resolve-fallback ${m}`)
62
+ const warned = new Set<string>()
63
+
64
+ /** `spec` as the bundler of `target` resolves it from the directory `from`. */
65
+ export function resolveNative(spec: string, from: string, target: ResolveTarget, define: Record<string, string> = {}, opts: ResolveOpts = {}): string {
66
+ const defs = JSON.stringify(define)
67
+ const conds = JSON.stringify(opts.conditions ?? [])
68
+ const key = `${target}\0${from}\0${spec}\0${defs}\0${conds}`
69
+ const hit = cache.get(key)
70
+ if (hit !== undefined) return hit
71
+ const disk = opts.appRoot !== undefined ? diskCache(opts.appRoot, `${target}\0${defs}\0${conds}`) : undefined
72
+ const saved = disk?.get(`${from}\0${spec}`, from)
73
+ if (saved !== undefined) {
74
+ cache.set(key, saved)
75
+ return saved
76
+ }
77
+ const timeout = opts.timeoutMs ?? RESOLVE_TIMEOUT_MS
78
+ 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
+ const out = r.exitCode === 0 ? r.stdout.toString() : ''
80
+ let path = out
81
+ if (path === '') {
82
+ // The probe did not answer: today's answer (`Bun.resolveSync`) stands, and the build output says why.
83
+ 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'
84
+ // F242: `Bun.resolveSync` answers with the bun/node conditions. For a browser-target build (client chunks, the Worker)
85
+ // that can be a node-only build of the package, so it is not an answer there.
86
+ if (target === 'browser') throw new BuildError('resolve-fallback', `cannot resolve "${spec}" from ${from} for a browser-target build: ${why}; the runtime resolver would pick the bun/node conditions, so there is no fallback`)
87
+ path = Bun.resolveSync(spec, from)
88
+ const msg = `${spec} from ${from} (${target}): ${why}; used Bun.resolveSync -> ${path}`
89
+ if (!warned.has(msg)) {
90
+ warned.add(msg)
91
+ ;(opts.warn ?? defaultWarn)(msg)
92
+ }
93
+ } else disk?.set(`${from}\0${spec}`, path, from)
94
+ cache.set(key, path)
95
+ return path
96
+ }
97
+
98
+ // F224: a probe is a child process plus a `Bun.build`: ~100-300 ms each, once per (package dir, specifier) in a build,
99
+ // and again in every build. The answers are kept across builds in `<appRoot>/.brust/resolve-cache.json`, valid for one
100
+ // lockfile and one Bun, per (target, define, conditions); an entry is dropped when its file is gone or the nearest
101
+ // `package.json` above it changed (a linked workspace package edits without touching the lockfile).
102
+ interface Saved { path: string; pkg: string; mtime: number; from: string; fromMtime: number }
103
+ const LOCKS = ['bun.lock', 'bun.lockb', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']
104
+ const disks = new Map<string, DiskCache | null>()
105
+
106
+ class DiskCache {
107
+ private entries: Record<string, Saved> = {}
108
+ constructor(private file: string, private salt: string, private appRoot: string) {
109
+ try {
110
+ const j = JSON.parse(readFileSync(file, 'utf8'))
111
+ if (j.salt === salt) this.entries = j.entries ?? {}
112
+ } catch {}
113
+ }
114
+ get(k: string, from: string): string | undefined {
115
+ const e = this.entries[k]
116
+ if (!e) return undefined
117
+ try {
118
+ // F245: the file is trusted as written, so every answer is re-validated: it must lie inside the app or in a
119
+ // `node_modules`, and still be a file; else the entry is dropped and the probe asks again.
120
+ const [fromPkg, fromMtime] = nearestPackageJson(from)
121
+ if (this.plausible(e.path) && statSync(e.path).isFile() && (e.pkg === '' || statSync(e.pkg).mtimeMs === e.mtime) && e.from === fromPkg && e.fromMtime === fromMtime) return e.path
122
+ } catch {}
123
+ delete this.entries[k]
124
+ return undefined
125
+ }
126
+ set(k: string, path: string, from: string): void {
127
+ const [pkg, mtime] = nearestPackageJson(dirname(path))
128
+ const [fromPkg, fromMtime] = nearestPackageJson(from)
129
+ this.entries[k] = { path, pkg, mtime, from: fromPkg, fromMtime }
130
+ try {
131
+ mkdirSync(dirname(this.file), { recursive: true })
132
+ const tmp = `${this.file}.${process.pid}.tmp`
133
+ writeFileSync(tmp, JSON.stringify({ salt: this.salt, entries: this.entries }))
134
+ renameSync(tmp, this.file)
135
+ } catch {}
136
+ }
137
+ private plausible(path: string): boolean {
138
+ return path.startsWith(this.appRoot + sep) || path.split(sep).includes('node_modules')
139
+ }
140
+ }
141
+
142
+ /** The nearest `package.json` at or above `dir` and its mtime (`['', 0]` when there is none). */
143
+ function nearestPackageJson(dir: string): [string, number] {
144
+ for (let d = dir; ; d = dirname(d)) {
145
+ const p = join(d, 'package.json')
146
+ if (existsSync(p)) return [p, statSync(p).mtimeMs]
147
+ if (dirname(d) === d) return ['', 0]
148
+ }
149
+ }
150
+
151
+ /** The cache for this app and (target, define, conditions), or none when no lockfile pins the install. */
152
+ function diskCache(appRoot: string, scope: string): DiskCache | undefined {
153
+ const id = `${appRoot}\0${scope}`
154
+ const have = disks.get(id)
155
+ if (have !== undefined) return have ?? undefined
156
+ let lock: string | undefined
157
+ for (let d = appRoot; lock === undefined; d = dirname(d)) {
158
+ lock = LOCKS.map((l) => join(d, l)).find((f) => existsSync(f))
159
+ if (dirname(d) === d) break
160
+ }
161
+ let made: DiskCache | null = null
162
+ if (lock !== undefined) {
163
+ try {
164
+ const salt = createHash('sha256').update(Bun.version).update(readFileSync(lock)).update(scope).digest('hex')
165
+ made = new DiskCache(join(appRoot, '.brust', `resolve-cache-${createHash('sha256').update(scope).digest('hex').slice(0, 8)}.json`), salt, appRoot)
166
+ } catch {}
167
+ }
168
+ disks.set(id, made)
169
+ return made ?? undefined
170
+ }
@@ -14,7 +14,7 @@ import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflar
14
14
  import { renderIcons } from './build-icons'
15
15
  import { evaluateBuildSlots } from './build-eval'
16
16
  import { preparePackageSources } from './pkg-src'
17
- import { type Compiled, compileApp } from './compile'
17
+ import { type Compiled, compileApp, scanAppWrites } from './compile'
18
18
  import { buildCss, CssGraph, directCss, resolveGlobals, routeCssSources } from './css'
19
19
  import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
20
20
  import { BuildError } from './errors'
@@ -112,7 +112,7 @@ export async function runBuild(opts: {
112
112
  const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
113
113
  if (opts.actionsOnly)
114
114
  return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
115
- const { serverOnly, cloudflare, cache, cssGlobal } = built
115
+ const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots } = built
116
116
  // L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
117
117
  // is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
118
118
  const remote =
@@ -121,7 +121,7 @@ export async function runBuild(opts: {
121
121
  if (actions?.prefix === '/') throw new BuildError('config', 'actions.prefix "/" is only for an actions-only build (brust build --actions-only) or a page-only build that points at one')
122
122
 
123
123
  // Everything that can fail on the app's own input runs before a byte is written.
124
- const { routes, componentFile, componentIssue } = await scanRoutes(entry, appRoot)
124
+ const { routes, componentFile, componentIssue, appModules, appGraph } = await scanRoutes(entry, appRoot)
125
125
  let leaves: ReturnType<typeof validateRoutes>
126
126
  try {
127
127
  leaves = validateRoutes(routes)
@@ -173,7 +173,7 @@ export async function runBuild(opts: {
173
173
  rmSync(tmp, { recursive: true, force: true })
174
174
  mkdirSync(tmp, { recursive: true })
175
175
  try {
176
- await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, actionsRemote: remote?.prefix ?? null, log: opts.log })
176
+ 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 })
177
177
  if (ssgPlan) {
178
178
  // Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
179
179
  const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
@@ -206,6 +206,10 @@ async function buildInto(
206
206
  leaves: ReturnType<typeof validateRoutes>
207
207
  componentFile: Map<Function, string>
208
208
  componentIssue: Map<Function, string>
209
+ /** F230: every app module of the route entry's static import graph (absolute paths). */
210
+ appModules: string[]
211
+ /** F230: each app module's in-root static imports (absolute paths). */
212
+ appGraph: Map<string, string[]>
209
213
  serverOnly: string[]
210
214
  actions: ActionsBuildConfig | null
211
215
  /** Absolute path of the actions entry (null when `[actions]` is off). */
@@ -216,6 +220,8 @@ async function buildInto(
216
220
  cf: CfPrepared | null
217
221
  /** `[css] global` as written (app-root relative). */
218
222
  cssGlobal: string[]
223
+ /** `[build] source_roots` (F256): normalised, app-root-relative directories outside the app a relative import may reach. */
224
+ sourceRoots: string[]
219
225
  /** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
220
226
  actionsRemote: string | null
221
227
  /** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
@@ -238,20 +244,54 @@ async function buildInto(
238
244
  // The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
239
245
  const runtime = await buildRuntime(out, ctx)
240
246
  const runtimeUrls = { core: `/_brust/${runtime.core}`, store: `/_brust/${runtime.store}` }
247
+ // F230: one scan per build for load-time writes into another app module's exports; every compile reads it.
248
+ const loadWrites = scanAppWrites(appRoot, o.appModules, o.log)
249
+ // Where a React island renders, the load-time writers run first: every one in the SSR jobs bundle (server code the
250
+ // worker runs anyway); in an island's browser chunk only a writer of a module that island's code imports (Mellow F1:
251
+ // a writer is often a server-side setup module), with a warning.
252
+ const scanned = JSON.parse(loadWrites) as { writers: [string, string[]][] }
253
+ const real = (f: string) => {
254
+ try {
255
+ return realpathSync(f)
256
+ } catch {
257
+ return f
258
+ }
259
+ }
260
+ const writes = scanned.writers.map(([w, mods]) => ({ file: real(resolve(appRoot, w)), mods: new Set(mods.map((m) => real(resolve(appRoot, m)))) }))
261
+ const writers = [...new Set(writes.map((w) => w.file))]
262
+ const appGraph = new Map([...o.appGraph].map(([f, deps]) => [real(f), deps.map(real)]))
263
+ const writersFor = (c: Compiled, files: string[]): string[] => {
264
+ if (writes.length === 0) return []
265
+ const reached = new Set<string>()
266
+ const stack = files.map(real)
267
+ while (stack.length > 0) {
268
+ const f = stack.pop()!
269
+ if (reached.has(f)) continue
270
+ reached.add(f)
271
+ stack.push(...(appGraph.get(f) ?? []))
272
+ }
273
+ const out = writes.filter((w) => [...w.mods].some((m) => reached.has(m)))
274
+ for (const w of out)
275
+ 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 ${[...w.mods].filter((m) => reached.has(m)).map((m) => relative(appRoot, m)).join(', ')} 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`)
276
+ return [...new Set(out.map((w) => w.file))]
277
+ }
241
278
  const compileOpts = {
242
279
  appRoot,
243
280
  leaves,
244
281
  componentFile: o.componentFile,
245
282
  componentIssue: o.componentIssue,
283
+ loadWrites,
246
284
  runtimeImport: runtimeUrls.core,
247
285
  serverOnly: o.serverOnly,
286
+ sourceRoots: o.sourceRoots,
248
287
  cssMaps: css.maps,
249
288
  }
250
289
  const firstLog: string[] = []
251
290
  let { compiled, routeComponent } = compileApp({ ...compileOpts, log: (l) => firstLog.push(l) })
252
291
  // F153c: the packages the app imports components from are compiled from their source when they publish it (and
253
292
  // it matches what they publish); the app is compiled again with those sources. A refused package stays the island.
254
- const external = [...new Set([...compiled.values()].flatMap((c) => (c.ir.external ? [c.ir.external.source] : [])))]
293
+ // F197: and the packages a helper is imported from, when the compiler asked to prove one plain.
294
+ const external = [...new Set([...compiled.values()].flatMap((c) => [...(c.ir.external ? [c.ir.external.source] : []), ...((c.ir.package_requests ?? []) as string[])]))]
255
295
  const packages = preparePackageSources(appRoot, external)
256
296
  if (Object.keys(packages.scopes).length > 0) {
257
297
  firstLog.length = 0
@@ -280,7 +320,7 @@ async function buildInto(
280
320
  mkdirSync(join(out, 'jinja'), { recursive: true })
281
321
  for (const c of compiled.values()) writeFileSync(join(out, 'jinja', `${c.id}.jinja`), c.jinja)
282
322
 
283
- const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrls)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrls, routeRoots))])
323
+ const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrls)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrls, routeRoots, writersFor))])
284
324
  const manifest = writeManifest({ leaves, routeComponent, compiled, runtime: { core: runtime.core, features: { ...runtime.features, store: runtime.store } }, chunks })
285
325
  // L33: a component whose chunk imports the store module (directly or through a shared chunk) has the page preload it.
286
326
  const readClient = (rel: string) => (existsSync(join(out, rel)) ? readFileSync(join(out, rel), 'utf8') : undefined)
@@ -289,7 +329,7 @@ async function buildInto(
289
329
  const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
290
330
  if (files.length > 0) r.css = files
291
331
  }
292
- await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest))
332
+ await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers)
293
333
  manifest.actions =
294
334
  o.actions && o.actionsEntry
295
335
  ? { entry: await buildActions(out, o.actionsEntry, css.maps), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite }
@@ -19,6 +19,7 @@ import { createHash } from 'node:crypto'
19
19
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
20
20
  import { dirname, join, posix, relative, resolve, sep } from 'node:path'
21
21
  import { BuildError } from './errors'
22
+ import { RELATIVE_SPEC } from './esm-resolve'
22
23
 
23
24
  /** What the compiler reads (`AnalyzeOptions.packages`): per importing scope (`""` = the app, else the root-relative
24
25
  * directory of a written package), each bare specifier → the root-relative path of its written entry source, or the
@@ -353,9 +354,11 @@ function writePackage(appRoot: string, dir: string, budget: { bytes: number }):
353
354
  // The JSX transform's own runtime import (the transpiler adds it), and React itself.
354
355
  if (NEVER(im.path)) continue
355
356
  if (im.kind === 'dynamic-import' || im.kind === 'require-call' || im.kind === 'require-resolve') return { ok: false, reason: `${f.path} has a ${im.kind} (${im.path})` }
356
- if (im.path.startsWith('./') || im.path.startsWith('../')) {
357
+ if (RELATIVE_SPEC.test(im.path)) {
357
358
  const base = posix.normalize(posix.join(posix.dirname(f.path), im.path))
358
- const hit = [base, `${base}.tsx`, `${base}.ts`, `${base}/index.tsx`, `${base}/index.ts`].find((c) => have.has(c))
359
+ // `.` and `..` name a directory (F244): only its index answers, never a sibling file of the same name.
360
+ const dirOnly = im.path === '.' || im.path === '..'
361
+ const hit = [...(dirOnly ? [] : [base, `${base}.tsx`, `${base}.ts`]), posix.join(base, 'index.tsx'), posix.join(base, 'index.ts')].find((c) => have.has(c))
359
362
  if (hit) imported.add(hit)
360
363
  else return { ok: false, reason: `${f.path} imports ${im.path}, which the source map does not carry` }
361
364
  } else if (im.path.startsWith('/') || /^[a-z]+:/i.test(im.path)) return { ok: false, reason: `${f.path} imports ${im.path}` }
package/src/build/scan.ts CHANGED
@@ -10,7 +10,7 @@ import { installServerOnlyRuntime } from './server-only'
10
10
  export async function scanRoutes(
11
11
  entryFile: string,
12
12
  appRoot?: string,
13
- ): Promise<{ routes: Route[]; componentFile: Map<Function, string>; componentIssue: Map<Function, string> }> {
13
+ ): Promise<{ routes: Route[]; componentFile: Map<Function, string>; componentIssue: Map<Function, string>; appModules: string[]; appGraph: Map<string, string[]> }> {
14
14
  // biome-ignore lint/suspicious/noExplicitAny: an arbitrary user module
15
15
  let mod: any
16
16
  installServerOnlyRuntime() // `server-only` resolves to an empty module: a loader may import server-only code
@@ -31,6 +31,8 @@ export async function scanRoutes(
31
31
  const transpiler = new Bun.Transpiler({ loader: 'tsx' })
32
32
  const inRoot = (f: string) => f.startsWith(root + sep) && !f.split(sep).includes('node_modules')
33
33
  const visited = new Set<string>()
34
+ // F230: each in-root module's in-root static imports (absolute paths), for the island chunks' load-time writers.
35
+ const appGraph = new Map<string, string[]>()
34
36
  // Post-order: a file's imports are mapped before the file itself, and the first file to claim a function
35
37
  // keeps it, so the module that defines a Component beats a barrel that re-exports it.
36
38
  const visit = async (file: string): Promise<void> => {
@@ -58,6 +60,7 @@ export async function scanRoutes(
58
60
  if (inRoot(dep)) deps.push(dep)
59
61
  else if (dep.endsWith('.tsx')) await note(dep, dep.split(sep).includes('node_modules') ? 'in node_modules' : `outside the app root ${root}`)
60
62
  }
63
+ appGraph.set(file, deps)
61
64
  for (const dep of deps) await visit(dep)
62
65
  if (!file.endsWith('.tsx')) return
63
66
  const m = await import(file)
@@ -79,5 +82,7 @@ export async function scanRoutes(
79
82
  await visit(entryFile)
80
83
  // A function reachable as a default somewhere is fine; only an unmapped one keeps its explanation.
81
84
  for (const f of componentFile.keys()) componentIssue.delete(f)
82
- return { routes, componentFile, componentIssue }
85
+ // F230: every app module of the static graph (`.ts`/`.tsx` inside the root, outside node_modules), for the scan of
86
+ // load-time writes into another module's exports (`scanLoadWrites`).
87
+ return { routes, componentFile, componentIssue, appModules: [...visited].filter(inRoot), appGraph }
83
88
  }
package/src/cli.ts CHANGED
@@ -6,7 +6,6 @@ import { BuildError, runBuild } from './build'
6
6
  import { runBunDev } from './dev/bun'
7
7
  import { runDev } from './dev'
8
8
  import { run } from './run'
9
- import { shadcnInit, spawnRunner } from './shadcn/init'
10
9
 
11
10
  const USAGE = `usage:
12
11
  brust build [entry=routes.tsx] [--out-dir dist] [--force] [--target bun|cloudflare|static] [--actions-only] [--strict] [--wasm-from-source] [--dev]
@@ -135,6 +134,8 @@ export async function main(argv: string[]): Promise<void> {
135
134
  if (cmd === 'shadcn') {
136
135
  const [sub, ...components] = positionals
137
136
  if (sub !== 'init') usage(`shadcn takes a subcommand: init (got ${sub === undefined ? 'none' : JSON.stringify(sub)})`)
137
+ // F252: loaded here, so no other command pays for it (it pulls in tsconfig-paths)
138
+ const { shadcnInit, spawnRunner } = await import('./shadcn/init')
138
139
  const code = await shadcnInit({
139
140
  root: process.cwd(),
140
141
  components,
package/src/dev/bun.ts CHANGED
@@ -10,7 +10,8 @@ import { type Diag, parseBuildError, parseWarnings } from './diag'
10
10
  import { tierCounts } from './devtools-data'
11
11
  import { type DevProxy, startProxy } from './proxy'
12
12
  import { HOTKEYS, Tui, type TuiOut, tuiMode } from './tui'
13
- import { defaultIgnored, watchLoop } from './watch'
13
+ import { loadBuildConfig } from '../build/config'
14
+ import { broadRootWarnings, defaultIgnored, watchLoop } from './watch'
14
15
 
15
16
  export interface BunDevOptions {
16
17
  appRoot: string
@@ -290,8 +291,12 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
290
291
 
291
292
  const isCss = (rel: string) => rel.startsWith('public/') && rel.endsWith('.css')
292
293
 
294
+ // F273: `[build] source_roots` are watched too (a bad config is the build's error to report, not the watcher's)
295
+ const extraRoots = await loadBuildConfig(appRoot).then((c) => c.sourceRoots.map((r) => resolve(appRoot, r)), () => [] as string[])
296
+ for (const w of broadRootWarnings(extraRoots, appRoot)) tui.log(w)
293
297
  const loop = watchLoop({
294
298
  root: appRoot,
299
+ extraRoots,
295
300
  ignored: (rel) => defaultIgnored(rel, 'dist'),
296
301
  debounceMs: DEBOUNCE_MS,
297
302
  async run(changed) {