diff --git a/packages/metro-transform-worker/API.md b/packages/metro-transform-worker/API.md index df45164685..d5e86ab20f 100644 --- a/packages/metro-transform-worker/API.md +++ b/packages/metro-transform-worker/API.md @@ -74,10 +74,19 @@ 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, +extras?: TransformExtras, +) => Promise; export type transform = typeof transform; +export type TransformExtras = Readonly<{assetUrlPath?: string | undefined}>; + export type Type = 'script' | 'module' | 'asset'; ``` diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 2387b786ba..d0e75e01e4 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', + }, + {assetUrlPath: '[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 fcf9a1a554..cf960049a9 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -133,6 +133,15 @@ export type JsTransformOptions = Readonly<{ unstable_transformProfile: TransformProfile, }>; +/** + * Inputs computed by Metro and passed alongside the public transform options. + * Kept out of JsTransformOptions because those are spread into + * BabelTransformerArgs, and these are for the default transformer only. + */ +export type TransformExtras = Readonly<{ + assetUrlPath?: string, +}>; + opaque type AbsolutePath = string; opaque type ProjectRelativePath = string; @@ -164,6 +173,7 @@ type JSONFile = { type TransformationContext = Readonly<{ config: JsTransformerConfig, + extras: TransformExtras, projectRoot: AbsolutePath, options: JsTransformOptions, }>; @@ -538,6 +548,7 @@ async function transformAsset( getBabelTransformArgs(file, context), assetRegistryPath, assetPlugins, + context.extras.assetUrlPath, ); const jsFile = { @@ -678,8 +689,10 @@ export const transform = async ( projectRelativePath: string, data: Buffer, options: JsTransformOptions, + extras?: TransformExtras = {}, ): Promise => { const context: TransformationContext = { + extras, 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..464c2e2527 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -243,6 +243,50 @@ 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. + * + * Roots are resolved as Server does when it decodes these paths, so that the + * index here and the index it reads back refer to the same directory. + */ +export function getAssetUrlPath( + assetPath: string, + projectRoot: string, + watchFolders: ReadonlyArray, +): string { + const projectRelativePath = path.relative( + path.resolve(projectRoot), + assetPath, + ); + if (isPathInsideRoot(projectRelativePath)) { + return normalizePathSeparatorsToPosix(projectRelativePath); + } + + for (let i = 0; i < watchFolders.length; i++) { + const watchFolderRelativePath = path.relative( + path.resolve(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..90bf123bdf 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, assetUrlPath}, content, ); diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index c13dcc4e07..632dec33e3 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -14,6 +14,7 @@ import type {LogEntry} from 'metro-core/private/Logger'; import type { JsTransformerConfig, JsTransformOptions, + TransformExtras, } from 'metro-transform-worker'; import traverse from '@babel/traverse'; @@ -21,7 +22,13 @@ 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, + // Split out of the options before they reach the transformer, which receives + // them as a separate TransformExtras argument, so that they are not spread + // into BabelTransformerArgs along with the public options. + assetUrlPath?: string, +}>; type TransformerInterface = { transform( @@ -30,6 +37,7 @@ type TransformerInterface = { string, Buffer, JsTransformOptions, + TransformExtras, ): Promise>, }; @@ -68,7 +76,7 @@ function asDeserializedBuffer(value: any): Buffer | null { export const transform = ( filename: string, - transformOptions: JsTransformOptions, + transformOptions: TransformOptions, projectRoot: string, transformerConfig: TransformerConfig, fileBuffer?: Buffer, @@ -97,7 +105,7 @@ export type Worker = { async function transformFile( projectRelativePath: string, data: Buffer, - transformOptions: JsTransformOptions, + transformOptions: TransformOptions, projectRoot: string, transformerConfig: TransformerConfig, ): Promise { @@ -117,12 +125,14 @@ async function transformFile( const sha1 = crypto.createHash('sha1').update(data).digest('hex'); + const {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..72bff7acce 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', + assetUrlPath: '[metro-watchFolders]/1/imgs/a.png', + }, + undefined, + ); + }); }); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 529315309a..8cb8feeceb 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -454,6 +454,7 @@ export default class Server { // and [metro-project] means projectRoot in _sourceRequestRoutingMap. projectRoot: this._config.projectRoot, publicPath: this._config.transformer.publicPath, + watchFolders: this._config.watchFolders, }); } @@ -574,9 +575,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, diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 61c8a844fb..580cb0c4b3 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -901,6 +901,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); @@ -1541,7 +1559,10 @@ describe('processRequest', () => { expect(getAssetsSerializer).toBeCalledWith( expect.anything(), - expect.objectContaining({projectRoot: '/root'}), + expect.objectContaining({ + projectRoot: '/root', + watchFolders: ['/root'], + }), ); }); }); @@ -1627,5 +1648,32 @@ describe('processRequest', () => { '/project/mybundle', ); }); + + test.each([ + '/project/imgs/a.png', + '/project/nested/deep/b.png', + '/external/packages/imgs/c.png', + '/external/packages/d.png', + ])( + 'asset URL path for %s round-trips back to the same file', + absolutePath => { + const {getAssetUrlPath} = require('../../Assets'); + const urlPath = getAssetUrlPath(absolutePath, '/project', [ + '/project', + '/external/packages', + ]); + + // Mirrors how _processSingleAssetRequest resolves an incoming URL. + const resolved = watchFolderServer._resolveWatchFolderPrefix( + './' + urlPath, + ); + expect( + path.resolve( + resolved?.rootDir ?? '/project', + resolved?.filePath ?? urlPath, + ), + ).toBe(absolutePath); + }, + ); }); }); 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(