@ttsc/unplugin 0.30.3 → 0.30.4

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.
@@ -1,6 +1,6 @@
1
- import { type FSWatcher, watch } from "chokidar";
2
1
  import fs from "node:fs";
3
2
  import path from "node:path";
3
+ import { createFilesystemPathIdentityContext } from "ttsc/path-identity";
4
4
 
5
5
  import {
6
6
  type TtscWatchInput,
@@ -44,6 +44,7 @@ interface ViteEnvironmentLike {
44
44
  * carries.
45
45
  */
46
46
  export interface ViteDevServerLike {
47
+ config?: { root?: string };
47
48
  environments?: Record<string, ViteEnvironmentLike>;
48
49
  hot?: ViteHotChannelLike;
49
50
  moduleGraph?: ViteModuleGraphLike;
@@ -57,12 +58,15 @@ interface InputCondition {
57
58
  }
58
59
 
59
60
  interface InputEntry {
61
+ aliases: Set<string>;
62
+ /** Latest native event already associated with this registered spelling. */
63
+ changedAt: number;
60
64
  conditions: Map<string, InputCondition>;
65
+ fallback: boolean;
61
66
  file: string;
62
- /** Missing paths and directory predicates still need the predicate poll. */
63
- poll: boolean;
64
- observed: boolean;
65
67
  links: Set<string>;
68
+ renameAliases: Set<string>;
69
+ scopes: Set<WatchScope>;
66
70
  }
67
71
 
68
72
  interface LinkedPath {
@@ -70,47 +74,217 @@ interface LinkedPath {
70
74
  inputs: Set<InputEntry>;
71
75
  }
72
76
 
77
+ interface WatchScope {
78
+ entries: Set<InputEntry>;
79
+ failed: boolean;
80
+ root: string;
81
+ pinned: boolean;
82
+ startedAt: number;
83
+ watcher?: { close(): void };
84
+ }
85
+
86
+ export interface ViteServeWatchOperations {
87
+ /** Override case-policy discovery for a simulated host filesystem. */
88
+ caseSensitive?(directory: string): boolean;
89
+ /** Override path semantics when testing a non-host platform. */
90
+ platform?: NodeJS.Platform;
91
+ poll(listener: () => void): { close(): void };
92
+ watch(
93
+ root: string,
94
+ listener: (eventType: string, file: string | null) => void,
95
+ onError: () => void,
96
+ ): { close(): void };
97
+ }
98
+
73
99
  /** Serve-time compiler dependencies never enter Vite's runtime import graph. */
74
100
  export interface ViteServeInputWatch {
75
101
  attach(server: ViteDevServerLike): void;
102
+ begin(): number;
76
103
  dispose(): Promise<void>;
104
+ /** Release every compiler input owned by one removed source module. */
105
+ forget(importer: string): void;
77
106
  replace(
78
107
  importer: string,
79
108
  inputs: readonly TtscWatchInput[],
80
109
  failed?: boolean,
110
+ startedAt?: number,
81
111
  ): void;
82
112
  }
83
113
 
84
114
  /**
85
- * One filesystem subscription per unique input, shared by all served modules.
115
+ * A bounded recursive filesystem observer shared by all served modules.
86
116
  *
87
117
  * Vite resolves transform-context addWatchFile as a runtime import, including
88
118
  * type-only .server files and non-module plugin assets. Use a separate watcher
89
119
  * for compiler inputs, including node_modules, which Vite's watcher ignores.
90
120
  * Ordinary files use events after their initial subscription is observed.
91
- * Missing spellings and directory predicates keep a shared predicate poll.
92
- * Linked files also share topology checks by directory because retargeting a
93
- * junction need not emit events on its previously watched descendants.
121
+ * Missing spellings and directory predicates use the recursive observer for
122
+ * their nearest available scope. Inputs a native scope cannot safely cover
123
+ * share one bounded fallback poll; linked files also share topology checks
124
+ * because retargeting a junction need not emit events on its old descendants.
94
125
  */
95
- export function createViteServeInputWatch(): ViteServeInputWatch {
126
+ export function createViteServeInputWatch(
127
+ operations: Partial<ViteServeWatchOperations> = {},
128
+ ): ViteServeInputWatch {
96
129
  const entries = new Map<string, InputEntry>();
130
+ const aliases = new Map<string, Set<InputEntry>>();
131
+ const renameAliases = new Map<string, Set<InputEntry>>();
97
132
  const importerInputs = new Map<string, Map<string, string>>();
98
133
  const pending = new Set<InputEntry>();
134
+ const polled = new Set<InputEntry>();
99
135
  const links = new Map<string, LinkedPath>();
136
+ const scopes = new Map<string, WatchScope>();
137
+ const componentLinks = new Map<string, string | null>();
138
+ const missingComponents = new Set<string>();
139
+ const changes = new Map<string, number>();
100
140
  let server: ViteDevServerLike | undefined;
101
- let watcher: FSWatcher | undefined;
102
- let poller: NodeJS.Timeout | undefined;
141
+ let projectRoot: string | undefined;
142
+ let changeSequence = 0;
143
+ let historyFloor = 0;
144
+ let linkIterator: MapIterator<[string, LinkedPath]> | undefined;
145
+ let pollIterator: SetIterator<InputEntry> | undefined;
146
+ let poller: { close(): void } | undefined;
103
147
  let flushTimer: NodeJS.Timeout | undefined;
104
- let failed = false;
148
+
149
+ const open = operations.watch ?? openRecursiveWatch;
150
+ const openPoller = operations.poll ?? openWatchPoller;
151
+ const platform = operations.platform ?? process.platform;
152
+ const createCaseIdentities = () =>
153
+ createFilesystemPathIdentityContext({
154
+ ...(operations.caseSensitive === undefined
155
+ ? {}
156
+ : { caseSensitive: operations.caseSensitive }),
157
+ platform,
158
+ throwOnRealpathError: false,
159
+ });
160
+ let caseIdentities = createCaseIdentities();
161
+ const directoryCaseSensitivity = new Map<string, boolean>();
162
+ let pathIdentityMemosDirty = false;
163
+
164
+ /** Drop path facts after topology or ownership changes make them stale. */
165
+ const resetPathIdentityMemos = (): void => {
166
+ caseIdentities = createCaseIdentities();
167
+ directoryCaseSensitivity.clear();
168
+ pathIdentityMemosDirty = false;
169
+ };
170
+
171
+ /** Lexical event key under the nearest existing directory's case policy. */
172
+ const watchPathKey = (file: string): string => {
173
+ const absolute = path.resolve(file);
174
+ if (platform !== "win32" && platform !== "darwin") {
175
+ return absolute;
176
+ }
177
+ let current = path.dirname(absolute);
178
+ const traversed: string[] = [];
179
+ let sensitive: boolean | undefined;
180
+ for (;;) {
181
+ const cacheKey = platform === "win32" ? current.toLowerCase() : current;
182
+ sensitive = directoryCaseSensitivity.get(cacheKey);
183
+ if (sensitive !== undefined) break;
184
+ traversed.push(cacheKey);
185
+ try {
186
+ if (fs.statSync(current).isDirectory()) {
187
+ sensitive = caseIdentities.caseSensitive(current);
188
+ break;
189
+ }
190
+ } catch {
191
+ // A missing suffix inherits the nearest existing ancestor's policy.
192
+ }
193
+ const parent = path.dirname(current);
194
+ if (parent === current) {
195
+ // The shared identity resolver uses the platform default when even the
196
+ // volume root cannot answer the read-only case-sensitivity probe.
197
+ sensitive = caseIdentities.caseSensitive(current);
198
+ break;
199
+ }
200
+ current = parent;
201
+ }
202
+ for (const directory of traversed) {
203
+ directoryCaseSensitivity.set(directory, sensitive);
204
+ }
205
+ return sensitive ? absolute : absolute.toLowerCase();
206
+ };
207
+
208
+ const unbindAlias = (alias: string, entry: InputEntry): void => {
209
+ const indexed = aliases.get(alias);
210
+ indexed?.delete(entry);
211
+ if (indexed?.size === 0) aliases.delete(alias);
212
+ };
213
+
214
+ const closeScope = (scope: WatchScope): void => {
215
+ scopes.delete(watchPathKey(scope.root));
216
+ try {
217
+ scope.watcher?.close();
218
+ } catch {
219
+ // The generation no longer trusts this scope, so cleanup is best effort.
220
+ }
221
+ scope.watcher = undefined;
222
+ };
223
+
224
+ const recordChange = (
225
+ eventType: string,
226
+ file: string,
227
+ ): { direct: Set<string>; parent: Set<string> } => {
228
+ const absolute = path.resolve(file);
229
+ const parent = path.dirname(absolute);
230
+ const direct = new Set<string>();
231
+ const parents = new Set<string>();
232
+ if (eventType === "rename") {
233
+ // Keep the active identity context until affected entries are removed.
234
+ // Their alias and scope indexes were built with that context; changing
235
+ // only the resolver here could make later events unable to reach them.
236
+ // `check()` resets the memos atomically if this topology change removes
237
+ // an entry, while unrelated renames leave still-valid indexes intact.
238
+ componentLinks.clear();
239
+ missingComponents.clear();
240
+ }
241
+ changeSequence += 1;
242
+ direct.add(watchPathKey(absolute));
243
+ parents.add(watchPathKey(parent));
244
+ for (const key of direct) {
245
+ componentLinks.delete(key);
246
+ missingComponents.delete(key);
247
+ changes.set(key, changeSequence);
248
+ }
249
+ for (const key of parents) changes.set(key, changeSequence);
250
+ if (changes.size > MAX_CHANGE_HISTORY) {
251
+ changes.clear();
252
+ historyFloor = changeSequence;
253
+ }
254
+ return { direct, parent: parents };
255
+ };
105
256
 
106
257
  const remove = (entry: InputEntry): void => {
107
258
  entries.delete(entry.file);
108
- watcher?.unwatch(entry.file);
259
+ pending.delete(entry);
260
+ polled.delete(entry);
261
+ for (const alias of entry.aliases) unbindAlias(alias, entry);
262
+ entry.aliases.clear();
263
+ for (const alias of entry.renameAliases) {
264
+ const indexed = renameAliases.get(alias);
265
+ indexed?.delete(entry);
266
+ if (indexed?.size === 0) renameAliases.delete(alias);
267
+ }
268
+ entry.renameAliases.clear();
269
+ for (const scope of entry.scopes) {
270
+ scope.entries.delete(entry);
271
+ if (scope.entries.size === 0 && !scope.pinned) closeScope(scope);
272
+ }
273
+ entry.scopes.clear();
109
274
  for (const file of entry.links) {
110
275
  const link = links.get(file);
111
276
  link?.inputs.delete(entry);
112
277
  if (link?.inputs.size === 0) links.delete(file);
113
278
  }
279
+ entry.links.clear();
280
+ // Component resolution is only an observation-time optimization. Entries
281
+ // can introduce arbitrary missing ancestors, so discard the shared memo
282
+ // when one leaves rather than retaining its path components indefinitely.
283
+ componentLinks.clear();
284
+ missingComponents.clear();
285
+ if (links.size === 0) linkIterator = undefined;
286
+ if (polled.size === 0) pollIterator = undefined;
287
+ pathIdentityMemosDirty = true;
114
288
  };
115
289
 
116
290
  const check = (selected: Iterable<InputEntry>): void => {
@@ -142,19 +316,48 @@ export function createViteServeInputWatch(): ViteServeInputWatch {
142
316
  remove(entry);
143
317
  }
144
318
  }
319
+ if (pathIdentityMemosDirty) resetPathIdentityMemos();
320
+ updatePoller();
145
321
  if (server !== undefined && importers.size !== 0) {
146
322
  invalidateImporters(server, importers);
147
323
  sendFullReload(server);
148
324
  }
149
325
  };
150
326
 
151
- const enqueue = (file: string, observed: boolean): void => {
327
+ const enqueue = (eventType: string, file: string): void => {
152
328
  const absolute = path.resolve(file);
153
- const direct = entries.get(absolute);
154
- if (direct !== undefined && observed) direct.observed = true;
155
- for (const candidate of [absolute, path.dirname(absolute)]) {
156
- const entry = entries.get(candidate);
157
- if (entry !== undefined) pending.add(entry);
329
+ const eventKeys = recordChange(eventType, absolute);
330
+ for (const key of eventKeys.direct) {
331
+ for (const entry of aliases.get(key) ?? []) {
332
+ entry.changedAt = changeSequence;
333
+ pending.add(entry);
334
+ }
335
+ }
336
+ for (const key of eventKeys.parent) {
337
+ for (const entry of aliases.get(key) ?? []) {
338
+ entry.changedAt = changeSequence;
339
+ pending.add(entry);
340
+ }
341
+ }
342
+ if (eventType === "rename") {
343
+ const exact = new Set<InputEntry>();
344
+ for (const key of eventKeys.direct) {
345
+ for (const entry of renameAliases.get(key) ?? []) exact.add(entry);
346
+ }
347
+ // Linux may report only the destination spelling of a directory rename.
348
+ // That spelling cannot be indexed before the move. Fall back to the
349
+ // renamed entry's parent only when no exact old spelling matched; the
350
+ // baseline check below still invalidates solely inputs that really moved.
351
+ const selected = exact;
352
+ if (selected.size === 0) {
353
+ for (const key of eventKeys.parent) {
354
+ for (const entry of renameAliases.get(key) ?? []) selected.add(entry);
355
+ }
356
+ }
357
+ for (const entry of selected) {
358
+ entry.changedAt = changeSequence;
359
+ pending.add(entry);
360
+ }
158
361
  }
159
362
  if (pending.size === 0 || flushTimer !== undefined) return;
160
363
  flushTimer = setTimeout(() => {
@@ -166,68 +369,309 @@ export function createViteServeInputWatch(): ViteServeInputWatch {
166
369
  flushTimer.unref();
167
370
  };
168
371
 
169
- const ensureWatcher = (): FSWatcher => {
170
- if (watcher !== undefined) return watcher;
171
- // Chokidar's persistent:false backend omits its native error listener on
172
- // Windows. Keep the owned subscription alive until dispose() closes it.
173
- const active = watch([], { depth: 0, ignoreInitial: false });
174
- watcher = active;
175
- // Initial add events compare compiler-time evidence too: an edit between
176
- // compilation and asynchronous watcher setup must not be missed.
177
- active.on("all", (event, file) => {
178
- if (watcher === active)
179
- enqueue(file, event === "add" || event === "addDir");
180
- });
181
- active.on("error", () => {
182
- if (watcher === active) failed = true;
183
- });
184
- poller = setInterval(() => {
185
- const selected = new Set(
186
- [...entries.values()].filter(
187
- (entry) => failed || entry.poll || !entry.observed,
188
- ),
189
- );
372
+ const bindAlias = (entry: InputEntry, alias: string): void => {
373
+ alias = watchPathKey(alias);
374
+ if (entry.aliases.has(alias)) return;
375
+ entry.aliases.add(alias);
376
+ let indexed = aliases.get(alias);
377
+ if (indexed === undefined) {
378
+ indexed = new Set();
379
+ aliases.set(alias, indexed);
380
+ }
381
+ indexed.add(entry);
382
+ };
383
+
384
+ const bindRenameAncestors = (entry: InputEntry, file: string): void => {
385
+ let current = path.dirname(path.resolve(file));
386
+ for (;;) {
387
+ const key = watchPathKey(current);
388
+ if (!entry.renameAliases.has(key)) {
389
+ entry.renameAliases.add(key);
390
+ let indexed = renameAliases.get(key);
391
+ if (indexed === undefined) {
392
+ indexed = new Set();
393
+ renameAliases.set(key, indexed);
394
+ }
395
+ indexed.add(entry);
396
+ }
397
+ const parent = path.dirname(current);
398
+ if (parent === current) return;
399
+ current = parent;
400
+ }
401
+ };
402
+
403
+ const ensureScope = (
404
+ root: string,
405
+ external: boolean,
406
+ pinned = false,
407
+ ): WatchScope | undefined => {
408
+ root = path.resolve(root);
409
+ const key = watchPathKey(root);
410
+ let scope = scopes.get(key);
411
+ if (scope === undefined) {
412
+ if (
413
+ external &&
414
+ [...scopes.values()].filter((candidate) => !candidate.pinned).length >=
415
+ MAX_EXTERNAL_WATCH_SCOPES
416
+ )
417
+ return undefined;
418
+ // Opening a scope is itself an observation boundary. Advance the same
419
+ // sequence returned by begin() so an external scope first discovered
420
+ // after compilation cannot claim it was already live at that token when
421
+ // no filesystem event happened in between.
422
+ changeSequence += 1;
423
+ scope = {
424
+ entries: new Set(),
425
+ failed: false,
426
+ pinned,
427
+ root,
428
+ startedAt: changeSequence,
429
+ };
430
+ scopes.set(key, scope);
431
+ try {
432
+ const owned = scope;
433
+ scope.watcher = open(
434
+ root,
435
+ (eventType, file) => {
436
+ if (scopes.get(key) !== owned) return;
437
+ if (file === null) {
438
+ resetPathIdentityMemos();
439
+ changeSequence += 1;
440
+ historyFloor = changeSequence;
441
+ changes.clear();
442
+ for (const candidate of owned.entries) {
443
+ candidate.changedAt = changeSequence;
444
+ pending.add(candidate);
445
+ }
446
+ scheduleFlush();
447
+ return;
448
+ }
449
+ enqueue(
450
+ eventType,
451
+ path.isAbsolute(file) ? file : path.resolve(root, file),
452
+ );
453
+ },
454
+ () => {
455
+ if (scopes.get(key) !== owned) return;
456
+ owned.failed = true;
457
+ try {
458
+ owned.watcher?.close();
459
+ } catch {
460
+ // The fallback owns validation now; a failed native handle is no
461
+ // longer useful, and cleanup must not replace that recovery.
462
+ }
463
+ owned.watcher = undefined;
464
+ for (const entry of owned.entries) requirePolling(entry);
465
+ updatePoller();
466
+ },
467
+ );
468
+ if (scope.failed) {
469
+ // An injected or platform watcher may report failure synchronously
470
+ // during construction, before its handle can be assigned above.
471
+ try {
472
+ scope.watcher?.close();
473
+ } catch {}
474
+ scope.watcher = undefined;
475
+ }
476
+ } catch {
477
+ scope.failed = true;
478
+ }
479
+ } else if (pinned) {
480
+ scope.pinned = true;
481
+ }
482
+ return scope;
483
+ };
484
+
485
+ const bindScope = (
486
+ root: string,
487
+ entry: InputEntry,
488
+ external: boolean,
489
+ ): boolean => {
490
+ const scope = ensureScope(root, external);
491
+ if (scope === undefined) return false;
492
+ scope.entries.add(entry);
493
+ entry.scopes.add(scope);
494
+ return !scope.failed;
495
+ };
496
+
497
+ function requirePolling(entry: InputEntry): void {
498
+ entry.fallback = true;
499
+ if (polled.size === 0) pollIterator = undefined;
500
+ polled.add(entry);
501
+ }
502
+
503
+ const scheduleFlush = (): void => {
504
+ if (pending.size === 0 || flushTimer !== undefined) return;
505
+ flushTimer = setTimeout(() => {
506
+ flushTimer = undefined;
507
+ const selected = [...pending];
508
+ pending.clear();
509
+ check(selected);
510
+ }, 0);
511
+ flushTimer.unref();
512
+ };
513
+
514
+ function updatePoller(): void {
515
+ const needed = links.size !== 0 || polled.size !== 0;
516
+ if (!needed) {
517
+ poller?.close();
518
+ poller = undefined;
519
+ return;
520
+ }
521
+ if (poller !== undefined) return;
522
+ poller = openPoller(() => {
523
+ const selected = new Set<InputEntry>();
524
+ for (
525
+ let count = 0;
526
+ count < MAX_FALLBACK_PROBES_PER_TICK && polled.size !== 0;
527
+ count += 1
528
+ ) {
529
+ pollIterator ??= polled.values();
530
+ let next = pollIterator.next();
531
+ if (next.done) {
532
+ pollIterator = polled.values();
533
+ next = pollIterator.next();
534
+ }
535
+ if (next.done) break;
536
+ selected.add(next.value);
537
+ }
190
538
  // Files reached through one linked directory share one topology check.
191
539
  // Content edits remain event-driven; retargeting a junction does not
192
- // reliably emit an event on its previously watched descendants.
193
- for (const [file, link] of links) {
540
+ // reliably emit an event on its previously watched descendants. Reconcile
541
+ // a fixed-size slice as a safety net; ordinary retargets arrive at once
542
+ // through the project-root observer, while even an enormous dependency
543
+ // graph has constant idle CPU cost.
544
+ for (
545
+ let count = 0;
546
+ count < MAX_LINK_PROBES_PER_TICK && links.size !== 0;
547
+ count += 1
548
+ ) {
549
+ linkIterator ??= links.entries();
550
+ let next = linkIterator.next();
551
+ if (next.done) {
552
+ linkIterator = links.entries();
553
+ next = linkIterator.next();
554
+ }
555
+ if (next.done) break;
556
+ const [file, link] = next.value;
194
557
  const target = realpath(file);
195
558
  if (target !== link.target) {
196
559
  link.target = target;
197
560
  for (const entry of link.inputs) {
198
561
  selected.add(entry);
199
- entry.observed = false;
200
- active.unwatch(entry.file);
201
- active.add(entry.file);
202
562
  }
203
563
  }
204
564
  }
205
565
  check(selected);
206
- }, 500);
207
- poller.unref();
208
- return watcher;
566
+ });
567
+ }
568
+
569
+ const observe = (entry: InputEntry): void => {
570
+ bindAlias(entry, entry.file);
571
+ bindRenameAncestors(entry, entry.file);
572
+ const root = projectRoot;
573
+ if (root !== undefined && containsPath(root, entry.file)) {
574
+ if (!bindScope(root, entry, false)) requirePolling(entry);
575
+ } else {
576
+ const external = nearestExistingDirectory(entry.file);
577
+ if (external === undefined || !bindScope(external, entry, true))
578
+ requirePolling(entry);
579
+ }
580
+
581
+ const target = realpath(entry.file);
582
+ if (hasMultipleLinks(entry.file)) requirePolling(entry);
583
+ if (target !== undefined) {
584
+ bindAlias(entry, target);
585
+ bindRenameAncestors(entry, target);
586
+ if (!sameSpelling(entry.file, target)) {
587
+ let link = links.get(entry.file);
588
+ if (link === undefined) {
589
+ link = { target, inputs: new Set() };
590
+ if (links.size === 0) linkIterator = undefined;
591
+ links.set(entry.file, link);
592
+ }
593
+ link.inputs.add(entry);
594
+ entry.links.add(entry.file);
595
+ if (root === undefined || !containsPath(root, target)) {
596
+ const targetRoot = nearestExistingDirectory(target);
597
+ if (targetRoot === undefined || !bindScope(targetRoot, entry, true))
598
+ requirePolling(entry);
599
+ }
600
+ }
601
+ }
602
+ if (root !== undefined) {
603
+ for (const linkedFile of linkedComponents(
604
+ entry.file,
605
+ root,
606
+ componentLinks,
607
+ missingComponents,
608
+ watchPathKey,
609
+ )) {
610
+ bindAlias(entry, linkedFile);
611
+ let link = links.get(linkedFile);
612
+ if (link === undefined) {
613
+ link = { target: realpath(linkedFile), inputs: new Set() };
614
+ if (links.size === 0) linkIterator = undefined;
615
+ links.set(linkedFile, link);
616
+ }
617
+ link.inputs.add(entry);
618
+ entry.links.add(linkedFile);
619
+ if (link.target === undefined) requirePolling(entry);
620
+ else if (
621
+ !containsPath(root, link.target) &&
622
+ !bindScope(link.target, entry, true)
623
+ )
624
+ requirePolling(entry);
625
+ }
626
+ }
209
627
  };
210
628
 
211
629
  return {
212
630
  attach(next) {
213
631
  server = next;
632
+ projectRoot = path.resolve(next.config?.root ?? process.cwd());
633
+ ensureScope(projectRoot, false, true);
634
+ },
635
+ begin() {
636
+ return changeSequence;
214
637
  },
215
638
  async dispose() {
216
639
  entries.clear();
640
+ aliases.clear();
641
+ renameAliases.clear();
217
642
  importerInputs.clear();
218
643
  pending.clear();
644
+ polled.clear();
219
645
  links.clear();
220
- if (poller !== undefined) clearInterval(poller);
646
+ componentLinks.clear();
647
+ missingComponents.clear();
648
+ changes.clear();
649
+ linkIterator = undefined;
650
+ pollIterator = undefined;
651
+ poller?.close();
221
652
  if (flushTimer !== undefined) clearTimeout(flushTimer);
222
653
  poller = undefined;
223
654
  flushTimer = undefined;
224
- const closing = watcher;
225
- watcher = undefined;
226
- failed = false;
227
- await closing?.close();
655
+ for (const scope of [...scopes.values()]) closeScope(scope);
656
+ resetPathIdentityMemos();
228
657
  // Retain the attached server across overlapping Vite restart containers.
229
658
  },
230
- replace(importer, inputs, failed = false) {
659
+ forget(importer) {
660
+ importer = path.resolve(importer);
661
+ const previous = importerInputs.get(importer);
662
+ if (previous === undefined) return;
663
+ importerInputs.delete(importer);
664
+ for (const [file, key] of previous) {
665
+ const entry = entries.get(file);
666
+ const condition = entry?.conditions.get(key);
667
+ condition?.importers.delete(importer);
668
+ if (condition?.importers.size === 0) entry?.conditions.delete(key);
669
+ if (entry?.conditions.size === 0) remove(entry);
670
+ }
671
+ if (pathIdentityMemosDirty) resetPathIdentityMemos();
672
+ updatePoller();
673
+ },
674
+ replace(importer, inputs, failed = false, startedAt) {
231
675
  if (server === undefined) return;
232
676
  importer = path.resolve(importer);
233
677
  const previous =
@@ -247,7 +691,8 @@ export function createViteServeInputWatch(): ViteServeInputWatch {
247
691
  ];
248
692
  }
249
693
  const current = new Map<string, string>();
250
- const added: string[] = [];
694
+ const added = new Set<InputEntry>();
695
+ const touched = new Set<InputEntry>();
251
696
  for (const input of inputs) {
252
697
  const file = path.resolve(input.file);
253
698
  const evidence = input.evidence;
@@ -256,41 +701,20 @@ export function createViteServeInputWatch(): ViteServeInputWatch {
256
701
  let entry = entries.get(file);
257
702
  if (entry === undefined) {
258
703
  entry = {
704
+ aliases: new Set(),
705
+ changedAt: 0,
259
706
  file,
260
707
  conditions: new Map(),
261
- poll: false,
262
- observed: false,
708
+ fallback: false,
263
709
  links: new Set(),
710
+ renameAliases: new Set(),
711
+ scopes: new Set(),
264
712
  };
265
713
  entries.set(file, entry);
266
- added.push(file);
267
- const directory = path.dirname(file);
268
- const directoryTarget = realpath(directory);
269
- const fileTarget = realpath(file);
270
- const linked = [
271
- ...(directoryTarget !== undefined &&
272
- !sameSpelling(directory, directoryTarget)
273
- ? [directory]
274
- : []),
275
- ...(fileTarget !== undefined &&
276
- directoryTarget !== undefined &&
277
- !sameSpelling(
278
- fileTarget,
279
- path.join(directoryTarget, path.basename(file)),
280
- )
281
- ? [file]
282
- : []),
283
- ];
284
- for (const linkedFile of linked) {
285
- let link = links.get(linkedFile);
286
- if (link === undefined) {
287
- link = { target: realpath(linkedFile), inputs: new Set() };
288
- links.set(linkedFile, link);
289
- }
290
- link.inputs.add(entry);
291
- entry.links.add(linkedFile);
292
- }
714
+ added.add(entry);
715
+ observe(entry);
293
716
  }
717
+ touched.add(entry);
294
718
  let condition = entry.conditions.get(key);
295
719
  if (condition === undefined) {
296
720
  condition = {
@@ -304,19 +728,6 @@ export function createViteServeInputWatch(): ViteServeInputWatch {
304
728
  entry.conditions.set(key, condition);
305
729
  }
306
730
  condition.importers.add(importer);
307
- const observation =
308
- evidence?.state?.codec === "predicates"
309
- ? evidence.state.observation
310
- : undefined;
311
- entry.poll ||=
312
- evidence?.missing === true ||
313
- evidence?.unavailable !== undefined ||
314
- (observation !== undefined &&
315
- observation.fileExists !== true &&
316
- observation.stat !== "file" &&
317
- observation.readFile?.ok !== true) ||
318
- (evidence?.state === undefined &&
319
- condition.baseline?.fileExists !== true);
320
731
  }
321
732
  for (const [file, key] of previous) {
322
733
  if (current.get(file) === key) continue;
@@ -328,12 +739,161 @@ export function createViteServeInputWatch(): ViteServeInputWatch {
328
739
  remove(entry);
329
740
  }
330
741
  }
331
- importerInputs.set(importer, current);
332
- if (added.length !== 0) ensureWatcher().add(added);
742
+ if (pathIdentityMemosDirty) resetPathIdentityMemos();
743
+ if (current.size === 0) importerInputs.delete(importer);
744
+ else importerInputs.set(importer, current);
745
+ // Registration and removal can each touch thousands of compiler inputs.
746
+ // Decide the one shared poller's state once per atomic replacement,
747
+ // rather than rescanning the whole graph once per input.
748
+ updatePoller();
749
+ // Watchers are live before this proof. It closes the compile-to-subscribe
750
+ // race without manufacturing one native subscription per input.
751
+ if (touched.size !== 0) {
752
+ if (startedAt === undefined || startedAt < historyFloor) {
753
+ check(touched);
754
+ } else {
755
+ const raced: InputEntry[] = [];
756
+ for (const entry of touched) {
757
+ if (entry.fallback || entry.changedAt > startedAt) {
758
+ raced.push(entry);
759
+ continue;
760
+ }
761
+ const observedAfterCompile =
762
+ added.has(entry) &&
763
+ (someSet(
764
+ entry.aliases,
765
+ (alias) => (changes.get(alias) ?? 0) > startedAt,
766
+ ) ||
767
+ someSet(
768
+ entry.renameAliases,
769
+ (alias) => (changes.get(alias) ?? 0) > startedAt,
770
+ ));
771
+ if (
772
+ someSet(
773
+ entry.scopes,
774
+ (scope) =>
775
+ scope.failed ||
776
+ scope.startedAt > startedAt ||
777
+ observedAfterCompile,
778
+ )
779
+ ) {
780
+ raced.push(entry);
781
+ }
782
+ }
783
+ if (raced.length !== 0) check(raced);
784
+ }
785
+ }
333
786
  },
334
787
  };
335
788
  }
336
789
 
790
+ const MAX_EXTERNAL_WATCH_SCOPES = 16;
791
+ const MAX_CHANGE_HISTORY = 100_000;
792
+ const MAX_FALLBACK_PROBES_PER_TICK = 64;
793
+ const MAX_LINK_PROBES_PER_TICK = 64;
794
+
795
+ /** Test a set without allocating a transient array on the registration path. */
796
+ function someSet<T>(
797
+ values: ReadonlySet<T>,
798
+ predicate: (value: T) => boolean,
799
+ ): boolean {
800
+ for (const value of values) if (predicate(value)) return true;
801
+ return false;
802
+ }
803
+
804
+ function openWatchPoller(listener: () => void): { close(): void } {
805
+ const timer = setInterval(listener, 500);
806
+ timer.unref();
807
+ return { close: () => clearInterval(timer) };
808
+ }
809
+
810
+ function openRecursiveWatch(
811
+ root: string,
812
+ listener: (eventType: string, file: string | null) => void,
813
+ onError: () => void,
814
+ ): { close(): void } {
815
+ const watcher = fs.watch(
816
+ root,
817
+ { persistent: false, recursive: true },
818
+ (eventType, file) =>
819
+ listener(eventType, file === null ? null : String(file)),
820
+ );
821
+ watcher.on("error", onError);
822
+ return { close: () => watcher.close() };
823
+ }
824
+
825
+ function containsPath(root: string, file: string): boolean {
826
+ const relative = path.relative(path.resolve(root), path.resolve(file));
827
+ return (
828
+ relative === "" ||
829
+ (relative !== ".." &&
830
+ !relative.startsWith(`..${path.sep}`) &&
831
+ !path.isAbsolute(relative))
832
+ );
833
+ }
834
+
835
+ function nearestExistingDirectory(file: string): string | undefined {
836
+ let current = path.resolve(file);
837
+ try {
838
+ if (!fs.statSync(current).isDirectory()) current = path.dirname(current);
839
+ } catch {
840
+ current = path.dirname(current);
841
+ }
842
+ for (;;) {
843
+ try {
844
+ if (fs.statSync(current).isDirectory()) {
845
+ return path.dirname(current) === current ? undefined : current;
846
+ }
847
+ } catch {
848
+ // Keep climbing to the nearest directory a recursive watch can own.
849
+ }
850
+ const parent = path.dirname(current);
851
+ if (parent === current) return undefined;
852
+ current = parent;
853
+ }
854
+ }
855
+
856
+ function linkedComponents(
857
+ file: string,
858
+ root: string,
859
+ cached: Map<string, string | null>,
860
+ missing: Set<string>,
861
+ keyOf: (file: string) => string,
862
+ ): string[] {
863
+ const relative = path.relative(root, file);
864
+ if (
865
+ relative === "" ||
866
+ relative === ".." ||
867
+ relative.startsWith(`..${path.sep}`) ||
868
+ path.isAbsolute(relative)
869
+ )
870
+ return [];
871
+ const output: string[] = [];
872
+ let current = path.resolve(root);
873
+ for (const component of relative.split(path.sep).slice(0, -1)) {
874
+ current = path.join(current, component);
875
+ const key = keyOf(current);
876
+ if (missing.has(key)) break;
877
+ const known = cached.get(key);
878
+ if (known !== undefined) {
879
+ if (known !== null) output.push(current);
880
+ continue;
881
+ }
882
+ try {
883
+ if (fs.lstatSync(current).isSymbolicLink()) {
884
+ cached.set(key, realpath(current) ?? current);
885
+ output.push(current);
886
+ } else {
887
+ cached.set(key, null);
888
+ }
889
+ } catch {
890
+ missing.add(key);
891
+ break;
892
+ }
893
+ }
894
+ return output;
895
+ }
896
+
337
897
  function realpath(file: string): string | undefined {
338
898
  try {
339
899
  return fs.realpathSync.native(file);
@@ -342,10 +902,20 @@ function realpath(file: string): string | undefined {
342
902
  }
343
903
  }
344
904
 
905
+ /** Whether writes can reach this input through an unobserved hardlink alias. */
906
+ function hasMultipleLinks(file: string): boolean {
907
+ try {
908
+ const stats = fs.statSync(file);
909
+ return stats.isFile() && stats.nlink > 1;
910
+ } catch {
911
+ return false;
912
+ }
913
+ }
914
+
345
915
  function sameSpelling(left: string, right: string): boolean {
346
916
  return process.platform === "win32"
347
- ? left.toLowerCase() === right.toLowerCase()
348
- : left === right;
917
+ ? path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
918
+ : path.resolve(left) === path.resolve(right);
349
919
  }
350
920
 
351
921
  /**