Skip to content

Commit 20e7379

Browse files
authored
fix: stabilize TanStack SSR remote revalidation (#5)
1 parent 05c0941 commit 20e7379

7 files changed

Lines changed: 480 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@module-federation/tanstack": patch
3+
---
4+
5+
add server-entry revalidation for TanStack Start SSR federation hosts

‎TODO.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,15 @@ Fixed upstream in `@module-federation/vite` 1.23.3:
145145
the host, and prove no stale container or shared module is served.
146146
`@module-federation/vite` has `revalidate()` and `maxAgeMs` for its own
147147
loader; the Module Federation runtime caches containers for the life of
148-
the process.
148+
the process. Server-entry revalidation is now available for Vite hosts
149+
through `ssrEntryLoader.maxAgeMs` and for Rsbuild hosts through
150+
`server.ssrEntryLoader.maxAgeMs`. For Vite's native loader, a manifest
151+
provides the version needed for automatic change detection; direct or
152+
convention-resolved server-entry URLs need an explicit `revalidate()` for
153+
forced refresh. Shared-singleton changes still require a host restart, so
154+
the shared-module portion remains open. Remote module-scope side effects
155+
are not automatically disposed during revalidation and remain the
156+
application's responsibility.
149157
- [ ] Test cyclic imports across chunks in a server remote graph.
150158
- [ ] In development, a Vite remote's server modules import React from the files
151159
its dev server resolves, so host and remote must resolve React to the

‎packages/tanstack/README.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ A Vite host renders before lazy shared modules resolve, so mark any other shared
6363
package the host imports from its entry or root route as `eager: true`, as the
6464
wrapper does for React. Remotes can keep their shares lazy.
6565

66+
For a Vite host, configure SSR entry revalidation directly with the Vite
67+
option:
68+
69+
```ts
70+
tanstackStartModuleFederation({
71+
name: "host",
72+
ssrEntryLoader: { maxAgeMs: 30_000 },
73+
// ...remotes and shared options
74+
});
75+
```
76+
77+
Vite's `maxAgeMs` revalidation is version-aware when the remote is resolved
78+
from an `mf-manifest.json`. Convention-resolved or direct server-entry URLs do
79+
not provide a remote version; use a manifest URL or call the Vite loader's
80+
explicit `revalidate()` when those URLs must be refreshed.
81+
6682
The wrapper omits the global Module Federation `target` option. TanStack
6783
Start's client and SSR environments need `@module-federation/vite` to select
6884
`web` and `node` independently.
@@ -151,6 +167,43 @@ When a remote's server entry cannot load, the host's runtime plugin throws a
151167
`RemoteEntryError` whose `phase` is `fetch`, `evaluate`, or `loader` and whose
152168
`cause` is the original error.
153169

170+
Server entries are cached for the lifetime of the Node process by default. To
171+
pick up a rebuilt remote while keeping the process running, configure the
172+
revalidation window on an Rsbuild host:
173+
174+
```ts
175+
tanstackStartModuleFederation({
176+
federation: {
177+
name: "host",
178+
remotes: {
179+
remote: "remote@https://example.test/mf-manifest.json",
180+
},
181+
},
182+
server: {
183+
ssrEntryLoader: {
184+
maxAgeMs: 30_000,
185+
},
186+
},
187+
});
188+
```
189+
190+
For an Rsbuild host, when `maxAgeMs` expires, the adapter invalidates the
191+
Module Federation remote container cache. Vite remotes also invalidate the Vite
192+
SSR entry-loader cache; the option is forwarded to that loader. `strategy` and
193+
`fetchMaxBytes` apply to Vite SSR entries, while `fetchTimeoutMs` applies to
194+
both Vite and CommonJS server-entry requests. Set either limit to `0` to
195+
disable it. Concurrent requests for an expired remote share one revalidation
196+
request on this adapter path.
197+
198+
Revalidation makes subsequent loads use the new remote entry and exposed
199+
modules, but does not safely replace already loaded process-level shared
200+
singletons such as React. Restart the host process after changing a shared
201+
singleton's version or implementation. For Rsbuild/CommonJS remotes,
202+
expiration also re-evaluates the downloaded server entry with a forced
203+
container re-registration. Module-scope side effects in a remote (for example
204+
timers or process listeners) are not automatically disposed, so use
205+
revalidation only for remotes whose evaluation is safe to repeat.
206+
154207
## Rendering remotes on the server
155208

156209
Hosts on either bundler render remotes the same way: load the remote in the

‎packages/tanstack/src/node-entry-loader.ts‎

Lines changed: 169 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
1-
import type { ModuleFederationRuntimePlugin } from "@module-federation/runtime";
1+
import type { ModuleFederation, ModuleFederationRuntimePlugin } from "@module-federation/runtime";
22

33
type LoadEntryArgs = Parameters<NonNullable<ModuleFederationRuntimePlugin["loadEntry"]>>[0];
44
type RemoteEntryExports = NonNullable<LoadEntryArgs["remoteEntryExports"]>;
55
type RemoteInfo = LoadEntryArgs["remoteInfo"];
66
type FederationHost = LoadEntryArgs["origin"];
77
type NodeModule = typeof import("node:module");
88

9+
export interface NodeEntryLoaderOptions {
10+
/** Re-check manifest/server entries after this many milliseconds. Omit to cache until exit. */
11+
maxAgeMs?: number;
12+
/** Revalidation strategy used by the Vite SSR entry loader. */
13+
strategy?: "temp-file" | "vm";
14+
/** Maximum time for a server-entry request. Defaults to 10 seconds; 0 disables the timeout. */
15+
fetchTimeoutMs?: number;
16+
/** Maximum server-entry response size for Vite remotes; 0 disables the limit. */
17+
fetchMaxBytes?: number;
18+
}
19+
920
/** A remote's server entry failed to load. `phase` says which step failed; `cause` keeps the original error. */
1021
export class RemoteEntryError extends Error {
1122
readonly phase: "fetch" | "evaluate" | "loader";
@@ -34,14 +45,25 @@ export class RemoteEntryError extends Error {
3445
* handles both production builds and Vite dev servers. It is loaded with Node's
3546
* `require`, not the bundle's, so its `import()` calls work in Rsbuild's runner too.
3647
*/
37-
export default function nodeEntryLoaderPlugin(): ModuleFederationRuntimePlugin {
48+
export default function nodeEntryLoaderPlugin(
49+
options: NodeEntryLoaderOptions = {},
50+
): ModuleFederationRuntimePlugin {
51+
const revalidationHooks = isRevalidationEnabled(options.maxAgeMs)
52+
? createRevalidationHooks(options.maxAgeMs)
53+
: {};
54+
3855
return {
3956
name: "tanstack-start-node-entry-loader",
57+
...revalidationHooks,
4058
async loadEntry(args) {
4159
const { remoteInfo } = args;
4260
if (!isNodeServer() || !/^https?:\/\//.test(remoteInfo.entry)) return undefined;
43-
if (remoteInfo.type === "commonjs-module") return loadCommonJsEntry(remoteInfo);
44-
if (remoteInfo.type === "module" || remoteInfo.type === "esm") return loadModuleEntry(args);
61+
if (remoteInfo.type === "commonjs-module") {
62+
return loadCommonJsEntry(remoteInfo, options.fetchTimeoutMs);
63+
}
64+
if (remoteInfo.type === "module" || remoteInfo.type === "esm") {
65+
return loadModuleEntry(args, options);
66+
}
4567
return undefined;
4668
},
4769
};
@@ -50,6 +72,11 @@ export default function nodeEntryLoaderPlugin(): ModuleFederationRuntimePlugin {
5072
// Matches @module-federation/vite's SSR fetch timeout.
5173
const FETCH_TIMEOUT_MS = 10_000;
5274

75+
function requestOptions(fetchTimeoutMs: number | undefined) {
76+
const timeout = fetchTimeoutMs ?? FETCH_TIMEOUT_MS;
77+
return timeout === 0 ? {} : { signal: AbortSignal.timeout(timeout) };
78+
}
79+
5380
function isNodeServer() {
5481
return typeof process !== "undefined" && typeof process.getBuiltinModule === "function";
5582
}
@@ -68,10 +95,13 @@ function appRequires() {
6895
.map((base) => createRequire(base));
6996
}
7097

71-
async function loadCommonJsEntry({ entry, entryGlobalName, name }: RemoteInfo) {
98+
async function loadCommonJsEntry(
99+
{ entry, entryGlobalName, name }: RemoteInfo,
100+
fetchTimeoutMs = FETCH_TIMEOUT_MS,
101+
) {
72102
let source: string;
73103
try {
74-
const response = await fetch(entry, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
104+
const response = await fetch(entry, requestOptions(fetchTimeoutMs));
75105
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
76106
source = await response.text();
77107
} catch (cause) {
@@ -141,22 +171,34 @@ const VITE_SHARED_PACKAGES = [
141171
];
142172

143173
type ViteSsrEntryLoaderModule = {
144-
default: (options: { resolvedShared: Record<string, string> }) => ModuleFederationRuntimePlugin;
174+
default: (options: {
175+
resolvedShared: Record<string, string>;
176+
maxAgeMs?: number;
177+
strategy?: "temp-file" | "vm";
178+
fetchTimeoutMs?: number;
179+
fetchMaxBytes?: number;
180+
}) => ModuleFederationRuntimePlugin;
181+
revalidate?: (remoteEntryUrl?: string) => void;
145182
SsrEntryHttpError?: new (...args: never[]) => Error;
146183
};
147184

148185
type ViteLoader = {
149186
httpError?: ViteSsrEntryLoaderModule["SsrEntryHttpError"];
150187
plugin: ModuleFederationRuntimePlugin;
188+
revalidate?: ViteSsrEntryLoaderModule["revalidate"];
151189
require: NodeJS.Require;
152190
resolvedShared: Record<string, string>;
153191
};
154192

155-
let viteLoader: ViteLoader | undefined;
193+
const viteLoaders = new WeakMap<ModuleFederation, ViteLoader>();
156194

157-
async function loadModuleEntry(args: LoadEntryArgs) {
195+
async function loadModuleEntry(args: LoadEntryArgs, options: NodeEntryLoaderOptions) {
158196
const { entry, name } = args.remoteInfo;
159-
viteLoader ??= createViteLoader(args);
197+
let viteLoader = viteLoaders.get(args.origin);
198+
if (!viteLoader) {
199+
viteLoader = createViteLoader(args, options);
200+
viteLoaders.set(args.origin, viteLoader);
201+
}
160202
// Shared modules the host loaded since the last remote are aligned too.
161203
provideHostModules(args.origin, viteLoader.resolvedShared, viteLoader.require);
162204

@@ -181,7 +223,7 @@ async function loadModuleEntry(args: LoadEntryArgs) {
181223
if (!remoteEntry) {
182224
const reachable = await fetch(entry, {
183225
method: "HEAD",
184-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
226+
...requestOptions(options.fetchTimeoutMs),
185227
}).then(
186228
(response) => response.ok,
187229
() => false,
@@ -197,7 +239,10 @@ async function loadModuleEntry(args: LoadEntryArgs) {
197239
return remoteEntry;
198240
}
199241

200-
function createViteLoader({ origin, remoteInfo }: LoadEntryArgs): ViteLoader {
242+
function createViteLoader(
243+
{ origin, remoteInfo }: LoadEntryArgs,
244+
options: NodeEntryLoaderOptions,
245+
): ViteLoader {
201246
const requires = appRequires();
202247
const resolve = (specifier: string) => {
203248
for (const require of requires) {
@@ -229,12 +274,123 @@ function createViteLoader({ origin, remoteInfo }: LoadEntryArgs): ViteLoader {
229274
const loaderModule = loader.require(loader.path) as ViteSsrEntryLoaderModule;
230275
return {
231276
httpError: loaderModule.SsrEntryHttpError,
232-
plugin: loaderModule.default({ resolvedShared }),
277+
plugin: loaderModule.default({ resolvedShared, ...options }),
278+
revalidate: loaderModule.revalidate,
233279
require: loader.require,
234280
resolvedShared,
235281
};
236282
}
237283

284+
function isRevalidationEnabled(maxAgeMs: number | undefined): maxAgeMs is number {
285+
return typeof maxAgeMs === "number" && Number.isFinite(maxAgeMs) && maxAgeMs >= 0;
286+
}
287+
288+
function createRevalidationHooks(maxAgeMs: number) {
289+
const lastLoadedAtByOrigin = new WeakMap<ModuleFederation, Map<string, number>>();
290+
// Re-registration is synchronous, but loading and evaluating the new entry is not. Keep a
291+
// per-remote barrier so concurrent expired requests share that asynchronous revalidation.
292+
const pendingRevalidationsByOrigin = new WeakMap<
293+
ModuleFederation,
294+
Map<string, PendingRevalidation>
295+
>();
296+
297+
return {
298+
async beforeRequest(context) {
299+
const { id, origin } = context;
300+
const remote = findRemote(origin, id);
301+
if (!remote) return context;
302+
303+
const pendingRevalidations = getPendingRevalidations(origin);
304+
const pendingRevalidation = pendingRevalidations.get(remote.name);
305+
if (pendingRevalidation) {
306+
await pendingRevalidation.promise;
307+
return context;
308+
}
309+
310+
const lastLoadedAt = getLastLoadedAt(origin);
311+
const loadedAt = lastLoadedAt.get(remote.name);
312+
if (loadedAt !== undefined && Date.now() - loadedAt >= maxAgeMs) {
313+
lastLoadedAt.delete(remote.name);
314+
const nextRevalidation = createPendingRevalidation();
315+
pendingRevalidations.set(remote.name, nextRevalidation);
316+
try {
317+
invalidateRemote(origin, remote);
318+
} catch (error) {
319+
pendingRevalidations.delete(remote.name);
320+
nextRevalidation.resolve();
321+
throw error;
322+
}
323+
}
324+
return context;
325+
},
326+
afterLoadRemote({ origin, remote, error }) {
327+
if (!remote) return;
328+
const pendingRevalidations = getPendingRevalidations(origin);
329+
const lastLoadedAt = getLastLoadedAt(origin);
330+
if (!error && !lastLoadedAt.has(remote.name)) lastLoadedAt.set(remote.name, Date.now());
331+
const pendingRevalidation = pendingRevalidations.get(remote.name);
332+
if (pendingRevalidation) {
333+
pendingRevalidations.delete(remote.name);
334+
pendingRevalidation.resolve();
335+
}
336+
},
337+
} satisfies Pick<ModuleFederationRuntimePlugin, "beforeRequest" | "afterLoadRemote">;
338+
339+
function getLastLoadedAt(origin: ModuleFederation) {
340+
let lastLoadedAt = lastLoadedAtByOrigin.get(origin);
341+
if (!lastLoadedAt) {
342+
lastLoadedAt = new Map();
343+
lastLoadedAtByOrigin.set(origin, lastLoadedAt);
344+
}
345+
return lastLoadedAt;
346+
}
347+
348+
function getPendingRevalidations(origin: ModuleFederation) {
349+
let pendingRevalidations = pendingRevalidationsByOrigin.get(origin);
350+
if (!pendingRevalidations) {
351+
pendingRevalidations = new Map();
352+
pendingRevalidationsByOrigin.set(origin, pendingRevalidations);
353+
}
354+
return pendingRevalidations;
355+
}
356+
}
357+
358+
interface PendingRevalidation {
359+
promise: Promise<void>;
360+
resolve: () => void;
361+
}
362+
363+
function createPendingRevalidation(): PendingRevalidation {
364+
let resolve = () => {};
365+
const promise = new Promise<void>((complete) => {
366+
resolve = complete;
367+
});
368+
return { promise, resolve };
369+
}
370+
371+
function findRemote(origin: ModuleFederation, id: string) {
372+
return origin.options.remotes.find((remote) => {
373+
const names = [remote.name, remote.alias].filter(
374+
(name): name is string => typeof name === "string",
375+
);
376+
return names.some((name) => id === name || id.startsWith(`${name}/`));
377+
});
378+
}
379+
380+
function invalidateRemote(
381+
origin: ModuleFederation,
382+
remote: ModuleFederation["options"]["remotes"][number],
383+
) {
384+
if (
385+
(remote.type === "module" || remote.type === "esm") &&
386+
"entry" in remote &&
387+
typeof remote.entry === "string"
388+
) {
389+
viteLoaders.get(origin)?.revalidate?.(remote.entry);
390+
}
391+
origin.registerRemotes([{ ...remote }], { force: true });
392+
}
393+
238394
/**
239395
* Vite remotes can import shared packages from node_modules instead of the share scope:
240396
* a Vite dev server's module runner externalizes them, and resolved imports in production

‎packages/tanstack/src/rsbuild.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "@module-federation/rsbuild-plugin";
66
import type { RsbuildPlugin } from "@rsbuild/core";
77
import { defaultShared, eagerShared, resolveShared } from "./shared";
8+
import type { NodeEntryLoaderOptions } from "./node-entry-loader";
89

910
type ModuleFederationOverride = Partial<ModuleFederationOptions>;
1011

@@ -21,6 +22,7 @@ type ServerFederationOptions =
2122
entryFilename?: string;
2223
environment?: string;
2324
forceCommonJsOutput?: boolean;
25+
ssrEntryLoader?: NodeEntryLoaderOptions;
2426
});
2527

2628
export type TanStackStartRsbuildModuleFederationOptions = {
@@ -96,6 +98,7 @@ export function tanstackStartModuleFederation({
9698
entryFilename,
9799
environment: serverEnvironment = "ssr",
98100
forceCommonJsOutput = true,
101+
ssrEntryLoader,
99102
...serverOverrides
100103
} = server === true ? {} : server;
101104

@@ -129,7 +132,12 @@ export function tanstackStartModuleFederation({
129132
{
130133
...withDefaults(hostOptions, eagerShared),
131134
manifest: false,
132-
runtimePlugins: [...(hostOptions.runtimePlugins ?? []), NODE_ENTRY_LOADER],
135+
runtimePlugins: [
136+
...(hostOptions.runtimePlugins ?? []),
137+
ssrEntryLoader
138+
? [NODE_ENTRY_LOADER, Object.fromEntries(Object.entries(ssrEntryLoader))]
139+
: NODE_ENTRY_LOADER,
140+
],
133141
},
134142
{ environment: serverEnvironment, target: "node" },
135143
),

0 commit comments

Comments
 (0)