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
7 changes: 7 additions & 0 deletions .changeset/ssr-node-entry-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@module-federation/runtime-core': patch
'@module-federation/sdk': patch
'@module-federation/retry-plugin': 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.
25 changes: 25 additions & 0 deletions packages/retry-plugin/__tests__/retry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,31 @@ describe('Retry Plugin', () => {
expect(mockRetryFn).toHaveBeenCalledTimes(2);
});

it('should stop retrying and preserve execution errors', async () => {
const executionError = new Error(
'ScriptExecutionError: remote entry execution failed',
);
executionError.name = 'ScriptExecutionError';
const mockRetryFn = rs
.fn()
.mockRejectedValueOnce(new Error('Script load error'))
.mockRejectedValueOnce(executionError)
.mockResolvedValueOnce({ module: 'loaded' });

const retryFunction = scriptRetry({
retryOptions: {
retryTimes: 3,
retryDelay: 0,
},
retryFn: mockRetryFn,
});

await expect(
retryFunction({ url: 'https://example.com/script.js' }),
).rejects.toBe(executionError);
expect(mockRetryFn).toHaveBeenCalledTimes(2);
});

it('should use getRetryUrl for script retries', async () => {
const mockRetryFn = rs
.fn()
Expand Down
9 changes: 9 additions & 0 deletions packages/retry-plugin/src/script-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import type { ScriptRetryOptions } from './types';
import logger from './logger';
import { getRetryUrl, combineUrlDomainWithPathQuery } from './utils';

const isScriptExecutionError = (error: unknown): error is Error =>
error instanceof Error &&
(error.name === 'ScriptExecutionError' ||
error.message.includes('ScriptExecutionError'));

export function scriptRetry<T extends Record<string, any>>({
retryOptions,
retryFn,
Expand Down Expand Up @@ -83,6 +88,10 @@ export function scriptRetry<T extends Record<string, any>>({
onSuccess({ domains, url: lastRequestUrl, tagName: 'script' });
break;
} catch (error) {
if (isScriptExecutionError(error)) {
throw error;
}

lastError = error;
attempts++;
if (attempts >= maxAttempts) {
Expand Down
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
246 changes: 246 additions & 0 deletions packages/runtime-core/__tests__/node-load.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/*
* @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();
});

it('does not retry invalid Node entry URLs', async () => {
const origin = new ModuleFederation({ name: 'test-host', remotes: [] });
const remoteInfo = getRemoteInfo({
name: 'remote',
entry: 'not a valid URL',
});
const loadEntryError = rs.fn();

origin.registerPlugins([
{
name: 'node-entry-invalid-url-test',
loadEntryError,
},
]);

const error = await getRemoteEntry({ origin, remoteInfo }).catch(
(reason) => reason,
);

expect(error.name).toBe('TypeError');
expect(error.message).toContain('Invalid URL');
expect(error.message).not.toContain(RUNTIME_008);
expect(loadEntryError).not.toHaveBeenCalled();
});
});
Loading