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..fa65ca2062 --- /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('each inline module is named beside its importer, in the virtual namespace', async () => { + const {code} = await build({dev: true}); + const names = [...new Set(code.match(/"virtual:[^"]*"/g))].sort(); + expect(names).toEqual([ + expect.stringMatching( + /^"virtual:inline-module\/index\.inline-[0-9a-f]{16}\.js"$/, + ), + expect.stringMatching( + /^"virtual:inline-module\/sibling\.inline-[0-9a-f]{16}\.js"$/, + ), + ]); +}); + +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..3cd68dedba --- /dev/null +++ b/packages/metro/src/lib/__tests__/resolveInlineModule-test.js @@ -0,0 +1,99 @@ +/** + * 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'; +import crypto from 'node:crypto'; +import path from 'node:path'; + +const ORIGIN = '/root/project/foo.js'; + +// The virtual path an inline module with `source` gets when imported from +// ORIGIN. +function inlinePath(source: string) { + const hash = crypto + .createHash('sha1') + .update(source) + .digest('hex') + .slice(0, 16); + return path.join(path.dirname(ORIGIN), `foo.inline-${hash}.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 beside the importer', () => { + const source = 'export default 1;'; + expect( + resolve('metro:inline;base64,' + Buffer.from(source).toString('base64')), + ).toEqual({ + type: 'virtualModule', + virtualPath: inlinePath(source), + source, + }); +}); + +test('decodes a percent-encoded payload', () => { + expect(resolve('metro:inline,export%20default%20%22a%2Cb%22%3B')).toEqual({ + type: 'virtualModule', + virtualPath: inlinePath('export default "a,b";'), + source: 'export default "a,b";', + }); +}); + +function virtualPathOf(specifier: string): ?string { + const resolution = resolve(specifier); + if (resolution.type !== 'virtualModule') { + throw new Error('Expected a virtual module'); + } + return resolution.virtualPath; +} + +test('the same source in either encoding has one virtual path', () => { + expect(virtualPathOf('metro:inline;base64,MQ==')).toBe( + virtualPathOf('metro:inline,1'), + ); +}); + +test('different sources have different virtual paths', () => { + expect(virtualPathOf('metro:inline,1')).not.toBe( + virtualPathOf('metro:inline,2'), + ); +}); + +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..4a2013048f --- /dev/null +++ b/packages/metro/src/lib/resolveInlineModule.js @@ -0,0 +1,84 @@ +/** + * 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'; + +import crypto from 'node:crypto'; +import path from 'node:path'; + +export const INLINE_MODULE_SPECIFIER = 'inline'; + +// 64 bits, enough that distinct sources beside one importer never share a +// path. A collision would be two sources at one virtual path, which Metro +// reports as an error rather than bundling either. +const INLINE_MODULE_HASH_LENGTH = 16; + +/** + * 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:`. + * + * Its virtual path sits beside the importer, with the importer's extension and + * a hash of the source in its name: `App.tsx` imports `App.inline-.tsx`. + * So each distinct inline module gets its own path, the same source spelled + * two ways is one module, and the transform treats it as the importer's kind + * of file. + * + * 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); + + const {dir, name, ext} = path.parse(context.originModulePath); + const hash = crypto + .createHash('sha1') + .update(source) + .digest('hex') + .slice(0, INLINE_MODULE_HASH_LENGTH); + return { + type: 'virtualModule', + virtualPath: path.join(dir, `${name}.inline-${hash}${ext}`), + source, + }; +}