Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-ssr-revalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/tanstack": patch
---

add server-entry revalidation for TanStack Start SSR federation hosts
10 changes: 9 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,15 @@ Fixed upstream in `@module-federation/vite` 1.23.3:
the host, and prove no stale container or shared module is served.
`@module-federation/vite` has `revalidate()` and `maxAgeMs` for its own
loader; the Module Federation runtime caches containers for the life of
the process.
the process. Server-entry revalidation is now available for Vite hosts
through `ssrEntryLoader.maxAgeMs` and for Rsbuild hosts through
`server.ssrEntryLoader.maxAgeMs`. For Vite's native loader, a manifest
provides the version needed for automatic change detection; direct or
convention-resolved server-entry URLs need an explicit `revalidate()` for
forced refresh. Shared-singleton changes still require a host restart, so
the shared-module portion remains open. Remote module-scope side effects
are not automatically disposed during revalidation and remain the
application's responsibility.
- [ ] Test cyclic imports across chunks in a server remote graph.
- [ ] In development, a Vite remote's server modules import React from the files
its dev server resolves, so host and remote must resolve React to the
Expand Down
53 changes: 53 additions & 0 deletions packages/tanstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ A Vite host renders before lazy shared modules resolve, so mark any other shared
package the host imports from its entry or root route as `eager: true`, as the
wrapper does for React. Remotes can keep their shares lazy.

For a Vite host, configure SSR entry revalidation directly with the Vite
option:

```ts
tanstackStartModuleFederation({
name: "host",
ssrEntryLoader: { maxAgeMs: 30_000 },
// ...remotes and shared options
});
```

Vite's `maxAgeMs` revalidation is version-aware when the remote is resolved
from an `mf-manifest.json`. Convention-resolved or direct server-entry URLs do
not provide a remote version; use a manifest URL or call the Vite loader's
explicit `revalidate()` when those URLs must be refreshed.

The wrapper omits the global Module Federation `target` option. TanStack
Start's client and SSR environments need `@module-federation/vite` to select
`web` and `node` independently.
Expand Down Expand Up @@ -151,6 +167,43 @@ When a remote's server entry cannot load, the host's runtime plugin throws a
`RemoteEntryError` whose `phase` is `fetch`, `evaluate`, or `loader` and whose
`cause` is the original error.

Server entries are cached for the lifetime of the Node process by default. To
pick up a rebuilt remote while keeping the process running, configure the
revalidation window on an Rsbuild host:

```ts
tanstackStartModuleFederation({
federation: {
name: "host",
remotes: {
remote: "remote@https://example.test/mf-manifest.json",
},
},
server: {
ssrEntryLoader: {
maxAgeMs: 30_000,
},
},
});
```

For an Rsbuild host, when `maxAgeMs` expires, the adapter invalidates the
Module Federation remote container cache. Vite remotes also invalidate the Vite
SSR entry-loader cache; the option is forwarded to that loader. `strategy` and
`fetchMaxBytes` apply to Vite SSR entries, while `fetchTimeoutMs` applies to
both Vite and CommonJS server-entry requests. Set either limit to `0` to
disable it. Concurrent requests for an expired remote share one revalidation
request on this adapter path.

Revalidation makes subsequent loads use the new remote entry and exposed
modules, but does not safely replace already loaded process-level shared
singletons such as React. Restart the host process after changing a shared
singleton's version or implementation. For Rsbuild/CommonJS remotes,
expiration also re-evaluates the downloaded server entry with a forced
container re-registration. Module-scope side effects in a remote (for example
timers or process listeners) are not automatically disposed, so use
revalidation only for remotes whose evaluation is safe to repeat.

## Rendering remotes on the server

Hosts on either bundler render remotes the same way: load the remote in the
Expand Down
182 changes: 169 additions & 13 deletions packages/tanstack/src/node-entry-loader.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import type { ModuleFederationRuntimePlugin } from "@module-federation/runtime";
import type { ModuleFederation, ModuleFederationRuntimePlugin } from "@module-federation/runtime";

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

