Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1b3250f
refactor(webpack-bundler-runtime): compose the bundler runtime from a…
ScriptedAlchemy Sep 25, 2026
3fbe50e
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
8becc1a
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
785a04d
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
c1fedea
fix(webpack-bundler-runtime): read the loadScriptNode shim's instance…
ScriptedAlchemy Sep 25, 2026
501c23a
fix(webpack-bundler-runtime): name the legacy root's export types so …
ScriptedAlchemy Sep 25, 2026
448ee20
test(webpack-bundler-runtime): catch re-exports, requires and bare im…
ScriptedAlchemy Sep 25, 2026
ce7549a
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
a8539ea
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
1ecb6ef
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
836dc35
fix(webpack-bundler-runtime): give the composed runtime shim init for…
ScriptedAlchemy Sep 25, 2026
9afd209
refactor(webpack-bundler-runtime): drop loadScriptNode from the compo…
ScriptedAlchemy Sep 25, 2026
5feb895
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
ab1bcc0
Merge remote-tracking branch 'origin/rfc5128/01-runtime-core-kernel' …
ScriptedAlchemy Sep 25, 2026
1a1b790
Merge branch 'rfc5128/01-runtime-core-kernel' into rfc5128/02-bundler…
ScriptedAlchemy Oct 9, 2026
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
5 changes: 5 additions & 0 deletions .changeset/bundler-runtime-compose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/webpack-bundler-runtime': minor
---

Add `createFederation` at `./compose` and four adapters at `./adapters/remotes`, `./adapters/consumes`, `./adapters/share-scope`, and `./adapters/container`, so a generated bootstrap can import only the bundler runtime parts a build uses. The root export is rebuilt from the same adapters and keeps its keys and `federation.runtime`.
289 changes: 289 additions & 0 deletions packages/webpack-bundler-runtime/__tests__/compose.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import { remote } from '@module-federation/runtime-core/remote';
import { shared } from '@module-federation/runtime-core/shared';
import type {
Capabilities,
Platform,
} from '@module-federation/runtime-core/kernel';
import { createFederation } from '../src/compose';
import { remotes } from '../src/adapters/remotes';
import { consumes } from '../src/adapters/consumes';
import { shareScope } from '../src/adapters/share-scope';
import { container } from '../src/adapters/container';
import type { Adapter } from '../src/types';

let seq = 0;

const containerEntry = {
init: () => undefined,
get: () => () => ({ answer: 42 }),
};

const fakePlatform: Platform = {
isBrowser: () => true,
loadScript: () => Promise.resolve(),
loadEntry: () => Promise.resolve(containerEntry as any),
};

function boot(
capabilities: Capabilities,
adapters: Adapter[],
initOptions: Record<string, any> = {},
) {
const name = `host${++seq}`;
const federation = createFederation({
buildId: `${name}:1.0.0`,
capabilities,
adapters,
});
const webpackRequire: any = (id: string) => {
const module = { exports: {} };
webpackRequire.m[id](module);
return module.exports;
};
const initPromises = {};
const initTokens = {};
Object.assign(webpackRequire, {
m: {},
c: {},
p: '',
S: {},
I: (shareScopeName: string, initScope?: unknown[]) =>
federation.bundlerRuntime.I!({
shareScopeName,
webpackRequire,
initPromises,
initTokens,
initScope: initScope as any,
}),
o: (obj: object, key: string | number) =>
Object.prototype.hasOwnProperty.call(obj, key),
federation,
});
federation.initOptions = { name, remotes: [], ...initOptions } as any;
federation.instance = federation.bundlerRuntime.init({ webpackRequire });
return { federation, webpackRequire, name };
}

async function loadRemoteModule(capabilities: Capabilities) {
const { webpackRequire } = boot(capabilities, [remotes, shareScope], {
remotes: [{ name: 'shop', entry: 'http://localhost/shop.js' }],
});
webpackRequire.m.external = (module: any) => {
module.exports = {};
};
const promises: Promise<unknown>[] = [];
webpackRequire.federation.bundlerRuntime.remotes({
chunkId: 'main',
promises,
webpackRequire,
chunkMapping: { main: ['button'] },
idToExternalAndNameMapping: { button: ['default', './Button', 'external'] },
idToRemoteMap: { button: [{ externalType: 'script', name: 'shop' }] },
});
await Promise.all(promises);
return () => webpackRequire('button');
}

