diff --git a/.changeset/remote-fetch-options.md b/.changeset/remote-fetch-options.md
new file mode 100644
index 00000000000..cee9b210a27
--- /dev/null
+++ b/.changeset/remote-fetch-options.md
@@ -0,0 +1,10 @@
+---
+'@module-federation/runtime-core': minor
+'@module-federation/sdk': minor
+---
+
+Support loading remote assets in the manifest with custom headers through the existing runtime `fetch` hook.
+
+- When a `fetch` hook plugin is registered, `module`/`esm` remotes load their remote entry and manifest-declared CSS through a fetch + blob loader, instead of native `import()` / ``.
+- The hook receives `remoteInfo`, so headers can be set per remote and support dynamic cases such as token refresh.
+- When no `fetch` hook is present, remotes load exactly as before.
diff --git a/apps/website-new/docs/en/guide/runtime/runtime-api.mdx b/apps/website-new/docs/en/guide/runtime/runtime-api.mdx
index 0ebd522f3c6..e48b74dce80 100644
--- a/apps/website-new/docs/en/guide/runtime/runtime-api.mdx
+++ b/apps/website-new/docs/en/guide/runtime/runtime-api.mdx
@@ -305,6 +305,45 @@ When `force: true` is set, the newly registered modules will overwrite already-r
:::
+### Loading remotes with custom headers via the `fetch` hook
+
+Some remotes require specific headers to be accessed. In that case you can configure the headers through the runtime [`fetch` hook](./runtime-hooks#fetch). You can handle dynamic cases such as token refresh or per-request auth logic inside this hook.
+
+For **`module` / `esm`** remotes, when a `fetch` hook is registered the remote entry and its manifest-declared CSS are loaded through a `fetch` + blob loader so the headers you add reach every asset request. With no `fetch` hook registered, remotes load as before via native `import()` / `` (which cannot carry custom headers).
+
+```ts
+import { createInstance } from '@module-federation/enhanced/runtime';
+
+const mf = createInstance({
+ name: 'mf_host',
+ remotes: [
+ {
+ name: 'sub1',
+ entry: 'https://localhost:2001/mf-manifest.json',
+ type: 'module',
+ },
+ ],
+ plugins: [
+ {
+ name: 'auth-fetch',
+ // `remoteInfo` lets you use different request options for specific remotes
+ fetch(url, init, remoteInfo) {
+ return fetch(url, {
+ ...init,
+ headers: { ...init.headers, Authorization: `Bearer ${token}` },
+ });
+ },
+ },
+ ],
+});
+```
+
+:::info
+
+Native preload hints (`` / ``) cannot carry headers, so they may fail on an authenticated origin; the actual loads still go through the `fetch` hook when the asset is used. Authenticated CSS is injected directly and does not emit the `createLink` hook, so plugin-added `nonce`/SRI attributes are not applied to it.
+
+:::
+
```tsx
diff --git a/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx b/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx
index f9a7d91a027..165463a8497 100644
--- a/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx
+++ b/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx
@@ -726,7 +726,8 @@ const changeScriptAttributePlugin: () => ModuleFederationRuntimePlugin =
`AsyncHook`
-The `fetch` function allows customizing the request that fetches the manifest JSON. A successful `Response` must yield a valid JSON.
+The `fetch` function allows customizing the request used to fetch the manifest JSON; a successfully loaded manifest `Response` must yield a valid JSON.
+You can also use the `fetch` function to apply custom `headers` (e.g. `Authorization`) to the remote entry and manifest-declared CSS resources of **`module` / `esm`** remotes.
```typescript
function fetch(
diff --git a/apps/website-new/docs/pt-BR/guide/runtime/runtime-api.mdx b/apps/website-new/docs/pt-BR/guide/runtime/runtime-api.mdx
index 44b146e5089..2bf37a59a15 100644
--- a/apps/website-new/docs/pt-BR/guide/runtime/runtime-api.mdx
+++ b/apps/website-new/docs/pt-BR/guide/runtime/runtime-api.mdx
@@ -305,6 +305,45 @@ Quando `force: true` é definir, o newly registered módulos vai overwrite já-r
:::
+### Carregando remotes com cabeçalhos personalizados via hook `fetch`
+
+Alguns remotes exigem cabeçalhos específicos para serem acessados. Nesse caso, você pode configurar os cabeçalhos através do [hook `fetch`](./runtime-hooks#fetch) do runtime. Você pode lidar com casos dinâmicos, como atualização de token ou lógica de autenticação por requisição, dentro desse hook.
+
+Para remotes do tipo **`module` / `esm`**, quando um hook `fetch` é registrado, o remote entry e o CSS declarado no manifest são carregados através de um carregador `fetch` + blob, de modo que os cabeçalhos que você adiciona alcancem todas as requisições de recursos. Sem um hook `fetch` registrado, os remotes carregam como antes, via `import()` / `` nativos (que não podem carregar cabeçalhos personalizados).
+
+```ts
+import { createInstance } from '@module-federation/enhanced/runtime';
+
+const mf = createInstance({
+ name: 'mf_host',
+ remotes: [
+ {
+ name: 'sub1',
+ entry: 'https://localhost:2001/mf-manifest.json',
+ type: 'module',
+ },
+ ],
+ plugins: [
+ {
+ name: 'auth-fetch',
+ // `remoteInfo` permite usar diferentes opções de requisição para remotes específicos
+ fetch(url, init, remoteInfo) {
+ return fetch(url, {
+ ...init,
+ headers: { ...init.headers, Authorization: `Bearer ${token}` },
+ });
+ },
+ },
+ ],
+});
+```
+
+:::info
+
+As dicas de preload nativas (`` / ``) não podem carregar cabeçalhos, portanto podem falhar em uma origem autenticada; os carregamentos reais ainda passam pelo hook `fetch` quando o recurso é utilizado. O CSS autenticado é injetado diretamente e não emite o hook `createLink`, então atributos `nonce`/SRI adicionados por plugins não são aplicados a ele.
+
+:::
+
```tsx
diff --git a/apps/website-new/docs/pt-BR/guide/runtime/runtime-hooks.mdx b/apps/website-new/docs/pt-BR/guide/runtime/runtime-hooks.mdx
index b1ecc4587c8..2ca741005a7 100644
--- a/apps/website-new/docs/pt-BR/guide/runtime/runtime-hooks.mdx
+++ b/apps/website-new/docs/pt-BR/guide/runtime/runtime-hooks.mdx
@@ -726,7 +726,8 @@ const changeScriptAttributePlugin: () => ModuleFederationRuntimePlugin =
`AsyncHook`
-O `fetch` função allows customizing o request esse fetches o manifest JSON. Uma successful `Response` deve yield uma valid JSON.
+O `fetch` função allows customizing o request esse fetches o manifest JSON; uma manifest `Response` carregada com sucesso deve retornar um JSON válido.
+Você também pode usar a função `fetch` para aplicar `headers` personalizados (por exemplo, `Authorization`) ao remote entry e aos recursos CSS declarados no manifest de remotes do tipo **`module` / `esm`**.
```typescript
function fetch(
diff --git a/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx b/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx
index 178ad9b4f4b..3be611634e8 100644
--- a/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx
+++ b/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx
@@ -305,6 +305,45 @@ interface RemoteWithVersion {
:::
+### 通过 fetch 钩子使用自定义请求头加载 remote
+
+某些 remote 需要特定的请求头才能访问,此时可以通过运行时的 [`fetch` 钩子](./runtime-hooks#fetch) 配置请求头。你可以在该钩子中处理诸如 token 刷新、按请求鉴权等动态场景。
+
+对于 **`module` / `esm`** 类型的 remote:当注册了 `fetch` 钩子时,其 remote entry 以及 manifest 中声明的 CSS 都会通过 `fetch` + blob 加载器加载,从而让你添加的请求头能够应用到每一个资源请求上。未注册 `fetch` 钩子时,remote 仍按原有方式通过原生 `import()` / `` 加载(这些方式无法携带自定义请求头)。
+
+```ts
+import { createInstance } from '@module-federation/enhanced/runtime';
+
+const mf = createInstance({
+ name: 'mf_host',
+ remotes: [
+ {
+ name: 'sub1',
+ entry: 'https://localhost:2001/mf-manifest.json',
+ type: 'module',
+ },
+ ],
+ plugins: [
+ {
+ name: 'auth-fetch',
+ // 可通过 `remoteInfo` 对特定 remote 使用不同的请求配置
+ fetch(url, init, remoteInfo) {
+ return fetch(url, {
+ ...init,
+ headers: { ...init.headers, Authorization: `Bearer ${token}` },
+ });
+ },
+ },
+ ],
+});
+```
+
+:::info
+
+原生的预加载提示(`` / ``)无法携带请求头,因此在需要鉴权的源上可能会失败;但最终使用时仍会通过 `fetch` 钩子加载。带鉴权的 CSS 是直接注入的,不会触发 `createLink` 钩子,因此插件添加的 `nonce`/SRI 属性不会作用于这类 CSS。
+
+:::
+
```tsx
diff --git a/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx b/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx
index 9bcf3b87d6d..03254642854 100644
--- a/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx
+++ b/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx
@@ -687,7 +687,8 @@ const changeScriptAttributePlugin: () => ModuleFederationRuntimePlugin =
`AsyncHook`
-`fetch` 函数允许自定义获取清单(manifest)JSON 的请求。成功的 `Response` 必须返回一个有效的 JSON。
+`fetch` 函数允许自定义获取清单(manifest)JSON 的请求,成功加载的清单 `Response` 必须返回一个有效的 JSON。
+你还可通过 `fetch` 函数将自定义的 `headers`(如 `Authorization`)应用到 **`module` / `esm`** 类型 remote 的 remote entry 及 manifest 中声明的 CSS 资源上。
```typescript
function fetch(
diff --git a/packages/runtime-core/__tests__/load-entry-fetch-hook.spec.ts b/packages/runtime-core/__tests__/load-entry-fetch-hook.spec.ts
new file mode 100644
index 00000000000..07bb2e634ab
--- /dev/null
+++ b/packages/runtime-core/__tests__/load-entry-fetch-hook.spec.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect, rs, beforeEach, afterEach } from '@rstest/core';
+import { loadModule } from '@module-federation/sdk';
+import { __loadEntryDomForTest } from '../src/utils/load';
+
+// Create a mocked fetch lifecycle loader hook
+function createLoaderHook(hasFetchListener: boolean) {
+ const listeners = new Set();
+ if (hasFetchListener) {
+ listeners.add(() => undefined);
+ }
+ return {
+ lifecycle: {
+ fetch: { emit: rs.fn(), listeners },
+ },
+ } as any;
+}
+
+// Create a mocked remote info
+function createRemoteInfo(name: string, entry: string) {
+ return {
+ name,
+ entry,
+ type: 'module',
+ entryGlobalName: name,
+ shareScope: 'default',
+ };
+}
+
+describe('loadEntryDom ESM with fetch lifecycle loader hook', () => {
+ let originalFetch: typeof globalThis.fetch;
+ let originalCreateObjectURL: typeof URL.createObjectURL;
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ rs.clearAllMocks();
+ loadModule.clearCache();
+ originalFetch = globalThis.fetch;
+ originalCreateObjectURL = URL.createObjectURL;
+ fetchMock = rs.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ text: () => Promise.resolve('export const ok = 1;'),
+ });
+ globalThis.fetch = fetchMock as any;
+ URL.createObjectURL = rs.fn(
+ () => 'data:text/javascript,export const ok = 1;',
+ );
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ URL.createObjectURL = originalCreateObjectURL;
+ });
+
+ it('uses the blob loader for module remotes when a fetch hook is registered', async () => {
+ const loaderHook = createLoaderHook(true);
+ const resourceContext: any = {
+ initiator: 'loadRemote',
+ id: 'a/say',
+ resourceType: 'remoteEntry',
+ };
+ const result = await __loadEntryDomForTest({
+ remoteInfo: createRemoteInfo('a', 'http://x/e.js'),
+ loaderHook,
+ resourceContext,
+ });
+ expect(fetchMock).toHaveBeenCalledWith('http://x/e.js', expect.anything());
+ // The loader's customFetch forwards remoteInfo and resourceContext as
+ // additional arguments so the plugin can add different headers per remote/resource.
+ expect(loaderHook.lifecycle.fetch.emit).toHaveBeenCalledWith(
+ 'http://x/e.js',
+ { headers: {} },
+ expect.objectContaining({ name: 'a' }),
+ resourceContext,
+ );
+ expect(result).toEqual(expect.objectContaining({ ok: 1 }));
+ });
+
+ it('wraps blob loader failures as RUNTIME_008 so loadEntryError recovery can fire', async () => {
+ fetchMock.mockResolvedValue({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ text: () => Promise.resolve(''),
+ });
+ const err = await __loadEntryDomForTest({
+ remoteInfo: createRemoteInfo('a', 'http://x/e.js'),
+ loaderHook: createLoaderHook(true),
+ }).then(
+ () => undefined,
+ (e: unknown) => e,
+ );
+ expect(err).toBeInstanceOf(Error);
+ // RUNTIME_008 = 'RUNTIME-008'; getRemoteEntry keys recovery off this code.
+ expect((err as Error).message).toContain('RUNTIME-008');
+ // The original failure is preserved for diagnostics.
+ expect((err as Error).message).toContain('401 Unauthorized');
+ });
+
+ it('does NOT use the blob loader for module remotes when no fetch hook is registered', async () => {
+ const loaderHook = createLoaderHook(false);
+ await __loadEntryDomForTest({
+ remoteInfo: createRemoteInfo('b', 'http://x/e2.js'),
+ loaderHook,
+ }).catch(() => undefined);
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(loaderHook.lifecycle.fetch.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/runtime-core/__tests__/preload-css-fetch-hook.spec.ts b/packages/runtime-core/__tests__/preload-css-fetch-hook.spec.ts
new file mode 100644
index 00000000000..ccfd749def5
--- /dev/null
+++ b/packages/runtime-core/__tests__/preload-css-fetch-hook.spec.ts
@@ -0,0 +1,118 @@
+import { describe, it, expect, rs, beforeEach, afterEach } from '@rstest/core';
+import { loadModule } from '@module-federation/sdk';
+import { preloadAssets } from '../src/utils/preload';
+
+// Create a mocked fetch lifecycle loader hook
+function createLoaderHook(hasFetchListener: boolean) {
+ const listeners = new Set();
+ if (hasFetchListener) {
+ listeners.add(() => undefined);
+ }
+ return {
+ options: { inBrowser: true },
+ loaderHook: {
+ lifecycle: {
+ fetch: { emit: rs.fn(), listeners },
+ createLink: { emit: rs.fn() },
+ createScript: { emit: rs.fn() },
+ },
+ },
+ } as any;
+}
+
+// Create a mocked remote info
+const createRemoteInfo = (name: string): any => ({
+ name,
+ entry: 'http://x/e.js',
+ type: 'module',
+ entryGlobalName: name,
+ shareScope: 'default',
+});
+
+describe('preloadAssets CSS with fetch lifecycle loader hook', () => {
+ let originalFetch: typeof globalThis.fetch;
+ let originalCreateObjectURL: typeof URL.createObjectURL;
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ rs.clearAllMocks();
+ loadModule.clearCache();
+ originalFetch = globalThis.fetch;
+ originalCreateObjectURL = URL.createObjectURL;
+ fetchMock = rs.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ text: () => Promise.resolve('.a{}'),
+ });
+ globalThis.fetch = fetchMock as any;
+ URL.createObjectURL = rs.fn(() => 'blob:css');
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ URL.createObjectURL = originalCreateObjectURL;
+ });
+
+ it('uses the blob loader for manifest CSS when a fetch hook is registered', async () => {
+ const host = createLoaderHook(true);
+ const assets: any = {
+ cssAssets: ['http://x/a.css'],
+ jsAssetsWithoutEntry: [],
+ entryAssets: [],
+ };
+ await preloadAssets(createRemoteInfo('a'), host, assets, false);
+ expect(fetchMock).toHaveBeenCalledWith('http://x/a.css', expect.anything());
+ // The loader's customFetch forwards remoteInfo and the resource context so
+ // the plugin can add different headers per remote/resource.
+ expect(host.loaderHook.lifecycle.fetch.emit).toHaveBeenCalledWith(
+ 'http://x/a.css',
+ { headers: {} },
+ expect.objectContaining({ name: 'a' }),
+ expect.anything(),
+ );
+ });
+
+ it('does NOT apply CSS through the blob loader during a preload hint (useLinkPreload)', async () => {
+ const host = createLoaderHook(true);
+ const assets: any = {
+ cssAssets: ['http://x/a.css'],
+ jsAssetsWithoutEntry: [],
+ entryAssets: [],
+ };
+ // useLinkPreload defaults to true (preloadRemote). The blob loader injects a
+ // rel=stylesheet that would apply the remote's CSS before it is loaded, so it
+ // must be skipped here rather than overriding host styles.
+ await preloadAssets(createRemoteInfo('a'), host, assets);
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(host.loaderHook.lifecycle.fetch.emit).not.toHaveBeenCalled();
+ });
+
+ it('does NOT use the blob loader for manifest CSS when no fetch hook is registered', async () => {
+ const host = createLoaderHook(false);
+ const assets: any = {
+ cssAssets: ['http://x/b.css'],
+ jsAssetsWithoutEntry: [],
+ entryAssets: [],
+ };
+ // We must fire the load event for the created by createLink function,
+ // this mimics the browser behavior and let the branch settle.
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((m) =>
+ m.addedNodes.forEach((node) => {
+ if (node instanceof HTMLLinkElement) {
+ node.dispatchEvent(new Event('load'));
+ }
+ }),
+ );
+ });
+ observer.observe(document.head, { childList: true });
+ try {
+ await preloadAssets(createRemoteInfo('b'), host, assets, false);
+ } finally {
+ observer.disconnect();
+ }
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(host.loaderHook.lifecycle.fetch.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts
index 9452f82736e..b12e6ed9bd0 100644
--- a/packages/runtime-core/src/utils/load.ts
+++ b/packages/runtime-core/src/utils/load.ts
@@ -3,6 +3,7 @@ import {
loadScriptNode,
composeKeyWithSeparator,
isBrowserEnvValue,
+ loadEsmEntryWithFetch,
} from '@module-federation/sdk';
import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from '../constant';
import { ModuleFederation } from '../core';
@@ -203,7 +204,36 @@ async function loadEntryDom({
switch (type) {
case 'esm':
case 'module':
- return loadEsmEntry({ entry, remoteEntryExports });
+ return loaderHook.lifecycle.fetch.listeners.size > 0
+ ? (loadEsmEntryWithFetch({
+ entry,
+ customFetch: async (url, init) =>
+ loaderHook.lifecycle.fetch.emit(
+ url,
+ init,
+ remoteInfo,
+ resourceContext,
+ ),
+ }).catch((loadError: unknown) => {
+ // Mirror loadEntryScript: surface blob-loader fetch/exec failures
+ // (e.g. BlobLoaderNetworkError on a 401) as RUNTIME_008 so
+ // getRemoteEntry's loadEntryError recovery — token refresh,
+ // failover — still fires for authenticated ESM remotes.
+ const originalMsg =
+ loadError instanceof Error
+ ? loadError.message
+ : String(loadError);
+ error(
+ RUNTIME_008,
+ runtimeDescMap,
+ {
+ remoteName: name,
+ resourceUrl: entry,
+ },
+ originalMsg,
+ );
+ }) as Promise)
+ : loadEsmEntry({ entry, remoteEntryExports });
case 'system':
return loadSystemJsEntry({ entry, remoteEntryExports });
default:
@@ -399,3 +429,5 @@ export function getRemoteInfo(remote: Remote): RemoteInfo {
shareScope: remote.shareScope || DEFAULT_SCOPE,
};
}
+
+export const __loadEntryDomForTest = loadEntryDom;
diff --git a/packages/runtime-core/src/utils/preload.ts b/packages/runtime-core/src/utils/preload.ts
index 9deb9810339..10f7962e64b 100644
--- a/packages/runtime-core/src/utils/preload.ts
+++ b/packages/runtime-core/src/utils/preload.ts
@@ -1,4 +1,9 @@
-import { createLink, createScript, safeToString } from '@module-federation/sdk';
+import {
+ createLink,
+ createScript,
+ loadCssWithFetch,
+ safeToString,
+} from '@module-federation/sdk';
import {
PreloadAssets,
PreloadAssetResult,
@@ -181,6 +186,36 @@ function waitForLinkPreload({
});
}
+// When a fetch hook is present it may modify request headers, so CSS must be
+// fetched through it and injected as a blob. A native cannot carry
+// headers, and a rel=preload hint would 401 on an authenticated origin.
+function waitForCssFetch({
+ host,
+ remoteInfo,
+ url,
+ context,
+}: {
+ host: ModuleFederation;
+ remoteInfo: RemoteInfo;
+ url: string;
+ context: ResourceLoadContext;
+}): Promise {
+ return loadCssWithFetch({
+ href: url,
+ customFetch: async (u, init) =>
+ host.loaderHook.lifecycle.fetch.emit(u, init, remoteInfo, context),
+ })
+ .then(() => createAssetResult(context, url, 'success'))
+ .catch((error) =>
+ createAssetResult(
+ context,
+ url,
+ isTimeoutError(error) ? 'timeout' : 'error',
+ error,
+ ),
+ );
+}
+
function waitForScriptPreload({
host,
remoteInfo,
@@ -272,40 +307,39 @@ export function preloadAssets(
);
});
- if (useLinkPreload) {
- const defaultAttrs = {
- rel: 'preload',
- as: 'style',
- };
- cssAssets.forEach((cssUrl) => {
- results.push(
- waitForLinkPreload({
- host,
- remoteInfo,
- url: cssUrl,
- attrs: defaultAttrs,
- context: createResourceContext(baseContext, 'css'),
- }),
- );
- });
- } else {
- const defaultAttrs = {
- rel: 'stylesheet',
- type: 'text/css',
- };
- cssAssets.forEach((cssUrl) => {
- results.push(
- waitForLinkPreload({
- host,
- remoteInfo,
- url: cssUrl,
- attrs: defaultAttrs,
- needDeleteLink: false,
- context: createResourceContext(baseContext, 'css'),
- }),
- );
- });
- }
+ // Push the preload/load task for a single CSS asset.
+ const pushCssAsset = (cssUrl: string) => {
+ const context = createResourceContext(baseContext, 'css');
+
+ // Authenticated CSS: when a fetch hook is present it can add headers per
+ // remoteInfo, so fetch through it and inject as a blob stylesheet.
+ // We should only do that on an actual load because
+ // 1. native rel=preload can't carry headers so we must skip it
+ // 2. applying the remote's CSS in preload might override host styles before remote module is loaded
+ if (host.loaderHook.lifecycle.fetch.listeners.size > 0) {
+ if (!useLinkPreload) {
+ results.push(
+ waitForCssFetch({ host, remoteInfo, url: cssUrl, context }),
+ );
+ }
+ return;
+ }
+
+ // Plain CSS via : a rel=preload hint, or an applied rel=stylesheet.
+ results.push(
+ waitForLinkPreload({
+ host,
+ remoteInfo,
+ url: cssUrl,
+ attrs: useLinkPreload
+ ? { rel: 'preload', as: 'style' }
+ : { rel: 'stylesheet', type: 'text/css' },
+ needDeleteLink: useLinkPreload ? undefined : false,
+ context,
+ }),
+ );
+ };
+ cssAssets.forEach(pushCssAsset);
if (useLinkPreload) {
const defaultAttrs = {
diff --git a/packages/sdk/__tests__/blobLoad.spec.ts b/packages/sdk/__tests__/blobLoad.spec.ts
new file mode 100644
index 00000000000..dbb9c141728
--- /dev/null
+++ b/packages/sdk/__tests__/blobLoad.spec.ts
@@ -0,0 +1,250 @@
+import { resolveSpec, rewriteModuleCode } from '../src/blobLoad';
+
+describe('resolveSpec', () => {
+ it('returns absolute urls untouched', () => {
+ expect(resolveSpec('https://x.com/a.js', 'https://b.com/')).toBe(
+ 'https://x.com/a.js',
+ );
+ expect(resolveSpec('blob:abc', 'https://b.com/')).toBe('blob:abc');
+ expect(resolveSpec('data:text/js,1', 'https://b.com/')).toBe(
+ 'data:text/js,1',
+ );
+ });
+
+ it('resolves relative and absolute-path specifiers against the base', () => {
+ expect(resolveSpec('./dep.js', 'https://b.com/app/entry.js')).toBe(
+ 'https://b.com/app/dep.js',
+ );
+ expect(resolveSpec('/root.js', 'https://b.com/app/entry.js')).toBe(
+ 'https://b.com/root.js',
+ );
+ });
+
+ it('throws on bare specifiers', () => {
+ expect(() => resolveSpec('react', 'https://b.com/')).toThrow(
+ 'bare specifier',
+ );
+ });
+});
+
+describe('rewriteModuleCode', () => {
+ const url = 'https://b.com/app/entry.js';
+
+ it('pins import.meta.url to the module url', () => {
+ const { code } = rewriteModuleCode('const u = import.meta.url;', url);
+ expect(code).toBe(`const u = ${JSON.stringify(url)};`);
+ });
+
+ it('routes dynamic import() through __mfDynImport with the base url', () => {
+ const { code } = rewriteModuleCode('const m = import("./x.js");', url);
+ expect(code).toBe(
+ `const m = __mfDynImport(${JSON.stringify(url)},"./x.js");`,
+ );
+ });
+
+ it('collects static relative/absolute deps and leaves bare imports alone', () => {
+ const src = `import a from "./a.js";\nexport { b } from "/b.js";\nimport c from "react";`;
+ const { deps } = rewriteModuleCode(src, url);
+ expect(deps.map((d) => d.spec).sort()).toEqual(['./a.js', '/b.js']);
+
+ const aDep = deps.find((d) => d.spec === './a.js')!;
+ expect(aDep.depUrl).toBe('https://b.com/app/a.js');
+ expect(aDep.original).toBe('from "./a.js"');
+ expect(aDep.quote).toBe('"');
+ });
+
+ it('collects side-effect imports', () => {
+ const { deps } = rewriteModuleCode('import "./side.js";', url);
+ expect(deps.map((d) => d.spec)).toEqual(['./side.js']);
+ });
+});
+
+import {
+ fetchText,
+ loadModule,
+ loadEsmEntryWithFetch,
+ loadCssWithFetch,
+} from '../src/blobLoad';
+
+describe('fetchText', () => {
+ beforeEach(() => {
+ (global as any).fetch = jest.fn();
+ });
+
+ it('merges fetchOptions headers and returns response text', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve('CODE'),
+ });
+ const text = await fetchText('https://b.com/e.js', {
+ fetchOptions: { headers: { Authorization: 'Bearer t' } },
+ });
+ expect(text).toBe('CODE');
+ const requestInit = (global.fetch as jest.Mock).mock.calls[0][1];
+ expect(requestInit.headers).toEqual({ Authorization: 'Bearer t' });
+ });
+
+ it('prefers customFetch when it returns a Response', async () => {
+ const customFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve('HOOKED'),
+ } as any);
+ const text = await fetchText('https://b.com/e.js', { customFetch });
+ expect(customFetch).toHaveBeenCalled();
+ expect(text).toBe('HOOKED');
+ expect(global.fetch as jest.Mock).not.toHaveBeenCalled();
+ });
+
+ it('throws a descriptive error on non-ok responses', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ text: () => Promise.resolve(''),
+ });
+ await expect(fetchText('https://b.com/e.js', {})).rejects.toThrow(
+ 'BlobLoaderNetworkError: 401',
+ );
+ });
+
+ it('normalizes a Headers instance into the request init', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve('CODE'),
+ });
+ await fetchText('https://b.com/e.js', {
+ fetchOptions: { headers: new Headers({ Authorization: 'Bearer t' }) },
+ });
+ const init = (global.fetch as jest.Mock).mock.calls[0][1];
+ expect(init.headers).toEqual({ authorization: 'Bearer t' });
+ });
+});
+
+describe('loadModule', () => {
+ beforeEach(() => {
+ (global as any).fetch = jest.fn();
+ (global.URL as any).createObjectURL = jest.fn(() => `blob:`);
+ loadModule.clearCache();
+ });
+
+ it('rewrites a static dep to a recursively-loaded blob url and caches by url', async () => {
+ const files: Record = {
+ 'https://b.com/entry.js': `import dep from "./dep.js";`,
+ 'https://b.com/dep.js': `export default 1;`,
+ };
+ (global.fetch as jest.Mock).mockImplementation((url: string) =>
+ Promise.resolve({ ok: true, text: () => Promise.resolve(files[url]) }),
+ );
+ const blobUrl = await loadModule('https://b.com/entry.js', {});
+ expect(blobUrl).toMatch(/^blob:/);
+ // recursively loads all dependencies
+ expect(
+ (global.fetch as jest.Mock).mock.calls.map((c) => c[0]).sort(),
+ ).toEqual(['https://b.com/dep.js', 'https://b.com/entry.js']);
+ // only fetches once and caches them, reload does not invoke more fetches
+ await loadModule('https://b.com/entry.js', {});
+ expect((global.fetch as jest.Mock).mock.calls.length).toBe(2);
+ });
+
+ it('evicts a failed load from the cache so a later call retries', async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ statusText: 'Unavailable',
+ text: () => Promise.resolve(''),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve('export default 1;'),
+ });
+ await expect(loadModule('https://b.com/x.js', {})).rejects.toThrow(
+ 'BlobLoaderNetworkError: 503',
+ );
+ const blobUrl = await loadModule('https://b.com/x.js', {});
+ expect(blobUrl).toMatch(/^blob:/);
+ expect((global.fetch as jest.Mock).mock.calls.length).toBe(2);
+ });
+});
+
+describe('loadCssWithFetch', () => {
+ beforeEach(() => {
+ (global as any).fetch = jest
+ .fn()
+ .mockResolvedValue({ ok: true, text: () => Promise.resolve('.a{}') });
+ (global.URL as any).createObjectURL = jest.fn(() => 'blob:css');
+ loadModule.clearCache();
+ document.head.innerHTML = '';
+ });
+
+ it('fetches css with headers and injects a single stylesheet, deduping repeat calls', async () => {
+ await loadCssWithFetch({
+ href: 'https://b.com/a.css',
+ fetchOptions: { headers: { Authorization: 'Bearer t' } },
+ });
+ await loadCssWithFetch({
+ href: 'https://b.com/a.css',
+ fetchOptions: { headers: { Authorization: 'Bearer t' } },
+ });
+ expect((global.fetch as jest.Mock).mock.calls.length).toBe(1);
+ const links = document.head.getElementsByTagName('link');
+ expect(links.length).toBe(1);
+ expect(links[0].rel).toBe('stylesheet');
+ expect(links[0].href).toContain('blob:css');
+ });
+
+ it('dedupes concurrent loads of the same href (single fetch, single link)', async () => {
+ let resolveFetch: (v: any) => void;
+ (global as any).fetch = jest.fn().mockImplementation(
+ () =>
+ new Promise((r) => {
+ resolveFetch = r;
+ }),
+ );
+ (global.URL as any).createObjectURL = jest.fn(() => 'blob:css');
+ loadModule.clearCache();
+ document.head.innerHTML = '';
+ const p1 = loadCssWithFetch({ href: 'https://b.com/c.css' });
+ const p2 = loadCssWithFetch({ href: 'https://b.com/c.css' });
+ resolveFetch!({ ok: true, text: () => Promise.resolve('.c{}') });
+ await Promise.all([p1, p2]);
+ expect((global.fetch as jest.Mock).mock.calls.length).toBe(1);
+ expect(document.head.getElementsByTagName('link').length).toBe(1);
+ });
+});
+
+describe('cross-instance dynamic-import context', () => {
+ beforeEach(() => {
+ (global as any).fetch = jest
+ .fn()
+ .mockResolvedValue({ ok: true, text: () => Promise.resolve('') });
+ (global.URL as any).createObjectURL = jest.fn(() => 'blob:m');
+ loadModule.clearCache();
+ });
+
+ it('stores load contexts on the shared __mfDynImport shim', async () => {
+ const ctx = { fetchOptions: { headers: { Authorization: 'Bearer t' } } };
+ await loadModule('https://b.com/entry.js', ctx);
+ // The registry hangs off the single global __mfDynImport function, so a second
+ // bundled copy of the SDK reading the same shim keeps the fetch context
+ // regardless of which copy's shim is installed.
+ const shim = (globalThis as Record)['__mfDynImport'];
+ expect(typeof shim).toBe('function');
+ expect(shim.blobLoaderContexts).toBeInstanceOf(Map);
+ expect(shim.blobLoaderContexts.get('https://b.com/entry.js')).toBe(ctx);
+ });
+
+ it('does not clobber a __mfDynImport shim already installed by another copy', async () => {
+ const existing = jest.fn();
+ (globalThis as Record)['__mfDynImport'] = existing;
+ // The shim installs synchronously before the (jsdom-unsupported) blob
+ // import, so swallow the import rejection; we only assert the shim survived.
+ await loadEsmEntryWithFetch({ entry: 'https://b.com/e.js' }).catch(
+ () => undefined,
+ );
+ expect((globalThis as Record)['__mfDynImport']).toBe(
+ existing,
+ );
+ delete (globalThis as Record)['__mfDynImport'];
+ });
+});
diff --git a/packages/sdk/src/blobLoad.ts b/packages/sdk/src/blobLoad.ts
new file mode 100644
index 00000000000..a91b29b6577
--- /dev/null
+++ b/packages/sdk/src/blobLoad.ts
@@ -0,0 +1,269 @@
+export interface BlobDep {
+ original: string;
+ spec: string;
+ quote: string;
+ depUrl: string;
+}
+
+// Resolve a specifier (relative, absolute-path, or already-absolute-url)
+// against a base url to a concrete fetchable url.
+// Shared dependencies should have already been resolved by the MF runtime
+// as relative loadShare asset urls, so anything bare is unexpected and throws.
+export function resolveSpec(spec: string, base: string): string {
+ if (/^(blob:|https?:|data:)/.test(spec)) return spec;
+ if (spec.startsWith('.') || spec.startsWith('/'))
+ return new URL(spec, base).href;
+ throw new Error(
+ `[loader] cannot resolve bare specifier "${spec}" (from ${base})`,
+ );
+}
+
+// Rewrite a module's source: pin import.meta.url, route dynamic imports through
+// __mfDynImport, and collect static relative/absolute specifiers (to be replaced with
+// recursively-loaded blob urls by the caller).
+export function rewriteModuleCode(
+ code: string,
+ url: string,
+): { code: string; deps: BlobDep[] } {
+ // import.meta.url -> the module's real url.
+ // In this case, modules with publicPath:"auto" can derive paths from it.
+ code = code.replace(/\bimport\.meta\.url\b/g, JSON.stringify(url));
+
+ // Dynamic import(...) -> __mfDynImport("", ...).
+ code = code.replace(
+ /(? recursively-loaded blob urls.
+ const specRe = /(? Promise | Response | void | false;
+
+export interface BlobLoaderContext {
+ fetchOptions?: RequestInit;
+ // Optional custom fetch (e.g. routed through the runtime loaderHook.fetch so
+ // existing fetch-hook plugins still compose). Falls back to native fetch.
+ customFetch?: BlobFetcher;
+}
+
+// The dynamic-import shim installed on globalThis as __mfDynImport. Its context
+// registry (absolute url -> fetcher + options) hangs off the function itself
+// rather than living in a second global, so two bundled copies/versions of the
+// SDK can coexist.
+type MFDynImportShim = ((base: string, spec: string) => Promise) & {
+ blobLoaderContexts?: Map;
+};
+
+// Resolve the blob loader context registry, lazily installing the global __mfDynImport
+// shim on first use (loadModule can run before the shim is formally installed).
+function createOrGetBlobLoaderContexts(): Map {
+ const g = globalThis as Record;
+ let shim = g['__mfDynImport'] as MFDynImportShim | undefined;
+ // If an existing shim from another SDK copy is installed, we reuse it instead of reinstalling.
+ if (typeof shim !== 'function') {
+ shim = createDynImportShim();
+ g['__mfDynImport'] = shim;
+ }
+ if (!shim.blobLoaderContexts) {
+ shim.blobLoaderContexts = new Map();
+ }
+ return shim.blobLoaderContexts;
+}
+// absolute url -> Promise for JS modules
+const jsCache = new Map>();
+// absolute url -> in-flight/settled promise, used to prevent double-inject the stylesheet.
+const cssCache = new Map>();
+
+function isResponseLike(res: unknown): res is Response {
+ return (
+ !!res &&
+ typeof res === 'object' &&
+ typeof (res as Response).text === 'function' &&
+ 'ok' in (res as Response)
+ );
+}
+
+// Create and normalize a const copy of headers, to avoid the case when the original headers are mutated by fetcher.
+export function toHeaderObject(
+ headers: RequestInit['headers'],
+): Record {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ return Object.fromEntries(headers.entries());
+ }
+ if (Array.isArray(headers)) {
+ return Object.fromEntries(headers);
+ }
+ return { ...(headers as Record) };
+}
+
+export async function fetchText(
+ url: string,
+ ctx: BlobLoaderContext,
+): Promise {
+ const init: RequestInit = {
+ ...(ctx.fetchOptions || {}),
+ headers: toHeaderObject(ctx.fetchOptions?.headers),
+ };
+ let res: Response | void | false = undefined;
+ if (ctx.customFetch) {
+ res = await ctx.customFetch(url, init);
+ }
+ if (!isResponseLike(res)) {
+ res = await fetch(url, init);
+ }
+ if (!res.ok) {
+ throw new Error(
+ `BlobLoaderNetworkError: ${res.status} ${res.statusText} for ${url}`,
+ );
+ }
+ return res.text();
+}
+
+// Fetch a module with headers, rewrite its imports.
+// Dynamic imports are handled lazily at runtime via __mfDynImport,
+// Static relative imports are pre-loaded recursively.
+function loadModuleImpl(url: string, ctx: BlobLoaderContext): Promise {
+ // Register the context for this url on every call so __mfDynImport uses the latest
+ // headers for that module's dynamic imports, even when the blob is cached.
+ createOrGetBlobLoaderContexts().set(url, ctx);
+ if (jsCache.has(url)) return jsCache.get(url)!;
+
+ const promise = (async () => {
+ const raw = await fetchText(url, ctx);
+ // Stage 1 rewrite: replace import.meta.url with url, and dynamic import(), collect static deps.
+ const { code: rewritten, deps } = rewriteModuleCode(raw, url);
+ let code = rewritten;
+ const blobUrls = await Promise.all(
+ deps.map((d) => loadModule(d.depUrl, ctx)),
+ );
+ // Stage 2 rewrite: replace static deps with blob urls.
+ deps.forEach((d, i) => {
+ const replaced = d.original.replace(
+ `${d.quote}${d.spec}${d.quote}`,
+ `${d.quote}${blobUrls[i]}${d.quote}`,
+ );
+ code = code.split(d.original).join(replaced);
+ });
+ return URL.createObjectURL(
+ new Blob([code], { type: 'application/javascript' }),
+ );
+ })();
+
+ jsCache.set(url, promise);
+ // Don't permanently cache a failed load — allow a later retry (e.g. via the
+ // runtime loadEntryError hook) to re-fetch instead of replaying the rejection.
+ promise.catch(() => {
+ if (jsCache.get(url) === promise) jsCache.delete(url);
+ });
+ return promise;
+}
+
+// Exported with a clearCache test seam.
+export const loadModule: ((
+ url: string,
+ ctx: BlobLoaderContext,
+) => Promise) & {
+ clearCache: () => void;
+} = Object.assign(loadModuleImpl, {
+ clearCache: () => {
+ createOrGetBlobLoaderContexts().clear();
+ jsCache.clear();
+ cssCache.clear();
+ },
+});
+
+// Runtime dynamic-import shim: resolve url + fetch from cache or with headers +
+// import as blob. Reads its context off the shared registry (createOrGetBlobLoaderContexts) so it
+// works for blob modules created by any bundled copy of the SDK.
+function createDynImportShim(): MFDynImportShim {
+ return (async (base: string, spec: string) => {
+ const resolved = resolveSpec(spec, base);
+ if (/^(blob:|data:)/.test(resolved)) {
+ return import(/* webpackIgnore: true */ /* @vite-ignore */ resolved);
+ }
+ const ctx = createOrGetBlobLoaderContexts().get(base) || {};
+ return import(
+ /* webpackIgnore: true */ /* @vite-ignore */ await loadModule(
+ resolved,
+ ctx,
+ )
+ );
+ }) as MFDynImportShim;
+}
+
+// The vite preload helper creates native preloads (no auth header) as a
+// perf hint; those 401 errors should be ignored instead of throwing — real
+// loads go via __mfDynImport. A stable handler ref lets addEventListener dedupe repeat
+// installs (identical callback + capture is a no-op per the DOM spec).
+const ignoreVitePreloadError = (e: Event) => e.preventDefault();
+
+function installMFDynImportShim(): void {
+ // Ensure the single global __mfDynImport shim (and its shared contexts map) exists.
+ // createOrGetBlobLoaderContexts() is idempotent: it installs the shim on first use and never
+ // clobbers one already installed by another bundled copy of the SDK.
+ createOrGetBlobLoaderContexts();
+
+ if (typeof window !== 'undefined') {
+ window.addEventListener('vite:preloadError', ignoreVitePreloadError);
+ }
+}
+
+export async function loadEsmEntryWithFetch({
+ entry,
+ fetchOptions,
+ customFetch,
+}: {
+ entry: string;
+ fetchOptions?: RequestInit;
+ customFetch?: BlobFetcher;
+}): Promise> {
+ installMFDynImportShim();
+ const blobUrl = await loadModule(entry, { fetchOptions, customFetch });
+ return import(/* webpackIgnore: true */ /* @vite-ignore */ blobUrl);
+}
+
+export function loadCssWithFetch({
+ href,
+ fetchOptions,
+ customFetch,
+}: {
+ href: string;
+ fetchOptions?: RequestInit;
+ customFetch?: BlobFetcher;
+}): Promise {
+ const cached = cssCache.get(href);
+ if (cached) return cached;
+ const promise = (async () => {
+ const text = await fetchText(href, { fetchOptions, customFetch });
+ const blob = URL.createObjectURL(new Blob([text], { type: 'text/css' }));
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = blob;
+ document.head.appendChild(link);
+ })();
+ cssCache.set(href, promise);
+ // Don't permanently cache a failed css load — allow a later retry.
+ promise.catch(() => {
+ if (cssCache.get(href) === promise) cssCache.delete(href);
+ });
+ return promise;
+}
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 91408c4a527..0e433f78301 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -18,6 +18,7 @@ export {
export type { Logger, InfrastructureLogger } from './logger';
export * from './env';
export * from './dom';
+export * from './blobLoad';
export * from './node';
export * from './normalizeOptions';
export { createModuleFederationConfig } from './createModuleFederationConfig';
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 579c154e785..8ac7ca390a8 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -12,7 +12,7 @@
"target": "es2021",
"allowJs": true,
"module": "esnext",
- "lib": ["es2021", "dom"],
+ "lib": ["es2021", "dom", "dom.iterable"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",