@nifrajs/web 2.12.1 → 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.
@@ -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