Skip to content

feat: SSR remote entry support#692

Draft
rr-jimmy-multani wants to merge 25 commits into
module-federation:mainfrom
rr-jimmy-multani:feat/ssr-remote-entry
Draft

feat: SSR remote entry support#692
rr-jimmy-multani wants to merge 25 commits into
module-federation:mainfrom
rr-jimmy-multani:feat/ssr-remote-entry

Conversation

@rr-jimmy-multani
Copy link
Copy Markdown

@rr-jimmy-multani rr-jimmy-multani commented May 4, 2026

Hey! Picking back up from #586. That PR fixed SSR in serve mode; this one adds full SSR support so remotes can be server-rendered from a built Nitro/Node server, not just during dev.

The problem

When using @module-federation/vite with a production SSR host (Vite 8 + Nitro), the server has no way to load a remote's exposed components. The browser remoteEntry.js imports shared packages via the MF share scope, which doesn't work in a Node context. There's no server-compatible entry to call init()/get() against.

What I changed

New: pluginSSRRemoteEntry emits a remoteEntry.server.js (ESM, Rolldown/Vite 8+) or remoteEntry.server.cjs (CJS, Vite 5-7) alongside the browser entry. MF internal packages are marked external only within the SSR module graph via a scoped enforce: 'pre' resolveId hook, so shared packages like react stay un-externalised globally and the loadShare alias rewrite stays intact for the browser entry.

New: ssrEntryLoader is a runtime plugin exported as @module-federation/vite/ssrEntryLoader (separate subpath to keep it out of browser bundles). It intercepts the loadEntry hook on the server, fetches the SSR remote entry over HTTP, rewrites relative imports and shared bare specifiers to file:// paths, and writes temp .mjs files for Node to load. Auto-injected into runtimePlugins for any app with remotes or exposes.

pluginAddEntry now skips emitting in the ssr environment. Some SSR frameworks scan emitted chunks from that environment to detect their SSR entry point. Without this guard, hostInit gets picked up instead of the framework's real handler.

virtualRemoteEntry excludes SSR-only plugins from static browser imports and loads them via import() inside init() on the server only.

Vite version support

ssrEntryLoader auto-injection and dev-mode SSR only activate on Vite 8+. They rely on ModuleRunner and FetchableDevEnvironment, which are Vite 8 APIs. On Vite 5-7, the injection is skipped entirely. pluginSSRRemoteEntry still emits remoteEntry.server.cjs at build time on those versions as an extension point, so a contributor can wire up their own loadEntry intercept without needing to change anything in this PR.

How I tested it

