Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/metro/src/ModuleGraph/worker/collectDependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);
}
},

Expand Down Expand Up @@ -906,7 +908,7 @@ function createModuleIDExpression(
}

function createModuleNameLiteral(dependency: InternalDependency) {
return types.stringLiteral(dependency.name);
return types.stringLiteral(getDebugName(dependency.name));
}

/**
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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});
});
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
@@ -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;
}
99 changes: 99 additions & 0 deletions packages/metro/src/lib/__tests__/resolveInlineModule-test.js
Original file line number Diff line number Diff line change
@@ -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',
);
});
16 changes: 14 additions & 2 deletions packages/metro/src/lib/metroSchemeResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
84 changes: 84 additions & 0 deletions packages/metro/src/lib/resolveInlineModule.js
Original file line number Diff line number Diff line change
@@ -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-<hash>.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,
};
}
Loading