@ttsc/unplugin 0.20.1 → 0.22.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.
@@ -40,15 +40,14 @@ export interface TtscTransformAlias {
40
40
  }
41
41
 
42
42
  /**
43
- * A single entry in the per-build transform cache.
43
+ * A single entry in the project transform cache.
44
44
  *
45
45
  * Stores the full compiler result together with SHA-256 hashes of every project
46
- * input file. On subsequent transforms the cached entry is validated by
47
- * re-hashing the project and comparing against {@link inputHashes}; a mismatch
48
- * triggers a full re-transform. Both sides hash the same set of files (the
49
- * project directory walk), so the comparison is meaningful; keying the
50
- * compiler's out-of-walk output paths on only one side is what made the cache
51
- * miss on every module.
46
+ * input file. In a cache with an explicit build lifecycle, the first delivery
47
+ * of each compiled module compares its supplied source with the generation
48
+ * snapshot in constant time; a repeated delivery re-hashes the complete input
49
+ * set. Persistent caches without that boundary perform complete validation on
50
+ * every hit.
52
51
  */
53
52
  export interface TtscCachedProjectTransform {
54
53
  /**
@@ -59,7 +58,7 @@ export interface TtscCachedProjectTransform {
59
58
  * directories (`node_modules` declarations, monorepo sibling sources,
60
59
  * out-of-root tsconfig `extends` ancestry), yet the host-owned reference
61
60
  * graph proves they are transform inputs. Long-lived hosts that never clear
62
- * the cache between builds (Metro workers, the Turbopack loader, Bun) would
61
+ * the cache between builds (Metro workers and the Turbopack loader) would
63
62
  * otherwise replay a project transform computed against a stale out-of-walk
64
63
  * input for the whole process lifetime; per-build hosts clear the cache on
65
64
  * `buildStart` and never replay across edits.
@@ -80,6 +79,12 @@ export interface TtscCachedProjectTransform {
80
79
  projectRoot: string;
81
80
  /** Raw compiler output returned by {@link TtscCompiler.transform}. */
82
81
  result: ITtscCompilerTransformation;
82
+ /**
83
+ * Files already delivered from this generation, keyed by filesystem identity.
84
+ * Build-scoped caches use this to skip complete validation only for a
85
+ * module's first delivery inside the current build.
86
+ */
87
+ servedFiles?: Set<string>;
83
88
  /**
84
89
  * Absolute path of the generated temp-dir tsconfig this compile ran against,
85
90
  * when an alias/compiler-options overlay required one. The compiler reports
@@ -102,11 +107,42 @@ export type TtscTransformCache = Map<
102
107
  Promise<TtscCachedProjectTransform>
103
108
  >;
104
109
 
105
- /** Create an empty transform cache for a single build session. */
110
+ /**
111
+ * Caches whose owner has declared a real per-build lifecycle by calling
112
+ * {@link beginTtscTransformBuild} before transforms begin.
113
+ */
114
+ const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet<TtscTransformCache>();
115
+
116
+ /** Create an empty persistent transform cache. */
106
117
  export function createTtscTransformCache(): TtscTransformCache {
107
118
  return new Map();
108
119
  }
109
120
 
121
+ /**
122
+ * Start a host build, clearing its prior generation and enabling constant-time
123
+ * first delivery for modules compiled during this build.
124
+ *
125
+ * Hosts without a guaranteed build-start callback use persistent validation
126
+ * unless they have another immutable lifecycle. Bun runtime setup, for example,
127
+ * defines one process-scoped module-loading session.
128
+ */
129
+ export function beginTtscTransformBuild(cache: TtscTransformCache): void {
130
+ cache.clear();
131
+ BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
132
+ }
133
+
134
+ /**
135
+ * Clear a cache and return it to persistent validation mode.
136
+ *
137
+ * This is distinct from {@link beginTtscTransformBuild}: hosts such as Vite's
138
+ * development server may invoke `buildStart` only once for a process that spans
139
+ * many edits, so that callback cannot authorize build-scoped shortcuts.
140
+ */
141
+ export function resetTtscTransformCache(cache: TtscTransformCache): void {
142
+ cache.clear();
143
+ BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
144
+ }
145
+
110
146
  /** Cached case-insensitivity probes for existing macOS filesystem locations. */
111
147
  const CASE_INSENSITIVE_FILESYSTEMS = new Map<string, boolean>();
112
148
 
@@ -157,8 +193,9 @@ export interface TtscTransformHooks {
157
193
  * @param options - Resolved plugin options.
158
194
  * @param aliases - Raw bundler alias configuration (Vite array or webpack
159
195
  * object).
160
- * @param cache - Optional per-build cache; cleared by the caller on
161
- * `buildStart`.
196
+ * @param cache - Optional project cache. Callers with a real `buildStart`
197
+ * boundary declare it through {@link beginTtscTransformBuild}; other hosts
198
+ * retain persistent validation.
162
199
  * @param hooks - Optional adapter callbacks; see {@link TtscTransformHooks}.
163
200
  * Dependency notifications fire on cache hits too; watch registrations are
164
201
  * per build, not per compilation.
@@ -192,70 +229,90 @@ export async function transformTtsc(
192
229
  tsconfig,
193
230
  });
194
231
 
195
- let transformed = cache?.get(key);
196
- if (transformed !== undefined) {
197
- // A rejected in-flight generation must not stay cached: evict it (only if
198
- // it is still the current entry) so a later call re-runs the transform.
199
- const cached = await awaitOrEvict(cache, key, transformed);
200
- if (
201
- // A file the plugin declared volatile must never be served from the
202
- // cache: its output depends on non-file inputs, so the input-hash
203
- // snapshot cannot prove freshness. Fall through to a fresh transform.
204
- !isVolatileFile({
205
- file,
206
- projectRoot: cached.projectRoot,
207
- result: cached.result,
208
- }) &&
209
- matchesCachedSource(cached, file, source)
210
- ) {
211
- reportSuccessDiagnostics(cached.result);
212
- // A resolved `"exception"` / `"failure"` envelope makes this throw; that
213
- // is a failed generation too, so evict before surfacing it.
214
- const code = selectOrEvict(cache, key, transformed, {
215
- file,
216
- projectRoot: cached.projectRoot,
217
- result: cached.result,
218
- });
219
- notifyWatchInputs(hooks, {
220
- file,
221
- projectRoot: cached.projectRoot,
222
- result: cached.result,
223
- temporaryTsconfig: cached.temporaryTsconfig,
232
+ for (;;) {
233
+ let transformed = cache?.get(key);
234
+ if (transformed !== undefined) {
235
+ // A rejected in-flight generation must not stay cached: evict it (only if
236
+ // it is still the current entry) so a later call re-runs the transform.
237
+ const cached = await awaitOrEvict(cache, key, transformed);
238
+ // While this caller awaited the old Promise, another caller may have
239
+ // invalidated it and installed a newer authoritative generation.
240
+ if (cache?.get(key) !== transformed) {
241
+ continue;
242
+ }
243
+ if (
244
+ // A file the plugin declared volatile must never be served from the
245
+ // cache: its output depends on non-file inputs, so the input-hash
246
+ // snapshot cannot prove freshness. Fall through to a fresh transform.
247
+ !isVolatileFile({
248
+ file,
249
+ projectRoot: cached.projectRoot,
250
+ result: cached.result,
251
+ }) &&
252
+ matchesCachedSource(
253
+ cached,
254
+ file,
255
+ source,
256
+ cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache),
257
+ )
258
+ ) {
259
+ reportSuccessDiagnostics(cached.result);
260
+ // A resolved `"exception"` / `"failure"` envelope makes this throw;
261
+ // that is a failed generation too, so evict before surfacing it.
262
+ const code = selectOrEvict(cache, key, transformed, {
263
+ file,
264
+ projectRoot: cached.projectRoot,
265
+ result: cached.result,
266
+ });
267
+ notifyWatchInputs(hooks, {
268
+ file,
269
+ projectRoot: cached.projectRoot,
270
+ result: cached.result,
271
+ temporaryTsconfig: cached.temporaryTsconfig,
272
+ });
273
+ markCachedSourceServed(cached, file);
274
+ return createTransformResult(source, code);
275
+ }
276
+ evictGeneration(cache, key, transformed);
277
+ // Another caller may have replaced the generation while this caller was
278
+ // awaiting or validating the old one. Retry that authoritative entry
279
+ // instead of deleting it or starting a redundant third compilation.
280
+ if (cache?.get(key) !== undefined) {
281
+ continue;
282
+ }
283
+ transformed = undefined;
284
+ }
285
+
286
+ if (transformed === undefined) {
287
+ transformed = transformProject({
288
+ aliasPaths,
289
+ compilerOptions: options.compilerOptions,
290
+ currentFile: file,
291
+ currentSource: source,
292
+ plugins: options.plugins,
293
+ tsconfig,
224
294
  });
225
- return createTransformResult(source, code);
295
+ cache?.set(key, transformed);
226
296
  }
227
- cache?.delete(key);
228
- transformed = undefined;
229
- }
230
-
231
- if (transformed === undefined) {
232
- transformed = transformProject({
233
- aliasPaths,
234
- compilerOptions: options.compilerOptions,
235
- currentFile: file,
236
- currentSource: source,
237
- plugins: options.plugins,
238
- tsconfig,
297
+ const generation = transformed;
298
+ const cached = await awaitOrEvict(cache, key, generation);
299
+ if (cache !== undefined && cache.get(key) !== generation) {
300
+ continue;
301
+ }
302
+ const { projectRoot, result, temporaryTsconfig } = cached;
303
+ reportSuccessDiagnostics(result);
304
+ const code = selectOrEvict(cache, key, generation, {
305
+ file,
306
+ projectRoot,
307
+ result,
239
308
  });
240
- cache?.set(key, transformed);
241
- }
242
- const generation = transformed;
243
- const { projectRoot, result, temporaryTsconfig } = await awaitOrEvict(
244
- cache,
245
- key,
246
- generation,
247
- );
248
- reportSuccessDiagnostics(result);
249
- const code = selectOrEvict(cache, key, generation, {
250
- file,
251
- projectRoot,
252
- result,
253
- });
254
- notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
255
- if (isVolatileFile({ file, projectRoot, result })) {
256
- hooks?.markVolatile?.();
309
+ notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
310
+ markCachedSourceServed(cached, file);
311
+ if (isVolatileFile({ file, projectRoot, result })) {
312
+ hooks?.markVolatile?.();
313
+ }
314
+ return createTransformResult(source, code);
257
315
  }
258
- return createTransformResult(source, code);
259
316
  }
260
317
 
261
318
  /**
@@ -745,31 +802,30 @@ export function createTransformResult(
745
802
  * Validate a cached project transform against the current on-disk project
746
803
  * state.
747
804
  *
748
- * Re-hashes every file under the project root and overlays the current module's
749
- * in-memory source, then compares the snapshot against the one captured when
750
- * the result was produced. Any input under the project root changing (the
751
- * module itself or a sibling the plugin reads) invalidates the entry and forces
752
- * a re-transform. Out-of-walk inputs the compiler reported (`node_modules`
753
- * declarations, sibling-package sources, out-of-root config ancestry) are
754
- * validated through {@link TtscCachedProjectTransform.externalInputHashes};
755
- * adapters additionally register them as derived watch inputs (the host-owned
756
- * `graph` union the reported `dependencies`) → `addWatchFile` → the bundler's
757
- * next `buildStart` cache clear.
805
+ * Always compares the current module's in-memory source with the generation
806
+ * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
807
+ * that comparison alone for the module's first delivery in the current build;
808
+ * repeated requests re-hash every project and out-of-walk input. Persistent
809
+ * caches with no guaranteed build boundary perform complete validation on every
810
+ * hit. Any mismatch forces a complete re-transform.
758
811
  *
759
- * Both this snapshot and {@link collectInputHashes} draw their keys from the
760
- * exact same {@link collectProjectInputHashes} walk, so the two always agree on
761
- * the key universe. The earlier implementation overlaid the compiler's output
762
- * keys here on only one side; those keys include out-of-walk program inputs
763
- * (`node_modules` declarations, sibling-package sources), so the snapshots
764
- * never matched and the cache missed on every module; re-transforming the whole
765
- * project once per file on any project that imports a typed dependency.
812
+ * The complete validation snapshot and {@link collectInputHashes} draw their
813
+ * keys from the exact same {@link collectProjectInputHashes} walk, so the two
814
+ * agree on the key universe.
766
815
  */
767
816
  function matchesCachedSource(
768
817
  cached: TtscCachedProjectTransform,
769
818
  file: string,
770
819
  source: string,
820
+ buildScoped: boolean,
771
821
  ): boolean {
772
822
  const currentKey = toProjectKey(cached.projectRoot, file);
823
+ if (cached.inputHashes[currentKey] !== hashText(source)) {
824
+ return false;
825
+ }
826
+ if (buildScoped && !cached.servedFiles?.has(pathIdentityKey(file))) {
827
+ return true;
828
+ }
773
829
  const currentHashes = collectProjectInputHashes(cached.projectRoot);
774
830
  currentHashes[currentKey] = hashText(source);
775
831
  if (!sameHashes(cached.inputHashes, currentHashes)) {
@@ -792,6 +848,14 @@ function matchesCachedSource(
792
848
  );
793
849
  }
794
850
 
851
+ /** Record a successfully selected module as delivered by this generation. */
852
+ function markCachedSourceServed(
853
+ cached: TtscCachedProjectTransform,
854
+ file: string,
855
+ ): void {
856
+ (cached.servedFiles ??= new Set()).add(pathIdentityKey(file));
857
+ }
858
+
795
859
  /**
796
860
  * Build the input-hash snapshot stored alongside a fresh compiler result.
797
861
  *
@@ -876,11 +940,12 @@ function listProjectInputFiles(root: string): string[] {
876
940
 
877
941
  /**
878
942
  * Report whether an absolute `file` belongs to the project walk universe of
879
- * `root`: it lies under `root` and no segment of the relative path (including
880
- * the basename) is an ignored directory name. The predicate mirrors
881
- * {@link listProjectInputFiles} exactly, so "walk-visible" here means "hashed by
882
- * {@link collectProjectInputHashes}". Anything else is an out-of-walk input that
883
- * only the reference graph can prove relevant.
943
+ * `root`: it lies under `root`, every component exists without traversing a
944
+ * symbolic link, the leaf is a regular file, and no segment of the relative
945
+ * path is ignored. The predicate mirrors {@link listProjectInputFiles} exactly,
946
+ * so "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
947
+ * Missing paths and files reached through symlinks or Windows junctions are
948
+ * out-of-walk inputs that only the reference graph can prove relevant.
884
949
  */
885
950
  export function isProjectWalkPath(root: string, file: string): boolean {
886
951
  const relative = path.relative(pathIdentityKey(root), pathIdentityKey(file));
@@ -892,9 +957,28 @@ export function isProjectWalkPath(root: string, file: string): boolean {
892
957
  ) {
893
958
  return false;
894
959
  }
895
- return relative
896
- .split(path.sep)
897
- .every((segment) => !isIgnoredProjectDirectory(segment));
960
+ const segments = relative.split(path.sep);
961
+ if (segments.some(isIgnoredProjectDirectory)) {
962
+ return false;
963
+ }
964
+ let current = path.resolve(root);
965
+ for (let index = 0; index < segments.length; ++index) {
966
+ current = path.join(current, segments[index]!);
967
+ let stats: fs.Stats;
968
+ try {
969
+ stats = fs.lstatSync(current);
970
+ } catch {
971
+ return false;
972
+ }
973
+ if (stats.isSymbolicLink()) {
974
+ return false;
975
+ }
976
+ const leaf = index === segments.length - 1;
977
+ if ((leaf && !stats.isFile()) || (!leaf && !stats.isDirectory())) {
978
+ return false;
979
+ }
980
+ }
981
+ return true;
898
982
  }
899
983
 
900
984
  /**
@@ -1092,6 +1176,7 @@ async function transformProject(props: {
1092
1176
  }),
1093
1177
  projectRoot,
1094
1178
  result,
1179
+ servedFiles: new Set(),
1095
1180
  // Remember the generated temp-dir tsconfig (disposed below) so watch
1096
1181
  // derivation can drop it from the envelope's config chain; a registered
1097
1182
  // but deleted file would invalidate every persistent-cache snapshot.
package/src/turbopack.ts CHANGED
@@ -45,9 +45,9 @@ const nodeModulesPattern = /(?:^|[/\\])node_modules(?:[/\\]|$)/;
45
45
  /**
46
46
  * Per-process transform cache. Turbopack runs loaders in a worker pool and
47
47
  * never signals build boundaries to a loader, so the cache lives for the
48
- * worker's lifetime; entries self-invalidate by re-hashing the project's input
49
- * files on every request (see `transformTtsc`), which is the same freshness
50
- * rule the bundler-plugin adapters rely on between watch rebuilds.
48
+ * worker's lifetime. Because no build-start boundary exists, every cache hit
49
+ * validates all project and graph inputs before selecting output (see
50
+ * `transformTtsc`).
51
51
  */
52
52
  const transformCache = createTtscTransformCache();
53
53