@brust/core 2.0.0-alpha.15 → 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.
- package/native/index.d.ts +17 -1
- package/native/index.js +55 -54
- package/package.json +11 -9
- package/src/build/bundle.ts +54 -10
- package/src/build/cloudflare.ts +5 -2
- package/src/build/compile.ts +29 -2
- package/src/build/config.ts +43 -5
- package/src/build/esm-resolve.ts +137 -10
- package/src/build/index.ts +45 -6
- package/src/build/pkg-src.ts +5 -2
- package/src/build/scan.ts +7 -2
- package/src/cli.ts +2 -1
- package/src/dev/bun.ts +6 -1
- package/src/dev/watch.ts +49 -8
- package/src/dev.ts +5 -1
- package/src/shadcn/init.ts +275 -16
- package/src/shadcn/registry.ts +149 -0
package/src/build/compile.ts
CHANGED
|
@@ -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,25 @@ 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): 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
|
+
|
|
115
136
|
export function compileApp(opts: {
|
|
116
137
|
appRoot: string
|
|
117
138
|
leaves: FlatRoute[]
|
|
@@ -131,11 +152,16 @@ export function compileApp(opts: {
|
|
|
131
152
|
slotValues?: Record<string, unknown>
|
|
132
153
|
/** F153c: the package sources this build wrote (`pkg-src.ts`); absent = package imports are islands. */
|
|
133
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
|
|
134
159
|
}): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
|
|
135
160
|
const { appRoot, leaves, componentFile } = opts
|
|
136
161
|
const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
|
|
137
162
|
const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
|
|
138
163
|
const packagesJson = opts.packages ? JSON.stringify(opts.packages) : undefined
|
|
164
|
+
const sourceRoots = opts.sourceRoots && opts.sourceRoots.length > 0 ? opts.sourceRoots : undefined
|
|
139
165
|
// The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
|
|
140
166
|
const { aliases, notes } = readPathAliases(appRoot)
|
|
141
167
|
for (const n of notes) opts.log(`warning tsconfig-paths ${n}`)
|
|
@@ -163,7 +189,7 @@ export function compileApp(opts: {
|
|
|
163
189
|
let rootId = rootIdOfFile.get(file)
|
|
164
190
|
if (!rootId) {
|
|
165
191
|
const rel = relative(appRoot, file)
|
|
166
|
-
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)
|
|
167
193
|
if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
|
|
168
194
|
for (const [i, c] of tree.components.entries()) {
|
|
169
195
|
// A lowering Error that came back as a diagnostic is a build error too (contract 9).
|
|
@@ -198,6 +224,7 @@ export function compileApp(opts: {
|
|
|
198
224
|
serverTs: c.serverTs,
|
|
199
225
|
evalTs: c.evalTs,
|
|
200
226
|
clientJs: c.clientJs,
|
|
227
|
+
clientModules: c.clientModules?.length ? c.clientModules : undefined,
|
|
201
228
|
diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
|
|
202
229
|
})
|
|
203
230
|
}
|
package/src/build/config.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/build/esm-resolve.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/build/index.ts
CHANGED
|
@@ -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,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)
|
|
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[] = []
|
|
@@ -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 }
|
package/src/build/pkg-src.ts
CHANGED
|
@@ -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 (
|
|
357
|
+
if (RELATIVE_SPEC.test(im.path)) {
|
|
357
358
|
const base = posix.normalize(posix.join(posix.dirname(f.path), im.path))
|
|
358
|
-
|
|
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
|
-
|
|
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 {
|
|
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) {
|
package/src/dev/watch.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// ONE run at a time, and every change that arrives while a run is in flight is kept for the next
|
|
3
3
|
// run (post-mortem 8f29c26: an edit during a rebuild was dropped). `run` receives the app-relative
|
|
4
4
|
// paths that changed since the previous run started.
|
|
5
|
-
import { type FSWatcher, statSync, watch } from 'node:fs'
|
|
6
|
-
import { join, sep } from 'node:path'
|
|
5
|
+
import { type FSWatcher, readdirSync, statSync, watch } from 'node:fs'
|
|
6
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
7
7
|
|
|
8
8
|
export interface WatchLoop {
|
|
9
9
|
/** Queue a run as if `rel` changed (the `r` hotkey, tests). */
|
|
@@ -14,7 +14,36 @@ export interface WatchLoop {
|
|
|
14
14
|
idle(): Promise<void>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** F273: a source root holding more files than this gets a warning (a root that broad slows every rebuild check). */
|
|
18
|
+
export const ROOT_FILE_WARN = 5_000
|
|
19
|
+
|
|
20
|
+
/** Files under `dir` (skipping dependency/VCS dirs), counting stops once past `limit`. */
|
|
21
|
+
export function countFiles(dir: string, limit: number): number {
|
|
22
|
+
let n = 0
|
|
23
|
+
const walk = (d: string): void => {
|
|
24
|
+
let ents: import('node:fs').Dirent[]
|
|
25
|
+
try {
|
|
26
|
+
ents = readdirSync(d, { withFileTypes: true })
|
|
27
|
+
} catch {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
for (const e of ents) {
|
|
31
|
+
if (n > limit) return
|
|
32
|
+
if (e.isDirectory()) {
|
|
33
|
+
if (!SKIP_DIRS.has(e.name)) walk(join(d, e.name))
|
|
34
|
+
} else n++
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
walk(dir)
|
|
38
|
+
return n
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Warning lines for source roots broader than `ROOT_FILE_WARN` files; `roots` are absolute. */
|
|
42
|
+
export function broadRootWarnings(roots: string[], appRoot: string, limit = ROOT_FILE_WARN): string[] {
|
|
43
|
+
return roots.flatMap((r) => (countFiles(r, limit) > limit ? [`[brust] warning: source root ${relative(appRoot, r) || r} holds more than ${limit} files; watching it slows every change check. Point source_roots at a narrower directory`] : []))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function watchLoop(o: { root: string; /** F273: `[build] source_roots`, absolute: watched too; their changes are reported app-relative (`../shared/x.ts`). */ extraRoots?: string[]; ignored: (rel: string) => boolean; debounceMs: number; run: (changed: Set<string>) => Promise<void> }): WatchLoop {
|
|
18
47
|
let pending = new Set<string>()
|
|
19
48
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
20
49
|
let running = false
|
|
@@ -44,24 +73,36 @@ export function watchLoop(o: { root: string; ignored: (rel: string) => boolean;
|
|
|
44
73
|
clearTimeout(timer)
|
|
45
74
|
timer = setTimeout(() => void pump(), o.debounceMs)
|
|
46
75
|
}
|
|
47
|
-
const
|
|
76
|
+
const onEvent = (base: string) => (_ev: string, name: string | null) => {
|
|
48
77
|
if (!name) return
|
|
49
|
-
const
|
|
78
|
+
const abs = join(base, String(name))
|
|
79
|
+
const rel = (base === o.root ? String(name) : relative(o.root, abs)).split(sep).join('/')
|
|
50
80
|
if (o.ignored(rel)) return
|
|
51
81
|
// A directory's own event is metadata noise (reading a dir bumps its atime, which FSEvents reports): a real
|
|
52
82
|
// change inside it reports the file itself. A path that no longer exists is a delete, which counts.
|
|
53
83
|
try {
|
|
54
|
-
if (statSync(
|
|
84
|
+
if (statSync(abs).isDirectory()) return
|
|
55
85
|
} catch {}
|
|
56
86
|
queue(rel)
|
|
57
|
-
}
|
|
87
|
+
}
|
|
88
|
+
const watchers: FSWatcher[] = [watch(o.root, { recursive: true }, onEvent(o.root))]
|
|
89
|
+
for (const r of o.extraRoots ?? []) {
|
|
90
|
+
const abs = resolve(r)
|
|
91
|
+
// a root inside the app is already watched; one that cannot be watched must not stop the session
|
|
92
|
+
if (abs === o.root || abs.startsWith(o.root + sep)) continue
|
|
93
|
+
try {
|
|
94
|
+
watchers.push(watch(abs, { recursive: true }, onEvent(abs)))
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`[brust] cannot watch source root ${abs}: ${(e as Error).message}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
58
99
|
return {
|
|
59
100
|
trigger: (rel = '.') => queue(rel),
|
|
60
101
|
close() {
|
|
61
102
|
closed = true
|
|
62
103
|
clearTimeout(timer)
|
|
63
104
|
pending.clear()
|
|
64
|
-
|
|
105
|
+
for (const w of watchers) w.close()
|
|
65
106
|
for (const w of idleWaiters.splice(0)) w()
|
|
66
107
|
},
|
|
67
108
|
idle: () => (!running && pending.size === 0 ? Promise.resolve() : new Promise<void>((r) => idleWaiters.push(r))),
|