diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAllFiles-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAllFiles-test.js index 1c1989a6e0..d7fc9e7f7c 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAllFiles-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAllFiles-test.js @@ -102,6 +102,25 @@ describe('getOrderedDependencyPaths', () => { ]); }); + test('Should exclude virtual modules, which have no file', async () => { + const jsOutput = [ + { + type: 'js/module', + data: {code: '//', lineCount: 1, map: [], functionMap: null}, + }, + ]; + const graph = { + dependencies: new Map([ + [1, {path: '/tmp/1.js', output: jsOutput}], + [2, {path: 'virtual:/tmp/1.generated.js', output: jsOutput}], + ]), + }; + + expect( + await getAllFiles([], graph, {processModuleFilter: () => true}), + ).toEqual(['/tmp/1.js']); + }); + test('Should add assets data dependencies correctly', async () => { const graph = { dependencies: new Map([ diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/hmrJSBundle-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/hmrJSBundle-test.js index 17f9f52b8f..46683c21c4 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/hmrJSBundle-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/hmrJSBundle-test.js @@ -184,3 +184,33 @@ Object { } `); }); + +test('gives a virtual module anchored at a real file URLs distinct from that file', () => { + const virtualFooModule: Module<> = { + ...fooModule, + path: 'virtual:/root/foo', + dependencies: new Map(), + }; + const {added} = hmrJSBundle( + { + added: new Map([ + ['root/foo', fooModule], + ['virtual:/root/foo', virtualFooModule], + ]), + modified: new Map(), + deleted: new Set(), + reset: false, + }, + graph, + options, + ); + expect( + added.map(({sourceURL, sourceMappingURL}) => [sourceURL, sourceMappingURL]), + ).toEqual([ + ['http://localhost/foo.bundle', 'http://localhost/foo.map'], + [ + 'http://localhost/foo.bundle//&virtual=true', + 'http://localhost/foo.map?virtual=true', + ], + ]); +}); diff --git a/packages/metro/src/DeltaBundler/Serializers/getAllFiles.js b/packages/metro/src/DeltaBundler/Serializers/getAllFiles.js index a729384311..ae86013dd9 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getAllFiles.js +++ b/packages/metro/src/DeltaBundler/Serializers/getAllFiles.js @@ -12,6 +12,7 @@ import type {Module, ReadOnlyGraph} from '../types'; import {getAssetFiles} from '../../Assets'; +import {isVirtualModulePath} from '../../lib/modulePath'; import {getJsOutput, isJsModule} from './helpers/js'; type Options = { @@ -36,7 +37,12 @@ export default async function getAllFiles( } for (const module of modules.values()) { - if (!isJsModule(module) || !processModuleFilter(module)) { + if ( + !isJsModule(module) || + !processModuleFilter(module) || + // A virtual module has no file. + isVirtualModulePath(module.path) + ) { continue; } diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js b/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js index d1a396ab6a..648ae7a1c9 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js @@ -156,6 +156,49 @@ describe('wrapModule()', () => { ); }); + test('names a virtual module by its virtual path, in its own namespace', () => { + expect( + raw( + wrapModule( + {...myModule, path: 'virtual:' + p('/root/foo.js')}, + { + createModuleId: createModuleIdFactory(), + dev: true, + includeAsyncPaths: false, + projectRoot: p('/root'), + serverRoot: p('/root'), + sourceUrl: null, + }, + ), + ), + ).toMatchInlineSnapshot( + `__d(function() { console.log("foo") },0,[1,2],"virtual:foo.js");`, + ); + }); + + test('marks the path of an async virtual dependency as virtual', () => { + const dep = nullthrows(myModule.dependencies.get('bar')); + myModule.dependencies.set('bar', { + ...dep, + absolutePath: 'virtual:' + p('/root/bar.js'), + data: {...dep.data, data: {...dep.data.data, asyncType: 'async'}}, + }); + expect( + raw( + wrapModule(myModule, { + createModuleId: createModuleIdFactory(), + dev: false, + includeAsyncPaths: true, + projectRoot: p('/root'), + serverRoot: p('/root'), + sourceUrl: 'http://localhost/Main.bundle?param1=true', + }), + ), + ).toMatchInlineSnapshot( + `__d(function() { console.log("foo") },0,{"0":1,"1":2,"paths":{"1":"/bar.bundle?param1=true&modulesOnly=true&runModule=false&virtual=true"}});`, + ); + }); + test('async dependency paths respect serverRoot', () => { const dep = nullthrows(myModule.dependencies.get('bar')); myModule.dependencies.set('bar', { diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js index 68900741f7..689788ca73 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js @@ -18,6 +18,12 @@ import type { import type {JsOutput} from 'metro-transform-worker'; import {isResolvedDependency} from '../../../lib/isResolvedDependency'; +import { + VIRTUAL_MODULE_URL_PARAM, + getModuleDisplayPath, + getModuleSourcePath, + isVirtualModulePath, +} from '../../../lib/modulePath'; import {normalizePathSeparatorsToPosix} from '../../../lib/pathUtils'; import invariant from 'invariant'; import * as jscSafeUrl from 'jsc-safe-url'; @@ -78,9 +84,7 @@ export function wrapModule(module: Module<>, options: Options): string { function getModuleVerboseName(module: Module<>, options: Options): string { // The relative path of the module, to make debugging easier. This is mapped // to `module.verboseName` in `require.js`. - return normalizePathSeparatorsToPosix( - path.relative(options.projectRoot, module.path), - ); + return getModuleDisplayPath(module.path, options.projectRoot); } // Default value stored in the `__d(...)` `paths` object for an async @@ -104,8 +108,14 @@ function getDefaultAsyncDependencyPath( const {searchParams} = new URL(jscSafeUrl.toNormalUrl(options.sourceUrl)); searchParams.set('modulesOnly', 'true'); searchParams.set('runModule', 'false'); + if (isVirtualModulePath(dependency.absolutePath)) { + searchParams.set(VIRTUAL_MODULE_URL_PARAM, 'true'); + } - const bundlePath = path.relative(options.serverRoot, dependency.absolutePath); + const bundlePath = path.relative( + options.serverRoot, + getModuleSourcePath(dependency.absolutePath), + ); return ( '/' + normalizePathSeparatorsToPosix( diff --git a/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js b/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js index 958a27fd9b..dbb0babe34 100644 --- a/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js +++ b/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js @@ -12,6 +12,11 @@ import type {DeltaResult, Module, ReadOnlyGraph} from '../types'; import type {HmrModule} from 'metro-runtime/src/modules/types'; +import { + VIRTUAL_MODULE_URL_PARAM, + getModuleSourcePath, + isVirtualModulePath, +} from '../../lib/modulePath'; import {isJsModule, wrapModule} from './helpers/js'; import debugModule from 'debug'; import * as jscSafeUrl from 'jsc-safe-url'; @@ -38,14 +43,15 @@ function generateModules( for (const module of sourceModules) { if (isJsModule(module)) { + const sourcePath = getModuleSourcePath(module.path); const getPathname = (extension: 'bundle' | 'map') => { return ( path .relative( options.serverRoot ?? options.projectRoot, path.join( - path.dirname(module.path), - path.basename(module.path, path.extname(module.path)) + + path.dirname(sourcePath), + path.basename(sourcePath, path.extname(sourcePath)) + '.' + extension, ), @@ -59,6 +65,9 @@ function generateModules( const clientUrl = new URL(options.clientUrl); clientUrl.searchParams.delete('excludeSource'); + if (isVirtualModulePath(module.path)) { + clientUrl.searchParams.set(VIRTUAL_MODULE_URL_PARAM, 'true'); + } clientUrl.pathname = getPathname('map'); const sourceMappingURL = clientUrl.toString(); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 28e3a1c443..0bde1b29cd 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -56,6 +56,12 @@ import {calculateBundleProgressRatio} from './lib/bundleProgressUtils'; import bundleToString from './lib/bundleToString'; import formatBundlingError from './lib/formatBundlingError'; import getGraphId from './lib/getGraphId'; +import { + VIRTUAL_MODULE_URL_PARAM, + getModuleSourcePath, + isVirtualModulePath, + isVirtualModuleUrl, +} from './lib/modulePath'; import parseBundleOptionsFromBundleRequestUrl from './lib/parseBundleOptionsFromBundleRequestUrl'; import parseJsonBody from './lib/parseJsonBody'; import RootUrlMap from './lib/RootUrlMap'; @@ -705,7 +711,8 @@ export default class Server { } else { const sourceFilePath = this._rootUrlMap.filePathOfUrlDecodedPathname(filePathname); - if (sourceFilePath != null) { + // Virtual modules aren't served by URL yet. + if (sourceFilePath != null && !isVirtualModuleUrl(urlObj.searchParams)) { await this._processSourceRequest(sourceFilePath, res); } else { next(); @@ -1750,8 +1757,14 @@ export default class Server { // eslint-disable-next-line consistent-return _getModuleSourceUrl(module: Module<>, mode: SourcePathsMode): string { switch (mode) { - case SourcePathsMode.ServerUrl: - return this._rootUrlMap.urlPathnameOfFilePath(module.path); + case SourcePathsMode.ServerUrl: { + const pathname = this._rootUrlMap.urlPathnameOfFilePath( + getModuleSourcePath(module.path), + ); + return isVirtualModulePath(module.path) + ? `${pathname}?${VIRTUAL_MODULE_URL_PARAM}=true` + : pathname; + } case SourcePathsMode.Absolute: return module.path; } diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 882202892f..72ba785c50 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -27,6 +27,7 @@ import {posixToSystemPath as p} from 'metro-resolver/private/__tests__/utils'; import MockRequest from 'mock-req'; // $FlowFixMe[untyped-import] import MockResponse from 'mock-res'; +import nullthrows from 'nullthrows'; const { getDefaultConfig: {getDefaultValues}, @@ -802,6 +803,32 @@ describe('processRequest', () => { }); }); + test('marks the url-server source path of a virtual module', async () => { + // $FlowFixMe[prop-missing] Missing from the flow-typed Jest definitions. + const buildGraphImpl = buildGraph.getMockImplementation(); + buildGraph.mockImplementationOnce(async (...args) => { + const graph = await buildGraphImpl(...args); + // A virtual module at the path of a real one. + dependencies.set('virtual:' + p('/root/foo.js'), { + ...nullthrows(dependencies.get(p('/root/foo.js'))), + path: 'virtual:' + p('/root/foo.js'), + getSource: () => Buffer.from('code-virtual'), + }); + return graph; + }); + + const response = await makeRequest('mybundle.map?sourcePaths=url-server'); + + expect( + response._getJSON().sections.map(section => section.map.sources), + ).toEqual([ + ['/require-js'], + ['/[metro-project]/mybundle.js'], + ['/[metro-project]/foo.js'], + ['/[metro-project]/foo.js?virtual=true'], + ]); + }); + test('passes in the unstable_transformProfile param', async () => { await makeRequest('index.bundle?unstable_transformProfile=hermes-stable'); @@ -1097,6 +1124,13 @@ describe('processRequest', () => { expect(response.getHeader('content-type')).toBe('image/png'); }); + + test('does not serve a file for a virtual module at its path', async () => { + // The request is passed on to the next handler, with no error. + await expect( + makeRequest('/[metro-project]/foo.js?virtual=true'), + ).rejects.toBeUndefined(); + }); }); describe('build(options)', () => { diff --git a/packages/metro/src/lib/__tests__/modulePath-test.js b/packages/metro/src/lib/__tests__/modulePath-test.js new file mode 100644 index 0000000000..0f9865fafc --- /dev/null +++ b/packages/metro/src/lib/__tests__/modulePath-test.js @@ -0,0 +1,51 @@ +/** + * 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. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +import { + deriveVirtualModulePath, + getModuleDisplayPath, + getModuleSourcePath, + isVirtualModulePath, + isVirtualModuleUrl, +} from '../modulePath'; + +test('a virtual module path is the virtual path in the virtual namespace', () => { + const modulePath = deriveVirtualModulePath('/root/App.generated.tsx'); + expect(modulePath).toBe('virtual:/root/App.generated.tsx'); + expect(isVirtualModulePath(modulePath)).toBe(true); + expect(getModuleSourcePath(modulePath)).toBe('/root/App.generated.tsx'); +}); + +test('real module paths pass through unchanged', () => { + expect(isVirtualModulePath('/root/App.tsx')).toBe(false); + expect(getModuleSourcePath('/root/App.tsx')).toBe('/root/App.tsx'); + expect(getModuleDisplayPath('/root/src/App.tsx', '/root')).toBe( + 'src/App.tsx', + ); +}); + +test('a virtual module is displayed relative to the root, in its namespace', () => { + expect( + getModuleDisplayPath('virtual:/root/src/App.generated.tsx', '/root'), + ).toBe('virtual:src/App.generated.tsx'); +}); + +test.each([ + ['?virtual=true', true], + ['?platform=ios&virtual=true', true], + ['?virtual', true], + ['?platform=ios', false], + ['', false], +])('isVirtualModuleUrl(%s) is %s', (search, expected) => { + expect(isVirtualModuleUrl(new URLSearchParams(search))).toBe(expected); +}); diff --git a/packages/metro/src/lib/__tests__/parseBundleOptionsFromBundleRequestUrl-test.js b/packages/metro/src/lib/__tests__/parseBundleOptionsFromBundleRequestUrl-test.js index c7e709c659..48d0d669ea 100644 --- a/packages/metro/src/lib/__tests__/parseBundleOptionsFromBundleRequestUrl-test.js +++ b/packages/metro/src/lib/__tests__/parseBundleOptionsFromBundleRequestUrl-test.js @@ -47,6 +47,15 @@ describe('parseBundleOptionsFromBundleRequestUrl', () => { ); }); + test('rejects a virtual module URL', () => { + expect(() => + parseBundleOptionsFromBundleRequestUrl( + 'http://localhost/App.bundle?platform=ios&virtual=true', + new Set(['ios']), + ), + ).toThrowError('Virtual modules cannot be requested by URL yet.'); + }); + test.each(['absolute', 'relative'])( '%s protocol urls- infers the source url and source map url from the pathname', type => { diff --git a/packages/metro/src/lib/modulePath.js b/packages/metro/src/lib/modulePath.js new file mode 100644 index 0000000000..ff601947da --- /dev/null +++ b/packages/metro/src/lib/modulePath.js @@ -0,0 +1,78 @@ +/** + * 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. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import {normalizePathSeparatorsToPosix} from './pathUtils'; +import path from 'node:path'; + +/** + * The namespace of virtual module paths in the graph. A virtual module's path + * is this prefix followed by its absolute virtual path, so it can never equal + * the absolute path of a real file, and anything that mistakes it for one + * fails rather than reading the wrong file. + */ +const VIRTUAL_MODULE_NAMESPACE = 'virtual:'; + +/** + * The URL search parameter that marks a virtual module. A virtual module's URL + * is the one a file at its virtual path would have, plus this parameter. + */ +export const VIRTUAL_MODULE_URL_PARAM = 'virtual'; + +/** + * The graph path of the virtual module at `virtualPath`. A virtual path names + * exactly one module: the same virtual path with different source is a + * modification of that module, as an edit to a file is. + */ +export function deriveVirtualModulePath(virtualPath: string): string { + return VIRTUAL_MODULE_NAMESPACE + virtualPath; +} + +export function isVirtualModulePath(modulePath: string): boolean { + return modulePath.startsWith(VIRTUAL_MODULE_NAMESPACE); +} + +/** + * The path a module is resolved from, transformed as, and addressed by in + * URLs. For a virtual module this is its virtual path, so imports inside it + * resolve from that path's directory and path-based transform configuration, + * extension checks included, behaves as for a file at that path. Otherwise it + * is the module path itself. + */ +export function getModuleSourcePath(modulePath: string): string { + return isVirtualModulePath(modulePath) + ? modulePath.slice(VIRTUAL_MODULE_NAMESPACE.length) + : modulePath; +} + +/** + * A module path for display, as in development module names: relative to + * `rootDir` with POSIX separators, and namespaced if the module is virtual. + */ +export function getModuleDisplayPath( + modulePath: string, + rootDir: string, +): string { + const relativePath = normalizePathSeparatorsToPosix( + path.relative(rootDir, getModuleSourcePath(modulePath)), + ); + return isVirtualModulePath(modulePath) + ? VIRTUAL_MODULE_NAMESPACE + relativePath + : relativePath; +} + +/** + * Whether a URL addresses a virtual module. Its path is the module's virtual + * path, which may also be a real file's, so such a URL must never be served as + * that file. + */ +export function isVirtualModuleUrl(searchParams: URLSearchParams): boolean { + return searchParams.has(VIRTUAL_MODULE_URL_PARAM); +} diff --git a/packages/metro/src/lib/parseBundleOptionsFromBundleRequestUrl.js b/packages/metro/src/lib/parseBundleOptionsFromBundleRequestUrl.js index 2e13cf101d..8e0612deb1 100644 --- a/packages/metro/src/lib/parseBundleOptionsFromBundleRequestUrl.js +++ b/packages/metro/src/lib/parseBundleOptionsFromBundleRequestUrl.js @@ -14,6 +14,7 @@ import type {TransformProfile} from 'metro-babel-transformer'; import parsePlatformFilePath from '../node-haste/lib/parsePlatformFilePath'; import {SourcePathsMode} from '../shared/types'; +import {isVirtualModuleUrl} from './modulePath'; import parseCustomResolverOptions from './parseCustomResolverOptions'; import parseCustomTransformOptions from './parseCustomTransformOptions'; import debugModule from 'debug'; @@ -70,6 +71,12 @@ export default function parseBundleOptionsFromBundleRequestUrl( hash, } = new URL(rawNonJscSafeUrlEncodedUrl, RESOLVE_BASE_URL /* baseURL */); + if (isVirtualModuleUrl(searchParams)) { + throw new Error('Virtual modules cannot be requested by URL yet.', { + cause: rawNonJscSafeUrlEncodedUrl, + }); + } + // e.g. "//localhost:8081/foo/bar.js?platform=ios" const isRelativeProtocol = rawNonJscSafeUrlEncodedUrl.startsWith('//');