@ttsc/unplugin 0.19.1 → 0.19.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.
- package/README.md +6 -2
- package/lib/api.js +3 -0
- package/lib/api.js.map +1 -1
- package/lib/api.mjs +1 -1
- package/lib/core/index.d.ts +2 -2
- package/lib/core/index.js +18 -4
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +16 -4
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.ts +64 -4
- package/lib/core/transform.js +321 -17
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +319 -18
- package/lib/core/transform.mjs.map +1 -1
- package/lib/turbopack.d.ts +7 -0
- package/lib/turbopack.js +14 -4
- package/lib/turbopack.js.map +1 -1
- package/lib/turbopack.mjs +14 -4
- package/lib/turbopack.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +29 -5
- package/src/core/transform.ts +427 -22
- package/src/turbopack.ts +22 -4
package/src/core/index.ts
CHANGED
|
@@ -4,8 +4,11 @@ import { createUnplugin } from "unplugin";
|
|
|
4
4
|
import type { TtscUnpluginOptions } from "./options";
|
|
5
5
|
import { resolveOptions } from "./options";
|
|
6
6
|
import {
|
|
7
|
+
collectExternalInputHashes,
|
|
8
|
+
collectProjectInputHashes,
|
|
7
9
|
createTtscTransformCache,
|
|
8
10
|
isDeclarationFile,
|
|
11
|
+
isProjectWalkPath,
|
|
9
12
|
stripQuery,
|
|
10
13
|
transformTtsc,
|
|
11
14
|
} from "./transform";
|
|
@@ -68,11 +71,24 @@ const unpluginFactory: UnpluginFactory<
|
|
|
68
71
|
return undefined;
|
|
69
72
|
}
|
|
70
73
|
return transformTtsc(file, source, options, aliases, transformCache, {
|
|
71
|
-
// Register plugin-reported dependencies
|
|
72
|
-
//
|
|
73
|
-
// in watch mode
|
|
74
|
-
//
|
|
74
|
+
// Register the derived watch inputs (plugin-reported `dependencies`
|
|
75
|
+
// unioned with the host-owned reference graph) so type-only inputs
|
|
76
|
+
// invalidate this module in watch mode and persistent caches;
|
|
77
|
+
// bundlers erase type-only imports from their own module graph and
|
|
78
|
+
// would otherwise serve stale generated code.
|
|
75
79
|
addWatchFile: (watched) => this.addWatchFile(watched),
|
|
80
|
+
// A module the plugin declared volatile depends on non-file inputs,
|
|
81
|
+
// which no file-dependency snapshot can represent; mark it
|
|
82
|
+
// uncacheable where the bundler exposes that control.
|
|
83
|
+
markVolatile: () => {
|
|
84
|
+
const native = this.getNativeBuildContext?.();
|
|
85
|
+
if (
|
|
86
|
+
native?.framework === "webpack" ||
|
|
87
|
+
native?.framework === "rspack"
|
|
88
|
+
) {
|
|
89
|
+
native.loaderContext?.cacheable?.(false);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
76
92
|
});
|
|
77
93
|
},
|
|
78
94
|
};
|
|
@@ -86,7 +102,15 @@ export type {
|
|
|
86
102
|
TtscUnpluginOptions,
|
|
87
103
|
} from "./options";
|
|
88
104
|
export type { TtscTransformHooks } from "./transform";
|
|
89
|
-
export {
|
|
105
|
+
export {
|
|
106
|
+
collectExternalInputHashes,
|
|
107
|
+
collectProjectInputHashes,
|
|
108
|
+
createTtscTransformCache,
|
|
109
|
+
isProjectWalkPath,
|
|
110
|
+
resolveOptions,
|
|
111
|
+
transformTtsc,
|
|
112
|
+
unplugin,
|
|
113
|
+
};
|
|
90
114
|
|
|
91
115
|
export default unplugin;
|
|
92
116
|
|
package/src/core/transform.ts
CHANGED
|
@@ -51,6 +51,20 @@ export interface TtscTransformAlias {
|
|
|
51
51
|
* miss on every module.
|
|
52
52
|
*/
|
|
53
53
|
export interface TtscCachedProjectTransform {
|
|
54
|
+
/**
|
|
55
|
+
* SHA-256 hash of every input the compiler reported outside the project walk
|
|
56
|
+
* (keyed by absolute path), captured at the time of the transform.
|
|
57
|
+
*
|
|
58
|
+
* The project walk cannot see files outside the project root or under ignored
|
|
59
|
+
* directories (`node_modules` declarations, monorepo sibling sources,
|
|
60
|
+
* out-of-root tsconfig `extends` ancestry), yet the host-owned reference
|
|
61
|
+
* graph proves they are transform inputs. Long-lived hosts that never clear
|
|
62
|
+
* the cache between builds (Metro workers, the Turbopack loader, Bun) would
|
|
63
|
+
* otherwise replay a project transform computed against a stale out-of-walk
|
|
64
|
+
* input for the whole process lifetime; per-build hosts clear the cache on
|
|
65
|
+
* `buildStart` and never replay across edits.
|
|
66
|
+
*/
|
|
67
|
+
externalInputHashes?: Record<string, string>;
|
|
54
68
|
/**
|
|
55
69
|
* SHA-256 hash of each project-relative input path at the time of the
|
|
56
70
|
* transform.
|
|
@@ -60,6 +74,15 @@ export interface TtscCachedProjectTransform {
|
|
|
60
74
|
projectRoot: string;
|
|
61
75
|
/** Raw compiler output returned by {@link TtscCompiler.transform}. */
|
|
62
76
|
result: ITtscCompilerTransformation;
|
|
77
|
+
/**
|
|
78
|
+
* Absolute path of the generated temp-dir tsconfig this compile ran against,
|
|
79
|
+
* when an alias/compiler-options overlay required one. The compiler reports
|
|
80
|
+
* it in the envelope's `graph.configs` chain, but it is disposed right after
|
|
81
|
+
* the compile, so registering it as a watch input would invalidate every
|
|
82
|
+
* bundler cache snapshot on the next build; watch derivation must skip
|
|
83
|
+
* exactly this path.
|
|
84
|
+
*/
|
|
85
|
+
temporaryTsconfig?: string;
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/**
|
|
@@ -85,12 +108,25 @@ export function createTtscTransformCache(): TtscTransformCache {
|
|
|
85
108
|
*/
|
|
86
109
|
export interface TtscTransformHooks {
|
|
87
110
|
/**
|
|
88
|
-
* Invoked once per absolute
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
111
|
+
* Invoked once per absolute watch-input path derived for the transformed file
|
|
112
|
+
* `F`: the plugin-reported `dependencies[F]` list unioned with the host-owned
|
|
113
|
+
* reference graph's contribution — the reachability closure of `graph.edges`
|
|
114
|
+
* from `F`, the `graph.globals` files, and the `graph.configs` chain — or,
|
|
115
|
+
* for a file the envelope declared `dependenciesComplete`, only
|
|
116
|
+
* `dependencies[F]` and the universal `graph.configs` chain. Adapters forward
|
|
117
|
+
* this to the bundler's `addWatchFile` so type-only inputs participate in
|
|
118
|
+
* watch-mode and persistent-cache invalidation. See {@link selectWatchInputs}
|
|
119
|
+
* for the exact derivation.
|
|
92
120
|
*/
|
|
93
121
|
addWatchFile?: (file: string) => void;
|
|
122
|
+
/**
|
|
123
|
+
* Invoked when the plugin declared the transformed file volatile (the
|
|
124
|
+
* envelope's `volatile` list): its output depends on non-file inputs that no
|
|
125
|
+
* file-dependency snapshot can represent. Adapters should mark the module
|
|
126
|
+
* uncacheable where the bundler exposes that control (e.g. a webpack loader
|
|
127
|
+
* context's `cacheable(false)`).
|
|
128
|
+
*/
|
|
129
|
+
markVolatile?: () => void;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
/**
|
|
@@ -150,7 +186,17 @@ export async function transformTtsc(
|
|
|
150
186
|
// A rejected in-flight generation must not stay cached: evict it (only if
|
|
151
187
|
// it is still the current entry) so a later call re-runs the transform.
|
|
152
188
|
const cached = await awaitOrEvict(cache, key, transformed);
|
|
153
|
-
if (
|
|
189
|
+
if (
|
|
190
|
+
// A file the plugin declared volatile must never be served from the
|
|
191
|
+
// cache: its output depends on non-file inputs, so the input-hash
|
|
192
|
+
// snapshot cannot prove freshness. Fall through to a fresh transform.
|
|
193
|
+
!isVolatileFile({
|
|
194
|
+
file,
|
|
195
|
+
projectRoot: cached.projectRoot,
|
|
196
|
+
result: cached.result,
|
|
197
|
+
}) &&
|
|
198
|
+
matchesCachedSource(cached, file, source)
|
|
199
|
+
) {
|
|
154
200
|
reportSuccessDiagnostics(cached.result);
|
|
155
201
|
// A resolved `"exception"` / `"failure"` envelope makes this throw; that
|
|
156
202
|
// is a failed generation too, so evict before surfacing it.
|
|
@@ -159,10 +205,11 @@ export async function transformTtsc(
|
|
|
159
205
|
projectRoot: cached.projectRoot,
|
|
160
206
|
result: cached.result,
|
|
161
207
|
});
|
|
162
|
-
|
|
208
|
+
notifyWatchInputs(hooks, {
|
|
163
209
|
file,
|
|
164
210
|
projectRoot: cached.projectRoot,
|
|
165
211
|
result: cached.result,
|
|
212
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
166
213
|
});
|
|
167
214
|
return createTransformResult(source, code);
|
|
168
215
|
}
|
|
@@ -182,14 +229,21 @@ export async function transformTtsc(
|
|
|
182
229
|
cache?.set(key, transformed);
|
|
183
230
|
}
|
|
184
231
|
const generation = transformed;
|
|
185
|
-
const { projectRoot, result } = await awaitOrEvict(
|
|
232
|
+
const { projectRoot, result, temporaryTsconfig } = await awaitOrEvict(
|
|
233
|
+
cache,
|
|
234
|
+
key,
|
|
235
|
+
generation,
|
|
236
|
+
);
|
|
186
237
|
reportSuccessDiagnostics(result);
|
|
187
238
|
const code = selectOrEvict(cache, key, generation, {
|
|
188
239
|
file,
|
|
189
240
|
projectRoot,
|
|
190
241
|
result,
|
|
191
242
|
});
|
|
192
|
-
|
|
243
|
+
notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
|
|
244
|
+
if (isVolatileFile({ file, projectRoot, result })) {
|
|
245
|
+
hooks?.markVolatile?.();
|
|
246
|
+
}
|
|
193
247
|
return createTransformResult(source, code);
|
|
194
248
|
}
|
|
195
249
|
|
|
@@ -256,29 +310,232 @@ function evictGeneration(
|
|
|
256
310
|
}
|
|
257
311
|
|
|
258
312
|
/**
|
|
259
|
-
* Forward
|
|
260
|
-
* `
|
|
313
|
+
* Forward every derived watch input for `file` to the adapter's `addWatchFile`
|
|
314
|
+
* hook: the plugin-reported `dependencies[file]` list unioned with the
|
|
315
|
+
* host-owned reference graph's contribution (`reach(edges, file)`, `globals`,
|
|
316
|
+
* `configs`).
|
|
261
317
|
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
318
|
+
* Envelope keys mirror the `typescript` keys (project-relative); values may be
|
|
319
|
+
* project-relative or absolute. Every path is absolutized against the project
|
|
320
|
+
* root and deduplicated; the file itself is dropped (the bundler already
|
|
321
|
+
* watches the module it transforms), and so is the disposed temp-dir tsconfig
|
|
322
|
+
* (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
|
|
266
323
|
*/
|
|
267
|
-
function
|
|
324
|
+
function notifyWatchInputs(
|
|
268
325
|
hooks: TtscTransformHooks | undefined,
|
|
269
326
|
props: {
|
|
270
327
|
file: string;
|
|
271
328
|
projectRoot: string;
|
|
272
329
|
result: ITtscCompilerTransformation;
|
|
330
|
+
temporaryTsconfig?: string;
|
|
273
331
|
},
|
|
274
332
|
): void {
|
|
275
333
|
const addWatchFile = hooks?.addWatchFile;
|
|
276
334
|
if (addWatchFile === undefined) {
|
|
277
335
|
return;
|
|
278
336
|
}
|
|
279
|
-
for (const
|
|
280
|
-
addWatchFile(
|
|
337
|
+
for (const input of selectWatchInputs(props)) {
|
|
338
|
+
addWatchFile(input);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Derive the absolute, deduplicated watch-input list for a single file.
|
|
344
|
+
*
|
|
345
|
+
* By default the derivation is a union: `dependencies[file] ∪ reach(edges,
|
|
346
|
+
* file) ∪ globals ∪ configs`. The plugin-reported list can only widen the
|
|
347
|
+
* host-owned language-semantic bound, never narrow it.
|
|
348
|
+
*
|
|
349
|
+
* An envelope that lists `file` in `dependenciesComplete` narrows it to
|
|
350
|
+
* `dependencies[file] ∪ configs`: the plugin declared its reported list the
|
|
351
|
+
* complete input set for that file, which transfers responsibility for the
|
|
352
|
+
* dropped `reach(edges, file) ∪ globals` bound to the plugin (see the protocol
|
|
353
|
+
* page's completeness contract). The config chain stays universal regardless,
|
|
354
|
+
* because compiler options reach generated code through the host rather than
|
|
355
|
+
* through any file a plugin could consult. A file the plugin also declared
|
|
356
|
+
* volatile keeps the baseline: the two declarations contradict, so the
|
|
357
|
+
* conservative one wins.
|
|
358
|
+
*
|
|
359
|
+
* Returns an empty list on exceptions.
|
|
360
|
+
*/
|
|
361
|
+
function selectWatchInputs(props: {
|
|
362
|
+
file: string;
|
|
363
|
+
projectRoot: string;
|
|
364
|
+
result: ITtscCompilerTransformation;
|
|
365
|
+
temporaryTsconfig?: string;
|
|
366
|
+
}): string[] {
|
|
367
|
+
const output: string[] = [];
|
|
368
|
+
const seen = new Set<string>();
|
|
369
|
+
const excluded = new Set(
|
|
370
|
+
props.temporaryTsconfig === undefined
|
|
371
|
+
? [props.file]
|
|
372
|
+
: [props.file, path.resolve(props.temporaryTsconfig)],
|
|
373
|
+
);
|
|
374
|
+
for (const absolute of [
|
|
375
|
+
...selectFileDependencies(props),
|
|
376
|
+
...selectGraphInputs({
|
|
377
|
+
...props,
|
|
378
|
+
complete: declaresCompleteDependencies(props) && !isVolatileFile(props),
|
|
379
|
+
}),
|
|
380
|
+
]) {
|
|
381
|
+
if (excluded.has(absolute) || seen.has(absolute)) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
seen.add(absolute);
|
|
385
|
+
output.push(absolute);
|
|
281
386
|
}
|
|
387
|
+
return output;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Flatten the host-owned reference graph for one file into absolute paths.
|
|
392
|
+
*
|
|
393
|
+
* The full contribution is the reachability closure of `edges` starting at the
|
|
394
|
+
* file, plus every global-scope file and the config chain. Flattening direct
|
|
395
|
+
* edges into a per-file list happens here — at the adapter boundary — because
|
|
396
|
+
* bundler `fileDependencies` snapshots are flat; the protocol itself carries
|
|
397
|
+
* only direct edges.
|
|
398
|
+
*
|
|
399
|
+
* `complete` drops the reach and globals halves, keeping only the universal
|
|
400
|
+
* config chain: the caller established that the plugin declared its own
|
|
401
|
+
* `dependencies[file]` list the complete replacement for them. Returns an empty
|
|
402
|
+
* list on exceptions or without a graph.
|
|
403
|
+
*/
|
|
404
|
+
function selectGraphInputs(props: {
|
|
405
|
+
complete: boolean;
|
|
406
|
+
file: string;
|
|
407
|
+
projectRoot: string;
|
|
408
|
+
result: ITtscCompilerTransformation;
|
|
409
|
+
}): string[] {
|
|
410
|
+
if (props.result.type === "exception") {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
const graph = props.result.graph;
|
|
414
|
+
if (graph === undefined) {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
const output: string[] = [];
|
|
418
|
+
if (!props.complete) {
|
|
419
|
+
output.push(...selectReachableEdges(props.projectRoot, props.file, graph));
|
|
420
|
+
output.push(...selectListedFiles(props.projectRoot, graph.globals));
|
|
421
|
+
}
|
|
422
|
+
output.push(...selectListedFiles(props.projectRoot, graph.configs));
|
|
423
|
+
return output;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Walk the reachability closure of the graph's direct `edges` from `file`,
|
|
428
|
+
* returning the absolute path of every file reached (the starting file itself
|
|
429
|
+
* excluded, even when a cycle points back at it).
|
|
430
|
+
*/
|
|
431
|
+
function selectReachableEdges(
|
|
432
|
+
projectRoot: string,
|
|
433
|
+
file: string,
|
|
434
|
+
graph: ITtscCompilerTransformation.IReferenceGraph,
|
|
435
|
+
): string[] {
|
|
436
|
+
const edges = new Map<string, string[]>();
|
|
437
|
+
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
438
|
+
if (!Array.isArray(targets)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
edges.set(
|
|
442
|
+
path.resolve(projectRoot, source),
|
|
443
|
+
targets
|
|
444
|
+
.filter(
|
|
445
|
+
(target): target is string =>
|
|
446
|
+
typeof target === "string" && target.length !== 0,
|
|
447
|
+
)
|
|
448
|
+
.map((target) => path.resolve(projectRoot, target)),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
const output: string[] = [];
|
|
452
|
+
const visited = new Set<string>([file]);
|
|
453
|
+
const queue = [file];
|
|
454
|
+
while (queue.length !== 0) {
|
|
455
|
+
const current = queue.pop()!;
|
|
456
|
+
for (const target of edges.get(current) ?? []) {
|
|
457
|
+
if (visited.has(target)) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
visited.add(target);
|
|
461
|
+
queue.push(target);
|
|
462
|
+
output.push(target);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return output;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Absolutize one graph string list (`globals`, `configs`), skipping members a
|
|
470
|
+
* malformed envelope section may carry. Duplicates survive; the caller
|
|
471
|
+
* deduplicates the merged list.
|
|
472
|
+
*/
|
|
473
|
+
function selectListedFiles(projectRoot: string, listed: unknown): string[] {
|
|
474
|
+
if (!Array.isArray(listed)) {
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
const output: string[] = [];
|
|
478
|
+
for (const entry of listed) {
|
|
479
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
output.push(path.resolve(projectRoot, entry));
|
|
483
|
+
}
|
|
484
|
+
return output;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Report whether the plugin declared `file` volatile: its output depends on
|
|
489
|
+
* non-file inputs (environment, time, network), so neither the project
|
|
490
|
+
* transform cache nor a bundler's persistent cache may replay it.
|
|
491
|
+
*/
|
|
492
|
+
function isVolatileFile(props: {
|
|
493
|
+
file: string;
|
|
494
|
+
projectRoot: string;
|
|
495
|
+
result: ITtscCompilerTransformation;
|
|
496
|
+
}): boolean {
|
|
497
|
+
if (props.result.type === "exception") {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
return declaresFile(props.result.volatile, props);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Report whether the envelope declared `dependencies[file]` complete, i.e. the
|
|
505
|
+
* plugin took responsibility for that file's whole input set beyond the file
|
|
506
|
+
* itself and the universal config chain. Callers must still keep the baseline
|
|
507
|
+
* for a file the same envelope declared volatile.
|
|
508
|
+
*/
|
|
509
|
+
function declaresCompleteDependencies(props: {
|
|
510
|
+
file: string;
|
|
511
|
+
projectRoot: string;
|
|
512
|
+
result: ITtscCompilerTransformation;
|
|
513
|
+
}): boolean {
|
|
514
|
+
if (props.result.type === "exception") {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
return declaresFile(props.result.dependenciesComplete, props);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Report whether one of the envelope's transformed-file lists (`volatile`,
|
|
522
|
+
* `dependenciesComplete`) names `file`. Members are keyed like `typescript`, so
|
|
523
|
+
* a project-relative and an absolute spelling of the same file both match; a
|
|
524
|
+
* malformed member is ignored rather than fatal.
|
|
525
|
+
*/
|
|
526
|
+
function declaresFile(
|
|
527
|
+
listed: unknown,
|
|
528
|
+
props: { file: string; projectRoot: string },
|
|
529
|
+
): boolean {
|
|
530
|
+
if (!Array.isArray(listed)) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
return listed.some(
|
|
534
|
+
(entry) =>
|
|
535
|
+
typeof entry === "string" &&
|
|
536
|
+
entry.length !== 0 &&
|
|
537
|
+
path.resolve(props.projectRoot, entry) === props.file,
|
|
538
|
+
);
|
|
282
539
|
}
|
|
283
540
|
|
|
284
541
|
/**
|
|
@@ -384,9 +641,11 @@ export function createTransformResult(
|
|
|
384
641
|
* in-memory source, then compares the snapshot against the one captured when
|
|
385
642
|
* the result was produced. Any input under the project root changing (the
|
|
386
643
|
* module itself or a sibling the plugin reads) invalidates the entry and forces
|
|
387
|
-
* a re-transform. Out-of-walk inputs
|
|
388
|
-
* declarations, sibling-package sources
|
|
389
|
-
*
|
|
644
|
+
* a re-transform. Out-of-walk inputs the compiler reported (`node_modules`
|
|
645
|
+
* declarations, sibling-package sources, out-of-root config ancestry) are
|
|
646
|
+
* validated through {@link TtscCachedProjectTransform.externalInputHashes};
|
|
647
|
+
* adapters additionally register them as derived watch inputs (the host-owned
|
|
648
|
+
* `graph` union the reported `dependencies`) → `addWatchFile` → the bundler's
|
|
390
649
|
* next `buildStart` cache clear.
|
|
391
650
|
*
|
|
392
651
|
* Both this snapshot and {@link collectInputHashes} draw their keys from the
|
|
@@ -405,7 +664,22 @@ function matchesCachedSource(
|
|
|
405
664
|
const currentKey = toProjectKey(cached.projectRoot, file);
|
|
406
665
|
const currentHashes = collectProjectInputHashes(cached.projectRoot);
|
|
407
666
|
currentHashes[currentKey] = hashText(source);
|
|
408
|
-
|
|
667
|
+
if (!sameHashes(cached.inputHashes, currentHashes)) {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
// Re-hash the out-of-walk inputs the compiler reported for this generation
|
|
671
|
+
// over exactly the recorded key universe, so an edit to a `node_modules`
|
|
672
|
+
// declaration or a monorepo sibling source invalidates the entry even in a
|
|
673
|
+
// host that never clears the cache between builds. A new out-of-walk input
|
|
674
|
+
// cannot appear without some recorded input changing first: a new reference
|
|
675
|
+
// edge requires editing an in-walk source, and a new global or config file
|
|
676
|
+
// requires a tsconfig or package manifest change, both of which the project
|
|
677
|
+
// walk above already detects.
|
|
678
|
+
const externalHashes = cached.externalInputHashes ?? {};
|
|
679
|
+
return sameHashes(
|
|
680
|
+
externalHashes,
|
|
681
|
+
collectExternalInputHashes(Object.keys(externalHashes)),
|
|
682
|
+
);
|
|
409
683
|
}
|
|
410
684
|
|
|
411
685
|
/**
|
|
@@ -434,7 +708,13 @@ function collectInputHashes(props: {
|
|
|
434
708
|
return hashes;
|
|
435
709
|
}
|
|
436
710
|
|
|
437
|
-
|
|
711
|
+
/**
|
|
712
|
+
* Hash every input file under `projectRoot` (the same walk universe
|
|
713
|
+
* {@link matchesCachedSource} validates against), keyed by project-relative
|
|
714
|
+
* slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
|
|
715
|
+
* can fold the identical input universe into their own cache fingerprints.
|
|
716
|
+
*/
|
|
717
|
+
export function collectProjectInputHashes(
|
|
438
718
|
projectRoot: string,
|
|
439
719
|
): Record<string, string> {
|
|
440
720
|
const hashes: Record<string, string> = {};
|
|
@@ -484,6 +764,119 @@ function listProjectInputFiles(root: string): string[] {
|
|
|
484
764
|
return out;
|
|
485
765
|
}
|
|
486
766
|
|
|
767
|
+
/**
|
|
768
|
+
* Report whether an absolute `file` belongs to the project walk universe of
|
|
769
|
+
* `root`: it lies under `root` and no segment of the relative path (including
|
|
770
|
+
* the basename) is an ignored directory name. The predicate mirrors
|
|
771
|
+
* {@link listProjectInputFiles} exactly, so "walk-visible" here means "hashed by
|
|
772
|
+
* {@link collectProjectInputHashes}". Anything else is an out-of-walk input that
|
|
773
|
+
* only the reference graph can prove relevant.
|
|
774
|
+
*/
|
|
775
|
+
export function isProjectWalkPath(root: string, file: string): boolean {
|
|
776
|
+
const relative = path.relative(path.resolve(root), path.resolve(file));
|
|
777
|
+
if (
|
|
778
|
+
relative.length === 0 ||
|
|
779
|
+
relative === ".." ||
|
|
780
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
781
|
+
path.isAbsolute(relative)
|
|
782
|
+
) {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
return relative
|
|
786
|
+
.split(path.sep)
|
|
787
|
+
.every((segment) => !isIgnoredProjectDirectory(segment));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Hash a list of absolute out-of-walk input paths: content SHA-256 for a
|
|
792
|
+
* readable file, a stable `missing` marker otherwise. The marker is state, not
|
|
793
|
+
* an error — a recorded input disappearing (or reappearing) must change the
|
|
794
|
+
* comparison exactly like a content edit. Exported so `@ttsc/metro` can re-hash
|
|
795
|
+
* its recorded snapshot with identical semantics at cache-key time.
|
|
796
|
+
*/
|
|
797
|
+
export function collectExternalInputHashes(
|
|
798
|
+
paths: readonly string[],
|
|
799
|
+
): Record<string, string> {
|
|
800
|
+
const hashes: Record<string, string> = {};
|
|
801
|
+
for (const file of paths) {
|
|
802
|
+
try {
|
|
803
|
+
hashes[file] = hashText(fs.readFileSync(file));
|
|
804
|
+
} catch {
|
|
805
|
+
hashes[file] = "missing";
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return hashes;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Derive the absolute out-of-walk input set of a whole project transform: the
|
|
813
|
+
* union of every reference-graph member (edge keys and targets, globals, the
|
|
814
|
+
* config chain) and every plugin-reported dependency, minus everything the
|
|
815
|
+
* project walk already hashes and the disposed temp-dir tsconfig. These are the
|
|
816
|
+
* inputs {@link matchesCachedSource}'s walk cannot see.
|
|
817
|
+
*
|
|
818
|
+
* A `dependenciesComplete` declaration deliberately does not narrow this set,
|
|
819
|
+
* unlike the per-file watch derivation. This cache replays one whole envelope,
|
|
820
|
+
* so its validity condition is the union over every file the envelope carries
|
|
821
|
+
* rather than one file's inputs; a miss here costs a re-transform, never a
|
|
822
|
+
* stale output; and it is the layer that re-runs the plugin's analysis, which
|
|
823
|
+
* is how a widened declaration is ever learned. The narrowing that matters
|
|
824
|
+
* lands at the bundler boundary through {@link selectWatchInputs}, which is what
|
|
825
|
+
* feeds persistent caches and watch graphs.
|
|
826
|
+
*/
|
|
827
|
+
function selectExternalInputPaths(props: {
|
|
828
|
+
projectRoot: string;
|
|
829
|
+
result: ITtscCompilerTransformation;
|
|
830
|
+
temporaryTsconfig?: string;
|
|
831
|
+
}): string[] {
|
|
832
|
+
if (props.result.type === "exception") {
|
|
833
|
+
return [];
|
|
834
|
+
}
|
|
835
|
+
const members: string[] = [];
|
|
836
|
+
const graph = props.result.graph;
|
|
837
|
+
if (graph !== undefined) {
|
|
838
|
+
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
839
|
+
members.push(source);
|
|
840
|
+
if (Array.isArray(targets)) {
|
|
841
|
+
members.push(...targets);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const listed of [graph.globals, graph.configs]) {
|
|
845
|
+
if (Array.isArray(listed)) {
|
|
846
|
+
members.push(...listed);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
for (const entries of Object.values(props.result.dependencies ?? {})) {
|
|
851
|
+
if (Array.isArray(entries)) {
|
|
852
|
+
members.push(...entries);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
const excluded =
|
|
856
|
+
props.temporaryTsconfig === undefined
|
|
857
|
+
? undefined
|
|
858
|
+
: path.resolve(props.temporaryTsconfig);
|
|
859
|
+
const output: string[] = [];
|
|
860
|
+
const seen = new Set<string>();
|
|
861
|
+
for (const member of members) {
|
|
862
|
+
if (typeof member !== "string" || member.length === 0) {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const absolute = path.resolve(props.projectRoot, member);
|
|
866
|
+
if (
|
|
867
|
+
absolute === excluded ||
|
|
868
|
+
seen.has(absolute) ||
|
|
869
|
+
isProjectWalkPath(props.projectRoot, absolute)
|
|
870
|
+
) {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
seen.add(absolute);
|
|
874
|
+
output.push(absolute);
|
|
875
|
+
}
|
|
876
|
+
output.sort();
|
|
877
|
+
return output;
|
|
878
|
+
}
|
|
879
|
+
|
|
487
880
|
function isIgnoredProjectDirectory(name: string): boolean {
|
|
488
881
|
return (
|
|
489
882
|
name === ".git" ||
|
|
@@ -544,7 +937,15 @@ async function transformProject(props: {
|
|
|
544
937
|
projectRoot,
|
|
545
938
|
tsconfig: configured.path,
|
|
546
939
|
}).transform();
|
|
940
|
+
const temporaryTsconfig =
|
|
941
|
+
configured.path === props.tsconfig ? undefined : configured.path;
|
|
547
942
|
return {
|
|
943
|
+
// Capture the out-of-walk input hashes while the generation is fresh so
|
|
944
|
+
// cache validation can re-check them; computed before dispose so the
|
|
945
|
+
// exclusion of the temp-dir tsconfig is the only reason it never keys.
|
|
946
|
+
externalInputHashes: collectExternalInputHashes(
|
|
947
|
+
selectExternalInputPaths({ projectRoot, result, temporaryTsconfig }),
|
|
948
|
+
),
|
|
548
949
|
inputHashes: collectInputHashes({
|
|
549
950
|
currentFile: props.currentFile,
|
|
550
951
|
currentSource: props.currentSource,
|
|
@@ -552,6 +953,10 @@ async function transformProject(props: {
|
|
|
552
953
|
}),
|
|
553
954
|
projectRoot,
|
|
554
955
|
result,
|
|
956
|
+
// Remember the generated temp-dir tsconfig (disposed below) so watch
|
|
957
|
+
// derivation can drop it from the envelope's config chain; a registered
|
|
958
|
+
// but deleted file would invalidate every persistent-cache snapshot.
|
|
959
|
+
...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
|
|
555
960
|
};
|
|
556
961
|
} finally {
|
|
557
962
|
configured.dispose();
|
package/src/turbopack.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TtscUnpluginOptions } from "./core/options";
|
|
2
2
|
import { resolveOptions } from "./core/options";
|
|
3
|
+
import type { TtscTransformHooks } from "./core/transform";
|
|
3
4
|
import {
|
|
4
5
|
createTtscTransformCache,
|
|
5
6
|
isDeclarationFile,
|
|
@@ -29,6 +30,13 @@ export interface TtscTurbopackLoaderContext {
|
|
|
29
30
|
* build that predates the method) still loads.
|
|
30
31
|
*/
|
|
31
32
|
addDependency?(file: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Toggle result cacheability. Part of the webpack loader context contract;
|
|
35
|
+
* called with `false` when the ttsc plugin declared the module volatile
|
|
36
|
+
* (output depends on non-file inputs), so the bundler never replays a cached
|
|
37
|
+
* result for it. Optional so a minimal stub context still loads.
|
|
38
|
+
*/
|
|
39
|
+
cacheable?(flag: boolean): void;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/** Matches any path segment that is a `node_modules` directory (cross-platform). */
|
|
@@ -79,19 +87,29 @@ export default function turbopack(
|
|
|
79
87
|
callback(undefined, source);
|
|
80
88
|
return;
|
|
81
89
|
}
|
|
82
|
-
// Forward plugin-reported dependencies
|
|
83
|
-
//
|
|
90
|
+
// Forward the derived watch inputs (plugin-reported dependencies plus the
|
|
91
|
+
// host-owned reference graph) into Turbopack's `fileDependencies` set so
|
|
92
|
+
// editing a type-only input a transform consulted re-runs this loader.
|
|
84
93
|
// `addDependency` is bound so the webpack loader context stays `this` inside
|
|
85
94
|
// it; the hook fires on cache hits too, which is required because the shared
|
|
86
|
-
// transform cache lives for the worker lifetime across requests.
|
|
95
|
+
// transform cache lives for the worker lifetime across requests. A module
|
|
96
|
+
// the plugin declared volatile is marked uncacheable through the same loader
|
|
97
|
+
// contract.
|
|
87
98
|
const addDependency = this.addDependency?.bind(this);
|
|
99
|
+
const cacheable = this.cacheable?.bind(this);
|
|
100
|
+
const hooks: TtscTransformHooks = {
|
|
101
|
+
...(addDependency === undefined ? {} : { addWatchFile: addDependency }),
|
|
102
|
+
...(cacheable === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { markVolatile: () => cacheable(false) }),
|
|
105
|
+
};
|
|
88
106
|
transformTtsc(
|
|
89
107
|
file,
|
|
90
108
|
source,
|
|
91
109
|
resolveOptions(this.getOptions?.() ?? {}),
|
|
92
110
|
undefined,
|
|
93
111
|
transformCache,
|
|
94
|
-
|
|
112
|
+
Object.keys(hooks).length === 0 ? undefined : hooks,
|
|
95
113
|
).then(
|
|
96
114
|
(result) => callback(undefined, result?.code ?? source),
|
|
97
115
|
(error) => callback(error),
|