From 8686d8ed299cdff2215bee9e1336d89418b75d21 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 11:36:46 +0200 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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}".`, }); } From 6c06f0bca01e76c583c3c0b2cc51d233bc8e4f73 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 18:55:20 +0200 Subject: [PATCH 08/19] refactor(repack): share installed-version resolver via lazy builtin access --- .../src/plugins/federationManifest/shared.ts | 123 +------ .../utils/__tests__/browserSafeBarrel.test.ts | 303 ++++++++++++++++++ .../repack/src/utils/sharedVersionResolver.ts | 127 ++++++++ 3 files changed, 443 insertions(+), 110 deletions(-) create mode 100644 packages/repack/src/utils/__tests__/browserSafeBarrel.test.ts create mode 100644 packages/repack/src/utils/sharedVersionResolver.ts diff --git a/packages/repack/src/plugins/federationManifest/shared.ts b/packages/repack/src/plugins/federationManifest/shared.ts index 778365ded..64c061bae 100644 --- a/packages/repack/src/plugins/federationManifest/shared.ts +++ b/packages/repack/src/plugins/federationManifest/shared.ts @@ -1,115 +1,18 @@ -import fs from 'node:fs'; -import { createRequire } from 'node:module'; -import path from 'node:path'; +import { + normalizeSharedEntries, + resolveInstalledVersion, +} from '../../utils/sharedVersionResolver.js'; 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'; - } -} +// The resolver and the normalizer moved to `utils/sharedVersionResolver.js` +// (lazily builtin-accessing) so `utils/defineShared` can share them without +// pulling Node builtins into the bundle-facing utils barrel. They stay +// re-exported here so every existing importer keeps resolving through +// './shared.js'. +export { + normalizeSharedEntries, + resolveInstalledVersion, +} from '../../utils/sharedVersionResolver.js'; /** * Build the `shared[]` block: one entry per shared dependency with the diff --git a/packages/repack/src/utils/__tests__/browserSafeBarrel.test.ts b/packages/repack/src/utils/__tests__/browserSafeBarrel.test.ts new file mode 100644 index 000000000..0ff1f3d5d --- /dev/null +++ b/packages/repack/src/utils/__tests__/browserSafeBarrel.test.ts @@ -0,0 +1,303 @@ +import fs from 'node:fs'; +import { isBuiltin } from 'node:module'; +import path from 'node:path'; + +/** + * Guards the browser-safety contract of the bundle-facing `utils` barrel: + * no Node builtin module may be reachable through **static** import edges + * from `utils/index.ts` (or from the package public entry's `./utils` + * subtree). Node builtins may only be reached lazily — `require()` inside a + * function body — so importing the barrel in a browser/RN bundle never + * drags `node:fs`-style modules into the graph. + * + * How the walker works: + * - Reads source files from disk and extracts static edges with a regex for + * `import … from '…'`, `export … from '…'` and side-effect `import '…'` + * (single-quoted, matching the repo's biome quote style). + * - `import type …` / `export type … from` are erased by TypeScript and are + * not runtime edges, so they are excluded. + * - `import()` and `require()` are excluded as non-static by construction: + * the patterns below never match them. + * - Relative specifiers resolve `./x.js` → `./x.ts` (the repo's ESM-style + * authoring convention). Bare specifiers (`dedent`, `webpack`, …) are + * external packages: recorded as builtin-check candidates but never + * walked into — the contract is about this repo's own static graph. + * + * Documented exclusions (pre-existing usage, out of scope for the resolver + * move that introduced this test): + * - From the package public entry, only the `./utils` subtree is walked. + * `export * as plugins from './plugins/index.js'` and the other entry + * exports statically reach Node builtins today (e.g. the federation + * manifest plugin imports `node:fs`); those namespaces are not part of + * the browser-safe contract. + * - Pre-existing edges inside the browser-safe target zone are pinned in + * `DOCUMENTED_EXCLUSIONS` below, each with its own scope. They shipped + * before this test existed; the resolver move changes neither of them. + * The exclusion is exact — new edges out of `utils/`, new files under + * `loaders/`, or new builtins are still violations. + */ + +const srcRoot = path.resolve(__dirname, '../..'); + +interface Edge { + /** Specifier as written, e.g. './federated.js' or 'node:fs'. */ + spec: string; + /** True for `import type` / `export type … from` (erased at compile time). */ + typeOnly: boolean; +} + +/** Strip comments and template-literal bodies, keeping the rest verbatim. */ +function stripCommentsAndTemplates(source: string): string { + let out = ''; + let i = 0; + const state: { kind: 'code' | 'line' | 'block' | 'template' } = { + kind: 'code', + }; + while (i < source.length) { + const two = source.slice(i, i + 2); + if (state.kind === 'code') { + if (two === '//') { + state.kind = 'line'; + i += 2; + continue; + } + if (two === '/*') { + state.kind = 'block'; + i += 2; + continue; + } + if (source[i] === '`') { + state.kind = 'template'; + out += '``'; + i += 1; + continue; + } + if (source[i] === "'" || source[i] === '"') { + // Keep single/double-quoted strings verbatim: import specifiers are + // written with them. Template literals (which can embed sample code) + // were already emptied above. + const quote = source[i]; + let j = i + 1; + while (j < source.length && source[j] !== quote) { + j += source[j] === '\\' ? 2 : 1; + } + out += source.slice(i, j + 1); + i = j + 1; + continue; + } + out += source[i]; + i += 1; + continue; + } + if (state.kind === 'line') { + if (source[i] === '\n') { + state.kind = 'code'; + out += '\n'; + } + i += 1; + continue; + } + if (state.kind === 'block') { + if (two === '*/') { + state.kind = 'code'; + i += 2; + continue; + } + if (source[i] === '\n') out += '\n'; + i += 1; + continue; + } + // template: skip until the closing backtick (no nested interpolation + // is expected in the files this walker covers). + if (source[i] === '\\') { + i += 2; + continue; + } + if (source[i] === '`') { + state.kind = 'code'; + } + i += 1; + } + return out; +} + +const FROM_EDGE = /(?:^|[\s;}])(import|export)\b([\s\S]*?)\bfrom\s*'([^']+)'/g; +const SIDE_EFFECT_IMPORT = /(?:^|[\s;}])import\s*'([^']+)'/g; + +/** Extract the static import/export-from edges of one source file. */ +function staticEdgesOf(filePath: string): Edge[] { + const source = stripCommentsAndTemplates(fs.readFileSync(filePath, 'utf-8')); + const edges: Edge[] = []; + for (const match of source.matchAll(FROM_EDGE)) { + const clause = match[2]; + // `import x from 'a'; import y from 'b'` on one line would make the lazy + // clause span junk; a genuine clause never contains quotes or semicolons. + if (/[';]/.test(clause)) continue; + edges.push({ spec: match[3], typeOnly: /^\s*type\b/.test(clause) }); + } + for (const match of source.matchAll(SIDE_EFFECT_IMPORT)) { + edges.push({ spec: match[1], typeOnly: false }); + } + return edges; +} + +/** Resolve a relative specifier using the repo's `./x.js` → `./x.ts` rule. */ +function resolveRelative(fromFile: string, spec: string): string | null { + const base = path.resolve(path.dirname(fromFile), spec); + const candidates = [ + base.replace(/\.js$/, '.ts'), + base.replace(/\.js$/, '.tsx'), + `${base.replace(/\.js$/, '')}.ts`, + `${base.replace(/\.js$/, '')}/index.ts`, + ]; + for (const candidate of new Set(candidates)) { + if (fs.existsSync(candidate) && candidate.endsWith('.ts')) { + return candidate; + } + } + return null; +} + +function specReachesBuiltin(spec: string): boolean { + return spec.startsWith('node:') || isBuiltin(spec); +} + +interface Exclusion { + /** Exact importing file the exception applies to. */ + file: string; + /** Exact specifier that is allowed (e.g. 'node:url' or '../loaders/x.js'). */ + spec: string; + /** Where the walk may follow the edge: nothing, the importing dir's subtree, or a dir. */ + follow: 'none' | 'self' | string; + reason: string; +} + +/** + * Pre-existing edges inside the browser-safe target zone, pinned visibly. + * Each one shipped before this test existed; `utils/getAssetTransformRules.ts` + * statically imports `loaders/assetsLoader/options.js` (which imports + * `schema-utils`, used for loader option schemas, not runtime bundling), + * `utils/federated.ts` statically imports `node:url`, and + * `utils/getDirname.ts` statically imports `node:path` and `node:url`. + * Removing any of them is a separate behavior change, out of scope for the + * resolver move that introduced this test. The exclusions are exact per + * (file, spec) pair: a NEW static builtin or escape edge anywhere in the + * walked graph is a violation. + */ +const DOCUMENTED_EXCLUSIONS: Exclusion[] = [ + { + file: path.join(srcRoot, 'utils/federated.ts'), + spec: 'node:url', + follow: 'none', + reason: 'pre-existing static node:url import', + }, + { + file: path.join(srcRoot, 'utils/getDirname.ts'), + spec: 'node:path', + follow: 'none', + reason: 'pre-existing static node:path import', + }, + { + file: path.join(srcRoot, 'utils/getDirname.ts'), + spec: 'node:url', + follow: 'none', + reason: 'pre-existing static node:url import', + }, + { + file: path.join(srcRoot, 'utils/getAssetTransformRules.ts'), + spec: '../loaders/assetsLoader/options.js', + follow: path.join(srcRoot, 'loaders/assetsLoader'), + reason: + 'pre-existing utils → loaders edge; subtree walk is bounded to the assetsLoader directory', + }, +]; + +function exclusionFor(exclusions: Exclusion[], file: string, spec: string) { + return exclusions.find( + (exclusion) => exclusion.file === file && exclusion.spec === spec + ); +} + +function collectViolations( + entries: Array<{ + file: string; + restrictToDir?: string; + exclusions?: Exclusion[]; + }> +): string[] { + const violations: string[] = []; + const queue = [...entries]; + const seen = new Set(); + + while (queue.length > 0) { + const { file, restrictToDir, exclusions = [] } = queue.shift()!; + if (seen.has(file)) continue; + seen.add(file); + + for (const edge of staticEdgesOf(file)) { + if (edge.typeOnly) continue; + const exclusion = exclusionFor(exclusions, file, edge.spec); + if (specReachesBuiltin(edge.spec) && !exclusion) { + violations.push( + `${path.relative(srcRoot, file)} statically imports '${edge.spec}'` + ); + } + if (!edge.spec.startsWith('.')) continue; // external package: not walked + const resolved = resolveRelative(file, edge.spec); + if (!resolved) continue; + const insideRestricted = + !restrictToDir || resolved.startsWith(restrictToDir + path.sep); + if (exclusion) { + if (exclusion.follow === 'none' || !insideRestricted) continue; + queue.push({ + file: resolved, + restrictToDir: + typeof exclusion.follow === 'string' + ? exclusion.follow + : restrictToDir, + exclusions, + }); + continue; + } + if (!insideRestricted) { + continue; // outside the restricted subtree: documented exclusion + } + queue.push({ file: resolved, restrictToDir, exclusions }); + } + } + return violations; +} + +describe('browser-safe utils barrel', () => { + it('has no builtin reachable through static edges from utils/index.ts', () => { + const violations = collectViolations([ + { + file: path.join(srcRoot, 'utils/index.ts'), + exclusions: DOCUMENTED_EXCLUSIONS, + }, + ]); + expect(violations).toEqual([]); + }); + + it('keeps the public entry builtin-free through the ./utils subtree', () => { + const violations = collectViolations([ + { + file: path.join(srcRoot, 'index.ts'), + restrictToDir: path.join(srcRoot, 'utils'), + exclusions: DOCUMENTED_EXCLUSIONS, + }, + ]); + expect(violations).toEqual([]); + }); + + // Forward contract for the resolver move and the upcoming defineShared: + // utils may reach Node builtins only through this module, and only via + // require() inside function bodies — so it must exist with zero static + // import edges. A future refactor adding a top-level import there must + // update this test deliberately. + it('exposes sharedVersionResolver.ts with zero static import edges', () => { + const resolver = path.join(srcRoot, 'utils/sharedVersionResolver.ts'); + expect(fs.existsSync(resolver)).toBe(true); + expect(staticEdgesOf(resolver)).toEqual([]); + }); +}); diff --git a/packages/repack/src/utils/sharedVersionResolver.ts b/packages/repack/src/utils/sharedVersionResolver.ts new file mode 100644 index 000000000..86fd83b52 --- /dev/null +++ b/packages/repack/src/utils/sharedVersionResolver.ts @@ -0,0 +1,127 @@ +/** + * Internal module hosting the installed-version resolver and the `shared` + * configuration normalizer, shared between the federation manifest plugin + * and `utils/defineShared.js`. + * + * CONTRACT: this module has ZERO static import edges and reaches Node + * builtins only via `require()` calls inside function bodies (precedent: + * `commands/common/config/loadProjectConfig.ts`). It is statically imported + * by `utils/defineShared.ts`, which is exported from the bundle-facing + * `utils` barrel — a top-level import here would pull Node builtins into + * every bundle that imports the barrel. The contract is pinned by + * `utils/__tests__/browserSafeBarrel.test.ts`; change it only by updating + * that test deliberately. + */ + +/** + * 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 fs = require('node:fs') as typeof import('node:fs'); + const path = require('node:path') as typeof import('node:path'); + const { createRequire } = + require('node:module') as typeof import('node:module'); + 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 { + const fs = require('node:fs') as typeof import('node:fs'); + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { + version?: string; + }; + return parsed.version ?? 'unknown'; + } catch { + return 'unknown'; + } +} From 3e010041b10bd22573ecff90a12160beeab3a03e Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 19:02:06 +0200 Subject: [PATCH 09/19] feat(repack): add defineShared exact-pin shared-config helper --- .gitignore | 4 +- .../@scoped/shared-util/package.json | 4 + .../node_modules/react-native/package.json | 4 + .../node_modules/react/package.json | 4 + .../node_modules/react/package.json | 4 + .../src/utils/__tests__/defineShared.test.ts | 249 ++++++++++++++++++ packages/repack/src/utils/defineShared.ts | 135 ++++++++++ packages/repack/src/utils/index.ts | 1 + website/src/latest/api/utils/_meta.json | 1 + website/src/latest/api/utils/define-shared.md | 67 +++++ 10 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/@scoped/shared-util/package.json create mode 100644 packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react-native/package.json create mode 100644 packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react/package.json create mode 100644 packages/repack/src/utils/__tests__/__fixtures__/define-shared-split-app/node_modules/react/package.json create mode 100644 packages/repack/src/utils/__tests__/defineShared.test.ts create mode 100644 packages/repack/src/utils/defineShared.ts create mode 100644 website/src/latest/api/utils/define-shared.md diff --git a/.gitignore b/.gitignore index 9415c2bc4..ee6ac6b95 100644 --- a/.gitignore +++ b/.gitignore @@ -390,5 +390,7 @@ packages/**/docs # watchman .watchman-cookie* -# Fixture node_modules for federation manifest tests +# Fixture node_modules for federation manifest / defineShared version tests !packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/ +!packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/ +!packages/repack/src/utils/__tests__/__fixtures__/define-shared-split-app/node_modules/ diff --git a/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/@scoped/shared-util/package.json b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/@scoped/shared-util/package.json new file mode 100644 index 000000000..2d1cc833e --- /dev/null +++ b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/@scoped/shared-util/package.json @@ -0,0 +1,4 @@ +{ + "name": "@scoped/shared-util", + "version": "1.2.3" +} diff --git a/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react-native/package.json b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react-native/package.json new file mode 100644 index 000000000..99ca1d053 --- /dev/null +++ b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react-native/package.json @@ -0,0 +1,4 @@ +{ + "name": "react-native", + "version": "0.84.1" +} diff --git a/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react/package.json b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react/package.json new file mode 100644 index 000000000..429b89129 --- /dev/null +++ b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/react/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "9.9.9" +} diff --git a/packages/repack/src/utils/__tests__/__fixtures__/define-shared-split-app/node_modules/react/package.json b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-split-app/node_modules/react/package.json new file mode 100644 index 000000000..6152e1085 --- /dev/null +++ b/packages/repack/src/utils/__tests__/__fixtures__/define-shared-split-app/node_modules/react/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "8.8.8" +} diff --git a/packages/repack/src/utils/__tests__/defineShared.test.ts b/packages/repack/src/utils/__tests__/defineShared.test.ts new file mode 100644 index 000000000..7419f19bb --- /dev/null +++ b/packages/repack/src/utils/__tests__/defineShared.test.ts @@ -0,0 +1,249 @@ +import path from 'node:path'; +import { + type DefineSharedDeps, + defineShared, + SharedDependencyUnresolvedError, +} from '../defineShared.js'; + +const APP = path.join(__dirname, '__fixtures__', 'define-shared-app'); +const SPLIT_APP = path.join( + __dirname, + '__fixtures__', + 'define-shared-split-app' +); + +describe('defineShared', () => { + describe('exact pins from installed packages', () => { + it('pins version and requiredVersion to the installed package version', () => { + const shared = defineShared({ react: 'auto' }, { context: APP }); + + expect(shared.react).toMatchObject({ + version: '9.9.9', + requiredVersion: '9.9.9', + }); + }); + + it('resolves the copy visible from each provided context', () => { + const appShared = defineShared(['react'], { context: APP }); + const splitShared = defineShared(['react'], { context: SPLIT_APP }); + + expect(appShared.react!.version).toBe('9.9.9'); + expect(splitShared.react!.version).toBe('8.8.8'); + }); + + it('defaults the context to the process working directory', () => { + const cwdSpy = jest + .spyOn(process, 'cwd') + .mockReturnValue(path.join(APP, 'src')); + + try { + // No explicit context: resolution must start from cwd (a subdirectory + // of the fixture app, so `react` resolves through its node_modules). + const shared = defineShared(['react']); + expect(shared.react!.version).toBe('9.9.9'); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('overwrites any user-provided version and requiredVersion', () => { + const shared = defineShared( + { react: { version: '1.2.3', requiredVersion: '^1.0.0' } }, + { context: APP } + ); + + expect(shared.react).toMatchObject({ + version: '9.9.9', + requiredVersion: '9.9.9', + }); + }); + + it('fails loud naming the package, the context and the fix', () => { + let caught: unknown; + try { + defineShared(['@fixture/not-installed'], { context: APP }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SharedDependencyUnresolvedError); + const error = caught as SharedDependencyUnresolvedError; + expect(error.name).toBe('SharedDependencyUnresolvedError'); + // Frozen text (design D2): names the package and the abs context, + // explains why an exact pin is required, and how to fix it. Never + // substitutes a placeholder, range or `unknown`. + expect(error.message).toBe( + 'defineShared: cannot resolve an installed version for "@fixture/not-installed" from context ' + + `"${APP}".\n` + + 'Shared dependencies must be pinned to one exact installed version so host and remotes agree — ' + + 'an unresolvable package cannot be pinned, and no placeholder or range is substituted.\n' + + `Fix: install "@fixture/not-installed" in the app resolved from "${APP}", ` + + 'or remove it from the shared dependency list.' + ); + }); + }); + + describe('identical pins from one dependency tree', () => { + it('emits byte-identical maps for two contexts resolving the same copy', () => { + const hostSide = defineShared(['react', 'react-native'], { + context: APP, + role: 'remote', + }); + const remoteSide = defineShared(['react', 'react-native'], { + // A different directory inside the same tree: the same installed + // copies resolve, so the emitted pins must be byte-identical. + context: path.join(APP, 'src', 'features'), + role: 'remote', + }); + + expect(JSON.stringify(hostSide)).toBe(JSON.stringify(remoteSide)); + }); + }); + + describe('role and mode eager convention', () => { + it('emits eager: true for every entry with the default host role', () => { + const shared = defineShared(['react', 'react-native'], { + context: APP, + }); + + expect(shared.react!.eager).toBe(true); + expect(shared['react-native']!.eager).toBe(true); + }); + + it('emits eager: false for every entry with role remote', () => { + const shared = defineShared(['react', 'react-native'], { + context: APP, + role: 'remote', + }); + + expect(shared.react!.eager).toBe(false); + expect(shared['react-native']!.eager).toBe(false); + }); + + it('emits eager: true for every entry in standalone mode regardless of role', () => { + const shared = defineShared(['react', 'react-native'], { + context: APP, + role: 'remote', + mode: 'standalone', + }); + + expect(shared.react!.eager).toBe(true); + expect(shared['react-native']!.eager).toBe(true); + }); + + it('lets an explicit eager value win over the convention', () => { + const shared = defineShared(['react', 'react-native'], { + context: APP, + role: 'remote', + mode: 'standalone', + }); + const eagerRemote = defineShared( + { react: { eager: true } }, + { context: APP, role: 'remote' } + ); + + // Both calls override the convention in opposite directions. + const explicitLazy = defineShared( + { react: { eager: false } }, + { context: APP, mode: 'standalone' } + ); + + expect(explicitLazy.react!.eager).toBe(false); + expect(eagerRemote.react!.eager).toBe(true); + // sanity: without overrides the convention still applies + expect(shared.react!.eager).toBe(true); + expect(shared['react-native']!.eager).toBe(true); + }); + }); + + describe('singleton and passthrough config', () => { + it('defaults singleton to true and honors the user override', () => { + const shared = defineShared( + { react: {}, 'react-native': { singleton: false } }, + { context: APP } + ); + + expect(shared.react!.singleton).toBe(true); + expect(shared['react-native']!.singleton).toBe(false); + }); + + it('passes through remaining config keys verbatim', () => { + const shared = defineShared( + { + react: { + import: false, + shareScope: 'custom', + strictVersion: true, + }, + }, + { context: APP } + ); + + expect(shared.react).toMatchObject({ + import: false, + shareScope: 'custom', + strictVersion: true, + }); + }); + }); + + describe('accepted dependency shapes (normalizeSharedEntries)', () => { + it('accepts a single string', () => { + const shared = defineShared('react', { context: APP }); + expect(Object.keys(shared)).toEqual(['react']); + }); + + it('accepts an array of strings', () => { + const shared = defineShared(['react', '@scoped/shared-util'], { + context: APP, + }); + expect(Object.keys(shared)).toEqual(['react', '@scoped/shared-util']); + expect(shared['@scoped/shared-util']!.version).toBe('1.2.3'); + }); + + it('accepts a record of name to config', () => { + const shared = defineShared( + { react: 'auto', 'react-native': { eager: false } }, + { context: APP } + ); + expect(Object.keys(shared)).toEqual(['react', 'react-native']); + expect(shared['react-native']!.eager).toBe(false); + }); + + it('accepts a mixed array of wrappers and name items', () => { + const deps: DefineSharedDeps = [ + 'react', + { 'react-native': { eager: false } }, + { name: '@scoped/shared-util', singleton: false }, + ]; + const shared = defineShared(deps, { context: APP }); + + expect(Object.keys(shared)).toEqual([ + 'react', + 'react-native', + '@scoped/shared-util', + ]); + expect(shared['@scoped/shared-util']!.singleton).toBe(false); + }); + }); + + describe('deep-import keys', () => { + it('emits a user-declared trailing-slash key verbatim, never version-resolved', () => { + const shared = defineShared( + { 'react-native/': { eager: true } }, + { context: APP } + ); + + expect(shared['react-native/']).toEqual({ eager: true }); + }); + + it('never injects synthetic deep-import keys', () => { + const shared = defineShared(['react', 'react-native'], { + context: APP, + }); + + expect(Object.keys(shared)).not.toContain('react-native/'); + expect(Object.keys(shared)).not.toContain('@react-native/'); + }); + }); +}); diff --git a/packages/repack/src/utils/defineShared.ts b/packages/repack/src/utils/defineShared.ts new file mode 100644 index 000000000..a82df69c4 --- /dev/null +++ b/packages/repack/src/utils/defineShared.ts @@ -0,0 +1,135 @@ +import { + normalizeSharedEntries, + resolveInstalledVersion, +} from './sharedVersionResolver.js'; + +/** + * Per-dependency configuration accepted inside any of the shapes + * `defineShared` takes. Unknown keys are passed through verbatim to the + * federation plugin `shared` option (`import`, `shareScope`, …). + */ +export interface SharedDepConfig { + singleton?: boolean; + eager?: boolean; + [key: string]: unknown; +} + +/** + * All dependency shapes `normalizeSharedEntries` handles: a single package + * name, an array of names / `{ [name]: config }` wrappers / + * `{ name, ...config }` items, or a `{ [name]: config | string }` record. + */ +export type DefineSharedDeps = + | string + | Array< + | string + | Record + | ({ name: string } & SharedDepConfig) + > + | Record; + +export interface DefineSharedOptions { + /** Directory version resolution starts from. Defaults to `process.cwd()`. */ + context?: string; + /** Eager convention role. Defaults to `'host'`. */ + role?: 'host' | 'remote'; + /** Eager convention mode. Defaults to `'federated'`. */ + mode?: 'federated' | 'standalone'; +} + +/** One emitted shared entry: exact pins plus the merged user config. */ +export interface SharedEntryOut { + singleton: boolean; + eager: boolean; + /** Exact installed version pin — never a range or committed literal. */ + version: string; + /** Same exact installed version pin. */ + requiredVersion: string; + [key: string]: unknown; +} + +export type SharedMap = Record; + +/** + * Thrown when a declared shared dependency cannot be resolved to an installed + * `package.json` from the context. `defineShared` never substitutes a + * placeholder, range, or `unknown` version. + */ +export class SharedDependencyUnresolvedError extends Error { + constructor( + public packageName: string, + public context: string + ) { + super( + `defineShared: cannot resolve an installed version for "${packageName}" from context "${context}".\n` + + 'Shared dependencies must be pinned to one exact installed version so host and remotes agree — ' + + 'an unresolvable package cannot be pinned, and no placeholder or range is substituted.\n' + + `Fix: install "${packageName}" in the app resolved from "${context}", ` + + 'or remove it from the shared dependency list.' + ); + this.name = 'SharedDependencyUnresolvedError'; + } +} + +/** + * Build the Module Federation `shared` map for `deps`, pinning `version` and + * `requiredVersion` of every entry to the exact version of the package + * actually installed and resolvable from `options.context`. + * + * Conventions applied when the user config does not set the key explicitly: + * + * - `singleton`: `true` + * - `eager`: `mode === 'standalone'` → `true`; otherwise role `host` → `true`, + * role `remote` → `false` + * + * Keys ending in `/` (deep-import markers like `react-native/`) are emitted + * verbatim and never version-resolved; `defineShared` never injects them — + * the federation plugins already add and dedupe those. + * + * The `mode` derivation from the CLI (`env.argv?.standalone`) deliberately + * lives in the calling config, not here: this function is pure with respect + * to the environment beyond explicit options. + */ +export function defineShared( + deps: DefineSharedDeps, + options: DefineSharedOptions = {} +): SharedMap { + const context = options.context ?? process.cwd(); + const role = options.role ?? 'host'; + const mode = options.mode ?? 'federated'; + const conventionEager = mode === 'standalone' || role === 'host'; + + const versionCache = new Map(); + const shared: SharedMap = {}; + + for (const { name, config } of normalizeSharedEntries(deps)) { + // Deep-import prefix markers are webpack matching hints, not packages: + // emit the user config verbatim and skip version resolution entirely. + if (name.endsWith('/')) { + // Verbatim user config: deep-import markers intentionally carry no + // pins/convention fields, so the cast only satisfies the shared type. + shared[name] = { ...config } as SharedEntryOut; + continue; + } + + if (!versionCache.has(name)) { + const installed = resolveInstalledVersion(name, context); + if (installed === 'unknown') { + throw new SharedDependencyUnresolvedError(name, context); + } + versionCache.set(name, installed); + } + const version = versionCache.get(name)!; + + const { singleton, eager, ...rest } = config; + shared[name] = { + ...rest, + singleton: singleton === undefined ? true : Boolean(singleton as unknown), + eager: eager === undefined ? conventionEager : Boolean(eager as unknown), + version, + requiredVersion: version, + }; + } + + return shared; +} diff --git a/packages/repack/src/utils/index.ts b/packages/repack/src/utils/index.ts index 46fe65969..729c54f32 100644 --- a/packages/repack/src/utils/index.ts +++ b/packages/repack/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './assetExtensions.js'; export * from './defineConfig.js'; +export * from './defineShared.js'; export * from './federated.js'; export * from './getAssetTransformRules.js'; export * from './getCodegenTransformRules.js'; diff --git a/website/src/latest/api/utils/_meta.json b/website/src/latest/api/utils/_meta.json index b45f5789b..f8ef4eb3c 100644 --- a/website/src/latest/api/utils/_meta.json +++ b/website/src/latest/api/utils/_meta.json @@ -1,5 +1,6 @@ [ "constants", + "define-shared", "get-asset-extension-regexp", "get-asset-transform-rules", "get-codegen-transform-rules", diff --git a/website/src/latest/api/utils/define-shared.md b/website/src/latest/api/utils/define-shared.md new file mode 100644 index 000000000..587107277 --- /dev/null +++ b/website/src/latest/api/utils/define-shared.md @@ -0,0 +1,67 @@ +# defineShared + +Build a Module Federation `shared` configuration map whose `version` and `requiredVersion` pins are resolved from the packages **actually installed** at bundler-config evaluation time. Use it in host and remote configs so the shared pins are never hand-committed literals and cannot drift by typo between apps. + +## Parameters + +```ts +function defineShared( + deps: DefineSharedDeps, + options?: { + context?: string; // default: process.cwd() + role?: 'host' | 'remote'; // default: 'host' + mode?: 'federated' | 'standalone'; // default: 'federated' + } +): Record; +``` + +### deps + +- Type: `string | Array | ({ name: string } & SharedDepConfig)> | Record` +- Required: `true` + +The shared dependency names, in any of the shapes the federation plugins accept. Any per-dependency config keys (`import`, `shareScope`, …) are passed through to the plugin untouched. + +### options.context + +Directory from which installed versions are resolved. Defaults to the process working directory — pass `env.context` from the config so each app resolves its own dependency tree. + +### options.role + +Drives the `eager` convention when an entry does not set `eager` explicitly: `host` → `eager: true`, `remote` → `eager: false`. Defaults to `'host'`. + +### options.mode + +`'standalone'` forces `eager: true` on every entry regardless of role — the mode used when a remote is built/run alone via `--standalone`. Derive it from the runtime flag, never from a committed file: `mode: env.argv?.standalone ? 'standalone' : 'federated'`. + +## Behavior + +- Every entry gets `singleton: true` and the exact installed version as both `version` and `requiredVersion`; user-provided `version`/`requiredVersion` values are overwritten by the installed pin. +- An explicit `singleton`/`eager` in the user config wins over the convention. +- Keys ending in `/` (deep-import markers like `react-native/`) are emitted verbatim and never version-resolved; `defineShared` never adds them — the federation plugins already do. +- A dependency that cannot be resolved to an installed package throws `SharedDependencyUnresolvedError` naming the package and the context, failing the config evaluation loudly. No placeholder or range is ever substituted. + +## Example + +```js title=rspack.mini-app.mjs +import * as Repack from "@callstack/repack"; + +const SHARED_DEPS = ["react", "react-native"]; + +export default Repack.defineRspackConfig((env) => ({ + // ... config + plugins: [ + new Repack.plugins.ModuleFederationPluginV1({ + name: "miniApp", + // ... exposes/remotes + shared: Repack.defineShared(SHARED_DEPS, { + context: env.context, + role: "remote", + mode: env.argv?.standalone ? "standalone" : "federated", + }), + }), + ], +})); +``` + +When host and remote resolve the same installed copies, both sides emit identical pins by construction. When split checkouts resolve different copies, each side pins what it actually sees — `federation-doctor` gates the divergence. From 7d06ba8a1abdccacd7db9074119099df92fdf344 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 19:16:39 +0200 Subject: [PATCH 10/19] feat(repack): add repack-federation.json loader with zero-flag doctor operation --- .../__tests__/federationDoctor.test.ts | 100 +++++ .../config-drift/manifests/catalog.json | 63 ++++ .../config-drift/manifests/host.json | 64 ++++ .../config-drift/repack-federation.json | 6 + .../config-invalid/repack-federation.json | 4 + .../config-noport/manifests/host.json | 64 ++++ .../config-noport/manifests/store.json | 57 +++ .../config-noport/repack-federation.json | 6 + .../config-url/repack-federation.json | 6 + .../config-valid/manifests/host.json | 64 ++++ .../config-valid/manifests/store.json | 57 +++ .../config-valid/repack-federation.json | 11 + .../nested/repack-federation.json | 6 + .../config-walkup/repack-federation.json | 6 + .../federation/__tests__/configFile.test.ts | 328 +++++++++++++++++ .../src/commands/federation/configFile.ts | 348 ++++++++++++++++++ .../repack/src/commands/federationDoctor.ts | 57 ++- packages/repack/src/commands/options.ts | 4 +- website/src/latest/api/cli/_meta.json | 5 + .../latest/api/cli/repack-federation-json.mdx | 57 +++ 20 files changed, 1292 insertions(+), 21 deletions(-) create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/manifests/catalog.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/manifests/host.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-invalid/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/manifests/host.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/manifests/store.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-url/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/manifests/host.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/manifests/store.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/nested/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/configFile.test.ts create mode 100644 packages/repack/src/commands/federation/configFile.ts create mode 100644 website/src/latest/api/cli/repack-federation-json.mdx diff --git a/packages/repack/src/commands/__tests__/federationDoctor.test.ts b/packages/repack/src/commands/__tests__/federationDoctor.test.ts index e8f86f306..bf4e91ae7 100644 --- a/packages/repack/src/commands/__tests__/federationDoctor.test.ts +++ b/packages/repack/src/commands/__tests__/federationDoctor.test.ts @@ -161,3 +161,103 @@ describe('federation-doctor command', () => { expect(exit).toHaveBeenLastCalledWith(2); }); }); + +describe('federation-doctor with repack-federation.json', () => { + const configFixture = (name: string) => path.join(FIXTURES, name); + let cwdSpy: jest.SpyInstance; + + function fromDir(dir: string) { + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(dir); + } + + afterEach(() => { + cwdSpy?.mockRestore(); + }); + + it('runs zero-flag using the file host and remotes', async () => { + fromDir(configFixture('config-valid')); + await federationDoctor([], cliConfig, {}); + + expect(stdout()).toContain('no issues found'); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('labels file-derived remotes with their declared names', async () => { + fromDir(configFixture('config-drift')); + await federationDoctor([], cliConfig, {}); + + expect(stdout()).toContain('SHARED_VERSION_DRIFT'); + expect(stdout()).toContain('remote "catalog"'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('exits 2 on a malformed file naming its path, never a stack', async () => { + // Invalid JSON is written at runtime: a committed broken .json would + // break the repo-wide biome check. + const malformedDir = path.join(tmpDir, 'config-malformed'); + fs.mkdirSync(malformedDir, { recursive: true }); + fs.writeFileSync( + path.join(malformedDir, 'repack-federation.json'), + '{ "host": { "manifest":\n' + ); + fromDir(malformedDir); + await federationDoctor([], cliConfig, {}); + + const printed = error.mock.calls.map(([line]) => String(line)).join('\n'); + expect(printed).toContain('Federation config'); + expect(printed).toContain( + path.join(malformedDir, 'repack-federation.json') + ); + expect(printed).toContain('is not valid JSON'); + expect(printed).not.toMatch(/\n\s+at\s+\S/); + // Malformed input must never fall through to defaults or a report. + expect(log).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('exits 2 naming the failing field path for a schema violation', async () => { + fromDir(configFixture('config-invalid')); + await federationDoctor([], cliConfig, {}); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('host.manifest is required (string)') + ); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('lets --host override the file host while remotes still come from the file', async () => { + // The file host is clean against the file remote (exit 0 baseline); + // a flag host that drifts from that remote proves per-value precedence. + fromDir(configFixture('config-valid')); + await federationDoctor([], cliConfig, { host: DRIFT_REMOTE }); + + expect(stdout()).toContain('SHARED_VERSION_DRIFT'); + expect(stdout()).toContain('remote "store"'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('exits 2 with the required-option message when neither source applies', async () => { + const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-nocfg-')); + fromDir(isolated); + await federationDoctor([], cliConfig, {}); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('--host')); + expect(exit).toHaveBeenCalledWith(2); + fs.rmSync(isolated, { recursive: true, force: true }); + }); + + it('behaves identically with and without declared ports', async () => { + fromDir(configFixture('config-valid')); + await federationDoctor([], cliConfig, {}); + const withPort = { out: stdout(), code: exit.mock.calls[0]?.[0] }; + exit.mockClear(); + log.mockClear(); + cwdSpy.mockRestore(); + + fromDir(configFixture('config-noport')); + await federationDoctor([], cliConfig, {}); + + expect(stdout()).toBe(withPort.out); + expect(exit).toHaveBeenCalledWith(withPort.code); + }); +}); diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/manifests/catalog.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/manifests/catalog.json new file mode 100644 index 000000000..8f3ba426a --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/manifests/catalog.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__/config-drift/manifests/host.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/manifests/host.json new file mode 100644 index 000000000..85e59aace --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/manifests/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__/config-drift/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/repack-federation.json new file mode 100644 index 000000000..dd7b61138 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-drift/repack-federation.json @@ -0,0 +1,6 @@ +{ + "host": { "manifest": "./manifests/host.json" }, + "remotes": { + "catalog": { "manifest": "./manifests/catalog.json", "port": 9000 } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-invalid/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-invalid/repack-federation.json new file mode 100644 index 000000000..87f8751c6 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-invalid/repack-federation.json @@ -0,0 +1,4 @@ +{ + "host": { "root": "." }, + "remotes": {} +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/manifests/host.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/manifests/host.json new file mode 100644 index 000000000..85e59aace --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/manifests/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__/config-noport/manifests/store.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/manifests/store.json new file mode 100644 index 000000000..68d11fb3d --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/manifests/store.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__/config-noport/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/repack-federation.json new file mode 100644 index 000000000..857d9226a --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-noport/repack-federation.json @@ -0,0 +1,6 @@ +{ + "host": { "manifest": "./manifests/host.json" }, + "remotes": { + "store": { "manifest": "./manifests/store.json" } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-url/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-url/repack-federation.json new file mode 100644 index 000000000..3054feec5 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-url/repack-federation.json @@ -0,0 +1,6 @@ +{ + "host": { "manifest": "../config-valid/manifests/host.json" }, + "remotes": { + "store": { "manifest": "http://localhost:8082" } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/manifests/host.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/manifests/host.json new file mode 100644 index 000000000..85e59aace --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/manifests/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__/config-valid/manifests/store.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/manifests/store.json new file mode 100644 index 000000000..68d11fb3d --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/manifests/store.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__/config-valid/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/repack-federation.json new file mode 100644 index 000000000..69198d07a --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-valid/repack-federation.json @@ -0,0 +1,11 @@ +{ + "host": { "manifest": "./manifests/host.json", "root": "." }, + "remotes": { + "store": { + "manifest": "./manifests/store.json", + "root": "./apps/store", + "standalone": true, + "port": 8082 + } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/nested/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/nested/repack-federation.json new file mode 100644 index 000000000..d839e5956 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/nested/repack-federation.json @@ -0,0 +1,6 @@ +{ + "host": { "manifest": "../../config-valid/manifests/host.json" }, + "remotes": { + "store": { "manifest": "../../config-valid/manifests/store.json" } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/repack-federation.json new file mode 100644 index 000000000..275b6fa12 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-walkup/repack-federation.json @@ -0,0 +1,6 @@ +{ + "host": { "manifest": "../config-valid/manifests/host.json" }, + "remotes": { + "store": { "manifest": "../config-valid/manifests/store.json" } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/configFile.test.ts b/packages/repack/src/commands/federation/__tests__/configFile.test.ts new file mode 100644 index 000000000..cb5baac65 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/configFile.test.ts @@ -0,0 +1,328 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + ConfigFileInvalidError, + describeJsonParseFailure, + FEDERATION_CONFIG_FILENAME, + findConfigPath, + loadFederationConfig, + resolveFederationWorkspace, + validateFederationConfig, +} from '../configFile.js'; + +const FIXTURES = path.join(__dirname, '__fixtures__'); +const VALID_DIR = path.join(FIXTURES, 'config-valid'); +const URL_DIR = path.join(FIXTURES, 'config-url'); +const WALKUP_DIR = path.join(FIXTURES, 'config-walkup'); + +let tmpDir: string; +// Truncated JSON built at runtime: a committed invalid .json would break the +// repo-wide biome check (same pattern as the corrupt-remote tmp fixture). +let malformedDir: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-config-')); + malformedDir = path.join(tmpDir, 'config-malformed'); + fs.mkdirSync(malformedDir); + fs.writeFileSync( + path.join(malformedDir, FEDERATION_CONFIG_FILENAME), + '{ "host": { "manifest":\n' + ); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('validateFederationConfig', () => { + it('accepts the minimal document', () => { + expect( + validateFederationConfig({ + host: { manifest: './shell/build' }, + remotes: { store: { manifest: 'http://localhost:8082' } }, + }) + ).toEqual([]); + }); + + it('accepts the full document with every optional field', () => { + expect( + validateFederationConfig({ + host: { manifest: './shell/build', root: '.' }, + remotes: { + store: { + manifest: './store/build', + root: './apps/store', + standalone: true, + port: 8082, + }, + }, + }) + ).toEqual([]); + }); + + it('rejects an unknown top-level key naming its path', () => { + expect( + validateFederationConfig({ + host: { manifest: '.' }, + remotes: {}, + bogus: true, + }) + ).toEqual(['bogus is not a known field']); + }); + + it('rejects unknown nested keys naming the full field path', () => { + expect( + validateFederationConfig({ + host: { manifest: '.', bogus: 1 }, + remotes: { store: { manifest: '.', bogus: 2 } }, + }) + ).toEqual([ + 'host.bogus is not a known field', + 'remotes.store.bogus is not a known field', + ]); + }); + + it('names the field path for every schema violation', () => { + expect( + validateFederationConfig({ host: { root: '.' }, remotes: {} }) + ).toEqual(['host.manifest is required (string)']); + expect( + validateFederationConfig({ + host: { manifest: '.' }, + remotes: { store: { manifest: '.', standalone: 'yes' } }, + }) + ).toEqual(['remotes.store.standalone must be a boolean']); + expect( + validateFederationConfig({ + host: { manifest: 42 }, + remotes: {}, + }) + ).toEqual(['host.manifest is required (string)']); + expect( + validateFederationConfig({ + host: { manifest: '.' }, + remotes: { store: {} }, + }) + ).toEqual(['remotes.store.manifest is required (string)']); + expect( + validateFederationConfig({ + host: { manifest: '.' }, + remotes: { store: { manifest: '.', port: '8082' } }, + }) + ).toEqual(['remotes.store.port must be a number']); + }); + + it('rejects remotes as an array', () => { + expect( + validateFederationConfig({ + host: { manifest: '.' }, + remotes: [{ manifest: '.' }], + }) + ).toEqual(['remotes must be a name-keyed object, not an array']); + }); +}); + +describe('findConfigPath', () => { + it('finds the file in the given directory', () => { + expect(findConfigPath(VALID_DIR)).toBe( + path.join(VALID_DIR, FEDERATION_CONFIG_FILENAME) + ); + }); + + it('walks up from a nested directory', () => { + expect(findConfigPath(path.join(VALID_DIR, 'apps', 'store'))).toBe( + path.join(VALID_DIR, FEDERATION_CONFIG_FILENAME) + ); + }); + + it('takes the first hit walking up', () => { + expect(findConfigPath(path.join(WALKUP_DIR, 'nested'))).toBe( + path.join(WALKUP_DIR, 'nested', FEDERATION_CONFIG_FILENAME) + ); + }); + + it('returns null when nothing exists up the tree', () => { + const isolated = path.join(tmpDir, 'deep', 'nested'); + fs.mkdirSync(isolated, { recursive: true }); + expect(findConfigPath(isolated)).toBeNull(); + }); +}); + +describe('loadFederationConfig', () => { + it('loads a valid document preserving optional fields', () => { + const loaded = loadFederationConfig({ cwd: VALID_DIR }); + expect(loaded).not.toBeNull(); + expect(loaded!.filePath).toBe( + path.join(VALID_DIR, FEDERATION_CONFIG_FILENAME) + ); + expect(loaded!.config.remotes.store).toEqual({ + manifest: './manifests/store.json', + root: './apps/store', + standalone: true, + port: 8082, + }); + }); + + it('rejects malformed JSON naming the file and never a stack', () => { + let caught: unknown; + try { + loadFederationConfig({ cwd: malformedDir }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigFileInvalidError); + const error = caught as ConfigFileInvalidError; + expect(error.filePath).toBe( + path.join(malformedDir, FEDERATION_CONFIG_FILENAME) + ); + expect(error.reasons).toHaveLength(1); + expect(error.reasons[0]).toContain('is not valid JSON'); + // The reason the command layer prints is a single clean line — it never + // carries a stack trace (the command-level test pins the output). + expect(error.reasons[0]).not.toMatch(/\n\s+at\s/); + }); + + it('enriches parse failures with line/column when a position is available', () => { + const raw = `{\n "host": \n}\n`; + // V8-style position-bearing message (older V8): line/column derived — + // byte 13 is the `}`, first character of line 3. + expect( + describeJsonParseFailure( + raw, + new SyntaxError('Unexpected token } in JSON at position 13') + ) + ).toBe( + 'is not valid JSON: Unexpected token } in JSON at position 13 (line 3, column 1)' + ); + // Newer V8 exposes no position — the message rides verbatim. + expect( + describeJsonParseFailure( + raw, + new SyntaxError('Unexpected token } in some recent V8') + ) + ).toBe('is not valid JSON: Unexpected token } in some recent V8'); + }); + + it('rejects schema violations with the exact field-path reasons', () => { + expect(() => + loadFederationConfig({ cwd: path.join(FIXTURES, 'config-invalid') }) + ).toThrow(ConfigFileInvalidError); + try { + loadFederationConfig({ cwd: path.join(FIXTURES, 'config-invalid') }); + } catch (error) { + expect((error as ConfigFileInvalidError).reasons).toEqual([ + 'host.manifest is required (string)', + ]); + } + }); + + it('returns null with nothing to load (never throws)', () => { + const isolated = path.join(tmpDir, 'empty'); + fs.mkdirSync(isolated, { recursive: true }); + expect(loadFederationConfig({ cwd: isolated })).toBeNull(); + }); +}); + +describe('resolveFederationWorkspace', () => { + it('reports source none without flags or file', () => { + const isolated = path.join(tmpDir, 'no-ws'); + fs.mkdirSync(isolated, { recursive: true }); + const ws = resolveFederationWorkspace(isolated, {}); + expect(ws).toEqual({ remotes: [], source: 'none' }); + }); + + it('resolves file values against the config directory, not cwd', () => { + const ws = resolveFederationWorkspace(path.join(VALID_DIR, 'deep', 'dir'), { + /* cwd deep inside: values still anchor at the config dir */ + }); + expect(ws.source).toBe('file'); + expect(ws.configPath).toBe( + path.join(VALID_DIR, FEDERATION_CONFIG_FILENAME) + ); + expect(ws.host).toEqual({ + source: path.join(VALID_DIR, 'manifests', 'host.json'), + root: VALID_DIR, + }); + expect(ws.remotes).toEqual([ + { + name: 'store', + source: path.join(VALID_DIR, 'manifests', 'store.json'), + root: path.join(VALID_DIR, 'apps', 'store'), + standalone: true, + port: 8082, + }, + ]); + }); + + it('keeps benign path values under the config dir (path-traversal boundary)', () => { + const ws = resolveFederationWorkspace(VALID_DIR, {}); + for (const source of [ + ws.host!.source, + ...ws.remotes.map((remote) => remote.source), + ]) { + expect(source.startsWith(VALID_DIR + path.sep)).toBe(true); + } + }); + + it('keeps http(s) manifest sources verbatim instead of path-resolving them', () => { + const ws = resolveFederationWorkspace(URL_DIR, {}); + expect(ws.remotes[0]!.source).toBe('http://localhost:8082'); + }); + + it('applies per-value precedence: --host overrides only the host', () => { + const ws = resolveFederationWorkspace(VALID_DIR, { + host: '/tmp/other/build', + }); + expect(ws.source).toBe('mixed'); + expect(ws.host).toEqual({ source: '/tmp/other/build' }); + // The remaining values still come from the file. + expect(ws.remotes[0]!.name).toBe('store'); + expect(ws.remotes[0]!.source).toBe( + path.join(VALID_DIR, 'manifests', 'store.json') + ); + }); + + it('replaces the remote set wholesale when --remotes is passed', () => { + const ws = resolveFederationWorkspace(VALID_DIR, { + remotes: 'http://one,http://two', + }); + expect(ws.source).toBe('mixed'); + expect(ws.remotes).toEqual([ + { source: 'http://one' }, + { source: 'http://two' }, + ]); + // host still from the file + expect(ws.host!.source).toBe( + path.join(VALID_DIR, 'manifests', 'host.json') + ); + }); + + it('reports source flags when both values come from flags', () => { + const ws = resolveFederationWorkspace(VALID_DIR, { + host: './h', + remotes: './r1,./r2', + }); + expect(ws.source).toBe('flags'); + expect(ws.host).toEqual({ source: './h' }); + expect(ws.remotes).toHaveLength(2); + }); + + it('treats port presence as behavior-neutral data', () => { + const withPort = resolveFederationWorkspace(VALID_DIR, {}); + const withoutPort = resolveFederationWorkspace( + path.join(FIXTURES, 'config-noport'), + {} + ); + // Same workspace shape minus the declared-but-unconsumed port. + expect(withoutPort.remotes).toEqual([ + { + name: 'store', + source: path.join(FIXTURES, 'config-noport', 'manifests', 'store.json'), + }, + ]); + expect(withPort.host!.source.endsWith('host.json')).toBe( + withoutPort.host!.source.endsWith('host.json') + ); + }); +}); diff --git a/packages/repack/src/commands/federation/configFile.ts b/packages/repack/src/commands/federation/configFile.ts new file mode 100644 index 000000000..c1a11d06e --- /dev/null +++ b/packages/repack/src/commands/federation/configFile.ts @@ -0,0 +1,348 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Name of the federation workspace config file tools discover and load. */ +export const FEDERATION_CONFIG_FILENAME = 'repack-federation.json'; + +/** `host` entry of `repack-federation.json`. */ +export interface FederationHostConfig { + /** Manifest source: .json path, directory, or http(s) URL. */ + manifest: string; + /** App root, for consumers that need it (init, dry-run). */ + root?: string; +} + +/** One named entry of the `remotes` map. */ +export interface FederationRemoteConfig { + manifest: string; + root?: string; + /** Whether this remote supports `--standalone` mode. */ + standalone?: boolean; + /** Dev-server port. Declared for the later runner/wizard PR; unused today. */ + port?: number; +} + +export interface FederationConfig { + host: FederationHostConfig; + remotes: Record; +} + +/** + * A discovered `repack-federation.json` that is not valid JSON or does not + * conform to the schema. Tools must print `reasons` with the file path and + * exit 2 — never fall back to defaults, never print a stack. + */ +export class ConfigFileInvalidError extends Error { + constructor( + public filePath: string, + public reasons: string[] + ) { + super(`${filePath}: ${reasons.join('; ')}`); + this.name = 'ConfigFileInvalidError'; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Field checker: returns a reason string when the value is invalid. */ +type FieldCheck = (value: unknown, where: string) => string | undefined; + +const requireString: FieldCheck = (value, where) => + typeof value === 'string' ? undefined : `${where} is required (string)`; + +const optionalString: FieldCheck = (value, where) => + value === undefined || typeof value === 'string' + ? undefined + : `${where} must be a string`; + +const optionalBoolean: FieldCheck = (value, where) => + value === undefined || typeof value === 'boolean' + ? undefined + : `${where} must be a boolean`; + +const optionalNumber: FieldCheck = (value, where) => + value === undefined || typeof value === 'number' + ? undefined + : `${where} must be a number`; + +/** + * Run per-field checks over an object, collecting one reason per violation. + * Unknown keys are reported by path; missing required keys are appended by + * the caller after the pass (a missing key never reaches a checker). + */ +function checkFields( + target: Record, + known: Record, + prefix: string, + reasons: string[] +): void { + for (const [key, value] of Object.entries(target)) { + const where = prefix ? `${prefix}.${key}` : key; + const check = known[key]; + if (!check) { + reasons.push(`${where} is not a known field`); + continue; + } + const problem = check(value, where); + if (problem) reasons.push(problem); + } +} + +/** + * Validate an unknown JSON document against the `repack-federation.json` + * schema: `{ host: { manifest, root? }, remotes: { name: { manifest, root?, + * standalone?, port? } } }`, strictly — unknown keys anywhere are invalid. + * Returns the reasons the document is invalid (empty when valid); every + * reason names the offending field path. + */ +export function validateFederationConfig(document: unknown): string[] { + if (!isObject(document)) return ['config must be a JSON object']; + + const reasons: string[] = []; + checkFields( + document, + { + host: () => undefined, + remotes: () => undefined, + }, + '', + reasons + ); + const { host, remotes } = document; + + if (host === undefined) { + reasons.push('host is required (object)'); + } else if (!isObject(host)) { + reasons.push('host must be an object'); + } else { + checkFields( + host, + { manifest: requireString, root: optionalString }, + 'host', + reasons + ); + // A missing key never reaches a checker; a wrong-typed one already + // produced the reason above — report each violation exactly once. + if (!('manifest' in host)) { + reasons.push('host.manifest is required (string)'); + } + } + + if (remotes === undefined) { + reasons.push('remotes is required (object)'); + } else if (Array.isArray(remotes)) { + reasons.push('remotes must be a name-keyed object, not an array'); + } else if (!isObject(remotes)) { + reasons.push('remotes must be an object'); + } else { + for (const [name, entry] of Object.entries(remotes)) { + const where = `remotes.${name}`; + if (!isObject(entry)) { + reasons.push(`${where} must be an object`); + continue; + } + checkFields( + entry, + { + manifest: requireString, + root: optionalString, + standalone: optionalBoolean, + port: optionalNumber, + }, + where, + reasons + ); + if (!('manifest' in entry)) { + reasons.push(`${where}.manifest is required (string)`); + } + } + } + + return reasons; +} + +/** + * Walk up from `cwd` looking for `repack-federation.json`; the first hit + * wins. Returns null (never throws) when no file exists up the tree. + */ +export function findConfigPath(cwd: string): string | null { + let currentDir = path.resolve(cwd); + for (;;) { + const candidate = path.join(currentDir, FEDERATION_CONFIG_FILENAME); + if (fs.existsSync(candidate)) return candidate; + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +/** + * Build the `reasons[0]` for a failed `JSON.parse`: the SyntaxError message, + * enriched with `(line X, column Y)` when the runtime exposes a position + * (older V8 embeds `at position N` in the message; newer V8 exposes neither, + * in which case the message is reported verbatim — "position if available"). + * Exported so the line/column math is testable independent of the runtime's + * JSON error style. + */ +export function describeJsonParseFailure( + rawText: string, + error: unknown +): string { + const syntaxError = error as SyntaxError & { position?: number }; + const message = + syntaxError instanceof Error ? syntaxError.message : String(error); + const fromMessage = /at position (\d+)/.exec(message)?.[1]; + const position = + fromMessage !== undefined ? Number(fromMessage) : syntaxError.position; + if ( + typeof position !== 'number' || + !Number.isFinite(position) || + position < 0 || + position > rawText.length + ) { + return `is not valid JSON: ${message}`; + } + const before = rawText.slice(0, position); + const line = before.split('\n').length; + const column = position - (before.lastIndexOf('\n') + 1) + 1; + return `is not valid JSON: ${message} (line ${line}, column ${column})`; +} + +/** + * Discover and load the federation workspace config. Returns `null` when no + * file exists; throws `ConfigFileInvalidError` for malformed JSON or schema + * violations — the calling tool maps that to exit code 2. + */ +export function loadFederationConfig(options: { cwd?: string } = {}): { + filePath: string; + config: FederationConfig; +} | null { + const filePath = findConfigPath(options.cwd ?? process.cwd()); + if (!filePath) return null; + + const rawText = fs.readFileSync(filePath, 'utf-8'); + let parsed: unknown; + try { + parsed = JSON.parse(rawText) as unknown; + } catch (error) { + throw new ConfigFileInvalidError(filePath, [ + describeJsonParseFailure(rawText, error), + ]); + } + + const reasons = validateFederationConfig(parsed); + if (reasons.length > 0) { + throw new ConfigFileInvalidError(filePath, reasons); + } + return { filePath, config: parsed as FederationConfig }; +} + +/** A manifest source resolved for use by a tool. */ +export interface ResolvedEntry { + source: string; + root?: string; +} + +export interface ResolvedRemote { + /** Declared name from the config file; flag-sourced remotes have none. */ + name?: string; + source: string; + root?: string; + standalone?: boolean; + port?: number; +} + +export interface ResolvedWorkspace { + configPath?: string; + host?: ResolvedEntry; + remotes: ResolvedRemote[]; + /** Where the effective values came from. */ + source: 'flags' | 'file' | 'mixed' | 'none'; +} + +/** Split a raw `--remotes` flag value, tolerating a merged CLI array. */ +export function parseRemoteSources( + 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) + ); +} + +function isUrlSource(source: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(source); +} + +/** Path values resolve against the config dir; URLs stay verbatim. */ +function resolveSource(source: string, configDir: string): string { + return isUrlSource(source) ? source : path.resolve(configDir, source); +} + +/** + * Combine CLI flags, the discovered `repack-federation.json`, and defaults + * into the workspace a command operates on. Precedence is per-value: + * a flag overrides only its own value; `--remotes` replaces the remote set + * wholesale (per-value merging of lists is undefined). + */ +export function resolveFederationWorkspace( + cwd: string, + flags: { host?: string; remotes?: string | string[] } +): ResolvedWorkspace { + const loaded = loadFederationConfig({ cwd }); + const configDir = loaded ? path.dirname(loaded.filePath) : cwd; + const flagRemotes = parseRemoteSources(flags.remotes); + const usesFlags = flags.host !== undefined || flagRemotes.length > 0; + // The file "supplies" values only where a flag did not already provide + // one: all flags + a present-but-unused file is source `flags`, not mixed. + const usesFile = + loaded !== null && (flags.host === undefined || flagRemotes.length === 0); + + const workspace: ResolvedWorkspace = { + remotes: [], + source: usesFlags + ? usesFile + ? 'mixed' + : 'flags' + : loaded + ? 'file' + : 'none', + }; + if (loaded) workspace.configPath = loaded.filePath; + + if (flags.host !== undefined) { + workspace.host = { source: flags.host }; + } else if (loaded) { + const { manifest, root } = loaded.config.host; + workspace.host = { + source: resolveSource(manifest, configDir), + ...(root === undefined ? {} : { root: path.resolve(configDir, root) }), + }; + } + + if (flagRemotes.length > 0) { + workspace.remotes = flagRemotes.map((source) => ({ source })); + } else if (loaded) { + workspace.remotes = Object.entries(loaded.config.remotes).map( + ([name, entry]) => ({ + name, + source: resolveSource(entry.manifest, configDir), + ...(entry.root === undefined + ? {} + : { root: path.resolve(configDir, entry.root) }), + ...(entry.standalone === undefined + ? {} + : { standalone: entry.standalone }), + ...(entry.port === undefined ? {} : { port: entry.port }), + }) + ); + } + + return workspace; +} diff --git a/packages/repack/src/commands/federationDoctor.ts b/packages/repack/src/commands/federationDoctor.ts index 7bc3166c3..358a7f26c 100644 --- a/packages/repack/src/commands/federationDoctor.ts +++ b/packages/repack/src/commands/federationDoctor.ts @@ -1,4 +1,8 @@ import path from 'node:path'; +import { + ConfigFileInvalidError, + resolveFederationWorkspace, +} from './federation/configFile.js'; import { type DoctorRemoteInput, doctorExitCode, @@ -14,18 +18,6 @@ import { } 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 { @@ -50,7 +42,26 @@ export async function federationDoctor( _cliConfig: CliConfig, args: FederationDoctorArguments ) { - if (!args.host) { + // Values come from flags first, then a discovered repack-federation.json, + // then defaults; a malformed config file is exit 2 with a plain message. + let workspace: ReturnType; + try { + workspace = resolveFederationWorkspace(process.cwd(), { + host: args.host, + remotes: args.remotes, + }); + } catch (error) { + if (error instanceof ConfigFileInvalidError) { + console.error( + `Federation config — ${error.filePath}: ${error.reasons.join('; ')}` + ); + process.exit(2); + return; + } + throw error; + } + + if (!workspace.host) { console.error( "Option '--host ' is required: pass the host manifest as a " + '.json file, a build output directory, or an http(s) URL.' @@ -59,8 +70,7 @@ export async function federationDoctor( return; } - const remoteSources = parseRemoteList(args.remotes); - if (remoteSources.length === 0) { + if (workspace.remotes.length === 0) { console.error( "Option '--remotes ' is required: pass a comma-separated list " + 'of remote manifest sources.' @@ -71,7 +81,7 @@ export async function federationDoctor( let host: LoadedManifest; try { - host = await loadManifest(args.host); + host = await loadManifest(workspace.host.source); } catch (error) { if ( error instanceof ManifestNotFoundError || @@ -85,17 +95,26 @@ export async function federationDoctor( } const remotes: DoctorRemoteInput[] = []; - for (const source of remoteSources) { + for (const remoteEntry of workspace.remotes) { + const { source } = remoteEntry; try { const remote = await loadManifest(source); remotes.push({ + // Config-file remotes carry their declared name, which labels + // findings better than any source-derived guess. name: - remote.manifest.name || remote.manifest.id || labelFromSource(source), + remoteEntry.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 }); + remotes.push({ + name: remoteEntry.name ?? labelFromSource(source), + missing: true, + }); continue; } if (error instanceof ManifestInvalidError) { diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index 669887ccc..8665a8498 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -112,12 +112,12 @@ 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', + 'Host manifest source: a .json file, a build output directory containing repack-federation-manifest.json, or an http(s) URL. Optional when repack-federation.json provides it', }, { name: '--remotes ', description: - 'Comma-separated list of remote manifest sources (same shapes as --host)', + 'Comma-separated list of remote manifest sources (same shapes as --host). Optional when repack-federation.json provides them', }, { name: '--format ', diff --git a/website/src/latest/api/cli/_meta.json b/website/src/latest/api/cli/_meta.json index acfbe413a..064e11206 100644 --- a/website/src/latest/api/cli/_meta.json +++ b/website/src/latest/api/cli/_meta.json @@ -19,6 +19,11 @@ "name": "federation-doctor", "label": "Federation doctor" }, + { + "type": "file", + "name": "repack-federation-json", + "label": "repack-federation.json" + }, { "type": "file", "name": "init", diff --git a/website/src/latest/api/cli/repack-federation-json.mdx b/website/src/latest/api/cli/repack-federation-json.mdx new file mode 100644 index 000000000..197539a55 --- /dev/null +++ b/website/src/latest/api/cli/repack-federation-json.mdx @@ -0,0 +1,57 @@ +# repack-federation.json + +`repack-federation.json` is the per-repo federation workspace map. It tells Re.Pack tooling where the host and each remote live, which remotes support standalone mode, and (reserved for a later release) which dev-server port each remote uses. Its presence is what unlocks **zero-flag operation** of `federation-doctor` (and, once shipped, `federation-init`). + +## Schema + +```json +{ + "host": { + "manifest": "./shell/build", + "root": "." + }, + "remotes": { + "store": { + "manifest": "http://localhost:8082", + "root": "./apps/store", + "standalone": true, + "port": 8082 + } + } +} +``` + +| Field | Type | Required | Meaning | +| --- | --- | --- | --- | +| `host.manifest` | string | yes | Host manifest source: a `.json` file, a build output directory containing `repack-federation-manifest.json`, or an `http(s)` URL | +| `host.root` | string | no | Host app root | +| `remotes.` | object | — | One entry per remote, keyed by its declared name (used to label findings) | +| `remotes..manifest` | string | yes | Remote manifest source (same shapes as `host.manifest`) | +| `remotes..root` | string | no | Remote app root | +| `remotes..standalone` | boolean | no | Whether the remote supports `--standalone`; absence means unsupported and commands refuse `--standalone` for it | +| `remotes..port` | number | no | Dev-server port; validated and preserved for the future dev-runner, no shipped tool acts on it today | + +Relative `manifest` and `root` values resolve against the directory containing `repack-federation.json`, not the caller's working directory. The schema is strict: any field outside this shape is a validation error naming the offending field path. + +## Discovery + +Tools walk up from the current working directory — the file's own directory first, then each ancestor — and use the **first** `repack-federation.json` found. One invocation operates on exactly one config file, so in split checkouts (host and remotes in different trees) run tools from the directory whose walk-up hits the file you mean. + +## Precedence: flags over file over defaults + +Precedence applies per value: + +- `--host ` overrides only `host.manifest`; remaining values still come from the file. +- `--remotes ` replaces the file's remote set wholesale (a list cannot be merged per-value). +- A "required option missing" failure happens only when neither a flag nor the file supplies the value. +- An invalid file is never silently ignored: the tool exits with code 2 and prints the file path and the exact validation failure (no stack trace, no fallback to defaults). + +## Example: zero-flag CI + +With the file above committed at the repository root, CI gates drift with a bare command: + +```bash +react-native federation-doctor +``` + +Both `--host` and `--remotes` come from the file; explicit flags still win when passed. From caefb4f8003b005bf4f1b7dc715272bdcb9fd514 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 19:19:14 +0200 Subject: [PATCH 11/19] feat(repack): doctor pairwise opt-in, role-aware eager advisory, host-native-first report --- .../__tests__/federationDoctor.test.ts | 23 ++ .../federation/__tests__/doctor.test.ts | 239 ++++++++++++++++++ .../repack/src/commands/federation/doctor.ts | 127 +++++++--- .../repack/src/commands/federationDoctor.ts | 1 + packages/repack/src/commands/options.ts | 5 + packages/repack/src/commands/types.ts | 2 + .../src/latest/api/cli/federation-doctor.mdx | 28 +- 7 files changed, 384 insertions(+), 41 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDoctor.test.ts b/packages/repack/src/commands/__tests__/federationDoctor.test.ts index bf4e91ae7..ddc1415ab 100644 --- a/packages/repack/src/commands/__tests__/federationDoctor.test.ts +++ b/packages/repack/src/commands/__tests__/federationDoctor.test.ts @@ -246,6 +246,29 @@ describe('federation-doctor with repack-federation.json', () => { fs.rmSync(isolated, { recursive: true, force: true }); }); + it('keeps the 0/1/2 exit-code contract with --pairwise', async () => { + fromDir(configFixture('config-valid')); + await federationDoctor([], cliConfig, { pairwise: true }); + expect(exit).toHaveBeenLastCalledWith(0); + exit.mockClear(); + + fromDir(configFixture('config-drift')); + await federationDoctor([], cliConfig, { pairwise: true }); + expect(exit).toHaveBeenLastCalledWith(1); + exit.mockClear(); + cwdSpy.mockRestore(); + + const badDir = path.join(tmpDir, 'pairwise-malformed'); + fs.mkdirSync(badDir, { recursive: true }); + fs.writeFileSync( + path.join(badDir, 'repack-federation.json'), + '{ "host": { "manifest":\n' + ); + fromDir(badDir); + await federationDoctor([], cliConfig, { pairwise: true }); + expect(exit).toHaveBeenLastCalledWith(2); + }); + it('behaves identically with and without declared ports', async () => { fromDir(configFixture('config-valid')); await federationDoctor([], cliConfig, {}); diff --git a/packages/repack/src/commands/federation/__tests__/doctor.test.ts b/packages/repack/src/commands/federation/__tests__/doctor.test.ts index f22896d4e..46ee285de 100644 --- a/packages/repack/src/commands/federation/__tests__/doctor.test.ts +++ b/packages/repack/src/commands/federation/__tests__/doctor.test.ts @@ -263,6 +263,245 @@ describe('doctor report rendering', () => { }); }); +describe('doctor extensions', () => { + describe('frozen host↔remote message text', () => { + it('keeps host↔remote shared-dep messages byte-identical', () => { + const report = runDoctor({ + host, + remotes: [{ name: 'store', manifest: remoteConflicting }], + }); + const messageFor = (code: string) => + report.findings.find((finding) => finding.code === code)!.message; + + expect(messageFor('SINGLETON_MISMATCH')).toBe( + 'Shared dependency "zustand" is singleton: true on host "shell" but false on remote "store".' + ); + expect(messageFor('SHARED_VERSION_DRIFT')).toBe( + 'Singleton shared dependency "react" resolves to different versions: host "shell" has 19.0.0, remote "store" has 19.1.0. Align the versions (or remove singleton).' + ); + expect(messageFor('SHARED_RANGE_UNRESOLVABLE')).toBe( + 'Shared dependency "react-native" declares ranges that cannot intersect: host "shell" requires ~0.79.2, remote "store" requires ~0.74.5.' + ); + expect(messageFor('EAGER_ADVISORY')).toBe( + 'Shared dependency "react-native" is eager: true on host "shell" but eager: false on remote "store" — expected host-eager/remote-lazy convention; reported as advisory.' + ); + }); + }); + + describe('--pairwise remote↔remote comparisons', () => { + // Both remotes match the host everywhere; they only drift from EACH + // OTHER on `extra-lib`, which the host does not share at all. + function driftingPair(): [FederationManifest, FederationManifest] { + const one = clone(remoteClean); + const two = clone(remoteClean); + one.shared.push({ + name: 'extra-lib', + version: '1.0.0', + singleton: true, + eager: true, + requiredVersion: '^1.0.0', + }); + two.shared.push({ + name: 'extra-lib', + version: '2.0.0', + singleton: true, + eager: true, + requiredVersion: '^2.0.0', + }); + return [one, two]; + } + + it('does not compare remotes with each other by default', () => { + const [one, two] = driftingPair(); + const report = runDoctor({ + host, + remotes: [ + { name: 'one', manifest: one }, + { name: 'two', manifest: two }, + ], + }); + + expect(report.findings).toEqual([]); + }); + + it('finds remote↔remote shared drift, naming both remotes, when enabled', () => { + const [one, two] = driftingPair(); + const report = runDoctor({ + host, + remotes: [ + { name: 'one', manifest: one }, + { name: 'two', manifest: two }, + ], + pairwise: true, + }); + + const drift = report.findings.find( + (finding) => finding.code === 'SHARED_VERSION_DRIFT' + ); + expect(drift?.severity).toBe('error'); + expect(drift?.message).toContain('remote "one" has 1.0.0'); + expect(drift?.message).toContain('remote "two" has 2.0.0'); + expect(doctorExitCode(report)).toBe(1); + }); + + it('treats any remote↔remote eager mismatch as an advisory in both directions', () => { + const build = () => { + const one = clone(remoteClean); + const two = clone(remoteClean); + one.shared[0]!.eager = true; + two.shared[0]!.eager = false; + return [one, two] as [FederationManifest, FederationManifest]; + }; + + for (const swap of [false, true]) { + const [a, b] = build(); + const report = runDoctor({ + host, + remotes: swap + ? [ + { name: 'two', manifest: b }, + { name: 'one', manifest: a }, + ] + : [ + { name: 'one', manifest: a }, + { name: 'two', manifest: b }, + ], + pairwise: true, + }); + + expect(codes(report)).not.toContain('EAGER_MISMATCH'); + const pairAdvisories = report.findings.filter((finding) => + finding.message.includes('no convention orders two remotes') + ); + expect(pairAdvisories).toHaveLength(1); + expect(pairAdvisories[0]!.severity).toBe('warning'); + expect(pairAdvisories[0]!.message).toContain('remote "one"'); + expect(pairAdvisories[0]!.message).toContain('remote "two"'); + // The pair advisory alone never fails the run. + expect(doctorExitCode(report)).toBe(0); + } + }); + + it('emits no native findings for remote↔remote pairs', () => { + const conflicting = clone(remoteConflicting); + const clean = clone(remoteClean); + const report = runDoctor({ + host, + remotes: [ + { name: 'one', manifest: conflicting }, + { name: 'two', manifest: clean }, + ], + pairwise: true, + }); + + const native = report.findings.filter( + (finding) => + finding.code === 'MISSING_NATIVE_MODULE' || + finding.code === 'HEURISTIC_ADVISORY' + ); + // Exactly the host↔remote finding for "one"; no remote↔remote native + // findings can name "two" without the host (the host is the provider). + expect(native).toHaveLength(1); + expect(native[0]!.message).toContain('remote "one"'); + expect(native[0]!.message).toContain('host "shell"'); + expect(native[0]!.message).not.toContain('remote "two"'); + }); + }); + + describe('host-native-first ordering without fail-fast', () => { + function orderingRemotes(): [ + { name: string; manifest: FederationManifest }, + { name: string; manifest: FederationManifest }, + ] { + // Drift-heavy but native-clean remote, listed FIRST: before bucketing + // its shared findings led the report. + const drifty = clone(remoteConflicting); + drifty.reactNative.nativeModules = + drifty.reactNative.nativeModules.filter( + (entry) => entry.package === 'react-native-reanimated' + ); + // Native-offending but shared-clean remote, listed SECOND. + const nativy = clone(remoteClean); + nativy.reactNative.nativeModules.push({ + package: 'react-native-maps', + version: '1.20.1', + modules: undefined, + turboModule: false, + confidence: 'static', + } as (typeof nativy.reactNative.nativeModules)[number]); + return [ + { name: 'drifty', manifest: drifty }, + { name: 'nativy', manifest: nativy }, + ]; + } + + it('lists native findings before shared findings and covers every remote', () => { + const report = runDoctor({ host, remotes: orderingRemotes() }); + const codes = report.findings.map((finding) => finding.code); + + expect(codes).toContain('MISSING_NATIVE_MODULE'); + expect(codes).toContain('SHARED_VERSION_DRIFT'); + expect(codes.indexOf('MISSING_NATIVE_MODULE')).toBeLessThan( + codes.indexOf('SHARED_VERSION_DRIFT') + ); + // No fail-fast: the second remote's finding is present even though the + // first remote already produced errors. + expect( + report.findings + .filter((finding) => finding.code === 'SHARED_VERSION_DRIFT') + .map((finding) => finding.message) + .join() + ).toContain('drifty'); + expect( + report.findings + .filter((finding) => finding.code === 'MISSING_NATIVE_MODULE') + .map((finding) => finding.message) + .join() + ).toContain('nativy'); + expect(doctorExitCode(report)).toBe(1); + }); + + it('emits manifest-meta findings last, after every comparison ran', () => { + const newerHost = clone(host); + newerHost.manifestVersion = 2 as 1; + const report = runDoctor({ + host: newerHost, + remotes: [ + { name: 'store', manifest: remoteConflicting }, + { name: 'payments', missing: true }, + ], + }); + const codes = report.findings.map((finding) => finding.code); + + const metaIndexes = [ + codes.indexOf('MANIFEST_VERSION_AHEAD'), + codes.indexOf('MISSING_REMOTE_MANIFEST'), + ]; + const realIndexes = [ + codes.indexOf('SHARED_VERSION_DRIFT'), + codes.indexOf('MISSING_NATIVE_MODULE'), + ]; + expect(codes).toContain('MANIFEST_VERSION_AHEAD'); + expect(codes).toContain('MISSING_REMOTE_MANIFEST'); + // Meta findings are emitted as a bucket after native and shared ones. + expect(Math.min(...metaIndexes)).toBeGreaterThan( + Math.max(...realIndexes) + ); + }); + + it('keeps the JSON array in report order', () => { + const report = runDoctor({ host, remotes: orderingRemotes() }); + const parsed = JSON.parse(doctorReportToJson(report)) as { + findings: Array<{ code: string }>; + }; + + expect(parsed.findings.map((finding) => finding.code)).toEqual( + report.findings.map((finding) => finding.code) + ); + }); + }); +}); + describe('rangesIntersect', () => { it.each([ // caret diff --git a/packages/repack/src/commands/federation/doctor.ts b/packages/repack/src/commands/federation/doctor.ts index 0d080f061..cb990c58e 100644 --- a/packages/repack/src/commands/federation/doctor.ts +++ b/packages/repack/src/commands/federation/doctor.ts @@ -35,8 +35,13 @@ export interface DoctorInput { remotes: DoctorRemoteInput[]; /** Downgrade missing-remote findings from error to warning. */ allowMissingManifests?: boolean; + /** Also compare every remote pair, shared-dependency checks only. */ + pairwise?: boolean; } +/** Which sides a shared-dependency comparison runs between. */ +export type DoctorPairKind = 'host-remote' | 'remote-remote'; + function sharedOf( manifest: FederationManifest ): FederationManifestSharedEntry[] { @@ -77,74 +82,85 @@ function checkManifestVersion( } } +/** + * Compare the shared-dependency blocks of two apps. Labels are preformatted + * (`host "shell"`, `remote "store"`) so host↔remote messages keep byte-stable + * text; `pairKind` selects the eager policy: host↔remote applies the + * host-eager/remote-lazy convention, remote↔remote reports any mismatch as an + * advisory — no convention orders two remotes (the host arbitrates their + * shares). + */ function checkSharedDeps( - host: FederationManifest, - remoteName: string, - remote: FederationManifest, - findings: DoctorFinding[] + left: FederationManifest, + leftLabel: string, + right: FederationManifest, + rightLabel: string, + findings: DoctorFinding[], + pairKind: DoctorPairKind ): void { - const remoteShared = new Map( - sharedOf(remote).map((entry) => [entry.name, entry]) + const rightShared = new Map( + sharedOf(right).map((entry) => [entry.name, entry]) ); - for (const hostEntry of sharedOf(host)) { - const remoteEntry = remoteShared.get(hostEntry.name); - if (!remoteEntry) continue; - const name = hostEntry.name; + for (const leftEntry of sharedOf(left)) { + const rightEntry = rightShared.get(leftEntry.name); + if (!rightEntry) continue; + const name = leftEntry.name; - if (hostEntry.singleton !== remoteEntry.singleton) { + if (leftEntry.singleton !== rightEntry.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}".`, + message: `Shared dependency "${name}" is singleton: ${leftEntry.singleton} on ${leftLabel} but ${rightEntry.singleton} on ${rightLabel}.`, }); } - if (hostEntry.eager !== remoteEntry.eager) { - const conventional = hostEntry.eager && !remoteEntry.eager; // host-eager / remote-lazy = MF convention + if (leftEntry.eager !== rightEntry.eager) { + const conventional = + pairKind === 'host-remote' && leftEntry.eager && !rightEntry.eager; // host-eager / remote-lazy = MF convention + const crossRemote = pairKind === 'remote-remote'; findings.push({ - severity: conventional ? 'warning' : 'error', - code: conventional ? 'EAGER_ADVISORY' : 'EAGER_MISMATCH', + severity: conventional || crossRemote ? 'warning' : 'error', + code: conventional || crossRemote ? '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}".`, + ? `Shared dependency "${name}" is eager: true on ${leftLabel} but eager: false on ${rightLabel} — expected host-eager/remote-lazy convention; reported as advisory.` + : crossRemote + ? `Shared dependency "${name}" is eager: ${leftEntry.eager} on ${leftLabel} but ${rightEntry.eager} on ${rightLabel} — no convention orders two remotes; reported as advisory.` + : `Shared dependency "${name}" is eager: ${leftEntry.eager} on ${leftLabel} but ${rightEntry.eager} on ${rightLabel}.`, }); } - const bothSingleton = hostEntry.singleton && remoteEntry.singleton; - if (bothSingleton && hostEntry.version !== remoteEntry.version) { - if ( - hostEntry.version === 'unknown' || - remoteEntry.version === 'unknown' - ) { + const bothSingleton = leftEntry.singleton && rightEntry.singleton; + if (bothSingleton && leftEntry.version !== rightEntry.version) { + if (leftEntry.version === 'unknown' || rightEntry.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.`, + message: `Shared dependency "${name}" is a singleton but its resolved version could not be determined on at least one side (${leftLabel}: ${leftEntry.version}, ${rightLabel}: ${rightEntry.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).`, + message: `Singleton shared dependency "${name}" resolves to different versions: ${leftLabel} has ${leftEntry.version}, ${rightLabel} has ${rightEntry.version}. Align the versions (or remove singleton).`, }); } } const verdict = rangesIntersect( - hostEntry.requiredVersion, - remoteEntry.requiredVersion + leftEntry.requiredVersion, + rightEntry.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}.`, + message: `Shared dependency "${name}" declares ranges that cannot intersect: ${leftLabel} requires ${leftEntry.requiredVersion}, ${rightLabel} requires ${rightEntry.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.`, + message: `Shared dependency "${name}" uses a requiredVersion this doctor cannot evaluate (${leftLabel}: ${leftEntry.requiredVersion}, ${rightLabel}: ${rightEntry.requiredVersion}); check compatibility manually.`, }); } } @@ -204,20 +220,57 @@ function checkRemoteManifests( * shared-dependency and native-module inconsistency found. */ export function runDoctor(input: DoctorInput): DoctorReport { - const findings: DoctorFinding[] = []; + // Three buckets, emitted native → shared → meta once EVERY comparison has + // run: the report leads with the most fatal crash class, and one remote's + // errors never suppress another remote's findings (no fail-fast). The + // ordering is report-only; the severity-to-exit-code mapping is unchanged. + const native: DoctorFinding[] = []; + const shared: DoctorFinding[] = []; + const meta: DoctorFinding[] = []; - checkManifestVersion(input.host, `Host "${input.host.name}"`, findings); + checkManifestVersion(input.host, `Host "${input.host.name}"`, meta); + const compared: Array<{ name: string; manifest: FederationManifest }> = []; 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); + compared.push({ name: remote.name, manifest: remote.manifest }); + checkManifestVersion(remote.manifest, `Remote "${remote.name}"`, meta); + checkSharedDeps( + input.host, + `host "${input.host.name}"`, + remote.manifest, + `remote "${remote.name}"`, + shared, + 'host-remote' + ); + // Native checks stay host↔remote only: native is directional and the + // host is the provider, so remote↔remote pairs have nothing to compare. + checkNativeModules(input.host, remote.name, remote.manifest, native); + } + + if (input.pairwise) { + // Opt-in, shared-only, every (remote_i, remote_j) with i < j — opt-in + // keeps the O(n²) noise off anyone's default report. + for (let i = 0; i < compared.length; i++) { + for (let j = i + 1; j < compared.length; j++) { + const left = compared[i]!; + const right = compared[j]!; + checkSharedDeps( + left.manifest, + `remote "${left.name}"`, + right.manifest, + `remote "${right.name}"`, + shared, + 'remote-remote' + ); + } + } } - checkRemoteManifests(input, findings); - return { findings }; + checkRemoteManifests(input, meta); + + return { findings: [...native, ...shared, ...meta] }; } /** diff --git a/packages/repack/src/commands/federationDoctor.ts b/packages/repack/src/commands/federationDoctor.ts index 358a7f26c..bfa9f1d9a 100644 --- a/packages/repack/src/commands/federationDoctor.ts +++ b/packages/repack/src/commands/federationDoctor.ts @@ -133,6 +133,7 @@ export async function federationDoctor( host: host.manifest, remotes, allowMissingManifests: args.allowMissingManifests, + pairwise: args.pairwise, }); if (args.format === 'json') { diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index 8665a8498..94ac1dc7a 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -128,6 +128,11 @@ export const federationDoctorCommandOptions = [ description: 'Report remotes without a manifest as warnings instead of errors', }, + { + name: '--pairwise', + description: + 'Additionally compare every remote pair for shared-dependency drift (shared checks only; native checks stay host-to-remote)', + }, ]; export const bundleCommandOptions = [ diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index e4bcf87b1..29ed0f4e5 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -53,6 +53,8 @@ export interface FederationDoctorArguments { remotes?: string | string[]; format?: string; allowMissingManifests?: boolean; + /** Also compare every remote pair, shared-dependency checks only. */ + pairwise?: boolean; } export interface CliConfig { diff --git a/website/src/latest/api/cli/federation-doctor.mdx b/website/src/latest/api/cli/federation-doctor.mdx index b365835eb..de974faae 100644 --- a/website/src/latest/api/cli/federation-doctor.mdx +++ b/website/src/latest/api/cli/federation-doctor.mdx @@ -4,6 +4,8 @@ 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. +When a [`repack-federation.json`](/api/cli/repack-federation-json) is discoverable from the working directory, the doctor runs **zero-flag** — no `--host`/`--remotes` needed — checking exactly the manifest sources declared there. Explicit flags still win, per value. + ## 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. @@ -35,6 +37,12 @@ npx react-native federation-doctor --host ./build --remotes http://localhost:808 # do not fail on remotes that ship no manifest yet npx react-native federation-doctor --host ./build --remotes http://localhost:8082 --allow-missing-manifests + +# zero-flag, driven by repack-federation.json +npx react-native federation-doctor + +# also compare remotes against each other (shared checks only) +npx react-native federation-doctor --pairwise ``` 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. @@ -44,16 +52,16 @@ Each finding carries a severity (`error`, `warning`, `info`), a stable code (for ### `--host` - Type: `string` -- Required +- Required unless `repack-federation.json` supplies `host.manifest` The host manifest: a `.json` file, a build output directory, or an `http(s)` URL. ### `--remotes` - Type: `string` -- Required +- Required unless `repack-federation.json` supplies the remotes -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. +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. When both flags and the config file are absent, the command exits 2 with the required-option message. ### `--format` @@ -67,13 +75,25 @@ Print findings as JSON (`{ "findings": [{ "severity", "code", "message" }] }`) i 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. +### `--pairwise` + +- Type: `boolean` + +Additionally compare every remote pair for shared-dependency drift (every `remote_i`/`remote_j` combination). Off by default — the host↔remote checks already cover every remote, and pairwise adds `O(n²)` findings. Only the shared checks run between remotes; native-module checks stay host↔remote because the host is the native provider. Enabling it never changes the host↔remote checks or their severities. + +## Report ordering + +The report lists host↔remote **native-module findings first**, then all shared-dependency findings (host↔remote, then pairwise), then manifest metadata (`MANIFEST_VERSION_AHEAD`, `MISSING_REMOTE_MANIFEST`) — the crash class most fatal is read first. The doctor never fails fast: every configured comparison runs before anything is printed, and one remote's errors never suppress another remote's findings. This is report ordering only; the exit code depends solely on the presence of `error`-severity findings. + +An `eager` mismatch that matches the Module Federation convention — host `eager: true`, remote `eager: false` — is reported as the advisory `EAGER_ADVISORY` (warning), since that split is the expected configuration; any other host↔remote eager mismatch is `EAGER_MISMATCH` (error). Between two remotes, any eager mismatch is an advisory: no convention orders remotes. + ## 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` | The check could not run: a required option is missing (and no config file supplies it), `repack-federation.json` is malformed or invalid, 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. From bf78deeb364b5fa7c06c604b99dbd246eec34b32 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 19:26:24 +0200 Subject: [PATCH 12/19] feat(repack): add plugin shared extraction and doctor --dry-run pre-build checks - additive getSharedConfiguration() on ModuleFederationPluginV1/V2: returns the constructor shared option verbatim (pre deep-import injection) - extractShared.ts: evaluate an app's bundler config in-process (loadProjectConfig + synthetic env), duck-type the plugin instance, and resolve its shared option into virtual manifest entries per app root - dryRun.ts: runDryRun reuses the generalized checkSharedDeps codes over virtual entries; MISSING_SHARED_PROVIDER (warning) from the both-declared package.json heuristic; UNBUILT_CAVEAT suffix on every finding message so the locked --format json shape survives - federation-doctor --dry-run wiring: no builds, no manifest fetches, no MISSING_REMOTE_MANIFEST; ConfigEvalError -> message + exit 2, never a stack; exit codes 0/1/2 identical across modes - dry-run-workspace fixtures with per-app mini node_modules pins + CJS stub configs; .gitignore negations so fixture node_modules get committed - federation-doctor.mdx: --dry-run option docs Focused: jest -- extractShared dryRun federationDoctor doctor => 83/83. Full: 49 suites / 520 tests; pnpm typecheck, pnpm lint:ci clean. --- .gitignore | 3 + .../__tests__/federationDoctor.test.ts | 87 ++++++++++ .../apps/broken/package.json | 7 + .../apps/broken/rspack.config.js | 3 + .../apps/host/alt-rspack.config.cjs | 21 +++ .../apps/host/node_modules/react/package.json | 4 + .../dry-run-workspace/apps/host/package.json | 7 + .../apps/host/rspack.config.js | 23 +++ .../apps/nonplugin/package.json | 7 + .../apps/nonplugin/rspack.config.js | 5 + .../node_modules/react/package.json | 4 + .../apps/remote-clean/package.json | 7 + .../apps/remote-clean/rspack.config.js | 22 +++ .../node_modules/react/package.json | 4 + .../apps/remote-drift/package.json | 7 + .../apps/remote-drift/rspack.config.js | 22 +++ .../clean/repack-federation.json | 9 + .../drift/repack-federation.json | 9 + .../hostile/repack-federation.json | 9 + .../federation/__tests__/dryRun.test.ts | 157 +++++++++++++++++ .../__tests__/extractShared.test.ts | 114 +++++++++++++ .../repack/src/commands/federation/doctor.ts | 5 +- .../repack/src/commands/federation/dryRun.ts | 161 ++++++++++++++++++ .../src/commands/federation/extractShared.ts | 137 +++++++++++++++ .../repack/src/commands/federationDoctor.ts | 30 ++++ packages/repack/src/commands/options.ts | 5 + packages/repack/src/commands/types.ts | 2 + .../src/plugins/ModuleFederationPluginV1.ts | 9 + .../src/plugins/ModuleFederationPluginV2.ts | 9 + .../src/latest/api/cli/federation-doctor.mdx | 15 ++ 30 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/rspack.config.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/alt-rspack.config.cjs create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/node_modules/react/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/rspack.config.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/rspack.config.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/node_modules/react/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/rspack.config.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/node_modules/react/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/rspack.config.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/clean/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/drift/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/hostile/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/dryRun.test.ts create mode 100644 packages/repack/src/commands/federation/__tests__/extractShared.test.ts create mode 100644 packages/repack/src/commands/federation/dryRun.ts create mode 100644 packages/repack/src/commands/federation/extractShared.ts diff --git a/.gitignore b/.gitignore index ee6ac6b95..156c0bf60 100644 --- a/.gitignore +++ b/.gitignore @@ -394,3 +394,6 @@ packages/**/docs !packages/repack/src/plugins/__tests__/__fixtures__/manifest-context/node_modules/ !packages/repack/src/utils/__tests__/__fixtures__/define-shared-app/node_modules/ !packages/repack/src/utils/__tests__/__fixtures__/define-shared-split-app/node_modules/ +!packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/node_modules/ +!packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/node_modules/ +!packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/node_modules/ diff --git a/packages/repack/src/commands/__tests__/federationDoctor.test.ts b/packages/repack/src/commands/__tests__/federationDoctor.test.ts index ddc1415ab..4b676cedf 100644 --- a/packages/repack/src/commands/__tests__/federationDoctor.test.ts +++ b/packages/repack/src/commands/__tests__/federationDoctor.test.ts @@ -284,3 +284,90 @@ describe('federation-doctor with repack-federation.json', () => { expect(exit).toHaveBeenCalledWith(withPort.code); }); }); + +describe('federation-doctor --dry-run', () => { + // Capability fixtures live OUTSIDE __tests__ (jest testMatch would collect + // any .ts there): up two levels, into commands/federation/__fixtures__. + const WORKSPACE = path.join( + FIXTURES, + '..', + '..', + '__fixtures__', + 'dry-run-workspace' + ); + let cwdSpy: jest.SpyInstance; + + function fromDir(dir: string) { + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(dir); + } + + afterEach(() => { + cwdSpy?.mockRestore(); + }); + + it('catches injected version divergence with zero builds and exits 1', async () => { + fromDir(path.join(WORKSPACE, 'drift')); + await federationDoctor([], cliConfig, { dryRun: true }); + + expect(stdout()).toContain('SHARED_VERSION_DRIFT'); + expect(stdout()).toContain('9.9.9'); + expect(stdout()).toContain('9.8.7'); + // The unbuilt caveat rides every finding message, text mode included. + expect(stdout()).toContain('dry-run'); + expect(stdout()).toContain('not verified against built manifests'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('never reports MISSING_REMOTE_MANIFEST — no manifests are consulted', async () => { + // The fixture workspace has NO build output at all: every manifest path + // in repack-federation.json points at a directory that does not exist. + fromDir(path.join(WORKSPACE, 'clean')); + await federationDoctor([], cliConfig, { dryRun: true }); + + expect(stdout()).not.toContain('MISSING_REMOTE_MANIFEST'); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('exits 2 on a hostile app config, printing a message and no stack', async () => { + fromDir(path.join(WORKSPACE, 'hostile')); + await federationDoctor([], cliConfig, { dryRun: true }); + + const printed = error.mock.calls.map(([line]) => String(line)).join('\n'); + expect(printed).toContain('kaboom'); + expect(printed).not.toMatch(/\n\s+at\s+\S/); + expect(log).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('exits 2 on a malformed repack-federation.json under --dry-run too', async () => { + const malformedDir = path.join(tmpDir, 'dryrun-malformed'); + fs.mkdirSync(malformedDir, { recursive: true }); + fs.writeFileSync( + path.join(malformedDir, 'repack-federation.json'), + '{ "host": { "manifest":\n' + ); + fromDir(malformedDir); + await federationDoctor([], cliConfig, { dryRun: true }); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('Federation config') + ); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('keeps the locked --format json shape with the caveat inside messages', async () => { + fromDir(path.join(WORKSPACE, 'drift')); + await federationDoctor([], cliConfig, { dryRun: true, format: 'json' }); + + expect(log).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(log.mock.calls[0][0] as string) as { + findings: Array>; + }; + expect(parsed.findings.length).toBeGreaterThan(0); + for (const finding of parsed.findings) { + expect(Object.keys(finding)).toEqual(['severity', 'code', 'message']); + expect(finding.message).toContain('not verified against built manifests'); + } + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/package.json new file mode 100644 index 000000000..fcac7957f --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/package.json @@ -0,0 +1,7 @@ +{ + "name": "broken-app", + "version": "0.0.0", + "dependencies": { + "react": "9.9.9" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/rspack.config.js b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/rspack.config.js new file mode 100644 index 000000000..64c593d07 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/broken/rspack.config.js @@ -0,0 +1,3 @@ +// Hostile config for the threat-matrix case: evaluating this config throws. +// Extraction must turn this into a ConfigEvalError (message, never a stack). +throw new Error('kaboom — hostile config exploded at import time'); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/alt-rspack.config.cjs b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/alt-rspack.config.cjs new file mode 100644 index 000000000..961cdf638 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/alt-rspack.config.cjs @@ -0,0 +1,21 @@ +// --config flag target proving the flag wins over discovered configs. +class ModuleFederationPluginV1 { + constructor(config) { + this.config = config; + } + + getSharedConfiguration() { + return this.config.shared; + } +} + +module.exports = () => ({ + plugins: [ + new ModuleFederationPluginV1({ + name: 'override', + shared: { + react: { singleton: true, eager: true, version: '9.9.9' }, + }, + }), + ], +}); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/node_modules/react/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/node_modules/react/package.json new file mode 100644 index 000000000..429b89129 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/node_modules/react/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "9.9.9" +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/package.json new file mode 100644 index 000000000..4430c9620 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/package.json @@ -0,0 +1,7 @@ +{ + "name": "host-app", + "version": "0.0.0", + "dependencies": { + "react": "9.9.9" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/rspack.config.js b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/rspack.config.js new file mode 100644 index 000000000..fa3e9b66a --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/rspack.config.js @@ -0,0 +1,23 @@ +// CJS stub of a host bundler config for --dry-run extraction tests. +// Mirrors the plugin shape `extractAppShared` duck-types: an instance with +// getSharedConfiguration() returning the constructor-provided shared option. +class ModuleFederationPluginV1 { + constructor(config) { + this.config = config; + } + + getSharedConfiguration() { + return this.config.shared; + } +} + +module.exports = () => ({ + plugins: [ + new ModuleFederationPluginV1({ + name: 'shell', + shared: { + react: { singleton: true, eager: true, version: '9.9.9' }, + }, + }), + ], +}); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/package.json new file mode 100644 index 000000000..2437a23b6 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "nonplugin-app", + "version": "0.0.0", + "dependencies": { + "react": "9.9.9" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/rspack.config.js b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/rspack.config.js new file mode 100644 index 000000000..383dbc077 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/nonplugin/rspack.config.js @@ -0,0 +1,5 @@ +// A config that loads fine but instantiates no federation plugin — the +// duck-typed extraction has nothing to read and must fail loudly. +module.exports = { + plugins: [{ apply() {} }], +}; diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/node_modules/react/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/node_modules/react/package.json new file mode 100644 index 000000000..429b89129 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/node_modules/react/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "9.9.9" +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/package.json new file mode 100644 index 000000000..a03f5e428 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/package.json @@ -0,0 +1,7 @@ +{ + "name": "remote-clean-app", + "version": "0.0.0", + "dependencies": { + "react": "9.9.9" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/rspack.config.js b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/rspack.config.js new file mode 100644 index 000000000..049beb878 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/rspack.config.js @@ -0,0 +1,22 @@ +// Remote stub aligned with the host: react 9.9.9 installed, lazy eager +// (the remote side of the host-eager/remote-lazy convention). +class ModuleFederationPluginV1 { + constructor(config) { + this.config = config; + } + + getSharedConfiguration() { + return this.config.shared; + } +} + +module.exports = () => ({ + plugins: [ + new ModuleFederationPluginV1({ + name: 'catalog', + shared: { + react: { singleton: true, eager: false, version: '9.9.9' }, + }, + }), + ], +}); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/node_modules/react/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/node_modules/react/package.json new file mode 100644 index 000000000..29086f4a5 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/node_modules/react/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "9.8.7" +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/package.json new file mode 100644 index 000000000..e3b64dd4c --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/package.json @@ -0,0 +1,7 @@ +{ + "name": "remote-drift-app", + "version": "0.0.0", + "dependencies": { + "react": "9.8.7" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/rspack.config.js b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/rspack.config.js new file mode 100644 index 000000000..3b91e433c --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/rspack.config.js @@ -0,0 +1,22 @@ +// Drifting remote stub: same shared declaration, but this app root has an +// older react installed — the divergence --dry-run must catch pre-build. +class ModuleFederationPluginV1 { + constructor(config) { + this.config = config; + } + + getSharedConfiguration() { + return this.config.shared; + } +} + +module.exports = () => ({ + plugins: [ + new ModuleFederationPluginV1({ + name: 'store', + shared: { + react: { singleton: true, eager: false, version: '9.9.9' }, + }, + }), + ], +}); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/clean/repack-federation.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/clean/repack-federation.json new file mode 100644 index 000000000..a743eca10 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/clean/repack-federation.json @@ -0,0 +1,9 @@ +{ + "host": { "manifest": "../apps/host/build", "root": "../apps/host" }, + "remotes": { + "remote-clean": { + "manifest": "../apps/remote-clean/build", + "root": "../apps/remote-clean" + } + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/drift/repack-federation.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/drift/repack-federation.json new file mode 100644 index 000000000..8eed84252 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/drift/repack-federation.json @@ -0,0 +1,9 @@ +{ + "host": { "manifest": "../apps/host/build", "root": "../apps/host" }, + "remotes": { + "remote-drift": { + "manifest": "../apps/remote-drift/build", + "root": "../apps/remote-drift" + } + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/hostile/repack-federation.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/hostile/repack-federation.json new file mode 100644 index 000000000..543ac7558 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/hostile/repack-federation.json @@ -0,0 +1,9 @@ +{ + "host": { "manifest": "../apps/host/build", "root": "../apps/host" }, + "remotes": { + "broken": { + "manifest": "../apps/broken/build", + "root": "../apps/broken" + } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/dryRun.test.ts b/packages/repack/src/commands/federation/__tests__/dryRun.test.ts new file mode 100644 index 000000000..63b98b81c --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/dryRun.test.ts @@ -0,0 +1,157 @@ +import type { FederationManifestSharedEntry } from '../../../plugins/federationManifest/types.js'; +import { doctorExitCode } from '../doctor.js'; +import { type DryRunApp, runDryRun, UNBUILT_CAVEAT } from '../dryRun.js'; + +const sharedEntry = ( + name: string, + version: string, + overrides: Partial = {} +): FederationManifestSharedEntry => ({ + name, + version, + singleton: true, + eager: true, + requiredVersion: version, + ...overrides, +}); + +const app = ( + name: string, + shared: FederationManifestSharedEntry[], + dependencies: Record = {} +): DryRunApp => ({ + name, + root: `/workspace/apps/${name}`, + packageJson: { name, dependencies }, + shared, +}); + +describe('runDryRun', () => { + it('reports singleton version drift naming both versions, as an error', () => { + const report = runDryRun({ + host: app('shell', [sharedEntry('react', '19.2.3')], { react: '19.2.3' }), + remotes: [ + app('store', [sharedEntry('react', '19.1.0')], { react: '19.1.0' }), + ], + }); + + const drift = report.findings.filter( + (finding) => finding.code === 'SHARED_VERSION_DRIFT' + ); + expect(drift).toHaveLength(1); + expect(drift[0]!.severity).toBe('error'); + expect(drift[0]!.message).toContain('19.2.3'); + expect(drift[0]!.message).toContain('19.1.0'); + // The exit-code contract is the shared one: an error means exit 1. + expect(doctorExitCode(report)).toBe(1); + }); + + it('carries the unbuilt caveat in EVERY finding message', () => { + const report = runDryRun({ + host: app('shell', [sharedEntry('react', '19.2.3')], { react: '19.2.3' }), + remotes: [ + app('store', [sharedEntry('react', '19.1.0')], { + react: '19.1.0', + '@acme/feature-lib': '2.0.0', + }), + ], + }); + + // Non-empty first: this scenario must produce drift AND missing-provider + // findings, and each one must tell the user it is pre-build derived. + expect(report.findings.length).toBeGreaterThan(1); + for (const finding of report.findings) { + expect(finding.message).toContain(UNBUILT_CAVEAT); + } + }); + + it('degrades an unresolvable installed version to VERSION_UNKNOWN info', () => { + const report = runDryRun({ + host: app('shell', [sharedEntry('react', 'unknown')], { react: '*' }), + remotes: [ + app('store', [sharedEntry('react', '19.1.0')], { react: '19.1.0' }), + ], + }); + + const unknown = report.findings.filter( + (finding) => finding.code === 'VERSION_UNKNOWN' + ); + expect(unknown).toHaveLength(1); + expect(unknown[0]!.severity).toBe('info'); + expect(doctorExitCode(report)).toBe(0); + }); + + it('warns when a both-declared library is missing from the host shared provides', () => { + const report = runDryRun({ + host: app('shell', [sharedEntry('react', '19.2.3')], { + react: '19.2.3', + '@acme/feature-lib': '2.0.0', + }), + remotes: [ + app('store', [sharedEntry('react', '19.2.3')], { + react: '19.2.3', + '@acme/feature-lib': '2.0.0', + }), + ], + }); + + const missing = report.findings.filter( + (finding) => finding.code === 'MISSING_SHARED_PROVIDER' + ); + expect(missing).toHaveLength(1); + // Heuristic (both-declared) — warning severity, never an error. + expect(missing[0]!.severity).toBe('warning'); + expect(missing[0]!.message).toContain('@acme/feature-lib'); + expect(missing[0]!.message).toContain('store'); + expect(doctorExitCode(report)).toBe(0); + }); + + it('only flags libraries BOTH apps declare (intersection heuristic)', () => { + const report = runDryRun({ + host: app('shell', [sharedEntry('react', '19.2.3')], { react: '19.2.3' }), + remotes: [ + app('store', [sharedEntry('react', '19.2.3')], { + react: '19.2.3', + 'remote-only-lib': '1.0.0', + }), + ], + }); + + expect( + report.findings.filter( + (finding) => finding.code === 'MISSING_SHARED_PROVIDER' + ) + ).toEqual([]); + }); + + it('produces no findings on a fully aligned, fully provisioned workspace', () => { + const report = runDryRun({ + host: app('shell', [sharedEntry('react', '19.2.3')], { react: '19.2.3' }), + remotes: [ + app('store', [sharedEntry('react', '19.2.3')], { react: '19.2.3' }), + ], + }); + + // Why empty: same installed version, same flags, and every expected lib + // (just react) is provided by the host — nothing left to report. + expect(report.findings).toEqual([]); + expect(doctorExitCode(report)).toBe(0); + }); + + it('never produces manifest-backed findings — dry-run does not consult manifests', () => { + const report = runDryRun({ + host: app('shell', [sharedEntry('react', '19.2.3')], { react: '19.2.3' }), + remotes: [ + // A remote that has never been built has no manifest at all; the + // DryRunApp carries none and no MISSING_REMOTE_MANIFEST may appear. + app('never-built', [sharedEntry('react', '19.2.3')], { + react: '19.2.3', + }), + ], + }); + + expect(report.findings.map((finding) => finding.code)).not.toContain( + 'MISSING_REMOTE_MANIFEST' + ); + }); +}); diff --git a/packages/repack/src/commands/federation/__tests__/extractShared.test.ts b/packages/repack/src/commands/federation/__tests__/extractShared.test.ts new file mode 100644 index 000000000..aec20a7bd --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/extractShared.test.ts @@ -0,0 +1,114 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ModuleFederationPluginV1 } from '../../../plugins/ModuleFederationPluginV1.js'; +import { ModuleFederationPluginV2 } from '../../../plugins/ModuleFederationPluginV2.js'; +import { ConfigEvalError, extractAppShared } from '../extractShared.js'; + +const WORKSPACE = path.join( + __dirname, + '..', + '__fixtures__', + 'dry-run-workspace' +); +const appDir = (app: string) => path.join(WORKSPACE, 'apps', app); + +describe('getSharedConfiguration accessor', () => { + it('returns the constructor shared option verbatim on ModuleFederationPluginV1', () => { + const shared = { react: { singleton: true, eager: true } }; + const plugin = new ModuleFederationPluginV1({ name: 'shell', shared }); + // Verbatim means the same object: no copy, no deep-import injection. + expect(plugin.getSharedConfiguration()).toBe(shared); + expect(Object.keys(plugin.getSharedConfiguration() as object)).toEqual([ + 'react', + ]); + }); + + it('returns the constructor shared option verbatim on ModuleFederationPluginV2', () => { + const shared = { react: { singleton: true, eager: false } }; + const plugin = new ModuleFederationPluginV2({ name: 'shell', shared }); + expect(plugin.getSharedConfiguration()).toBe(shared); + }); +}); + +describe('extractAppShared', () => { + it('evaluates a CJS stub config and resolves exact versions from the app root', async () => { + const extracted = await extractAppShared(appDir('host')); + + expect(extracted.name).toBe('shell'); + expect(extracted.pluginName).toBe('ModuleFederationPluginV1'); + // The fixture's mini node_modules/react pins 9.9.9 and must win over any + // react resolvable further up the tree (same proof as defineShared). + expect(extracted.shared).toEqual([ + { + name: 'react', + version: '9.9.9', + singleton: true, + eager: true, + requiredVersion: '9.9.9', + }, + ]); + }); + + it('resolves per-app installed versions, not one global tree', async () => { + const clean = await extractAppShared(appDir('remote-clean')); + const drift = await extractAppShared(appDir('remote-drift')); + + expect(clean.shared).toEqual([ + expect.objectContaining({ + name: 'react', + version: '9.9.9', + eager: false, + }), + ]); + expect(drift.shared).toEqual([ + expect.objectContaining({ + name: 'react', + version: '9.8.7', + eager: false, + }), + ]); + }); + + it('lets an explicit --config path win over discovered config files', async () => { + const extracted = await extractAppShared(appDir('host'), { + configPath: path.join(appDir('host'), 'alt-rspack.config.cjs'), + }); + + expect(extracted.name).toBe('override'); + }); + + it('turns a config that throws on import into a ConfigEvalError without a stack', async () => { + await expect(extractAppShared(appDir('broken'))).rejects.toThrow( + ConfigEvalError + ); + await expect(extractAppShared(appDir('broken'))).rejects.toThrow('kaboom'); + try { + await extractAppShared(appDir('broken')); + } catch (error) { + expect((error as Error).message).not.toMatch(/\n\s+at\s/); + } + }); + + it('rejects a config that instantiates no federation plugin', async () => { + await expect(extractAppShared(appDir('nonplugin'))).rejects.toThrow( + ConfigEvalError + ); + try { + await extractAppShared(appDir('nonplugin')); + } catch (error) { + expect((error as Error).message).toContain( + path.join(appDir('nonplugin'), 'rspack.config.js') + ); + } + }); + + it('rejects an app directory with no bundler configuration at all', async () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-nocfg-')); + try { + await expect(extractAppShared(emptyDir)).rejects.toThrow(ConfigEvalError); + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/repack/src/commands/federation/doctor.ts b/packages/repack/src/commands/federation/doctor.ts index cb990c58e..f0daefdc6 100644 --- a/packages/repack/src/commands/federation/doctor.ts +++ b/packages/repack/src/commands/federation/doctor.ts @@ -89,8 +89,11 @@ function checkManifestVersion( * host-eager/remote-lazy convention, remote↔remote reports any mismatch as an * advisory — no convention orders two remotes (the host arbitrates their * shares). + * + * Exported for `dryRun.ts`, which feeds it manifest-shaped virtual entries + * derived from bundler configs instead of built manifests. */ -function checkSharedDeps( +export function checkSharedDeps( left: FederationManifest, leftLabel: string, right: FederationManifest, diff --git a/packages/repack/src/commands/federation/dryRun.ts b/packages/repack/src/commands/federation/dryRun.ts new file mode 100644 index 000000000..515779a11 --- /dev/null +++ b/packages/repack/src/commands/federation/dryRun.ts @@ -0,0 +1,161 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { + FederationManifest, + FederationManifestSharedEntry, +} from '../../plugins/federationManifest/types.js'; +import type { + ResolvedEntry, + ResolvedRemote, + ResolvedWorkspace, +} from './configFile.js'; +import { + checkSharedDeps, + type DoctorFinding, + type DoctorReport, +} from './doctor.js'; +import { ConfigEvalError, extractAppShared } from './extractShared.js'; + +/** + * Suffix appended to every dry-run finding message: the check ran before any + * build, so nothing here is backed by a built manifest. It rides inside + * `message` so the locked `--format json` shape survives untouched. + */ +export const UNBUILT_CAVEAT = + ' [dry-run: derived from package.json and bundler configs before any ' + + 'build; not verified against built manifests]'; + +/** The `package.json` fields the dry-run reads. */ +export interface DryRunPackageJson { + name?: string; + dependencies?: Record; +} + +/** One app as the pre-build checks see it — no manifest anywhere. */ +export interface DryRunApp { + name: string; + root: string; + packageJson: DryRunPackageJson; + /** Virtual manifest entries extracted from the app's bundler config. */ + shared: FederationManifestSharedEntry[]; +} + +export interface DryRunInput { + host: DryRunApp; + remotes: DryRunApp[]; +} + +/** + * `checkSharedDeps` reads only the `shared` block; virtual entries derived + * from configs are manifest-shaped exactly where it matters. + */ +function asManifestLike(app: DryRunApp): FederationManifest { + return { shared: app.shared } as unknown as FederationManifest; +} + +/** + * Run the pre-build checks: shared-dependency version alignment over the + * virtual entries (reusing the post-build `checkSharedDeps` codes and + * severities verbatim) and expected-library coverage from the both-declared + * heuristic. No builds, no app runs, no manifest fetches — and therefore no + * `MISSING_REMOTE_MANIFEST` findings either. + */ +export function runDryRun(input: DryRunInput): DoctorReport { + const findings: DoctorFinding[] = []; + const hostProvides = new Set(input.host.shared.map((entry) => entry.name)); + const hostDependencies = input.host.packageJson.dependencies ?? {}; + + for (const remote of input.remotes) { + checkSharedDeps( + asManifestLike(input.host), + `host "${input.host.name}"`, + asManifestLike(remote), + `remote "${remote.name}"`, + findings, + 'host-remote' + ); + + // Expected-libs coverage: a library BOTH apps declare is the + // share-by-convention signal (federation-init writes scanned deps into + // the remote's package.json, feeding this derivation). Heuristic, so + // warning severity — a pre-build gate that cries error on guesses gets + // disabled. + for (const library of Object.keys(remote.packageJson.dependencies ?? {})) { + if (!(library in hostDependencies) || hostProvides.has(library)) { + continue; + } + findings.push({ + severity: 'warning', + code: 'MISSING_SHARED_PROVIDER', + message: + `Library "${library}" is declared in the package.json of both ` + + `host "${input.host.name}" and remote "${remote.name}", so the ` + + 'remote is expected to receive it as a shared dependency, but the ' + + "host's shared configuration does not provide it; add it to the " + + "host's shared list or remove it from one app's dependencies.", + }); + } + } + + return { + findings: findings.map((finding) => ({ + ...finding, + message: `${finding.message}${UNBUILT_CAVEAT}`, + })), + }; +} + +/** Read the app package.json the expected-libs check derives from. */ +export function readDryRunPackageJson(root: string): DryRunPackageJson { + const file = path.join(root, 'package.json'); + if (!fs.existsSync(file)) { + throw new ConfigEvalError( + `App package.json is missing: ${file} — the dry-run derives its ` + + 'inputs from package.json and bundler configs.' + ); + } + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')) as DryRunPackageJson; + } catch { + throw new ConfigEvalError(`App package.json is not valid JSON: ${file}`); + } +} + +/** + * Build the pure `runDryRun` input from a resolved workspace: per app, the + * root defaults to the config file's directory, the shared entries come from + * evaluating the app's bundler config, and remotes keep their declared + * names as finding labels. + */ +export async function collectDryRunInput( + cwd: string, + workspace: ResolvedWorkspace +): Promise { + const baseDir = workspace.configPath + ? path.dirname(workspace.configPath) + : cwd; + + const toApp = async ( + entry: ResolvedEntry | ResolvedRemote, + declaredName?: string + ): Promise => { + const root = entry.root ?? baseDir; + const extracted = await extractAppShared(root); + return { + name: declaredName ?? extracted.name, + root, + packageJson: readDryRunPackageJson(root), + shared: extracted.shared, + }; + }; + + const host = await toApp(workspace.host!); + const remotes: DryRunApp[] = []; + // Sequential on purpose: extraction evaluates user configs in-process and + // the first failure aborts the run — order decides which one is reported. + for (const remote of workspace.remotes) { + remotes.push(await toApp(remote, remote.name)); + } + + return { host, remotes }; +} diff --git a/packages/repack/src/commands/federation/extractShared.ts b/packages/repack/src/commands/federation/extractShared.ts new file mode 100644 index 000000000..426449ed3 --- /dev/null +++ b/packages/repack/src/commands/federation/extractShared.ts @@ -0,0 +1,137 @@ +import path from 'node:path'; +import { buildSharedEntries } from '../../plugins/federationManifest/shared.js'; +import type { FederationManifestSharedEntry } from '../../plugins/federationManifest/types.js'; +import { getConfigFilePath } from '../common/config/getConfigFilePath.js'; +import { loadProjectConfig } from '../common/config/loadProjectConfig.js'; + +/** + * An app's bundler configuration could not be located, evaluated, or does + * not instantiate a federation plugin. Tools map this to exit code 2 — + * "could not run" — printing only `message`, never a stack. + */ +export class ConfigEvalError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigEvalError'; + } +} + +/** What extraction learned from one app's configuration. */ +export interface ExtractedAppShared { + /** Federation name declared by the plugin, else the app dir basename. */ + name: string; + /** Constructor name of the matched plugin ('ModuleFederationPluginV1'…), + * for consumers that must mirror the plugin version (federation-init). */ + pluginName: string | undefined; + /** Virtual manifest entries: the user's shared option resolved against + * this app's installed packages. */ + shared: FederationManifestSharedEntry[]; +} + +/** The minimal environment a config function is evaluated with. */ +interface SyntheticConfigEnv { + mode: 'production'; + context: string; + platform: 'ios'; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function discoverConfigPath(root: string, customPath?: string): string { + // Same discovery order the bundler commands use (rspack first), with an + // explicit --config-style path always winning. + try { + return getConfigFilePath('rspack', root, customPath); + } catch { + // fall through to webpack candidates + } + try { + return getConfigFilePath('webpack', root, customPath); + } catch { + throw new ConfigEvalError( + `No bundler configuration found in ${root} — the dry-run reads the ` + + 'shared setup from the app rspack or webpack configuration.' + ); + } +} + +/** + * Evaluate one app's bundler configuration in-process and extract the + * `shared` option from the Module Federation plugin instance it + * instantiates, resolved into manifest-shaped entries against the app's + * installed versions. + * + * The config is loaded exactly the way the bundler loads it + * (`loadProjectConfig`), so a config that cannot be evaluated here fails + * the same way it would fail a build. Duck-typed plugin detection + * (`getSharedConfiguration`) keeps this module free of the heavy plugin + * imports; the trust level is the same as running the bundler on this + * machine's own configs. + */ +export async function extractAppShared( + root: string, + options: { configPath?: string } = {} +): Promise { + const configPath = discoverConfigPath(root, options.configPath); + + let config: unknown; + try { + config = await loadProjectConfig(configPath); + } catch (error) { + throw new ConfigEvalError( + `Failed to load bundler config ${configPath}: ${messageOf(error)}` + ); + } + + if (typeof config === 'function') { + const env: SyntheticConfigEnv = { + mode: 'production', + context: root, + platform: 'ios', + }; + try { + config = await ( + config as (env: SyntheticConfigEnv, argv: object) => unknown + )(env, {}); + } catch (error) { + throw new ConfigEvalError( + `Bundler config ${configPath} failed while producing its ` + + `configuration: ${messageOf(error)}` + ); + } + } + + const plugins = Array.isArray((config as { plugins?: unknown[] })?.plugins) + ? ((config as { plugins: unknown[] }).plugins as unknown[]) + : []; + const plugin = plugins.find( + (candidate) => + typeof (candidate as { getSharedConfiguration?: unknown }) + ?.getSharedConfiguration === 'function' + ) as + | { + getSharedConfiguration: () => unknown; + config?: { name?: unknown }; + constructor?: { name?: string }; + } + | undefined; + + if (!plugin) { + throw new ConfigEvalError( + `No Module Federation plugin instance with getSharedConfiguration() ` + + `found in ${configPath} — the dry-run reads the shared option from ` + + 'the ModuleFederationPluginV1/V2 the config instantiates.' + ); + } + + return { + name: + typeof plugin.config?.name === 'string' && plugin.config.name + ? plugin.config.name + : path.basename(root), + pluginName: plugin.constructor?.name, + shared: buildSharedEntries(plugin.getSharedConfiguration(), root), + }; +} diff --git a/packages/repack/src/commands/federationDoctor.ts b/packages/repack/src/commands/federationDoctor.ts index bfa9f1d9a..ef178ae8d 100644 --- a/packages/repack/src/commands/federationDoctor.ts +++ b/packages/repack/src/commands/federationDoctor.ts @@ -3,6 +3,7 @@ import { ConfigFileInvalidError, resolveFederationWorkspace, } from './federation/configFile.js'; +import type { DoctorReport } from './federation/doctor.js'; import { type DoctorRemoteInput, doctorExitCode, @@ -10,6 +11,8 @@ import { formatDoctorReport, runDoctor, } from './federation/doctor.js'; +import { collectDryRunInput, runDryRun } from './federation/dryRun.js'; +import { ConfigEvalError } from './federation/extractShared.js'; import { type LoadedManifest, loadManifest, @@ -79,6 +82,33 @@ export async function federationDoctor( return; } + if (args.dryRun) { + // Pre-build mode: inputs come from package.json files and evaluated + // bundler configs only — no builds, no app runs, no manifest fetches. + // A config that cannot be evaluated is "could not run": exit 2, message + // only, never a stack. Exit codes come from the shared contract. + let report: DoctorReport; + try { + report = runDryRun(await collectDryRunInput(process.cwd(), workspace)); + } catch (error) { + if (error instanceof ConfigEvalError) { + console.error(`Federation dry-run — ${error.message}`); + process.exit(2); + return; + } + throw error; + } + + if (args.format === 'json') { + console.log(doctorReportToJson(report)); + } else { + console.log(formatDoctorReport(report)); + } + + process.exit(doctorExitCode(report)); + return; + } + let host: LoadedManifest; try { host = await loadManifest(workspace.host.source); diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index 94ac1dc7a..0713e34d7 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -133,6 +133,11 @@ export const federationDoctorCommandOptions = [ description: 'Additionally compare every remote pair for shared-dependency drift (shared checks only; native checks stay host-to-remote)', }, + { + name: '--dry-run', + description: + 'Pre-build mode: check shared version alignment, expected-library provisioning and config sanity from package.json and bundler configs only — no builds, no manifest fetches. Every finding carries an unbuilt caveat', + }, ]; export const bundleCommandOptions = [ diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index 29ed0f4e5..922731556 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -55,6 +55,8 @@ export interface FederationDoctorArguments { allowMissingManifests?: boolean; /** Also compare every remote pair, shared-dependency checks only. */ pairwise?: boolean; + /** Pre-build mode over package.json + bundler configs; never reads manifests. */ + dryRun?: boolean; } export interface CliConfig { diff --git a/packages/repack/src/plugins/ModuleFederationPluginV1.ts b/packages/repack/src/plugins/ModuleFederationPluginV1.ts index 83fbb3ac0..48a408a01 100644 --- a/packages/repack/src/plugins/ModuleFederationPluginV1.ts +++ b/packages/repack/src/plugins/ModuleFederationPluginV1.ts @@ -124,6 +124,15 @@ export class ModuleFederationPluginV1 { this.manifest = manifest || undefined; } + /** + * Returns the `shared` option exactly as it was passed to the constructor, + * before any deep-import injection — tooling (`federation-doctor --dry-run`, + * `federation-init`) reads what the user wrote, not what the plugin emits. + */ + getSharedConfiguration(): unknown { + return this.config.shared; + } + /** * This method provides compatibility between webpack and Rspack for the ModuleFederation plugin. * In Rspack, Module Federation 1.5 is implemented under the name that's used in webpack for the original version. diff --git a/packages/repack/src/plugins/ModuleFederationPluginV2.ts b/packages/repack/src/plugins/ModuleFederationPluginV2.ts index 36dcae7e5..d9ac680d8 100644 --- a/packages/repack/src/plugins/ModuleFederationPluginV2.ts +++ b/packages/repack/src/plugins/ModuleFederationPluginV2.ts @@ -161,6 +161,15 @@ export class ModuleFederationPluginV2 { } } + /** + * Returns the `shared` option exactly as it was passed to the constructor, + * before any deep-import injection — tooling (`federation-doctor --dry-run`, + * `federation-init`) reads what the user wrote, not what the plugin emits. + */ + getSharedConfiguration(): unknown { + return this.config.shared; + } + private adaptRuntimePlugins( context: string, runtimePlugins: string[] | undefined = [] diff --git a/website/src/latest/api/cli/federation-doctor.mdx b/website/src/latest/api/cli/federation-doctor.mdx index de974faae..fc7551e48 100644 --- a/website/src/latest/api/cli/federation-doctor.mdx +++ b/website/src/latest/api/cli/federation-doctor.mdx @@ -43,6 +43,9 @@ npx react-native federation-doctor # also compare remotes against each other (shared checks only) npx react-native federation-doctor --pairwise + +# pre-build checks — no builds, no manifests +npx react-native federation-doctor --dry-run ``` 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. @@ -81,6 +84,18 @@ Downgrade `MISSING_REMOTE_MANIFEST` from error to warning. Use it while rolling Additionally compare every remote pair for shared-dependency drift (every `remote_i`/`remote_j` combination). Off by default — the host↔remote checks already cover every remote, and pairwise adds `O(n²)` findings. Only the shared checks run between remotes; native-module checks stay host↔remote because the host is the native provider. Enabling it never changes the host↔remote checks or their severities. +### `--dry-run` + +- Type: `boolean` + +Pre-build mode: validate the federation workspace **before anything is built**. Inputs come only from the apps' `package.json` files, their bundler configurations (evaluated in-process, the same way the bundler loads them) and `repack-federation.json` — the mode performs no build, no app run and no manifest fetch, so remotes that were never built raise no `MISSING_REMOTE_MANIFEST` finding. It checks: + +- **Version alignment** across host and remotes from the versions actually installed per app root, reusing the manifest-mode finding codes verbatim (`SHARED_VERSION_DRIFT`, `VERSION_UNKNOWN`, `SINGLETON_MISMATCH`, the eager advisories). +- **Expected-library coverage**: every library the remote's `package.json` shares with the host but the host's shared configuration does not provide is reported as `MISSING_SHARED_PROVIDER` (warning — this is a both-declared heuristic, never an error). +- **Config sanity**: the workspace config loads and every app config evaluates; a config that throws or instantiates no federation plugin is "could not run" — exit 2, message only. + +App roots come from the `root` fields of `repack-federation.json` (defaulting to the config file's directory). Because nothing here is manifest-backed, **every** dry-run finding carries an explicit unbuilt caveat in its message: `[dry-run: derived from package.json and bundler configs before any build; not verified against built manifests]` — in text and JSON output alike; the `--format json` shape is unchanged. The exit-code contract (0/1/2) is identical to the post-build mode. + ## Report ordering The report lists host↔remote **native-module findings first**, then all shared-dependency findings (host↔remote, then pairwise), then manifest metadata (`MANIFEST_VERSION_AHEAD`, `MISSING_REMOTE_MANIFEST`) — the crash class most fatal is read first. The doctor never fails fast: every configured comparison runs before anything is printed, and one remote's errors never suppress another remote's findings. This is report ordering only; the exit code depends solely on the presence of `error`-severity findings. From eaf49ea1e05d386ad181296c32552cfd97483afe Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 19:39:47 +0200 Subject: [PATCH 13/19] feat(repack): add runtime-only --standalone mode via env.argv with tooling-side refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --standalone boolean flag on start and bundle commands - EnvOptions.argv (new additive public type): getEnvOptions always sets argv = { standalone: }, so configs read env.argv?.standalone safely; reaches defineRspackConfig/defineWebpackConfig evaluation through the same { ...env, platform } spread as --platform — no second injection point, nothing ever persisted - assertStandaloneSupported in configFile.ts: refuses only when repack-federation.json exists AND declares the matching remote entry without standalone: true (standalone: false = unsupported); no file or unmatched root proceeds; malformed file refuses as CLIError, no stack - start/bundle call the guard up front when --standalone is requested — refusal is tooling-side only, the bundler runtime never reads the workspace map - docs: --standalone sections on start/bundle CLI pages Focused: jest -- getEnvOptions configFile options standalone => 53/53. Full: 50 suites / 536 tests; pnpm typecheck, pnpm lint:ci clean. --- .../src/commands/__tests__/options.test.ts | 14 +++ .../src/commands/__tests__/standalone.test.ts | 100 ++++++++++++++++++ packages/repack/src/commands/bundle.ts | 8 ++ .../config/__tests__/getEnvOptions.test.ts | 81 ++++++++++++++ .../commands/common/config/getEnvOptions.ts | 4 + .../repack-federation.json | 6 ++ .../config-standalone/repack-federation.json | 19 ++++ .../federation/__tests__/configFile.test.ts | 62 +++++++++++ .../src/commands/federation/configFile.ts | 41 +++++++ packages/repack/src/commands/options.ts | 10 ++ packages/repack/src/commands/start.ts | 8 ++ packages/repack/src/commands/types.ts | 4 + packages/repack/src/types.ts | 8 ++ website/src/latest/api/cli/bundle.mdx | 6 ++ website/src/latest/api/cli/start.mdx | 6 ++ 15 files changed, 377 insertions(+) create mode 100644 packages/repack/src/commands/__tests__/standalone.test.ts create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone-default/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone/repack-federation.json diff --git a/packages/repack/src/commands/__tests__/options.test.ts b/packages/repack/src/commands/__tests__/options.test.ts index 8e90c60ba..07465cc3f 100644 --- a/packages/repack/src/commands/__tests__/options.test.ts +++ b/packages/repack/src/commands/__tests__/options.test.ts @@ -18,3 +18,17 @@ describe.each([ ); }); }); + +describe('--standalone registration', () => { + test.each([ + ['start', startCommandOptions], + ['bundle', bundleCommandOptions], + ])('%s command exposes --standalone as a boolean flag', (_, options) => { + const standaloneOption = options.find( + (option) => option.name === '--standalone' + ); + // Boolean commander flag: no parse, no default — presence means true. + expect(standaloneOption).toBeDefined(); + expect(standaloneOption?.parse).toBeUndefined(); + }); +}); diff --git a/packages/repack/src/commands/__tests__/standalone.test.ts b/packages/repack/src/commands/__tests__/standalone.test.ts new file mode 100644 index 000000000..b2709c80a --- /dev/null +++ b/packages/repack/src/commands/__tests__/standalone.test.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { bundle } from '../bundle.js'; +import { start } from '../start.js'; +import type { CliConfig } from '../types.js'; + +const cliConfigFor = (root: string): CliConfig => ({ + root, + platforms: ['ios'], + reactNativePath: '/project/node_modules/react-native', +}); + +function workspaceWith(config: object): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-standalone-')); + fs.writeFileSync( + path.join(dir, 'repack-federation.json'), + JSON.stringify(config, null, 2) + ); + return dir; +} + +// No bundler config lives in these workspaces: when the standalone guard +// PASSES, the command proceeds and fails later on config discovery — a +// different message, which is exactly what distinguishes "proceeded" from +// "refused" without mocking the whole compiler stack. +const UNSUPPORTED = () => + workspaceWith({ + host: { manifest: './build' }, + remotes: { mini: { manifest: './mini/build' } }, + }); + +const SUPPORTED = () => + workspaceWith({ + host: { manifest: './build' }, + remotes: { mini: { manifest: './mini/build', standalone: true } }, + }); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('start/bundle --standalone refusal wiring', () => { + it('bundle refuses an unsupported remote before touching the compiler', async () => { + const root = UNSUPPORTED(); + try { + await expect( + bundle([], cliConfigFor(root), { + platform: 'ios', + dev: false, + standalone: true, + }) + ).rejects.toThrow( + '--standalone refused: remote "mini" does not declare standalone support' + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('start refuses the same way', async () => { + const root = UNSUPPORTED(); + try { + await expect( + start([], cliConfigFor(root), { + host: 'localhost', + standalone: true, + }) + ).rejects.toThrow('--standalone refused'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('bundle proceeds past the guard when --standalone was never requested', async () => { + const root = UNSUPPORTED(); + try { + await expect( + bundle([], cliConfigFor(root), { platform: 'ios', dev: false }) + ).rejects.toThrow(/configuration file/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('bundle proceeds past the guard for a remote declaring standalone: true', async () => { + const root = SUPPORTED(); + try { + await expect( + bundle([], cliConfigFor(root), { + platform: 'ios', + dev: false, + standalone: true, + }) + ).rejects.toThrow(/configuration file/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/repack/src/commands/bundle.ts b/packages/repack/src/commands/bundle.ts index 894da4639..8fa8ccd37 100644 --- a/packages/repack/src/commands/bundle.ts +++ b/packages/repack/src/commands/bundle.ts @@ -9,6 +9,7 @@ import { setupRspackEnvironment, writeStats, } from './common/index.js'; +import { assertStandaloneSupported } from './federation/configFile.js'; import type { BundleArguments, Bundler, @@ -60,6 +61,13 @@ export async function bundle( args: BundleArguments, forcedBundler?: Bundler ) { + // Tooling-side standalone refusal, before anything is compiled: refuse + // only if repack-federation.json exists and declares this app's remote + // entry unsupported. The bundler runtime never reads the workspace map. + if (args.standalone) { + assertStandaloneSupported(cliConfig.root); + } + const bundler = forcedBundler ?? detectBundler( diff --git a/packages/repack/src/commands/common/config/__tests__/getEnvOptions.test.ts b/packages/repack/src/commands/common/config/__tests__/getEnvOptions.test.ts index 284383fc2..fb4c42eb1 100644 --- a/packages/repack/src/commands/common/config/__tests__/getEnvOptions.test.ts +++ b/packages/repack/src/commands/common/config/__tests__/getEnvOptions.test.ts @@ -1,3 +1,8 @@ +import type { EnvOptions } from '../../../../types.js'; +import { + defineRspackConfig, + defineWebpackConfig, +} from '../../../../utils/defineConfig.js'; import { getEnvOptions } from '../getEnvOptions.js'; describe('getEnvOptions', () => { @@ -24,6 +29,8 @@ describe('getEnvOptions', () => { bundleFilename: '/a/b/c/main.js', sourceMapFilename: undefined, assetsPath: undefined, + // Always present so configs can read `env.argv?.standalone` safely. + argv: { standalone: false }, }); expect( @@ -50,6 +57,7 @@ describe('getEnvOptions', () => { bundleFilename: '/a/b/c/main.js', sourceMapFilename: '/a/b/c/main.js.map', assetsPath: '/a/b/c/assets', + argv: { standalone: false }, }); }); @@ -71,6 +79,7 @@ describe('getEnvOptions', () => { hmr: true, https: undefined, }, + argv: { standalone: false }, }); expect( @@ -90,6 +99,78 @@ describe('getEnvOptions', () => { hmr: true, https: undefined, }, + argv: { standalone: false }, + }); + }); + + describe('argv pass-through (runtime-only flags)', () => { + it('populates argv.standalone for bundle only when the flag is set', () => { + const base = { + platform: 'ios', + dev: false, + }; + expect( + getEnvOptions({ + args: { ...base, standalone: true }, + command: 'bundle', + rootDir: '/x/y/z', + reactNativePath: '/rn', + }).argv + ).toEqual({ standalone: true }); + + expect( + getEnvOptions({ + args: base, + command: 'bundle', + rootDir: '/x/y/z', + reactNativePath: '/rn', + }).argv + ).toEqual({ standalone: false }); + }); + + it('populates argv.standalone for start only when the flag is set', () => { + expect( + getEnvOptions({ + args: { host: 'localhost', standalone: true }, + command: 'start', + rootDir: '/x/y/z', + reactNativePath: '/rn', + }).argv + ).toEqual({ standalone: true }); + + expect( + getEnvOptions({ + args: { host: 'localhost' }, + command: 'start', + rootDir: '/x/y/z', + reactNativePath: '/rn', + }).argv + ).toEqual({ standalone: false }); + }); + + it('reaches config functions through both define channels, like platform', () => { + // makeCompilerConfig calls the user's config fn with `{ ...env, + // platform }` — the exact spread that carries `platform` must carry + // `argv` to the defineRspackConfig/defineWebpackConfig channels. + const env = getEnvOptions({ + args: { platform: 'ios', dev: false, standalone: true }, + command: 'bundle', + rootDir: '/x/y/z', + reactNativePath: '/rn', + }); + const configEnv: EnvOptions = { ...env, platform: 'ios' }; + + const seen: unknown[] = []; + defineRspackConfig((e) => { + seen.push(e.argv); + return {}; + })(configEnv); + defineWebpackConfig((e) => { + seen.push(e.argv); + return {}; + })(configEnv); + + expect(seen).toEqual([{ standalone: true }, { standalone: true }]); }); }); }); diff --git a/packages/repack/src/commands/common/config/getEnvOptions.ts b/packages/repack/src/commands/common/config/getEnvOptions.ts index 331998441..29ba5b804 100644 --- a/packages/repack/src/commands/common/config/getEnvOptions.ts +++ b/packages/repack/src/commands/common/config/getEnvOptions.ts @@ -14,6 +14,10 @@ export function getEnvOptions(opts: GetEnvOptionsOptions): EnvOptions { const env: EnvOptions = { context: opts.rootDir, reactNativePath: opts.reactNativePath, + // Always present so configs can read `env.argv?.standalone` safely. + // Reaches the config function through the same `{ ...env, platform }` + // spread that carries `platform` — no second injection point. + argv: { standalone: opts.args.standalone === true }, }; if (opts.command === 'bundle') { diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone-default/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone-default/repack-federation.json new file mode 100644 index 000000000..2c2f99387 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone-default/repack-federation.json @@ -0,0 +1,6 @@ +{ + "host": { "manifest": "./manifests/host.json" }, + "remotes": { + "default-root": { "manifest": "./manifests/default-root.json" } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone/repack-federation.json new file mode 100644 index 000000000..a6fbab2d4 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-standalone/repack-federation.json @@ -0,0 +1,19 @@ +{ + "host": { "manifest": "./manifests/host.json", "root": "." }, + "remotes": { + "supported": { + "manifest": "./manifests/supported.json", + "root": "./apps/supported", + "standalone": true + }, + "undeclared": { + "manifest": "./manifests/undeclared.json", + "root": "./apps/undeclared" + }, + "declined": { + "manifest": "./manifests/declined.json", + "root": "./apps/declined", + "standalone": false + } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/configFile.test.ts b/packages/repack/src/commands/federation/__tests__/configFile.test.ts index cb5baac65..9f2a5a844 100644 --- a/packages/repack/src/commands/federation/__tests__/configFile.test.ts +++ b/packages/repack/src/commands/federation/__tests__/configFile.test.ts @@ -1,7 +1,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { CLIError } from '../../../helpers/index.js'; import { + assertStandaloneSupported, ConfigFileInvalidError, describeJsonParseFailure, FEDERATION_CONFIG_FILENAME, @@ -326,3 +328,63 @@ describe('resolveFederationWorkspace', () => { ); }); }); + +describe('assertStandaloneSupported', () => { + const STANDALONE_DIR = path.join(FIXTURES, 'config-standalone'); + const appRoot = (name: string) => path.join(STANDALONE_DIR, 'apps', name); + + it('proceeds for an entry that declares standalone: true', () => { + expect(() => assertStandaloneSupported(appRoot('supported'))).not.toThrow(); + }); + + it('refuses an entry with no standalone declaration, naming remote + file + fix', () => { + expect(() => assertStandaloneSupported(appRoot('undeclared'))).toThrow( + '--standalone refused: remote "undeclared" does not declare ' + + 'standalone support. Set "standalone": true for it in ' + + path.join(STANDALONE_DIR, FEDERATION_CONFIG_FILENAME) + + '.' + ); + }); + + it('treats standalone: false as unsupported', () => { + expect(() => assertStandaloneSupported(appRoot('declined'))).toThrow( + 'remote "declined" does not declare standalone support' + ); + }); + + it('defaults an entry with no root to the config directory itself', () => { + expect(() => + assertStandaloneSupported( + path.join(FIXTURES, 'config-standalone-default') + ) + ).toThrow('remote "default-root" does not declare standalone support'); + }); + + it('proceeds when the root matches no remote entry (e.g. the host)', () => { + // The host runs standalone without any declaration; host.root "." equals + // the config dir here, and no REMOTE entry matches it in config-valid. + expect(() => assertStandaloneSupported(VALID_DIR)).not.toThrow(); + }); + + it('proceeds with no config file at all (no declaration needed to work)', () => { + const isolated = path.join(tmpDir, 'standalone-nocfg'); + fs.mkdirSync(isolated, { recursive: true }); + expect(() => assertStandaloneSupported(isolated)).not.toThrow(); + }); + + it('refuses with a clear CLIError when the config file is malformed', () => { + let caught: unknown; + try { + assertStandaloneSupported(malformedDir); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CLIError); + const message = (caught as Error).message; + expect(message).toContain('--standalone refused'); + expect(message).toContain( + path.join(malformedDir, FEDERATION_CONFIG_FILENAME) + ); + expect(message).not.toMatch(/\n\s+at\s/); + }); +}); diff --git a/packages/repack/src/commands/federation/configFile.ts b/packages/repack/src/commands/federation/configFile.ts index c1a11d06e..1b9949e5d 100644 --- a/packages/repack/src/commands/federation/configFile.ts +++ b/packages/repack/src/commands/federation/configFile.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { CLIError } from '../../helpers/index.js'; /** Name of the federation workspace config file tools discover and load. */ export const FEDERATION_CONFIG_FILENAME = 'repack-federation.json'; @@ -346,3 +347,43 @@ export function resolveFederationWorkspace( return workspace; } + +/** + * Tooling-side gate for `--standalone`: refuse only when a + * `repack-federation.json` exists AND declares the app's entry as not + * supporting standalone. No config file, or a root matching no remote + * entry, proceeds unopposed — standalone needs no declaration to *work*, + * only support-refusal needs the file. Bundler-runtime code never calls + * this and never reads the workspace map. + */ +export function assertStandaloneSupported(rootDir: string): void { + const target = path.resolve(rootDir); + let loaded: ReturnType; + try { + loaded = loadFederationConfig({ cwd: target }); + } catch (error) { + if (error instanceof ConfigFileInvalidError) { + // Refusal runs before any compile: a CLIError keeps the message + // clear, the exit non-zero and the stack hidden (repo pattern). + throw new CLIError( + `--standalone refused: workspace config ${error.filePath}: ` + + `${error.reasons.join('; ')} — fix it before requesting standalone mode.` + ); + } + throw error; + } + if (!loaded) return; + + const configDir = path.dirname(loaded.filePath); + for (const [name, entry] of Object.entries(loaded.config.remotes)) { + const entryRoot = path.resolve(configDir, entry.root ?? '.'); + if (entryRoot !== target) continue; + if (entry.standalone !== true) { + throw new CLIError( + `--standalone refused: remote "${name}" does not declare standalone support. ` + + `Set "standalone": true for it in ${loaded.filePath}.` + ); + } + return; + } +} diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index 0713e34d7..a4e0bec88 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -94,6 +94,11 @@ export const startCommandOptions = [ 'Bundler engine to use: "rspack" or "webpack". If not specified, auto-detected from config filename.', parse: parseBundler, }, + { + name: '--standalone', + description: + 'Run this app in standalone mode: all shared dependencies become eager and no remote is consumed. Runtime-only — it is never persisted to any file', + }, ]; export const federationManifestCommandOptions = [ @@ -240,4 +245,9 @@ export const bundleCommandOptions = [ 'Bundler engine to use: "rspack" or "webpack". If not specified, auto-detected from config filename.', parse: parseBundler, }, + { + name: '--standalone', + description: + 'Build this app in standalone mode: all shared dependencies become eager and no remote is consumed. Runtime-only — it is never persisted to any file', + }, ]; diff --git a/packages/repack/src/commands/start.ts b/packages/repack/src/commands/start.ts index f1d81865f..77bc3d3cf 100644 --- a/packages/repack/src/commands/start.ts +++ b/packages/repack/src/commands/start.ts @@ -25,6 +25,7 @@ import { setupRspackEnvironment, } from './common/index.js'; import logo from './common/logo.js'; +import { assertStandaloneSupported } from './federation/configFile.js'; import type { Bundler, CliConfig, @@ -51,6 +52,13 @@ export async function start( args: StartArguments, forcedBundler?: Bundler ) { + // Tooling-side standalone refusal, before anything is compiled: refuse + // only if repack-federation.json exists and declares this app's remote + // entry unsupported. The bundler runtime never reads the workspace map. + if (args.standalone) { + assertStandaloneSupported(cliConfig.root); + } + const bundler = forcedBundler ?? detectBundler( diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index 922731556..7047262f0 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -20,6 +20,8 @@ export interface BundleArguments { config?: string; webpackConfig?: string; bundler?: Bundler; + /** Runtime-only standalone mode; reaches configs via `env.argv`. */ + standalone?: boolean; } export interface StartArguments { @@ -40,6 +42,8 @@ export interface StartArguments { config?: string; webpackConfig?: string; bundler?: Bundler; + /** Runtime-only standalone mode; reaches configs via `env.argv`. */ + standalone?: boolean; } export interface FederationManifestArguments { diff --git a/packages/repack/src/types.ts b/packages/repack/src/types.ts index fce056413..17f5882ec 100644 --- a/packages/repack/src/types.ts +++ b/packages/repack/src/types.ts @@ -77,6 +77,14 @@ export interface EnvOptions { * If `undefined`, then development server should not be run. */ devServer?: DevServerOptions; + + /** + * Tooling-set runtime flags passed through from the CLI, never persisted. + * The only key today is `standalone: boolean`, set by `--standalone` on + * `start`/`bundle`; configs derive mode from it, e.g. + * `mode: env.argv?.standalone ? 'standalone' : 'federated'`. + */ + argv?: Record; } export interface HMRMessage { diff --git a/website/src/latest/api/cli/bundle.mdx b/website/src/latest/api/cli/bundle.mdx index 6e3e47299..0cfceb7f4 100644 --- a/website/src/latest/api/cli/bundle.mdx +++ b/website/src/latest/api/cli/bundle.mdx @@ -145,6 +145,12 @@ Select the bundler explicitly. Without this option, Re.Pack infers the bundler f Path to a bundler config file, e.g webpack.config.js. +### `--standalone` + +- Type: `boolean` + +Build the bundle in standalone mode: the flag reaches your bundler config as `env.argv.standalone`, so configs can switch shared dependencies to eager, e.g. `mode: env.argv?.standalone ? 'standalone' : 'federated'` in [`defineShared`](/api/utils/define-shared). It is runtime-only — never written to any file, so there is nothing to forget to revert. If a [`repack-federation.json`](/api/cli/repack-federation-json) declares this app's remote without `"standalone": true`, the command refuses with a clear message before compiling; no config file, or an app that is not listed, builds standalone unopposed. + ### `-h`, `--help` Display help for command. diff --git a/website/src/latest/api/cli/start.mdx b/website/src/latest/api/cli/start.mdx index 95aa0d258..4280296b4 100644 --- a/website/src/latest/api/cli/start.mdx +++ b/website/src/latest/api/cli/start.mdx @@ -123,6 +123,12 @@ Select the bundler explicitly. Without this option, Re.Pack infers the bundler f Path to a bundler config file, e.g webpack.config.js. +### `--standalone` + +- Type: `boolean` + +Run the dev server in standalone mode: the flag reaches your bundler config as `env.argv.standalone`, so configs can switch shared dependencies to eager, e.g. `mode: env.argv?.standalone ? 'standalone' : 'federated'` in [`defineShared`](/api/utils/define-shared). It is runtime-only — never written to any file, so there is nothing to forget to revert. If a [`repack-federation.json`](/api/cli/repack-federation-json) declares this app's remote without `"standalone": true`, the command refuses with a clear message before compiling; no config file, or an app that is not listed, runs standalone unopposed. + ### `-h`, `--help` Display help for command. From 622501d4e63f303718604c4ea482bb7c5183df02 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 19:58:18 +0200 Subject: [PATCH 14/19] feat(repack): add feature-folder static scanner with dynamic-import advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scanFeatures.ts: regex/state-machine scanner (no babel, zero new deps) returning { dependencies, advisories }; a code-state walk masks comments and string/template content (escape-tracked), collects static import-from / bare import / export-from / require('lit') specifiers, maps subpaths to package roots, ignores react/jsx-runtime noise, filters relative and @/-alias imports, skips __tests__/ and *.test.* files and unsupported extensions - every dynamic pattern (import(), computed/template require) yields an honesty advisory naming file:line and stating the set is NOT exhaustive — manifest dynamicImportDetected semantics, never a silent pass - features-store fixtures cover static imports, scoped packages, requires, dynamic template-literal requires, commented/fake imports in strings and comments, relative imports and test-file skips - biome/tsconfig exclusions for src/commands/federation/__fixtures__: fixture sources are input data with fake imports and line-significant formatting — never linted, formatted or typechecked (features-store/__tests__ uses .jsx so jest testMatch never collects it) Focused: jest -- scanFeatures => 11/11. Full: 51 suites / 547 tests; pnpm typecheck, pnpm lint:ci clean. --- biome.jsonc | 5 +- .../__fixtures__/features-store/Screen.tsx | 11 + .../features-store/__tests__/Screen.test.jsx | 3 + .../features-store/clean/static.js | 4 + .../__fixtures__/features-store/deep.js | 7 + .../__fixtures__/features-store/dynamic.ts | 17 ++ .../features-store/helpers/Widget.test.jsx | 3 + .../__fixtures__/features-store/local.ts | 6 + .../__fixtures__/features-store/noise.js | 18 ++ .../__fixtures__/features-store/notes.md | 5 + .../__fixtures__/features-store/widget.jsx | 4 + .../federation/__tests__/scanFeatures.test.ts | 101 +++++++ .../src/commands/federation/scanFeatures.ts | 259 ++++++++++++++++++ packages/repack/tsconfig.build.json | 3 +- packages/repack/tsconfig.json | 6 +- 15 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/Screen.tsx create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/__tests__/Screen.test.jsx create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/clean/static.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/deep.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/dynamic.ts create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/helpers/Widget.test.jsx create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/local.ts create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/noise.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/notes.md create mode 100644 packages/repack/src/commands/federation/__fixtures__/features-store/widget.jsx create mode 100644 packages/repack/src/commands/federation/__tests__/scanFeatures.test.ts create mode 100644 packages/repack/src/commands/federation/scanFeatures.ts diff --git a/biome.jsonc b/biome.jsonc index 4eebd5f81..678d10048 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -13,7 +13,10 @@ // ESM redirect stubs in a commonjs package - biome 2 infers module type // from package.json and refuses to parse them as modules "!packages/repack/client/*.js", - "!packages/repack/mf/*.js" + "!packages/repack/mf/*.js", + // Scanner/config fixtures are literal INPUT data (fake imports, + // adversarial strings, throw-on-import configs): never checked + "!**/src/commands/federation/__fixtures__" ] }, "formatter": { diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/Screen.tsx b/packages/repack/src/commands/federation/__fixtures__/features-store/Screen.tsx new file mode 100644 index 000000000..331eda014 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/Screen.tsx @@ -0,0 +1,11 @@ +// Static collection happy path: imports, scoped package and a require. +import FlashList from '@shopify/flash-list'; +import React from 'react'; +import { StyleSheet, Text } from 'react-native'; + +const legacy = require('legacy-bridge'); + +export const styles = StyleSheet.create({ row: {} }); +export const list = FlashList; +export const bridge = legacy; +export const hello = React.createElement(Text, null, 'hi'); diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/__tests__/Screen.test.jsx b/packages/repack/src/commands/federation/__fixtures__/features-store/__tests__/Screen.test.jsx new file mode 100644 index 000000000..bd0ab22d5 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/__tests__/Screen.test.jsx @@ -0,0 +1,3 @@ +import testLib from 'test-only-pkg'; + +export const t = testLib; diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/clean/static.js b/packages/repack/src/commands/federation/__fixtures__/features-store/clean/static.js new file mode 100644 index 000000000..ee352c778 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/clean/static.js @@ -0,0 +1,4 @@ +// A folder with zero dynamic patterns must come back with zero advisories. +import moment from 'moment'; + +export const now = moment; diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/deep.js b/packages/repack/src/commands/federation/__fixtures__/features-store/deep.js new file mode 100644 index 000000000..c7c49e9f2 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/deep.js @@ -0,0 +1,7 @@ +// Subpath specifiers map to their package root; scoped packages keep two +// segments. +import mapValues from '@utils/collection/map'; +import merge from 'lodash/merge'; +import 'zone.js/dist/zone'; + +export const fns = [merge, mapValues]; diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/dynamic.ts b/packages/repack/src/commands/federation/__fixtures__/features-store/dynamic.ts new file mode 100644 index 000000000..5630c8c9d --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/dynamic.ts @@ -0,0 +1,17 @@ +// Every dynamic pattern the scanner cannot resolve must produce an +// honesty advisory naming this file and line — never a silent pass. +export async function loadLazy(name: string) { + return import('./lazy-' + name); +} + +export function computedDep(kind: string) { + return require('pkg-' + kind); +} + +export function templateDep(area: string) { + return require(`@geo/${area}-map`); +} + +export function literalDynamic() { + return import('some-async-pkg'); +} diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/helpers/Widget.test.jsx b/packages/repack/src/commands/federation/__fixtures__/features-store/helpers/Widget.test.jsx new file mode 100644 index 000000000..cfd453ecc --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/helpers/Widget.test.jsx @@ -0,0 +1,3 @@ +import testLib from 'test-only-pkg'; + +export const w = testLib; diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/local.ts b/packages/repack/src/commands/federation/__fixtures__/features-store/local.ts new file mode 100644 index 000000000..ccca279f3 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/local.ts @@ -0,0 +1,6 @@ +// Relative and alias imports are project-internal, never dependencies. +import data from '../data.json'; +import config from '@/config'; +import { helper } from './helper'; + +export const stuff = [helper, data, config]; diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/noise.js b/packages/repack/src/commands/federation/__fixtures__/features-store/noise.js new file mode 100644 index 000000000..eaafe738f --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/noise.js @@ -0,0 +1,18 @@ +// Adversarial noise: comments, strings and templates must NEVER yield +// dependencies. Only `real-lib` (and the ignored jsx-runtime import) exist +// for the scanner to see here. +// import 'commented-line-pkg' +/* import 'block-comment-pkg' + require('block-require-pkg') */ +import real from 'real-lib'; +import { jsx } from 'react/jsx-runtime'; + +const slogan = 'react'; +const tricky = 'from \'sneaky-quoted-pkg\''; +const inTemplate = ` + import fake from 'template-fake-pkg'; + require('template-require-fake-pkg'); +`; +const escaped = 'he said "import \'escaped-quoted-pkg\'" fine'; + +export const all = [real, jsx, slogan, tricky, inTemplate, escaped]; diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/notes.md b/packages/repack/src/commands/federation/__fixtures__/features-store/notes.md new file mode 100644 index 000000000..ac129779d --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/notes.md @@ -0,0 +1,5 @@ +# Notes + +```js +import 'md-pkg'; +``` diff --git a/packages/repack/src/commands/federation/__fixtures__/features-store/widget.jsx b/packages/repack/src/commands/federation/__fixtures__/features-store/widget.jsx new file mode 100644 index 000000000..18db2c3c0 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/features-store/widget.jsx @@ -0,0 +1,4 @@ +// .jsx must be scanned like the other supported extensions. +import { useState } from 'react'; + +export const counter = () => useState(0); diff --git a/packages/repack/src/commands/federation/__tests__/scanFeatures.test.ts b/packages/repack/src/commands/federation/__tests__/scanFeatures.test.ts new file mode 100644 index 000000000..ff1660f5a --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/scanFeatures.test.ts @@ -0,0 +1,101 @@ +import path from 'node:path'; +import { scanFeatureFolder } from '../scanFeatures.js'; + +const FEATURES = path.join(__dirname, '..', '__fixtures__', 'features-store'); + +describe('scanFeatureFolder', () => { + const result = scanFeatureFolder(FEATURES); + + it('collects static imports, bare imports, exports-from and requires', () => { + expect(result.dependencies).toEqual([ + '@shopify/flash-list', + '@utils/collection', + 'legacy-bridge', + 'lodash', + // clean/static.js — proves nested folders are walked + 'moment', + 'react', + 'react-native', + 'real-lib', + 'zone.js', + ]); + }); + + it('maps subpath specifiers to their package root', () => { + // deep.js declares lodash/merge, @utils/collection/map, zone.js/dist/* + expect(result.dependencies).toContain('lodash'); + expect(result.dependencies).toContain('@utils/collection'); + expect(result.dependencies).toContain('zone.js'); + expect(result.dependencies).not.toContain('lodash/merge'); + }); + + it('ignores react/jsx-runtime noise instead of listing it as a package', () => { + expect(result.dependencies).not.toContain('react/jsx-runtime'); + expect(result.dependencies).not.toContain('react/jsx-dev-runtime'); + }); + + it('never yields dependencies from comments', () => { + for (const fake of [ + 'commented-line-pkg', + 'block-comment-pkg', + 'block-require-pkg', + ]) { + expect(result.dependencies).not.toContain(fake); + } + }); + + it('never yields dependencies from string or template literal content', () => { + for (const fake of [ + 'sneaky-quoted-pkg', + 'template-fake-pkg', + 'template-require-fake-pkg', + 'escaped-quoted-pkg', + ]) { + expect(result.dependencies).not.toContain(fake); + } + }); + + it('filters relative and alias imports', () => { + expect(result.dependencies).not.toContain('.'); + expect(result.dependencies).not.toContain('./helper'); + expect(result.dependencies).not.toContain('../data.json'); + expect(result.dependencies).not.toContain('@/config'); + }); + + it('skips __tests__/ directories and *.test.* files', () => { + expect(result.dependencies).not.toContain('test-only-pkg'); + }); + + it('scans only supported source extensions', () => { + // notes.md contains an import-looking line — .md is never scanned. + expect(result.dependencies).not.toContain('md-pkg'); + }); + + it('advises on every dynamic pattern, naming file and line, never silently passing', () => { + // dynamic.ts lines: 4 import(, 8 computed require, 12 template require, + // 16 literal import() — exactly four advisories, one per pattern. + expect(result.advisories).toHaveLength(4); + const joined = result.advisories.join('\n'); + expect(joined).toContain('dynamic.ts:4'); + expect(joined).toContain('dynamic.ts:8'); + expect(joined).toContain('dynamic.ts:12'); + expect(joined).toContain('dynamic.ts:16'); + for (const advisory of result.advisories) { + expect(advisory).toContain('may be missing'); + } + }); + + it('does not claim dynamic-import dependencies were found', () => { + // The only static-ish package behind a dynamic pattern in the fixture: + expect(result.dependencies).not.toContain('some-async-pkg'); + }); + + it('reports zero advisories for a folder with no dynamic patterns', () => { + const clean = scanFeatureFolder(path.join(FEATURES, 'clean')); + + // Why empty here: clean/static.js has one static import and no dynamic + // pattern — the deps side proves the folder was really scanned. + expect(clean.dependencies).toEqual(['moment']); + expect(clean.advisories).toEqual([]); + }); +}); diff --git a/packages/repack/src/commands/federation/scanFeatures.ts b/packages/repack/src/commands/federation/scanFeatures.ts new file mode 100644 index 000000000..f25058118 --- /dev/null +++ b/packages/repack/src/commands/federation/scanFeatures.ts @@ -0,0 +1,259 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Static feature-folder scanner for `federation-init`. + * + * A regex/state-machine scanner on purpose: `@babel/parser` is not a runtime + * dependency of this package and adding one is out of scope. Its job is a + * dependency-NAME list, not semantics, which puts its weakness ceiling far + * below generic parsing — and every pattern it cannot resolve statically + * becomes an explicit honesty advisory (manifest `dynamicImportDetected` + * semantics): the scan never claims to be complete when dynamic patterns + * were seen. + */ +export interface FeatureScanResult { + /** Package names, deduped and sorted; relative/alias imports excluded. */ + dependencies: string[]; + /** Honesty advisories naming `file:line` for unresolvable patterns. */ + advisories: string[]; +} + +const SOURCE_EXTENSIONS = new Set([ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.cjs', +]); + +/** Noise specifiers skipped entirely (compiler artifacts, not user deps). */ +const IGNORED_PACKAGES = new Set([ + 'react/jsx-runtime', + 'react/jsx-dev-runtime', +]); + +/** + * The last significant token seen in code context (comments and string + * content never set it). Only `from`, a bare `import` and a `require(` + * open/close make a following string a dependency; `import(` and + * template-literal `require` are dynamic patterns. + */ +type Signal = + | '' + | 'other' + | 'import' + | 'from' + | 'require' + | 'import-paren' + | 'require-paren'; + +function collectSourceFiles(dir: string, found: string[]): void { + const entries = fs + .readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Documented skip: test folders are not feature surface. + if (entry.name === '__tests__') continue; + collectSourceFiles(full, found); + } else if ( + entry.isFile() && + SOURCE_EXTENSIONS.has(path.extname(entry.name)) && + // Documented skip: *.test.* files anywhere. + !entry.name.includes('.test.') + ) { + found.push(full); + } + } +} + +/** Map a bare-module specifier to its package name, or null if internal. */ +function packageOf(specifier: string): string | null { + if ( + specifier === '' || + specifier.startsWith('.') || + specifier.startsWith('/') || + // '@/' is a path alias, not an npm scope + specifier.startsWith('@/') || + IGNORED_PACKAGES.has(specifier) + ) { + return null; + } + const segments = specifier.split('/'); + if (specifier.startsWith('@')) { + return segments.length >= 2 + ? `${segments[0]}/${segments[1]}` + : (segments[0] ?? null); + } + return segments[0] ?? null; +} + +function advisory(relFile: string, line: number, what: string): string { + return ( + `${relFile}:${line} — ${what} cannot be resolved statically; ` + + 'dependencies behind it may be missing from the scanned set, so this ' + + 'dependency list is NOT exhaustive.' + ); +} + +function scanCode( + code: string, + relFile: string, + dependencies: Set, + advisories: string[] +): void { + let i = 0; + let line = 1; + let signal: Signal = ''; + + const skipWhitespace = (from: number) => { + let k = from; + while (k < code.length && /\s/.test(code[k])) k++; + return k; + }; + + while (i < code.length) { + const ch = code[i]; + + if (ch === '\n') { + line++; + i++; + continue; + } + if (ch === ' ' || ch === '\t' || ch === '\r') { + i++; + continue; + } + if (ch === '/' && code[i + 1] === '/') { + const nextLine = code.indexOf('\n', i); + i = nextLine === -1 ? code.length : nextLine; + continue; + } + if (ch === '/' && code[i + 1] === '*') { + const end = code.indexOf('*/', i + 2); + const stop = end === -1 ? code.length : end + 2; + for (let k = i; k < stop; k++) if (code[k] === '\n') line++; + i = stop; + continue; + } + + if (ch === '"' || ch === "'") { + const stringLine = line; + let j = i + 1; + let value = ''; + while (j < code.length) { + if (code[j] === '\\') { + // Escape tracking: `\"` inside a string is content, not a terminator. + value += code[j + 1] ?? ''; + j += 2; + continue; + } + if (code[j] === ch) break; + if (code[j] === '\n') { + // Unterminated string literal: recover at the newline. + line++; + } + value += code[j]; + j++; + } + i = j + 1; + + if (signal === 'from' || signal === 'import') { + const pkg = packageOf(value); + if (pkg) dependencies.add(pkg); + } else if (signal === 'require-paren') { + // Only require('lit') — anything before the closing paren is a + // computed require and gets the honesty advisory instead. + const after = skipWhitespace(i); + if (code[after] === ')') { + const pkg = packageOf(value); + if (pkg) dependencies.add(pkg); + } else { + advisories.push( + advisory( + relFile, + stringLine, + 'require() of a concatenated expression' + ) + ); + } + } + signal = ''; + continue; + } + + if (ch === '`') { + if (signal === 'require-paren') { + advisories.push( + advisory(relFile, line, 'require() of a template literal') + ); + } + let j = i + 1; + while (j < code.length) { + if (code[j] === '\\') { + j += 2; + continue; + } + if (code[j] === '`') break; + if (code[j] === '\n') line++; + j++; + } + i = j + 1; + signal = ''; + continue; + } + + if (/[A-Za-z_$]/.test(ch)) { + let j = i; + while (j < code.length && /[\w$]/.test(code[j])) j++; + const word = code.slice(i, j); + i = j; + signal = + word === 'import' || word === 'from' || word === 'require' + ? word + : 'other'; + continue; + } + + if (ch === '(') { + if (signal === 'import') { + // Any dynamic import() — even a literal one — may pull packages + // the static set cannot promise; report rather than collect. + advisories.push(advisory(relFile, line, 'dynamic import()')); + signal = 'import-paren'; + } else if (signal === 'require') { + signal = 'require-paren'; + } else { + signal = 'other'; + } + i++; + continue; + } + + signal = ''; + i++; + } +} + +/** + * Statically scan a feature folder and return its dependency-name set plus + * one honesty advisory per unresolvable dynamic pattern. Scans + * `.js .jsx .ts .tsx .mjs .cjs`, skipping `__tests__/` directories and + * `*.test.*` files (documented behavior). + */ +export function scanFeatureFolder(rootDir: string): FeatureScanResult { + const files: string[] = []; + collectSourceFiles(rootDir, files); + + const dependencies = new Set(); + const advisories: string[] = []; + for (const file of files) { + const rel = path.relative(rootDir, file).split(path.sep).join('/'); + scanCode(fs.readFileSync(file, 'utf-8'), rel, dependencies, advisories); + } + + return { dependencies: [...dependencies].sort(), advisories }; +} diff --git a/packages/repack/tsconfig.build.json b/packages/repack/tsconfig.build.json index 70bc0661f..146453456 100644 --- a/packages/repack/tsconfig.build.json +++ b/packages/repack/tsconfig.build.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.json", "include": ["src/**/*"], - "exclude": ["**/__tests__/**"], + // Capability fixtures are scanner/config input data, not shipped code + "exclude": ["**/__tests__/**", "src/commands/federation/__fixtures__/**"], "compilerOptions": { "outDir": "dist", "rootDir": "src", diff --git a/packages/repack/tsconfig.json b/packages/repack/tsconfig.json index 15125145d..7a017a48e 100644 --- a/packages/repack/tsconfig.json +++ b/packages/repack/tsconfig.json @@ -8,5 +8,9 @@ "@callstack/repack-dev-server/*": ["../dev-server/src/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*"], + // Capability fixtures are scanner/evaluation INPUT data (stub configs, + // fake feature sources), not project code: they import packages that do + // not exist and must keep their literal shape — never typechecked. + "exclude": ["src/commands/federation/__fixtures__/**"] } From c6645310e93b8bd157e1926ce90dbbf30f6c17e2 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 08:42:43 +0200 Subject: [PATCH 15/19] feat(repack): add federation-init plan engine (idempotent, diff-before-write) with prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeInitPlan computes everything federation-init would write without writing it: key-level merges over package.json / repack-federation.json / host remotes surgery (anchored, manual-steps degradation), create-only versionless rspack+webpack configs consuming defineShared with the remote role and env.argv-derived mode, host-vs-remote divergence and --yes pin-rewrite plans. applyPlan writes exactly plan.files; prompt helpers (apply [y/N], divergence [a]lign/[i]gnore/[c]ancel) take an injected ask. Evidence: - Focused: pnpm --filter @callstack/repack test -- initPlan initApply diff -> 40 passed (3 suites); full suite 54 suites / 587 tests green. - Gates: pnpm typecheck and pnpm lint:ci clean on this tree. - Runtime harness: N/A at unit level — pure plan/merge/diff engine; the real end-to-end run lands with the flat command into a scratch copy of tester-federation (task 10.1 harness). - Rollback: revert this commit — init/{plan,merge,templates,diff,apply, prompt}.ts, the three suites and the init-workspace fixtures are new files; nothing imports them yet (scanner untouched, no command wired). --- .gitignore | 2 + .../@shopify/flash-list/package.json | 1 + .../node_modules/react-native/package.json | 1 + .../apps/host/node_modules/react/package.json | 1 + .../init-workspace/apps/host/package.json | 10 + .../init-workspace/apps/host/rspack.config.js | 37 ++ .../node_modules/react-native/package.json | 1 + .../node_modules/react/package.json | 1 + .../apps/remote-drift/package.json | 8 + .../init-workspace/features/store/index.tsx | 16 + .../init-workspace/repack-federation.json | 9 + .../federation/__tests__/diff.test.ts | 87 ++++ .../federation/__tests__/initApply.test.ts | 245 +++++++++ .../federation/__tests__/initPlan.test.ts | 475 +++++++++++++++++ .../src/commands/federation/init/apply.ts | 40 ++ .../src/commands/federation/init/diff.ts | 109 ++++ .../src/commands/federation/init/merge.ts | 484 ++++++++++++++++++ .../src/commands/federation/init/plan.ts | 361 +++++++++++++ .../src/commands/federation/init/prompt.ts | 58 +++ .../src/commands/federation/init/templates.ts | 74 +++ 20 files changed, 2020 insertions(+) create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/@shopify/flash-list/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react-native/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/rspack.config.js create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react-native/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/features/store/index.tsx create mode 100644 packages/repack/src/commands/federation/__fixtures__/init-workspace/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/diff.test.ts create mode 100644 packages/repack/src/commands/federation/__tests__/initApply.test.ts create mode 100644 packages/repack/src/commands/federation/__tests__/initPlan.test.ts create mode 100644 packages/repack/src/commands/federation/init/apply.ts create mode 100644 packages/repack/src/commands/federation/init/diff.ts create mode 100644 packages/repack/src/commands/federation/init/merge.ts create mode 100644 packages/repack/src/commands/federation/init/plan.ts create mode 100644 packages/repack/src/commands/federation/init/prompt.ts create mode 100644 packages/repack/src/commands/federation/init/templates.ts diff --git a/.gitignore b/.gitignore index 156c0bf60..71a5e7340 100644 --- a/.gitignore +++ b/.gitignore @@ -397,3 +397,5 @@ packages/**/docs !packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/host/node_modules/ !packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-clean/node_modules/ !packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-drift/node_modules/ +!packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/ +!packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/ diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/@shopify/flash-list/package.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/@shopify/flash-list/package.json new file mode 100644 index 000000000..56ffa42d1 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/@shopify/flash-list/package.json @@ -0,0 +1 @@ +{ "name": "@shopify/flash-list", "version": "9.9.9" } diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react-native/package.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react-native/package.json new file mode 100644 index 000000000..c99ca848b --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react-native/package.json @@ -0,0 +1 @@ +{ "name": "react-native", "version": "9.9.9" } diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react/package.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react/package.json new file mode 100644 index 000000000..ed0f702f9 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/node_modules/react/package.json @@ -0,0 +1 @@ +{ "name": "react", "version": "9.9.9" } diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/package.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/package.json new file mode 100644 index 000000000..9e8456d53 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/package.json @@ -0,0 +1,10 @@ +{ + "name": "host-app", + "version": "0.0.0", + "dependencies": { + "react": "9.9.9", + "react-native": "9.9.9", + "left-pad": "1.3.3", + "@shopify/flash-list": "2.0.0" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/rspack.config.js b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/rspack.config.js new file mode 100644 index 000000000..fc5836afe --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/host/rspack.config.js @@ -0,0 +1,37 @@ +// Host bundler config stub for federation-init plan tests. +// Carries a real `remotes: { ... }` block so `ensureRemotesEntry` has an +// anchor to surgically extend, and the plugin shape `extractAppShared` +// duck-types for shared extraction / plugin-version mirroring. +class RepackPlugin { + constructor(config) { + this.config = config; + } +} + +class ModuleFederationPluginV1 { + constructor(config) { + this.config = config; + } + + getSharedConfiguration() { + return this.config.shared; + } +} + +module.exports = () => ({ + plugins: [ + new RepackPlugin({}), + new ModuleFederationPluginV1({ + name: 'HostApp', + remotes: { + 'remote-drift': 'remote-drift@remote-drift/remoteEntry.js', + }, + shared: { + react: { singleton: true, eager: true }, + 'react-native': { singleton: true, eager: true }, + '@shopify/flash-list': { singleton: true, eager: true }, + 'left-pad': { singleton: true, eager: true }, + }, + }), + ], +}); diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react-native/package.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react-native/package.json new file mode 100644 index 000000000..c99ca848b --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react-native/package.json @@ -0,0 +1 @@ +{ "name": "react-native", "version": "9.9.9" } diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react/package.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react/package.json new file mode 100644 index 000000000..be1719f66 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/node_modules/react/package.json @@ -0,0 +1 @@ +{ "name": "react", "version": "9.8.7" } diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/package.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/package.json new file mode 100644 index 000000000..216441e41 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/apps/remote-drift/package.json @@ -0,0 +1,8 @@ +{ + "name": "remote-drift-app", + "version": "0.0.0", + "dependencies": { + "react": "^9.8.7", + "react-native": "9.9.9" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/features/store/index.tsx b/packages/repack/src/commands/federation/__fixtures__/init-workspace/features/store/index.tsx new file mode 100644 index 000000000..8af1fad46 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/features/store/index.tsx @@ -0,0 +1,16 @@ +// Feature folder for federation-init scan tests: static imports only here. +import React from 'react'; +import { View } from 'react-native'; +import { FlashList } from '@shopify/flash-list'; +import { formatPrice } from './price'; + +export function StoreScreen({ products }) { + return ( + + {formatPrice(item.price)}} + /> + + ); +} diff --git a/packages/repack/src/commands/federation/__fixtures__/init-workspace/repack-federation.json b/packages/repack/src/commands/federation/__fixtures__/init-workspace/repack-federation.json new file mode 100644 index 000000000..f9fe7ab08 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/init-workspace/repack-federation.json @@ -0,0 +1,9 @@ +{ + "host": { "manifest": "apps/host/build", "root": "apps/host" }, + "remotes": { + "remote-drift": { + "manifest": "apps/remote-drift/build", + "root": "apps/remote-drift" + } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/diff.test.ts b/packages/repack/src/commands/federation/__tests__/diff.test.ts new file mode 100644 index 000000000..98d4ae00f --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/diff.test.ts @@ -0,0 +1,87 @@ +import { diffLines, formatFileDiff } from '../init/diff.js'; + +describe('diffLines — LCS correctness', () => { + it('reports pure appends', () => { + expect(diffLines('a\nb', 'a\nb\nc')).toEqual([ + { kind: 'context', text: 'a' }, + { kind: 'context', text: 'b' }, + { kind: 'add', text: 'c' }, + ]); + }); + + it('reports pure removals', () => { + expect(diffLines('a\nb\nc', 'a\nc')).toEqual([ + { kind: 'context', text: 'a' }, + { kind: 'remove', text: 'b' }, + { kind: 'context', text: 'c' }, + ]); + }); + + it('reports a replacement as remove followed by add', () => { + expect(diffLines('a\nx\nc', 'a\ny\nc')).toEqual([ + { kind: 'context', text: 'a' }, + { kind: 'remove', text: 'x' }, + { kind: 'add', text: 'y' }, + { kind: 'context', text: 'c' }, + ]); + }); + + it('keeps LCS alignment on an interleaved case', () => { + const lines = diffLines('a\nb\nc\nd', 'b\nc\ne\nd'); + expect(lines).toEqual([ + { kind: 'remove', text: 'a' }, + { kind: 'context', text: 'b' }, + { kind: 'context', text: 'c' }, + { kind: 'add', text: 'e' }, + { kind: 'context', text: 'd' }, + ]); + // Reconstruction contract: contexts+adds rebuild b; contexts+removes rebuild a. + expect( + lines + .filter((l) => l.kind !== 'remove') + .map((l) => l.text) + .join('\n') + ).toBe('b\nc\ne\nd'); + expect( + lines + .filter((l) => l.kind !== 'add') + .map((l) => l.text) + .join('\n') + ).toBe('a\nb\nc\nd'); + }); + + it('treats identical content as all context', () => { + expect( + diffLines('same\ncontent', 'same\ncontent').every( + (l) => l.kind === 'context' + ) + ).toBe(true); + }); +}); + +describe('formatFileDiff', () => { + it('renders new files as full content with a (new file) header', () => { + const rendered = formatFileDiff('remote/rspack.store.mts', null, 'a\nb'); + expect(rendered).toContain('(new file)'); + expect(rendered).toContain('remote/rspack.store.mts'); + expect(rendered).toContain('a'); + expect(rendered).toContain('b'); + }); + + it('renders modified files with - / + prefixes', () => { + const rendered = formatFileDiff('package.json', 'a\nold\nc', 'a\nnew\nc'); + expect(rendered).toContain('- old'); + expect(rendered).toContain('+ new'); + }); + + it('trims unchanged runs beyond three context lines', () => { + const before = Array.from({ length: 30 }, (_, i) => `line${i}`).join('\n'); + const after = before.replace('line15', 'CHANGED'); + const rendered = formatFileDiff('big.txt', before, after); + expect(rendered).toContain('+ CHANGED'); + expect(rendered).toContain('line12'); + expect(rendered).toContain('line18'); + expect(rendered).not.toContain('line2'); + expect(rendered).not.toContain('line25'); + }); +}); diff --git a/packages/repack/src/commands/federation/__tests__/initApply.test.ts b/packages/repack/src/commands/federation/__tests__/initApply.test.ts new file mode 100644 index 000000000..416e2f02d --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/initApply.test.ts @@ -0,0 +1,245 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { applyPlan, printAlignmentReport } from '../init/apply.js'; +import type { InitPlan } from '../init/plan.js'; +import { computeInitPlan, formatPlanDiff } from '../init/plan.js'; +import { askApplyChanges, askDivergenceAction } from '../init/prompt.js'; + +const FIXTURE = path.join(__dirname, '..', '__fixtures__', 'init-workspace'); + +function baseInput(root: string, overrides: Record = {}) { + return { + workspaceRoot: root, + remoteName: 'store', + remoteRoot: path.join(root, 'remotes', 'store'), + featureFolder: path.join(root, 'features', 'store'), + hostRoot: path.join(root, 'apps', 'host'), + hostConfigPath: path.join(root, 'apps', 'host', 'rspack.config.js'), + scannedDependencies: ['left-pad', 'react', 'react-native'], + scannedAdvisories: [], + hostSharedProvides: ['left-pad', 'react', 'react-native'], + pluginVersion: 'V1' as const, + existingRemotes: [ + { name: 'remote-drift', root: path.join(root, 'apps', 'remote-drift') }, + ], + ...overrides, + }; +} + +function copyWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-init-apply-')); + fs.cpSync(FIXTURE, dir, { recursive: true }); + return dir; +} + +function hashTree(dir: string): Map { + const files = new Map(); + const walk = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) walk(full); + else files.set(full, fs.readFileSync(full, 'utf-8')); + } + }; + walk(dir); + return files; +} + +function fakeAsk(answers: string[]) { + let index = 0; + const questions: string[] = []; + const ask = async (question: string) => { + questions.push(question); + return answers[index++] ?? ''; + }; + return { ask, questions }; +} + +let tmp: string; + +beforeEach(() => { + tmp = copyWorkspace(); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('applyPlan — writes exactly the plan, after confirmation', () => { + it('writes exactly plan.files and nothing else', () => { + const plan = computeInitPlan(baseInput(tmp)); + const before = hashTree(tmp); + + applyPlan(plan); + + const after = hashTree(tmp); + const planned = new Set(plan.files.map((file) => path.resolve(file.path))); + for (const [file, content] of before) { + if (!planned.has(path.resolve(file))) { + expect(after.get(file)).toBe(content); + } + } + for (const file of plan.files) { + expect(after.get(path.resolve(file.path))).toBe(file.after); + } + // Nothing appeared outside the plan. + const additions = [...after.keys()].filter( + (f) => !before.has(f) && !planned.has(path.resolve(f)) + ); + expect(additions).toEqual([]); + expect(plan.files.length).toBeGreaterThan(0); + }); + + it('materializes both bundler configs on disk, versionless, mirroring the host plugin', () => { + applyPlan(computeInitPlan(baseInput(tmp))); + + for (const bundler of ['rspack', 'webpack']) { + const configPath = path.join( + tmp, + 'remotes', + 'store', + `${bundler}.store.mts` + ); + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('Repack.defineShared(SHARED_DEPS'); + expect(content).toContain("role: 'remote'"); + expect(content).toContain( + "mode: env.argv?.standalone ? 'standalone' : 'federated'" + ); + expect(content).toContain('ModuleFederationPluginV1'); + // Versionless assertion on the file as written. + expect(content).not.toMatch(/["'][0-9]+\.[0-9]+\.[0-9]+["']/); + } + }); + + it('is idempotent through the real write path', () => { + applyPlan(computeInitPlan(baseInput(tmp))); + const afterFirst = hashTree(tmp); + + const second = computeInitPlan(baseInput(tmp)); + expect(second.files).toEqual([]); + applyPlan(second); + + const afterSecond = hashTree(tmp); + expect([...afterSecond.keys()].sort()).toEqual( + [...afterFirst.keys()].sort() + ); + for (const [file, content] of afterFirst) { + expect(afterSecond.get(file)).toBe(content); + } + }); + + it('apply of an align plan rewrites remote pins on disk', () => { + const plan = computeInitPlan(baseInput(tmp, { align: true })); + applyPlan(plan); + + const drift = JSON.parse( + fs.readFileSync( + path.join(tmp, 'apps', 'remote-drift', 'package.json'), + 'utf-8' + ) + ) as { dependencies: Record }; + expect(drift.dependencies.react).toBe('9.9.9'); + }); + + it('declining the confirmation means apply is never called and the tree is untouched', async () => { + const plan = computeInitPlan(baseInput(tmp)); + const before = hashTree(tmp); + + const { ask } = fakeAsk(['n']); + const confirmed = await askApplyChanges(ask, plan.files.length); + + // The command only calls applyPlan when confirmed — the engine contract: + // a decline produces no writes at all. + expect(confirmed).toBe(false); + const after = hashTree(tmp); + for (const [file, content] of before) { + expect(after.get(file)).toBe(content); + } + }); +}); + +describe('prompt decisions — injected readline stubs', () => { + it('apply confirms only on y/yes and defaults to N', async () => { + await expect(askApplyChanges(fakeAsk(['y']).ask, 3)).resolves.toBe(true); + await expect(askApplyChanges(fakeAsk(['Y']).ask, 3)).resolves.toBe(true); + await expect(askApplyChanges(fakeAsk(['yes']).ask, 3)).resolves.toBe(true); + await expect(askApplyChanges(fakeAsk(['']).ask, 3)).resolves.toBe(false); + await expect(askApplyChanges(fakeAsk(['n']).ask, 3)).resolves.toBe(false); + await expect(askApplyChanges(fakeAsk(['sure!']).ask, 3)).resolves.toBe( + false + ); + }); + + it('apply question names the file count and the [y/N] default', async () => { + const { ask, questions } = fakeAsk(['y']); + await askApplyChanges(ask, 4); + expect(questions[0]).toContain('4'); + expect(questions[0]).toContain('[y/N]'); + }); + + it('divergence maps a/i/c and cancels on anything else', async () => { + await expect(askDivergenceAction(fakeAsk(['a']).ask)).resolves.toBe( + 'align' + ); + await expect(askDivergenceAction(fakeAsk(['A']).ask)).resolves.toBe( + 'align' + ); + await expect(askDivergenceAction(fakeAsk(['i']).ask)).resolves.toBe( + 'ignore' + ); + await expect(askDivergenceAction(fakeAsk(['c']).ask)).resolves.toBe( + 'cancel' + ); + // An empty answer waits on a human — the safe default is cancel. + await expect(askDivergenceAction(fakeAsk(['']).ask)).resolves.toBe( + 'cancel' + ); + await expect(askDivergenceAction(fakeAsk(['wat']).ask)).resolves.toBe( + 'cancel' + ); + }); +}); + +describe('--yes reporting', () => { + it('prints pkg: old → new per aligned pin plus the install instruction', () => { + const plan = computeInitPlan(baseInput(tmp, { align: true })); + const lines: string[] = []; + printAlignmentReport(plan, (line) => lines.push(line)); + + expect(lines.some((line) => line.includes('react: ^9.8.7 → 9.9.9'))).toBe( + true + ); + expect(lines.join('\n')).toMatch(/run your package manager install/i); + }); + + it('prints nothing when there was nothing to align', () => { + const plan = computeInitPlan(baseInput(tmp, { align: true })); + const empty: InitPlan = { ...plan, alignment: [] }; + const lines: string[] = []; + printAlignmentReport(empty, (line) => lines.push(line)); + expect(lines).toEqual([]); + }); +}); + +describe('formatPlanDiff', () => { + it('names every planned file and the manual/advisory sections', () => { + const plan = computeInitPlan( + baseInput(tmp, { + // Force a manual step: point the surgery at a non-existent config. + hostConfigPath: path.join(tmp, 'apps', 'host', 'nope.rspack.config.js'), + scannedAdvisories: ['lazy.tsx:3 — dynamic import()'], + }) + ); + const rendered = formatPlanDiff(plan); + + expect(rendered).toContain('remotes/store/package.json'); + expect(rendered).toContain('Manual steps:'); + expect(rendered).toContain('Advisories:'); + expect(rendered).toContain('lazy.tsx:3 — dynamic import()'); + expect(rendered).toContain( + 'remote-drift: react: remote 9.8.7 vs host 9.9.9' + ); + }); +}); diff --git a/packages/repack/src/commands/federation/__tests__/initPlan.test.ts b/packages/repack/src/commands/federation/__tests__/initPlan.test.ts new file mode 100644 index 000000000..128b95a28 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/initPlan.test.ts @@ -0,0 +1,475 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ensureRemotesEntry } from '../init/merge.js'; +import { computeInitPlan } from '../init/plan.js'; + +const FIXTURE = path.join(__dirname, '..', '__fixtures__', 'init-workspace'); + +interface PlanLike { + files: Array<{ path: string; before: string | null; after: string }>; + manualSteps: string[]; + advisories: string[]; + divergence: Array<{ + remote: string; + pkg: string; + hostVersion: string; + remoteVersion: string; + }>; + alignment: Array<{ + remote: string; + pkg: string; + from: string; + to: string; + }>; +} + +function baseInput(root: string, overrides: Record = {}) { + return { + workspaceRoot: root, + remoteName: 'store', + remoteRoot: path.join(root, 'remotes', 'store'), + featureFolder: path.join(root, 'features', 'store'), + hostRoot: path.join(root, 'apps', 'host'), + hostConfigPath: path.join(root, 'apps', 'host', 'rspack.config.js'), + scannedDependencies: [ + '@shopify/flash-list', + 'react', + 'left-pad', + 'react-native', + 'zustand', + ], + scannedAdvisories: [], + hostSharedProvides: [ + '@shopify/flash-list', + 'react', + 'left-pad', + 'react-native', + ], + pluginVersion: 'V1' as const, + existingRemotes: [ + { name: 'remote-drift', root: path.join(root, 'apps', 'remote-drift') }, + ], + ...overrides, + }; +} + +function copyWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-init-')); + fs.cpSync(FIXTURE, dir, { recursive: true }); + return dir; +} + +function applyManually(plan: PlanLike): void { + for (const file of plan.files) { + fs.mkdirSync(path.dirname(file.path), { recursive: true }); + fs.writeFileSync(file.path, file.after); + } +} + +function fileFor(root: string, rel: string, plan: PlanLike) { + const abs = path.resolve(root, rel); + return plan.files.find((file) => path.resolve(file.path) === abs); +} + +let tmp: string; + +beforeEach(() => { + tmp = copyWorkspace(); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('computeInitPlan — fresh scaffold', () => { + it('plans all three registration surfaces plus both bundler configs', () => { + const plan = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + + expect(fileFor(tmp, 'remotes/store/package.json', plan)).toMatchObject({ + before: null, + }); + expect(fileFor(tmp, 'remotes/store/rspack.store.mts', plan)?.before).toBe( + null + ); + expect(fileFor(tmp, 'remotes/store/webpack.store.mts', plan)?.before).toBe( + null + ); + // Host surgery and workspace-map registration carry before content. + expect(fileFor(tmp, 'apps/host/rspack.config.js', plan)?.before).toContain( + 'remotes: {' + ); + expect(fileFor(tmp, 'repack-federation.json', plan)?.before).toContain( + 'remote-drift' + ); + }); + + it('remote package.json deps are scan ∩ host provides at host versions', () => { + const plan = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + const pkg = fileFor(tmp, 'remotes/store/package.json', plan); + const written = JSON.parse(pkg?.after ?? '{}') as { + name: string; + dependencies: Record; + }; + + // Installed pin wins over the declared range (flash-list declares 2.0.0, + // installs 9.9.9); declared version is the fallback when the package is + // not resolvable (left-pad → declared 1.3.3). + expect(written.dependencies).toEqual({ + '@shopify/flash-list': '9.9.9', + react: '9.9.9', + 'left-pad': '1.3.3', + 'react-native': '9.9.9', + }); + expect(written.name).toBe('store'); + }); + + it('advises about scanned deps the host does not share instead of writing them', () => { + const plan = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + const pkg = fileFor(tmp, 'remotes/store/package.json', plan); + + expect(pkg?.after).not.toContain('zustand'); + expect(plan.advisories.some((a) => a.includes('"zustand"'))).toBe(true); + }); + + it('registers the remote in repack-federation.json without touching other entries', () => { + const plan = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + const config = fileFor(tmp, 'repack-federation.json', plan); + const written = JSON.parse(config?.after ?? '{}') as { + remotes: Record>; + }; + + expect(written.remotes.store).toEqual({ + manifest: 'remotes/store/build', + root: 'remotes/store', + }); + expect(written.remotes['remote-drift']).toEqual({ + manifest: 'apps/remote-drift/build', + root: 'apps/remote-drift', + }); + }); + + it('carries standalone: true into the new workspace entry when requested', () => { + const plan = computeInitPlan( + baseInput(tmp, { standalone: true }) + ) as unknown as PlanLike; + const config = fileFor(tmp, 'repack-federation.json', plan); + const written = JSON.parse(config?.after ?? '{}') as { + remotes: Record>; + }; + + expect(written.remotes.store.standalone).toBe(true); + }); + + it('surgically extends the host remotes block, keeping manual content', () => { + const plan = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + const host = fileFor(tmp, 'apps/host/rspack.config.js', plan); + + expect(host?.after).toContain("'store': 'store@store/remoteEntry.js'"); + expect(host?.after).toContain( + "'remote-drift': 'remote-drift@remote-drift/remoteEntry.js'" + ); + }); + + it('produces versionless configs for both bundlers with the remote role and derived mode', () => { + const plan = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + const rspack = fileFor(tmp, 'remotes/store/rspack.store.mts', plan)?.after; + const webpack = fileFor( + tmp, + 'remotes/store/webpack.store.mts', + plan + )?.after; + + for (const content of [rspack, webpack]) { + expect(content).toBeDefined(); + expect(content).toContain('Repack.defineShared(SHARED_DEPS'); + expect(content).toContain("role: 'remote'"); + expect(content).toContain( + "mode: env.argv?.standalone ? 'standalone' : 'federated'" + ); + expect(content).toContain('const SHARED_DEPS = ['); + // Versionless assertion: no x.y.z pin anywhere in the generated config. + expect(content).not.toMatch(/["'][0-9]+\.[0-9]+\.[0-9]+["']/); + } + expect(rspack).toContain('defineRspackConfig'); + expect(webpack).toContain('defineWebpackConfig'); + }); + + it('mirrors the host plugin version into the generated configs', () => { + const plan = computeInitPlan( + baseInput(tmp, { pluginVersion: 'V2' }) + ) as unknown as PlanLike; + + const rspack = fileFor(tmp, 'remotes/store/rspack.store.mts', plan)?.after; + const webpack = fileFor( + tmp, + 'remotes/store/webpack.store.mts', + plan + )?.after; + expect(rspack).toContain('ModuleFederationPluginV2'); + expect(rspack).not.toContain('ModuleFederationPluginV1'); + expect(webpack).toContain('ModuleFederationPluginV2'); + }); + + it('carries scanner advisories into the plan', () => { + const plan = computeInitPlan( + baseInput(tmp, { + scannedAdvisories: [ + 'index.tsx:7 — dynamic import() cannot be resolved', + ], + }) + ) as unknown as PlanLike; + + expect(plan.advisories).toContainEqual( + 'index.tsx:7 — dynamic import() cannot be resolved' + ); + }); +}); + +describe('computeInitPlan — idempotent, edit-safe re-runs', () => { + it('re-runs on the post-apply state with an empty file list', () => { + const first = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + applyManually(first); + + const second = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + + expect(second.files).toEqual([]); + expect(second.manualSteps).toEqual([]); + }); + + it('never duplicates keys when a merge is forced over post-apply content', () => { + const first = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + applyManually(first); + + const after = fs.readFileSync( + path.join(tmp, 'repack-federation.json'), + 'utf-8' + ); + // The name appears exactly once as a key in the merged document. + expect(after.match(/"store":/g)).toHaveLength(1); + const hostAfter = fs.readFileSync( + path.join(tmp, 'apps', 'host', 'rspack.config.js'), + 'utf-8' + ); + expect(hostAfter.match(/'store':/g)).toHaveLength(1); + }); + + it('keeps manual edits byte-present and proposes only genuinely missing additions', () => { + const first = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + applyManually(first); + + // Manual edits a human might actually make: + const pkgPath = path.join(tmp, 'remotes', 'store', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { + dependencies: Record; + }; + pkg.dependencies.nanoid = '5.0.0'; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + + const hostPath = path.join(tmp, 'apps', 'host', 'rspack.config.js'); + const hostSource = fs.readFileSync(hostPath, 'utf-8'); + fs.writeFileSync( + hostPath, + hostSource.replace( + "'store': 'store@store/remoteEntry.js',", + "'store': 'store@store/remoteEntry.js',\n 'catalog': 'catalog@catalog/remoteEntry.js'," + ) + ); + + const second = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + expect(second.files).toEqual([]); + + const keptPkg = fs.readFileSync(pkgPath, 'utf-8'); + expect(keptPkg).toContain('"nanoid": "5.0.0"'); + const keptHost = fs.readFileSync(hostPath, 'utf-8'); + expect(keptHost).toContain("'catalog': 'catalog@catalog/remoteEntry.js'"); + }); + + it('treats an existing remote config as create-only and advises about missing deps', () => { + const first = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + applyManually(first); + + // A manually added dependency appears in the scan but not the config. + const second = computeInitPlan( + baseInput(tmp, { + scannedDependencies: [ + '@shopify/flash-list', + 'react', + 'left-pad', + 'react-native', + 'zustand', + ], + hostSharedProvides: [ + '@shopify/flash-list', + 'react', + 'left-pad', + 'react-native', + ], + }) + ) as unknown as PlanLike; + + expect( + fileFor(tmp, 'remotes/store/rspack.store.mts', second) + ).toBeUndefined(); + expect( + fileFor(tmp, 'remotes/store/webpack.store.mts', second) + ).toBeUndefined(); + + // Now manually remove left-pad from the generated SHARED_DEPS list: + const cfgPath = path.join(tmp, 'remotes', 'store', 'rspack.store.mts'); + const cfg = fs.readFileSync(cfgPath, 'utf-8'); + fs.writeFileSync(cfgPath, cfg.replace(" 'left-pad',\n", '')); + + const third = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + expect( + fileFor(tmp, 'remotes/store/rspack.store.mts', third) + ).toBeUndefined(); + expect( + third.advisories.some( + (a) => a.includes('rspack.store.mts') && a.includes('left-pad') + ) + ).toBe(true); + }); +}); + +describe('computeInitPlan — divergence', () => { + it('computes host-vs-existing-remote installed-version divergence', () => { + const plan = computeInitPlan(baseInput(tmp)) as unknown as PlanLike; + + // react: host 9.9.9 vs remote-drift 9.8.7. react-native resolves to + // 9.9.9 on both sides, so no divergence may be claimed for it. + expect(plan.divergence).toEqual([ + { + remote: 'remote-drift', + pkg: 'react', + hostVersion: '9.9.9', + remoteVersion: '9.8.7', + }, + ]); + // Without --yes alignment is a question, not a write. + expect( + fileFor(tmp, 'apps/remote-drift/package.json', plan) + ).toBeUndefined(); + expect(plan.alignment).toEqual([]); + }); + + it('align=true rewrites divergent remote pins to exact host versions with an old→new report', () => { + const plan = computeInitPlan( + baseInput(tmp, { align: true }) + ) as unknown as PlanLike; + const drift = fileFor(tmp, 'apps/remote-drift/package.json', plan); + + expect(drift).toBeDefined(); + const written = JSON.parse(drift?.after ?? '{}') as { + dependencies: Record; + }; + // Exact installed host version, no caret range. + expect(written.dependencies.react).toBe('9.9.9'); + // Untouched deps survive the pin rewrite. + expect(written.dependencies['react-native']).toBe('9.9.9'); + expect(plan.alignment).toEqual([ + { remote: 'remote-drift', pkg: 'react', from: '^9.8.7', to: '9.9.9' }, + ]); + }); +}); + +describe('ensureRemotesEntry — host surgery anchors', () => { + const pluginBlock = (options: string) => ` +export default () => ({ + plugins: [ + new Repack.plugins.ModuleFederationPluginV1({ +${options} + }), + ], +}); +`; + + it('adds the key to an existing remotes block', () => { + const source = pluginBlock(` name: 'HostApp', + remotes: { + 'a': 'a@a/remoteEntry.js', + },`); + const result = ensureRemotesEntry( + source, + 'store', + 'store@store/remoteEntry.js' + ); + + expect(result.changed).toBe(true); + expect(result.after).toContain("'store': 'store@store/remoteEntry.js'"); + expect(result.after).toContain("'a': 'a@a/remoteEntry.js'"); + // Idempotent: a second run finds the key and changes nothing. + const again = ensureRemotesEntry( + result.after as string, + 'store', + 'store@store/remoteEntry.js' + ); + expect(again.changed).toBe(false); + expect(again.after).toBe(result.after); + }); + + it('inserts a remotes property after name: when none exists', () => { + const source = pluginBlock(` name: 'HostApp', + shared: {},`); + const result = ensureRemotesEntry( + source, + 'store', + 'store@store/remoteEntry.js' + ); + + expect(result.changed).toBe(true); + expect(result.after).toContain('remotes: {'); + expect(result.after).toContain("'store': 'store@store/remoteEntry.js'"); + // The insertion point is anchored after the name property. + const after = result.after as string; + expect(after.indexOf('name:')).toBeLessThan(after.indexOf('remotes: {')); + expect(after.indexOf('remotes: {')).toBeLessThan( + after.indexOf('shared: {}') + ); + }); + + it('degrades to a manual step when no plugin block can be anchored', () => { + const noPlugin = 'export default () => ({ plugins: [] });'; + const result = ensureRemotesEntry(noPlugin, 'store', 'x'); + + expect(result.changed).toBe(false); + expect(result.manualStep).toContain('remotes'); + expect(result.after ?? noPlugin).toBe(noPlugin); + }); + + it('degrades to a manual step when two plugin instances make the anchor ambiguous', () => { + const source = + pluginBlock(` name: 'HostApp',`) + + pluginBlock(` name: 'OtherApp',`); + const result = ensureRemotesEntry(source, 'store', 'x'); + + expect(result.changed).toBe(false); + expect(result.manualStep).toMatch(/two|multiple/i); + }); + + it('degrades to a manual step when the options object has neither remotes nor name', () => { + const source = pluginBlock(` shared: {},`); + const result = ensureRemotesEntry(source, 'store', 'x'); + + expect(result.changed).toBe(false); + expect(result.manualStep).toBeDefined(); + }); + + it('never anchors on the word remotes inside a string value', () => { + const source = pluginBlock(` name: 'HostApp', + // remotes: { inside a comment } + shared: { 'remotes-pkg': {} },`); + const result = ensureRemotesEntry( + source, + 'store', + 'store@store/remoteEntry.js' + ); + + expect(result.changed).toBe(true); + const after = result.after as string; + // The insertion went after name:, not into the comment or the shared map. + expect(after.indexOf('remotes: {')).toBeLessThan( + after.indexOf("shared: { 'remotes-pkg'") + ); + }); +}); diff --git a/packages/repack/src/commands/federation/init/apply.ts b/packages/repack/src/commands/federation/init/apply.ts new file mode 100644 index 000000000..84a7a28fa --- /dev/null +++ b/packages/repack/src/commands/federation/init/apply.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { InitPlan } from './plan.js'; + +/** + * Thin writer over a computed plan. Nothing here decides *whether* to write + * — the diff-before-write gate lives in the command; this runs only after + * confirmation (or `--yes`). Writes exactly `plan.files`, never anything + * else, so the plan's before/after content is the complete story of what + * lands on disk. + */ +export function applyPlan( + plan: InitPlan, + write: (filePath: string, content: string) => void = (filePath, content) => + fs.writeFileSync(filePath, content) +): void { + for (const file of plan.files) { + fs.mkdirSync(path.dirname(file.path), { recursive: true }); + write(file.path, file.after); + } +} + +/** + * `--yes` auto-align report: names each rewritten pin old→new and tells the + * user to run their package manager install — rewriting pins without + * reinstalling is only half the alignment, and the report says so. + */ +export function printAlignmentReport( + plan: InitPlan, + log: (line: string) => void = (line) => console.log(line) +): void { + if (plan.alignment.length === 0) return; + log('Aligned shared dependency pins to the host:'); + for (const entry of plan.alignment) { + log(` ${entry.remote}: ${entry.pkg}: ${entry.from} → ${entry.to}`); + } + log( + 'Pins were rewritten but not installed — run your package manager install (e.g. `pnpm install`) before building.' + ); +} diff --git a/packages/repack/src/commands/federation/init/diff.ts b/packages/repack/src/commands/federation/init/diff.ts new file mode 100644 index 000000000..4906da9d7 --- /dev/null +++ b/packages/repack/src/commands/federation/init/diff.ts @@ -0,0 +1,109 @@ +/** + * Hand-rolled LCS line diff for the `federation-init` diff-before-write + * gate. Output is for humans reading a terminal, not for `git apply`. + */ + +export type DiffLineKind = 'context' | 'add' | 'remove'; + +export interface DiffLine { + kind: DiffLineKind; + text: string; +} + +/** Classic LCS diff over lines — small files, no Myers needed. */ +export function diffLines(before: string, after: string): DiffLine[] { + const a = before.split('\n'); + const b = after.split('\n'); + // lengths[n][...] table, built bottom-up + const lengths: number[][] = Array.from({ length: a.length + 1 }, () => + new Array(b.length + 1).fill(0) + ); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + lengths[i][j] = + a[i] === b[j] + ? lengths[i + 1][j + 1] + 1 + : Math.max(lengths[i + 1][j], lengths[i][j + 1]); + } + } + + const lines: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + lines.push({ kind: 'context', text: a[i] }); + i++; + j++; + } else if (lengths[i + 1][j] >= lengths[i][j + 1]) { + lines.push({ kind: 'remove', text: a[i++] }); + } else { + lines.push({ kind: 'add', text: b[j++] }); + } + } + while (i < a.length) lines.push({ kind: 'remove', text: a[i++] }); + while (j < b.length) lines.push({ kind: 'add', text: b[j++] }); + return lines; +} + +const CONTEXT_LINES = 3; + +/** Render changed runs with up to 3 context lines, unified-style. */ +export function formatDiffBody( + lines: DiffLine[], + fromNewFile: boolean +): string { + const prefix: Record = { + context: ' ', + add: fromNewFile ? ' ' : '+ ', + remove: '- ', + }; + + let body: string; + if (fromNewFile) { + body = lines.map((line) => `${prefix[line.kind]}${line.text}`).join('\n'); + } else { + const keep = new Array(lines.length).fill(false); + lines.forEach((line, index) => { + if (line.kind === 'context') return; + for ( + let k = Math.max(0, index - CONTEXT_LINES); + k <= Math.min(lines.length - 1, index + CONTEXT_LINES); + k++ + ) { + keep[k] = true; + } + }); + body = lines + .filter((_, index) => keep[index]) + .map((line) => `${prefix[line.kind]}${line.text}`) + .join('\n'); + } + return body; +} + +/** + * Render one planned file write as a human-readable diff. New files show + * their full content under a `(new file)` header; modified files show only + * the changed runs. + */ +export function formatFileDiff( + displayPath: string, + before: string | null, + after: string +): string { + if (before === null) { + return ( + `+++ ${displayPath} (new file)\n` + + after + .split('\n') + .map((text) => ` ${text}`) + .join('\n') + ); + } + const lines = diffLines(before, after); + if (lines.every((line) => line.kind === 'context')) { + return `--- ${displayPath} (no changes)`; + } + return `--- ${displayPath}\n${formatDiffBody(lines, false)}`; +} diff --git a/packages/repack/src/commands/federation/init/merge.ts b/packages/repack/src/commands/federation/init/merge.ts new file mode 100644 index 000000000..1c9517de6 --- /dev/null +++ b/packages/repack/src/commands/federation/init/merge.ts @@ -0,0 +1,484 @@ +/** + * Key-level merge primitives for `federation-init`. Every surface the + * command touches is merged by parsed data — never marker-driven wholesale + * regeneration — so re-runs add only what is genuinely missing and manual + * edits always survive. Marker comments in generated configs are provenance + * only. + */ + +/** Result of a text-level merge: `changed` false means no file write. */ +export interface MergeResult { + changed: boolean; + after?: string; + /** Present when the edit could not be anchored safely: instructions for a human. */ + manualStep?: string; +} + +function stringifyJson(document: unknown): string { + return `${JSON.stringify(document, null, 2)}\n`; +} + +/** + * Add missing `dependencies` entries to a package.json document, preserving + * key order and never touching existing values (divergence alignment is a + * separate, explicit operation). + */ +export function mergePackageJsonDeps( + source: string | null, + remoteName: string, + deps: Record +): { after: string; added: string[] } { + if (source === null) { + const dependencies: Record = {}; + for (const name of Object.keys(deps).sort()) + dependencies[name] = deps[name]; + return { + after: stringifyJson({ + name: remoteName, + version: '0.0.0', + private: true, + dependencies, + }), + added: Object.keys(dependencies), + }; + } + + const document = JSON.parse(source) as { + dependencies?: Record; + }; + document.dependencies = document.dependencies ?? {}; + const added: string[] = []; + for (const name of Object.keys(deps).sort()) { + if (!(name in document.dependencies)) { + document.dependencies[name] = deps[name]; + added.push(name); + } + } + return { after: stringifyJson(document), added }; +} + +/** Rewrite declared dependency versions in a package.json document. */ +export function rewritePackageJsonDeps( + source: string, + deps: Record +): { + after: string; + changed: boolean; + /** Previous declared value per rewritten package, for old→new reports. */ + previous: Record; +} { + const document = JSON.parse(source) as { + dependencies?: Record; + }; + document.dependencies = document.dependencies ?? {}; + const previous: Record = {}; + let changed = false; + for (const [name, version] of Object.entries(deps)) { + previous[name] = document.dependencies[name]; + if (document.dependencies[name] !== version) { + document.dependencies[name] = version; + changed = true; + } + } + return { after: stringifyJson(document), changed, previous }; +} + +/** + * Add a missing `remotes.` entry to a `repack-federation.json` + * document. Existing entries are never modified; creating the file from + * scratch seeds a schema-valid minimal document. + */ +export function mergeFederationConfig( + source: string | null, + remoteName: string, + entry: Record +): MergeResult { + if (source === null) { + return { + changed: true, + after: stringifyJson({ + host: { manifest: 'build' }, + remotes: { [remoteName]: entry }, + }), + }; + } + const document = JSON.parse(source) as { + remotes?: Record; + }; + document.remotes = document.remotes ?? {}; + if (remoteName in document.remotes) { + return { changed: false, after: source }; + } + document.remotes[remoteName] = entry; + return { changed: true, after: stringifyJson(document) }; +} + +// --- Host-config text surgery ------------------------------------------------ + +/** + * Produce a same-length view of the source where comment and string content + * is blanked (newlines kept). All structural scanning runs on the masked + * view, so the words `remotes`/`name` inside strings or comments can never + * anchor an insertion; values are read from the original by index. + */ +function maskNonCode(source: string): string { + let out = ''; + let i = 0; + while (i < source.length) { + const c = source[i]; + if (c === '/' && source[i + 1] === '/') { + while (i < source.length && source[i] !== '\n') { + out += ' '; + i++; + } + continue; + } + if (c === '/' && source[i + 1] === '*') { + while ( + i < source.length && + !(source[i] === '*' && source[i + 1] === '/') + ) { + out += source[i] === '\n' ? '\n' : ' '; + i++; + } + out += ' '; + i = Math.min(i + 2, source.length); + continue; + } + if (c === "'" || c === '"' || c === '`') { + // Quote characters survive (so quoted keys stay visible to structural + // scans); string content is blanked (so words inside can never anchor). + out += c; + i++; + while (i < source.length) { + if (source[i] === '\\') { + out += source[i + 1] === '\n' ? '\n' : ' '; + out += ' '; + i += 2; + continue; + } + if (source[i] === c) { + out += c; + i++; + break; + } + out += source[i] === '\n' ? '\n' : ' '; + i++; + } + continue; + } + out += c; + i++; + } + return out; +} + +/** Read a quoted string literal from the ORIGINAL source at `start`. */ +function readQuoted( + source: string, + start: number +): { value: string; end: number } | null { + const quote = source[start]; + if (quote !== "'" && quote !== '"') return null; + let value = ''; + let i = start + 1; + while (i < source.length) { + if (source[i] === '\\') { + value += source[i + 1] ?? ''; + i += 2; + continue; + } + if (source[i] === quote) return { value, end: i + 1 }; + value += source[i]; + i++; + } + return null; +} + +/** Index just past the bracket matching the opener at `open` (masked view). */ +function matchBracket(masked: string, open: number): number { + const pairs: Record = { '(': ')', '{': '}', '[': ']' }; + const closers = new Set(Object.values(pairs)); + const stack: string[] = []; + for (let i = open; i < masked.length; i++) { + const c = masked[i]; + if (pairs[c]) stack.push(pairs[c]); + else if (closers.has(c)) { + if (stack.pop() !== c) return -1; + if (stack.length === 0) return i; + } + } + return -1; +} + +interface PropertyHit { + name: string; + /** Index of the token start in the source. */ + tokenStart: number; + /** Index of the `:` separator in the source. */ + colon: number; +} + +/** + * Collect depth-0 property keys of the object literal whose opening `{` sits + * at `open` (masked view), including quoted keys. Values are not descended + * into: depth tracking skips nested structures. + */ +function topLevelProperties( + source: string, + masked: string, + open: number +): { props: PropertyHit[]; close: number } { + const close = matchBracket(masked, open); + const props: PropertyHit[] = []; + if (close === -1) return { props, close: -1 }; + + let depth = 0; + let i = open + 1; + const skipWs = (from: number) => { + let k = from; + while (k < close && /\s/.test(masked[k])) k++; + return k; + }; + + while (i < close) { + const c = masked[i]; + if (c === '{' || c === '[' || c === '(') { + depth++; + i++; + continue; + } + if (c === '}' || c === ']' || c === ')') { + depth--; + i++; + continue; + } + if (depth > 0) { + i++; + continue; + } + if (c === '`') { + let j = i + 1; + while (j < close && masked[j] !== '`') j++; + i = j + 1; + continue; + } + if (c === "'" || c === '"') { + const quoted = readQuoted(source, i); + if (!quoted) break; + const afterKey = skipWs(quoted.end); + if (masked[afterKey] === ':') { + props.push({ name: quoted.value, tokenStart: i, colon: afterKey }); + } + i = afterKey + 1; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + let j = i; + while (j < close && /[\w$]/.test(masked[j])) j++; + const token = masked.slice(i, j); + const afterToken = skipWs(j); + if (masked[afterToken] === ':') { + props.push({ name: token, tokenStart: i, colon: afterToken }); + } + i = afterToken + 1; + continue; + } + i++; + } + return { props, close }; +} + +function indentOfLine(source: string, index: number): string { + const lineStart = source.lastIndexOf('\n', index) + 1; + const match = /^[ \t]*/.exec(source.slice(lineStart, index)); + return match ? match[0] : ''; +} + +function manualInstruction(name: string, why: string): string { + return ( + `Register remote "${name}" manually in the host Module Federation plugin: ` + + `add '${name}': '${name}@${name}/remoteEntry.js' to its remotes object. ` + + `(${why})` + ); +} + +/** + * Anchor-safely add `'': ''` to the host config's Module + * Federation `remotes` object. Anchors, in order: an existing `remotes: {}` + * block, then an insertion point after the plugin's `name:` property. When + * neither can be located unambiguously, returns a manual instruction instead + * of a guessed hunk — the plan shows it pre-confirm and `--yes` aborts + * rather than silently skipping a required registration. + */ +export function ensureRemotesEntry( + source: string, + name: string, + entryValue: string +): MergeResult { + const masked = maskNonCode(source); + + const callSites: number[] = []; + const pluginRe = /ModuleFederationPlugin(?:V1|V2)\b/g; + let match = pluginRe.exec(masked); + while (match) { + let i = match.index + match[0].length; + while (i < masked.length && /\s/.test(masked[i])) i++; + if (masked[i] === '(') callSites.push(i); + match = pluginRe.exec(masked); + } + + if (callSites.length === 0) { + return { + changed: false, + manualStep: manualInstruction( + name, + 'no ModuleFederationPluginV1/V2 call site could be located' + ), + }; + } + if (callSites.length > 1) { + return { + changed: false, + manualStep: manualInstruction( + name, + `the config has multiple (${callSites.length}) Module Federation plugin call sites — the anchor is ambiguous` + ), + }; + } + + let optionsOpen = callSites[0] + 1; + while (optionsOpen < masked.length && /\s/.test(masked[optionsOpen])) + optionsOpen++; + if (masked[optionsOpen] !== '{') { + return { + changed: false, + manualStep: manualInstruction( + name, + 'the plugin options object could not be brace-matched' + ), + }; + } + + const { props, close } = topLevelProperties(source, masked, optionsOpen); + if (close === -1) { + return { + changed: false, + manualStep: manualInstruction( + name, + 'the plugin options object is not brace-balanced' + ), + }; + } + + const entryLine = (indent: string) => `${indent}'${name}': '${entryValue}',`; + + const remotesProp = props.find((p) => p.name === 'remotes'); + if (remotesProp) { + let valueStart = remotesProp.colon + 1; + while (valueStart < source.length && /\s/.test(masked[valueStart])) + valueStart++; + if (masked[valueStart] !== '{') { + return { + changed: false, + manualStep: manualInstruction( + name, + 'the host remotes property is not an object literal' + ), + }; + } + const inner = topLevelProperties(source, masked, valueStart); + if (inner.close === -1) { + return { + changed: false, + manualStep: manualInstruction( + name, + 'the host remotes object is not brace-balanced' + ), + }; + } + if (inner.props.some((p) => p.name === name)) { + return { changed: false, after: source }; + } + const closeIndent = indentOfLine(source, inner.close); + const innerIndent = `${closeIndent} `; + const between = source.slice(valueStart + 1, inner.close); + const trimmedEnd = between.replace(/\s+$/, ''); + let replacement: string; + if (trimmedEnd.trim() === '') { + replacement = `{${entryLine(innerIndent)}\n${closeIndent}}`; + } else if ( + trimmedEnd.endsWith(',') || + trimmedEnd.endsWith("'") || + trimmedEnd.endsWith('}') + ) { + replacement = `{${trimmedEnd}${trimmedEnd.endsWith(',') ? '' : ','}\n${entryLine( + innerIndent + )}\n${closeIndent}}`; + } else { + // e.g. a trailing comment inside the object — do not guess. + return { + changed: false, + manualStep: manualInstruction( + name, + 'the host remotes object ends in a pattern this tool will not guess' + ), + }; + } + const after = + source.slice(0, valueStart) + replacement + source.slice(inner.close + 1); + return { changed: true, after }; + } + + const nameProp = props.find((p) => p.name === 'name'); + if (!nameProp) { + return { + changed: false, + manualStep: manualInstruction( + name, + 'the plugin options object has neither a remotes nor a name property to anchor on' + ), + }; + } + + let valueStart = nameProp.colon + 1; + while (valueStart < source.length && /\s/.test(masked[valueStart])) + valueStart++; + const nameValue = readQuoted(source, valueStart); + if (!nameValue) { + return { + changed: false, + manualStep: manualInstruction( + name, + 'the plugin name property is not a plain string literal' + ), + }; + } + + const nameIndent = indentOfLine(source, nameProp.tokenStart); + const innerIndent = `${nameIndent} `; + const remotesBlock = `remotes: {\n${entryLine(innerIndent)}\n${nameIndent}}`; + let afterComma = nameValue.end; + while (afterComma < source.length && /[ \t]/.test(source[afterComma])) + afterComma++; + if (source[afterComma] === ',') { + const insertAt = afterComma + 1; + return { + changed: true, + after: `${source.slice(0, insertAt)}\n${nameIndent}${remotesBlock}${source.slice(insertAt)}`, + }; + } + if (masked[afterComma] === '}' || masked[afterComma] === ')') { + return { + changed: true, + after: `${source.slice(0, nameValue.end)},\n${nameIndent}${remotesBlock}${source.slice(nameValue.end)}`, + }; + } + return { + changed: false, + manualStep: manualInstruction( + name, + 'the plugin name property ends in a pattern this tool will not guess' + ), + }; +} diff --git a/packages/repack/src/commands/federation/init/plan.ts b/packages/repack/src/commands/federation/init/plan.ts new file mode 100644 index 000000000..1501dff8b --- /dev/null +++ b/packages/repack/src/commands/federation/init/plan.ts @@ -0,0 +1,361 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveInstalledVersion } from '../../../utils/sharedVersionResolver.js'; +import { formatFileDiff } from './diff.js'; +import { + ensureRemotesEntry, + mergeFederationConfig, + mergePackageJsonDeps, + rewritePackageJsonDeps, +} from './merge.js'; +import type { RemoteConfigTemplate } from './templates.js'; +import { renderRemoteConfig, sharedDepsFromConfig } from './templates.js'; + +/** One planned file write. `before: null` means the file will be created. */ +export interface InitPlanFile { + path: string; + before: string | null; + after: string; +} + +/** Host-vs-existing-remote installed-version divergence for one package. */ +export interface InitDivergenceEntry { + remote: string; + pkg: string; + hostVersion: string; + remoteVersion: string; +} + +/** One pin the align step rewrites, for old→new reports. */ +export interface InitAlignEntry { + remote: string; + pkg: string; + from: string; + to: string; +} + +export interface InitPlan { + workspaceRoot: string; + files: InitPlanFile[]; + manualSteps: string[]; + advisories: string[]; + divergence: InitDivergenceEntry[]; + alignment: InitAlignEntry[]; +} + +export interface InitPlanInput { + workspaceRoot: string; + remoteName: string; + remoteRoot: string; + featureFolder: string; + hostRoot: string; + hostConfigPath: string; + /** Output of the feature-folder scanner. */ + scannedDependencies: string[]; + scannedAdvisories?: string[]; + /** Shared dependency names the host config provides (from extraction). */ + hostSharedProvides: string[]; + /** Mirrored from the host's evaluated plugin instance (D4). */ + pluginVersion: 'V1' | 'V2'; + /** Other remotes already in the workspace, for divergence checks. */ + existingRemotes?: Array<{ name: string; root: string }>; + /** `--yes`: auto-align divergent remote pins to the host versions. */ + align?: boolean; + /** Write `standalone: true` into the new workspace-map entry. */ + standalone?: boolean; + /** Injected read — the plan performs no fs beyond reads; writes are apply's. */ + readFile?: (filePath: string) => string | null; +} + +function defaultReadFile(filePath: string): string | null { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null; +} + +function toPosix(p: string): string { + return p.split(path.sep).join('/'); +} + +function remoteRegistrationValue(remoteName: string): string { + return `${remoteName}@${remoteName}/remoteEntry.js`; +} + +/** + * Compute everything `federation-init` would write, without writing it: + * key-level merges over every surface, create-only remote configs, anchored + * host surgery with manual-steps degradation, divergence data and the + * optional align rewrites. The plan carries full before/after content, which + * is what makes the diff-before-write gate possible. + */ +export function computeInitPlan(input: InitPlanInput): InitPlan { + const read = input.readFile ?? defaultReadFile; + const files: InitPlanFile[] = []; + const manualSteps: string[] = []; + const advisories: string[] = [...(input.scannedAdvisories ?? [])]; + const divergence: InitDivergenceEntry[] = []; + const alignment: InitAlignEntry[] = []; + + // 1. Dependency set: scan ∩ host shared provides, at host versions. + const provides = new Set(input.hostSharedProvides); + const sharedDeps = input.scannedDependencies.filter((dep) => + provides.has(dep) + ); + const hostPackageJson = readJson( + path.join(input.hostRoot, 'package.json'), + read + ); + const declaredHostDeps = (hostPackageJson?.dependencies ?? {}) as Record< + string, + string + >; + const depVersions: Record = {}; + for (const dep of sharedDeps) { + let version = resolveInstalledVersion(dep, input.hostRoot); + if (version === 'unknown') { + // Installed pin wins; the host's declared version is the fallback. + version = declaredHostDeps[dep] ?? 'unknown'; + } + if (version === 'unknown') { + advisories.push( + `Shared dependency "${dep}" has no installed or host-declared version — omitted from the generated package.json; add it manually if the remote needs it.` + ); + continue; + } + depVersions[dep] = version; + } + for (const dep of input.scannedDependencies) { + if (!provides.has(dep)) { + advisories.push( + `Scanned dependency "${dep}" is not shared by the host — add it to the remote package.json yourself if the remote needs it.` + ); + } + } + + // 2. Remote package.json: key-level dep merge (create when absent). + const remotePkgPath = path.join(input.remoteRoot, 'package.json'); + const remotePkgSource = read(remotePkgPath); + const pkgMerge = mergePackageJsonDeps( + remotePkgSource, + path.basename(input.remoteRoot), + depVersions + ); + if (pkgMerge.added.length > 0) { + files.push({ + path: remotePkgPath, + before: remotePkgSource, + after: pkgMerge.after, + }); + } + + // 3. Remote bundler configs: create-only, both bundlers, versionless. + const featureRelRaw = toPosix( + path.relative(input.remoteRoot, input.featureFolder) + ); + const featureFolderRel = featureRelRaw.startsWith('..') + ? featureRelRaw + : `./${featureRelRaw}`; + for (const bundler of ['rspack', 'webpack'] as const) { + const configPath = path.join( + input.remoteRoot, + `${bundler}.${input.remoteName}.mts` + ); + const existing = read(configPath); + if (existing === null) { + const template: RemoteConfigTemplate = { + bundler, + remoteName: input.remoteName, + featureFolderRel, + sharedDeps, + pluginVersion: input.pluginVersion, + }; + files.push({ + path: configPath, + before: null, + after: renderRemoteConfig(template), + }); + } else { + // Existing config (generated or hand-written): never rewrite — advise + // about scanned deps its shared list does not carry. + const listed = sharedDepsFromConfig(existing); + if (listed === null) { + advisories.push( + `${path.basename(configPath)} already exists and carries no federation-init markers — it is left untouched; make sure its shared setup covers: ${sharedDeps.join(', ')}.` + ); + } else { + const missing = sharedDeps.filter((dep) => !listed.includes(dep)); + if (missing.length > 0) { + advisories.push( + `${path.basename(configPath)} is missing shared deps from the latest scan: ${missing.join(', ')} — add them to its SHARED_DEPS list yourself.` + ); + } + } + } + } + + // 4. Host surgery: anchored remotes registration, manual-steps degradation. + const hostSource = read(input.hostConfigPath); + if (hostSource === null) { + manualSteps.push( + `No host bundler configuration found at ${input.hostConfigPath} — register remote "${input.remoteName}" ('${remoteRegistrationValue(input.remoteName)}') in the host Module Federation plugin yourself.` + ); + } else { + const surgery = ensureRemotesEntry( + hostSource, + input.remoteName, + remoteRegistrationValue(input.remoteName) + ); + if (surgery.changed && surgery.after !== undefined) { + files.push({ + path: input.hostConfigPath, + before: hostSource, + after: surgery.after, + }); + } else if (surgery.manualStep) { + manualSteps.push(surgery.manualStep); + } + } + + // 5. Workspace map: repack-federation.json remotes. entry. + const configPath = path.join(input.workspaceRoot, 'repack-federation.json'); + const configSource = read(configPath); + const relativeRoot = toPosix( + path.relative(input.workspaceRoot, input.remoteRoot) + ); + const mapEntry: Record = { + manifest: `${relativeRoot}/build`, + root: relativeRoot, + ...(input.standalone === true ? { standalone: true } : {}), + }; + const mapMerge = mergeFederationConfig( + configSource, + input.remoteName, + mapEntry + ); + if (mapMerge.changed && mapMerge.after !== undefined) { + files.push({ + path: configPath, + before: configSource, + after: mapMerge.after, + }); + } + + // 6. Divergence: host vs every existing remote, installed versions only. + for (const remote of input.existingRemotes ?? []) { + for (const dep of sharedDeps) { + const hostVersion = resolveInstalledVersion(dep, input.hostRoot); + const remoteVersion = resolveInstalledVersion(dep, remote.root); + if ( + hostVersion !== 'unknown' && + remoteVersion !== 'unknown' && + hostVersion !== remoteVersion + ) { + divergence.push({ + remote: remote.name, + pkg: dep, + hostVersion, + remoteVersion, + }); + } + } + } + + // 7. Align (--yes): rewrite divergent remote pins to exact host versions. + if (input.align === true) { + const byRemote = new Map>(); + for (const entry of divergence) { + const list = byRemote.get(entry.remote) ?? []; + list.push({ pkg: entry.pkg, to: entry.hostVersion }); + byRemote.set(entry.remote, list); + } + for (const remote of input.existingRemotes ?? []) { + const rewrites = byRemote.get(remote.name); + if (!rewrites || rewrites.length === 0) continue; + const remoteManifestPath = path.join(remote.root, 'package.json'); + const source = read(remoteManifestPath); + if (source === null) { + manualSteps.push( + `Remote "${remote.name}" has divergent shared versions but no package.json at ${remote.root} — align it manually.` + ); + continue; + } + const deps: Record = {}; + for (const r of rewrites) deps[r.pkg] = r.to; + const rewritten = rewritePackageJsonDeps(source, deps); + if (rewritten.changed) { + files.push({ + path: remoteManifestPath, + before: source, + after: rewritten.after, + }); + } + for (const r of rewrites) { + alignment.push({ + remote: remote.name, + pkg: r.pkg, + from: rewritten.previous[r.pkg] ?? 'unknown', + to: r.to, + }); + } + } + } + + return { + workspaceRoot: input.workspaceRoot, + files, + manualSteps, + advisories, + divergence, + alignment, + }; +} + +function readJson( + filePath: string, + read: (p: string) => string | null +): Record | null { + const source = read(filePath); + if (source === null) return null; + try { + return JSON.parse(source) as Record; + } catch { + return null; + } +} + +/** + * Render the whole plan as the pre-confirm printout: diffs for every file + * write, then manual steps, version divergence and advisories. + */ +export function formatPlanDiff(plan: InitPlan): string { + const display = (filePath: string): string => { + const rel = path.relative(plan.workspaceRoot, filePath); + return !rel.startsWith('..') && !path.isAbsolute(rel) + ? toPosix(rel) + : filePath; + }; + + const sections: string[] = []; + for (const file of plan.files) { + sections.push(formatFileDiff(display(file.path), file.before, file.after)); + } + if (plan.divergence.length > 0) { + sections.push( + `Shared version divergence (host vs existing remotes):\n${plan.divergence + .map( + (entry) => + ` ${entry.remote}: ${entry.pkg}: remote ${entry.remoteVersion} vs host ${entry.hostVersion}` + ) + .join('\n')}` + ); + } + if (plan.manualSteps.length > 0) { + sections.push( + `Manual steps:\n${plan.manualSteps.map((step) => ` - ${step}`).join('\n')}` + ); + } + if (plan.advisories.length > 0) { + sections.push( + `Advisories:\n${plan.advisories.map((a) => ` - ${a}`).join('\n')}` + ); + } + return sections.join('\n\n'); +} diff --git a/packages/repack/src/commands/federation/init/prompt.ts b/packages/repack/src/commands/federation/init/prompt.ts new file mode 100644 index 000000000..fd37414ac --- /dev/null +++ b/packages/repack/src/commands/federation/init/prompt.ts @@ -0,0 +1,58 @@ +import readline from 'node:readline'; + +/** Injected question function — tests stub it; the command wires readline. */ +export type Ask = (question: string) => Promise; + +export type DivergenceAction = 'align' | 'ignore' | 'cancel'; + +/** Real prompt channel: node:readline, one interface per question. */ +export function createReadlineAsk(): Ask { + return (question) => + new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + let answered = false; + rl.question(question, (answer) => { + answered = true; + rl.close(); + resolve(answer); + }); + // A closed input (non-interactive run) answers nothing: the defaults + // ([y/N] decline, [c]ancel) keep the no-write side safe. + rl.on('close', () => { + if (!answered) resolve(''); + }); + }); +} + +/** Apply confirmation gate — the default is No. */ +export async function askApplyChanges( + ask: Ask, + fileCount: number +): Promise { + const answer = ( + await ask( + `Apply ${fileCount} file change${fileCount === 1 ? '' : 's'}? [y/N]: ` + ) + ) + .trim() + .toLowerCase(); + return answer === 'y' || answer === 'yes'; +} + +/** Divergence decision — an unusable answer cancels, never guesses a write. */ +export async function askDivergenceAction(ask: Ask): Promise { + const answer = ( + await ask( + 'Shared versions diverge between the host and existing remotes. ' + + '[a]lign remote pins to the host, [i]gnore and continue, or [c]ancel? [c]: ' + ) + ) + .trim() + .toLowerCase(); + if (answer === 'a' || answer === 'align') return 'align'; + if (answer === 'i' || answer === 'ignore') return 'ignore'; + return 'cancel'; +} diff --git a/packages/repack/src/commands/federation/init/templates.ts b/packages/repack/src/commands/federation/init/templates.ts new file mode 100644 index 000000000..1af87aff2 --- /dev/null +++ b/packages/repack/src/commands/federation/init/templates.ts @@ -0,0 +1,74 @@ +/** + * Versionless bundler-config templates for `federation-init`. Generated + * configs consume `defineShared()` for the shared setup and contain no + * literal version pins — exact pins materialize at build time from the + * installed packages. The `SHARED_DEPS` list between the marker comments is + * the merge surface; markers are provenance only, merges are key-level. + */ + +export interface RemoteConfigTemplate { + bundler: 'rspack' | 'webpack'; + remoteName: string; + /** Feature folder path relative to the remote root, e.g. `../../features/store`. */ + featureFolderRel: string; + /** Scanned ∩ host-provides package names — names only, never versions. */ + sharedDeps: string[]; + /** Mirrored from the host's evaluated plugin instance. */ + pluginVersion: 'V1' | 'V2'; +} + +export function renderRemoteConfig(config: RemoteConfigTemplate): string { + const { bundler, remoteName, featureFolderRel, sharedDeps, pluginVersion } = + config; + const defineFn = + bundler === 'rspack' ? 'defineRspackConfig' : 'defineWebpackConfig'; + const depsBlock = + sharedDeps.length === 0 + ? 'const SHARED_DEPS = [];' + : `const SHARED_DEPS = [\n${sharedDeps + .map((dep) => ` '${dep}',`) + .join('\n')}\n];`; + + return `// @repack:federation-init ${remoteName} — manual edits are preserved; re-runs merge key-level, never rewrite +import * as Repack from '@callstack/repack'; + +// repack:federation-init:shared:start +${depsBlock} +// repack:federation-init:shared:end + +export default Repack.${defineFn}((env) => ({ + mode: env.mode, + context: env.context, + entry: '${featureFolderRel}/index', + plugins: [ + new Repack.RepackPlugin(), + new Repack.plugins.ModuleFederationPlugin${pluginVersion}({ + name: '${remoteName}', + remotes: {}, + exposes: { + './*': '${featureFolderRel}/*', + }, + shared: Repack.defineShared(SHARED_DEPS, { + context: env.context, + role: 'remote', + mode: env.argv?.standalone ? 'standalone' : 'federated', + }), + }), + ], +})); +`; +} + +/** + * Extract the `SHARED_DEPS` names from a generated config between the + * marker comments. Returns null when the markers are absent (a hand-written + * config), which callers treat as "cannot determine". + */ +export function sharedDepsFromConfig(content: string): string[] | null { + const block = + /repack:federation-init:shared:start([\s\S]*?)repack:federation-init:shared:end/.exec( + content + ); + if (!block) return null; + return [...block[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]); +} From 04acc0011d0bf931b09db5b50a2935753d82b83a Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 09:26:49 +0200 Subject: [PATCH 16/19] feat(repack): register flat federation-init command and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-native federation-init --name composes scan -> computeInitPlan -> formatPlanDiff -> prompt gate -> apply on the workspace resolved from repack-federation.json. Command-level refusals are exit 2 with clear messages and zero writes: missing --name/feature folder, no/malformed workspace config, --standalone on a targeted remote that does not declare it (assertStandaloneSupported), and manual steps blocking --yes. Interactive runs show diffs first, then [a/i/c] on divergence and apply [y/N]; --yes auto-aligns and reports each pkg: old -> new pin rewrite. Evidence: - Focused: pnpm --filter @callstack/repack test -- index options federationInit -> 37 passed (5 suites); full suite 55 suites / 602 tests green. pnpm typecheck and pnpm lint:ci clean. - Runtime harness: ran the built dist command outside jest (node -e require('dist/commands/federationInit.js')) against a scratch copy of the init-workspace workspace: printed the full plan diff, applied 6 files, and reported divergence + alignment ('remote-drift: react: ^9.8.7 -> 9.9.9' + package-manager-install instruction). tester-federation end-to-end lands with 11.2: today's tester app has no repack-federation.json (11.1 creates it), keeps configs under configs/ (outside init's default discovery), and init has no --config passthrough by design. - Rollback: revert this commit — removes the command entry, options, args type and doc page; the init/ engine files become inert (no other importer). --- .../commands/__tests__/federationInit.test.ts | 315 ++++++++++++++++++ .../src/commands/__tests__/index.test.ts | 29 +- .../src/commands/__tests__/options.test.ts | 26 +- .../repack/src/commands/federationInit.ts | 255 ++++++++++++++ packages/repack/src/commands/index.ts | 9 + packages/repack/src/commands/options.ts | 18 + packages/repack/src/commands/types.ts | 9 + website/src/latest/api/cli/_meta.json | 5 + .../src/latest/api/cli/federation-init.mdx | 49 +++ 9 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 packages/repack/src/commands/__tests__/federationInit.test.ts create mode 100644 packages/repack/src/commands/federationInit.ts create mode 100644 website/src/latest/api/cli/federation-init.mdx diff --git a/packages/repack/src/commands/__tests__/federationInit.test.ts b/packages/repack/src/commands/__tests__/federationInit.test.ts new file mode 100644 index 000000000..1622f0656 --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationInit.test.ts @@ -0,0 +1,315 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { federationInit } from '../federationInit.js'; +import type { CliConfig } from '../types.js'; + +// The command builds its prompts through createReadlineAsk(); tests route +// that channel through a queued stub. +jest.mock('../federation/init/prompt.js', () => { + const actual = jest.requireActual('../federation/init/prompt.js'); + return { + ...actual, + createReadlineAsk: () => (question: string) => + ((globalThis as any).__repackInitAsk ?? (async () => ''))(question), + }; +}); + +const FIXTURE = path.join( + __dirname, + '..', + 'federation', + '__fixtures__', + 'init-workspace' +); + +const cliConfigFor = (root: string): CliConfig => ({ + root, + platforms: ['ios'], + reactNativePath: '/project/node_modules/react-native', +}); + +function copyWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-init-cmd-')); + fs.cpSync(FIXTURE, dir, { recursive: true }); + return dir; +} + +function hashTree(dir: string): Map { + const files = new Map(); + const walk = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) walk(full); + else files.set(full, fs.readFileSync(full, 'utf-8')); + } + }; + walk(dir); + return files; +} + +function queuedAsk(answers: string[]) { + let index = 0; + (globalThis as any).__repackInitAsk = async () => answers[index++] ?? ''; +} + +let root: string; +let log: jest.SpyInstance; +let error: jest.SpyInstance; +let exit: jest.SpyInstance; + +beforeEach(() => { + root = copyWorkspace(); + 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 + ); +}); + +afterEach(() => { + delete (globalThis as any).__repackInitAsk; + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +function stdout(): string { + return log.mock.calls.map(([line]) => String(line)).join('\n'); +} + +function stderr(): string { + return error.mock.calls.map(([line]) => String(line)).join('\n'); +} + +describe('federation-init refusals — exit 2, clear message, no writes', () => { + it('refuses --standalone for a targeted remote that does not declare it, writing nothing', async () => { + const before = hashTree(root); + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'remote-drift', + standalone: true, + } + ); + + expect(stderr()).toContain( + '--standalone refused: remote "remote-drift" does not declare standalone support' + ); + expect(exit).toHaveBeenCalledWith(2); + expect(hashTree(root)).toEqual(before); + }); + + it('requires --name', async () => { + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + {} + ); + + expect(stderr()).toContain('--name'); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('refuses a missing feature folder', async () => { + await federationInit( + [path.join(root, 'features', 'nope')], + cliConfigFor(root), + { + name: 'store', + } + ); + + expect(stderr()).toContain('feature folder'); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('requires a repack-federation.json workspace', async () => { + fs.rmSync(path.join(root, 'repack-federation.json')); + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + } + ); + + expect(stderr()).toContain('repack-federation.json'); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('prints no stack for a malformed workspace config', async () => { + fs.writeFileSync(path.join(root, 'repack-federation.json'), '{ nope'); + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + } + ); + + expect(stderr()).toContain('Federation config'); + expect(stderr()).not.toContain(' at '); + expect(exit).toHaveBeenCalledWith(2); + }); + + it('aborts under --yes when required manual steps remain instead of silently skipping', async () => { + // A duck-typed plugin the version regex cannot name: extraction works, + // the remotes surgery refuses to guess — --yes must abort. + fs.writeFileSync( + path.join(root, 'apps', 'host', 'rspack.config.js'), + `class MyFederationPlugin { + constructor(config) { this.config = config; } + getSharedConfiguration() { return this.config.shared; } +} +module.exports = () => ({ + plugins: [new MyFederationPlugin({ name: 'HostApp', shared: { react: {} } })], +}); +` + ); + const before = hashTree(root); + + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + yes: true, + } + ); + + expect(stderr()).toContain('manual'); + expect(exit).toHaveBeenCalledWith(2); + expect(hashTree(root)).toEqual(before); + }); +}); + +describe('federation-init --yes', () => { + it('scaffolds all surfaces, auto-aligns divergence and reports old→new pins', async () => { + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + yes: true, + } + ); + + expect(exit).not.toHaveBeenCalled(); + expect( + fs.existsSync(path.join(root, 'remotes', 'store', 'rspack.store.mts')) + ).toBe(true); + expect( + fs.existsSync(path.join(root, 'remotes', 'store', 'webpack.store.mts')) + ).toBe(true); + + const map = JSON.parse( + fs.readFileSync(path.join(root, 'repack-federation.json'), 'utf-8') + ) as { remotes: Record }; + expect(map.remotes.store).toEqual({ + manifest: 'remotes/store/build', + root: 'remotes/store', + }); + expect( + fs.readFileSync( + path.join(root, 'apps', 'host', 'rspack.config.js'), + 'utf-8' + ) + ).toContain("'store': 'store@store/remoteEntry.js'"); + + const out = stdout(); + expect(out).toContain('react: ^9.8.7 → 9.9.9'); + expect(out).toMatch(/run your package manager install/i); + }); + + it('re-running with --yes is a no-op on an already-scaffolded remote', async () => { + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + yes: true, + } + ); + const afterFirst = hashTree(root); + log.mockClear(); + exit.mockClear(); + + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + yes: true, + } + ); + + expect(stdout()).toContain('Nothing to do'); + expect(exit).not.toHaveBeenCalled(); + expect(hashTree(root)).toEqual(afterFirst); + }); +}); + +describe('federation-init interactive gate', () => { + it('cancels everything on [c] at the divergence prompt, writing nothing', async () => { + const before = hashTree(root); + queuedAsk(['c']); + + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + } + ); + + expect(exit).toHaveBeenCalledWith(1); + expect(hashTree(root)).toEqual(before); + }); + + it('writes nothing when the apply prompt is declined', async () => { + const before = hashTree(root); + queuedAsk(['i', 'n']); // ignore divergence, then decline the diffs + + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + } + ); + + expect(stdout()).toContain('Declined'); + expect(exit).not.toHaveBeenCalledWith(2); + expect(hashTree(root)).toEqual(before); + }); + + it('applies after [i]gnore then y, and the plan diff was printed first', async () => { + queuedAsk(['i', 'y']); + + await federationInit( + [path.join(root, 'features', 'store')], + cliConfigFor(root), + { + name: 'store', + } + ); + + // The divergence was shown before the prompt, side-by-side style. + expect(stdout()).toContain('react: remote 9.8.7 vs host 9.9.9'); + // The diffs were shown pre-confirm. + expect(stdout()).toContain('remotes/store/package.json'); + expect( + fs.existsSync(path.join(root, 'remotes', 'store', 'rspack.store.mts')) + ).toBe(true); + // Ignore means the divergent remote was NOT rewritten. + expect( + fs.readFileSync( + path.join(root, 'apps', 'remote-drift', 'package.json'), + 'utf-8' + ) + ).toContain('"^9.8.7"'); + }); +}); diff --git a/packages/repack/src/commands/__tests__/index.test.ts b/packages/repack/src/commands/__tests__/index.test.ts index 916707d49..533ebd1ce 100644 --- a/packages/repack/src/commands/__tests__/index.test.ts +++ b/packages/repack/src/commands/__tests__/index.test.ts @@ -1,9 +1,10 @@ import { bundle } from '../bundle.js'; -import { createBoundCommands } from '../index.js'; +import commands, { createBoundCommands } from '../index.js'; import type { BundleArguments, CliConfig, StartArguments } from '../types.js'; jest.mock('../bundle.js'); jest.mock('../federationDoctor.js'); +jest.mock('../federationInit.js'); jest.mock('../federationManifest.js'); jest.mock('../start.js'); @@ -49,3 +50,29 @@ describe('createBoundCommands', () => { } ); }); + +describe('command registry', () => { + test('federation-init is a flat command alongside the other federation commands', () => { + const names = commands.map((command) => command.name); + expect(names).toEqual( + expect.arrayContaining([ + 'federation-init', + 'federation-doctor', + 'federation-manifest', + ]) + ); + + const init = commands.find((command) => command.name === 'federation-init'); + // Flat RN-CLI command object: name/description/options/func, no + // subcommand tree. + expect(typeof init?.func).toBe('function'); + expect(Array.isArray(init?.options)).toBe(true); + expect(typeof init?.description).toBe('string'); + expect(init).not.toHaveProperty('subcommands'); + }); + + test('federation-init is not exposed through the deprecated bound entry points', () => { + const names = createBoundCommands('webpack').map((command) => command.name); + expect(names).not.toContain('federation-init'); + }); +}); diff --git a/packages/repack/src/commands/__tests__/options.test.ts b/packages/repack/src/commands/__tests__/options.test.ts index 07465cc3f..8fd549d1a 100644 --- a/packages/repack/src/commands/__tests__/options.test.ts +++ b/packages/repack/src/commands/__tests__/options.test.ts @@ -1,4 +1,8 @@ -import { bundleCommandOptions, startCommandOptions } from '../options.js'; +import { + bundleCommandOptions, + federationInitCommandOptions, + startCommandOptions, +} from '../options.js'; describe.each([ ['start', startCommandOptions], @@ -32,3 +36,23 @@ describe('--standalone registration', () => { expect(standaloneOption?.parse).toBeUndefined(); }); }); + +describe('federation-init command options', () => { + test('exposes --name, --yes and --standalone', () => { + const names = federationInitCommandOptions.map((option) => option.name); + expect(names).toContain('--name '); + expect(names).toContain('--yes'); + expect(names).toContain('--standalone'); + }); + + test('--yes and --standalone are boolean flags', () => { + for (const name of ['--yes', '--standalone']) { + const option = federationInitCommandOptions.find( + (candidate) => candidate.name === name + ) as { name: string; parse?: unknown } | undefined; + // Boolean commander flags: no parse, no default — presence means true. + expect(option).toBeDefined(); + expect(option?.parse).toBeUndefined(); + } + }); +}); diff --git a/packages/repack/src/commands/federationInit.ts b/packages/repack/src/commands/federationInit.ts new file mode 100644 index 000000000..c2acec1d9 --- /dev/null +++ b/packages/repack/src/commands/federationInit.ts @@ -0,0 +1,255 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { CLIError } from '../helpers/index.js'; +import { getConfigFilePath } from './common/config/getConfigFilePath.js'; +import { + assertStandaloneSupported, + ConfigFileInvalidError, + FEDERATION_CONFIG_FILENAME, + loadFederationConfig, +} from './federation/configFile.js'; +import { + ConfigEvalError, + extractAppShared, +} from './federation/extractShared.js'; +import { applyPlan, printAlignmentReport } from './federation/init/apply.js'; +import type { InitPlanInput } from './federation/init/plan.js'; +import { computeInitPlan, formatPlanDiff } from './federation/init/plan.js'; +import { + askApplyChanges, + askDivergenceAction, + createReadlineAsk, +} from './federation/init/prompt.js'; +import { scanFeatureFolder } from './federation/scanFeatures.js'; +import type { CliConfig, FederationInitArguments } from './types.js'; + +/** Same discovery order the rest of the tooling uses: rspack first. */ +function discoverHostConfigPath(root: string): string | null { + try { + return getConfigFilePath('rspack', root); + } catch { + // fall through to webpack candidates + } + try { + return getConfigFilePath('webpack', root); + } catch { + return null; + } +} + +/** + * Scaffold a new Module Federation remote from an existing feature folder: + * scan its imports, generate versionless rspack/webpack configs built on + * `defineShared`, and register the remote in its `package.json`, the host's + * `remotes` and `repack-federation.json`. Everything is planned and diffed + * before a single write; `--yes` pre-approves the diffs and auto-aligns + * divergent remote pins to the host. + * + * The command is intentionally thin — the plan engine in + * `federation/init/plan.ts` owns all merge and divergence logic. + * + * @param argv Original, non-parsed arguments; the first one is the feature folder. + * @param cliConfig Configuration object; its `root` locates the workspace. + * @param args Parsed command line arguments. + */ +export async function federationInit( + argv: string[], + cliConfig: CliConfig, + args: FederationInitArguments +) { + const fail = (message: string): void => { + console.error(message); + process.exit(2); + }; + + const remoteName = args.name?.trim(); + if (!remoteName) { + fail( + "Option '--name ' is required: the name of the remote to scaffold." + ); + return; + } + + const folderArg = argv[0]; + if (!folderArg) { + fail( + 'No feature folder given: pass the folder to scaffold as the first ' + + 'argument, e.g. react-native federation-init ./features/store ' + + '--name store.' + ); + return; + } + const featureFolder = path.resolve(process.cwd(), folderArg); + if ( + !fs.existsSync(featureFolder) || + !fs.statSync(featureFolder).isDirectory() + ) { + fail( + `The feature folder ${featureFolder} does not exist — nothing was written.` + ); + return; + } + + // Workspace: the config file is the only source of truth here — init + // refuses without one rather than guessing host roots. + let loaded: ReturnType; + try { + loaded = loadFederationConfig({ cwd: cliConfig.root }); + } catch (error) { + if (error instanceof ConfigFileInvalidError) { + fail( + `Federation config — ${error.filePath}: ${error.reasons.join('; ')}` + ); + return; + } + throw error; + } + if (!loaded) { + fail( + `No ${FEDERATION_CONFIG_FILENAME} found from ${cliConfig.root} — run ` + + 'federation-init inside a federation workspace that declares the ' + + 'host and its remotes. Nothing was written.' + ); + return; + } + const { config } = loaded; + const configDir = path.dirname(loaded.filePath); + + // Standalone enforcement against the targeted entry (before any compute, + // so the refusal provably performs no writes). + if (args.standalone) { + const targeted = config.remotes[remoteName]; + if (targeted) { + try { + assertStandaloneSupported( + path.resolve(configDir, targeted.root ?? '.') + ); + } catch (error) { + if (error instanceof CLIError) { + fail(`${error.message} Nothing was written.`); + return; + } + throw error; + } + } + } + + const hostRoot = config.host.root + ? path.resolve(configDir, config.host.root) + : configDir; + + // Host extraction: shared provides + plugin-version mirroring (D4). + let hostShared: Awaited>; + try { + hostShared = await extractAppShared(hostRoot); + } catch (error) { + if (error instanceof ConfigEvalError) { + fail(`Federation init — ${error.message}`); + return; + } + throw error; + } + const hostConfigPath = discoverHostConfigPath(hostRoot); + if (hostConfigPath === null) { + fail( + `Federation init — no host bundler configuration found in ${hostRoot}; ` + + 'the remotes registration needs one to anchor on. Nothing was written.' + ); + return; + } + + const scan = scanFeatureFolder(featureFolder); + const existingEntry = config.remotes[remoteName]; + const remoteRoot = existingEntry + ? path.resolve(configDir, existingEntry.root ?? '.') + : path.join(configDir, 'remotes', remoteName); + + const planInput: InitPlanInput = { + workspaceRoot: configDir, + remoteName, + remoteRoot, + featureFolder, + hostRoot, + hostConfigPath, + scannedDependencies: scan.dependencies, + scannedAdvisories: scan.advisories, + hostSharedProvides: hostShared.shared.map((entry) => + entry.name.replace(/\/$/, '') + ), + pluginVersion: + hostShared.pluginName === 'ModuleFederationPluginV2' ? 'V2' : 'V1', + existingRemotes: Object.entries(config.remotes) + .filter(([name]) => name !== remoteName) + .map(([name, entry]) => ({ + name, + root: path.resolve(configDir, entry.root ?? '.'), + })), + standalone: args.standalone === true ? true : undefined, + }; + + let plan = computeInitPlan(planInput); + // The full printout — diffs, divergence, manual steps, advisories — is + // shown before any prompt or write: diff-before-write. + const printed = formatPlanDiff(plan); + if (printed !== '') console.log(printed); + + if (plan.manualSteps.length > 0 && args.yes) { + fail( + '--yes cannot proceed while manual steps remain — the plan above ' + + 'requires human registration the tool will not guess. Nothing was written.' + ); + return; + } + + if (plan.divergence.length > 0) { + if (args.yes) { + // --yes pre-approves the diffs AND auto-aligns (D7/spec): recompute + // with alignment so the remote pins land on the host's exact versions. + plan = computeInitPlan({ ...planInput, align: true }); + } else { + const action = await askDivergenceAction(createReadlineAsk()); + if (action === 'cancel') { + console.log('Cancelled — nothing was written.'); + process.exit(1); + return; + } + if (action === 'align') { + plan = computeInitPlan({ ...planInput, align: true }); + } + } + } + + if (plan.files.length === 0) { + console.log( + `Nothing to do — remote "${remoteName}" is already registered and its ` + + 'surfaces are current. Review the advisories above if any.' + ); + return; + } + + if (!args.yes) { + const confirmed = await askApplyChanges( + createReadlineAsk(), + plan.files.length + ); + if (!confirmed) { + console.log('Declined — no files were written.'); + return; + } + } + + applyPlan(plan); + printAlignmentReport(plan); + + console.log( + `Remote "${remoteName}" scaffolded: ${plan.files.length} file(s) written.` + ); + if (plan.manualSteps.length > 0) { + console.log( + `Manual steps still needed:\n${plan.manualSteps.map((s) => ` - ${s}`).join('\n')}` + ); + } + console.log( + 'Dependencies are declared, not installed — run your package manager install (e.g. `pnpm install`) before building.' + ); +} diff --git a/packages/repack/src/commands/index.ts b/packages/repack/src/commands/index.ts index a30fd8c5f..65b52f7e3 100644 --- a/packages/repack/src/commands/index.ts +++ b/packages/repack/src/commands/index.ts @@ -1,9 +1,11 @@ import { bundle } from './bundle.js'; import { federationDoctor } from './federationDoctor.js'; +import { federationInit } from './federationInit.js'; import { federationManifest } from './federationManifest.js'; import { bundleCommandOptions, federationDoctorCommandOptions, + federationInitCommandOptions, federationManifestCommandOptions, startCommandOptions, } from './options.js'; @@ -56,6 +58,13 @@ const federationCommands = [ options: federationDoctorCommandOptions, func: federationDoctor, }, + { + name: 'federation-init', + description: + 'Scaffold a new federation remote from a feature folder: scanned deps, versionless defineShared configs and workspace registration, all diffed before any write.', + options: federationInitCommandOptions, + func: federationInit, + }, ] as const; const commands = [...bundlerCommands, ...federationCommands]; diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index a4e0bec88..c868bc511 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -145,6 +145,24 @@ export const federationDoctorCommandOptions = [ }, ]; +export const federationInitCommandOptions = [ + { + name: '--name ', + description: + 'Name of the remote to scaffold from the given feature folder (used as the federation name and remote key)', + }, + { + name: '--yes', + description: + 'Pre-approve all presented diffs and auto-align divergent remote shared pins to the host versions (reporting each pkg: old → new)', + }, + { + name: '--standalone', + description: + 'Record standalone support for the scaffolded remote in repack-federation.json. Refused for an existing remote that does not declare it', + }, +]; + export const bundleCommandOptions = [ { name: '--entry-file ', diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index 7047262f0..587997c2f 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -63,6 +63,15 @@ export interface FederationDoctorArguments { dryRun?: boolean; } +export interface FederationInitArguments { + /** Name of the remote to scaffold. */ + name?: string; + /** Pre-approve all diffs and auto-align divergent remote pins to the host. */ + yes?: boolean; + /** Record standalone support for the new remote (refused for unsupported targets). */ + standalone?: 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 064e11206..44e06c8ad 100644 --- a/website/src/latest/api/cli/_meta.json +++ b/website/src/latest/api/cli/_meta.json @@ -19,6 +19,11 @@ "name": "federation-doctor", "label": "Federation doctor" }, + { + "type": "file", + "name": "federation-init", + "label": "Federation init" + }, { "type": "file", "name": "repack-federation-json", diff --git a/website/src/latest/api/cli/federation-init.mdx b/website/src/latest/api/cli/federation-init.mdx new file mode 100644 index 000000000..ffd11360d --- /dev/null +++ b/website/src/latest/api/cli/federation-init.mdx @@ -0,0 +1,49 @@ +# federation-init + +`federation-init` scaffolds a new Module Federation remote from an existing feature folder in one command. It scans the folder's imports, generates **versionless** rspack and webpack configurations built on [`defineShared`](/api/utils/define-shared), and registers the remote in its `package.json`, the host's `remotes` and [`repack-federation.json`](/api/cli/repack-federation-json). Nothing is written before you see the diff. + +## Usage + +import { PackageManagerTabs } from '@theme'; + + + +Run it inside a federation workspace — a directory tree containing a `repack-federation.json` that declares the host and its remotes. The feature folder is the first positional argument; `--name` is the remote name used in every registration. + +## Options + +| Option | Description | +| --- | --- | +| `--name ` | Name of the remote to scaffold (required) | +| `--yes` | Pre-approve all presented diffs and auto-align divergent remote shared pins to the host | +| `--standalone` | Record standalone support for the scaffolded remote in `repack-federation.json`. Refused for an existing remote that does not declare it | + +## What it writes + +For a new remote `store` from `features/store`: + +- `remotes/store/package.json` — dependencies derived from the feature-folder scan, intersected with what the host actually shares, pinned to the host's exact installed versions; +- `remotes/store/rspack.store.mts` and `remotes/store/webpack.store.mts` — versionless configs whose shared setup is a `defineShared(SHARED_DEPS, { role: 'remote', mode: env.argv?.standalone ? ... })` call. They contain **no version pins**: exact pins materialize at build time from the installed packages. The Module Federation plugin version mirrors the one the host config instantiates; +- the host config — its `remotes` object gains `store: 'store@store/remoteEntry.js'` via anchored text surgery; +- `repack-federation.json` — a `remotes.store` entry with its `manifest` path and `root`. + +## Diff-before-write and idempotency + +Every run prints the exact diff of every file it intends to write, plus advisories and manual steps, and asks before applying anything (`[y/N]`). Re-running for an already-scaffolded remote is a no-op: all merges are key-level, so manual edits to generated files always survive and no duplicate keys ever appear. If the host config cannot be anchored safely, the plan shows manual instructions instead of guessing — and `--yes` refuses to skip them. + +When the scan sees dynamic `import()` or computed `require()` patterns, the output carries an honesty advisory: dependencies behind them may be missing from the generated config. + +## Shared version divergence + +If an existing remote resolves a shared dependency to a different installed version than the host, `federation-init` shows the version diff and asks: `[a]lign` rewrites the divergent remote's pins to the host's exact versions, `[i]gnore` continues without touching them, `[c]ancel` writes nothing. With `--yes` the alignment happens automatically and every rewritten pin is reported (`react: ^19.1.0 → 19.2.3`) — pins are rewritten, not installed, so run your package manager install afterwards. + +## Exit codes + +- `0` — scaffolding applied, or nothing left to do +- `1` — cancelled at the divergence prompt +- `2` — unusable input or workspace (missing `--name`/feature folder, malformed `repack-federation.json`, standalone refusal, manual steps blocking `--yes`); always a clear message, never a stack trace, and never a partial write From b9a2b081c090b991712dfc0d537561c631a63984 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 09:46:07 +0200 Subject: [PATCH 17/19] refactor(testers): migrate federation tester configs to defineShared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded 19.2.3/0.84.1 pins, pkg.dependencies lookups and react(-native)/package.json proto-defineShared imports in all 8 tester-federation and tester-federation-v2 rspack/webpack configs with Repack.defineShared(SHARED_DEPS, { context, role, mode }): host configs role 'host' (eager), mini configs role 'remote' with the runtime-only mode line via env.argv?.standalone. Exact pins now resolve from the installed workspace — the old committed literals had drifted (react 19.2.3 vs installed 19.2.8, react-native 0.84.1 vs installed 0.86.0). Add apps/tester-federation/repack-federation.json as the demo workspace map (host root+manifest, MiniApp manifest/root/standalone/port) driving zero-flag federation-doctor. E2E smoke evidence (real rspack 1.6.0 + webpack 5.105.4 builds, ios): - host/mini/v2-mini builds: compiled successfully, exit 0 (final configs) - USE_WEBPACK=1 --bundler webpack host build: compiled successfully - zero-flag federation-doctor from apps/tester-federation (config file drives it): 0 errors, 7 EAGER_ADVISORY warnings, exit 0 — the host-eager/remote-lazy pair reports as advisory, not EAGER_MISMATCH - doctor --pairwise across both mini manifests: 0 errors, exit 0 - standalone flip: react-native webpack-bundle ... --standalone on mini -> manifest shows all shared eager=true, git status shows no tracked file changed (runtime-only via env.argv, nothing committed) - manifests agree on every exact pin (doctor reports zero version drift) --- .../configs/rspack.host-app.mts | 57 +++++------------ .../configs/rspack.mini-app.mts | 59 ++++++----------- .../configs/webpack.host-app.mts | 57 +++++------------ .../configs/webpack.mini-app.mts | 59 ++++++----------- .../configs/rspack.host-app.mts | 62 +++++------------- .../configs/rspack.mini-app.mts | 64 ++++++------------- .../configs/webpack.host-app.mts | 62 +++++------------- .../configs/webpack.mini-app.mts | 64 ++++++------------- apps/tester-federation/repack-federation.json | 14 ++++ 9 files changed, 154 insertions(+), 344 deletions(-) create mode 100644 apps/tester-federation/repack-federation.json diff --git a/apps/tester-federation-v2/configs/rspack.host-app.mts b/apps/tester-federation-v2/configs/rspack.host-app.mts index 43fedabe2..523a89e5b 100644 --- a/apps/tester-federation-v2/configs/rspack.host-app.mts +++ b/apps/tester-federation-v2/configs/rspack.host-app.mts @@ -1,8 +1,17 @@ import * as Repack from '@callstack/repack'; import rspack from '@rspack/core'; -import reactPkg from 'react/package.json' with { type: 'json' }; -import reactNativePkg from 'react-native/package.json' with { type: 'json' }; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', +]; export default Repack.defineRspackConfig((env) => { const { mode, context, platform } = env; @@ -52,44 +61,10 @@ export default Repack.defineRspackConfig((env) => { MiniApp: `MiniApp@http://localhost:8082/${platform}/mf-manifest.json`, }, dts: false, - shared: { - react: { - singleton: true, - eager: true, - version: reactPkg.version, - requiredVersion: reactPkg.version, - }, - 'react-native': { - singleton: true, - eager: true, - version: reactNativePkg.version, - requiredVersion: reactNativePkg.version, - }, - '@react-navigation/native': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'host', + }), }), // silence missing @react-native-masked-view optionally required by @react-navigation/elements new rspack.IgnorePlugin({ diff --git a/apps/tester-federation-v2/configs/rspack.mini-app.mts b/apps/tester-federation-v2/configs/rspack.mini-app.mts index c8043906c..a27a43154 100644 --- a/apps/tester-federation-v2/configs/rspack.mini-app.mts +++ b/apps/tester-federation-v2/configs/rspack.mini-app.mts @@ -1,8 +1,17 @@ import * as Repack from '@callstack/repack'; import rspack from '@rspack/core'; -import reactPkg from 'react/package.json' with { type: 'json' }; -import reactNativePkg from 'react-native/package.json' with { type: 'json' }; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', +]; export default Repack.defineRspackConfig((env) => { const { mode, context, platform } = env; @@ -49,44 +58,12 @@ export default Repack.defineRspackConfig((env) => { './MiniAppNavigator': './src/mini/navigation/MainNavigator', }, dts: false, - shared: { - react: { - singleton: true, - eager: false, - version: reactPkg.version, - requiredVersion: reactPkg.version, - }, - 'react-native': { - singleton: true, - eager: false, - version: reactNativePkg.version, - requiredVersion: reactNativePkg.version, - }, - '@react-navigation/native': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'remote', + // `--standalone` is runtime-only (env.argv): never committed. + mode: env.argv?.standalone ? 'standalone' : 'federated', + }), }), // silence missing @react-native-masked-view optionally required by @react-navigation/elements new rspack.IgnorePlugin({ diff --git a/apps/tester-federation-v2/configs/webpack.host-app.mts b/apps/tester-federation-v2/configs/webpack.host-app.mts index 32dc1ac25..bbd255a28 100644 --- a/apps/tester-federation-v2/configs/webpack.host-app.mts +++ b/apps/tester-federation-v2/configs/webpack.host-app.mts @@ -1,9 +1,18 @@ // @ts-check import * as Repack from '@callstack/repack'; -import reactPkg from 'react/package.json' with { type: 'json' }; -import reactNativePkg from 'react-native/package.json' with { type: 'json' }; import webpack from 'webpack'; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', +]; export default Repack.defineWebpackConfig((env) => { const { mode, context, platform } = env; @@ -48,44 +57,10 @@ export default Repack.defineWebpackConfig((env) => { MiniApp: `MiniApp@http://localhost:8082/${platform}/mf-manifest.json`, }, dts: false, - shared: { - react: { - singleton: true, - eager: true, - version: reactPkg.version, - requiredVersion: reactPkg.version, - }, - 'react-native': { - singleton: true, - eager: true, - version: reactNativePkg.version, - requiredVersion: reactNativePkg.version, - }, - '@react-navigation/native': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'host', + }), }), // silence missing @react-native-masked-view optionally required by @react-navigation/elements new webpack.IgnorePlugin({ diff --git a/apps/tester-federation-v2/configs/webpack.mini-app.mts b/apps/tester-federation-v2/configs/webpack.mini-app.mts index fedc3334f..207445011 100644 --- a/apps/tester-federation-v2/configs/webpack.mini-app.mts +++ b/apps/tester-federation-v2/configs/webpack.mini-app.mts @@ -1,8 +1,17 @@ import * as Repack from '@callstack/repack'; -import reactPkg from 'react/package.json' with { type: 'json' }; -import reactNativePkg from 'react-native/package.json' with { type: 'json' }; import webpack from 'webpack'; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', +]; export default Repack.defineWebpackConfig((env) => { const { mode, context, platform } = env; @@ -47,44 +56,12 @@ export default Repack.defineWebpackConfig((env) => { './MiniAppNavigator': './src/mini/navigation/MainNavigator', }, dts: false, - shared: { - react: { - singleton: true, - eager: false, - version: reactPkg.version, - requiredVersion: reactPkg.version, - }, - 'react-native': { - singleton: true, - eager: false, - version: reactNativePkg.version, - requiredVersion: reactNativePkg.version, - }, - '@react-navigation/native': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'remote', + // `--standalone` is runtime-only (env.argv): never committed. + mode: env.argv?.standalone ? 'standalone' : 'federated', + }), }), // silence missing @react-native-masked-view optionally required by @react-navigation/elements new webpack.IgnorePlugin({ diff --git a/apps/tester-federation/configs/rspack.host-app.mts b/apps/tester-federation/configs/rspack.host-app.mts index 238dc550a..edf9ce45d 100644 --- a/apps/tester-federation/configs/rspack.host-app.mts +++ b/apps/tester-federation/configs/rspack.host-app.mts @@ -1,7 +1,19 @@ import * as Repack from '@callstack/repack'; import { RsdoctorRspackPlugin } from '@rsdoctor/rspack-plugin'; import rspack from '@rspack/core'; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', + '@react-native-async-storage/async-storage', +]; export default Repack.defineRspackConfig((env) => { const { mode, context, platform } = env; @@ -43,50 +55,10 @@ export default Repack.defineRspackConfig((env) => { }), new Repack.plugins.ModuleFederationPluginV1({ name: 'HostApp', - shared: { - react: { - singleton: true, - eager: true, - requiredVersion: '19.2.3', - }, - 'react-native': { - singleton: true, - eager: true, - requiredVersion: '0.84.1', - }, - '@react-navigation/native': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - '@react-native-async-storage/async-storage': { - singleton: true, - eager: true, - version: - pkg.dependencies['@react-native-async-storage/async-storage'], - requiredVersion: - pkg.dependencies['@react-native-async-storage/async-storage'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'host', + }), }), new rspack.IgnorePlugin({ resourceRegExp: /^@react-native-masked-view/, diff --git a/apps/tester-federation/configs/rspack.mini-app.mts b/apps/tester-federation/configs/rspack.mini-app.mts index 0e867a2c1..2da9945ef 100644 --- a/apps/tester-federation/configs/rspack.mini-app.mts +++ b/apps/tester-federation/configs/rspack.mini-app.mts @@ -1,7 +1,19 @@ import * as Repack from '@callstack/repack'; import { RsdoctorRspackPlugin } from '@rsdoctor/rspack-plugin'; import rspack from '@rspack/core'; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', + '@react-native-async-storage/async-storage', +]; export default Repack.defineRspackConfig((env) => { const { mode, context, platform } = env; @@ -46,50 +58,12 @@ export default Repack.defineRspackConfig((env) => { exposes: { './MiniAppNavigator': './src/mini/navigation/MainNavigator', }, - shared: { - react: { - singleton: true, - eager: false, - requiredVersion: '19.2.3', - }, - 'react-native': { - singleton: true, - eager: false, - requiredVersion: '0.84.1', - }, - '@react-navigation/native': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - '@react-native-async-storage/async-storage': { - singleton: true, - eager: false, - version: - pkg.dependencies['@react-native-async-storage/async-storage'], - requiredVersion: - pkg.dependencies['@react-native-async-storage/async-storage'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'remote', + // `--standalone` is runtime-only (env.argv): never committed. + mode: env.argv?.standalone ? 'standalone' : 'federated', + }), }), new rspack.IgnorePlugin({ resourceRegExp: /^@react-native-masked-view/, diff --git a/apps/tester-federation/configs/webpack.host-app.mts b/apps/tester-federation/configs/webpack.host-app.mts index 299887b48..9099d076e 100644 --- a/apps/tester-federation/configs/webpack.host-app.mts +++ b/apps/tester-federation/configs/webpack.host-app.mts @@ -1,6 +1,18 @@ import * as Repack from '@callstack/repack'; import webpack from 'webpack'; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', + '@react-native-async-storage/async-storage', +]; export default Repack.defineWebpackConfig((env) => { const { mode, context, platform } = env; @@ -40,50 +52,10 @@ export default Repack.defineWebpackConfig((env) => { // @ts-expect-error new Repack.plugins.ModuleFederationPluginV1({ name: 'HostApp', - shared: { - react: { - singleton: true, - eager: true, - requiredVersion: '19.2.3', - }, - 'react-native': { - singleton: true, - eager: true, - requiredVersion: '0.84.1', - }, - '@react-navigation/native': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: true, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: true, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - '@react-native-async-storage/async-storage': { - singleton: true, - eager: true, - version: - pkg.dependencies['@react-native-async-storage/async-storage'], - requiredVersion: - pkg.dependencies['@react-native-async-storage/async-storage'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'host', + }), }), new webpack.IgnorePlugin({ resourceRegExp: /^@react-native-masked-view/, diff --git a/apps/tester-federation/configs/webpack.mini-app.mts b/apps/tester-federation/configs/webpack.mini-app.mts index 72710e5f1..19a8b9b81 100644 --- a/apps/tester-federation/configs/webpack.mini-app.mts +++ b/apps/tester-federation/configs/webpack.mini-app.mts @@ -1,6 +1,18 @@ import * as Repack from '@callstack/repack'; import webpack from 'webpack'; -import pkg from '../package.json' with { type: 'json' }; + +// Shared dependencies are versionless here: defineShared pins every entry to +// the exact version installed in this workspace, so host and remotes always +// agree without hand-maintained literals. +const SHARED_DEPS = [ + 'react', + 'react-native', + '@react-navigation/native', + '@react-navigation/native-stack', + 'react-native-safe-area-context', + 'react-native-screens', + '@react-native-async-storage/async-storage', +]; export default Repack.defineWebpackConfig((env) => { const { mode, context, platform } = env; @@ -44,50 +56,12 @@ export default Repack.defineWebpackConfig((env) => { exposes: { './MiniAppNavigator': './src/mini/navigation/MainNavigator', }, - shared: { - react: { - singleton: true, - eager: false, - requiredVersion: '19.2.3', - }, - 'react-native': { - singleton: true, - eager: false, - requiredVersion: '0.84.1', - }, - '@react-navigation/native': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native'], - requiredVersion: pkg.dependencies['@react-navigation/native'], - }, - '@react-navigation/native-stack': { - singleton: true, - eager: false, - version: pkg.dependencies['@react-navigation/native-stack'], - requiredVersion: pkg.dependencies['@react-navigation/native-stack'], - }, - 'react-native-safe-area-context': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-safe-area-context'], - requiredVersion: pkg.dependencies['react-native-safe-area-context'], - }, - 'react-native-screens': { - singleton: true, - eager: false, - version: pkg.dependencies['react-native-screens'], - requiredVersion: pkg.dependencies['react-native-screens'], - }, - '@react-native-async-storage/async-storage': { - singleton: true, - eager: false, - version: - pkg.dependencies['@react-native-async-storage/async-storage'], - requiredVersion: - pkg.dependencies['@react-native-async-storage/async-storage'], - }, - }, + shared: Repack.defineShared(SHARED_DEPS, { + context, + role: 'remote', + // `--standalone` is runtime-only (env.argv): never committed. + mode: env.argv?.standalone ? 'standalone' : 'federated', + }), }), new webpack.IgnorePlugin({ resourceRegExp: /^@react-native-masked-view/, diff --git a/apps/tester-federation/repack-federation.json b/apps/tester-federation/repack-federation.json new file mode 100644 index 000000000..52dd84c1e --- /dev/null +++ b/apps/tester-federation/repack-federation.json @@ -0,0 +1,14 @@ +{ + "host": { + "root": ".", + "manifest": "build/host-app/ios" + }, + "remotes": { + "MiniApp": { + "root": ".", + "manifest": "build/mini-app/ios", + "standalone": true, + "port": 8082 + } + } +} From 49716daa5b120896b1f7a1cbe2adbed0129f8e0b Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 09:49:32 +0200 Subject: [PATCH 18/19] fix(repack): pass CLI positional args and discover init-generated configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects surfaced by the 11.2 e2e smoke on real tester builds: 1. react-native federation-manifest/federation-init crashed under @react-native-community/cli >= 17 (commander wiring): positionals not declared in the command name never reach func — argv[0] carries the parsed options object, so path.resolve threw ERR_INVALID_ARG_TYPE even with --source given. Fix: declare optional positionals in the command names ('federation-manifest [source]', 'federation-init [feature-folder]') and trust only string argv[0], falling back to parsed args otherwise. Reproduced live pre-fix, verified post-fix from apps/tester-federation and a scratch init dogfood workspace. 2. federation-doctor --dry-run could not read the shared setup of remotes scaffolded by federation-init, which generates rspack..mts — not a conventional rspack.config.* name. extractAppShared now falls back to a tooling-style rspack.. config when it is the only rspack-prefixed candidate (webpack second, ambiguity never guessed). Strict TDD: RED 1 failing discovery test + 3 new command/registry tests failing for the wiring reason; GREEN 85/85 focused, full 55 suites/607 tests, typecheck, lint:ci. Live post-fix: zero-flag doctor exit 0 with EAGER_ADVISORY only; --dry-run on an init-scaffolded scratch workspace exit 0 (host via conventional name, remote via the new fallback); real CLI 20 federation-init applied 5 files, idempotent re-run 'Nothing to do' --- .../commands/__tests__/federationInit.test.ts | 15 ++++++ .../__tests__/federationManifest.test.ts | 14 ++++++ .../src/commands/__tests__/index.test.ts | 27 ++++++++-- .../apps/ambiguous/package.json | 1 + .../apps/ambiguous/rspack.a.cjs | 1 + .../apps/ambiguous/rspack.b.cjs | 1 + .../apps/remote-generated/package.json | 7 +++ .../apps/remote-generated/rspack.store.cjs | 23 +++++++++ .../__tests__/extractShared.test.ts | 18 +++++++ .../src/commands/federation/extractShared.ts | 50 +++++++++++++++++-- .../repack/src/commands/federationInit.ts | 4 +- .../repack/src/commands/federationManifest.ts | 6 ++- packages/repack/src/commands/index.ts | 8 ++- 13 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.a.cjs create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.b.cjs create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/package.json create mode 100644 packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/rspack.store.cjs diff --git a/packages/repack/src/commands/__tests__/federationInit.test.ts b/packages/repack/src/commands/__tests__/federationInit.test.ts index 1622f0656..138f9c723 100644 --- a/packages/repack/src/commands/__tests__/federationInit.test.ts +++ b/packages/repack/src/commands/__tests__/federationInit.test.ts @@ -102,6 +102,21 @@ describe('federation-init refusals — exit 2, clear message, no writes', () => expect(hashTree(root)).toEqual(before); }); + it('treats a non-string argv[0] (commander options object) as no folder given', async () => { + // RN CLI >= 17 passes the parsed options object as argv[0] whenever no + // positional was captured; path.resolve must never receive it. + const before = hashTree(root); + await federationInit( + [{ name: 'store' }] as unknown as string[], + cliConfigFor(root), + { name: 'store' } + ); + + expect(stderr()).toContain('No feature folder given'); + expect(exit).toHaveBeenCalledWith(2); + expect(hashTree(root)).toEqual(before); + }); + it('requires --name', async () => { await federationInit( [path.join(root, 'features', 'store')], diff --git a/packages/repack/src/commands/__tests__/federationManifest.test.ts b/packages/repack/src/commands/__tests__/federationManifest.test.ts index fb75fd8ee..76384c740 100644 --- a/packages/repack/src/commands/__tests__/federationManifest.test.ts +++ b/packages/repack/src/commands/__tests__/federationManifest.test.ts @@ -76,6 +76,20 @@ describe('federation-manifest command', () => { expect(exit).not.toHaveBeenCalled(); }); + it('ignores a non-string argv[0] (commander options object) and uses --source', async () => { + // RN CLI >= 17 passes the parsed options object as argv[0] whenever no + // positional was captured; it must not be mistaken for the source. + await federationManifest( + [{ source: HOST_FILE, json: true }] as unknown as string[], + cliConfig, + { source: HOST_FILE, json: true } + ); + + expect(log).toHaveBeenCalledTimes(1); + expect(JSON.parse(log.mock.calls[0][0] as string).name).toBe('shell'); + expect(exit).not.toHaveBeenCalled(); + }); + it('exits 2 when no source is given', async () => { await federationManifest([], cliConfig, {}); diff --git a/packages/repack/src/commands/__tests__/index.test.ts b/packages/repack/src/commands/__tests__/index.test.ts index 533ebd1ce..48eb56c20 100644 --- a/packages/repack/src/commands/__tests__/index.test.ts +++ b/packages/repack/src/commands/__tests__/index.test.ts @@ -52,17 +52,34 @@ describe('createBoundCommands', () => { }); describe('command registry', () => { + test('commands reading a positional argument declare it in the name', () => { + // @react-native-community/cli >= 17 wires plugin commands through + // commander: a positional that is not declared in the command name never + // reaches `func` — argv[0] carries the parsed options object instead and + // `react-native federation-manifest ` crashes in path.resolve. + // Optional positional declarations make the CLI pass the value through. + const names = commands.map((command) => command.name); + expect(names).toEqual( + expect.arrayContaining([ + 'federation-manifest [source]', + 'federation-init [feature-folder]', + ]) + ); + }); + test('federation-init is a flat command alongside the other federation commands', () => { const names = commands.map((command) => command.name); expect(names).toEqual( expect.arrayContaining([ - 'federation-init', + expect.stringMatching(/^federation-init/), 'federation-doctor', - 'federation-manifest', + expect.stringMatching(/^federation-manifest/), ]) ); - const init = commands.find((command) => command.name === 'federation-init'); + const init = commands.find((command) => + command.name.startsWith('federation-init') + ); // Flat RN-CLI command object: name/description/options/func, no // subcommand tree. expect(typeof init?.func).toBe('function'); @@ -73,6 +90,8 @@ describe('command registry', () => { test('federation-init is not exposed through the deprecated bound entry points', () => { const names = createBoundCommands('webpack').map((command) => command.name); - expect(names).not.toContain('federation-init'); + expect(names.some((name) => name.startsWith('federation-init'))).toBe( + false + ); }); }); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/package.json new file mode 100644 index 000000000..9f2da0f17 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/package.json @@ -0,0 +1 @@ +{ "name": "ambiguous", "version": "0.0.0" } diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.a.cjs b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.a.cjs new file mode 100644 index 000000000..ff5132270 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.a.cjs @@ -0,0 +1 @@ +module.exports = () => ({ plugins: [] }); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.b.cjs b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.b.cjs new file mode 100644 index 000000000..ff5132270 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/ambiguous/rspack.b.cjs @@ -0,0 +1 @@ +module.exports = () => ({ plugins: [] }); diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/package.json b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/package.json new file mode 100644 index 000000000..b1aa822e8 --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/package.json @@ -0,0 +1,7 @@ +{ + "name": "remote-generated", + "version": "0.0.0", + "dependencies": { + "react": "9.9.9" + } +} diff --git a/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/rspack.store.cjs b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/rspack.store.cjs new file mode 100644 index 000000000..fd965d42f --- /dev/null +++ b/packages/repack/src/commands/federation/__fixtures__/dry-run-workspace/apps/remote-generated/rspack.store.cjs @@ -0,0 +1,23 @@ +// CJS stub of a federation-init generated remote config (`rspack..mts` +// in production; `.cjs` so jest can evaluate it). The app dir carries no +// conventional `rspack.config.*` file — extraction must discover this. +class ModuleFederationPluginV1 { + constructor(config) { + this.config = config; + } + + getSharedConfiguration() { + return this.config.shared; + } +} + +module.exports = () => ({ + plugins: [ + new ModuleFederationPluginV1({ + name: 'store', + shared: { + react: { singleton: true, eager: false, version: '9.9.9' }, + }, + }), + ], +}); diff --git a/packages/repack/src/commands/federation/__tests__/extractShared.test.ts b/packages/repack/src/commands/federation/__tests__/extractShared.test.ts index aec20a7bd..b4787f6e5 100644 --- a/packages/repack/src/commands/federation/__tests__/extractShared.test.ts +++ b/packages/repack/src/commands/federation/__tests__/extractShared.test.ts @@ -103,6 +103,24 @@ describe('extractAppShared', () => { } }); + it('discovers a single init-style rspack. config when no conventional file exists', async () => { + // federation-init generates `rspack..mts` — not a conventional + // name. dry-run extraction must still find it when it is the only + // rspack-prefixed config in the app dir. + const extracted = await extractAppShared(appDir('remote-generated')); + + expect(extracted.name).toBe('store'); + expect(extracted.shared).toEqual([ + expect.objectContaining({ name: 'react', eager: false }), + ]); + }); + + it('refuses to guess between several init-style configs', async () => { + await expect(extractAppShared(appDir('ambiguous'))).rejects.toThrow( + ConfigEvalError + ); + }); + it('rejects an app directory with no bundler configuration at all', async () => { const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-nocfg-')); try { diff --git a/packages/repack/src/commands/federation/extractShared.ts b/packages/repack/src/commands/federation/extractShared.ts index 426449ed3..38188ebbb 100644 --- a/packages/repack/src/commands/federation/extractShared.ts +++ b/packages/repack/src/commands/federation/extractShared.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import { buildSharedEntries } from '../../plugins/federationManifest/shared.js'; import type { FederationManifestSharedEntry } from '../../plugins/federationManifest/types.js'; @@ -39,6 +40,42 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * `rspack..` / `webpack..` — the shape federation-init + * generates for scaffolded remotes (`rspack.store.mts`). Not conventional + * bundler entry names, but a tool-generated workspace should not need a + * conventional name for the tool's own checks to find its configs. + */ +const TOOLING_STYLE_CONFIG = /^(rspack|webpack)\..+\.(mts|cts|ts|mjs|cjs|js)$/; + +/** + * Last-resort discovery for app dirs that carry a tool-generated config + * instead of a conventional `rspack.config.*`: use it only when the choice + * is unambiguous — one rspack-style file wins (bundler preference order), + * otherwise one webpack-style file; several candidates is a guess, and the + * dry-run never guesses. + */ +function discoverToolingStyleConfigPath(root: string): string | null { + let candidates: string[]; + try { + candidates = fs.readdirSync(root); + } catch { + return null; + } + const byBundler = { rspack: [] as string[], webpack: [] as string[] }; + for (const entry of candidates) { + const match = TOOLING_STYLE_CONFIG.exec(entry); + if (match) byBundler[match[1] as 'rspack' | 'webpack'].push(entry); + } + const preferred = + byBundler.rspack.length === 1 + ? byBundler.rspack + : byBundler.rspack.length === 0 && byBundler.webpack.length === 1 + ? byBundler.webpack + : null; + return preferred ? path.join(root, preferred[0]) : null; +} + function discoverConfigPath(root: string, customPath?: string): string { // Same discovery order the bundler commands use (rspack first), with an // explicit --config-style path always winning. @@ -50,11 +87,16 @@ function discoverConfigPath(root: string, customPath?: string): string { try { return getConfigFilePath('webpack', root, customPath); } catch { - throw new ConfigEvalError( - `No bundler configuration found in ${root} — the dry-run reads the ` + - 'shared setup from the app rspack or webpack configuration.' - ); + // fall through to the tool-generated naming + } + if (customPath === undefined) { + const toolingStyle = discoverToolingStyleConfigPath(root); + if (toolingStyle !== null) return toolingStyle; } + throw new ConfigEvalError( + `No bundler configuration found in ${root} — the dry-run reads the ` + + 'shared setup from the app rspack or webpack configuration.' + ); } /** diff --git a/packages/repack/src/commands/federationInit.ts b/packages/repack/src/commands/federationInit.ts index c2acec1d9..8369acdb5 100644 --- a/packages/repack/src/commands/federationInit.ts +++ b/packages/repack/src/commands/federationInit.ts @@ -70,7 +70,9 @@ export async function federationInit( return; } - const folderArg = argv[0]; + // RN CLI >= 17 hands `argv` the commander-parsed values; an uncaptured + // optional positional surfaces as the options object, never a path. + const folderArg = typeof argv[0] === 'string' ? argv[0] : undefined; if (!folderArg) { fail( 'No feature folder given: pass the folder to scaffold as the first ' + diff --git a/packages/repack/src/commands/federationManifest.ts b/packages/repack/src/commands/federationManifest.ts index 5ccb98b35..4c5054c73 100644 --- a/packages/repack/src/commands/federationManifest.ts +++ b/packages/repack/src/commands/federationManifest.ts @@ -19,7 +19,11 @@ export async function federationManifest( _cliConfig: CliConfig, args: FederationManifestArguments ) { - const source = argv[0] ?? args.source; + // RN CLI >= 17 hands `argv` the commander-parsed values; an uncaptured + // optional positional surfaces as the options object, never a path. Only + // trust a string; anything else falls through to `--source`. + const positional = typeof argv[0] === 'string' ? argv[0] : undefined; + const source = positional ?? args.source; if (!source) { console.error( 'No manifest source given. Pass it as the first argument or with ' + diff --git a/packages/repack/src/commands/index.ts b/packages/repack/src/commands/index.ts index 65b52f7e3..5620dd4aa 100644 --- a/packages/repack/src/commands/index.ts +++ b/packages/repack/src/commands/index.ts @@ -46,7 +46,10 @@ const bundlerCommands = [ const federationCommands = [ { - name: 'federation-manifest', + // Optional positional declared in the name: RN CLI >= 17 routes plugin + // commands through commander, which only forwards positionals that the + // command name declares — otherwise argv[0] is the options object. + name: 'federation-manifest [source]', description: 'Inspect a federation manifest from a file, directory or URL.', options: federationManifestCommandOptions, func: federationManifest, @@ -59,7 +62,8 @@ const federationCommands = [ func: federationDoctor, }, { - name: 'federation-init', + // Positional declaration required for RN CLI >= 17 (see federation-manifest). + name: 'federation-init [feature-folder]', description: 'Scaffold a new federation remote from a feature folder: scanned deps, versionless defineShared configs and workspace registration, all diffed before any write.', options: federationInitCommandOptions, From 6b6758fe292cc3dd90cb15b67a389c33a0d8f145 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 09:50:41 +0200 Subject: [PATCH 19/19] docs(repack): federation workspace workflow guide and design-doc corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - website: new docs/features/federation-workspace.md tying defineShared, repack-federation.json, federation-doctor (zero-flag/--dry-run/ --pairwise/EAGER_ADVISORY) and federation-init into one workflow guide, wired into the features _meta. - agent_context/federation-tools/design.md: PR 4 command-surface correction — the init codemod ships as the flat `react-native federation-init` command (no repack bin, no subcommand tree, no shared.config.ts convention) — plus as-built notes for defineShared, the workspace-map schema, doctor extensions, init behavior, and the RN CLI >= 17 positional-declaration contract. API doc completeness verified (not re-edited): define-shared.md, repack-federation-json.mdx, federation-init.mdx and federation-doctor.mdx already cover --dry-run/--pairwise/advisory semantics and --standalone (bundle/start pages). --- agent_context/federation-tools/design.md | 47 ++++++ website/src/latest/docs/features/_meta.json | 1 + .../docs/features/federation-workspace.md | 137 ++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 website/src/latest/docs/features/federation-workspace.md diff --git a/agent_context/federation-tools/design.md b/agent_context/federation-tools/design.md index 5a01ee7d3..cf729704d 100644 --- a/agent_context/federation-tools/design.md +++ b/agent_context/federation-tools/design.md @@ -174,6 +174,13 @@ Each PR is shippable alone and lands with docs in the same PR. (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. + - **Command surface correction (as shipped).** No `repack federation init` + subcommand tree: the codemod is the flat `react-native federation-init` + command, alongside `federation-manifest`/`federation-doctor` in the same + `commands` array. And no `shared.config.ts` convention: the committed + workspace file is `repack-federation.json` (the host/remotes map for the + tools); shared versions are never literals at all — `defineShared` + resolves exact pins from the installed packages at build time. - **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 @@ -258,6 +265,46 @@ Deltas from the design above, all deliberate: with exit 2 — results from an unparseable manifest cannot be trusted, so the escape hatch deliberately does not cover it. +## PR 4 implementation notes (as built) + +- `defineShared(deps, { context, role, mode })` exported from + `@callstack/repack`: exact pins resolve from the installed packages (never + committed literals or ranges), `eager` is the role+mode convention — host + eager / remote federated-lazy / standalone all-eager — never an identity. + `--standalone` reaches configs through `env.argv` only and is never + persisted to any file. +- `repack-federation.json` is the committed workspace map — + `{ host: { manifest, root? }, remotes: { name: { manifest, root?, + standalone?, port? } } }`, strict schema, unknown keys invalid. It drives + zero-flag `federation-doctor`, is the only workspace source for + `federation-init` (no `--config` passthrough by design), and gates + `--standalone`: a remote entry without `"standalone": true` is refused + before compiling. `port` is declared for PR 5, unused today. +- Doctor extensions: `--dry-run` pre-build mode (package.json + bundler + configs + workspace map only, every finding carries the unbuilt caveat), + opt-in `--pairwise` (shared-only remote↔remote; native checks stay + host↔remote), `EAGER_ADVISORY` warning for the host-eager/remote-lazy + convention (only other eager splits remain `EAGER_MISMATCH` errors), + host-native-first report ordering, no fail-fast. +- `federation-init --name ` scans the folder + statically (dynamic imports → explicit non-exhaustive advisories), + generates versionless `rspack..mts` / `webpack..mts` + configs on `defineShared`, merges scanned deps ∩ host provides into the + remote `package.json` at host versions, anchors the host `remotes` + registration and the workspace-map entry — everything planned and diffed + before any write, `--yes` pre-approves and auto-aligns divergent pins, + re-runs are idempotent ("Nothing to do"). +- RN CLI >= 17 positional contract: a command reading a positional argument + must declare it in the command name (`federation-manifest [source]`, + `federation-init [feature-folder]`) — otherwise commander passes the + parsed options object as `argv[0]`. Command implementations additionally + trust only string `argv[0]`. Dry-run config extraction also discovers + tooling-style `rspack..*` configs when unambiguous, so + init-scaffolded remotes are checkable without conventional filenames + (apps keeping fully custom names, like the tester apps' + `config..mts` pair in one directory, still need `--config`-style + flows from the PR 5 runner). + ## Referenced surface (verified 2026-09) - `packages/repack/src/plugins/ModuleFederationPluginV1.ts` / `V2.ts` — no diff --git a/website/src/latest/docs/features/_meta.json b/website/src/latest/docs/features/_meta.json index fe3e9adde..265c66b74 100644 --- a/website/src/latest/docs/features/_meta.json +++ b/website/src/latest/docs/features/_meta.json @@ -3,6 +3,7 @@ "code-splitting", "module-federation", "federation-manifest", + "federation-workspace", "dev-server", "flow-support", "devtools", diff --git a/website/src/latest/docs/features/federation-workspace.md b/website/src/latest/docs/features/federation-workspace.md new file mode 100644 index 000000000..b774222e8 --- /dev/null +++ b/website/src/latest/docs/features/federation-workspace.md @@ -0,0 +1,137 @@ +# Federation Workspace + +A Module Federation setup is one system: a host, its remotes, and the shared +dependencies they must agree on. When that agreement lives in eight hand-edited +config files, it drifts — versions go stale, eager flags stop matching, and the +crash shows up in production. The federation workspace tools make the agreement +a single source of truth that is derived, checked and scaffolded instead of +hand-maintained: + +- [`defineShared`](/api/utils/define-shared) — builds the `shared` option with + exact pins resolved from what is actually installed. +- [`repack-federation.json`](/api/cli/repack-federation-json) — the committed + map of the workspace: which host, which remotes, where their manifests are. +- [`federation-doctor`](/api/cli/federation-doctor) — the CI gate that checks + the workspace, before or after a build. +- [`federation-init`](/api/cli/federation-init) — scaffolds a new remote from + a feature folder, wired into all of the above. + +## One source of truth for shared dependencies + +Versions of shared dependencies are never literals in your configs. List the +packages, and `defineShared` pins each one to the exact installed version at +build time — host and remotes resolve from the same install, so their manifests +agree by construction: + +```ts +// configs/rspack.host-app.mts +import * as Repack from '@callstack/repack'; + +const SHARED_DEPS = ['react', 'react-native', '@react-navigation/native']; + +export default Repack.defineRspackConfig((env) => ({ + // ... + plugins: [ + new Repack.plugins.ModuleFederationPluginV1({ + name: 'HostApp', + shared: Repack.defineShared(SHARED_DEPS, { + context: env.context, + role: 'host', + }), + }), + ], +})); +``` + +A remote config is the same call with `role: 'remote'` plus the standalone +mode line: + +```ts +shared: Repack.defineShared(SHARED_DEPS, { + context: env.context, + role: 'remote', + // `--standalone` is runtime-only (env.argv): never committed. + mode: env.argv?.standalone ? 'standalone' : 'federated', +}), +``` + +`eager` follows the Module Federation convention from `role` and `mode`: the +host is eager, federated remotes are lazy, and a build with +`react-native webpack-bundle ... --standalone` makes everything eager so the +remote runs without the host. The flag travels through `env.argv` only — there +is no mode file to forget to revert. + +## Declaring the workspace + +`repack-federation.json` at the workspace root tells the tools what the setup +is. Paths are relative to the file; unknown fields are rejected, so typos fail +loudly: + +```json +{ + "host": { "root": ".", "manifest": "build/host-app/ios" }, + "remotes": { + "MiniApp": { + "root": ".", + "manifest": "build/mini-app/ios", + "standalone": true, + "port": 8082 + } + } +} +``` + +`standalone: true` declares that a remote supports standalone builds; running +`--standalone` for a remote that does not declare it is refused before +anything compiles. `port` is consumed by future tooling. + +## Checking the workspace + +With the file in place, the doctor needs no flags — run it from anywhere inside +the workspace: + +```sh +# after building host and remotes (manifests required) +npx react-native federation-doctor + +# before anything is built: package.json + configs only +npx react-native federation-doctor --dry-run + +# additionally compare remotes with each other (shared-only, opt-in) +npx react-native federation-doctor --pairwise +``` + +Exit codes are CI-shaped: `0` clean (warnings allowed), `1` drift found, `2` +the check could not run. A host that is eager where a remote is lazy — the +expected convention, not drift — is reported as an `EAGER_ADVISORY` warning, +not an error. See +[`federation-doctor`](/api/cli/federation-doctor) for every finding code. + +## Adding a remote + +`federation-init` turns a feature folder into a registered remote: it scans +the folder's imports, generates versionless `rspack..mts` / +`webpack..mts` configs on `defineShared`, merges the scanned +dependencies (at the host's exact versions) into the remote's `package.json`, +registers the remote in the host's federation plugin and in +`repack-federation.json` — and shows every diff before writing any of it: + +```sh +npx react-native federation-init ./features/store --name store +``` + +`--yes` pre-approves the diffs and auto-aligns divergent pins for CI-style +runs; re-running is idempotent. The dependencies are declared, not installed — +run your package manager afterwards. + +## End to end + +1. Write configs with `defineShared` (host: `role: 'host'`, remotes: + `role: 'remote'` + the `env.argv` mode line). +2. Commit `repack-federation.json` with host and remotes. +3. Gate CI with `federation-doctor` post-build and `--dry-run` pre-build. +4. Grow the workspace with `federation-init`; `--standalone` any remote that + declares it. + +Worked example: `apps/tester-federation` in the Re.Pack repository is a +complete host + remote workspace built exactly this way.