diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index 9bd5be1cec..570cf1bd51 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -812,7 +812,9 @@ const DefaultDependencyTransformer: DependencyTransformer = { >; // Always add the debug name argument last if (state.keepRequireNames) { - path.node.arguments.push(types.stringLiteral(dependency.name)); + path.node.arguments.push( + types.stringLiteral(getDebugName(dependency.name)), + ); } }, @@ -906,7 +908,7 @@ function createModuleIDExpression( } function createModuleNameLiteral(dependency: InternalDependency) { - return types.stringLiteral(dependency.name); + return types.stringLiteral(getDebugName(dependency.name)); } /** @@ -929,6 +931,26 @@ function createModuleNameLiteral(dependency: InternalDependency) { * * This method should be utilized by `registerDependency`. */ +const INLINE_MODULE_PREFIX = 'metro:inline'; + +/** + * The name shown beside a dependency in development output. An inline module + * specifier carries a whole module in its payload, so only its header is kept. + */ +function getDebugName(name: string): string { + if ( + name.length > INLINE_MODULE_PREFIX.length && + name.slice(0, INLINE_MODULE_PREFIX.length).toLowerCase() === + INLINE_MODULE_PREFIX + ) { + const commaIndex = name.indexOf(','); + if (commaIndex !== -1) { + return name.slice(0, commaIndex + 1) + '...'; + } + } + return name; +} + function getKeyForDependency(qualifier: ImportQualifier): string { const {asyncType, contextParams, isESMImport, name} = qualifier; diff --git a/packages/metro/src/integration_tests/__tests__/inline-module-test.js b/packages/metro/src/integration_tests/__tests__/inline-module-test.js new file mode 100644 index 0000000000..6acae7f0bd --- /dev/null +++ b/packages/metro/src/integration_tests/__tests__/inline-module-test.js @@ -0,0 +1,66 @@ +/** + * 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'; + +const Metro = require('../../..'); +const execBundle = require('../execBundle'); + +jest.setTimeout(30 * 1000); + +async function build({dev}: {dev: boolean}) { + const config = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + return Metro.runBuild(config, { + entry: 'inline-module/index.js', + dev, + minify: !dev, + }); +} + +test('an inline module resolves to a virtual module anchored at its importer', async () => { + const {code} = await build({dev: true}); + expect(execBundle(code)).toEqual({ + // The inline module's relative import resolved to the shared counter. + fromInline: 11, + // Same source, different importer: a separate module instance, sharing the + // counter it imports. + fromSibling: 102, + // The weak id refers to the same module instance as the static require. + fromWeakId: 1003, + weakIdIsOwnModuleId: true, + }); +}); + +test('the virtual module is named after its importer and a hash of its source', async () => { + const {code} = await build({dev: true}); + const names = [ + ...code.matchAll(/"([^"]*inline-module\/[^"]*\?virtual=[0-9a-f]+)"/g), + ] + .map(match => match[1]) + .sort(); + expect(names).toEqual([ + expect.stringMatching(/^inline-module\/index\.js\?virtual=[0-9a-f]{40}$/), + expect.stringMatching(/^inline-module\/sibling\.js\?virtual=[0-9a-f]{40}$/), + ]); +}); + +test('development output abbreviates inline module specifiers in require names', async () => { + const {code} = await build({dev: true}); + expect(code).toContain('"metro:inline;base64,..."'); + expect(code).not.toContain('"metro:inline;base64,aW1wb3J0'); +}); + +test('builds in production', async () => { + const {code} = await build({dev: false}); + expect(execBundle(code)).toMatchObject({fromInline: 11, fromSibling: 102}); +}); diff --git a/packages/metro/src/integration_tests/basic_bundle/inline-module/index.js b/packages/metro/src/integration_tests/basic_bundle/inline-module/index.js new file mode 100644 index 0000000000..2dd97ee5aa --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/inline-module/index.js @@ -0,0 +1,35 @@ +/** + * 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. + * + * @format + * @oncall react_native + */ + +declare var require: { + (id: string | number): any, + resolveWeak: (id: string) => string | number, +}; + +// The same source from a different origin is a different module with its own +// module scope. +const {default: bumpFromSibling} = require('./sibling'); +// The inline module imports a sibling relatively: it resolves from this file's +// directory, because the virtual module is anchored here. +const inline = require('metro:inline;base64,aW1wb3J0IHtpbmNyZW1lbnR9IGZyb20gJy4vc3ViZGlyL2NvdW50ZXInOwpleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBidW1wKG4pIHsKICByZXR1cm4gaW5jcmVtZW50KCkgKyBuOwp9CmV4cG9ydCBjb25zdCBvd25Nb2R1bGVJZCA9IG1vZHVsZS5pZDsK'); + +const weakId = require.resolveWeak( + 'metro:inline;base64,aW1wb3J0IHtpbmNyZW1lbnR9IGZyb20gJy4vc3ViZGlyL2NvdW50ZXInOwpleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBidW1wKG4pIHsKICByZXR1cm4gaW5jcmVtZW50KCkgKyBuOwp9CmV4cG9ydCBjb25zdCBvd25Nb2R1bGVJZCA9IG1vZHVsZS5pZDsK', +); + +const dynamicRequire = require; + +module.exports = { + fromInline: inline.default(10), + fromSibling: bumpFromSibling(100), + // Requiring by the weak id reaches the same instance as the static require. + fromWeakId: dynamicRequire(weakId).default(1000), + weakIdIsOwnModuleId: weakId === inline.ownModuleId, +}; diff --git a/packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js b/packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js new file mode 100644 index 0000000000..3f98f782bf --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js @@ -0,0 +1,11 @@ +/** + * 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. + * + * @format + * @oncall react_native + */ + +module.exports = require('metro:inline;base64,aW1wb3J0IHtpbmNyZW1lbnR9IGZyb20gJy4vc3ViZGlyL2NvdW50ZXInOwpleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBidW1wKG4pIHsKICByZXR1cm4gaW5jcmVtZW50KCkgKyBuOwp9CmV4cG9ydCBjb25zdCBvd25Nb2R1bGVJZCA9IG1vZHVsZS5pZDsK'); diff --git a/packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js b/packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js new file mode 100644 index 0000000000..f8b8ef0f9b --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js @@ -0,0 +1,15 @@ +/** + * 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. + * + * @format + * @oncall react_native + */ + +let count = 0; + +export function increment(): number { + return ++count; +} diff --git a/packages/metro/src/lib/__tests__/resolveInlineModule-test.js b/packages/metro/src/lib/__tests__/resolveInlineModule-test.js new file mode 100644 index 0000000000..14e518c0c4 --- /dev/null +++ b/packages/metro/src/lib/__tests__/resolveInlineModule-test.js @@ -0,0 +1,66 @@ +/** + * 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 metroSchemeResolver from '../metroSchemeResolver'; +import {createResolutionContext} from 'metro-resolver/private/__tests__/utils'; + +const ORIGIN = '/root/project/foo.js'; + +function resolve(specifier: string) { + return metroSchemeResolver( + { + ...createResolutionContext({}), + originModulePath: ORIGIN, + resolveRequest: () => { + throw new Error('inline module resolution must not delegate'); + }, + }, + specifier, + null, + ); +} + +test('decodes a base64 payload into a virtual module at the importing module path', () => { + const source = 'export default 1;'; + expect( + resolve('metro:inline;base64,' + Buffer.from(source).toString('base64')), + ).toEqual({ + type: 'virtualModule', + virtualPath: ORIGIN, + source, + }); +}); + +test('decodes a percent-encoded payload', () => { + expect(resolve('metro:inline,export%20default%20%22a%2Cb%22%3B')).toEqual({ + type: 'virtualModule', + virtualPath: ORIGIN, + source: 'export default "a,b";', + }); +}); + +test('ignores the case of the parameters', () => { + expect(resolve('metro:inline;BASE64,MQ==')).toMatchObject({source: '1'}); +}); + +test('rejects an unknown parameter', () => { + expect(() => resolve('metro:inline;gzip,hello')).toThrow( + "Unsupported parameter 'gzip'", + ); +}); + +test('rejects a specifier with no payload separator', () => { + expect(() => resolve('metro:inline')).toThrow( + 'Malformed inline module specifier', + ); +}); diff --git a/packages/metro/src/lib/metroSchemeResolver.js b/packages/metro/src/lib/metroSchemeResolver.js index 590256bd5e..253e7cb2fb 100644 --- a/packages/metro/src/lib/metroSchemeResolver.js +++ b/packages/metro/src/lib/metroSchemeResolver.js @@ -11,6 +11,10 @@ import type {CustomResolver} from 'metro-resolver'; +import { + INLINE_MODULE_SPECIFIER, + resolveInlineModule, +} from './resolveInlineModule'; import * as path from 'node:path'; const BABEL_RUNTIME_SPECIFIER = 'babel-runtime'; @@ -35,12 +39,20 @@ function getBabelRuntimePackageJsonPath(): string { } /** - * Resolver used for Metro's own `metro:` URI scheme, currently handling only - * metro:babel-runtime and subpaths. + * Resolver used for Metro's own `metro:` URI scheme, handling + * metro:babel-runtime (and subpaths) and metro:inline modules. */ export default ((context, specifier, platform) => { const {protocol, pathname} = new URL(specifier); + if ( + pathname === INLINE_MODULE_SPECIFIER || + pathname.startsWith(INLINE_MODULE_SPECIFIER + ';') || + pathname.startsWith(INLINE_MODULE_SPECIFIER + ',') + ) { + return resolveInlineModule(context, pathname); + } + // Maps `metro:babel-runtime` (and subpaths, e.g. // `metro:babel-runtime/helpers/interopRequireDefault`) to metro-runtime's // `@babel/runtime` dependency, so injected Babel helpers resolve diff --git a/packages/metro/src/lib/resolveInlineModule.js b/packages/metro/src/lib/resolveInlineModule.js new file mode 100644 index 0000000000..6c8fd33106 --- /dev/null +++ b/packages/metro/src/lib/resolveInlineModule.js @@ -0,0 +1,64 @@ +/** + * 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 type {ResolutionContext, VirtualResolution} from 'metro-resolver'; + +export const INLINE_MODULE_SPECIFIER = 'inline'; + +/** + * Resolves the `inline` pathname of Metro's own URI scheme, so that a transform + * may emit a module inline in its importer: + * + * require('metro:inline;base64,ZXhwb3J0IGRlZmF1bHQgMQ==') + * + * The payload is the module's source, encoded as in a `data:` URL. Unlike a + * `data:` URL, an inline module belongs to its importer: the same payload in + * two modules is two modules, and imports inside it resolve exactly as they + * would from the importer. That is what makes it usable for modules a + * transform derives from the importing file, which is not what `data:` + * promises, so it is deliberately not spelled `data:`. + * + * The result is a pure function of (origin module, specifier). Nothing on disk + * can invalidate it. + */ +export function resolveInlineModule( + context: ResolutionContext, + pathname: string, +): VirtualResolution { + const commaIndex = pathname.indexOf(','); + if (commaIndex === -1) { + throw new Error(`Malformed inline module specifier: metro:${pathname}`); + } + const parameters = pathname + .slice(INLINE_MODULE_SPECIFIER.length, commaIndex) + .split(';') + .map(part => part.trim().toLowerCase()) + .filter(part => part !== ''); + const payload = pathname.slice(commaIndex + 1); + + for (const parameter of parameters) { + if (parameter !== 'base64') { + throw new Error( + `Unsupported parameter '${parameter}' in inline module specifier: metro:${pathname}`, + ); + } + } + + const source = parameters.includes('base64') + ? Buffer.from(payload, 'base64').toString('utf8') + : decodeURIComponent(payload); + + return { + type: 'virtualModule', + virtualPath: context.originModulePath, + source, + }; +}