@brust/core 2.0.0-alpha.15 → 2.0.0-alpha.17

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'
@@ -70,6 +70,8 @@ export interface Compiled {
70
70
  /** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
71
71
  evalTs?: string
72
72
  clientJs?: string
73
+ /** Modules the chunk imports that other chunks share (R34.124): `<name>.js` beside it. */
74
+ clientModules?: { name: string; code: string }[]
73
75
  /** The compiler's diagnostics for this component (`dev.json`, L14). */
74
76
  diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string }[]
75
77
  }
@@ -112,6 +114,27 @@ export function replaces(kept: Seen, next: Seen): boolean {
112
114
  return next.role > kept.role
113
115
  }
114
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, sourceRoots: string[] = []): 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
+ // F276: a module under a `[build] source_roots` directory (`../shared/x.ts`) is scanned too; any other `..` key is not.
128
+ const inSourceRoot = (k: string) => sourceRoots.some((r) => k.startsWith(`${r}/`))
129
+ const rel = [...new Set(modules.flatMap((f) => [toKey(appRoot, f), toKey(real, realpathSync(f))]))].filter((k) => !k.startsWith('..') || inSourceRoot(k)).sort()
130
+ const { aliases } = readPathAliases(appRoot)
131
+ const t0 = performance.now()
132
+ const json = scanLoadWrites(appRoot, rel, aliases.length > 0 ? JSON.stringify(aliases) : undefined, sourceRoots.length > 0 ? sourceRoots : undefined)
133
+ const ms = performance.now() - t0
134
+ if (process.env.BRUST_BUILD_TIMINGS) log(`[brust] scan load-time writes: ${rel.length} modules in ${ms.toFixed(1)} ms`)
135
+ return json
136
+ }
137
+
115
138
  export function compileApp(opts: {
116
139
  appRoot: string
117
140
  leaves: FlatRoute[]
@@ -131,11 +154,16 @@ export function compileApp(opts: {
131
154
  slotValues?: Record<string, unknown>
132
155
  /** F153c: the package sources this build wrote (`pkg-src.ts`); absent = package imports are islands. */
133
156
  packages?: PackageSources
157
+ /** F256: `[build] source_roots`, normalised and relative to the app root (`../shared`). */
158
+ sourceRoots?: string[]
159
+ /** F230: `scanAppWrites`'s result; absent = no module is refused for a load-time write. */
160
+ loadWrites?: string
134
161
  }): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
135
162
  const { appRoot, leaves, componentFile } = opts
136
163
  const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
137
164
  const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
138
165
  const packagesJson = opts.packages ? JSON.stringify(opts.packages) : undefined
166
+ const sourceRoots = opts.sourceRoots && opts.sourceRoots.length > 0 ? opts.sourceRoots : undefined
139
167
  // The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
140
168
  const { aliases, notes } = readPathAliases(appRoot)
141
169
  for (const n of notes) opts.log(`warning tsconfig-paths ${n}`)
@@ -163,7 +191,7 @@ export function compileApp(opts: {
163
191
  let rootId = rootIdOfFile.get(file)
164
192
  if (!rootId) {
165
193
  const rel = relative(appRoot, file)
166
- const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson)
194
+ const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson, sourceRoots, opts.loadWrites)
167
195
  if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
168
196
  for (const [i, c] of tree.components.entries()) {
169
197
  // A lowering Error that came back as a diagnostic is a build error too (contract 9).
@@ -198,6 +226,7 @@ export function compileApp(opts: {
198
226
  serverTs: c.serverTs,
199
227
  evalTs: c.evalTs,
200
228
  clientJs: c.clientJs,
229
+ clientModules: c.clientModules?.length ? c.clientModules : undefined,
201
230
  diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
202
231
  })
203
232
  }
@@ -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
  }
@@ -5,21 +5,50 @@
5
5
  // the bundler itself: a throwaway `Bun.build` of a virtual file placed in `from` that imports `spec`, with the same
6
6
  // `target`; the first module the build loads from outside that file is the answer. Equal to the native pick by
7
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.
9
- import { join } from 'node:path'
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 = /^\.\.?(\/|$)/
10
16
 
11
17
  export type ResolveTarget = 'bun' | 'browser'
12
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
+
13
34
  const cache = new Map<string, string>()
14
35
 
15
- // argv: [target, entry, spec, define JSON]; prints the resolved path. The define is the build's own: `NODE_ENV` flips Bun's
16
- // `development` condition, so a probe without it picks another file than the build does.
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`.
17
46
  const PROBE = `
18
- const [target, entry, spec, define] = process.argv.slice(1)
47
+ const [target, entry, spec, define, conditions] = process.argv.slice(1)
19
48
  const source = 'import ' + JSON.stringify(spec) + '\\n'
20
49
  let found
21
50
  await Bun.build({
22
- entrypoints: [entry], target, write: false, define: JSON.parse(define), files: { [entry]: source },
51
+ entrypoints: [entry], target, write: false, define: JSON.parse(define), conditions: JSON.parse(conditions), files: { [entry]: source },
23
52
  plugins: [{ name: 'o', setup(b) { b.onLoad({ filter: /./ }, (a) => {
24
53
  if (a.path === entry) return { contents: source, loader: 'ts' }
25
54
  found ??= a.path
@@ -29,15 +58,113 @@ await Bun.build({
29
58
  if (found) process.stdout.write(found)
30
59
  `
31
60
 
61
+ const defaultWarn = (m: string) => console.warn(`warning resolve-fallback ${m}`)
62
+ const warned = new Set<string>()
63
+
32
64
  /** `spec` as the bundler of `target` resolves it from the directory `from`. */
33
- export function resolveNative(spec: string, from: string, target: ResolveTarget, define: Record<string, string> = {}): string {
65
+ export function resolveNative(spec: string, from: string, target: ResolveTarget, define: Record<string, string> = {}, opts: ResolveOpts = {}): string {
34
66
  const defs = JSON.stringify(define)
35
- const key = `${target}\0${from}\0${spec}\0${defs}`
67
+ const conds = JSON.stringify(opts.conditions ?? [])
68
+ const key = `${target}\0${from}\0${spec}\0${defs}\0${conds}`
36
69
  const hit = cache.get(key)
37
70
  if (hit !== undefined) return hit
38
- const r = Bun.spawnSync([process.execPath, '-e', PROBE, target, join(from, '__brust_resolve__.ts'), spec, defs], { cwd: from, stdout: 'pipe', stderr: 'pipe' })
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' })
39
79
  const out = r.exitCode === 0 ? r.stdout.toString() : ''
40
- const path = out !== '' ? out : Bun.resolveSync(spec, from)
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)
41
94
  cache.set(key, path)
42
95
  return path
43
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
+ }
@@ -5,7 +5,7 @@
5
5
  import { BuildError } from './errors'
6
6
 
7
7
  /** Feature modules of `@brust/runtime-dom/features/*` the browser build ships, in load order. */
8
- export const RUNTIME_FEATURES = ['basics', 'if', 'for', 'model', 'html', 'island'] as const
8
+ export const RUNTIME_FEATURES = ['basics', 'if', 'for', 'model', 'html', 'island', 'collection'] as const
9
9
  export type RuntimeFeature = (typeof RUNTIME_FEATURES)[number]
10
10
 
11
11
  /** Directive attribute → the module that binds it. `null`: handled by the core (host, props, inert-markup marker). */
@@ -25,6 +25,10 @@ export function featureOfAttr(attr: string): RuntimeFeature | null | undefined {
25
25
  case 'x-model':
26
26
  case 'x-html':
27
27
  return attr.slice(2) as RuntimeFeature
28
+ case 'x-collection-root':
29
+ case 'x-collection-item':
30
+ case 'x-collection-use':
31
+ return 'collection'
28
32
  default:
29
33
  return /^x-(bind|on)-/.test(attr) ? 'basics' : undefined
30
34
  }
@@ -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, sourceRoots)
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,13 +244,46 @@ 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, o.sourceRoots)
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[] = []
@@ -254,7 +293,7 @@ async function buildInto(
254
293
  // F197: and the packages a helper is imported from, when the compiler asked to prove one plain.
255
294
  const external = [...new Set([...compiled.values()].flatMap((c) => [...(c.ir.external ? [c.ir.external.source] : []), ...((c.ir.package_requests ?? []) as string[])]))]
256
295
  const packages = preparePackageSources(appRoot, external)
257
- if (Object.keys(packages.scopes).length > 0) {
296
+ if (Object.keys(packages.scopes).length > 0 || Object.keys(packages.recognised).length > 0) {
258
297
  firstLog.length = 0
259
298
  ;({ compiled, routeComponent } = compileApp({ ...compileOpts, packages, log: (l) => firstLog.push(l) }))
260
299
  }
@@ -281,7 +320,7 @@ async function buildInto(
281
320
  mkdirSync(join(out, 'jinja'), { recursive: true })
282
321
  for (const c of compiled.values()) writeFileSync(join(out, 'jinja', `${c.id}.jinja`), c.jinja)
283
322
 
284
- 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))])
285
324
  const manifest = writeManifest({ leaves, routeComponent, compiled, runtime: { core: runtime.core, features: { ...runtime.features, store: runtime.store } }, chunks })
286
325
  // L33: a component whose chunk imports the store module (directly or through a shared chunk) has the page preload it.
287
326
  const readClient = (rel: string) => (existsSync(join(out, rel)) ? readFileSync(join(out, rel), 'utf8') : undefined)
@@ -290,7 +329,7 @@ async function buildInto(
290
329
  const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
291
330
  if (files.length > 0) r.css = files
292
331
  }
293
- 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)
294
333
  manifest.actions =
295
334
  o.actions && o.actionsEntry
296
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,8 @@ 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'
23
+ import { RECOGNISED, verifyRecognised } from './recognised'
22
24
 
23
25
  /** What the compiler reads (`AnalyzeOptions.packages`): per importing scope (`""` = the app, else the root-relative
24
26
  * directory of a written package), each bare specifier → the root-relative path of its written entry source, or the
@@ -26,6 +28,9 @@ import { BuildError } from './errors'
26
28
  export interface PackageSources {
27
29
  scopes: Record<string, Record<string, string>>
28
30
  refused: Record<string, Record<string, string>>
31
+ /** R34.140: packages the compiler recognises by version + hash instead of reading (`recognised.ts`), name → version. A
32
+ * name is here only when EVERY copy of it the build met verified. */
33
+ recognised: Record<string, string>
29
34
  /** Each written directory (root-relative) → the package's real directory: a bare import in an artifact compiled from
30
35
  * a package source resolves from the package, as it does in a real install (not read by the compiler). */
31
36
  dirs: Record<string, string>
@@ -219,11 +224,27 @@ export function integrityGaps(sources: { path: string; text: string }[], publish
219
224
  // Import specifiers are module names, which bundling replaces; they are checked as imports instead.
220
225
  for (const im of new Bun.Transpiler({ loader }).scanImports(s.text)) sk.strings.delete(im.path)
221
226
  for (const v of sk.strings) if (!pub.strings.has(v)) gaps.push(`${s.path}: literal ${JSON.stringify(v.startsWith('#') ? v.slice(1) : v).slice(0, 60)}`)
222
- for (const v of sk.names) if (!JSX_NAMES.has(v) && !JSX_RUNTIME.test(v) && !KEYWORDS.has(v) && !pub.names.has(v)) gaps.push(`${s.path}: name \`${v}\``)
227
+ const aliases = importAliases(transpile(s.text, loader), pub.names)
228
+ for (const v of sk.names) if (!JSX_NAMES.has(v) && !JSX_RUNTIME.test(v) && !KEYWORDS.has(v) && !pub.names.has(v) && !aliases.has(v)) gaps.push(`${s.path}: name \`${v}\``)
223
229
  }
224
230
  return gaps
225
231
  }
226
232
 
233
+ /** R34.142 (W1b): `import { X as Y } from './sibling'` names the same binding as `X` under a local name. A bundler resolves
234
+ * the binding to the sibling's declaration, so `Y` is not in the published file; `X` is. `Y` is dropped from the name check
235
+ * ONLY for a RELATIVE import DECLARATION at the start of a line (a sibling of the same package, bundled in) whose imported name `X` the published file has.
236
+ * An alias of anything else stays a name difference. */
237
+ function importAliases(js: string, pubNames: Set<string>): Set<string> {
238
+ const out = new Set<string>()
239
+ for (const m of js.matchAll(/^import\s*\{([^}]*)\}\s*from\s*["'](\.[^"']*)["']/gm)) {
240
+ for (const item of m[1]!.split(',')) {
241
+ const parts = item.trim().split(/\s+as\s+/)
242
+ if (parts.length === 2 && /^[A-Za-z_$][\w$]*$/.test(parts[1]!) && pubNames.has(parts[0]!)) out.add(parts[1]!)
243
+ }
244
+ }
245
+ return out
246
+ }
247
+
227
248
 
228
249
  /** The `local as exported` pairs of the `export { … }` clauses of transpiled JS (`x` alone is `x as x`). */
229
250
  function exportPairs(js: string): Set<string> {
@@ -353,9 +374,11 @@ function writePackage(appRoot: string, dir: string, budget: { bytes: number }):
353
374
  // The JSX transform's own runtime import (the transpiler adds it), and React itself.
354
375
  if (NEVER(im.path)) continue
355
376
  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('../')) {
377
+ if (RELATIVE_SPEC.test(im.path)) {
357
378
  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))
379
+ // `.` and `..` name a directory (F244): only its index answers, never a sibling file of the same name.
380
+ const dirOnly = im.path === '.' || im.path === '..'
381
+ const hit = [...(dirOnly ? [] : [base, `${base}.tsx`, `${base}.ts`]), posix.join(base, 'index.tsx'), posix.join(base, 'index.ts')].find((c) => have.has(c))
359
382
  if (hit) imported.add(hit)
360
383
  else return { ok: false, reason: `${f.path} imports ${im.path}, which the source map does not carry` }
361
384
  } else if (im.path.startsWith('/') || /^[a-z]+:/i.test(im.path)) return { ok: false, reason: `${f.path} imports ${im.path}` }
@@ -398,6 +421,20 @@ function writePackage(appRoot: string, dir: string, budget: { bytes: number }):
398
421
  return { ok: true, dir: outRel, entry: posix.join(outRel, (entrySrc ?? barrel)!.path), imports: [...bare].sort() }
399
422
  }
400
423
 
424
+ /** The verified version of the pinned package at `dir` (`RECOGNISED`), `null` when its version or entry bytes differ. */
425
+ function recognisedVersion(name: string, dir: string): string | null {
426
+ try {
427
+ const pj = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record<string, unknown>
428
+ const entryRel = esmEntry(pj)
429
+ if (!entryRel) return null
430
+ const entry = join(dir, entryRel)
431
+ if (!statSync(entry).isFile()) return null
432
+ return verifyRecognised(name, pj.version, readFileSync(entry))
433
+ } catch {
434
+ return null
435
+ }
436
+ }
437
+
401
438
  /**
402
439
  * Writes the sources of every package `specs` reaches from the app (and of every package those sources import),
403
440
  * and returns the resolution map for the compiler. The directory is rebuilt from nothing on every call.
@@ -417,7 +454,8 @@ export function preparePackageSources(appRoot: string, specs: Iterable<string>):
417
454
  rmSync(join(root, PKG_SRC_DIR), { recursive: true, force: true })
418
455
  mkdirSync(join(root, PKG_SRC_DIR), { recursive: true })
419
456
  if (!realpathSync(join(root, PKG_SRC_DIR)).startsWith(root + sep)) throw new BuildError('package-source', `${PKG_SRC_DIR} resolves outside the app root (a link?); remove it and build again`)
420
- const out: PackageSources = { scopes: {}, refused: {}, dirs: {} }
457
+ const out: PackageSources = { scopes: {}, refused: {}, dirs: {}, recognised: {} }
458
+ const recognisedBad = new Set<string>()
421
459
  writtenDirs.clear()
422
460
  const byDir = new Map<string, Outcome>()
423
461
  const budget = { bytes: 0 }
@@ -443,6 +481,14 @@ export function preparePackageSources(appRoot: string, specs: Iterable<string>):
443
481
  continue
444
482
  }
445
483
  let o = byDir.get(dir)
484
+ if (!o && Object.hasOwn(RECOGNISED, name)) {
485
+ // R34.140: not read: pinned by version and hash. The refusal stays (its imports stay islands where nothing recognises them).
486
+ const v = recognisedVersion(name, dir)
487
+ if (v) out.recognised[name] ??= v
488
+ else recognisedBad.add(name)
489
+ o = { ok: false, reason: v ? `recognised by the compiler (${name}@${v}), not read` : `${name} is not the pinned release the compiler recognises` }
490
+ byDir.set(dir, o)
491
+ }
446
492
  if (!o) {
447
493
  try {
448
494
  o = byDir.size >= LIMITS.packages ? { ok: false, reason: `more than ${LIMITS.packages} packages` } : writePackage(root, dir, budget)
@@ -461,6 +507,7 @@ export function preparePackageSources(appRoot: string, specs: Iterable<string>):
461
507
  if (o.ok) (out.scopes[scope] ??= {})[spec] = o.entry
462
508
  else refuse(o.reason)
463
509
  }
510
+ for (const n of recognisedBad) delete out.recognised[n]
464
511
  mkdirSync(join(root, PKG_SRC_DIR), { recursive: true })
465
512
  writeFileSync(join(root, PKG_SRC_DIR, 'index.json'), `${JSON.stringify(out, null, 2)}\n`)
466
513
  return out
@@ -0,0 +1,19 @@
1
+ // Packages the compiler RECOGNISES by what they are rather than reading their source (R34.140). A recognised package is
2
+ // pinned: the installed package.json `version` AND the SHA-256 of its published ESM entry must be one of the pairs below.
3
+ // Any other version, or the same version with another byte in the entry, is not recognised (its import stays the React
4
+ // island it is without it). The compiler is told only the package name and the verified version (`index.json#recognised`).
5
+ import { createHash } from 'node:crypto'
6
+
7
+ /** package name → published version → SHA-256 (hex) of the file `module` / `exports['.'].import` names. */
8
+ export const RECOGNISED: Record<string, Record<string, string>> = {
9
+ // `createCollection` (the 3-tuple form roving focus, accordion, menus and slider use) of the 1.2.0 release
10
+ '@radix-ui/react-collection': { '1.2.0': '52a6719c7c1a7a5202ab5ae58bb552f0d7853965a0833cf0e71749ac580e349f' },
11
+ }
12
+
13
+ /** The verified version of `name` when `version` and the bytes of its ESM `entry` are a pinned pair; else `null`. */
14
+ export function verifyRecognised(name: string, version: unknown, entryBytes: Uint8Array): string | null {
15
+ const pins = RECOGNISED[name]
16
+ if (!pins || typeof version !== 'string' || !Object.hasOwn(pins, version)) return null
17
+ const sha = createHash('sha256').update(entryBytes).digest('hex')
18
+ return sha === pins[version] ? version : null
19
+ }