@ttsc/metro 0.19.0 → 0.19.2
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 +10 -1
- package/lib/core/fingerprint.d.ts +81 -0
- package/lib/core/fingerprint.js +449 -0
- package/lib/core/fingerprint.js.map +1 -0
- package/lib/core/fingerprint.mjs +441 -0
- package/lib/core/fingerprint.mjs.map +1 -0
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +6 -0
- package/lib/index.mjs.map +1 -1
- package/lib/transformer.d.ts +19 -9
- package/lib/transformer.js +64 -29
- package/lib/transformer.js.map +1 -1
- package/lib/transformer.mjs +63 -28
- package/lib/transformer.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/fingerprint.ts +514 -0
- package/src/index.ts +8 -0
- package/src/transformer.ts +75 -28
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project fingerprint and reference-graph snapshot for `@ttsc/metro`.
|
|
3
|
+
*
|
|
4
|
+
* Metro's transform cache keys each file on its own content plus one static
|
|
5
|
+
* transformer key computed once per run (`getCacheKey`, called on the main
|
|
6
|
+
* process at `Transformer` construction). A ttsc transform's output can depend
|
|
7
|
+
* on inputs Metro never keys: other project sources reached through type-only
|
|
8
|
+
* edges, `node_modules` declarations, monorepo sibling sources, and the
|
|
9
|
+
* tsconfig `extends` ancestry. This module folds all of them into the static
|
|
10
|
+
* key so the cache key incorporates every input that can influence a
|
|
11
|
+
* transform's output:
|
|
12
|
+
*
|
|
13
|
+
* - **Project walk.** Every input file under the fingerprint roots (Metro's
|
|
14
|
+
* `projectRoot` plus the resolved tsconfig's directory when it lies outside),
|
|
15
|
+
* hashed with the exact walk universe the `@ttsc/unplugin` transform core
|
|
16
|
+
* validates its own cache against.
|
|
17
|
+
* - **Recorded out-of-walk inputs.** The transform core cannot walk files outside
|
|
18
|
+
* the roots or under ignored directories, but the host-owned reference graph
|
|
19
|
+
* (samchon/ttsc#718) reports them per transform. Workers record them into a
|
|
20
|
+
* snapshot under `node_modules/.cache/ttsc-metro`; the next run's
|
|
21
|
+
* `getCacheKey` re-hashes the recorded set.
|
|
22
|
+
*
|
|
23
|
+
* Snapshot layout: one main file carrying a random epoch id plus per-worker
|
|
24
|
+
* files with unique names, so concurrent workers never race a shared write.
|
|
25
|
+
* `withTtsc` (the single config process, before workers exist) compacts worker
|
|
26
|
+
* files into the main file. Readers take the union of every file, reading the
|
|
27
|
+
* worker files strictly before the main file: the compactor renames the merged
|
|
28
|
+
* main into place strictly before deleting a worker file, so a worker file that
|
|
29
|
+
* disappears mid-read is always already merged into the main the reader loads
|
|
30
|
+
* afterwards.
|
|
31
|
+
*
|
|
32
|
+
* Sound degradations, by design:
|
|
33
|
+
*
|
|
34
|
+
* - No readable snapshot (first run, wiped cache dir, unwritable filesystem)
|
|
35
|
+
* folds a random nonce: that run shares no cache entries with any other run.
|
|
36
|
+
* - A recreated snapshot carries a fresh epoch id, so it can never alias a key
|
|
37
|
+
* from an older epoch whose recorded set is unknown.
|
|
38
|
+
* - A plugin-declared volatile output (non-file inputs; unrepresentable in any
|
|
39
|
+
* file fingerprint) marks the snapshot volatile, which also folds a nonce
|
|
40
|
+
* until a later run records the volatile declaration gone.
|
|
41
|
+
* - A recorded input that disappears hashes as a stable `missing` marker, so
|
|
42
|
+
* deletion and reappearance both move the key.
|
|
43
|
+
*/
|
|
44
|
+
import {
|
|
45
|
+
collectExternalInputHashes,
|
|
46
|
+
collectProjectInputHashes,
|
|
47
|
+
isProjectWalkPath,
|
|
48
|
+
} from "@ttsc/unplugin/api";
|
|
49
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
50
|
+
import fs from "node:fs";
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
|
|
53
|
+
/** Bumped when the snapshot JSON shape changes; mismatches read as corrupt. */
|
|
54
|
+
const SNAPSHOT_VERSION = 1;
|
|
55
|
+
|
|
56
|
+
/** Snapshot directory segments under the fingerprint base directory. */
|
|
57
|
+
const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
|
|
58
|
+
|
|
59
|
+
/** Main snapshot file name (epoch id + compacted recorded inputs). */
|
|
60
|
+
const MAIN_SNAPSHOT = "graph-inputs.json";
|
|
61
|
+
|
|
62
|
+
/** Worker snapshot file prefix; each worker appends a unique suffix. */
|
|
63
|
+
const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
|
|
64
|
+
|
|
65
|
+
/** Union of the snapshot state readable on disk. */
|
|
66
|
+
interface SnapshotState {
|
|
67
|
+
/** Random epoch id minted when the main snapshot was created. */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Absolute paths of every recorded out-of-walk input. */
|
|
70
|
+
files: string[];
|
|
71
|
+
/** Whether any recorded transform declared volatile output. */
|
|
72
|
+
volatile: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Serialized shape of the main and worker snapshot files. */
|
|
76
|
+
interface SnapshotDocument {
|
|
77
|
+
files: string[];
|
|
78
|
+
id?: string;
|
|
79
|
+
version: number;
|
|
80
|
+
volatile: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the base directory both fingerprint sides agree on: Metro's
|
|
85
|
+
* `projectRoot` when known (`withTtsc` reads it from the config, `getCacheKey`
|
|
86
|
+
* from Metro's cache-key options, the transformer from each file's transform
|
|
87
|
+
* options — all the same value in a real Metro run), else the working directory
|
|
88
|
+
* Metro was launched from.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveFingerprintBase(
|
|
91
|
+
projectRoot: string | undefined,
|
|
92
|
+
): string {
|
|
93
|
+
return path.resolve(
|
|
94
|
+
typeof projectRoot === "string" && projectRoot.length !== 0
|
|
95
|
+
? projectRoot
|
|
96
|
+
: process.cwd(),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The directories whose walk universes the fingerprint hashes: the base
|
|
102
|
+
* directory, plus the resolved tsconfig's directory when the tsconfig is not
|
|
103
|
+
* already inside the base walk (an explicit out-of-root `project`, or a
|
|
104
|
+
* monorepo-root tsconfig discovered above the app). Matching the transform
|
|
105
|
+
* core's own validation universe keeps the invariant simple: everything the
|
|
106
|
+
* core treats as an input is fingerprinted, either by a walk here or by the
|
|
107
|
+
* recorded out-of-walk snapshot.
|
|
108
|
+
*/
|
|
109
|
+
export function fingerprintRoots(
|
|
110
|
+
base: string,
|
|
111
|
+
explicitProject: string | undefined,
|
|
112
|
+
): string[] {
|
|
113
|
+
const tsconfig = resolveProjectTsconfig(base, explicitProject);
|
|
114
|
+
if (isProjectWalkPath(base, tsconfig)) {
|
|
115
|
+
return [base];
|
|
116
|
+
}
|
|
117
|
+
return [base, path.dirname(tsconfig)];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Locate the tsconfig governing the project, mirroring the transform core's
|
|
122
|
+
* discovery: an explicit `project` resolves against the working directory;
|
|
123
|
+
* otherwise ancestor directories starting at `base` are searched for a
|
|
124
|
+
* `tsconfig.json`, falling back to `<base>/tsconfig.json`.
|
|
125
|
+
*/
|
|
126
|
+
function resolveProjectTsconfig(
|
|
127
|
+
base: string,
|
|
128
|
+
explicitProject: string | undefined,
|
|
129
|
+
): string {
|
|
130
|
+
if (explicitProject !== undefined && explicitProject.length !== 0) {
|
|
131
|
+
return path.isAbsolute(explicitProject)
|
|
132
|
+
? explicitProject
|
|
133
|
+
: path.resolve(process.cwd(), explicitProject);
|
|
134
|
+
}
|
|
135
|
+
let current = base;
|
|
136
|
+
while (true) {
|
|
137
|
+
const candidate = path.join(current, "tsconfig.json");
|
|
138
|
+
if (fs.existsSync(candidate)) {
|
|
139
|
+
return candidate;
|
|
140
|
+
}
|
|
141
|
+
const parent = path.dirname(current);
|
|
142
|
+
if (parent === current) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
current = parent;
|
|
146
|
+
}
|
|
147
|
+
return path.resolve(base, "tsconfig.json");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compute the fingerprint `getCacheKey` folds into Metro's static transformer
|
|
152
|
+
* key. Never throws: any failure degrades to a nonce, which soundly disables
|
|
153
|
+
* cross-run cache reuse for this run instead of serving stale output.
|
|
154
|
+
*/
|
|
155
|
+
export function computeProjectFingerprint(props: {
|
|
156
|
+
explicitProject?: string;
|
|
157
|
+
projectRoot?: string;
|
|
158
|
+
}): string {
|
|
159
|
+
try {
|
|
160
|
+
const base = resolveFingerprintBase(props.projectRoot);
|
|
161
|
+
const hash = createHash("sha256");
|
|
162
|
+
for (const root of fingerprintRoots(base, props.explicitProject)) {
|
|
163
|
+
hash.update(stableStringify(collectProjectInputHashes(root)));
|
|
164
|
+
}
|
|
165
|
+
const snapshot = readSnapshotState(base);
|
|
166
|
+
if (snapshot === undefined || snapshot.volatile) {
|
|
167
|
+
hash.update(nonce());
|
|
168
|
+
} else {
|
|
169
|
+
hash.update(`snapshot:${snapshot.id}`);
|
|
170
|
+
hash.update(stableStringify(collectExternalInputHashes(snapshot.files)));
|
|
171
|
+
}
|
|
172
|
+
return hash.digest("hex");
|
|
173
|
+
} catch {
|
|
174
|
+
return nonce();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A value no other run can reproduce. Folding it means this run's cache entries
|
|
180
|
+
* are written but never reused by later runs, and this run reuses nothing from
|
|
181
|
+
* earlier ones — the sound fallback whenever the recorded out-of-walk input set
|
|
182
|
+
* is unknown or unrepresentable.
|
|
183
|
+
*/
|
|
184
|
+
function nonce(): string {
|
|
185
|
+
return `nonce:${randomBytes(32).toString("hex")}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Prepare the snapshot for a new run. Called from `withTtsc` in the single
|
|
190
|
+
* Metro config process, before any worker exists: creates the main snapshot
|
|
191
|
+
* (fresh epoch id) when missing or corrupt, compacts leftover worker files into
|
|
192
|
+
* it, and sweeps unparseable worker files plus crash-leftover temp files. An
|
|
193
|
+
* unparseable worker file's recordings are unrecoverable, so its removal mints
|
|
194
|
+
* a fresh epoch id — every key that might have depended on the lost recordings
|
|
195
|
+
* is soundly orphaned, and later runs stabilize instead of degrading to a nonce
|
|
196
|
+
* forever. Never throws — an unwritable cache directory leaves the snapshot
|
|
197
|
+
* unreadable and `getCacheKey` degrades to a nonce.
|
|
198
|
+
*/
|
|
199
|
+
export function prepareSnapshot(projectRoot: string | undefined): void {
|
|
200
|
+
try {
|
|
201
|
+
const base = resolveFingerprintBase(projectRoot);
|
|
202
|
+
// A nonexistent base can never be a working Metro setup (Metro verifies
|
|
203
|
+
// the project root exists), so preparing a snapshot there would only
|
|
204
|
+
// materialize directory trees at arbitrary paths.
|
|
205
|
+
if (!fs.existsSync(base)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const directory = snapshotDirectory(base);
|
|
209
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
210
|
+
// Read the worker files strictly before the main file (see the module doc
|
|
211
|
+
// comment): a concurrent compactor deletes a worker file only after the
|
|
212
|
+
// merged main is renamed into place, so whatever this enumeration misses
|
|
213
|
+
// is already inside the main read below.
|
|
214
|
+
const workers = readWorkerFiles(directory);
|
|
215
|
+
const main = readMainDocument(directory);
|
|
216
|
+
const files = new Set(main?.files ?? []);
|
|
217
|
+
const volatile =
|
|
218
|
+
workers.entries.length === 0
|
|
219
|
+
? (main?.volatile ?? false)
|
|
220
|
+
: // Worker files carry the previous run's fresh observations, so they
|
|
221
|
+
// own the volatile verdict: a removed volatile declaration must be
|
|
222
|
+
// able to clear the sticky flag.
|
|
223
|
+
workers.entries.some((entry) => entry.volatile);
|
|
224
|
+
for (const entry of workers.entries) {
|
|
225
|
+
for (const file of entry.files) {
|
|
226
|
+
files.add(file);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
writeSnapshotDocument(path.join(directory, MAIN_SNAPSHOT), {
|
|
230
|
+
files: [...files].sort(),
|
|
231
|
+
id:
|
|
232
|
+
workers.corruptPaths.length === 0
|
|
233
|
+
? (main?.id ?? randomBytes(16).toString("hex"))
|
|
234
|
+
: randomBytes(16).toString("hex"),
|
|
235
|
+
version: SNAPSHOT_VERSION,
|
|
236
|
+
volatile,
|
|
237
|
+
});
|
|
238
|
+
for (const file of [
|
|
239
|
+
...workers.paths,
|
|
240
|
+
...workers.corruptPaths,
|
|
241
|
+
...listTemporaryFiles(directory),
|
|
242
|
+
]) {
|
|
243
|
+
try {
|
|
244
|
+
fs.rmSync(file, { force: true });
|
|
245
|
+
} catch {
|
|
246
|
+
// A locked worker file stays behind; readers union it, so nothing is
|
|
247
|
+
// lost, and the next compaction retries.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// Snapshot maintenance must never break the Metro config process.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Crash-leftover temp files from the atomic writer, swept at compaction. Only
|
|
257
|
+
* files older than a day qualify: a young temp file may belong to a live writer
|
|
258
|
+
* in a concurrently running Metro instance, and deleting it mid-write would
|
|
259
|
+
* silently drop that writer's recordings.
|
|
260
|
+
*/
|
|
261
|
+
function listTemporaryFiles(directory: string): string[] {
|
|
262
|
+
const horizon = Date.now() - 24 * 60 * 60 * 1000;
|
|
263
|
+
try {
|
|
264
|
+
return fs
|
|
265
|
+
.readdirSync(directory)
|
|
266
|
+
.filter((name) => name.endsWith(".tmp"))
|
|
267
|
+
.map((name) => path.join(directory, name))
|
|
268
|
+
.filter((file) => {
|
|
269
|
+
try {
|
|
270
|
+
return fs.statSync(file).mtimeMs < horizon;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
} catch {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Read the unioned snapshot state, or `undefined` when the main snapshot is
|
|
282
|
+
* missing or any snapshot file is corrupt (a torn or foreign write means the
|
|
283
|
+
* recorded set cannot be trusted, so the caller degrades to a nonce).
|
|
284
|
+
*/
|
|
285
|
+
export function readSnapshotState(base: string): SnapshotState | undefined {
|
|
286
|
+
const directory = snapshotDirectory(base);
|
|
287
|
+
// Worker files strictly before the main file — see the module doc comment.
|
|
288
|
+
const workers = readWorkerFiles(directory);
|
|
289
|
+
if (workers.corruptPaths.length !== 0) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
const main = readMainDocument(directory);
|
|
293
|
+
if (main === undefined || typeof main.id !== "string") {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const files = new Set(main.files);
|
|
297
|
+
let volatile = main.volatile;
|
|
298
|
+
for (const entry of workers.entries) {
|
|
299
|
+
for (const file of entry.files) {
|
|
300
|
+
files.add(file);
|
|
301
|
+
}
|
|
302
|
+
volatile ||= entry.volatile;
|
|
303
|
+
}
|
|
304
|
+
return { files: [...files].sort(), id: main.id, volatile };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Recorder held by each Metro worker. Collects the out-of-walk inputs derived
|
|
309
|
+
* for every transformed file (the plugin-reported dependencies unioned with the
|
|
310
|
+
* reference graph's reach, globals, and configs — delivered through the
|
|
311
|
+
* transform core's `addWatchFile` hook) plus any volatile declaration, and
|
|
312
|
+
* persists them to this worker's uniquely named snapshot file whenever the
|
|
313
|
+
* observed state grows. The unique name makes worker writes race-free;
|
|
314
|
+
* `withTtsc` compacts the files on the next run.
|
|
315
|
+
*/
|
|
316
|
+
export function createSnapshotRecorder(): {
|
|
317
|
+
record: (props: {
|
|
318
|
+
explicitProject?: string;
|
|
319
|
+
input: string;
|
|
320
|
+
projectRoot?: string;
|
|
321
|
+
}) => void;
|
|
322
|
+
recordVolatile: (props: {
|
|
323
|
+
explicitProject?: string;
|
|
324
|
+
projectRoot?: string;
|
|
325
|
+
}) => void;
|
|
326
|
+
} {
|
|
327
|
+
const suffix = `${process.pid.toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
328
|
+
interface BaseState {
|
|
329
|
+
dirty: boolean;
|
|
330
|
+
files: Set<string>;
|
|
331
|
+
roots: string[];
|
|
332
|
+
volatile: boolean;
|
|
333
|
+
}
|
|
334
|
+
const states = new Map<string, BaseState>();
|
|
335
|
+
|
|
336
|
+
function stateFor(
|
|
337
|
+
projectRoot: string | undefined,
|
|
338
|
+
explicitProject: string | undefined,
|
|
339
|
+
): BaseState {
|
|
340
|
+
const base = resolveFingerprintBase(projectRoot);
|
|
341
|
+
let state = states.get(base);
|
|
342
|
+
if (state === undefined) {
|
|
343
|
+
state = {
|
|
344
|
+
dirty: false,
|
|
345
|
+
files: new Set(),
|
|
346
|
+
roots: fingerprintRoots(base, explicitProject),
|
|
347
|
+
volatile: false,
|
|
348
|
+
};
|
|
349
|
+
states.set(base, state);
|
|
350
|
+
}
|
|
351
|
+
return state;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function flush(base: string, state: BaseState): void {
|
|
355
|
+
if (!state.dirty) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const directory = snapshotDirectory(base);
|
|
360
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
361
|
+
writeSnapshotDocument(
|
|
362
|
+
path.join(directory, `${WORKER_SNAPSHOT_PREFIX}${suffix}.json`),
|
|
363
|
+
{
|
|
364
|
+
files: [...state.files].sort(),
|
|
365
|
+
version: SNAPSHOT_VERSION,
|
|
366
|
+
volatile: state.volatile,
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
// Cleared only on success so a transient write failure retries on the
|
|
370
|
+
// next recording instead of silently dropping the observed state.
|
|
371
|
+
state.dirty = false;
|
|
372
|
+
} catch {
|
|
373
|
+
// An unwritable snapshot leaves the main snapshot unreadable or stale;
|
|
374
|
+
// getCacheKey degrades to a nonce rather than serving stale output.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
record(props) {
|
|
380
|
+
const base = resolveFingerprintBase(props.projectRoot);
|
|
381
|
+
const state = stateFor(props.projectRoot, props.explicitProject);
|
|
382
|
+
const input = path.resolve(props.input);
|
|
383
|
+
if (
|
|
384
|
+
state.files.has(input) ||
|
|
385
|
+
state.roots.some((root) => isProjectWalkPath(root, input))
|
|
386
|
+
) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
state.files.add(input);
|
|
390
|
+
state.dirty = true;
|
|
391
|
+
flush(base, state);
|
|
392
|
+
},
|
|
393
|
+
recordVolatile(props) {
|
|
394
|
+
const base = resolveFingerprintBase(props.projectRoot);
|
|
395
|
+
const state = stateFor(props.projectRoot, props.explicitProject);
|
|
396
|
+
if (state.volatile) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
state.volatile = true;
|
|
400
|
+
state.dirty = true;
|
|
401
|
+
flush(base, state);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function snapshotDirectory(base: string): string {
|
|
407
|
+
return path.join(base, ...SNAPSHOT_DIRECTORY);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Read every worker snapshot file in `directory`. A file that disappears
|
|
412
|
+
* mid-read was compacted (merged into the main snapshot first) and is skipped;
|
|
413
|
+
* a file that exists but does not parse is reported in `corruptPaths` so
|
|
414
|
+
* readers can degrade to a nonce and the compactor can sweep it.
|
|
415
|
+
*/
|
|
416
|
+
function readWorkerFiles(directory: string): {
|
|
417
|
+
corruptPaths: string[];
|
|
418
|
+
entries: SnapshotDocument[];
|
|
419
|
+
paths: string[];
|
|
420
|
+
} {
|
|
421
|
+
let names: string[];
|
|
422
|
+
try {
|
|
423
|
+
names = fs.readdirSync(directory);
|
|
424
|
+
} catch {
|
|
425
|
+
return { corruptPaths: [], entries: [], paths: [] };
|
|
426
|
+
}
|
|
427
|
+
const entries: SnapshotDocument[] = [];
|
|
428
|
+
const paths: string[] = [];
|
|
429
|
+
const corruptPaths: string[] = [];
|
|
430
|
+
for (const name of names) {
|
|
431
|
+
if (!name.startsWith(WORKER_SNAPSHOT_PREFIX) || !name.endsWith(".json")) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const file = path.join(directory, name);
|
|
435
|
+
let text: string;
|
|
436
|
+
try {
|
|
437
|
+
text = fs.readFileSync(file, "utf8");
|
|
438
|
+
} catch {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const parsed = parseSnapshotDocument(text);
|
|
442
|
+
if (parsed === undefined) {
|
|
443
|
+
corruptPaths.push(file);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
entries.push(parsed);
|
|
447
|
+
paths.push(file);
|
|
448
|
+
}
|
|
449
|
+
return { corruptPaths, entries, paths };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function readMainDocument(directory: string): SnapshotDocument | undefined {
|
|
453
|
+
let text: string;
|
|
454
|
+
try {
|
|
455
|
+
text = fs.readFileSync(path.join(directory, MAIN_SNAPSHOT), "utf8");
|
|
456
|
+
} catch {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
return parseSnapshotDocument(text);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function parseSnapshotDocument(text: string): SnapshotDocument | undefined {
|
|
463
|
+
let value: unknown;
|
|
464
|
+
try {
|
|
465
|
+
value = JSON.parse(text);
|
|
466
|
+
} catch {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
const document = value as Record<string, unknown>;
|
|
473
|
+
if (document.version !== SNAPSHOT_VERSION || !Array.isArray(document.files)) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
files: document.files.filter(
|
|
478
|
+
(entry): entry is string => typeof entry === "string",
|
|
479
|
+
),
|
|
480
|
+
...(typeof document.id === "string" ? { id: document.id } : {}),
|
|
481
|
+
version: SNAPSHOT_VERSION,
|
|
482
|
+
volatile: document.volatile === true,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Write a snapshot document atomically (unique temp file, then rename). */
|
|
487
|
+
function writeSnapshotDocument(file: string, document: SnapshotDocument): void {
|
|
488
|
+
const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
489
|
+
fs.writeFileSync(temp, JSON.stringify(document), "utf8");
|
|
490
|
+
try {
|
|
491
|
+
fs.renameSync(temp, file);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
fs.rmSync(temp, { force: true });
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* JSON-serialise with object keys sorted recursively, so two semantically equal
|
|
500
|
+
* records always hash to the same fingerprint regardless of property order.
|
|
501
|
+
* Shared with the transformer's option digest.
|
|
502
|
+
*/
|
|
503
|
+
export function stableStringify(value: unknown): string {
|
|
504
|
+
if (Array.isArray(value)) {
|
|
505
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
506
|
+
}
|
|
507
|
+
if (value !== null && typeof value === "object") {
|
|
508
|
+
return `{${Object.entries(value)
|
|
509
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
510
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
511
|
+
.join(",")}}`;
|
|
512
|
+
}
|
|
513
|
+
return JSON.stringify(value);
|
|
514
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
|
|
33
|
+
import { prepareSnapshot } from "./core/fingerprint";
|
|
33
34
|
import type { TtscMetroOptions } from "./core/options";
|
|
34
35
|
import { ENV_KEY, serializeOptions } from "./core/options";
|
|
35
36
|
|
|
@@ -69,6 +70,13 @@ export function withTtsc<T extends MetroConfigLike>(
|
|
|
69
70
|
options: TtscMetroOptions = {},
|
|
70
71
|
): T {
|
|
71
72
|
process.env[ENV_KEY] = serializeOptions(options);
|
|
73
|
+
// Prepare the reference-graph snapshot backing the transformer's cache-key
|
|
74
|
+
// fingerprint (see `core/fingerprint.ts`). This runs in the single Metro
|
|
75
|
+
// config process before any worker exists, so it is the race-free moment to
|
|
76
|
+
// mint the snapshot epoch and compact the previous run's worker files.
|
|
77
|
+
prepareSnapshot(
|
|
78
|
+
typeof config.projectRoot === "string" ? config.projectRoot : undefined,
|
|
79
|
+
);
|
|
72
80
|
return {
|
|
73
81
|
...config,
|
|
74
82
|
transformer: {
|
package/src/transformer.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The ttsc pass reuses `@ttsc/unplugin`'s `transformTtsc`, so the plugin
|
|
12
12
|
* contract, tsconfig discovery, and per-build cache are identical to every
|
|
13
|
-
* other bundler integration.
|
|
14
|
-
*
|
|
13
|
+
* other bundler integration. Cross-file cache invalidation rides the project
|
|
14
|
+
* fingerprint {@link getCacheKey} folds into Metro's static transformer key (see
|
|
15
|
+
* `core/fingerprint.ts`); the package README covers the v1 cost model and the
|
|
16
|
+
* remaining watch-session boundary.
|
|
15
17
|
*/
|
|
16
18
|
import {
|
|
17
19
|
createTtscTransformCache,
|
|
@@ -22,6 +24,11 @@ import { createHash } from "node:crypto";
|
|
|
22
24
|
import { createRequire } from "node:module";
|
|
23
25
|
import path from "node:path";
|
|
24
26
|
|
|
27
|
+
import {
|
|
28
|
+
computeProjectFingerprint,
|
|
29
|
+
createSnapshotRecorder,
|
|
30
|
+
stableStringify,
|
|
31
|
+
} from "./core/fingerprint";
|
|
25
32
|
import type { ResolvedTtscMetroOptions } from "./core/options";
|
|
26
33
|
import { resolveOptionsFromEnv } from "./core/options";
|
|
27
34
|
import { resolveUpstreamTransformer } from "./core/upstream";
|
|
@@ -45,6 +52,7 @@ const DECLARATION = /\.d\.[cm]?ts$/;
|
|
|
45
52
|
let resolved: ResolvedTtscMetroOptions | undefined;
|
|
46
53
|
let unpluginOptions: ReturnType<typeof resolveOptions> | undefined;
|
|
47
54
|
const cache = createTtscTransformCache();
|
|
55
|
+
const snapshotRecorder = createSnapshotRecorder();
|
|
48
56
|
|
|
49
57
|
/** Lazily resolve the worker-side options (from {@link resolveOptionsFromEnv}). */
|
|
50
58
|
function options(): ResolvedTtscMetroOptions {
|
|
@@ -106,12 +114,33 @@ export async function transform(params: {
|
|
|
106
114
|
let transformedSrc = params.src;
|
|
107
115
|
try {
|
|
108
116
|
unpluginOptions ??= resolveOptions(opts.ttsc);
|
|
117
|
+
const projectRoot =
|
|
118
|
+
typeof params.options.projectRoot === "string"
|
|
119
|
+
? params.options.projectRoot
|
|
120
|
+
: undefined;
|
|
121
|
+
const explicitProject =
|
|
122
|
+
typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined;
|
|
109
123
|
const result = await transformTtsc(
|
|
110
124
|
resolveAbsoluteFilename(params.filename, params.options),
|
|
111
125
|
params.src,
|
|
112
126
|
unpluginOptions,
|
|
113
127
|
undefined,
|
|
114
128
|
cache,
|
|
129
|
+
{
|
|
130
|
+
// Metro offers no per-file dependency registration, so the derived
|
|
131
|
+
// watch inputs (plugin-reported dependencies unioned with the
|
|
132
|
+
// reference graph's reach, globals, and configs) feed the snapshot
|
|
133
|
+
// that the next run's getCacheKey re-hashes instead. Fires on cache
|
|
134
|
+
// hits too, so a worker that never recompiled still records the
|
|
135
|
+
// inputs backing the outputs it serves.
|
|
136
|
+
addWatchFile: (input) =>
|
|
137
|
+
snapshotRecorder.record({ explicitProject, input, projectRoot }),
|
|
138
|
+
// A volatile declaration means the output depends on non-file inputs
|
|
139
|
+
// that no file fingerprint can represent; the snapshot marks it and
|
|
140
|
+
// getCacheKey degrades to a per-run nonce (no cross-run reuse).
|
|
141
|
+
markVolatile: () =>
|
|
142
|
+
snapshotRecorder.recordVolatile({ explicitProject, projectRoot }),
|
|
143
|
+
},
|
|
115
144
|
);
|
|
116
145
|
if (result !== undefined && typeof result.code === "string") {
|
|
117
146
|
transformedSrc = result.code;
|
|
@@ -131,15 +160,25 @@ export async function transform(params: {
|
|
|
131
160
|
/**
|
|
132
161
|
* Metro transform-cache key.
|
|
133
162
|
*
|
|
134
|
-
* Metro
|
|
135
|
-
* the
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
163
|
+
* Metro calls this once per run (dev-server start or cold `metro bundle`), on
|
|
164
|
+
* the main process, and folds the result into every file's per-content cache
|
|
165
|
+
* key. It must therefore incorporate every input that can influence a
|
|
166
|
+
* transform's output beyond the file's own content:
|
|
167
|
+
*
|
|
168
|
+
* - The transformer identity: package version + resolved options + the upstream
|
|
169
|
+
* transformer's own key (forwarded Metro's args, e.g. `projectRoot`, so a
|
|
170
|
+
* `babel.config.js` change still busts the cache);
|
|
171
|
+
* - The project fingerprint (see `core/fingerprint.ts`): every input file under
|
|
172
|
+
* the project walk (tsconfig, plugin configs, type-only siblings) plus the
|
|
173
|
+
* recorded out-of-walk reference-graph members from previous transforms
|
|
174
|
+
* (`node_modules` declarations, monorepo sibling sources, out-of-root config
|
|
175
|
+
* ancestry).
|
|
176
|
+
*
|
|
177
|
+
* A change to any fingerprinted input re-keys every transformed file —
|
|
178
|
+
* project-level granularity, forced by Metro's single static key — replacing
|
|
179
|
+
* the former manual `--reset-cache` step. Resolving the upstream is
|
|
180
|
+
* deliberately non-fatal here: a missing peer must not crash cache-key
|
|
181
|
+
* computation. See the README "Caveats" and samchon/ttsc#721.
|
|
143
182
|
*/
|
|
144
183
|
export function getCacheKey(...args: unknown[]): string {
|
|
145
184
|
const opts = options();
|
|
@@ -157,9 +196,34 @@ export function getCacheKey(...args: unknown[]): string {
|
|
|
157
196
|
if (upstreamKey.length !== 0) {
|
|
158
197
|
hash.update(upstreamKey);
|
|
159
198
|
}
|
|
199
|
+
hash.update(
|
|
200
|
+
computeProjectFingerprint({
|
|
201
|
+
explicitProject:
|
|
202
|
+
typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined,
|
|
203
|
+
projectRoot: cacheKeyProjectRoot(args),
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
160
206
|
return hash.digest("hex");
|
|
161
207
|
}
|
|
162
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Extract Metro's `projectRoot` from the cache-key options
|
|
211
|
+
* (`metro-transform-worker` calls `getCacheKey({ projectRoot,
|
|
212
|
+
* enableBabelRCLookup })`). Defensive against foreign callers: anything but a
|
|
213
|
+
* non-empty string yields `undefined` and the fingerprint falls back to the
|
|
214
|
+
* working directory.
|
|
215
|
+
*/
|
|
216
|
+
function cacheKeyProjectRoot(args: unknown[]): string | undefined {
|
|
217
|
+
const first = args[0];
|
|
218
|
+
if (typeof first !== "object" || first === null) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
const projectRoot = (first as Record<string, unknown>).projectRoot;
|
|
222
|
+
return typeof projectRoot === "string" && projectRoot.length !== 0
|
|
223
|
+
? projectRoot
|
|
224
|
+
: undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
163
227
|
/**
|
|
164
228
|
* Fold the upstream transformer's cache key in, defensively. Forwards Metro's
|
|
165
229
|
* own `getCacheKey` arguments so the upstream's babelrc-derived key is
|
|
@@ -230,20 +294,3 @@ function packageVersion(): string {
|
|
|
230
294
|
return "0";
|
|
231
295
|
}
|
|
232
296
|
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* JSON-serialise with object keys sorted recursively, so two semantically equal
|
|
236
|
-
* option sets always hash to the same cache key regardless of property order.
|
|
237
|
-
*/
|
|
238
|
-
function stableStringify(value: unknown): string {
|
|
239
|
-
if (Array.isArray(value)) {
|
|
240
|
-
return `[${value.map(stableStringify).join(",")}]`;
|
|
241
|
-
}
|
|
242
|
-
if (value !== null && typeof value === "object") {
|
|
243
|
-
return `{${Object.entries(value)
|
|
244
|
-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
245
|
-
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
246
|
-
.join(",")}}`;
|
|
247
|
-
}
|
|
248
|
-
return JSON.stringify(value);
|
|
249
|
-
}
|