@ttsc/unplugin 0.30.2 → 0.30.3

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
+ import { type FSWatcher, watch } from "chokidar";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
- import type { ITtscCompilerTransformation } from "ttsc";
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;
@@ -55,188 +50,304 @@ export interface ViteDevServerLike {
55
50
  ws?: ViteHotChannelLike;
56
51
  }
57
52
 
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. */
90
- watch(
91
- input: string,
92
- importer: string,
93
- condition: ViteServeInputWatchCondition,
94
- ): void;
53
+ interface InputCondition {
54
+ baseline?: TtscWatchInputBaseline;
55
+ evidence?: TtscWatchInputEvidence;
56
+ importers: Set<string>;
95
57
  }
96
58
 
97
- /** A legacy availability condition or an exact compiler predicate proof. */
98
- export type ViteServeInputWatchCondition =
99
- | "exists"
100
- | "file"
101
- | ITtscCompilerTransformation.IInputObservation;
59
+ interface InputEntry {
60
+ conditions: Map<string, InputCondition>;
61
+ file: string;
62
+ /** Missing paths and directory predicates still need the predicate poll. */
63
+ poll: boolean;
64
+ observed: boolean;
65
+ links: Set<string>;
66
+ }
102
67
 
103
- /** Poll bookkeeping for one registered unsafe path. */
104
- interface IMissingInputEntry {
105
- condition: ViteServeInputWatchCondition;
106
- importers: Set<string>;
107
- spelling: string;
68
+ interface LinkedPath {
69
+ target: string | undefined;
70
+ inputs: Set<InputEntry>;
108
71
  }
109
72
 
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;
73
+ /** Serve-time compiler dependencies never enter Vite's runtime import graph. */
74
+ export interface ViteServeInputWatch {
75
+ attach(server: ViteDevServerLike): void;
76
+ dispose(): Promise<void>;
77
+ replace(
78
+ importer: string,
79
+ inputs: readonly TtscWatchInput[],
80
+ failed?: boolean,
81
+ ): void;
82
+ }
83
+
84
+ /**
85
+ * One filesystem subscription per unique input, shared by all served modules.
86
+ *
87
+ * Vite resolves transform-context addWatchFile as a runtime import, including
88
+ * type-only .server files and non-module plugin assets. Use a separate watcher
89
+ * for compiler inputs, including node_modules, which Vite's watcher ignores.
90
+ * 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.
94
+ */
95
+ export function createViteServeInputWatch(): ViteServeInputWatch {
96
+ const entries = new Map<string, InputEntry>();
97
+ const importerInputs = new Map<string, Map<string, string>>();
98
+ const pending = new Set<InputEntry>();
99
+ const links = new Map<string, LinkedPath>();
114
100
  let server: ViteDevServerLike | undefined;
101
+ let watcher: FSWatcher | undefined;
102
+ let poller: NodeJS.Timeout | undefined;
103
+ let flushTimer: NodeJS.Timeout | undefined;
104
+ let failed = false;
115
105
 
116
- const stopPollingIfEmpty = (): void => {
117
- if (entries.size !== 0 || poller === undefined) {
118
- return;
106
+ const remove = (entry: InputEntry): void => {
107
+ entries.delete(entry.file);
108
+ watcher?.unwatch(entry.file);
109
+ for (const file of entry.links) {
110
+ const link = links.get(file);
111
+ link?.inputs.delete(entry);
112
+ if (link?.inputs.size === 0) links.delete(file);
119
113
  }
120
- clearInterval(poller);
121
- poller = undefined;
122
114
  };
123
- const poll = (): void => {
115
+
116
+ const check = (selected: Iterable<InputEntry>): void => {
124
117
  const importers = new Set<string>();
125
- for (const [identity, entry] of entries) {
126
- if (!viteServeInputWatchConditionChanged(entry)) {
127
- continue;
118
+ for (const entry of selected) {
119
+ if (entries.get(entry.file) !== entry) continue;
120
+ let baseline: TtscWatchInputBaseline | undefined;
121
+ for (const [key, condition] of entry.conditions) {
122
+ const state = condition.evidence?.state;
123
+ let changed: boolean;
124
+ if (state?.codec === "predicates") {
125
+ changed =
126
+ validateGraphInputObservation(entry.file, state.observation)
127
+ .length !== 0;
128
+ } else {
129
+ baseline ??= captureWatchInputBaseline(entry.file);
130
+ changed =
131
+ baseline === undefined ||
132
+ (condition.evidence?.state !== undefined
133
+ ? !watchInputEvidenceMatchesBaseline(condition.evidence, baseline)
134
+ : JSON.stringify(condition.baseline) !==
135
+ JSON.stringify(baseline));
136
+ }
137
+ if (!changed) continue;
138
+ for (const importer of condition.importers) importers.add(importer);
139
+ entry.conditions.delete(key);
128
140
  }
129
- entries.delete(identity);
130
- for (const importer of entry.importers) {
131
- importers.add(importer);
141
+ if (entry.conditions.size === 0) {
142
+ remove(entry);
132
143
  }
133
144
  }
134
- stopPollingIfEmpty();
135
- if (server === undefined || importers.size === 0) {
136
- return;
145
+ if (server !== undefined && importers.size !== 0) {
146
+ invalidateImporters(server, importers);
147
+ sendFullReload(server);
148
+ }
149
+ };
150
+
151
+ const enqueue = (file: string, observed: boolean): void => {
152
+ 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);
137
158
  }
138
- invalidateImporters(server, importers);
139
- sendFullReload(server);
159
+ if (pending.size === 0 || flushTimer !== undefined) return;
160
+ flushTimer = setTimeout(() => {
161
+ flushTimer = undefined;
162
+ const selected = [...pending];
163
+ pending.clear();
164
+ check(selected);
165
+ }, 0);
166
+ flushTimer.unref();
167
+ };
168
+
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
+ );
190
+ // Files reached through one linked directory share one topology check.
191
+ // 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) {
194
+ const target = realpath(file);
195
+ if (target !== link.target) {
196
+ link.target = target;
197
+ for (const entry of link.inputs) {
198
+ selected.add(entry);
199
+ entry.observed = false;
200
+ active.unwatch(entry.file);
201
+ active.add(entry.file);
202
+ }
203
+ }
204
+ }
205
+ check(selected);
206
+ }, 500);
207
+ poller.unref();
208
+ return watcher;
140
209
  };
141
210
 
142
211
  return {
143
212
  attach(next) {
144
213
  server = next;
145
214
  },
146
- dispose() {
215
+ async dispose() {
147
216
  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.
217
+ importerInputs.clear();
218
+ pending.clear();
219
+ links.clear();
220
+ if (poller !== undefined) clearInterval(poller);
221
+ if (flushTimer !== undefined) clearTimeout(flushTimer);
222
+ poller = undefined;
223
+ flushTimer = undefined;
224
+ const closing = watcher;
225
+ watcher = undefined;
226
+ failed = false;
227
+ await closing?.close();
228
+ // Retain the attached server across overlapping Vite restart containers.
158
229
  },
159
- serving() {
160
- return server !== undefined;
161
- },
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;
230
+ replace(importer, inputs, failed = false) {
231
+ if (server === undefined) return;
232
+ importer = path.resolve(importer);
233
+ const previous =
234
+ importerInputs.get(importer) ?? new Map<string, string>();
235
+ if (failed) {
236
+ // An exception can omit the dependency whose deletion caused it.
237
+ // Keep the last successful spellings until a successful delivery can
238
+ // replace them, observing their current failed state for recovery.
239
+ const reported = new Set(
240
+ inputs.map((input) => path.resolve(input.file)),
241
+ );
242
+ inputs = [
243
+ ...inputs,
244
+ ...[...previous.keys()]
245
+ .filter((file) => !reported.has(file))
246
+ .map((file) => ({ file })),
247
+ ];
248
+ }
249
+ const current = new Map<string, string>();
250
+ const added: string[] = [];
251
+ for (const input of inputs) {
252
+ const file = path.resolve(input.file);
253
+ const evidence = input.evidence;
254
+ const key = JSON.stringify(evidence ?? null);
255
+ current.set(file, key);
256
+ let entry = entries.get(file);
257
+ if (entry === undefined) {
258
+ entry = {
259
+ file,
260
+ conditions: new Map(),
261
+ poll: false,
262
+ observed: false,
263
+ links: new Set(),
264
+ };
265
+ 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
+ }
293
+ }
294
+ let condition = entry.conditions.get(key);
295
+ if (condition === undefined) {
296
+ condition = {
297
+ evidence,
298
+ baseline:
299
+ evidence?.state === undefined
300
+ ? captureWatchInputBaseline(file)
301
+ : undefined,
302
+ importers: new Set(),
303
+ };
304
+ entry.conditions.set(key, condition);
305
+ }
306
+ 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);
169
320
  }
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?.();
321
+ for (const [file, key] of previous) {
322
+ if (current.get(file) === key) continue;
323
+ const entry = entries.get(file);
324
+ const condition = entry?.conditions.get(key);
325
+ condition?.importers.delete(importer);
326
+ if (condition?.importers.size === 0) entry?.conditions.delete(key);
327
+ if (entry?.conditions.size === 0 && !current.has(file)) {
328
+ remove(entry);
329
+ }
179
330
  }
331
+ importerInputs.set(importer, current);
332
+ if (added.length !== 0) ensureWatcher().add(added);
180
333
  },
181
334
  };
182
335
  }
183
336
 
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
- }
217
-
218
- /** Whether one registered condition no longer describes its lexical path. */
219
- function viteServeInputWatchConditionChanged(
220
- entry: IMissingInputEntry,
221
- ): boolean {
222
- if (typeof entry.condition !== "string") {
223
- return (
224
- validateGraphInputObservation(entry.spelling, entry.condition).length !==
225
- 0
226
- );
227
- }
337
+ function realpath(file: string): string | undefined {
228
338
  try {
229
- if (!fs.existsSync(entry.spelling)) {
230
- return false;
231
- }
232
- return (
233
- entry.condition === "exists" || !fs.statSync(entry.spelling).isDirectory()
234
- );
339
+ return fs.realpathSync.native(file);
235
340
  } catch {
236
- return false;
341
+ return undefined;
237
342
  }
238
343
  }
239
344
 
345
+ function sameSpelling(left: string, right: string): boolean {
346
+ return process.platform === "win32"
347
+ ? left.toLowerCase() === right.toLowerCase()
348
+ : left === right;
349
+ }
350
+
240
351
  /**
241
352
  * Invalidate every module-graph node of the registered importers so the next
242
353
  * request retransforms them. Importers keep their original absolute spelling so