@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.
- 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 +9 -6
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +30 -18
- 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/fn.d.ts +2 -2
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +2 -2
- package/dist/fn.js.map +1 -1
- package/dist/index.d.ts +0 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/internal/parity.d.ts +86 -0
- package/dist/internal/parity.d.ts.map +1 -0
- package/dist/internal/parity.js +441 -0
- package/dist/internal/parity.js.map +1 -0
- package/dist/vite.d.ts +8 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +43 -5
- package/dist/vite.js.map +1 -1
- package/package.json +7 -2
- package/src/build-vite.ts +7 -0
- package/src/build.ts +33 -17
- package/src/dev-ssr-graph.ts +1 -1
- package/src/fn.ts +6 -6
- package/src/index.ts +20 -5
- package/src/internal/parity.ts +579 -0
- package/src/vite.ts +67 -5
|
@@ -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
|
|
477
|
-
//
|
|
478
|
-
// the
|
|
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
|
|