@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.
- package/native/index.d.ts +18 -1
- package/native/index.js +55 -54
- package/package.json +12 -9
- package/src/build/bundle.ts +54 -10
- package/src/build/cloudflare.ts +5 -2
- package/src/build/compile.ts +31 -2
- package/src/build/config.ts +43 -5
- package/src/build/esm-resolve.ts +137 -10
- package/src/build/features.ts +5 -1
- package/src/build/index.ts +46 -7
- package/src/build/pkg-src.ts +51 -4
- package/src/build/recognised.ts +19 -0
- package/src/build/scan.ts +13 -3
- 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,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
|
}
|
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/features.ts
CHANGED
|
@@ -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
|
}
|
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, 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 }
|
package/src/build/pkg-src.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
377
|
+
if (RELATIVE_SPEC.test(im.path)) {
|
|
357
378
|
const base = posix.normalize(posix.join(posix.dirname(f.path), im.path))
|
|
358
|
-
|
|
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
|
+
}
|