Validated against the tanstack-ssr example in gioboa/module-federation-vite-examples (see gioboa/module-federation-vite-examples#3). Remote Widget and Counter SSR-render in the initial HTML, hydrate in the browser, and the counter works. Zero console errors. Tested on Node 22, 24, and 25.

All 366 tests pass (47 new across pluginSSRRemoteEntry and ssrEntryLoader). Also ran the full module-federation-vite-examples suite against this branch. All examples pass. The vue example has a timing-sensitive test that's flaky in local environments but passes in CI identically with and without this PR.

One thing worth flagging: if the host's SSR bundler inlines React (Nitro does this by default), you get a React singleton mismatch. Adding nitro: { traceDeps: ['react', 'react-dom'] } to vite.config.ts fixes it by telling Nitro to externalise React instead. That's a consumer-side config, not something the plugin automates, but happy to add it to the docs if that'd be useful.

Emits a Node-compatible SSR remote entry alongside the browser entry so
that host apps can server-side render federated components without
client-only fallbacks.

New files:
- src/plugins/pluginSSRRemoteEntry.ts — emits remoteEntry.server.js
  (ESM, Rolldown/Vite 8+) or remoteEntry.server.cjs (CJS, Vite 5-7).
  Shared packages are marked external only within the SSR module graph
  via a scoped pre-enforce resolveId hook, keeping the browser remote
  entry bare-specifier-free.
- src/utils/ssrEntryLoader.ts — MF runtime plugin that intercepts the
  loadEntry lifecycle hook on the server to fetch and evaluate the
  dedicated SSR remote entry via HTTP. Auto-injected into runtimePlugins
  for apps with exposes.
- src/virtualModules/virtualRemoteEntrySSR.ts — generates the SSR
  container API (init/get) that mirrors the browser entry shape.
- src/virtualModules/virtualExposesSSR.ts — generates the SSR exposes
  map without CSS injection or browser globals.

Modified files:
- src/virtualModules/virtualRemoteEntry.ts — filters SSR-only runtime
  plugins (ssrEntryLoader) from static browser imports to prevent
  Node module externalisation errors in the browser bundle.
- src/utils/normalizeModuleFederationOptions.ts — adds ssrExternals
  option for user-provided Node-only package exclusions in the SSR build.
- src/index.ts — wires pluginSSRRemoteEntry into the plugin array and
  auto-injects ssrEntryLoader into runtimePlugins for remotes.
- tsdown.config.ts / package.json — exposes ssrEntryLoader as a
  dedicated @module-federation/vite/ssrEntryLoader subpath export.
…hosts with remotes

- pluginAddEntry: guard buildStart emitFile to client environment only,
  preventing hostInit from being picked up by Nitro as the SSR request
  handler (TypeError: mod.fetch is not a function)

- virtualRemoteEntry: load SSR-only plugins (ssrEntryLoader) via dynamic
  import inside init() on the server, keeping static browser imports clean

- index.ts: inject ssrEntryLoader for any app with remotes (not just
  apps with exposes) so hosts can also intercept remote loadEntry calls

- ssrEntryLoader: rewrite shared bare specifiers (react, react-dom) in
  temp files to absolute file:// paths anchored to the MF plugin location,
  ensuring temp modules resolve the same physical React as the host app
…hare rewrite

The global sharedPattern external in pluginSSRRemoteEntry was marking
shared packages (react, react-dom) as Rolldown externals for the entire
remote build. This bypassed pluginProxySharedModule_preBuild's alias
rewrite, leaving bare "react" specifiers in Widget chunks and loadShare
modules — which browsers cannot resolve.

Upstream's module cache pattern (PR module-federation#638) already eliminated the
REQUIRE_TLA issue that necessitated this workaround. The SSR entry's
per-module-graph scoped externals (enforce:pre resolveId) are sufficient
to keep shared packages external for the SSR entry only.
…skips non-client env

When the client-environment guard in buildStart returns early (e.g. for
Vinext RSC environments), emitFileId is never set. generateBundle then
calls this.getFileName(undefined) which throws 'Unable to get file name
for unknown file undefined'. Guard with !emitFileId to match the same
early-return semantics as buildStart.
pluginAddEntry: change the environment guard from excluding all
non-'client' environments to only skipping the 'ssr' environment.
Vinext and other RSC frameworks use environment names other than
'client' (e.g. react-server) that must still emit their entry chunks.
Only Nitro's 'ssr' environment causes the mod.fetch false positive.

Also replace all `any` casts we introduced with proper types:
- (this as { environment?: { name?: string } }) in plugin hooks
- (chunk as { code: string }) for OutputChunk write
- Promise<unknown> for nodeImport return type
- Record<string, unknown> for globalThis window check
pluginSSRRemoteEntry.test.ts (31 tests):
- Plugin metadata: names, enforce, apply
- Pre-plugin configResolved: alias path mapping for SSR externals
- Pre-plugin resolveId: virtual ID passthrough, SSR-only bare specifier
  externalisation, abs-path re-externalisation, SSR graph tracking
- Main plugin resolveId/load: virtual module routing
- buildStart: Rolldown vs Rollup detection, env guard, exposes guard
- generateBundle: ESM-to-CJS transform, use-strict injection, Rolldown no-op

ssrEntryLoader.test.ts (16 tests):
- Factory shape and plugin name
- Browser guard (window present returns undefined)
- Manifest URL derivation
- URL convention fallback (.server.cjs first, .server.js fallback, HTML rejection)
- Manifest SSR entry resolution (path prefix, missing field, fetch failure)
- Code transformation (transitive import fetching, preload-helper no-op,
  __vite__mapDeps removal, shared bare specifier to file:// rewrite)

Uses vi.resetModules() + dynamic import per test to isolate ssrEntryLoader's
module-level manifest and temp-file caches between tests.
Copy link
Copy Markdown
Collaborator

@gioboa gioboa left a comment

Choose a reason for hiding this comment

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

Thanks for your help

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@module-federation/vite@692

commit: e771bbe

@rr-jimmy-multani
Copy link
Copy Markdown
Author

Thanks for your help

Hey @gioboa! I've also updated the POC repo to test a few more scenarios: shared context singleton across the MF boundary, multiple exposes, hydration correctness, error boundary when the remote is unreachable, and client-side navigation. Take a look when you get a chance: https://github.com/rr-jimmy-multani/rr-tanstack-poc

Should any of these scenarios live in the examples/ folder instead, or alongside the existing examples?

@gioboa
Copy link
Copy Markdown
Collaborator

gioboa commented May 5, 2026

Let's add the app in the examples repository 🙏 thanks

…le scanners run

Frameworks like TanStack Start assume exactly one isEntry chunk in the client
bundle and throw when they encounter extras. MF emits additional entry chunks
(hostInit, remoteEntry, virtualExposes) that are not the real app entry.

The mf:normalize-entry-chunks enforce:'pre' plugin clears isEntry on any chunk
whose facadeModuleId matches a known MF virtual module pattern before any
framework generateBundle hook fires.
…gnostic SSR

Previously ssrEntryLoader used createRequire at runtime to resolve bare
specifiers in remote SSR temp files. This broke under pnpm (transitive deps
not hoisted) and in Nitro production builds (import.meta.url rebased to
.output/server/).

The fix resolves all shared packages (react, react-dom, @module-federation/runtime,
plus any user-declared MF shared packages) at build time from the Vite plugin's
own installed location and passes them as pre-resolved absolute paths in the
ssrEntryLoader plugin options. The runtime code simply reads from this map
directly — no createRequire walk-up needed.
@rr-jimmy-multani
Copy link
Copy Markdown
Author

Hey @gioboa! Is there a way to trigger another pkg.pr.new build? I'd like to use it to test changes in the examples repo. Thanks!

Copy link
Copy Markdown
Collaborator

@gioboa gioboa left a comment

Choose a reason for hiding this comment

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

There you go

rr-jimmy-multani and others added 5 commits May 7, 2026 16:09
Integration tests import from src/ without a prior build step, so
@module-federation/vite/ssrEntryLoader doesn't resolve. Guard the injection
with a resolve check and silently skip in source-only environments.
Consumer builds always have lib/ available so the behaviour is unchanged.
In Vite dev mode, federated remote components now load correctly in both SSR
and CSR — previously they showed 'Loading' fallbacks indefinitely.

Four changes work together:

1. pluginSSRRemoteEntry: remove apply:'build' from resolveId/load hooks so the
   Vite dev server can respond to virtual SSR module requests. A configureServer
   middleware serves the SSR remote entry and exposes at /__mf_ssr__/ URLs that
   ssrEntryLoader can discover via the .server.js convention fallback.

2. ssrEntryLoader: add /__mf_ssr__/<filename>.server.js as a third convention
   candidate, found after .server.cjs and .server.js.

3. virtualRemotes: fix the dev proxy so React.lazy() can use it without
   crashing — add Symbol.toPrimitive/toString to prevent 'Cannot convert object
   to primitive value', a has trap that reports 'default' exists while pending,
   and return the proxy function itself for get('default') so React renders null
   instead of throwing 'Element type is invalid'.

4. pluginAddEntry: inject hostInit into the first transformed source file when
   rollupOptions.input is unset and no index.html exists (TanStack Start, other
   frameworks that manage their own client entry via Vinxi).
…acadeModuleId pattern

Two fixes to restore production build compatibility after upstream merge:

1. index.ts: Broaden the guard that prevents duplicate export const
   __moduleExports. The previous guard only matched 'export const
   __moduleExports' but the Nitro SSR intermediate files use a re-export
   form (export { __moduleExports }) which didn't match. Added a second
   check for export { ... __moduleExports ... } patterns.

2. index.ts: Add 'virtual:mf:' (colon variant) to the facadeModuleId check
   in mf:normalize-entry-chunks. VirtualModule.getImportId() generates IDs
   with 'virtual:mf:' (colon) not 'virtual:mf-' (dash), causing the
   isEntry normalisation to miss MF-injected chunks and breaking the
   TanStack Start bundle scanner.

3. pluginAddEntry: Add content-based guard to the dev injection fallback
   so it only fires on the actual client hydration module (contains
   hydrateRoot/createRoot) rather than any source file.
…ToEnvironment

In Vite 8 multi-environment setups (TanStack Start via Vinxi, Nuxt, etc.),
each environment has its own plugin pipeline. Without applyToEnvironment,
the addEntry enforce:'post' plugin only runs in the default environment
and the transform hook never fires for modules processed by the client
or ssr environments. Setting applyToEnvironment: () => true makes the
plugin active in all environments so the MF bootstrap injection fires
wherever the client entry module is actually processed.
@rr-jimmy-multani rr-jimmy-multani marked this pull request as draft May 9, 2026 00:57
rr-jimmy-multani and others added 5 commits May 9, 2026 22:02
Adds dev-mode SSR support for federated remotes using Vite 8's
ModuleRunner and FetchableDevEnvironment APIs.

- pluginSSRRemoteEntry: exposes a /__mf_runner__ HTTP endpoint (Vite 8+
  only, guarded by server.fetchModule presence) that proxies fetchModule
  calls through the remote's ssr environment plugin pipeline. Adds
  resolveId aliases mapping /__mf_ssr__/*.server.js and *.exposes.js
  paths to their virtual module IDs so ModuleRunner can traverse the
  full Vite plugin pipeline without serialisation.

- ssrEntryLoader: when the SSR entry URL contains /__mf_ssr__/, creates
  a ModuleRunner per remote origin backed by an HTTP transport that
  POSTs to /__mf_runner__. Returns null cleanly when vite/module-runner
  is unavailable (Vite < 8) — dev-mode SSR on older Vite versions is
  intentionally unsupported with a clear extension point documented in
  the file-level comment.
… getBuiltins/externals

- virtualRuntimeInitStatus: replace noop SSR init with real @module-federation/runtime
  init including ssrEntryLoader plugin, configured with host remotes so loadRemote
  resolves correct entry URLs. Uses @vite-ignore dynamic imports to stay browser-safe.

- setSsrRemotes/index.ts: expose setSsrRemotes helper and call it from index.ts config
  hook so the SSR runtime gets the normalized remotes before initVirtualModules runs.

- pluginSSRRemoteEntry: /__mf_runner__ endpoint now handles getBuiltins invoke (returns
  env builtins list), falls back to createRequire for bare package specifiers the SSR
  env can't resolve (e.g. @module-federation/runtime from a virtual importer context).

- ssrEntryLoader: ModuleRunner now passes hmr:false (HTTP transport has no WebSocket),
  removed debug console.log traces, dropped timeout wrapper (no longer needed).
… in Vite 8 multi-env

In Vite 8 multi-environment mode, configResolved and the transform hook
both fire once per environment (client, ssr, etc.). Without guards:

- configResolved: the 'ssr' environment's rollupOptions.input overwrote
  entryFiles set by the 'client' environment, breaking TanStack Start's
  virtual client entry detection (entryFiles ended up empty or pointing
  at the SSR entry instead of virtual:tanstack-start-client-entry).

- transform: the hostAutoInit import was injected into the ssr environment's
  copy of the client entry module, setting clientInjected=true and preventing
  the real injection in the client environment pipeline.

Fix: guard both hooks to skip any environment that isn't 'client' (or the
unnamed default when environments aren't named — Vite 5–7 compatibility).
`this.environment` doesn't exist on configResolved/transform hook
contexts in Vite 5–7 — accessing it via a typed cast threw
TypeError on older versions. Read it through a Record cast so
absent properties resolve to undefined instead of throwing.
@rr-jimmy-multani rr-jimmy-multani force-pushed the feat/ssr-remote-entry branch from 14169aa to b108a3f Compare May 11, 2026 16:04
@rr-jimmy-multani
Copy link
Copy Markdown
Author

Sorry @gioboa! Could you please trigger another pkg.pr.new build?

@rr-jimmy-multani
Copy link
Copy Markdown
Author

Hey @gioboa! Updated the PR with dev-mode SSR support. Validated it against my POC repo using TanStack Start + Nitro.

One thing worth flagging: I looked at supporting dev-mode SSR on Vite 5–7 but it relies on ModuleRunner and FetchableDevEnvironment, which are Vite 8+ only. Build-mode SSR works across Vite 5–7 (CJS output) and Vite 8+ (ESM). I left a documented extension point in ssrEntryLoader.ts for anyone wanting to add Vite < 8 dev support later.

Validated all four scenarios on Node 20, 22, and 24:

Scenario Node 20 Node 22 Node 24
Dev, JS disabled
Dev, JS enabled
Build, JS disabled
Build, JS enabled

Copy link
Copy Markdown
Collaborator

@gioboa gioboa left a comment

Choose a reason for hiding this comment

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

Thanks for your hard work
I know how it's difficult to support all the cases 🙈

@rr-jimmy-multani
Copy link
Copy Markdown
Author

Hey @gioboa! Could you approve another workflow run?

…ntexts

The previous fix used 'this as unknown as Record<string,unknown>' which
changed the TypeScript type but not the runtime value — if Vite 5–7 calls
the hook with this=undefined (strict mode), ctx is still undefined and
ctx['environment'] throws. Added an explicit null/object check before
accessing the property.
Auto-injection of ssrEntryLoader was happening unconditionally at
federation() call time, before any Vite version context was available.
On Vite 5–7 Rollup resolves runtimePlugin specifiers at build time,
causing 'failed to resolve @module-federation/vite/ssrEntryLoader'
errors across all integration tests.

Move the injection into a configResolved hook on the early-init plugin
where config.version is available, and skip entirely when viteMajor < 8.
pluginSSRRemoteEntry still emits remoteEntry.server.cjs on Vite 5–7 as
an extension point for future contributors adding older Vite SSR support.
On Vite 5–7 Rollup statically resolves dynamic import specifiers during
resolveDynamicImport. The getSsrNoopResolveCode() function was emitting
a dynamic import of '@module-federation/vite/ssrEntryLoader' into all
generated virtual modules unconditionally, causing Rollup to fail when
it couldn't find the subpath export on older Vite versions.

Add enableSsrInit flag (default false) threaded through initVirtualModules,
writeRuntimeInitStatus, and the bootstrap code generators. Pass true only
when viteMajor >= 8 in configResolved. Documents the extension point for
future contributors adding Vite 5–7 SSR support.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants