Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a795830
docs(spec): add fetchOptions ESM remote loading design
Jun 16, 2026
8cafac0
docs(plan): add fetchOptions ESM remote loading implementation plan
Jun 16, 2026
30b1d33
feat(sdk): add blob-loader import rewriter and resolveSpec
Jun 16, 2026
b65089d
test(sdk): cover BlobDep splice fields and side-effect imports
Jun 16, 2026
fa2280a
feat(sdk): add fetch+blob ESM loader (loadEsmEntryWithFetch, loadCssW…
Jun 16, 2026
1fa0628
fix(sdk): normalize headers, evict failed loads, refresh context, ded…
Jun 16, 2026
e5ad6c8
feat(runtime-core): thread per-remote fetchOptions through registerRe…
Jun 16, 2026
deff5e5
feat(runtime-core): route ESM remotes with fetchOptions to blob loader
Jun 16, 2026
2407d30
feat(runtime-core): authenticate manifest fetch with remote fetchOptions
Jun 16, 2026
00736af
feat(runtime-core): fetch manifest CSS with headers for fetchOptions …
Jun 16, 2026
0807940
fix(sdk): dedupe in-flight css loads; harden preload css test
Jun 16, 2026
cf8c1e7
chore: changeset + document deferred limitations for fetchOptions ESM…
Jun 16, 2026
fb4d1b0
refactor(sdk): rename js module cache to jsCache for clarity
Jun 17, 2026
558bcc2
refactor(sdk): simplify fetchText init construction to single spread
Jun 17, 2026
abd683f
chore(ts): add dom.iterable to base lib; drop Headers cast in blobLoad
Jun 17, 2026
6dd88ae
refactor(sdk): clean up blobLoad (rename shim, clarify comments)
Jun 17, 2026
341b2d1
feat(runtime-core): merge call-level and per-remote fetchOptions
Jun 17, 2026
75bdbeb
docs(runtime-core): tidy comments in preload css fetch path
Jun 17, 2026
e368600
fix(runtime-core): forward fetchOptions to remoteEntry preload
Jun 17, 2026
a888df7
docs(runtime): document registerRemotes fetchOptions; remove scratch …
Jun 17, 2026
028079a
fix(runtime,sdk): address PR review on remote fetchOptions
Jun 17, 2026
9f9720d
refactor(sdk): hang blob-load context registry off __mfDyn
Jun 17, 2026
dc80e48
refactor(sdk): drop dynImportInstalled flag from blob loader
Jun 17, 2026
1adcded
refactor(sdk): rename blob loader symbols for clarity
Jun 17, 2026
9195060
refactor(runtime-core): collapse preload CSS branches into one helper
Jun 17, 2026
a2fbd96
refactor(runtime-core): drive remote header auth via fetch hook, not …
Jun 23, 2026
d9f7f36
test(runtime-core): drop manifest-fetch-hook spec covering pre-existi…
Jun 23, 2026
4d5369e
test(runtime-core): tidy fetch-hook spec naming and helpers
Jun 23, 2026
03abb6c
docs(runtime): simplify fetch-hook header docs in en and zh
Jun 23, 2026
f9438b0
docs(changeset): clarify and reorganize remote-fetch-options entry
Jun 23, 2026
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
10 changes: 10 additions & 0 deletions .changeset/remote-fetch-options.md
Original file line number Diff line number Diff line change
@@ -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()` / `<link>`.
- 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.
39 changes: 39 additions & 0 deletions apps/website-new/docs/en/guide/runtime/runtime-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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()` / `<link>` (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 (`<link rel="modulepreload">` / `<link rel="preload">`) 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.

:::

<Tabs>
<Tab label="Build Plugin (Use build plugin)">
```tsx
Expand Down
3 changes: 2 additions & 1 deletion apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,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(
Expand Down
39 changes: 39 additions & 0 deletions apps/website-new/docs/zh/guide/runtime/runtime-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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()` / `<link>` 加载(这些方式无法携带自定义请求头)。

```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

原生的预加载提示(`<link rel="modulepreload">` / `<link rel="preload">`)无法携带请求头,因此在需要鉴权的源上可能会失败;但最终使用时仍会通过 `fetch` 钩子加载。带鉴权的 CSS 是直接注入的,不会触发 `createLink` 钩子,因此插件添加的 `nonce`/SRI 属性不会作用于这类 CSS。

:::

<Tabs>
<Tab label="Build Plugin(使用构建插件)">
```tsx
Expand Down
3 changes: 2 additions & 1 deletion apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,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(
Expand Down
98 changes: 98 additions & 0 deletions packages/runtime-core/__tests__/load-entry-fetch-hook.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as sdk 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<any>();
if (hasFetchListener) {
listeners.add(() => undefined);
}
return {
lifecycle: {
fetch: { emit: vi.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 loadEsmEntryWithFetch: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
loadEsmEntryWithFetch = vi
.spyOn(sdk, 'loadEsmEntryWithFetch')
.mockResolvedValue({ ok: 1 });
});

afterEach(() => {
loadEsmEntryWithFetch.mockRestore();
});

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',
};
await __loadEntryDomForTest({
remoteInfo: createRemoteInfo('a', 'http://x/e.js'),
loaderHook,
resourceContext,
});
expect(loadEsmEntryWithFetch).toHaveBeenCalledWith(
expect.objectContaining({
entry: 'http://x/e.js',
customFetch: expect.any(Function),
}),
);
// The loader's customFetch accepts remoteInfo and resourceContext as
// additional arguments so the plugin can add different headers per remote/resource.
const { customFetch } = loadEsmEntryWithFetch.mock.calls[0][0] as any;
await customFetch('http://x/e.js', { headers: {} });
expect(loaderHook.lifecycle.fetch.emit).toHaveBeenCalledWith(
'http://x/e.js',
{ headers: {} },
expect.objectContaining({ name: 'a' }),
resourceContext,
);
});

it('wraps blob loader failures as RUNTIME_008 so loadEntryError recovery can fire', async () => {
loadEsmEntryWithFetch.mockRejectedValueOnce(
new Error('BlobLoaderNetworkError: 401 Unauthorized for http://x/e.js'),
);
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 () => {
await __loadEntryDomForTest({
remoteInfo: createRemoteInfo('b', 'http://x/e2.js'),
loaderHook: createLoaderHook(false),
}).catch(() => undefined);
expect(loadEsmEntryWithFetch).not.toHaveBeenCalled();
});
});
102 changes: 102 additions & 0 deletions packages/runtime-core/__tests__/preload-css-fetch-hook.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as sdk 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<any>();
if (hasFetchListener) {
listeners.add(() => undefined);
}
return {
options: { inBrowser: true },
loaderHook: {
lifecycle: {
fetch: { emit: vi.fn(), listeners },
createLink: { emit: vi.fn() },
createScript: { emit: vi.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 loadCssWithFetch: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
loadCssWithFetch = vi
.spyOn(sdk, 'loadCssWithFetch')
.mockResolvedValue(undefined as any);
});

afterEach(() => {
loadCssWithFetch.mockRestore();
});

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(loadCssWithFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: 'http://x/a.css',
customFetch: expect.any(Function),
}),
);
});

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(loadCssWithFetch).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 <link> 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(loadCssWithFetch).not.toHaveBeenCalled();
});
});
34 changes: 33 additions & 1 deletion packages/runtime-core/src/utils/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<RemoteEntryExports>)
: loadEsmEntry({ entry, remoteEntryExports });
case 'system':
return loadSystemJsEntry({ entry, remoteEntryExports });
default:
Expand Down Expand Up @@ -399,3 +429,5 @@ export function getRemoteInfo(remote: Remote): RemoteInfo {
shareScope: remote.shareScope || DEFAULT_SCOPE,
};
}

export const __loadEntryDomForTest = loadEntryDom;
Loading