async function loadSharedModule(capabilities: Capabilities) {
const { webpackRequire } = boot(capabilities, [consumes, shareScope], {
shared: {
react: {
version: '18.0.0',
scope: ['default'],
get: () => () => ({ react: '18.0.0' }),
shareConfig: { singleton: false, requiredVersion: '^18.0.0' },
},
},
});
const promises: Promise<unknown>[] = [];
webpackRequire.federation.bundlerRuntime.consumes({
chunkId: 'main',
promises,
webpackRequire,
chunkMapping: { main: ['react-consume'] },
installedModules: {},
moduleToHandlerMapping: {
'react-consume': {
shareKey: 'react',
getter: () => Promise.resolve(() => ({ react: 'fallback' })),
shareInfo: {
shareConfig: { singleton: false, requiredVersion: '^18.0.0' },
scope: ['default'],
},
},
},
});
await Promise.allSettled(promises);
return () => webpackRequire('react-consume');
}

describe('createFederation', () => {
test.each([
['remotes', remotes, ['remotes']],
['consumes', consumes, ['consumes', 'getSharedFallbackGetter']],
['share-scope', shareScope, ['I', 'installInitialConsumes']],
['container', container, ['initContainerEntry']],
])('%s exposes exactly its bundlerRuntime keys', (_, adapter, keys) => {
const { bundlerRuntime } = createFederation({
buildId: 'host:1.0.0',
capabilities: {},
adapters: [adapter],
});
expect(Object.keys(bundlerRuntime).sort()).toEqual(
['S', 'init', ...keys].sort(),
);
});

test('without adapters exposes only the composer keys', () => {
const federation = createFederation({
buildId: 'host:1.0.0',
capabilities: {},
adapters: [],
});
expect(Object.keys(federation.bundlerRuntime).sort()).toEqual([
'S',
'init',
]);
expect(Object.keys(federation.runtime)).toEqual(['init']);
});

test('init passes buildId as the default id', () => {
const { federation, name } = boot({}, []);
expect(federation.instance!.options.id).toBe(`${name}:1.0.0`);
});

test('init keeps an explicit id', () => {
const { federation } = boot({}, [], { id: 'explicit' });
expect(federation.instance!.options.id).toBe('explicit');
});

test('init runs each adapter beforeInit before the instance is created', () => {
const seen: string[] = [];
const probe: Adapter = {
bundlerRuntime: {},
beforeInit: (webpackRequire, initOptions) => {
seen.push(initOptions.name);
expect(webpackRequire.federation.instance).toBeUndefined();
},
};
const { name } = boot({}, [probe]);
expect(seen).toEqual([name]);
});

test('consumes beforeInit registers the tree-shaking share plugin', () => {
const { federation } = boot({ shared }, [consumes, shareScope]);
expect(
federation.instance!.options.plugins.map((plugin) => plugin.name),
).toContain('tree-shake-plugin');
});

test('without consumes no tree-shaking share plugin is registered', () => {
const { federation } = boot({ shared }, [shareScope]);
expect(
federation.instance!.options.plugins.map((plugin) => plugin.name),
).not.toContain('tree-shake-plugin');
});

test('rspack 1.x initializes through runtime.init on the copied keys', () => {
const name = `host${++seq}`;
const composed = createFederation({
buildId: `${name}:1.0.0`,
capabilities: { platform: fakePlatform },
adapters: [shareScope],
});
// @rspack/core 1.x moduleFederationDefaultRuntime: copy the keys, then call runtime.init.
const federation: any = {};
for (const key in composed) {
federation[key] = (composed as any)[key];
}
federation.initOptions = { name, remotes: [] };
federation.instance = federation.runtime.init(federation.initOptions);
expect(federation.instance.options.id).toBe(`${name}:1.0.0`);
});
});

