@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.
@@ -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
+ }