From 8686d8ed299cdff2215bee9e1336d89418b75d21 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 11:36:46 +0200 Subject: [PATCH 1/7] feat(repack): add federation manifest schema, builder and emitter --- .changeset/federation-manifest.md | 5 + .gitignore | 3 + packages/repack/src/commands/consts.ts | 1 + .../@acme/scoped-native/package.json | 4 + .../scoped-native/react-native.config.js | 2 + .../node_modules/native-ui-lib/ios/RNUILib.mm | 2 + .../node_modules/native-ui-lib/package.json | 8 + .../node_modules/pure-js-lib/package.json | 4 + .../node_modules/react-native/package.json | 5 + .../node_modules/react/package.json | 4 + .../manifest-context/package.json | 5 + .../__tests__/federationManifest.test.ts | 583 ++++++++++++++++++ .../applyFederationManifest.ts | 119 ++++ .../buildFederationManifest.ts | 260 ++++++++ .../federationManifest/detectNativeModules.ts | 170 +++++ .../src/plugins/federationManifest/index.ts | 19 + .../src/plugins/federationManifest/shared.ts | 141 +++++ .../src/plugins/federationManifest/types.ts | 99 +++ 18 files changed, 1434 insertions(+) create mode 100644 .changeset/federation-manifest.md create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/package.json create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/react-native.config.js create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/ios/RNUILib.mm create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/package.json create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/pure-js-lib/package.json create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react-native/package.json create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react/package.json create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/package.json create mode 100644 packages/repack/src/plugins/__tests__/federationManifest.test.ts create mode 100644 packages/repack/src/plugins/federationManifest/applyFederationManifest.ts create mode 100644 packages/repack/src/plugins/federationManifest/buildFederationManifest.ts create mode 100644 packages/repack/src/plugins/federationManifest/detectNativeModules.ts create mode 100644 packages/repack/src/plugins/federationManifest/index.ts create mode 100644 packages/repack/src/plugins/federationManifest/shared.ts create mode 100644 packages/repack/src/plugins/federationManifest/types.ts diff --git a/.changeset/federation-manifest.md b/.changeset/federation-manifest.md new file mode 100644 index 000000000..00b26e0df --- /dev/null +++ b/.changeset/federation-manifest.md @@ -0,0 +1,5 @@ +--- +'@callstack/repack': minor +--- + +Add an opt-in `manifest` option to both module federation plugins. When set, the build emits `repack-federation-manifest.json` next to the bundle: shared dependencies report the versions actually installed in `node_modules` instead of the `*` range the plugins configure by default, and an additive `reactNative` block lists the native modules found in the module graph. Field shapes follow the upstream `mf-manifest.json` spec, so existing tooling can parse the file as-is. With the option absent, builds are byte-identical to before. diff --git a/.gitignore b/.gitignore index a57347794..9415c2bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -389,3 +389,6 @@ packages/**/docs # watchman .watchman-cookie* + +# Fixture node_modules for federation manifest tests +!packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/ diff --git a/packages/repack/src/commands/consts.ts b/packages/repack/src/commands/consts.ts index 4753f66de..2db759894 100644 --- a/packages/repack/src/commands/consts.ts +++ b/packages/repack/src/commands/consts.ts @@ -43,6 +43,7 @@ export const DEV_SERVER_ASSET_TYPES = new RegExp( '^remote-assets', // TODO (jbroma): Find a more generic way to handle this '^mf-manifest.json$', + '^repack-federation-manifest.json$', '^@mf-types.zip$', '^@mf-types.d.ts$', ].join('|') diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/package.json b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/package.json new file mode 100644 index 000000000..7e73bcd52 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/package.json @@ -0,0 +1,4 @@ +{ + "name": "@acme/scoped-native", + "version": "0.2.0" +} diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/react-native.config.js b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/react-native.config.js new file mode 100644 index 000000000..4166383a6 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/@acme/scoped-native/react-native.config.js @@ -0,0 +1,2 @@ +// Fixture config file that marks this package as React-Native facing. +module.exports = {}; diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/ios/RNUILib.mm b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/ios/RNUILib.mm new file mode 100644 index 000000000..c906fe74e --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/ios/RNUILib.mm @@ -0,0 +1,2 @@ +// Placeholder native source so the fixture package has an ios/ directory. +// Used only by federationManifest tests running against this fixture context. diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/package.json b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/package.json new file mode 100644 index 000000000..b735785c0 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/native-ui-lib/package.json @@ -0,0 +1,8 @@ +{ + "name": "native-ui-lib", + "version": "1.2.3", + "codegenConfig": { + "name": "RNUILib", + "type": "modules" + } +} diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/pure-js-lib/package.json b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/pure-js-lib/package.json new file mode 100644 index 000000000..1e4c7ee90 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/pure-js-lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "pure-js-lib", + "version": "4.5.6" +} diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react-native/package.json b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react-native/package.json new file mode 100644 index 000000000..ef09a175d --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react-native/package.json @@ -0,0 +1,5 @@ +{ + "name": "react-native", + "version": "0.0.0-fixture", + "keywords": ["react-native"] +} diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react/package.json b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react/package.json new file mode 100644 index 000000000..345467a61 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/react/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "18.0.0-fixture" +} diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/package.json b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/package.json new file mode 100644 index 000000000..5b8b69bae --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/package.json @@ -0,0 +1,5 @@ +{ + "name": "manifest-context", + "version": "9.9.9", + "private": true +} diff --git a/packages/repack/src/plugins/__tests__/federationManifest.test.ts b/packages/repack/src/plugins/__tests__/federationManifest.test.ts new file mode 100644 index 000000000..beffd45a4 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/federationManifest.test.ts @@ -0,0 +1,583 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import memfs from 'memfs'; +import { DEV_SERVER_ASSET_TYPES } from '../../commands/consts.js'; +import { + applyFederationManifest, + normalizeFederationManifestOption, +} from '../federationManifest/applyFederationManifest.js'; +import { buildFederationManifest } from '../federationManifest/buildFederationManifest.js'; +import { detectNativeModules } from '../federationManifest/detectNativeModules.js'; +import { buildSharedEntries } from '../federationManifest/shared.js'; +import { DEFAULT_MANIFEST_FILENAME } from '../federationManifest/types.js'; +import { AssetsCopyProcessor } from '../utils/AssetsCopyProcessor.js'; + +const FIXTURES_CONTEXT = path.join( + __dirname, + '__fixtures__', + 'manifest-context' +); + +const pkgResource = (pkg: string, file = 'index.js') => + path.join(FIXTURES_CONTEXT, 'node_modules', pkg, file); + +class FakeRawSource { + constructor(public value: string) {} + source() { + return this.value; + } +} + +function createFakeCompiler(overrides: Record = {}) { + const compilationCallbacks: Array<(compilation: unknown) => void> = []; + const compiler = { + context: FIXTURES_CONTEXT, + options: { name: 'ios', output: { publicPath: 'auto' } }, + hooks: { + compilation: { + tap: (_name: string, cb: (compilation: unknown) => void) => { + compilationCallbacks.push(cb); + }, + }, + }, + webpack: { sources: { RawSource: FakeRawSource } }, + ...overrides, + }; + return { compiler, compilationCallbacks }; +} + +function createFakeCompilation({ + modules = [], + warnings = [], + assets = {}, +}: { + modules?: unknown[]; + warnings?: Array<{ message: string }>; + assets?: Record; +} = {}) { + const afterProcessCallbacks: Array<() => void> = []; + return { + modules: new Set(modules), + warnings, + hooks: { + afterProcessAssets: { + tap: (_name: string, cb: () => void) => afterProcessCallbacks.push(cb), + }, + }, + getAsset: (name: string) => assets[name], + emitAsset: (name: string, source: FakeRawSource) => { + assets[name] = source; + }, + assets, + afterProcessCallbacks, + }; +} + +/** Run the full tap chain and return emitted assets as parsed JSON. */ +function emitManifest( + compilerParams: Parameters[1], + compilationOverrides?: Parameters[0] +) { + const { compiler, compilationCallbacks } = createFakeCompiler(); + applyFederationManifest(compiler, compilerParams); + const compilation = createFakeCompilation(compilationOverrides); + compilationCallbacks.forEach((cb) => { + cb(compilation); + }); + compilation.afterProcessCallbacks.forEach((cb) => { + cb(); + }); + return { + assets: compilation.assets as Record, + warnings: compilation.warnings, + }; +} + +const baseParams = ( + overrides: Partial[1]> = {} +): Parameters[1] => ({ + option: true, + name: 'catalog', + shared: { react: { singleton: true, eager: true } }, + ...overrides, +}); + +describe('normalizeFederationManifestOption', () => { + it('applies defaults for `manifest: true`', () => { + expect(normalizeFederationManifestOption(true)).toEqual({ + fileName: DEFAULT_MANIFEST_FILENAME, + filePath: undefined, + nativeAnalysis: true, + }); + }); + + it('keeps custom values from the object form', () => { + expect( + normalizeFederationManifestOption({ + fileName: 'custom.json', + filePath: 'static', + nativeAnalysis: false, + }) + ).toEqual({ + fileName: 'custom.json', + filePath: 'static', + nativeAnalysis: false, + }); + }); +}); + +describe('buildSharedEntries', () => { + it('resolves installed versions relative to the compiler context', () => { + expect( + buildSharedEntries( + { + react: { singleton: true, eager: true, requiredVersion: '^18.0.0' }, + 'react-native/': { singleton: true, eager: true }, + 'not-installed': { singleton: true }, + }, + FIXTURES_CONTEXT + ) + ).toEqual([ + { + name: 'react', + version: '18.0.0-fixture', + singleton: true, + eager: true, + requiredVersion: '^18.0.0', + }, + { + name: 'react-native/', + version: '0.0.0-fixture', + singleton: true, + eager: true, + requiredVersion: '*', + }, + { + name: 'not-installed', + version: 'unknown', + singleton: true, + eager: false, + requiredVersion: '*', + }, + ]); + }); + + it('normalizes array configs with string and wrapper entries', () => { + const entries = buildSharedEntries( + ['react', { 'react-native': { singleton: true } }], + FIXTURES_CONTEXT + ); + expect(entries).toEqual([ + { + name: 'react', + version: '18.0.0-fixture', + singleton: false, + eager: false, + requiredVersion: '*', + }, + { + name: 'react-native', + version: '0.0.0-fixture', + singleton: true, + eager: false, + requiredVersion: '*', + }, + ]); + }); + + it('normalizes @module-federation/sdk shared items with a name property', () => { + const entries = buildSharedEntries( + [{ name: 'react', singleton: true, version: '^18.0.0' }], + FIXTURES_CONTEXT + ); + expect(entries[0]).toMatchObject({ + name: 'react', + singleton: true, + requiredVersion: '^18.0.0', + }); + }); +}); + +describe('detectNativeModules', () => { + it('classifies packages by native signals and ignores pure JS packages', () => { + const result = detectNativeModules({ + modules: [ + { resource: pkgResource('native-ui-lib') }, + { resource: pkgResource('react-native') }, + { resource: pkgResource('@acme/scoped-native') }, + { resource: pkgResource('pure-js-lib') }, + { resource: pkgResource('react') }, + { resource: '/some/project/src/App.js' }, + ], + warnings: [], + }); + + expect(result).toEqual({ + nativeModules: [ + { + package: '@acme/scoped-native', + version: '0.2.0', + turboModule: false, + confidence: 'heuristic', + }, + { + package: 'native-ui-lib', + version: '1.2.3', + modules: ['RNUILib'], + turboModule: true, + confidence: 'static', + }, + { + package: 'react-native', + version: '0.0.0-fixture', + turboModule: false, + confidence: 'heuristic', + }, + ], + dynamicImportDetected: false, + degraded: false, + }); + }); + + it('flags dynamic imports and downgrades static confidence', () => { + const result = detectNativeModules({ + modules: [{ resource: pkgResource('native-ui-lib') }], + warnings: [ + { + message: + 'Critical dependency: the request of a dependency is an expression', + }, + ], + }); + + expect(result.dynamicImportDetected).toBe(true); + expect(result.nativeModules[0].confidence).toBe('heuristic'); + }); + + it('degrades to an empty list instead of throwing', () => { + const explodingModules = { + [Symbol.iterator]: () => { + throw new Error('boom'); + }, + }; + const result = detectNativeModules({ + modules: explodingModules as unknown as Iterable, + warnings: [], + }); + + expect(result).toEqual({ + nativeModules: [], + dynamicImportDetected: false, + degraded: true, + }); + }); +}); + +describe('buildFederationManifest', () => { + const manifestFor = ( + overrides: Partial[0]> = {} + ) => + buildFederationManifest({ + context: FIXTURES_CONTEXT, + name: 'catalog', + shared: { + react: { singleton: true, eager: true }, + 'react-native': { singleton: true, eager: false }, + }, + publicPath: 'auto', + nativeModules: [], + dynamicImportDetected: false, + nativeAnalysis: true, + nativeAnalysisDegraded: false, + ...overrides, + }); + + it('produces schema v1 with upstream-compatible metadata for a remote', () => { + const manifest = manifestFor({ + exposes: { './App': './src/App' }, + filename: 'catalog.container.bundle', + }); + + expect(manifest.manifestVersion).toBe(1); + expect(manifest.id).toBe('catalog'); + expect(manifest.metaData).toMatchObject({ + name: 'catalog', + globalName: 'catalog', + type: 'remote', + remoteEntry: { + name: 'catalog.container.bundle', + path: '', + type: 'var', + }, + publicPath: 'auto', + }); + expect(manifest.exposes).toEqual([ + { id: 'catalog:App', name: 'App', path: './App' }, + ]); + }); + + it('omits remoteEntry for a host without exposes', () => { + const manifest = manifestFor(); + expect(manifest.metaData.type).toBe('host'); + expect(manifest.metaData.remoteEntry).toBeUndefined(); + }); + + it('reports resolved shared versions, not the declared star range', () => { + const manifest = manifestFor(); + expect(manifest.shared).toEqual([ + { + name: 'react', + version: '18.0.0-fixture', + singleton: true, + eager: true, + requiredVersion: '*', + }, + { + name: 'react-native', + version: '0.0.0-fixture', + singleton: true, + eager: false, + requiredVersion: '*', + }, + ]); + }); + + it('normalizes remotes from string, array and keyed-object configs', () => { + const manifest = manifestFor({ + remotes: { + app1: 'app1@http://localhost:6789/app1.container.bundle', + app2: 'app2@dynamic', + }, + }); + expect(manifest.remotes).toEqual([ + { + federationContainerName: 'app1', + moduleName: 'app1', + alias: 'app1', + entry: 'http://localhost:6789/app1.container.bundle', + }, + { + federationContainerName: 'app2', + moduleName: 'app2', + alias: 'app2', + entry: 'dynamic', + }, + ]); + + const arrayManifest = manifestFor({ + remotes: ['remote1@dynamic', 'remote2@dynamic'], + }); + expect(arrayManifest.remotes.map((r) => r.federationContainerName)).toEqual( + ['remote1', 'remote2'] + ); + }); + + it('fills the reactNative block from the compiler context', () => { + const manifest = manifestFor({ + platform: 'ios', + nativeModules: [ + { + package: 'native-ui-lib', + version: '1.2.3', + turboModule: true, + confidence: 'static', + }, + ], + }); + expect(manifest.reactNative).toEqual({ + version: '0.0.0-fixture', + platforms: ['ios'], + nativeModules: [ + { + package: 'native-ui-lib', + version: '1.2.3', + turboModule: true, + confidence: 'static', + }, + ], + dynamicImportDetected: false, + }); + }); + + it('defaults platforms to ios and android when the compiler name is unknown', () => { + expect(manifestFor().reactNative.platforms).toEqual(['ios', 'android']); + }); + + it('adds an honest note when the native list may be incomplete', () => { + expect( + manifestFor({ dynamicImportDetected: true }).reactNative.note + ).toMatch(/not guaranteed to be exhaustive/); + expect(manifestFor({ nativeAnalysis: false }).reactNative.note).toMatch( + /disabled/ + ); + expect( + manifestFor({ nativeAnalysisDegraded: true }).reactNative.note + ).toMatch(/detection failed/); + }); + + it('uses the git sha as buildVersion inside a repo', () => { + const manifest = manifestFor(); + expect(manifest.metaData.buildInfo).toEqual({ + buildVersion: expect.stringMatching(/^[0-9a-f]{7,40}$/), + buildName: 'catalog', + }); + }); + + it('falls back to the package version outside a git repo', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-manifest-')); + try { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'loose', version: '3.2.1' }) + ); + expect(manifestFor({ context: tmpDir }).metaData.buildInfo).toEqual({ + buildVersion: '3.2.1', + buildName: 'catalog', + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('falls back to unknown when nothing identifies the build', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-manifest-')); + try { + expect(manifestFor({ context: tmpDir }).metaData.buildInfo).toEqual({ + buildVersion: 'unknown', + buildName: 'catalog', + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('applyFederationManifest', () => { + it('emits the manifest with the default file name', () => { + const { assets } = emitManifest(baseParams()); + const source = assets[DEFAULT_MANIFEST_FILENAME]; + expect(source).toBeDefined(); + expect(JSON.parse(source.value)).toMatchObject({ + manifestVersion: 1, + name: 'catalog', + reactNative: { version: '0.0.0-fixture', platforms: ['ios'] }, + }); + }); + + it('honors custom fileName and filePath', () => { + const { assets } = emitManifest( + baseParams({ option: { fileName: 'custom.json', filePath: 'static' } }) + ); + expect(assets['static/custom.json']).toBeDefined(); + }); + + it('skips emission with a warning when the asset name is taken', () => { + const { assets, warnings } = emitManifest(baseParams(), { + assets: { [DEFAULT_MANIFEST_FILENAME]: new FakeRawSource('{}') }, + }); + expect(JSON.parse(assets[DEFAULT_MANIFEST_FILENAME].value)).toEqual({}); + expect(warnings[0].message).toMatch(/already exists/); + }); + + it('degrades to a warning instead of failing the build', () => { + const { compiler, compilationCallbacks } = createFakeCompiler(); + applyFederationManifest(compiler, baseParams()); + const compilation = createFakeCompilation(); + compilation.emitAsset = () => { + throw new Error('emit exploded'); + }; + compilationCallbacks.forEach((cb) => { + cb(compilation); + }); + expect(() => + compilation.afterProcessCallbacks.forEach((cb) => { + cb(); + }) + ).not.toThrow(); + expect(compilation.warnings[0].message).toMatch(/emit exploded/); + }); + + it('never registers anything beyond compilation/afterProcessAssets', () => { + const { compiler, compilationCallbacks } = createFakeCompiler(); + applyFederationManifest(compiler, baseParams()); + expect(compilationCallbacks).toHaveLength(1); + const compilation = createFakeCompilation(); + compilationCallbacks.forEach((cb) => { + cb(compilation); + }); + expect(compilation.afterProcessCallbacks).toHaveLength(1); + }); +}); + +describe('dev-server and output pipeline interactions', () => { + const name = DEFAULT_MANIFEST_FILENAME; + + it('serves the manifest from the dev server', () => { + expect(DEV_SERVER_ASSET_TYPES.test(name)).toBe(true); + expect(DEV_SERVER_ASSET_TYPES.test('some-random-file.txt')).toBe(false); + }); + + it('passes through AssetsCopyProcessor untouched while the chunk manifest is rewritten', async () => { + const volume = new memfs.Volume(); + const filesystem = memfs.createFsFromVolume(volume); + const manifestContent = JSON.stringify({ + manifestVersion: 1, + name: 'catalog', + }); + + volume.fromJSON({ + '/out/index.bundle': + 'console.log(1);\n//# sourceMappingURL=index.bundle.map', + '/out/index.bundle.map': '{"file":"index.bundle","sources":[]}', + // ManifestPlugin-style per-chunk manifest, the file AssetsCopyProcessor + // is built to rewrite + '/out/index.bundle.json': + '{"files":["index.bundle"],"auxiliaryFiles":["index.bundle.map"]}', + // Our federation manifest, worst case: also listed in the chunk's + // auxiliary files even though compilation-level assets never are + '/out/repack-federation-manifest.json': manifestContent, + }); + + const processor = new AssetsCopyProcessor( + { + platform: 'android', + outputPath: '/out', + bundleOutput: '/dest/main.jsbundle', + bundleOutputDir: '/dest', + sourcemapOutput: '/dest/main.jsbundle.map', + assetsDest: '/dest/assets', + logger: { debug: () => {} }, + }, + filesystem as unknown as typeof fs + ); + + processor.enqueueChunk( + { + id: 'main', + files: ['index.bundle'], + auxiliaryFiles: [ + 'index.bundle.json', + 'index.bundle.map', + DEFAULT_MANIFEST_FILENAME, + ], + } as unknown as Parameters[0], + { isEntry: true, sourceMapFile: 'index.bundle.map' } + ); + await Promise.all(processor.execute()); + + // The chunk manifest is rewritten for the entry bundle... + const rewritten = filesystem.readFileSync( + '/dest/main.jsbundle.json', + 'utf-8' + ); + expect(rewritten).toContain('main.jsbundle'); + + // ...our manifest is only ever copied, byte for byte + const copied = filesystem.readFileSync( + `/dest/assets/${DEFAULT_MANIFEST_FILENAME}`, + 'utf-8' + ); + expect(copied).toBe(manifestContent); + }); +}); diff --git a/packages/repack/src/plugins/federationManifest/applyFederationManifest.ts b/packages/repack/src/plugins/federationManifest/applyFederationManifest.ts new file mode 100644 index 000000000..9bdec46ee --- /dev/null +++ b/packages/repack/src/plugins/federationManifest/applyFederationManifest.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import type { Compiler as RspackCompiler } from '@rspack/core'; +import { buildFederationManifest } from './buildFederationManifest.js'; +import { detectNativeModules } from './detectNativeModules.js'; +import { + DEFAULT_MANIFEST_FILENAME, + type FederationManifestObjectOptions, + type FederationManifestOption, +} from './types.js'; + +const PLUGIN_NAME = 'RepackFederationManifestPlugin'; + +/** Everything the manifest needs, captured from the plugin config at apply. */ +export interface FederationManifestParams { + /** The user-provided `manifest` option (truthy; caller gates the call). */ + option: NonNullable; + name: string; + /** Normalized shared config (what the inner MF plugin receives). */ + shared: unknown; + /** Raw user `remotes` config, before remote loaders are generated. */ + remotes?: unknown; + /** Raw user `exposes` config. */ + exposes?: unknown; + filename?: string; +} + +export function normalizeFederationManifestOption( + option: NonNullable +): Required> & { + filePath?: string; +} { + const objectOptions = + typeof option === 'object' && option !== null ? option : {}; + return { + fileName: objectOptions.fileName || DEFAULT_MANIFEST_FILENAME, + filePath: objectOptions.filePath, + nativeAnalysis: objectOptions.nativeAnalysis ?? true, + }; +} + +/** + * Register the compiler hooks that emit the Repack federation manifest. + * + * Must only be called when the `manifest` option is enabled: this is the + * only place the plugin taps compiler hooks, keeping the default path + * byte-identical to the pre-manifest behavior. + */ +export function applyFederationManifest( + __compiler: unknown, + params: FederationManifestParams +): void { + const compiler = __compiler as RspackCompiler; + const options = normalizeFederationManifestOption(params.option); + + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.afterProcessAssets.tap(PLUGIN_NAME, () => { + try { + const { nativeModules, dynamicImportDetected, degraded } = + options.nativeAnalysis + ? detectNativeModules(compilation) + : { + nativeModules: [], + dynamicImportDetected: false, + degraded: false, + }; + + const rawPublicPath = compiler.options.output.publicPath; + const manifest = buildFederationManifest({ + context: compiler.context, + name: params.name, + shared: params.shared, + remotes: params.remotes, + exposes: params.exposes, + filename: params.filename, + publicPath: + typeof rawPublicPath === 'string' ? rawPublicPath : 'auto', + platform: + typeof compiler.options.name === 'string' + ? compiler.options.name + : undefined, + nativeModules, + dynamicImportDetected, + nativeAnalysis: options.nativeAnalysis, + nativeAnalysisDegraded: degraded, + }); + + const assetName = options.filePath + ? path.posix.join(options.filePath, options.fileName) + : options.fileName; + + if (compilation.getAsset(assetName)) { + compilation.warnings.push( + new Error( + `[${PLUGIN_NAME}] Asset '${assetName}' already exists, ` + + 'skipping manifest emission. Rename it with the manifest.fileName option.' + ) + ); + return; + } + + compilation.emitAsset( + assetName, + new compiler.webpack.sources.RawSource( + JSON.stringify(manifest, null, 2) + ) + ); + } catch (error) { + // The manifest is observational: a failure here must never fail the + // build, so degrade to a warning. + compilation.warnings.push( + new Error( + `[${PLUGIN_NAME}] Failed to emit the federation manifest: ` + + `${error instanceof Error ? error.message : String(error)}` + ) + ); + } + }); + }); +} diff --git a/packages/repack/src/plugins/federationManifest/buildFederationManifest.ts b/packages/repack/src/plugins/federationManifest/buildFederationManifest.ts new file mode 100644 index 000000000..856dad0db --- /dev/null +++ b/packages/repack/src/plugins/federationManifest/buildFederationManifest.ts @@ -0,0 +1,260 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { buildSharedEntries } from './shared.js'; +import type { + FederationManifest, + FederationManifestExposeEntry, + FederationManifestRemoteEntry, + FederationNativeModule, +} from './types.js'; + +export interface BuildFederationManifestParams { + /** `compiler.context`, used to resolve installed package versions. */ + context: string; + /** Container name from the plugin config. */ + name: string; + /** Normalized `shared` config handed to the inner MF plugin. */ + shared: unknown; + /** Raw `remotes` config from the user. */ + remotes?: unknown; + /** Raw `exposes` config from the user. */ + exposes?: unknown; + /** Resolved remote entry filename, if the plugin computed one. */ + filename?: string; + /** `output.publicPath` observed at emit time. */ + publicPath: string; + /** Compiler name, used to narrow `reactNative.platforms` when known. */ + platform?: string; + nativeModules: FederationNativeModule[]; + dynamicImportDetected: boolean; + /** False when `nativeAnalysis: false` was configured. */ + nativeAnalysis: boolean; + /** Set when the native scan failed and returned an empty list. */ + nativeAnalysisDegraded: boolean; +} + +export function buildFederationManifest( + params: BuildFederationManifestParams +): FederationManifest { + const name = params.name || 'unknown'; + const hasExposes = + !!params.exposes && + (Array.isArray(params.exposes) + ? params.exposes.length > 0 + : Object.keys(params.exposes).length > 0); + + const dynamicImportNote = + 'A dynamic require was detected in the module graph, so nativeModules is not guaranteed to be exhaustive.'; + const degradedNote = + 'Native module detection failed; nativeModules is empty and must not be trusted.'; + const nativeAnalysisDisabledNote = + 'Native module analysis was disabled with nativeAnalysis: false; nativeModules is empty.'; + const note = params.nativeAnalysisDegraded + ? degradedNote + : !params.nativeAnalysis + ? nativeAnalysisDisabledNote + : params.dynamicImportDetected + ? dynamicImportNote + : undefined; + + return { + manifestVersion: 1, + id: name, + name, + metaData: { + name, + globalName: name, + type: hasExposes ? 'remote' : 'host', + buildInfo: { + buildVersion: resolveBuildVersion(params.context), + buildName: name, + }, + ...(hasExposes || params.filename + ? { + remoteEntry: { + name: params.filename ?? `${name}.container.bundle`, + path: '', + type: 'var', + }, + } + : {}), + publicPath: params.publicPath, + }, + shared: buildSharedEntries(params.shared, params.context), + remotes: buildRemoteEntries(params.remotes), + exposes: buildExposeEntries(name, params.exposes), + reactNative: { + version: resolveReactNativeVersion(params.context), + platforms: + params.platform === 'ios' || params.platform === 'android' + ? [params.platform] + : ['ios', 'android'], + nativeModules: params.nativeModules, + dynamicImportDetected: params.dynamicImportDetected, + ...(note ? { note } : {}), + }, + }; +} + +function buildRemoteEntries(remotes: unknown): FederationManifestRemoteEntry[] { + const entries: FederationManifestRemoteEntry[] = []; + + const parseRemoteString = (value: string): FederationManifestRemoteEntry => { + const atIndex = value.indexOf('@'); + // No `@`: the whole string is the container name (dynamic-style shorthand) + if (atIndex <= 0) { + return { + federationContainerName: value, + moduleName: value, + alias: value, + entry: 'dynamic', + }; + } + const containerName = value.slice(0, atIndex); + let rest = value.slice(atIndex + 1); + // `app1@app1@http://...` nests the module name before the entry + let moduleName = containerName; + if (!rest.startsWith('http') && rest.includes('@')) { + const nested = rest.slice(0, rest.indexOf('@')); + moduleName = nested || containerName; + rest = rest.slice(rest.indexOf('@') + 1); + } + return { + federationContainerName: containerName, + moduleName, + alias: containerName, + entry: rest, + }; + }; + + const visit = (remote: unknown) => { + if (typeof remote === 'string') { + entries.push(parseRemoteString(remote)); + } else if (Array.isArray(remote)) { + remote.forEach(visit); + } else if (typeof remote === 'object' && remote !== null) { + const obj = remote as Record; + // V2 style: { name, alias, entry } + if (typeof obj.entry === 'string' || typeof obj.name === 'string') { + const containerName = + typeof obj.name === 'string' ? obj.name : String(obj.alias ?? ''); + entries.push({ + federationContainerName: containerName, + moduleName: + typeof obj.moduleName === 'string' ? obj.moduleName : containerName, + alias: typeof obj.alias === 'string' ? obj.alias : containerName, + entry: typeof obj.entry === 'string' ? obj.entry : 'dynamic', + }); + return; + } + // V1 style: { [key]: external | { external } }, keyed maps reach this + // branch through the object loop below + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string' || Array.isArray(value)) { + entries.push( + withAliasFromKey(parseRemoteStringInner(key, value), key) + ); + } else if (typeof value === 'object' && value !== null) { + const nested = value as Record; + const external = nested.external; + const parsed = + typeof external === 'string' + ? parseRemoteStringInner(key, external) + : { + federationContainerName: key, + moduleName: key, + alias: key, + entry: 'dynamic', + }; + entries.push(withAliasFromKey(parsed, key)); + } + } + } + }; + + const parseRemoteStringInner = ( + key: string, + value: unknown + ): FederationManifestRemoteEntry => { + const first = Array.isArray(value) ? value[0] : value; + if (typeof first === 'string') { + const parsed = parseRemoteString(first); + return { + ...parsed, + federationContainerName: parsed.federationContainerName || key, + }; + } + return { + federationContainerName: key, + moduleName: key, + alias: key, + entry: 'dynamic', + }; + }; + + const withAliasFromKey = ( + entry: FederationManifestRemoteEntry, + key: string + ): FederationManifestRemoteEntry => ({ + ...entry, + alias: key, + }); + + visit(remotes); + return entries; +} + +function buildExposeEntries( + name: string, + exposes: unknown +): FederationManifestExposeEntry[] { + const keys: string[] = Array.isArray(exposes) + ? exposes.filter((key): key is string => typeof key === 'string') + : Object.keys((exposes as Record) ?? {}); + + return keys.map((key) => ({ + id: `${name}:${key.replace(/^\.\//, '')}`, + name: key.replace(/^\.\//, ''), + path: key, + })); +} + +function resolveBuildVersion(context: string): string { + try { + const git = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: context, + encoding: 'utf-8', + timeout: 5000, + }); + const sha = git.status === 0 ? git.stdout.trim() : ''; + if (sha) return sha; + } catch { + // git unavailable or context is not a repo + } + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(context, 'package.json'), 'utf-8') + ) as { version?: string }; + if (parsed.version) return parsed.version; + } catch { + // no readable package.json + } + return 'unknown'; +} + +function resolveReactNativeVersion(context: string): string { + try { + const requireFromContext = createRequire( + path.join(context, 'federation-manifest-resolver.js') + ); + const pkgJsonPath = requireFromContext.resolve('react-native/package.json'); + const parsed = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as { + version?: string; + }; + return parsed.version ?? 'unknown'; + } catch { + return 'unknown'; + } +} diff --git a/packages/repack/src/plugins/federationManifest/detectNativeModules.ts b/packages/repack/src/plugins/federationManifest/detectNativeModules.ts new file mode 100644 index 000000000..37273d1b6 --- /dev/null +++ b/packages/repack/src/plugins/federationManifest/detectNativeModules.ts @@ -0,0 +1,170 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { FederationNativeModule } from './types.js'; + +interface NativePackageInfo { + pkgJsonPath: string; + pkgDir: string; +} + +const NODE_MODULES_SEGMENT = /(^|[/\\])node_modules[/\\]/; + +interface DetectionResult { + nativeModules: FederationNativeModule[]; + dynamicImportDetected: boolean; + /** True when the scan itself failed and the list is not trustworthy. */ + degraded: boolean; +} + +/** + * Scan the compilation module graph for packages that ship native code. + * + * Every module whose `resource` lives inside a `node_modules` directory is + * mapped to its owning package, and each package is classified with a + * heuristic: a package is "native" when it has an `ios/` or `android/` + * directory, a `codegenConfig`, a `react-native.config.js`, or the + * `react-native` keyword. + * + * The scan never throws: any failure degrades to an empty list with a + * `degraded` flag, so a broken heuristic can never fail a build. + */ +export function detectNativeModules(compilation: { + modules?: Iterable; + warnings?: ArrayLike<{ message?: string }>; +}): DetectionResult { + try { + const dynamicImportDetected = hasDynamicImportWarning(compilation); + const packageCache = new Map(); + const found = new Map(); + + for (const module of compilation.modules ?? []) { + const resource = + typeof (module as { resource?: unknown })?.resource === 'string' + ? ((module as { resource: string }).resource as string) + : undefined; + if (!resource || !NODE_MODULES_SEGMENT.test(resource)) continue; + + const info = resolveOwningPackage(resource, packageCache); + if (!info) continue; + + const classified = classifyPackage(info); + if (classified && !found.has(classified.package)) { + found.set(classified.package, classified); + } + } + + let nativeModules = [...found.values()].sort((a, b) => + a.package.localeCompare(b.package) + ); + + // With a dynamic require in the graph the scan cannot claim a complete + // view, so no entry keeps the stronger `static` label. + if (dynamicImportDetected) { + nativeModules = nativeModules.map((entry) => ({ + ...entry, + confidence: 'heuristic' as const, + })); + } + + return { nativeModules, dynamicImportDetected, degraded: false }; + } catch { + return { nativeModules: [], dynamicImportDetected: false, degraded: true }; + } +} + +function hasDynamicImportWarning(compilation: { + warnings?: ArrayLike<{ message?: string }>; +}): boolean { + const warnings = compilation.warnings ?? []; + for (let i = 0; i < warnings.length; i++) { + if (/Critical dependency/.test(warnings[i]?.message ?? '')) { + return true; + } + } + return false; +} + +function resolveOwningPackage( + resource: string, + cache: Map +): NativePackageInfo | null { + const match = NODE_MODULES_SEGMENT.exec(resource); + if (!match) return null; + + const afterNodeModules = resource.slice( + resource.lastIndexOf('node_modules') + 'node_modules'.length + 1 + ); + const segments = afterNodeModules.split(/[/\\]/); + const packageDirName = segments[0]?.startsWith('@') + ? `${segments[0]}/${segments[1]}` + : segments[0]; + if (!packageDirName) return null; + + const nodeModulesDir = resource.slice( + 0, + resource.lastIndexOf('node_modules') + 'node_modules'.length + ); + const cacheKey = path.join(nodeModulesDir, packageDirName); + if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null; + + let info: NativePackageInfo | null = null; + const pkgDir = cacheKey; + const pkgJsonPath = path.join(pkgDir, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + info = { pkgJsonPath, pkgDir }; + } + cache.set(cacheKey, info); + return info; +} + +function classifyPackage( + info: NativePackageInfo +): FederationNativeModule | null { + let pkgJson: { + name?: string; + version?: string; + keywords?: unknown; + codegenConfig?: { name?: unknown }; + }; + try { + pkgJson = JSON.parse(fs.readFileSync(info.pkgJsonPath, 'utf-8')); + } catch { + return null; + } + if (!pkgJson.name) return null; + + const hasNativeDir = + fs.existsSync(path.join(info.pkgDir, 'ios')) || + fs.existsSync(path.join(info.pkgDir, 'android')); + const hasCodegen = Boolean(pkgJson.codegenConfig); + const hasNativeConfigFile = fs.existsSync( + path.join(info.pkgDir, 'react-native.config.js') + ); + const hasReactNativeKeyword = + Array.isArray(pkgJson.keywords) && + pkgJson.keywords.includes('react-native'); + + if ( + !hasNativeDir && + !hasCodegen && + !hasNativeConfigFile && + !hasReactNativeKeyword + ) { + return null; + } + + const codegenName = + typeof pkgJson.codegenConfig?.name === 'string' + ? pkgJson.codegenConfig.name + : undefined; + + return { + package: pkgJson.name, + version: pkgJson.version ?? 'unknown', + ...(codegenName ? { modules: [codegenName] } : {}), + turboModule: hasCodegen, + // Native source directories or codegen config are direct evidence; + // keyword/config-file presence alone is a strong hint, not proof. + confidence: hasNativeDir || hasCodegen ? 'static' : 'heuristic', + }; +} diff --git a/packages/repack/src/plugins/federationManifest/index.ts b/packages/repack/src/plugins/federationManifest/index.ts new file mode 100644 index 000000000..47156b345 --- /dev/null +++ b/packages/repack/src/plugins/federationManifest/index.ts @@ -0,0 +1,19 @@ +export { + applyFederationManifest, + type FederationManifestParams, + normalizeFederationManifestOption, +} from './applyFederationManifest.js'; +export { buildFederationManifest } from './buildFederationManifest.js'; +export { detectNativeModules } from './detectNativeModules.js'; +export { + DEFAULT_MANIFEST_FILENAME, + type FederationManifest, + type FederationManifestExposeEntry, + type FederationManifestNativeBlock, + type FederationManifestObjectOptions, + type FederationManifestOption, + type FederationManifestRemoteEntry, + type FederationManifestSharedEntry, + type FederationNativeModule, + type NativeModuleConfidence, +} from './types.js'; diff --git a/packages/repack/src/plugins/federationManifest/shared.ts b/packages/repack/src/plugins/federationManifest/shared.ts new file mode 100644 index 000000000..f487a09db --- /dev/null +++ b/packages/repack/src/plugins/federationManifest/shared.ts @@ -0,0 +1,141 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { FederationManifestSharedEntry } from './types.js'; + +/** + * Normalize every accepted `shared` configuration shape into a flat list of + * `{ name, config }` pairs. Handles the object map, the array of strings, the + * array of `{ [name]: config }` wrappers (both plugins) and the + * `{ name, ...config }` items (`@module-federation/sdk` `SharedItem`). + */ +export function normalizeSharedEntries( + shared: unknown +): Array<{ name: string; config: Record }> { + const entries: Array<{ name: string; config: Record }> = []; + + const push = (name: string, config: unknown) => { + entries.push({ + name, + config: + typeof config === 'object' && config !== null + ? (config as Record) + : {}, + }); + }; + + const fromObject = (obj: Record) => { + // `{ react: {...} }` wrapper used by both plugins in array form + const keys = Object.keys(obj); + if ( + keys.length === 1 && + (typeof obj[keys[0]] === 'object' || typeof obj[keys[0]] === 'string') + ) { + push(keys[0], obj[keys[0]]); + return; + } + // `{ name: 'react', singleton: true }` item from @module-federation/sdk + if (typeof obj.name === 'string') { + const { name, ...config } = obj; + push(name, config); + return; + } + // plain `{ [dependencyName]: config | string }` map + for (const key of keys) { + push(key, obj[key]); + } + }; + + if (typeof shared === 'string') { + push(shared, {}); + } else if (Array.isArray(shared)) { + for (const item of shared) { + if (typeof item === 'string') { + push(item, {}); + } else if (typeof item === 'object' && item !== null) { + fromObject(item as Record); + } + } + } else if (typeof shared === 'object' && shared !== null) { + fromObject(shared as Record); + } + + return entries; +} + +/** + * Resolve the installed version of a package relative to `context`. + * Returns `'unknown'` instead of throwing when the package cannot be located + * (e.g. deep-import sharing keys like `react-native/`, or missing packages). + */ +export function resolveInstalledVersion( + packageName: string, + context: string +): string { + const name = packageName.replace(/\/$/, ''); + try { + const requireFromContext = createRequire( + path.join(context, 'federation-manifest-resolver.js') + ); + // Preferred path: the manifest file itself, when the package exports it + try { + const pkgJsonPath = requireFromContext.resolve(`${name}/package.json`); + return readVersion(pkgJsonPath); + } catch { + // Fall back to walking up from the main entry point, for packages whose + // `exports` map does not expose package.json + const mainPath = requireFromContext.resolve(name); + let dir = path.dirname(mainPath); + for (let i = 0; i < 10 && dir !== path.dirname(dir); i++) { + const candidate = path.join(dir, 'package.json'); + if (fs.existsSync(candidate)) { + return readVersion(candidate); + } + dir = path.dirname(dir); + } + } + } catch { + // not resolvable from this context + } + return 'unknown'; +} + +function readVersion(packageJsonPath: string): string { + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { + version?: string; + }; + return parsed.version ?? 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Build the `shared[]` block: one entry per shared dependency with the + * installed version resolved from `context`, plus the singleton/eager/ + * requiredVersion values as configured. + */ +export function buildSharedEntries( + shared: unknown, + context: string +): FederationManifestSharedEntry[] { + const versionCache = new Map(); + + return normalizeSharedEntries(shared).map(({ name, config }) => { + if (!versionCache.has(name)) { + versionCache.set(name, resolveInstalledVersion(name, context)); + } + return { + name, + version: versionCache.get(name) as string, + singleton: Boolean(config.singleton), + eager: Boolean(config.eager), + requiredVersion: + (typeof config.requiredVersion === 'string' && + config.requiredVersion) || + (typeof config.version === 'string' && config.version) || + '*', + }; + }); +} diff --git a/packages/repack/src/plugins/federationManifest/types.ts b/packages/repack/src/plugins/federationManifest/types.ts new file mode 100644 index 000000000..a32b54896 --- /dev/null +++ b/packages/repack/src/plugins/federationManifest/types.ts @@ -0,0 +1,99 @@ +/** Default asset name for the Repack federation manifest. */ +export const DEFAULT_MANIFEST_FILENAME = 'repack-federation-manifest.json'; + +/** + * Object form of the `manifest` option on `ModuleFederationPluginV1` and + * `ModuleFederationPluginV2`. + */ +export interface FederationManifestObjectOptions { + /** + * Name of the emitted manifest file. + * Defaults to `repack-federation-manifest.json`. + */ + fileName?: string; + /** + * Subdirectory inside the compiler output to emit the manifest into. + * Defaults to the output root. + */ + filePath?: string; + /** + * Scan the module graph for native modules and populate the + * `reactNative.nativeModules` block. Defaults to `true` when the manifest + * is enabled. + */ + nativeAnalysis?: boolean; +} + +/** + * Value accepted by the `manifest` option: `true` enables emission with + * defaults, an object customizes it, `false` (or absent) disables it. + */ +export type FederationManifestOption = + | boolean + | FederationManifestObjectOptions; + +/** Confidence level of a native module detection. */ +export type NativeModuleConfidence = 'static' | 'heuristic'; + +/** One entry in `reactNative.nativeModules`. */ +export interface FederationNativeModule { + package: string; + version: string; + modules?: string[]; + turboModule: boolean; + confidence: NativeModuleConfidence; +} + +/** One entry in `shared[]`. */ +export interface FederationManifestSharedEntry { + name: string; + version: string; + singleton: boolean; + eager: boolean; + requiredVersion: string; +} + +/** One entry in `remotes[]`, upstream mf-manifest compatible shape. */ +export interface FederationManifestRemoteEntry { + federationContainerName: string; + moduleName: string; + alias: string; + entry: string; +} + +/** One entry in `exposes[]`. */ +export interface FederationManifestExposeEntry { + id: string; + name: string; + path: string; +} + +/** React Native specific extension block. */ +export interface FederationManifestNativeBlock { + version: string; + newArch?: boolean; + platforms: string[]; + nativeModules: FederationNativeModule[]; + dynamicImportDetected: boolean; + /** Present whenever the list may be incomplete or detection degraded. */ + note?: string; +} + +/** Schema v1 of `repack-federation-manifest.json`. */ +export interface FederationManifest { + manifestVersion: 1; + id: string; + name: string; + metaData: { + name: string; + globalName: string; + type: 'host' | 'remote'; + buildInfo: { buildVersion: string; buildName: string }; + remoteEntry?: { name: string; path: string; type: string }; + publicPath: string; + }; + shared: FederationManifestSharedEntry[]; + remotes: FederationManifestRemoteEntry[]; + exposes: FederationManifestExposeEntry[]; + reactNative: FederationManifestNativeBlock; +} From a5b01b63ece07b5fc6a70b914ff98ca9e49374d8 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 11:36:58 +0200 Subject: [PATCH 2/7] feat(repack): wire opt-in manifest option into module federation plugins --- .../src/plugins/ModuleFederationPluginV1.ts | 30 ++- .../src/plugins/ModuleFederationPluginV2.ts | 40 +++- .../ModuleFederationPluginV1.test.ts | 130 ++++++++++++ .../ModuleFederationPluginV2.test.ts | 123 +++++++++++ .../__fixtures__/manifest-context/entry.js | 1 + .../federationManifestCompilation.test.ts | 200 ++++++++++++++++++ packages/repack/src/plugins/index.ts | 1 + 7 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/entry.js create mode 100644 packages/repack/src/plugins/__tests__/federationManifestCompilation.test.ts diff --git a/packages/repack/src/plugins/ModuleFederationPluginV1.ts b/packages/repack/src/plugins/ModuleFederationPluginV1.ts index 0fc240b6a..83fbb3ac0 100644 --- a/packages/repack/src/plugins/ModuleFederationPluginV1.ts +++ b/packages/repack/src/plugins/ModuleFederationPluginV1.ts @@ -2,6 +2,10 @@ import type { container, Compiler as RspackCompiler } from '@rspack/core'; import type { Compiler as WebpackCompiler } from 'webpack'; import { isRspackCompiler } from '../helpers/index.js'; import { Federated } from '../utils/federated.js'; +import { + applyFederationManifest, + type FederationManifestOption, +} from './federationManifest/index.js'; type MFPluginV1 = typeof container.ModuleFederationPluginV1; type MFPluginV1Options = ConstructorParameters[0]; @@ -32,6 +36,15 @@ type SharedConfig = SharedObject extends { [key: string]: infer U } export interface ModuleFederationPluginV1Config extends MFPluginV1Options { /** Enable or disable adding React Native deep imports to shared dependencies */ reactNativeDeepImports?: boolean; + /** + * Emit a `repack-federation-manifest.json` describing this container: + * resolved shared dependency versions, remotes, exposes and a React Native + * native-module block. Disabled by default. + * + * Pass `true` for defaults or an object to customize `fileName`, + * `filePath` and `nativeAnalysis`. + */ + manifest?: FederationManifestOption; } /** @@ -102,11 +115,13 @@ export interface ModuleFederationPluginV1Config extends MFPluginV1Options { export class ModuleFederationPluginV1 { private config: MFPluginV1Options; private deepImports: boolean; + private manifest: FederationManifestOption | undefined; constructor(pluginConfig: ModuleFederationPluginV1Config) { - const { reactNativeDeepImports, ...config } = pluginConfig; + const { reactNativeDeepImports, manifest, ...config } = pluginConfig; this.config = config; this.deepImports = reactNativeDeepImports ?? true; + this.manifest = manifest || undefined; } /** @@ -318,5 +333,18 @@ export class ModuleFederationPluginV1 { remotes: remotesConfig, shared: sharedConfig, }).apply(compiler); + + // Taps compiler hooks, so it stays behind the opt-in flag: existing + // setups (and compiler mocks without `hooks`) must not hit this path. + if (this.manifest) { + applyFederationManifest(compiler, { + option: this.manifest, + name: this.config.name || 'unknown', + shared: sharedConfig, + remotes: this.config.remotes, + exposes: this.config.exposes, + filename: filenameConfig, + }); + } } } diff --git a/packages/repack/src/plugins/ModuleFederationPluginV2.ts b/packages/repack/src/plugins/ModuleFederationPluginV2.ts index ddb329a10..36dcae7e5 100644 --- a/packages/repack/src/plugins/ModuleFederationPluginV2.ts +++ b/packages/repack/src/plugins/ModuleFederationPluginV2.ts @@ -3,6 +3,10 @@ import type { Compiler as RspackCompiler } from '@rspack/core'; import { name as isIdentifier } from 'estree-util-is-identifier-name'; import type { Compiler as WebpackCompiler } from 'webpack'; import { isRspackCompiler } from '../helpers/index.js'; +import { + applyFederationManifest, + type FederationManifestOption, +} from './federationManifest/index.js'; type JsModuleDescriptor = { identifier: string; @@ -30,6 +34,19 @@ export interface ModuleFederationPluginV2Config defaultRuntimePlugins?: string[]; /** Enable or disable adding React Native deep imports to shared dependencies. Defaults to true */ reactNativeDeepImports?: boolean; + /** + * Emit a `repack-federation-manifest.json` describing this container: + * resolved shared dependency versions, remotes, exposes and a React Native + * native-module block. Disabled by default. + * + * Pass `true` for defaults or an object to customize `fileName`, + * `filePath` and `nativeAnalysis`. + * + * Note: this option is consumed by Re.Pack and not forwarded to the + * `@module-federation/enhanced` plugin, which emits its own + * `mf-manifest.json` with its own defaults regardless of this flag. + */ + manifest?: FederationManifestOption; } /** @@ -101,12 +118,18 @@ export class ModuleFederationPluginV2 { public config: MF.ModuleFederationPluginOptions; private deepImports: boolean; private defaultRuntimePlugins: string[]; + private manifest: FederationManifestOption | undefined; constructor(pluginConfig: ModuleFederationPluginV2Config) { - const { defaultRuntimePlugins, reactNativeDeepImports, ...config } = - pluginConfig; + const { + defaultRuntimePlugins, + reactNativeDeepImports, + manifest, + ...config + } = pluginConfig; this.config = config; this.deepImports = reactNativeDeepImports ?? true; + this.manifest = manifest || undefined; this.defaultRuntimePlugins = defaultRuntimePlugins ?? [ '@callstack/repack/mf/core-plugin', '@callstack/repack/mf/resolver-plugin', @@ -367,5 +390,18 @@ export class ModuleFederationPluginV2 { }; new ModuleFederationPlugin(config).apply(compiler); + + // Taps compiler hooks, so it stays behind the opt-in flag: existing + // setups (and compiler mocks without `hooks`) must not hit this path. + if (this.manifest) { + applyFederationManifest(compiler, { + option: this.manifest, + name: this.config.name || 'unknown', + shared: sharedConfig, + remotes: this.config.remotes, + exposes: this.config.exposes, + filename: this.config.filename, + }); + } } } diff --git a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts index 18eacfb36..34ff06586 100644 --- a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts +++ b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import type { Compiler } from '@rspack/core'; import { ModuleFederationPluginV1 } from '../ModuleFederationPluginV1.js'; @@ -14,6 +15,68 @@ const mockCompiler = { }, } as unknown as Compiler; +/** + * Compiler stub with just enough surface for the opt-in manifest emission: + * real `compilation`/`afterProcessAssets` tap chains, driven manually. + */ +function createHookCompiler(context: string) { + const compilationTaps: Array<(compilation: unknown) => void> = []; + const compiler = { + context, + options: { name: 'ios', output: { publicPath: 'auto' } }, + hooks: { + compilation: { + tap: (_name: string, cb: (compilation: unknown) => void) => { + compilationTaps.push(cb); + }, + }, + }, + webpack: { + container: { + ModuleFederationPluginV1: mockPlugin, + ModuleFederationPlugin: mockPlugin, + }, + sources: { + RawSource: class { + constructor(public value: string) {} + source() { + return this.value; + } + }, + }, + }, + }; + + const emit = (plugin: ModuleFederationPluginV1) => { + plugin.apply(compiler as unknown as Compiler); + const assets: Record = {}; + const compilation = { + modules: new Set(), + warnings: [], + hooks: { + afterProcessAssets: { + tap: (_name: string, cb: () => void) => { + cb(); + }, + }, + }, + getAsset: (name: string) => + assets[name] + ? ({ source: { source: () => assets[name] } } as never) + : undefined, + emitAsset: (name: string, source: { source: () => string }) => { + assets[name] = source.source(); + }, + }; + compilationTaps.forEach((cb) => { + cb(compilation); + }); + return assets; + }; + + return { compiler, emit }; +} + describe('ModuleFederationPlugin', () => { afterEach(() => { mockPlugin.mockClear(); @@ -284,4 +347,71 @@ describe('ModuleFederationPlugin', () => { const config = mockPlugin.mock.calls[0][0]; expect(config.filename).toBe('remoteEntry.js'); }); + + it('should not touch compiler hooks when the manifest option is absent', () => { + // `mockCompiler` has no `hooks`: any unconditional hook registration + // would throw here, so the existing mocks pin the default no-op behavior + expect(() => { + new ModuleFederationPluginV1({ name: 'test' }).apply(mockCompiler); + }).not.toThrow(); + + const config = mockPlugin.mock.calls[0][0]; + expect(config).not.toHaveProperty('manifest'); + }); + + it('should emit repack-federation-manifest.json when manifest is enabled', () => { + const { emit } = createHookCompiler( + path.join(__dirname, '__fixtures__', 'manifest-context') + ); + const assets = emit( + new ModuleFederationPluginV1({ + name: 'app1', + exposes: { './App': './src/App' }, + manifest: true, + }) + ); + + const manifest = JSON.parse(assets['repack-federation-manifest.json']); + expect(manifest).toMatchObject({ + manifestVersion: 1, + name: 'app1', + metaData: { type: 'remote' }, + }); + expect( + manifest.shared.map((entry: { name: string }) => entry.name) + ).toEqual( + expect.arrayContaining([ + 'react', + 'react-native', + 'react-native/', + '@react-native/', + ]) + ); + expect( + manifest.shared.find((entry: { name: string }) => entry.name === 'react') + ).toMatchObject({ + version: '18.0.0-fixture', + singleton: true, + eager: true, + }); + expect(manifest.reactNative.version).toBe('0.0.0-fixture'); + }); + + it('should honor a custom fileName and keep it out of the inner plugin config', () => { + const { emit } = createHookCompiler( + path.join(__dirname, '__fixtures__', 'manifest-context') + ); + const assets = emit( + new ModuleFederationPluginV1({ + name: 'app1', + manifest: { fileName: 'custom-manifest.json' }, + }) + ); + + expect(assets['custom-manifest.json']).toBeDefined(); + expect(assets['repack-federation-manifest.json']).toBeUndefined(); + + const config = mockPlugin.mock.calls[0][0]; + expect(config).not.toHaveProperty('manifest'); + }); }); diff --git a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV2.test.ts b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV2.test.ts index 4071c87c9..641867052 100644 --- a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV2.test.ts +++ b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV2.test.ts @@ -26,6 +26,70 @@ const mockPlugin = MFPluginRspack as unknown as jest.Mock< typeof MFPluginRspack >; +/** + * Compiler stub with just enough surface for the opt-in manifest emission: + * real `compilation`/`afterProcessAssets` tap chains, driven manually. + * Context is this package so `@module-federation/enhanced` and the default + * shared dependencies resolve for real. + */ +function createHookCompiler() { + const compilationTaps: Array<(compilation: unknown) => void> = []; + const compiler = { + context: __dirname, + options: { name: 'ios', output: { publicPath: 'auto' } }, + hooks: { + compilation: { + tap: (_name: string, cb: (compilation: unknown) => void) => { + compilationTaps.push(cb); + }, + }, + }, + webpack: { + DefinePlugin: jest.fn(() => ({ + apply: jest.fn(), + })), + rspackVersion: '1.0.0', + sources: { + RawSource: class { + constructor(public value: string) {} + source() { + return this.value; + } + }, + }, + }, + }; + + const emit = (plugin: ModuleFederationPluginV2) => { + plugin.apply(compiler as unknown as Compiler); + const assets: Record = {}; + const compilation = { + modules: new Set(), + warnings: [], + hooks: { + afterProcessAssets: { + tap: (_name: string, cb: () => void) => { + cb(); + }, + }, + }, + getAsset: (name: string) => + assets[name] + ? ({ source: { source: () => assets[name] } } as never) + : undefined, + emitAsset: (name: string, source: { source: () => string }) => { + assets[name] = source.source(); + }, + }; + compilationTaps.forEach((cb) => { + cb(compilation); + }); + return assets; + }; + + return { compiler, emit }; +} + const corePluginPath = require.resolve('@callstack/repack/mf/core-plugin'); const resolverPluginPath = require.resolve( '@callstack/repack/mf/resolver-plugin' @@ -325,4 +389,63 @@ describe('ModuleFederationPlugin', () => { }).not.toThrow(); }); }); + + it('should not touch compiler hooks when the manifest option is absent', () => { + // `mockCompiler` has no `hooks`: any unconditional hook registration + // would throw here, so the existing mocks pin the default no-op behavior + expect(() => { + new ModuleFederationPluginV2({ name: 'test' }).apply(mockCompiler); + }).not.toThrow(); + + const config = mockPlugin.mock.calls[0][0]; + expect(config).not.toHaveProperty('manifest'); + }); + + it('should emit repack-federation-manifest.json when manifest is enabled', () => { + const { emit } = createHookCompiler(); + const assets = emit( + new ModuleFederationPluginV2({ + name: 'app1', + exposes: { './App': './src/App' }, + manifest: true, + }) + ); + + const manifest = JSON.parse(assets['repack-federation-manifest.json']); + expect(manifest).toMatchObject({ + manifestVersion: 1, + name: 'app1', + metaData: { type: 'remote' }, + }); + expect( + manifest.shared.map((entry: { name: string }) => entry.name) + ).toEqual(expect.arrayContaining(['react', 'react-native'])); + // resolved from the real node_modules of this package, never the + // declared `*` range + expect( + manifest.shared.find((entry: { name: string }) => entry.name === 'react') + .version + ).toMatch(/^\d+\.\d+\.\d+/); + expect(manifest.reactNative.version).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('should honor a custom fileName and keep it out of the inner plugin config', () => { + const { emit } = createHookCompiler(); + const assets = emit( + new ModuleFederationPluginV2({ + name: 'app1', + manifest: { fileName: 'custom-manifest.json', nativeAnalysis: false }, + }) + ); + + expect(assets['custom-manifest.json']).toBeDefined(); + expect(assets['repack-federation-manifest.json']).toBeUndefined(); + + const manifest = JSON.parse(assets['custom-manifest.json']); + expect(manifest.reactNative.nativeModules).toEqual([]); + expect(manifest.reactNative.note).toMatch(/disabled/); + + const config = mockPlugin.mock.calls[0][0]; + expect(config).not.toHaveProperty('manifest'); + }); }); diff --git a/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/entry.js b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/entry.js new file mode 100644 index 000000000..9258849ab --- /dev/null +++ b/packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/entry.js @@ -0,0 +1 @@ +console.log('webpack entry'); diff --git a/packages/repack/src/plugins/__tests__/federationManifestCompilation.test.ts b/packages/repack/src/plugins/__tests__/federationManifestCompilation.test.ts new file mode 100644 index 000000000..2beca0624 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/federationManifestCompilation.test.ts @@ -0,0 +1,200 @@ +import path from 'node:path'; +import { type Compiler, rspack } from '@rspack/core'; +import memfs from 'memfs'; +import RspackVirtualModulePlugin from 'rspack-plugin-virtual-module'; +import webpack from 'webpack'; +import { DEFAULT_MANIFEST_FILENAME } from '../federationManifest/types.js'; +import { ModuleFederationPluginV1 } from '../ModuleFederationPluginV1.js'; + +interface CapturedEmission { + assetExists: boolean; + source: string | undefined; + inChunkAuxiliaryFiles: boolean; +} + +/** + * Taps after `RepackFederationManifestPlugin` (registration order) to observe + * what the emission produced and whether any chunk adopted the asset. + */ +class EmissionCapturePlugin { + constructor( + public captured: { current?: CapturedEmission }, + public assetName: string = DEFAULT_MANIFEST_FILENAME + ) {} + + apply(__compiler: unknown) { + const compiler = __compiler as Compiler; + compiler.hooks.compilation.tap('EmissionCapturePlugin', (compilation) => { + compilation.hooks.afterProcessAssets.tap('EmissionCapturePlugin', () => { + const asset = compilation.getAsset(this.assetName); + this.captured.current = { + assetExists: !!asset, + source: asset + ? (asset.source.source() as Buffer | string).toString() + : undefined, + inChunkAuxiliaryFiles: [...compilation.chunks].some((chunk) => + chunk.auxiliaryFiles?.has(this.assetName) + ), + }; + }); + }); + } +} + +async function compileWithManifest( + plugins: Array<{ apply(compiler: Compiler): void }> +) { + const fileSystem = memfs.createFsFromVolume(new memfs.Volume()); + + const compiler = rspack({ + context: __dirname, + mode: 'production', + devtool: false, + entry: 'index.js', + output: { + filename: 'index.bundle', + path: '/out', + chunkFilename: '[name].chunk.bundle', + }, + plugins: [ + new RspackVirtualModulePlugin({ + 'index.js': "console.log('host');", + }), + ...plugins, + ], + }); + + // @ts-expect-error memfs is compatible enough + compiler.outputFileSystem = fileSystem; + + await new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error) return reject(error); + if (stats?.hasErrors()) return reject(new Error(stats.toString())); + resolve(); + }) + ); + + return fileSystem; +} + +describe('federation manifest emission (real compiler)', () => { + it('emits an intact manifest via rspack without attaching it to any chunk', async () => { + const captured: { current?: CapturedEmission } = {}; + + const fileSystem = await compileWithManifest([ + // react-only shared config: the react-native default would pull flow + // typed sources into this bare rspack run, which it cannot parse + new ModuleFederationPluginV1({ + name: 'manifestHost', + shared: { react: { singleton: true, eager: true } }, + reactNativeDeepImports: false, + manifest: true, + }), + new EmissionCapturePlugin(captured), + ]); + + expect(captured.current?.assetExists).toBe(true); + // Chunk-level detachment is what keeps OutputPlugin and + // AssetsCopyProcessor, which iterate chunk files and auxiliary files, + // away from the manifest + expect(captured.current?.inChunkAuxiliaryFiles).toBe(false); + + const onDisk = fileSystem + .readFileSync(path.join('/out', DEFAULT_MANIFEST_FILENAME), 'utf-8') + .toString(); + expect(onDisk).toBe(captured.current?.source); + + const manifest = JSON.parse(onDisk); + expect(manifest).toMatchObject({ + manifestVersion: 1, + id: 'manifestHost', + name: 'manifestHost', + metaData: { + type: 'host', + buildInfo: { buildName: 'manifestHost' }, + }, + }); + expect(manifest.metaData.buildInfo.buildVersion).toMatch( + /^[0-9a-f]{7,40}$|^\d+\.\d+\.\d+$|^unknown$/ + ); + expect( + manifest.shared.find((entry: { name: string }) => entry.name === 'react') + .version + ).toMatch(/^\d+\.\d+\.\d+/); + expect(manifest.reactNative.version).toMatch(/^\d+\.\d+\.\d+/); + expect(Array.isArray(manifest.reactNative.nativeModules)).toBe(true); + expect(manifest.reactNative.platforms).toEqual(['ios', 'android']); + }); + + it('emits nothing when the manifest option is absent', async () => { + const captured: { current?: CapturedEmission } = {}; + + const fileSystem = await compileWithManifest([ + new ModuleFederationPluginV1({ + name: 'manifestHost', + shared: { react: { singleton: true, eager: true } }, + reactNativeDeepImports: false, + }), + new EmissionCapturePlugin(captured), + ]); + + expect(captured.current?.assetExists).toBe(false); + expect( + fileSystem.existsSync(path.join('/out', DEFAULT_MANIFEST_FILENAME)) + ).toBe(false); + }); + + it('emits the manifest with webpack too (bundler-agnostic emit path)', async () => { + const fileSystem = memfs.createFsFromVolume(new memfs.Volume()); + const captured: { current?: CapturedEmission } = {}; + + const compiler = webpack({ + context: __dirname, + mode: 'production', + devtool: false, + entry: path.join( + __dirname, + '__fixtures__', + 'manifest-context', + 'entry.js' + ), + output: { + filename: 'index.bundle', + path: '/out', + }, + plugins: [ + new ModuleFederationPluginV1({ + name: 'webpackHost', + shared: { react: { singleton: true, eager: true } }, + reactNativeDeepImports: false, + manifest: { fileName: 'webpack-federation-manifest.json' }, + }), + new EmissionCapturePlugin( + captured, + 'webpack-federation-manifest.json' + ) as never, + ], + }); + + // @ts-expect-error memfs is compatible enough + compiler.outputFileSystem = fileSystem; + + await new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error) return reject(error); + if (stats?.hasErrors()) return reject(new Error(stats.toString())); + resolve(); + }) + ); + + expect(captured.current?.assetExists).toBe(true); + const onDisk = fileSystem + .readFileSync('/out/webpack-federation-manifest.json', 'utf-8') + .toString(); + expect(JSON.parse(onDisk)).toMatchObject({ + manifestVersion: 1, + name: 'webpackHost', + }); + }); +}); diff --git a/packages/repack/src/plugins/index.ts b/packages/repack/src/plugins/index.ts index a4c866e44..03d3ebe8f 100644 --- a/packages/repack/src/plugins/index.ts +++ b/packages/repack/src/plugins/index.ts @@ -1,6 +1,7 @@ export * from './BabelPlugin.js'; export * from './CodeSigningPlugin/index.js'; export * from './DevelopmentPlugin.js'; +export * from './federationManifest/types.js'; export * from './HermesBytecodePlugin/index.js'; export * from './LoggerPlugin.js'; export * from './ManifestPlugin.js'; From 75c9a1aac1bcf5092ed35a36f1a0a55ce34da0e5 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 11:37:12 +0200 Subject: [PATCH 3/7] docs(repack): document the opt-in federation manifest --- agent_context/README.md | 1 + agent_context/federation-tools/design.md | 235 ++++++++++++++++++ website/src/latest/docs/features/_meta.json | 1 + .../docs/features/federation-manifest.md | 153 ++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 agent_context/federation-tools/design.md create mode 100644 website/src/latest/docs/features/federation-manifest.md diff --git a/agent_context/README.md b/agent_context/README.md index 35109711a..29b3863ac 100644 --- a/agent_context/README.md +++ b/agent_context/README.md @@ -9,3 +9,4 @@ works, and is kept in sync with the implementation as it evolves. | Folder | Topic | | --- | --- | | [rspackv2-jul2026](./rspackv2-jul2026/design.md) | Dual Rspack 1.x/2.x support | +| [federation-tools](./federation-tools/design.md) | Federation manifest, doctor, shared-config retrofit, dev runner | diff --git a/agent_context/federation-tools/design.md b/agent_context/federation-tools/design.md new file mode 100644 index 000000000..b349201b5 --- /dev/null +++ b/agent_context/federation-tools/design.md @@ -0,0 +1,235 @@ +# Federation Tools — Design + +Effort: tooling that makes multi-app Module Federation (host + remotes, often in +separate repos) safe to ship with Re.Pack. This doc tracks the thinking and the +decisions as the effort evolves, one section per shipped piece. + +## The problem (verified in code) + +- `requiredVersion` defaults to `'*'` in both plugins + (`packages/repack/src/plugins/ModuleFederationPluginV1.ts:194`, + `ModuleFederationPluginV2.ts:209`). No build-time or runtime code ever + compares host vs remote versions. +- Native module compatibility is documented in a single sentence + (`website/src/latest/docs/getting-started/microfrontends.md:30`) and enforced + by nothing. +- Shared config blocks are duplicated by hand across host and every remote; + `eager` conventions are social, not code. +- Result: version/native drift is only discovered as runtime crashes + (issues #1367, #1368, #1428). + +## The base primitive: a Repack federation manifest + +Everything downstream (doctor, CI gate, init/codemod, dev runner) consumes one +artifact: a machine-readable manifest emitted at build time by +`ModuleFederationPluginV1`/`V2`. The manifest, not the doctor, is the primitive. + +### Alignment with upstream (rspack.rs / Module Federation 2.0) + +MF 2.0 already standardizes `mf-manifest.json` +([spec](https://github.com/module-federation/core/blob/main/arch-doc/manifest-specification.md)): +`id`, `name`, `metaData`, `shared[]` (with resolved `version`, `singleton`, +`requiredVersion`, `hash`, `assets`), `remotes[]`, `exposes[]`. The MF plugin +that Repack V2 wraps already emits it. We do **not** invent a parallel format: + +- The Repack manifest **reuses the upstream schema shape verbatim** and adds a + single React-Native extension block. Web MF and Repack consumers can parse + the common part with the same tooling. +- Distinct filename to avoid collision with the upstream `mf-manifest.json` + (which V2's inner plugin may also emit): default + **`repack-federation-manifest.json`**, configurable. +- Option naming mirrors upstream so it is muscle memory for rspack/MF users: + `manifest?: boolean | { fileName?: string; filePath?: string; native?: ... }` + on both plugin configs — same shape as `PluginManifestOptions` in + `@module-federation/rspack`. + +### Schema (v1) — `repack-federation-manifest.json` + +```jsonc +{ + "manifestVersion": 1, + // --- upstream mf-manifest-compatible fields --- + "id": "catalog", + "name": "catalog", + "metaData": { + "name": "catalog", + "globalName": "catalog", + "type": "remote", + "buildInfo": { "buildVersion": "", "buildName": "catalog" }, + "remoteEntry": { "name": "remoteEntry.container.jsbundle", "path": "", "type": "var" }, + "publicPath": "https://cdn.example.com/catalog/" + }, + "shared": [ + { + "name": "react", + "version": "19.1.0", // RESOLVED version, not '*' + "singleton": true, + "eager": true, + "requiredVersion": "^19.1.0", + "assets": { "js": { "sync": ["..."], "async": [] }, "css": { "js": ..., "async": [] } } + } + ], + "remotes": [ /* upstream shape: federationContainerName, moduleName, alias, entry */ ], + "exposes": [ /* upstream shape: id, name, path, assets */ ], + + // --- React Native extension block (Repack-specific, additive) --- + "reactNative": { + "version": "0.80.1", + "newArch": true, + "platforms": ["ios", "android"], + "nativeModules": [ + { + "package": "react-native-svg", + "version": "15.11.2", + "modules": ["RNSVG", "RNSVGPackage"], + "turboModule": true, + "confidence": "static" // static | heuristic — see detection + } + ], + "dynamicImportDetected": true // when true, nativeModules is NOT exhaustive + } +} +``` + +`manifestVersion` is the compatibility contract for consumers; additive fields +only within a major, bumps are the doctor's job to interpret. + +### Resolved versions + +Today nothing in the MF plugins reads installed versions — `requiredVersion` +stays `'*'` and resolution is deferred to the bundler internals. Emitting real +versions means, at emission time, resolving each shared dep from +`compiler.context` (the pattern `DevelopmentPlugin.ts:100-118` already uses for +RN's own `package.json`) and, where available, cross-checking against +compilation stats. No change to how the bundler resolves anything — the +manifest **observes**, it never alters resolution. + +### Native module detection — honest scope + +- **Build-time (static):** walk the compilation module graph; map every module + that resolves inside `node_modules/` with native code (presence of + `ios/`/`android/` or `codegenConfig` in `package.json`) to a declaration. + This is the same dependency→capability table pattern as + `commands/common/config/validatePlugins.ts`, inverted (we emit instead of warn). +- **Honesty flag:** if any dynamic `require()`/template-literal import is found + in the graph, `dynamicImportDetected: true` is set. The doctor must treat a + `heuristic`/possibly-incomplete list as "verify these", never as a green + checkmark. We never claim exhaustive static guarantees we cannot make. +- **Runtime (later PR, separate):** wrap remote module resolution failures + (ScriptManager/ResolverPlugin) to report + `remote X requested NativeY; host manifest does not declare it` instead of a + raw crash. Build-time manifest + runtime error-context are complementary. + +### Backward compatibility — non-negotiables + +1. **Opt-in.** Default `manifest: false` (or absent). With the flag absent, + plugin output is byte-identical to today. This is test-enforced with + existing snapshot tests. +2. **No unconditional hook taps.** Existing MF plugin tests use bare compiler + mocks without `hooks` (`__tests__/ModuleFederationPluginV1.test.ts:8-15`); + hook registration happens only when the option is enabled. +3. **Emit via `afterProcessAssets`** (the `ManifestPlugin.ts:14-32` precedent), + bundler-agnostic (`compilation.emitAsset` + `compiler.webpack.sources.RawSource` + work on both webpack and Rspack — no `isRspackCompiler` branch needed). +4. **Filename collisions:** dev-server asset allowlist + (`commands/consts.ts:37-48`) must serve the new filename; verify the emitted + `.json` passing through `AssetsCopyProcessor` (it rewrites the + ManifestPlugin's `.json` today) and `OutputPlugin`'s entry-chunk assertions. +5. **Migration path to default-on:** opt-in for a minor line → docs + codemod + that adds the flag → default-on in the next major, with `manifest: false` + escape hatch retained. No breaking change before that major. + +## Delivery plan — one feature per PR + +Each PR is shippable alone and lands with docs in the same PR. + +- **PR 1 — Manifest emission.** `manifest` option on V1+V2, schema v1, + resolved versions, native module block, dev-server allowlist, unit tests, + docs page (`website/src/latest/docs/features/`), agent_context kept in sync. +- **PR 2 — Manifest inspection CLI.** `repack federation manifest `: + pretty-prints a manifest (local file, built output dir, or remote URL). + Trivially useful, first consumer, validates schema ergonomics. +- **PR 3 — `repack federation doctor`.** Inputs: host + list of remotes + (local paths or URLs). Compares manifests: shared-version/range drift, + singleton/eager mismatches, native modules the host does not declare. + `--format json` + exit codes so it runs as a CI gate (multi-repo story). +- **PR 4 — Single-source shared config + retrofit.** `defineShared()` helper + (or shared `shared.config.ts` convention) deriving versions from real + `package.json`; codemod `repack federation init` that generates/repairs + host & remote configs from installed versions. +- **PR 5 — Dev runner (interactive).** `repack federation dev`: light + `@clack/prompts`-style selection (which remotes, iOS/Android, auto ports), + then exits interactive mode and streams raw logs in plain scrollable + terminal output. Equal non-interactive flags (`--ios --remotes cart,catalog + --ci`) for CI. Status dashboard (N servers, ports, health) as a web page, + not a TUI — bundler output and alternate-screen UIs fight each other. +- **PR 6+ — Runtime mismatch context** (ResolverPlugin/ScriptManager error + enrichment), roadmap debt (#1420 V1/V2 duality, v5 docs, Expo #1413). + +## User interaction contract + +Every shipped command must have: deterministic non-interactive flags (CI +parity with the wizard), human-readable default output, `--json` for +machines, actionable messages that name the package and the versions in +conflict, and a docs page with copy-pasteable examples. +## Open decisions + +- [x] Manifest filename/option naming (proposed above) — confirmed; shipped as + `manifest` / `repack-federation-manifest.json` in PR 1. +- [x] `buildVersion` source: chain implemented in PR 1 — `git rev-parse + --short HEAD` in `compiler.context` (non-fatal), else root `package.json` + `version`, else `"unknown"`. +- [ ] Doctor host input: does the host need the manifest option enabled, or + may the doctor fall back to `package.json` heuristics with a `degraded` + badge? + +## PR 1 implementation notes (as built) + +Deltas from the design above, all deliberate: + +- **Option collision in V2.** `ModuleFederationPluginOptions` from + `@module-federation/sdk` already declares `manifest?: boolean | + PluginManifestOptions` (it configures the wrapped plugin's own + `mf-manifest.json`). Repack V2 now **consumes** `manifest` for the Repack + manifest and does not forward it to the inner plugin; the inner plugin keeps + its default behavior and still emits `mf-manifest.json`. Consequence: V2 + users can no longer tune the upstream manifest options through Repack's + config. Accepted tradeoff for naming symmetry; documented on the features + page. +- **`reactNative.newArch` omitted in v1 output.** Not reliably detectable from + the bundler context (it is an app build flag, not a JS graph fact). The + field is reserved in the schema type but never written; PR 3 (doctor) must + not depend on it. +- **`reactNative.note` added** (additive string field): explains disabled + (`nativeAnalysis: false`), degraded (scan threw), or non-exhaustive + (dynamic import detected) native lists. +- **Confidence downgrade:** when `dynamicImportDetected` is true, every + `static` entry drops to `heuristic`; the label reflects list completeness, + not just per-package evidence. +- **Dev-server allowlist** covers the default filename only; a custom + `manifest.fileName` is served from disk/CDN, not the dev-server asset + route. Revisit if users ask. +- **Emission shape:** pretty-printed JSON via `compilation.emitAsset` in + `compilation.hooks.afterProcessAssets`; collision with an existing asset of + the same name skips emission with a warning instead of erroring. +- **Native toggle named `nativeAnalysis`.** The sketch above had `native?:`; + the shipped boolean is `nativeAnalysis` to avoid reading as "include native + code". +- **Chunk-level detachment verified:** `emitAsset` without chunk association + keeps the manifest out of `chunk.auxiliaryFiles`, which is what keeps + `OutputPlugin`/`AssetsCopyProcessor` (chunk-iteration based) from touching + it; asserted in `federationManifestCompilation.test.ts` against a real + rspack run plus a direct `AssetsCopyProcessor` memfs test. + + +## Referenced surface (verified 2026-09) + +- `packages/repack/src/plugins/ModuleFederationPluginV1.ts` / `V2.ts` — no + compiler hooks today; `apply()` is config-munging then delegates; hook taps + go inside `apply()` gated on the option. +- `packages/repack/src/plugins/ManifestPlugin.ts:14-32` — emit pattern. +- `packages/repack/src/plugins/DevelopmentPlugin.ts:100-118` — resolved + version read pattern. +- `packages/repack/src/commands/consts.ts:37-48` — dev-server asset allowlist. +- `packages/repack/src/modules/FederationRuntimePlugins/ResolverPlugin.ts:43-86` + — existing runtime consumer of upstream `mf-manifest.json` (version-as-URL). diff --git a/website/src/latest/docs/features/_meta.json b/website/src/latest/docs/features/_meta.json index 65f77ab24..fe3e9adde 100644 --- a/website/src/latest/docs/features/_meta.json +++ b/website/src/latest/docs/features/_meta.json @@ -2,6 +2,7 @@ "module-resolution", "code-splitting", "module-federation", + "federation-manifest", "dev-server", "flow-support", "devtools", diff --git a/website/src/latest/docs/features/federation-manifest.md b/website/src/latest/docs/features/federation-manifest.md new file mode 100644 index 000000000..c5a4a1071 --- /dev/null +++ b/website/src/latest/docs/features/federation-manifest.md @@ -0,0 +1,153 @@ +# Federation Manifest + +Running Module Federation across several apps means host and remotes live in +different repos, get upgraded at different times, and share dependencies that +have to agree on versions. Today nothing checks that agreement. A shared +package upgraded on one side, or a native module that only the host ships, +shows up as a crash in production. The federation manifest is the first tool +for catching that before release: at build time, Re.Pack writes a JSON file +describing what the container actually contains, and you can diff it against +the other side. + +The option is opt-in. Without it, nothing about your build output changes. + +## Enabling it + +Add `manifest: true` to either `ModuleFederationPlugin` version: + +```ts +// webpack.config.mts +new Repack.plugins.ModuleFederationPlugin({ + name: 'catalog', + exposes: { + './Home': './src/Home', + }, + shared: { + react: { singleton: true, eager: true }, + 'react-native': { singleton: true, eager: true }, + 'react-native-svg': { singleton: true }, + }, + manifest: true, +}); +``` + +The build now emits an extra file, `repack-federation-manifest.json`, next to +your bundles in the output directory. The development server serves it too, at +`http://localhost:8081/repack-federation-manifest.json`, so you can inspect a +running container with `curl | jq`. + +## Options + +| Option | Type | Default | Description | +| ---------------- | --------- | -------------------------------- | ---------------------------------------------------- | +| `fileName` | `string` | `repack-federation-manifest.json`| Asset name of the emitted file | +| `filePath` | `string` | output root | Subdirectory of the build output to emit into | +| `nativeAnalysis` | `boolean` | `true` | Scan the module graph for native modules | + +```ts +manifest: { + fileName: 'catalog-manifest.json', + filePath: 'federation', +} +``` + +With `nativeAnalysis: false`, the `reactNative.nativeModules` list stays empty +and the manifest says so in its `note` field. Use it if the scan is too slow +for your setup or you do not care about the native block yet. + +## What the manifest looks like + +A trimmed example from a remote called `catalog`: + +```json +{ + "manifestVersion": 1, + "id": "catalog", + "name": "catalog", + "metaData": { + "name": "catalog", + "globalName": "catalog", + "type": "remote", + "buildInfo": { "buildVersion": "1a2b3c4", "buildName": "catalog" }, + "remoteEntry": { "name": "catalog.container.bundle", "path": "", "type": "var" }, + "publicPath": "auto" + }, + "shared": [ + { + "name": "react", + "version": "19.1.0", + "singleton": true, + "eager": true, + "requiredVersion": "*" + }, + { + "name": "react-native-svg", + "version": "15.11.2", + "singleton": true, + "eager": false, + "requiredVersion": "*" + } + ], + "remotes": [], + "exposes": [{ "id": "catalog:Home", "name": "Home", "path": "./Home" }], + "reactNative": { + "version": "0.80.1", + "platforms": ["ios", "android"], + "nativeModules": [ + { + "package": "react-native-svg", + "version": "15.11.2", + "turboModule": false, + "confidence": "static" + } + ], + "dynamicImportDetected": false + } +} +``` + +Two details are worth calling out. + +The `version` in each `shared` entry is the version installed in your +`node_modules`, resolved at build time. Your config may say +`requiredVersion: '*'` (the default in both plugins), and the manifest reports +what that star actually resolved to. That is the number you want when checking +host against remote. + +`metaData` follows the shape of the upstream Module Federation 2.0 +`mf-manifest.json`, so tooling written against that spec can read the common +fields. The `reactNative` block is the Re.Pack-specific addition, and +`manifestVersion` is the compatibility contract for anyone consuming the file. + +## How native modules are detected + +The scan walks every module in the compilation, finds the ones that resolved +inside `node_modules`, and asks a question about the owning package: does it +have an `ios/` or `android/` directory, or a `codegenConfig`, or a +`react-native.config.js`, or the `react-native` keyword? Direct evidence +(native folders, codegen config) gets `confidence: "static"`. Keyword or +config-file presence gets `confidence: "heuristic"`. + +There is a hole in every static scan: `require(someVariable)` with a computed +path. When webpack or Rspack reports one (the "Critical dependency" warning), +the manifest sets `dynamicImportDetected: true` and every entry drops to +`heuristic`, because the list can no longer claim to be complete. Treat the +list as "verify these", never as a green checkmark. + +A note on `mf-manifest.json`: the `@module-federation/enhanced` plugin that +`ModuleFederationPluginV2` wraps emits its own `mf-manifest.json` with its own +defaults. The `manifest` option here belongs to Re.Pack, controls only the +`repack-federation-manifest.json` file, and is not forwarded to the wrapped +plugin. The two files coexist. + +## Where this is going + +The manifest is the base primitive for a set of federation tools. The follow-up +work is a `repack federation` CLI: one command to pretty-print a manifest from +a file or URL, and a doctor that takes a host plus its remotes and reports +version drift, singleton or eager mismatches, and native modules the host does +not declare, with exit codes you can run as a CI gate. + +Adopt it today by turning the flag on in host and remotes and keeping the +output next to your bundles. Even before the doctor lands, the diff between +two manifests you can read with your eyes is already hard to argue with. From 6205e346161aad3cdef5e7141fdebd780dc9a4a4 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 13:51:51 +0200 Subject: [PATCH 4/7] feat(repack): add federation manifest inspection and doctor commands --- .changeset/federation-manifest.md | 2 + agent_context/federation-tools/design.md | 46 ++- .../__tests__/federationDoctor.test.ts | 163 ++++++++++ .../__tests__/federationManifest.test.ts | 109 +++++++ .../src/commands/__tests__/index.test.ts | 2 + .../__tests__/__fixtures__/host.json | 64 ++++ .../__tests__/__fixtures__/remote-clean.json | 57 ++++ .../__fixtures__/remote-conflicting.json | 63 ++++ .../repack-federation-manifest.json | 64 ++++ .../federation/__tests__/doctor.test.ts | 258 ++++++++++++++++ .../federation/__tests__/inspect.test.ts | 50 ++++ .../federation/__tests__/loadManifest.test.ts | 202 +++++++++++++ .../repack/src/commands/federation/doctor.ts | 278 ++++++++++++++++++ .../repack/src/commands/federation/inspect.ts | 103 +++++++ .../src/commands/federation/loadManifest.ts | 187 ++++++++++++ .../src/commands/federation/semverRange.ts | 119 ++++++++ .../repack/src/commands/federationDoctor.ts | 126 ++++++++ .../repack/src/commands/federationManifest.ts | 51 ++++ packages/repack/src/commands/index.ts | 34 ++- packages/repack/src/commands/options.ts | 34 +++ packages/repack/src/commands/types.ts | 13 + website/src/latest/api/cli/_meta.json | 10 + .../src/latest/api/cli/federation-doctor.mdx | 132 +++++++++ .../latest/api/cli/federation-manifest.mdx | 65 ++++ 24 files changed, 2223 insertions(+), 9 deletions(-) create mode 100644 packages/repack/src/commands/__tests__/federationDoctor.test.ts create mode 100644 packages/repack/src/commands/__tests__/federationManifest.test.ts create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/host.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/remote-clean.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/remote-conflicting.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/repack-federation-manifest.json create mode 100644 packages/repack/src/commands/federation/__tests__/doctor.test.ts create mode 100644 packages/repack/src/commands/federation/__tests__/inspect.test.ts create mode 100644 packages/repack/src/commands/federation/__tests__/loadManifest.test.ts create mode 100644 packages/repack/src/commands/federation/doctor.ts create mode 100644 packages/repack/src/commands/federation/inspect.ts create mode 100644 packages/repack/src/commands/federation/loadManifest.ts create mode 100644 packages/repack/src/commands/federation/semverRange.ts create mode 100644 packages/repack/src/commands/federationDoctor.ts create mode 100644 packages/repack/src/commands/federationManifest.ts create mode 100644 website/src/latest/api/cli/federation-doctor.mdx create mode 100644 website/src/latest/api/cli/federation-manifest.mdx diff --git a/.changeset/federation-manifest.md b/.changeset/federation-manifest.md index 00b26e0df..b9cbb0b15 100644 --- a/.changeset/federation-manifest.md +++ b/.changeset/federation-manifest.md @@ -3,3 +3,5 @@ --- Add an opt-in `manifest` option to both module federation plugins. When set, the build emits `repack-federation-manifest.json` next to the bundle: shared dependencies report the versions actually installed in `node_modules` instead of the `*` range the plugins configure by default, and an additive `reactNative` block lists the native modules found in the module graph. Field shapes follow the upstream `mf-manifest.json` spec, so existing tooling can parse the file as-is. With the option absent, builds are byte-identical to before. + +Two commands consume the manifest. `npx react-native federation-manifest ` prints a human-readable summary of what a host or remote shipped, or the raw document with `--json`. `npx react-native federation-doctor --host --remotes ` compares a host manifest against its remotes and reports singleton version drift, `singleton`/`eager` mismatches, unresolvable `requiredVersion` ranges, native modules the host does not declare, and remotes that ship no manifest. It exits 1 on drift and 2 when a check could not run, so it works as a CI gate; `--format json` and `--allow-missing-manifests` cover scripts and gradual rollout. diff --git a/agent_context/federation-tools/design.md b/agent_context/federation-tools/design.md index b349201b5..5a01ee7d3 100644 --- a/agent_context/federation-tools/design.md +++ b/agent_context/federation-tools/design.md @@ -146,13 +146,30 @@ Each PR is shippable alone and lands with docs in the same PR. - **PR 1 — Manifest emission.** `manifest` option on V1+V2, schema v1, resolved versions, native module block, dev-server allowlist, unit tests, docs page (`website/src/latest/docs/features/`), agent_context kept in sync. -- **PR 2 — Manifest inspection CLI.** `repack federation manifest `: +- **PR 2 — Manifest inspection CLI.** `federation-manifest `: pretty-prints a manifest (local file, built output dir, or remote URL). Trivially useful, first consumer, validates schema ergonomics. -- **PR 3 — `repack federation doctor`.** Inputs: host + list of remotes +- **PR 3 — `federation-doctor`.** Inputs: host + list of remotes (local paths or URLs). Compares manifests: shared-version/range drift, singleton/eager mismatches, native modules the host does not declare. `--format json` + exit codes so it runs as a CI gate (multi-repo story). + - **Command surface correction (as shipped).** There is no `repack` + binary and no `repack federation ...` subcommand tree. Both commands are + flat entries in the RN Community CLI `commands` array + (`packages/repack/src/commands/index.ts`, surfaced through + `react-native.config.js`), invoked as + `npx react-native federation-manifest|federation-doctor`. + `createBoundCommands` (deprecated webpack/rspack entry points) excludes + them — they are bundler-independent. + - **Exit codes locked:** `0` clean (warnings/infos allowed); `1` drift — + any error-severity finding, including `MISSING_REMOTE_MANIFEST` unless + `--allow-missing-manifests` downgrades it to a warning; `2` the check + could not run — missing required option, host manifest not found, or a + corrupt (invalid) manifest on any side. 2 means "no answer", 1 means + "bad answer"; CI treats both as failure. + - **No degraded host fallback.** The host must ship a manifest; the + doctor does not fall back to `package.json` heuristics (closes the open + decision below). - **PR 4 — Single-source shared config + retrofit.** `defineShared()` helper (or shared `shared.config.ts` convention) deriving versions from real `package.json`; codemod `repack federation init` that generates/repairs @@ -179,9 +196,9 @@ conflict, and a docs page with copy-pasteable examples. - [x] `buildVersion` source: chain implemented in PR 1 — `git rev-parse --short HEAD` in `compiler.context` (non-fatal), else root `package.json` `version`, else `"unknown"`. -- [ ] Doctor host input: does the host need the manifest option enabled, or - may the doctor fall back to `package.json` heuristics with a `degraded` - badge? +- [x] Doctor host input: resolved at implementation — the host must ship a + manifest; missing or corrupt host manifest exits 2, no `package.json` + heuristic fallback. ## PR 1 implementation notes (as built) @@ -222,6 +239,25 @@ Deltas from the design above, all deliberate: rspack run plus a direct `AssetsCopyProcessor` memfs test. +## PR 2/3 CLI notes (as built) + +- **Heuristic honesty, enforced in the doctor:** a host native list is + trusted only when no `dynamicImportDetected` flag and no + `confidence: heuristic` entry is present; otherwise missing-native-module + findings downgrade to `HEURISTIC_ADVISORY` warnings. Unsupported + `requiredVersion` syntax yields `SHARED_RANGE_UNSUPPORTED` (warning) and + unknown singleton versions yield `VERSION_UNKNOWN` (info) — the doctor + reports what it cannot check instead of guessing or passing silently. +- **Host-app-project native-module caveat:** the host manifest's + `nativeModules` is node_modules-scope; a module wired from the host's app + project won't be listed. `MISSING_NATIVE_MODULE` (error on a trusted host + list) names this case in its message and asks for manual verification — + the manifest cannot distinguish "absent" from "native to the app project". +- **Corrupt vs missing asymmetry:** a missing remote manifest is a finding + (exit 1, escapable with `--allow-missing-manifests`); a corrupt one aborts + with exit 2 — results from an unparseable manifest cannot be trusted, so + the escape hatch deliberately does not cover it. + ## Referenced surface (verified 2026-09) - `packages/repack/src/plugins/ModuleFederationPluginV1.ts` / `V2.ts` — no diff --git a/packages/repack/src/commands/__tests__/federationDoctor.test.ts b/packages/repack/src/commands/__tests__/federationDoctor.test.ts new file mode 100644 index 000000000..e8f86f306 --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationDoctor.test.ts @@ -0,0 +1,163 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { federationDoctor } from '../federationDoctor.js'; +import type { CliConfig } from '../types.js'; + +const FIXTURES = path.join( + __dirname, + '..', + 'federation', + '__tests__', + '__fixtures__' +); +const HOST_FILE = path.join(FIXTURES, 'host.json'); +const CLEAN_REMOTE = path.join(FIXTURES, 'remote-clean.json'); +const DRIFT_REMOTE = path.join(FIXTURES, 'remote-conflicting.json'); + +const cliConfig: CliConfig = { + root: '/project', + platforms: ['ios'], + reactNativePath: '/project/node_modules/react-native', +}; + +let tmpDir: string; +let invalidRemote: string; +let log: jest.SpyInstance; +let error: jest.SpyInstance; +let exit: jest.SpyInstance; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-doctor-cmd-')); + invalidRemote = path.join(tmpDir, 'invalid.json'); + fs.writeFileSync(invalidRemote, 'not json at all'); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + log = jest.spyOn(console, 'log').mockImplementation(() => {}); + error = jest.spyOn(console, 'error').mockImplementation(() => {}); + exit = jest + .spyOn(process, 'exit') + .mockImplementation( + (() => undefined) as (code?: string | number | null) => never + ); +}); + +function stdout(): string { + return log.mock.calls.map(([line]) => String(line)).join('\n'); +} + +describe('federation-doctor command', () => { + it('exits 0 and prints a clean report when nothing drifts', async () => { + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: CLEAN_REMOTE, + }); + + expect(stdout()).toContain('no issues found'); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('exits 1 and names the drift for a conflicting remote', async () => { + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: DRIFT_REMOTE, + }); + + expect(stdout()).toContain('SHARED_VERSION_DRIFT'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('exits 2 when the host manifest is missing', async () => { + await federationDoctor([], cliConfig, { + host: path.join(tmpDir, 'no-host.json'), + remotes: CLEAN_REMOTE, + }); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('Host manifest') + ); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('exits 2 when a remote manifest exists but is corrupt', async () => { + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: invalidRemote, + }); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('cannot be trusted') + ); + expect(exit).toHaveBeenCalledWith(2); + expect(log).not.toHaveBeenCalled(); + }); + + it('exits 1 for a missing remote manifest, 0 with --allow-missing-manifests', async () => { + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: path.join(tmpDir, 'no-remote.json'), + }); + expect(stdout()).toContain('MISSING_REMOTE_MANIFEST'); + expect(exit).toHaveBeenCalledWith(1); + + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: path.join(tmpDir, 'no-remote.json'), + allowMissingManifests: true, + }); + expect(stdout()).toContain('MISSING_REMOTE_MANIFEST'); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('splits a comma-separated --remotes list', async () => { + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: `${DRIFT_REMOTE},${CLEAN_REMOTE}`, + }); + + expect(stdout()).toContain('SHARED_VERSION_DRIFT'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('accepts an array of remotes from repeated flags', async () => { + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: [CLEAN_REMOTE, DRIFT_REMOTE], + }); + + expect(stdout()).toContain('SHARED_VERSION_DRIFT'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('prints parseable findings JSON as the only stdout write with --format json', async () => { + await federationDoctor([], cliConfig, { + host: HOST_FILE, + remotes: DRIFT_REMOTE, + format: 'json', + }); + + expect(log).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(log.mock.calls[0][0] as string) as { + findings: Array<{ code: string }>; + }; + expect(parsed.findings.map((finding) => finding.code)).toContain( + 'SHARED_VERSION_DRIFT' + ); + }); + + it('exits 2 when required options are absent', async () => { + await federationDoctor([], cliConfig, { remotes: CLEAN_REMOTE }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('--host')); + + await federationDoctor([], cliConfig, { host: HOST_FILE }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('--remotes')); + + expect(exit).toHaveBeenCalledTimes(2); + expect(exit).toHaveBeenLastCalledWith(2); + }); +}); diff --git a/packages/repack/src/commands/__tests__/federationManifest.test.ts b/packages/repack/src/commands/__tests__/federationManifest.test.ts new file mode 100644 index 000000000..fb75fd8ee --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationManifest.test.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { FederationManifest } from '../../plugins/federationManifest/types.js'; +import { federationManifest } from '../federationManifest.js'; +import type { CliConfig } from '../types.js'; + +const FIXTURES = path.join( + __dirname, + '..', + 'federation', + '__tests__', + '__fixtures__' +); +const HOST_FILE = path.join(FIXTURES, 'host.json'); + +const cliConfig: CliConfig = { + root: '/project', + platforms: ['ios'], + reactNativePath: '/project/node_modules/react-native', +}; + +let tmpDir: string; +let log: jest.SpyInstance; +let error: jest.SpyInstance; +let exit: jest.SpyInstance; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-manifest-cmd-')); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + log = jest.spyOn(console, 'log').mockImplementation(() => {}); + error = jest.spyOn(console, 'error').mockImplementation(() => {}); + exit = jest + .spyOn(process, 'exit') + .mockImplementation( + (() => undefined) as (code?: string | number | null) => never + ); +}); + +function stdout(): string { + return log.mock.calls.map(([line]) => String(line)).join('\n'); +} + +describe('federation-manifest command', () => { + it('prints the formatted manifest for a positional source', async () => { + await federationManifest([HOST_FILE], cliConfig, {}); + + expect(stdout()).toContain('shell'); + expect(stdout()).toContain('shared (3)'); + expect(exit).not.toHaveBeenCalled(); + }); + + it('accepts --source and prints parseable JSON as the only stdout write', async () => { + await federationManifest([], cliConfig, { source: HOST_FILE, json: true }); + + expect(log).toHaveBeenCalledTimes(1); + const parsed = JSON.parse( + log.mock.calls[0][0] as string + ) as FederationManifest; + expect(parsed.name).toBe('shell'); + }); + + it('prefers the positional source over --source', async () => { + await federationManifest([HOST_FILE], cliConfig, { + source: '/does/not/matter.json', + json: true, + }); + + expect(log).toHaveBeenCalledTimes(1); + expect(exit).not.toHaveBeenCalled(); + }); + + it('exits 2 when no source is given', async () => { + await federationManifest([], cliConfig, {}); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('--source')); + expect(exit).toHaveBeenCalledWith(2); + expect(log).not.toHaveBeenCalled(); + }); + + it('exits 2 with an actionable message for an unresolvable source', async () => { + const missing = path.join(tmpDir, 'nope.json'); + + await federationManifest([missing], cliConfig, {}); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('ManifestNotFoundError') + ); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('exits 2 with an actionable message for a corrupt manifest', async () => { + const corrupt = path.join(tmpDir, 'corrupt.json'); + fs.writeFileSync(corrupt, '{"manifestVersion": 1, '); + + await federationManifest([corrupt], cliConfig, {}); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('ManifestInvalidError') + ); + expect(exit).toHaveBeenCalledWith(2); + }); +}); diff --git a/packages/repack/src/commands/__tests__/index.test.ts b/packages/repack/src/commands/__tests__/index.test.ts index ecf0f5c2e..916707d49 100644 --- a/packages/repack/src/commands/__tests__/index.test.ts +++ b/packages/repack/src/commands/__tests__/index.test.ts @@ -3,6 +3,8 @@ import { createBoundCommands } from '../index.js'; import type { BundleArguments, CliConfig, StartArguments } from '../types.js'; jest.mock('../bundle.js'); +jest.mock('../federationDoctor.js'); +jest.mock('../federationManifest.js'); jest.mock('../start.js'); const cliConfig: CliConfig = { diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/host.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/host.json new file mode 100644 index 000000000..85e59aace --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/host.json @@ -0,0 +1,64 @@ +{ + "manifestVersion": 1, + "id": "shell", + "name": "shell", + "metaData": { + "name": "shell", + "globalName": "shell", + "type": "host", + "buildInfo": { "buildVersion": "abc1234", "buildName": "shell" }, + "publicPath": "auto" + }, + "shared": [ + { + "name": "react", + "version": "19.0.0", + "singleton": true, + "eager": true, + "requiredVersion": "^19.0.0" + }, + { + "name": "react-native", + "version": "0.79.2", + "singleton": true, + "eager": true, + "requiredVersion": "~0.79.2" + }, + { + "name": "zustand", + "version": "5.0.3", + "singleton": true, + "eager": false, + "requiredVersion": "^5.0.0" + } + ], + "remotes": [ + { + "federationContainerName": "store", + "moduleName": "store", + "alias": "store", + "entry": "http://localhost:5001/store.container.js" + } + ], + "exposes": [], + "reactNative": { + "version": "0.79.2", + "platforms": ["ios", "android"], + "nativeModules": [ + { + "package": "react-native-reanimated", + "version": "3.17.1", + "turboModule": true, + "confidence": "static" + }, + { + "package": "react-native-gesture-handler", + "version": "2.24.0", + "turboModule": false, + "confidence": "static" + } + ], + "dynamicImportDetected": false, + "note": "Native module list covers statically imported modules." + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/remote-clean.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/remote-clean.json new file mode 100644 index 000000000..68d11fb3d --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/remote-clean.json @@ -0,0 +1,57 @@ +{ + "manifestVersion": 1, + "id": "store", + "name": "store", + "metaData": { + "name": "store", + "globalName": "store", + "type": "remote", + "buildInfo": { "buildVersion": "def5678", "buildName": "store" }, + "remoteEntry": { + "name": "store.container.js", + "path": "", + "type": "var" + }, + "publicPath": "auto" + }, + "shared": [ + { + "name": "react", + "version": "19.0.0", + "singleton": true, + "eager": true, + "requiredVersion": "^19.0.0" + }, + { + "name": "react-native", + "version": "0.79.2", + "singleton": true, + "eager": true, + "requiredVersion": "~0.79.2" + }, + { + "name": "zustand", + "version": "5.0.3", + "singleton": true, + "eager": false, + "requiredVersion": "^5.0.0" + } + ], + "remotes": [], + "exposes": [ + { "id": "store:Button", "name": "Button", "path": "./src/Button" } + ], + "reactNative": { + "version": "0.79.2", + "platforms": ["ios", "android"], + "nativeModules": [ + { + "package": "react-native-reanimated", + "version": "3.17.1", + "turboModule": true, + "confidence": "static" + } + ], + "dynamicImportDetected": false + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/remote-conflicting.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/remote-conflicting.json new file mode 100644 index 000000000..8f3ba426a --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/remote-conflicting.json @@ -0,0 +1,63 @@ +{ + "manifestVersion": 1, + "id": "store", + "name": "store", + "metaData": { + "name": "store", + "globalName": "store", + "type": "remote", + "buildInfo": { "buildVersion": "9f0e1c2", "buildName": "store" }, + "remoteEntry": { + "name": "store.container.js", + "path": "", + "type": "var" + }, + "publicPath": "auto" + }, + "shared": [ + { + "name": "react", + "version": "19.1.0", + "singleton": true, + "eager": true, + "requiredVersion": "^19.0.0" + }, + { + "name": "react-native", + "version": "0.79.2", + "singleton": true, + "eager": false, + "requiredVersion": "~0.74.5" + }, + { + "name": "zustand", + "version": "5.0.3", + "singleton": false, + "eager": false, + "requiredVersion": "^5.0.0" + } + ], + "remotes": [], + "exposes": [ + { "id": "store:Checkout", "name": "Checkout", "path": "./src/Checkout" } + ], + "reactNative": { + "version": "0.79.2", + "platforms": ["ios", "android"], + "nativeModules": [ + { + "package": "react-native-reanimated", + "version": "3.17.1", + "turboModule": true, + "confidence": "static" + }, + { + "package": "react-native-maps", + "version": "1.20.1", + "turboModule": false, + "confidence": "static" + } + ], + "dynamicImportDetected": false + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/repack-federation-manifest.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/repack-federation-manifest.json new file mode 100644 index 000000000..85e59aace --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/repack-federation-manifest.json @@ -0,0 +1,64 @@ +{ + "manifestVersion": 1, + "id": "shell", + "name": "shell", + "metaData": { + "name": "shell", + "globalName": "shell", + "type": "host", + "buildInfo": { "buildVersion": "abc1234", "buildName": "shell" }, + "publicPath": "auto" + }, + "shared": [ + { + "name": "react", + "version": "19.0.0", + "singleton": true, + "eager": true, + "requiredVersion": "^19.0.0" + }, + { + "name": "react-native", + "version": "0.79.2", + "singleton": true, + "eager": true, + "requiredVersion": "~0.79.2" + }, + { + "name": "zustand", + "version": "5.0.3", + "singleton": true, + "eager": false, + "requiredVersion": "^5.0.0" + } + ], + "remotes": [ + { + "federationContainerName": "store", + "moduleName": "store", + "alias": "store", + "entry": "http://localhost:5001/store.container.js" + } + ], + "exposes": [], + "reactNative": { + "version": "0.79.2", + "platforms": ["ios", "android"], + "nativeModules": [ + { + "package": "react-native-reanimated", + "version": "3.17.1", + "turboModule": true, + "confidence": "static" + }, + { + "package": "react-native-gesture-handler", + "version": "2.24.0", + "turboModule": false, + "confidence": "static" + } + ], + "dynamicImportDetected": false, + "note": "Native module list covers statically imported modules." + } +} diff --git a/packages/repack/src/commands/federation/__tests__/doctor.test.ts b/packages/repack/src/commands/federation/__tests__/doctor.test.ts new file mode 100644 index 000000000..6c85f22e5 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/doctor.test.ts @@ -0,0 +1,258 @@ +import type { FederationManifest } from '../../../plugins/federationManifest/types.js'; +import { + doctorExitCode, + doctorReportToJson, + formatDoctorReport, + runDoctor, +} from '../doctor.js'; +import { rangesIntersect } from '../semverRange.js'; +import hostFixture from './__fixtures__/host.json'; +import remoteCleanFixture from './__fixtures__/remote-clean.json'; +import remoteConflictingFixture from './__fixtures__/remote-conflicting.json'; + +const host = hostFixture as unknown as FederationManifest; +const remoteClean = remoteCleanFixture as unknown as FederationManifest; +const remoteConflicting = + remoteConflictingFixture as unknown as FederationManifest; + +function clone(manifest: FederationManifest): FederationManifest { + return JSON.parse(JSON.stringify(manifest)) as FederationManifest; +} + +function codes(report: ReturnType): string[] { + return report.findings.map((finding) => finding.code); +} + +function findingFor(code: string) { + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + const finding = report.findings.find((entry) => entry.code === code); + if (!finding) throw new Error(`No ${code} finding in ${codes(report)}`); + return finding; +} + +describe('runDoctor', () => { + it('reports nothing for a matching host and remote', () => { + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteClean }], + }); + + expect(report.findings).toEqual([]); + expect(doctorExitCode(report)).toBe(0); + }); + + it('flags a singleton version drift naming both versions', () => { + const finding = findingFor('SHARED_VERSION_DRIFT'); + + expect(finding.severity).toBe('error'); + expect(finding.message).toContain('react'); + expect(finding.message).toContain('19.0.0'); + expect(finding.message).toContain('19.1.0'); + expect(finding.message).toContain('shell'); + expect(finding.message).toContain('store'); + }); + + it('warns when declared ranges cannot intersect', () => { + const finding = findingFor('SHARED_RANGE_UNRESOLVABLE'); + + expect(finding.severity).toBe('warning'); + expect(finding.message).toContain('react-native'); + expect(finding.message).toContain('~0.79.2'); + expect(finding.message).toContain('~0.74.5'); + }); + + it('flags singleton and eager mismatches naming both values', () => { + const singleton = findingFor('SINGLETON_MISMATCH'); + expect(singleton.severity).toBe('error'); + expect(singleton.message).toContain('true'); + expect(singleton.message).toContain('false'); + + const eager = findingFor('EAGER_MISMATCH'); + expect(eager.severity).toBe('error'); + expect(eager.message).toContain('true'); + expect(eager.message).toContain('false'); + }); + + it('errors when a remote native module is absent from a trusted host list', () => { + const finding = findingFor('MISSING_NATIVE_MODULE'); + + expect(finding.severity).toBe('error'); + expect(finding.message).toContain('react-native-maps'); + expect(finding.message).toContain('store'); + expect(finding.message).toMatch( + /if the host provides this module from its app project rather than node_modules, verify manually/i + ); + }); + + it('downgrades the native-module finding to an advisory when the host uses dynamic imports', () => { + const dynamicHost = clone(host); + dynamicHost.reactNative.dynamicImportDetected = true; + + const report = runDoctor({ + host: dynamicHost, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + + expect(codes(report)).not.toContain('MISSING_NATIVE_MODULE'); + expect(codes(report)).toContain('HEURISTIC_ADVISORY'); + const advisory = report.findings.find( + (finding) => finding.code === 'HEURISTIC_ADVISORY' + ); + expect(advisory?.severity).toBe('warning'); + expect(advisory?.message).toContain('verify manually'); + }); + + it('downgrades the native-module finding when the host list is heuristic', () => { + const heuristicHost = clone(host); + heuristicHost.reactNative.nativeModules[0]!.confidence = 'heuristic'; + + const report = runDoctor({ + host: heuristicHost, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + + expect(codes(report)).not.toContain('MISSING_NATIVE_MODULE'); + expect(codes(report)).toContain('HEURISTIC_ADVISORY'); + }); + + it('notes an unknown singleton version without failing', () => { + const unknownRemote = clone(remoteClean); + unknownRemote.shared[0]!.version = 'unknown'; + + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: unknownRemote }], + }); + + expect(codes(report)).toContain('VERSION_UNKNOWN'); + expect(doctorExitCode(report)).toBe(0); + }); + + it('errors on a missing remote manifest, warning when allowed', () => { + const strict = runDoctor({ + host, + remotes: [{ name: 'payments', missing: true }], + }); + const missing = strict.findings.find( + (finding) => finding.code === 'MISSING_REMOTE_MANIFEST' + ); + expect(missing?.severity).toBe('error'); + expect(missing?.message).toContain('payments'); + expect(missing?.message).toContain('manifest: true'); + expect(doctorExitCode(strict)).toBe(1); + + const lenient = runDoctor({ + host, + remotes: [{ name: 'payments', missing: true }], + allowMissingManifests: true, + }); + expect( + lenient.findings.find( + (finding) => finding.code === 'MISSING_REMOTE_MANIFEST' + )?.severity + ).toBe('warning'); + expect(doctorExitCode(lenient)).toBe(0); + }); + + it('warns about a newer manifest version but keeps comparing', () => { + const newerRemote = clone(remoteClean); + newerRemote.manifestVersion = 2 as 1; + + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: newerRemote }], + }); + + const versionFinding = report.findings.find( + (finding) => finding.code === 'MANIFEST_VERSION_AHEAD' + ); + expect(versionFinding?.severity).toBe('warning'); + expect(versionFinding?.message).toContain('2'); + // Comparison still ran: no spurious errors were introduced. + expect(doctorExitCode(report)).toBe(0); + }); + + it('maps any error finding to exit code 1', () => { + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + + expect(doctorExitCode(report)).toBe(1); + }); +}); + +describe('doctor report rendering', () => { + it('renders a clean summary and lists findings with their codes', () => { + const clean = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteClean }], + }); + expect(formatDoctorReport(clean)).toContain('no issues found'); + + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + const text = formatDoctorReport(report); + expect(text).toContain('SHARED_VERSION_DRIFT'); + expect(text).toContain('error'); + }); + + it('serializes findings with a stable key order', () => { + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + + const parsed = JSON.parse(doctorReportToJson(report)) as { + findings: Array>; + }; + for (const finding of parsed.findings) { + expect(Object.keys(finding)).toEqual(['severity', 'code', 'message']); + } + }); +}); + +describe('rangesIntersect', () => { + it.each([ + // caret + ['^15.0.0', '^15.4.0', true], + ['^15.0.0', '^16.0.0', false], + ['^0.74.5', '^0.74.9', true], + ['^0.74.5', '^0.75.0', false], + ['^0.0.3', '^0.0.4', false], + // tilde + ['~0.74.5', '~0.74.9', true], + ['~0.74.5', '~0.75.0', false], + ['~1.2.3', '~1.3.0', false], + // exact + ['19.0.0', '19.0.0', true], + ['19.0.0', '19.0.1', false], + ['19.0.0', '^19.0.0', true], + ['19.0.0', '^18.0.0', false], + // gte + ['>=18', '19.0.0', true], + ['>=18', '17.9.9', false], + ['>=18', '>=20', true], + // x-ranges + ['19.x', '19.4.0', true], + ['19.x', '20.0.0', false], + ['19.x', '19.x', true], + ['*', '^1.0.0', true], + ] as const)('%s vs %s -> %s', (a, b, expected) => { + expect(rangesIntersect(a, b)).toBe(expected); + }); + + it.each([ + ['^15.x', '15.0.0'], + ['15.0.0 || 16.0.0', '^15.0.0'], + ['15.0.0 - 16.0.0', '^15.0.0'], + ['>15', '15.0.0'], + ])('returns null for unsupported syntax %s', (a, b) => { + expect(rangesIntersect(a, b)).toBeNull(); + }); +}); diff --git a/packages/repack/src/commands/federation/__tests__/inspect.test.ts b/packages/repack/src/commands/federation/__tests__/inspect.test.ts new file mode 100644 index 000000000..c1d606693 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/inspect.test.ts @@ -0,0 +1,50 @@ +import type { FederationManifest } from '../../../plugins/federationManifest/types.js'; +import { formatManifest } from '../inspect.js'; +import hostFixture from './__fixtures__/host.json'; +import remoteConflictingFixture from './__fixtures__/remote-conflicting.json'; + +const host = hostFixture as unknown as FederationManifest; +const remote = remoteConflictingFixture as unknown as FederationManifest; + +describe('formatManifest', () => { + it('renders the core identity, shared deps, remotes and native block', () => { + const output = formatManifest(host); + + expect(output).toContain('shell'); + expect(output).toContain('type:'); + expect(output).toContain('host'); + expect(output).toContain('build:'); + expect(output).toContain('abc1234'); + + expect(output).toContain('shared'); + expect(output).toContain('react'); + expect(output).toContain('19.0.0'); + expect(output).toContain('^19.0.0'); + expect(output).toContain('singleton'); + + expect(output).toContain('remotes'); + expect(output).toContain('store'); + expect(output).toContain('http://localhost:5001/store.container.js'); + + expect(output).toContain('react-native'); + expect(output).toContain('0.79.2'); + expect(output).toContain('react-native-reanimated'); + expect(output).toContain('turbo-module'); + expect(output).toContain('static'); + }); + + it('renders exposes and the heuristic note', () => { + const output = formatManifest(remote); + + expect(output).toContain('exposes'); + expect(output).toContain('Checkout'); + expect(output).toContain('./src/Checkout'); + + const withNote = JSON.parse(JSON.stringify(remote)) as FederationManifest; + withNote.reactNative.note = 'Native module list may be incomplete.'; + + expect(formatManifest(withNote)).toContain( + 'note: Native module list may be incomplete.' + ); + }); +}); diff --git a/packages/repack/src/commands/federation/__tests__/loadManifest.test.ts b/packages/repack/src/commands/federation/__tests__/loadManifest.test.ts new file mode 100644 index 000000000..d59f3cf97 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/loadManifest.test.ts @@ -0,0 +1,202 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + loadManifest, + ManifestInvalidError, + ManifestNotFoundError, +} from '../loadManifest.js'; + +const FIXTURES = path.join(__dirname, '__fixtures__'); + +let tmpDir: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-manifest-')); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function mockFetch( + handler: (url: string) => { ok: boolean; body: unknown } | undefined +) { + return jest + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input: URL | RequestInfo) => { + const url = String(input); + const response = handler(url); + if (!response) throw new Error(`Unexpected fetch: ${url}`); + return { + ok: response.ok, + status: response.ok ? 200 : 404, + json: async () => response.body, + } as Response; + }); +} + +describe('loadManifest', () => { + it('loads a manifest from a file path', async () => { + const file = path.join(FIXTURES, 'host.json'); + + const result = await loadManifest(file); + + expect(result.manifest.name).toBe('shell'); + expect(result.source).toBe(file); + expect(result.resolvedFrom).toBe(file); + }); + + it('resolves the default filename inside a directory', async () => { + const result = await loadManifest(FIXTURES); + + expect(result.manifest.name).toBe('shell'); + expect(result.resolvedFrom).toBe( + path.join(FIXTURES, 'repack-federation-manifest.json') + ); + }); + + it('honours an explicit manifestPath inside a directory', async () => { + const result = await loadManifest(FIXTURES, { + manifestPath: path.join(FIXTURES, 'remote-clean.json'), + }); + + expect(result.manifest.name).toBe('store'); + expect(result.resolvedFrom).toBe(path.join(FIXTURES, 'remote-clean.json')); + }); + + it('fetches a directory URL using the default filename', async () => { + const body = JSON.parse( + fs.readFileSync(path.join(FIXTURES, 'remote-clean.json'), 'utf-8') + ); + const fetchMock = mockFetch((url) => + url === 'https://example.com/store/repack-federation-manifest.json' + ? { ok: true, body } + : undefined + ); + + const result = await loadManifest('https://example.com/store'); + + expect(result.manifest.name).toBe('store'); + expect(result.resolvedFrom).toBe( + 'https://example.com/store/repack-federation-manifest.json' + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('uses a URL ending in .json as-is', async () => { + const body = JSON.parse( + fs.readFileSync(path.join(FIXTURES, 'host.json'), 'utf-8') + ); + const fetchMock = mockFetch((url) => + url === 'https://example.com/custom-name.json' + ? { ok: true, body } + : undefined + ); + + const result = await loadManifest('https://example.com/custom-name.json'); + + expect(result.resolvedFrom).toBe('https://example.com/custom-name.json'); + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://example.com/custom-name.json') + ); + }); + + it('prefers an explicit manifestPath URL over the source', async () => { + const body = JSON.parse( + fs.readFileSync(path.join(FIXTURES, 'remote-clean.json'), 'utf-8') + ); + mockFetch((url) => + url === 'https://cdn.example.com/explicit.json' + ? { ok: true, body } + : undefined + ); + + const result = await loadManifest('https://example.com/store', { + manifestPath: 'https://cdn.example.com/explicit.json', + }); + + expect(result.resolvedFrom).toBe('https://cdn.example.com/explicit.json'); + }); + + it('reports a failed fetch as a missing manifest', async () => { + mockFetch(() => ({ ok: false, body: null })); + + await expect( + loadManifest('https://example.com/store') + ).rejects.toBeInstanceOf(ManifestNotFoundError); + }); + + it('reports a network failure as an invalid manifest', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('ECONNREFUSED')); + + await expect( + loadManifest('https://example.com/store') + ).rejects.toBeInstanceOf(ManifestInvalidError); + }); + + it('reports a missing file as not found', async () => { + await expect( + loadManifest(path.join(tmpDir, 'nope.json')) + ).rejects.toBeInstanceOf(ManifestNotFoundError); + }); + + it('reports a missing manifest inside a directory as not found', async () => { + const emptyDir = fs.mkdtempSync(path.join(tmpDir, 'empty-')); + + await expect(loadManifest(emptyDir)).rejects.toBeInstanceOf( + ManifestNotFoundError + ); + }); + + it('reports malformed JSON as invalid', async () => { + const file = path.join(tmpDir, 'broken.json'); + fs.writeFileSync(file, '{"manifestVersion": 1, '); + + await expect(loadManifest(file)).rejects.toBeInstanceOf( + ManifestInvalidError + ); + }); + + it('reports a document without manifestVersion as invalid', async () => { + const file = path.join(tmpDir, 'no-version.json'); + fs.writeFileSync(file, JSON.stringify({ name: 'shell' })); + + await expect(loadManifest(file)).rejects.toBeInstanceOf( + ManifestInvalidError + ); + }); + + it('reports a document without name or id as invalid', async () => { + const file = path.join(tmpDir, 'no-identity.json'); + fs.writeFileSync(file, JSON.stringify({ manifestVersion: 1 })); + + await expect(loadManifest(file)).rejects.toBeInstanceOf( + ManifestInvalidError + ); + }); + + it('reports a non-object document as invalid', async () => { + const file = path.join(tmpDir, 'array.json'); + fs.writeFileSync(file, JSON.stringify([{ manifestVersion: 1 }])); + + await expect(loadManifest(file)).rejects.toBeInstanceOf( + ManifestInvalidError + ); + }); + + it('reports a numeric manifestVersion and a string id as valid', async () => { + const file = path.join(tmpDir, 'id-only.json'); + fs.writeFileSync(file, JSON.stringify({ manifestVersion: 1, id: 'only' })); + + await expect(loadManifest(file)).resolves.toMatchObject({ + manifest: { id: 'only' }, + }); + }); +}); diff --git a/packages/repack/src/commands/federation/doctor.ts b/packages/repack/src/commands/federation/doctor.ts new file mode 100644 index 000000000..38fedcfb3 --- /dev/null +++ b/packages/repack/src/commands/federation/doctor.ts @@ -0,0 +1,278 @@ +import type { + FederationManifest, + FederationManifestSharedEntry, + FederationNativeModule, +} from '../../plugins/federationManifest/types.js'; +import { rangesIntersect } from './semverRange.js'; + +/** The highest manifest schema version this doctor understands. */ +const SUPPORTED_MANIFEST_VERSION = 1; + +/** One problem (or advisory) found while comparing manifests. */ +export interface DoctorFinding { + severity: 'error' | 'warning' | 'info'; + code: string; + message: string; +} + +/** Result of a doctor run; the caller maps it to a process exit code. */ +export interface DoctorReport { + findings: DoctorFinding[]; +} + +/** A remote to compare against the host. */ +export interface DoctorRemoteInput { + /** Name used to refer to the remote in findings. */ + name: string; + /** Its manifest, when available. */ + manifest?: FederationManifest; + /** True when no manifest could be obtained for this remote. */ + missing?: boolean; +} + +export interface DoctorInput { + host: FederationManifest; + remotes: DoctorRemoteInput[]; + /** Downgrade missing-remote findings from error to warning. */ + allowMissingManifests?: boolean; +} + +function sharedOf( + manifest: FederationManifest +): FederationManifestSharedEntry[] { + return Array.isArray(manifest?.shared) ? manifest.shared : []; +} + +function nativeModulesOf( + manifest: FederationManifest +): FederationNativeModule[] { + const modules = manifest?.reactNative?.nativeModules; + return Array.isArray(modules) ? modules : []; +} + +/** + * A host native-module list is only authoritative when nothing was detected + * heuristically and no dynamic require was seen in the module graph. + */ +function hostNativeListIsTrusted(host: FederationManifest): boolean { + const native = host?.reactNative; + if (!native || native.dynamicImportDetected) return false; + return !nativeModulesOf(host).some( + (entry) => entry.confidence === 'heuristic' + ); +} + +function checkManifestVersion( + manifest: FederationManifest, + label: string, + findings: DoctorFinding[] +): void { + const version = manifest?.manifestVersion; + if (typeof version === 'number' && version > SUPPORTED_MANIFEST_VERSION) { + findings.push({ + severity: 'warning', + code: 'MANIFEST_VERSION_AHEAD', + message: `${label} declares manifestVersion ${version}, newer than the v1 schema this doctor understands; comparing best effort.`, + }); + } +} + +function checkSharedDeps( + host: FederationManifest, + remoteName: string, + remote: FederationManifest, + findings: DoctorFinding[] +): void { + const remoteShared = new Map( + sharedOf(remote).map((entry) => [entry.name, entry]) + ); + + for (const hostEntry of sharedOf(host)) { + const remoteEntry = remoteShared.get(hostEntry.name); + if (!remoteEntry) continue; + const name = hostEntry.name; + + if (hostEntry.singleton !== remoteEntry.singleton) { + findings.push({ + severity: 'error', + code: 'SINGLETON_MISMATCH', + message: `Shared dependency "${name}" is singleton: ${hostEntry.singleton} on host "${host.name}" but ${remoteEntry.singleton} on remote "${remoteName}".`, + }); + } + if (hostEntry.eager !== remoteEntry.eager) { + findings.push({ + severity: 'error', + code: 'EAGER_MISMATCH', + message: `Shared dependency "${name}" is eager: ${hostEntry.eager} on host "${host.name}" but ${remoteEntry.eager} on remote "${remoteName}".`, + }); + } + + const bothSingleton = hostEntry.singleton && remoteEntry.singleton; + if (bothSingleton && hostEntry.version !== remoteEntry.version) { + if ( + hostEntry.version === 'unknown' || + remoteEntry.version === 'unknown' + ) { + findings.push({ + severity: 'info', + code: 'VERSION_UNKNOWN', + message: `Shared dependency "${name}" is a singleton but its resolved version could not be determined on at least one side (host: ${hostEntry.version}, remote "${remoteName}": ${remoteEntry.version}); verify they match manually.`, + }); + } else { + findings.push({ + severity: 'error', + code: 'SHARED_VERSION_DRIFT', + message: `Singleton shared dependency "${name}" resolves to different versions: host "${host.name}" has ${hostEntry.version}, remote "${remoteName}" has ${remoteEntry.version}. Align the versions (or remove singleton).`, + }); + } + } + + const verdict = rangesIntersect( + hostEntry.requiredVersion, + remoteEntry.requiredVersion + ); + if (verdict === false) { + findings.push({ + severity: 'warning', + code: 'SHARED_RANGE_UNRESOLVABLE', + message: `Shared dependency "${name}" declares ranges that cannot intersect: host "${host.name}" requires ${hostEntry.requiredVersion}, remote "${remoteName}" requires ${remoteEntry.requiredVersion}.`, + }); + } else if (verdict === null) { + findings.push({ + severity: 'warning', + code: 'SHARED_RANGE_UNSUPPORTED', + message: `Shared dependency "${name}" uses a requiredVersion this doctor cannot evaluate (host: ${hostEntry.requiredVersion}, remote "${remoteName}": ${remoteEntry.requiredVersion}); check compatibility manually.`, + }); + } + } +} + +function checkNativeModules( + host: FederationManifest, + remoteName: string, + remote: FederationManifest, + findings: DoctorFinding[] +): void { + const hostPackages = new Set( + nativeModulesOf(host).map((entry) => entry.package) + ); + const trusted = hostNativeListIsTrusted(host); + + for (const entry of nativeModulesOf(remote)) { + if (hostPackages.has(entry.package)) continue; + if (!trusted) { + findings.push({ + severity: 'warning', + code: 'HEURISTIC_ADVISORY', + message: + `Native module "${entry.package}" (${entry.version}) used by remote "${remoteName}" is not in host "${host.name}" nativeModules, ` + + 'but the host list is heuristic or incomplete (dynamic imports were detected); verify manually.', + }); + continue; + } + findings.push({ + severity: 'error', + code: 'MISSING_NATIVE_MODULE', + message: + `Native module "${entry.package}" (${entry.version}) used by remote "${remoteName}" is not listed in host "${host.name}" reactNative.nativeModules. ` + + 'If the host provides this module from its app project rather than node_modules, verify manually.', + }); + } +} + +function checkRemoteManifests( + input: DoctorInput, + findings: DoctorFinding[] +): void { + for (const remote of input.remotes) { + if (!remote.missing) continue; + findings.push({ + severity: input.allowMissingManifests ? 'warning' : 'error', + code: 'MISSING_REMOTE_MANIFEST', + message: + `Remote "${remote.name}" has no federation manifest, so it was not checked. ` + + 'Enable `manifest: true` in its ModuleFederationPlugin config and redeploy it, or pass a local manifest file for it.', + }); + } +} + +/** + * Compare a host manifest against a set of remote manifests and report every + * shared-dependency and native-module inconsistency found. + */ +export function runDoctor(input: DoctorInput): DoctorReport { + const findings: DoctorFinding[] = []; + + checkManifestVersion(input.host, `Host "${input.host.name}"`, findings); + for (const remote of input.remotes) { + if (remote.missing || !remote.manifest) { + continue; + } + checkManifestVersion(remote.manifest, `Remote "${remote.name}"`, findings); + checkSharedDeps(input.host, remote.name, remote.manifest, findings); + checkNativeModules(input.host, remote.name, remote.manifest, findings); + } + checkRemoteManifests(input, findings); + + return { findings }; +} + +/** + * Map a doctor report to a process exit code: 1 when any error was found, + * 0 otherwise. Exit code 2 is produced by the caller when a manifest fails + * to load or parse (`ManifestNotFoundError` / `ManifestInvalidError`). + */ +export function doctorExitCode(report: DoctorReport): 0 | 1 | 2 { + return report.findings.some((finding) => finding.severity === 'error') + ? 1 + : 0; +} + +function labelForSeverity(severity: DoctorFinding['severity']): string { + return severity === 'error' + ? 'error ' + : severity === 'warning' + ? 'warning' + : 'info '; +} + +/** + * Render a doctor report as aligned plain text. + */ +export function formatDoctorReport(report: DoctorReport): string { + const errors = report.findings.filter((f) => f.severity === 'error').length; + const warnings = report.findings.filter( + (f) => f.severity === 'warning' + ).length; + const infos = report.findings.filter((f) => f.severity === 'info').length; + + if (report.findings.length === 0) return 'Doctor: no issues found.'; + + const lines = [ + `Doctor: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}, ${infos} info`, + '', + ]; + for (const finding of report.findings) { + lines.push( + `${labelForSeverity(finding.severity)} ${finding.code} ${finding.message}` + ); + } + return lines.join('\n'); +} + +/** + * Serialize a doctor report as JSON with a stable key order. + */ +export function doctorReportToJson(report: DoctorReport): string { + return JSON.stringify( + { + findings: report.findings.map((finding) => ({ + severity: finding.severity, + code: finding.code, + message: finding.message, + })), + }, + null, + 2 + ); +} diff --git a/packages/repack/src/commands/federation/inspect.ts b/packages/repack/src/commands/federation/inspect.ts new file mode 100644 index 000000000..78eac2749 --- /dev/null +++ b/packages/repack/src/commands/federation/inspect.ts @@ -0,0 +1,103 @@ +import type { FederationManifest } from '../../plugins/federationManifest/types.js'; + +function pad(value: string, width: number): string { + return value + ' '.repeat(Math.max(0, width - value.length)); +} + +function widthOf(values: string[], minimum: number): number { + return values.reduce((max, value) => Math.max(max, value.length), minimum); +} + +/** + * Render a human-readable multi-line summary of a federation manifest. + */ +export function formatManifest(manifest: FederationManifest): string { + const lines: string[] = []; + const meta = manifest.metaData; + const buildInfo = meta?.buildInfo; + + lines.push(`${manifest.name || manifest.id}`); + lines.push(` id: ${manifest.id}`); + lines.push(` name: ${manifest.name}`); + lines.push(` type: ${meta?.type ?? 'unknown'}`); + lines.push( + ` build: ${buildInfo?.buildVersion ?? 'unknown'} (${buildInfo?.buildName ?? 'unknown'})` + ); + + const shared = manifest.shared ?? []; + lines.push('', `shared (${shared.length}):`); + if (shared.length > 0) { + const nameWidth = widthOf( + shared.map((entry) => entry.name), + 'package'.length + ); + const versionWidth = widthOf( + shared.map((entry) => entry.version), + 'resolved'.length + ); + const requiredWidth = widthOf( + shared.map((entry) => entry.requiredVersion), + 'required'.length + ); + lines.push( + ` ${pad('package', nameWidth)} ${pad('resolved', versionWidth)} ${pad('required', requiredWidth)} flags` + ); + for (const entry of shared) { + const flags = [entry.singleton && 'singleton', entry.eager && 'eager'] + .filter(Boolean) + .join(' '); + lines.push( + ` ${pad(entry.name, nameWidth)} ${pad(entry.version, versionWidth)} ${pad(entry.requiredVersion, requiredWidth)} ${flags}` + ); + } + } + + const remotes = manifest.remotes ?? []; + lines.push('', `remotes (${remotes.length}):`); + if (remotes.length > 0) { + const aliasWidth = widthOf( + remotes.map((entry) => entry.alias), + 'alias'.length + ); + for (const entry of remotes) { + lines.push( + ` ${pad(entry.alias, aliasWidth)} ${entry.federationContainerName} ${entry.entry}` + ); + } + } + + const exposes = manifest.exposes ?? []; + lines.push('', `exposes (${exposes.length}):`); + for (const entry of exposes) { + lines.push(` ${entry.name} ${entry.path}`); + } + + const native = manifest.reactNative; + lines.push('', 'react-native:'); + lines.push(` version: ${native?.version ?? 'unknown'}`); + if (native?.dynamicImportDetected) { + lines.push(' dynamic imports detected: true'); + } + const modules = native?.nativeModules ?? []; + lines.push(` native modules (${modules.length}):`); + if (modules.length > 0) { + const packageWidth = widthOf( + modules.map((entry) => entry.package), + 'package'.length + ); + const versionWidth = widthOf( + modules.map((entry) => entry.version), + 'version'.length + ); + for (const entry of modules) { + lines.push( + ` ${pad(entry.package, packageWidth)} ${pad(entry.version, versionWidth)} ${pad(entry.confidence, 9)} ${entry.turboModule ? 'turbo-module' : ''}` + ); + } + } + if (native?.note) { + lines.push(` note: ${native.note}`); + } + + return lines.join('\n'); +} diff --git a/packages/repack/src/commands/federation/loadManifest.ts b/packages/repack/src/commands/federation/loadManifest.ts new file mode 100644 index 000000000..636064558 --- /dev/null +++ b/packages/repack/src/commands/federation/loadManifest.ts @@ -0,0 +1,187 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + DEFAULT_MANIFEST_FILENAME, + type FederationManifest, +} from '../../plugins/federationManifest/types.js'; + +/** Result of loading a manifest from any supported source. */ +export interface LoadedManifest { + /** The validated manifest document. */ + manifest: FederationManifest; + /** The source string exactly as passed to `loadManifest`. */ + source: string; + /** The concrete file path or URL the manifest was read from. */ + resolvedFrom: string; +} + +export interface LoadManifestOptions { + /** + * Explicit manifest file/URL to use instead of appending + * `repack-federation-manifest.json` to the source. Applied to both + * filesystem and http(s) sources. + */ + manifestPath?: string; +} + +/** No manifest document exists at the given source. */ +export class ManifestNotFoundError extends Error { + constructor( + message: string, + public readonly source: string + ) { + super(message); + this.name = 'ManifestNotFoundError'; + } +} + +/** A manifest exists at the source but it cannot be read or parsed. */ +export class ManifestInvalidError extends Error { + constructor( + message: string, + public readonly source: string + ) { + super(message); + this.name = 'ManifestInvalidError'; + } +} + +/** + * Minimal runtime check for a manifest document: a numeric + * `manifestVersion` and a `name` or `id` to identify it by. + */ +function isFederationManifest(value: unknown): value is FederationManifest { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.manifestVersion === 'number' && + (typeof candidate.name === 'string' || typeof candidate.id === 'string') + ); +} + +function parseAndValidate( + content: string, + source: string, + resolvedFrom: string +): LoadedManifest { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new ManifestInvalidError( + `Manifest at ${resolvedFrom} is not valid JSON.`, + source + ); + } + if (!isFederationManifest(parsed)) { + throw new ManifestInvalidError( + `Manifest at ${resolvedFrom} does not look like a federation manifest: ` + + 'it must have a numeric "manifestVersion" and a "name" or "id" string.', + source + ); + } + return { manifest: parsed, source, resolvedFrom }; +} + +function isHttpUrl(source: string): boolean { + return /^https?:\/\//i.test(source); +} + +function manifestUrlFrom(base: string): URL { + const url = new URL(base); + if (url.pathname.endsWith('.json')) return url; + if (!url.pathname.endsWith('/')) url.pathname += '/'; + url.pathname += DEFAULT_MANIFEST_FILENAME; + return url; +} + +async function loadFromUrl( + source: string, + options: LoadManifestOptions +): Promise { + const url = manifestUrlFrom(options.manifestPath ?? source); + let response: Response; + try { + response = await globalThis.fetch(url); + } catch (error) { + throw new ManifestInvalidError( + `Could not fetch manifest from ${url}: ${error instanceof Error ? error.message : String(error)}`, + source + ); + } + if (!response.ok) { + throw new ManifestNotFoundError( + `No manifest found at ${url} (HTTP ${response.status}).`, + source + ); + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + throw new ManifestInvalidError( + `Manifest response from ${url} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + source + ); + } + if (!isFederationManifest(parsed)) { + throw new ManifestInvalidError( + `Manifest at ${url} does not look like a federation manifest: ` + + 'it must have a numeric "manifestVersion" and a "name" or "id" string.', + source + ); + } + return { manifest: parsed, source, resolvedFrom: url.toString() }; +} + +async function loadFromFile( + source: string, + options: LoadManifestOptions +): Promise { + const base = path.resolve(options.manifestPath ?? source); + let isDirectory = false; + try { + isDirectory = fs.statSync(base).isDirectory(); + } catch { + throw new ManifestNotFoundError( + options.manifestPath + ? `No manifest file at ${base}.` + : `No manifest found at ${base}. Pass a file, a directory containing ${DEFAULT_MANIFEST_FILENAME}, or a URL.`, + source + ); + } + const filePath = isDirectory + ? path.join(base, DEFAULT_MANIFEST_FILENAME) + : base; + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ManifestNotFoundError( + `No manifest file at ${filePath}.`, + source + ); + } + throw new ManifestInvalidError( + `Could not read manifest at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + source + ); + } + return parseAndValidate(content, source, filePath); +} + +/** + * Load a federation manifest from a file path, a directory containing + * `repack-federation-manifest.json`, or an http(s) URL. Sources (and + * `manifestPath` values) ending in `.json` are used as-is; otherwise the + * default manifest filename is appended. + */ +export async function loadManifest( + source: string, + options: LoadManifestOptions = {} +): Promise { + return isHttpUrl(options.manifestPath ?? source) + ? loadFromUrl(source, options) + : loadFromFile(source, options); +} diff --git a/packages/repack/src/commands/federation/semverRange.ts b/packages/repack/src/commands/federation/semverRange.ts new file mode 100644 index 000000000..9029a6baf --- /dev/null +++ b/packages/repack/src/commands/federation/semverRange.ts @@ -0,0 +1,119 @@ +/** + * Minimal semver-range math for the federation doctor. Supports the subset of + * range syntax that appears in `shared[].requiredVersion`: exact versions, + * caret (`^15.0.0`), tilde (`~0.74.5`), `>=18` and major/minor `x` ranges + * (`19.x`). Anything else is reported as unsupported by returning `null`, + * so callers can warn instead of failing. + */ + +/** Parsed `[major, minor, patch]` tuple. */ +export type Version = [number, number, number]; + +/** Half-open interval `[lower, upper)` with `null` meaning unbounded. */ +export interface VersionInterval { + lower: Version | null; + upper: Version | null; +} + +const RANGE_RE = + /^(\^|~|>=)?v?(\d+|x|X|\*)(?:\.(\d+|x|X|\*)(?:\.(\d+|x|X|\*))?)?$/; + +function isWildcard(part: string | undefined): boolean { + return part === 'x' || part === 'X' || part === '*'; +} + +function isUnbounded(part: string | undefined): boolean { + return part === undefined || part === 'x' || part === 'X' || part === '*'; +} + +function compareVersions(a: Version, b: Version): number { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + return 0; +} + +/** + * Parse a version range into an interval. Returns `null` for syntax this + * helper does not understand (comparators other than `>=`, hyphen ranges, + * unions, prereleases, and wildcards mixed with `^`/`~`/`>=`). + */ +export function parseRange(raw: string): VersionInterval | null { + const range = raw.trim(); + if (range === '' || isUnbounded(range)) { + return { lower: null, upper: null }; + } + const match = RANGE_RE.exec(range); + if (!match) return null; + const [, op = '', a, b, c] = match; + + // Only plain ranges allow wildcards; `^1.x`, `~1.x` and `>=x` stay unsupported. + if (op !== '' && (isWildcard(a) || isWildcard(b) || isWildcard(c))) { + return null; + } + + const major = Number.parseInt(a, 10); + const minor = isUnbounded(b) ? undefined : Number.parseInt(b as string, 10); + const patch = isUnbounded(c) ? undefined : Number.parseInt(c as string, 10); + const lower: Version = [major, minor ?? 0, patch ?? 0]; + + if (op === '>=') { + return { lower, upper: null }; + } + if (op === '^') { + if (major > 0) return { lower, upper: [major + 1, 0, 0] }; + if (minor === undefined) return { lower, upper: [1, 0, 0] }; + if (minor > 0) return { lower, upper: [0, minor + 1, 0] }; + // ^0.0.x only allows patch-level changes within 0.0 + return { lower, upper: [0, 0, (patch ?? 0) + 1] }; + } + if (op === '~') { + return minor === undefined + ? { lower, upper: [major + 1, 0, 0] } + : { lower, upper: [major, minor + 1, 0] }; + } + // Plain version or x-range: the interval covers exactly what it allows. + if (minor === undefined) return { lower, upper: [major + 1, 0, 0] }; + if (patch === undefined) return { lower, upper: [major, minor + 1, 0] }; + return { lower, upper: [major, minor, patch + 1] }; +} + +/** + * Whether two intervals leave at least one version allowed. + */ +export function intervalsIntersect( + a: VersionInterval, + b: VersionInterval +): boolean { + // A `null` lower bound means "unbounded below", so the effective lower bound + // is the other side; likewise a `null` upper bound means "unbounded above". + const lower = + a.lower === null + ? b.lower + : b.lower === null + ? a.lower + : compareVersions(a.lower, b.lower) >= 0 + ? a.lower + : b.lower; + const upper = + a.upper === null + ? b.upper + : b.upper === null + ? a.upper + : compareVersions(a.upper, b.upper) <= 0 + ? a.upper + : b.upper; + if (lower === null || upper === null) return true; + return compareVersions(lower, upper) < 0; +} + +/** + * Whether two range strings can resolve to a common version. + * Returns `null` when either range uses syntax this helper does not support. + */ +export function rangesIntersect(a: string, b: string): boolean | null { + const parsedA = parseRange(a); + const parsedB = parseRange(b); + if (parsedA === null || parsedB === null) return null; + return intervalsIntersect(parsedA, parsedB); +} diff --git a/packages/repack/src/commands/federationDoctor.ts b/packages/repack/src/commands/federationDoctor.ts new file mode 100644 index 000000000..7bc3166c3 --- /dev/null +++ b/packages/repack/src/commands/federationDoctor.ts @@ -0,0 +1,126 @@ +import path from 'node:path'; +import { + type DoctorRemoteInput, + doctorExitCode, + doctorReportToJson, + formatDoctorReport, + runDoctor, +} from './federation/doctor.js'; +import { + type LoadedManifest, + loadManifest, + ManifestInvalidError, + ManifestNotFoundError, +} from './federation/loadManifest.js'; +import type { CliConfig, FederationDoctorArguments } from './types.js'; + +/** Split `--remotes` into sources, tolerating a merged array from the CLI. */ +function parseRemoteList(remotes: string | string[] | undefined): string[] { + if (!remotes) return []; + const values = Array.isArray(remotes) ? remotes : [remotes]; + return values.flatMap((value) => + value + .split(',') + .map((source) => source.trim()) + .filter(Boolean) + ); +} + +/** Best-effort label for a remote whose manifest could not be loaded. */ +function labelFromSource(source: string): string { + try { + const pathname = new URL(source).pathname; + return path.basename(pathname) || source; + } catch { + return path.basename(source) || source; + } +} + +/** + * Compare a host federation manifest against a set of remotes and report + * shared-dependency and native-module drift. Exit code 1 when any drift was + * found, 2 when a manifest could not be loaded or parsed. + * + * @param _argv Original, non-parsed arguments. + * @param _cliConfig Configuration object containing platform and project settings. + * @param args Parsed command line arguments. + */ +export async function federationDoctor( + _argv: string[], + _cliConfig: CliConfig, + args: FederationDoctorArguments +) { + if (!args.host) { + console.error( + "Option '--host ' is required: pass the host manifest as a " + + '.json file, a build output directory, or an http(s) URL.' + ); + process.exit(2); + return; + } + + const remoteSources = parseRemoteList(args.remotes); + if (remoteSources.length === 0) { + console.error( + "Option '--remotes ' is required: pass a comma-separated list " + + 'of remote manifest sources.' + ); + process.exit(2); + return; + } + + let host: LoadedManifest; + try { + host = await loadManifest(args.host); + } catch (error) { + if ( + error instanceof ManifestNotFoundError || + error instanceof ManifestInvalidError + ) { + console.error(`Host manifest — ${error.name}: ${error.message}`); + process.exit(2); + return; + } + throw error; + } + + const remotes: DoctorRemoteInput[] = []; + for (const source of remoteSources) { + try { + const remote = await loadManifest(source); + remotes.push({ + name: + remote.manifest.name || remote.manifest.id || labelFromSource(source), + manifest: remote.manifest, + }); + } catch (error) { + if (error instanceof ManifestNotFoundError) { + remotes.push({ name: labelFromSource(source), missing: true }); + continue; + } + if (error instanceof ManifestInvalidError) { + console.error(`Remote "${source}" — ${error.name}: ${error.message}`); + console.error( + 'A corrupt remote manifest means its checks cannot be trusted; fix or remove it before running the doctor.' + ); + process.exit(2); + return; + } + throw error; + } + } + + const report = runDoctor({ + host: host.manifest, + remotes, + allowMissingManifests: args.allowMissingManifests, + }); + + if (args.format === 'json') { + console.log(doctorReportToJson(report)); + } else { + console.log(formatDoctorReport(report)); + } + + process.exit(doctorExitCode(report)); +} diff --git a/packages/repack/src/commands/federationManifest.ts b/packages/repack/src/commands/federationManifest.ts new file mode 100644 index 000000000..5ccb98b35 --- /dev/null +++ b/packages/repack/src/commands/federationManifest.ts @@ -0,0 +1,51 @@ +import { formatManifest } from './federation/inspect.js'; +import { + loadManifest, + ManifestInvalidError, + ManifestNotFoundError, +} from './federation/loadManifest.js'; +import type { CliConfig, FederationManifestArguments } from './types.js'; + +/** + * Print a human-readable summary of a federation manifest, or the raw + * manifest as JSON with `--json`. + * + * @param argv Original, non-parsed arguments; the first one is the manifest source. + * @param _cliConfig Configuration object containing platform and project settings. + * @param args Parsed command line arguments. + */ +export async function federationManifest( + argv: string[], + _cliConfig: CliConfig, + args: FederationManifestArguments +) { + const source = argv[0] ?? args.source; + if (!source) { + console.error( + 'No manifest source given. Pass it as the first argument or with ' + + '--source: a .json file, a directory containing ' + + 'repack-federation-manifest.json, or an http(s) URL.' + ); + process.exit(2); + return; + } + + try { + const { manifest } = await loadManifest(source); + if (args.json) { + console.log(JSON.stringify(manifest, null, 2)); + } else { + console.log(formatManifest(manifest)); + } + } catch (error) { + if ( + error instanceof ManifestNotFoundError || + error instanceof ManifestInvalidError + ) { + console.error(`${error.name}: ${error.message}`); + process.exit(2); + return; + } + throw error; + } +} diff --git a/packages/repack/src/commands/index.ts b/packages/repack/src/commands/index.ts index 858fb2fcb..a30fd8c5f 100644 --- a/packages/repack/src/commands/index.ts +++ b/packages/repack/src/commands/index.ts @@ -1,5 +1,12 @@ import { bundle } from './bundle.js'; -import { bundleCommandOptions, startCommandOptions } from './options.js'; +import { federationDoctor } from './federationDoctor.js'; +import { federationManifest } from './federationManifest.js'; +import { + bundleCommandOptions, + federationDoctorCommandOptions, + federationManifestCommandOptions, + startCommandOptions, +} from './options.js'; import { start } from './start.js'; import type { BundleArguments, @@ -8,7 +15,7 @@ import type { StartArguments, } from './types.js'; -const commands = [ +const bundlerCommands = [ { name: 'bundle', description: 'Build the bundle for the provided JavaScript entry file.', @@ -35,15 +42,34 @@ const commands = [ }, ] as const; +const federationCommands = [ + { + name: 'federation-manifest', + description: 'Inspect a federation manifest from a file, directory or URL.', + options: federationManifestCommandOptions, + func: federationManifest, + }, + { + name: 'federation-doctor', + description: + 'Check host and remote federation manifests for shared and native module drift.', + options: federationDoctorCommandOptions, + func: federationDoctor, + }, +] as const; + +const commands = [...bundlerCommands, ...federationCommands]; + export default commands; /** * Creates command definitions with a forced bundler engine. * Used by deprecated entry points (`commands/rspack`, `commands/webpack`) - * to maintain backwards compatibility. + * to maintain backwards compatibility. Bundler-independent commands + * (`federation-*`) are not exposed through those entry points. */ export function createBoundCommands(bundler: Bundler) { - return commands.map((cmd) => ({ + return bundlerCommands.map((cmd) => ({ ...cmd, func: ( _: string[], diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index fa7e18819..669887ccc 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -96,6 +96,40 @@ export const startCommandOptions = [ }, ]; +export const federationManifestCommandOptions = [ + { + name: '--source ', + description: + 'Federation manifest to inspect: a .json file, a directory containing repack-federation-manifest.json, or an http(s) URL. Also accepted as the first positional argument', + }, + { + name: '--json', + description: 'Print the raw manifest as JSON to stdout', + }, +]; + +export const federationDoctorCommandOptions = [ + { + name: '--host ', + description: + 'Host manifest source: a .json file, a build output directory containing repack-federation-manifest.json, or an http(s) URL', + }, + { + name: '--remotes ', + description: + 'Comma-separated list of remote manifest sources (same shapes as --host)', + }, + { + name: '--format ', + description: 'Output format: "json" prints machine-readable findings', + }, + { + name: '--allow-missing-manifests', + description: + 'Report remotes without a manifest as warnings instead of errors', + }, +]; + export const bundleCommandOptions = [ { name: '--entry-file ', diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index ee98126e6..e4bcf87b1 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -42,6 +42,19 @@ export interface StartArguments { bundler?: Bundler; } +export interface FederationManifestArguments { + source?: string; + json?: boolean; +} + +export interface FederationDoctorArguments { + host?: string; + /** Comma-separated string; an array appears if the CLI merges repeated flags. */ + remotes?: string | string[]; + format?: string; + allowMissingManifests?: boolean; +} + export interface CliConfig { root: string; platforms: string[]; diff --git a/website/src/latest/api/cli/_meta.json b/website/src/latest/api/cli/_meta.json index 63b9b0d8c..acfbe413a 100644 --- a/website/src/latest/api/cli/_meta.json +++ b/website/src/latest/api/cli/_meta.json @@ -9,6 +9,16 @@ "name": "bundle", "label": "Bundle" }, + { + "type": "file", + "name": "federation-manifest", + "label": "Federation manifest" + }, + { + "type": "file", + "name": "federation-doctor", + "label": "Federation doctor" + }, { "type": "file", "name": "init", diff --git a/website/src/latest/api/cli/federation-doctor.mdx b/website/src/latest/api/cli/federation-doctor.mdx new file mode 100644 index 000000000..b365835eb --- /dev/null +++ b/website/src/latest/api/cli/federation-doctor.mdx @@ -0,0 +1,132 @@ +# federation-doctor + +`federation-doctor` compares a host's [federation manifest](/docs/features/federation-manifest) against the manifests of its remotes and reports the drift that causes runtime crashes in multi-app Module Federation setups: singleton version mismatches, conflicting `requiredVersion` ranges, `singleton`/`eager` disagreements, and native modules a remote uses that the host does not declare. + +It is designed to run as a CI gate: deterministic flags, human-readable output by default, `--format json` for machines, and an exit code that fails the job. + +## Usage + +Both the host and each remote are manifest sources: a `.json` file, a directory containing `repack-federation-manifest.json` (for example a build output directory), or an `http(s)` URL. Remotes were deployed somewhere; hosts usually have a local build. + +import { PackageManagerTabs } from '@theme'; + + + +Examples: + +```bash +# host from a local build, remotes from running dev servers / CDNs +npx react-native federation-doctor \ + --host ./build \ + --remotes http://localhost:8082,https://cdn.example.com/catalog/ + +# local manifest files for every app +npx react-native federation-doctor \ + --host ./shell/build \ + --remotes ./store/build,./catalog/build/repack-federation-manifest.json + +# machine-readable output +npx react-native federation-doctor --host ./build --remotes http://localhost:8082 --format json + +# do not fail on remotes that ship no manifest yet +npx react-native federation-doctor --host ./build --remotes http://localhost:8082 --allow-missing-manifests +``` + +Each finding carries a severity (`error`, `warning`, `info`), a stable code (for example `SHARED_VERSION_DRIFT`, `MISSING_NATIVE_MODULE`, `MISSING_REMOTE_MANIFEST`), and a message naming the package and the versions in conflict. + +## Options + +### `--host` + +- Type: `string` +- Required + +The host manifest: a `.json` file, a build output directory, or an `http(s)` URL. + +### `--remotes` + +- Type: `string` +- Required + +Comma-separated list of remote manifest sources. A remote whose manifest does not exist is reported as `MISSING_REMOTE_MANIFEST`; a remote whose manifest exists but is corrupt aborts the run, because its checks cannot be trusted. + +### `--format` + +- Type: `"json"` + +Print findings as JSON (`{ "findings": [{ "severity", "code", "message" }] }`) instead of the aligned text report. Nothing else is written to stdout in this mode. + +### `--allow-missing-manifests` + +- Type: `boolean` + +Downgrade `MISSING_REMOTE_MANIFEST` from error to warning. Use it while rolling the manifest option out to every remote; the check itself still runs and still fails on real drift. + +## Exit codes + +| Code | Meaning | +| ---- | ------- | +| `0` | No errors. Warnings and infos may have been reported. | +| `1` | Drift found (at least one `error`-severity finding), or a remote has no manifest and `--allow-missing-manifests` was not passed. | +| `2` | The check could not run: a required option is missing, the host manifest does not exist, or a manifest (host or remote) is corrupt. | + +`2` means "you do not have an answer"; `1` means "you have an answer and it is bad". A CI job should treat any non-zero code as failure. + +## Honesty rules: degraded and heuristic results + +The doctor never claims more certainty than the manifests carry: + +- **Heuristic native module lists.** If a manifest was built with dynamic `require()`s in the graph, its `reactNative.nativeModules` list is flagged non-exhaustive and its entries drop to `heuristic` confidence. The doctor then reports missing native modules as warnings to verify (`HEURISTIC_ADVISORY`), not errors — an incomplete list cannot prove absence. +- **Unknown resolved versions.** A singleton whose resolved version could not be determined is reported as `VERSION_UNKNOWN` (info) with both sides named, for manual verification, instead of passing silently. +- **Unsupported range syntax.** `requiredVersion` values the doctor cannot evaluate (unions, hyphen ranges, comparators other than `>=`) produce `SHARED_RANGE_UNSUPPORTED` (warning) telling you to check manually, rather than a guessed verdict. +- **Newer manifest schemas.** A `manifestVersion` newer than the doctor understands is reported (`MANIFEST_VERSION_AHEAD`) and compared best effort. + +### Host app project native modules + +A remote using a native module that is absent from the host's `nativeModules` is reported as `MISSING_NATIVE_MODULE` (error) — but with a caveat. The host manifest lists native modules found in the module graph through `node_modules`; a module wired up from the host's **app project** (own `ios/`/`android/` sources, not a package) may not appear. The finding message says so: if the host provides this module from its app project rather than `node_modules`, verify it manually. The same asymmetry means a module list from the remote side is equally partial; treat both lists as evidence, not as the app's full native surface. + +## GitHub Action example + +Gate a PR on host/remote compatibility. The doctor's exit code fails the job; the text report lands in the logs. + +```yaml name="federation-doctor.yml" +name: federation-doctor +on: + pull_request: + schedule: + - cron: '0 6 * * *' # catch drift shipped by already-deployed remotes + +jobs: + doctor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: npm ci + + # produce the host manifest (or download it from your CI cache/bucket) + - run: npm run bundle:android # any build with `manifest: true` in the MF plugin + + # remotes are checked where they actually live + - name: Check host against deployed remotes + run: | + npx react-native federation-doctor \ + --host ./build \ + --remotes https://cdn.example.com/store/,https://cdn.example.com/catalog/ \ + --format json +``` + +The `run` step exits with the doctor's code, so `1` (drift) and `2` (a manifest could not be read) both fail the job. Add `--allow-missing-manifests` during rollout so remotes that do not emit a manifest yet only warn. + +## `-h`, `--help` + +Display help for command. diff --git a/website/src/latest/api/cli/federation-manifest.mdx b/website/src/latest/api/cli/federation-manifest.mdx new file mode 100644 index 000000000..b68f2b19e --- /dev/null +++ b/website/src/latest/api/cli/federation-manifest.mdx @@ -0,0 +1,65 @@ +# federation-manifest + +`federation-manifest` prints the contents of a Repack federation manifest — the `repack-federation-manifest.json` file emitted by a build with the [`manifest` option](/docs/features/federation-manifest) enabled — so you can inspect what a host or remote actually shipped. + +## Usage + +A manifest source is one of: + +- a path to a `.json` manifest file, +- a directory containing `repack-federation-manifest.json` (for example a build output directory), +- an `http(s)` URL pointing at either of the two. + +Pass the source as the first positional argument, or with `--source`: + +import { PackageManagerTabs } from '@theme'; + + + +Examples: + +```bash +# a build output directory +npx react-native federation-manifest ./build + +# an explicit manifest file +npx react-native federation-manifest ./build/repack-federation-manifest.json + +# a deployed manifest, straight from the server +npx react-native federation-manifest http://localhost:8082 + +# machine-readable output for scripts +npx react-native federation-manifest ./build --json | jq '.reactNative.nativeModules' +``` + +The default output is a human-readable summary: identity and build info, the shared dependency table with resolved and required versions, remotes, exposes, and the React Native block with detected native modules. + +## Options + +### `--source` + +- Type: `string` + +The manifest to inspect. Equivalent to passing the source as the first positional argument; the positional argument wins when both are given. + +### `--json` + +- Type: `boolean` + +Print the raw manifest document as JSON to stdout. Nothing else is written to stdout in this mode, so the output pipes directly into `jq` and similar tools. + +## Exit codes + +| Code | Meaning | +| ---- | ------- | +| `0` | The manifest was printed. | +| `2` | No source was given, no manifest exists at the source, or the manifest is not readable/valid JSON. | + +## `-h`, `--help` + +Display help for command. From 544fc8ed0857f7ddd1586167f598b91358ea9f66 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 15:37:36 +0200 Subject: [PATCH 5/7] fix(repack): exclude synthetic deep-import keys from the federation manifest --- .../ModuleFederationPluginV1.test.ts | 16 +++++++-------- .../__tests__/federationManifest.test.ts | 20 +++++++++++-------- .../src/plugins/federationManifest/shared.ts | 10 +++++++++- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts index 34ff06586..6cd3c6a3e 100644 --- a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts +++ b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts @@ -377,16 +377,14 @@ describe('ModuleFederationPlugin', () => { name: 'app1', metaData: { type: 'remote' }, }); - expect( - manifest.shared.map((entry: { name: string }) => entry.name) - ).toEqual( - expect.arrayContaining([ - 'react', - 'react-native', - 'react-native/', - '@react-native/', - ]) + const sharedNames = manifest.shared.map( + (entry: { name: string }) => entry.name ); + expect(sharedNames).toEqual(expect.arrayContaining(['react', 'react-native'])); + // Synthetic deep-import prefixes injected by the plugin must not leak + // into the manifest as fake shared dependencies + expect(sharedNames).not.toContain('react-native/'); + expect(sharedNames).not.toContain('@react-native/'); expect( manifest.shared.find((entry: { name: string }) => entry.name === 'react') ).toMatchObject({ diff --git a/packages/repack/src/plugins/__tests__/federationManifest.test.ts b/packages/repack/src/plugins/__tests__/federationManifest.test.ts index beffd45a4..386ccdd3a 100644 --- a/packages/repack/src/plugins/__tests__/federationManifest.test.ts +++ b/packages/repack/src/plugins/__tests__/federationManifest.test.ts @@ -133,7 +133,6 @@ describe('buildSharedEntries', () => { buildSharedEntries( { react: { singleton: true, eager: true, requiredVersion: '^18.0.0' }, - 'react-native/': { singleton: true, eager: true }, 'not-installed': { singleton: true }, }, FIXTURES_CONTEXT @@ -146,13 +145,6 @@ describe('buildSharedEntries', () => { eager: true, requiredVersion: '^18.0.0', }, - { - name: 'react-native/', - version: '0.0.0-fixture', - singleton: true, - eager: true, - requiredVersion: '*', - }, { name: 'not-installed', version: 'unknown', @@ -163,6 +155,18 @@ describe('buildSharedEntries', () => { ]); }); + it('skips synthetic deep-import sharing keys with a trailing slash', () => { + const entries = buildSharedEntries( + { + react: { singleton: true }, + 'react-native/': { singleton: true }, + '@react-native/': { singleton: true }, + }, + FIXTURES_CONTEXT + ); + expect(entries.map((entry) => entry.name)).toEqual(['react']); + }); + it('normalizes array configs with string and wrapper entries', () => { const entries = buildSharedEntries( ['react', { 'react-native': { singleton: true } }], diff --git a/packages/repack/src/plugins/federationManifest/shared.ts b/packages/repack/src/plugins/federationManifest/shared.ts index f487a09db..778365ded 100644 --- a/packages/repack/src/plugins/federationManifest/shared.ts +++ b/packages/repack/src/plugins/federationManifest/shared.ts @@ -122,7 +122,15 @@ export function buildSharedEntries( ): FederationManifestSharedEntry[] { const versionCache = new Map(); - return normalizeSharedEntries(shared).map(({ name, config }) => { + // Skip synthetic deep-import sharing keys with a trailing slash (e.g. + // `react-native/`, `@react-native/`) auto-injected by the federation + // plugins. They are webpack prefix-matching markers, not real packages, + // so they carry no shareable version information. + const names = normalizeSharedEntries(shared).filter( + ({ name }) => !name.endsWith('/') + ); + + return names.map(({ name, config }) => { if (!versionCache.has(name)) { versionCache.set(name, resolveInstalledVersion(name, context)); } From 12ddc48edd560a1bb020ea3033611309f159b172 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 15:58:32 +0200 Subject: [PATCH 6/7] style(repack): format federation manifest shared assertion --- .../src/plugins/__tests__/ModuleFederationPluginV1.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts index 6cd3c6a3e..384e5fdeb 100644 --- a/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts +++ b/packages/repack/src/plugins/__tests__/ModuleFederationPluginV1.test.ts @@ -380,7 +380,9 @@ describe('ModuleFederationPlugin', () => { const sharedNames = manifest.shared.map( (entry: { name: string }) => entry.name ); - expect(sharedNames).toEqual(expect.arrayContaining(['react', 'react-native'])); + expect(sharedNames).toEqual( + expect.arrayContaining(['react', 'react-native']) + ); // Synthetic deep-import prefixes injected by the plugin must not leak // into the manifest as fake shared dependencies expect(sharedNames).not.toContain('react-native/'); From c5df67f0ff8e23be0d99afc077d55a186aecb0c9 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 18:43:57 +0200 Subject: [PATCH 7/7] fix(repack): report conventional host-eager/remote-lazy as EAGER_ADVISORY warning --- .../federation/__tests__/doctor.test.ts | 52 +++++++++++++++++-- .../repack/src/commands/federation/doctor.ts | 9 ++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/repack/src/commands/federation/__tests__/doctor.test.ts b/packages/repack/src/commands/federation/__tests__/doctor.test.ts index 6c85f22e5..f22896d4e 100644 --- a/packages/repack/src/commands/federation/__tests__/doctor.test.ts +++ b/packages/repack/src/commands/federation/__tests__/doctor.test.ts @@ -64,16 +64,62 @@ describe('runDoctor', () => { expect(finding.message).toContain('~0.74.5'); }); - it('flags singleton and eager mismatches naming both values', () => { + it('flags a singleton mismatch as error and the conventional eager mismatch as an advisory', () => { const singleton = findingFor('SINGLETON_MISMATCH'); expect(singleton.severity).toBe('error'); expect(singleton.message).toContain('true'); expect(singleton.message).toContain('false'); - const eager = findingFor('EAGER_MISMATCH'); - expect(eager.severity).toBe('error'); + // The conflicting fixture puts react-native eager: true on the host and + // eager: false on the remote — the expected host-eager/remote-lazy + // convention — so it is an advisory, not an error. + const eager = findingFor('EAGER_ADVISORY'); + expect(eager.severity).toBe('warning'); expect(eager.message).toContain('true'); expect(eager.message).toContain('false'); + expect(eager.message).toContain('host-eager/remote-lazy convention'); + + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + expect(codes(report)).not.toContain('EAGER_MISMATCH'); + }); + + it('errors on a reverse eager mismatch keeping the legacy message byte-identical', () => { + const lazyHost = clone(host); + const eagerRemote = clone(remoteConflicting); + // react-native: host eager: false against remote eager: true — no + // convention orders this; it stays the legacy EAGER_MISMATCH error. + lazyHost.shared[1]!.eager = false; + eagerRemote.shared[1]!.eager = true; + + const report = runDoctor({ + host: lazyHost, + remotes: [{ name: 'store', manifest: eagerRemote }], + }); + + const eager = report.findings.find( + (finding) => finding.code === 'EAGER_MISMATCH' + ); + expect(eager?.severity).toBe('error'); + expect(eager?.message).toBe( + 'Shared dependency "react-native" is eager: false on host "shell" but true on remote "store".' + ); + expect(codes(report)).not.toContain('EAGER_ADVISORY'); + }); + + it('exits 0 when the conventional eager advisory is the only finding', () => { + const lazyRemote = clone(remoteClean); + lazyRemote.shared[0]!.eager = false; + + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: lazyRemote }], + }); + + expect(codes(report)).toEqual(['EAGER_ADVISORY']); + expect(doctorExitCode(report)).toBe(0); }); it('errors when a remote native module is absent from a trusted host list', () => { diff --git a/packages/repack/src/commands/federation/doctor.ts b/packages/repack/src/commands/federation/doctor.ts index 38fedcfb3..0d080f061 100644 --- a/packages/repack/src/commands/federation/doctor.ts +++ b/packages/repack/src/commands/federation/doctor.ts @@ -100,10 +100,13 @@ function checkSharedDeps( }); } if (hostEntry.eager !== remoteEntry.eager) { + const conventional = hostEntry.eager && !remoteEntry.eager; // host-eager / remote-lazy = MF convention findings.push({ - severity: 'error', - code: 'EAGER_MISMATCH', - message: `Shared dependency "${name}" is eager: ${hostEntry.eager} on host "${host.name}" but ${remoteEntry.eager} on remote "${remoteName}".`, + severity: conventional ? 'warning' : 'error', + code: conventional ? 'EAGER_ADVISORY' : 'EAGER_MISMATCH', + message: conventional + ? `Shared dependency "${name}" is eager: true on host "${host.name}" but eager: false on remote "${remoteName}" — expected host-eager/remote-lazy convention; reported as advisory.` + : `Shared dependency "${name}" is eager: ${hostEntry.eager} on host "${host.name}" but ${remoteEntry.eager} on remote "${remoteName}".`, }); }