@nifrajs/web 2.12.0 → 2.13.0
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/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +3 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +4 -0
- package/dist/build.js.map +1 -1
- package/dist/dev-ssr-graph.js +1 -1
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/internal/parity.d.ts +77 -0
- package/dist/internal/parity.d.ts.map +1 -0
- package/dist/internal/parity.js +428 -0
- package/dist/internal/parity.js.map +1 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +2 -0
- package/dist/vite.js.map +1 -1
- package/package.json +7 -2
- package/src/build-vite.ts +7 -0
- package/src/build.ts +8 -0
- package/src/dev-ssr-graph.ts +1 -1
- package/src/internal/parity.ts +554 -0
- package/src/vite.ts +2 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared identity and development/production parity facts.
|
|
3
|
+
*
|
|
4
|
+
* The CLI doctor and the web pipelines must answer the same question: will identity-sensitive
|
|
5
|
+
* modules and emitted app surfaces resolve to one coherent graph? This internal seam owns path and
|
|
6
|
+
* manifest normalization so those callers cannot quietly grow separate rules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
|
|
10
|
+
import { lstat, realpath, stat } from "node:fs/promises"
|
|
11
|
+
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
12
|
+
import { discoverRoutes } from "../fs.ts"
|
|
13
|
+
|
|
14
|
+
const DEPENDENCY_FIELDS = [
|
|
15
|
+
"dependencies",
|
|
16
|
+
"devDependencies",
|
|
17
|
+
"peerDependencies",
|
|
18
|
+
"optionalDependencies",
|
|
19
|
+
] as const
|
|
20
|
+
const IDENTITY_SENSITIVE_PACKAGES = new Set([
|
|
21
|
+
"@nifrajs/core",
|
|
22
|
+
"react",
|
|
23
|
+
"react-dom",
|
|
24
|
+
"preact",
|
|
25
|
+
"svelte",
|
|
26
|
+
"solid-js",
|
|
27
|
+
"vue",
|
|
28
|
+
])
|
|
29
|
+
const MAX_WORKSPACE_IMPORTERS = 2_048
|
|
30
|
+
const MAX_LINKED_PACKAGES = 64
|
|
31
|
+
const MAX_LINK_PROBES = 4_096
|
|
32
|
+
|
|
33
|
+
export interface IdentityParityCopy {
|
|
34
|
+
readonly version: string
|
|
35
|
+
readonly path: string
|
|
36
|
+
readonly importers: readonly string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IdentityParityFinding {
|
|
40
|
+
readonly package: string
|
|
41
|
+
readonly copies: readonly IdentityParityCopy[]
|
|
42
|
+
readonly explanation: string
|
|
43
|
+
readonly remediation: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IdentityParityResult {
|
|
47
|
+
readonly workspaceRoot: string
|
|
48
|
+
readonly findings: readonly IdentityParityFinding[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IdentityParityOptions {
|
|
52
|
+
/** Scan the governing workspace when the caller is a package subdirectory. */
|
|
53
|
+
readonly useWorkspaceRoot?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BuildManifestLike {
|
|
57
|
+
readonly entry: string
|
|
58
|
+
readonly assets: readonly string[]
|
|
59
|
+
readonly routes: Readonly<Record<string, readonly string[]>>
|
|
60
|
+
readonly publicFiles?: readonly string[]
|
|
61
|
+
readonly css?: readonly string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ParityManifest {
|
|
65
|
+
readonly moduleGraph: {
|
|
66
|
+
readonly routes: readonly string[]
|
|
67
|
+
readonly routeChunks: Readonly<Record<string, number>>
|
|
68
|
+
readonly emittedAssets: readonly string[]
|
|
69
|
+
}
|
|
70
|
+
readonly publicFiles: readonly string[]
|
|
71
|
+
readonly css: readonly string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DevelopmentParityInput {
|
|
75
|
+
readonly routes: Readonly<Record<string, number>>
|
|
76
|
+
readonly publicFiles: readonly string[]
|
|
77
|
+
readonly css: readonly string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SOURCE_EXTENSIONS = /\.(?:c|m)?(?:j|t)sx?$|\.(?:mdx|svelte|vue)$/
|
|
81
|
+
const CSS_IMPORT =
|
|
82
|
+
/(?:import\s+(?:[^"']+\s+from\s+)?|from\s+)["'][^"']+\.(?:css|scss|sass|less|styl)(?:\?[^"']*)?["']/i
|
|
83
|
+
/** A single-file-component `<style>` block (Svelte/Vue). The bundler extracts these into the app
|
|
84
|
+
* stylesheet even though no `import "...css"` statement exists, so the dev contract must count them
|
|
85
|
+
* or a scoped-style component would look style-free next to a production manifest that carries css. */
|
|
86
|
+
const SFC_STYLE = /<style[\s>]/i
|
|
87
|
+
const isSingleFileComponent = (file: string): boolean =>
|
|
88
|
+
file.endsWith(".svelte") || file.endsWith(".vue")
|
|
89
|
+
|
|
90
|
+
export type ManifestParitySection = "module-graph" | "public-files" | "css"
|
|
91
|
+
|
|
92
|
+
export interface ManifestParityDifference {
|
|
93
|
+
readonly section: ManifestParitySection
|
|
94
|
+
readonly development: unknown
|
|
95
|
+
readonly production: unknown
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
99
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
100
|
+
|
|
101
|
+
const readJson = async (path: string): Promise<Record<string, unknown> | undefined> => {
|
|
102
|
+
try {
|
|
103
|
+
const value: unknown = JSON.parse(await Bun.file(path).text())
|
|
104
|
+
return isRecord(value) ? value : undefined
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dependencyNames = (pkg: Record<string, unknown>): readonly string[] => {
|
|
111
|
+
const names = new Set<string>()
|
|
112
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
113
|
+
const value = pkg[field]
|
|
114
|
+
if (!isRecord(value)) continue
|
|
115
|
+
for (const name of Object.keys(value)) names.add(name)
|
|
116
|
+
}
|
|
117
|
+
return [...names]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const workspacePatterns = (pkg: Record<string, unknown>): readonly string[] => {
|
|
121
|
+
const raw = pkg.workspaces
|
|
122
|
+
const entries = Array.isArray(raw)
|
|
123
|
+
? raw
|
|
124
|
+
: isRecord(raw) && Array.isArray(raw.packages)
|
|
125
|
+
? raw.packages
|
|
126
|
+
: []
|
|
127
|
+
return entries
|
|
128
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
129
|
+
.sort()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const pathExists = (path: string): Promise<boolean> =>
|
|
133
|
+
stat(path).then(
|
|
134
|
+
() => true,
|
|
135
|
+
() => false,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const pathInside = (root: string, path: string): boolean =>
|
|
139
|
+
path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
|
|
140
|
+
|
|
141
|
+
const workspaceContains = async (
|
|
142
|
+
root: string,
|
|
143
|
+
patterns: readonly string[],
|
|
144
|
+
target: string,
|
|
145
|
+
): Promise<boolean> => {
|
|
146
|
+
if (root === target) return patterns.length > 0
|
|
147
|
+
for (const pattern of patterns) {
|
|
148
|
+
const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
|
|
149
|
+
for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
|
|
150
|
+
if (match.split(/[\\/]/).includes("node_modules")) continue
|
|
151
|
+
const packageRoot = resolve(root, dirname(match))
|
|
152
|
+
const targetRelative = relative(packageRoot, target)
|
|
153
|
+
if (
|
|
154
|
+
targetRelative === "" ||
|
|
155
|
+
(!targetRelative.startsWith(`..${sep}`) && targetRelative !== "..")
|
|
156
|
+
)
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Resolve the workspace root once, even when the caller starts in a workspace package. */
|
|
164
|
+
export async function resolveParityWorkspaceRoot(
|
|
165
|
+
start: string,
|
|
166
|
+
): Promise<{ readonly root: string; readonly package: Record<string, unknown> }> {
|
|
167
|
+
const original = realpathSync(resolve(start))
|
|
168
|
+
let current = original
|
|
169
|
+
for (;;) {
|
|
170
|
+
const pkg = await readJson(join(current, "package.json"))
|
|
171
|
+
if (pkg !== undefined && (await workspaceContains(current, workspacePatterns(pkg), original))) {
|
|
172
|
+
return { root: current, package: pkg }
|
|
173
|
+
}
|
|
174
|
+
if (await pathExists(join(current, ".git"))) return { root: original, package: {} }
|
|
175
|
+
const parent = dirname(current)
|
|
176
|
+
if (parent === current) return { root: original, package: {} }
|
|
177
|
+
current = parent
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const workspaceImporters = async (
|
|
182
|
+
root: string,
|
|
183
|
+
rootPackage: Record<string, unknown>,
|
|
184
|
+
): Promise<readonly { root: string; package: Record<string, unknown> }[]> => {
|
|
185
|
+
const manifests = new Set<string>([join(root, "package.json")])
|
|
186
|
+
for (const pattern of workspacePatterns(rootPackage)) {
|
|
187
|
+
const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
|
|
188
|
+
for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
|
|
189
|
+
if (match.split(/[\\/]/).includes("node_modules")) continue
|
|
190
|
+
manifests.add(join(root, match))
|
|
191
|
+
if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const importers: { root: string; package: Record<string, unknown> }[] = []
|
|
195
|
+
for (const manifest of [...manifests].sort()) {
|
|
196
|
+
const pkg = await readJson(manifest)
|
|
197
|
+
if (pkg !== undefined) importers.push({ root: dirname(manifest), package: pkg })
|
|
198
|
+
}
|
|
199
|
+
return importers
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const resolvedInstalledCopy = async (
|
|
203
|
+
importer: string,
|
|
204
|
+
boundary: string,
|
|
205
|
+
name: string,
|
|
206
|
+
): Promise<{ readonly path: string; readonly version: string } | undefined> => {
|
|
207
|
+
const parts = name.split("/")
|
|
208
|
+
for (let dir = importer; ; dir = dirname(dir)) {
|
|
209
|
+
const packageDir = join(dir, "node_modules", ...parts)
|
|
210
|
+
const meta = await readJson(join(packageDir, "package.json"))
|
|
211
|
+
if (meta !== undefined) {
|
|
212
|
+
try {
|
|
213
|
+
return {
|
|
214
|
+
path: await realpath(packageDir),
|
|
215
|
+
version:
|
|
216
|
+
typeof meta.version === "string" && meta.version.length > 0 ? meta.version : "unknown",
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (dir === boundary) return undefined
|
|
223
|
+
const parent = dirname(dir)
|
|
224
|
+
if (parent === dir) return undefined
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const linkedRepoBoundary = async (packageRoot: string): Promise<string> => {
|
|
229
|
+
let dir = packageRoot
|
|
230
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
231
|
+
if (await pathExists(join(dir, ".git"))) return dir
|
|
232
|
+
const parent = dirname(dir)
|
|
233
|
+
if (parent === dir) break
|
|
234
|
+
dir = parent
|
|
235
|
+
}
|
|
236
|
+
return packageRoot
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const linkedPackageRoots = async (
|
|
240
|
+
importerRoots: readonly string[],
|
|
241
|
+
manifests: readonly Record<string, unknown>[],
|
|
242
|
+
scanRoot: string,
|
|
243
|
+
): Promise<readonly string[]> => {
|
|
244
|
+
const realScanRoot = await realpath(scanRoot).catch(() => scanRoot)
|
|
245
|
+
const found = new Map<string, true>()
|
|
246
|
+
let probes = 0
|
|
247
|
+
for (const [index, importerRoot] of importerRoots.entries()) {
|
|
248
|
+
const pkg = manifests[index]
|
|
249
|
+
if (pkg === undefined) continue
|
|
250
|
+
for (const name of dependencyNames(pkg)) {
|
|
251
|
+
if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) return [...found.keys()]
|
|
252
|
+
const candidate = join(importerRoot, "node_modules", ...name.split("/"))
|
|
253
|
+
const link = await lstat(candidate).catch(() => undefined)
|
|
254
|
+
if (link === undefined || !link.isSymbolicLink()) continue
|
|
255
|
+
const resolved = await realpath(candidate).catch(() => undefined)
|
|
256
|
+
if (resolved === undefined || pathInside(realScanRoot, resolved)) continue
|
|
257
|
+
found.set(resolved, true)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return [...found.keys()]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const displayPath = (cwd: string, path: string): string => {
|
|
264
|
+
const rel = relative(cwd, path)
|
|
265
|
+
return rel === "" ? "." : rel
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const identityTargets = (pkg: Record<string, unknown>): readonly string[] =>
|
|
269
|
+
dependencyNames(pkg)
|
|
270
|
+
.filter((name) => name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name))
|
|
271
|
+
.sort()
|
|
272
|
+
|
|
273
|
+
const IDENTITY_REMEDIATION =
|
|
274
|
+
"Align dependency ranges and reinstall from the workspace root so every importer resolves one physical copy."
|
|
275
|
+
|
|
276
|
+
/** Find duplicate identity-sensitive package realpaths without reading application payloads. */
|
|
277
|
+
export async function collectIdentityParity(
|
|
278
|
+
cwd: string,
|
|
279
|
+
rootPackage?: Record<string, unknown>,
|
|
280
|
+
options: IdentityParityOptions = {},
|
|
281
|
+
): Promise<IdentityParityResult> {
|
|
282
|
+
const requestedRoot = resolve(cwd)
|
|
283
|
+
const localPackage = await readJson(join(requestedRoot, "package.json"))
|
|
284
|
+
const workspace =
|
|
285
|
+
options.useWorkspaceRoot && localPackage !== undefined
|
|
286
|
+
? await resolveParityWorkspaceRoot(requestedRoot)
|
|
287
|
+
: { root: requestedRoot, package: localPackage ?? rootPackage ?? {} }
|
|
288
|
+
const scanRoot = workspace.root
|
|
289
|
+
const scanPackage = workspace.package
|
|
290
|
+
const importers = await workspaceImporters(scanRoot, scanPackage)
|
|
291
|
+
const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
|
|
292
|
+
const record = (
|
|
293
|
+
name: string,
|
|
294
|
+
copy: { readonly path: string; readonly version: string },
|
|
295
|
+
importer: string,
|
|
296
|
+
): void => {
|
|
297
|
+
const copies = byPackage.get(name) ?? new Map()
|
|
298
|
+
const entry = copies.get(copy.path) ?? { version: copy.version, importers: new Set<string>() }
|
|
299
|
+
entry.importers.add(importer)
|
|
300
|
+
copies.set(copy.path, entry)
|
|
301
|
+
byPackage.set(name, copies)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const targets = new Set<string>()
|
|
305
|
+
for (const importer of importers) {
|
|
306
|
+
for (const name of identityTargets(importer.package)) {
|
|
307
|
+
if (importer.package.name === name) continue
|
|
308
|
+
targets.add(name)
|
|
309
|
+
const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
|
|
310
|
+
if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const name of targets) {
|
|
314
|
+
const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
|
|
315
|
+
if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const linkedRoots = await linkedPackageRoots(
|
|
319
|
+
[...importers.map((importer) => importer.root), scanRoot],
|
|
320
|
+
[...importers.map((importer) => importer.package), scanPackage],
|
|
321
|
+
scanRoot,
|
|
322
|
+
)
|
|
323
|
+
for (const linkedRoot of linkedRoots) {
|
|
324
|
+
const linkedPackage = await readJson(join(linkedRoot, "package.json"))
|
|
325
|
+
const boundary = await linkedRepoBoundary(linkedRoot)
|
|
326
|
+
const linkedTargets = new Set([
|
|
327
|
+
...targets,
|
|
328
|
+
...(linkedPackage === undefined ? [] : identityTargets(linkedPackage)),
|
|
329
|
+
])
|
|
330
|
+
for (const name of linkedTargets) {
|
|
331
|
+
if (linkedPackage?.name === name) continue
|
|
332
|
+
const copy = await resolvedInstalledCopy(linkedRoot, boundary, name)
|
|
333
|
+
if (copy !== undefined) record(name, copy, displayPath(cwd, linkedRoot))
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const findings: IdentityParityFinding[] = []
|
|
338
|
+
for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
339
|
+
if (copies.size < 2) continue
|
|
340
|
+
findings.push({
|
|
341
|
+
package: name,
|
|
342
|
+
copies: [...copies.entries()]
|
|
343
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
344
|
+
.map(([path, copy]) => ({
|
|
345
|
+
version: copy.version,
|
|
346
|
+
path: displayPath(cwd, path),
|
|
347
|
+
importers: [...copy.importers].sort(),
|
|
348
|
+
})),
|
|
349
|
+
explanation: `${name} is loaded from more than one physical path, so module state and symbols are not shared`,
|
|
350
|
+
remediation: IDENTITY_REMEDIATION,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
return { workspaceRoot: scanRoot, findings }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Fail before dev/build when the same identity-sensitive package has multiple realpaths. */
|
|
357
|
+
export async function assertIdentityParity(
|
|
358
|
+
cwd: string,
|
|
359
|
+
rootPackage?: Record<string, unknown>,
|
|
360
|
+
options: IdentityParityOptions = {},
|
|
361
|
+
): Promise<IdentityParityResult> {
|
|
362
|
+
const result = await collectIdentityParity(cwd, rootPackage, options)
|
|
363
|
+
if (result.findings.length === 0) return result
|
|
364
|
+
const details = result.findings
|
|
365
|
+
.map(
|
|
366
|
+
(finding) =>
|
|
367
|
+
`${finding.package}: ${finding.explanation}. ${finding.remediation} Paths: ${finding.copies
|
|
368
|
+
.map((copy) => copy.path)
|
|
369
|
+
.join(", ")}`,
|
|
370
|
+
)
|
|
371
|
+
.join("; ")
|
|
372
|
+
throw new Error(`[nifra] identity parity failed: ${details}`)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const assetName = (url: string): string => url.replace(/^.*\//, "")
|
|
376
|
+
const assetExtension = (url: string): string => {
|
|
377
|
+
const file = assetName(url).toLowerCase()
|
|
378
|
+
const dot = file.lastIndexOf(".")
|
|
379
|
+
return dot === -1 ? "other" : file.slice(dot + 1)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Convert hashed production asset names into the stable module-graph contract. */
|
|
383
|
+
export function logicalStaticAssets(manifest: BuildManifestLike): readonly string[] {
|
|
384
|
+
const routeOwners = new Map<string, string[]>()
|
|
385
|
+
for (const [route, chunks] of Object.entries(manifest.routes)) {
|
|
386
|
+
chunks.forEach((chunk, index) => {
|
|
387
|
+
const owners = routeOwners.get(assetName(chunk)) ?? []
|
|
388
|
+
owners.push(`route:${route}:${index}`)
|
|
389
|
+
routeOwners.set(assetName(chunk), owners)
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
const cssOwners = new Map<string, string>()
|
|
393
|
+
for (const [index, css] of (manifest.css ?? []).entries())
|
|
394
|
+
cssOwners.set(assetName(css), `stylesheet:${index}`)
|
|
395
|
+
const logical = new Set<string>()
|
|
396
|
+
for (const asset of manifest.assets) {
|
|
397
|
+
const file = assetName(asset)
|
|
398
|
+
if (asset === manifest.entry) {
|
|
399
|
+
logical.add("js:entry")
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
const owners = routeOwners.get(file)
|
|
403
|
+
if (owners !== undefined) {
|
|
404
|
+
for (const owner of owners) logical.add(`js:${owner}`)
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
const css = cssOwners.get(file)
|
|
408
|
+
if (css !== undefined) {
|
|
409
|
+
logical.add(`css:${css}`)
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
if (assetExtension(asset) !== "js") logical.add(`asset:${assetExtension(asset)}`)
|
|
413
|
+
}
|
|
414
|
+
for (const file of manifest.publicFiles ?? []) logical.add(`public:${file}`)
|
|
415
|
+
return [...logical].sort()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function normalizeBuildManifest(manifest: BuildManifestLike): ParityManifest {
|
|
419
|
+
return {
|
|
420
|
+
moduleGraph: {
|
|
421
|
+
routes: Object.keys(manifest.routes).sort(),
|
|
422
|
+
routeChunks: Object.fromEntries(
|
|
423
|
+
Object.entries(manifest.routes)
|
|
424
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
425
|
+
.map(([route, chunks]) => [route, chunks.length]),
|
|
426
|
+
),
|
|
427
|
+
emittedAssets: logicalStaticAssets(manifest).filter(
|
|
428
|
+
(asset) => !asset.startsWith("css:") && !asset.startsWith("public:"),
|
|
429
|
+
),
|
|
430
|
+
},
|
|
431
|
+
publicFiles: [...(manifest.publicFiles ?? [])].sort(),
|
|
432
|
+
css: (manifest.css ?? []).length > 0 ? ["css:present"] : [],
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function createDevelopmentParityManifest(input: DevelopmentParityInput): ParityManifest {
|
|
437
|
+
const routes = Object.fromEntries(
|
|
438
|
+
Object.entries(input.routes).sort(([a], [b]) => a.localeCompare(b)),
|
|
439
|
+
)
|
|
440
|
+
const publicFiles = [...input.publicFiles].sort()
|
|
441
|
+
return {
|
|
442
|
+
moduleGraph: {
|
|
443
|
+
routes: Object.keys(routes),
|
|
444
|
+
routeChunks: routes,
|
|
445
|
+
emittedAssets: [
|
|
446
|
+
"js:entry",
|
|
447
|
+
...Object.entries(routes).flatMap(([route, count]) =>
|
|
448
|
+
Array.from({ length: count }, (_, index) => `js:route:${route}:${index}`),
|
|
449
|
+
),
|
|
450
|
+
].sort(),
|
|
451
|
+
},
|
|
452
|
+
publicFiles,
|
|
453
|
+
css: [...input.css].sort(),
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const sourceFilesUnder = (root: string): readonly string[] => {
|
|
458
|
+
const files: string[] = []
|
|
459
|
+
const walk = (current: string): void => {
|
|
460
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
461
|
+
if (["node_modules", "dist", "build", ".git", ".nifra", "coverage"].includes(entry.name))
|
|
462
|
+
continue
|
|
463
|
+
const path = join(current, entry.name)
|
|
464
|
+
if (entry.isDirectory()) walk(path)
|
|
465
|
+
else if (entry.isFile() && SOURCE_EXTENSIONS.test(entry.name)) files.push(path)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (existsSync(root)) walk(root)
|
|
469
|
+
return files
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const publicFilesUnder = (publicDir: string | false | undefined): readonly string[] => {
|
|
473
|
+
if (publicDir === undefined || publicDir === false || !existsSync(publicDir)) return []
|
|
474
|
+
const root = resolve(publicDir)
|
|
475
|
+
const files: string[] = []
|
|
476
|
+
const walk = (current: string): void => {
|
|
477
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
478
|
+
const path = join(current, entry.name)
|
|
479
|
+
if (entry.isDirectory()) walk(path)
|
|
480
|
+
else if (entry.isFile()) files.push(`/${relative(root, path).split(sep).join("/")}`)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
walk(root)
|
|
484
|
+
return files.sort()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Read the source-side dev contract, independent of any production output. */
|
|
488
|
+
export function collectDevelopmentParityInput(
|
|
489
|
+
routesDir: string,
|
|
490
|
+
publicDir: string | false | undefined,
|
|
491
|
+
): DevelopmentParityInput {
|
|
492
|
+
const routeManifest = discoverRoutes(routesDir)
|
|
493
|
+
const routes: Record<string, number> = Object.fromEntries(
|
|
494
|
+
routeManifest.routes.map((route) => [route.id, route.layoutIds.length + 1]),
|
|
495
|
+
)
|
|
496
|
+
if (routeManifest.notFound !== undefined) routes._404 = 1
|
|
497
|
+
const sourceRoot = dirname(resolve(routesDir))
|
|
498
|
+
const css = sourceFilesUnder(sourceRoot).some((file) => {
|
|
499
|
+
const content = readFileSync(file, "utf8")
|
|
500
|
+
return CSS_IMPORT.test(content) || (isSingleFileComponent(file) && SFC_STYLE.test(content))
|
|
501
|
+
})
|
|
502
|
+
? ["css:present"]
|
|
503
|
+
: []
|
|
504
|
+
return {
|
|
505
|
+
routes,
|
|
506
|
+
publicFiles: publicFilesUnder(publicDir),
|
|
507
|
+
css,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const equal = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
|
|
512
|
+
|
|
513
|
+
export function compareManifestParity(
|
|
514
|
+
development: ParityManifest,
|
|
515
|
+
production: ParityManifest,
|
|
516
|
+
): readonly ManifestParityDifference[] {
|
|
517
|
+
const differences: ManifestParityDifference[] = []
|
|
518
|
+
if (!equal(development.moduleGraph, production.moduleGraph))
|
|
519
|
+
differences.push({
|
|
520
|
+
section: "module-graph",
|
|
521
|
+
development: development.moduleGraph,
|
|
522
|
+
production: production.moduleGraph,
|
|
523
|
+
})
|
|
524
|
+
if (!equal(development.publicFiles, production.publicFiles))
|
|
525
|
+
differences.push({
|
|
526
|
+
section: "public-files",
|
|
527
|
+
development: development.publicFiles,
|
|
528
|
+
production: production.publicFiles,
|
|
529
|
+
})
|
|
530
|
+
if (!equal(development.css, production.css))
|
|
531
|
+
differences.push({ section: "css", development: development.css, production: production.css })
|
|
532
|
+
return differences
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Validate a production manifest against the source manifest a development server should serve. */
|
|
536
|
+
export function assertDevelopmentProductionParity(
|
|
537
|
+
development: DevelopmentParityInput,
|
|
538
|
+
production: BuildManifestLike,
|
|
539
|
+
): readonly ManifestParityDifference[] {
|
|
540
|
+
const differences = compareManifestParity(
|
|
541
|
+
createDevelopmentParityManifest(development),
|
|
542
|
+
normalizeBuildManifest(production),
|
|
543
|
+
)
|
|
544
|
+
if (differences.length > 0) {
|
|
545
|
+
const detail = differences
|
|
546
|
+
.map(
|
|
547
|
+
(difference) =>
|
|
548
|
+
`${difference.section}: development=${JSON.stringify(difference.development)} production=${JSON.stringify(difference.production)}`,
|
|
549
|
+
)
|
|
550
|
+
.join("; ")
|
|
551
|
+
throw new Error(`[nifra] development and production manifest parity failed: ${detail}`)
|
|
552
|
+
}
|
|
553
|
+
return differences
|
|
554
|
+
}
|
package/src/vite.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { createDevDiagnostics } from "./dev-diagnostics.ts"
|
|
|
21
21
|
import { listenOrExplain } from "./dev-port.ts"
|
|
22
22
|
import { discoverRoutes } from "./fs.ts"
|
|
23
23
|
import { DEFAULT_DEV_PORT, generateClientEntry, setSsrModuleLoader } from "./index.ts"
|
|
24
|
+
import { assertIdentityParity } from "./internal/parity.ts"
|
|
24
25
|
import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
|
|
25
26
|
import { importVite } from "./internal/vite-import.ts"
|
|
26
27
|
import { scopedName } from "./plugins/css-modules.ts"
|
|
@@ -296,6 +297,7 @@ function packageNameOf(specifier: string): string | undefined {
|
|
|
296
297
|
export async function createViteDevServer(options: ViteDevServerOptions): Promise<ViteDevServer> {
|
|
297
298
|
const root = resolvePath(options.root ?? process.cwd())
|
|
298
299
|
const routesDir = resolvePath(options.routesDir)
|
|
300
|
+
await assertIdentityParity(root)
|
|
299
301
|
const port = options.port ?? DEFAULT_DEV_PORT
|
|
300
302
|
|
|
301
303
|
// Which files are the app's own components, for framework plugins that hot-patch at component
|