@ttsc/unplugin 0.30.2 → 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,21 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import type { ITtscCompilerTransformation } from "ttsc";
3
+ import { createFilesystemPathIdentityContext } from "ttsc/path-identity";
4
4
 
5
- import { pathIdentityKey, validateGraphInputObservation } from "./transform";
6
-
7
- /**
8
- * How often each registered Vite-unsafe input is predicate-polled, in
9
- * milliseconds.
10
- *
11
- * Polling is the only watch primitive that covers the whole class: the dev
12
- * server's chokidar watcher ignores every `node_modules` directory, which is
13
- * exactly where superseding resolution candidates usually live, and `fs.watch`
14
- * cannot observe a path whose parent directories do not exist yet. One `stat`
15
- * every half second per unavailable path is negligible against a dev server's
16
- * baseline. Rich inputs replay only the predicates the compiler recorded.
17
- */
18
- const MISSING_INPUT_POLL_INTERVAL = 500;
5
+ import {
6
+ type TtscWatchInput,
7
+ type TtscWatchInputBaseline,
8
+ type TtscWatchInputEvidence,
9
+ captureWatchInputBaseline,
10
+ pathIdentityKey,
11
+ validateGraphInputObservation,
12
+ watchInputEvidenceMatchesBaseline,
13
+ } from "./transform";
19
14
 
20
15
  /** One module node inside a Vite module graph; opaque to this module. */
21
16
  type ViteModuleNodeLike = object;
@@ -49,194 +44,880 @@ interface ViteEnvironmentLike {
49
44
  * carries.
50
45
  */
51
46
  export interface ViteDevServerLike {
47
+ config?: { root?: string };
52
48
  environments?: Record<string, ViteEnvironmentLike>;
53
49
  hot?: ViteHotChannelLike;
54
50
  moduleGraph?: ViteModuleGraphLike;
55
51
  ws?: ViteHotChannelLike;
56
52
  }
57
53
 
58
- /**
59
- * Filesystem watch for derived watch inputs that Vite cannot safely register as
60
- * added imports while a development server is running.
61
- *
62
- * Vite serve treats every transform-context `addWatchFile()` registration as an
63
- * added import: `TransformPluginContext.addWatchFile` stores the path in
64
- * `_addedImports`, and `vite:import-analysis` resolves each entry like a real
65
- * import of the transformed module. A missing path or an existing directory
66
- * that failed a compiler file predicate then fails that resolve and turns the
67
- * importer's first request into a 500, even though the transform succeeded.
68
- *
69
- * This registry is the serve-only replacement for those registrations. Each
70
- * path is polled until its exact compiler predicate observation changes; legacy
71
- * envelopes retain their exists-or-file availability check. Its importers are
72
- * then invalidated in the server's module graphs and one full-reload is sent,
73
- * so the next request retransforms against the new resolution winner. The
74
- * project transform cache re-validates the compiler observation, so the
75
- * retransform recompiles instead of replaying.
76
- */
77
- export interface ViteServeMissingInputWatch {
78
- /** Adopt the dev server whose module graphs creation events invalidate. */
79
- attach(server: ViteDevServerLike): void;
80
- /** Stop every poll; safe to call repeatedly. */
81
- dispose(): void;
82
- /**
83
- * Report whether a dev server has ever been attached. This is not a liveness
84
- * predicate — the reference intentionally survives the server's close (see
85
- * {@link dispose}) — so route decisions must also gate on the resolved
86
- * config's `command`, as the adapter does.
87
- */
88
- serving(): boolean;
89
- /** Register one unsafe watch input and its exact recorded condition. */
54
+ interface InputCondition {
55
+ baseline?: TtscWatchInputBaseline;
56
+ evidence?: TtscWatchInputEvidence;
57
+ importers: Set<string>;
58
+ }
59
+
60
+ interface InputEntry {
61
+ aliases: Set<string>;
62
+ /** Latest native event already associated with this registered spelling. */
63
+ changedAt: number;
64
+ conditions: Map<string, InputCondition>;
65
+ fallback: boolean;
66
+ file: string;
67
+ links: Set<string>;
68
+ renameAliases: Set<string>;
69
+ scopes: Set<WatchScope>;
70
+ }
71
+
72
+ interface LinkedPath {
73
+ target: string | undefined;
74
+ inputs: Set<InputEntry>;
75
+ }
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 };
90
92
  watch(
91
- input: string,
93
+ root: string,
94
+ listener: (eventType: string, file: string | null) => void,
95
+ onError: () => void,
96
+ ): { close(): void };
97
+ }
98
+
99
+ /** Serve-time compiler dependencies never enter Vite's runtime import graph. */
100
+ export interface ViteServeInputWatch {
101
+ attach(server: ViteDevServerLike): void;
102
+ begin(): number;
103
+ dispose(): Promise<void>;
104
+ /** Release every compiler input owned by one removed source module. */
105
+ forget(importer: string): void;
106
+ replace(
92
107
  importer: string,
93
- condition: ViteServeInputWatchCondition,
108
+ inputs: readonly TtscWatchInput[],
109
+ failed?: boolean,
110
+ startedAt?: number,
94
111
  ): void;
95
112
  }
