Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/clean-dodos-federate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@module-federation/manifest': patch
---

Preserve the configured Module Federation library type when reconciling
Rspack-pre-emitted stats and manifests, including ESM `module` remote entries.
6 changes: 6 additions & 0 deletions .changeset/warm-tap-application-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@module-federation/runtime-core": minor
"@module-federation/runtime": minor
---

Add a typed application lifecycle hook group for host-supervised pause and resume transitions.
73 changes: 73 additions & 0 deletions packages/manifest/__tests__/StatsManager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Stats, moduleFederationPlugin } from '@module-federation/sdk';
import type { Compiler } from 'webpack';

jest.mock(
'@module-federation/dts-plugin/core',
() => ({
isTSProject: () => false,
retrieveTypesAssetsInfo: () => ({}) as const,
}),
{ virtual: true },
);

jest.mock(
'@module-federation/managers',
() => ({
ContainerManager: class {
init() {}
},
RemoteManager: class {
init() {}
},
SharedManager: class {
init() {}
},
PKGJsonManager: class {},
UNKNOWN_MODULE_NAME: 'unknown',
utils: {},
}),
{ virtual: true },
);

import { StatsManager } from '../src/StatsManager';

describe('StatsManager', () => {
it('reconciles pre-emitted Rspack metadata to the configured ESM library type', () => {
const manager = new StatsManager();
manager.init(
{
name: 'esm_remote',
library: { type: 'module' },
exposes: { './App': './src/App' },
} as moduleFederationPlugin.ModuleFederationPluginOptions,
{ pluginVersion: 'test', bundler: 'rspack' },
);
const stats = {
id: 'esm_remote',
name: 'esm_remote',
metaData: {
name: 'esm_remote',
globalName: 'esm_remote',
buildInfo: { buildVersion: '1.0.0', buildName: 'esm_remote' },
remoteEntry: {
name: 'remoteEntry.mjs',
path: '',
type: 'global',
},
types: { path: '', name: '', api: '', zip: '' },
pluginVersion: 'test',
},
exposes: [],
shared: [],
remotes: [],
} as unknown as Stats;
const compiler = {
context: process.cwd(),
options: { output: { publicPath: 'auto' } },
} as unknown as Compiler;

const updated = manager.updateStats(stats, compiler);

expect(updated.metaData.remoteEntry.type).toBe('module');
});
});
7 changes: 7 additions & 0 deletions packages/manifest/src/StatsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,13 @@ class StatsManager {

updateStats(stats: Stats, compiler: Compiler): Stats {
const { metaData } = stats;
const configuredRemoteEntryType = this._options.library?.type;
if (configuredRemoteEntryType && metaData.remoteEntry) {
// Rspack may pre-emit stats with the default `global` type even when
// the configured container library and emitted remote entry are ESM.
// The configured library is authoritative for runtime loader selection.
metaData.remoteEntry.type = configuredRemoteEntryType;
}
if (!metaData.types) {
metaData.types = getTypesMetaInfo(this._options, compiler.context);
}
Expand Down
110 changes: 110 additions & 0 deletions packages/runtime-core/__tests__/hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,116 @@ describe('hooks', () => {
expect(module(1, 2, 3, 4, 5)).toBe(15);
});

it('registers typed application pause and resume hooks', async () => {
const calls: string[] = [];
const instance = new ModuleFederation({
name: '@federation/application-lifecycle-hooks',
plugins: [
{
name: 'application-lifecycle-test-plugin',
async prePause(context) {
calls.push(`prePause:${context.transitionId}`);
return { delayMs: 25 };
},
pause(event) {
calls.push(`pause:${event.committedAt}`);
},
async preResume(context) {
calls.push(`preResume:${context.transitionId}`);
},
resume(event) {
calls.push(`resume:${event.committedAt}`);
},
lifecycleError(event) {
calls.push(`error:${event.phase}`);
},
},
],
});
const transition = {
transitionId: 'transition-1',
lifecycleEpoch: 7,
scope: 'realm' as const,
reason: 'host-suspend',
force: false,
origin: instance,
};

const decision =
await instance.applicationHook.lifecycle.prePause.emit(transition);
expect(decision).toEqual({ delayMs: 25 });

await instance.applicationHook.lifecycle.pause.emit({
...transition,
committedAt: 100,
});
await instance.applicationHook.lifecycle.preResume.emit(transition);
await instance.applicationHook.lifecycle.resume.emit({
...transition,
committedAt: 200,
});
await instance.applicationHook.lifecycle.lifecycleError.emit({
phase: 'resume',
transition,
error: new Error('resume failed'),
});

expect(calls).toEqual([
'prePause:transition-1',
'pause:100',
'preResume:transition-1',
'resume:200',
'error:resume',
]);
});

it('runs application lifecycle hooks serially without coupling bridge renders to transitions', async () => {
const calls: string[] = [];
const instance = new ModuleFederation({
name: '@federation/application-lifecycle-ordering',
plugins: [
{
name: 'first-application-lifecycle-plugin',
async prePause() {
calls.push('first:start');
await Promise.resolve();
calls.push('first:end');
},
beforeBridgeRender() {
calls.push('bridge');
},
},
{
name: 'second-application-lifecycle-plugin',
prePause() {
calls.push('second');
},
},
],
});
const transition = {
transitionId: 'transition-2',
lifecycleEpoch: 8,
scope: 'mount' as const,
reason: 'visibility-change',
force: false,
origin: instance,
};

await instance.bridgeHook.lifecycle.beforeBridgeRender.emit({});
await instance.bridgeHook.lifecycle.beforeBridgeRender.emit({});
expect(calls).toEqual(['bridge', 'bridge']);

await instance.applicationHook.lifecycle.prePause.emit(transition);
expect(calls).toEqual([
'bridge',
'bridge',
'first:start',
'first:end',
'second',
]);
});

it('loader hooks', async () => {
const testRemoteEntry =
'http://localhost:1111/resources/hooks/app2/federation-remote-entry.js';
Expand Down
34 changes: 34 additions & 0 deletions packages/runtime-core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
RemoteEntryInitOptions,
CallFrom,
ResourceLoadContext,
LifecycleDecision,
LifecycleTransitionError,
PauseCommittedEvent,
PauseTransitionContext,
ResumeCommittedEvent,
ResumeTransitionContext,
} from './type';
import { getBuilderId, registerPlugins, getRemoteEntry, error } from './utils';
import {
Expand Down Expand Up @@ -281,6 +287,34 @@ export class ModuleFederation {
void | Record<string, any>
>(),
});
applicationHook = new PluginSystem({
prePause: new AsyncHook<

@Nsttt Nsttt Jul 13, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we define aggregation here instead of inheriting the AsyncHook last-result-wins behavior? With two plugins returning { delayMs: 5000 } and { delayMs: 10 }, the host only receives 10, so it cannot consider the first request. Returning all decisions, or explicitly taking the maximum, plus a multi-plugin test would make this contract predictable.

[PauseTransitionContext],
| LifecycleDecision
| void
| false
| Promise<LifecycleDecision | void | false>
>('prePause'),
pause: new AsyncHook<

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think the generic AsyncHook semantics fit these committed/error notifications. A listener can return false, or reject, which prevents every later plugin from seeing a pause or resume that already happened. Could these hooks use serial fan-out that always attempts every listener, with an explicit error policy?

[PauseCommittedEvent],
void | false | Promise<void | false>
>('pause'),
preResume: new AsyncHook<
[ResumeTransitionContext],
| LifecycleDecision
| void
| false
| Promise<LifecycleDecision | void | false>
>('preResume'),
resume: new AsyncHook<
[ResumeCommittedEvent],
void | false | Promise<void | false>
>('resume'),
lifecycleError: new AsyncHook<
[LifecycleTransitionError],
void | false | Promise<void | false>
>('lifecycleError'),
});
moduleInfo?: GlobalModuleInfo[string];

constructor(userOptions: UserOptions) {
Expand Down
14 changes: 13 additions & 1 deletion packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,19 @@ export {
getGlobalSnapshot,
getInfoWithoutType,
} from './global';
export type { UserOptions, ModuleFederationRuntimePlugin } from './type';
export type {
ApplicationLifecyclePhase,
ApplicationLifecycleScope,
ApplicationLifecycleTransition,
LifecycleDecision,
LifecycleTransitionError,
ModuleFederationRuntimePlugin,
PauseCommittedEvent,
PauseTransitionContext,
ResumeCommittedEvent,
ResumeTransitionContext,
UserOptions,
} from './type';
export { assert, error } from './utils/logger';
export { registerGlobalPlugins } from './global';
export {
Expand Down
72 changes: 71 additions & 1 deletion packages/runtime-core/src/type/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,82 @@ type RemoteLifeCycleCyclePartial = Partial<{
[k in keyof RemoteLifeCycle]: Parameters<RemoteLifeCycle[k]['on']>[0];
}>;

export type ApplicationLifecycleScope =
| 'installation'
| 'realm'
| 'contribution'
| 'mount';

/**
* A host-owned application transition supplied to trusted runtime plugins.
*
* The runtime exposes these hooks as coordination primitives; the host owns
* transaction serialization, deadline enforcement, cancellation policy, and
* any durable lifecycle state.
*/
export interface ApplicationLifecycleTransition {
transitionId: string;
lifecycleEpoch: number;
scope: ApplicationLifecycleScope;
reason: string;
force: boolean;
deadline?: number;
signal?: AbortSignal;
context?: Readonly<Record<string, unknown>>;
origin: ModuleFederation;
}

export interface PauseTransitionContext extends ApplicationLifecycleTransition {
checkpointReference?: string;
}

export interface ResumeTransitionContext extends ApplicationLifecycleTransition {
checkpointReference?: string;
}

/**
* A non-binding pre-transition hint for the host. Hosts decide whether a
* delay is permitted, including how forced transitions are handled.
*/
export interface LifecycleDecision {
delayMs?: number;
}

export interface PauseCommittedEvent extends ApplicationLifecycleTransition {
committedAt: number;
checkpointReference?: string;
}

export interface ResumeCommittedEvent extends ApplicationLifecycleTransition {
committedAt: number;
}
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve checkpoint on committed resumes

When a host resumes from a checkpoint, preResume exposes checkpointReference, but the committed resume event drops it here. Plugins that only react after a successful resume cannot tell which checkpoint was activated without keeping separate state or casting around the public type, so the new lifecycle API loses the checkpoint metadata on the completion event.

Useful? React with 👍 / 👎.


export type ApplicationLifecyclePhase =
| 'prePause'
| 'pause'
| 'preResume'
| 'resume';

export interface LifecycleTransitionError {
phase: ApplicationLifecyclePhase;
transition: ApplicationLifecycleTransition;
error: unknown;
}

type ApplicationLifeCycle = ModuleFederation['applicationHook']['lifecycle'];
type ApplicationLifeCyclePartial = Partial<{
[k in keyof ApplicationLifeCycle]: Parameters<
ApplicationLifeCycle[k]['on']
>[0];
}>;

export type ModuleFederationRuntimePlugin = CoreLifeCyclePartial &
SnapshotLifeCycleCyclePartial &
SharedLifeCycleCyclePartial &
RemoteLifeCycleCyclePartial &
ModuleLifeCycleCyclePartial &
ModuleBridgeLifeCycleCyclePartial & {
ModuleBridgeLifeCycleCyclePartial &
ApplicationLifeCyclePartial & {
name: string;
version?: string;
apply?: (instance: ModuleFederation) => void;
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/utils/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function registerPlugins(
instance.snapshotHandler.hooks,
instance.loaderHook,
instance.bridgeHook,
instance.applicationHook,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One subtle side effect of adding another PluginSystem: every runtime plugin now goes through applyPlugin once more, and each system has its own registration map. That changes plugin.apply(instance) from six calls to seven even for plugins with no application hooks. Could apply be owned by host-level registration and run once, separately from wiring callbacks into each hook group?

];
// Incorporate global plugins
if (globalPlugins.length > 0) {
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ export {
getRemoteInfo,
registerGlobalPlugins,
type ModuleFederationRuntimePlugin,
type ApplicationLifecyclePhase,
type ApplicationLifecycleScope,
type ApplicationLifecycleTransition,
type LifecycleDecision,
type LifecycleTransitionError,
type PauseCommittedEvent,
type PauseTransitionContext,
type ResumeCommittedEvent,
type ResumeTransitionContext,
type Federation,
} from '@module-federation/runtime-core';

Expand Down
Loading