UNPKG

metro-transform-worker

Version:

🚇 Transform worker for Metro.

793 lines (711 loc) • 22.6 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format * @oncall react_native */ import type {PluginEntry, Plugins} from '@babel/core'; import type { BabelTransformer, BabelTransformerArgs, CustomTransformOptions, TransformProfile, } from 'metro-babel-transformer'; import type { BasicSourceMap, FBSourceFunctionMap, MetroSourceMapSegmentTuple, } from 'metro-source-map'; import type { ImportExportPluginOptions, InlinePluginOptions, InlineRequiresPluginOptions, } from 'metro-transform-plugins'; import type {TransformResultDependency} from 'metro/private/DeltaBundler'; import type {AllowOptionalDependencies} from 'metro/private/DeltaBundler/types'; import type { DependencyTransformer, DynamicRequiresBehavior, } from 'metro/private/ModuleGraph/worker/collectDependencies'; import * as assetTransformer from './utils/assetTransformer'; import getMinifier from './utils/getMinifier'; import {transformFromAstSync} from '@babel/core'; import generate from '@babel/generator'; import * as babylon from '@babel/parser'; import * as types from '@babel/types'; import {stableHash} from 'metro-cache'; import {getCacheKey as metroGetCacheKey} from 'metro-cache-key'; import { fromRawMappings, functionMapBabelPlugin, toBabelSegments, toSegmentTuple, } from 'metro-source-map'; import metroTransformPlugins from 'metro-transform-plugins'; import collectDependencies from 'metro/private/ModuleGraph/worker/collectDependencies'; import generateImportNames from 'metro/private/ModuleGraph/worker/generateImportNames'; import { importLocationsPlugin, locToKey, } from 'metro/private/ModuleGraph/worker/importLocationsPlugin'; import * as JsFileWrapping from 'metro/private/ModuleGraph/worker/JsFileWrapping'; import nullthrows from 'nullthrows'; const InternalInvalidRequireCallError = collectDependencies.InvalidRequireCallError; type MinifierConfig = $ReadOnly<{[string]: mixed, ...}>; export type MinifierOptions = { code: string, map: ?BasicSourceMap, filename: string, reserved: $ReadOnlyArray<string>, config: MinifierConfig, ... }; export type MinifierResult = { code: string, map?: BasicSourceMap, ... }; export type Minifier = MinifierOptions => | MinifierResult | Promise<MinifierResult>; export type Type = 'script' | 'module' | 'asset'; export type JsTransformerConfig = $ReadOnly<{ assetPlugins: $ReadOnlyArray<string>, assetRegistryPath: string, asyncRequireModulePath: string, babelTransformerPath: string, dynamicDepsInPackages: DynamicRequiresBehavior, enableBabelRCLookup: boolean, enableBabelRuntime: boolean | string, globalPrefix: string, hermesParser: boolean, minifierConfig: MinifierConfig, minifierPath: string, optimizationSizeLimit: number, publicPath: string, allowOptionalDependencies: AllowOptionalDependencies, unstable_dependencyMapReservedName: ?string, unstable_disableModuleWrapping: boolean, unstable_disableNormalizePseudoGlobals: boolean, unstable_compactOutput: boolean, /** Enable `require.context` statements which can be used to import multiple files in a directory. */ unstable_allowRequireContext: boolean, /** With inlineRequires, enable a module-scope memo var and inline as (v || v=require('foo')) */ unstable_memoizeInlineRequires?: boolean, /** With inlineRequires, do not memoize these module specifiers */ unstable_nonMemoizedInlineRequires?: $ReadOnlyArray<string>, /** Whether to rename scoped `require` functions to `_$$_REQUIRE`, usually an extraneous operation when serializing to iife (default). */ unstable_renameRequire?: boolean, }>; export type {CustomTransformOptions} from 'metro-babel-transformer'; export type JsTransformOptions = $ReadOnly<{ customTransformOptions?: CustomTransformOptions, dev: boolean, experimentalImportSupport?: boolean, inlinePlatform: boolean, inlineRequires: boolean, minify: boolean, nonInlinedRequires?: $ReadOnlyArray<string>, platform: ?string, type: Type, unstable_memoizeInlineRequires?: boolean, unstable_nonMemoizedInlineRequires?: $ReadOnlyArray<string>, unstable_staticHermesOptimizedRequire?: boolean, unstable_transformProfile: TransformProfile, }>; opaque type Path = string; type BaseFile = $ReadOnly<{ code: string, filename: Path, inputFileSize: number, }>; type AssetFile = $ReadOnly<{ ...BaseFile, type: 'asset', }>; type JSFileType = 'js/script' | 'js/module' | 'js/module/asset'; type JSFile = $ReadOnly<{ ...BaseFile, ast?: ?BabelNodeFile, type: JSFileType, functionMap: FBSourceFunctionMap | null, unstable_importDeclarationLocs?: ?$ReadOnlySet<string>, }>; type JSONFile = { ...BaseFile, type: Type, }; type TransformationContext = $ReadOnly<{ config: JsTransformerConfig, projectRoot: Path, options: JsTransformOptions, }>; export type JsOutput = $ReadOnly<{ data: $ReadOnly<{ code: string, lineCount: number, map: Array<MetroSourceMapSegmentTuple>, functionMap: ?FBSourceFunctionMap, }>, type: JSFileType, }>; type TransformResponse = $ReadOnly<{ dependencies: $ReadOnlyArray<TransformResultDependency>, output: $ReadOnlyArray<JsOutput>, }>; function getDynamicDepsBehavior( inPackages: DynamicRequiresBehavior, filename: string, ): DynamicRequiresBehavior { switch (inPackages) { case 'reject': return 'reject'; case 'throwAtRuntime': const isPackage = /(?:^|[/\\])node_modules[/\\]/.test(filename); return isPackage ? inPackages : 'reject'; default: (inPackages: empty); throw new Error( `invalid value for dynamic deps behavior: \`${inPackages}\``, ); } } const minifyCode = async ( config: JsTransformerConfig, projectRoot: string, filename: string, code: string, source: string, map: Array<MetroSourceMapSegmentTuple>, reserved?: $ReadOnlyArray<string> = [], ): Promise<{ code: string, map: Array<MetroSourceMapSegmentTuple>, ... }> => { const sourceMap = fromRawMappings([ { code, source, map, // functionMap is overridden by the serializer functionMap: null, path: filename, // isIgnored is overriden by the serializer isIgnored: false, }, ]).toMap(undefined, {}); const minify = getMinifier(config.minifierPath); try { const minified = await minify({ code, map: sourceMap, filename, reserved, config: config.minifierConfig, }); return { code: minified.code, map: minified.map ? toBabelSegments(minified.map).map(toSegmentTuple) : [], }; } catch (error) { if (error.constructor.name === 'JS_Parse_Error') { throw new Error( `${error.message} in file ${filename} at ${error.line}:${error.col}`, ); } throw error; } }; const disabledDependencyTransformer: DependencyTransformer = { transformSyncRequire: () => void 0, transformImportCall: () => void 0, transformImportMaybeSyncCall: () => void 0, transformPrefetch: () => void 0, transformIllegalDynamicRequire: () => void 0, }; class InvalidRequireCallError extends Error { innerError: InternalInvalidRequireCallError; filename: string; constructor(innerError: InternalInvalidRequireCallError, filename: string) { super(`${filename}:${innerError.message}`); this.innerError = innerError; this.filename = filename; } } async function transformJS( file: JSFile, {config, options, projectRoot}: TransformationContext, ): Promise<TransformResponse> { // Transformers can output null ASTs (if they ignore the file). In that case // we need to parse the module source code to get their AST. let ast = file.ast ?? babylon.parse(file.code, {sourceType: 'unambiguous'}); const {importDefault, importAll} = generateImportNames(ast); // Add "use strict" if the file was parsed as a module, and the directive did // not exist yet. const {directives} = ast.program; if ( ast.program.sourceType === 'module' && directives != null && directives.findIndex(d => d.value.value === 'use strict') === -1 ) { directives.push(types.directive(types.directiveLiteral('use strict'))); } // Perform the import-export transform (in case it's still needed), then // fold requires and perform constant folding (if in dev). const plugins: Array<PluginEntry> = []; if (options.experimentalImportSupport === true) { plugins.push([ metroTransformPlugins.importExportPlugin, { importAll, importDefault, resolve: false, } as ImportExportPluginOptions, ]); } if (options.inlineRequires) { plugins.push([ metroTransformPlugins.inlineRequiresPlugin, { ignoredRequires: options.nonInlinedRequires, inlineableCalls: [importDefault, importAll], memoizeCalls: // $FlowFixMe[incompatible-type] is this always (?boolean)? options.customTransformOptions?.unstable_memoizeInlineRequires ?? options.unstable_memoizeInlineRequires, nonMemoizedModules: options.unstable_nonMemoizedInlineRequires, } as InlineRequiresPluginOptions, ]); } plugins.push([ metroTransformPlugins.inlinePlugin, { dev: options.dev, inlinePlatform: options.inlinePlatform, isWrapped: false, // $FlowFixMe[incompatible-type] expects a string if inlinePlatform platform: options.platform, } as InlinePluginOptions, ]); ast = nullthrows( transformFromAstSync(ast, '', { ast: true, babelrc: false, code: false, configFile: false, comments: true, filename: file.filename, plugins, sourceMaps: false, // Not-Cloning the input AST here should be safe because other code paths above this call // are mutating the AST as well and no code is depending on the original AST. // However, switching the flag to false caused issues with ES Modules if `experimentalImportSupport` isn't used https://github.com/facebook/metro/issues/641 // either because one of the plugins is doing something funky or Babel messes up some caches. // Make sure to test the above mentioned case before flipping the flag back to false. cloneInputAst: true, }).ast, ); if (!options.dev) { // Run the constant folding plugin in its own pass, avoiding race conditions // with other plugins that have exit() visitors on Program (e.g. the ESM // transform). ast = nullthrows( transformFromAstSync(ast, '', { ast: true, babelrc: false, code: false, configFile: false, comments: true, filename: file.filename, plugins: [metroTransformPlugins.constantFoldingPlugin], sourceMaps: false, cloneInputAst: false, }).ast, ); } let dependencyMapName = ''; let dependencies; let wrappedAst; // If the module to transform is a script (meaning that is not part of the // dependency graph and it code will just be prepended to the bundle modules), // we need to wrap it differently than a commonJS module (also, scripts do // not have dependencies). if (file.type === 'js/script') { dependencies = []; wrappedAst = JsFileWrapping.wrapPolyfill(ast); } else { try { const importDeclarationLocs = file.unstable_importDeclarationLocs ?? null; const opts = { asyncRequireModulePath: config.asyncRequireModulePath, dependencyTransformer: config.unstable_disableModuleWrapping === true ? disabledDependencyTransformer : undefined, dynamicRequires: getDynamicDepsBehavior( config.dynamicDepsInPackages, file.filename, ), inlineableCalls: [importDefault, importAll], keepRequireNames: options.dev, allowOptionalDependencies: config.allowOptionalDependencies, dependencyMapName: config.unstable_dependencyMapReservedName, unstable_allowRequireContext: config.unstable_allowRequireContext, unstable_isESMImportAtSource: importDeclarationLocs != null ? (loc: BabelSourceLocation) => importDeclarationLocs.has(locToKey(loc)) : null, }; ({ast, dependencies, dependencyMapName} = collectDependencies(ast, opts)); } catch (error) { if (error instanceof InternalInvalidRequireCallError) { throw new InvalidRequireCallError(error, file.filename); } throw error; } if (config.unstable_disableModuleWrapping === true) { wrappedAst = ast; } else { ({ast: wrappedAst} = JsFileWrapping.wrapModule( ast, importDefault, importAll, dependencyMapName, config.globalPrefix, // TODO: This config is optional to allow its introduction in a minor // release. It should be made non-optional in ConfigT or removed in // future. config.unstable_renameRequire === false, { unstable_useStaticHermesModuleFactory: Boolean( options.customTransformOptions ?.unstable_staticHermesOptimizedRequire, ), }, )); } } const minify = options.minify && options.unstable_transformProfile !== 'hermes-canary' && options.unstable_transformProfile !== 'hermes-stable'; const reserved = []; if (config.unstable_dependencyMapReservedName != null) { reserved.push(config.unstable_dependencyMapReservedName); } if ( minify && file.inputFileSize <= config.optimizationSizeLimit && !config.unstable_disableNormalizePseudoGlobals ) { reserved.push( ...metroTransformPlugins.normalizePseudoGlobals(wrappedAst, { reservedNames: reserved, }), ); } const result = generate( wrappedAst, { comments: true, compact: config.unstable_compactOutput, filename: file.filename, retainLines: false, sourceFileName: file.filename, sourceMaps: true, }, file.code, ); let map = result.rawMappings ? result.rawMappings.map(toSegmentTuple) : []; let code = result.code; if (minify) { ({map, code} = await minifyCode( config, projectRoot, file.filename, result.code, file.code, map, reserved, )); } let lineCount; ({lineCount, map} = countLinesAndTerminateMap(code, map)); const output: Array<JsOutput> = [ { data: { code, lineCount, map, functionMap: file.functionMap, }, type: file.type, }, ]; return { dependencies, output, }; } /** * Transforms an asset file */ async function transformAsset( file: AssetFile, context: TransformationContext, ): Promise<TransformResponse> { const {assetRegistryPath, assetPlugins} = context.config; const result = await assetTransformer.transform( getBabelTransformArgs(file, context), assetRegistryPath, assetPlugins, ); const jsFile = { ...file, type: 'js/module/asset' as const, ast: result.ast, functionMap: null, }; return transformJS(jsFile, context); } /** * Transforms a JavaScript file with Babel before processing the file with * the generic JavaScript transformation. */ async function transformJSWithBabel( file: JSFile, context: TransformationContext, ): Promise<TransformResponse> { const {babelTransformerPath} = context.config; // $FlowFixMe[unsupported-syntax] dynamic require const transformer: BabelTransformer = require(babelTransformerPath); const transformResult = await transformer.transform( getBabelTransformArgs(file, context, [ // functionMapBabelPlugin populates metadata.metro.functionMap functionMapBabelPlugin, // importLocationsPlugin populates metadata.metro.unstable_importDeclarationLocs importLocationsPlugin, ]), ); const jsFile: JSFile = { ...file, ast: transformResult.ast, functionMap: transformResult.metadata?.metro?.functionMap ?? // Fallback to deprecated explicitly-generated `functionMap` transformResult.functionMap ?? null, unstable_importDeclarationLocs: transformResult.metadata?.metro?.unstable_importDeclarationLocs, }; return await transformJS(jsFile, context); } async function transformJSON( file: JSONFile, {options, config, projectRoot}: TransformationContext, ): Promise<TransformResponse> { let code = config.unstable_disableModuleWrapping === true ? JsFileWrapping.jsonToCommonJS(file.code) : JsFileWrapping.wrapJson( file.code, config.globalPrefix, Boolean( options.customTransformOptions ?.unstable_staticHermesOptimizedRequire, ), ); let map: Array<MetroSourceMapSegmentTuple> = []; // TODO: When we can reuse transformJS for JSON, we should not derive `minify` separately. const minify = options.minify && options.unstable_transformProfile !== 'hermes-canary' && options.unstable_transformProfile !== 'hermes-stable'; if (minify) { ({map, code} = await minifyCode( config, projectRoot, file.filename, code, file.code, map, )); } let jsType: JSFileType; if (file.type === 'asset') { jsType = 'js/module/asset'; } else if (file.type === 'script') { jsType = 'js/script'; } else { jsType = 'js/module'; } let lineCount; ({lineCount, map} = countLinesAndTerminateMap(code, map)); const output: Array<JsOutput> = [ { data: {code, lineCount, map, functionMap: null}, type: jsType, }, ]; return { dependencies: [], output, }; } function getBabelTransformArgs( file: $ReadOnly<{filename: Path, code: string, ...}>, {options, config, projectRoot}: TransformationContext, plugins?: Plugins = [], ): BabelTransformerArgs { const {inlineRequires: _, ...babelTransformerOptions} = options; return { filename: file.filename, options: { ...babelTransformerOptions, enableBabelRCLookup: config.enableBabelRCLookup, enableBabelRuntime: config.enableBabelRuntime, globalPrefix: config.globalPrefix, hermesParser: config.hermesParser, projectRoot, publicPath: config.publicPath, }, plugins, src: file.code, }; } export const transform = async ( config: JsTransformerConfig, projectRoot: string, filename: string, data: Buffer, options: JsTransformOptions, ): Promise<TransformResponse> => { const context: TransformationContext = { config, projectRoot, options, }; const sourceCode = data.toString('utf8'); const reservedStrings = []; if ( options.customTransformOptions?.unstable_staticHermesOptimizedRequire == true ) { reservedStrings.push('_$$_METRO_MODULE_ID'); } if (config.unstable_dependencyMapReservedName != null) { reservedStrings.push(config.unstable_dependencyMapReservedName); } for (const reservedString of reservedStrings) { const position = sourceCode.indexOf(reservedString); if (position > -1) { throw new SyntaxError( 'Source code contains the reserved string `' + reservedString + '` at character offset ' + position, ); } } if (filename.endsWith('.json')) { const jsonFile: JSONFile = { filename, inputFileSize: data.length, code: sourceCode, type: options.type, }; return await transformJSON(jsonFile, context); } if (options.type === 'asset') { const file: AssetFile = { filename, inputFileSize: data.length, code: sourceCode, type: options.type, }; return await transformAsset(file, context); } const file: JSFile = { filename, inputFileSize: data.length, code: sourceCode, type: options.type === 'script' ? 'js/script' : 'js/module', functionMap: null, }; return await transformJSWithBabel(file, context); }; export const getCacheKey = (config: JsTransformerConfig): string => { const {babelTransformerPath, minifierPath, ...remainingConfig} = config; const filesKey = metroGetCacheKey([ __filename, require.resolve(babelTransformerPath), require.resolve(minifierPath), require.resolve('./utils/getMinifier'), require.resolve('./utils/assetTransformer'), require.resolve('metro/private/ModuleGraph/worker/generateImportNames'), require.resolve('metro/private/ModuleGraph/worker/JsFileWrapping'), ...metroTransformPlugins.getTransformPluginCacheKeyFiles(), ]); // $FlowFixMe[unsupported-syntax] const babelTransformer = require(babelTransformerPath); return [ filesKey, stableHash(remainingConfig).toString('hex'), babelTransformer.getCacheKey ? babelTransformer.getCacheKey() : '', ].join('$'); }; function countLinesAndTerminateMap( code: string, map: $ReadOnlyArray<MetroSourceMapSegmentTuple>, ): { lineCount: number, map: Array<MetroSourceMapSegmentTuple>, } { const NEWLINE = /\r\n?|\n|\u2028|\u2029/g; let lineCount = 1; let lastLineStart = 0; // Count lines and keep track of where the last line starts for (const match of code.matchAll(NEWLINE)) { lineCount++; lastLineStart = match.index + match[0].length; } const lastLineLength = code.length - lastLineStart; const lastLineIndex1Based = lineCount; const lastLineNextColumn0Based = lastLineLength; // If there isn't a mapping at one-past-the-last column of the last line, // add one that maps to nothing. This ensures out-of-bounds lookups hit the // null mapping rather than aliasing to whichever mapping happens to be last. // ASSUMPTION: Mappings are generated in order of increasing line and column. const lastMapping = map[map.length - 1]; const terminatingMapping = [lastLineIndex1Based, lastLineNextColumn0Based]; if ( !lastMapping || lastMapping[0] !== terminatingMapping[0] || lastMapping[1] !== terminatingMapping[1] ) { return { lineCount, map: map.concat([terminatingMapping]), }; } return {lineCount, map: [...map]}; } /** * Backwards-compatibility with CommonJS consumers using interopRequireDefault. * Do not add to this list. * * @deprecated Default import from 'metro-transform-worker' is deprecated, use named exports. */ export default { getCacheKey, transform, };