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": ".",