feat: SSR remote entry support#692
Conversation
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.
…ntal typescript dep
commit: |
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 |
|
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.
|
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! |
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.
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.
14169aa to
b108a3f
Compare
|
Sorry @gioboa! Could you please trigger another pkg.pr.new build? |
|
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 Validated all four scenarios on Node 20, 22, and 24:
|
gioboa
left a comment
There was a problem hiding this comment.
Thanks for your hard work
I know how it's difficult to support all the cases 🙈
|
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.
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/vitewith a production SSR host (Vite 8 + Nitro), the server has no way to load a remote's exposed components. The browserremoteEntry.jsimports shared packages via the MF share scope, which doesn't work in a Node context. There's no server-compatible entry to callinit()/get()against.What I changed
New:
pluginSSRRemoteEntryemits aremoteEntry.server.js(ESM, Rolldown/Vite 8+) orremoteEntry.server.cjs(CJS, Vite 5-7) alongside the browser entry. MF internal packages are marked external only within the SSR module graph via a scopedenforce: 'pre'resolveIdhook, so shared packages likereactstay un-externalised globally and theloadSharealias rewrite stays intact for the browser entry.New:
ssrEntryLoaderis a runtime plugin exported as@module-federation/vite/ssrEntryLoader(separate subpath to keep it out of browser bundles). It intercepts theloadEntryhook on the server, fetches the SSR remote entry over HTTP, rewrites relative imports and shared bare specifiers tofile://paths, and writes temp.mjsfiles for Node to load. Auto-injected intoruntimePluginsfor any app withremotesorexposes.pluginAddEntrynow skips emitting in thessrenvironment. Some SSR frameworks scan emitted chunks from that environment to detect their SSR entry point. Without this guard,hostInitgets picked up instead of the framework's real handler.virtualRemoteEntryexcludes SSR-only plugins from static browser imports and loads them viaimport()insideinit()on the server only.Vite version support
ssrEntryLoaderauto-injection and dev-mode SSR only activate on Vite 8+. They rely onModuleRunnerandFetchableDevEnvironment, which are Vite 8 APIs. On Vite 5-7, the injection is skipped entirely.pluginSSRRemoteEntrystill emitsremoteEntry.server.cjsat build time on those versions as an extension point, so a contributor can wire up their ownloadEntryintercept without needing to change anything in this PR.How I tested it
Validated against the
tanstack-ssrexample in gioboa/module-federation-vite-examples (see gioboa/module-federation-vite-examples#3). RemoteWidgetandCounterSSR-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
pluginSSRRemoteEntryandssrEntryLoader). Also ran the fullmodule-federation-vite-examplessuite against this branch. All examples pass. Thevueexample 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'] }tovite.config.tsfixes 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.