From 3b7f555ea63b58f9747324b6dd5beca000d9d699 Mon Sep 17 00:00:00 2001 From: "dm.choi" Date: Sat, 10 Oct 2026 00:02:32 +0900 Subject: [PATCH] fix: stabilize TanStack SSR remote revalidation --- .changeset/tidy-ssr-revalidation.md | 5 + TODO.md | 10 +- packages/tanstack/README.md | 53 ++++++ packages/tanstack/src/node-entry-loader.ts | 182 +++++++++++++++++-- packages/tanstack/src/rsbuild.ts | 10 +- test/release-contracts.test.mjs | 36 ++++ test/runtime.test.mjs | 202 ++++++++++++++++++++- 7 files changed, 480 insertions(+), 18 deletions(-) create mode 100644 .changeset/tidy-ssr-revalidation.md diff --git a/.changeset/tidy-ssr-revalidation.md b/.changeset/tidy-ssr-revalidation.md new file mode 100644 index 0000000..ecb00c6 --- /dev/null +++ b/.changeset/tidy-ssr-revalidation.md @@ -0,0 +1,5 @@ +--- +"@module-federation/tanstack": patch +--- + +add server-entry revalidation for TanStack Start SSR federation hosts diff --git a/TODO.md b/TODO.md index ef738f2..5c5e885 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/packages/tanstack/README.md b/packages/tanstack/README.md index f54143b..d419ffb 100644 --- a/packages/tanstack/README.md +++ b/packages/tanstack/README.md @@ -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. @@ -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 diff --git a/packages/tanstack/src/node-entry-loader.ts b/packages/tanstack/src/node-entry-loader.ts index 8d48f45..2bec321 100644 --- a/packages/tanstack/src/node-entry-loader.ts +++ b/packages/tanstack/src/node-entry-loader.ts @@ -1,4 +1,4 @@ -import type { ModuleFederationRuntimePlugin } from "@module-federation/runtime"; +import type { ModuleFederation, ModuleFederationRuntimePlugin } from "@module-federation/runtime"; type LoadEntryArgs = Parameters>[0]; type RemoteEntryExports = NonNullable; @@ -6,6 +6,17 @@ 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"; @@ -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; }, }; @@ -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"; } @@ -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) { @@ -141,22 +171,34 @@ const VITE_SHARED_PACKAGES = [ ]; type ViteSsrEntryLoaderModule = { - default: (options: { resolvedShared: Record }) => ModuleFederationRuntimePlugin; + default: (options: { + resolvedShared: Record; + 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; }; -let viteLoader: ViteLoader | undefined; +const viteLoaders = new WeakMap(); -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); @@ -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, @@ -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) { @@ -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>(); + // 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 + >(); + + 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; + + 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; + resolve: () => void; +} + +function createPendingRevalidation(): PendingRevalidation { + let resolve = () => {}; + const promise = new Promise((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 diff --git a/packages/tanstack/src/rsbuild.ts b/packages/tanstack/src/rsbuild.ts index 4852b64..0db1c07 100644 --- a/packages/tanstack/src/rsbuild.ts +++ b/packages/tanstack/src/rsbuild.ts @@ -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; @@ -21,6 +22,7 @@ type ServerFederationOptions = entryFilename?: string; environment?: string; forceCommonJsOutput?: boolean; + ssrEntryLoader?: NodeEntryLoaderOptions; }); export type TanStackStartRsbuildModuleFederationOptions = { @@ -96,6 +98,7 @@ export function tanstackStartModuleFederation({ entryFilename, environment: serverEnvironment = "ssr", forceCommonJsOutput = true, + ssrEntryLoader, ...serverOverrides } = server === true ? {} : server; @@ -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" }, ), diff --git a/test/release-contracts.test.mjs b/test/release-contracts.test.mjs index 90ba1f0..75d9cc4 100644 --- a/test/release-contracts.test.mjs +++ b/test/release-contracts.test.mjs @@ -76,6 +76,24 @@ test("Vite hosts provide their own React eagerly so remotes cannot replace it", assert.equal(overridden.shared.react.shareConfig.eager, false, "explicit entries win"); }); +test("Vite hosts preserve SSR entry-loader revalidation options", () => { + const options = upstreamOptions({ + name: "host", + ssrEntryLoader: { + fetchMaxBytes: 1024, + fetchTimeoutMs: 5000, + maxAgeMs: 1000, + strategy: "vm", + }, + }); + assert.deepEqual(options.ssrEntryLoader, { + fetchMaxBytes: 1024, + fetchTimeoutMs: 5000, + maxAgeMs: 1000, + strategy: "vm", + }); +}); + test("preserves explicit federation settings and shared entries", () => { const options = upstreamOptions({ name: "remote", @@ -368,6 +386,24 @@ test("Rsbuild SSR hosts load remotes from an async-node CommonJS server", () => assert.match(emitted["index.js"], /await createRequire\(import\.meta\.url\)\("\.\/index\.cjs"\)/); }); +test("Rsbuild SSR hosts forward server entry revalidation options", () => { + const [, server] = federationOptions({ + federation: rsbuildHost, + server: { + ssrEntryLoader: { + fetchMaxBytes: 1024, + fetchTimeoutMs: 5000, + maxAgeMs: 1000, + strategy: "vm", + }, + }, + }); + assert.deepEqual(server.runtimePlugins.at(-1), [ + "@module-federation/tanstack/node-entry-loader", + { fetchMaxBytes: 1024, fetchTimeoutMs: 5000, maxAgeMs: 1000, strategy: "vm" }, + ]); +}); + test("Rsbuild ESM server output is left to TanStack Start", () => { const plugins = tanstackStartRsbuildModuleFederation({ federation: rsbuildHost, diff --git a/test/runtime.test.mjs b/test/runtime.test.mjs index 9cd4e95..a89d303 100644 --- a/test/runtime.test.mjs +++ b/test/runtime.test.mjs @@ -18,6 +18,7 @@ const { getRemoteStylesheets, lazyRemote } = await import( ); // The React copy the runtime resolves. const packageRequire = createRequire(join(root, "packages", "tanstack", "package.json")); +const { createInstance } = packageRequire("@module-federation/runtime"); const { createElement, Suspense } = packageRequire("react"); const { renderToString } = packageRequire("react-dom/server"); const { prerenderToNodeStream } = packageRequire("react-dom/static"); @@ -35,7 +36,10 @@ before(async () => { response.writeHead(404).end(); return; } - response.writeHead(200, { "content-type": file.type }).end(file.body); + const body = typeof file.body === "function" ? file.body() : file.body; + const send = () => response.writeHead(200, { "content-type": file.type }).end(body); + if (file.delayMs) setTimeout(send, file.delayMs); + else send(); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); origin = `http://127.0.0.1:${server.address().port}`; @@ -43,8 +47,8 @@ before(async () => { after(() => server.close()); -function serve(path, body, type = "text/javascript") { - files.set(path, { body, type }); +function serve(path, body, type = "text/javascript", delayMs = 0) { + files.set(path, { body, delayMs, type }); return `${origin}${path}`; } @@ -74,6 +78,198 @@ test("loads HTTP CommonJS containers as CommonJS modules", async () => { assert.deepEqual(factory(), { request: "./Card", separator: "/", filename: entry }); }); +test("revalidates a CommonJS server entry after maxAgeMs expires", async () => { + let version = "v1"; + const entry = serve( + "/revalidate/remoteEntry.ssr.cjs", + () => ` + module.exports.remote = { + init() {}, + async get() { + return () => ({ default: "${version}" }); + }, + }; + `, + ); + const host = createInstance({ + name: "revalidate_host", + remotes: [ + { + name: "revalidate_remote", + entry, + entryGlobalName: "remote", + type: "commonjs-module", + }, + ], + plugins: [nodeEntryLoader({ maxAgeMs: 0 })], + }); + + assert.deepEqual(await host.loadRemote("revalidate_remote/Value"), { default: "v1" }); + version = "v2"; + assert.deepEqual(await host.loadRemote("revalidate_remote/Value"), { default: "v2" }); + assert.equal( + requests.filter((request) => request === "/revalidate/remoteEntry.ssr.cjs").length, + 2, + ); +}); + +test("measures maxAgeMs from the remote entry load, not the last expose request", async () => { + const originalNow = Date.now; + let now = 0; + Date.now = () => now; + try { + let version = "v1"; + const entry = serve( + "/revalidate/ttl-remoteEntry.ssr.cjs", + () => ` + module.exports.remote = { + init() {}, + async get() { + return () => ({ default: "${version}" }); + }, + }; + `, + ); + const host = createInstance({ + name: "ttl_host", + remotes: [ + { + name: "ttl_remote", + entry, + entryGlobalName: "remote", + type: "commonjs-module", + }, + ], + plugins: [nodeEntryLoader({ maxAgeMs: 100 })], + }); + + assert.deepEqual(await host.loadRemote("ttl_remote/Value"), { default: "v1" }); + now = 60; + version = "v2"; + assert.deepEqual(await host.loadRemote("ttl_remote/Value"), { default: "v1" }); + now = 101; + version = "v3"; + assert.deepEqual(await host.loadRemote("ttl_remote/Value"), { default: "v3" }); + assert.equal( + requests.filter((request) => request === "/revalidate/ttl-remoteEntry.ssr.cjs").length, + 2, + ); + } finally { + Date.now = originalNow; + } +}); + +test("single-flights concurrent remote revalidation requests", async () => { + const originalNow = Date.now; + let now = 0; + Date.now = () => now; + try { + let version = "v1"; + const entry = serve( + "/revalidate/concurrent-remoteEntry.ssr.cjs", + () => ` + module.exports.remote = { + init() {}, + async get() { + return () => ({ default: "${version}" }); + }, + }; + `, + "text/javascript", + 10, + ); + const host = createInstance({ + name: "concurrent_revalidate_host", + remotes: [ + { + name: "concurrent_revalidate_remote", + entry, + entryGlobalName: "remote", + type: "commonjs-module", + }, + ], + plugins: [nodeEntryLoader({ maxAgeMs: 100 })], + }); + + assert.deepEqual(await host.loadRemote("concurrent_revalidate_remote/Value"), { + default: "v1", + }); + now = 101; + version = "v2"; + const values = await Promise.all([ + host.loadRemote("concurrent_revalidate_remote/Value"), + host.loadRemote("concurrent_revalidate_remote/Value"), + ]); + + assert.deepEqual(values, [{ default: "v2" }, { default: "v2" }]); + assert.equal( + requests.filter((request) => request === "/revalidate/concurrent-remoteEntry.ssr.cjs").length, + 2, + ); + } finally { + Date.now = originalNow; + } +}); + +test("allows disabling the CommonJS entry timeout", async () => { + const entry = serve( + "/timeout/remoteEntry.ssr.cjs", + ` + module.exports.remote = { + init() {}, + async get() { + return () => ({ default: "loaded" }); + }, + }; + `, + "text/javascript", + 20, + ); + const container = await nodeEntryLoader({ fetchTimeoutMs: 0 }).loadEntry({ + origin: {}, + remoteInfo: { + entry, + entryGlobalName: "remote", + name: "timeout_remote", + type: "commonjs-module", + }, + }); + + assert.equal(typeof container.get, "function"); +}); + +test("revalidates a Vite SSR entry after maxAgeMs expires", async () => { + let requestsForViteEntry = 0; + const entry = serve("/revalidate-vite/remoteEntry.ssr.js", () => { + requestsForViteEntry += 1; + return ` + export async function init() {} + export async function get() { + return () => ({ default: "v${requestsForViteEntry}" }); + } + `; + }); + const outcome = await runInHost(` + const { createRequire } = await import("node:module"); + const { createInstance } = createRequire(process.cwd() + "/package.json")( + "@module-federation/runtime", + ); + const host = createInstance({ + name: "revalidate_vite_host", + remotes: [{ name: "revalidate_vite_remote", entry: ${JSON.stringify(entry)}, type: "module" }], + plugins: [loader({ maxAgeMs: 0 })], + }); + const first = await host.loadRemote("revalidate_vite_remote/Value"); + const second = await host.loadRemote("revalidate_vite_remote/Value"); + console.log(JSON.stringify({ first, second })); + `); + assert.deepEqual(outcome, { + first: { default: "v1" }, + second: { default: "v2" }, + }); + assert.equal(requestsForViteEntry, 2); +}); + test("reports which step failed and keeps the cause", async () => { await assert.rejects(loadEntry(`${origin}/missing/remoteEntry.ssr.cjs`), (error) => { assert.ok(error instanceof RemoteEntryError);