@nifrajs/web 2.12.1 → 2.14.1

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.
@@ -0,0 +1,579 @@
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 type IdentityParityCause = "version-skew" | "duplicate-path"
40
+
41
+ export interface IdentityParityFinding {
42
+ readonly package: string
43
+ readonly copies: readonly IdentityParityCopy[]
44
+ /** Unique package versions observed across the physical copies. */
45
+ readonly versions: readonly string[]
46
+ /** `version-skew` when copies advertise different versions; otherwise the same-version path split. */
47
+ readonly cause: IdentityParityCause
48
+ readonly explanation: string
49
+ readonly remediation: string
50
+ }
51
+
52
+ export interface IdentityParityResult {
53
+ readonly workspaceRoot: string
54
+ readonly findings: readonly IdentityParityFinding[]
55
+ }
56
+
57
+ export interface IdentityParityOptions {
58
+ /** Scan the governing workspace when the caller is a package subdirectory. */
59
+ readonly useWorkspaceRoot?: boolean
60
+ }
61
+
62
+ export interface BuildManifestLike {
63
+ readonly entry: string
64
+ readonly assets: readonly string[]
65
+ readonly routes: Readonly<Record<string, readonly string[]>>
66
+ readonly publicFiles?: readonly string[]
67
+ readonly css?: readonly string[]
68
+ }
69
+
70
+ export interface ParityManifest {
71
+ readonly moduleGraph: {
72
+ readonly routes: readonly string[]
73
+ readonly routeChunks: Readonly<Record<string, number>>
74
+ readonly emittedAssets: readonly string[]
75
+ }
76
+ readonly publicFiles: readonly string[]
77
+ readonly css: readonly string[]
78
+ }
79
+
80
+ export interface DevelopmentParityInput {
81
+ readonly routes: Readonly<Record<string, number>>
82
+ readonly publicFiles: readonly string[]
83
+ readonly css: readonly string[]
84
+ }
85
+
86
+ const SOURCE_EXTENSIONS = /\.(?:c|m)?(?:j|t)sx?$|\.(?:mdx|svelte|vue)$/
87
+ const CSS_IMPORT =
88
+ /(?:import\s+(?:[^"']+\s+from\s+)?|from\s+)["'][^"']+\.(?:css|scss|sass|less|styl)(?:\?[^"']*)?["']/i
89
+ /** A single-file-component `<style>` block (Svelte/Vue). The bundler extracts these into the app
90
+ * stylesheet even though no `import "...css"` statement exists, so the dev contract must count them
91
+ * or a scoped-style component would look style-free next to a production manifest that carries css. */
92
+ const SFC_STYLE = /<style[\s>]/i
93
+ const isSingleFileComponent = (file: string): boolean =>
94
+ file.endsWith(".svelte") || file.endsWith(".vue")
95
+
96
+ export type ManifestParitySection = "module-graph" | "public-files" | "css"
97
+
98
+ export interface ManifestParityDifference {
99
+ readonly section: ManifestParitySection
100
+ readonly development: unknown
101
+ readonly production: unknown
102
+ }
103
+
104
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
105
+ typeof value === "object" && value !== null && !Array.isArray(value)
106
+
107
+ const readJson = async (path: string): Promise<Record<string, unknown> | undefined> => {
108
+ try {
109
+ const value: unknown = JSON.parse(await Bun.file(path).text())
110
+ return isRecord(value) ? value : undefined
111
+ } catch {
112
+ return undefined
113
+ }
114
+ }
115
+
116
+ const dependencyNames = (pkg: Record<string, unknown>): readonly string[] => {
117
+ const names = new Set<string>()
118
+ for (const field of DEPENDENCY_FIELDS) {
119
+ const value = pkg[field]
120
+ if (!isRecord(value)) continue
121
+ for (const name of Object.keys(value)) names.add(name)
122
+ }
123
+ return [...names]
124
+ }
125
+
126
+ const workspacePatterns = (pkg: Record<string, unknown>): readonly string[] => {
127
+ const raw = pkg.workspaces
128
+ const entries = Array.isArray(raw)
129
+ ? raw
130
+ : isRecord(raw) && Array.isArray(raw.packages)
131
+ ? raw.packages
132
+ : []
133
+ return entries
134
+ .filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
135
+ .sort()
136
+ }
137
+
138
+ const pathExists = (path: string): Promise<boolean> =>
139
+ stat(path).then(
140
+ () => true,
141
+ () => false,
142
+ )
143
+
144
+ const pathInside = (root: string, path: string): boolean =>
145
+ path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
146
+
147
+ const workspaceContains = async (
148
+ root: string,
149
+ patterns: readonly string[],
150
+ target: string,
151
+ ): Promise<boolean> => {
152
+ if (root === target) return patterns.length > 0
153
+ for (const pattern of patterns) {
154
+ const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
155
+ for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
156
+ if (match.split(/[\\/]/).includes("node_modules")) continue
157
+ const packageRoot = resolve(root, dirname(match))
158
+ const targetRelative = relative(packageRoot, target)
159
+ if (
160
+ targetRelative === "" ||
161
+ (!targetRelative.startsWith(`..${sep}`) && targetRelative !== "..")
162
+ )
163
+ return true
164
+ }
165
+ }
166
+ return false
167
+ }
168
+
169
+ /** Resolve the workspace root once, even when the caller starts in a workspace package. */
170
+ export async function resolveParityWorkspaceRoot(
171
+ start: string,
172
+ ): Promise<{ readonly root: string; readonly package: Record<string, unknown> }> {
173
+ const original = realpathSync(resolve(start))
174
+ let current = original
175
+ for (;;) {
176
+ const pkg = await readJson(join(current, "package.json"))
177
+ if (pkg !== undefined && (await workspaceContains(current, workspacePatterns(pkg), original))) {
178
+ return { root: current, package: pkg }
179
+ }
180
+ if (await pathExists(join(current, ".git"))) return { root: original, package: {} }
181
+ const parent = dirname(current)
182
+ if (parent === current) return { root: original, package: {} }
183
+ current = parent
184
+ }
185
+ }
186
+
187
+ const workspaceImporters = async (
188
+ root: string,
189
+ rootPackage: Record<string, unknown>,
190
+ ): Promise<readonly { root: string; package: Record<string, unknown> }[]> => {
191
+ const manifests = new Set<string>([join(root, "package.json")])
192
+ for (const pattern of workspacePatterns(rootPackage)) {
193
+ const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
194
+ for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
195
+ if (match.split(/[\\/]/).includes("node_modules")) continue
196
+ manifests.add(join(root, match))
197
+ if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
198
+ }
199
+ }
200
+ const importers: { root: string; package: Record<string, unknown> }[] = []
201
+ for (const manifest of [...manifests].sort()) {
202
+ const pkg = await readJson(manifest)
203
+ if (pkg !== undefined) importers.push({ root: dirname(manifest), package: pkg })
204
+ }
205
+ return importers
206
+ }
207
+
208
+ export const resolvedInstalledCopy = async (
209
+ importer: string,
210
+ boundary: string,
211
+ name: string,
212
+ ): Promise<{ readonly path: string; readonly version: string } | undefined> => {
213
+ const parts = name.split("/")
214
+ for (let dir = importer; ; dir = dirname(dir)) {
215
+ const packageDir = join(dir, "node_modules", ...parts)
216
+ const meta = await readJson(join(packageDir, "package.json"))
217
+ if (meta !== undefined) {
218
+ try {
219
+ return {
220
+ path: await realpath(packageDir),
221
+ version:
222
+ typeof meta.version === "string" && meta.version.length > 0 ? meta.version : "unknown",
223
+ }
224
+ } catch {
225
+ return undefined
226
+ }
227
+ }
228
+ if (dir === boundary) return undefined
229
+ const parent = dirname(dir)
230
+ if (parent === dir) return undefined
231
+ }
232
+ }
233
+
234
+ const linkedRepoBoundary = async (packageRoot: string): Promise<string> => {
235
+ let dir = packageRoot
236
+ for (let depth = 0; depth < 8; depth++) {
237
+ if (await pathExists(join(dir, ".git"))) return dir
238
+ const parent = dirname(dir)
239
+ if (parent === dir) break
240
+ dir = parent
241
+ }
242
+ return packageRoot
243
+ }
244
+
245
+ const linkedPackageRoots = async (
246
+ importerRoots: readonly string[],
247
+ manifests: readonly Record<string, unknown>[],
248
+ scanRoot: string,
249
+ ): Promise<readonly string[]> => {
250
+ const realScanRoot = await realpath(scanRoot).catch(() => scanRoot)
251
+ const found = new Map<string, true>()
252
+ let probes = 0
253
+ for (const [index, importerRoot] of importerRoots.entries()) {
254
+ const pkg = manifests[index]
255
+ if (pkg === undefined) continue
256
+ for (const name of dependencyNames(pkg)) {
257
+ if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) return [...found.keys()]
258
+ const candidate = join(importerRoot, "node_modules", ...name.split("/"))
259
+ const link = await lstat(candidate).catch(() => undefined)
260
+ if (link === undefined || !link.isSymbolicLink()) continue
261
+ const resolved = await realpath(candidate).catch(() => undefined)
262
+ if (resolved === undefined || pathInside(realScanRoot, resolved)) continue
263
+ found.set(resolved, true)
264
+ }
265
+ }
266
+ return [...found.keys()]
267
+ }
268
+
269
+ export const displayPath = (cwd: string, path: string): string => {
270
+ const rel = relative(cwd, path)
271
+ return rel === "" ? "." : rel
272
+ }
273
+
274
+ const identityTargets = (pkg: Record<string, unknown>): readonly string[] =>
275
+ dependencyNames(pkg)
276
+ .filter((name) => name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name))
277
+ .sort()
278
+
279
+ const IDENTITY_REMEDIATION =
280
+ "Align dependency ranges and reinstall from the workspace root so every importer resolves one physical copy; nifra does not rewrite lockfiles for identity conflicts."
281
+
282
+ /** Find duplicate identity-sensitive package realpaths without reading application payloads. */
283
+ export async function collectIdentityParity(
284
+ cwd: string,
285
+ rootPackage?: Record<string, unknown>,
286
+ options: IdentityParityOptions = {},
287
+ ): Promise<IdentityParityResult> {
288
+ const requestedRoot = resolve(cwd)
289
+ const localPackage = await readJson(join(requestedRoot, "package.json"))
290
+ const workspace =
291
+ options.useWorkspaceRoot && localPackage !== undefined
292
+ ? await resolveParityWorkspaceRoot(requestedRoot)
293
+ : { root: requestedRoot, package: localPackage ?? rootPackage ?? {} }
294
+ const scanRoot = workspace.root
295
+ const scanPackage = workspace.package
296
+ const importers = await workspaceImporters(scanRoot, scanPackage)
297
+ const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
298
+ const record = (
299
+ name: string,
300
+ copy: { readonly path: string; readonly version: string },
301
+ importer: string,
302
+ ): void => {
303
+ const copies = byPackage.get(name) ?? new Map()
304
+ const entry = copies.get(copy.path) ?? { version: copy.version, importers: new Set<string>() }
305
+ entry.importers.add(importer)
306
+ copies.set(copy.path, entry)
307
+ byPackage.set(name, copies)
308
+ }
309
+
310
+ const targets = new Set<string>()
311
+ for (const importer of importers) {
312
+ for (const name of identityTargets(importer.package)) {
313
+ if (importer.package.name === name) continue
314
+ targets.add(name)
315
+ const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
316
+ if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
317
+ }
318
+ }
319
+ for (const name of targets) {
320
+ const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
321
+ if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
322
+ }
323
+
324
+ const linkedRoots = await linkedPackageRoots(
325
+ [...importers.map((importer) => importer.root), scanRoot],
326
+ [...importers.map((importer) => importer.package), scanPackage],
327
+ scanRoot,
328
+ )
329
+ for (const linkedRoot of linkedRoots) {
330
+ const linkedPackage = await readJson(join(linkedRoot, "package.json"))
331
+ const boundary = await linkedRepoBoundary(linkedRoot)
332
+ const linkedTargets = new Set([
333
+ ...targets,
334
+ ...(linkedPackage === undefined ? [] : identityTargets(linkedPackage)),
335
+ ])
336
+ for (const name of linkedTargets) {
337
+ if (linkedPackage?.name === name) continue
338
+ const copy = await resolvedInstalledCopy(linkedRoot, boundary, name)
339
+ if (copy !== undefined) record(name, copy, displayPath(cwd, linkedRoot))
340
+ }
341
+ }
342
+
343
+ const findings: IdentityParityFinding[] = []
344
+ for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
345
+ if (copies.size < 2) continue
346
+ const resolvedCopies = [...copies.entries()]
347
+ .sort(([a], [b]) => a.localeCompare(b))
348
+ .map(([path, copy]) => ({
349
+ version: copy.version,
350
+ path: displayPath(cwd, path),
351
+ importers: [...copy.importers].sort(),
352
+ }))
353
+ const versions = [...new Set(resolvedCopies.map((copy) => copy.version))].sort()
354
+ const cause: IdentityParityCause = versions.length > 1 ? "version-skew" : "duplicate-path"
355
+ findings.push({
356
+ package: name,
357
+ copies: resolvedCopies,
358
+ versions,
359
+ cause,
360
+ explanation:
361
+ cause === "version-skew"
362
+ ? `${name} resolves to multiple versions and physical paths, so module state and symbols are not shared`
363
+ : `${name} is loaded from more than one physical path, so module state and symbols are not shared`,
364
+ remediation: IDENTITY_REMEDIATION,
365
+ })
366
+ }
367
+ return { workspaceRoot: scanRoot, findings }
368
+ }
369
+
370
+ /** One `- pkg [cause]: ...` line per finding, shared by the hard failure and the dev warning. */
371
+ export function formatIdentityParityFindings(findings: readonly IdentityParityFinding[]): string {
372
+ return findings
373
+ .map(
374
+ (finding) =>
375
+ `- ${finding.package} [${finding.cause}]: ${finding.explanation}. ${finding.remediation}\n` +
376
+ ` versions: ${finding.versions.join(", ")}; paths: ${finding.copies
377
+ .map((copy) => copy.path)
378
+ .join(", ")}`,
379
+ )
380
+ .join("\n")
381
+ }
382
+
383
+ /** `2 primary package findings` / `1 primary package finding`. */
384
+ export const identityParityHeadline = (count: number): string =>
385
+ `${count} primary package finding${count === 1 ? "" : "s"}`
386
+
387
+ /** Fail before dev/build when the same identity-sensitive package has multiple realpaths. */
388
+ export async function assertIdentityParity(
389
+ cwd: string,
390
+ rootPackage?: Record<string, unknown>,
391
+ options: IdentityParityOptions = {},
392
+ ): Promise<IdentityParityResult> {
393
+ const result = await collectIdentityParity(cwd, rootPackage, options)
394
+ if (result.findings.length === 0) return result
395
+ throw new Error(
396
+ `[nifra] identity parity failed (${identityParityHeadline(result.findings.length)}):\n${formatIdentityParityFindings(result.findings)}`,
397
+ )
398
+ }
399
+
400
+ const assetName = (url: string): string => url.replace(/^.*\//, "")
401
+ const assetExtension = (url: string): string => {
402
+ const file = assetName(url).toLowerCase()
403
+ const dot = file.lastIndexOf(".")
404
+ return dot === -1 ? "other" : file.slice(dot + 1)
405
+ }
406
+
407
+ /** Convert hashed production asset names into the stable module-graph contract. */
408
+ export function logicalStaticAssets(manifest: BuildManifestLike): readonly string[] {
409
+ const routeOwners = new Map<string, string[]>()
410
+ for (const [route, chunks] of Object.entries(manifest.routes)) {
411
+ chunks.forEach((chunk, index) => {
412
+ const owners = routeOwners.get(assetName(chunk)) ?? []
413
+ owners.push(`route:${route}:${index}`)
414
+ routeOwners.set(assetName(chunk), owners)
415
+ })
416
+ }
417
+ const cssOwners = new Map<string, string>()
418
+ for (const [index, css] of (manifest.css ?? []).entries())
419
+ cssOwners.set(assetName(css), `stylesheet:${index}`)
420
+ const logical = new Set<string>()
421
+ for (const asset of manifest.assets) {
422
+ const file = assetName(asset)
423
+ if (asset === manifest.entry) {
424
+ logical.add("js:entry")
425
+ continue
426
+ }
427
+ const owners = routeOwners.get(file)
428
+ if (owners !== undefined) {
429
+ for (const owner of owners) logical.add(`js:${owner}`)
430
+ continue
431
+ }
432
+ const css = cssOwners.get(file)
433
+ if (css !== undefined) {
434
+ logical.add(`css:${css}`)
435
+ continue
436
+ }
437
+ if (assetExtension(asset) !== "js") logical.add(`asset:${assetExtension(asset)}`)
438
+ }
439
+ for (const file of manifest.publicFiles ?? []) logical.add(`public:${file}`)
440
+ return [...logical].sort()
441
+ }
442
+
443
+ export function normalizeBuildManifest(manifest: BuildManifestLike): ParityManifest {
444
+ return {
445
+ moduleGraph: {
446
+ routes: Object.keys(manifest.routes).sort(),
447
+ routeChunks: Object.fromEntries(
448
+ Object.entries(manifest.routes)
449
+ .sort(([a], [b]) => a.localeCompare(b))
450
+ .map(([route, chunks]) => [route, chunks.length]),
451
+ ),
452
+ emittedAssets: logicalStaticAssets(manifest).filter(
453
+ (asset) => !asset.startsWith("css:") && !asset.startsWith("public:"),
454
+ ),
455
+ },
456
+ publicFiles: [...(manifest.publicFiles ?? [])].sort(),
457
+ css: (manifest.css ?? []).length > 0 ? ["css:present"] : [],
458
+ }
459
+ }
460
+
461
+ export function createDevelopmentParityManifest(input: DevelopmentParityInput): ParityManifest {
462
+ const routes = Object.fromEntries(
463
+ Object.entries(input.routes).sort(([a], [b]) => a.localeCompare(b)),
464
+ )
465
+ const publicFiles = [...input.publicFiles].sort()
466
+ return {
467
+ moduleGraph: {
468
+ routes: Object.keys(routes),
469
+ routeChunks: routes,
470
+ emittedAssets: [
471
+ "js:entry",
472
+ ...Object.entries(routes).flatMap(([route, count]) =>
473
+ Array.from({ length: count }, (_, index) => `js:route:${route}:${index}`),
474
+ ),
475
+ ].sort(),
476
+ },
477
+ publicFiles,
478
+ css: [...input.css].sort(),
479
+ }
480
+ }
481
+
482
+ const sourceFilesUnder = (root: string): readonly string[] => {
483
+ const files: string[] = []
484
+ const walk = (current: string): void => {
485
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
486
+ if (["node_modules", "dist", "build", ".git", ".nifra", "coverage"].includes(entry.name))
487
+ continue
488
+ const path = join(current, entry.name)
489
+ if (entry.isDirectory()) walk(path)
490
+ else if (entry.isFile() && SOURCE_EXTENSIONS.test(entry.name)) files.push(path)
491
+ }
492
+ }
493
+ if (existsSync(root)) walk(root)
494
+ return files
495
+ }
496
+
497
+ const publicFilesUnder = (publicDir: string | false | undefined): readonly string[] => {
498
+ if (publicDir === undefined || publicDir === false || !existsSync(publicDir)) return []
499
+ const root = resolve(publicDir)
500
+ const files: string[] = []
501
+ const walk = (current: string): void => {
502
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
503
+ const path = join(current, entry.name)
504
+ if (entry.isDirectory()) walk(path)
505
+ else if (entry.isFile()) files.push(`/${relative(root, path).split(sep).join("/")}`)
506
+ }
507
+ }
508
+ walk(root)
509
+ return files.sort()
510
+ }
511
+
512
+ /** Read the source-side dev contract, independent of any production output. */
513
+ export function collectDevelopmentParityInput(
514
+ routesDir: string,
515
+ publicDir: string | false | undefined,
516
+ ): DevelopmentParityInput {
517
+ const routeManifest = discoverRoutes(routesDir)
518
+ const routes: Record<string, number> = Object.fromEntries(
519
+ routeManifest.routes.map((route) => [route.id, route.layoutIds.length + 1]),
520
+ )
521
+ if (routeManifest.notFound !== undefined) routes._404 = 1
522
+ const sourceRoot = dirname(resolve(routesDir))
523
+ const css = sourceFilesUnder(sourceRoot).some((file) => {
524
+ const content = readFileSync(file, "utf8")
525
+ return CSS_IMPORT.test(content) || (isSingleFileComponent(file) && SFC_STYLE.test(content))
526
+ })
527
+ ? ["css:present"]
528
+ : []
529
+ return {
530
+ routes,
531
+ publicFiles: publicFilesUnder(publicDir),
532
+ css,
533
+ }
534
+ }
535
+
536
+ const equal = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
537
+
538
+ export function compareManifestParity(
539
+ development: ParityManifest,
540
+ production: ParityManifest,
541
+ ): readonly ManifestParityDifference[] {
542
+ const differences: ManifestParityDifference[] = []
543
+ if (!equal(development.moduleGraph, production.moduleGraph))
544
+ differences.push({
545
+ section: "module-graph",
546
+ development: development.moduleGraph,
547
+ production: production.moduleGraph,
548
+ })
549
+ if (!equal(development.publicFiles, production.publicFiles))
550
+ differences.push({
551
+ section: "public-files",
552
+ development: development.publicFiles,
553
+ production: production.publicFiles,
554
+ })
555
+ if (!equal(development.css, production.css))
556
+ differences.push({ section: "css", development: development.css, production: production.css })
557
+ return differences
558
+ }
559
+
560
+ /** Validate a production manifest against the source manifest a development server should serve. */
561
+ export function assertDevelopmentProductionParity(
562
+ development: DevelopmentParityInput,
563
+ production: BuildManifestLike,
564
+ ): readonly ManifestParityDifference[] {
565
+ const differences = compareManifestParity(
566
+ createDevelopmentParityManifest(development),
567
+ normalizeBuildManifest(production),
568
+ )
569
+ if (differences.length > 0) {
570
+ const detail = differences
571
+ .map(
572
+ (difference) =>
573
+ `${difference.section}: development=${JSON.stringify(difference.development)} production=${JSON.stringify(difference.production)}`,
574
+ )
575
+ .join("; ")
576
+ throw new Error(`[nifra] development and production manifest parity failed: ${detail}`)
577
+ }
578
+ return differences
579
+ }
package/src/vite.ts CHANGED
@@ -21,6 +21,12 @@ 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 {
25
+ assertIdentityParity,
26
+ collectIdentityParity,
27
+ formatIdentityParityFindings,
28
+ identityParityHeadline,
29
+ } from "./internal/parity.ts"
24
30
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
25
31
  import { importVite } from "./internal/vite-import.ts"
