Skip to content
Merged
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
15 changes: 3 additions & 12 deletions apps/example-host/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import {
View,
} from 'react-native';

// @ts-ignore
import NestedMiniInfo from 'nestedMini/nestedMiniInfo';
import Card from './Card';

// @ts-ignore
const Info = React.lazy(() => import('mini/info'));

// @ts-ignore
const NestedMiniInfo = React.lazy(() => import('nestedMini/nestedMiniInfo'));

function App(): React.JSX.Element {
const [shouldLoadMini, setShouldLoadMini] = useState(false);
const [lodashVersion, setLodashVersion] = useState<string>();
Expand Down Expand Up @@ -76,15 +75,7 @@ function App(): React.JSX.Element {
title="Nested Federated Remote"
description="Dynamically loaded nested module"
>
<React.Suspense
fallback={
<View>
<ActivityIndicator size="large" color="#8b5cf6" />
</View>
}
>
<NestedMiniInfo />
</React.Suspense>
<NestedMiniInfo />
</Card>
</View>
</View>
Expand Down
5 changes: 2 additions & 3 deletions apps/example-nested-mini/src/nested-mini-info.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { VERSION } from 'lodash';
// @ts-ignore
import Info from 'mini/info';
import React from 'react';
import { View } from 'react-native';

// @ts-ignore
const Info = React.lazy(() => import('mini/info'));

export default function NestedMiniInfo() {
return (
<View testID="nested-mini">
Expand Down
51 changes: 36 additions & 15 deletions packages/core/src/modules/asyncRequire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,39 @@ function joinComponents(prefix: string, suffix: string) {

// get the public path from the url
// e.g. http://host:8081/a/b.bundle -> http://host:8081/a
function getPublicPath(url: string) {
return url.split('/').slice(0, -1).join('/');
}

// get bundle id from the url path
// e.g. /a/b.bundle?platform=ios -> a/b
function getBundleId(urlPath: string) {
const [bundlePath] = urlPath.split('?');
return bundlePath.slice(1).replace('.bundle', '');
function getPublicPath(url?: string) {
return url?.split('/').slice(0, -1).join('/');
}

function isUrl(url: string) {
return url.match(/^https?:\/\//);
}

function isSameOrigin(url: string, origin?: string) {
return origin && url.startsWith(origin);
// get bundle id from the bundle path
// e.g. /a/b.bundle?platform=ios -> a/b
// e.g. http://host:8081/a/b.bundle -> a/b
function getBundleId(bundlePath: string, publicPath: string) {
let path = bundlePath;
// remove the public path if it's an url
if (isUrl(path)) {
path = path.replace(publicPath, '');
}
// remove the leading slash
if (path.startsWith('/')) {
path = path.slice(1);
}
// remove the query params
path = path.split('?')[0];
// remove the bundle extension
return path.replace('.bundle', '');
}

function isSameOrigin(url: string, originPublicPath?: string) {
// if it's not a fully qualified url, we assume it's the same origin
if (!isUrl(url)) {
return true;
}
return !!originPublicPath && url.startsWith(originPublicPath);
}

// prefix the bundle path with the public path
Expand All @@ -42,7 +58,7 @@ function getBundlePath(bundlePath: string, bundleOrigin?: string) {
if (!bundleOrigin) {
return bundlePath;
}
return joinComponents(getPublicPath(bundleOrigin), bundlePath);
return joinComponents(bundleOrigin, bundlePath);
}

function buildLoadBundleAsyncWrapper() {
Expand All @@ -58,7 +74,11 @@ function buildLoadBundleAsyncWrapper() {
return async (originalBundlePath: string) => {
const scope = globalThis.__FEDERATION__.__NATIVE__[__METRO_GLOBAL_PREFIX__];

const bundlePath = getBundlePath(originalBundlePath, scope.origin);
// entry is always in the root directory of assets associated with remote
// based on that, we extract the public path from the origin URL
// e.g. http://example.com/a/b/c/mf-manfiest.json -> http://example.com/a/b/c
const publicPath = getPublicPath(scope.origin);
const bundlePath = getBundlePath(originalBundlePath, publicPath);

// ../../node_modules/ -> ..%2F..%2Fnode_modules/ so that it's not automatically sanitized
const encodedBundlePath = bundlePath.replaceAll('../', '..%2F');
Expand All @@ -67,13 +87,14 @@ function buildLoadBundleAsyncWrapper() {

// when the origin is not the same, it means we are loading a remote container
// we can return early since dependencies are processed differently for entry bundles
if (!isSameOrigin(bundlePath, scope.origin)) {
if (!isSameOrigin(bundlePath, publicPath)) {
return result;
}

// at this point the code in the bundle has been evaluated
// but not yet executed through metroRequire
const bundleId = getBundleId(originalBundlePath);
// note: at this point, public path is always defined
const bundleId = getBundleId(bundlePath, publicPath!);
const shared = scope.deps.shared[bundleId];
const remotes = scope.deps.remotes[bundleId];

Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import { VirtualModuleManager } from '../utils';
import { createBabelTransformer } from './babel-transformer';
import {
isUsingMFBundleCommand,
isUsingMFCommand,
prepareTmpDir,
replaceExtension,
Expand Down Expand Up @@ -126,7 +127,10 @@ function augmentConfig(
...config,
serializer: {
...config.serializer,
customSerializer: getModuleFederationSerializer(options),
customSerializer: getModuleFederationSerializer(
options,
isUsingMFBundleCommand()
),
getModulesRunBeforeMainModule: () => {
return isHost ? [initHostPath] : [];
},
Expand Down
50 changes: 44 additions & 6 deletions packages/core/src/plugin/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import baseJSBundle from 'metro/src/DeltaBundler/Serializers/baseJSBundle';
import CountingSet from 'metro/src/lib/CountingSet';
import bundleToString from 'metro/src/lib/bundleToString';
import type { ModuleFederationConfigNormalized, Shared } from '../types';
import { ConfigError } from '../utils/errors';

type CustomSerializer = SerializerConfigT['customSerializer'];

export function getModuleFederationSerializer(
mfConfig: ModuleFederationConfigNormalized
mfConfig: ModuleFederationConfigNormalized,
isUsingMFBundleCommand: boolean
): CustomSerializer {
return async (entryPoint, preModules, graph, options) => {
const syncRemoteModules = collectSyncRemoteModules(graph, mfConfig.remotes);
Expand All @@ -25,11 +27,17 @@ export function getModuleFederationSerializer(
}

// skip non-project source like node_modules
// this includes handling of shared modules!
if (!isProjectSource(entryPoint, options.projectRoot)) {
return getBundleCode(entryPoint, preModules, graph, options);
}

const bundlePath = getBundlePath(entryPoint, options.projectRoot);
const bundlePath = getBundlePath(
entryPoint,
options.projectRoot,
mfConfig.exposes,
isUsingMFBundleCommand
);
const finalPreModules = [
getSyncShared(syncSharedModules, bundlePath, mfConfig.name),
getSyncRemotes(syncRemoteModules, bundlePath, mfConfig.name),
Expand Down Expand Up @@ -156,10 +164,40 @@ function isProjectSource(entryPoint: string, projectRoot: string) {
);
}

function getBundlePath(entryPoint: string, projectRoot: string) {
const relativePath = path.relative(projectRoot, entryPoint);
const { dir, name } = path.parse(relativePath);
return path.format({ dir, name, ext: '' });
function getBundlePath(
entryPoint: string,
projectRoot: string,
exposes: ModuleFederationConfigNormalized['exposes'],
isUsingMFBundleCommand: boolean
) {
const relativeEntryPath = path.relative(projectRoot, entryPoint);
if (!isUsingMFBundleCommand) {
const { dir, name } = path.parse(relativeEntryPath);
return path.format({ dir, name, ext: '' });
}

// try to match with an exposed module first
const exposedMatchedKey = Object.keys(exposes).find((exposeKey) =>
exposes[exposeKey].match(relativeEntryPath)
);

if (exposedMatchedKey) {
// handle as exposed module
let exposedName = exposedMatchedKey;
// Remove './' prefix
if (exposedName.startsWith('./')) {
exposedName = exposedName.slice(2);
}
return `exposed/${exposedName}`;
}

throw new ConfigError(
`Unable to handle entry point: ${relativeEntryPath}. ` +
'Expected to match an entrypoint with one of the exposed keys, but failed. ' +
'This is most likely a configuration error. ' +
'If you believe this is not a configuration issue, please report it as a bug. ' +
`Debug info: entryPoint="${entryPoint}", projectRoot="${projectRoot}", exposesKeys=[${Object.keys(exposes).join(', ')}]`
);
}

function getBundleCode(
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/runtime/init-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,19 @@ globalThis.__FEDERATION__.__NATIVE__[name].deps ??= {
remotes: {},
};

globalThis.__FEDERATION__.__NATIVE__[name].init = Promise.all([
globalThis.__FEDERATION__.__NATIVE__[name].init = Promise.all(
instance.initializeSharing(shareScopeName, {
strategy: shareStrategy,
from: 'build',
initScope: [],
}),
]).then(() =>
})
).then(() =>
Promise.all([
...Object.keys(usedShared).map(loadSharedToRegistry),
...__EARLY_REMOTES__.map(loadRemoteToRegistry),
])
);

// IMPORTANT: load early shared deps immediately without
// waiting for the async part of initializeSharing to resolve
__EARLY_SHARED__.forEach(loadSharedToRegistry);
7 changes: 5 additions & 2 deletions packages/core/src/runtime/remote-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,20 @@ async function init(shared = {}, initScope = []) {
initScope.push(initToken);
instance.initShareScopeMap(shareScopeName, shared);

await Promise.all(
const initSharingPromise = Promise.all(
instance.initializeSharing(shareScopeName, {
strategy: shareStrategy,
from: 'build',
initScope,
})
);

// load early shared deps
// IMPORTANT: load early shared deps immediately without
// waiting for the async part of initializeSharing to resolve
__EARLY_SHARED__.forEach(loadSharedToRegistry);

await initSharingPromise;

// setup HMR client after the initializing sync shared deps
if (__DEV__ && !hmrInitialized) {
const hmr = require('mf:remote-hmr');
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/remote-module-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function loadRemoteToRegistry(id) {
const remoteModule = await loadRemote(id);
cloneModule(remoteModule, registry[id]);
})();
return loading[id];
await loading[id];
}
}

Expand Down