@ttsc/metro 0.28.3 → 0.28.4
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 +21 -1
- package/lib/core/fingerprint.d.ts +101 -4
- package/lib/core/fingerprint.js +124 -13
- package/lib/core/fingerprint.js.map +1 -1
- package/lib/core/fingerprint.mjs +125 -15
- package/lib/core/fingerprint.mjs.map +1 -1
- package/lib/index.d.ts +8 -0
- package/lib/index.js +131 -1
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +132 -2
- package/lib/index.mjs.map +1 -1
- package/lib/transformer.js +25 -20
- package/lib/transformer.js.map +1 -1
- package/lib/transformer.mjs +26 -21
- package/lib/transformer.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/fingerprint.ts +207 -21
- package/src/index.ts +141 -2
- package/src/transformer.ts +26 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttsc/metro",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.4",
|
|
4
4
|
"description": "Metro (React Native / Expo) adapter for ttsc plugins.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "lib/index.mjs",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"src"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ttsc/unplugin": "^0.28.
|
|
38
|
+
"@ttsc/unplugin": "^0.28.4"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@expo/metro-config": "*",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"tinyglobby": "^0.2.16",
|
|
63
63
|
"tslib": "^2.8.1",
|
|
64
64
|
"typescript": "^7.0.2",
|
|
65
|
-
"ttsc": "0.28.
|
|
65
|
+
"ttsc": "0.28.4"
|
|
66
66
|
},
|
|
67
67
|
"repository": {
|
|
68
68
|
"type": "git",
|
package/src/core/fingerprint.ts
CHANGED
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
collectExternalInputHashes,
|
|
49
49
|
collectProjectInputHashes,
|
|
50
50
|
isProjectWalkPath,
|
|
51
|
+
mergeMembershipPolicyOverlay,
|
|
52
|
+
readProjectMembershipPolicy,
|
|
51
53
|
} from "@ttsc/unplugin/api";
|
|
52
54
|
import { createHash, randomBytes } from "node:crypto";
|
|
53
55
|
import fs from "node:fs";
|
|
@@ -131,10 +133,152 @@ export function fingerprintRoots(
|
|
|
131
133
|
explicitProject: string | undefined,
|
|
132
134
|
): string[] {
|
|
133
135
|
const tsconfig = resolveProjectTsconfig(base, explicitProject);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
// Containment, not walk membership. The question here is whether the
|
|
137
|
+
// tsconfig's directory already sits inside the subtree the base walk covers,
|
|
138
|
+
// so that adding it would repeat the same walk. `isProjectWalkPath` answers a
|
|
139
|
+
// different question, whether the walk *hashes* that path, and once the walk
|
|
140
|
+
// stopped hashing files that cannot enter the program it began answering
|
|
141
|
+
// `false` for every `tsconfig.json`, which returned the base twice and hashed
|
|
142
|
+
// the whole project twice on every cache key (samchon/ttsc#1307).
|
|
143
|
+
const directory = path.dirname(path.resolve(tsconfig));
|
|
144
|
+
const relative = path.relative(path.resolve(base), directory);
|
|
145
|
+
const inside =
|
|
146
|
+
relative === "" ||
|
|
147
|
+
(relative !== ".." &&
|
|
148
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
149
|
+
!path.isAbsolute(relative));
|
|
150
|
+
return inside ? [base] : [base, directory];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One project, and the membership policy that describes it.
|
|
155
|
+
*
|
|
156
|
+
* The recorder's question is whether the project walk already covers an input,
|
|
157
|
+
* so it needs both the walk's roots and the policy that walk used, and it is
|
|
158
|
+
* wrong exactly when those two describe different projects. Passing them
|
|
159
|
+
* separately made that mismatch expressible — the policy for one project
|
|
160
|
+
* alongside the root of another — and passing the policy alone made it
|
|
161
|
+
* expressible in a quieter way still, since a recorder that resolved its own
|
|
162
|
+
* could describe a different program than the walk hashed. Both halves travel
|
|
163
|
+
* together so neither can be supplied without the other (samchon/ttsc#1316).
|
|
164
|
+
*/
|
|
165
|
+
export interface TtscMetroProjectView {
|
|
166
|
+
/** The base directory both fingerprint sides agree on. */
|
|
167
|
+
readonly base: string;
|
|
168
|
+
/** The caller's explicit `project`, if any. */
|
|
169
|
+
readonly explicitProject: string | undefined;
|
|
170
|
+
/** The membership policy resolved for that project. */
|
|
171
|
+
readonly policy: ReturnType<typeof readProjectMembershipPolicy>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve one transform's project view, once, for every watch input it reports.
|
|
176
|
+
*
|
|
177
|
+
* Exported because the recorder is asked once per input while the answer is a
|
|
178
|
+
* property of the project, and validating the memo means stat-ing the whole
|
|
179
|
+
* `extends` chain (samchon/ttsc#1316).
|
|
180
|
+
*/
|
|
181
|
+
export function resolveProjectView(props: {
|
|
182
|
+
compilerOptions?: Record<string, unknown>;
|
|
183
|
+
explicitProject?: string;
|
|
184
|
+
projectRoot?: string;
|
|
185
|
+
}): TtscMetroProjectView {
|
|
186
|
+
const base = resolveFingerprintBase(props.projectRoot);
|
|
187
|
+
return {
|
|
188
|
+
base,
|
|
189
|
+
explicitProject: props.explicitProject,
|
|
190
|
+
policy: membershipPolicy(
|
|
191
|
+
resolveProjectTsconfig(base, props.explicitProject),
|
|
192
|
+
props.compilerOptions,
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The membership policy of one project, memoized per resolved tsconfig and
|
|
199
|
+
* caller overlay.
|
|
200
|
+
*
|
|
201
|
+
* Every use of the walk pair has to ask the same policy, or the two halves
|
|
202
|
+
* disagree about the same project. The walk hashes what the configuration can
|
|
203
|
+
* admit, and `isProjectWalkPath` answers whether the walk covers a path, so a
|
|
204
|
+
* permissive answer here would claim coverage the walk does not provide and the
|
|
205
|
+
* input would be recorded nowhere at all (samchon/ttsc#1307).
|
|
206
|
+
*/
|
|
207
|
+
const MEMBERSHIP_POLICIES = new Map<
|
|
208
|
+
string,
|
|
209
|
+
{
|
|
210
|
+
policy: ReturnType<typeof readProjectMembershipPolicy>;
|
|
211
|
+
sources: readonly string[];
|
|
212
|
+
stamp: string;
|
|
136
213
|
}
|
|
137
|
-
|
|
214
|
+
>();
|
|
215
|
+
|
|
216
|
+
function membershipPolicy(
|
|
217
|
+
tsconfig: string,
|
|
218
|
+
compilerOptions?: Record<string, unknown>,
|
|
219
|
+
): ReturnType<typeof readProjectMembershipPolicy> {
|
|
220
|
+
// Keyed by the config's path and the caller's overlay, and never trusted on
|
|
221
|
+
// the key alone: a hit is served only while the config's own stamp still
|
|
222
|
+
// matches. A Metro worker outlives many runs, and this is consulted once per
|
|
223
|
+
// delivered file, so a memo that trusted its key would hold the policy a
|
|
224
|
+
// project had when the worker started. An edit adding `exclude` would then
|
|
225
|
+
// leave the worker judging a file in-walk while the next run's walk skipped
|
|
226
|
+
// it, which is precisely the both-sides-disagree hole this policy exists to
|
|
227
|
+
// close.
|
|
228
|
+
//
|
|
229
|
+
// The overlay belongs in the key rather than the stamp because it is part of
|
|
230
|
+
// the question, not part of any file: keyed by path alone the memo would hand
|
|
231
|
+
// a caller who passed `allowJs` the policy resolved for a caller who did not.
|
|
232
|
+
const key = [tsconfig, stableStringify(compilerOptions ?? {})].join(
|
|
233
|
+
String.fromCharCode(0),
|
|
234
|
+
);
|
|
235
|
+
const existing = MEMBERSHIP_POLICIES.get(key);
|
|
236
|
+
if (existing !== undefined && existing.stamp === stampOf(existing.sources)) {
|
|
237
|
+
return existing.policy;
|
|
238
|
+
}
|
|
239
|
+
// The caller's compiler-options overlay wins for the compile, so it has to
|
|
240
|
+
// win here too, exactly as it does in the adapter: a project given
|
|
241
|
+
// `allowJs: true` through `withTtsc` has a wider program than its tsconfig
|
|
242
|
+
// alone describes, and a narrower policy here would ask a different question
|
|
243
|
+
// about the same project (samchon/ttsc#1316).
|
|
244
|
+
const policy = mergeMembershipPolicyOverlay(
|
|
245
|
+
readProjectMembershipPolicy(tsconfig),
|
|
246
|
+
compilerOptions ?? {},
|
|
247
|
+
path.dirname(path.resolve(tsconfig)),
|
|
248
|
+
);
|
|
249
|
+
// Stamp the whole `extends` chain, not the leaf. Adding `exclude` to a shared
|
|
250
|
+
// `tsconfig.base.json` leaves the leaf's own mtime and size untouched while
|
|
251
|
+
// changing every answer the policy gives, so a leaf-only stamp would keep the
|
|
252
|
+
// worker on the pre-edit policy for its lifetime.
|
|
253
|
+
const sources =
|
|
254
|
+
policy.sources.length === 0 ? [tsconfig] : [...policy.sources];
|
|
255
|
+
MEMBERSHIP_POLICIES.set(key, {
|
|
256
|
+
policy,
|
|
257
|
+
sources,
|
|
258
|
+
stamp: stampOf(sources),
|
|
259
|
+
});
|
|
260
|
+
return policy;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A stamp over every config a policy was read from, in a stable order. */
|
|
264
|
+
function stampOf(sources: readonly string[]): string {
|
|
265
|
+
return sources
|
|
266
|
+
.map((source) => {
|
|
267
|
+
try {
|
|
268
|
+
const stats = fs.statSync(source);
|
|
269
|
+
// A directory occupying a candidate path can never be the config, so it
|
|
270
|
+
// contributes its existence and not its modification time, which moves
|
|
271
|
+
// whenever any child is added or removed (samchon/ttsc#1316).
|
|
272
|
+
return stats.isDirectory()
|
|
273
|
+
? `${source}:directory`
|
|
274
|
+
: `${source}:${stats.mtimeMs}:${stats.size}`;
|
|
275
|
+
} catch {
|
|
276
|
+
// Absent now. `readProjectMembershipPolicy` answers for that too, and
|
|
277
|
+
// the policy must be re-asked once the config appears.
|
|
278
|
+
return `${source}:absent`;
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
.join("|");
|
|
138
282
|
}
|
|
139
283
|
|
|
140
284
|
/**
|
|
@@ -173,14 +317,36 @@ function resolveProjectTsconfig(
|
|
|
173
317
|
* cross-run cache reuse for this run instead of serving stale output.
|
|
174
318
|
*/
|
|
175
319
|
export function computeProjectFingerprint(props: {
|
|
320
|
+
compilerOptions?: Record<string, unknown>;
|
|
176
321
|
explicitProject?: string;
|
|
177
322
|
projectRoot?: string;
|
|
178
323
|
}): string {
|
|
179
324
|
try {
|
|
180
325
|
const base = resolveFingerprintBase(props.projectRoot);
|
|
181
326
|
const hash = createHash("sha256");
|
|
327
|
+
// Judge the fingerprint's walk by the same configuration the compile
|
|
328
|
+
// does. Metro folds this into one static key, so an entry the program
|
|
329
|
+
// could never contain used to re-key every transformed file rather than
|
|
330
|
+
// costing one compile the way it does for a bundler (samchon/ttsc#1307).
|
|
331
|
+
//
|
|
332
|
+
// The caller's compiler-options overlay is part of that configuration, and
|
|
333
|
+
// has to reach the walk as well as the recorder. The two are the halves of
|
|
334
|
+
// one cache key and run in different processes, so they agree only by
|
|
335
|
+
// deriving from the same declared options: a walk resolved without the
|
|
336
|
+
// overlay while the recorder resolves with it leaves an overlay-admitted
|
|
337
|
+
// input in neither half, which is the both-sides-disagree hole in its
|
|
338
|
+
// quietest form (samchon/ttsc#1316).
|
|
339
|
+
const project = resolveProjectView({
|
|
340
|
+
compilerOptions: props.compilerOptions,
|
|
341
|
+
explicitProject: props.explicitProject,
|
|
342
|
+
projectRoot: props.projectRoot,
|
|
343
|
+
});
|
|
182
344
|
for (const root of fingerprintRoots(base, props.explicitProject)) {
|
|
183
|
-
hash.update(
|
|
345
|
+
hash.update(
|
|
346
|
+
stableStringify(
|
|
347
|
+
collectProjectInputHashes(root, undefined, undefined, project.policy),
|
|
348
|
+
),
|
|
349
|
+
);
|
|
184
350
|
}
|
|
185
351
|
const snapshot = readSnapshotState(base);
|
|
186
352
|
if (snapshot === undefined || snapshot.volatile) {
|
|
@@ -386,14 +552,29 @@ export function readSnapshotState(base: string): SnapshotState | undefined {
|
|
|
386
552
|
*/
|
|
387
553
|
export function createSnapshotRecorder(): {
|
|
388
554
|
record: (props: {
|
|
389
|
-
explicitProject?: string;
|
|
390
555
|
input: string;
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
556
|
+
/**
|
|
557
|
+
* The project this input belongs to, with the policy its walk uses,
|
|
558
|
+
* resolved once per transform through {@link resolveProjectView}.
|
|
559
|
+
*
|
|
560
|
+
* One value rather than a root and a policy side by side, because the
|
|
561
|
+
* recorder is wrong precisely when those two describe different projects.
|
|
562
|
+
* Deriving the policy here instead would be the same fault in a quieter
|
|
563
|
+
* form: a recorder that resolved its own could describe a different program
|
|
564
|
+
* than the walk hashed, which is what happened when the caller's
|
|
565
|
+
* compiler-options overlay reached one half and not the other, and the
|
|
566
|
+
* input was then covered by neither (samchon/ttsc#1316).
|
|
567
|
+
*
|
|
568
|
+
* Resolving it once per transform is also what makes it affordable.
|
|
569
|
+
* `record` runs once per watch input rather than once per file, and
|
|
570
|
+
* validating the memo means stat-ing the whole `extends` chain, measured at
|
|
571
|
+
* 12 microseconds per stat — a few thousand modules times fifteen inputs
|
|
572
|
+
* each cost over half a second per run for an answer that cannot change
|
|
573
|
+
* between two inputs of one file.
|
|
574
|
+
*/
|
|
575
|
+
project: TtscMetroProjectView;
|
|
396
576
|
}) => void;
|
|
577
|
+
recordVolatile: (props: { project: TtscMetroProjectView }) => void;
|
|
397
578
|
} {
|
|
398
579
|
const suffix = `${process.pid.toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
399
580
|
interface BaseState {
|
|
@@ -405,18 +586,15 @@ export function createSnapshotRecorder(): {
|
|
|
405
586
|
}
|
|
406
587
|
const states = new Map<string, BaseState>();
|
|
407
588
|
|
|
408
|
-
function stateFor(
|
|
409
|
-
|
|
410
|
-
explicitProject: string | undefined,
|
|
411
|
-
): BaseState {
|
|
412
|
-
const base = resolveFingerprintBase(projectRoot);
|
|
589
|
+
function stateFor(project: TtscMetroProjectView): BaseState {
|
|
590
|
+
const base = project.base;
|
|
413
591
|
let state = states.get(base);
|
|
414
592
|
if (state === undefined) {
|
|
415
593
|
state = {
|
|
416
594
|
dirty: false,
|
|
417
595
|
files: new Set(),
|
|
418
596
|
observed: false,
|
|
419
|
-
roots: fingerprintRoots(base, explicitProject),
|
|
597
|
+
roots: fingerprintRoots(base, project.explicitProject),
|
|
420
598
|
volatile: false,
|
|
421
599
|
};
|
|
422
600
|
states.set(base, state);
|
|
@@ -459,15 +637,23 @@ export function createSnapshotRecorder(): {
|
|
|
459
637
|
|
|
460
638
|
return {
|
|
461
639
|
record(props) {
|
|
462
|
-
const base =
|
|
463
|
-
const state = stateFor(props.
|
|
640
|
+
const base = props.project.base;
|
|
641
|
+
const state = stateFor(props.project);
|
|
464
642
|
const input = path.resolve(props.input);
|
|
465
643
|
const firstObservation = !state.observed;
|
|
466
644
|
state.observed = true;
|
|
467
645
|
if (
|
|
468
646
|
state.files.has(input) ||
|
|
469
647
|
(fs.existsSync(input) &&
|
|
470
|
-
state.roots.some((root) =>
|
|
648
|
+
state.roots.some((root) =>
|
|
649
|
+
isProjectWalkPath(
|
|
650
|
+
root,
|
|
651
|
+
input,
|
|
652
|
+
undefined,
|
|
653
|
+
undefined,
|
|
654
|
+
props.project.policy,
|
|
655
|
+
),
|
|
656
|
+
))
|
|
471
657
|
) {
|
|
472
658
|
// Even when every input belongs to the project walk, the worker must
|
|
473
659
|
// publish that it performed a clean transform. Otherwise an old main
|
|
@@ -483,8 +669,8 @@ export function createSnapshotRecorder(): {
|
|
|
483
669
|
flush(base, state);
|
|
484
670
|
},
|
|
485
671
|
recordVolatile(props) {
|
|
486
|
-
const base =
|
|
487
|
-
const state = stateFor(props.
|
|
672
|
+
const base = props.project.base;
|
|
673
|
+
const state = stateFor(props.project);
|
|
488
674
|
if (state.volatile) {
|
|
489
675
|
flush(base, state);
|
|
490
676
|
return;
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
* module.exports = withTtsc(getDefaultConfig(__dirname));
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
|
-
import {
|
|
30
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
31
|
+
import { createRequire } from "node:module";
|
|
32
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
31
33
|
import { fileURLToPath } from "node:url";
|
|
32
34
|
|
|
33
35
|
import { prepareSnapshot } from "./core/fingerprint";
|
|
@@ -64,12 +66,22 @@ interface MetroConfigLike {
|
|
|
64
66
|
* With no `options`, the transformer auto-discovers `tsconfig.json` and runs
|
|
65
67
|
* the plugins configured there: the standard ttsc model. Pass `options` only to
|
|
66
68
|
* override the project path, plugin list, or include/exclude filters.
|
|
69
|
+
*
|
|
70
|
+
* A `babelTransformerPath` the config already carried is chained rather than
|
|
71
|
+
* replaced: it becomes the upstream this transformer delegates to, so wrapping
|
|
72
|
+
* a working config keeps whatever it configured. `react-native-svg-transformer`
|
|
73
|
+
* is installed by exactly that assignment, and replacing it silently sent every
|
|
74
|
+
* `.svg` to the auto-detected Expo default instead, with the build still
|
|
75
|
+
* succeeding (samchon/ttsc#1321). An explicit `upstreamTransformer` option
|
|
76
|
+
* still wins, since that is the caller saying it outright.
|
|
67
77
|
*/
|
|
68
78
|
export function withTtsc<T extends MetroConfigLike>(
|
|
69
79
|
config: T,
|
|
70
80
|
options: TtscMetroOptions = {},
|
|
71
81
|
): T {
|
|
72
|
-
process.env[ENV_KEY] = serializeOptions(
|
|
82
|
+
process.env[ENV_KEY] = serializeOptions(
|
|
83
|
+
inheritConfiguredTransformer(config, options),
|
|
84
|
+
);
|
|
73
85
|
// Prepare the reference-graph snapshot backing the transformer's cache-key
|
|
74
86
|
// fingerprint (see `core/fingerprint.ts`). This runs in the single Metro
|
|
75
87
|
// config process before any worker exists, so it is the race-free moment to
|
|
@@ -86,6 +98,133 @@ export function withTtsc<T extends MetroConfigLike>(
|
|
|
86
98
|
} as T;
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Adopt the config's own `babelTransformerPath` as the upstream to delegate to.
|
|
103
|
+
*
|
|
104
|
+
* The value `withTtsc` overwrites is precisely the transformer that should run
|
|
105
|
+
* after the ttsc pass, so taking it as the default `upstreamTransformer` is
|
|
106
|
+
* what makes the wrapper additive in the one field it sets. Everything else in
|
|
107
|
+
* the config was already spread through untouched, which is what made the loss
|
|
108
|
+
* hard to see (samchon/ttsc#1321).
|
|
109
|
+
*
|
|
110
|
+
* An explicit option wins, and this package's own transformer is never adopted:
|
|
111
|
+
* a config wrapped twice would otherwise name this module as its own upstream
|
|
112
|
+
* and delegate into itself.
|
|
113
|
+
*/
|
|
114
|
+
function inheritConfiguredTransformer(
|
|
115
|
+
config: MetroConfigLike,
|
|
116
|
+
options: TtscMetroOptions,
|
|
117
|
+
): TtscMetroOptions {
|
|
118
|
+
const declared = config.transformer?.babelTransformerPath;
|
|
119
|
+
if (
|
|
120
|
+
options.upstreamTransformer !== undefined ||
|
|
121
|
+
typeof declared !== "string" ||
|
|
122
|
+
declared.length === 0
|
|
123
|
+
) {
|
|
124
|
+
return options;
|
|
125
|
+
}
|
|
126
|
+
// Resolve before judging. Ownership is a property of the module, not of the
|
|
127
|
+
// string, and every spelling has to become one absolute path before either
|
|
128
|
+
// question can be answered honestly.
|
|
129
|
+
const resolved = resolveFromProject(declared, config);
|
|
130
|
+
if (isOwnTransformer(resolved)) {
|
|
131
|
+
return options;
|
|
132
|
+
}
|
|
133
|
+
return { ...options, upstreamTransformer: resolved };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a declared `babelTransformerPath` the way Metro would: from the
|
|
138
|
+
* project.
|
|
139
|
+
*
|
|
140
|
+
* Metro resolves this value against the project, while the worker resolves
|
|
141
|
+
* `upstreamTransformer` with a `require` rooted in this package's own
|
|
142
|
+
* `lib/core`. Those are different places, so passing the caller's spelling
|
|
143
|
+
* through unchanged asks the worker to find the module somewhere it was never
|
|
144
|
+
* meant to be. Resolving here, once, in the config process that still knows the
|
|
145
|
+
* project root, removes the ambiguity for every spelling at once:
|
|
146
|
+
*
|
|
147
|
+
* - A relative `./metro-svg.cjs` becomes the file the caller meant, instead of
|
|
148
|
+
* one looked for inside `@ttsc/metro` and not found;
|
|
149
|
+
* - A bare `react-native-svg-transformer` becomes its real location, which
|
|
150
|
+
* matters under pnpm, where this package sits in a virtual store and walking
|
|
151
|
+
* up from it never reaches the project's own `node_modules`;
|
|
152
|
+
* - An absolute path resolves to itself, unchanged;
|
|
153
|
+
* - And `require.resolve("@ttsc/metro/transformer")` — a caller who wired this
|
|
154
|
+
* package by hand — becomes a path {@link isOwnTransformer} can recognise,
|
|
155
|
+
* which no comparison against the bare specifier could.
|
|
156
|
+
*
|
|
157
|
+
* A specifier that cannot be resolved is handed on exactly as written. It may
|
|
158
|
+
* still resolve in the worker, and if it does not, `resolveUpstreamTransformer`
|
|
159
|
+
* names it in an error; inventing a path here would only move the failure
|
|
160
|
+
* somewhere less legible.
|
|
161
|
+
*/
|
|
162
|
+
function resolveFromProject(declared: string, config: MetroConfigLike): string {
|
|
163
|
+
const base =
|
|
164
|
+
typeof config.projectRoot === "string" && config.projectRoot.length !== 0
|
|
165
|
+
? config.projectRoot
|
|
166
|
+
: process.cwd();
|
|
167
|
+
try {
|
|
168
|
+
return createRequire(join(resolve(base), "package.json")).resolve(declared);
|
|
169
|
+
} catch {
|
|
170
|
+
return declared;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether a `babelTransformerPath` already points at a `@ttsc/metro`
|
|
176
|
+
* transformer — this copy or any other.
|
|
177
|
+
*
|
|
178
|
+
* Adopting one would make this transformer its own upstream. That is not merely
|
|
179
|
+
* redundant: the worker options are process-global, so the adopted copy reads
|
|
180
|
+
* the same `TTSC_METRO_OPTIONS`, finds itself named there, and recurses until
|
|
181
|
+
* the stack ends. Comparing directory strings was not enough, because a second
|
|
182
|
+
* installed copy — a differently hoisted `node_modules`, a shared config
|
|
183
|
+
* package that already wrapped — lives in a different directory and passed the
|
|
184
|
+
* check.
|
|
185
|
+
*
|
|
186
|
+
* So the question asked is "is this module a `@ttsc/metro` transformer", not
|
|
187
|
+
* "is this string our path": the real path settles the same-copy case through
|
|
188
|
+
* symlinks and drive-letter spellings, and the owning `package.json` settles
|
|
189
|
+
* every other copy.
|
|
190
|
+
*/
|
|
191
|
+
function isOwnTransformer(declared: string): boolean {
|
|
192
|
+
const candidate = resolve(declared);
|
|
193
|
+
if (sameRealPath(candidate, transformerModulePath())) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (!/^transformer\.(?:js|mjs|cjs)$/i.test(basename(candidate))) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const manifest = join(dirname(dirname(candidate)), "package.json");
|
|
201
|
+
return JSON.parse(readFileSync(manifest, "utf8")).name === "@ttsc/metro";
|
|
202
|
+
} catch {
|
|
203
|
+
// No readable manifest beside it, so nothing identifies it as ours.
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether two paths name the same file on disk.
|
|
210
|
+
*
|
|
211
|
+
* Resolved through `realpath` so a symlinked install and its target compare
|
|
212
|
+
* equal, and case-folded on Windows, where `D:\` and `d:\` and a differently
|
|
213
|
+
* cased base name all address one file.
|
|
214
|
+
*/
|
|
215
|
+
function sameRealPath(left: string, right: string): boolean {
|
|
216
|
+
const identity = (file: string): string => {
|
|
217
|
+
let resolved = resolve(file);
|
|
218
|
+
try {
|
|
219
|
+
resolved = realpathSync.native(resolved);
|
|
220
|
+
} catch {
|
|
221
|
+
// Not on disk: the resolved spelling is the best identity available.
|
|
222
|
+
}
|
|
223
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
224
|
+
};
|
|
225
|
+
return identity(left) === identity(right);
|
|
226
|
+
}
|
|
227
|
+
|
|
89
228
|
/**
|
|
90
229
|
* Absolute path to the built transformer module Metro will `require`.
|
|
91
230
|
*
|
package/src/transformer.ts
CHANGED
|
@@ -27,6 +27,7 @@ import path from "node:path";
|
|
|
27
27
|
import {
|
|
28
28
|
computeProjectFingerprint,
|
|
29
29
|
createSnapshotRecorder,
|
|
30
|
+
resolveProjectView,
|
|
30
31
|
stableStringify,
|
|
31
32
|
} from "./core/fingerprint";
|
|
32
33
|
import type { ResolvedTtscMetroOptions } from "./core/options";
|
|
@@ -112,7 +113,7 @@ export async function transform(params: {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
let transformedSrc = params.src;
|
|
115
|
-
|
|
116
|
+
{
|
|
116
117
|
unpluginOptions ??= resolveOptions(opts.ttsc);
|
|
117
118
|
const projectRoot =
|
|
118
119
|
typeof params.options.projectRoot === "string"
|
|
@@ -120,6 +121,11 @@ export async function transform(params: {
|
|
|
120
121
|
: undefined;
|
|
121
122
|
const explicitProject =
|
|
122
123
|
typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined;
|
|
124
|
+
const project = resolveProjectView({
|
|
125
|
+
compilerOptions: opts.ttsc.compilerOptions,
|
|
126
|
+
explicitProject,
|
|
127
|
+
projectRoot,
|
|
128
|
+
});
|
|
123
129
|
const result = await transformTtsc(
|
|
124
130
|
resolveAbsoluteFilename(params.filename, params.options),
|
|
125
131
|
params.src,
|
|
@@ -133,25 +139,29 @@ export async function transform(params: {
|
|
|
133
139
|
// that the next run's getCacheKey re-hashes instead. Fires on cache
|
|
134
140
|
// hits too, so a worker that never recompiled still records the
|
|
135
141
|
// inputs backing the outputs it serves.
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
// The project view is resolved once for this file and handed to every
|
|
143
|
+
// one of its watch inputs. `record` runs per input, and validating the
|
|
144
|
+
// memo means stat-ing the whole `extends` chain, which is an answer
|
|
145
|
+
// that cannot change between two inputs of one file
|
|
146
|
+
// (samchon/ttsc#1316).
|
|
147
|
+
addWatchFile: (input) => snapshotRecorder.record({ input, project }),
|
|
138
148
|
// A volatile declaration means the output depends on non-file inputs
|
|
139
149
|
// that no file fingerprint can represent; the snapshot marks it and
|
|
140
150
|
// getCacheKey degrades to a per-run nonce (no cross-run reuse).
|
|
141
|
-
markVolatile: () =>
|
|
142
|
-
snapshotRecorder.recordVolatile({ explicitProject, projectRoot }),
|
|
151
|
+
markVolatile: () => snapshotRecorder.recordVolatile({ project }),
|
|
143
152
|
},
|
|
144
153
|
);
|
|
154
|
+
// A file the program does not contain comes back as `undefined` from the
|
|
155
|
+
// shared transform, exactly as an unchanged one does, so it passes through
|
|
156
|
+
// here with no special case. That decision belongs to
|
|
157
|
+
// `@ttsc/unplugin`'s core and is shared with every bundler adapter; this
|
|
158
|
+
// transformer used to hold its own copy of it, recognising the case by
|
|
159
|
+
// searching the error text for "did not return output" while the adapters
|
|
160
|
+
// failed the build for the identical condition (samchon/ttsc#1308).
|
|
161
|
+
// Genuine compile and type failures still propagate so Metro surfaces them.
|
|
145
162
|
if (result !== undefined && typeof result.code === "string") {
|
|
146
163
|
transformedSrc = result.code;
|
|
147
164
|
}
|
|
148
|
-
} catch (error) {
|
|
149
|
-
// A file that is not part of the tsconfig program is not a build error,
|
|
150
|
-
// pass it through untransformed. Genuine compile/type failures propagate so
|
|
151
|
-
// Metro surfaces them, matching the other ttsc bundler integrations.
|
|
152
|
-
if (!isFileOutsideProject(error)) {
|
|
153
|
-
throw error;
|
|
154
|
-
}
|
|
155
165
|
}
|
|
156
166
|
|
|
157
167
|
return upstream.transform({ ...params, src: transformedSrc });
|
|
@@ -198,6 +208,10 @@ export function getCacheKey(...args: unknown[]): string {
|
|
|
198
208
|
}
|
|
199
209
|
hash.update(
|
|
200
210
|
computeProjectFingerprint({
|
|
211
|
+
// The same overlay `transform` hands the recorder. Both read these
|
|
212
|
+
// options from `options()`, so the walk and the recorder judge one
|
|
213
|
+
// project by one program (samchon/ttsc#1316).
|
|
214
|
+
compilerOptions: opts.ttsc.compilerOptions,
|
|
201
215
|
explicitProject:
|
|
202
216
|
typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined,
|
|
203
217
|
projectRoot: cacheKeyProjectRoot(args),
|
|
@@ -276,16 +290,6 @@ export function shouldTransform(
|
|
|
276
290
|
return true;
|
|
277
291
|
}
|
|
278
292
|
|
|
279
|
-
/**
|
|
280
|
-
* `transformTtsc` throws `"ttsc transform did not return output for <file>"`
|
|
281
|
-
* when the requested file is not part of the compiled program (e.g. excluded
|
|
282
|
-
* from the tsconfig). That case is non-fatal: the file should pass through.
|
|
283
|
-
*/
|
|
284
|
-
function isFileOutsideProject(error: unknown): boolean {
|
|
285
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
286
|
-
return message.includes("did not return output");
|
|
287
|
-
}
|
|
288
|
-
|
|
289
293
|
function packageVersion(): string {
|
|
290
294
|
try {
|
|
291
295
|
const pkg = nodeRequire("@ttsc/metro/package.json") as { version?: string };
|