96
113
 
97
- /** A legacy availability condition or an exact compiler predicate proof. */
98
- export type ViteServeInputWatchCondition =
99
- | "exists"
100
- | "file"
101
- | ITtscCompilerTransformation.IInputObservation;
114
+ /**
115
+ * A bounded recursive filesystem observer shared by all served modules.
116
+ *
117
+ * Vite resolves transform-context addWatchFile as a runtime import, including
118
+ * type-only .server files and non-module plugin assets. Use a separate watcher
119
+ * for compiler inputs, including node_modules, which Vite's watcher ignores.
120
+ * Ordinary files use events after their initial subscription is observed.
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.
125
+ */
126
+ export function createViteServeInputWatch(
127
+ operations: Partial<ViteServeWatchOperations> = {},
128
+ ): ViteServeInputWatch {
129
+ const entries = new Map<string, InputEntry>();
130
+ const aliases = new Map<string, Set<InputEntry>>();
131
+ const renameAliases = new Map<string, Set<InputEntry>>();
132
+ const importerInputs = new Map<string, Map<string, string>>();
133
+ const pending = new Set<InputEntry>();
134
+ const polled = new Set<InputEntry>();
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>();
140
+ let server: ViteDevServerLike | 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;
147
+ let flushTimer: NodeJS.Timeout | undefined;
102
148
 
103
- /** Poll bookkeeping for one registered unsafe path. */
104
- interface IMissingInputEntry {
105
- condition: ViteServeInputWatchCondition;
106
- importers: Set<string>;
107
- spelling: string;
108
- }
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;
109
163
 
110
- /** Create an empty missing-input watch for one plugin instance. */
111
- export function createViteServeMissingInputWatch(): ViteServeMissingInputWatch {
112
- const entries = new Map<string, IMissingInputEntry>();
113
- let poller: NodeJS.Timeout | undefined;
114
- let server: ViteDevServerLike | undefined;
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
+ };
115
170
 
116
- const stopPollingIfEmpty = (): void => {
117
- if (entries.size !== 0 || poller === undefined) {
118
- return;
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;
119
253
  }
120
- clearInterval(poller);
121
- poller = undefined;
254
+ return { direct, parent: parents };
122
255
  };
123
- const poll = (): void => {
256
+
257
+ const remove = (entry: InputEntry): void => {
258
+ entries.delete(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();
274
+ for (const file of entry.links) {
275
+ const link = links.get(file);
276
+ link?.inputs.delete(entry);
277
+ if (link?.inputs.size === 0) links.delete(file);
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;
288
+ };
289
+
290
+ const check = (selected: Iterable<InputEntry>): void => {
124
291
  const importers = new Set<string>();
125
- for (const [identity, entry] of entries) {
126
- if (!viteServeInputWatchConditionChanged(entry)) {
127
- continue;
292
+ for (const entry of selected) {
293
+ if (entries.get(entry.file) !== entry) continue;
294
+ let baseline: TtscWatchInputBaseline | undefined;
295
+ for (const [key, condition] of entry.conditions) {
296
+ const state = condition.evidence?.state;
297
+ let changed: boolean;
298
+ if (state?.codec === "predicates") {
299
+ changed =
300
+ validateGraphInputObservation(entry.file, state.observation)
301
+ .length !== 0;
302
+ } else {
303
+ baseline ??= captureWatchInputBaseline(entry.file);
304
+ changed =
305
+ baseline === undefined ||
306
+ (condition.evidence?.state !== undefined
307
+ ? !watchInputEvidenceMatchesBaseline(condition.evidence, baseline)
308
+ : JSON.stringify(condition.baseline) !==
309
+ JSON.stringify(baseline));
310
+ }
311
+ if (!changed) continue;
312
+ for (const importer of condition.importers) importers.add(importer);
313
+ entry.conditions.delete(key);
314
+ }
315
+ if (entry.conditions.size === 0) {
316
+ remove(entry);
317
+ }
318
+ }
319
+ if (pathIdentityMemosDirty) resetPathIdentityMemos();
320
+ updatePoller();
321
+ if (server !== undefined && importers.size !== 0) {
322
+ invalidateImporters(server, importers);
323
+ sendFullReload(server);
324
+ }
325
+ };
326
+
327
+ const enqueue = (eventType: string, file: string): void => {
328
+ const absolute = path.resolve(file);
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
+ }
361
+ }
362
+ if (pending.size === 0 || flushTimer !== undefined) return;
363
+ flushTimer = setTimeout(() => {
364
+ flushTimer = undefined;
365
+ const selected = [...pending];
366
+ pending.clear();
367
+ check(selected);
368
+ }, 0);
369
+ flushTimer.unref();
370
+ };
371
+
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);
128
396
  }
129
- entries.delete(identity);
130
- for (const importer of entry.importers) {
131
- importers.add(importer);
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;
132
478
  }
479
+ } else if (pinned) {
480
+ scope.pinned = true;
133
481
  }
134
- stopPollingIfEmpty();
135
- if (server === undefined || importers.size === 0) {
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;
136
519
  return;
137
520
  }
138
- invalidateImporters(server, importers);
139
- sendFullReload(server);
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
+ }
538
+ // Files reached through one linked directory share one topology check.
539
+ // Content edits remain event-driven; retargeting a junction does not
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;
557
+ const target = realpath(file);
558
+ if (target !== link.target) {
559
+ link.target = target;
560
+ for (const entry of link.inputs) {
561
+ selected.add(entry);
562
+ }
563
+ }
564
+ }
565
+ check(selected);
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
+ }
140
627
  };