describe('adapters against enabled and disabled handlers', () => {
test('remotes loads a remote module with the remote capability', async () => {
const load = await loadRemoteModule({ remote, platform: fakePlatform });
expect(load()).toEqual({ answer: 42 });
});

test('remotes fails with the disabled remote error without it', async () => {
const load = await loadRemoteModule({});
expect(load).toThrow(/Remote loading is disabled/);
});

test('consumes resolves a shared module with the shared capability', async () => {
const load = await loadSharedModule({ shared });
expect(load()).toEqual({ react: '18.0.0' });
});

test('consumes fails with the disabled shared error without it', async () => {
const load = await loadSharedModule({});
expect(load).toThrow(/Shared dependency loading is disabled/);
});

test('share-scope initializes and exposes the share scope with the shared capability', async () => {
const { webpackRequire } = boot({ shared }, [shareScope], {
shared: {
lodash: {
version: '4.0.0',
get: () => () => ({}),
scope: ['default'],
shareConfig: { singleton: false, requiredVersion: '^4.0.0' },
},
},
});
await webpackRequire.I('default');
expect(Object.keys(webpackRequire.S.default)).toEqual(['lodash']);
});

test('share-scope initializes an empty scope without the shared capability', async () => {
const { webpackRequire } = boot({}, [shareScope]);
await expect(webpackRequire.I('default')).resolves.toBe(true);
expect(webpackRequire.S).toEqual({});
});

test('share-scope installInitialConsumes fails with the disabled shared error without it', () => {
const { webpackRequire } = boot({}, [shareScope]);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
webpackRequire.m.eager = () => undefined;
expect(() =>
webpackRequire.federation.bundlerRuntime.installInitialConsumes({
webpackRequire,
installedModules: {},
initialConsumes: ['eager'],
moduleToHandlerMapping: {
eager: {
shareKey: 'react',
getter: () => Promise.resolve(() => ({})),
shareInfo: { shareConfig: {}, scope: ['default'] },
},
},
}),
).not.toThrow();
expect(() => webpackRequire('eager')).toThrow(
/Shared dependency loading is disabled/,
);
});

test.each([
['with the shared capability', { shared }],
['without the shared capability', {}],
])(
'container adopts the host share scope %s',
async (_, capabilities: Capabilities) => {
const { webpackRequire, federation } = boot(capabilities, [
container,
shareScope,
]);
const hostScope = {};
await federation.bundlerRuntime.initContainerEntry({
webpackRequire,
shareScope: hostScope,
shareScopeKey: 'default',
});
expect(webpackRequire.S.default).toBe(hostScope);
},
);
});
21 changes: 21 additions & 0 deletions packages/webpack-bundler-runtime/__tests__/consumer-types.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { join } from 'path';
import ts from 'typescript';

test('published declarations assign across runtime-core types', () => {
const program = ts.createProgram(
[join(__dirname, 'fixtures/consumer-types/index.ts')],
{
strict: true,
noEmit: true,
skipLibCheck: true,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
target: ts.ScriptTarget.ES2020,
types: [],
},
);
const errors = ts
.getPreEmitDiagnostics(program)
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'));
expect(errors).toEqual([]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type {
ModuleFederationRuntimePlugin,
UserOptions,
} from '@module-federation/runtime-core';
import { initOptions } from '@module-federation/webpack-bundler-runtime';
import { createFederation } from '@module-federation/webpack-bundler-runtime/compose';
import { consumes } from '@module-federation/webpack-bundler-runtime/adapters/consumes';

declare const plugin: ModuleFederationRuntimePlugin;

export const options: UserOptions = initOptions!;
initOptions!.plugins!.push(plugin);
export const federation = createFederation({
buildId: 'host:1.0.0',
capabilities: {},
adapters: [consumes],
});
34 changes: 34 additions & 0 deletions packages/webpack-bundler-runtime/__tests__/import-boundary.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { readdirSync, readFileSync } from 'fs';
import { join, relative } from 'path';

const srcDir = join(__dirname, '../src');
const target = `['"]@module-federation/(?:runtime|runtime-core|runtime/helpers)['"]`;
const forbidden = new RegExp(
[
`^\\s*(?:import|export)(?!\\s+type\\b)[^;]*?\\bfrom\\s*${target}`,
`^\\s*import\\s*${target}`,
`\\b(?:require|import)\\(\\s*${target}\\s*\\)`,
].join('|'),
'gm',
);

function sourceFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? sourceFiles(join(dir, entry.name))
: entry.name.endsWith('.ts')
? [join(dir, entry.name)]
: [],
);
}

test('only the legacy root imports the runtime or runtime-core root or runtime/helpers', () => {
const offenders = sourceFiles(srcDir)
.filter((file) => relative(srcDir, file) !== 'index.ts')
.flatMap((file) =>
(readFileSync(file, 'utf8').match(forbidden) ?? []).map(
(line) => `${relative(srcDir, file)}: ${line}`,
),
);
expect(offenders).toEqual([]);
});
Loading
Loading