From cc9ca47150d5416eddf9bc1549c38948ae3d59a0 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 12 Oct 2026 14:55:54 +0100 Subject: [PATCH] Resolver: Add metro:inline specifiers for importer-anchored inline modules Adds `metro:inline;base64,` (or `metro:inline,`) under the existing `metro:` scheme resolver. The payload is the module's source, and the resolver answers with a `VirtualResolution` whose virtual path sits beside the importing file, named for the importer and a hash of the source: `App.tsx` imports `App.inline-.tsx`. Relative and package imports inside the module resolve as they would from the importer, the transform treats it as the importer's kind of file, and the same payload in two files is two modules. That is what a transform needs when it emits a module derived from the file it is transforming. `react-native-worklets` bundle mode does this today by writing files into `node_modules` mid-transform and patching `getOrComputeSha1` to return a random SHA-1 for them, which defeats the transform cache for every worklet. This is deliberately not spelled `data:`. A `data:` URL is importer-independent and has no base URL, and Node and browsers treat the same URL as one module wherever it is imported. Inline modules are the opposite on both counts. `keepRequireNames` output abbreviates an inline specifier to its header, so a dev bundle carries each payload once as the module rather than again in every `require` call. Changelog: [Feature] Resolver: `metro:inline;base64,` resolves to a virtual module owned by the importer --- .../ModuleGraph/worker/collectDependencies.js | 26 ++++- .../__tests__/inline-module-test.js | 66 +++++++++++++ .../basic_bundle/inline-module/index.js | 35 +++++++ .../basic_bundle/inline-module/sibling.js | 11 +++ .../inline-module/subdir/counter.js | 15 +++ .../lib/__tests__/resolveInlineModule-test.js | 99 +++++++++++++++++++ packages/metro/src/lib/metroSchemeResolver.js | 16 ++- packages/metro/src/lib/resolveInlineModule.js | 84 ++++++++++++++++ 8 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 packages/metro/src/integration_tests/__tests__/inline-module-test.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/inline-module/index.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js create mode 100644 packages/metro/src/lib/__tests__/resolveInlineModule-test.js create mode 100644 packages/metro/src/lib/resolveInlineModule.js 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, + }; +}