141
628
 
142
629
  return {
143
630
  attach(next) {
144
631
  server = next;
632
+ projectRoot = path.resolve(next.config?.root ?? process.cwd());
633
+ ensureScope(projectRoot, false, true);
634
+ },
635
+ begin() {
636
+ return changeSequence;
145
637
  },
146
- dispose() {
638
+ async dispose() {
147
639
  entries.clear();
148
- if (poller !== undefined) {
149
- clearInterval(poller);
150
- poller = undefined;
151
- }
152
- // The server reference deliberately survives: `vite.restartServer`
153
- // configures the replacement server (attach) before it closes the old
154
- // one (whose buildEnd runs this dispose), so unsetting it here would
155
- // detach the freshly attached replacement and revive the 500 this
156
- // module exists to prevent. A same-instance `vite build` after a serve
157
- // is instead excluded by the adapter's `config.command` gate.
640
+ aliases.clear();
641
+ renameAliases.clear();
642
+ importerInputs.clear();
643
+ pending.clear();
644
+ polled.clear();
645
+ links.clear();
646
+ componentLinks.clear();
647
+ missingComponents.clear();
648
+ changes.clear();
649
+ linkIterator = undefined;
650
+ pollIterator = undefined;
651
+ poller?.close();
652
+ if (flushTimer !== undefined) clearTimeout(flushTimer);
653
+ poller = undefined;
654
+ flushTimer = undefined;
655
+ for (const scope of [...scopes.values()]) closeScope(scope);
656
+ resetPathIdentityMemos();
657
+ // Retain the attached server across overlapping Vite restart containers.
158
658
  },
159
- serving() {
160
- return server !== undefined;
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();
161
673
  },
162
- watch(input, importer, condition) {
163
- const spelling = path.resolve(input);
164
- const identity = viteServeMissingInputWatchKey(spelling, condition);
165
- const existing = entries.get(identity);
166
- if (existing !== undefined) {
167
- existing.importers.add(path.resolve(importer));
168
- return;
674
+ replace(importer, inputs, failed = false, startedAt) {
675
+ if (server === undefined) return;
676
+ importer = path.resolve(importer);
677
+ const previous =
678
+ importerInputs.get(importer) ?? new Map<string, string>();
679
+ if (failed) {
680
+ // An exception can omit the dependency whose deletion caused it.
681
+ // Keep the last successful spellings until a successful delivery can
682
+ // replace them, observing their current failed state for recovery.
683
+ const reported = new Set(
684
+ inputs.map((input) => path.resolve(input.file)),
685
+ );
686
+ inputs = [
687
+ ...inputs,
688
+ ...[...previous.keys()]
689
+ .filter((file) => !reported.has(file))
690
+ .map((file) => ({ file })),
691
+ ];
692
+ }
693
+ const current = new Map<string, string>();
694
+ const added = new Set<InputEntry>();
695
+ const touched = new Set<InputEntry>();
696
+ for (const input of inputs) {
697
+ const file = path.resolve(input.file);
698
+ const evidence = input.evidence;
699
+ const key = JSON.stringify(evidence ?? null);
700
+ current.set(file, key);
701
+ let entry = entries.get(file);
702
+ if (entry === undefined) {
703
+ entry = {
704
+ aliases: new Set(),
705
+ changedAt: 0,
706
+ file,
707
+ conditions: new Map(),
708
+ fallback: false,
709
+ links: new Set(),
710
+ renameAliases: new Set(),
711
+ scopes: new Set(),
712
+ };
713
+ entries.set(file, entry);
714
+ added.add(entry);
715
+ observe(entry);
716
+ }
717
+ touched.add(entry);
718
+ let condition = entry.conditions.get(key);
719
+ if (condition === undefined) {
720
+ condition = {
721
+ evidence,
722
+ baseline:
723
+ evidence?.state === undefined
724
+ ? captureWatchInputBaseline(file)
725
+ : undefined,
726
+ importers: new Set(),
727
+ };
728
+ entry.conditions.set(key, condition);
729
+ }
730
+ condition.importers.add(importer);
731
+ }
732
+ for (const [file, key] of previous) {
733
+ if (current.get(file) === key) continue;
734
+ const entry = entries.get(file);
735
+ const condition = entry?.conditions.get(key);
736
+ condition?.importers.delete(importer);
737
+ if (condition?.importers.size === 0) entry?.conditions.delete(key);
738
+ if (entry?.conditions.size === 0 && !current.has(file)) {
739
+ remove(entry);
740
+ }
169
741
  }
170
- entries.set(identity, {
171
- condition,
172
- importers: new Set([path.resolve(importer)]),
173
- spelling,
174
- });
175
- if (poller === undefined) {
176
- poller = setInterval(poll, MISSING_INPUT_POLL_INTERVAL);
177
- // A poller must never keep the dev-server process alive on its own.
178
- poller.unref?.();
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
+ }
179
785
  }
180
786
  },
181
787
  };
182
788
  }
183
789
 
184
- /** Key a private poll by predicate and exact lexical spelling. */
185
- export function viteServeMissingInputWatchKey(
186
- input: string,
187
- condition: ViteServeInputWatchCondition,
188
- ): string {
189
- // Missing aliases can share a physical parent now and later retarget or
190
- // diverge. Neither predicate may let one lexical spelling answer for another.
191
- const predicate =
192
- typeof condition === "string"
193
- ? condition
194
- : `predicates:${JSON.stringify([
195
- condition.accessibleEntries === undefined
196
- ? null
197
- : [
198
- condition.accessibleEntries.directories,
199
- condition.accessibleEntries.files,
200
- ],
201
- condition.directoryExists ?? null,
202
- condition.fileExists ?? null,
203
- condition.readFile === undefined
204
- ? null
205
- : condition.readFile.ok
206
- ? [true, condition.readFile.hash]
207
- : [false],
208
- condition.realpath === undefined
209
- ? null
210
- : condition.realpath.ok
211
- ? [true, condition.realpath.path]
212
- : [false],
213
- condition.stat ?? null,
214
- ])}`;
215
- return `${predicate}:${path.resolve(input)}`;
216
- }
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;
217
794
 
218
- /** Whether one registered condition no longer describes its lexical path. */
219
- function viteServeInputWatchConditionChanged(
220
- entry: IMissingInputEntry,
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,
221
799
  ): boolean {
222
- if (typeof entry.condition !== "string") {
223
- return (
224
- validateGraphInputObservation(entry.spelling, entry.condition).length !==
225
- 0
226
- );
227
- }
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);
228
837
  try {
229
- if (!fs.existsSync(entry.spelling)) {
230
- return false;
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.
231
849
  }
232
- return (
233
- entry.condition === "exists" || !fs.statSync(entry.spelling).isDirectory()
234
- );
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
+
897
+ function realpath(file: string): string | undefined {
898
+ try {
899
+ return fs.realpathSync.native(file);
900
+ } catch {
901
+ return undefined;
902
+ }
903
+ }
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;
235
910
  } catch {
236
911
  return false;
237
912
  }
238
913
  }
239
914
 
915
+ function sameSpelling(left: string, right: string): boolean {
916
+ return process.platform === "win32"
917
+ ? path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
918
+ : path.resolve(left) === path.resolve(right);
919
+ }
920
+
240
921
  /**
241
922
  * Invalidate every module-graph node of the registered importers so the next
242
923
  * request retransforms them. Importers keep their original absolute spelling so