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/quiet-remotes-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/tanstack": patch
---

unify remote fetch timeout, response-size limits, and manifest cache behavior across Vite and Rsbuild runtimes
1 change: 1 addition & 0 deletions packages/tanstack/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 11 additions & 11 deletions packages/tanstack/src/node-entry-loader.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<ModuleFederationRuntimePlugin["loadEntry"]>>[0];
type RemoteEntryExports = NonNullable<LoadEntryArgs["remoteEntryExports"]>;
Expand All @@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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}.`,
Expand Down
82 changes: 82 additions & 0 deletions packages/tanstack/src/remote-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<T>(
url: string,
options: RemoteFetchOptions = {},
): Promise<T> {
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}).`,
);
}
}
45 changes: 33 additions & 12 deletions packages/tanstack/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -39,7 +42,7 @@ export function lazyRemote<T extends ComponentType<any>>(
};
}

export type RemoteStylesheetOptions = {
export type RemoteStylesheetOptions = RemoteFetchOptions & {
/** How long a fetched manifest is reused, in milliseconds. Defaults to 30 seconds. */
maxAgeMs?: number;
};
Expand All @@ -51,7 +54,7 @@ type RemoteManifest = {
metaData?: { publicPath?: string };
};

const manifests = new Map<string, { fetchedAt: number; manifest: Promise<RemoteManifest> }>();
const manifests = new Map<string, ManifestCacheEntry>();

/**
* Lists the stylesheets a remote's manifest declares for an exposed module, as absolute
Expand All @@ -63,9 +66,13 @@ const manifests = new Map<string, { fetchedAt: number; manifest: Promise<RemoteM
export async function getRemoteStylesheets(
manifestUrl: string,
expose: string,
{ maxAgeMs = 30_000 }: RemoteStylesheetOptions = {},
{ maxAgeMs = 30_000, fetchTimeoutMs, fetchMaxBytes }: RemoteStylesheetOptions = {},
): Promise<string[]> {
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,
Expand All @@ -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<RemoteManifest>;
}

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<RemoteManifest>(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;
}
1 change: 1 addition & 0 deletions test/release-artifacts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions test/remote-fetch.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
});
});
Loading
Loading