@ttsc/unplugin 0.18.4 → 0.19.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.
@@ -147,10 +147,14 @@ export async function transformTtsc(
147
147
 
148
148
  let transformed = cache?.get(key);
149
149
  if (transformed !== undefined) {
150
- const cached = await transformed;
150
+ // A rejected in-flight generation must not stay cached: evict it (only if
151
+ // it is still the current entry) so a later call re-runs the transform.
152
+ const cached = await awaitOrEvict(cache, key, transformed);
151
153
  if (matchesCachedSource(cached, file, source)) {
152
154
  reportSuccessDiagnostics(cached.result);
153
- const code = selectTransformedSource({
155
+ // A resolved `"exception"` / `"failure"` envelope makes this throw; that
156
+ // is a failed generation too, so evict before surfacing it.
157
+ const code = selectOrEvict(cache, key, transformed, {
154
158
  file,
155
159
  projectRoot: cached.projectRoot,
156
160
  result: cached.result,
@@ -177,13 +181,80 @@ export async function transformTtsc(
177
181
  });
178
182
  cache?.set(key, transformed);
179
183
  }
180
- const { projectRoot, result } = await transformed;
184
+ const generation = transformed;
185
+ const { projectRoot, result } = await awaitOrEvict(cache, key, generation);
181
186
  reportSuccessDiagnostics(result);
182
- const code = selectTransformedSource({ file, projectRoot, result });
187
+ const code = selectOrEvict(cache, key, generation, {
188
+ file,
189
+ projectRoot,
190
+ result,
191
+ });
183
192
  notifyFileDependencies(hooks, { file, projectRoot, result });
184
193
  return createTransformResult(source, code);
185
194
  }
186
195
 
196
+ /**
197
+ * Await a cached generation, evicting it on rejection.
198
+ *
199
+ * The cache stores the in-flight transform Promise before it settles so
200
+ * concurrent callers share one compilation. A rejected generation must not
201
+ * remain the authoritative cached result, or a transient toolchain/host failure
202
+ * becomes permanent for a long-lived worker. Eviction is identity-guarded so a
203
+ * newer generation another caller installed under the same key survives.
204
+ */
205
+ async function awaitOrEvict(
206
+ cache: TtscTransformCache | undefined,
207
+ key: string,
208
+ generation: Promise<TtscCachedProjectTransform>,
209
+ ): Promise<TtscCachedProjectTransform> {
210
+ try {
211
+ return await generation;
212
+ } catch (error) {
213
+ evictGeneration(cache, key, generation);
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Extract the transformed source, evicting the generation when the result is a
220
+ * host `"exception"` or compiler `"failure"` (which makes
221
+ * {@link selectTransformedSource} throw). Such a failed generation must not be
222
+ * replayed to later callers of an unchanged module.
223
+ */
224
+ function selectOrEvict(
225
+ cache: TtscTransformCache | undefined,
226
+ key: string,
227
+ generation: Promise<TtscCachedProjectTransform>,
228
+ props: {
229
+ file: string;
230
+ projectRoot: string;
231
+ result: ITtscCompilerTransformation;
232
+ },
233
+ ): string {
234
+ try {
235
+ return selectTransformedSource(props);
236
+ } catch (error) {
237
+ evictGeneration(cache, key, generation);
238
+ throw error;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Delete a failed generation from the cache only when it is still the entry
244
+ * stored under `key`. The identity check prevents an older failed generation's
245
+ * cleanup from removing a newer replacement created by another caller for the
246
+ * same key.
247
+ */
248
+ function evictGeneration(
249
+ cache: TtscTransformCache | undefined,
250
+ key: string,
251
+ generation: Promise<TtscCachedProjectTransform>,
252
+ ): void {
253
+ if (cache?.get(key) === generation) {
254
+ cache.delete(key);
255
+ }
256
+ }
257
+
187
258
  /**
188
259
  * Forward the plugin-reported dependency list for `file` to the adapter's
189
260
  * `addWatchFile` hook.
@@ -462,6 +533,13 @@ async function transformProject(props: {
462
533
  try {
463
534
  const result = new TtscCompiler({
464
535
  cwd: projectRoot,
536
+ // The generated tsconfig (if any) lives in the system temp directory,
537
+ // so declare the real project as the plugin config anchor: utility
538
+ // plugin config discovery (banner.config.*, strip.config.*,
539
+ // lint.config.*) and relative configFile resolution walk the project,
540
+ // never the temp tree. In the passthrough case this equals the
541
+ // tsconfig's own directory, the default anchor.
542
+ pluginConfigDir: projectRoot,
465
543
  plugins: props.plugins,
466
544
  projectRoot,
467
545
  tsconfig: configured.path,
@@ -568,6 +646,14 @@ function normalizeCompilerOptionsForGeneratedTsconfig(
568
646
  return output;
569
647
  }
570
648
 
649
+ /**
650
+ * Absolutize the relative path-typed keys of one plugin entry before it is
651
+ * written into the generated temp-dir tsconfig: `config`/`source`/`transform`
652
+ * are the descriptor-resolution keys, and `configFile` is the config-file
653
+ * override accepted by the shipped utility plugins (`@ttsc/banner`,
654
+ * `@ttsc/strip`, `@ttsc/lint`). Left relative, each would resolve against the
655
+ * temp directory instead of the project.
656
+ */
571
657
  function normalizePluginConfigForGeneratedTsconfig(
572
658
  entry: unknown,
573
659
  tsconfigDir: string,
@@ -576,7 +662,7 @@ function normalizePluginConfigForGeneratedTsconfig(
576
662
  return entry;
577
663
  }
578
664
  const output: Record<string, unknown> = { ...entry };
579
- for (const key of ["config", "source", "transform"]) {
665
+ for (const key of ["config", "configFile", "source", "transform"]) {
580
666
  const value = output[key];
581
667
  if (typeof value === "string" && isRelativeSpecifier(value)) {
582
668
  output[key] = path.resolve(tsconfigDir, value);
@@ -148,6 +148,15 @@ function resolveExtendsConfig(
148
148
  );
149
149
  }
150
150
  const resolver = createRequire(tsconfig);
151
+ // A bare package root selects its preset through `package.json#tsconfig`,
152
+ // matching TypeScript's config resolution and the core project reader. Such
153
+ // presets often ship no JS/JSON entrypoint, so Node's entrypoint resolver and
154
+ // the `<specifier>.json` fallback both miss them, silently dropping the
155
+ // preset's inherited `paths`.
156
+ const viaManifest = resolvePackageManifestTsconfig(resolver, specifier);
157
+ if (viaManifest !== null) {
158
+ return viaManifest;
159
+ }
151
160
  try {
152
161
  return resolveRealPath(resolver.resolve(specifier));
153
162
  } catch {
@@ -159,6 +168,58 @@ function resolveExtendsConfig(
159
168
  }
160
169
  }
161
170
 
171
+ /**
172
+ * When `specifier` names a bare package root, resolve the config file its
173
+ * `package.json#tsconfig` field selects (anchored at the package directory).
174
+ * Best-effort: returns `null` for a subpath, an unresolvable/unparsable
175
+ * manifest, a missing `tsconfig` field, or a field target that does not exist —
176
+ * the compiler owns the real diagnostic, and this reader must not invent
177
+ * aliases.
178
+ */
179
+ function resolvePackageManifestTsconfig(
180
+ resolver: NodeRequire,
181
+ specifier: string,
182
+ ): string | null {
183
+ if (!isBarePackageRoot(specifier)) {
184
+ return null;
185
+ }
186
+ let manifestPath: string;
187
+ try {
188
+ manifestPath = resolver.resolve(`${specifier}/package.json`);
189
+ } catch {
190
+ return null;
191
+ }
192
+ let field: unknown;
193
+ try {
194
+ const text = fs.readFileSync(manifestPath, "utf8");
195
+ field = (
196
+ JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text) as {
197
+ tsconfig?: unknown;
198
+ }
199
+ ).tsconfig;
200
+ } catch {
201
+ return null;
202
+ }
203
+ if (typeof field !== "string" || field.length === 0) {
204
+ return null;
205
+ }
206
+ return resolveExistingExtendsPath(
207
+ path.resolve(path.dirname(manifestPath), field),
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Return true when `specifier` is a bare package root (no subpath): a plain
213
+ * package name (`preset`) or a scoped name (`@scope/preset`). Subpaths such as
214
+ * `@scope/preset/base.json` resolve directly and keep their current meaning.
215
+ */
216
+ function isBarePackageRoot(specifier: string): boolean {
217
+ if (specifier.startsWith("@")) {
218
+ return specifier.split("/").length === 2;
219
+ }
220
+ return !specifier.includes("/");
221
+ }
222
+
162
223
  /**
163
224
  * Try an on-disk `extends` location as-is, with `.json` appended, and as a
164
225
  * directory containing `tsconfig.json`. Returns the first existing match.
package/src/turbopack.ts CHANGED
@@ -21,6 +21,14 @@ export interface TtscTurbopackLoaderContext {
21
21
  resourcePath: string;
22
22
  /** The rule's `options` object, when one was configured. */
23
23
  getOptions?(): TtscUnpluginOptions | undefined;
24
+ /**
25
+ * Register an additional file the transformed module depends on. Part of the
26
+ * webpack loader context contract Turbopack implements; a registered file
27
+ * enters Turbopack's `fileDependencies` set so editing it re-runs this loader
28
+ * for the owning module. Optional so a minimal stub context (or a Turbopack
29
+ * build that predates the method) still loads.
30
+ */
31
+ addDependency?(file: string): void;
24
32
  }
25
33
 
26
34
  /** Matches any path segment that is a `node_modules` directory (cross-platform). */
@@ -71,12 +79,19 @@ export default function turbopack(
71
79
  callback(undefined, source);
72
80
  return;
73
81
  }
82
+ // Forward plugin-reported dependencies into Turbopack's `fileDependencies`
83
+ // set so editing a type-only input a transform consulted re-runs this loader.
84
+ // `addDependency` is bound so the webpack loader context stays `this` inside
85
+ // it; the hook fires on cache hits too, which is required because the shared
86
+ // transform cache lives for the worker lifetime across requests.
87
+ const addDependency = this.addDependency?.bind(this);
74
88
  transformTtsc(
75
89
  file,
76
90
  source,
77
91
  resolveOptions(this.getOptions?.() ?? {}),
78
92
  undefined,
79
93
  transformCache,
94
+ addDependency === undefined ? undefined : { addWatchFile: addDependency },
80
95
  ).then(
81
96
  (result) => callback(undefined, result?.code ?? source),
82
97
  (error) => callback(error),