Skip to content

RFC: Select runtime capabilities with package conditions #5036

Description

@ScriptedAlchemy

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:

implementation: require.resolve('@module-federation/runtime-tools')

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:

  1. The build integration selects a coherent runtime family: the cooperating runtime-tools, runtime, runtime-core, bundler-runtime, and SDK packages.
  2. The selected implementation owns its entrypoints and capability files.
  3. The bundler selects those files before optimization.

A common directory or matching version strings alone do not prove that two packages belong to a compatible family.

Package selection

Use runtime-tools as 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-shared to 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

  • Disabling remote consumption must preserve entry transport and loadEntry for shared fallback entries.
  • Disabling sharing must preserve the share-scope storage and initialization needed by containers.
  • Disabling default snapshot plugins does not mean removing the whole snapshot subsystem. Bridge prefetch and other callers need their own audit.
  • Enabled and disabled handlers need honest shared interfaces. Do not cast a disabled object to a full concrete handler.
  • Public imports and generated code must share the intended instance, registry, hooks, and share scopes. Closing a broad internal import must not remove a supported public entry.

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.alias validation 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 externals and federation's externalRuntime can 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[].resolve rule, outside existing oneOf branches. Preserve inherited conditions with .... Match selected package ownership rather than package name alone, and verify the final rule order and byDependency overrides.

The rule controls imports made by matched modules. It does not require a pre-loader. Conditional-map key order determines precedence; the order of conditionNames does 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:

  • Preserve @module-federation/runtime, runtime/helpers, and runtime/core, or provide and test explicit replacements before removing them.
  • Test both directions: a new plugin with an older runtime, and an older plugin with a new runtime. Keep legacy define behavior until the corresponding integrators can migrate.
  • Test packed artifacts through Vite resolution, Rolldown, and dependency prebundling before claiming Vite support.
  • Define external-core metadata and missing-metadata behavior with the consuming integrations. Name/version alone does not establish capability or evaluator compatibility.

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 dist mappings 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:

PR Scope
#5093 Atomic family resolution
#5094 Compiler coordination
#5095 Package capability selectors
#5096 Runtime-image and evaluated-entry reuse checks
#5097 Independent esbuild output-path correction
#5098 Rollout documentation
#5107 Tree-shaking share-plugin selection and graph tests

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.

ALL-OFF profile Webpack production Webpack optimizations off Rspack production Rspack optimizations off
Conditions applied manually 30,799 B 202,933 B 64,653 B 165,355 B
Composed prototype 16,173 B 141,762 B 19,542 B 130,108 B

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.

Activity

  1. 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
  2. ScriptedAlchemy commented on Sep 14, 2026

    @ScriptedAlchemy
    MemberAuthor

    @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..

  3. 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
  4. 2heal1 commented on Sep 15, 2026

    @2heal1
    Member

    LGTM, let's go!!

  5. gioboa commented on Sep 15, 2026

    @gioboa
    Contributor

    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:

    1. Entry points we rely on. @module-federation/vite aliases @module-federation/runtime and @module-federation/runtime/helpers (src/index.ts#L1965; the helpers path is derived from the resolved index path, src/index.ts#L267, which is exactly the private-layout dependency section 2 wants to close), picks the runtime entry from the package's exports (normalizeModuleFederationOptions.ts#L774), and introspects @module-federation/runtime/core to generate the externalRuntime shim (pluginExternalRuntimeCore.ts#L13, #L198). Please keep those three as stable entries, or name their replacements before "Close alternate import paths" lands.

    2. Legacy defines vs. conditions ordering. We set FEDERATION_OPTIMIZE_NO_REMOTE / NO_SHARED / NO_SNAPSHOT_PLUGIN and ENV_TARGET through Vite's define (runtimeCapabilityOptimization.ts#L7, src/index.ts#L1042). Section 5 covers old runtime + new plugin, but not the inverse: if runtime-core drops the typeof FEDERATION_OPTIMIZE_* checks before we emit module-federation:* conditions, a user's disableShared: true silently 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.

    3. Bundler-agnostic condition scoping. Vite, Rolldown and Rollup have root resolve.conditions and no module.rules[].resolve. The packaging spike should include Vite's resolver, Rolldown, and esbuild prebundling (optimizeDeps) for #mf/* imports and namespaced conditions, and implementation should accept a family anchor as well as the runtime entry path it means today.

    4. External runtime compatibility. We are the integration using externalRuntime / _FEDERATION_RUNTIME_CORE in 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.

  6. Nsttt commented on Sep 15, 2026

    @Nsttt
    Member

    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.

  7. ScriptedAlchemy commented on Sep 16, 2026

    @ScriptedAlchemy
    MemberAuthor

    @gioboa Yes, the default package export conditions would resolve to the existing full namespace export composition. The default behavior would remain unchanged.

  8. Nsttt commented on Sep 16, 2026

    @Nsttt
    Member

    I 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.alias when validating the family, then adds a rule containing conditionNames. 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 runtime could still resolve to a different runtime-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 WeakMap won’t be shared between separately installed plugin copies

    The 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 afterPlugins covers it.

    In Webpack createChildCompiler() applies the supplied plugins and copies most parent hook registrations, but it doesn’t replay the normal top-level afterPlugins initialization sequence. It also shallow-copies the compiler options, so nested configuration needs care.

    Our example only fills in state.implementation and state.profile inside afterPlugins. 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 externals configuration needs the same attention as externalRuntime

    I wonder if its worth to point out this discussion to ordinary bundler externals, not only federation’s externalRuntime option.

    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.

  9. ScriptedAlchemy commented on Sep 25, 2026

    @ScriptedAlchemy
    MemberAuthor

    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.

  10. changed the title [-]RFC: Keep runtime families together and select capabilities through packages[/-] [+]RFC: Select runtime capabilities with package conditions[/+] on Oct 3, 2026
  11. ScriptedAlchemy commented on Oct 3, 2026

    @ScriptedAlchemy
    MemberAuthor

    @Nsttt these are valid gaps in the original sketch. I've shortened both RFCs and made the boundaries explicit.

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.

  12. ScriptedAlchemy commented on Oct 9, 2026

    @ScriptedAlchemy
    MemberAuthor

    Closing this package-conditions proposal in favor of #5128 (runtime composition through capability imports). Closing its implementation stack: #5093, #5094, #5095, #5096, #5097, #5098, and #5107. RFC #5128 and its implementation stack remain open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions