metro-transform-worker
Version:
🚇 Transform worker for Metro.
600 lines (524 loc) • 15.9 kB
JavaScript
/**
* 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.
*
*
* @format
*/
;
const getMinifier = require("./utils/getMinifier");
const { transformFromAstSync } = require("@babel/core");
const generate = require("@babel/generator").default;
const babylon = require("@babel/parser");
const types = require("@babel/types");
const { stableHash } = require("metro-cache");
const getCacheKey = require("metro-cache-key");
const HermesCompiler = require("metro-hermes-compiler");
const {
fromRawMappings,
toBabelSegments,
toSegmentTuple,
} = require("metro-source-map");
const metroTransformPlugins = require("metro-transform-plugins");
const countLines = require("metro/src/lib/countLines");
const {
InvalidRequireCallError: InternalInvalidRequireCallError,
} = require("metro/src/ModuleGraph/worker/collectDependencies");
const generateImportNames = require("metro/src/ModuleGraph/worker/generateImportNames");
const JsFileWrapping = require("metro/src/ModuleGraph/worker/JsFileWrapping");
const nullthrows = require("nullthrows");
function getDynamicDepsBehavior(inPackages, filename) {
switch (inPackages) {
case "reject":
return "reject";
case "throwAtRuntime":
const isPackage = /(?:^|[/\\])node_modules[/\\]/.test(filename);
return isPackage ? inPackages : "reject";
default:
inPackages;
throw new Error(
`invalid value for dynamic deps behavior: \`${inPackages}\``
);
}
}
const minifyCode = async (
config,
projectRoot,
filename,
code,
source,
map,
reserved = []
) => {
const sourceMap = fromRawMappings([
{
code,
source,
map,
functionMap: null,
path: filename,
},
]).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 compileToBytecode = (rawCode, type, options) => {
let code = rawCode;
if (type.startsWith("js/module")) {
const index = code.lastIndexOf(")");
code =
code.slice(0, index) +
",$$METRO_D[0],$$METRO_D[1],$$METRO_D[2]" +
code.slice(index);
}
return HermesCompiler.compile(code, options);
};
const disabledDependencyTransformer = {
transformSyncRequire: () => void 0,
transformImportCall: () => void 0,
transformJSResource: () => void 0,
transformPrefetch: () => void 0,
transformIllegalDynamicRequire: () => void 0,
};
class InvalidRequireCallError extends Error {
constructor(innerError, filename) {
super(`${filename}:${innerError.message}`);
this.innerError = innerError;
this.filename = filename;
}
}
async function transformJS(file, { config, options, projectRoot }) {
var _file$ast;
// 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 = file.ast) !== null && _file$ast !== void 0
? _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 = [];
const babelPluginOpts = {
...options,
inlineableCalls: [importDefault, importAll],
importDefault,
importAll,
};
if (options.experimentalImportSupport === true) {
plugins.push([metroTransformPlugins.importExportPlugin, babelPluginOpts]);
}
if (options.inlineRequires) {
plugins.push([
// $FlowFixMe[untyped-import] untyped module
require("babel-preset-fbjs/plugins/inline-requires"),
{ ...babelPluginOpts, ignoredRequires: options.nonInlinedRequires },
]);
}
plugins.push([metroTransformPlugins.inlinePlugin, babelPluginOpts]);
ast = nullthrows(
transformFromAstSync(ast, "", {
ast: true,
babelrc: false,
code: false,
configFile: false,
comments: false,
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: false,
filename: file.filename,
plugins: [
[metroTransformPlugins.constantFoldingPlugin, babelPluginOpts],
],
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 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,
}; // $FlowFixMe[unsupported-syntax] dynamic require
const collectDependencies = require(config.unstable_collectDependenciesPath);
({ 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
));
}
}
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: false,
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
));
}
const output = [
{
data: {
code,
lineCount: countLines(code),
map,
functionMap: file.functionMap,
},
type: file.type,
},
];
if (options.runtimeBytecodeVersion != null) {
output.push({
data: compileToBytecode(code, file.type, {
sourceURL: file.filename,
sourceMap: fromRawMappings([
{
code,
source: file.code,
map,
functionMap: null,
path: file.filename,
},
]).toString(),
}),
type: getBytecodeFileType(file.type),
});
}
return {
dependencies,
output,
};
}
/**
* Transforms an asset file
*/
async function transformAsset(file, context) {
const assetTransformer = require("./utils/assetTransformer");
const { assetRegistryPath, assetPlugins } = context.config;
const result = await assetTransformer.transform(
getBabelTransformArgs(file, context),
assetRegistryPath,
assetPlugins
);
const jsFile = {
...file,
type: "js/module/asset",
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, context) {
var _transformResult$func;
const { babelTransformerPath } = context.config; // $FlowFixMe[unsupported-syntax] dynamic require
const transformer = require(babelTransformerPath);
const transformResult = await transformer.transform(
getBabelTransformArgs(file, context)
);
const jsFile = {
...file,
ast: transformResult.ast,
functionMap:
(_transformResult$func = transformResult.functionMap) !== null &&
_transformResult$func !== void 0
? _transformResult$func
: null,
};
return await transformJS(jsFile, context);
}
async function transformJSON(file, { options, config, projectRoot }) {
let code =
config.unstable_disableModuleWrapping === true
? JsFileWrapping.jsonToCommonJS(file.code)
: JsFileWrapping.wrapJson(file.code, config.globalPrefix);
let map = []; // 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;
if (file.type === "asset") {
jsType = "js/module/asset";
} else if (file.type === "script") {
jsType = "js/script";
} else {
jsType = "js/module";
}
const output = [
{
data: {
code,
lineCount: countLines(code),
map,
functionMap: null,
},
type: jsType,
},
];
if (options.runtimeBytecodeVersion != null) {
output.push({
data: compileToBytecode(code, jsType, {
sourceURL: file.filename,
sourceMap: fromRawMappings([
{
code,
source: file.code,
map,
functionMap: null,
path: file.filename,
},
]).toString(),
}),
type: getBytecodeFileType(jsType),
});
}
return {
dependencies: [],
output,
};
}
/**
* Returns the bytecode type for a file type
*/
function getBytecodeFileType(type) {
switch (type) {
case "js/module/asset":
return "bytecode/module/asset";
case "js/script":
return "bytecode/script";
default:
type;
return "bytecode/module";
}
}
function getBabelTransformArgs(file, { options, config, projectRoot }) {
return {
filename: file.filename,
options: {
...options,
enableBabelRCLookup: config.enableBabelRCLookup,
enableBabelRuntime: config.enableBabelRuntime,
globalPrefix: config.globalPrefix,
hermesParser: config.hermesParser,
// Inline requires are now performed at a secondary step. We cannot
// unfortunately remove it from the internal transformer, since this one
// is used by other tooling, and this would affect it.
inlineRequires: false,
nonInlinedRequires: [],
projectRoot,
publicPath: config.publicPath,
},
plugins: [],
src: file.code,
};
}
module.exports = {
transform: async (config, projectRoot, filename, data, options) => {
const context = {
config,
projectRoot,
options,
};
const sourceCode = data.toString("utf8");
const { unstable_dependencyMapReservedName } = config;
if (unstable_dependencyMapReservedName != null) {
const position = sourceCode.indexOf(unstable_dependencyMapReservedName);
if (position > -1) {
throw new SyntaxError(
"Source code contains the reserved string `" +
unstable_dependencyMapReservedName +
"` at character offset " +
position
);
}
}
if (filename.endsWith(".json")) {
const jsonFile = {
filename,
inputFileSize: data.length,
code: sourceCode,
type: options.type,
};
return await transformJSON(jsonFile, context);
}
if (options.type === "asset") {
const file = {
filename,
inputFileSize: data.length,
code: sourceCode,
type: options.type,
};
return await transformAsset(file, context);
}
const file = {
filename,
inputFileSize: data.length,
code: sourceCode,
type: options.type === "script" ? "js/script" : "js/module",
functionMap: null,
};
return await transformJSWithBabel(file, context);
},
getCacheKey: (config) => {
const {
babelTransformerPath,
minifierPath,
unstable_collectDependenciesPath,
...remainingConfig
} = config;
const filesKey = getCacheKey([
require.resolve(babelTransformerPath),
require.resolve(minifierPath),
require.resolve("./utils/getMinifier"),
require.resolve("./utils/assetTransformer"),
require.resolve(unstable_collectDependenciesPath),
require.resolve("metro/src/ModuleGraph/worker/generateImportNames"),
require.resolve("metro/src/ModuleGraph/worker/JsFileWrapping"),
...metroTransformPlugins.getTransformPluginCacheKeyFiles(),
]); // $FlowFixMe[unsupported-syntax]
const babelTransformer = require(babelTransformerPath);
return [
filesKey,
stableHash(remainingConfig).toString("hex"),
babelTransformer.getCacheKey ? babelTransformer.getCacheKey() : "",
].join("$");
},
};