Repository navigation
feat(runtime): add pause/resume hooks and preserve ESM entries #4891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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. |
| 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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,12 @@ import { | |
| RemoteEntryInitOptions, | ||
| CallFrom, | ||
| ResourceLoadContext, | ||
| LifecycleDecision, | ||
| LifecycleTransitionError, | ||
| PauseCommittedEvent, | ||
| PauseTransitionContext, | ||
| ResumeCommittedEvent, | ||
| ResumeTransitionContext, | ||
| } from './type'; | ||
| import { getBuilderId, registerPlugins, getRemoteEntry, error } from './utils'; | ||
| import { | ||
|
|
@@ -281,6 +287,34 @@ export class ModuleFederation { | |
| void | Record<string, any> | ||
| >(), | ||
| }); | ||
| applicationHook = new PluginSystem({ | ||
| prePause: new AsyncHook< | ||
| [PauseTransitionContext], | ||
| | LifecycleDecision | ||
| | void | ||
| | false | ||
| | Promise<LifecycleDecision | void | false> | ||
| >('prePause'), | ||
| pause: new AsyncHook< | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not think the generic |
||
| [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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a host resumes from a checkpoint, 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ export function registerPlugins( | |
| instance.snapshotHandler.hooks, | ||
| instance.loaderHook, | ||
| instance.bridgeHook, | ||
| instance.applicationHook, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One subtle side effect of adding another |
||
| ]; | ||
| // Incorporate global plugins | ||
| if (globalPlugins.length > 0) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
AsyncHooklast-result-wins behavior? With two plugins returning{ delayMs: 5000 }and{ delayMs: 10 }, the host only receives10, 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.