* Copyright (c) 2024 Huawei Technologies Co., Ltd.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const pathUtils = require('path');
const fs = require('fs');
const colors = require('colors/safe');
const HARMONY_PLATFORM_NAME = 'harmony';
const RNOH_FALLBACK_PLATFORM_NAME = 'ios';
* @param msg {string}
*/
function info(msg) {
const infoPrefix = '[' + colors.bold(colors.cyan(`INFO`)) + ']';
console.log(infoPrefix, msg);
}
* @type {string | null}
*/
let REQUEST_RESOLUTION_LATEST_PLATFORM = null;
* @param options {import("./metro.config").HarmonyMetroConfigOptions}
* @returns {import("metro-config").InputConfigT}
*/
function createHarmonyMetroConfig(options) {
* The default value needs to be changed to @react-native-oh/react-native-harmony but this is a breaking change.
*/
const reactNativeHarmonyPackageName =
options?.reactNativeHarmonyPackageName ?? 'react-native-harmony';
const reactNativeHarmonyPattern =
options?.__reactNativeHarmonyPattern ??
pathUtils.sep +
reactNativeHarmonyPackageName.replace('/', pathUtils.sep) +
pathUtils.sep;
const reactNativeInteropLibraryPackagePattern =
options?.__reactNativeInteropLibraryPackagePattern;
return {
transformer: {
assetRegistryPath: 'react-native/Libraries/Image/AssetRegistry',
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
serializer: {
getModulesRunBeforeMainModule: () => {
if (REQUEST_RESOLUTION_LATEST_PLATFORM !== 'harmony') {
return [];
}
return [require.resolve('./Libraries/Core/InitializeCore')];
},
},
resolver: {
blockList: [/\.cxx/],
resolveRequest: (ctx, moduleName, platform) =>
resolveRequestForPlatform(
ctx,
moduleName,
platform,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
),
},
};
}
module.exports = {
createHarmonyMetroConfig,
};
* @param ctx {Parameters<NonNullable<import("metro-config").ResolverConfigT["resolveRequest"]>>[0]}
* @param moduleName {string}
* @param nodeModulesPaths {string[]}
* @param reactNativeHarmonyPackageName {string}
* @param reactNativeInteropLibraryPackagePattern {string | undefined}
*/
function resolveIfInteropLibraryRequest(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) {
const rnInteropLibraryPackageName =
getHarmonyPackageByAliasMap(nodeModulesPaths)[reactNativeHarmonyPackageName]
?.name;
* We have to check if the module is not resolved from interop package
* to prevent an infinite resolution loop caused by a circular dependency.
* e.g.
* origin module: react-native-harmony/Libraries/Image/ImageSourceUtils.js
* redirected to module: react-native-harmony-61-interop/Libraries/Image/ImageSourceUtils.js
* and react-native-harmony-61-interop/Libraries/Image/ImageSourceUtils.js imports react-native-harmony/Libraries/Image/ImageSourceUtils.js
* This creates a circular dependency, causing an infinite resolution loop.
*/
if (
rnInteropLibraryPackageName &&
!ctx.originModulePath.includes(
reactNativeInteropLibraryPackagePattern ??
getRNInteropLibraryPackagePattern(rnInteropLibraryPackageName)
)
) {
try {
const newModuleName = moduleName.replace(
reactNativeHarmonyPackageName,
rnInteropLibraryPackageName
);
return ctx.resolveRequest(ctx, newModuleName, HARMONY_PLATFORM_NAME);
} catch {}
}
return null;
}
function resolveRequestForPlatform(
ctx,
moduleName,
platform,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
) {
REQUEST_RESOLUTION_LATEST_PLATFORM = platform;
const nodeModulesPaths = [
pathUtils.resolve('node_modules'),
...(ctx.nodeModulesPaths ?? []),
];
if (platform !== HARMONY_PLATFORM_NAME) {
return ctx.resolveRequest(ctx, moduleName, platform);
}
const harmonyResolutionResult = resolveHarmonyPlatformRequest(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
);
if (harmonyResolutionResult) {
return harmonyResolutionResult;
}
return ctx.resolveRequest(ctx, moduleName, platform);
}
function resolveHarmonyPlatformRequest(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
) {
return (
resolveReactNativeRequestIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) ??
resolveHarmonyPackageRequestIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) ??
resolveImportFromHarmonyPackageDirectoryIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
) ??
resolveAliasedInternalImportIfNeeded(
ctx,
moduleName,
nodeModulesPaths
) ??
resolveVirtualizedListImportIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) ??
resolveGenericHarmonyAliasImport(ctx, moduleName, nodeModulesPaths) ??
null
);
}
function resolveReactNativeRequestIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) {
if (!isReactNativeRequest(moduleName)) {
return null;
}
return resolveReactNativeImport(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
);
}
function resolveHarmonyPackageRequestIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) {
if (!isHarmonyPackageRequest(moduleName, reactNativeHarmonyPackageName)) {
return null;
}
return resolveHarmonyPackageImport(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
);
}
function resolveImportFromHarmonyPackageDirectoryIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
) {
if (!ctx.originModulePath.includes(reactNativeHarmonyPattern)) {
return null;
}
return resolveImportFromHarmonyPackageDirectory(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
);
}
function resolveAliasedInternalImportIfNeeded(
ctx,
moduleName,
nodeModulesPaths
) {
if (
!isHarmonyPackageInternalImport(
nodeModulesPaths,
ctx.originModulePath,
moduleName
)
) {
return null;
}
return resolveAliasedInternalImport(
ctx,
moduleName,
nodeModulesPaths
);
}
function resolveVirtualizedListImportIfNeeded(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) {
if (!isVirtualizedListImport(ctx.originModulePath, moduleName)) {
return null;
}
return resolveVirtualizedListImport(
ctx,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
);
}
function isReactNativeRequest(moduleName) {
return moduleName === 'react-native' || moduleName.startsWith('react-native/');
}
function isHarmonyPackageRequest(moduleName, reactNativeHarmonyPackageName) {
return (
moduleName === reactNativeHarmonyPackageName ||
moduleName.startsWith(`${reactNativeHarmonyPackageName}/`)
);
}
function resolveReactNativeImport(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) {
const newModuleName = moduleName.replace(
'react-native',
reactNativeHarmonyPackageName
);
const maybeInteropLibraryResult = resolveIfInteropLibraryRequest(
ctx,
newModuleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
);
if (maybeInteropLibraryResult) {
return maybeInteropLibraryResult;
}
return resolveWithHarmonyFallback(ctx, newModuleName);
}
function resolveHarmonyPackageImport(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) {
const result = resolveIfInteropLibraryRequest(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
);
if (result) {
return result;
}
const maybeResult = resolveRequestOnlyForHarmony(ctx, moduleName);
if (maybeResult) {
return maybeResult;
}
return resolveWithHarmonyFallback(ctx, moduleName);
}
function resolveImportFromHarmonyPackageDirectory(
ctx,
moduleName,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern
) {
const rnInteropLibraryPackage =
getHarmonyPackageByAliasMap(nodeModulesPaths)[reactNativeHarmonyPackageName];
if (rnInteropLibraryPackage && moduleName.startsWith('.')) {
const redirectInternalImports =
rnInteropLibraryPackage.redirectInternalImports;
if (redirectInternalImports) {
const moduleAbsPath = pathUtils.resolve(
pathUtils.dirname(ctx.originModulePath),
moduleName
);
try {
const newModuleName = moduleAbsPath.replace(
reactNativeHarmonyPattern,
reactNativeInteropLibraryPackagePattern ??
getRNInteropLibraryPackagePattern(rnInteropLibraryPackage.name)
);
return ctx.resolveRequest(ctx, newModuleName, HARMONY_PLATFORM_NAME);
} catch {}
}
}
const maybeResult = resolveRequestOnlyForHarmony(ctx, moduleName);
if (maybeResult) {
return maybeResult;
}
return ctx.resolveRequest(ctx, moduleName, RNOH_FALLBACK_PLATFORM_NAME);
}
function resolveAliasedInternalImport(ctx, moduleName, nodeModulesPaths) {
const alias = getPackageNameFromOriginModulePath(ctx.originModulePath);
if (!alias) {
return null;
}
const harmonyPackage = getHarmonyPackageByAliasMap(nodeModulesPaths);
const harmonyPackageName = harmonyPackage[alias]?.name;
const redirectInternalImports = harmonyPackage[alias]?.redirectInternalImports;
if (
!harmonyPackageName ||
isRequestFromHarmonyPackage(ctx.originModulePath, harmonyPackageName) ||
!redirectInternalImports
) {
return null;
}
const moduleAbsPath = pathUtils.resolve(
pathUtils.dirname(ctx.originModulePath),
moduleName
);
const modulePathRelativeToOriginalPackage = extractPackageRelativePath(
moduleAbsPath,
alias
);
if (!modulePathRelativeToOriginalPackage) {
return null;
}
const newModuleName = `${harmonyPackageName}/${modulePathRelativeToOriginalPackage}`;
try {
return ctx.resolveRequest(ctx, newModuleName, HARMONY_PLATFORM_NAME);
} catch {
return null;
}
}
function isVirtualizedListImport(originModulePath, moduleName) {
return (
originModulePath.includes('@react-native/virtualized-lists') &&
moduleName === './VirtualizedList'
);
}
function resolveVirtualizedListImport(
ctx,
nodeModulesPaths,
reactNativeHarmonyPackageName,
reactNativeInteropLibraryPackagePattern
) {
const rnInteropLibraryPackage =
getHarmonyPackageByAliasMap(nodeModulesPaths)[reactNativeHarmonyPackageName];
if (!rnInteropLibraryPackage) {
return null;
}
const newModuleName =
`@react-native-oh` +
(reactNativeInteropLibraryPackagePattern ??
getRNInteropLibraryPackagePattern(rnInteropLibraryPackage.name)) +
`patched-virtualized-list${pathUtils.sep}VirtualizedList`;
try {
return ctx.resolveRequest(ctx, newModuleName, HARMONY_PLATFORM_NAME);
} catch {
return null;
}
}
function resolveGenericHarmonyAliasImport(ctx, moduleName, nodeModulesPaths) {
const harmonyPackageByAlias = getHarmonyPackageByAliasMap(nodeModulesPaths);
const alias = getPackageName(moduleName);
if (!alias) {
return null;
}
const harmonyPackageName = harmonyPackageByAlias[alias]?.name;
if (
!harmonyPackageName ||
isRequestFromHarmonyPackage(ctx.originModulePath, harmonyPackageName)
) {
return null;
}
return ctx.resolveRequest(
ctx,
moduleName.replace(alias, harmonyPackageName),
HARMONY_PLATFORM_NAME
);
}
function resolveWithHarmonyFallback(ctx, moduleName) {
try {
return ctx.resolveRequest(ctx, moduleName, HARMONY_PLATFORM_NAME);
} catch {
return ctx.resolveRequest(ctx, moduleName, RNOH_FALLBACK_PLATFORM_NAME);
}
}
function extractPackageRelativePath(moduleAbsPath, alias) {
const normalizedAlias = alias.replace(/\//g, pathUtils.sep);
const expectedPrefix = `${pathUtils.sep}node_modules${pathUtils.sep}${normalizedAlias}${pathUtils.sep}`;
const parts = moduleAbsPath.split(expectedPrefix);
if (parts.length < 2 || !parts[1]) {
return null;
}
return parts[1].replace(/\\/g, '/');
}
* Let's say we have following files:
* foo.js
* foo.harmony.tsx
*
* By default, in that situation foo.js will be resolved. This function however chooses foo.harmony.tsx.
* In the past, RNOH redirected imports back to the RN package, and RNOH used different extensions than original files.
*
* @param ctx {Parameters<NonNullable<import("metro-config").ResolverConfigT["resolveRequest"]>>[0]}
* @param moduleName {string}
*/
function resolveRequestOnlyForHarmony(ctx, moduleName) {
for (const sourceExt of ctx.sourceExts) {
const result = resolveHarmonyRequestForSourceExt(ctx, moduleName, sourceExt);
if (isHarmonySourceFileResult(result)) {
return result;
}
}
return null;
}
function resolveHarmonyRequestForSourceExt(ctx, moduleName, sourceExt) {
try {
return ctx.resolveRequest(
{ ...ctx, sourceExts: [sourceExt] },
moduleName,
HARMONY_PLATFORM_NAME
);
} catch {
return null;
}
}
function isHarmonySourceFileResult(result) {
if (!result || result.type !== 'sourceFile') {
return false;
}
const lastDotIndex = result.filePath.lastIndexOf('.');
const beforeLastDot = result.filePath.substring(0, lastDotIndex);
return beforeLastDot.endsWith('.' + HARMONY_PLATFORM_NAME);
}
* @param moduleName {string}
* @returns {string | null}
*/
function getPackageName(moduleName) {
if (moduleName.startsWith('.')) {
return null;
}
if (moduleName.startsWith('@')) {
const segments = moduleName.split('/', 3);
if (segments.length === 2) {
return moduleName;
}
if (segments.length > 2) {
return `${segments[0]}/${segments[1]}`;
}
return null;
}
if (moduleName.includes('/')) {
return moduleName.split('/')[0];
}
return moduleName;
}
* @param originModulePath {string}
* @returns {string}
*/
function getPackageNameFromOriginModulePath(originModulePath) {
const nodeModulesPosition = originModulePath.search('node_modules');
if (nodeModulesPosition === -1) {
return null;
}
const pathRelativeToNodeModules =
originModulePath.substring(nodeModulesPosition);
const pathSegments = pathRelativeToNodeModules.split(pathUtils.sep);
const module = pathSegments[1];
if (!module) {
return null;
}
if (module.startsWith('@')) {
if (!pathSegments[2]) {
return null;
}
return `${pathSegments[1]}/${pathSegments[2]}`;
}
return pathSegments[1];
}
* @param nodeModulesPaths {readonly string[]}
* @param originModulePath {string}
* @param moduleName {string}
* @returns {boolean}
*/
function isHarmonyPackageInternalImport(
nodeModulesPaths,
originModulePath,
moduleName
) {
if (moduleName.startsWith('.')) {
const alias = getPackageNameFromOriginModulePath(originModulePath);
const slashes = new RegExp('/', 'g');
if (alias && originModulePath.includes(`${pathUtils.sep}node_modules${pathUtils.sep}${alias.replace(slashes, pathUtils.sep)}${pathUtils.sep}`)) {
const harmonyPackage = getHarmonyPackageByAliasMap(nodeModulesPaths);
const harmonyPackageName = harmonyPackage[alias]?.name;
if (
harmonyPackageName &&
!isRequestFromHarmonyPackage(originModulePath, harmonyPackageName)
) {
return true;
}
}
}
return false;
}
* @param originModulePath {string}
* @param harmonyPackageName {string}
* @returns {boolean}
*/
function isRequestFromHarmonyPackage(originModulePath, harmonyPackageName) {
const slashes = new RegExp('/', 'g');
const packagePath = harmonyPackageName.replace(slashes, pathUtils.sep);
return originModulePath.includes(
`${pathUtils.sep}node_modules${pathUtils.sep}${packagePath}${pathUtils.sep}`
);
}
* @type {Record<string, {name: string, redirectInternalImports: boolean}> | undefined}
*/
let cachedHarmonyPackageByAliasMap = undefined;
* @param nodeModulesPaths {readonly string[]}
*/
function getHarmonyPackageByAliasMap(nodeModulesPaths) {
* @type {Record<string, {name: string, redirectInternalImports: boolean}>}
*/
const initialAcc = {};
if (cachedHarmonyPackageByAliasMap) {
return cachedHarmonyPackageByAliasMap;
}
const harmonyNodeModules = findHarmonyNodeModulePaths(
findHarmonyNodeModuleSearchPaths(nodeModulesPaths)
);
cachedHarmonyPackageByAliasMap = buildHarmonyPackageByAliasMap(
harmonyNodeModules,
initialAcc
);
const harmonyPackagesCount = Object.keys(
cachedHarmonyPackageByAliasMap
).length;
if (harmonyPackagesCount > 0) {
const prettyHarmonyPackagesCount = colors.bold(
harmonyPackagesCount > 0
? colors.green(harmonyPackagesCount.toString())
: harmonyPackagesCount.toString()
);
info(
`Redirected imports to ${prettyHarmonyPackagesCount} harmony-specific third-party package(s):`
);
if (harmonyPackagesCount > 0) {
Object.entries(cachedHarmonyPackageByAliasMap).forEach(
([original, { name: alias }]) => {
info(
`• ${colors.bold(colors.gray(original))} → ${colors.bold(alias)}`
);
}
);
}
} else {
info('No harmony-specific third-party packages have been detected');
}
console.log('');
return cachedHarmonyPackageByAliasMap;
}
function buildHarmonyPackageByAliasMap(harmonyNodeModules, initialAcc) {
return harmonyNodeModules.reduce((acc, nodeModule) => {
const harmonyNodeModuleName = getHarmonyNodeModuleName(nodeModule);
if (!harmonyNodeModuleName) {
return acc;
}
const packageJsonPath = `${nodeModule.resolvedPath}${pathUtils.sep}package.json`;
const packageJson = readHarmonyModulePackageJSON(packageJsonPath);
const alias = packageJson.harmony?.alias;
if (!alias) {
return acc;
}
acc[alias] = {
name: harmonyNodeModuleName,
redirectInternalImports: packageJson?.harmony?.redirectInternalImports ?? false,
};
return acc;
}, initialAcc);
}
function getHarmonyNodeModuleName({ resolvedPath, unresolvedPath }) {
const harmonyNodeModulePath = resolvedPath.includes('node_modules')
? resolvedPath
: unresolvedPath;
const segments = harmonyNodeModulePath.split(pathUtils.sep);
if (segments.length === 0) {
return null;
}
let harmonyNodeModuleName = segments[segments.length - 1];
if (segments.length > 1) {
const parentDir = segments[segments.length - 2];
if (parentDir.startsWith('@')) {
harmonyNodeModuleName = `${parentDir}/${harmonyNodeModuleName}`;
}
}
return harmonyNodeModuleName;
}
* @param nodeModulesPaths {readonly string[]}
* @returns {string[]}
*/
function findHarmonyNodeModuleSearchPaths(nodeModulesPaths) {
* @type string[]
*/
let searchPaths = [];
for (const nodeModulesPath of nodeModulesPaths) {
if (fs.existsSync(nodeModulesPath)) {
fs.readdirSync(nodeModulesPath)
.filter((dirName) => dirName.startsWith('@'))
.forEach((dirName) => {
searchPaths.push(`${nodeModulesPath}${pathUtils.sep}${dirName}`);
});
searchPaths.push(nodeModulesPath);
}
}
return searchPaths;
}
* @param searchPaths {string[]}
* @returns {{resolvedPath:string, unresolvedPath: string}[]}
*/
function findHarmonyNodeModulePaths(searchPaths) {
return searchPaths
.map((searchPath) =>
fs
.readdirSync(searchPath, { withFileTypes: true })
.map((dirent) => createHarmonyNodeModulePaths(searchPath, dirent))
.filter(({ resolvedPath }) => hasPackageJSON(resolvedPath))
)
.flat();
}
function createHarmonyNodeModulePaths(searchPath, dirent) {
const direntPath =
(dirent.parentPath ?? dirent.path) + pathUtils.sep + dirent.name;
if (dirent.isSymbolicLink()) {
return {
resolvedPath: pathUtils.resolve(
dirent.parentPath ?? dirent.path,
fs.readlinkSync(direntPath)
),
unresolvedPath: pathUtils.resolve(dirent.parentPath ?? dirent.path, direntPath),
};
}
return { resolvedPath: direntPath, unresolvedPath: direntPath };
}
* @param nodeModulePath {string}
* @returns {boolean}
*/
function hasPackageJSON(nodeModulePath) {
if (!fs.existsSync(nodeModulePath)) {
return false;
}
if (!fs.lstatSync(nodeModulePath).isDirectory()) {
return false;
}
const nodeModuleContentNames = fs.readdirSync(nodeModulePath);
return nodeModuleContentNames.includes('package.json');
}
* @param packageJSONPath {string}
* @returns {{name: string, harmony?: {alias?: string, redirectInternalImports?: boolean}}}
*/
function readHarmonyModulePackageJSON(packageJSONPath) {
return JSON.parse(fs.readFileSync(packageJSONPath).toString());
}
* @param rnInteropLibraryPackageName {string}
* @param isInMonorepo {boolean}
* @returns {string} - Either package name without the scope or the full package name with platform specific separator
*/
function getRNInteropLibraryPackagePattern(rnInteropLibraryPackageName) {
return `${pathUtils.sep}${rnInteropLibraryPackageName.replace('/', pathUtils.sep)}${pathUtils.sep}`;
}