diff --git a/.changeset/quiet-remotes-fetch.md b/.changeset/quiet-remotes-fetch.md new file mode 100644 index 0000000..1dc3fdc --- /dev/null +++ b/.changeset/quiet-remotes-fetch.md @@ -0,0 +1,5 @@ +--- +"@module-federation/tanstack": patch +--- + +unify remote fetch timeout, response-size limits, and manifest cache behavior across Vite and Rsbuild runtimes diff --git a/packages/tanstack/rslib.config.ts b/packages/tanstack/rslib.config.ts index 6ab4262..1444214 100644 --- a/packages/tanstack/rslib.config.ts +++ b/packages/tanstack/rslib.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "@rslib/core"; const entry = { index: "./src/index.ts", "node-entry-loader": "./src/node-entry-loader.ts", + "remote-fetch": "./src/remote-fetch.ts", rsbuild: "./src/rsbuild.ts", runtime: "./src/runtime.ts", shared: "./src/shared.ts", diff --git a/packages/tanstack/src/node-entry-loader.ts b/packages/tanstack/src/node-entry-loader.ts index c7d19f2..f58e0a0 100644 --- a/packages/tanstack/src/node-entry-loader.ts +++ b/packages/tanstack/src/node-entry-loader.ts @@ -1,5 +1,11 @@ import type { ModuleFederation, ModuleFederationRuntimePlugin } from "@module-federation/runtime"; import { tanstackStartSharedPackages } from "./shared"; +import { + DEFAULT_REMOTE_FETCH_TIMEOUT_MS, + fetchRemoteText, + type RemoteFetchOptions, +} from "./remote-fetch"; +export type { RemoteFetchOptions } from "./remote-fetch"; type LoadEntryArgs = Parameters>[0]; type RemoteEntryExports = NonNullable; @@ -19,15 +25,11 @@ const tanstackStartResolutionPackages = [ ...tanstackStartSharedPackages, ] as const; -export interface NodeEntryLoaderOptions { +export interface NodeEntryLoaderOptions extends RemoteFetchOptions { /** 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. */ @@ -79,7 +81,7 @@ export default function nodeEntryLoaderPlugin( const { remoteInfo } = args; if (!isNodeServer() || !/^https?:\/\//.test(remoteInfo.entry)) return undefined; if (remoteInfo.type === "commonjs-module") { - return loadCommonJsEntry(remoteInfo, options.fetchTimeoutMs); + return loadCommonJsEntry(remoteInfo, options); } if (remoteInfo.type === "module" || remoteInfo.type === "esm") { return loadModuleEntry(args, options); @@ -90,7 +92,7 @@ export default function nodeEntryLoaderPlugin( } // Matches @module-federation/vite's SSR fetch timeout. -const FETCH_TIMEOUT_MS = 10_000; +const FETCH_TIMEOUT_MS = DEFAULT_REMOTE_FETCH_TIMEOUT_MS; function requestOptions(fetchTimeoutMs: number | undefined) { const timeout = fetchTimeoutMs ?? FETCH_TIMEOUT_MS; @@ -160,13 +162,11 @@ function resolvePackageRoot(specifier: string, require: NodeJS.Require) { async function loadCommonJsEntry( { entry, entryGlobalName, name }: RemoteInfo, - fetchTimeoutMs = FETCH_TIMEOUT_MS, + options: RemoteFetchOptions, ) { let source: string; try { - const response = await fetch(entry, requestOptions(fetchTimeoutMs)); - if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`.trim()); - source = await response.text(); + source = await fetchRemoteText(entry, options); } catch (cause) { throw new RemoteEntryError( `Could not fetch the server entry of remote "${name}" from ${entry}.`, diff --git a/packages/tanstack/src/remote-fetch.ts b/packages/tanstack/src/remote-fetch.ts new file mode 100644 index 0000000..d1f1b95 --- /dev/null +++ b/packages/tanstack/src/remote-fetch.ts @@ -0,0 +1,82 @@ +export type RemoteFetchOptions = { + /** Maximum time for the request and response body to complete. Defaults to 10 seconds. */ + fetchTimeoutMs?: number; + /** Maximum response body size in bytes. Defaults to 10 MiB. Set to 0 to disable. */ + fetchMaxBytes?: number; +}; + +export const DEFAULT_REMOTE_FETCH_TIMEOUT_MS = 10_000; +export const DEFAULT_REMOTE_FETCH_MAX_BYTES = 10 * 1024 * 1024; + +/** Fetches a remote response as text with a bounded timeout and response body. */ +export async function fetchRemoteText( + url: string, + options: RemoteFetchOptions = {}, +): Promise { + const timeoutMs = options.fetchTimeoutMs ?? DEFAULT_REMOTE_FETCH_TIMEOUT_MS; + const maxBytes = options.fetchMaxBytes ?? DEFAULT_REMOTE_FETCH_MAX_BYTES; + const controller = timeoutMs === 0 ? undefined : new AbortController(); + const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined; + + try { + const response = await fetch(url, controller ? { signal: controller.signal } : undefined); + if (!response.ok) throw new Error(`${url} responded with HTTP ${response.status}.`); + + const contentLength = response.headers.get("content-length"); + if (maxBytes > 0 && contentLength && Number(contentLength) > maxBytes) { + await response.body?.cancel(); + throw new Error( + `${url} response exceeds the maximum size of ${maxBytes} bytes ` + + `(content-length: ${contentLength}).`, + ); + } + + if (!response.body) { + const text = await response.text(); + assertResponseSize(url, new TextEncoder().encode(text).byteLength, maxBytes); + return text; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + assertResponseSize(url, bytes, maxBytes); + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; + } + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +/** Fetches and parses a remote JSON response using the shared fetch policy. */ +export async function fetchRemoteJson( + url: string, + options: RemoteFetchOptions = {}, +): Promise { + const text = await fetchRemoteText(url, options); + try { + return JSON.parse(text) as T; + } catch (cause) { + throw new Error(`Could not parse the JSON response from ${url}.`, { cause }); + } +} + +function assertResponseSize(url: string, bytes: number, maxBytes: number) { + if (maxBytes > 0 && bytes > maxBytes) { + throw new Error( + `${url} response exceeds the maximum size of ${maxBytes} bytes (received: ${bytes}).`, + ); + } +} diff --git a/packages/tanstack/src/runtime.ts b/packages/tanstack/src/runtime.ts index 1c9ba22..26a4919 100644 --- a/packages/tanstack/src/runtime.ts +++ b/packages/tanstack/src/runtime.ts @@ -1,4 +1,7 @@ import { createElement, lazy, type ComponentProps, type ComponentType } from "react"; +import { fetchRemoteJson, type RemoteFetchOptions } from "./remote-fetch"; + +export type { RemoteFetchOptions } from "./remote-fetch"; export type LazyRemoteOptions = { /** How long a failed load stays failed before a render tries again, in milliseconds. Defaults to 5 seconds. */ @@ -39,7 +42,7 @@ export function lazyRemote>( }; } -export type RemoteStylesheetOptions = { +export type RemoteStylesheetOptions = RemoteFetchOptions & { /** How long a fetched manifest is reused, in milliseconds. Defaults to 30 seconds. */ maxAgeMs?: number; }; @@ -51,7 +54,7 @@ type RemoteManifest = { metaData?: { publicPath?: string }; }; -const manifests = new Map }>(); +const manifests = new Map(); /** * Lists the stylesheets a remote's manifest declares for an exposed module, as absolute @@ -63,9 +66,13 @@ const manifests = new Map { - const manifest = await fetchManifest(manifestUrl, maxAgeMs); + const manifest = await fetchManifest(manifestUrl, { + maxAgeMs, + fetchTimeoutMs, + fetchMaxBytes, + }); const name = expose.replace(/^\.\//, ""); const exposed = manifest.exposes?.find( (candidate) => candidate.path === expose || candidate.name === name, @@ -79,18 +86,32 @@ export async function getRemoteStylesheets( return [...new Set([...sync, ...async])].map((asset) => new URL(asset, base).href); } -function fetchManifest(url: string, maxAgeMs: number) { +interface ManifestCacheEntry { + fetchedAt?: number; + manifest: Promise; +} + +function fetchManifest( + url: string, + { maxAgeMs = 30_000, fetchTimeoutMs, fetchMaxBytes }: RemoteStylesheetOptions, +) { const cached = manifests.get(url); - if (cached && Date.now() - cached.fetchedAt < maxAgeMs) return cached.manifest; + if (cached && (cached.fetchedAt === undefined || Date.now() - cached.fetchedAt < maxAgeMs)) { + return cached.manifest; + } - const manifest = fetch(url).then(async (response) => { - if (!response.ok) throw new Error(`${url} responded with HTTP ${response.status}.`); - return (await response.json()) as RemoteManifest; - }); - manifests.set(url, { fetchedAt: Date.now(), manifest }); + const manifest = fetchRemoteJson(url, { fetchTimeoutMs, fetchMaxBytes }); + const entry: ManifestCacheEntry = { manifest }; + manifests.set(url, entry); // A failed fetch is not reused: the next call retries. manifest.catch(() => { - if (manifests.get(url)?.manifest === manifest) manifests.delete(url); + if (manifests.get(url) === entry) manifests.delete(url); }); + manifest.then( + () => { + if (manifests.get(url) === entry) entry.fetchedAt = Date.now(); + }, + () => undefined, + ); return manifest; } diff --git a/test/release-artifacts.test.mjs b/test/release-artifacts.test.mjs index f7399af..fae106c 100644 --- a/test/release-artifacts.test.mjs +++ b/test/release-artifacts.test.mjs @@ -46,6 +46,7 @@ test("npm package contains only runtime, metadata, and documentation files", () const entries = [ "index", "node-entry-loader", + "remote-fetch", "rsbuild", "runtime", "shared", diff --git a/test/remote-fetch.test.mjs b/test/remote-fetch.test.mjs new file mode 100644 index 0000000..f621a64 --- /dev/null +++ b/test/remote-fetch.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const dist = join(root, "packages", "tanstack", "dist"); +const { fetchRemoteJson, fetchRemoteText } = await import( + pathToFileURL(join(dist, "remote-fetch.js")).href +); + +const originalFetch = globalThis.fetch; + +test.after(() => { + globalThis.fetch = originalFetch; +}); + +test("fetchRemoteText enforces the response size limit while streaming", async () => { + globalThis.fetch = async () => new Response("0123456789", { status: 200 }); + + await assert.rejects( + fetchRemoteText("https://remote.test/entry.js", { fetchMaxBytes: 5 }), + /exceeds the maximum size of 5 bytes/, + ); +}); + +test("fetchRemoteText rejects a response whose content-length is already too large", async () => { + globalThis.fetch = async () => + new Response("ok", { headers: { "content-length": "100" }, status: 200 }); + + await assert.rejects( + fetchRemoteText("https://remote.test/entry.js", { fetchMaxBytes: 5 }), + /content-length: 100/, + ); +}); + +test("fetchRemoteJson rejects non-2xx and malformed responses", async () => { + globalThis.fetch = async (url) => + url.endsWith("/missing") + ? new Response("missing", { status: 404 }) + : new Response("not-json", { status: 200 }); + + await assert.rejects(fetchRemoteJson("https://remote.test/missing"), /HTTP 404/); + await assert.rejects(fetchRemoteJson("https://remote.test/malformed"), /Could not parse/); +}); + +test("fetchRemoteText aborts a request that exceeds the timeout", async () => { + globalThis.fetch = async (_url, { signal }) => + new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + + await assert.rejects(fetchRemoteText("https://remote.test/slow", { fetchTimeoutMs: 5 }), { + name: "AbortError", + }); +}); diff --git a/test/runtime.test.mjs b/test/runtime.test.mjs index d0840b7..54f94fd 100644 --- a/test/runtime.test.mjs +++ b/test/runtime.test.mjs @@ -238,6 +238,58 @@ test("allows disabling the CommonJS entry timeout", async () => { assert.equal(typeof container.get, "function"); }); +test("bounds CommonJS server-entry responses", async () => { + const entry = serve( + "/limits/remoteEntry.ssr.cjs", + `module.exports.remote = { init() {}, get() {} };`, + ); + + await assert.rejects( + nodeEntryLoader({ fetchMaxBytes: 16 }).loadEntry({ + origin: {}, + remoteInfo: { + entry, + entryGlobalName: "remote", + name: "limited_remote", + type: "commonjs-module", + }, + }), + (error) => { + assert.ok(error instanceof RemoteEntryError); + assert.equal(error.phase, "fetch"); + assert.match(error.cause.message, /exceeds the maximum size of 16 bytes/); + return true; + }, + ); +}); + +test("times out CommonJS server-entry responses", async () => { + const entry = serve( + "/limits/slow-remoteEntry.ssr.cjs", + `module.exports.remote = { init() {}, get() {} };`, + "text/javascript", + 50, + ); + + await assert.rejects( + nodeEntryLoader({ fetchTimeoutMs: 5 }).loadEntry({ + origin: {}, + remoteInfo: { + entry, + entryGlobalName: "remote", + name: "slow_remote", + type: "commonjs-module", + }, + }), + (error) => { + assert.ok(error instanceof RemoteEntryError); + assert.equal(error.phase, "fetch"); + assert.equal(error.cause.name, "AbortError"); + return true; + }, + ); +}); + test("revalidates a Vite SSR entry after maxAgeMs expires", async () => { let requestsForViteEntry = 0; const entry = serve("/revalidate-vite/remoteEntry.ssr.js", () => { @@ -490,6 +542,62 @@ test("getRemoteStylesheets reuses manifests briefly and retries failures", async assert.equal(fetches(), 3, "an expired manifest is fetched again"); }); +test("getRemoteStylesheets applies fetch limits and parses JSON through the shared policy", async () => { + const oversized = serve( + "/styles/oversized-mf-manifest.json", + JSON.stringify({ exposes: [] }), + "application/json", + ); + await assert.rejects( + getRemoteStylesheets(oversized, "./Card", { fetchMaxBytes: 8 }), + /exceeds the maximum size of 8 bytes/, + ); + + const malformed = serve("/styles/malformed-mf-manifest.json", "not-json", "application/json"); + await assert.rejects( + getRemoteStylesheets(malformed, "./Card"), + /Could not parse the JSON response/, + ); + + const slow = serve( + "/styles/slow-mf-manifest.json", + JSON.stringify({ exposes: [] }), + "application/json", + 50, + ); + await assert.rejects(getRemoteStylesheets(slow, "./Card", { fetchTimeoutMs: 5 }), { + name: "AbortError", + }); +}); + +test("getRemoteStylesheets single-flights in-flight manifests and timestamps successful cache entries", async () => { + let version = "v1"; + const path = "/styles/concurrent-mf-manifest.json"; + const url = serve( + path, + () => + JSON.stringify({ + exposes: [{ name: "Card", path: "./Card" }], + metaData: { publicPath: "auto" }, + version, + }), + "application/json", + 20, + ); + + const [first, second] = await Promise.all([ + getRemoteStylesheets(url, "./Card"), + getRemoteStylesheets(url, "./Card"), + ]); + assert.deepEqual(first, []); + assert.deepEqual(second, []); + assert.equal(requests.filter((request) => request === path).length, 1); + + version = "v2"; + await getRemoteStylesheets(url, "./Card", { maxAgeMs: 0 }); + assert.equal(requests.filter((request) => request === path).length, 2); +}); + /** Server-renders `element` to HTML once every Suspense boundary has settled. */ async function prerender(element) { const errors = [];