@ttsc/metro 0.16.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/LICENSE +21 -0
- package/README.md +91 -0
- package/lib/core/options.d.ts +77 -0
- package/lib/core/options.js +76 -0
- package/lib/core/options.js.map +1 -0
- package/lib/core/options.mjs +72 -0
- package/lib/core/options.mjs.map +1 -0
- package/lib/core/upstream.d.ts +44 -0
- package/lib/core/upstream.js +64 -0
- package/lib/core/upstream.js.map +1 -0
- package/lib/core/upstream.mjs +60 -0
- package/lib/core/upstream.mjs.map +1 -0
- package/lib/index.d.ts +28 -0
- package/lib/index.js +73 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +70 -0
- package/lib/index.mjs.map +1 -0
- package/lib/transformer.d.ts +52 -0
- package/lib/transformer.js +217 -0
- package/lib/transformer.js.map +1 -0
- package/lib/transformer.mjs +211 -0
- package/lib/transformer.mjs.map +1 -0
- package/package.json +83 -0
- package/src/core/options.ts +130 -0
- package/src/core/upstream.ts +87 -0
- package/src/index.ts +92 -0
- package/src/transformer.ts +249 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { TtscUnpluginOptions } from "@ttsc/unplugin/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options accepted by {@link withTtsc} and the Metro transformer.
|
|
5
|
+
*
|
|
6
|
+
* The `project` / `compilerOptions` / `plugins` fields are inherited from
|
|
7
|
+
* `@ttsc/unplugin` so the Metro adapter speaks the exact same configuration
|
|
8
|
+
* language as every other bundler integration. The remaining fields are
|
|
9
|
+
* Metro-specific.
|
|
10
|
+
*
|
|
11
|
+
* Every field is JSON-serialisable on purpose: `withTtsc` runs in the Metro
|
|
12
|
+
* **config** process, but the transformer runs in Metro's **worker** processes,
|
|
13
|
+
* so the resolved options have to survive a structured-clone / env round-trip
|
|
14
|
+
* to reach them (see {@link serializeOptions}). That is why `include`/`exclude`
|
|
15
|
+
* are plain substring patterns rather than `RegExp`.
|
|
16
|
+
*/
|
|
17
|
+
export interface TtscMetroOptions extends TtscUnpluginOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Explicit module path of the upstream Metro Babel transformer to delegate to
|
|
20
|
+
* after the ttsc pass.
|
|
21
|
+
*
|
|
22
|
+
* When omitted it is auto-detected: `@expo/metro-config/babel-transformer`
|
|
23
|
+
* first (Expo), then `@react-native/metro-babel-transformer`, then the legacy
|
|
24
|
+
* `metro-react-native-babel-transformer`.
|
|
25
|
+
*/
|
|
26
|
+
upstreamTransformer?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Substring patterns; when non-empty only files whose path contains one of
|
|
30
|
+
* them are run through the ttsc pass. Non-matching files are passed straight
|
|
31
|
+
* to the upstream transformer.
|
|
32
|
+
*/
|
|
33
|
+
include?: string[];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Substring patterns; files whose path contains one of them skip the ttsc
|
|
37
|
+
* pass and go straight to the upstream transformer. Applied after
|
|
38
|
+
* {@link include}.
|
|
39
|
+
*/
|
|
40
|
+
exclude?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fully-resolved options, split into the ttsc-side overlay and Metro-side
|
|
45
|
+
* knobs.
|
|
46
|
+
*/
|
|
47
|
+
export interface ResolvedTtscMetroOptions {
|
|
48
|
+
/** Options forwarded verbatim to the `@ttsc/unplugin` transform core. */
|
|
49
|
+
ttsc: TtscUnpluginOptions;
|
|
50
|
+
/** Explicit upstream transformer module path, or `undefined` to auto-detect. */
|
|
51
|
+
upstreamTransformer?: string;
|
|
52
|
+
/** Resolved include patterns (never `undefined`). */
|
|
53
|
+
include: string[];
|
|
54
|
+
/** Resolved exclude patterns (never `undefined`). */
|
|
55
|
+
exclude: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Environment variable that carries the resolved options from the Metro config
|
|
60
|
+
* process to the worker processes.
|
|
61
|
+
*
|
|
62
|
+
* Metro forks its transform workers (jest-worker) from the process that loaded
|
|
63
|
+
* `metro.config.js`, so a variable set on `process.env` before Metro boots is
|
|
64
|
+
* inherited by every worker. This is the only channel `withTtsc`'s arguments
|
|
65
|
+
* can reach the transformer through: the worker never sees the `withTtsc`
|
|
66
|
+
* call.
|
|
67
|
+
*/
|
|
68
|
+
export const ENV_KEY = "TTSC_METRO_OPTIONS";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Serialise user options for transport to the worker processes via
|
|
72
|
+
* {@link ENV_KEY}.
|
|
73
|
+
*/
|
|
74
|
+
export function serializeOptions(options: TtscMetroOptions): string {
|
|
75
|
+
return JSON.stringify(options ?? {});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reconstruct the resolved options inside a worker process.
|
|
80
|
+
*
|
|
81
|
+
* Reads {@link ENV_KEY}; when it is unset or malformed the adapter falls back to
|
|
82
|
+
* defaults, which means "auto-discover `tsconfig.json` and read its configured
|
|
83
|
+
* plugins", the standard ttsc behaviour, and the right thing for a project that
|
|
84
|
+
* called `withTtsc(config)` with no explicit options.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveOptionsFromEnv(): ResolvedTtscMetroOptions {
|
|
87
|
+
const raw = process.env[ENV_KEY];
|
|
88
|
+
const parsed = parse(raw);
|
|
89
|
+
return {
|
|
90
|
+
ttsc: {
|
|
91
|
+
project: parsed.project,
|
|
92
|
+
compilerOptions: parsed.compilerOptions,
|
|
93
|
+
...("plugins" in parsed ? { plugins: parsed.plugins } : {}),
|
|
94
|
+
},
|
|
95
|
+
upstreamTransformer:
|
|
96
|
+
typeof parsed.upstreamTransformer === "string"
|
|
97
|
+
? parsed.upstreamTransformer
|
|
98
|
+
: undefined,
|
|
99
|
+
include: toStringArray(parsed.include),
|
|
100
|
+
exclude: toStringArray(parsed.exclude),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parse(raw: string | undefined): TtscMetroOptions {
|
|
105
|
+
if (raw === undefined || raw.length === 0) {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const value: unknown = JSON.parse(raw);
|
|
110
|
+
// Only a plain object is a valid payload; arrays, `null`, numbers, strings,
|
|
111
|
+
// and booleans (all valid JSON) degrade to defaults rather than leaking a
|
|
112
|
+
// wrong-shaped value downstream.
|
|
113
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
114
|
+
? (value as TtscMetroOptions)
|
|
115
|
+
: {};
|
|
116
|
+
} catch {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Coerce an untrusted env value into a `string[]`. A non-array (e.g. the common
|
|
123
|
+
* mistake of passing a bare string for `include`/`exclude`) becomes `[]` so the
|
|
124
|
+
* worker never calls `.some` on a non-array and crashes.
|
|
125
|
+
*/
|
|
126
|
+
function toStringArray(value: unknown): string[] {
|
|
127
|
+
return Array.isArray(value)
|
|
128
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
129
|
+
: [];
|
|
130
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The subset of the Metro Babel-transformer contract this adapter relies on.
|
|
7
|
+
*
|
|
8
|
+
* Metro loads the module named by `transformer.babelTransformerPath` and calls
|
|
9
|
+
* its `transform` once per file, expecting a Babel AST back. `getCacheKey` is
|
|
10
|
+
* an optional export Metro folds into its transform-cache key; Metro invokes it
|
|
11
|
+
* with arguments (e.g. `{ projectRoot, enableBabelRCLookup }`), so it is typed
|
|
12
|
+
* variadic.
|
|
13
|
+
*/
|
|
14
|
+
export interface UpstreamTransformer {
|
|
15
|
+
transform(params: {
|
|
16
|
+
src: string;
|
|
17
|
+
filename: string;
|
|
18
|
+
options: Record<string, unknown>;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}): Promise<{ ast: object }>;
|
|
21
|
+
getCacheKey?: (...args: unknown[]) => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Upstream transformer module specifiers tried (in order) when no explicit
|
|
26
|
+
* `upstreamTransformer` is configured: Expo first, then modern bare React
|
|
27
|
+
* Native, then the legacy package.
|
|
28
|
+
*/
|
|
29
|
+
export const UPSTREAM_CANDIDATES = [
|
|
30
|
+
"@expo/metro-config/babel-transformer",
|
|
31
|
+
"@react-native/metro-babel-transformer",
|
|
32
|
+
"metro-react-native-babel-transformer",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the upstream Metro Babel transformer to delegate to.
|
|
37
|
+
*
|
|
38
|
+
* Detection order, most specific first:
|
|
39
|
+
*
|
|
40
|
+
* 1. An explicit `customPath` (the `upstreamTransformer` option);
|
|
41
|
+
* 2. Each of {@link UPSTREAM_CANDIDATES} in turn.
|
|
42
|
+
*
|
|
43
|
+
* These are declared as optional peers and resolved at runtime against the
|
|
44
|
+
* consumer project, so the adapter carries no Metro/Expo dependency itself.
|
|
45
|
+
* Resolution is not memoised: Node's own module cache already makes the
|
|
46
|
+
* repeated `require` a cheap lookup, and keeping no module-level state lets a
|
|
47
|
+
* changed `upstreamTransformer` always take effect.
|
|
48
|
+
*
|
|
49
|
+
* `load` is injectable purely so the resolution order and the not-found path
|
|
50
|
+
* can be tested deterministically; production always uses the real `require`.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveUpstreamTransformer(
|
|
53
|
+
customPath?: string,
|
|
54
|
+
load: (modulePath: string) => UpstreamTransformer | undefined = tryRequire,
|
|
55
|
+
): UpstreamTransformer {
|
|
56
|
+
if (customPath !== undefined && customPath.length !== 0) {
|
|
57
|
+
const upstream = load(customPath);
|
|
58
|
+
if (upstream === undefined) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`[@ttsc/metro] Could not load the configured upstream transformer: ${customPath}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return upstream;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const candidate of UPSTREAM_CANDIDATES) {
|
|
67
|
+
const upstream = load(candidate);
|
|
68
|
+
if (upstream !== undefined) {
|
|
69
|
+
return upstream;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
throw new Error(
|
|
74
|
+
"[@ttsc/metro] Could not find an upstream Metro transformer. Install " +
|
|
75
|
+
"@expo/metro-config (Expo) or @react-native/metro-babel-transformer " +
|
|
76
|
+
"(React Native), or set the `upstreamTransformer` option to an explicit " +
|
|
77
|
+
"module path.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tryRequire(modulePath: string): UpstreamTransformer | undefined {
|
|
82
|
+
try {
|
|
83
|
+
return nodeRequire(modulePath) as UpstreamTransformer;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ttsc/metro`: Metro (React Native / Expo) adapter for ttsc plugins.
|
|
3
|
+
*
|
|
4
|
+
* Metro bundles with Babel, which strips TypeScript types and never runs ttsc
|
|
5
|
+
* plugins, so neither the `ttsc` CLI nor `@ttsc/unplugin` can reach an RN/Expo
|
|
6
|
+
* build. {@link withTtsc} wires a Metro custom transformer that runs the ttsc
|
|
7
|
+
* plugin pass on each TypeScript file before handing the result to the
|
|
8
|
+
* project's existing Expo/React-Native Babel transformer.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* Expo project
|
|
12
|
+
* ```js
|
|
13
|
+
* // metro.config.js
|
|
14
|
+
* const { getDefaultConfig } = require("expo/metro-config");
|
|
15
|
+
* const { withTtsc } = require("@ttsc/metro");
|
|
16
|
+
*
|
|
17
|
+
* module.exports = withTtsc(getDefaultConfig(__dirname));
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* Bare React Native
|
|
22
|
+
* ```js
|
|
23
|
+
* // metro.config.js
|
|
24
|
+
* const { getDefaultConfig } = require("@react-native/metro-config");
|
|
25
|
+
* const { withTtsc } = require("@ttsc/metro");
|
|
26
|
+
*
|
|
27
|
+
* module.exports = withTtsc(getDefaultConfig(__dirname));
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
import type { TtscMetroOptions } from "./core/options";
|
|
34
|
+
import { ENV_KEY, serializeOptions } from "./core/options";
|
|
35
|
+
|
|
36
|
+
export type {
|
|
37
|
+
ResolvedTtscMetroOptions,
|
|
38
|
+
TtscMetroOptions,
|
|
39
|
+
} from "./core/options";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Minimal structural type for a Metro config object, avoids a hard dependency
|
|
43
|
+
* on Metro's types while letting {@link withTtsc} preserve the caller's exact
|
|
44
|
+
* config type.
|
|
45
|
+
*/
|
|
46
|
+
interface MetroConfigLike {
|
|
47
|
+
transformer?: {
|
|
48
|
+
babelTransformerPath?: string;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
};
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Wrap a Metro config so ttsc plugins run on every TypeScript file.
|
|
56
|
+
*
|
|
57
|
+
* Sets `transformer.babelTransformerPath` to this package's transformer and
|
|
58
|
+
* publishes the resolved options to Metro's worker processes via the
|
|
59
|
+
* {@link ENV_KEY} environment variable (the workers never see this call, so env
|
|
60
|
+
* is the transport, see `core/options.ts`). Compatible with Expo's
|
|
61
|
+
* `getDefaultConfig()` and bare React Native alike.
|
|
62
|
+
*
|
|
63
|
+
* With no `options`, the transformer auto-discovers `tsconfig.json` and runs
|
|
64
|
+
* the plugins configured there: the standard ttsc model. Pass `options` only to
|
|
65
|
+
* override the project path, plugin list, or include/exclude filters.
|
|
66
|
+
*/
|
|
67
|
+
export function withTtsc<T extends MetroConfigLike>(
|
|
68
|
+
config: T,
|
|
69
|
+
options: TtscMetroOptions = {},
|
|
70
|
+
): T {
|
|
71
|
+
process.env[ENV_KEY] = serializeOptions(options);
|
|
72
|
+
return {
|
|
73
|
+
...config,
|
|
74
|
+
transformer: {
|
|
75
|
+
...config.transformer,
|
|
76
|
+
babelTransformerPath: transformerModulePath(),
|
|
77
|
+
},
|
|
78
|
+
} as T;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Absolute path to the built transformer module Metro will `require`.
|
|
83
|
+
*
|
|
84
|
+
* Always the CommonJS build (`transformer.js`) next to this module: Metro
|
|
85
|
+
* resolves `babelTransformerPath` with `require`, and `metro.config.js` is a
|
|
86
|
+
* CommonJS module. Rollup rewrites `import.meta.url` for both the CJS and ESM
|
|
87
|
+
* builds, so this resolves correctly regardless of how the config loaded this
|
|
88
|
+
* entry.
|
|
89
|
+
*/
|
|
90
|
+
function transformerModulePath(): string {
|
|
91
|
+
return join(dirname(fileURLToPath(import.meta.url)), "transformer.js");
|
|
92
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metro custom transformer for ttsc.
|
|
3
|
+
*
|
|
4
|
+
* Metro loads this module via `transformer.babelTransformerPath` (wired by
|
|
5
|
+
* {@link withTtsc}) and calls {@link transform} once per file. The flow is:
|
|
6
|
+
*
|
|
7
|
+
* TypeScript source -> ttsc plugin pass (typia, nestia, …) via @ttsc/unplugin's
|
|
8
|
+
* core -> transformed TypeScript source -> upstream Expo/RN Babel transformer
|
|
9
|
+
* (strips types, RN transforms) -> Babel AST (what Metro consumes)
|
|
10
|
+
*
|
|
11
|
+
* The ttsc pass reuses `@ttsc/unplugin`'s `transformTtsc`, so the plugin
|
|
12
|
+
* contract, tsconfig discovery, and per-build cache are identical to every
|
|
13
|
+
* other bundler integration. See the package README for the v1 cost model and
|
|
14
|
+
* the cross-file cache-invalidation caveat.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
createTtscTransformCache,
|
|
18
|
+
resolveOptions,
|
|
19
|
+
transformTtsc,
|
|
20
|
+
} from "@ttsc/unplugin/api";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import type { ResolvedTtscMetroOptions } from "./core/options";
|
|
26
|
+
import { resolveOptionsFromEnv } from "./core/options";
|
|
27
|
+
import { resolveUpstreamTransformer } from "./core/upstream";
|
|
28
|
+
|
|
29
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Matches the TypeScript source extensions the ttsc pass handles (`.ts`,
|
|
33
|
+
* `.tsx`, `.cts`, `.mts`). JavaScript and declaration files are passed straight
|
|
34
|
+
* through to the upstream transformer.
|
|
35
|
+
*/
|
|
36
|
+
const TS_EXTENSION = /\.[cm]?tsx?$/;
|
|
37
|
+
const DECLARATION = /\.d\.[cm]?ts$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-worker singletons. Metro loads this module once per worker process and
|
|
41
|
+
* reuses it across every file that worker handles, so the resolved options, the
|
|
42
|
+
* transform cache, and the memoised `@ttsc/unplugin` options are all scoped to
|
|
43
|
+
* the worker.
|
|
44
|
+
*/
|
|
45
|
+
let resolved: ResolvedTtscMetroOptions | undefined;
|
|
46
|
+
let unpluginOptions: ReturnType<typeof resolveOptions> | undefined;
|
|
47
|
+
const cache = createTtscTransformCache();
|
|
48
|
+
|
|
49
|
+
/** Lazily resolve the worker-side options (from {@link resolveOptionsFromEnv}). */
|
|
50
|
+
function options(): ResolvedTtscMetroOptions {
|
|
51
|
+
return (resolved ??= resolveOptionsFromEnv());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve Metro's per-file `filename` to an absolute path.
|
|
56
|
+
*
|
|
57
|
+
* Metro hands the babel transformer a path **relative to `projectRoot`** (it
|
|
58
|
+
* reads the file via `fs.readFileSync(path.resolve(projectRoot, filename))`)
|
|
59
|
+
* and passes `projectRoot` inside `options`. The ttsc pass needs an absolute
|
|
60
|
+
* path that matches a key in the compiled program, so resolve against
|
|
61
|
+
* `projectRoot`, never `process.cwd()`, which differs from `projectRoot` in
|
|
62
|
+
* monorepos and when Metro is launched from a parent directory. Getting this
|
|
63
|
+
* wrong makes every file look "outside the project" and silently skips the
|
|
64
|
+
* plugin pass.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveAbsoluteFilename(
|
|
67
|
+
filename: string,
|
|
68
|
+
options?: Record<string, unknown>,
|
|
69
|
+
): string {
|
|
70
|
+
if (path.isAbsolute(filename)) {
|
|
71
|
+
return filename;
|
|
72
|
+
}
|
|
73
|
+
const projectRoot =
|
|
74
|
+
options !== undefined && typeof options.projectRoot === "string"
|
|
75
|
+
? options.projectRoot
|
|
76
|
+
: process.cwd();
|
|
77
|
+
return path.resolve(projectRoot, filename);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Metro transform entry point.
|
|
82
|
+
*
|
|
83
|
+
* Runs the ttsc plugin pass on TypeScript files, then delegates to the upstream
|
|
84
|
+
* Expo/React-Native Babel transformer to produce the AST Metro expects. The
|
|
85
|
+
* upstream call receives Metro's original params (notably the project-relative
|
|
86
|
+
* `filename`, which Babel expects); only `src` is replaced with the
|
|
87
|
+
* ttsc-transformed source.
|
|
88
|
+
*/
|
|
89
|
+
export async function transform(params: {
|
|
90
|
+
src: string;
|
|
91
|
+
filename: string;
|
|
92
|
+
options: Record<string, unknown>;
|
|
93
|
+
[key: string]: unknown;
|
|
94
|
+
}): Promise<{ ast: object }> {
|
|
95
|
+
const opts = options();
|
|
96
|
+
const upstream = resolveUpstreamTransformer(opts.upstreamTransformer);
|
|
97
|
+
|
|
98
|
+
// Gate on the project-relative path Metro supplies, so include/exclude
|
|
99
|
+
// substrings match what the user writes (e.g. "src/generated") and never
|
|
100
|
+
// collide with an absolute ancestor directory name. The absolute path is used
|
|
101
|
+
// only to address the file inside the compiled program.
|
|
102
|
+
if (!shouldTransform(params.filename, opts)) {
|
|
103
|
+
return upstream.transform(params);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let transformedSrc = params.src;
|
|
107
|
+
try {
|
|
108
|
+
unpluginOptions ??= resolveOptions(opts.ttsc);
|
|
109
|
+
const result = await transformTtsc(
|
|
110
|
+
resolveAbsoluteFilename(params.filename, params.options),
|
|
111
|
+
params.src,
|
|
112
|
+
unpluginOptions,
|
|
113
|
+
undefined,
|
|
114
|
+
cache,
|
|
115
|
+
);
|
|
116
|
+
if (result !== undefined && typeof result.code === "string") {
|
|
117
|
+
transformedSrc = result.code;
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// A file that is not part of the tsconfig program is not a build error,
|
|
121
|
+
// pass it through untransformed. Genuine compile/type failures propagate so
|
|
122
|
+
// Metro surfaces them, matching the other ttsc bundler integrations.
|
|
123
|
+
if (!isFileOutsideProject(error)) {
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return upstream.transform({ ...params, src: transformedSrc });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Metro transform-cache key.
|
|
133
|
+
*
|
|
134
|
+
* Metro already content-hashes each file, so this only has to invalidate when
|
|
135
|
+
* the transformer itself changes: package version + resolved options + the
|
|
136
|
+
* upstream transformer's own key (forwarded Metro's args, e.g. `projectRoot`,
|
|
137
|
+
* so a `babel.config.js` change still busts the cache). Resolving the upstream
|
|
138
|
+
* is deliberately non-fatal here: a missing peer must not crash cache-key
|
|
139
|
+
* computation. NOTE: this does not encode the tsconfig / plugin configuration
|
|
140
|
+
* or cross-file type dependencies, so after editing those (or a depended-upon
|
|
141
|
+
* type) run Metro with `--reset-cache`. See the README "Caveats" and
|
|
142
|
+
* samchon/ttsc#255.
|
|
143
|
+
*/
|
|
144
|
+
export function getCacheKey(...args: unknown[]): string {
|
|
145
|
+
const opts = options();
|
|
146
|
+
const hash = createHash("sha256");
|
|
147
|
+
hash.update(`@ttsc/metro:${packageVersion()}`);
|
|
148
|
+
hash.update(
|
|
149
|
+
stableStringify({
|
|
150
|
+
ttsc: opts.ttsc,
|
|
151
|
+
include: opts.include,
|
|
152
|
+
exclude: opts.exclude,
|
|
153
|
+
upstreamTransformer: opts.upstreamTransformer ?? null,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
const upstreamKey = upstreamCacheKey(opts.upstreamTransformer, args);
|
|
157
|
+
if (upstreamKey.length !== 0) {
|
|
158
|
+
hash.update(upstreamKey);
|
|
159
|
+
}
|
|
160
|
+
return hash.digest("hex");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Fold the upstream transformer's cache key in, defensively. Forwards Metro's
|
|
165
|
+
* own `getCacheKey` arguments so the upstream's babelrc-derived key is
|
|
166
|
+
* preserved, and never throws: a missing peer or a throwing upstream
|
|
167
|
+
* `getCacheKey` yields an empty contribution rather than failing the whole
|
|
168
|
+
* build's cache keying.
|
|
169
|
+
*/
|
|
170
|
+
function upstreamCacheKey(
|
|
171
|
+
upstreamTransformer: string | undefined,
|
|
172
|
+
args: unknown[],
|
|
173
|
+
): string {
|
|
174
|
+
let upstream;
|
|
175
|
+
try {
|
|
176
|
+
upstream = resolveUpstreamTransformer(upstreamTransformer);
|
|
177
|
+
} catch {
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
if (upstream.getCacheKey === undefined) {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
return String(upstream.getCacheKey(...args) ?? "");
|
|
185
|
+
} catch {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Decide whether a file should run through the ttsc pass. Only TypeScript
|
|
192
|
+
* sources (`.ts`/`.tsx`/`.cts`/`.mts`, excluding `.d.ts`) qualify; `exclude`
|
|
193
|
+
* substrings win over `include`, and an empty `include` means "all TypeScript".
|
|
194
|
+
* Exported for unit testing.
|
|
195
|
+
*/
|
|
196
|
+
export function shouldTransform(
|
|
197
|
+
filename: string,
|
|
198
|
+
opts: ResolvedTtscMetroOptions,
|
|
199
|
+
): boolean {
|
|
200
|
+
if (!TS_EXTENSION.test(filename) || DECLARATION.test(filename)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (opts.exclude.some((pattern) => filename.includes(pattern))) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
opts.include.length !== 0 &&
|
|
208
|
+
!opts.include.some((pattern) => filename.includes(pattern))
|
|
209
|
+
) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `transformTtsc` throws `"ttsc transform did not return output for <file>"`
|
|
217
|
+
* when the requested file is not part of the compiled program (e.g. excluded
|
|
218
|
+
* from the tsconfig). That case is non-fatal: the file should pass through.
|
|
219
|
+
*/
|
|
220
|
+
function isFileOutsideProject(error: unknown): boolean {
|
|
221
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
222
|
+
return message.includes("did not return output");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function packageVersion(): string {
|
|
226
|
+
try {
|
|
227
|
+
const pkg = nodeRequire("@ttsc/metro/package.json") as { version?: string };
|
|
228
|
+
return pkg.version ?? "0";
|
|
229
|
+
} catch {
|
|
230
|
+
return "0";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
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
|
+
}
|