Skip to content
6 changes: 6 additions & 0 deletions .changeset/bright-runtimes-load.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@module-federation/runtime': patch
'@module-federation/runtime-core': patch
---

`createInstance` now constructs the runtime's own `ModuleFederation` class, not a debug constructor that another bundle set on the global. `init` reuses a global instance only when it supports the same remote loading, shared loading, snapshot plugins, and build target as the calling runtime. Updated runtimes do not reuse instances created by older runtimes.
20 changes: 20 additions & 0 deletions packages/runtime-core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { formatShareConfigs } from './utils/share';
declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean;
declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean;
declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean;
declare const ENV_TARGET: 'web' | 'node';

type BridgeHookContext = object;
type BridgeHookResult = {
Expand All @@ -72,7 +73,26 @@ const USE_SHARED =
? !FEDERATION_OPTIMIZE_NO_SHARED
: true;

// What this build of the runtime can do. `init` in another bundle reuses an instance only
// when this string matches its own: remote loading, shared loading, snapshot plugins, and
// the entry loader the build target selects.
const RUNTIME_CAPABILITIES = [
USE_REMOTE && 'remote',
USE_SHARED && 'shared',
USE_REMOTE && USE_SNAPSHOT && 'snapshot',
typeof ENV_TARGET !== 'undefined' ? ENV_TARGET : 'universal',
]
.filter(Boolean)
.join(',');

export class ModuleFederation {
/** @internal A getter, because a static field compiles to a static block that Metro's Babel rejects. */
static get runtimeCapabilities(): string {
return RUNTIME_CAPABILITIES;
}
/** @internal Read by other bundles, so it lives on the instance. */
readonly runtimeCapabilities = RUNTIME_CAPABILITIES;

options: Options;
hooks = new PluginSystem({
beforeInit: new SyncWaterfallHook<{
Expand Down
298 changes: 298 additions & 0 deletions packages/runtime/__tests__/fixtures/optimized-runtimes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
// Bundles independent copies of @module-federation/runtime, each with its own build-time
// flags, and runs one scenario. Each scenario runs in its own process, so every scenario
// starts with an empty __FEDERATION__ global.
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import path from 'node:path';
import { build } from 'esbuild';

const runtimeDir = path.resolve(import.meta.dirname, '../..');
const packageDir = path.resolve(runtimeDir, '..');
const require = createRequire(path.join(runtimeDir, 'package.json'));

async function bundle({
buildId,
disableRemote = false,
disableShared = false,
disableSnapshot = false,
target = 'node',
}) {
const result = await build({
entryPoints: [path.join(runtimeDir, 'src/index.ts')],
bundle: true,
write: false,
platform: 'node',
format: 'cjs',
alias: {
'@module-federation/runtime-core': path.join(
packageDir,
'runtime-core/src/index.ts',
),
'@module-federation/error-codes/browser': path.join(
packageDir,
'error-codes/src/browser.ts',
),
'@module-federation/error-codes': path.join(
packageDir,
'error-codes/src/index.ts',
),
},
define: {
__VERSION__: '"test"',
FEDERATION_DEBUG: '"true"',
FEDERATION_OPTIMIZE_NO_REMOTE: String(disableRemote),
FEDERATION_OPTIMIZE_NO_SHARED: String(disableShared),
FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: String(disableSnapshot),
FEDERATION_BUILD_IDENTIFIER: JSON.stringify(buildId),
ENV_TARGET: JSON.stringify(target),
},
});
const module = { exports: {} };
new Function('require', 'module', 'exports', result.outputFiles[0].text)(
require,
module,
module.exports,
);
return module.exports;
}

const server = createServer((request, response) => {
if (request.url === '/mf-manifest.json') {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
id: 'manifest-tiny',
name: 'manifest-tiny',
metaData: {
globalName: 'manifestTiny',
publicPath: `http://${request.headers.host}/`,
buildInfo: { buildVersion: '1.0.0' },
remoteEntry: {
name: 'remoteEntry.js',
path: '',
type: 'commonjs-module',
},
},
remotes: [],
shared: [],
exposes: [],
}),
);
return;
}
response.setHeader('Content-Type', 'text/javascript');
response.end(`module.exports = {
init() {},
get(expose) {
if (expose !== './value') throw new Error('Unexpected expose: ' + expose);
return () => ({ value: 'remote-value' });
}
};`);
});

await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const entry = `http://127.0.0.1:${server.address().port}/remoteEntry.js`;

const options = (name, version, remoteName = 'tiny') => ({
name,
version,
remotes: [{ name: remoteName, entry }],
});
const sharedToken = {
token: {
version: '1.0.0',
get: async () => () => ({ value: 'shared-value' }),
},
};
const value = async (runtime, id = 'tiny/value') =>
(await runtime.loadRemote(id))?.value;
const errorOf = (promise, what) =>
promise.then(
() => `${what} unexpectedly succeeded`,
(error) => error.message,
);
const loadError = (runtime) =>
errorOf(
Promise.resolve().then(() => runtime.loadRemote('tiny/value')),
'Remote loading',
);
const instances = (pick) => globalThis.__FEDERATION__.__INSTANCES__.map(pick);

const scenarios = {
async 'distinct-last-disabled'() {
const full = await bundle({ buildId: 'full-app@1.0.0' });
const disabled = await bundle({
buildId: 'disabled-app@1.0.0',
disableRemote: true,
});
const disabledInstance = disabled.createInstance(options('disabled-app'));
const created = full.createInstance(options('full-created'));
const initialized = full.init(options('full-initialized'));
return {
disabledError: await loadError(disabledInstance),
createdValue: await value(created),
initializedValue: await value(initialized),
moduleValue: await value(full),
instanceNames: instances((instance) => instance.name),
};
},

async 'distinct-last-full'() {
const disabled = await bundle({
buildId: 'disabled-app@1.0.0',
disableRemote: true,
});
const full = await bundle({ buildId: 'full-app@1.0.0' });
const initialized = full.init(options('full-app'));
const disabledInstance = disabled.init(options('disabled-app'));
return {
fullValue: await value(initialized),
fullModuleValue: await value(full),
disabledError: await loadError(disabledInstance),
disabledModuleError: await loadError(disabled),
instanceNames: instances((instance) => instance.name),
};
},

async 'version-isolated'() {
const full = await bundle({ buildId: 'app@2.0.0' });
const disabled = await bundle({
buildId: 'app@1.0.0',
disableRemote: true,
});
const disabledInstance = disabled.init(options('app', '1.0.0'));
const initialized = full.init(options('app', '2.0.0'));
return {
disabledError: await loadError(disabledInstance),
fullValue: await value(initialized),
fullModuleValue: await value(full),
versions: instances((instance) => instance.options.version),
};
},

async 'compatible-reuse'() {
const full = await bundle({ buildId: 'app@1.0.0' });
const compatible = await bundle({ buildId: 'app@2.0.0' });
const first = full.init({
...options('app', '1.0.0'),
shared: sharedToken,
});
const fresh = full.createInstance(options('app', '1.0.0', 'fresh'));
const repeated = compatible.init(options('app', '1.0.0'));
const shared = await repeated.loadShare('token');
return {
firstValue: await value(first),
repeatedValue: await value(repeated),
moduleValue: await value(full),
compatibleModuleValue: await value(compatible),
freshValue: await value(fresh, 'fresh/value'),
sharedValue: shared?.()?.value,
instanceCount: globalThis.__FEDERATION__.__INSTANCES__.length,
};
},

'collision-different-build': () =>
disabledThenFull({ fullBuildId: 'app@2.0.0' }),

'collision-same-build': () => disabledThenFull({ fullBuildId: 'app@1.0.0' }),

// Without snapshot plugins, only the remote capability tells the two builds apart.
'collision-remote-without-snapshot': () =>
disabledThenFull({ fullBuildId: 'app@1.0.0', disableSnapshot: true }),

async 'collision-full-first'() {
const full = await bundle({ buildId: 'app@2.0.0' });
const disabled = await bundle({
buildId: 'app@1.0.0',
disableRemote: true,
});
const initialized = full.init(options('app', '1.0.0'));
const disabledInstance = disabled.init(options('app', '1.0.0'));
return {
fullValue: await value(initialized),
disabledError: await loadError(disabledInstance),
disabledModuleError: await loadError(disabled),
registeredIds: instances((instance) => instance.options.id),
};
},

async 'collision-shared'() {
const full = await bundle({ buildId: 'app@2.0.0' });
const disabled = await bundle({
buildId: 'app@1.0.0',
disableShared: true,
});
const sharedOptions = { ...options('app', '1.0.0'), shared: sharedToken };
const disabledInstance = disabled.init(sharedOptions);
const initialized = full.init(sharedOptions);
const shared = await initialized.loadShare('token');
return {
disabledError: await errorOf(
disabledInstance.loadShare('token'),
'Shared loading',
),
sharedValue: shared?.()?.value,
fullValue: await value(initialized),
};
},

async 'collision-snapshot'() {
const full = await bundle({ buildId: 'app@2.0.0' });
const disabled = await bundle({
buildId: 'app@1.0.0',
disableSnapshot: true,
});
const direct = disabled.init(options('app', '1.0.0'));
const initialized = full.init({
name: 'app',
version: '1.0.0',
remotes: [
{
name: 'manifest-tiny',
entry: entry.replace('remoteEntry.js', 'mf-manifest.json'),
},
],
});
return {
directValue: await value(direct),
manifestValue: await value(initialized, 'manifest-tiny/value'),
moduleValue: await value(full, 'manifest-tiny/value'),
};
},

// A web-target runtime loads entries with the DOM loader, which cannot run in Node.
async 'collision-target'() {
const web = await bundle({ buildId: 'app@1.0.0', target: 'web' });
const node = await bundle({ buildId: 'app@1.0.0' });
const webInstance = web.init(options('app', '1.0.0'));
const nodeInstance = node.init(options('app', '1.0.0'));
return {
separate: webInstance !== nodeInstance,
nodeValue: await value(nodeInstance),
nodeModuleValue: await value(node),
};
},
};

async function disabledThenFull({ fullBuildId, disableSnapshot = false }) {
const full = await bundle({ buildId: fullBuildId, disableSnapshot });
const disabled = await bundle({ buildId: 'app@1.0.0', disableRemote: true });
const disabledInstance = disabled.init(options('app', '1.0.0'));
const initialized = full.init(options('app', '1.0.0'));
const repeated = full.init(options('app', '1.0.0'));
return {
disabledError: await loadError(disabledInstance),
fullValue: await value(initialized),
fullModuleValue: await value(full),
repeatedValue: await value(repeated),
registeredIds: instances((instance) => instance.options.id),
};
}

const scenario = process.argv[2];
try {
if (!scenarios[scenario]) throw new Error(`Unknown scenario: ${scenario}`);
console.log(JSON.stringify(await scenarios[scenario]()));
} finally {
await new Promise((resolve) => server.close(resolve));
}
20 changes: 2 additions & 18 deletions packages/runtime/__tests__/global.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, rs, expect } from '@rstest/core';
import { init, loadRemote, loadShare, loadShareSync } from '../src/index';
import { describe, it, expect } from '@rstest/core';
import { loadRemote, loadShare, loadShareSync } from '../src/index';
import { getInfoWithoutType } from '@module-federation/runtime-core';

type IsAssignable<Actual, Expected> = [Actual] extends [Expected]
Expand All @@ -8,22 +8,6 @@ type IsAssignable<Actual, Expected> = [Actual] extends [Expected]
type ExpectFalse<T extends false> = T;

describe('global', () => {
it('inject mode', () => {
globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = rs.fn();
const injectArgs = {
name: '@federation/inject-mode',
remotes: [],
};
const GM = init(injectArgs);
expect(GM.constructor).toBe(
globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__,
);
expect(globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__).toBeCalledWith({
...injectArgs,
id: '',
});
});

it('getInfoWithoutType', () => {
const snapshot = {
'@federation/app1': 1,
Expand Down
Loading