Repository navigation
RFC: Select runtime capabilities with package conditions #5036
Description
Activity
- changed the title
[-]RFC: Make runtime implementation selection package-native and condition-driven[/-][+]RFC: Make runtime composition package-native, condition-driven, and implementation-coherent[/+]on Sep 7, 2026 @2heal1 @zackarychapple @Nsttt @gioboa would appreciate your input on this proposal, as this will be a substantial change to the package architecture, though - in theory - non-breaking..
Reacted by Hanric and Néstor- changed the title
[-]RFC: Make runtime composition package-native, condition-driven, and implementation-coherent[/-][+]RFC: Keep runtime families together and select capabilities through packages[/+]on Sep 14, 2026 LGTM, let's go!!
Thanks for the ping. The direction is right for me too: structural exclusion is the real fix for the "runtime bundled per container" half of module-federation/vite#1292. Four concerns from the Vite plugin side, all about keeping "non-breaking in theory" true in practice:
-
Entry points we rely on.
@module-federation/vitealiases@module-federation/runtimeand@module-federation/runtime/helpers(src/index.ts#L1965; the helpers path is derived from the resolvedindexpath, src/index.ts#L267, which is exactly the private-layout dependency section 2 wants to close), picks the runtime entry from the package'sexports(normalizeModuleFederationOptions.ts#L774), and introspects@module-federation/runtime/coreto generate theexternalRuntimeshim (pluginExternalRuntimeCore.ts#L13, #L198). Please keep those three as stable entries, or name their replacements before "Close alternate import paths" lands. -
Legacy defines vs. conditions ordering. We set
FEDERATION_OPTIMIZE_NO_REMOTE / NO_SHARED / NO_SNAPSHOT_PLUGINandENV_TARGETthrough Vite'sdefine(runtimeCapabilityOptimization.ts#L7, src/index.ts#L1042). Section 5 covers old runtime + new plugin, but not the inverse: if runtime-core drops thetypeof FEDERATION_OPTIMIZE_*checks before we emitmodule-federation:*conditions, a user'sdisableShared: truesilently resolves the full handler again, with no error. Please keep the globals honoured for a release cycle after the conditions ship, or warn when a legacy define is seen without its condition. -
Bundler-agnostic condition scoping. Vite, Rolldown and Rollup have root
resolve.conditionsand nomodule.rules[].resolve. The packaging spike should include Vite's resolver, Rolldown, and esbuild prebundling (optimizeDeps) for#mf/*imports and namespaced conditions, andimplementationshould accept a family anchor as well as the runtime entry path it means today. -
External runtime compatibility. We are the integration using
externalRuntime/_FEDERATION_RUNTIME_COREin production (lazy shim, exposing providers, standalone fallback). Section 5 leaves the metadata open; a capability profile on_FEDERATION_RUNTIME_CORE_FROM(we publish name/version there, injectExternalRuntimeCorePlugin.ts#L70) would let a consumer built with a reduced profile check the host core before reusing it.
Happy to test the spike against the Vite plugin as soon as there is a packed artifact.
Reacted by dm.choi-
Generally speaking looks fair. I'll have another look tonight or tomorrow when I land.
Great work thinking this through, we might find things along the way.
@gioboa Yes, the default package export conditions would resolve to the existing full namespace export composition. The default behavior would remain unchanged.
Reacted by Giorgio BoaReacted by Giorgio BoaI like the direction here. Choosing the runtime implementation once, then letting the packages select the code they need, makes sense.
My main concern is whether we’re checking what the bundler actually ends up using, rather than just what we initially resolved.
I know the wiring is still pseudocode but I’d make the following cases explicit before implementing it.
1. Could a rule-level alias still replace part of the selected runtime?
The example checks
compiler.options.resolve.aliaswhen validating the family, then adds a rule containingconditionNames. But that doesn’t check aliases defined inside existing module rules.For example, an application could already have:
{ test: /\.[cm]?js$/, resolve: { alias: { '@module-federation/runtime-core$': anotherRuntimeCoreEntry, }, }, }
The root aliases could look fine, but an import from our selected
runtimecould still resolve to a differentruntime-core. Adding conditions doesn’t, by itself, remove that alias or establish that its replacement is compatible.There’s a second consequence: if the replacement core is outside
belongsToFamily(), its own imports wouldn’t get our selected conditions either.I think we need to check the actual resolved imports between the cooperating packages, not just the configuration we started with.
Let the bundler resolve them, then verify that it selected compatible members of the intended famly.
2. The
WeakMapwon’t be shared between separately installed plugin copiesThe module-level
const selections = new WeakMap()works when multiple participants use the same loaded copy of the selection module. But two installed copies would each have their own map. Each would register its own finalizer and see only its own participants.Imagine an application using two wrappers:
Wrapper A → installed plugin copy A → runtime family A Wrapper B → installed plugin copy B → runtime family B Both apply to the same compiler.Neither copy would necessarily notice the other’s incompatible selection. They could both decide their configuration is valid and append separate rules.
I’d put the shared coordination state on the compiler itself, under a symbol, rather than rely only on module-local state. Something like
Symbol.for(...), with a small versioned shape, could work. Plugin versions that can’t understand the same state should fail and thats good i think ?3. What exactly happens with child compilers?
I’d make this an explicit decision rather than assume
afterPluginscovers it.In Webpack
createChildCompiler()applies the supplied plugins and copies most parent hook registrations, but it doesn’t replay the normal top-levelafterPluginsinitialization sequence. It also shallow-copies the compiler options, so nested configuration needs care.Our example only fills in
state.implementationandstate.profileinsideafterPlugins. A plugin applied to a child compiler therefore can’t assume that state will be finalized through the same path. Inheriting the parent’s rule isn’t automatically equivalent either, because that rule closes over the parent’s selection.Do children inherit the parent’s implementation and profile? Can they select their own? Are some combinations unsupported? Any of those could be reasonable, but we should choose deliberately.
4. Regular
externalsconfiguration needs the same attention asexternalRuntimeI wonder if its worth to point out this discussion to ordinary bundler
externals, not only federation’sexternalRuntimeoption.For example:
externals: { '@module-federation/runtime': 'commonjs @module-federation/runtime', }
The external package’s internal imports aren’t being selected by our family-scoped bundler rule. They’re resolved later by the runtime environment, which might not use the capability conditions selected for the build.
We shouldn’t assume those two paths selected the same code or reached the same state-owning module.
I’d explicitly say whether these combinations are supported, require an external-runtime compatibility check, or should fail maybe.
5. Runtime compatibility also needs to cover cached evaluated entries
This is the runtime-side case I’d be most interested in testing.
The existing entry-loading cache is global, and its key uses the remote’s name and entry URL. When that key already exists, the loader returns the cached promise rather than running that instance’s loading hooks and platform loader again.
So consider:
Host A loads remote X using custom evaluator A. Host B uses a different implementation and loads the same remote X. Host B receives A’s cached result without running evaluator B.These can be different host instances, so checking whether an existing global instance or debug constructor is compatible doesn’t necessarily catch it.
Compatible implementations could still dedupe, incompatible ones should be isolated or rejected I think.
None of this changes the overall direction for me. But these are the things I noticed by thinking about it and having AI have a look at the different implementations we have so far.
Also I might be wrong on some things, @ScriptedAlchemy or @2heal1 you can point out if any of the points above are wrong.
Alternate proposal posted as its own issue for input: #5128. It keeps the runtime-family goal but replaces defines and resolver conditions with subpath exports and a composed bootstrap. The seven PRs in this stack are cross-linked from there.
- changed the title
[-]RFC: Keep runtime families together and select capabilities through packages[/-][+]RFC: Select runtime capabilities with package conditions[/+]on Oct 3, 2026 ScriptedAlchemy commented
on Oct 3, 2026 MemberAuthorMore actions@Nsttt these are valid gaps in the original sketch. I've shortened both RFCs and made the boundaries explicit.
- Resolved imports: checking root aliases is insufficient. The proof needs a real rule-level alias that redirects a cooperating package, then checks the graph the bundler actually built. The composition stack currently warns on some Webpack family mismatches; its Rspack graph summary lacks package-root identity. That is not complete protection.
- Installed copies: both stacks now use a compiler symbol rather than just the illustrated module-local WeakMap. That still needs contract validation and agreement on every participant's family and target. A shared slot, and the separate serializer fix in fix(enhanced): key cache serializers by install directory #5144, do not prove those properties.
- Children: the selector stack intends to inherit finalized parent selection and reject a new child participant. Composition has a full-runtime fallback when its planner never ran. These are different policies; real child-compiler and parent-option-isolation tests need to prove each.
- Externals: ordinary externals are part of the matrix, including callbacks, layers and subpaths. A synthetic root-request probe cannot stand in for the final graph or external runtime identity.
- Evaluated entries: agreed. feat(runtime): reject incompatible runtime image reuse #5096 adds metadata-based cache checks, with legacy missing-metadata limits. RFC: Compose the runtime from capability imports #5128's loader still needs equivalent evaluator-reuse proof. Two distinct hosts using the same remote name/URL are a separate test from instance reuse.
The targeted proof/fix pass is running. I won't mark these concerns resolved from green CI alone.
@gioboa I've also made the root/helpers/core entry contracts, both old/new plugin-runtime directions, Vite/Rolldown/prebundling, and external-core metadata explicit release gates. The prior default-export answer covered only part of your questions. Neither RFC now claims that Vite migration or external-profile compatibility is already verified.
Proposal
Resolve one runtime implementation, then let its packages select capability and platform code through package-private imports and bundler conditions. Disabled capability implementations should stay out of the module graph even when minification and dead-code elimination are off.
This is the package-conditions alternative to #5128, which generates imports for a kernel and its capabilities. Both proposals remain open for a decision. They are alternative stacks, not successive migrations.
The initial public configuration stays unchanged:
Why change it
The existing bootstrap reaches broad runtime entrypoints and relies on
FEDERATION_*defines to remove unused features later. Resolution also mixes package identity, private distribution paths, and compatibility fallbacks. An incomplete custom implementation can fall back to default packages one member at a time.The design separates those responsibilities:
A common directory or matching version strings alone do not prove that two packages belong to a compatible family.
Package selection
Use
runtime-toolsas the stable build-facing entrypoint. It must preserve the shapes consumed by Enhanced and Rspack without importing a broad namespace that reconnects every feature.Packages expose independent private selectors. For example, a sharing selector maps
module-federation:no-sharedto the disabled implementation and otherwise selects the compatible default. Each branch needs valid ESM and CommonJS targets. Remote handling, snapshot-plugin registration, container entry, and platform loading need separate selectors so combinations do not depend on the first matching condition.The default path retains legacy define behavior during the compatibility window. A malformed new family contract is an error, not a reason to silently use unrelated default packages.
A small hybrid remains an option: generated imports can select bundler adapters while package selectors select core handlers and platform code. The comparison with #5128 must establish whether selectors still earn their complexity.
Preserve the small contracts
loadEntryfor shared fallback entries.Keep entry caching, hooks, recovery, and errors in the common coordinator. Select DOM, Node, or supported worker evaluation through the chosen family's platform entry. A bare SDK import from the Node plugin must not bypass a custom evaluator.
Compiler ownership and final resolution
The requirements below incorporate Nsttt's review. They are acceptance criteria, not a claim that every current PR implements them.
One compiler-level owner. Separately installed plugin copies must coordinate through a versioned slot on the compiler. A module-local WeakMap is insufficient. Validate the slot contract, collect all participant identities and capability requirements, then finalize once. Incompatible families, targets, or participant requirements must produce a deterministic diagnostic in either application order.
Check the graph the bundler produced. Root
resolve.aliasvalidation is insufficient. Rule-level aliases and dependency-specific resolution can replace a family member after initial selection. Verify the resolved cooperating package identities and relevant edges, including linked packages and renamed replacements. Do not claim coherence from the initial lookup alone.Define child behavior. The current #5036 implementation intends to inherit finalized parent selection and reject a federation participant applied directly to a child compiler. Test real
createChildCompiler(), hook inheritance, and nested options. Do not assume top-level initialization hooks run again, or mutate the parent's options through a shallow copy.Treat externals separately. Both ordinary bundler
externalsand federation'sexternalRuntimecan move resolution outside the local graph. Local conditions cannot prune an already-built external runtime. Supported combinations need an explicit compatibility path; unsupported combinations need a diagnostic.Check evaluated-entry reuse. Different host instances can still share the global name-plus-entry cache. Before returning another host's evaluated result, validate evaluator/family and target compatibility. Compatible requests should dedupe. Incompatible requests must be isolated or rejected. Include pending promises, retries, and reset behavior.
Condition scoping
For Webpack, add a family-scoped sibling
module.rules[].resolverule, outside existingoneOfbranches. Preserve inherited conditions with.... Match selected package ownership rather than package name alone, and verify the final rule order andbyDependencyoverrides.The rule controls imports made by matched modules. It does not require a pre-loader. Conditional-map key order determines precedence; the order of
conditionNamesdoes not.This mechanism is not automatically portable to every bundler. Rspack's native bootstrap resolves an absolute CommonJS entry, which can bypass package export selection. Vite and Rolldown do not expose Webpack's per-rule resolution model.
Compatibility and packaging gates
Gioboa's Vite review identifies additional release gates:
@module-federation/runtime,runtime/helpers, andruntime/core, or provide and test explicit replacements before removing them.The initial API remains a resolved implementation path. Package-specifier/family-anchor support is a separate API decision.
Before broad migration, prove a clean source build, declaration generation, packing, and downstream selection. Published
distmappings must not break a clean source checkout. Emit every selector target, including non-default branches. Test ESM and CommonJS separately and together, including public imports beside the generated bootstrap.What exists and what remains
Implementation order:
The stack currently retains legacy compiler selection rather than enabling the full condition-driven rollout by default. #5096 adds checks for metadata-bearing images and evaluated entries; missing metadata remains a compatibility limitation. Do not read that as complete protection for older runtimes.
An exact-head proof pass is checking the review cases above. Existing green CI does not establish the complete compatibility matrix, and no architecture decision or merge approval is implied here.
Evidence and tradeoffs
The prototype harness compares package selectors and generated composition. These are recorded prototype results, not fresh measurements of the current stacks.
A substantial part of the Rspack improvement comes from using the ESM bundler runtime instead of its CommonJS entry. It cannot all be attributed to capability composition. The selector stack's legacy path also had measured overhead, so compatibility includes size as well as behavior.
The original probes support family-scoped selection in the tested Webpack rule ordering. They do not prove arbitrary later plugin mutations, packed-runtime integration, or Rspack bootstrap parity.
Acceptance and remaining decisions
The core assertion is structural: with optimizations off, modules used exclusively by a disabled capability are absent, while the built application still executes its configured behavior. Check resolved graph identities, not only emitted text or bundle size.
Cover default/custom/legacy families; enabled/disabled combinations; shared fallbacks with remotes disabled; web/Node/worker loaders; mixed formats; root and rule aliases; multiple copies; child compilers; cache/watch rebuilds; externals; and custom evaluator reuse.
Remaining decisions are the family contract and version marker, facade exports, external metadata policy, condition precedence, and the packaging strategy. Full snapshot removal and dynamic capability changes are outside this proposal. Choose between this design and #5128 only after comparing the same behavioral and packaging gates.