UNPKG

@expo/metro-config

Version:

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

539 lines 25.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; /** * Copyright © 2023 650 Industries. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ const assert_1 = __importDefault(require("assert")); const sourceMapString_1 = __importDefault(require("metro/src/DeltaBundler/Serializers/sourceMapString")); const bundleToString_1 = __importDefault(require("metro/src/lib/bundleToString")); const path_1 = __importDefault(require("path")); const debugId_1 = require("./debugId"); const exportHermes_1 = require("./exportHermes"); const exportPath_1 = require("./exportPath"); const baseJSBundle_1 = require("./fork/baseJSBundle"); const getCssDeps_1 = require("./getCssDeps"); const getAssets_1 = __importDefault(require("../transform-worker/getAssets")); const filePath_1 = require("../utils/filePath"); // 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 + '$'); } const sourceMapString = typeof sourceMapString_1.default !== 'function' ? sourceMapString_1.default.sourceMapString : sourceMapString_1.default; 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(); [ { test: pathToRegex(entryFile), }, ].map((chunkSettings) => gatherChunks(preModules, chunks, chunkSettings, preModules, graph, options, false, true)); // Get the common modules and extract them into a separate chunk. const entryChunk = [...chunks.values()].find((chunk) => !chunk.isAsync && chunk.hasAbsolutePath(entryFile)); if (entryChunk) { 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); } } } } 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 entry chunk. // TODO: Extract the metro-runtime to a common chunk apart from the entry chunk then load the common dependencies before the entry chunk. if (commonDependencies.length) { for (const dep of commonDependencies) { entryChunk.deps.add(dep); } // const commonDependenciesUnique = [...new Set(commonDependencies)]; // const commonChunk = new Chunk( // chunkIdForModules(commonDependenciesUnique), // commonDependenciesUnique, // graph, // options, // false, // true // ); // entryChunk.requiredChunks.add(commonChunk); // chunks.add(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) { for (const dep of chunk.deps) { if (entryChunk.deps.has(dep)) { chunk.deps.delete(dep); } } } } // Remove empty chunks for (const chunk of [...chunks.values()]) { if (!chunk.isEntry && chunk.deps.size === 0) { chunks.delete(chunk); } } } const jsAssets = await serializeChunksAsync(chunks, config.serializer ?? {}, serializeChunkOptions); // TODO: Can this be anything besides true? const isExporting = true; const baseUrl = (0, baseJSBundle_1.getBaseUrlOption)(graph, { serializerOptions: serializeChunkOptions }); const assetPublicUrl = (baseUrl.replace(/\/+$/, '') ?? '') + '/assets'; const publicPath = isExporting ? graph.transformOptions.platform === 'web' ? `/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: (0, baseJSBundle_1.getPlatformOption)(graph, options) ?? 'web', projectRoot: options.projectRoot, // this._getServerRootDir(), publicPath, })); 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; } getFilenameForConfig(serializerConfig) { return this.getFilename(this.getStableChunkSource(serializerConfig)); } 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 = (0, baseJSBundle_1.baseJSBundleWithDependencies)(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: (0, baseJSBundle_1.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) { return [...this.deps].some((module) => module.path === absolutePath); } getComputedPathsForAsyncDependencies(serializerConfig, chunks) { const baseUrl = (0, baseJSBundle_1.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 (dependency.data.data.asyncType) { const chunkContainingModule = chunks.find((chunk) => chunk.hasAbsolutePath(dependency.absolutePath)); (0, assert_1.default)(chunkContainingModule, 'Chunk containing module not found: ' + dependency.absolutePath); // NOTE(kitten): We shouldn't have any async imports on non-async chunks // However, due to how chunks merge, some async imports may now be pointing // at entrypoint (or vendor) chunks. We omit the path so that the async import // helper doesn't reload and reevaluate the entrypoint. if (chunkContainingModule.isAsync) { const moduleIdName = chunkContainingModule.getFilenameForConfig(serializerConfig); computedAsyncModulePaths[dependency.absolutePath] = (baseUrl ?? '/') + moduleIdName; } } }); }); return computedAsyncModulePaths; } getAdjustedSourceMapUrl(serializerConfig) { // 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 = (0, baseJSBundle_1.getBaseUrlOption)(this.graph, this.options); const filename = this.getFilenameForConfig(serializerConfig); 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, chunks, preModules }) { return this.serializeToCodeWithTemplates(serializerConfig, { skipWrapping: false, sourceMapUrl: this.getAdjustedSourceMapUrl(serializerConfig) ?? undefined, computedAsyncModulePaths: this.getComputedPathsForAsyncDependencies(serializerConfig, chunks), debugId, preModules, }); } boolishTransformOption(name) { const value = this.graph.transformOptions?.customTransformOptions?.[name]; return value === true || value === 'true' || value === '1'; } async serializeToAssetsAsync(serializerConfig, chunks, { includeSourceMaps, unstable_beforeAssetSerializationPlugins }) { // Create hash without wrapping to prevent it changing when the wrapping changes. const outputFile = this.getFilenameForConfig(serializerConfig); // 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, { chunks, 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) => chunk.getFilenameForConfig(serializerConfig)), // 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: [ ...new Set([...this.deps] .map((module) => { return module.output.map((output) => { if ('expoDomComponentReference' in output.data && typeof output.data.expoDomComponentReference === 'string') { return output.data.expoDomComponentReference; } return undefined; }); }) .flat()), ].filter((value) => typeof value === 'string'), reactClientReferences: [ ...new Set([...this.deps] .map((module) => { return module.output.map((output) => { if ('reactClientReference' in output.data && typeof output.data.reactClientReference === 'string') { return output.data.reactClientReference; } return undefined; }); }) .flat()), ].filter((value) => typeof value === 'string'), reactServerReferences: [ ...new Set([...this.deps] .map((module) => { return module.output.map((output) => { if ('reactServerReference' in output.data && typeof output.data.reactServerReference === 'string') { return output.data.reactServerReference; } return undefined; }); }) .flat()), ].filter((value) => typeof value === 'string'), }, source: jsCode.code, }; const assets = [jsAsset]; const mutateSourceMapWithDebugId = (sourceMap) => { // TODO: Upstream this so we don't have to parse the source map back and forth. if (!debugId) { return sourceMap; } // NOTE: debugId isn't required for inline source maps because the source map is included in the same file, therefore // we don't need to disambiguate between multiple source maps. const sourceMapObject = JSON.parse(sourceMap); sourceMapObject.debugId = debugId; // NOTE: Sentry does this, but bun does not. // sourceMapObject.debug_id = debugId; return JSON.stringify(sourceMapObject); }; 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 mutate the original source map with a `debugId` when hermes is enabled since we'll have different source maps. const sourceMap = mutateSourceMapWithDebugId(sourceMapString(modules, { excludeSource: false, ...this.options, })); 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 (0, exportHermes_1.buildHermesBundleAsync)({ 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 = mutateSourceMapWithDebugId(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 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 (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; } async function serializeChunksAsync(chunks, serializerConfig, options) { const jsAssets = []; const chunksArray = [...chunks.values()]; await Promise.all(chunksArray.map(async (chunk) => { jsAssets.push(...(await chunk.serializeToAssetsAsync(serializerConfig, chunksArray, options))); })); return jsAssets; } 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)); } //# sourceMappingURL=serializeChunks.js.map