26
32
  import { scopedName } from "./plugins/css-modules.ts"
@@ -33,6 +39,18 @@ interface FetchApp {
33
39
  fetch(request: Request): Response | Promise<Response>
34
40
  }
35
41
 
42
+ // A node in Vite's module graph. `importers` is the union of client and SSR importers - walking it
43
+ // over-invalidates (a client-only importer gets re-evaluated on the SSR side too), which is safe:
44
+ // re-evaluation is idempotent, and correctness of the served module beats sparing a dev re-eval.
45
+ interface ViteModuleNode {
46
+ readonly importers: Set<ViteModuleNode>
47
+ }
48
+ // Structural slice of Vite's legacy unified module graph. Absent on a Vite that renamed it (the
49
+ // invalidation degrades to Vite's own change handling, i.e. today's behavior) - never a hard failure.
50
+ interface ViteModuleGraph {
51
+ getModulesByFile(file: string): Set<ViteModuleNode> | undefined
52
+ invalidateModule(mod: ViteModuleNode): void
53
+ }
36
54
  // Structural slice of the Vite dev server this module drives (avoids a hard type dep on `vite`).
37
55
  interface ViteLike {
38
56
  /** Load a module through VITE's graph - the seam that makes the Vite pipeline own SSR too. */
@@ -40,6 +58,8 @@ interface ViteLike {
40
58
  readonly middlewares: (req: IncomingMessage, res: ServerResponse, next: () => void) => void
41
59
  transformIndexHtml(url: string, html: string): Promise<string>
42
60
  ssrFixStacktrace(err: Error): void
61
+ /** The module graph, used to invalidate a changed file AND its transitive importers before reload. */
62
+ readonly moduleGraph?: ViteModuleGraph
43
63
  readonly watcher: {
44
64
  on(event: "change" | "add" | "unlink", cb: (path: string) => void): void
45
65
  }
@@ -98,6 +118,14 @@ export interface ViteDevServerOptions {
98
118
  readonly publicDir?: string | false
99
119
  /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
100
120
  readonly publicEnvPrefix?: string
121
+ /**
122
+ * Downgrade the startup identity-parity check from a hard failure to a loud warning (dev only).
123
+ * The check catches two physical copies of an identity-sensitive package (e.g. React) resolving in
124
+ * one process, which reliably breaks hydration and framework context. When a duplicate comes from a
125
+ * linked sibling repo you cannot fix in the moment, set this to keep the dev server running while you
126
+ * resolve it; `nifra build` never honors it. Wired to `nifra dev --allow-duplicate-identity`.
127
+ */
128
+ readonly allowDuplicateIdentity?: boolean
101
129
  }
102
130
 
103
131
  export interface ViteDevServer {
@@ -296,6 +324,16 @@ function packageNameOf(specifier: string): string | undefined {
296
324
  export async function createViteDevServer(options: ViteDevServerOptions): Promise<ViteDevServer> {
297
325
  const root = resolvePath(options.root ?? process.cwd())
298
326
  const routesDir = resolvePath(options.routesDir)
327
+ if (options.allowDuplicateIdentity === true) {
328
+ const parity = await collectIdentityParity(root)
329
+ if (parity.findings.length > 0)
330
+ console.warn(
331
+ `[nifra] identity parity bypassed via --allow-duplicate-identity (${identityParityHeadline(parity.findings.length)}):\n${formatIdentityParityFindings(parity.findings)}\n` +
332
+ "[nifra] dev is continuing, but duplicate module identity can break hydration and framework context - fix the resolution before you ship.",
333
+ )
334
+ } else {
335
+ await assertIdentityParity(root)
336
+ }
299
337
  const port = options.port ?? DEFAULT_DEV_PORT
300
338
 
301
339
  // Which files are the app's own components, for framework plugins that hot-patch at component
@@ -472,12 +510,36 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
472
510
  setSsrModuleLoader(ssrLoad)
473
511
  app = await options.createApp(entryUrl, ssrLoad)
474
512
 
513
+ // Evict a changed file from the SSR graph together with every module that (transitively) imports it,
514
+ // BEFORE re-creating the app. Vite re-evaluates a directly-changed module on its own, but a parent
515
+ // that merely imports the changed leaf keeps its cached SSR bindings - so the re-imported entry walks
516
+ // a graph that is fresh at the leaf and stale everywhere above it. That is what surfaces downstream as
517
+ // phantom hydration mismatches (SSR renders through an old module, the client through the new one) and
518
+ // stale i18n catalogs. Invalidating the importer closure makes the next `ssrLoadModule(entry)` re-walk
519
+ // the whole affected subtree. `importers` is the client+SSR union, so this over-invalidates slightly;
520
+ // in a dev server that only costs a re-evaluation, never correctness.
521
+ const invalidateImporterClosure = (path: string): void => {
522
+ const graph = vite.moduleGraph
523
+ if (graph === undefined) return
524
+ const roots = graph.getModulesByFile(path)
525
+ if (roots === undefined) return
526
+ const seen = new Set<ViteModuleNode>()
527
+ const stack = [...roots]
528
+ for (let mod = stack.pop(); mod !== undefined; mod = stack.pop()) {
529
+ if (seen.has(mod)) continue
530
+ seen.add(mod)
531
+ graph.invalidateModule(mod)
532
+ for (const importer of mod.importers) stack.push(importer)
533
+ }
534
+ }
535
+
475
536
  // Re-create the app on change so a hard reload picks up a route ADD/REMOVE (the manifest comes
476
- // from a directory scan, which `ssrLoadModule` cannot invalidate). Module CONTENT no longer needs a
477
- // version counter: Vite owns SSR resolution now and re-evaluates changed modules itself, which is
478
- // the cache-busting the old `importQuery` was emulating against Bun's import cache.
537
+ // from a directory scan, which `ssrLoadModule` cannot invalidate). Module CONTENT is re-evaluated by
538
+ // Vite plus the importer-closure invalidation above; no version counter against Bun's import cache is
539
+ // needed anymore, the way the old `importQuery` cache-buster was.
479
540
  let refreshVersion = 0
480
- const refreshApp = (): void => {
541
+ const refreshApp = (path?: string): void => {
542
+ if (path !== undefined) invalidateImporterClosure(path)
481
543
  const version = ++refreshVersion
482
544
  Promise.resolve(options.createApp(entryUrl, ssrLoad))
483
545
  .then((next) => {
@@ -489,7 +551,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
489
551
  for (const event of ["add", "unlink"] as const) {
490
552
  vite.watcher.on(event, (path) => {
491
553
  if (path.startsWith(`${routesDir}/`) || path.startsWith(`${routesDir}\\`)) writeClientEntry()
492
- refreshApp()
554
+ refreshApp(path)
493
555
  })
494
556
  }
495
557