1// Copyright 2021-present 650 Industries (Expo). All rights reserved.
2
3import chalk from 'chalk';
4import Debug from 'debug';
5import type { BabelTransformer, BabelTransformerArgs } from 'metro-babel-transformer';
6import resolveFrom from 'resolve-from';
7
8import { generateFunctionMap } from './generateFunctionMap';
9import { getBabelConfig } from './getBabelConfig';
10
11const debug = Debug('expo:metro:exotic-babel-transformer');
12
13let babelCore: typeof import('@babel/core') | undefined;
14
15function getBabelCoreFromProject(projectRoot: string) {
16  if (babelCore) return babelCore;
17  babelCore = require(resolveFrom(projectRoot, '@babel/core'));
18  return babelCore!;
19}
20
21let babelParser: typeof import('@babel/parser') | undefined;
22
23function getBabelParserFromProject(projectRoot: string) {
24  if (babelParser) return babelParser;
25  babelParser = require(resolveFrom(projectRoot, '@babel/parser'));
26  return babelParser!;
27}
28
29function sucrase(
30  args: BabelTransformerArgs,
31  {
32    transforms,
33  }: {
34    transforms: string[];
35  }
36): Partial<ReturnType<BabelTransformer['transform']>> {
37  const {
38    src,
39    filename,
40    options: { dev },
41  } = args;
42  const { transform } = require('sucrase');
43
44  const results = transform(src, {
45    filePath: filename,
46    production: !dev,
47    transforms,
48  });
49
50  return {
51    code: results.code,
52    functionMap: null,
53  };
54}
55
56const getExpensiveSucraseTransforms = (filename: string) => [
57  'jsx',
58  'imports',
59  /\.tsx?$/.test(filename) ? 'typescript' : 'flow',
60];
61
62function parseAst(projectRoot: string, sourceCode: string) {
63  const babylon = getBabelParserFromProject(projectRoot);
64
65  return babylon.parse(sourceCode, {
66    sourceType: 'unambiguous',
67  });
68}
69
70export type Rule = {
71  warn?: boolean;
72  type?: 'module' | 'app';
73  name?: string;
74  test: ((args: BabelTransformerArgs) => boolean) | RegExp;
75  transform: BabelTransformer['transform'];
76};
77
78/** Create a transformer that emulates Webpack's loader system. */
79export function createMultiRuleTransformer({
80  getRuleType,
81  rules,
82}: {
83  getRuleType: (args: BabelTransformerArgs) => string;
84  rules: Rule[];
85}): BabelTransformer['transform'] {
86  // const warnings: string[] = [];
87  return function transform(args: BabelTransformerArgs) {
88    const { filename, options } = args;
89    const OLD_BABEL_ENV = process.env.BABEL_ENV;
90    process.env.BABEL_ENV = options?.dev ? 'development' : process.env.BABEL_ENV || 'production';
91
92    try {
93      const ruleType = getRuleType(args);
94
95      for (const rule of rules) {
96        // optimization for checking node modules
97        if (rule.type && rule.type !== ruleType) {
98          continue;
99        }
100
101        const isMatched =
102          typeof rule.test === 'function' ? rule.test(args) : rule.test.test(args.filename);
103        if (isMatched) {
104          const results = rule.transform(args);
105          // @ts-ignore: Add extra property for testing
106          results._ruleName = rule.name;
107          // Perform a basic parse if none exists, this enables us to control the output, but only if it changed.
108          if (results.code && !results.ast) {
109            // Parse AST with babel otherwise Metro transformer will throw away the returned results.
110            results.ast = parseAst(options?.projectRoot, results.code);
111          }
112
113          // TODO: Suboptimal warnings
114          // if (rule.warn) {
115          //   const matchName =
116          //     filename.match(/node_modules\/((:?@[\w\d-]+\/[\w\d-]+)|(:?[\w\d-]+))\/?/)?.[1] ??
117          //     filename;
118          //   if (matchName && !warnings.includes(matchName)) {
119          //     warnings.push(matchName);
120          //     console.warn(chalk.yellow.bold`warn `, matchName);
121          //     console.warn(
122          //       chalk.yellow`untranspiled module is potentially causing bundler slowdown, using modules that support commonjs will make your dev server much faster.`
123          //     );
124          //   }
125          // }
126
127          return results;
128        }
129      }
130      throw new Error('no loader rule to handle file: ' + filename);
131    } finally {
132      if (OLD_BABEL_ENV) {
133        process.env.BABEL_ENV = OLD_BABEL_ENV;
134      }
135    }
136  };
137}
138
139function app(args: BabelTransformerArgs) {
140  debug('app:', args.filename);
141
142  const { filename, options, src, plugins } = args;
143  const babelConfig = {
144    // ES modules require sourceType='module' but OSS may not always want that
145    sourceType: 'unambiguous',
146    ...getBabelConfig(filename, options, plugins),
147    // Variables that are exposed to the user's babel preset.
148    caller: {
149      name: 'metro',
150
151      platform: options.platform,
152    },
153    ast: true,
154  };
155
156  // Surface a warning function so babel linters can be used.
157  Object.defineProperty(babelConfig.caller, 'onWarning', {
158    enumerable: false,
159    writable: false,
160    value: (babelConfig.caller.onWarning = function (msg: any) {
161      // Format the file path first so users know where the warning came from.
162      console.warn(chalk.bold.yellow`warn ` + args.filename);
163      console.warn(msg);
164    }),
165  });
166
167  const { parseSync, transformFromAstSync } = getBabelCoreFromProject(options.projectRoot);
168  const sourceAst = parseSync(src, babelConfig);
169
170  // Should never happen.
171  if (!sourceAst) return { ast: null };
172
173  const result = transformFromAstSync(sourceAst, src, babelConfig);
174
175  // TODO: Disable by default
176  const functionMap = generateFunctionMap(sourceAst, { filename });
177  // The result from `transformFromAstSync` can be null (if the file is ignored)
178  if (!result) {
179    return { ast: null, functionMap };
180  }
181
182  return { ast: result.ast, functionMap };
183}
184
185export const loaders: Record<string, (args: BabelTransformerArgs) => any> = {
186  // Perform the standard, and most expensive transpilation sequence.
187  app,
188
189  // Transpile react-native with sucrase.
190  reactNativeModule(args) {
191    // Special file needs full transpilation.
192    if (args.filename.includes('react-native/Libraries/Events/EventPolyfill.js')) {
193      // Match React Native modules which use non-standard flow features, convert them using babel (most expensive).
194      return app(args);
195    }
196
197    debug('rn:', args.filename);
198    return sucrase(args, {
199      transforms: ['jsx', 'flow', 'imports'],
200    });
201  },
202
203  // Transpile expo modules with sucrase.
204  expoModule(args) {
205    debug('expo:', args.filename);
206    // TODO: Fix all expo packages
207    return sucrase(args, {
208      transforms: [
209        'imports',
210        // TODO: fix expo-processing, expo/vector-icons
211        /(expo-processing|expo\/vector-icons)/.test(args.filename) && 'jsx',
212        // TODO: fix expo-asset-utils
213        /(expo-asset-utils)/.test(args.filename) && 'flow',
214      ].filter(Boolean) as string[],
215    });
216  },
217
218  // Transpile known community modules with the most expensive sucrase
219  untranspiledModule(args) {
220    debug('known issues:', args.filename);
221    return sucrase(args, {
222      transforms: getExpensiveSucraseTransforms(args.filename),
223    });
224  },
225
226  // Pass all modules through without transpiling them.
227  passthroughModule(args) {
228    const { filename, options, src } = args;
229    debug('passthrough:', filename);
230
231    // Perform a basic ast parse, this doesn't matter since the worker will parse and ignore anyways.
232    const ast = parseAst(options.projectRoot, src);
233
234    // TODO: Disable by default
235    const functionMap = generateFunctionMap(ast, { filename });
236
237    return {
238      code: src,
239      functionMap,
240      ast,
241    };
242  },
243};
244