From 13639e36811c7d9e467a82a70ff5729f6581c8a4 Mon Sep 17 00:00:00 2001 From: "dm.choi" Date: Thu, 6 Aug 2026 23:48:00 +0900 Subject: [PATCH 1/2] fix(runtime): enable SSR remote-entry retry --- .changeset/ssr-node-entry-retry.md | 6 + .../__tests__/mock/mock-script.ts | 4 +- .../runtime-core/__tests__/node-load.spec.ts | 162 ++++++++++++++++++ packages/runtime-core/src/utils/load.ts | 74 ++++++-- .../sdk/__tests__/node-builtin-esm.spec.ts | 43 +++++ packages/sdk/src/node.ts | 94 +++++++--- 6 files changed, 347 insertions(+), 36 deletions(-) create mode 100644 .changeset/ssr-node-entry-retry.md create mode 100644 packages/runtime-core/__tests__/node-load.spec.ts diff --git a/.changeset/ssr-node-entry-retry.md b/.changeset/ssr-node-entry-retry.md new file mode 100644 index 00000000000..7659674975c --- /dev/null +++ b/.changeset/ssr-node-entry-retry.md @@ -0,0 +1,6 @@ +--- +'@module-federation/runtime-core': patch +'@module-federation/sdk': patch +--- + +Enable retry-plugin recovery for Node.js remote entry transport failures while keeping remote entry execution errors non-retryable. diff --git a/packages/runtime-core/__tests__/mock/mock-script.ts b/packages/runtime-core/__tests__/mock/mock-script.ts index 809b7f3d5e1..9f790ec473f 100644 --- a/packages/runtime-core/__tests__/mock/mock-script.ts +++ b/packages/runtime-core/__tests__/mock/mock-script.ts @@ -105,7 +105,9 @@ const rewrite = (methods: Array, builder: typeof injector) => { } }; -rewrite(mountElementMethods, injector); +if (typeof window !== 'undefined') { + rewrite(mountElementMethods, injector); +} /** * vite 无法让 jsdom 和当前环境处于同一个执行环境 diff --git a/packages/runtime-core/__tests__/node-load.spec.ts b/packages/runtime-core/__tests__/node-load.spec.ts new file mode 100644 index 00000000000..11979f4c29a --- /dev/null +++ b/packages/runtime-core/__tests__/node-load.spec.ts @@ -0,0 +1,162 @@ +/* + * @rstest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; +import { RUNTIME_008 } from '@module-federation/error-codes'; +import { ModuleFederation } from '../src/core'; +import { resetFederationGlobalInfo } from '../src/global'; +import { getRemoteEntry, getRemoteInfo } from '../src/utils/load'; + +const ENTRY = 'https://origin.example/remoteEntry.js'; +const FALLBACK_ENTRY = 'https://backup.example/remoteEntry.js?retryCount=1'; +const REMOTE_ENTRY_SOURCE = ` + module.exports = { + get() {}, + init() {}, + }; +`; + +const createResponse = (body: string) => ({ + text: async () => body, +}); + +describe('getRemoteEntry - Node.js entry loading', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + resetFederationGlobalInfo(); + delete (globalThis as any).remote; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + resetFederationGlobalInfo(); + delete (globalThis as any).remote; + }); + + it('recovers a transport failure through loadEntryError and uses the rewritten entry URL', async () => { + const fetchMock = rs.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === ENTRY) { + throw new TypeError('fetch failed'); + } + if (url === FALLBACK_ENTRY) { + return createResponse(REMOTE_ENTRY_SOURCE); + } + throw new Error(`Unexpected URL: ${url}`); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const origin = new ModuleFederation({ name: 'test-host', remotes: [] }); + const remoteInfo = getRemoteInfo({ name: 'remote', entry: ENTRY }); + const loadEntryError = rs.fn( + async ({ getRemoteEntry, globalLoading, uniqueKey }: any) => { + delete globalLoading[uniqueKey]; + return getRemoteEntry({ + origin, + remoteInfo, + getEntryUrl: () => FALLBACK_ENTRY, + }); + }, + ); + + origin.registerPlugins([ + { + name: 'node-entry-retry-test', + loadEntryError, + }, + ]); + + const result = await getRemoteEntry({ origin, remoteInfo }); + + expect(result).toEqual( + expect.objectContaining({ + get: expect.any(Function), + init: expect.any(Function), + }), + ); + expect(loadEntryError).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + ENTRY, + FALLBACK_ENTRY, + ]); + }); + + it('normalizes an unrecovered Node transport failure as RUNTIME_008', async () => { + globalThis.fetch = rs + .fn() + .mockRejectedValue( + new TypeError('fetch failed'), + ) as unknown as typeof fetch; + + const origin = new ModuleFederation({ name: 'test-host', remotes: [] }); + const remoteInfo = getRemoteInfo({ name: 'remote', entry: ENTRY }); + + const error = await getRemoteEntry({ origin, remoteInfo }).catch( + (reason) => reason, + ); + + expect(error.message).toContain(RUNTIME_008); + expect(error.message).toContain('fetch failed'); + }); + + it('does not retry a Node remote entry execution failure', async () => { + globalThis.fetch = rs + .fn() + .mockResolvedValue( + createResponse(`throw new TypeError('execution failed');`), + ) as unknown as typeof fetch; + + const origin = new ModuleFederation({ name: 'test-host', remotes: [] }); + const remoteInfo = getRemoteInfo({ name: 'remote', entry: ENTRY }); + const loadEntryError = rs.fn(); + + origin.registerPlugins([ + { + name: 'node-entry-execution-error-test', + loadEntryError, + }, + ]); + + const error = await getRemoteEntry({ origin, remoteInfo }).catch( + (reason) => reason, + ); + + expect(error.message).toContain('execution failed'); + expect(error.message).toContain('ScriptExecutionError'); + expect(error.message).toContain(RUNTIME_008); + expect(loadEntryError).not.toHaveBeenCalled(); + }); + + it('does not classify createScript hook failures as network errors', async () => { + globalThis.fetch = rs + .fn() + .mockRejectedValue( + new TypeError('fetch should not be called'), + ) as unknown as typeof fetch; + + const origin = new ModuleFederation({ name: 'test-host', remotes: [] }); + const remoteInfo = getRemoteInfo({ name: 'remote', entry: ENTRY }); + const loadEntryError = rs.fn(); + const hookError = new Error('createScript hook failed'); + + origin.registerPlugins([ + { + name: 'node-entry-hook-error-test', + createScript() { + throw hookError; + }, + loadEntryError, + }, + ]); + + const error = await getRemoteEntry({ origin, remoteInfo }).catch( + (reason) => reason, + ); + + expect(error).toBe(hookError); + expect(error.message).not.toContain(RUNTIME_008); + expect(loadEntryError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index a624f9a109f..52c708b4ca2 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -40,6 +40,22 @@ function isEsmRemoteEntryLoadError(err: unknown): boolean { ); } +function isScriptExecutionError(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === 'ScriptExecutionError' || + err.message.includes('ScriptExecutionError')) + ); +} + +function isScriptNetworkError(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === 'ScriptNetworkError' || + err.message.includes('ScriptNetworkError')) + ); +} + export function isEsmRemoteType(type: RemoteInfo['type']): boolean { return type === 'esm' || type === 'module'; } @@ -270,10 +286,12 @@ async function loadEntryDom({ async function loadEntryNode({ remoteInfo, loaderHook, + getEntryUrl, resourceContext, }: { remoteInfo: RemoteInfo; loaderHook: ModuleFederation['loaderHook']; + getEntryUrl?: (url: string) => string; resourceContext?: ResourceLoadContext; }) { const { entry, entryGlobalName: globalName, name, type } = remoteInfo; @@ -286,18 +304,22 @@ async function loadEntryNode({ return remoteEntryExports; } - return loadScriptNode(entry, { + const url = getEntryUrl ? getEntryUrl(entry) : entry; + return loadScriptNode(url, { attrs: { name, globalName, type }, loaderHook: { - createScriptHook: (url: string, attrs: Record = {}) => { + createScriptHook: ( + scriptUrl: string, + attrs: Record = {}, + ) => { const res = loaderHook.lifecycle.createScript.emit({ - url, + url: scriptUrl, attrs, remoteInfo, resourceContext: resourceContext ? { ...resourceContext, - url, + url: scriptUrl, } : undefined, }); @@ -311,16 +333,34 @@ async function loadEntryNode({ return; }, }, - }) - .then(() => { + }).then( + () => { return handleRemoteEntryLoaded(name, globalName, entry); - }) - .catch((e) => { - const msg = e instanceof Error ? e.message : String(e); + }, + (loadError: unknown) => { + // Only errors classified by the Node loader as script failures should + // enter the runtime's retryable RUNTIME_008 path. Hook/configuration + // errors must retain their original error and stay non-retryable. + if ( + !isScriptNetworkError(loadError) && + !isScriptExecutionError(loadError) + ) { + throw loadError; + } + + const originalMsg = + loadError instanceof Error ? loadError.message : String(loadError); error( - `Failed to load Node.js entry for remote "${name}" from "${entry}". ${msg}`, + RUNTIME_008, + runtimeDescMap, + { + remoteName: name, + resourceUrl: url, + }, + originalMsg, ); - }); + }, + ); } export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string { @@ -378,7 +418,12 @@ export async function getRemoteEntry(params: { getEntryUrl, resourceContext, }) - : loadEntryNode({ remoteInfo, loaderHook, resourceContext }); + : loadEntryNode({ + remoteInfo, + loaderHook, + getEntryUrl, + resourceContext, + }); }) .then(async (res) => { await origin.loaderHook.lifecycle.afterLoadEntry.emit({ @@ -392,12 +437,11 @@ export async function getRemoteEntry(params: { const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); // ScriptExecutionError means the script downloaded fine but its IIFE // threw at runtime — retrying would reproduce the same error, so exclude it. - const isScriptExecutionError = - err instanceof Error && err.message.includes('ScriptExecutionError'); + const scriptExecutionError = isScriptExecutionError(err); const isScriptLoadError = err instanceof Error && err.message.includes(RUNTIME_008) && - !isScriptExecutionError; + !scriptExecutionError; if (isScriptLoadError && !_inErrorHandling) { const wrappedGetRemoteEntry = ( diff --git a/packages/sdk/__tests__/node-builtin-esm.spec.ts b/packages/sdk/__tests__/node-builtin-esm.spec.ts index 5f541f70e7c..00316e7d1ce 100644 --- a/packages/sdk/__tests__/node-builtin-esm.spec.ts +++ b/packages/sdk/__tests__/node-builtin-esm.spec.ts @@ -50,6 +50,27 @@ const loadNodeEsmScript = async ( }); }; +const loadNodeScript = async ( + url = DEFAULT_REMOTE_ENTRY_URL, +): Promise => { + const { createScriptNode } = await import('../src/node'); + + return new Promise((resolve, reject) => { + createScriptNode( + url, + (error, scriptContext) => { + if (error) { + reject(error); + return; + } + + resolve(scriptContext as T); + }, + {}, + ); + }); +}; + describe('Node ESM builtin loading', () => { const originalFetch = globalThis.fetch; @@ -206,4 +227,26 @@ describe('Node ESM builtin loading', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith(remoteEntryUrl); }); + + it('marks Node script fetch failures as ScriptNetworkError', async () => { + const fetchMock = jest + .fn() + .mockRejectedValue(new TypeError('fetch failed')); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(loadNodeScript()).rejects.toMatchObject({ + name: 'ScriptNetworkError', + }); + }); + + it('marks Node script execution failures as ScriptExecutionError', async () => { + setRemoteEntryFetchMock( + DEFAULT_REMOTE_ENTRY_URL, + `throw new TypeError('execution failed');`, + ); + + await expect(loadNodeScript()).rejects.toMatchObject({ + name: 'ScriptExecutionError', + }); + }); }); diff --git a/packages/sdk/src/node.ts b/packages/sdk/src/node.ts index 2b059c88d8e..8c9dc2b4108 100644 --- a/packages/sdk/src/node.ts +++ b/packages/sdk/src/node.ts @@ -30,6 +30,41 @@ function importNodeModule(name: string): Promise { return promise; } +type NodeScriptErrorType = 'ScriptNetworkError' | 'ScriptExecutionError'; + +const isNodeScriptError = ( + error: unknown, + type: NodeScriptErrorType, +): error is Error => + error instanceof Error && + (error.name === type || error.message.startsWith(`${type}:`)); + +const createNodeScriptError = ( + type: NodeScriptErrorType, + error: unknown, + context?: string, +): Error => { + if (isNodeScriptError(error, type) && !context) { + return error; + } + + const message = error instanceof Error ? error.message : String(error); + const fullMessage = `${type}: ${context ? `${context} - ` : ''}${message}`; + const scriptError = new Error(fullMessage); + scriptError.name = type; + return scriptError; +}; + +const createScriptNetworkError = (url: string, error: unknown): Error => + createNodeScriptError( + 'ScriptNetworkError', + error, + `Failed to load Node.js script "${url}"`, + ); + +const createScriptExecutionError = (error: unknown): Error => + createNodeScriptError('ScriptExecutionError', error); + const lazyLoaderHookFetch = async ( input: RequestInfo | URL, init?: RequestInit, @@ -74,7 +109,7 @@ export const createScriptNode = urlObj = new URL(url); } catch (e) { console.error('Error constructing URL:', e); - cb(new Error(`Invalid URL: ${e}`)); + cb(createScriptNetworkError(url, e)); return; } @@ -88,9 +123,16 @@ export const createScriptNode = }; const handleScriptFetch = async (f: typeof fetch, urlObj: URL) => { + let data: string; try { const res = await f(urlObj.href); - const data = await res.text(); + data = await res.text(); + } catch (error) { + cb(createScriptNetworkError(urlObj.href, error)); + return; + } + + try { const [path, vm] = await Promise.all([ importNodeModule('path'), importNodeModule('vm'), @@ -153,38 +195,39 @@ export const createScriptNode = undefined, exportedInterface as keyof typeof scriptContext.module.exports, ); - } catch (e) { - cb( - e instanceof Error - ? e - : new Error(`Script execution error: ${e}`), - ); + } catch (error) { + cb(createScriptExecutionError(error)); } }; getFetch() .then(async (f) => { if (attrs?.['type'] === 'esm' || attrs?.['type'] === 'module') { - return loadModule(urlObj.href, { - fetch: f, - vm: await importNodeModule('vm'), - }) + let vm: typeof import('vm'); + try { + vm = await importNodeModule('vm'); + } catch (error) { + cb(createScriptExecutionError(error)); + return; + } + + return loadModule(urlObj.href, { fetch: f, vm }) .then(async (module) => { await module.evaluate(); cb(undefined, module.namespace); }) - .catch((e) => { + .catch((error) => { cb( - e instanceof Error - ? e - : new Error(`Script execution error: ${e}`), + isNodeScriptError(error, 'ScriptNetworkError') + ? error + : createScriptExecutionError(error), ); }); } handleScriptFetch(f, urlObj); }) - .catch((err) => { - cb(err); + .catch((error) => { + cb(createScriptNetworkError(urlObj.href, error)); }); } : ( @@ -418,8 +461,19 @@ async function loadModule(url: string, options: LoadModuleOptions) { ); } - const response = await fetch(url); - const code = await response.text(); + let response: Response; + try { + response = await fetch(url); + } catch (error) { + throw createScriptNetworkError(url, error); + } + + let code: string; + try { + code = await response.text(); + } catch (error) { + throw createScriptNetworkError(url, error); + } const nodeUrl = await importNodeModule('node:url'); const cwdFileUrl = nodeUrl.pathToFileURL(process.cwd()).href; From 3e9d2f62633794e77a64cbf9a19b29c3ae1306f0 Mon Sep 17 00:00:00 2001 From: "dm.choi" Date: Fri, 7 Aug 2026 00:19:50 +0900 Subject: [PATCH 2/2] fix(sdk): classify HTTP entry failures as network errors --- .changeset/ssr-node-entry-retry.md | 2 +- .../runtime-core/__tests__/node-load.spec.ts | 61 ++++++++++++++++++- .../sdk/__tests__/node-builtin-esm.spec.ts | 43 ++++++++++++- packages/sdk/src/node.ts | 16 +++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/.changeset/ssr-node-entry-retry.md b/.changeset/ssr-node-entry-retry.md index 7659674975c..a9666a6b960 100644 --- a/.changeset/ssr-node-entry-retry.md +++ b/.changeset/ssr-node-entry-retry.md @@ -3,4 +3,4 @@ '@module-federation/sdk': patch --- -Enable retry-plugin recovery for Node.js remote entry transport failures while keeping remote entry execution errors non-retryable. +Enable retry-plugin recovery for Node.js remote entry transport and non-success HTTP response failures while keeping remote entry execution errors non-retryable. diff --git a/packages/runtime-core/__tests__/node-load.spec.ts b/packages/runtime-core/__tests__/node-load.spec.ts index 11979f4c29a..52f839c05af 100644 --- a/packages/runtime-core/__tests__/node-load.spec.ts +++ b/packages/runtime-core/__tests__/node-load.spec.ts @@ -17,7 +17,14 @@ const REMOTE_ENTRY_SOURCE = ` }; `; -const createResponse = (body: string) => ({ +const createResponse = ( + body: string, + init: { ok?: boolean; status?: number; statusText?: string } = {}, +) => ({ + ok: true, + status: 200, + statusText: 'OK', + ...init, text: async () => body, }); @@ -83,6 +90,58 @@ describe('getRemoteEntry - Node.js entry loading', () => { ]); }); + it('recovers an HTTP entry failure through loadEntryError and uses the rewritten entry URL', async () => { + const fetchMock = rs.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === ENTRY) { + return createResponse('Service Unavailable', { + ok: false, + status: 503, + statusText: 'Service Unavailable', + }); + } + if (url === FALLBACK_ENTRY) { + return createResponse(REMOTE_ENTRY_SOURCE); + } + throw new Error(`Unexpected URL: ${url}`); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const origin = new ModuleFederation({ name: 'test-host', remotes: [] }); + const remoteInfo = getRemoteInfo({ name: 'remote', entry: ENTRY }); + const loadEntryError = rs.fn( + async ({ getRemoteEntry, globalLoading, uniqueKey }: any) => { + delete globalLoading[uniqueKey]; + return getRemoteEntry({ + origin, + remoteInfo, + getEntryUrl: () => FALLBACK_ENTRY, + }); + }, + ); + + origin.registerPlugins([ + { + name: 'node-entry-http-error-retry-test', + loadEntryError, + }, + ]); + + const result = await getRemoteEntry({ origin, remoteInfo }); + + expect(result).toEqual( + expect.objectContaining({ + get: expect.any(Function), + init: expect.any(Function), + }), + ); + expect(loadEntryError).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + ENTRY, + FALLBACK_ENTRY, + ]); + }); + it('normalizes an unrecovered Node transport failure as RUNTIME_008', async () => { globalThis.fetch = rs .fn() diff --git a/packages/sdk/__tests__/node-builtin-esm.spec.ts b/packages/sdk/__tests__/node-builtin-esm.spec.ts index 00316e7d1ce..909a1b2ba65 100644 --- a/packages/sdk/__tests__/node-builtin-esm.spec.ts +++ b/packages/sdk/__tests__/node-builtin-esm.spec.ts @@ -4,7 +4,14 @@ import { pathToFileURL } from 'node:url'; const DEFAULT_REMOTE_ENTRY_URL = 'http://example.com/remoteEntry.js'; -const createResponse = (body: string) => ({ +const createResponse = ( + body: string, + init: { ok?: boolean; status?: number; statusText?: string } = {}, +) => ({ + ok: true, + status: 200, + statusText: 'OK', + ...init, text: async () => body, }); @@ -239,6 +246,40 @@ describe('Node ESM builtin loading', () => { }); }); + it('marks non-success Node script responses as ScriptNetworkError', async () => { + const response = createResponse('Not Found', { + ok: false, + status: 404, + statusText: 'Not Found', + }); + const textMock = jest.spyOn(response, 'text'); + const fetchMock = setFetchMock(() => response); + + await expect(loadNodeScript()).rejects.toMatchObject({ + name: 'ScriptNetworkError', + message: expect.stringContaining('HTTP 404 Not Found'), + }); + expect(fetchMock).toHaveBeenCalledWith(DEFAULT_REMOTE_ENTRY_URL); + expect(textMock).not.toHaveBeenCalled(); + }); + + it('marks non-success ESM responses as ScriptNetworkError', async () => { + const response = createResponse('Server Error', { + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + const textMock = jest.spyOn(response, 'text'); + const fetchMock = setFetchMock(() => response); + + await expect(loadNodeEsmScript()).rejects.toMatchObject({ + name: 'ScriptNetworkError', + message: expect.stringContaining('HTTP 500 Internal Server Error'), + }); + expect(fetchMock).toHaveBeenCalledWith(DEFAULT_REMOTE_ENTRY_URL); + expect(textMock).not.toHaveBeenCalled(); + }); + it('marks Node script execution failures as ScriptExecutionError', async () => { setRemoteEntryFetchMock( DEFAULT_REMOTE_ENTRY_URL, diff --git a/packages/sdk/src/node.ts b/packages/sdk/src/node.ts index 8c9dc2b4108..36e05b89fd8 100644 --- a/packages/sdk/src/node.ts +++ b/packages/sdk/src/node.ts @@ -62,6 +62,14 @@ const createScriptNetworkError = (url: string, error: unknown): Error => `Failed to load Node.js script "${url}"`, ); +const createScriptHttpError = (url: string, response: Response): Error => + createScriptNetworkError( + url, + new Error( + `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`, + ), + ); + const createScriptExecutionError = (error: unknown): Error => createNodeScriptError('ScriptExecutionError', error); @@ -126,6 +134,10 @@ export const createScriptNode = let data: string; try { const res = await f(urlObj.href); + if (!res.ok) { + cb(createScriptHttpError(urlObj.href, res)); + return; + } data = await res.text(); } catch (error) { cb(createScriptNetworkError(urlObj.href, error)); @@ -468,6 +480,10 @@ async function loadModule(url: string, options: LoadModuleOptions) { throw createScriptNetworkError(url, error); } + if (!response.ok) { + throw createScriptHttpError(url, response); + } + let code: string; try { code = await response.text();