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
5 changes: 5 additions & 0 deletions .changeset/runtime-shared-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/runtime-core": patch
---

`loadShare` retries an async shared module whose previous load failed, instead of returning that load's rejected promise.
230 changes: 230 additions & 0 deletions packages/runtime-core/__tests__/fixtures/shared-retry-integration.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { createServer } from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import webpack from 'webpack';

const repo = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../..',
);
const scenario = process.argv[2];
if (
!['loaded', 'retry-loaded', 'initial-pending', 'retry-pending'].includes(
scenario,
)
) {
throw new Error(`Unknown scenario: ${scenario}`);
}
const require = createRequire(path.join(repo, 'package.json'));
const deferred = () => {
let resolve;
const promise = new Promise((yes) => {
resolve = yes;
});
return { promise, resolve };
};
const dir = await mkdtemp(path.join(os.tmpdir(), 'shared-retry-integration-'));
let server;
try {
await writeFile(
path.join(dir, 'store.js'),
`let count = 0;
exports.version = '2.0.0';
exports.increment = () => ++count;
exports.read = () => count;
`,
);
await writeFile(
path.join(dir, 'consumer.js'),
"exports.consume = () => import('store');\n",
);
const compiler = webpack({
mode: 'development',
context: dir,
target: 'node',
entry: {},
devtool: false,
output: {
path: path.join(dir, 'dist'),
filename: '[name].js',
library: { type: 'commonjs-module' },
uniqueName: 'remote',
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: 'remote',
library: { type: 'commonjs-module' },
filename: 'remoteEntry.js',
exposes: { './consumer': './consumer.js' },
shared: {
store: {
import: './store.js',
version: '2.0.0',
singleton: true,
requiredVersion: '*',
},
},
}),
// Keep the HTTP-served Node container self-contained.
new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
],
});
try {
await new Promise((resolve, reject) =>
compiler.run((error, stats) => {
if (error || stats.hasErrors())
reject(
error || new Error(stats.toString({ all: false, errors: true })),
);
else resolve();
}),
);
} finally {
await new Promise((resolve, reject) =>
compiler.close((error) => (error ? reject(error) : resolve())),
);
}
const remoteEntry = await readFile(path.join(dir, 'dist/remoteEntry.js'));
const source = await build({
stdin: {
contents:
"export { ModuleFederation } from './packages/runtime-core/src/core';",
resolveDir: repo,
loader: 'ts',
},
bundle: true,
write: false,
platform: 'node',
format: 'cjs',
logLevel: 'silent',
alias: {
'@module-federation/sdk': path.join(repo, 'packages/sdk/src/index.ts'),
'@module-federation/error-codes/browser': path.join(
repo,
'packages/error-codes/src/browser.ts',
),
'@module-federation/error-codes': path.join(
repo,
'packages/error-codes/src/index.ts',
),
},
define: {
__VERSION__: '"integration"',
ENV_TARGET: '"node"',
FEDERATION_DEBUG: '"false"',
},
});
const module = { exports: {} };
new Function('require', 'module', 'exports', source.outputFiles[0].text)(
require,
module,
module.exports,
);
const { ModuleFederation } = module.exports;
const retry = scenario.startsWith('retry-');
const pending = scenario.endsWith('-pending');
let failNext = retry;
const requested = deferred();
const release = deferred();
server = createServer(async (request, response) => {
if (request.url === '/remoteEntry.js') {
response.setHeader('Content-Type', 'application/javascript');
response.end(remoteEntry);
} else if (request.url === '/store.mjs') {
if (failNext) {
failNext = false;
response.writeHead(503).end('temporarily unavailable');
return;
}
requested.resolve();
if (pending) await release.promise;
response.setHeader('Content-Type', 'application/javascript');
response.end(`let count = 0;
export const store = {
version: '1.0.0',
increment: () => ++count,
read: () => count,
};`);
} else {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
let loadedStore;
const mf = new ModuleFederation({
name: 'host',
remotes: [],
shareStrategy: 'version-first',
shared: {
store: {
version: '1.0.0',
shareConfig: { singleton: true, requiredVersion: '*' },
get: () => {
if (loadedStore) return () => loadedStore;
return (async () => {
const response = await fetch(`${origin}/store.mjs`);
if (!response.ok)
throw new Error(`store request failed: ${response.status}`);
// Node cannot import an HTTP URL, so import the fetched source as a data URL.
const url = `data:text/javascript;base64,${Buffer.from(await response.text()).toString('base64')}`;
loadedStore = (await import(url)).store;
return () => loadedStore;
})();
},
},
},
});
const loadRemoteStore = async () => {
mf.registerRemotes([{ name: 'remote', entry: `${origin}/remoteEntry.js` }]);
const consumer = await mf.loadRemote('remote/consumer');
return consumer.consume();
};

let initialError;
if (retry) {
initialError = await mf.loadShare('store').then(
() => 'first load unexpectedly succeeded',
(error) => error.message,
);
}
const hostLoad = mf.loadShare('store');
let result;
if (pending) {
// Hold the host's store response until the remote has consumed and changed the store.
await Promise.race([requested.promise, hostLoad]);
const remote = await loadRemoteStore();
const remoteBefore = remote.increment();
release.resolve();
const local = (await hostLoad)();
result = {
localVersion: local.version,
remoteVersion: remote.version,
remoteBefore,
localAfter: local.increment(),
remoteAfter: remote.read(),
};
} else {
const local = (await hostLoad)();
const localBefore = local.increment();
const remote = await loadRemoteStore();
result = {
localVersion: local.version,
remoteVersion: remote.version,
localBefore,
remoteAfter: remote.increment(),
localAfter: local.read(),
};
}
console.log(JSON.stringify({ initialError, ...result }));
} finally {
if (server) {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
await rm(dir, { recursive: true, force: true });
}
138 changes: 138 additions & 0 deletions packages/runtime-core/__tests__/shared-diagnostics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,144 @@ describe('shared lifecycle hooks', () => {
});
expect(errorEvent?.error).toBeInstanceOf(Error);
});

it('retries an async shared after a transient load failure', async () => {
let attempts = 0;
const factory = () => ({ value: 'recovered' });
const mf = new ModuleFederation({
name: 'shared-retry-host',
remotes: [],
shared: {
retryable: {
version: '1.0.0',
get: () => {
attempts += 1;
return attempts === 1
? Promise.reject(new Error('transient shared failure'))
: Promise.resolve(factory);
},
},
},
});

await expect(mf.loadShare('retryable')).rejects.toThrow(
'transient shared failure',
);
await expect(mf.loadShare('retryable')).resolves.toBe(factory);
expect(attempts).toBe(2);
});

it('retries an async shared when the selected provider is not registered', async () => {
let attempts = 0;
const factory = () => ({ value: 'recovered' });
const mf = new ModuleFederation({
name: 'unregistered-shared-retry-host',
remotes: [],
shared: {
retryable: {
version: '1.0.0',
scope: 'custom',
get: () => Promise.resolve(factory),
},
},
});

const resolver = () => ({
version: '2.0.0',
scope: ['default'],
strategy: 'version-first' as const,
shareConfig: {
requiredVersion: '^2.0.0',
singleton: false,
eager: false,
strictVersion: false,
},
from: 'resolver',
deps: [],
useIn: [],
loading: null,
get: () => {
attempts += 1;
return attempts === 1
? Promise.reject(new Error('transient unregistered failure'))
: Promise.resolve(factory);
},
});

await expect(mf.loadShare('retryable', { resolver })).rejects.toThrow(
'transient unregistered failure',
);
await expect(mf.loadShare('retryable', { resolver })).resolves.toBe(
factory,
);
expect(attempts).toBe(2);
});

it('clears a failed load from every scope the resolved shared uses', async () => {
const mf = new ModuleFederation({
name: 'multi-scope-shared-retry-host',
remotes: [],
shared: {
retryable: {
version: '1.0.0',
scope: 'custom',
get: () => Promise.resolve(() => ({})),
},
},
});
const resolver = () => ({
version: '2.0.0',
scope: ['default', 'other'],
strategy: 'version-first' as const,
shareConfig: {
requiredVersion: '^2.0.0',
singleton: false,
eager: false,
strictVersion: false,
},
from: 'resolver',
deps: [],
useIn: [],
loading: null,
get: () => Promise.reject(new Error('multi-scope failure')),
});

await expect(mf.loadShare('retryable', { resolver })).rejects.toThrow(
'multi-scope failure',
);
expect(
['default', 'other'].map(
(scope) => mf.shareScopeMap[scope].retryable['2.0.0'].loading,
),
).toEqual([null, null]);
});

it('retries a shared when the provider throws synchronously', async () => {
let attempts = 0;
const factory = () => ({ value: 'recovered' });
const mf = new ModuleFederation({
name: 'sync-throw-shared-retry-host',
remotes: [],
shared: {
retryable: {
version: '1.0.0',
get: () => {
attempts += 1;
if (attempts === 1) {
throw new Error('synchronous shared failure');
}
return factory;
},
},
},
});

await expect(mf.loadShare('retryable')).rejects.toThrow(
'synchronous shared failure',
);
await expect(mf.loadShare('retryable')).resolves.toBe(factory);
expect(attempts).toBe(2);
});
});

type RawSharedEvent =
Expand Down
Loading