Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/ssr-node-entry-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@module-federation/runtime-core': patch
'@module-federation/sdk': patch
---

Enable retry-plugin recovery for Node.js remote entry transport and non-success HTTP response failures while keeping remote entry execution errors non-retryable.
4 changes: 3 additions & 1 deletion packages/runtime-core/__tests__/mock/mock-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ const rewrite = (methods: Array<string>, builder: typeof injector) => {
}
};

rewrite(mountElementMethods, injector);
if (typeof window !== 'undefined') {
rewrite(mountElementMethods, injector);
}

/**
* vite 无法让 jsdom 和当前环境处于同一个执行环境
Expand Down
221 changes: 221 additions & 0 deletions packages/runtime-core/__tests__/node-load.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* @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,
init: { ok?: boolean; status?: number; statusText?: string } = {},
) => ({
ok: true,
status: 200,
statusText: 'OK',
...init,
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('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('<html>Service Unavailable</html>', {
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()
.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();
});
});
74 changes: 59 additions & 15 deletions packages/runtime-core/src/utils/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, any> = {}) => {
createScriptHook: (
scriptUrl: string,
attrs: Record<string, any> = {},
) => {
const res = loaderHook.lifecycle.createScript.emit({
url,
url: scriptUrl,
attrs,
remoteInfo,
resourceContext: resourceContext
? {
...resourceContext,
url,
url: scriptUrl,
}
: undefined,
});
Expand All @@ -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 {
Expand Down Expand Up @@ -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({
Expand All @@ -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 = (
Expand Down
Loading