diff --git a/packages/metro-symbolicate/src/Symbolication.js b/packages/metro-symbolicate/src/Symbolication.js index 585c0055a0..21b985e9e0 100644 --- a/packages/metro-symbolicate/src/Symbolication.js +++ b/packages/metro-symbolicate/src/Symbolication.js @@ -165,7 +165,7 @@ class SymbolicationContext { // IOS: foo.js:57:foo, Android: bar.js:75:bar symbolicate(stackTrace: string): string { return stackTrace.replace( - /(?:([^@: \n(]+)(@|:))?(?:(?:([^@: \n(]+):)?(\d+):(\d+)|\[native code\])/g, + /(?:([^@: \n(]+)(@|:))?(?:(?:([^@: \n(]+):)?(\d+):(\d+)|\[native code\](?::\d+:\d+)?)/g, (match, func, delimiter, fileName, line, column) => { if (delimiter === ':' && func && !fileName) { fileName = func; @@ -694,10 +694,25 @@ class SingleMapSymbolicationContext extends SymbolicationContext (some message with no frame) +1 ??? 0x0 throws6 + 1 (thrower.min.js:1:161) +2 ??? 0x0 o + 1 (thrower.min.js:1:464) +3 ??? 0x0 ([native code]:0:0) +4 ??? 0x0 ([native code]:0:0) diff --git a/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap b/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap index e2b6639165..d2135b3829 100644 --- a/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap +++ b/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap @@ -216,6 +216,16 @@ Array [ ] `; +exports[`symbolicating a crash reporter stack trace with 0:0 native frames 1`] = ` +"Non-fatal Exception: JavaScriptError +0 ??? 0x0 (some message with no frame) +1 ??? 0x0 throws6 + 1 (thrower.js:18:null) +2 ??? 0x0 o + 1 (thrower.js:30:arguments) +3 ??? 0x0 (null:null:null) +4 ??? 0x0 (null:null:null) +" +`; + exports[`symbolicating a profiler map 1`] = ` "JS_0000_xxxxxxxxxxxxxxxxxxxxxx throws0::thrower.js:48:11 JS_0001_xxxxxxxxxxxxxxxxxxxxxx throws6::thrower.js:35:38 diff --git a/packages/metro-symbolicate/src/__tests__/symbolicate-test.js b/packages/metro-symbolicate/src/__tests__/symbolicate-test.js index 78989fe817..24e40c17d6 100644 --- a/packages/metro-symbolicate/src/__tests__/symbolicate-test.js +++ b/packages/metro-symbolicate/src/__tests__/symbolicate-test.js @@ -215,6 +215,11 @@ test('symbolicating a stack trace in Node format', async () => execute([TESTFILE_MAP], read('testfile.node.stack')), ).resolves.toMatchSnapshot()); +test('symbolicating a crash reporter stack trace with 0:0 native frames', async () => + await expect( + execute([TESTFILE_MAP], read('testfile.crashreporter.stack')), + ).resolves.toMatchSnapshot()); + test('symbolicating a single entry', async () => await expect(execute([TESTFILE_MAP, '1', '161'])).resolves.toEqual( 'thrower.js:18:null\n', diff --git a/packages/metro-transform-worker/API.md b/packages/metro-transform-worker/API.md index df6d43c32e..40e3dbc4c9 100644 --- a/packages/metro-transform-worker/API.md +++ b/packages/metro-transform-worker/API.md @@ -74,7 +74,14 @@ export type MinifierOptions = { export type MinifierResult = {code: string; map?: BasicSourceMap | undefined}; -export const transform: (config: JsTransformerConfig, projectRoot: string, projectRelativePath: string, data: Buffer, options: JsTransformOptions) => Promise; +export const transform: ( +config: JsTransformerConfig, +projectRoot: string, +projectRelativePath: string, +data: Buffer, +options: JsTransformOptions, +assetUrlPath?: string, +) => Promise; export type transform = typeof transform; diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 2387b786ba..21c78542c8 100644 --- a/packages/metro-transform-worker/src/__tests__/index-test.js +++ b/packages/metro-transform-worker/src/__tests__/index-test.js @@ -135,6 +135,27 @@ test('transforms a simple module', async () => { expect(result.dependencies).toEqual([]); }); +test('uses the indexed watch folder path for asset URLs', async () => { + fs.mkdirSync('/root/external', {recursive: true}); + fs.writeFileSync('/root/external/test.mp4', 'asset data'); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'external/test.mp4', + Buffer.from('asset data'), + { + ...baseTransformOptions, + type: 'asset', + }, + '[metro-watchFolders]/1/test.mp4', + ); + + expect(result.output[0].data.code).toContain( + '"httpServerLocation": "/assets/[metro-watchFolders]/1"', + ); +}); + test('transforms a module with dependencies', async () => { const contents = [ '"use strict";', diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index c48f6f8025..b1c2d9f4a2 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -162,6 +162,7 @@ type JSONFile = { }; type TransformationContext = Readonly<{ + assetUrlPath?: string, config: JsTransformerConfig, projectRoot: AbsolutePath, options: JsTransformOptions, @@ -537,6 +538,7 @@ async function transformAsset( getBabelTransformArgs(file, context), assetRegistryPath, assetPlugins, + context.assetUrlPath, ); const jsFile = { @@ -677,8 +679,10 @@ export const transform = async ( projectRelativePath: string, data: Buffer, options: JsTransformOptions, + assetUrlPath?: string, ): Promise => { const context: TransformationContext = { + assetUrlPath, config, options, projectRoot, diff --git a/packages/metro-transform-worker/src/utils/assetTransformer.js b/packages/metro-transform-worker/src/utils/assetTransformer.js index f3bd13d5e8..e705adaff4 100644 --- a/packages/metro-transform-worker/src/utils/assetTransformer.js +++ b/packages/metro-transform-worker/src/utils/assetTransformer.js @@ -20,6 +20,7 @@ export async function transform( {filename, options, src}: BabelTransformerArgs, assetRegistryPath: string, assetDataPlugins: ReadonlyArray, + assetUrlPath?: string, ): Promise<{ast: File, ...}> { options = options || { platform: '', @@ -32,7 +33,7 @@ export async function transform( const data = await getAssetData( absolutePath, - filename, + assetUrlPath ?? filename, assetDataPlugins, options.platform, options.publicPath, diff --git a/packages/metro/src/Assets.js b/packages/metro/src/Assets.js index 2b5ef3598a..5ceb03cb09 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -243,6 +243,41 @@ export async function getAssetData( return await applyAssetDataPlugins(assetDataPlugins, assetData); } +/** + * Returns the path used to identify an asset in its development server URL. + * Assets outside projectRoot use an indexed watch folder prefix so that the + * URL unambiguously identifies their configured root. + */ +export function getAssetUrlPath( + assetPath: string, + projectRoot: string, + watchFolders: ReadonlyArray, +): string { + const projectRelativePath = path.relative(projectRoot, assetPath); + if (isPathInsideRoot(projectRelativePath)) { + return normalizePathSeparatorsToPosix(projectRelativePath); + } + + for (let i = 0; i < watchFolders.length; i++) { + const watchFolderRelativePath = path.relative(watchFolders[i], assetPath); + if (isPathInsideRoot(watchFolderRelativePath)) { + return normalizePathSeparatorsToPosix( + path.join('[metro-watchFolders]', String(i), watchFolderRelativePath), + ); + } + } + + return normalizePathSeparatorsToPosix(projectRelativePath); +} + +function isPathInsideRoot(relativePath: string): boolean { + return ( + relativePath !== '..' && + !relativePath.startsWith('..' + path.sep) && + !path.isAbsolute(relativePath) + ); +} + async function applyAssetDataPlugins( assetDataPlugins: ReadonlyArray, assetData: AssetData, diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js index c13c148b66..08682ce876 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js @@ -10,7 +10,7 @@ jest.mock('../../../Assets'); -import {getAssetData} from '../../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../../Assets'; import getAssets from '../getAssets'; beforeEach(() => { @@ -18,6 +18,9 @@ beforeEach(() => { path, localPath, })); + getAssetUrlPath.mockImplementation( + jest.requireActual('../../../Assets').getAssetUrlPath, + ); }); test('should return the bundle assets', async () => { @@ -82,16 +85,32 @@ test('should return the bundle assets', async () => { ], }, ], + [ + '/external/6.png', + { + path: '/external/6.png', + output: [ + { + type: 'js/module/asset', + data: {code: '//', lineCount: 1, map: [], functionMap: null}, + }, + ], + }, + ], ]); expect( await getAssets(dependencies, { projectRoot: '/tmp', - watchFolders: ['/tmp'], + watchFolders: ['/tmp', '/external'], processModuleFilter: () => true, }), ).toEqual([ {path: '/tmp/3.png', localPath: '3.png'}, {path: '/tmp/5.mov', localPath: '5.mov'}, + { + path: '/external/6.png', + localPath: '[metro-watchFolders]/1/6.png', + }, ]); }); diff --git a/packages/metro/src/DeltaBundler/Serializers/getAssets.js b/packages/metro/src/DeltaBundler/Serializers/getAssets.js index bc0c5d94d1..8111a1e1c4 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getAssets.js +++ b/packages/metro/src/DeltaBundler/Serializers/getAssets.js @@ -12,7 +12,7 @@ import type {AssetData} from '../../Assets'; import type {Module, ReadOnlyDependencies} from '../types'; -import {getAssetData} from '../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../Assets'; import {getJsOutput, isJsModule} from './helpers/js'; import path from 'node:path'; @@ -22,6 +22,7 @@ type Options = { platform: ?string, projectRoot: string, publicPath: string, + watchFolders: ReadonlyArray, }; export default async function getAssets( @@ -41,7 +42,11 @@ export default async function getAssets( promises.push( getAssetData( module.path, - path.relative(options.projectRoot, module.path), + getAssetUrlPath( + module.path, + options.projectRoot, + options.watchFolders, + ), options.assetPlugins, options.platform, options.publicPath, diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index 4fe9cfccc7..b1c8a9745f 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -13,6 +13,7 @@ import type {TransformResult, TransformResultWithSource} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions} from './Worker'; import type {ConfigT} from 'metro-config'; +import {getAssetUrlPath} from '../Assets'; import {normalizePathSeparatorsToPosix} from '../lib/pathUtils'; import getTransformCacheKey from './getTransformCacheKey'; import WorkerFarm from './WorkerFarm'; @@ -112,6 +113,14 @@ export default class Transformer { this._config.projectRoot, filePath, ); + const assetUrlPath = + type === 'asset' + ? getAssetUrlPath( + filePath, + this._config.projectRoot, + this._config.watchFolders, + ) + : null; const partialKey = stableHash([ // This is the hash related to the global Bundler config. @@ -121,6 +130,9 @@ export default class Transformer { // addition to content hash because transformers receive path as an // input, and may apply e.g. extension-based logic. normalizePathSeparatorsToPosix(projectRelativePath), + assetUrlPath == null + ? null + : normalizePathSeparatorsToPosix(assetUrlPath), customTransformOptions, dev, experimentalImportSupport, @@ -170,7 +182,9 @@ export default class Transformer { ? {result, sha1} : await this._workerFarm.transform( projectRelativePath, - transformerOptions, + assetUrlPath == null + ? transformerOptions + : {...transformerOptions, unstable_assetUrlPath: assetUrlPath}, content, ); diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index c13dcc4e07..112c771999 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -21,7 +21,12 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -export type {JsTransformOptions as TransformOptions} from 'metro-transform-worker'; +export type TransformOptions = Readonly<{ + ...JsTransformOptions, + // Passed separately to the default transformer and not exposed to custom + // Babel transformers. + unstable_assetUrlPath?: string, +}>; type TransformerInterface = { transform( @@ -30,6 +35,7 @@ type TransformerInterface = { string, Buffer, JsTransformOptions, + ?string, ): Promise>, }; @@ -68,7 +74,7 @@ function asDeserializedBuffer(value: any): Buffer | null { export const transform = ( filename: string, - transformOptions: JsTransformOptions, + transformOptions: TransformOptions, projectRoot: string, transformerConfig: TransformerConfig, fileBuffer?: Buffer, @@ -97,7 +103,7 @@ export type Worker = { async function transformFile( projectRelativePath: string, data: Buffer, - transformOptions: JsTransformOptions, + transformOptions: TransformOptions, projectRoot: string, transformerConfig: TransformerConfig, ): Promise { @@ -117,12 +123,15 @@ async function transformFile( const sha1 = crypto.createHash('sha1').update(data).digest('hex'); + const {unstable_assetUrlPath: assetUrlPath, ...publicTransformOptions} = + transformOptions; const result = await Transformer.transform( transformerConfig.transformerConfig, projectRoot, projectRelativePath, data, - transformOptions, + publicTransformOptions, + assetUrlPath, ); // The babel cache caches scopes and pathes for already traversed AST nodes. diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 6d49546a78..cf57e61ad8 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -22,6 +22,7 @@ jest const Transformer = require('../Transformer').default; const {getDefaultValues} = require('metro-config').getDefaultConfig; const {mergeConfig} = require('metro-config/private/loadConfig'); +const path = require('node:path'); const fs = jest.requireMock('node:fs'); @@ -215,4 +216,37 @@ describe('Transformer', function () { expect(require('../getTransformCacheKey')).not.toBeCalled(); }); + + test('passes an indexed watch folder URL path to asset transforms', async () => { + const workerTransform = + require('../WorkerFarm').default.prototype.transform; + workerTransform.mockClear(); + workerTransform.mockReturnValue({ + sha1: 'abcdefabcdefabcdefabcdefabcdefabcdefabcd', + result: {}, + }); + fs.mkdirSync('/external', {recursive: true}); + + const transformerInstance = new Transformer( + { + ...commonOptions, + cacheStores: [], + watchFolders: ['/root', '/external'], + }, + {getOrComputeSha1}, + ); + + await transformerInstance.transformFile('/external/imgs/a.png', { + type: 'asset', + }); + + expect(workerTransform).toHaveBeenCalledWith( + path.join('..', 'external', 'imgs', 'a.png'), + { + type: 'asset', + unstable_assetUrlPath: '[metro-watchFolders]/1/imgs/a.png', + }, + undefined, + ); + }); }); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 42c3cbebfc..ebaf0988a2 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -450,6 +450,7 @@ export default class Server { platform, projectRoot: this._getServerRootDir(), publicPath: this._config.transformer.publicPath, + watchFolders: this._config.watchFolders, }); } @@ -570,9 +571,12 @@ export default class Server { try { const depGraph = await this._bundler.getBundler().getDependencyGraph(); + const resolvedAssetPath = this._resolveWatchFolderPrefix( + './' + assetPath, + ); const data = await getAsset( - assetPath, - this._config.projectRoot, + resolvedAssetPath?.filePath ?? assetPath, + resolvedAssetPath?.rootDir ?? this._config.projectRoot, this._config.watchFolders, urlObj.searchParams.get('platform'), this._config.resolver.assetExts, @@ -1372,6 +1376,7 @@ export default class Server { platform: transformOptions.platform, publicPath: this._config.transformer.publicPath, projectRoot: this._config.projectRoot, + watchFolders: this._config.watchFolders, }); }, finish({mres, result}) { diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 838170548f..645f324425 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -893,6 +893,24 @@ describe('processRequest', () => { ); }); + test('should resolve an indexed watch folder asset path', async () => { + getAsset.mockResolvedValue(Promise.resolve('i am image')); + + const response = await makeRequest( + '/assets/[metro-watchFolders]/0/imgs/a.png?platform=ios', + ); + expect(response._getString()).toBe('i am image'); + + expect(getAsset).toBeCalledWith( + './imgs/a.png', + '/root', + ['/root'], + 'ios', + expect.any(Array), + expect.any(Function), + ); + }); + test('should serve range request', async () => { const mockData = 'i am image'; getAsset.mockResolvedValue(mockData); diff --git a/packages/metro/src/__tests__/Assets-test.js b/packages/metro/src/__tests__/Assets-test.js index 4b35afd058..a359fc0f59 100644 --- a/packages/metro/src/__tests__/Assets-test.js +++ b/packages/metro/src/__tests__/Assets-test.js @@ -20,7 +20,12 @@ jest.mock('../lib/imageSize', () => ({ jest.useRealTimers(); -const {getAsset, getAssetData, getAssetSize} = require('../Assets'); +const { + getAsset, + getAssetData, + getAssetSize, + getAssetUrlPath, +} = require('../Assets'); const getImageDimensions = require('../lib/imageSize').getImageDimensions; const crypto = require('node:crypto'); const path = require('node:path'); @@ -30,6 +35,26 @@ const fs = jest.requireMock('node:fs'); const mockImageWidth = 300; const mockImageHeight = 200; +describe('getAssetUrlPath', () => { + test('uses a project-relative path for assets within projectRoot', () => { + expect( + getAssetUrlPath('/root/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('imgs/a.png'); + }); + + test('uses an indexed path for assets within a watch folder', () => { + expect( + getAssetUrlPath('/external/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('[metro-watchFolders]/1/imgs/a.png'); + }); + + test('falls back to a project-relative path outside configured roots', () => { + expect( + getAssetUrlPath('/other/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('../other/imgs/a.png'); + }); +}); + describe('getAssetSize', () => { test('returns null for non-image assets', () => { expect(getAssetSize('mp4', Buffer.from('video'), '/root/video.mp4')).toBe(