export interface NodeEntryLoaderOptions {
/** Re-check manifest/server entries after this many milliseconds. Omit to cache until exit. */
maxAgeMs?: number;
/** Revalidation strategy used by the Vite SSR entry loader. */
strategy?: "temp-file" | "vm";
/** Maximum time for a server-entry request. Defaults to 10 seconds; 0 disables the timeout. */
fetchTimeoutMs?: number;
/** Maximum server-entry response size for Vite remotes; 0 disables the limit. */
fetchMaxBytes?: number;
}

/** A remote's server entry failed to load. `phase` says which step failed; `cause` keeps the original error. */
export class RemoteEntryError extends Error {
readonly phase: "fetch" | "evaluate" | "loader";
Expand Down Expand Up @@ -34,14 +45,25 @@ export class RemoteEntryError extends Error {
* handles both production builds and Vite dev servers. It is loaded with Node's
* `require`, not the bundle's, so its `import()` calls work in Rsbuild's runner too.
*/
export default function nodeEntryLoaderPlugin(): ModuleFederationRuntimePlugin {
export default function nodeEntryLoaderPlugin(
options: NodeEntryLoaderOptions = {},
): ModuleFederationRuntimePlugin {
const revalidationHooks = isRevalidationEnabled(options.maxAgeMs)
? createRevalidationHooks(options.maxAgeMs)
: {};

return {
name: "tanstack-start-node-entry-loader",
...revalidationHooks,
async loadEntry(args) {
const { remoteInfo } = args;
if (!isNodeServer() || !/^https?:\/\//.test(remoteInfo.entry)) return undefined;
if (remoteInfo.type === "commonjs-module") return loadCommonJsEntry(remoteInfo);
if (remoteInfo.type === "module" || remoteInfo.type === "esm") return loadModuleEntry(args);
if (remoteInfo.type === "commonjs-module") {
return loadCommonJsEntry(remoteInfo, options.fetchTimeoutMs);
}
if (remoteInfo.type === "module" || remoteInfo.type === "esm") {
return loadModuleEntry(args, options);
}
return undefined;
},
};
Expand All @@ -50,6 +72,11 @@ export default function nodeEntryLoaderPlugin(): ModuleFederationRuntimePlugin {
// Matches @module-federation/vite's SSR fetch timeout.
const FETCH_TIMEOUT_MS = 10_000;

function requestOptions(fetchTimeoutMs: number | undefined) {
const timeout = fetchTimeoutMs ?? FETCH_TIMEOUT_MS;
return timeout === 0 ? {} : { signal: AbortSignal.timeout(timeout) };
}

function isNodeServer() {
return typeof process !== "undefined" && typeof process.getBuiltinModule === "function";
}
Expand All @@ -68,10 +95,13 @@ function appRequires() {
.map((base) => createRequire(base));
}

async function loadCommonJsEntry({ entry, entryGlobalName, name }: RemoteInfo) {
async function loadCommonJsEntry(
{ entry, entryGlobalName, name }: RemoteInfo,
fetchTimeoutMs = FETCH_TIMEOUT_MS,
) {
let source: string;
try {
const response = await fetch(entry, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
const response = await fetch(entry, requestOptions(fetchTimeoutMs));
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
source = await response.text();
} catch (cause) {
Expand Down Expand Up @@ -141,22 +171,34 @@ const VITE_SHARED_PACKAGES = [
];

type ViteSsrEntryLoaderModule = {
default: (options: { resolvedShared: Record<string, string> }) => ModuleFederationRuntimePlugin;
default: (options: {
resolvedShared: Record<string, string>;
maxAgeMs?: number;
strategy?: "temp-file" | "vm";
fetchTimeoutMs?: number;
fetchMaxBytes?: number;
}) => ModuleFederationRuntimePlugin;
revalidate?: (remoteEntryUrl?: string) => void;
SsrEntryHttpError?: new (...args: never[]) => Error;
};

type ViteLoader = {
httpError?: ViteSsrEntryLoaderModule["SsrEntryHttpError"];
plugin: ModuleFederationRuntimePlugin;
revalidate?: ViteSsrEntryLoaderModule["revalidate"];
require: NodeJS.Require;
resolvedShared: Record<string, string>;
};

let viteLoader: ViteLoader | undefined;
const viteLoaders = new WeakMap<ModuleFederation, ViteLoader>();

async function loadModuleEntry(args: LoadEntryArgs) {
async function loadModuleEntry(args: LoadEntryArgs, options: NodeEntryLoaderOptions) {
const { entry, name } = args.remoteInfo;
viteLoader ??= createViteLoader(args);
let viteLoader = viteLoaders.get(args.origin);
if (!viteLoader) {
viteLoader = createViteLoader(args, options);
viteLoaders.set(args.origin, viteLoader);
}
// Shared modules the host loaded since the last remote are aligned too.
provideHostModules(args.origin, viteLoader.resolvedShared, viteLoader.require);

Expand All @@ -181,7 +223,7 @@ async function loadModuleEntry(args: LoadEntryArgs) {
if (!remoteEntry) {
const reachable = await fetch(entry, {
method: "HEAD",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
...requestOptions(options.fetchTimeoutMs),
}).then(
(response) => response.ok,
() => false,
Expand All @@ -197,7 +239,10 @@ async function loadModuleEntry(args: LoadEntryArgs) {
return remoteEntry;
}

function createViteLoader({ origin, remoteInfo }: LoadEntryArgs): ViteLoader {
function createViteLoader(
{ origin, remoteInfo }: LoadEntryArgs,
options: NodeEntryLoaderOptions,
): ViteLoader {
const requires = appRequires();
const resolve = (specifier: string) => {
for (const require of requires) {
Expand Down Expand Up @@ -229,12 +274,123 @@ function createViteLoader({ origin, remoteInfo }: LoadEntryArgs): ViteLoader {
const loaderModule = loader.require(loader.path) as ViteSsrEntryLoaderModule;
return {
httpError: loaderModule.SsrEntryHttpError,
plugin: loaderModule.default({ resolvedShared }),
plugin: loaderModule.default({ resolvedShared, ...options }),
revalidate: loaderModule.revalidate,
require: loader.require,
resolvedShared,
};
}

function isRevalidationEnabled(maxAgeMs: number | undefined): maxAgeMs is number {
return typeof maxAgeMs === "number" && Number.isFinite(maxAgeMs) && maxAgeMs >= 0;
}

function createRevalidationHooks(maxAgeMs: number) {
const lastLoadedAtByOrigin = new WeakMap<ModuleFederation, Map<string, number>>();
// Re-registration is synchronous, but loading and evaluating the new entry is not. Keep a
// per-remote barrier so concurrent expired requests share that asynchronous revalidation.
const pendingRevalidationsByOrigin = new WeakMap<
ModuleFederation,
Map<string, PendingRevalidation>
>();

return {
async beforeRequest(context) {
const { id, origin } = context;
const remote = findRemote(origin, id);
if (!remote) return context;

const pendingRevalidations = getPendingRevalidations(origin);
const pendingRevalidation = pendingRevalidations.get(remote.name);
if (pendingRevalidation) {
await pendingRevalidation.promise;
return context;
}

const lastLoadedAt = getLastLoadedAt(origin);
const loadedAt = lastLoadedAt.get(remote.name);
if (loadedAt !== undefined && Date.now() - loadedAt >= maxAgeMs) {
lastLoadedAt.delete(remote.name);
const nextRevalidation = createPendingRevalidation();
pendingRevalidations.set(remote.name, nextRevalidation);
try {
invalidateRemote(origin, remote);
} catch (error) {
pendingRevalidations.delete(remote.name);
nextRevalidation.resolve();
throw error;
}
}
return context;
},
afterLoadRemote({ origin, remote, error }) {
if (!remote) return;
const pendingRevalidations = getPendingRevalidations(origin);
const lastLoadedAt = getLastLoadedAt(origin);
if (!error && !lastLoadedAt.has(remote.name)) lastLoadedAt.set(remote.name, Date.now());
const pendingRevalidation = pendingRevalidations.get(remote.name);
if (pendingRevalidation) {
pendingRevalidations.delete(remote.name);
pendingRevalidation.resolve();
}
},
} satisfies Pick<ModuleFederationRuntimePlugin, "beforeRequest" | "afterLoadRemote">;

function getLastLoadedAt(origin: ModuleFederation) {
let lastLoadedAt = lastLoadedAtByOrigin.get(origin);
if (!lastLoadedAt) {
lastLoadedAt = new Map();
lastLoadedAtByOrigin.set(origin, lastLoadedAt);
}
return lastLoadedAt;
}

function getPendingRevalidations(origin: ModuleFederation) {
let pendingRevalidations = pendingRevalidationsByOrigin.get(origin);
if (!pendingRevalidations) {
pendingRevalidations = new Map();
pendingRevalidationsByOrigin.set(origin, pendingRevalidations);
}
return pendingRevalidations;
}
}

interface PendingRevalidation {
promise: Promise<void>;
resolve: () => void;
}

function createPendingRevalidation(): PendingRevalidation {
let resolve = () => {};
const promise = new Promise<void>((complete) => {
resolve = complete;
});
return { promise, resolve };
}

function findRemote(origin: ModuleFederation, id: string) {
return origin.options.remotes.find((remote) => {
const names = [remote.name, remote.alias].filter(
(name): name is string => typeof name === "string",
);
return names.some((name) => id === name || id.startsWith(`${name}/`));
});
}

function invalidateRemote(
origin: ModuleFederation,
remote: ModuleFederation["options"]["remotes"][number],
) {
if (
(remote.type === "module" || remote.type === "esm") &&
"entry" in remote &&
typeof remote.entry === "string"
) {
viteLoaders.get(origin)?.revalidate?.(remote.entry);
}
origin.registerRemotes([{ ...remote }], { force: true });
}

/**
* Vite remotes can import shared packages from node_modules instead of the share scope:
* a Vite dev server's module runner externalizes them, and resolved imports in production
Expand Down
10 changes: 9 additions & 1 deletion packages/tanstack/src/rsbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@module-federation/rsbuild-plugin";
import type { RsbuildPlugin } from "@rsbuild/core";
import { defaultShared, eagerShared, resolveShared } from "./shared";
import type { NodeEntryLoaderOptions } from "./node-entry-loader";

type ModuleFederationOverride = Partial<ModuleFederationOptions>;

Expand All @@ -21,6 +22,7 @@ type ServerFederationOptions =
entryFilename?: string;
environment?: string;
forceCommonJsOutput?: boolean;
ssrEntryLoader?: NodeEntryLoaderOptions;
});

export type TanStackStartRsbuildModuleFederationOptions = {
Expand Down Expand Up @@ -96,6 +98,7 @@ export function tanstackStartModuleFederation({
entryFilename,
environment: serverEnvironment = "ssr",
forceCommonJsOutput = true,
ssrEntryLoader,
...serverOverrides
} = server === true ? {} : server;

Expand Down Expand Up @@ -129,7 +132,12 @@ export function tanstackStartModuleFederation({
{
...withDefaults(hostOptions, eagerShared),
manifest: false,
runtimePlugins: [...(hostOptions.runtimePlugins ?? []), NODE_ENTRY_LOADER],
runtimePlugins: [
...(hostOptions.runtimePlugins ?? []),
ssrEntryLoader
? [NODE_ENTRY_LOADER, Object.fromEntries(Object.entries(ssrEntryLoader))]
: NODE_ENTRY_LOADER,
],
},
{ environment: serverEnvironment, target: "node" },
),
Expand Down
Loading
Loading