Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 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
162 changes: 162 additions & 0 deletions packages/runtime-core/__tests__/node-load.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
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
43 changes: 43 additions & 0 deletions packages/sdk/__tests__/node-builtin-esm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ const loadNodeEsmScript = async <T = unknown>(
});
};

const loadNodeScript = async <T = unknown>(
url = DEFAULT_REMOTE_ENTRY_URL,
): Promise<T> => {
const { createScriptNode } = await import('../src/node');

return new Promise<T>((resolve, reject) => {
createScriptNode(
url,
(error, scriptContext) => {
if (error) {
reject(error);
return;
}

resolve(scriptContext as T);
},
{},
);
});
};

describe('Node ESM builtin loading', () => {
const originalFetch = globalThis.fetch;

Expand Down Expand Up @@ -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',
});
});
});
Loading
Loading