UNPKG

@expo/metro-config

Version:

A Metro config for running React Native projects with the Metro bundler

612 lines 27.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Chunk = void 0; exports.graphToSerialAssetsAsync = graphToSerialAssetsAsync; exports.getSortedModules = getSortedModules; const bundleToString_1 = __importDefault(require("@expo/metro/metro/lib/bundleToString")); const isResolvedDependency_1 = require("@expo/metro/metro/lib/isResolvedDependency"); const assert_1 = __importDefault(require("assert")); const path_1 = __importDefault(require("path")); const computeChunkFilenames_1 = require("./computeChunkFilenames"); const debugId_1 = require("./debugId"); const exportPath_1 = require("./exportPath"); const getCssDeps_1 = require("./getCssDeps"); const sourceMap_1 = require("./sourceMap"); const getAssets_1 = __importDefault(require("../transform-worker/getAssets")); const filePath_1 = require("../utils/filePath"); // Lazy-loaded to avoid pulling in metro-source-map at startup let _buildHermesBundleAsync; function getBuildHermesBundleAsync() { if (!_buildHermesBundleAsync) { _buildHermesBundleAsync = require('./exportHermes').buildHermesBundleAsync; } return _buildHermesBundleAsync; } let _baseJSBundleWithDependencies; function getBaseJSBundleWithDependencies() { if (!_baseJSBundleWithDependencies) { _baseJSBundleWithDependencies = require('./fork/baseJSBundle').baseJSBundleWithDependencies; } return _baseJSBundleWithDependencies; } let _getBaseUrlOption; function getBaseUrlOption(...args) { if (!_getBaseUrlOption) { _getBaseUrlOption = require('./fork/baseJSBundle').getBaseUrlOption; } return _getBaseUrlOption(...args); } let _getPlatformOption; function getPlatformOption(...args) { if (!_getPlatformOption) { _getPlatformOption = require('./fork/baseJSBundle').getPlatformOption; } return _getPlatformOption(...args); } async function graphToSerialAssetsAsync(config, serializeChunkOptions, ...props) { const [entryFile, preModules, graph, options] = props; const cssDeps = (0, getCssDeps_1.getCssSerialAssets)(graph.dependencies, { entryFile, projectRoot: options.projectRoot, }); // Create chunks for splitting. const chunks = new Set(); gatherChunks(preModules, chunks, { test: pathToRegex(entryFile) }, preModules, graph, options, false, true); const entryChunk = findEntryChunk(chunks, entryFile); if (entryChunk) { removeEntryDepsFromAsyncChunks(entryChunk, chunks); const commonChunk = extractCommonChunk(chunks, graph, options); if (commonChunk) { entryChunk.requiredChunks.add(commonChunk); chunks.add(commonChunk); } deduplicateAgainstKnownChunks(chunks, entryChunk, commonChunk); removeEmptyChunks(chunks); if (commonChunk) { createRuntimeChunk(entryChunk, chunks, graph, options); } } // TODO(@kitten): unclear why `isExporting` is hardcoded below const recomputeChunkNames = !!options.serializerOptions?.exporting; const jsAssets = await serializeChunksAsync(chunks, config.serializer ?? {}, serializeChunkOptions, recomputeChunkNames); // TODO: Can this be anything besides true? const isExporting = true; const baseUrl = getBaseUrlOption(graph, { serializerOptions: serializeChunkOptions }); const assetPublicUrl = (baseUrl.replace(/\/+$/, '') ?? '') + '/assets'; const platform = getPlatformOption(graph, options) ?? 'web'; const isHosted = platform === 'web' || (graph.transformOptions?.customTransformOptions?.hosted && isExporting); const publicPath = isExporting ? isHosted ? `/assets?export_path=${assetPublicUrl}` : assetPublicUrl : '/assets/?unstable_path=.'; // TODO: Convert to serial assets // TODO: Disable this call dynamically in development since assets are fetched differently. const metroAssets = (await (0, getAssets_1.default)(graph.dependencies, { processModuleFilter: options.processModuleFilter, assetPlugins: config.transformer?.assetPlugins ?? [], platform, projectRoot: options.projectRoot, // this._getServerRootDir(), publicPath, isHosted, })); return { artifacts: [...jsAssets, ...cssDeps], assets: metroAssets, }; } class Chunk { name; entries; graph; options; isAsync; isVendor; isEntry; deps = new Set(); preModules = new Set(); // Chunks that are required to be loaded synchronously before this chunk. // These are included in the HTML as <script> tags. requiredChunks = new Set(); constructor(name, entries, graph, options, isAsync = false, isVendor = false, isEntry = false) { this.name = name; this.entries = entries; this.graph = graph; this.options = options; this.isAsync = isAsync; this.isVendor = isVendor; this.isEntry = isEntry; this.deps = new Set(entries); } getPlatform() { (0, assert_1.default)(this.graph.transformOptions.platform, "platform is required to be in graph's transformOptions"); return this.graph.transformOptions.platform; } getFilename(src) { return !this.options.serializerOptions?.exporting ? this.name : (0, exportPath_1.getExportPathForDependencyWithOptions)(this.name, { platform: this.getPlatform(), src, serverRoot: this.options.serverRoot, }); } getStableChunkSource(serializerConfig) { return this.options.dev ? '' : this.serializeToCodeWithTemplates(serializerConfig, { // Disable source maps when creating a sha to reduce the number of possible changes that could // influence the cache hit. serializerOptions: { includeSourceMaps: false, }, sourceMapUrl: undefined, debugId: undefined, }).code; } serializeToCodeWithTemplates(serializerConfig, options = {}) { const entryFile = this.name; // TODO: Disable all debugId steps when a dev server is enabled. This is an export-only feature. const preModules = [...(options.preModules ?? this.preModules).values()]; const dependencies = [...this.deps]; const jsSplitBundle = getBaseJSBundleWithDependencies()(entryFile, preModules, dependencies, { ...this.options, runBeforeMainModule: serializerConfig?.getModulesRunBeforeMainModule?.(path_1.default.relative(this.options.projectRoot, entryFile)) ?? [], runModule: this.options.runModule && !this.isVendor && (this.isEntry || !this.isAsync), modulesOnly: this.options.modulesOnly || preModules.length === 0, platform: this.getPlatform(), baseUrl: getBaseUrlOption(this.graph, this.options), splitChunks: !!this.options.serializerOptions?.splitChunks, skipWrapping: true, computedAsyncModulePaths: null, ...options, }); return { code: (0, bundleToString_1.default)(jsSplitBundle).code, paths: jsSplitBundle.paths }; } hasAbsolutePath(absolutePath) { for (const dep of this.deps) { if (dep.path === absolutePath) { return true; } } return false; } _asyncTargets; getAsyncChunkTargets(chunkByPath) { if (this._asyncTargets) { return this._asyncTargets; } const targets = new Set(); this._asyncTargets = targets; if (this.options.includeAsyncPaths) { return targets; } for (const module of this.deps) { for (const dep of module.dependencies.values()) { if ((0, isResolvedDependency_1.isResolvedDependency)(dep) && dep.data.data.asyncType) { const target = chunkByPath.get(dep.absolutePath); // NOTE(kitten): Chunk merges can leave async imports pointing at non-async // (entry/vendor) chunks; those are loaded eagerly so we skip them here. if (target?.isAsync) targets.add(target); } } } return targets; } getComputedPathsForAsyncDependencies(chunksByPath, filenamesByChunk) { const baseUrl = getBaseUrlOption(this.graph, this.options); // Only calculate production paths when all chunks are being exported. if (this.options.includeAsyncPaths) { return null; } const computedAsyncModulePaths = {}; this.deps.forEach((module) => { module.dependencies.forEach((dependency) => { if ((0, isResolvedDependency_1.isResolvedDependency)(dependency) && dependency.data.data.asyncType) { const chunkContainingModule = chunksByPath.get(dependency.absolutePath); (0, assert_1.default)(chunkContainingModule, 'Chunk containing module not found: ' + dependency.absolutePath); if (chunkContainingModule.isAsync) { const moduleIdName = filenamesByChunk.get(chunkContainingModule); (0, assert_1.default)(moduleIdName, 'Precomputed filename missing for async chunk: ' + chunkContainingModule.name); computedAsyncModulePaths[dependency.absolutePath] = (baseUrl ?? '/') + moduleIdName; } } }); }); return computedAsyncModulePaths; } getAdjustedSourceMapUrl(filename) { // Metro really only accounts for development, so we'll use the defaults here. if (this.options.dev) { return this.options.sourceMapUrl ?? null; } if (this.options.serializerOptions?.includeSourceMaps !== true) { return null; } if (this.options.inlineSourceMap || !this.options.sourceMapUrl) { return this.options.sourceMapUrl ?? null; } const platform = this.getPlatform(); const isAbsolute = platform !== 'web'; const baseUrl = getBaseUrlOption(this.graph, this.options); const isAbsoluteBaseUrl = !!baseUrl?.match(/https?:\/\//); const pathname = (isAbsoluteBaseUrl ? '' : baseUrl.replace(/\/+$/, '')) + '/' + filename.replace(/^\/+$/, '') + '.map'; let adjustedSourceMapUrl = this.options.sourceMapUrl; // Metro has lots of issues... if (this.options.sourceMapUrl.startsWith('//localhost')) { adjustedSourceMapUrl = 'http:' + this.options.sourceMapUrl; } try { const parsed = new URL(pathname, isAbsoluteBaseUrl ? baseUrl : adjustedSourceMapUrl); if (isAbsoluteBaseUrl || isAbsolute) { return parsed.href; } return parsed.pathname; } catch (error) { // NOTE: export:embed that don't use baseUrl will use file paths instead of URLs. if (!this.options.dev && isAbsolute) { return adjustedSourceMapUrl; } console.error(`Failed to link source maps because the source map URL "${this.options.sourceMapUrl}" is corrupt:`, error); return null; } } serializeToCode(serializerConfig, { debugId, chunksByPath, filenamesByChunk, filename, preModules, }) { return this.serializeToCodeWithTemplates(serializerConfig, { skipWrapping: false, sourceMapUrl: this.getAdjustedSourceMapUrl(filename) ?? undefined, computedAsyncModulePaths: this.getComputedPathsForAsyncDependencies(chunksByPath, filenamesByChunk), debugId, preModules, }); } boolishTransformOption(name) { const value = this.graph.transformOptions?.customTransformOptions?.[name]; return value === true || value === 'true' || value === '1'; } async serializeToAssetsAsync(serializerConfig, chunksByPath, filenamesByChunk, { includeSourceMaps, unstable_beforeAssetSerializationPlugins }) { const outputFile = filenamesByChunk.get(this); (0, assert_1.default)(outputFile, 'Precomputed filename missing for chunk: ' + this.name); // We already use a stable hash for the output filename, so we'll reuse that for the debugId. const debugId = (0, debugId_1.stringToUUID)(path_1.default.basename(outputFile, path_1.default.extname(outputFile))); let finalPreModules = [...this.preModules]; if (unstable_beforeAssetSerializationPlugins) { for (const plugin of unstable_beforeAssetSerializationPlugins) { finalPreModules = plugin({ graph: this.graph, premodules: finalPreModules, debugId, }); } } const jsCode = this.serializeToCode(serializerConfig, { chunksByPath, filenamesByChunk, filename: outputFile, debugId, preModules: new Set(finalPreModules), }); const relativeEntry = path_1.default.relative(this.options.projectRoot, this.name); const jsAsset = { filename: outputFile, originFilename: relativeEntry, type: 'js', metadata: { isAsync: this.isAsync, requires: [...this.requiredChunks.values()].map((chunk) => { const filename = filenamesByChunk.get(chunk); (0, assert_1.default)(filename, 'Precomputed filename missing for required chunk: ' + chunk.name); return filename; }), // Provide a list of module paths that can be used for matching chunks to routes. // TODO: Move HTML serializing closer to this code so we can reduce passing this much data around. modulePaths: [...this.deps].map((module) => module.path), paths: jsCode.paths, expoDomComponentReferences: collectOutputReferences(this.deps, 'expoDomComponentReference'), reactClientReferences: collectOutputReferences(this.deps, 'reactClientReference'), reactServerReferences: collectOutputReferences(this.deps, 'reactServerReference'), loaderReferences: collectOutputReferences(this.deps, 'loaderReference'), }, source: jsCode.code, }; const assets = [jsAsset]; // debugId is passed into `sourceMapString` so the bundler-map path // emits it inline rather than a JSON.parse + JSON.stringify // roundtrip; the Hermes branch below has to splice into a finished // JSON string because `buildHermesBundleAsync` is opaque. // NOTE: skipped for inline source maps since they don't need // disambiguation. We only emit `debugId` (Sentry also reads // `debug_id`, but bun doesn't). if ( // Only include the source map if the `options.sourceMapUrl` option is provided and we are exporting a static build. includeSourceMaps && !this.options.inlineSourceMap && this.options.sourceMapUrl) { const modules = [ ...finalPreModules, ...getSortedModules([...this.deps], { createModuleId: this.options.createModuleId, }), ].map((module) => { // TODO: Make this user-configurable. // Make all paths relative to the server root to prevent the entire user filesystem from being exposed. if (path_1.default.isAbsolute(module.path)) { return { ...module, path: '/' + (0, filePath_1.toPosixPath)(path_1.default.relative(this.options.serverRoot ?? this.options.projectRoot, module.path)), }; } return module; }); // TODO: We may not need to set `debugId` on the bundler sourcemap when // Hermes is enabled, since we ship a separate `.hbc.map` for that case. const sourceMap = (0, sourceMap_1.sourceMapString)(modules, { excludeSource: false, ...this.options, debugId, }); assets.push({ filename: this.options.dev ? jsAsset.filename + '.map' : outputFile + '.map', originFilename: jsAsset.originFilename, type: 'map', metadata: {}, source: sourceMap, }); } if (this.boolishTransformOption('bytecode') && this.isHermesEnabled()) { const adjustedSource = jsAsset.source.replace(/^\/\/# (sourceMappingURL)=(.*)$/gm, (...props) => { if (props[1] === 'sourceMappingURL') { const mapName = props[2].replace(/\.js\.map$/, '.hbc.map'); return `//# ${props[1]}=` + mapName; } return ''; }); // TODO: Generate hbc for each chunk const hermesBundleOutput = await getBuildHermesBundleAsync()({ projectRoot: this.options.projectRoot, filename: this.name, code: adjustedSource, map: assets[1] ? assets[1].source : null, // TODO: Maybe allow prod + no minify. minify: true, //!this.options.dev, }); if (hermesBundleOutput.hbc) { // TODO: Unclear if we should add multiple assets, link the assets, or mutate the first asset. // jsAsset.metadata.hbc = hermesBundleOutput.hbc; // @ts-expect-error: TODO jsAsset.source = hermesBundleOutput.hbc; jsAsset.filename = jsAsset.filename.replace(/\.js$/, '.hbc'); // Replace mappings with hbc if (jsAsset.metadata.paths) { jsAsset.metadata.paths = Object.fromEntries(Object.entries(jsAsset.metadata.paths).map(([key, value]) => [ key, Object.fromEntries(Object.entries(value).map(([key, value]) => [ key, value ? value.replace(/\.js$/, '.hbc') : value, ])), ])); } } if (assets[1] && hermesBundleOutput.sourcemap) { assets[1].source = debugId ? (0, sourceMap_1.appendDebugIdToSourceMap)(hermesBundleOutput.sourcemap, debugId) : hermesBundleOutput.sourcemap; assets[1].filename = assets[1].filename.replace(/\.js\.map$/, '.hbc.map'); } } return assets; } supportsBytecode() { return this.getPlatform() !== 'web'; } isHermesEnabled() { // TODO: Revisit. // TODO: There could be an issue with having the serializer for export:embed output hermes since the native scripts will // also create hermes bytecode. We may need to disable in one of the two places. return (!this.options.dev && this.supportsBytecode() && this.graph.transformOptions.customTransformOptions?.engine === 'hermes'); } } exports.Chunk = Chunk; function getSortedModules(modules, { createModuleId, }) { // Assign IDs to modules in a consistent order for (const module of modules) { createModuleId(module.path); } // Sort by IDs return modules.sort((a, b) => createModuleId(a.path) - createModuleId(b.path)); } // Convert file paths to regex matchers. function pathToRegex(path) { // Escape regex special characters, except for '*' let regexSafePath = path.replace(/[-[\]{}()+?.,\\^$|#\s]/g, '\\$&'); // Replace '*' with '.*' to act as a wildcard in regex regexSafePath = regexSafePath.replace(/\*/g, '.*'); // Create a RegExp object with the modified string return new RegExp('^' + regexSafePath + '$'); } function collectOutputReferences(modules, key) { return [ ...new Set([...modules] .map((module) => { return module.output.map((output) => { // TODO: This is a mess. This needs to be properly typed const data = output.data; if (key in data && typeof data[key] === 'string') { return data[key]; } return undefined; }); }) .flat()), ].filter((value) => typeof value === 'string'); } function getEntryModulesForChunkSettings(graph, settings) { return [...graph.dependencies.entries()] .filter(([path]) => settings.test.test(path)) .map(([, module]) => module); } function chunkIdForModules(modules) { return modules .map((module) => module.path) .sort() .join('=>'); } function gatherChunks(runtimePremodules, chunks, settings, preModules, graph, options, isAsync = false, isEntry = false) { let entryModules = getEntryModulesForChunkSettings(graph, settings); const existingChunks = [...chunks.values()]; entryModules = entryModules.filter((module) => { return !existingChunks.find((chunk) => chunk.entries.includes(module)); }); // Prevent processing the same entry file twice. if (!entryModules.length) { return chunks; } const entryChunk = new Chunk(chunkIdForModules(entryModules), entryModules, graph, options, isAsync, false, isEntry); // Add all the pre-modules to the first chunk. if (preModules.length) { // On native, use the preModules in insert code in the entry chunk. for (const module of preModules.values()) { entryChunk.preModules.add(module); } } chunks.add(entryChunk); function includeModule(entryModule) { for (const dependency of entryModule.dependencies.values()) { if (!(0, isResolvedDependency_1.isResolvedDependency)(dependency)) { continue; } else if (dependency.data.data.asyncType && // Support disabling multiple chunks. entryChunk.options.serializerOptions?.splitChunks !== false) { const isEntry = dependency.data.data.asyncType === 'worker'; gatherChunks(runtimePremodules, chunks, { test: pathToRegex(dependency.absolutePath) }, isEntry ? runtimePremodules : [], graph, options, true, isEntry); } else { const module = graph.dependencies.get(dependency.absolutePath); if (module) { // Prevent circular dependencies from creating infinite loops. if (!entryChunk.deps.has(module)) { entryChunk.deps.add(module); includeModule(module); } } } } } for (const entryModule of entryModules) { includeModule(entryModule); } return chunks; } function findEntryChunk(chunks, entryFile) { return [...chunks.values()].find((chunk) => !chunk.isAsync && chunk.hasAbsolutePath(entryFile)); } function removeEntryDepsFromAsyncChunks(entryChunk, chunks) { for (const chunk of chunks.values()) { if (!chunk.isEntry && chunk.isAsync) { for (const dep of chunk.deps.values()) { if (entryChunk.deps.has(dep)) { // Remove the dependency from the async chunk since it will be loaded in the main chunk. chunk.deps.delete(dep); } } } } } function extractCommonChunk(chunks, graph, options) { const toCompare = [...chunks.values()]; const commonDependencies = []; while (toCompare.length) { const chunk = toCompare.shift(); for (const chunk2 of toCompare) { if (chunk !== chunk2 && chunk.isAsync && chunk2.isAsync) { const commonDeps = [...chunk.deps].filter((dep) => chunk2.deps.has(dep)); for (const dep of commonDeps) { chunk.deps.delete(dep); chunk2.deps.delete(dep); } commonDependencies.push(...commonDeps); } } } // If common dependencies were found, extract them to the shared chunk. if (commonDependencies.length) { const commonDependenciesUnique = [...new Set(commonDependencies)]; return new Chunk('/__common.js', commonDependenciesUnique, graph, options, false, true); } return undefined; } function deduplicateAgainstKnownChunks(chunks, entryChunk, commonChunk) { // TODO: Optimize this pass more. // Remove all dependencies from async chunks that are already in the common chunk. for (const chunk of [...chunks.values()]) { if (!chunk.isEntry && chunk !== commonChunk) { for (const dep of chunk.deps) { if (entryChunk.deps.has(dep) || commonChunk?.deps.has(dep)) { chunk.deps.delete(dep); } } } } } function removeEmptyChunks(chunks) { for (const chunk of [...chunks.values()]) { if (!chunk.isEntry && chunk.deps.size === 0) { chunks.delete(chunk); } } } function createRuntimeChunk(entryChunk, chunks, graph, options) { const runtimeChunk = new Chunk('/__expo-metro-runtime.js', [], graph, options, false, true); // All premodules (including metro-runtime) should load first for (const preModule of entryChunk.preModules) { runtimeChunk.preModules.add(preModule); } entryChunk.preModules = new Set(); for (const chunk of chunks) { // Runtime chunk has to load before any other a.k.a all chunks require it. chunk.requiredChunks.add(runtimeChunk); } chunks.add(runtimeChunk); } function makeChunkByPathLookupMap(chunks) { const chunkByPath = new Map(); for (const chunk of chunks) { for (const module of chunk.deps) { if (!chunkByPath.has(module.path)) { chunkByPath.set(module.path, chunk); } } } return chunkByPath; } async function serializeChunksAsync(chunks, serializerConfig, options, recomputeChunkNames) { const jsAssets = []; const chunksByPath = makeChunkByPathLookupMap(chunks); const filenamesByChunk = (0, computeChunkFilenames_1.precomputeChunkFilenames)({ chunks, chunksByPath, serializerConfig, recomputeChunkNames, }); const serializeTasks = []; for (const chunk of chunks) { serializeTasks.push((async () => { jsAssets.push(...(await chunk.serializeToAssetsAsync(serializerConfig, chunksByPath, filenamesByChunk, options))); })()); } await Promise.all(serializeTasks); return jsAssets; } //# sourceMappingURL=serializeChunks.js.map