From 8686d8ed299cdff2215bee9e1336d89418b75d21 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 21 Sep 2026 11:36:46 +0200 Subject: [PATCH 01/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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. From d660cdad6fcf3a798a075cb0d456dd3dc0aa63d4 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:01:47 +0200 Subject: [PATCH 20/54] feat(repack): add config and host port fields to federation config schema --- .../config-dev-twin/config.host-app.mts | 3 + .../config-dev-twin/config.mini-app.mts | 3 + .../__fixtures__/config-dev-twin/package.json | 6 ++ .../config-dev-twin/repack-federation.json | 17 ++++ .../federation/__tests__/configFile.test.ts | 96 ++++++++++++++++++- .../src/commands/federation/configFile.ts | 36 ++++++- 6 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.host-app.mts create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.mini-app.mts create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/package.json create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/repack-federation.json diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.host-app.mts b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.host-app.mts new file mode 100644 index 000000000..207cfc8e0 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.host-app.mts @@ -0,0 +1,3 @@ +// Twin-layout fixture: a per-app bundler config whose NAME matches no +// discovery rule, mirroring tester-federation's config.host-app.mts. +export default { plugins: [] }; diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.mini-app.mts b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.mini-app.mts new file mode 100644 index 000000000..6c7790163 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/config.mini-app.mts @@ -0,0 +1,3 @@ +// Twin-layout fixture: the twin's per-app bundler config, same naming +// blindness as config.host-app.mts. +export default { plugins: [] }; diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/package.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/package.json new file mode 100644 index 000000000..835c35ec2 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/package.json @@ -0,0 +1,6 @@ +{ + "name": "config-dev-twin", + "dependencies": { + "react": "19.1.0" + } +} diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/repack-federation.json new file mode 100644 index 000000000..8b71b03a9 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-dev-twin/repack-federation.json @@ -0,0 +1,17 @@ +{ + "host": { + "manifest": "./build/host-app/ios", + "root": ".", + "config": "config.host-app.mts", + "port": 8081 + }, + "remotes": { + "MiniApp": { + "manifest": "./build/mini-app/ios", + "root": ".", + "standalone": true, + "port": 8082, + "config": "config.mini-app.mts" + } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/configFile.test.ts b/packages/repack/src/commands/federation/__tests__/configFile.test.ts index 9f2a5a844..de94223a8 100644 --- a/packages/repack/src/commands/federation/__tests__/configFile.test.ts +++ b/packages/repack/src/commands/federation/__tests__/configFile.test.ts @@ -50,11 +50,17 @@ describe('validateFederationConfig', () => { it('accepts the full document with every optional field', () => { expect( validateFederationConfig({ - host: { manifest: './shell/build', root: '.' }, + host: { + manifest: './shell/build', + root: '.', + config: 'config.host-app.mts', + port: 8081, + }, remotes: { store: { manifest: './store/build', root: './apps/store', + config: 'config.store.mts', standalone: true, port: 8082, }, @@ -63,6 +69,48 @@ describe('validateFederationConfig', () => { ).toEqual([]); }); + it('accepts per-app config fields on the host and on remotes', () => { + // Delta spec "Valid document with config fields": both values must + // validate and survive for consumers (preservation pinned in load tests). + expect( + validateFederationConfig({ + host: { + manifest: './build/host-app/ios', + config: 'config.host-app.mts', + }, + remotes: { + MiniApp: { + manifest: './build/mini-app/ios', + config: 'config.mini-app.mts', + }, + }, + }) + ).toEqual([]); + }); + + it('still rejects wrong-typed or unknown config-adjacent fields', () => { + // Delta spec "Wrong-typed or unknown fields still invalid": the additive + // fields must not soften strictness — each reason names its field path. + expect( + validateFederationConfig({ + host: { manifest: '.', config: 42 }, + remotes: {}, + }) + ).toEqual(['host.config must be a string']); + expect( + validateFederationConfig({ + host: { manifest: '.', bundleConfig: 'config.x.mts' }, + remotes: {}, + }) + ).toEqual(['host.bundleConfig is not a known field']); + expect( + validateFederationConfig({ + host: { manifest: '.', port: 'x' }, + remotes: {}, + }) + ).toEqual(['host.port must be a number']); + }); + it('rejects an unknown top-level key naming its path', () => { expect( validateFederationConfig({ @@ -166,6 +214,37 @@ describe('loadFederationConfig', () => { }); }); + it('preserves declared config and host port fields for consumers', () => { + const dir = path.join(tmpDir, 'config-fields'); + fs.mkdirSync(dir); + fs.writeFileSync( + path.join(dir, FEDERATION_CONFIG_FILENAME), + JSON.stringify({ + host: { + manifest: './build/host-app/ios', + config: 'config.host-app.mts', + port: 8081, + }, + remotes: { + MiniApp: { + manifest: './build/mini-app/ios', + config: 'config.mini-app.mts', + }, + }, + }) + ); + const loaded = loadFederationConfig({ cwd: dir }); + expect(loaded!.config.host).toEqual({ + manifest: './build/host-app/ios', + config: 'config.host-app.mts', + port: 8081, + }); + expect(loaded!.config.remotes.MiniApp).toEqual({ + manifest: './build/mini-app/ios', + config: 'config.mini-app.mts', + }); + }); + it('rejects malformed JSON naming the file and never a stack', () => { let caught: unknown; try { @@ -272,6 +351,21 @@ describe('resolveFederationWorkspace', () => { expect(ws.remotes[0]!.source).toBe('http://localhost:8082'); }); + it('resolves declared config against the config file directory, not caller cwd', () => { + // Delta spec "Config path resolves against the config file's directory": + // invoked from a deep nested cwd, the config paths still anchor at the + // file's own directory, absolute. + const twinDir = path.join(FIXTURES, 'config-dev-twin'); + const ws = resolveFederationWorkspace( + path.join(twinDir, 'deep', 'nested', 'cwd'), + {} + ); + expect(ws.host!.config).toBe(path.join(twinDir, 'config.host-app.mts')); + expect(ws.remotes[0]!.config).toBe( + path.join(twinDir, 'config.mini-app.mts') + ); + }); + it('applies per-value precedence: --host overrides only the host', () => { const ws = resolveFederationWorkspace(VALID_DIR, { host: '/tmp/other/build', diff --git a/packages/repack/src/commands/federation/configFile.ts b/packages/repack/src/commands/federation/configFile.ts index 1b9949e5d..e12786268 100644 --- a/packages/repack/src/commands/federation/configFile.ts +++ b/packages/repack/src/commands/federation/configFile.ts @@ -11,6 +11,13 @@ export interface FederationHostConfig { manifest: string; /** App root, for consumers that need it (init, dry-run). */ root?: string; + /** + * Path to this app's bundler config, resolved against the config-file + * directory. Absent ⇒ consumers fall back to the app's own discovery. + */ + config?: string; + /** Dev-server port. Consumed by `federation-dev` as the host port default. */ + port?: number; } /** One named entry of the `remotes` map. */ @@ -19,8 +26,10 @@ export interface FederationRemoteConfig { root?: string; /** Whether this remote supports `--standalone` mode. */ standalone?: boolean; - /** Dev-server port. Declared for the later runner/wizard PR; unused today. */ + /** Dev-server port. Consumed by `federation-dev` as the declared port. */ port?: number; + /** Per-app bundler config path, same semantics as `host.config`. */ + config?: string; } export interface FederationConfig { @@ -93,8 +102,9 @@ function checkFields( /** * 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. + * schema: `{ host: { manifest, root?, config?, port? }, remotes: { name: + * { manifest, root?, standalone?, port?, config? } } }`, strictly — unknown + * keys anywhere are invalid. * Returns the reasons the document is invalid (empty when valid); every * reason names the offending field path. */ @@ -120,7 +130,12 @@ export function validateFederationConfig(document: unknown): string[] { } else { checkFields( host, - { manifest: requireString, root: optionalString }, + { + manifest: requireString, + root: optionalString, + config: optionalString, + port: optionalNumber, + }, 'host', reasons ); @@ -151,6 +166,7 @@ export function validateFederationConfig(document: unknown): string[] { root: optionalString, standalone: optionalBoolean, port: optionalNumber, + config: optionalString, }, where, reasons @@ -244,6 +260,8 @@ export function loadFederationConfig(options: { cwd?: string } = {}): { export interface ResolvedEntry { source: string; root?: string; + /** Declared bundler config, absolute against the config-file directory. */ + config?: string; } export interface ResolvedRemote { @@ -253,6 +271,8 @@ export interface ResolvedRemote { root?: string; standalone?: boolean; port?: number; + /** Declared bundler config, absolute against the config-file directory. */ + config?: string; } export interface ResolvedWorkspace { @@ -320,10 +340,13 @@ export function resolveFederationWorkspace( if (flags.host !== undefined) { workspace.host = { source: flags.host }; } else if (loaded) { - const { manifest, root } = loaded.config.host; + const { manifest, root, config } = loaded.config.host; workspace.host = { source: resolveSource(manifest, configDir), ...(root === undefined ? {} : { root: path.resolve(configDir, root) }), + ...(config === undefined + ? {} + : { config: path.resolve(configDir, config) }), }; } @@ -341,6 +364,9 @@ export function resolveFederationWorkspace( ? {} : { standalone: entry.standalone }), ...(entry.port === undefined ? {} : { port: entry.port }), + ...(entry.config === undefined + ? {} + : { config: path.resolve(configDir, entry.config) }), }) ); } From 9743ffa4b7268bfd4116672f7822b245e282a615 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:03:44 +0200 Subject: [PATCH 21/54] refactor(repack): make federation standalone gate name-keyed --- .../federation/__tests__/configFile.test.ts | 45 +++++++++++++++++++ .../src/commands/federation/configFile.ts | 36 +++++++++++---- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/packages/repack/src/commands/federation/__tests__/configFile.test.ts b/packages/repack/src/commands/federation/__tests__/configFile.test.ts index de94223a8..27ba3889b 100644 --- a/packages/repack/src/commands/federation/__tests__/configFile.test.ts +++ b/packages/repack/src/commands/federation/__tests__/configFile.test.ts @@ -3,10 +3,12 @@ import os from 'node:os'; import path from 'node:path'; import { CLIError } from '../../../helpers/index.js'; import { + assertRemoteStandalone, assertStandaloneSupported, ConfigFileInvalidError, describeJsonParseFailure, FEDERATION_CONFIG_FILENAME, + type FederationConfig, findConfigPath, loadFederationConfig, resolveFederationWorkspace, @@ -482,3 +484,46 @@ describe('assertStandaloneSupported', () => { expect(message).not.toMatch(/\n\s+at\s/); }); }); + +describe('assertRemoteStandalone', () => { + const configPath = path.join( + FIXTURES, + 'config-standalone', + FEDERATION_CONFIG_FILENAME + ); + const config: FederationConfig = { + host: { manifest: './manifests/host.json', root: '.' }, + remotes: { + supported: { + manifest: './manifests/supported.json', + standalone: true, + }, + undeclared: { manifest: './manifests/undeclared.json' }, + declined: { manifest: './manifests/declined.json', standalone: false }, + }, + }; + + it('refuses a name without standalone: true, naming remote + field + file', () => { + expect(() => + assertRemoteStandalone(config, configPath, 'undeclared') + ).toThrow( + '--standalone refused: remote "undeclared" does not declare ' + + `standalone support. Set "standalone": true for it in ${configPath}.` + ); + expect(() => + assertRemoteStandalone(config, configPath, 'declined') + ).toThrow('remote "declined" does not declare standalone support'); + }); + + it('proceeds for a declared remote and for a name with no entry', () => { + // Unknown names are the command layer's unknown-`--apps` error (exit 2); + // this gate only refuses DECLARED-but-unsupported entries — the same + // "matches nothing ⇒ proceed" rule the root-keyed shipped with. + expect(() => + assertRemoteStandalone(config, configPath, 'supported') + ).not.toThrow(); + expect(() => + assertRemoteStandalone(config, configPath, 'ghost') + ).not.toThrow(); + }); +}); diff --git a/packages/repack/src/commands/federation/configFile.ts b/packages/repack/src/commands/federation/configFile.ts index e12786268..fb4d5a030 100644 --- a/packages/repack/src/commands/federation/configFile.ts +++ b/packages/repack/src/commands/federation/configFile.ts @@ -374,13 +374,38 @@ export function resolveFederationWorkspace( return workspace; } +/** + * Tooling-side gate for `--standalone`, name-keyed: refuse when the loaded + * config declares a remote entry by that name WITHOUT `standalone: true`. + * A name with no entry proceeds unopposed — unknown names are the calling + * command's own error, and standalone needs no declaration to *work*, only + * support-refusal needs the file. This is the single refuse-rule; the + * root-keyed `assertStandaloneSupported` is a wrapper over it, so shipped + * semantics and messages never drift. Bundler-runtime code never calls + * this and never reads the workspace map. + */ +export function assertRemoteStandalone( + config: FederationConfig, + configPath: string, + remoteName: string +): void { + const entry = config.remotes[remoteName]; + if (!entry) return; + if (entry.standalone !== true) { + throw new CLIError( + `--standalone refused: remote "${remoteName}" does not declare standalone support. ` + + `Set "standalone": true for it in ${configPath}.` + ); + } +} + /** * 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. + * only support-refusal needs the file. Thin root-keyed wrapper over + * `assertRemoteStandalone` (the one refuse-rule). */ export function assertStandaloneSupported(rootDir: string): void { const target = path.resolve(rootDir); @@ -404,12 +429,7 @@ export function assertStandaloneSupported(rootDir: string): void { 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}.` - ); - } + assertRemoteStandalone(loaded.config, loaded.filePath, name); return; } } From fe5360d89fbfe50e3cd7dd5cefcf1432c22978af Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:06:19 +0200 Subject: [PATCH 22/54] fix(repack): use declared config for dry-run shared attribution --- .../federation/__tests__/dryRun.test.ts | 67 ++++++++++++++++++- .../repack/src/commands/federation/dryRun.ts | 8 ++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/repack/src/commands/federation/__tests__/dryRun.test.ts b/packages/repack/src/commands/federation/__tests__/dryRun.test.ts index 63b98b81c..b78ce222a 100644 --- a/packages/repack/src/commands/federation/__tests__/dryRun.test.ts +++ b/packages/repack/src/commands/federation/__tests__/dryRun.test.ts @@ -1,6 +1,26 @@ +import path from 'node:path'; import type { FederationManifestSharedEntry } from '../../../plugins/federationManifest/types.js'; +import { resolveFederationWorkspace } from '../configFile.js'; import { doctorExitCode } from '../doctor.js'; -import { type DryRunApp, runDryRun, UNBUILT_CAVEAT } from '../dryRun.js'; +import { + collectDryRunInput, + type DryRunApp, + runDryRun, + UNBUILT_CAVEAT, +} from '../dryRun.js'; +import { extractAppShared } from '../extractShared.js'; + +// collectDryRunInput evaluates real user configs in-process; these tests +// pin WHICH config each app is routed to, so the extractor is a spy over an +// inert result (requireActual keeps ConfigEvalError for the import). +jest.mock('../extractShared.js', () => ({ + ...jest.requireActual('../extractShared.js'), + extractAppShared: jest.fn(async (_root: string) => ({ + name: 'stub', + pluginName: 'Stub', + shared: [], + })), +})); const sharedEntry = ( name: string, @@ -155,3 +175,48 @@ describe('runDryRun', () => { ); }); }); + +describe('collectDryRunInput config pass-through', () => { + const extractMock = jest.mocked(extractAppShared); + const FIXTURES = path.join(__dirname, '__fixtures__'); + + it('routes each declared config as configPath into extractAppShared', async () => { + // Delta spec "Twin-app shared-root layout attributes correctly" at the + // routing level: both twins share root "." and their config NAMES match + // no discovery rule — only the declared config fields can attribute + // them, so each app must reach the extractor with its own configPath. + const twinDir = path.join(FIXTURES, 'config-dev-twin'); + const workspace = resolveFederationWorkspace(twinDir, {}); + + await collectDryRunInput(twinDir, workspace); + + expect(extractMock).toHaveBeenCalledWith(twinDir, { + configPath: path.join(twinDir, 'config.host-app.mts'), + }); + expect(extractMock).toHaveBeenCalledWith(twinDir, { + configPath: path.join(twinDir, 'config.mini-app.mts'), + }); + expect(extractMock).toHaveBeenCalledTimes(2); + }); + + it('calls without configPath when no config fields are declared', async () => { + // Delta spec "Pass-through is additive only": apps without `config` + // keep today's call shape — discovery stays exactly as shipped. + const workspaceDir = path.join( + __dirname, + '..', + '__fixtures__', + 'dry-run-workspace', + 'clean' + ); + const workspace = resolveFederationWorkspace(workspaceDir, {}); + + await collectDryRunInput(workspaceDir, workspace); + + expect(extractMock).toHaveBeenCalledTimes(2); + for (const call of extractMock.mock.calls) { + // Today's call shape carries no configPath value at all. + expect(call[1] ?? {}).not.toHaveProperty('configPath'); + } + }); +}); diff --git a/packages/repack/src/commands/federation/dryRun.ts b/packages/repack/src/commands/federation/dryRun.ts index 515779a11..7f6723cf8 100644 --- a/packages/repack/src/commands/federation/dryRun.ts +++ b/packages/repack/src/commands/federation/dryRun.ts @@ -140,7 +140,13 @@ export async function collectDryRunInput( declaredName?: string ): Promise => { const root = entry.root ?? baseDir; - const extracted = await extractAppShared(root); + // A declared per-app `config` always wins over name-based discovery — + // the twin-app shared-root layout is only attributable through it. + // Absent field ⇒ today's discovery, untouched. + const extracted = await extractAppShared( + root, + entry.config ? { configPath: entry.config } : {} + ); return { name: declaredName ?? extracted.name, root, From 702adb3429cf47ad8dd88f6e311841e596713d95 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:08:38 +0200 Subject: [PATCH 23/54] feat(repack): resolve local react-native cli for federation runner --- .../federation/__tests__/rnBin.test.ts | 85 +++++++++++++++++++ .../repack/src/commands/federation/rnBin.ts | 68 +++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 packages/repack/src/commands/federation/__tests__/rnBin.test.ts create mode 100644 packages/repack/src/commands/federation/rnBin.ts diff --git a/packages/repack/src/commands/federation/__tests__/rnBin.test.ts b/packages/repack/src/commands/federation/__tests__/rnBin.test.ts new file mode 100644 index 000000000..617ca7410 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/rnBin.test.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CLIError } from '../../../helpers/index.js'; +import { resolveReactNativeBin } from '../rnBin.js'; + +const FIXTURES = path.join(__dirname, '__fixtures__', 'rnbin'); +const appDir = (app: string) => path.join(FIXTURES, app); + +describe('resolveReactNativeBin', () => { + it('resolves the local package cli.js to an absolute existing path', () => { + const bin = resolveReactNativeBin(appDir('app')); + expect(bin).toBe( + path.join(FIXTURES, 'app', 'node_modules', 'react-native', 'cli.js') + ); + expect(fs.existsSync(bin)).toBe(true); + }); + + it('follows a pnpm-symlinked package to an absolute script path', () => { + const bin = resolveReactNativeBin(appDir('pnpmapp')); + expect(path.isAbsolute(bin)).toBe(true); + expect(fs.realpathSync(bin)).toBe( + fs.realpathSync( + path.join( + FIXTURES, + 'pnpmapp', + 'node_modules', + '.pnpm', + 'react-native@100.0.0', + 'node_modules', + 'react-native', + 'scripts', + 'cli.js' + ) + ) + ); + }); + + it('prefers the local package over a planted PATH shim', () => { + // Threat row "Executable-file classification": resolution is a local + // require.resolve chain — PATH is never consulted, so a global + // react-native can never shadow the app's own install. + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnbin-shim-')); + const shim = path.join(shimDir, 'react-native'); + fs.writeFileSync(shim, '#!/bin/sh\necho shim\n'); + fs.chmodSync(shim, 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${shimDir}${path.delimiter}${originalPath ?? ''}`; + try { + const bin = resolveReactNativeBin(appDir('app')); + expect(bin).not.toContain(shimDir); + expect(bin).toBe( + path.join(FIXTURES, 'app', 'node_modules', 'react-native', 'cli.js') + ); + } finally { + process.env.PATH = originalPath; + fs.rmSync(shimDir, { recursive: true, force: true }); + } + }); + + it('fails with a clean CLIError naming react-native when no local package exists', () => { + // jest's module registry always resolves a real react-native from the + // repo tree, so the miss leg is exercised through the documented + // resolution seam with a Node-shaped MODULE_NOT_FOUND. + const miss = Object.assign( + new Error("Cannot find module 'react-native/package.json'"), + { code: 'MODULE_NOT_FOUND' } + ); + let caught: unknown; + try { + resolveReactNativeBin('/isolated/app-without-react-native', { + requireResolve: () => { + throw miss; + }, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CLIError); + const message = (caught as Error).message; + expect(message).toContain('react-native'); + expect(message).toContain('/isolated/app-without-react-native'); + expect(message).not.toMatch(/\n\s+at\s/); + }); +}); diff --git a/packages/repack/src/commands/federation/rnBin.ts b/packages/repack/src/commands/federation/rnBin.ts new file mode 100644 index 000000000..4126d97d8 --- /dev/null +++ b/packages/repack/src/commands/federation/rnBin.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { CLIError } from '../../helpers/index.js'; + +/** The resolution call this module rides — injectable for tests (jest's + * own resolver never misses, so the missing-package mapping is unpinnable + * without the seam; production always uses the real `require.resolve`). */ +type ResolvePackages = ( + request: string, + options: { paths: string[] } +) => string; + +/** + * Resolve the app's LOCAL `react-native` CLI script to an absolute path. + * + * Resolution is a pure module-resolution chain — `require.resolve` over + * `[appRoot, ...extraPaths, cwd]` — so PATH is never consulted and a + * global `react-native` can never shadow the app's own install (threat row + * "Executable-file classification"). The chain mirrors Node's own upward + * `node_modules` walk, which also follows pnpm's symlinked layout: the + * returned path is the real, absolute script file. + * + * The plan executes the result as `process.execPath …` — spawning + * the `.bin/react-native` shim would break under some pnpm layouts, the + * resolved cli.js is deterministic. + * + * @param appRoot the app whose install owns the CLI + * @param options.extraPaths additional resolution bases (e.g. the workspace + * config directory), consulted after the app root + * @param options.requireResolve resolution seam for tests only + */ +export function resolveReactNativeBin( + appRoot: string, + options: { + extraPaths?: string[]; + requireResolve?: ResolvePackages; + } = {} +): string { + const { extraPaths = [], requireResolve = require.resolve } = options; + let packageJsonPath: string; + try { + packageJsonPath = requireResolve('react-native/package.json', { + paths: [appRoot, ...extraPaths, process.cwd()], + }); + } catch { + throw new CLIError( + `Cannot resolve the "react-native" package from ${appRoot} — ` + + 'federation-dev runs each app with its own local react-native CLI; ' + + 'install react-native in the app (or run from inside the workspace).' + ); + } + + const packageDir = path.dirname(packageJsonPath); + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { + bin?: Record | string; + }; + const bin = + typeof manifest.bin === 'string' + ? manifest.bin + : manifest.bin?.['react-native']; + if (!bin) { + throw new CLIError( + `The react-native package at ${packageDir} declares no ` + + '"bin.react-native" script — it cannot be used to start an app.' + ); + } + return path.resolve(packageDir, bin); +} From 47bd15030dca01ff9627bd68a5caf29848485c7a Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:10:39 +0200 Subject: [PATCH 24/54] feat(repack): add federation dev plan resolver --- .../federation/__tests__/devPlan.test.ts | 260 ++++++++++++++++++ .../repack/src/commands/federation/devPlan.ts | 159 +++++++++++ 2 files changed, 419 insertions(+) create mode 100644 packages/repack/src/commands/federation/__tests__/devPlan.test.ts create mode 100644 packages/repack/src/commands/federation/devPlan.ts diff --git a/packages/repack/src/commands/federation/__tests__/devPlan.test.ts b/packages/repack/src/commands/federation/__tests__/devPlan.test.ts new file mode 100644 index 000000000..588193ba3 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/devPlan.test.ts @@ -0,0 +1,260 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CLIError } from '../../../helpers/index.js'; +import { loadFederationConfig } from '../configFile.js'; +import { buildPlan } from '../devPlan.js'; + +const FIXTURES = path.join(__dirname, '__fixtures__'); +const TWIN_DIR = path.join(FIXTURES, 'config-dev-twin'); +const TWIN_CONFIG = path.join(TWIN_DIR, 'repack-federation.json'); +const RN_CLI = '/abs/node_modules/react-native/cli.js'; + +const twinLoaded = loadFederationConfig({ cwd: TWIN_DIR })!; + +const baseInput = () => ({ + configPath: TWIN_CONFIG, + config: twinLoaded.config, + session: { remotes: ['MiniApp'] }, + overrides: {}, + ports: {}, + rnCliPath: RN_CLI, +}); + +describe('buildPlan session set and ordering', () => { + it('plans the host first and remotes in file declaration order', () => { + const plan = buildPlan(baseInput()); + expect(plan.map((app) => app.name)).toEqual(['host', 'MiniApp']); + expect(plan[0]!.role).toBe('host'); + expect(plan[1]!.role).toBe('remote'); + }); + + it('keeps file order with several remotes and filters unselected ones', () => { + const plan = buildPlan({ + ...baseInput(), + config: { + host: { manifest: '.' }, + remotes: { + zeta: { manifest: '.' }, + alpha: { manifest: '.' }, + unselected: { manifest: '.' }, + }, + }, + session: { remotes: ['zeta', 'alpha'] }, + }); + expect(plan.map((app) => app.name)).toEqual(['host', 'zeta', 'alpha']); + }); + + it('includes a standalone remote even when --apps omitted it', () => { + const plan = buildPlan({ + ...baseInput(), + session: { remotes: [], standaloneRemote: 'MiniApp' }, + }); + expect(plan.map((app) => app.name)).toEqual(['host', 'MiniApp']); + }); + + it('refuses an unknown session name (e.g. --apps --no-interactive) and plans nothing for it', () => { + // Threat row "Subprocess spawn": a flag-shaped --apps value is an + // unknown remote name — the plan layer refuses it (the command maps + // this to exit 2), so it can never reach a spawn argv as a name. + let caught: unknown; + try { + buildPlan({ ...baseInput(), session: { remotes: ['--no-interactive'] } }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CLIError); + expect((caught as Error).message).toContain('--no-interactive'); + }); +}); + +describe('buildPlan port and config precedence', () => { + it('applies --port > host port field > 8081 for the host', () => { + // Twin host declares port 8081; the flag wins over the file… + const flagged = buildPlan({ + ...baseInput(), + overrides: { port: 9000 }, + }); + expect(flagged[0]!.port).toBe(9000); + // …and the declared field wins over the default. + const declared = buildPlan(baseInput()); + expect(declared[0]!.port).toBe(8081); + // No flag, no declaration ⇒ built-in default. + const defaulted = buildPlan({ + ...baseInput(), + config: { host: { manifest: '.' }, remotes: {} }, + session: { remotes: [] }, + }); + expect(defaulted[0]!.port).toBe(8081); + }); + + it('lets the port planner override declared values (auto-ports reassignment)', () => { + const plan = buildPlan({ ...baseInput(), ports: { MiniApp: 9321 } }); + expect(plan[1]!.port).toBe(9321); + }); + + it('routes each declared config to its own app and sets absolute spawn cwd', () => { + const plan = buildPlan(baseInput()); + expect(plan[0]!.config).toBe(path.join(TWIN_DIR, 'config.host-app.mts')); + expect(plan[1]!.config).toBe(path.join(TWIN_DIR, 'config.mini-app.mts')); + for (const app of plan) { + expect(app.spawn.cwd).toBe(TWIN_DIR); + expect(path.isAbsolute(app.spawn.cwd)).toBe(true); + } + }); + + it('omits --config entirely when the app declares none', () => { + const plan = buildPlan({ + ...baseInput(), + config: { host: { manifest: '.' }, remotes: {} }, + session: { remotes: [] }, + }); + expect(plan[0]!.config).toBeUndefined(); + expect(plan[0]!.spawn.args).not.toContain('--config'); + }); + + it('lets a per-run config choice override the declared field', () => { + const plan = buildPlan({ + ...baseInput(), + overrides: { configChoices: { MiniApp: '/run/choices/mini.mts' } }, + }); + expect(plan[1]!.config).toBe('/run/choices/mini.mts'); + }); +}); + +describe('buildPlan spawn shape', () => { + it('spawns process.execPath with the rn cli and the unified start command', () => { + const plan = buildPlan(baseInput()); + for (const app of plan) { + expect(app.spawn.file).toBe(process.execPath); + expect(app.spawn.args.slice(0, 4)).toEqual([ + RN_CLI, + 'start', + '--bundler', + // Twin configs are `config.*.mts` — no engine prefix, no conventional + // config in the app dir ⇒ detectBundler's rspack default. + 'rspack', + ]); + expect(app.spawn.args).toContain('--no-interactive'); + expect(app.spawn.args).toContain('--no-reverse-port'); + } + }); + + it('forwards --platform to every child only when selected', () => { + const withPlatform = buildPlan({ + ...baseInput(), + overrides: { platform: 'ios' }, + }); + for (const app of withPlatform) { + const index = app.spawn.args.indexOf('--platform'); + expect(index).toBeGreaterThan(-1); + expect(app.spawn.args[index + 1]).toBe('ios'); + } + const without = buildPlan(baseInput()); + for (const app of without) { + expect(app.spawn.args).not.toContain('--platform'); + } + }); + + it('puts --standalone only on the targeted remote, never the host', () => { + const plan = buildPlan({ + ...baseInput(), + session: { remotes: ['MiniApp'], standaloneRemote: 'MiniApp' }, + }); + expect(plan[0]!.spawn.args).not.toContain('--standalone'); + expect(plan[1]!.spawn.args).toContain('--standalone'); + }); + + it('resolves the bundler per app from the resolved config name', () => { + const plan = buildPlan({ + ...baseInput(), + overrides: { configChoices: { MiniApp: '/abs/webpack.mini.mts' } }, + }); + expect(plan[1]!.bundler).toBe('webpack'); + expect(plan[1]!.spawn.args[3]).toBe('webpack'); + }); + + it('renders commandLine from the exact argv and quotes hostile values', () => { + const plan = buildPlan(baseInput()); + const host = plan[0]!; + const argv = [host.spawn.file, ...host.spawn.args]; + expect(host.commandLine).toBe( + argv.map((part) => (/\s/.test(part) ? `"${part}"` : part)).join(' ') + ); + expect(host.commandLine).toContain('--port 8081'); + }); + + it('keeps hostile-but-valid names and metachar config paths as exact argv entries with no shell field', () => { + // Threat row "Subprocess spawn": hostile strings must ride the argv + // array verbatim; nothing in the plan may ask for a shell. + const plan = buildPlan({ + ...baseInput(), + config: { + host: { manifest: '.' }, + remotes: { + 'evil name\'s "twin"': { + manifest: '.', + config: 'config $(rm -rf /); echo .mts', + port: 8099, + }, + }, + }, + session: { remotes: ['evil name\'s "twin"'] }, + }); + const evil = plan[1]!; + expect(evil.name).toBe('evil name\'s "twin"'); + expect(evil.spawn.args).toContain( + path.resolve(path.dirname(TWIN_CONFIG), 'config $(rm -rf /); echo .mts') + ); + expect(evil.spawn.file).toBe(process.execPath); + expect(JSON.stringify(plan)).not.toContain('"shell"'); + // The display render quotes the metachar path — it never interpolates it. + expect(evil.commandLine).toContain( + `"${path.resolve(path.dirname(TWIN_CONFIG), 'config $(rm -rf /); echo .mts')}"` + ); + }); +}); + +describe('buildPlan cwd authority', () => { + it('resolves spawn.cwd against the config file directory from a nested cwd', () => { + // Threat row "Process cwd authority": the runner may be invoked from + // anywhere; the plan's paths anchor at the config file, not process.cwd. + const nested = fs.mkdtempSync(path.join(os.tmpdir(), 'devplan-cwd-')); + const previousCwd = process.cwd(); + process.chdir(nested); + try { + const loaded = loadFederationConfig({ + cwd: path.join(TWIN_DIR, 'deep', 'nested'), + })!; + const plan = buildPlan({ + ...baseInput(), + configPath: loaded.filePath, + config: loaded.config, + }); + for (const app of plan) { + expect(app.spawn.cwd).toBe(TWIN_DIR); + } + } finally { + process.chdir(previousCwd); + fs.rmSync(nested, { recursive: true, force: true }); + } + }); +}); + +describe('buildPlan auto-port display (dry-run discipline)', () => { + it('maps an auto port to undefined with --port in the command', () => { + const plan = buildPlan({ + ...baseInput(), + config: { + host: { manifest: '.' }, + remotes: { floater: { manifest: '.', root: '.' } }, + }, + session: { remotes: ['floater'] }, + ports: { floater: 'auto' }, + }); + const floater = plan[1]!; + expect(floater.port).toBeUndefined(); + expect(floater.commandLine).toContain('--port '); + expect(floater.url).toBe('http://localhost:'); + }); +}); diff --git a/packages/repack/src/commands/federation/devPlan.ts b/packages/repack/src/commands/federation/devPlan.ts new file mode 100644 index 000000000..f32a2297d --- /dev/null +++ b/packages/repack/src/commands/federation/devPlan.ts @@ -0,0 +1,159 @@ +import path from 'node:path'; +import { CLIError } from '../../helpers/index.js'; +import { detectBundler } from '../common/config/detectBundler.js'; +import type { Bundler } from '../types.js'; +import type { FederationConfig } from './configFile.js'; + +/** One app exactly as the supervisor and the tables will consume it. */ +export interface PlannedApp { + /** 'host' | declared remote name. */ + name: string; + role: 'host' | 'remote'; + /** Absolute app root (spawn cwd). */ + root: string; + /** Absolute bundler config; absent ⇒ child-side discovery (today). */ + config?: string; + bundler: Bundler; + /** undefined only in dry-run for unmanaged apps. */ + port?: number; + url: string; + standalone?: boolean; + spawn: { file: string; args: string[]; cwd: string }; + /** Display + JSON `command`: the exact argv, shell-quoted for readability. */ + commandLine: string; +} + +export interface PlanInput { + configPath: string; + config: FederationConfig; + session: { remotes: string[]; standaloneRemote?: string }; + overrides: { + port?: number; + platform?: 'ios' | 'android'; + configChoices?: Record; + }; + /** From the port planner; `'auto'` only in dry-run for unmanaged apps. */ + ports: Record; + rnCliPath: string; +} + +/** Quote argv parts the way a shell display would — pure rendering. */ +function renderCommand(argv: string[]): string { + return argv.map((part) => (/\s/.test(part) ? `"${part}"` : part)).join(' '); +} + +function buildApp( + input: PlanInput, + name: string, + role: 'host' | 'remote', + entry: { root?: string; config?: string; port?: number }, + standalone: boolean +): PlannedApp { + const configDir = path.dirname(input.configPath); + const root = path.resolve(configDir, entry.root ?? '.'); + const declared = input.ports[name]; + const port = + declared !== undefined + ? typeof declared === 'number' + ? declared + : undefined + : role === 'host' + ? (input.overrides.port ?? entry.port ?? 8081) + : entry.port; + const portDisplay = port === undefined ? '' : String(port); + const appConfig = + input.overrides.configChoices?.[name] ?? + (entry.config === undefined + ? undefined + : path.resolve(configDir, entry.config)); + const bundler = detectBundler(root, appConfig); + + // Executed as `process.execPath start …`: the argv head is the + // resolved local react-native CLI, so PATH is never consulted. + const args = [input.rnCliPath, 'start', '--bundler', bundler]; + if (appConfig !== undefined) args.push('--config', appConfig); + args.push('--port', portDisplay); + // The supervisor owns stdin and signals: children are never interactive. + args.push('--no-interactive'); + if (input.overrides.platform !== undefined) { + args.push('--platform', input.overrides.platform); + } + if (standalone) args.push('--standalone'); + // The runner owns adb reversal entirely (host included reverses itself via + // runAdbReverse), so no child may reverse on its own (D8). + args.push('--no-reverse-port'); + + const spawn = { file: process.execPath, args, cwd: root }; + return { + name, + role, + root, + ...(appConfig === undefined ? {} : { config: appConfig }), + bundler, + ...(port === undefined ? {} : { port }), + url: `http://localhost:${portDisplay}`, + ...(standalone ? { standalone } : {}), + spawn, + commandLine: renderCommand([spawn.file, ...spawn.args]), + }; +} + +/** + * Resolve the session plan: the host plus every selected remote, host first + * and remotes in file declaration order. Pure — no probing, no spawning, no + * reading `process.cwd`: all paths anchor at the config file's directory + * (threat row "Process cwd authority") and every hostile-but-valid value + * stays an exact argv array entry — the plan carries no `shell` field + * anywhere (threat row "Subprocess spawn"). + * + * Precedence per value: flags > file > defaults — `--port` > host `port` + * field > 8081; per-run config choice > declared `config` > absent (spawn + * without `--config`, the app's own discovery applies). A port-planner + * entry, when present, wins outright: it already resolved conflicts + * (`--auto-ports` reassignment) or marks dry-run auto allocation. + */ +export function buildPlan(input: PlanInput): PlannedApp[] { + const unknown = input.session.remotes.filter( + (name) => !(name in input.config.remotes) + ); + if ( + input.session.standaloneRemote !== undefined && + !(input.session.standaloneRemote in input.config.remotes) + ) { + unknown.push(input.session.standaloneRemote); + } + if (unknown.length > 0) { + throw new CLIError( + `Unknown app ${unknown.map((name) => JSON.stringify(name)).join(', ')} — ` + + `not a declared remote in ${input.configPath}. Known remotes: ` + + (Object.keys(input.config.remotes).join(', ') || '(none)') + + '.' + ); + } + + // `--standalone r` implies r ∈ session, even when --apps omitted it. + const sessionRemotes = [...input.session.remotes]; + if ( + input.session.standaloneRemote !== undefined && + !sessionRemotes.includes(input.session.standaloneRemote) + ) { + sessionRemotes.push(input.session.standaloneRemote); + } + + const plan: PlannedApp[] = [ + buildApp(input, 'host', 'host', input.config.host, false), + ]; + for (const [name, entry] of Object.entries(input.config.remotes)) { + if (!sessionRemotes.includes(name)) continue; + plan.push( + buildApp( + input, + name, + 'remote', + entry, + name === input.session.standaloneRemote + ) + ); + } + return plan; +} From a86d00ada1b2ab876f75a9eadaf9ceb0951cefa9 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:15:03 +0200 Subject: [PATCH 25/54] feat(repack): add federation dev port planner --- .../federation/__tests__/portPlanner.test.ts | Bin 0 -> 5041 bytes .../src/commands/federation/portPlanner.ts | 147 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/repack/src/commands/federation/__tests__/portPlanner.test.ts create mode 100644 packages/repack/src/commands/federation/portPlanner.ts diff --git a/packages/repack/src/commands/federation/__tests__/portPlanner.test.ts b/packages/repack/src/commands/federation/__tests__/portPlanner.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..59c76bc012540b0635a9fd1953ecb95fdd8da8a8 GIT binary patch literal 5041 zcmc&&+iu%95Z&isKVmj0U>UIGr2DXi+O%lX?gi*J0h)aZik6mWTa8S*Bo)`l8rYB6 zFWfKLGbAP1iIcW@Y4cD?B!|N@XU+^=&KKG^nmMOk3BZXU#)KJtODZ$zGVOT}B^9OKmRfLgaGv z;EI>7Kk(?dRz)QZFX6!pza&l5gx&GhF+omBfQJpP)|3HFKeq1k`r1@5&hf}bqe|zZ zVrQ}kSANbj`1C~7PCxC1?XbsEv585^M_3r0OKVg4S>~t0%?2dQ)Mj-4Zb136*zGp0 zdE;Eq=Ry^);TfEV+w1=0y}d%(+>~RP^xBB@29)#(5HfMyA0O|`fTA0j_kwmCz!4`^nLFDVIe+K3@`wjaIbWvAQmqgaF$JPHrX>+z z_FPz&Zl-IXgM)zZcfi>m6^MS6)}<4bMCjkMsWwSCsvdxN^7}?(Qfc9&>3vfin1pl- zW*+pEk!H|d$i&A!geOXX2MjnJMfQY1rO}n2wnE7wrYDFygDi_^vlpbIw;lHFNxvD% zCp()7l^n#0qY<5`YIU9b$b&$#g8uvGUlM9Nm^2fXly)=3j-2)yIZHPQ*M=>f>--{x z6jU~yi$#*0Gam1J67o}jgGmg`4K^9+0R(-<(ARB8fdalaq@;#b!yzZm(676LTHhyA z{h^tx{NP2EvwlE`L?Z+?jFr|O`3`C|C*MrRE6z5kU}G#4lP0SJyu`bm&14H*$Wbn9 zmfoEB?K5(If>&0W*N`^(8G?35V>!{_hfpi;8cca5ACT28Ddu8DxiDsh`0N8{U0upz zpDsmJOTzM{FvS*OzN}C&y*7jfe2!AHlN=ys)Eup(CkK6BW;j#dA>p?W;~T@@1bBz= z-=aagW=8V#QzUsSS`W9yB?{4I;L>)T7rHG`;<)+Pb!EA0n+O$3R8*DLNhOWt0^1ep z3O${b1{FE;-Cz3FztG==_HN^)t|~~V!hfj#O+C&lZDm}5qI{!jmlH~Y@qSx(gDhJZ z3nM{*DZaIPu;!yk`#P`Wl1|Z-mR3Hw)MfEB;iAj5_flt5??)026-SbOKSH{OyKC>X zm_VXmiF}r9IF4DE!2L?>MoY=Uhn96192{D=sw7vBg+_~JSQuF@cdB1lf>Us(mgc@H zy!D7C$$KL|4cHEQ?Wu(&rff6_4Q=(U1(*7Iixcv?F z&F=5+<-QuD4xJyO6ZB)R3F~*k0_A-=jHPfG7-6B-fe>B>O9k@GpumLwR-zBvNfYS$ z^E{Uel!QC=^ZVf~8hg6I_XSl_v%aEwn9NdC5hC*P!uL z*qrMN8iP<=tSj+orE?&%R&0m#QlYot2ErS4X?OGP%;^#Lv zu8q6-m!iY#Zir2HiD2Kne_hGC@oNAjXD7#JCx2~MES74%SBKO=L*@zZPsEvtHxfkw zLfT=ehBfjN302@?Ma!8~J~P7cLyFNTDDGBOY`EL3Ji7T)#r3=8fLbMOsTY^07o}=1 z6WhLByHqp_a5@XkWttvqKwvC>H&85I zU8`^}P`ZfUiHi5)zF@Rm3LVI#R$fbEvc}qYdwavb0mbej-1Ndv7+=?W_}ZSvDts`Y z-r>Vv(gXY*0=rmgL&*y16vN-PKWY2`m^mLI6VC&A|4S BQ%3** literal 0 HcmV?d00001 diff --git a/packages/repack/src/commands/federation/portPlanner.ts b/packages/repack/src/commands/federation/portPlanner.ts new file mode 100644 index 000000000..de3465696 --- /dev/null +++ b/packages/repack/src/commands/federation/portPlanner.ts @@ -0,0 +1,147 @@ +import http from 'node:http'; +import net from 'node:net'; +import type { PlannedApp } from './devPlan.js'; + +/** Busy probe for one port — injectable so tests never touch real sockets. */ +export type Probe = (port: number) => Promise; + +const TCP_CONNECT_TIMEOUT_MS = 150; +const STATUS_PROBE_TIMEOUT_MS = 150; + +export interface PortPlanResult { + /** Final port per app; apps in `conflicts` have NO entry. */ + ports: Record; + conflicts: Array<{ app: string; port: number }>; +} + +/** + * A port is busy when EITHER leg answers: a TCP connect succeeds (a live + * listener exists) or a `GET /status` responds. GET-only, 127.0.0.1 only, + * hard timeouts on both legs (threat row "Network probes"): a hang, a + * garbage body or a slow server all settle inside ~2×150 ms — a probe that + * never answers is classified free, never left open. + */ +export async function isPortBusy(port: number): Promise { + if (await canConnect(port)) return true; + return answersStatus(port); +} + +function canConnect(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }); + const settle = (busy: boolean) => { + socket.destroy(); + resolve(busy); + }; + socket.setTimeout(TCP_CONNECT_TIMEOUT_MS, () => settle(false)); + socket.once('connect', () => settle(true)); + socket.once('error', () => settle(false)); + }); +} + +function answersStatus(port: number): Promise { + return new Promise((resolve) => { + const request = http.get( + { + host: '127.0.0.1', + port, + path: '/status', + timeout: STATUS_PROBE_TIMEOUT_MS, + }, + (response) => { + // ANY response is a positive answer — the body decides readiness + // elsewhere, busy-ness is binary here. + response.destroy(); + resolve(true); + } + ); + request.on('timeout', () => { + request.destroy(); + resolve(false); + }); + request.on('error', () => resolve(false)); + }); +} + +/** + * Allocate a free port via `listen 0` and release it. TOCTOU is inherent to + * the release-then-spawn pattern — post-spawn detection + * (`classifyDeadChild`) is the designed safety net. + */ +export function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + }); +} + +/** + * Resolve session ports from a first-pass plan: declared ports win when + * free, unmanaged apps always get a freshly allocated free port (no + * `--auto-ports` needed), and a busy declared port is either reassigned + * (`--auto-ports`) or reported as a conflict the command turns into exit 1 + * — before anything spawns, never as a hang. + */ +export async function planPorts( + plan: PlannedApp[], + opts: { autoPorts: boolean; probePort: Probe } +): Promise { + const ports: Record = {}; + const conflicts: PortPlanResult['conflicts'] = []; + + for (const app of plan) { + if (app.port === undefined) { + // Unmanaged: always allocated, nothing declared to clash with. + ports[app.name] = await getFreePort(); + continue; + } + if (await opts.probePort(app.port)) { + if (opts.autoPorts) { + ports[app.name] = await getFreePort(); + } else { + conflicts.push({ app: app.name, port: app.port }); + } + continue; + } + ports[app.name] = app.port; + } + + return { ports, conflicts }; +} + +export interface DeadChildVerdict { + kind: 'address-in-use' | 'crash'; + message: string; +} + +/** + * TOCTOU classification: the child died; was its port hijacked between + * probe and spawn? Port answers `GET /status` ⇒ an address-in-use-style + * verdict naming app and port; port silent ⇒ plain crash. Either way the + * verdict is terminal — the supervisor never leaves a dead child pending. + */ +export function classifyDeadChild( + appName: string, + port: number, + statusAnswered: boolean +): DeadChildVerdict { + if (statusAnswered) { + return { + kind: 'address-in-use', + message: + `${appName} exited but port ${port} is serving /status — another ` + + `process took the port between the probe and the spawn ` + + `(EADDRINUSE-style). Free the port or rerun with --auto-ports.`, + }; + } + return { + kind: 'crash', + message: + `${appName} exited before its server became ready ` + + `(port ${port} answers nothing — not a port conflict).`, + }; +} From de04b1b8091f6e13ca8883b0eb1a3cbcf52bde30 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:25:27 +0200 Subject: [PATCH 26/54] feat(repack): add execa dev supervisor for federation sessions --- .../federation/__tests__/supervisor.test.ts | 190 +++++++++++++++ .../src/commands/federation/supervisor.ts | 230 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 packages/repack/src/commands/federation/__tests__/supervisor.test.ts create mode 100644 packages/repack/src/commands/federation/supervisor.ts diff --git a/packages/repack/src/commands/federation/__tests__/supervisor.test.ts b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts new file mode 100644 index 000000000..444b1c2cc --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts @@ -0,0 +1,190 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import execa from 'execa'; +import type { PlannedApp } from '../devPlan.js'; +import type { LogSink } from '../supervisor.js'; +import { DevSupervisor } from '../supervisor.js'; + +jest.mock('execa'); +const execaMock = execa as jest.MockedFunction; + +class FakeChild extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + kill = jest.fn(); +} + +const planned = (name: string, port: number): PlannedApp => ({ + name, + role: name === 'host' ? 'host' : 'remote', + root: `/workspace/${name}`, + bundler: 'rspack', + port, + url: `http://localhost:${port}`, + spawn: { + file: process.execPath, + args: ['/rn/cli.js', 'start', '--no-interactive'], + cwd: `/workspace/${name}`, + }, + commandLine: 'node /rn/cli.js start', +}); + +const plan = [planned('host', 8081), planned('MiniApp', 8082)]; + +let children: FakeChild[] = []; +let lines: string[] = []; +let sink: LogSink; +let probeStatus: jest.Mock; +let supervisors: DevSupervisor[] = []; + +beforeEach(() => { + children = []; + lines = []; + supervisors = []; + sink = { log: (line: string) => lines.push(line) }; + probeStatus = jest.fn(async () => null); + execaMock.mockImplementation(((_options: unknown) => { + const child = new FakeChild(); + children.push(child); + return child; + }) as unknown as typeof execa); +}); + +afterEach(() => { + // Park every live session: shutdown + exit so no readiness poller + // outlives its test (polling stops on child exit). + for (const supervisor of supervisors) supervisor.shutdown('app-exit'); + for (const child of children) child.emit('exit', 0, null); + jest.useRealTimers(); +}); + +const makeSupervisor = (opts?: { graceMs?: number }) => { + const supervisor = new DevSupervisor(plan, sink, { + ...(opts?.graceMs === undefined ? {} : { graceMs: opts.graceMs }), + probeStatus, + }); + supervisors.push(supervisor); + return supervisor; +}; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe('DevSupervisor spawn discipline', () => { + it('spawns each app with file/args/cwd, no shell, ignore-stdin and piped stdio', () => { + const supervisor = makeSupervisor(); + void supervisor.run(); + expect(execaMock).toHaveBeenCalledTimes(2); + // MockedFunction picks execa's (file, options?) overload; the call this + // supervisor makes is the (file, args, options) one — view it as such. + const calls = execaMock.mock.calls as unknown as Array< + [string, string[], Record] + >; + // Host first, then remotes in plan order. + expect(calls[0]![1]).toEqual(plan[0]!.spawn.args); + for (const [index, app] of plan.entries()) { + const call = calls[index]!; + expect(call[0]).toBe(process.execPath); + expect(call[1]).toEqual(app.spawn.args); + const options = call[2]; + expect(options.cwd).toBe(app.spawn.cwd); + expect(options.stdin).toBe('ignore'); + expect(options.stdout).toBe('pipe'); + expect(options.stderr).toBe('pipe'); + expect(options.shell).toBeFalsy(); + } + }); + + it('prefixes and line-splits partial chunks keeping ANSI and content intact', async () => { + const supervisor = makeSupervisor(); + void supervisor.run(); + children[0]!.stdout.write('hello wo'); + children[0]!.stdout.write('rld\n'); + children[0]!.stderr.write('\u001b[31mcompiling\u001b[39m\n'); + await flush(); + expect(lines).toEqual([ + '[host] hello world', + '[host] \u001b[31mcompiling\u001b[39m', + ]); + }); +}); + +describe('DevSupervisor readiness', () => { + it('marks an app running once probeStatus reports packager-status:running', async () => { + jest.useFakeTimers(); + probeStatus.mockImplementation(async () => 'packager-status:running'); + const supervisor = makeSupervisor(); + void supervisor.run(); + await jest.advanceTimersByTimeAsync(1000); + expect(supervisor.getStatuses().host).toBe('running'); + expect(supervisor.getStatuses().MiniApp).toBe('running'); + }); + + it('marks a dead child failed, never pending forever', async () => { + jest.useFakeTimers(); + const supervisor = makeSupervisor(); + const run = supervisor.run(); + children[1]!.emit('exit', 1, null); + await jest.advanceTimersByTimeAsync(1000); + expect(supervisor.getStatuses().MiniApp).toBe('failed'); + children[0]!.emit('exit', 0, null); + const result = await run; + expect(result.exitCode).toBe(1); + expect(result.apps.MiniApp.status).toBe('failed'); + }); +}); + +describe('DevSupervisor ordered shutdown', () => { + it('SIGINTs all children, SIGTERMs survivors after the grace window, resolves exit 0 only once all are gone', async () => { + jest.useFakeTimers(); + const supervisor = makeSupervisor({ graceMs: 5000 }); + const run = supervisor.run(); + + supervisor.shutdown('interrupt'); + expect(children[0]!.kill).toHaveBeenCalledWith('SIGINT'); + expect(children[1]!.kill).toHaveBeenCalledWith('SIGINT'); + + // Host is polite, MiniApp ignores SIGINT: after the grace window it + // gets SIGTERM, and the session stays open until BOTH are gone. + children[0]!.emit('exit', 0, null); + let settled = false; + void run.then(() => { + settled = true; + }); + await jest.advanceTimersByTimeAsync(5000); + expect(children[1]!.kill).toHaveBeenCalledWith('SIGTERM'); + expect(settled).toBe(false); + children[1]!.emit('exit', 0, null); + const result = await run; + expect(result.exitCode).toBe(0); + }); + + it('a second interrupt escalates to SIGTERM-all immediately', async () => { + jest.useFakeTimers(); + const supervisor = makeSupervisor({ graceMs: 5000 }); + void supervisor.run(); + supervisor.shutdown('interrupt'); + supervisor.shutdown('interrupt'); + expect(children[0]!.kill).toHaveBeenCalledWith('SIGTERM'); + expect(children[1]!.kill).toHaveBeenCalledWith('SIGTERM'); + }); +}); + +describe('DevSupervisor crash isolation', () => { + it('keeps siblings untouched, names the crashed child + code and fails the session', async () => { + jest.useFakeTimers(); + const supervisor = makeSupervisor(); + const run = supervisor.run(); + children[1]!.emit('exit', 3, null); + await jest.advanceTimersByTimeAsync(100); + // Sibling untouched: no signal reached the host. + expect(children[0]!.kill).not.toHaveBeenCalled(); + expect( + lines.some((line) => line.includes('MiniApp') && line.includes('3')) + ).toBe(true); + supervisor.shutdown('interrupt'); + children[0]!.emit('exit', 0, null); + children[1]!.emit('exit', 3, null); + const result = await run; + expect(result.exitCode).toBe(1); + }); +}); diff --git a/packages/repack/src/commands/federation/supervisor.ts b/packages/repack/src/commands/federation/supervisor.ts new file mode 100644 index 000000000..0e8f3f9e8 --- /dev/null +++ b/packages/repack/src/commands/federation/supervisor.ts @@ -0,0 +1,230 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import execa from 'execa'; +import type { PlannedApp } from './devPlan.js'; +import { classifyDeadChild } from './portPlanner.js'; + +/** Where prefixed child log lines go — the runner's sink (D5). */ +export interface LogSink { + log(line: string): void; +} + +/** Returns the `GET /status` body, or null when nothing answered. */ +export type StatusProbe = (url: string) => Promise; + +export type AppStatus = 'starting' | 'running' | 'failed' | 'exited'; + +export interface SessionResult { + exitCode: 0 | 1; + apps: Record< + string, + { status: 'running' | 'failed' | 'exited'; code?: number } + >; +} + +const DEFAULT_GRACE_MS = 5000; +const READINESS_POLL_FIRST_MS = 500; +const READINESS_POLL_MAX_MS = 2000; + +interface TrackedChild { + app: PlannedApp; + child: ChildProcessWithoutNullStreams; + status: AppStatus; + exitCode: number | null; + /** Set by the exit handler — the one liveness truth for signals. */ + exited: boolean; + /** Bytes of a line split across pipe chunks. */ + pending: { out: string; err: string }; + pollTimer?: ReturnType; +} + +/** + * Child-per-app supervisor on execa@^5 (v5 API only). One process per app + * gives crash isolation and real RSS reclamation; every child runs with + * `--no-interactive` and piped stdio, so the supervisor is the sole owner + * of stdin and signals, and the only writer of the session's stdout is the + * sink it logs into. + */ +export class DevSupervisor { + private tracked: TrackedChild[] = []; + private shutdownReason: 'interrupt' | 'app-exit' | null = null; + private escalated = false; + private graceTimer?: ReturnType; + private sessionFailed = false; + private allGone!: Promise; + private markAllGone!: () => void; + + constructor( + private plan: PlannedApp[], + private out: LogSink, + private opts: { graceMs?: number; probeStatus: StatusProbe } + ) { + this.allGone = new Promise((resolve) => { + this.markAllGone = resolve; + }); + } + + /** Current per-app health, exactly as the status table renders it. */ + getStatuses(): Record { + return Object.fromEntries( + this.tracked.map((entry) => [entry.app.name, entry.status]) + ); + } + + /** Spawn every app (host first, remotes in plan order) and live until they are gone. */ + async run(): Promise { + for (const app of this.plan) { + const child = execa(app.spawn.file, app.spawn.args, { + cwd: app.spawn.cwd, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }) as unknown as ChildProcessWithoutNullStreams; + // The v5 child is also a promise; failures surface through the + // exit/error events this class drives — never as unhandled rejections. + const asPromise = child as unknown as Partial>; + if (typeof asPromise.catch === 'function') + asPromise.catch(() => undefined); + const entry: TrackedChild = { + app, + child, + status: 'starting', + exitCode: null, + exited: false, + pending: { out: '', err: '' }, + }; + this.tracked.push(entry); + child.stdout?.on('data', (chunk: Buffer) => + this.onChunk(entry, 'out', chunk) + ); + child.stderr?.on('data', (chunk: Buffer) => + this.onChunk(entry, 'err', chunk) + ); + child.once('exit', (code, signal) => this.onExit(entry, code, signal)); + this.pollReadiness(entry); + } + + await this.allGone; + const apps: SessionResult['apps'] = {}; + for (const entry of this.tracked) { + const status = + entry.status === 'starting' || entry.status === 'running' + ? entry.status === 'running' + ? 'running' + : 'exited' + : entry.status; + apps[entry.app.name] = { + status, + ...(entry.exitCode === null ? {} : { code: entry.exitCode }), + }; + } + return { exitCode: this.sessionFailed ? 1 : 0, apps }; + } + + /** + * Ordered shutdown: SIGINT to every live child → grace window → SIGTERM + * to survivors. A second interrupt escalates to SIGTERM-all immediately + * (threat row "Signals & terminal state"). `run()` resolves only after + * every child is gone — never orphaning a dev server. + */ + shutdown(reason: 'interrupt' | 'app-exit'): void { + if (this.shutdownReason === null) { + this.shutdownReason = reason; + for (const entry of this.tracked) { + if (!this.isGone(entry)) entry.child.kill('SIGINT'); + } + this.graceTimer = setTimeout( + () => this.escalate(), + this.opts.graceMs ?? DEFAULT_GRACE_MS + ); + return; + } + if (reason === 'interrupt' && !this.escalated) this.escalate(); + } + + private escalate(): void { + this.escalated = true; + if (this.graceTimer) clearTimeout(this.graceTimer); + for (const entry of this.tracked) { + if (!this.isGone(entry)) entry.child.kill('SIGTERM'); + } + } + + private isGone(entry: TrackedChild): boolean { + return entry.exited; + } + + private onChunk(entry: TrackedChild, stream: 'out' | 'err', chunk: Buffer) { + // Line-split on \n across chunks; the content passes through UNALTERED + // (ANSI included) — the prefix is the only addition. + const text = entry.pending[stream] + chunk.toString(); + const lines = text.split('\n'); + entry.pending[stream] = lines.pop() ?? ''; + for (const line of lines) { + this.out.log(`[${entry.app.name}] ${line}`); + } + } + + private onExit( + entry: TrackedChild, + code: number | null, + signal: string | null + ) { + if (entry.exited) return; + entry.exited = true; + if (entry.pollTimer) clearTimeout(entry.pollTimer); + entry.exitCode = code; + for (const stream of ['out', 'err'] as const) { + const rest = entry.pending[stream]; + if (rest !== '') { + entry.pending[stream] = ''; + this.out.log(`[${entry.app.name}] ${rest}`); + } + } + + if (this.shutdownReason !== null) { + entry.status = 'exited'; + } else if (code === 0) { + entry.status = 'exited'; + } else { + entry.status = 'failed'; + this.sessionFailed = true; + this.out.log( + `[${entry.app.name}] exited with code ${code ?? `signal ${signal}`}` + ); + // TOCTOU: a dead child whose port still answers /status lost it to + // another process between probe and spawn — name it precisely. + void this.classifyCrash(entry); + } + if (this.tracked.every((e) => this.isGone(e))) this.markAllGone(); + } + + private async classifyCrash(entry: TrackedChild) { + if (entry.app.port === undefined) return; + const body = await this.opts.probeStatus(entry.app.url); + const verdict = classifyDeadChild( + entry.app.name, + entry.app.port, + body !== null + ); + if (verdict.kind === 'address-in-use') { + this.out.log(`[${entry.app.name}] ${verdict.message}`); + } + } + + private pollReadiness(entry: TrackedChild) { + const schedule = (delay: number) => { + entry.pollTimer = setTimeout(() => void tick(delay), delay); + }; + const tick = async (delay: number) => { + if (this.isGone(entry) || entry.status === 'running') return; + const body = await this.opts.probeStatus(entry.app.url); + if (this.isGone(entry)) return; + if (body?.startsWith('packager-status:running')) { + entry.status = 'running'; + return; + } + schedule(Math.min(delay * 2, READINESS_POLL_MAX_MS)); + }; + schedule(READINESS_POLL_FIRST_MS); + } +} From 32875ae5c0227caa6be0bef931514fcaf1bafcb9 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:28:15 +0200 Subject: [PATCH 27/54] feat(repack): add runner console sink --- .../__tests__/runnerConsole.test.ts | 79 +++++++++++++++++++ .../src/commands/federation/runnerConsole.ts | 79 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts create mode 100644 packages/repack/src/commands/federation/runnerConsole.ts diff --git a/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts b/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts new file mode 100644 index 000000000..5d8b9e550 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts @@ -0,0 +1,79 @@ +import { EventEmitter } from 'node:events'; +import { RunnerConsole } from '../runnerConsole.js'; + +/** Recording Writable stand-in — write() returns real backpressure values. */ +class FakeStream extends EventEmitter { + chunks: string[] = []; + writeResult = true; + isTTY?: boolean = true; + columns = 80; + write(chunk: string): boolean { + this.chunks.push(chunk); + return this.writeResult; + } + get output(): string { + return this.chunks.join(''); + } +} + +describe('RunnerConsole sink core (5a)', () => { + let stream: FakeStream; + let stdoutWriteBefore: unknown; + + beforeEach(() => { + stream = new FakeStream(); + stdoutWriteBefore = process.stdout.write; + }); + + afterEach(() => { + // No global patch may survive — and none may ever be installed. + expect(process.stdout.write).toBe(stdoutWriteBefore); + }); + + it('writes append-only newline-terminated lines and reports real backpressure', () => { + const console0 = new RunnerConsole({ stdout: stream }); + expect(console0.log('[host] ready')).toBe(true); + stream.writeResult = false; + expect(console0.log('[host] busy')).toBe(false); + expect(stream.output).toBe('[host] ready\n[host] busy\n'); + expect(process.stdout.write).toBe(stdoutWriteBefore); + }); + + it('never writes to process.stdout — the injected sink is the only outlet', () => { + const spy = jest.spyOn(process.stdout, 'write'); + const console0 = new RunnerConsole({ stdout: stream }); + console0.log('routed'); + console0.persist(['plan', 'rows']); + expect(stream.output).toContain('routed'); + expect(stream.output).toContain('plan\nrows\n'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('persists static blocks that stay untouched by later output', () => { + const console0 = new RunnerConsole({ stdout: stream }); + console0.persist(['PLAN', ' host 8081']); + const persisted = stream.chunks.length; + console0.log('[host] compiling'); + // The persisted block is NOT rewritten, redrawn or erased: later lines + // only ever append after it. + expect(stream.chunks.slice(0, persisted)).toEqual([ + 'PLAN\n', + ' host 8081\n', + ]); + expect(stream.output.endsWith('[host] compiling\n')).toBe(true); + }); + + it('non-TTY mode emits zero cursor escape codes across the whole surface', () => { + stream.isTTY = undefined; + const console0 = new RunnerConsole({ stdout: stream }); + console0.log('log line'); + console0.setStatus(['host running']); + console0.persist(['help']); + console0.armKeymap({ q: () => undefined }); + console0.onResize(() => undefined); + console0.release(); + expect(stream.output).toBe('log line\nhost running\nhelp\n'); + expect(stream.output).not.toMatch(/\u001b\[/); + }); +}); diff --git a/packages/repack/src/commands/federation/runnerConsole.ts b/packages/repack/src/commands/federation/runnerConsole.ts new file mode 100644 index 000000000..1bc2aaa51 --- /dev/null +++ b/packages/repack/src/commands/federation/runnerConsole.ts @@ -0,0 +1,79 @@ +import type { WriteStream } from 'node:tty'; + +/** The one stdout shape the console writes to. */ +export type ConsoleStream = Pick & { + isTTY?: boolean; + columns?: number; + on?: (event: string, listener: () => void) => unknown; + off?: (event: string, listener: () => void) => unknown; +}; + +/** + * The single stdout owner of a federation-dev session (D5). + * + * Ownership discipline, pinned by tests: + * - `process.stdout.write` is NEVER monkey-patched: every producer calls + * this object (`log` for live lines, `persist` for static blocks, child + * pipe chunks routed through `log`). + * - Writes are append-only and `\n`-terminated; `log`/`persist` report the + * real backpressure (`write` return value), never a lie. + * - Non-TTY (CI, `--no-interactive` pipes) is plain mode: zero cursor + * escape codes, no prompts, no keymap. + * - The live status block, resize repaint, coalescing and raw-mode + * keymap lifecycle (5b) live behind `setStatus`/`armKeymap`/`onResize`/ + * `release` — in this core they degrade to the same plain discipline. + */ +export class RunnerConsole { + private stream: ConsoleStream; + private resizeListeners: Array<() => void> = []; + private onResizeEvent = () => { + for (const listener of this.resizeListeners) listener(); + }; + + constructor(options: { stdout: ConsoleStream }) { + this.stream = options.stdout; + if (typeof this.stream.on === 'function') { + this.stream.on('resize', this.onResizeEvent); + } + } + + private get isTTY(): boolean { + return this.stream.isTTY === true; + } + + /** Append one live line (child logs ride this prefixed). Returns backpressure. */ + log(line: string): boolean { + return this.stream.write(`${line}\n`); + } + + /** Print a static block (plan table, help, guidance): written once, never redrawn. */ + persist(lines: string[]): void { + for (const line of lines) this.stream.write(`${line}\n`); + } + + /** + * Update the status rows. 5a core: static print per update — the live + * owned-block implementation replaces this seam in the 5b console polish. + */ + setStatus(rows: string[]): void { + this.persist(rows); + } + + /** Arm the session keymap (raw mode with guaranteed restore). Non-TTY: documented no-op. */ + armKeymap(_map: Record void>): void { + // 5b implements the TTY leg; non-TTY stays a no-op by design. + } + + /** Register a repaint hook fired on stdout 'resize'. */ + onResize(fn: () => void): void { + this.resizeListeners.push(fn); + } + + /** Restore terminal state. Idempotent; also wired to exit-hook by the command. */ + release(): void { + if (typeof this.stream.off === 'function') { + this.stream.off('resize', this.onResizeEvent); + } + this.resizeListeners = []; + } +} From 4dd40593edc53f207bf9be71a5a918b4607f970e Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:29:42 +0200 Subject: [PATCH 28/54] feat(repack): add federation dev plan/status tables --- .../federation/__tests__/statusTable.test.ts | 142 ++++++++++++++++++ .../src/commands/federation/statusTable.ts | 86 +++++++++++ 2 files changed, 228 insertions(+) create mode 100644 packages/repack/src/commands/federation/__tests__/statusTable.test.ts create mode 100644 packages/repack/src/commands/federation/statusTable.ts diff --git a/packages/repack/src/commands/federation/__tests__/statusTable.test.ts b/packages/repack/src/commands/federation/__tests__/statusTable.test.ts new file mode 100644 index 000000000..fc52818e0 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/statusTable.test.ts @@ -0,0 +1,142 @@ +import type { PlannedApp } from '../devPlan.js'; +import { + planToJson, + renderPlanTable, + renderStatusTable, + statusToJson, +} from '../statusTable.js'; + +const host: PlannedApp = { + name: 'host', + role: 'host', + root: '/ws', + config: '/ws/config.host-app.mts', + bundler: 'rspack', + port: 8081, + url: 'http://localhost:8081', + spawn: { file: 'node', args: [], cwd: '/ws' }, + commandLine: 'node /rn/cli.js start --port 8081', +}; + +const mini: PlannedApp = { + name: 'MiniApp', + role: 'remote', + root: '/ws', + bundler: 'rspack', + url: 'http://localhost:', + spawn: { file: 'node', args: [], cwd: '/ws' }, + commandLine: 'node /rn/cli.js start --port ', +}; + +/** Collapse column padding to single spaces: content-level golden. */ +const norm = (rows: string[]) => + rows.map((row) => row.split(/\s+/).filter(Boolean).join(' ')); + +describe('renderPlanTable', () => { + it('renders deterministic ASCII rows, host first, one line per app', () => { + const rows = renderPlanTable([host, mini]); + // Exact golden (approval-verified): deterministic, fixed-width ASCII. + expect(rows).toEqual([ + 'PLAN', + 'app config port url', + 'host /ws/config.host-app.mts 8081 http://localhost:8081', + 'MiniApp - auto http://localhost:', + ]); + expect(norm(rows)).toEqual([ + 'PLAN', + 'app config port url', + 'host /ws/config.host-app.mts 8081 http://localhost:8081', + 'MiniApp - auto http://localhost:', + ]); + for (const row of rows) { + expect(row).not.toMatch(/[^\x20-\x7e]/); // printable ASCII only + } + // Fixed-width columns: every row shares the column start offsets. + const header = rows[1]!; + expect(header.length).toBeGreaterThan(0); + for (const row of rows.slice(2)) { + expect(row.indexOf('http://')).toBe(header.indexOf('url')); + } + }); + + it('is byte-identical across identical inputs (CI-deterministic)', () => { + expect(renderPlanTable([host, mini])).toEqual( + renderPlanTable([host, mini]) + ); + }); +}); + +describe('renderStatusTable', () => { + it('renders health rows with the planned ports and URLs', () => { + const rows = renderStatusTable([host, mini], { + host: 'running', + MiniApp: 'starting', + }); + expect(rows).toEqual([ + 'STATUS', + 'app port url health', + 'host 8081 http://localhost:8081 running', + 'MiniApp auto http://localhost: starting', + ]); + expect(norm(rows)).toEqual([ + 'STATUS', + 'app port url health', + 'host 8081 http://localhost:8081 running', + 'MiniApp auto http://localhost: starting', + ]); + }); +}); + +describe('JSON output contract', () => { + it('emits exactly the event/apps envelope with the spec field list', () => { + const doc = JSON.parse(planToJson([host, mini])); + expect(Object.keys(doc)).toEqual(['event', 'apps']); + expect(doc.event).toBe('plan'); + expect(Object.keys(doc.apps[0])).toEqual([ + 'name', + 'role', + 'root', + 'config', + 'bundler', + 'port', + 'url', + 'command', + 'status', + ]); + expect(doc.apps).toEqual([ + { + name: 'host', + role: 'host', + root: '/ws', + config: '/ws/config.host-app.mts', + bundler: 'rspack', + port: 8081, + url: 'http://localhost:8081', + command: 'node /rn/cli.js start --port 8081', + status: 'planned', + }, + { + name: 'MiniApp', + role: 'remote', + root: '/ws', + config: null, + bundler: 'rspack', + port: null, + url: 'http://localhost:', + command: 'node /rn/cli.js start --port ', + status: 'planned', + }, + ]); + }); + + it('emits live status docs with the transitioned statuses', () => { + const doc = JSON.parse( + statusToJson([host, mini], { host: 'running', MiniApp: 'failed' }) + ); + expect(doc.event).toBe('status'); + expect(doc.apps.map((app: { status: string }) => app.status)).toEqual([ + 'running', + 'failed', + ]); + }); +}); diff --git a/packages/repack/src/commands/federation/statusTable.ts b/packages/repack/src/commands/federation/statusTable.ts new file mode 100644 index 000000000..ad2a0fa6a --- /dev/null +++ b/packages/repack/src/commands/federation/statusTable.ts @@ -0,0 +1,86 @@ +import type { PlannedApp } from './devPlan.js'; +import type { AppStatus } from './supervisor.js'; + +/** Fixed-width ASCII column render: same input ⇒ byte-identical rows. */ +function renderTable( + title: string, + headers: string[], + rows: string[][] +): string[] { + const widths = headers.map((header, column) => + Math.max(header.length, ...rows.map((row) => row[column]!.length)) + ); + const line = (cells: string[]) => + cells + .slice(0, -1) + .map((cell, column) => cell.padEnd(widths[column]! + 2)) + .join('') + cells[cells.length - 1]!; + return [title, line(headers), ...rows.map(line)]; +} + +const portCell = (app: PlannedApp) => + app.port === undefined ? 'auto' : String(app.port); +const configCell = (app: PlannedApp) => app.config ?? '-'; + +/** PLAN table: app / resolved config / port / URL — host first, file order. */ +export function renderPlanTable(plan: PlannedApp[]): string[] { + return renderTable( + 'PLAN', + ['app', 'config', 'port', 'url'], + plan.map((app) => [app.name, configCell(app), portCell(app), app.url]) + ); +} + +/** STATUS table: app / port / URL / health — same order as the plan. */ +export function renderStatusTable( + plan: PlannedApp[], + statuses: Record +): string[] { + return renderTable( + 'STATUS', + ['app', 'port', 'url', 'health'], + plan.map((app) => [ + app.name, + portCell(app), + app.url, + statuses[app.name] ?? 'starting', + ]) + ); +} + +/** One JSON app entry — spec field list, in order. */ +function jsonApp( + app: PlannedApp, + status: 'planned' | AppStatus +): Record { + return { + name: app.name, + role: app.role, + root: app.root, + config: app.config ?? null, + bundler: app.bundler, + port: app.port ?? null, + url: app.url, + command: app.commandLine, + status, + }; +} + +/** `--json` plan document (`event: "plan"`, planning-time status). */ +export function planToJson(plan: PlannedApp[]): string { + return JSON.stringify({ + event: 'plan', + apps: plan.map((app) => jsonApp(app, 'planned')), + }); +} + +/** `--json` status document (`event: "status"`, live transitions). */ +export function statusToJson( + plan: PlannedApp[], + statuses: Record +): string { + return JSON.stringify({ + event: 'status', + apps: plan.map((app) => jsonApp(app, statuses[app.name] ?? 'starting')), + }); +} From 9b01c07eef667136308ce80809dab09095758768 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:50:36 +0200 Subject: [PATCH 29/54] feat(repack): add federation-dev command with dry-run and json output --- .../commands/__tests__/federationDev.test.ts | 152 +++++++++++++ .../src/commands/__tests__/index.test.ts | 4 + .../repack/src/commands/federation-dev.ts | 212 ++++++++++++++++++ packages/repack/src/commands/index.ts | 9 + packages/repack/src/commands/options.ts | 40 ++++ packages/repack/src/commands/types.ts | 18 ++ 6 files changed, 435 insertions(+) create mode 100644 packages/repack/src/commands/__tests__/federationDev.test.ts create mode 100644 packages/repack/src/commands/federation-dev.ts diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts new file mode 100644 index 000000000..5e62bb356 --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -0,0 +1,152 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import execa from 'execa'; +import * as portPlanner from '../federation/portPlanner.js'; +import { federationDev } from '../federation-dev.js'; + +jest.mock('execa'); +const execaMock = execa as unknown as jest.Mock; + +const FIXTURES = path.join( + __dirname, + '..', + 'federation', + '__tests__', + '__fixtures__' +); +const TWIN = path.join(FIXTURES, 'config-dev-twin'); + +const cliConfig = { + root: '/project', + platforms: ['ios'], + reactNativePath: '/project/node_modules/react-native', +}; + +let exitSpy: jest.SpyInstance; +let logSpy: jest.SpyInstance; +let errorSpy: jest.SpyInstance; +let tmpDir: string; +let previousCwd: string; + +const output = () => + [...logSpy.mock.calls, ...errorSpy.mock.calls] + .map((call) => call.map(String).join(' ')) + .join('\n'); + +beforeEach(() => { + previousCwd = process.cwd(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fed-dev-')); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + // Deterministic probes: the developer's machine may really hold 8081/8082. + jest.spyOn(portPlanner, 'isPortBusy').mockResolvedValue(false); + process.chdir(TWIN); +}); + +afterEach(() => { + process.chdir(previousCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); +}); + +describe('federation-dev usage errors (exit 2, spawn nothing)', () => { + it('missing workspace file names repack-federation.json and federation-init', async () => { + process.chdir(tmpDir); + await federationDev([], cliConfig, { + apps: 'MiniApp', + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain('repack-federation.json'); + expect(output()).toContain('federation-init'); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('invalid file names the path and the failing field, no stack', async () => { + fs.writeFileSync( + path.join(tmpDir, 'repack-federation.json'), + JSON.stringify({ host: { root: '.' }, remotes: {} }) + ); + process.chdir(tmpDir); + await federationDev([], cliConfig, { interactive: false }); + expect(exitSpy).toHaveBeenCalledWith(2); + const text = output(); + expect(text).toContain(path.join(tmpDir, 'repack-federation.json')); + expect(text).toContain('host.manifest'); + expect(text).not.toMatch(/\n\s+at\s/); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('unknown --apps name is named and known remotes are listed', async () => { + await federationDev([], cliConfig, { + apps: 'NoSuchApp', + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain('NoSuchApp'); + // Lists the known remote so the user sees the typo's alternative. + expect(output()).toContain('MiniApp'); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('a flag-shaped --apps value is an unknown name, not an option', async () => { + // Threat row "Subprocess spawn": `--apps --no-interactive` never reaches + // any spawn argv — it is rejected as an unknown app name. + await federationDev([], cliConfig, { + apps: '--no-interactive', + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain('--no-interactive'); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('--platform web exits 2 naming ios and android', async () => { + await federationDev([], cliConfig, { + platform: 'web', + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain('ios'); + expect(output()).toContain('android'); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('a non-integer --port exits 2', async () => { + await federationDev([], cliConfig, { + port: Number('abc'), + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('standalone on an undeclared remote exits 2 naming the remote and the field', async () => { + process.chdir(path.join(FIXTURES, 'config-standalone')); + await federationDev([], cliConfig, { + standalone: 'undeclared', + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain('undeclared'); + expect(output()).toContain('standalone'); + expect(execaMock).not.toHaveBeenCalled(); + }); +}); + +describe('federation-dev non-TTY defaults', () => { + it('defaults the plan to host plus every declared remote without prompting', async () => { + // jest's stdout is not a TTY: the wizard never runs; the default + // session is host + all remotes (spec "Non-TTY default plan"). + await federationDev([], cliConfig, { dryRun: true }); + expect(exitSpy).toHaveBeenCalledWith(0); + const text = output(); + expect(text).toContain('host'); + expect(text).toContain('MiniApp'); + expect(execaMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/repack/src/commands/__tests__/index.test.ts b/packages/repack/src/commands/__tests__/index.test.ts index 48eb56c20..68ae9cd82 100644 --- a/packages/repack/src/commands/__tests__/index.test.ts +++ b/packages/repack/src/commands/__tests__/index.test.ts @@ -3,6 +3,7 @@ import commands, { createBoundCommands } from '../index.js'; import type { BundleArguments, CliConfig, StartArguments } from '../types.js'; jest.mock('../bundle.js'); +jest.mock('../federation-dev.js'); jest.mock('../federationDoctor.js'); jest.mock('../federationInit.js'); jest.mock('../federationManifest.js'); @@ -73,6 +74,9 @@ describe('command registry', () => { expect.arrayContaining([ expect.stringMatching(/^federation-init/), 'federation-doctor', + // federation-dev is a flat command too: discoverable by plain name, + // no positional to declare, outside createBoundCommands. + 'federation-dev', expect.stringMatching(/^federation-manifest/), ]) ); diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts new file mode 100644 index 000000000..13b7deb1b --- /dev/null +++ b/packages/repack/src/commands/federation-dev.ts @@ -0,0 +1,212 @@ +import path from 'node:path'; +import { CLIError } from '../helpers/index.js'; +import { + assertRemoteStandalone, + ConfigFileInvalidError, + FEDERATION_CONFIG_FILENAME, + loadFederationConfig, +} from './federation/configFile.js'; +import type { PlanInput, PlannedApp } from './federation/devPlan.js'; +import { buildPlan } from './federation/devPlan.js'; +import { isPortBusy, planPorts } from './federation/portPlanner.js'; +import { resolveReactNativeBin } from './federation/rnBin.js'; +import { planToJson, renderPlanTable } from './federation/statusTable.js'; +import type { CliConfig, FederationDevArguments } from './types.js'; + +/** Split `--apps` into names, tolerating a merged array from the CLI. */ +function parseAppList(apps: string | string[] | undefined): string[] { + if (!apps) return []; + const values = Array.isArray(apps) ? apps : [apps]; + return values.flatMap((value) => + value + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + ); +} + +/** Usage error: actionable message, exit 2, nothing spawned. */ +function usageError(message: string): void { + console.error(message); + process.exit(2); +} + +function printPlan(plan: PlannedApp[], json: boolean): void { + if (json) { + console.log(planToJson(plan)); + return; + } + for (const row of renderPlanTable(plan)) console.log(row); +} + +/** + * Run every app a `repack-federation.json` declares — host plus a session of + * remotes — as one supervised dev session. Exit codes: 0 success or dry-run + * plan, 1 port conflict / failed session, 2 usage or config error (nothing + * spawned). `--dry-run` prints the plan and spawns nothing. + * + * @param _argv Original, non-parsed arguments. + * @param _cliConfig Configuration object containing platform and project settings. + * @param args Parsed command line arguments. + */ +export async function federationDev( + _argv: string[], + _cliConfig: CliConfig, + args: FederationDevArguments +) { + if ( + args.port !== undefined && + (!Number.isInteger(args.port) || args.port < 1 || args.port > 65535) + ) { + usageError( + `Invalid --port ${args.port}: expected an integer between 1 and 65535.` + ); + return; + } + if ( + args.platform !== undefined && + args.platform !== 'ios' && + args.platform !== 'android' + ) { + usageError( + `Invalid --platform "${args.platform}": only "ios" and "android" are ` + + 'supported — the runner starts one dev server session per platform.' + ); + return; + } + + let loaded: ReturnType; + try { + loaded = loadFederationConfig(); + } catch (error) { + if (error instanceof ConfigFileInvalidError) { + usageError(`${error.filePath}: ${error.reasons.join('; ')}`); + return; + } + throw error; + } + if (!loaded) { + usageError( + `No ${FEDERATION_CONFIG_FILENAME} found — federation-dev runs the apps ` + + 'that file declares. Create one with "react-native federation-init".' + ); + return; + } + const { filePath, config } = loaded; + + const declaredNames = Object.keys(config.remotes); + const requested = parseAppList(args.apps); + const unknown = requested.filter((name) => !(name in config.remotes)); + if (unknown.length > 0) { + usageError( + `Unknown app ${unknown.map((name) => JSON.stringify(name)).join(', ')} — ` + + `not a declared remote in ${filePath}. Known remotes: ` + + (declaredNames.join(', ') || '(none)') + + '.' + ); + return; + } + if (args.standalone !== undefined && !(args.standalone in config.remotes)) { + usageError( + `Unknown app ${JSON.stringify(args.standalone)} — not a declared ` + + `remote in ${filePath}. Known remotes: ` + + (declaredNames.join(', ') || '(none)') + + '.' + ); + return; + } + + // Standalone refuses through the shipped gate, before any planning. + if (args.standalone !== undefined) { + try { + assertRemoteStandalone(config, filePath, args.standalone); + } catch (error) { + if (error instanceof CLIError) { + usageError(error.message); + return; + } + throw error; + } + } + + // Wizard gate: --apps, --no-interactive or a non-TTY stdout suppress the + // interactive wizard; the default session is then host + every remote. + const session: PlanInput['session'] = { + remotes: requested.length > 0 ? requested : declaredNames, + standaloneRemote: args.standalone, + }; + + const configDir = path.dirname(filePath); + const hostRoot = path.resolve(configDir, config.host.root ?? '.'); + let rnCliPath: string; + try { + rnCliPath = resolveReactNativeBin(hostRoot, { extraPaths: [configDir] }); + } catch (error) { + usageError(error instanceof Error ? error.message : String(error)); + return; + } + + const planBase = { + configPath: filePath, + config, + session, + overrides: { + port: args.port, + platform: args.platform === 'android' ? 'android' : args.platform, + }, + rnCliPath, + } as const; + + let effective: PlannedApp[]; + try { + effective = buildPlan({ ...planBase, ports: {} }); + } catch (error) { + if (error instanceof CLIError) { + usageError(error.message); + return; + } + throw error; + } + + const conflicts: { app: string; port: number }[] = []; + let ports: Record; + + if (args.dryRun) { + // Conflict rules run read-only against the real machine: a busy declared + // port fails the same way a live run would — but nothing is allocated or + // spawned, and unmanaged apps display `auto`. + ports = {}; + for (const app of effective) { + if (app.port === undefined) { + ports[app.name] = 'auto'; + } else if (await isPortBusy(app.port)) { + if (args.autoPorts) ports[app.name] = 'auto'; + else conflicts.push({ app: app.name, port: app.port }); + } else { + ports[app.name] = app.port; + } + } + } else { + const resolved = await planPorts(effective, { + autoPorts: args.autoPorts === true, + probePort: isPortBusy, + }); + conflicts.push(...resolved.conflicts); + ports = resolved.ports; + } + + if (conflicts.length > 0) { + for (const conflict of conflicts) { + console.error( + `Port ${conflict.port} declared by ${conflict.app} is already in ` + + 'use. Free the port or rerun with --auto-ports.' + ); + } + process.exit(1); + return; + } + + const plan = buildPlan({ ...planBase, ports }); + printPlan(plan, args.json === true); + process.exit(0); +} diff --git a/packages/repack/src/commands/index.ts b/packages/repack/src/commands/index.ts index 5620dd4aa..e15db4a59 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 { federationDev } from './federation-dev.js'; import { federationDoctor } from './federationDoctor.js'; import { federationInit } from './federationInit.js'; import { federationManifest } from './federationManifest.js'; import { bundleCommandOptions, + federationDevCommandOptions, federationDoctorCommandOptions, federationInitCommandOptions, federationManifestCommandOptions, @@ -54,6 +56,13 @@ const federationCommands = [ options: federationManifestCommandOptions, func: federationManifest, }, + { + name: 'federation-dev', + description: + 'Run the host and selected remotes from repack-federation.json as one supervised dev session, with plan preview via --dry-run.', + options: federationDevCommandOptions, + func: federationDev, + }, { name: 'federation-doctor', description: diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index c868bc511..371e5946b 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -163,6 +163,46 @@ export const federationInitCommandOptions = [ }, ]; +export const federationDevCommandOptions = [ + { + name: '--apps ', + description: + 'Comma-separated remotes to run for this session (default: every remote in repack-federation.json)', + }, + { + name: '--platform ', + description: 'App platform to print run guidance for: "ios" or "android"', + }, + { + name: '--port ', + description: + 'Host dev-server port (overrides the port declared in repack-federation.json)', + parse: (val: string) => Number(val), + }, + { + name: '--auto-ports', + description: 'Reassign busy ports to free ones instead of failing', + }, + { + name: '--standalone ', + description: + 'Launch the named remote in standalone mode (requires it to declare "standalone": true)', + }, + { + name: '--no-interactive', + description: 'Skip the interactive session wizard and use the default plan', + }, + { + name: '--json', + description: 'Print the plan and status as machine-readable JSON', + }, + { + name: '--dry-run', + description: + 'Print the plan a live run would use and exit without spawning anything', + }, +]; + export const bundleCommandOptions = [ { name: '--entry-file ', diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index 587997c2f..b672aabdc 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -63,6 +63,24 @@ export interface FederationDoctorArguments { dryRun?: boolean; } +export interface FederationDevArguments { + /** Comma-separated remote names; absent means every declared remote. */ + apps?: string | string[]; + platform?: string; + /** Host dev-server port; parsed Number, range-checked by the command. */ + port?: number; + /** Reassign busy declared ports instead of failing with a conflict. */ + autoPorts?: boolean; + /** Remote to launch in standalone mode, gated by the file declaration. */ + standalone?: string; + /** False with --no-interactive: suppresses the interactive wizard. */ + interactive?: boolean; + /** Machine-readable plan/status documents on stdout. */ + json?: boolean; + /** Print the plan and spawn nothing. */ + dryRun?: boolean; +} + export interface FederationInitArguments { /** Name of the remote to scaffold. */ name?: string; From f55d6973494607e895012ff334fa02938962d27c Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:58:12 +0200 Subject: [PATCH 30/54] feat(repack): wire federation-dev live session with exit codes --- .../commands/__tests__/federationDev.test.ts | 202 ++++++++++++++++++ .../repack/src/commands/federation-dev.ts | 94 +++++++- 2 files changed, 294 insertions(+), 2 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 5e62bb356..08828650b 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -1,13 +1,26 @@ +import { EventEmitter } from 'node:events'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; +import { PassThrough } from 'node:stream'; import execa from 'execa'; +import { runAdbReverse } from '../common/runAdbReverse.js'; import * as portPlanner from '../federation/portPlanner.js'; import { federationDev } from '../federation-dev.js'; jest.mock('execa'); const execaMock = execa as unknown as jest.Mock; +jest.mock('../common/runAdbReverse.js'); +const adbMock = runAdbReverse as jest.Mock; + +class FakeChild extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + kill = jest.fn(); +} + const FIXTURES = path.join( __dirname, '..', @@ -150,3 +163,192 @@ describe('federation-dev non-TTY defaults', () => { expect(execaMock).not.toHaveBeenCalled(); }); }); + +describe('federation-dev live session', () => { + let children: FakeChild[]; + let servers: http.Server[]; + let liveWorkspace: string; + + // OS-assigned free port when 0; resolves with the port actually bound. + const startServer = (port: number) => + new Promise((resolve, reject) => { + const server = http.createServer((_req, res) => { + // The readiness contract real dev servers answer on /status. + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('packager-status:running'); + }); + server.once('error', reject); + server.listen(port, () => { + servers.push(server); + resolve((server.address() as { port: number }).port); + }); + }); + + const waitFor = async ( + predicate: () => boolean, + timeoutMs = 8000, + describeState: () => string = () => '' + ) => { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) + throw new Error(`waitFor timed out: ${describeState()}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }; + + beforeEach(() => { + children = []; + servers = []; + liveWorkspace = ''; + execaMock.mockImplementation(() => { + const child = new FakeChild(); + children.push(child); + return child; + }); + }); + + afterEach(async () => { + for (const child of children) child.emit('exit', 0, null); + if (liveWorkspace) + fs.rmSync(liveWorkspace, { recursive: true, force: true }); + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + // Readiness pollers keep a socket pool warm; force-kill the + // keep-alive handles so close() cannot hang the suite. + server.closeAllConnections?.(); + }) + ) + ); + }); + + it('spawns host + selected remote with the requested port, prints adb guidance, never runs adb itself', async () => { + // No servers needed: the assertions cover spawn argv and guidance, and + // a clean exit-0 ends the session whatever the readiness state. + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + platform: 'ios', + port: 8090, + interactive: false, + }); + await waitFor(() => execaMock.mock.calls.length === 2); + + const spawnArgs = execaMock.mock.calls.map( + (call) => (call as unknown as [string, string[]])[1] + ); + expect(spawnArgs).toHaveLength(2); + const hostArgs = spawnArgs.find((a) => a.includes('8090')); + const remoteArgs = spawnArgs.find((a) => a.includes('8082')); + expect(hostArgs).toBeDefined(); + expect(remoteArgs).toBeDefined(); + for (const args of spawnArgs) { + expect(args).toContain('--no-interactive'); + // Threat row "adb execution": no adb ever rides the spawn mock. + expect(args.join(' ')).not.toContain('adb'); + } + // Host port goes through the audited runAdbReverse helper, once. + expect(adbMock).toHaveBeenCalledTimes(1); + expect(adbMock).toHaveBeenCalledWith( + expect.objectContaining({ port: 8090 }) + ); + // Remote port is guidance for the developer, not an executed command. + expect(output()).toContain('adb reverse tcp:8082 tcp:8082'); + // Platform guidance names the runnable command and the host port. + expect(output()).toContain('run-ios'); + expect(output()).toContain('8090'); + + for (const child of children) child.emit('exit', 0, null); + await command; + expect(exitSpy).toHaveBeenLastCalledWith(0); + }, 20000); + + it('--json live emits a parseable plan doc and status docs ending exited', async () => { + // OS-assigned ports inside a workspace under the fixtures tree (so the + // repo's react-native stays resolvable): well-known 8081/8082 may be + // held by real dev servers on the developer's machine. + const hostPort = await startServer(0); + const remotePort = await startServer(0); + liveWorkspace = fs.mkdtempSync(path.join(FIXTURES, 'live-')); + fs.writeFileSync( + path.join(liveWorkspace, 'repack-federation.json'), + JSON.stringify({ + host: { manifest: './build/host', root: '.', port: hostPort }, + remotes: { + MiniApp: { manifest: './build/mini', root: '.', port: remotePort }, + }, + }) + ); + process.chdir(liveWorkspace); + + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + json: true, + interactive: false, + }); + await waitFor(() => execaMock.mock.calls.length === 2); + const docsUpToRunning = () => + logSpy.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('{')) + .map((line) => JSON.parse(line)); + await waitFor( + () => { + const docs = docsUpToRunning().filter((d) => d.event === 'status'); + return ( + docs.length > 0 && + docs[docs.length - 1].apps.every( + (app: { status: string }) => app.status === 'running' + ) + ); + }, + 12000, + () => + JSON.stringify({ + spawnCalls: execaMock.mock.calls.length, + children: children.length, + docs: docsUpToRunning().map((doc) => doc.event), + }) + ); + for (const child of children) child.emit('exit', 0, null); + await command; + + const docs = docsUpToRunning(); + expect(docs[0].event).toBe('plan'); + const finalDoc = docs[docs.length - 1]; + expect(finalDoc.event).toBe('status'); + expect(finalDoc.apps.map((app: { status: string }) => app.status)).toEqual([ + 'exited', + 'exited', + ]); + expect(exitSpy).toHaveBeenLastCalledWith(0); + }, 20000); + + it('exits 1 naming app and busy port without spawning or hanging', async () => { + jest + .spyOn(portPlanner, 'isPortBusy') + .mockImplementation(async (port: number) => port === 8082); + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + interactive: false, + }); + await expect(command).resolves.toBeUndefined(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(output()).toContain('MiniApp'); + expect(output()).toContain('8082'); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('--dry-run --json is byte-identical across runs and spawns nothing', async () => { + await federationDev([], cliConfig, { dryRun: true, json: true }); + const firstRun = logSpy.mock.calls.map((call) => String(call[0])); + logSpy.mockClear(); + await federationDev([], cliConfig, { dryRun: true, json: true }); + const secondRun = logSpy.mock.calls.map((call) => String(call[0])); + expect(secondRun).toEqual(firstRun); + expect(JSON.parse(firstRun[0]!).event).toBe('plan'); + expect(execaMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index 13b7deb1b..0b53c5553 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -1,5 +1,7 @@ +import http from 'node:http'; import path from 'node:path'; import { CLIError } from '../helpers/index.js'; +import { runAdbReverse } from './common/runAdbReverse.js'; import { assertRemoteStandalone, ConfigFileInvalidError, @@ -10,9 +12,41 @@ import type { PlanInput, PlannedApp } from './federation/devPlan.js'; import { buildPlan } from './federation/devPlan.js'; import { isPortBusy, planPorts } from './federation/portPlanner.js'; import { resolveReactNativeBin } from './federation/rnBin.js'; -import { planToJson, renderPlanTable } from './federation/statusTable.js'; +import { RunnerConsole } from './federation/runnerConsole.js'; +import { + planToJson, + renderPlanTable, + renderStatusTable, + statusToJson, +} from './federation/statusTable.js'; +import { DevSupervisor } from './federation/supervisor.js'; import type { CliConfig, FederationDevArguments } from './types.js'; +/** + * Readiness/TOCTOU probe: `GET url/status` answers with its body, anything + * else (refused, timeout, non-200) is silence — never a thrown error, the + * supervisor treats both as "not up". + */ +function probeStatus(url: string): Promise { + return new Promise((resolve) => { + const request = http.get(`${url}/status`, { timeout: 1000 }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + body += chunk; + }); + response.on('end', () => + resolve(response.statusCode === 200 ? body : null) + ); + }); + request.on('error', () => resolve(null)); + request.on('timeout', () => { + request.destroy(); + resolve(null); + }); + }); +} + /** Split `--apps` into names, tolerating a merged array from the CLI. */ function parseAppList(apps: string | string[] | undefined): string[] { if (!apps) return []; @@ -208,5 +242,61 @@ export async function federationDev( const plan = buildPlan({ ...planBase, ports }); printPlan(plan, args.json === true); - process.exit(0); + + if (args.dryRun) { + process.exit(0); + return; + } + + // Live session. adb: the host port goes through the audited + // `runAdbReverse` helper exactly once (device discovery lives inside it); + // remote ports are printed guidance only — the runner never executes adb + // for them (threat row "adb execution"). + const host = plan.find((app) => app.role === 'host')!; + const runnerConsole = new RunnerConsole({ stdout: process.stdout }); + const platform = args.platform ?? 'ios'; + await runAdbReverse({ port: host.port as number }); + for (const app of plan) { + if (app.role === 'remote') { + console.log( + `Remote port: run "adb reverse tcp:${app.port} tcp:${app.port}" on ` + + 'your device to reach it from the app.' + ); + } + } + console.log( + `Run your app with: react-native run-${platform} — it reaches the host ` + + `dev server at ${host.url}` + ); + + const supervisor = new DevSupervisor(plan, runnerConsole, { probeStatus }); + // One Ctrl-C asks for the supervisor's ordered shutdown; the second one + // escalates inside the supervisor (SIGINT → grace → SIGTERM). + const onSigint = () => supervisor.shutdown('interrupt'); + process.on('SIGINT', onSigint); + + let lastDoc = ''; + const statusWatch = setInterval(() => { + if (!args.json) return; + const doc = statusToJson(plan, supervisor.getStatuses()); + if (doc !== lastDoc) { + lastDoc = doc; + console.log(doc); + } + }, 250); + + const result = await supervisor.run(); + clearInterval(statusWatch); + process.off('SIGINT', onSigint); + + const statuses = supervisor.getStatuses(); + if (args.json) { + console.log(statusToJson(plan, statuses)); + } else { + const rows = renderStatusTable(plan, statuses); + for (const row of rows) console.log(row); + runnerConsole.persist(rows); + } + runnerConsole.release(); + process.exit(result.exitCode); } From edabef58a4ad6a37a7549e1b6384117c467eb91b Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 12:59:54 +0200 Subject: [PATCH 31/54] fix(dev-server): build url from normalized port --- .../utils/__tests__/normalizeOptions.test.ts | 34 +++++++++++++++++++ .../dev-server/src/utils/normalizeOptions.ts | 4 ++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/dev-server/src/utils/__tests__/normalizeOptions.test.ts diff --git a/packages/dev-server/src/utils/__tests__/normalizeOptions.test.ts b/packages/dev-server/src/utils/__tests__/normalizeOptions.test.ts new file mode 100644 index 000000000..dcbfbe9a4 --- /dev/null +++ b/packages/dev-server/src/utils/__tests__/normalizeOptions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeOptions } from '../normalizeOptions.js'; + +const base = { + devMiddleware: {} as never, + rootDir: '/project', +}; + +describe('normalizeOptions url', () => { + it('uses the default port in the url when port is omitted', () => { + const { url, port } = normalizeOptions({ ...base }); + expect(port).toBe(8081); + expect(url).toBe('http://localhost:8081'); + expect(url).not.toContain('undefined'); + }); + + it('keeps an explicit port in the url', () => { + const { url } = normalizeOptions({ ...base, port: 9000 }); + expect(url).toBe('http://localhost:9000'); + }); + + it('combines an explicit host with the default port', () => { + const { url } = normalizeOptions({ ...base, host: '0.0.0.0' }); + expect(url).toBe('http://0.0.0.0:8081'); + }); + + it('uses the https scheme with the default port for an https server', () => { + const { url } = normalizeOptions({ + ...base, + server: { type: 'https', options: {} }, + }); + expect(url).toBe('https://localhost:8081'); + }); +}); diff --git a/packages/dev-server/src/utils/normalizeOptions.ts b/packages/dev-server/src/utils/normalizeOptions.ts index f9576d9f5..b148712c3 100644 --- a/packages/dev-server/src/utils/normalizeOptions.ts +++ b/packages/dev-server/src/utils/normalizeOptions.ts @@ -65,7 +65,9 @@ export function normalizeOptions(options: Server.Options): NormalizedOptions { const hot = options.hot ?? false; const protocol = https ? 'https' : 'http'; - const url = `${protocol}://${host}:${options.port}`; + // the normalized `port`, not `options.port` — an omitted port would + // otherwise leak `undefined` into every url built from this one + const url = `${protocol}://${host}:${port}`; const proxy = normalizeProxyOptions(options.proxy, url); const setupMiddlewares = normalizeSetupMiddlewares(options.setupMiddlewares); From aaca321b4c1db793cdd22b440b3bc17a4edcae6b Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 13:07:11 +0200 Subject: [PATCH 32/54] feat(repack): add federation-dev interactive wizard --- packages/repack/package.json | 1 + .../commands/__tests__/federationDev.test.ts | 52 +++ .../repack/src/commands/federation-dev.ts | 57 +++- .../federation/__tests__/wizard.test.ts | 233 +++++++++++++ .../repack/src/commands/federation/wizard.ts | 308 ++++++++++++++++++ pnpm-lock.yaml | 3 + 6 files changed, 643 insertions(+), 11 deletions(-) create mode 100644 packages/repack/src/commands/federation/__tests__/wizard.test.ts create mode 100644 packages/repack/src/commands/federation/wizard.ts diff --git a/packages/repack/package.json b/packages/repack/package.json index 50cf9a889..78620d900 100644 --- a/packages/repack/package.json +++ b/packages/repack/package.json @@ -81,6 +81,7 @@ } }, "dependencies": { + "@clack/prompts": "^0.9.1", "@callstack/repack-dev-server": "workspace:*", "@discoveryjs/json-ext": "^0.5.7", "@rspack/plugin-react-refresh": "1.0.0", diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 08828650b..926a20ec8 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -7,6 +7,7 @@ import { PassThrough } from 'node:stream'; import execa from 'execa'; import { runAdbReverse } from '../common/runAdbReverse.js'; import * as portPlanner from '../federation/portPlanner.js'; +import * as wizard from '../federation/wizard.js'; import { federationDev } from '../federation-dev.js'; jest.mock('execa'); @@ -164,6 +165,57 @@ describe('federation-dev non-TTY defaults', () => { }); }); +describe('federation-dev wizard gate', () => { + let wizardSpy: jest.SpyInstance; + const originalIsTTY = process.stdout.isTTY; + + beforeEach(() => { + wizardSpy = jest + .spyOn(wizard, 'runWizard') + .mockResolvedValue({ status: 'cancelled' } as never); + }); + + afterEach(() => { + process.stdout.isTTY = originalIsTTY; + }); + + it('never runs the wizard when stdout is not a TTY', async () => { + process.stdout.isTTY = false; + await federationDev([], cliConfig, { dryRun: true }); + expect(wizardSpy).not.toHaveBeenCalled(); + }); + + it('runs the wizard on a TTY when neither --apps nor --no-interactive is given', async () => { + process.stdout.isTTY = true; + wizardSpy.mockResolvedValue({ + status: 'completed', + answers: { + session: { remotes: ['MiniApp'] }, + platform: undefined, + ports: { host: 8099 }, + }, + }); + await federationDev([], cliConfig, { dryRun: true }); + expect(wizardSpy).toHaveBeenCalledTimes(1); + // The answers drive the plan: the wizard's host port shows up. + expect(output()).toContain('8099'); + }); + + it('a cancelled wizard exits 0 without spawning', async () => { + process.stdout.isTTY = true; + await federationDev([], cliConfig, { dryRun: true }); + expect(wizardSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('--no-interactive suppresses the wizard even on a TTY', async () => { + process.stdout.isTTY = true; + await federationDev([], cliConfig, { dryRun: true, interactive: false }); + expect(wizardSpy).not.toHaveBeenCalled(); + }); +}); + describe('federation-dev live session', () => { let children: FakeChild[]; let servers: http.Server[]; diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index 0b53c5553..719b262b0 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -20,6 +20,7 @@ import { statusToJson, } from './federation/statusTable.js'; import { DevSupervisor } from './federation/supervisor.js'; +import { runWizard } from './federation/wizard.js'; import type { CliConfig, FederationDevArguments } from './types.js'; /** @@ -163,13 +164,6 @@ export async function federationDev( } } - // Wizard gate: --apps, --no-interactive or a non-TTY stdout suppress the - // interactive wizard; the default session is then host + every remote. - const session: PlanInput['session'] = { - remotes: requested.length > 0 ? requested : declaredNames, - standaloneRemote: args.standalone, - }; - const configDir = path.dirname(filePath); const hostRoot = path.resolve(configDir, config.host.root ?? '.'); let rnCliPath: string; @@ -180,20 +174,26 @@ export async function federationDev( return; } - const planBase = { + // Wizard gate: --apps, --no-interactive or a non-TTY stdout suppress the + // interactive wizard; the default session is then host + every remote. + const planBase: PlanInput = { configPath: filePath, config, - session, + session: { + remotes: requested.length > 0 ? requested : declaredNames, + standaloneRemote: args.standalone, + }, overrides: { port: args.port, platform: args.platform === 'android' ? 'android' : args.platform, }, + ports: {}, rnCliPath, - } as const; + }; let effective: PlannedApp[]; try { - effective = buildPlan({ ...planBase, ports: {} }); + effective = buildPlan(planBase); } catch (error) { if (error instanceof CLIError) { usageError(error.message); @@ -202,6 +202,41 @@ export async function federationDev( throw error; } + // The wizard is an input source only: its answers rewrite the same plan + // inputs the flags drive, then everything continues down one path. + if ( + args.apps === undefined && + args.interactive !== false && + process.stdout.isTTY + ) { + const outcome = await runWizard({ config, planned: effective }); + if (outcome.status === 'cancelled') { + // Cancel is a clean no-op, not a failure (init prompts precedent). + process.exit(0); + return; + } + const { answers } = outcome; + planBase.session = { + remotes: + answers.session.remotes.length > 0 + ? answers.session.remotes + : declaredNames, + standaloneRemote: answers.session.standaloneRemote ?? args.standalone, + }; + if (answers.platform !== undefined) { + planBase.overrides.platform = answers.platform; + } + try { + effective = buildPlan({ ...planBase, ports: answers.ports }); + } catch (error) { + if (error instanceof CLIError) { + usageError(error.message); + return; + } + throw error; + } + } + const conflicts: { app: string; port: number }[] = []; let ports: Record; diff --git a/packages/repack/src/commands/federation/__tests__/wizard.test.ts b/packages/repack/src/commands/federation/__tests__/wizard.test.ts new file mode 100644 index 000000000..cac6d6671 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/wizard.test.ts @@ -0,0 +1,233 @@ +import { PassThrough } from 'node:stream'; +import type { FederationConfig } from '../configFile.js'; +import type { PlannedApp } from '../devPlan.js'; +import type { WizardAnswers, WizardOutcome } from '../wizard.js'; +import { runWizard } from '../wizard.js'; + +// Clack-shaped stub injected through the loadClack seam — the wizard never +// hard-imports @clack/prompts on the test path, so no virtual mocks needed. +interface ClackStub { + multiselect: jest.Mock; + select: jest.Mock; + confirm: jest.Mock; + text: jest.Mock; + cancel: jest.Mock; + isCancel: jest.Mock; +} + +const CANCEL = Symbol('cancel'); + +const makeClack = (): ClackStub => ({ + multiselect: jest.fn(), + select: jest.fn(), + confirm: jest.fn(), + text: jest.fn(), + cancel: jest.fn(), + isCancel: jest.fn((value: unknown) => value === CANCEL), +}); + +const config = { + host: { manifest: './build/host', root: '.', port: 8081 }, + remotes: { + MiniApp: { + manifest: './build/mini', + root: '.', + port: 8082, + standalone: true, + }, + SideApp: { manifest: './build/side', root: '.', port: 8083 }, + }, +} as unknown as FederationConfig; + +const planned = [ + { name: 'host', role: 'host', port: 8081 }, + { name: 'MiniApp', role: 'remote', port: 8082, standalone: true }, + { name: 'SideApp', role: 'remote', port: 8083 }, +] as unknown as PlannedApp[]; + +const runWith = async ( + clack: ClackStub, + overrides: { config?: FederationConfig; planned?: PlannedApp[] } = {} +): Promise => + runWizard({ + config: overrides.config ?? config, + planned: overrides.planned ?? planned, + loadClack: async () => clack as never, + input: new PassThrough(), + output: new PassThrough(), + }); + +describe('runWizard (clack path)', () => { + let clack: ClackStub; + + beforeEach(() => { + clack = makeClack(); + }); + + it('maps the answer sequence to wizard answers in spec order', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('ios'); + clack.confirm + .mockResolvedValueOnce(true) // host port + .mockResolvedValueOnce(true) // MiniApp port + .mockResolvedValueOnce(false); // standalone: no + const outcome = await runWith(clack); + expect(outcome).toEqual({ + status: 'completed', + answers: { + session: { remotes: ['MiniApp'] }, + platform: 'ios', + ports: { host: 8081, MiniApp: 8082 }, + }, + }); + // One execution path: the first block is a remotes multiselect over the + // declared remotes only (host is never optional). + expect(clack.multiselect).toHaveBeenCalledTimes(1); + const options = ( + clack.multiselect.mock.calls[0] as unknown as [ + { options: Array<{ value: string }> }, + ] + )[0].options; + expect(options.map((option) => option.value)).toEqual([ + 'MiniApp', + 'SideApp', + ]); + // Answers feed the same planning record the non-interactive path uses. + expect( + Object.keys(outcome.status === 'completed' ? outcome.answers.ports : {}) + ).toEqual(['host', 'MiniApp']); + }); + + it('port override: confirm "no" then text answer wins', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('ios'); + clack.confirm + .mockResolvedValueOnce(false) // host port: override + .mockResolvedValueOnce(true) // MiniApp port + .mockResolvedValueOnce(false); // standalone + clack.text.mockResolvedValue('8090'); + const outcome = await runWith(clack); + expect(outcome.status === 'completed' && outcome.answers.ports.host).toBe( + 8090 + ); + expect(clack.text).toHaveBeenCalledTimes(1); + }); + + it('platform "all" means no platform override', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('all'); + clack.confirm.mockResolvedValue(true); // all port confirms + const outcome = await runWith(clack); + expect(outcome.status === 'completed' && outcome.answers.platform).toBe( + undefined + ); + }); + + it('standalone is confirmed for selected remotes that declare it', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('all'); + clack.confirm + .mockResolvedValueOnce(true) // host port + .mockResolvedValueOnce(true) // MiniApp port + .mockResolvedValueOnce(true); // standalone: yes + const outcome = await runWith(clack); + expect( + outcome.status === 'completed' && outcome.answers.session.standaloneRemote + ).toBe('MiniApp'); + }); + + it('never offers standalone for remotes without the declaration', async () => { + // SideApp declares no standalone flag and is the only selection. + clack.multiselect.mockResolvedValue(['SideApp']); + clack.select.mockResolvedValue('all'); + clack.confirm + .mockResolvedValueOnce(true) // host port + .mockResolvedValueOnce(true); // SideApp port + const outcome = await runWith(clack); + // No third confirm: the standalone block never ran for SideApp. + expect(clack.confirm).toHaveBeenCalledTimes(2); + expect( + outcome.status === 'completed' && outcome.answers.session.standaloneRemote + ).toBeUndefined(); + }); + + it('cancel returns the cancelled outcome and says so via clack cancel', async () => { + clack.multiselect.mockResolvedValue(CANCEL); + const outcome = await runWith(clack); + expect(outcome).toEqual({ status: 'cancelled' }); + expect(clack.cancel).toHaveBeenCalledTimes(1); + }); +}); + +describe('runWizard (readline fallback)', () => { + const feed = (answers: string[]) => { + const input = new PassThrough(); + // readline/promises consumes lines as they arrive. + setTimeout(() => input.write(`${answers.join('\n')}\n`), 0); + return input; + }; + + const runFallback = async (answers: string[]) => { + const output = new PassThrough(); + let captured = ''; + output.on('data', (chunk) => { + captured += String(chunk); + }); + const outcome = await runWizard({ + config, + planned, + loadClack: async () => { + throw new Error('clack unavailable'); + }, + input: feed(answers), + output, + }); + return { outcome, captured }; + }; + + it('returns the same output shape as the clack path', async () => { + const { outcome, captured } = await runFallback([ + 'MiniApp', // remotes + 'ios', // platform + '', // host port: default + '8090', // MiniApp port: override + 'n', // standalone + ]); + const expected: WizardAnswers = { + session: { remotes: ['MiniApp'] }, + platform: 'ios', + ports: { host: 8081, MiniApp: 8090 }, + }; + expect(outcome).toEqual({ status: 'completed', answers: expected }); + expect(captured).toContain('MiniApp'); + expect(captured).toContain('8081'); + }); + + it('empty answers take the defaults (all remotes, all platforms)', async () => { + // six questions: remotes, platform, three ports, standalone(MiniApp) + const { outcome } = await runFallback(['', '', '', '', '', 'n']); + expect(outcome).toEqual({ + status: 'completed', + answers: { + session: { remotes: ['MiniApp', 'SideApp'] }, + platform: undefined, + ports: { host: 8081, MiniApp: 8082, SideApp: 8083 }, + }, + }); + }); + + it('EOF on stdin cancels like the clack path', async () => { + const input = new PassThrough(); + setTimeout(() => input.end(), 0); + const outcome = await runWizard({ + config, + planned, + loadClack: async () => { + throw new Error('clack unavailable'); + }, + input, + output: new PassThrough(), + }); + expect(outcome).toEqual({ status: 'cancelled' }); + }); +}); diff --git a/packages/repack/src/commands/federation/wizard.ts b/packages/repack/src/commands/federation/wizard.ts new file mode 100644 index 000000000..65bc2b91a --- /dev/null +++ b/packages/repack/src/commands/federation/wizard.ts @@ -0,0 +1,308 @@ +import nodeReadline from 'node:readline'; +import type { FederationConfig } from './configFile.js'; +import type { PlannedApp } from './devPlan.js'; + +/** What the wizard collects — plain planning inputs, nothing else. */ +export interface WizardAnswers { + session: { remotes: string[]; standaloneRemote?: string }; + /** Absent means "all": no per-platform spawn arg or guidance. */ + platform?: 'ios' | 'android'; + /** Per-app port as confirmed or overridden, keyed by app name. */ + ports: Record; +} + +export type WizardOutcome = + | { status: 'completed'; answers: WizardAnswers } + | { status: 'cancelled' }; + +/** The slice of `@clack/prompts` the wizard uses, injectable for tests. */ +interface ClackLike { + multiselect(options: { + message: string; + options: Array<{ value: string; label: string }>; + initialValue?: string[]; + maxItems?: number; + required?: boolean; + }): Promise; + select(options: { + message: string; + options: Array<{ value: string; label: string }>; + }): Promise; + confirm(options: { + message: string; + initialValue?: boolean; + }): Promise; + text(options: { + message: string; + validate?: (value: string) => string | undefined; + }): Promise; + cancel(message: string): void; + isCancel(value: unknown): boolean; +} + +async function loadClackDefault(): Promise { + return (await import('@clack/prompts')) as unknown as ClackLike; +} + +function portListAnswer(answer: string, declared: string[]): string[] { + const trimmed = answer.trim(); + if (trimmed === '') return declared; + return trimmed + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + +const validatePort = (value: string): string | undefined => { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return 'Enter an integer port between 1 and 65535.'; + } + return undefined; +}; + +async function runClackWizard( + clack: ClackLike, + config: FederationConfig, + planned: PlannedApp[] +): Promise { + const cancelled: WizardOutcome = { status: 'cancelled' }; + const declaredRemotes = Object.keys(config.remotes); + + const selected = await clack.multiselect({ + message: 'Which remotes to run?', + options: declaredRemotes.map((name) => ({ value: name, label: name })), + initialValue: declaredRemotes, + maxItems: 8, + required: false, + }); + if (clack.isCancel(selected)) { + clack.cancel('Session cancelled.'); + return cancelled; + } + const remotes = selected as string[]; + + const platformAnswer = await clack.select({ + message: 'Which app platform are you running?', + options: [ + { value: 'ios', label: 'iOS' }, + { value: 'android', label: 'Android' }, + { value: 'all', label: 'All / decide later' }, + ], + }); + if (clack.isCancel(platformAnswer)) { + clack.cancel('Session cancelled.'); + return cancelled; + } + const platform = + platformAnswer === 'ios' || platformAnswer === 'android' + ? platformAnswer + : undefined; + + const ports: Record = {}; + for (const app of planned) { + if (app.role === 'remote' && !remotes.includes(app.name)) continue; + const keep = await clack.confirm({ + message: `Use port ${app.port} for ${app.name}?`, + initialValue: true, + }); + if (clack.isCancel(keep)) { + clack.cancel('Session cancelled.'); + return cancelled; + } + if (keep === true) { + ports[app.name] = app.port as number; + continue; + } + const override = await clack.text({ + message: `Port for ${app.name}:`, + validate: validatePort, + }); + if (clack.isCancel(override)) { + clack.cancel('Session cancelled.'); + return cancelled; + } + ports[app.name] = Number(override); + } + + // Standalone is only ever offered for selected remotes that declare it — + // the file stays the single source of the capability (spec scenario). + let standaloneRemote: string | undefined; + for (const name of remotes) { + if (config.remotes[name]?.standalone !== true) continue; + const runStandalone = await clack.confirm({ + message: `Run ${name} in standalone mode?`, + initialValue: false, + }); + if (clack.isCancel(runStandalone)) { + clack.cancel('Session cancelled.'); + return cancelled; + } + if (runStandalone === true) { + standaloneRemote = name; + break; + } + } + + return { + status: 'completed', + answers: { + session: standaloneRemote ? { remotes, standaloneRemote } : { remotes }, + platform, + ports, + }, + }; +} + +/** + * Line reader that BUFFERS answers arriving while no question is pending — + * `readline/promises.question()` drops exactly those lines, which hangs the + * next question on any piped input (all answers arrive in one chunk). EOF + * makes every further question reject, which the wizard maps to cancel. + */ +function createLineReader( + stream: NodeJS.ReadableStream, + out: NodeJS.WritableStream +) { + const rl = nodeReadline.createInterface({ + input: stream as NodeJS.ReadStream, + output: out as NodeJS.WriteStream, + terminal: Boolean((stream as { isTTY?: boolean }).isTTY), + }); + const buffered: string[] = []; + let waiter: ((line: string | null) => void) | null = null; + let ended = false; + rl.on('line', (line) => { + if (waiter) { + const resolve = waiter; + waiter = null; + resolve(line); + } else { + buffered.push(line); + } + }); + rl.on('close', () => { + ended = true; + waiter?.(null); + waiter = null; + }); + const closedStream = new Error('stdin closed while the wizard was asking'); + return { + async question(prompt: string): Promise { + out.write(prompt); + if (buffered.length > 0) return buffered.shift() as string; + if (ended) throw closedStream; + const line = await new Promise((resolve) => { + waiter = resolve; + }); + if (line === null) throw closedStream; + return line; + }, + close() { + rl.close(); + }, + }; +} + +async function runReadlineWizard( + config: FederationConfig, + planned: PlannedApp[], + streams: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } +): Promise { + const rl = createLineReader( + streams.input ?? process.stdin, + streams.output ?? process.stdout + ); + const cancelled: WizardOutcome = { status: 'cancelled' }; + try { + const declaredRemotes = Object.keys(config.remotes); + const remotes = portListAnswer( + await rl.question( + `Remotes to run (comma-separated, empty = all: ${declaredRemotes.join(', ')}): ` + ), + declaredRemotes + ); + const platformAnswer = ( + await rl.question('Platform (ios/android, empty = all): ') + ) + .trim() + .toLowerCase(); + const platform = + platformAnswer === 'ios' || platformAnswer === 'android' + ? platformAnswer + : undefined; + + const ports: Record = {}; + for (const app of planned) { + if (app.role === 'remote' && !remotes.includes(app.name)) continue; + let answer = (await rl.question(`Port for ${app.name} [${app.port}]: `)) + .trim() + .toLowerCase(); + while (answer !== '' && validatePort(answer)) { + answer = ( + await rl.question( + `Port for ${app.name} [${app.port}] (integer 1-65535, empty = default): ` + ) + ) + .trim() + .toLowerCase(); + } + ports[app.name] = answer === '' ? (app.port as number) : Number(answer); + } + + let standaloneRemote: string | undefined; + for (const name of remotes) { + if (config.remotes[name]?.standalone !== true) continue; + const answer = ( + await rl.question(`Run ${name} in standalone mode? (y/N): `) + ) + .trim() + .toLowerCase(); + if (answer === 'y' || answer === 'yes') { + standaloneRemote = name; + break; + } + } + + return { + status: 'completed', + answers: { + session: standaloneRemote ? { remotes, standaloneRemote } : { remotes }, + platform, + ports, + }, + }; + } catch { + // readline/promises rejects when stdin closes mid-question: the user + // walked away — the same outcome as cancelling in the clack wizard. + return cancelled; + } finally { + rl.close(); + } +} + +/** + * Collect session inputs on an interactive terminal: remotes → platform → + * ports → standalone. An input source only — the answers feed the same + * `buildPlan` the flags drive, never a second execution path. Falls back to + * sequential readline prompts when clack cannot be loaded; the caller keeps + * the non-TTY case away from this function entirely. + */ +export async function runWizard(input: { + config: FederationConfig; + /** First-pass plan: its per-app ports are the wizard's defaults. */ + planned: PlannedApp[]; + loadClack?: () => Promise; + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +}): Promise { + let clack: ClackLike | null = null; + try { + clack = await (input.loadClack ?? loadClackDefault)(); + } catch { + clack = null; + } + return clack + ? runClackWizard(clack, input.config, input.planned) + : runReadlineWizard(input.config, input.planned, input); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 348abf584..22b9ed5e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -588,6 +588,9 @@ importers: '@callstack/repack-dev-server': specifier: workspace:* version: link:../dev-server + '@clack/prompts': + specifier: ^0.9.1 + version: 0.9.1 '@discoveryjs/json-ext': specifier: ^0.5.7 version: 0.5.7 From 9720cb867387f2d73f443168ff45f2a19f7f56fa Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 13:11:05 +0200 Subject: [PATCH 33/54] feat(repack): add live status block and keymap to federation-dev --- .../commands/__tests__/federationDev.test.ts | 31 ++-- .../repack/src/commands/federation-dev.ts | 85 ++++++--- .../__tests__/runnerConsole.test.ts | 166 ++++++++++++++++++ .../src/commands/federation/runnerConsole.ts | 161 +++++++++++++++-- 4 files changed, 399 insertions(+), 44 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 926a20ec8..7c241dfe0 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -40,14 +40,26 @@ const cliConfig = { let exitSpy: jest.SpyInstance; let logSpy: jest.SpyInstance; let errorSpy: jest.SpyInstance; +let stdoutSpy: jest.SpyInstance; let tmpDir: string; let previousCwd: string; +// The command's stdout owner writes escape sequences straight to +// process.stdout; the sink's output has to be captured alongside console. const output = () => - [...logSpy.mock.calls, ...errorSpy.mock.calls] + [...logSpy.mock.calls, ...errorSpy.mock.calls, ...stdoutSpy.mock.calls] .map((call) => call.map(String).join(' ')) .join('\n'); +/** JSON docs the sink wrote to stdout, oldest first. */ +const stdoutDocs = () => + stdoutSpy.mock.calls + .map((call: unknown[]) => String(call[0])) + .join('') + .split('\n') + .filter((line: string) => line.startsWith('{')) + .map((line: string) => JSON.parse(line)); + beforeEach(() => { previousCwd = process.cwd(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fed-dev-')); @@ -56,6 +68,9 @@ beforeEach(() => { .mockImplementation((() => undefined) as never); logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation((() => true) as never); // Deterministic probes: the developer's machine may really hold 8081/8082. jest.spyOn(portPlanner, 'isPortBusy').mockResolvedValue(false); process.chdir(TWIN); @@ -341,11 +356,7 @@ describe('federation-dev live session', () => { interactive: false, }); await waitFor(() => execaMock.mock.calls.length === 2); - const docsUpToRunning = () => - logSpy.mock.calls - .map((call) => String(call[0])) - .filter((line) => line.startsWith('{')) - .map((line) => JSON.parse(line)); + const docsUpToRunning = () => stdoutDocs(); await waitFor( () => { const docs = docsUpToRunning().filter((d) => d.event === 'status'); @@ -395,12 +406,12 @@ describe('federation-dev live session', () => { it('--dry-run --json is byte-identical across runs and spawns nothing', async () => { await federationDev([], cliConfig, { dryRun: true, json: true }); - const firstRun = logSpy.mock.calls.map((call) => String(call[0])); - logSpy.mockClear(); + const firstRun = stdoutDocs(); + stdoutSpy.mockClear(); await federationDev([], cliConfig, { dryRun: true, json: true }); - const secondRun = logSpy.mock.calls.map((call) => String(call[0])); + const secondRun = stdoutDocs(); expect(secondRun).toEqual(firstRun); - expect(JSON.parse(firstRun[0]!).event).toBe('plan'); + expect(firstRun[0]!.event).toBe('plan'); expect(execaMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index 719b262b0..f6447e5fe 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -19,6 +19,7 @@ import { renderStatusTable, statusToJson, } from './federation/statusTable.js'; +import type { SessionResult } from './federation/supervisor.js'; import { DevSupervisor } from './federation/supervisor.js'; import { runWizard } from './federation/wizard.js'; import type { CliConfig, FederationDevArguments } from './types.js'; @@ -66,12 +67,16 @@ function usageError(message: string): void { process.exit(2); } -function printPlan(plan: PlannedApp[], json: boolean): void { - if (json) { - console.log(planToJson(plan)); - return; - } - for (const row of renderPlanTable(plan)) console.log(row); +/** `d` on the keymap: poke the host dev server's debugger endpoint. */ +function postOpenDebugger(url: string): void { + const request = http.request( + `${url}/open-debugger`, + { method: 'POST', timeout: 2000 }, + (response) => response.resume() + ); + request.on('error', () => undefined); + request.on('timeout', () => request.destroy()); + request.end(); } /** @@ -276,9 +281,23 @@ export async function federationDev( } const plan = buildPlan({ ...planBase, ports }); - printPlan(plan, args.json === true); + + // From here on the sink is the one stdout owner (D5 row H): plan, logs, + // status block and JSON contracts all route through it, nothing else + // writes stdout while the session is alive. + const runnerConsole = new RunnerConsole({ + stdout: process.stdout, + stdin: process.stdin, + }); + // Terminal restore on every exit path. (`exit-hook` is ESM-only and a + // CJS build cannot require it; `process.on('exit')` fires for + // process.exit() too and the finally below covers the throw paths.) + const releaseTerminal = () => runnerConsole.release(); + process.on('exit', releaseTerminal); + runnerConsole.persist(args.json ? [planToJson(plan)] : renderPlanTable(plan)); if (args.dryRun) { + runnerConsole.release(); process.exit(0); return; } @@ -288,50 +307,70 @@ export async function federationDev( // remote ports are printed guidance only — the runner never executes adb // for them (threat row "adb execution"). const host = plan.find((app) => app.role === 'host')!; - const runnerConsole = new RunnerConsole({ stdout: process.stdout }); const platform = args.platform ?? 'ios'; await runAdbReverse({ port: host.port as number }); for (const app of plan) { if (app.role === 'remote') { - console.log( + runnerConsole.log( `Remote port: run "adb reverse tcp:${app.port} tcp:${app.port}" on ` + 'your device to reach it from the app.' ); } } - console.log( + runnerConsole.log( `Run your app with: react-native run-${platform} — it reaches the host ` + `dev server at ${host.url}` ); + // The keymap disclosure (D5 row F): exactly these keys do something. + runnerConsole.persist(['Keys: q quit | d open debugger | Ctrl-C quit']); const supervisor = new DevSupervisor(plan, runnerConsole, { probeStatus }); // One Ctrl-C asks for the supervisor's ordered shutdown; the second one // escalates inside the supervisor (SIGINT → grace → SIGTERM). const onSigint = () => supervisor.shutdown('interrupt'); process.on('SIGINT', onSigint); + runnerConsole.armKeymap({ + q: onSigint, + '\u0003': onSigint, + d: () => postOpenDebugger(host.url), + }); let lastDoc = ''; + let lastRowsKey = ''; const statusWatch = setInterval(() => { - if (!args.json) return; - const doc = statusToJson(plan, supervisor.getStatuses()); - if (doc !== lastDoc) { - lastDoc = doc; - console.log(doc); + const statuses = supervisor.getStatuses(); + if (args.json) { + const doc = statusToJson(plan, statuses); + if (doc !== lastDoc) { + lastDoc = doc; + runnerConsole.log(doc); + } + } else { + const rows = renderStatusTable(plan, statuses); + const key = rows.join('\n'); + if (key !== lastRowsKey) { + lastRowsKey = key; + runnerConsole.setStatus(rows); + } } }, 250); - const result = await supervisor.run(); - clearInterval(statusWatch); - process.off('SIGINT', onSigint); + let result: SessionResult; + try { + result = await supervisor.run(); + } finally { + clearInterval(statusWatch); + process.off('SIGINT', onSigint); + process.off('exit', releaseTerminal); + runnerConsole.release(); + } + // Terminal is back to plain: the final summary is static output. const statuses = supervisor.getStatuses(); if (args.json) { - console.log(statusToJson(plan, statuses)); + runnerConsole.persist([statusToJson(plan, statuses)]); } else { - const rows = renderStatusTable(plan, statuses); - for (const row of rows) console.log(row); - runnerConsole.persist(rows); + runnerConsole.persist(renderStatusTable(plan, statuses)); } - runnerConsole.release(); process.exit(result.exitCode); } diff --git a/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts b/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts index 5d8b9e550..3f5cae7c6 100644 --- a/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts +++ b/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts @@ -77,3 +77,169 @@ describe('RunnerConsole sink core (5a)', () => { expect(stream.output).not.toMatch(/\u001b\[/); }); }); + +/** Complete ANSI strip incl. 256-color/truecolor — the D5 row-B discipline. */ +const stripAnsi = (value: string) => + value.replace( + /[\u001B\u009B][[\]()#;?]*(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~])/g, + '' + ); + +const ERASE_ROW_UP = '\x1b[1A\x1b[2K'; + +class FakeStdin extends EventEmitter { + isTTY = true; + setRawMode = jest.fn(); + ref = jest.fn(); + unref = jest.fn(); +} + +describe('RunnerConsole live status block (5b, D5)', () => { + let stream: FakeStream; + let stdin: FakeStdin; + + beforeEach(() => { + jest.useFakeTimers(); + stream = new FakeStream(); + stdin = new FakeStdin(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const make = () => new RunnerConsole({ stdout: stream, stdin }); + + it('paints the owned block once and redraws only with cursorUp+eraseLine', () => { + const console0 = make(); + console0.setStatus(['host 8081 running']); + expect(stream.output).toBe('host 8081 running\n'); + + console0.setStatus(['host 8081 failed']); + // Coalesced window: still no repaint inside ~60 ms. + expect(stream.output).toBe('host 8081 running\n'); + jest.advanceTimersByTime(60); + expect(stream.output).toBe( + 'host 8081 running\n' + ERASE_ROW_UP + 'host 8081 failed\n' + ); + // Forbidden legacy mechanisms (D5 row A): no move-down, no clear-down. + expect(stream.output).not.toContain('\x1b[1B'); + expect(stream.output).not.toContain('\x1b[J'); + }); + + it('erases exactly H rows for an H-row block', () => { + const console0 = make(); + console0.setStatus(['row one', 'row two']); + console0.setStatus(['row one!', 'row two!']); + jest.advanceTimersByTime(60); + expect(stream.output).toBe( + 'row one\nrow two\n' + + ERASE_ROW_UP + + ERASE_ROW_UP + + 'row one!\nrow two!\n' + ); + }); + + it('never repaints unchanged content while log lines lift and re-drop the block', () => { + const console0 = make(); + console0.setStatus(['host running']); + const afterPaint = stream.chunks.length; + console0.setStatus(['host running']); + jest.advanceTimersByTime(1000); + expect(stream.chunks).toHaveLength(afterPaint); + + // A log line while the block is visible: the block is lifted (erased), + // the line appended, the block re-dropped below — the invariant is that + // the owned block is always the last thing on screen. + console0.log('[host] compiled main.js'); + expect(stream.output).toBe( + 'host running\n' + + ERASE_ROW_UP + + '[host] compiled main.js\n' + + 'host running\n' + ); + }); + + it('counts 256/truecolor escapes as zero width and clamps visible rows to columns-1', () => { + stream.columns = 40; + const console0 = make(); + // 39 visible chars wrapped in 256-color + truecolor escapes: fits, kept verbatim. + const fits = + '\x1b[38;5;208m' + + 'x'.repeat(20) + + '\x1b[0m' + + '\x1b[38;2;10;20;30m' + + 'y'.repeat(19) + + '\x1b[0m'; + console0.setStatus([fits]); + expect(stream.output).toBe(`${fits}\n`); + + stream.chunks = []; + const tooLong = 'z'.repeat(100); + console0.setStatus([tooLong]); + const painted = stream.output.replace(/\n$/, ''); + expect(stripAnsi(painted).length).toBeLessThanOrEqual(39); + }); + + it('resize repaints the owned block immediately', () => { + const console0 = make(); + console0.setStatus(['host running']); + const before = stream.chunks.length; + stream.columns = 120; + stream.emit('resize'); + expect(stream.chunks.length).toBeGreaterThan(before); + expect(stream.chunks[before]).toBe(ERASE_ROW_UP); + expect(stream.output.endsWith('host running\n')).toBe(true); + }); + + it('raw mode is on only while armed and release is idempotent', () => { + const console0 = make(); + expect(stdin.setRawMode).not.toHaveBeenCalled(); + console0.armKeymap({ q: () => undefined }); + expect(stdin.setRawMode).toHaveBeenCalledTimes(1); + expect(stdin.setRawMode).toHaveBeenCalledWith(true); + console0.release(); + console0.release(); + expect(stdin.setRawMode).toHaveBeenCalledTimes(2); + expect(stdin.setRawMode).toHaveBeenLastCalledWith(false); + // Re-arm then release again: one more restore, no extra calls. + const qSpy = jest.fn(); + console0.armKeymap({ q: qSpy }); + stdin.setRawMode.mockClear(); + console0.release(); + expect(stdin.setRawMode).toHaveBeenCalledTimes(1); + expect(stdin.setRawMode).toHaveBeenCalledWith(false); + }); + + it('keymap handles the mapped keys; every other key triggers nothing', () => { + const console0 = make(); + const q = jest.fn(); + const d = jest.fn(); + const ctrlC = jest.fn(); + const other = jest.fn(); + console0.armKeymap({ q, d, '\u0003': ctrlC, z: other }); + stdin.emit('data', Buffer.from('z')); + expect(other).toHaveBeenCalledTimes(1); + stdin.emit('data', Buffer.from('?')); + stdin.emit('data', Buffer.from('\u001b[A')); + expect(q).not.toHaveBeenCalled(); + expect(d).not.toHaveBeenCalled(); + expect(ctrlC).not.toHaveBeenCalled(); + stdin.emit('data', Buffer.from('q')); + expect(q).toHaveBeenCalledTimes(1); + stdin.emit('data', Buffer.from('d')); + expect(d).toHaveBeenCalledTimes(1); + stdin.emit('data', Buffer.from('\u0003')); + expect(ctrlC).toHaveBeenCalledTimes(1); + }); + + it('pending repaints are dropped on release and never written after', () => { + const console0 = make(); + console0.setStatus(['host running']); + console0.setStatus(['host failed']); + console0.release(); + stream.chunks = []; + jest.advanceTimersByTime(500); + expect(stream.output).toBe(''); + }); +}); diff --git a/packages/repack/src/commands/federation/runnerConsole.ts b/packages/repack/src/commands/federation/runnerConsole.ts index 1bc2aaa51..ca97ba741 100644 --- a/packages/repack/src/commands/federation/runnerConsole.ts +++ b/packages/repack/src/commands/federation/runnerConsole.ts @@ -8,6 +8,31 @@ export type ConsoleStream = Pick & { off?: (event: string, listener: () => void) => unknown; }; +/** The stdin shape the keymap needs (a `tty.ReadStream` in production). */ +export type StdinStream = { + isTTY?: boolean; + setRawMode?: (mode: boolean) => unknown; + on?: (event: string, listener: (chunk: Buffer) => void) => unknown; + off?: (event: string, listener: (chunk: Buffer) => void) => unknown; +}; + +/** + * Complete ANSI strip — includes 256-color (`38;5;…`) and truecolor + * (`38;2;…;…;…`) SGRs the legacy `terminal.ts` regex misses, which is + * exactly where its cursor-up off-by-N ghosts came from (D5 row B). + */ +const ANSI_PATTERN = + /[\u001B\u009B][[\]()#;?]*(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~])/g; + +/** Up one line + erase it — the ONLY redraw primitive of the owned block. */ +const ERASE_ROW_UP = '\u001b[1A\u001b[2K'; + +/** Status repaint coalescing window (D5 row E). */ +const COALESCE_MS = 60; + +const sameRows = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((row, index) => row === b[index]); + /** * The single stdout owner of a federation-dev session (D5). * @@ -19,19 +44,41 @@ export type ConsoleStream = Pick & { * real backpressure (`write` return value), never a lie. * - Non-TTY (CI, `--no-interactive` pipes) is plain mode: zero cursor * escape codes, no prompts, no keymap. - * - The live status block, resize repaint, coalescing and raw-mode - * keymap lifecycle (5b) live behind `setStatus`/`armKeymap`/`onResize`/ - * `release` — in this core they degrade to the same plain discipline. + * - The status block is a fixed H-row owned region redrawn exclusively + * with `ERASE_ROW_UP` repeats — never `moveCursor` down, never + * `clearScreenDown` (D5 row A). Rows are width-clamped by ANSI-stripped + * length to `columns - 1` so the redraw math is exact (D5 row B). + * Repaints are coalesced to ~60 ms and only on content change; log lines + * stream immediately with the block lifted and re-dropped below them, so + * the block is always the last thing on screen. + * - Raw mode is on only while the session keymap is armed; `release()` + * restores it idempotently, and the command adds `process.on('exit')` + + * `finally` coverage for every other exit path. */ export class RunnerConsole { private stream: ConsoleStream; + private stdin: StdinStream | undefined; private resizeListeners: Array<() => void> = []; private onResizeEvent = () => { + // A resize invalidates the width math: repaint the owned block now. + if (this.repaintTimer !== null) { + clearTimeout(this.repaintTimer); + this.repaintTimer = null; + this.pending = null; + } + if (this.visible !== null) this.paintNow(this.visible); for (const listener of this.resizeListeners) listener(); }; + /** Rows currently occupying the screen bottom, or null when no block. */ + private visible: string[] | null = null; + /** Newest rows waiting for the coalescing window, or null. */ + private pending: string[] | null = null; + private repaintTimer: ReturnType | null = null; + private keyListener: ((chunk: Buffer) => void) | null = null; - constructor(options: { stdout: ConsoleStream }) { + constructor(options: { stdout: ConsoleStream; stdin?: StdinStream }) { this.stream = options.stdout; + this.stdin = options.stdin; if (typeof this.stream.on === 'function') { this.stream.on('resize', this.onResizeEvent); } @@ -41,27 +88,106 @@ export class RunnerConsole { return this.stream.isTTY === true; } + private clampWidth(row: string): string { + const max = (this.stream.columns ?? 80) - 1; + const stripped = row.replace(ANSI_PATTERN, ''); + // Only over-wide rows lose their styling — clamping a styled row + // mid-escape would leak sequences into text. + return stripped.length > max ? stripped.slice(0, max) : row; + } + + private eraseBlock(): void { + if (this.visible !== null) { + this.stream.write(ERASE_ROW_UP.repeat(this.visible.length)); + } + } + + private dropBlock(): void { + if (this.visible !== null) { + this.stream.write(`${this.visible.join('\n')}\n`); + } + } + + private paintNow(rows: string[]): void { + this.eraseBlock(); + this.stream.write(`${rows.join('\n')}\n`); + this.visible = rows; + } + /** Append one live line (child logs ride this prefixed). Returns backpressure. */ log(line: string): boolean { + if (this.isTTY && this.visible !== null) { + this.eraseBlock(); + const written = this.stream.write(`${line}\n`); + // The owned block must always be the last thing on screen. + this.dropBlock(); + return written; + } return this.stream.write(`${line}\n`); } /** Print a static block (plan table, help, guidance): written once, never redrawn. */ persist(lines: string[]): void { - for (const line of lines) this.stream.write(`${line}\n`); + for (const line of lines) this.log(line); } /** - * Update the status rows. 5a core: static print per update — the live - * owned-block implementation replaces this seam in the 5b console polish. + * Update the owned status rows. First paint is immediate; later changes + * coalesce to one repaint per ~60 ms and only when the content actually + * changed (D5 row E). Non-TTY stays plain and immediate. */ setStatus(rows: string[]): void { - this.persist(rows); + if (!this.isTTY) { + for (const row of rows) this.stream.write(`${row}\n`); + return; + } + const clamped = rows.map((row) => this.clampWidth(row)); + if (this.visible === null) { + this.paintNow(clamped); + return; + } + if (sameRows(clamped, this.pending ?? this.visible)) return; + this.pending = clamped; + if (this.repaintTimer === null) { + this.repaintTimer = setTimeout(() => { + this.repaintTimer = null; + const next = this.pending; + this.pending = null; + if (next !== null && !sameRows(next, this.visible ?? [])) { + this.paintNow(next); + } + }, COALESCE_MS); + } } /** Arm the session keymap (raw mode with guaranteed restore). Non-TTY: documented no-op. */ - armKeymap(_map: Record void>): void { - // 5b implements the TTY leg; non-TTY stays a no-op by design. + armKeymap(map: Record void>): void { + const stdin = this.stdin; + if ( + !this.isTTY || + !stdin || + stdin.isTTY !== true || + typeof stdin.on !== 'function' + ) { + return; + } + this.disarmKeymap(); + stdin.setRawMode?.(true); + const listener = (chunk: Buffer) => { + // Exactly the mapped keys do something; everything else is a + // deliberate no-op the persisted help line discloses (D5 row F). + map[chunk.toString()]?.(); + }; + this.keyListener = listener; + stdin.on('data', listener); + } + + private disarmKeymap(): void { + if (this.keyListener !== null && this.stdin) { + this.stdin.off?.('data', this.keyListener); + this.keyListener = null; + this.stdin.setRawMode?.(false); + } } /** Register a repaint hook fired on stdout 'resize'. */ @@ -69,11 +195,24 @@ export class RunnerConsole { this.resizeListeners.push(fn); } - /** Restore terminal state. Idempotent; also wired to exit-hook by the command. */ + /** + * Restore terminal state. Idempotent; the command also registers it on + * `process.on('exit')` and a `finally`, covering every exit path without + * the ESM-only `exit-hook` import a CJS build cannot require. + */ release(): void { if (typeof this.stream.off === 'function') { this.stream.off('resize', this.onResizeEvent); } this.resizeListeners = []; + if (this.repaintTimer !== null) { + clearTimeout(this.repaintTimer); + this.repaintTimer = null; + } + this.pending = null; + // The block on screen becomes static history: no erase accounting and + // no re-drop of output persisted after the release. + this.visible = null; + this.disarmKeymap(); } } From c0322fcb9879911ee330489ac048180406c3457a Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 13:24:43 +0200 Subject: [PATCH 34/54] chore(tester-federation): adopt config fields and federation-dev scripts --- apps/tester-federation/package.json | 4 ++-- apps/tester-federation/repack-federation.json | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/tester-federation/package.json b/apps/tester-federation/package.json index 6753efc94..b17ddd230 100644 --- a/apps/tester-federation/package.json +++ b/apps/tester-federation/package.json @@ -3,12 +3,12 @@ "version": "0.0.1", "private": true, "scripts": { + "start": "react-native federation-dev", + "start:dry": "react-native federation-dev --dry-run", "android": "react-native run-android --appId com.tester.federation --no-packager", "ios": "react-native run-ios --no-packager", "pods": "(cd ios && bundle install && (bundle exec pod install || bundle exec pod update))", "pods:update": "(cd ios && bundle install && bundle exec pod update)", - "start:hostapp": "react-native webpack-start --config config.host-app.mts", - "start:miniapp": "react-native webpack-start --config config.mini-app.mts --port 8082", "bundle": "pnpm run \"/^bundle:(hostapp|miniapp)$/\"", "bundle:hostapp": "pnpm run \"/^bundle:hostapp:(ios|android)$/\"", "bundle:miniapp": "pnpm run \"/^bundle:miniapp:(ios|android)$/\"", diff --git a/apps/tester-federation/repack-federation.json b/apps/tester-federation/repack-federation.json index 52dd84c1e..60a1d7044 100644 --- a/apps/tester-federation/repack-federation.json +++ b/apps/tester-federation/repack-federation.json @@ -1,14 +1,17 @@ { "host": { "root": ".", - "manifest": "build/host-app/ios" + "manifest": "build/host-app/ios", + "config": "config.host-app.mts", + "port": 8081 }, "remotes": { "MiniApp": { "root": ".", "manifest": "build/mini-app/ios", "standalone": true, - "port": 8082 + "port": 8082, + "config": "config.mini-app.mts" } } } From d5dfec40ab5296cd4ece67720e0582e1f75fb51e Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 13:30:31 +0200 Subject: [PATCH 35/54] docs(website): document federation-dev command --- website/src/latest/api/cli/_meta.json | 5 + website/src/latest/api/cli/federation-dev.mdx | 133 ++++++++++++++++++ .../latest/api/cli/repack-federation-json.mdx | 20 ++- 3 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 website/src/latest/api/cli/federation-dev.mdx diff --git a/website/src/latest/api/cli/_meta.json b/website/src/latest/api/cli/_meta.json index 44e06c8ad..7cf4d60f7 100644 --- a/website/src/latest/api/cli/_meta.json +++ b/website/src/latest/api/cli/_meta.json @@ -14,6 +14,11 @@ "name": "federation-manifest", "label": "Federation manifest" }, + { + "type": "file", + "name": "federation-dev", + "label": "Federation dev" + }, { "type": "file", "name": "federation-doctor", diff --git a/website/src/latest/api/cli/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx new file mode 100644 index 000000000..c78ba1bd7 --- /dev/null +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -0,0 +1,133 @@ +# federation-dev + +`federation-dev` runs your whole federation workspace — the host plus a session of remotes — as one supervised dev session from the [`repack-federation.json`](/api/cli/repack-federation-json) workspace map. Each app gets its own `react-native start` child process; the runner prefixes every child log line with the app name, watches each dev server's `/status` endpoint for readiness, and shuts everything down in order on Ctrl-C. + +It is deliberately CI-friendly: with no TTY (or `--no-interactive`) there are no prompts at all — the session is the host plus every declared remote — and `--json` / `--dry-run` give machine-readable, deterministic output. + +## Usage + +import { PackageManagerTabs } from '@theme'; + + + +Run it from anywhere inside the workspace — the nearest `repack-federation.json` up the directory tree is the one used, and all app roots, configs and ports are resolved against that file's directory. + +## What it runs + +For each app in the session the runner spawns, with the app's root as working directory: + +```bash +react-native start --bundler [--config ] --port --no-interactive [--platform

] [--standalone] --no-reverse-port +``` + +- `--config` is passed only when the app declares a `config` field in `repack-federation.json` (or you pick one in the wizard); without it the child does its own config discovery, exactly like plain `react-native start` today. +- The bundler per app is auto-detected the same way `start` does it — from the resolved config file name — and a per-app `--bundler` mismatch never happens because each child resolves only its own. +- `--no-reverse-port` on every child: adb reversal is the runner's job, not the children's (see [adb](#adb-and-run-guidance)). + +## The interactive session (wizard) + +On a TTY, with neither `--apps` nor `--no-interactive` given, `federation-dev` opens a wizard before anything spawns: + +1. **Remotes** — multi-select which declared remotes to run (all selected by default; the host always runs). +2. **Platform** — `ios`, `android` or *all*. +3. **Ports** — confirms each app's planned port; answering *no* asks for a replacement. +4. **Standalone** — offered **only** for selected remotes that declare `"standalone": true`; at most one remote runs standalone. + +The wizard is an input source only: its answers feed the exact same plan resolution the flags drive, so a wizard run and the equivalent flag run produce identical plans. Cancelling exits 0 without spawning anything. + +## Options + +### `--apps ` + +- Type: `string` (comma-separated remote names) + +The remotes to include in the session. Unknown names exit 2 and list the known ones. Absent on a TTY means the wizard decides; absent without a TTY means every declared remote. + +### `--platform ` + +- Type: `string` + +Compiles scope: it is passed to each child dev server (`start --platform …`) and selects the run-guidance line (`react-native run-ios` / `run-android`). Any other value (e.g. `web`) exits 2 — the bundler compile scope only supports the two native platforms. + +### `--port ` + +- Type: `number` + +Host dev-server port for this run; overrides the file's `host.port`. Must be an integer between 1 and 65535. + +### `--auto-ports` + +Reassign busy ports to OS-assigned free ones instead of failing with a conflict. Applies to every app whose declared port is busy. + +### `--standalone ` + +Launches that remote with `--standalone`. Refused (exit 2) unless the remote declares `"standalone": true` in `repack-federation.json` — the file is the single source of standalone capability. + +### `--no-interactive` + +Skip the wizard even on a TTY. The session is then the deterministic default: host plus every declared remote, planned ports, no standalone. + +### `--json` + +Print the plan as one `{"event":"plan", …}` document before spawning, and `{"event":"status", …}` documents whenever app health transitions. Each app entry carries `name`, `role`, `root`, `config`, `bundler`, `port` (`null` for auto-allocated in dry runs), `url`, `command` and `status` (`planned`/`starting`/`running`/`failed`/`exited`). Child logs stay plain `[name]`-prefixed lines on stdout; parse stdout line by line and keep only lines starting with `{`. + +### `--dry-run` + +Print the exact plan a live run would use — configs, ports, URLs and full commands — then exit without spawning anything. Port-conflict rules still run read-only: a busy declared port fails a dry run the same way it would fail the real one. Unmanaged ports display as `auto` (the table), `null` (the JSON). + +## Port allocation + +Precedence per app: `--port` (host only) > the app's `port` field > `8081` for the host, none for remotes. + +- Before spawning, every planned port is probed. A busy declared port is a **conflict**: exit 1, naming app and port, nothing spawned — never a hang. `--auto-ports` turns the conflict into a fresh free port. +- Apps without a declared port always get an OS-assigned free port. +- **TOCTOU note**: a process can steal a port between the probe and the child binding it. If a child dies right after spawn while its port answers `GET /status`, the runner reports it as an address-in-use style failure naming the port, not a mystery crash. + +## Session keys + +While the session runs, exactly these keys do something (the runner prints this line at startup): + +| Key | Action | +| --- | --- | +| `q` | Quit — same as Ctrl-C | +| `d` | Ask the host dev server to open the debugger (`POST /open-debugger`) | +| Ctrl-C | Ordered shutdown: SIGINT to children → grace window → SIGTERM to survivors; a second Ctrl-C skips the grace window | + +Every other key is a deliberate no-op. The terminal (raw mode, cursor state) is always restored, including on crashes. Suspending with Ctrl-Z is not supported in runner mode — suspend from another terminal if you must. + +## adb and run guidance + +- The **host** port is reversed automatically through the react-native CLI's audited adb helper, once, for all connected devices. +- **Remote** ports are never adb-touched automatically — the runner prints the exact command for you: `adb reverse tcp: tcp:`. +- A final guidance line names the run command for the chosen platform and the host URL the app dials. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success — or a cancelled wizard, or a completed dry run | +| 1 | Port conflict (no `--auto-ports`), or a child failed/crashed during the session | +| 2 | Usage or config error — unknown app, bad `--platform`/`--port`, missing or invalid `repack-federation.json`, refused `--standalone`. Nothing is spawned | + +## CI examples + +```bash +# fail fast on a bad workspace file without starting anything +react-native federation-dev --dry-run --no-interactive + +# deterministic machine-readable plan for tooling +react-native federation-dev --dry-run --json --no-interactive + +# headless session: MiniApp only, custom host port, never prompts +react-native federation-dev --apps MiniApp --platform ios --port 8090 --no-interactive + +# a busy 8081 on shared runners should not fail the job +react-native federation-dev --no-interactive --auto-ports +``` + +Without a TTY the runner prints plain `[name]`-prefixed child logs and static plan/status tables — no cursor movement, no spinners — so CI logs stay greppable. diff --git a/website/src/latest/api/cli/repack-federation-json.mdx b/website/src/latest/api/cli/repack-federation-json.mdx index 197539a55..a68147c89 100644 --- a/website/src/latest/api/cli/repack-federation-json.mdx +++ b/website/src/latest/api/cli/repack-federation-json.mdx @@ -1,6 +1,6 @@ # 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`). +`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, which bundler config each app runs, and which dev-server port each app uses. Its presence is what unlocks **zero-flag operation** of [`federation-doctor`](/api/cli/federation-doctor), [`federation-init`](/api/cli/federation-init) and the dev runner [`federation-dev`](/api/cli/federation-dev). ## Schema @@ -8,14 +8,17 @@ { "host": { "manifest": "./shell/build", - "root": "." + "root": ".", + "config": "config.host-app.mts", + "port": 8081 }, "remotes": { "store": { "manifest": "http://localhost:8082", "root": "./apps/store", "standalone": true, - "port": 8082 + "port": 8082, + "config": "config.store.mts" } } } @@ -25,13 +28,20 @@ | --- | --- | --- | --- | | `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 | +| `host.config` | string | no | Bundler config for the host dev server, resolved against this file's directory; consumed by `federation-dev` | +| `host.port` | number | no | Host dev-server port (default `8081`); consumed by `federation-dev` | | `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 | +| `remotes..port` | number | no | Dev-server port; `federation-dev` fails with a conflict when a declared port is busy (escape hatch: `--auto-ports`) | +| `remotes..config` | string | no | Bundler config for this remote's dev server, resolved against this file's directory; consumed by `federation-dev` | -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. +Relative `manifest`, `root` and `config` 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. + +:::caution Forward incompatibility +Strictness cuts both ways: `config` and `host.port` are new keys, and older Re.Pack tooling **rejects** a file that contains them instead of ignoring them. Bump the Re.Pack version across a workspace before adding these fields, or every tool pinned to the older version fails with exit code 2 on the same file. +::: ## Discovery From 9cd53040dac8ecd5b704288b5f286f103e2e2f2b Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 13:35:30 +0200 Subject: [PATCH 36/54] docs: record federation-dev as-built design deviations --- agent_context/federation-tools/design.md | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/agent_context/federation-tools/design.md b/agent_context/federation-tools/design.md index cf729704d..ea97e10a2 100644 --- a/agent_context/federation-tools/design.md +++ b/agent_context/federation-tools/design.md @@ -305,6 +305,39 @@ Deltas from the design above, all deliberate: `config..mts` pair in one directory, still need `--config`-style flows from the PR 5 runner). +## PR 5 implementation notes (as built) + +- **Status surface is a terminal block, not a web dashboard (D10 deviation).** + The design explored a browser status page; what shipped is a runner-owned + fixed-height status block (`runnerConsole.ts`) redrawn only with + cursor-up + erase-line sequences, coalesced to ~60 ms and only on content + change. Rationale: zero new serving surface, the append-only prefixed log + pane stays greppable, and CI (non-TTY) degrades to plain tables for free. + `--json` carries the same state machine for machines. +- **Spawn shape resolved**: `react-native start --bundler + [--config …] --port

--no-interactive [--platform p] [--standalone] + --no-reverse-port`. The `-start` commands named in early drafts + do not exist post-#1424; `start --bundler` is the supported path. The + spec's "Plan resolution" wording was corrected to match at apply time. + Children spawn as `process.execPath …` — PATH is + never consulted (threat row "Subprocess spawn"). +- **exit-hook NOT used despite the design naming it**: `exit-hook@4` is + ESM-only and repack ships CJS — `require` would crash. Terminal restore + rides `process.on('exit')` (fires for `process.exit()` too) + `finally` + + idempotent `release()`. Same guarantee, no ESM interop risk. +- **Wizard fallback reads with a queueing line reader**: + `readline/promises.question()` drops lines arriving while no question is + pending (batch/piped input resolves only the first question) and never + rejects on EOF — both hang a sequential prompt chain. + `wizard.ts createLineReader` buffers `line` events and maps EOF to + cancel. Verified empirically before adopting. +- `--dry-run` runs conflict probes read-only but allocates nothing; + unmanaged ports display `auto` / JSON `null` (D4). Live ports are + always numbers by spawn time. +- Drive-by shipped in `packages/dev-server`: `normalizeOptions` built + `url` from the raw `options.port`, leaking `undefined` into every URL + (and proxy targets) when `port` was omitted. Own commit, reverts alone. + ## Referenced surface (verified 2026-09) - `packages/repack/src/plugins/ModuleFederationPluginV1.ts` / `V2.ts` — no From 0e50e3d7ce03d215b640ff0188167676c533c10f Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 15:27:50 +0200 Subject: [PATCH 37/54] feat(repack): show repack banner in federation dev --- .../commands/__tests__/federationDev.test.ts | 29 +++++++++ packages/repack/src/commands/common/logo.ts | 14 +++-- .../repack/src/commands/federation-dev.ts | 14 +++++ .../federation/__tests__/devHeader.test.ts | 59 +++++++++++++++++++ .../src/commands/federation/devHeader.ts | 24 ++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 packages/repack/src/commands/federation/__tests__/devHeader.test.ts create mode 100644 packages/repack/src/commands/federation/devHeader.ts diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 7c241dfe0..37be03e41 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; import execa from 'execa'; +import packageJson from '../../../package.json'; import { runAdbReverse } from '../common/runAdbReverse.js'; import * as portPlanner from '../federation/portPlanner.js'; import * as wizard from '../federation/wizard.js'; @@ -404,6 +405,34 @@ describe('federation-dev live session', () => { expect(execaMock).not.toHaveBeenCalled(); }); + it('human dry-run opens with the repack banner', async () => { + await federationDev([], cliConfig, { dryRun: true }); + const text = output(); + expect(text).toContain('Re.Pack'); + expect(text).toContain(`v${packageJson.version}`); + expect(text).toContain( + 'one supervised session for your module-federation workspace' + ); + // The banner is context, not a replacement: the plan still prints. + expect(text).toContain('PLAN'); + }); + + it('--dry-run --json keeps the banner out of stdout', async () => { + await federationDev([], cliConfig, { dryRun: true, json: true }); + const raw = stdoutSpy.mock.calls + .map((call: unknown[]) => String(call[0])) + .join(''); + expect(raw).not.toContain('Re.Pack'); + expect(raw).not.toContain('one supervised session'); + // stdout stays a pure JSON contract. + expect( + raw + .trim() + .split('\n') + .every((line) => line.startsWith('{')) + ).toBe(true); + }); + it('--dry-run --json is byte-identical across runs and spawns nothing', async () => { await federationDev([], cliConfig, { dryRun: true, json: true }); const firstRun = stdoutDocs(); diff --git a/packages/repack/src/commands/common/logo.ts b/packages/repack/src/commands/common/logo.ts index 973f93d2e..bef40a75b 100644 --- a/packages/repack/src/commands/common/logo.ts +++ b/packages/repack/src/commands/common/logo.ts @@ -1,16 +1,20 @@ import * as colorette from 'colorette'; import gradient from 'gradient-string'; -const logoStr = ` +/** The raw ASCII art, shared by every consumer that renders the banner. */ +export const logoStr = ` ▄▀▀▀ ▀▀▀▀ █▀▀█ █▀▀█ ▄▀▀▀ █ █ █ ▀▀▀▀ █▀▀▀ █▀▀█ █ █▀▀▄ ▀ ▀▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀`; +/** The Re.Pack purple→teal gradient, single source for all banner art. */ +export const repackGradient = gradient([ + { color: '#9b6dff', pos: 0.45 }, + { color: '#3ce4cb', pos: 0.9 }, +]); + export default function logo(version: string, bundler: string) { - const gradientLogo = gradient([ - { color: '#9b6dff', pos: 0.45 }, - { color: '#3ce4cb', pos: 0.9 }, - ]).multiline(logoStr); + const gradientLogo = repackGradient.multiline(logoStr); return `${gradientLogo}\n${version}, powered by ${colorette.bold(bundler)}\n\n`; } diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index f6447e5fe..e04f6c1d3 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -1,5 +1,7 @@ import http from 'node:http'; import path from 'node:path'; +import * as colorette from 'colorette'; +import packageJson from '../../package.json'; import { CLIError } from '../helpers/index.js'; import { runAdbReverse } from './common/runAdbReverse.js'; import { @@ -8,6 +10,7 @@ import { FEDERATION_CONFIG_FILENAME, loadFederationConfig, } from './federation/configFile.js'; +import { devHeader } from './federation/devHeader.js'; import type { PlanInput, PlannedApp } from './federation/devPlan.js'; import { buildPlan } from './federation/devPlan.js'; import { isPortBusy, planPorts } from './federation/portPlanner.js'; @@ -179,6 +182,17 @@ export async function federationDev( return; } + // Session context before anything else speaks (start.ts logo precedent): + // a one-shot write, while stdout is still unowned — the wizard, plan and + // status block all come after it. `--json` keeps stdout a pure contract. + if (!args.json) { + process.stdout.write( + `${devHeader(packageJson.version, { + colors: process.stdout.isTTY === true && colorette.isColorSupported, + })}\n\n` + ); + } + // Wizard gate: --apps, --no-interactive or a non-TTY stdout suppress the // interactive wizard; the default session is then host + every remote. const planBase: PlanInput = { diff --git a/packages/repack/src/commands/federation/__tests__/devHeader.test.ts b/packages/repack/src/commands/federation/__tests__/devHeader.test.ts new file mode 100644 index 000000000..7c102731a --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/devHeader.test.ts @@ -0,0 +1,59 @@ +import { logoStr } from '../../common/logo.js'; +import { devHeader } from '../devHeader.js'; + +/** Same complete ANSI strip the runner console uses. */ +const stripAnsi = (text: string) => + text.replace( + /[\u001B\u009B][[\]()#;?]*(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~])/g, + '' + ); + +const DESCRIPTION = + 'one supervised session for your module-federation workspace'; + +/** + * chalk@4 — gradient-string's color backend — fixes its color level at + * require time. Load the whole chain fresh with FORCE_COLOR so the color + * branch really colorizes, exactly like on a developer TTY. + */ +const loadHeaderColored = () => { + const previous = process.env.FORCE_COLOR; + process.env.FORCE_COLOR = '3'; + try { + let header: typeof import('../devHeader.js') | undefined; + jest.isolateModules(() => { + header = require('../devHeader.js'); + }); + if (!header) throw new Error('devHeader module did not load'); + return header.devHeader; + } finally { + if (previous === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = previous; + } +}; + +describe('devHeader', () => { + it('names the version and the one-line description in both modes', () => { + for (const colors of [true, false]) { + const header = devHeader('9.8.7', { colors }); + expect(header).toContain('9.8.7'); + expect(header).toContain(DESCRIPTION); + } + }); + + it('color mode reuses the shared gradient ASCII art', () => { + const header = loadHeaderColored()('9.8.7', { colors: true }); + // The exact art logo.ts renders, glyph-graded one char at a time under + // the gradient — shared source, not a copy (strip ANSI to see it). + expect(stripAnsi(header)).toContain(logoStr.trim()); + // Gradient output is ANSI: at least one ESC sequence. + expect(header).toContain('\u001b'); + }); + + it('plain mode is free of ANSI escape bytes', () => { + const header = devHeader('9.8.7', { colors: false }); + expect(header).not.toContain('\u001b'); + expect(header).toContain('Re.Pack'); + expect(header).toContain('v9.8.7'); + }); +}); diff --git a/packages/repack/src/commands/federation/devHeader.ts b/packages/repack/src/commands/federation/devHeader.ts new file mode 100644 index 000000000..b8955a896 --- /dev/null +++ b/packages/repack/src/commands/federation/devHeader.ts @@ -0,0 +1,24 @@ +import { logoStr, repackGradient } from '../common/logo.js'; + +const DESCRIPTION = + 'federation dev — one supervised session for your module-federation workspace'; + +/** + * The one-shot session banner `react-native federation-dev` prints before + * any wizard, plan or status block takes over stdout (start.ts logo + * precedent). Pure: the caller decides `colors` (TTY and color support) and + * owns the trailing newline. + * + * Color mode reuses the shared gradient ASCII art from `logo.ts` (single + * source — never a second copy of the art). Plain mode is what CI, pipes + * and `NO_COLOR` get: text only, zero ANSI bytes. + */ +export function devHeader( + version: string, + options: { colors: boolean } +): string { + if (options.colors) { + return `${repackGradient.multiline(logoStr)}\n${version} · ${DESCRIPTION}`; + } + return `Re.Pack v${version} — federation dev\n${version} · ${DESCRIPTION}`; +} From 644ca703c4f877aa61f3bb705552ddf31853320b Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 15:49:21 +0200 Subject: [PATCH 38/54] fix(repack): show key legends in federation-dev wizard The wizard's clack prompts rendered with no hint text, so nothing explained how to move, choose or exit. Every step now embeds its key legend in the visible message block (clack 0.9.1 has no prompt-level hint that renders eagerly), the dev banner closes with a global one-line legend (dim in color mode, plain bytes elsewhere), and the readline fallback carries equivalent inline legends. Legends are cosmetic: answers, exit codes and cancel semantics are untouched. --- .../commands/__tests__/federationDev.test.ts | 5 ++ .../federation/__tests__/devHeader.test.ts | 12 ++++ .../federation/__tests__/wizard.test.ts | 56 +++++++++++++++++++ .../src/commands/federation/devHeader.ts | 17 +++++- .../repack/src/commands/federation/wizard.ts | 54 ++++++++++++++---- 5 files changed, 132 insertions(+), 12 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 37be03e41..52db24c34 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -413,6 +413,10 @@ describe('federation-dev live session', () => { expect(text).toContain( 'one supervised session for your module-federation workspace' ); + // Global key legend right under the banner, plain in this no-color run. + expect(text).toContain( + '↑↓ move · space toggle · enter confirm · Ctrl-C cancel' + ); // The banner is context, not a replacement: the plan still prints. expect(text).toContain('PLAN'); }); @@ -424,6 +428,7 @@ describe('federation-dev live session', () => { .join(''); expect(raw).not.toContain('Re.Pack'); expect(raw).not.toContain('one supervised session'); + expect(raw).not.toContain('Ctrl-C cancel'); // stdout stays a pure JSON contract. expect( raw diff --git a/packages/repack/src/commands/federation/__tests__/devHeader.test.ts b/packages/repack/src/commands/federation/__tests__/devHeader.test.ts index 7c102731a..3dc2ef321 100644 --- a/packages/repack/src/commands/federation/__tests__/devHeader.test.ts +++ b/packages/repack/src/commands/federation/__tests__/devHeader.test.ts @@ -50,6 +50,18 @@ describe('devHeader', () => { expect(header).toContain('\u001b'); }); + it('prints the global key legend under the banner in both modes', () => { + const legend = '↑↓ move · space toggle · enter confirm · Ctrl-C cancel'; + // Plain mode: the legend is visible with zero ANSI bytes around it. + const plain = devHeader('9.8.7', { colors: false }); + expect(plain).toContain(legend); + expect(plain).not.toContain('\u001b'); + // Color mode: the legend renders dim — ANSI-wrapped but readable. + const colored = loadHeaderColored()('9.8.7', { colors: true }); + expect(stripAnsi(colored)).toContain(legend); + expect(colored).toContain(`\u001b[2m${legend}`); + }); + it('plain mode is free of ANSI escape bytes', () => { const header = devHeader('9.8.7', { colors: false }); expect(header).not.toContain('\u001b'); diff --git a/packages/repack/src/commands/federation/__tests__/wizard.test.ts b/packages/repack/src/commands/federation/__tests__/wizard.test.ts index cac6d6671..20a97b50f 100644 --- a/packages/repack/src/commands/federation/__tests__/wizard.test.ts +++ b/packages/repack/src/commands/federation/__tests__/wizard.test.ts @@ -151,6 +151,48 @@ describe('runWizard (clack path)', () => { ).toBeUndefined(); }); + // Every step must be self-explanatory BEFORE any key press: the clack + // option carries the key legend and the visible message embeds it (clack + // 0.9.1 has no prompt-level hint that renders eagerly). + it('every clack step carries its key legend in hint and message', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('ios'); + clack.confirm + .mockResolvedValueOnce(false) // host port: override -> text step + .mockResolvedValueOnce(true) // MiniApp port + .mockResolvedValueOnce(false); // standalone: no + clack.text.mockResolvedValue('8090'); + await runWith(clack); + + const optionsOf = (call: unknown[]): { message: string; hint?: string } => + call[0] as { message: string; hint?: string }; + + const multi = optionsOf(clack.multiselect.mock.calls[0]); + const multiLegend = '↑↓ move · space toggle · a toggle all · enter confirm'; + expect(multi.hint).toBe(multiLegend); + expect(multi.message).toContain(multiLegend); + + const select = optionsOf(clack.select.mock.calls[0]); + const selectLegend = '↑↓ move · enter confirm'; + expect(select.hint).toBe(selectLegend); + expect(select.message).toContain(selectLegend); + + // Port flow: the confirm and the override text both carry the port legend. + const portConfirm = optionsOf(clack.confirm.mock.calls[0]); + const portLegend = 'type a port · enter to accept'; + expect(portConfirm.hint).toBe(portLegend); + expect(portConfirm.message).toContain(portLegend); + const portText = optionsOf(clack.text.mock.calls[0]); + expect(portText.hint).toBe(portLegend); + expect(portText.message).toContain(portLegend); + + // Standalone confirm gets the left/right legend. + const standaloneConfirm = optionsOf(clack.confirm.mock.calls[2]); + const standaloneLegend = '←/→ choose · enter confirm'; + expect(standaloneConfirm.hint).toBe(standaloneLegend); + expect(standaloneConfirm.message).toContain(standaloneLegend); + }); + it('cancel returns the cancelled outcome and says so via clack cancel', async () => { clack.multiselect.mockResolvedValue(CANCEL); const outcome = await runWith(clack); @@ -216,6 +258,20 @@ describe('runWizard (readline fallback)', () => { }); }); + it('fallback questions carry the legend inline (readline has no chrome)', async () => { + const { captured } = await runFallback([ + 'MiniApp', // remotes + 'ios', // platform + '8090', // host port + '', // MiniApp port: default + 'n', // standalone + ]); + expect(captured).toContain('type names · enter to accept'); + expect(captured).toContain('type ios/android · enter to accept'); + expect(captured).toContain('type a port · enter to accept'); + expect(captured).toContain('type y/n · enter to accept'); + }); + it('EOF on stdin cancels like the clack path', async () => { const input = new PassThrough(); setTimeout(() => input.end(), 0); diff --git a/packages/repack/src/commands/federation/devHeader.ts b/packages/repack/src/commands/federation/devHeader.ts index b8955a896..9c7eadb37 100644 --- a/packages/repack/src/commands/federation/devHeader.ts +++ b/packages/repack/src/commands/federation/devHeader.ts @@ -1,8 +1,17 @@ +import * as colors from 'colorette'; import { logoStr, repackGradient } from '../common/logo.js'; const DESCRIPTION = 'federation dev — one supervised session for your module-federation workspace'; +/** + * The one-line key legend printed under the banner: the wizard's clack + * chrome carries per-step hints, but the global controls (cancel) and the + * overall gesture vocabulary need one always-visible home. + */ +export const DEV_SESSION_LEGEND = + '↑↓ move · space toggle · enter confirm · Ctrl-C cancel'; + /** * The one-shot session banner `react-native federation-dev` prints before * any wizard, plan or status block takes over stdout (start.ts logo @@ -17,8 +26,12 @@ export function devHeader( version: string, options: { colors: boolean } ): string { + // The legend closes the banner: dim in color mode, plain bytes elsewhere. + const legend = options.colors + ? colors.dim(DEV_SESSION_LEGEND) + : DEV_SESSION_LEGEND; if (options.colors) { - return `${repackGradient.multiline(logoStr)}\n${version} · ${DESCRIPTION}`; + return `${repackGradient.multiline(logoStr)}\n${version} · ${DESCRIPTION}\n${legend}`; } - return `Re.Pack v${version} — federation dev\n${version} · ${DESCRIPTION}`; + return `Re.Pack v${version} — federation dev\n${version} · ${DESCRIPTION}\n${legend}`; } diff --git a/packages/repack/src/commands/federation/wizard.ts b/packages/repack/src/commands/federation/wizard.ts index 65bc2b91a..4e4941f8a 100644 --- a/packages/repack/src/commands/federation/wizard.ts +++ b/packages/repack/src/commands/federation/wizard.ts @@ -19,6 +19,7 @@ export type WizardOutcome = interface ClackLike { multiselect(options: { message: string; + hint?: string; options: Array<{ value: string; label: string }>; initialValue?: string[]; maxItems?: number; @@ -26,20 +27,43 @@ interface ClackLike { }): Promise; select(options: { message: string; + hint?: string; options: Array<{ value: string; label: string }>; }): Promise; confirm(options: { message: string; + hint?: string; initialValue?: boolean; }): Promise; text(options: { message: string; + hint?: string; validate?: (value: string) => string | undefined; }): Promise; cancel(message: string): void; isCancel(value: unknown): boolean; } +/** + * Key legends per step kind. Clack 0.9.1 has no prompt-level `hint` option + * (its `hint` is per-option and renders inline next to choices), so the + * legend is embedded as a second line of the `message` — the one block clack + * renders verbatim from the very first frame, before any key press. The + * `hint` field travels alongside for tests and any future clack that grows + * a native prompt-level hint. + */ +const HINTS = { + multiselect: '↑↓ move · space toggle · a toggle all · enter confirm', + select: '↑↓ move · enter confirm', + confirm: '←/→ choose · enter confirm', + port: 'type a port · enter to accept', +} as const; + +/** Message plus its legend line, visible on the first render. */ +function legended(message: string, hint: string) { + return { message: `${message}\n${hint}`, hint }; +} + async function loadClackDefault(): Promise { return (await import('@clack/prompts')) as unknown as ClackLike; } @@ -70,7 +94,7 @@ async function runClackWizard( const declaredRemotes = Object.keys(config.remotes); const selected = await clack.multiselect({ - message: 'Which remotes to run?', + ...legended('Which remotes to run?', HINTS.multiselect), options: declaredRemotes.map((name) => ({ value: name, label: name })), initialValue: declaredRemotes, maxItems: 8, @@ -83,7 +107,7 @@ async function runClackWizard( const remotes = selected as string[]; const platformAnswer = await clack.select({ - message: 'Which app platform are you running?', + ...legended('Which app platform are you running?', HINTS.select), options: [ { value: 'ios', label: 'iOS' }, { value: 'android', label: 'Android' }, @@ -103,7 +127,7 @@ async function runClackWizard( for (const app of planned) { if (app.role === 'remote' && !remotes.includes(app.name)) continue; const keep = await clack.confirm({ - message: `Use port ${app.port} for ${app.name}?`, + ...legended(`Use port ${app.port} for ${app.name}?`, HINTS.port), initialValue: true, }); if (clack.isCancel(keep)) { @@ -115,7 +139,7 @@ async function runClackWizard( continue; } const override = await clack.text({ - message: `Port for ${app.name}:`, + ...legended(`Port for ${app.name}:`, HINTS.port), validate: validatePort, }); if (clack.isCancel(override)) { @@ -131,7 +155,7 @@ async function runClackWizard( for (const name of remotes) { if (config.remotes[name]?.standalone !== true) continue; const runStandalone = await clack.confirm({ - message: `Run ${name} in standalone mode?`, + ...legended(`Run ${name} in standalone mode?`, HINTS.confirm), initialValue: false, }); if (clack.isCancel(runStandalone)) { @@ -218,12 +242,15 @@ async function runReadlineWizard( const declaredRemotes = Object.keys(config.remotes); const remotes = portListAnswer( await rl.question( - `Remotes to run (comma-separated, empty = all: ${declaredRemotes.join(', ')}): ` + `Remotes to run (comma-separated, empty = all: ${declaredRemotes.join(', ')})` + + ` — type names · enter to accept: ` ), declaredRemotes ); const platformAnswer = ( - await rl.question('Platform (ios/android, empty = all): ') + await rl.question( + 'Platform (ios/android, empty = all) — type ios/android · enter to accept: ' + ) ) .trim() .toLowerCase(); @@ -235,13 +262,18 @@ async function runReadlineWizard( const ports: Record = {}; for (const app of planned) { if (app.role === 'remote' && !remotes.includes(app.name)) continue; - let answer = (await rl.question(`Port for ${app.name} [${app.port}]: `)) + let answer = ( + await rl.question( + `Port for ${app.name} [${app.port}] — type a port · enter to accept: ` + ) + ) .trim() .toLowerCase(); while (answer !== '' && validatePort(answer)) { answer = ( await rl.question( - `Port for ${app.name} [${app.port}] (integer 1-65535, empty = default): ` + `Port for ${app.name} [${app.port}] (integer 1-65535, empty = default)` + + ` — type a port · enter to accept: ` ) ) .trim() @@ -254,7 +286,9 @@ async function runReadlineWizard( for (const name of remotes) { if (config.remotes[name]?.standalone !== true) continue; const answer = ( - await rl.question(`Run ${name} in standalone mode? (y/N): `) + await rl.question( + `Run ${name} in standalone mode? (y/N) — type y/n · enter to accept: ` + ) ) .trim() .toLowerCase(); From a11f6f9ef627235fea97c3e066aa22505c2485a7 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 15:56:08 +0200 Subject: [PATCH 39/54] Revert "fix(repack): show key legends in federation-dev wizard" This reverts commit 644ca703c4f877aa61f3bb705552ddf31853320b. --- .../commands/__tests__/federationDev.test.ts | 5 -- .../federation/__tests__/devHeader.test.ts | 12 ---- .../federation/__tests__/wizard.test.ts | 56 ------------------- .../src/commands/federation/devHeader.ts | 17 +----- .../repack/src/commands/federation/wizard.ts | 54 ++++-------------- 5 files changed, 12 insertions(+), 132 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 52db24c34..37be03e41 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -413,10 +413,6 @@ describe('federation-dev live session', () => { expect(text).toContain( 'one supervised session for your module-federation workspace' ); - // Global key legend right under the banner, plain in this no-color run. - expect(text).toContain( - '↑↓ move · space toggle · enter confirm · Ctrl-C cancel' - ); // The banner is context, not a replacement: the plan still prints. expect(text).toContain('PLAN'); }); @@ -428,7 +424,6 @@ describe('federation-dev live session', () => { .join(''); expect(raw).not.toContain('Re.Pack'); expect(raw).not.toContain('one supervised session'); - expect(raw).not.toContain('Ctrl-C cancel'); // stdout stays a pure JSON contract. expect( raw diff --git a/packages/repack/src/commands/federation/__tests__/devHeader.test.ts b/packages/repack/src/commands/federation/__tests__/devHeader.test.ts index 3dc2ef321..7c102731a 100644 --- a/packages/repack/src/commands/federation/__tests__/devHeader.test.ts +++ b/packages/repack/src/commands/federation/__tests__/devHeader.test.ts @@ -50,18 +50,6 @@ describe('devHeader', () => { expect(header).toContain('\u001b'); }); - it('prints the global key legend under the banner in both modes', () => { - const legend = '↑↓ move · space toggle · enter confirm · Ctrl-C cancel'; - // Plain mode: the legend is visible with zero ANSI bytes around it. - const plain = devHeader('9.8.7', { colors: false }); - expect(plain).toContain(legend); - expect(plain).not.toContain('\u001b'); - // Color mode: the legend renders dim — ANSI-wrapped but readable. - const colored = loadHeaderColored()('9.8.7', { colors: true }); - expect(stripAnsi(colored)).toContain(legend); - expect(colored).toContain(`\u001b[2m${legend}`); - }); - it('plain mode is free of ANSI escape bytes', () => { const header = devHeader('9.8.7', { colors: false }); expect(header).not.toContain('\u001b'); diff --git a/packages/repack/src/commands/federation/__tests__/wizard.test.ts b/packages/repack/src/commands/federation/__tests__/wizard.test.ts index 20a97b50f..cac6d6671 100644 --- a/packages/repack/src/commands/federation/__tests__/wizard.test.ts +++ b/packages/repack/src/commands/federation/__tests__/wizard.test.ts @@ -151,48 +151,6 @@ describe('runWizard (clack path)', () => { ).toBeUndefined(); }); - // Every step must be self-explanatory BEFORE any key press: the clack - // option carries the key legend and the visible message embeds it (clack - // 0.9.1 has no prompt-level hint that renders eagerly). - it('every clack step carries its key legend in hint and message', async () => { - clack.multiselect.mockResolvedValue(['MiniApp']); - clack.select.mockResolvedValue('ios'); - clack.confirm - .mockResolvedValueOnce(false) // host port: override -> text step - .mockResolvedValueOnce(true) // MiniApp port - .mockResolvedValueOnce(false); // standalone: no - clack.text.mockResolvedValue('8090'); - await runWith(clack); - - const optionsOf = (call: unknown[]): { message: string; hint?: string } => - call[0] as { message: string; hint?: string }; - - const multi = optionsOf(clack.multiselect.mock.calls[0]); - const multiLegend = '↑↓ move · space toggle · a toggle all · enter confirm'; - expect(multi.hint).toBe(multiLegend); - expect(multi.message).toContain(multiLegend); - - const select = optionsOf(clack.select.mock.calls[0]); - const selectLegend = '↑↓ move · enter confirm'; - expect(select.hint).toBe(selectLegend); - expect(select.message).toContain(selectLegend); - - // Port flow: the confirm and the override text both carry the port legend. - const portConfirm = optionsOf(clack.confirm.mock.calls[0]); - const portLegend = 'type a port · enter to accept'; - expect(portConfirm.hint).toBe(portLegend); - expect(portConfirm.message).toContain(portLegend); - const portText = optionsOf(clack.text.mock.calls[0]); - expect(portText.hint).toBe(portLegend); - expect(portText.message).toContain(portLegend); - - // Standalone confirm gets the left/right legend. - const standaloneConfirm = optionsOf(clack.confirm.mock.calls[2]); - const standaloneLegend = '←/→ choose · enter confirm'; - expect(standaloneConfirm.hint).toBe(standaloneLegend); - expect(standaloneConfirm.message).toContain(standaloneLegend); - }); - it('cancel returns the cancelled outcome and says so via clack cancel', async () => { clack.multiselect.mockResolvedValue(CANCEL); const outcome = await runWith(clack); @@ -258,20 +216,6 @@ describe('runWizard (readline fallback)', () => { }); }); - it('fallback questions carry the legend inline (readline has no chrome)', async () => { - const { captured } = await runFallback([ - 'MiniApp', // remotes - 'ios', // platform - '8090', // host port - '', // MiniApp port: default - 'n', // standalone - ]); - expect(captured).toContain('type names · enter to accept'); - expect(captured).toContain('type ios/android · enter to accept'); - expect(captured).toContain('type a port · enter to accept'); - expect(captured).toContain('type y/n · enter to accept'); - }); - it('EOF on stdin cancels like the clack path', async () => { const input = new PassThrough(); setTimeout(() => input.end(), 0); diff --git a/packages/repack/src/commands/federation/devHeader.ts b/packages/repack/src/commands/federation/devHeader.ts index 9c7eadb37..b8955a896 100644 --- a/packages/repack/src/commands/federation/devHeader.ts +++ b/packages/repack/src/commands/federation/devHeader.ts @@ -1,17 +1,8 @@ -import * as colors from 'colorette'; import { logoStr, repackGradient } from '../common/logo.js'; const DESCRIPTION = 'federation dev — one supervised session for your module-federation workspace'; -/** - * The one-line key legend printed under the banner: the wizard's clack - * chrome carries per-step hints, but the global controls (cancel) and the - * overall gesture vocabulary need one always-visible home. - */ -export const DEV_SESSION_LEGEND = - '↑↓ move · space toggle · enter confirm · Ctrl-C cancel'; - /** * The one-shot session banner `react-native federation-dev` prints before * any wizard, plan or status block takes over stdout (start.ts logo @@ -26,12 +17,8 @@ export function devHeader( version: string, options: { colors: boolean } ): string { - // The legend closes the banner: dim in color mode, plain bytes elsewhere. - const legend = options.colors - ? colors.dim(DEV_SESSION_LEGEND) - : DEV_SESSION_LEGEND; if (options.colors) { - return `${repackGradient.multiline(logoStr)}\n${version} · ${DESCRIPTION}\n${legend}`; + return `${repackGradient.multiline(logoStr)}\n${version} · ${DESCRIPTION}`; } - return `Re.Pack v${version} — federation dev\n${version} · ${DESCRIPTION}\n${legend}`; + return `Re.Pack v${version} — federation dev\n${version} · ${DESCRIPTION}`; } diff --git a/packages/repack/src/commands/federation/wizard.ts b/packages/repack/src/commands/federation/wizard.ts index 4e4941f8a..65bc2b91a 100644 --- a/packages/repack/src/commands/federation/wizard.ts +++ b/packages/repack/src/commands/federation/wizard.ts @@ -19,7 +19,6 @@ export type WizardOutcome = interface ClackLike { multiselect(options: { message: string; - hint?: string; options: Array<{ value: string; label: string }>; initialValue?: string[]; maxItems?: number; @@ -27,43 +26,20 @@ interface ClackLike { }): Promise; select(options: { message: string; - hint?: string; options: Array<{ value: string; label: string }>; }): Promise; confirm(options: { message: string; - hint?: string; initialValue?: boolean; }): Promise; text(options: { message: string; - hint?: string; validate?: (value: string) => string | undefined; }): Promise; cancel(message: string): void; isCancel(value: unknown): boolean; } -/** - * Key legends per step kind. Clack 0.9.1 has no prompt-level `hint` option - * (its `hint` is per-option and renders inline next to choices), so the - * legend is embedded as a second line of the `message` — the one block clack - * renders verbatim from the very first frame, before any key press. The - * `hint` field travels alongside for tests and any future clack that grows - * a native prompt-level hint. - */ -const HINTS = { - multiselect: '↑↓ move · space toggle · a toggle all · enter confirm', - select: '↑↓ move · enter confirm', - confirm: '←/→ choose · enter confirm', - port: 'type a port · enter to accept', -} as const; - -/** Message plus its legend line, visible on the first render. */ -function legended(message: string, hint: string) { - return { message: `${message}\n${hint}`, hint }; -} - async function loadClackDefault(): Promise { return (await import('@clack/prompts')) as unknown as ClackLike; } @@ -94,7 +70,7 @@ async function runClackWizard( const declaredRemotes = Object.keys(config.remotes); const selected = await clack.multiselect({ - ...legended('Which remotes to run?', HINTS.multiselect), + message: 'Which remotes to run?', options: declaredRemotes.map((name) => ({ value: name, label: name })), initialValue: declaredRemotes, maxItems: 8, @@ -107,7 +83,7 @@ async function runClackWizard( const remotes = selected as string[]; const platformAnswer = await clack.select({ - ...legended('Which app platform are you running?', HINTS.select), + message: 'Which app platform are you running?', options: [ { value: 'ios', label: 'iOS' }, { value: 'android', label: 'Android' }, @@ -127,7 +103,7 @@ async function runClackWizard( for (const app of planned) { if (app.role === 'remote' && !remotes.includes(app.name)) continue; const keep = await clack.confirm({ - ...legended(`Use port ${app.port} for ${app.name}?`, HINTS.port), + message: `Use port ${app.port} for ${app.name}?`, initialValue: true, }); if (clack.isCancel(keep)) { @@ -139,7 +115,7 @@ async function runClackWizard( continue; } const override = await clack.text({ - ...legended(`Port for ${app.name}:`, HINTS.port), + message: `Port for ${app.name}:`, validate: validatePort, }); if (clack.isCancel(override)) { @@ -155,7 +131,7 @@ async function runClackWizard( for (const name of remotes) { if (config.remotes[name]?.standalone !== true) continue; const runStandalone = await clack.confirm({ - ...legended(`Run ${name} in standalone mode?`, HINTS.confirm), + message: `Run ${name} in standalone mode?`, initialValue: false, }); if (clack.isCancel(runStandalone)) { @@ -242,15 +218,12 @@ async function runReadlineWizard( const declaredRemotes = Object.keys(config.remotes); const remotes = portListAnswer( await rl.question( - `Remotes to run (comma-separated, empty = all: ${declaredRemotes.join(', ')})` + - ` — type names · enter to accept: ` + `Remotes to run (comma-separated, empty = all: ${declaredRemotes.join(', ')}): ` ), declaredRemotes ); const platformAnswer = ( - await rl.question( - 'Platform (ios/android, empty = all) — type ios/android · enter to accept: ' - ) + await rl.question('Platform (ios/android, empty = all): ') ) .trim() .toLowerCase(); @@ -262,18 +235,13 @@ async function runReadlineWizard( const ports: Record = {}; for (const app of planned) { if (app.role === 'remote' && !remotes.includes(app.name)) continue; - let answer = ( - await rl.question( - `Port for ${app.name} [${app.port}] — type a port · enter to accept: ` - ) - ) + let answer = (await rl.question(`Port for ${app.name} [${app.port}]: `)) .trim() .toLowerCase(); while (answer !== '' && validatePort(answer)) { answer = ( await rl.question( - `Port for ${app.name} [${app.port}] (integer 1-65535, empty = default)` + - ` — type a port · enter to accept: ` + `Port for ${app.name} [${app.port}] (integer 1-65535, empty = default): ` ) ) .trim() @@ -286,9 +254,7 @@ async function runReadlineWizard( for (const name of remotes) { if (config.remotes[name]?.standalone !== true) continue; const answer = ( - await rl.question( - `Run ${name} in standalone mode? (y/N) — type y/n · enter to accept: ` - ) + await rl.question(`Run ${name} in standalone mode? (y/N): `) ) .trim() .toLowerCase(); From 6b66079c2b65b249073c15c504741638f8dbf2b2 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 15:56:35 +0200 Subject: [PATCH 40/54] docs: record wizard chrome constraints and TUI guidance --- agent_context/federation-tools/design.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/agent_context/federation-tools/design.md b/agent_context/federation-tools/design.md index ea97e10a2..32725b4eb 100644 --- a/agent_context/federation-tools/design.md +++ b/agent_context/federation-tools/design.md @@ -337,6 +337,28 @@ Deltas from the design above, all deliberate: - Drive-by shipped in `packages/dev-server`: `normalizeOptions` built `url` from the raw `options.port`, leaking `undefined` into every URL (and proxy targets) when `port` was omitted. Own commit, reverts alone. +- **Wizard chrome: clack ceiling + TUI guidance (for future terminal UIs).** + `@clack/prompts@0.9.1` was verified to have NO prompt-level hint option + (`hint` exists only per-option; `confirm`/`text` have none) and renders + `message` strictly ABOVE the options list. A first attempt to add key + legends by embedding them in the message therefore landed mid-flow — + confusing — and was reverted (`a11f6f9e`); the wizard ships as plain + clack. Rules learned, to reuse when a richer TUI is worth its cost: + (1) keep chrome text out of `message`; legends belong under the options + or under the banner, never between title and choices; (2) clack's + dim-gray palette clashes with the runner's gradient banner — a custom + prompt kit (frame `║`, `▸` cursor, bottom legend, repack purple/teal + accents) built on the `runnerConsole` ownership primitives (bounded + cursor redraw, resize, raw-mode lifecycle) is the upgrade path AND would + drop `@clack/prompts` from repack's runtime deps (only `packages/init` + would keep it), removing the maintainer-sign-off item; (3) the + long-session ergonomics contract still applies to any prompt: static + rows after commit, one owned redraw region, plain fallback off-TTY; + (4) legends must describe the exact keymap rendered (D5 row F). +- Banner (`devHeader.ts`) shares one art/palette source with `logo.ts`; + human mode prints the ASCII gradient + version line, CI/NO_COLOR prints + one plain line, `--json` prints nothing (JSON stdout purity is pinned + by a command test). ## Referenced surface (verified 2026-09) From 63cc7951a4158209f037d05a38abcccdca0e59f1 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 16:27:20 +0200 Subject: [PATCH 41/54] test(repack): spell control bytes as escapes in portPlanner test --- .../federation/__tests__/portPlanner.test.ts | Bin 5041 -> 5043 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/repack/src/commands/federation/__tests__/portPlanner.test.ts b/packages/repack/src/commands/federation/__tests__/portPlanner.test.ts index 59c76bc012540b0635a9fd1953ecb95fdd8da8a8..9e5154487be89e909986cce2f6ae31f9e8b8ba9e 100644 GIT binary patch delta 35 rcmdm}zFB?4d>+0SgY?9rq{Q@8g_tyT&B=xQ`IGs0csHx_cCi5f;LHnF delta 33 pcmdn2zEOR{d>$T#^u(g1#Pn1J9(B#h0epIs&vN^1*5vJC0|3B@3a|hG From b7bab56cdcec7404c321195f875187f012003a5e Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 16:30:25 +0200 Subject: [PATCH 42/54] fix(repack): follow effective plan platform in federation-dev guidance --- .../commands/__tests__/federationDev.test.ts | 51 +++++++++++++++++++ .../repack/src/commands/federation-dev.ts | 12 ++++- website/src/latest/api/cli/federation-dev.mdx | 4 +- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 37be03e41..3952d3f4f 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -333,6 +333,57 @@ describe('federation-dev live session', () => { expect(exitSpy).toHaveBeenLastCalledWith(0); }, 20000); + it('run guidance follows --platform android', async () => { + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + platform: 'android', + interactive: false, + }); + await waitFor(() => execaMock.mock.calls.length === 2); + expect(output()).toContain('run-android'); + expect(output()).not.toContain('run-ios'); + for (const child of children) child.emit('exit', 0, null); + await command; + }, 20000); + + it('run guidance follows the wizard platform, not the absent flag', async () => { + // Wizard answer overrides: no --platform flag, wizard picks android — + // the printed line must say run-android (the plan, not args, is truth). + const originalIsTTY = process.stdout.isTTY; + process.stdout.isTTY = true; + try { + jest.spyOn(wizard, 'runWizard').mockResolvedValue({ + status: 'completed', + answers: { + session: { remotes: ['MiniApp'] }, + platform: 'android', + ports: {}, + }, + } as never); + // No --apps and no --no-interactive: the wizard gate is open on a TTY. + const command = federationDev([], cliConfig, {}); + await waitFor(() => execaMock.mock.calls.length === 2); + expect(output()).toContain('run-android'); + expect(output()).not.toContain('run-ios'); + for (const child of children) child.emit('exit', 0, null); + await command; + } finally { + process.stdout.isTTY = originalIsTTY; + } + }, 20000); + + it('run guidance names both platforms when none is selected', async () => { + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + interactive: false, + }); + await waitFor(() => execaMock.mock.calls.length === 2); + expect(output()).toContain('run-ios'); + expect(output()).toContain('run-android'); + for (const child of children) child.emit('exit', 0, null); + await command; + }, 20000); + it('--json live emits a parseable plan doc and status docs ending exited', async () => { // OS-assigned ports inside a workspace under the fixtures tree (so the // repo's react-native stays resolvable): well-known 8081/8082 may be diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index e04f6c1d3..55585b192 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -321,7 +321,12 @@ export async function federationDev( // remote ports are printed guidance only — the runner never executes adb // for them (threat row "adb execution"). const host = plan.find((app) => app.role === 'host')!; - const platform = args.platform ?? 'ios'; + // The effective platform lives in the final plan (flags and wizard + // answers both land there as `--platform

` on every child) — reading + // args directly would print the flag's value over a wizard selection. + const platformFlagIndex = host.spawn.args.indexOf('--platform'); + const platform = + platformFlagIndex >= 0 ? host.spawn.args[platformFlagIndex + 1] : undefined; await runAdbReverse({ port: host.port as number }); for (const app of plan) { if (app.role === 'remote') { @@ -331,8 +336,11 @@ export async function federationDev( ); } } + const runTargets = platform + ? `run-${platform}` + : 'run-ios or run-android (pick your device)'; runnerConsole.log( - `Run your app with: react-native run-${platform} — it reaches the host ` + + `Run your app with: react-native ${runTargets} — it reaches the host ` + `dev server at ${host.url}` ); // The keymap disclosure (D5 row F): exactly these keys do something. diff --git a/website/src/latest/api/cli/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx index c78ba1bd7..23c67101a 100644 --- a/website/src/latest/api/cli/federation-dev.mdx +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -52,7 +52,7 @@ The remotes to include in the session. Unknown names exit 2 and list the known o - Type: `string` -Compiles scope: it is passed to each child dev server (`start --platform …`) and selects the run-guidance line (`react-native run-ios` / `run-android`). Any other value (e.g. `web`) exits 2 — the bundler compile scope only supports the two native platforms. +Compiles scope: it is passed to each child dev server (`start --platform …`) and selects the run-guidance line (`react-native run-ios` / `run-android`). Any other value (e.g. `web`) exits 2 — the bundler compile scope only supports the two native platforms. When no platform is selected (no flag, wizard leaves it unset), the guidance names both `run-ios` and `run-android`. The guidance always follows the effective platform in the final plan, so a wizard selection counts even without the flag. ### `--port ` @@ -104,7 +104,7 @@ Every other key is a deliberate no-op. The terminal (raw mode, cursor state) is - The **host** port is reversed automatically through the react-native CLI's audited adb helper, once, for all connected devices. - **Remote** ports are never adb-touched automatically — the runner prints the exact command for you: `adb reverse tcp: tcp:`. -- A final guidance line names the run command for the chosen platform and the host URL the app dials. +- A final guidance line names the run command for the effective platform from the final plan (both `run-ios` and `run-android` when none was selected) and the host URL the app dials. ## Exit codes From 6cb1dd3a43bd6742ac54c515302ba15634f7a614 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 16:34:37 +0200 Subject: [PATCH 43/54] fix(repack): resolve each app react-native CLI from its own root --- .../commands/__tests__/federationDev.test.ts | 71 +++++++++++++++++++ .../repack/src/commands/federation-dev.ts | 35 ++++++--- .../config-split-roots/repack-federation.json | 14 ++++ .../federation/__tests__/devPlan.test.ts | 2 +- .../repack/src/commands/federation/devPlan.ts | 12 +++- .../repack/src/commands/federation/rnBin.ts | 27 ++++--- website/src/latest/api/cli/federation-dev.mdx | 1 + 7 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 3952d3f4f..559b54e74 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -6,8 +6,10 @@ import path from 'node:path'; import { PassThrough } from 'node:stream'; import execa from 'execa'; import packageJson from '../../../package.json'; +import { CLIError } from '../../helpers/index.js'; import { runAdbReverse } from '../common/runAdbReverse.js'; import * as portPlanner from '../federation/portPlanner.js'; +import * as rnBin from '../federation/rnBin.js'; import * as wizard from '../federation/wizard.js'; import { federationDev } from '../federation-dev.js'; @@ -168,6 +170,75 @@ describe('federation-dev usage errors (exit 2, spawn nothing)', () => { }); }); +describe('federation-dev per-app react-native CLI resolution', () => { + it('resolves each app CLI from its own root', async () => { + // Threat-adjacent semantics: the CLI an app runs with is the one + // installed in THAT app's root — the host's install never stands in + // for a remote rooted elsewhere. + process.chdir(path.join(FIXTURES, 'config-split-roots')); + await federationDev([], cliConfig, { + dryRun: true, + json: true, + interactive: false, + }); + const plan = stdoutDocs()[0]; + const host = plan.apps.find((app: { role: string }) => app.role === 'host'); + const mini = plan.apps.find( + (app: { name: string }) => app.name === 'MiniApp' + ); + expect(host.command).toContain( + path.join('rnbin', 'app', 'node_modules', 'react-native', 'cli.js') + ); + expect(mini.command).toContain(path.join('rnbin', 'pnpmapp')); + expect(mini.command).not.toContain( + path.join('rnbin', 'app', 'node_modules', 'react-native', 'cli.js') + ); + const hostCli = host.command.split(' ')[1]; + const miniCli = mini.command.split(' ')[1]; + expect(miniCli).not.toBe(hostCli); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('exits 2 naming the app whose own root lacks react-native', async () => { + // No silent fall-back to the host's CLI: an app rooted where no + // react-native resolves is a usage error naming that app. jest's own + // resolver never truly misses inside the repo tree, so the per-root + // failure is driven through rnBin's documented error contract (the + // real MODULE_NOT_FOUND mapping is pinned in rnBin.test). + // realpath: the command anchors paths on process.cwd(), which realpaths + // /var to /private/var on macOS — compare the same absolute form. + const miniRoot = path.join(fs.realpathSync(tmpDir), 'mini'); + jest + .spyOn(rnBin, 'resolveReactNativeBin') + .mockImplementation((root: string) => { + if (root === miniRoot) { + throw new CLIError( + `Cannot resolve the "react-native" package from ${root}` + ); + } + return '/resolved/react-native/cli.js'; + }); + fs.mkdirSync(miniRoot); + fs.writeFileSync( + path.join(tmpDir, 'repack-federation.json'), + JSON.stringify({ + host: { manifest: './build/host', root: '.' }, + remotes: { MiniApp: { manifest: './build/mini', root: 'mini' } }, + }) + ); + process.chdir(tmpDir); + await federationDev([], cliConfig, { + apps: 'MiniApp', + dryRun: true, + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain('MiniApp'); + expect(output()).toContain(miniRoot); + expect(execaMock).not.toHaveBeenCalled(); + }); +}); + describe('federation-dev non-TTY defaults', () => { it('defaults the plan to host plus every declared remote without prompting', async () => { // jest's stdout is not a TTY: the wizard never runs; the default diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index 55585b192..c2e74be89 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -173,14 +173,33 @@ export async function federationDev( } const configDir = path.dirname(filePath); - const hostRoot = path.resolve(configDir, config.host.root ?? '.'); - let rnCliPath: string; - try { - rnCliPath = resolveReactNativeBin(hostRoot, { extraPaths: [configDir] }); - } catch (error) { - usageError(error instanceof Error ? error.message : String(error)); - return; + // Each app runs with the react-native CLI resolved from its OWN root — + // memoized per distinct root (single-dir twins resolve once) and an + // unresolvable root fails as a usage error naming that app. No silent + // fall-back to another app's install. + const namesByRoot = new Map(); + const noteRoot = (name: string, root: string) => { + namesByRoot.set(root, [...(namesByRoot.get(root) ?? []), name]); + }; + noteRoot('host', path.resolve(configDir, config.host.root ?? '.')); + for (const [name, remote] of Object.entries(config.remotes)) { + noteRoot(name, path.resolve(configDir, remote.root ?? '.')); } + const cliByRoot = new Map(); + const rnCliForRoot = (root: string): string => { + const cached = cliByRoot.get(root); + if (cached !== undefined) return cached; + try { + const cli = resolveReactNativeBin(root); + cliByRoot.set(root, cli); + return cli; + } catch (error) { + const names = namesByRoot.get(root)?.join(', ') ?? root; + throw new CLIError( + `${names}: ${error instanceof Error ? error.message : String(error)}` + ); + } + }; // Session context before anything else speaks (start.ts logo precedent): // a one-shot write, while stdout is still unowned — the wizard, plan and @@ -207,7 +226,7 @@ export async function federationDev( platform: args.platform === 'android' ? 'android' : args.platform, }, ports: {}, - rnCliPath, + rnCliForRoot, }; let effective: PlannedApp[]; diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json new file mode 100644 index 000000000..a75c006dd --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json @@ -0,0 +1,14 @@ +{ + "host": { + "manifest": "./build/host/ios", + "root": "../rnbin/app", + "port": 8091 + }, + "remotes": { + "MiniApp": { + "manifest": "../../rnbin/pnpmapp/build/mini/ios", + "root": "../rnbin/pnpmapp", + "port": 8092 + } + } +} diff --git a/packages/repack/src/commands/federation/__tests__/devPlan.test.ts b/packages/repack/src/commands/federation/__tests__/devPlan.test.ts index 588193ba3..5afdbfa86 100644 --- a/packages/repack/src/commands/federation/__tests__/devPlan.test.ts +++ b/packages/repack/src/commands/federation/__tests__/devPlan.test.ts @@ -18,7 +18,7 @@ const baseInput = () => ({ session: { remotes: ['MiniApp'] }, overrides: {}, ports: {}, - rnCliPath: RN_CLI, + rnCliForRoot: () => RN_CLI, }); describe('buildPlan session set and ordering', () => { diff --git a/packages/repack/src/commands/federation/devPlan.ts b/packages/repack/src/commands/federation/devPlan.ts index f32a2297d..1f29b2dae 100644 --- a/packages/repack/src/commands/federation/devPlan.ts +++ b/packages/repack/src/commands/federation/devPlan.ts @@ -34,7 +34,12 @@ export interface PlanInput { }; /** From the port planner; `'auto'` only in dry-run for unmanaged apps. */ ports: Record; - rnCliPath: string; + /** + * The `react-native` CLI for a given app root — every app runs with the + * CLI installed in its OWN root (callers memoize per distinct root and + * map resolution failures to the owning app; no cross-app fallback). + */ + rnCliForRoot: (appRoot: string) => string; } /** Quote argv parts the way a shell display would — pure rendering. */ @@ -69,8 +74,9 @@ function buildApp( const bundler = detectBundler(root, appConfig); // Executed as `process.execPath start …`: the argv head is the - // resolved local react-native CLI, so PATH is never consulted. - const args = [input.rnCliPath, 'start', '--bundler', bundler]; + // app's own resolved local react-native CLI, so PATH is never consulted + // and no other app's install is ever borrowed. + const args = [input.rnCliForRoot(root), 'start', '--bundler', bundler]; if (appConfig !== undefined) args.push('--config', appConfig); args.push('--port', portDisplay); // The supervisor owns stdin and signals: children are never interactive. diff --git a/packages/repack/src/commands/federation/rnBin.ts b/packages/repack/src/commands/federation/rnBin.ts index 4126d97d8..ce159539c 100644 --- a/packages/repack/src/commands/federation/rnBin.ts +++ b/packages/repack/src/commands/federation/rnBin.ts @@ -13,40 +13,37 @@ type ResolvePackages = ( /** * Resolve the app's LOCAL `react-native` CLI script to an absolute path. * - * Resolution is a pure module-resolution chain — `require.resolve` over - * `[appRoot, ...extraPaths, cwd]` — so PATH is never consulted and a - * global `react-native` can never shadow the app's own install (threat row - * "Executable-file classification"). The chain mirrors Node's own upward - * `node_modules` walk, which also follows pnpm's symlinked layout: the - * returned path is the real, absolute script file. + * Resolution is a pure module-resolution chain rooted at `appRoot` alone — + * Node's upward `node_modules` walk from the app's own directory (which + * also follows pnpm's symlinked layout) — so PATH is never consulted and + * neither the caller's cwd nor any other app's install can stand in for + * this app's own CLI (threat row "Executable-file classification"). An + * app rooted where react-native does not resolve is an error for THAT + * app; callers must not fall back to another root — federation-dev names + * the failing app and exits 2. * * The plan executes the result as `process.execPath …` — spawning * the `.bin/react-native` shim would break under some pnpm layouts, the * resolved cli.js is deterministic. * * @param appRoot the app whose install owns the CLI - * @param options.extraPaths additional resolution bases (e.g. the workspace - * config directory), consulted after the app root * @param options.requireResolve resolution seam for tests only */ export function resolveReactNativeBin( appRoot: string, - options: { - extraPaths?: string[]; - requireResolve?: ResolvePackages; - } = {} + options: { requireResolve?: ResolvePackages } = {} ): string { - const { extraPaths = [], requireResolve = require.resolve } = options; + const { requireResolve = require.resolve } = options; let packageJsonPath: string; try { packageJsonPath = requireResolve('react-native/package.json', { - paths: [appRoot, ...extraPaths, process.cwd()], + paths: [appRoot], }); } catch { throw new CLIError( `Cannot resolve the "react-native" package from ${appRoot} — ` + 'federation-dev runs each app with its own local react-native CLI; ' + - 'install react-native in the app (or run from inside the workspace).' + 'install react-native in the app.' ); } diff --git a/website/src/latest/api/cli/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx index 23c67101a..7803e9d0d 100644 --- a/website/src/latest/api/cli/federation-dev.mdx +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -28,6 +28,7 @@ react-native start --bundler [--config ] --por - `--config` is passed only when the app declares a `config` field in `repack-federation.json` (or you pick one in the wizard); without it the child does its own config discovery, exactly like plain `react-native start` today. - The bundler per app is auto-detected the same way `start` does it — from the resolved config file name — and a per-app `--bundler` mismatch never happens because each child resolves only its own. - `--no-reverse-port` on every child: adb reversal is the runner's job, not the children's (see [adb](#adb-and-run-guidance)). +- Each app is started with the `react-native` CLI resolved from **its own root** (Node's upward `node_modules` walk from the app directory — PATH is never consulted). An app rooted where `react-native` does not resolve exits 2 naming that app; the runner never borrows another app's CLI. ## The interactive session (wizard) From a86a57927cb88569840c14c79b4ec0024c6b765e Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 16:37:34 +0200 Subject: [PATCH 44/54] feat(repack): add --config flag to federation-dev --- .../commands/__tests__/federationDev.test.ts | 56 +++++++++++++++++++ .../repack/src/commands/federation-dev.ts | 20 ++++++- .../src/commands/federation/configFile.ts | 13 ++++- packages/repack/src/commands/options.ts | 5 ++ packages/repack/src/commands/types.ts | 2 + website/src/latest/api/cli/federation-dev.mdx | 4 ++ 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 559b54e74..76e67f905 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -170,6 +170,62 @@ describe('federation-dev usage errors (exit 2, spawn nothing)', () => { }); }); +describe('federation-dev --config ', () => { + it('loads the specific file and anchors the plan on its directory', async () => { + // cwd sits on the twin fixture on purpose: --config must beat the + // walk-up default, and every plan path anchors on the FILE's dir. + const configPath = path.join(tmpDir, 'custom-federation.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + host: { manifest: './build/host', root: '.', port: 8123 }, + remotes: {}, + }) + ); + await federationDev([], cliConfig, { + config: configPath, + dryRun: true, + json: true, + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(0); + const plan = stdoutDocs()[0]; + expect(plan.apps).toHaveLength(1); + expect(plan.apps[0].port).toBe(8123); + expect(plan.apps[0].root).toBe(tmpDir); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('a missing --config file exits 2 naming the resolved path', async () => { + const missing = path.join(tmpDir, 'nope-federation.json'); + await federationDev([], cliConfig, { + config: missing, + dryRun: true, + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain(missing); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('an invalid --config file exits 2 through the config-error path', async () => { + const badPath = path.join(tmpDir, 'bad-federation.json'); + fs.writeFileSync( + badPath, + JSON.stringify({ host: { root: '.' }, remotes: {} }) + ); + await federationDev([], cliConfig, { + config: badPath, + dryRun: true, + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain(badPath); + expect(output()).toContain('host.manifest'); + expect(execaMock).not.toHaveBeenCalled(); + }); +}); + describe('federation-dev per-app react-native CLI resolution', () => { it('resolves each app CLI from its own root', async () => { // Threat-adjacent semantics: the CLI an app runs with is the one diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index c2e74be89..3d88e90bb 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import * as colorette from 'colorette'; @@ -118,9 +119,26 @@ export async function federationDev( return; } + // --config : a specific workspace file instead of the walk-up + // default. Resolved against the caller's cwd up front; everything + // downstream anchors on THIS file's directory as usual. + let explicitConfigPath: string | undefined; + if (args.config !== undefined) { + explicitConfigPath = path.resolve(args.config); + if (!fs.existsSync(explicitConfigPath)) { + usageError( + `--config ${explicitConfigPath} does not exist — pass the path to ` + + 'your repack-federation.json.' + ); + return; + } + } + let loaded: ReturnType; try { - loaded = loadFederationConfig(); + loaded = loadFederationConfig( + explicitConfigPath ? { filePath: explicitConfigPath } : {} + ); } catch (error) { if (error instanceof ConfigFileInvalidError) { usageError(`${error.filePath}: ${error.reasons.join('; ')}`); diff --git a/packages/repack/src/commands/federation/configFile.ts b/packages/repack/src/commands/federation/configFile.ts index fb4d5a030..6c481451a 100644 --- a/packages/repack/src/commands/federation/configFile.ts +++ b/packages/repack/src/commands/federation/configFile.ts @@ -232,12 +232,19 @@ export function describeJsonParseFailure( * 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 } = {}): { +export function loadFederationConfig( + options: { + cwd?: string; + /** Use this exact config file instead of walking up from `cwd`. */ + filePath?: string; + } = {} +): { filePath: string; config: FederationConfig; } | null { - const filePath = findConfigPath(options.cwd ?? process.cwd()); - if (!filePath) return null; + const filePath = + options.filePath ?? findConfigPath(options.cwd ?? process.cwd()); + if (!filePath || !fs.existsSync(filePath)) return null; const rawText = fs.readFileSync(filePath, 'utf-8'); let parsed: unknown; diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index 371e5946b..6b51e3331 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -201,6 +201,11 @@ export const federationDevCommandOptions = [ description: 'Print the plan a live run would use and exit without spawning anything', }, + { + name: '--config ', + description: + 'Path to a specific repack-federation.json (default: the nearest one up from the current directory)', + }, ]; export const bundleCommandOptions = [ diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index b672aabdc..3c77a98a2 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -79,6 +79,8 @@ export interface FederationDevArguments { json?: boolean; /** Print the plan and spawn nothing. */ dryRun?: boolean; + /** Specific repack-federation.json to run; overrides the walk-up default. */ + config?: string; } export interface FederationInitArguments { diff --git a/website/src/latest/api/cli/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx index 7803e9d0d..c7a46681a 100644 --- a/website/src/latest/api/cli/federation-dev.mdx +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -77,6 +77,10 @@ Skip the wizard even on a TTY. The session is then the deterministic default: ho Print the plan as one `{"event":"plan", …}` document before spawning, and `{"event":"status", …}` documents whenever app health transitions. Each app entry carries `name`, `role`, `root`, `config`, `bundler`, `port` (`null` for auto-allocated in dry runs), `url`, `command` and `status` (`planned`/`starting`/`running`/`failed`/`exited`). Child logs stay plain `[name]`-prefixed lines on stdout; parse stdout line by line and keep only lines starting with `{`. +### `--config ` + +Use a specific `repack-federation.json` instead of the nearest one up from the current directory. The path is resolved against your shell's cwd; app roots, configs and ports then anchor on **that file's** directory, exactly as with the default lookup. A missing path or an invalid file exits 2 naming it — nothing spawns. + ### `--dry-run` Print the exact plan a live run would use — configs, ports, URLs and full commands — then exit without spawning anything. Port-conflict rules still run read-only: a busy declared port fails a dry run the same way it would fail the real one. Unmanaged ports display as `auto` (the table), `null` (the JSON). From 86c0568bd25e0368e15e355c4cb942071b5c9dcc Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 16:40:37 +0200 Subject: [PATCH 45/54] chore(tester-federation-v2): adopt federation-dev workspace map and scripts --- apps/tester-federation-v2/package.json | 4 ++-- .../tester-federation-v2/repack-federation.json | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 apps/tester-federation-v2/repack-federation.json diff --git a/apps/tester-federation-v2/package.json b/apps/tester-federation-v2/package.json index 222874f47..079942194 100644 --- a/apps/tester-federation-v2/package.json +++ b/apps/tester-federation-v2/package.json @@ -7,8 +7,8 @@ "ios": "react-native run-ios --no-packager", "pods": "(cd ios && bundle install && (bundle exec pod install || bundle exec pod update))", "pods:update": "(cd ios && bundle install && bundle exec pod update)", - "start:hostapp": "react-native webpack-start --config config.host-app.mts", - "start:miniapp": "react-native webpack-start --config config.mini-app.mts --port 8082" + "start": "react-native federation-dev", + "start:dry": "react-native federation-dev --dry-run" }, "dependencies": { "@callstack/repack": "workspace:*", diff --git a/apps/tester-federation-v2/repack-federation.json b/apps/tester-federation-v2/repack-federation.json new file mode 100644 index 000000000..60a1d7044 --- /dev/null +++ b/apps/tester-federation-v2/repack-federation.json @@ -0,0 +1,17 @@ +{ + "host": { + "root": ".", + "manifest": "build/host-app/ios", + "config": "config.host-app.mts", + "port": 8081 + }, + "remotes": { + "MiniApp": { + "root": ".", + "manifest": "build/mini-app/ios", + "standalone": true, + "port": 8082, + "config": "config.mini-app.mts" + } + } +} From 204c1854e96d909dfd7f8a1b1b555cd00ad77fbf Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 16:41:16 +0200 Subject: [PATCH 46/54] refactor(repack): drop unfired app-exit shutdown reason from supervisor --- .../src/commands/federation/__tests__/supervisor.test.ts | 2 +- packages/repack/src/commands/federation/supervisor.ts | 4 ++-- website/src/latest/api/cli/federation-dev.mdx | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/repack/src/commands/federation/__tests__/supervisor.test.ts b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts index 444b1c2cc..4a644b37b 100644 --- a/packages/repack/src/commands/federation/__tests__/supervisor.test.ts +++ b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts @@ -53,7 +53,7 @@ beforeEach(() => { afterEach(() => { // Park every live session: shutdown + exit so no readiness poller // outlives its test (polling stops on child exit). - for (const supervisor of supervisors) supervisor.shutdown('app-exit'); + for (const supervisor of supervisors) supervisor.shutdown('interrupt'); for (const child of children) child.emit('exit', 0, null); jest.useRealTimers(); }); diff --git a/packages/repack/src/commands/federation/supervisor.ts b/packages/repack/src/commands/federation/supervisor.ts index 0e8f3f9e8..dcc058df8 100644 --- a/packages/repack/src/commands/federation/supervisor.ts +++ b/packages/repack/src/commands/federation/supervisor.ts @@ -46,7 +46,7 @@ interface TrackedChild { */ export class DevSupervisor { private tracked: TrackedChild[] = []; - private shutdownReason: 'interrupt' | 'app-exit' | null = null; + private shutdownReason: 'interrupt' | null = null; private escalated = false; private graceTimer?: ReturnType; private sessionFailed = false; @@ -126,7 +126,7 @@ export class DevSupervisor { * (threat row "Signals & terminal state"). `run()` resolves only after * every child is gone — never orphaning a dev server. */ - shutdown(reason: 'interrupt' | 'app-exit'): void { + shutdown(reason: 'interrupt'): void { if (this.shutdownReason === null) { this.shutdownReason = reason; for (const entry of this.tracked) { diff --git a/website/src/latest/api/cli/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx index c7a46681a..c733c9c83 100644 --- a/website/src/latest/api/cli/federation-dev.mdx +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -105,6 +105,8 @@ While the session runs, exactly these keys do something (the runner prints this Every other key is a deliberate no-op. The terminal (raw mode, cursor state) is always restored, including on crashes. Suspending with Ctrl-Z is not supported in runner mode — suspend from another terminal if you must. +A dead child never ends the session: if any app crashes — the host included — the others keep serving and the runner keeps printing until **you** quit (`q`/Ctrl-C); the final status table marks the dead app `failed` and the session exits 1. + ## adb and run guidance - The **host** port is reversed automatically through the react-native CLI's audited adb helper, once, for all connected devices. From 859138c232efe167820f2511893b54aded06e396 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 18:00:36 +0200 Subject: [PATCH 47/54] test(repack): pin dry-run port-conflict wiring at command level --- .../commands/__tests__/federationDev.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 76e67f905..20b27fb10 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -170,6 +170,40 @@ describe('federation-dev usage errors (exit 2, spawn nothing)', () => { }); }); +describe('federation-dev dry-run port conflicts (command wiring)', () => { + beforeEach(() => { + // Twin fixture declares 8081/8082; make MiniApp's declared port busy. + jest + .spyOn(portPlanner, 'isPortBusy') + .mockImplementation(async (port: number) => port === 8082); + }); + + it('a busy declared port fails the dry run: exit 1, names app+port, spawns nothing', async () => { + await federationDev([], cliConfig, { dryRun: true, interactive: false }); + expect(exitSpy).toHaveBeenCalledWith(1); + const text = output(); + expect(text).toContain('MiniApp'); + expect(text).toContain('8082'); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('--auto-ports rescues the same conflict: plan prints with auto, exit 0', async () => { + await federationDev([], cliConfig, { + dryRun: true, + json: true, + autoPorts: true, + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(0); + const plan = stdoutDocs()[0]; + const mini = plan.apps.find( + (app: { name: string }) => app.name === 'MiniApp' + ); + expect(mini.port).toBeNull(); // `auto` discipline: null in JSON, no spawn + expect(execaMock).not.toHaveBeenCalled(); + }); +}); + describe('federation-dev --config ', () => { it('loads the specific file and anchors the plan on its directory', async () => { // cwd sits on the twin fixture on purpose: --config must beat the From a0b339e1e9d06fa8a3c6bf6b8a312e8ce6d4ba46 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 18:01:44 +0200 Subject: [PATCH 48/54] docs(website): cover config flag, per-app CLI, v2 usage and dead-host session --- agent_context/federation-tools/design.md | 24 +++++++++++++++++++ website/src/latest/api/cli/federation-dev.mdx | 3 +++ 2 files changed, 27 insertions(+) diff --git a/agent_context/federation-tools/design.md b/agent_context/federation-tools/design.md index 32725b4eb..aad5f062c 100644 --- a/agent_context/federation-tools/design.md +++ b/agent_context/federation-tools/design.md @@ -360,6 +360,30 @@ Deltas from the design above, all deliberate: one plain line, `--json` prints nothing (JSON stdout purity is pinned by a command test). +### Pre-PR hardening (post-audit) + +- **Run guidance follows the final plan, not `args.platform`**: the + effective platform is read back from the host's planned + `--platform

`, so a wizard selection counts; with no platform + selected the line names both `run-ios` and `run-android`. +- **Per-app react-native CLI**: `PlanInput.rnCliPath` became + `rnCliForRoot(root)`; `federation-dev` memoizes resolution per distinct + app root (single-dir twins still resolve once), `rnBin.ts` dropped the + cwd/extraPaths fall-backs, and an unresolvable root exits 2 naming the + owning app — no CLI is ever borrowed across apps. +- **`--config `**: picks a specific `repack-federation.json` + (resolved against the caller cwd); `loadFederationConfig` gained a + `filePath` option and all anchoring lands on the file's directory; + missing/invalid file exits 2 naming the path. +- **tester-federation-v2 adoption**: same map shape as v1 + (`HostApp`/`MiniApp`, configs `config.{host,mini}-app.mts`, ports + 8081/8082, `standalone: true` — its mini config reads + `env.argv.standalone`), `start`/`start:dry` scripts; + `federation-dev --dry-run --json` verified there. +- **Dead code**: supervisor's never-fired `'app-exit'` shutdown reason + removed; docs state the shipped behavior — a dead child (host + included) never ends the session; the user quits explicitly. + ## Referenced surface (verified 2026-09) - `packages/repack/src/plugins/ModuleFederationPluginV1.ts` / `V2.ts` — no diff --git a/website/src/latest/api/cli/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx index c733c9c83..2a344a65c 100644 --- a/website/src/latest/api/cli/federation-dev.mdx +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -4,6 +4,8 @@ It is deliberately CI-friendly: with no TTY (or `--no-interactive`) there are no prompts at all — the session is the host plus every declared remote — and `--json` / `--dry-run` give machine-readable, deterministic output. +It is plugin-version agnostic: workspaces on `ModuleFederationPluginV1` or `ModuleFederationPluginV2` are run identically — the runner only knows app roots, configs and ports from the workspace map (see `apps/tester-federation` and `apps/tester-federation-v2` in the repo for both setups). + ## Usage import { PackageManagerTabs } from '@theme'; @@ -29,6 +31,7 @@ react-native start --bundler [--config ] --por - The bundler per app is auto-detected the same way `start` does it — from the resolved config file name — and a per-app `--bundler` mismatch never happens because each child resolves only its own. - `--no-reverse-port` on every child: adb reversal is the runner's job, not the children's (see [adb](#adb-and-run-guidance)). - Each app is started with the `react-native` CLI resolved from **its own root** (Node's upward `node_modules` walk from the app directory — PATH is never consulted). An app rooted where `react-native` does not resolve exits 2 naming that app; the runner never borrows another app's CLI. +- Tester caveat: apps whose config file switches bundler behind an env flag (the testers' `USE_WEBPACK` wrapper) are invisible to bundler detection — the plan always names the config's default engine, so set the env flag *and* expect the planned `--bundler` to describe the default path, not the env-forced one. ## The interactive session (wizard) From a43f7562cffaa14de621ff97e2b9d1e399f744e4 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 18:36:07 +0200 Subject: [PATCH 49/54] test(repack): generate rnBin fixtures at runtime for CI The __fixtures__/rnbin tree sat under node_modules/ paths, so it was gitignored and invisible on CI: the fixture root missed with ENOENT and require.resolve walked up to the repo's real react-native, breaking 4 tests. Stand-in installs are now built under os.tmpdir() in beforeAll (symlink leg skips where symlinks are unavailable), and the dead local tree plus its config-split-roots pointer are gone. --- packages/repack/jest.config.js | 3 + .../commands/__tests__/federationDev.test.ts | 119 +++++++++---- .../config-split-roots/repack-federation.json | 14 -- .../__tests__/helpers/rnbinFixtures.ts | 164 ++++++++++++++++++ .../federation/__tests__/rnBin.test.ts | 68 ++++---- 5 files changed, 296 insertions(+), 72 deletions(-) delete mode 100644 packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json create mode 100644 packages/repack/src/commands/federation/__tests__/helpers/rnbinFixtures.ts diff --git a/packages/repack/jest.config.js b/packages/repack/jest.config.js index 950eee740..8f8cce177 100644 --- a/packages/repack/jest.config.js +++ b/packages/repack/jest.config.js @@ -6,4 +6,7 @@ module.exports = { setupFiles: ['./jest.setup.js'], testEnvironment: 'node', testMatch: ['**/__tests__/**/*.ts?(x)'], + // testMatch treats EVERY .ts under __tests__ as a suite: shared + // fixture-building helpers live in __tests__/helpers/ to stay out. + testPathIgnorePatterns: ['/node_modules/', '__tests__/helpers/'], }; diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 20b27fb10..084850513 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -6,8 +6,11 @@ import path from 'node:path'; import { PassThrough } from 'node:stream'; import execa from 'execa'; import packageJson from '../../../package.json'; -import { CLIError } from '../../helpers/index.js'; import { runAdbReverse } from '../common/runAdbReverse.js'; +import { + createRnbinFixtures, + type RnbinFixtures, +} from '../federation/__tests__/helpers/rnbinFixtures.js'; import * as portPlanner from '../federation/portPlanner.js'; import * as rnBin from '../federation/rnBin.js'; import * as wizard from '../federation/wizard.js'; @@ -261,11 +264,56 @@ describe('federation-dev --config ', () => { }); describe('federation-dev per-app react-native CLI resolution', () => { + // The app installs are generated at runtime under os.tmpdir(): a fixture + // node_modules/ tree is gitignored by definition, so a committed one + // would be missing on CI and resolution would walk up to the repo's + // REAL react-native, silently testing the wrong package. + let rnFixtures: RnbinFixtures; + let workspace: string; + // The monorepo root's node_modules — what resolution walks UP to when an + // app root has no install. No command may ever reference it. + const repoNodeModules = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + 'node_modules' + ); + + const writeWorkspace = (name: string, miniRoot: string) => { + const dir = path.join(rnFixtures.root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'repack-federation.json'), + JSON.stringify({ + host: { manifest: './build/host/ios', root: '../app', port: 8091 }, + remotes: { + MiniApp: { + manifest: './build/mini/ios', + root: miniRoot, + port: 8092, + }, + }, + }) + ); + return dir; + }; + + beforeAll(() => { + rnFixtures = createRnbinFixtures(); + }); + + afterAll(() => { + rnFixtures.cleanup(); + }); + it('resolves each app CLI from its own root', async () => { // Threat-adjacent semantics: the CLI an app runs with is the one // installed in THAT app's root — the host's install never stands in // for a remote rooted elsewhere. - process.chdir(path.join(FIXTURES, 'config-split-roots')); + workspace = writeWorkspace('split-roots', '../pnpmapp'); + process.chdir(workspace); await federationDev([], cliConfig, { dryRun: true, json: true, @@ -276,47 +324,60 @@ describe('federation-dev per-app react-native CLI resolution', () => { const mini = plan.apps.find( (app: { name: string }) => app.name === 'MiniApp' ); - expect(host.command).toContain( - path.join('rnbin', 'app', 'node_modules', 'react-native', 'cli.js') - ); - expect(mini.command).toContain(path.join('rnbin', 'pnpmapp')); - expect(mini.command).not.toContain( - path.join('rnbin', 'app', 'node_modules', 'react-native', 'cli.js') + const hostCli = path.join( + rnFixtures.appRoot, + 'node_modules', + 'react-native', + 'cli.js' ); - const hostCli = host.command.split(' ')[1]; + expect(host.command).toContain(hostCli); + // The pnpm app's CLI lives inside its own root — reached through the + // symlink (realpath under node_modules/.pnpm) or, where symlinks are + // unavailable, through a plain node_modules/react-native. + const miniCliExpected = rnFixtures.pnpmFallbackDir + ? path.join(rnFixtures.pnpmFallbackDir, 'scripts', 'cli.js') + : rnFixtures.pnpmCliRealpath; + expect(mini.command).toContain(miniCliExpected); + expect(mini.command).not.toContain(hostCli); const miniCli = mini.command.split(' ')[1]; expect(miniCli).not.toBe(hostCli); + // CI-equivalence: nothing resolved to the repo's own install. + expect(host.command).not.toContain(repoNodeModules); + expect(mini.command).not.toContain(repoNodeModules); expect(execaMock).not.toHaveBeenCalled(); }); it('exits 2 naming the app whose own root lacks react-native', async () => { // No silent fall-back to the host's CLI: an app rooted where no - // react-native resolves is a usage error naming that app. jest's own - // resolver never truly misses inside the repo tree, so the per-root - // failure is driven through rnBin's documented error contract (the - // real MODULE_NOT_FOUND mapping is pinned in rnBin.test). + // react-native resolves (the bare `noroot` fixture) is a usage error + // naming that app. jest's own resolver never truly misses — it falls + // back to the repo tree even with paths: [root] — so the miss is + // driven through rnBin's documented requireResolve seam with a + // Node-shaped MODULE_NOT_FOUND, mapping to the real CLIError. The + // host still resolves for real, against its tmp fixture install. // realpath: the command anchors paths on process.cwd(), which realpaths - // /var to /private/var on macOS — compare the same absolute form. - const miniRoot = path.join(fs.realpathSync(tmpDir), 'mini'); + // /var to /private/var on macOS — rnFixtures.root is already realpath'd. + const miniRoot = rnFixtures.noRoot; + // Capture the real implementation BEFORE the spy swaps the export: + // jest.requireActual hands back the very object spyOn mutates. + const realResolve = rnBin.resolveReactNativeBin; jest .spyOn(rnBin, 'resolveReactNativeBin') - .mockImplementation((root: string) => { + .mockImplementation((root: string, options?: unknown) => { if (root === miniRoot) { - throw new CLIError( - `Cannot resolve the "react-native" package from ${root}` - ); + return realResolve(root, { + requireResolve: () => { + throw Object.assign( + new Error("Cannot find module 'react-native/package.json'"), + { code: 'MODULE_NOT_FOUND' } + ); + }, + }); } - return '/resolved/react-native/cli.js'; + return realResolve(root, options as never); }); - fs.mkdirSync(miniRoot); - fs.writeFileSync( - path.join(tmpDir, 'repack-federation.json'), - JSON.stringify({ - host: { manifest: './build/host', root: '.' }, - remotes: { MiniApp: { manifest: './build/mini', root: 'mini' } }, - }) - ); - process.chdir(tmpDir); + workspace = writeWorkspace('missing-root', '../noroot'); + process.chdir(workspace); await federationDev([], cliConfig, { apps: 'MiniApp', dryRun: true, diff --git a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json b/packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json deleted file mode 100644 index a75c006dd..000000000 --- a/packages/repack/src/commands/federation/__tests__/__fixtures__/config-split-roots/repack-federation.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "host": { - "manifest": "./build/host/ios", - "root": "../rnbin/app", - "port": 8091 - }, - "remotes": { - "MiniApp": { - "manifest": "../../rnbin/pnpmapp/build/mini/ios", - "root": "../rnbin/pnpmapp", - "port": 8092 - } - } -} diff --git a/packages/repack/src/commands/federation/__tests__/helpers/rnbinFixtures.ts b/packages/repack/src/commands/federation/__tests__/helpers/rnbinFixtures.ts new file mode 100644 index 000000000..66e8753d1 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/helpers/rnbinFixtures.ts @@ -0,0 +1,164 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Runtime-generated stand-in installs for `resolveReactNativeBin`, built + * under `os.tmpdir()` instead of a committed fixture tree: a fixture + * `node_modules/` directory is gitignored by definition, so a committed + * tree would only exist on machines that ran `pnpm install` and CI would + * fall back to the repo's REAL react-native through Node's upward walk. + */ +export interface RnbinFixtures { + /** realpath'd tmp root; every other path lives under it. */ + root: string; + /** Plain install: node_modules/react-native/cli.js. */ + appRoot: string; + /** pnpm-style install: node_modules/react-native is a REAL SYMLINK + * into .pnpm/react-native@100.0.0/node_modules/react-native (whose CLI + * sits at scripts/cli.js). Absent when the platform refuses symlinks — + * then a plain directory takes its place, so resolution still lands + * inside pnpmAppRoot but symlink-following is not proven. */ + pnpmAppRoot: string; + /** Realpath of the CLI the pnpm app resolves to (the .pnpm target). */ + pnpmCliRealpath: string; + /** Where it is declared when symlinks are unavailable (null otherwise). */ + pnpmFallbackDir: string | null; + /** Empty app dir: no react-native anywhere under it (or under root). */ + noRoot: string; + /** False when fs.symlinkSync failed — symlink-specific tests skip. */ + symlinkSupported: boolean; + cleanup: () => void; +} + +/** Node resolves `paths: [root]` by walking ancestors too: a tmpdir with a + * node_modules/react-native above it would silently satisfy a "missing" + * resolution, so refuse to build on such a machine. */ +function assertNoAncestorReactNative(dir: string) { + let current = path.parse(dir).root; + const parts = path.relative(current, dir).split(path.sep).filter(Boolean); + const visited = [current]; + for (const part of parts) { + current = path.join(current, part); + visited.push(current); + } + for (const ancestor of visited) { + if (fs.existsSync(path.join(ancestor, 'node_modules', 'react-native'))) { + throw new Error( + `Cannot build rnbin fixtures: ${ancestor} has an ancestor ` + + 'node_modules/react-native that would shadow the fixture roots.' + ); + } + } +} + +/** One-shot probe: some CI boxes (Windows without developer mode) refuse + * symlink creation — symlink-shaped tests skip there instead of flaking. */ +export function canSymlink(): boolean { + const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-symlink-')); + try { + fs.mkdirSync(path.join(probe, 'target')); + fs.symlinkSync( + 'target', + path.join(probe, 'link'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + return true; + } catch { + return false; + } finally { + fs.rmSync(probe, { recursive: true, force: true }); + } +} + +export function createRnbinFixtures(): RnbinFixtures { + const tmpRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'repack-rnbin-')) + ); + assertNoAncestorReactNative(tmpRoot); + + const appRoot = path.join(tmpRoot, 'app'); + const appPackageDir = path.join(appRoot, 'node_modules', 'react-native'); + fs.mkdirSync(appPackageDir, { recursive: true }); + fs.writeFileSync( + path.join(appPackageDir, 'package.json'), + JSON.stringify({ + name: 'react-native', + version: '100.0.0', + bin: { 'react-native': './cli.js' }, + }) + ); + fs.writeFileSync(path.join(appPackageDir, 'cli.js'), '#!/usr/bin/env node\n'); + + const pnpmAppRoot = path.join(tmpRoot, 'pnpmapp'); + const pnpmTargetDir = path.join( + pnpmAppRoot, + 'node_modules', + '.pnpm', + 'react-native@100.0.0', + 'node_modules', + 'react-native' + ); + fs.mkdirSync(path.join(pnpmTargetDir, 'scripts'), { recursive: true }); + fs.writeFileSync( + path.join(pnpmTargetDir, 'package.json'), + JSON.stringify({ + name: 'react-native', + version: '100.0.0', + bin: { 'react-native': './scripts/cli.js' }, + }) + ); + fs.writeFileSync( + path.join(pnpmTargetDir, 'scripts', 'cli.js'), + '#!/usr/bin/env node\n' + ); + + let symlinkSupported = true; + let pnpmFallbackDir: string | null = null; + try { + fs.symlinkSync( + path.join( + '.pnpm', + 'react-native@100.0.0', + 'node_modules', + 'react-native' + ), + path.join(pnpmAppRoot, 'node_modules', 'react-native'), + 'junction' + ); + } catch { + // Windows without developer mode / symlink privileges: fall back to a + // real directory so resolution still lands in this app's install, and + // let symlink-specific tests skip instead of flaking CI. + symlinkSupported = false; + pnpmFallbackDir = path.join(pnpmAppRoot, 'node_modules', 'react-native'); + fs.mkdirSync(pnpmFallbackDir, { recursive: true }); + fs.writeFileSync( + path.join(pnpmFallbackDir, 'package.json'), + JSON.stringify({ + name: 'react-native', + version: '100.0.0', + bin: { 'react-native': './scripts/cli.js' }, + }) + ); + fs.mkdirSync(path.join(pnpmFallbackDir, 'scripts'), { recursive: true }); + fs.writeFileSync( + path.join(pnpmFallbackDir, 'scripts', 'cli.js'), + '#!/usr/bin/env node\n' + ); + } + + const noRoot = path.join(tmpRoot, 'noroot'); + fs.mkdirSync(noRoot, { recursive: true }); + + return { + root: tmpRoot, + appRoot, + pnpmAppRoot, + pnpmCliRealpath: path.join(pnpmTargetDir, 'scripts', 'cli.js'), + pnpmFallbackDir, + noRoot, + symlinkSupported, + cleanup: () => fs.rmSync(tmpRoot, { recursive: true, force: true }), + }; +} diff --git a/packages/repack/src/commands/federation/__tests__/rnBin.test.ts b/packages/repack/src/commands/federation/__tests__/rnBin.test.ts index 617ca7410..b56c7a7fe 100644 --- a/packages/repack/src/commands/federation/__tests__/rnBin.test.ts +++ b/packages/repack/src/commands/federation/__tests__/rnBin.test.ts @@ -3,38 +3,46 @@ import os from 'node:os'; import path from 'node:path'; import { CLIError } from '../../../helpers/index.js'; import { resolveReactNativeBin } from '../rnBin.js'; +import { + canSymlink, + createRnbinFixtures, + type RnbinFixtures, +} from './helpers/rnbinFixtures.js'; -const FIXTURES = path.join(__dirname, '__fixtures__', 'rnbin'); -const appDir = (app: string) => path.join(FIXTURES, app); +// Stand-in installs are generated at runtime under os.tmpdir(): a fixture +// node_modules/ tree is gitignored by definition, so a committed one would +// be missing on CI and resolution would walk up to the repo's REAL +// react-native, silently testing the wrong package. +let fixtures: RnbinFixtures; + +beforeAll(() => { + fixtures = createRnbinFixtures(); +}); + +afterAll(() => { + fixtures.cleanup(); +}); describe('resolveReactNativeBin', () => { it('resolves the local package cli.js to an absolute existing path', () => { - const bin = resolveReactNativeBin(appDir('app')); + const bin = resolveReactNativeBin(fixtures.appRoot); expect(bin).toBe( - path.join(FIXTURES, 'app', 'node_modules', 'react-native', 'cli.js') + path.join(fixtures.appRoot, 'node_modules', 'react-native', 'cli.js') ); expect(fs.existsSync(bin)).toBe(true); }); - it('follows a pnpm-symlinked package to an absolute script path', () => { - const bin = resolveReactNativeBin(appDir('pnpmapp')); - expect(path.isAbsolute(bin)).toBe(true); - expect(fs.realpathSync(bin)).toBe( - fs.realpathSync( - path.join( - FIXTURES, - 'pnpmapp', - 'node_modules', - '.pnpm', - 'react-native@100.0.0', - 'node_modules', - 'react-native', - 'scripts', - 'cli.js' - ) - ) - ); - }); + const itSymlink = canSymlink() ? it : it.skip; + itSymlink( + 'follows a pnpm-symlinked package to an absolute script path', + () => { + const bin = resolveReactNativeBin(fixtures.pnpmAppRoot); + expect(path.isAbsolute(bin)).toBe(true); + expect(fs.realpathSync(bin)).toBe( + fs.realpathSync(fixtures.pnpmCliRealpath) + ); + } + ); it('prefers the local package over a planted PATH shim', () => { // Threat row "Executable-file classification": resolution is a local @@ -47,10 +55,10 @@ describe('resolveReactNativeBin', () => { const originalPath = process.env.PATH; process.env.PATH = `${shimDir}${path.delimiter}${originalPath ?? ''}`; try { - const bin = resolveReactNativeBin(appDir('app')); + const bin = resolveReactNativeBin(fixtures.appRoot); expect(bin).not.toContain(shimDir); expect(bin).toBe( - path.join(FIXTURES, 'app', 'node_modules', 'react-native', 'cli.js') + path.join(fixtures.appRoot, 'node_modules', 'react-native', 'cli.js') ); } finally { process.env.PATH = originalPath; @@ -60,15 +68,17 @@ describe('resolveReactNativeBin', () => { it('fails with a clean CLIError naming react-native when no local package exists', () => { // jest's module registry always resolves a real react-native from the - // repo tree, so the miss leg is exercised through the documented - // resolution seam with a Node-shaped MODULE_NOT_FOUND. + // repo tree — even with paths: [appRoot] it falls back on a miss — so + // the miss leg is exercised through the documented resolution seam + // with a Node-shaped MODULE_NOT_FOUND. fixtures.noRoot stands in for + // the real app root that would miss on a normal machine. const miss = Object.assign( new Error("Cannot find module 'react-native/package.json'"), { code: 'MODULE_NOT_FOUND' } ); let caught: unknown; try { - resolveReactNativeBin('/isolated/app-without-react-native', { + resolveReactNativeBin(fixtures.noRoot, { requireResolve: () => { throw miss; }, @@ -79,7 +89,7 @@ describe('resolveReactNativeBin', () => { expect(caught).toBeInstanceOf(CLIError); const message = (caught as Error).message; expect(message).toContain('react-native'); - expect(message).toContain('/isolated/app-without-react-native'); + expect(message).toContain(fixtures.noRoot); expect(message).not.toMatch(/\n\s+at\s/); }); }); From c928a2f390a127763f53c1d00be3df4ae18db66a Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 19:44:14 +0200 Subject: [PATCH 50/54] feat(repack): plan the app-launch command for federation-dev --- .../federation/__tests__/launchPlan.test.ts | 89 +++++++++++++++++++ .../src/commands/federation/launchPlan.ts | 55 ++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/repack/src/commands/federation/__tests__/launchPlan.test.ts create mode 100644 packages/repack/src/commands/federation/launchPlan.ts diff --git a/packages/repack/src/commands/federation/__tests__/launchPlan.test.ts b/packages/repack/src/commands/federation/__tests__/launchPlan.test.ts new file mode 100644 index 000000000..a242db8da --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/launchPlan.test.ts @@ -0,0 +1,89 @@ +import path from 'node:path'; +import type { PlannedApp } from '../devPlan.js'; +import { buildLaunchPlan } from '../launchPlan.js'; + +// Only the fields the launch target reads: roots, role, standalone mark. +const app = ( + name: string, + root: string, + role: 'host' | 'remote', + standalone = false +): PlannedApp => + ({ name, role, root, ...(standalone ? { standalone } : {}) }) as PlannedApp; + +const cliForRoot = (root: string) => path.join(root, 'node_modules', 'cli.js'); + +const hostRoot = '/workspace/app'; +const miniRoot = '/workspace/miniapp'; +const plan = [ + app('host', hostRoot, 'host'), + app('MiniApp', miniRoot, 'remote'), +]; + +describe('buildLaunchPlan', () => { + it('targets the host root with run-ios and the host CLI, --no-packager always', () => { + const launch = buildLaunchPlan({ + plan, + platform: 'ios', + rnCliForRoot: cliForRoot, + }); + expect(launch.triggerApp).toBe('host'); + expect(launch.root).toBe(hostRoot); + // Same execa discipline as the supervisor: process.execPath head, the + // target root's OWN react-native CLI next. + expect(launch.file).toBe(process.execPath); + expect(launch.args).toEqual([ + cliForRoot(hostRoot), + 'run-ios', + '--no-packager', + ]); + expect(launch.cwd).toBe(hostRoot); + }); + + it('run-android on android, and --device passes through verbatim', () => { + const launch = buildLaunchPlan({ + plan, + platform: 'android', + device: 'emulator-5554', + rnCliForRoot: cliForRoot, + }); + expect(launch.args).toEqual([ + cliForRoot(hostRoot), + 'run-android', + '--no-packager', + '--device', + 'emulator-5554', + ]); + }); + + it('a standalone session launches the standalone remote with ITS own CLI', () => { + // The standalone remote serves the app alone, so the app must be built + // against the remote's project — root, CLI and cwd all follow it. + const standalonePlan = [ + app('host', hostRoot, 'host'), + app('MiniApp', miniRoot, 'remote', true), + ]; + const launch = buildLaunchPlan({ + plan: standalonePlan, + platform: 'ios', + rnCliForRoot: cliForRoot, + }); + expect(launch.triggerApp).toBe('MiniApp'); + expect(launch.root).toBe(miniRoot); + expect(launch.cwd).toBe(miniRoot); + expect(launch.args).toContain(cliForRoot(miniRoot)); + expect(launch.args).not.toContain(cliForRoot(hostRoot)); + }); + + it('a hostile device id stays one exact argv entry, never shell-split', () => { + // Threat row "Subprocess spawn": passthrough with zero interpretation. + const launch = buildLaunchPlan({ + plan, + platform: 'android', + device: 'x; rm -rf /', + rnCliForRoot: cliForRoot, + }); + const deviceIndex = launch.args.indexOf('--device'); + expect(launch.args[deviceIndex + 1]).toBe('x; rm -rf /'); + }); +}); diff --git a/packages/repack/src/commands/federation/launchPlan.ts b/packages/repack/src/commands/federation/launchPlan.ts new file mode 100644 index 000000000..f2805a492 --- /dev/null +++ b/packages/repack/src/commands/federation/launchPlan.ts @@ -0,0 +1,55 @@ +import type { PlannedApp } from './devPlan.js'; + +/** The one-shot app-launch child, in the supervisor's spawn shape. */ +export interface LaunchTarget { + /** App project the app is built and launched from (spawn cwd). */ + root: string; + /** Plan row whose readiness triggers the launch. */ + triggerApp: string; + /** Same head as the supervised children: `process.execPath`. */ + file: string; + /** `[, run-, --no-packager, (--device …)?]`. */ + args: string[]; + cwd: string; +} + +/** + * Resolve the one-shot `run-` command that puts the app on the + * device once the session is serving (pure — the caller gates on an + * explicit launch choice and a narrowed platform). + * + * Target: a standalone session is served by the standalone remote alone, + * so the app project is THAT remote's root; otherwise the host root. The + * CLI is resolved from the target root exactly like the dev-server + * children — each app runs with its own install, never a borrowed one. + * + * `--no-packager` always: the session's dev servers ARE the packager, and + * the RN CLI starting a second one would either fail on the busy port or + * split bundle serving away from the federation session. Device selection + * is delegated to the RN CLI — `--device` rides verbatim as one argv + * entry (threat row "Subprocess spawn": no shell, no interpretation) and + * an ambiguous id surfaces as the CLI's own error in the launch stream. + */ +export function buildLaunchPlan(input: { + plan: PlannedApp[]; + platform: 'ios' | 'android'; + device?: string; + rnCliForRoot: (appRoot: string) => string; +}): LaunchTarget { + const target = + input.plan.find((app) => app.standalone === true) ?? + input.plan.find((app) => app.role === 'host')!; + const root = target.root; + return { + root, + triggerApp: target.name, + file: process.execPath, + args: [ + input.rnCliForRoot(root), + `run-${input.platform}`, + '--no-packager', + ...(input.device === undefined ? [] : ['--device', input.device]), + ], + cwd: root, + }; +} From c172d4c6537753261c65e71f8e332a9dd0edb237 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 19:47:50 +0200 Subject: [PATCH 51/54] feat(repack): attach one-shot processes to the dev supervisor --- .../federation/__tests__/supervisor.test.ts | 117 ++++++++++++++- .../src/commands/federation/supervisor.ts | 137 ++++++++++++++++-- 2 files changed, 240 insertions(+), 14 deletions(-) diff --git a/packages/repack/src/commands/federation/__tests__/supervisor.test.ts b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts index 4a644b37b..c69bd6597 100644 --- a/packages/repack/src/commands/federation/__tests__/supervisor.test.ts +++ b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts @@ -58,9 +58,15 @@ afterEach(() => { jest.useRealTimers(); }); -const makeSupervisor = (opts?: { graceMs?: number }) => { +const makeSupervisor = (opts?: { + graceMs?: number; + onFirstReady?: (appName: string) => void; +}) => { const supervisor = new DevSupervisor(plan, sink, { ...(opts?.graceMs === undefined ? {} : { graceMs: opts.graceMs }), + ...(opts?.onFirstReady === undefined + ? {} + : { onFirstReady: opts.onFirstReady }), probeStatus, }); supervisors.push(supervisor); @@ -188,3 +194,112 @@ describe('DevSupervisor crash isolation', () => { expect(result.exitCode).toBe(1); }); }); + +describe('DevSupervisor first-ready callback', () => { + it('fires onFirstReady once per app on the first running transition only', async () => { + jest.useFakeTimers(); + probeStatus.mockImplementation(async () => 'packager-status:running'); + const onFirstReady = jest.fn(); + const supervisor = makeSupervisor({ onFirstReady }); + void supervisor.run(); + await jest.advanceTimersByTimeAsync(1000); + expect(onFirstReady.mock.calls.map((call) => call[0]).sort()).toEqual([ + 'MiniApp', + 'host', + ]); + // Later polls never re-fire: readiness flips once and stays flipped. + await jest.advanceTimersByTimeAsync(5000); + expect(onFirstReady).toHaveBeenCalledTimes(2); + }); +}); + +describe('DevSupervisor one-shot attached process', () => { + const target = { + file: process.execPath, + args: ['/rn/cli.js', 'run-android', '--no-packager'], + cwd: '/workspace/app', + }; + + it('spawns through execa with the supervisor discipline and streams [launch]-prefixed lines outside the status table', async () => { + const supervisor = makeSupervisor(); + void supervisor.run(); + supervisor.spawnOneShot(target); + const calls = execaMock.mock.calls as unknown as Array< + [string, string[], Record] + >; + expect(calls).toHaveLength(3); + const launchCall = calls[2]!; + expect(launchCall[0]).toBe(process.execPath); + expect(launchCall[1]).toEqual(target.args); + expect(launchCall[2].cwd).toBe(target.cwd); + expect(launchCall[2].stdin).toBe('ignore'); + expect(launchCall[2].stdout).toBe('pipe'); + expect(launchCall[2].stderr).toBe('pipe'); + expect(launchCall[2].shell).toBeFalsy(); + // Same append-only prefixed log pane, same line-splitting discipline. + children[2]!.stdout.write('BUILD SUC'); + children[2]!.stdout.write('CESS\n'); + await flush(); + expect(lines).toContain('[launch] BUILD SUCCESS'); + // The status table is untouched: no 'launch' row appears. + expect(Object.keys(supervisor.getStatuses())).toEqual(['host', 'MiniApp']); + }); + + it('exit 0 persists App launched and never fails the session', async () => { + const supervisor = makeSupervisor(); + const run = supervisor.run(); + supervisor.spawnOneShot(target); + children[2]!.emit('exit', 0, null); + await flush(); + expect(lines).toContain('[launch] App launched'); + supervisor.shutdown('interrupt'); + children[0]!.emit('exit', 0, null); + children[1]!.emit('exit', 0, null); + const result = await run; + expect(result.exitCode).toBe(0); + }); + + it("nonzero exit persists the code but the session exit code stays the apps'", async () => { + const supervisor = makeSupervisor(); + const run = supervisor.run(); + supervisor.spawnOneShot(target); + children[2]!.emit('exit', 7, null); + await flush(); + expect(lines).toContain('[launch] exited with code 7'); + // The failed launch is NOT a session failure: the apps still decide. + supervisor.shutdown('interrupt'); + children[0]!.emit('exit', 0, null); + children[1]!.emit('exit', 0, null); + const result = await run; + expect(result.exitCode).toBe(0); + }); + + it('shutdown SIGINTs a live one-shot with the children and escalation SIGTERMs it', async () => { + jest.useFakeTimers(); + const supervisor = makeSupervisor({ graceMs: 5000 }); + void supervisor.run(); + supervisor.spawnOneShot(target); + supervisor.shutdown('interrupt'); + expect(children[2]!.kill).toHaveBeenCalledWith('SIGINT'); + supervisor.shutdown('interrupt'); + expect(children[2]!.kill).toHaveBeenCalledWith('SIGTERM'); + // Shutdown-killed one-shots stay silent: the interrupt, not a crash. + children[2]!.emit('exit', null, 'SIGTERM'); + await jest.advanceTimersByTimeAsync(0); + expect( + lines.some((line) => line.includes('[launch] exited with code')) + ).toBe(false); + }); + + it('a session ending without shutdown still kills a live one-shot, silently', async () => { + const supervisor = makeSupervisor(); + const run = supervisor.run(); + supervisor.spawnOneShot(target); + // Both apps exit on their own: the session ends while the launch runs. + children[0]!.emit('exit', 0, null); + children[1]!.emit('exit', 0, null); + await run; + expect(children[2]!.kill).toHaveBeenCalled(); + expect(lines.some((line) => line.includes('[launch] exited'))).toBe(false); + }); +}); diff --git a/packages/repack/src/commands/federation/supervisor.ts b/packages/repack/src/commands/federation/supervisor.ts index dcc058df8..2760502c9 100644 --- a/packages/repack/src/commands/federation/supervisor.ts +++ b/packages/repack/src/commands/federation/supervisor.ts @@ -21,6 +21,22 @@ export interface SessionResult { >; } +/** A one-shot attached process in the same spawn shape as a planned app. */ +export interface OneShotTarget { + file: string; + args: string[]; + cwd: string; +} + +interface OneShotChild { + name: string; + child: ChildProcessWithoutNullStreams; + exited: boolean; + /** Set when the supervisor kills it at session end: exit stays silent. */ + killed: boolean; + pending: { out: string; err: string }; +} + const DEFAULT_GRACE_MS = 5000; const READINESS_POLL_FIRST_MS = 500; const READINESS_POLL_MAX_MS = 2000; @@ -46,6 +62,7 @@ interface TrackedChild { */ export class DevSupervisor { private tracked: TrackedChild[] = []; + private oneShots: OneShotChild[] = []; private shutdownReason: 'interrupt' | null = null; private escalated = false; private graceTimer?: ReturnType; @@ -56,7 +73,12 @@ export class DevSupervisor { constructor( private plan: PlannedApp[], private out: LogSink, - private opts: { graceMs?: number; probeStatus: StatusProbe } + private opts: { + graceMs?: number; + probeStatus: StatusProbe; + /** Fires once per app the first time its probe reports running. */ + onFirstReady?: (appName: string) => void; + } ) { this.allGone = new Promise((resolve) => { this.markAllGone = resolve; @@ -70,6 +92,60 @@ export class DevSupervisor { ); } + /** + * Attach a one-shot child (the app launch) to the session: spawned with + * the same execa discipline as the dev-server children, streamed through + * the same prefixed log pane, and killed with the children on shutdown or + * session end. It is deliberately NOT tracked: it has no port to watch, + * no status row, and its exit code never touches the session result. + */ + spawnOneShot(target: OneShotTarget, name = 'launch'): void { + const child = execa(target.file, target.args, { + cwd: target.cwd, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }) as unknown as ChildProcessWithoutNullStreams; + const asPromise = child as unknown as Partial>; + if (typeof asPromise.catch === 'function') asPromise.catch(() => undefined); + const entry: OneShotChild = { + name, + child, + exited: false, + killed: false, + pending: { out: '', err: '' }, + }; + this.oneShots.push(entry); + child.stdout?.on('data', (chunk: Buffer) => + this.streamPrefixed(name, entry.pending, 'out', chunk) + ); + child.stderr?.on('data', (chunk: Buffer) => + this.streamPrefixed(name, entry.pending, 'err', chunk) + ); + child.once('exit', (code, signal) => + this.onOneShotExit(entry, code, signal) + ); + } + + private onOneShotExit( + entry: OneShotChild, + code: number | null, + signal: string | null + ): void { + if (entry.exited) return; + entry.exited = true; + this.flushPending(entry.name, entry.pending); + // Shutdown/end-of-session kills are the supervisor's own doing: silent. + if (this.shutdownReason !== null || entry.killed) return; + if (code === 0) { + this.out.log(`[${entry.name}] App launched`); + } else { + this.out.log( + `[${entry.name}] exited with code ${code ?? `signal ${signal}`}` + ); + } + } + /** Spawn every app (host first, remotes in plan order) and live until they are gone. */ async run(): Promise { for (const app of this.plan) { @@ -104,6 +180,15 @@ export class DevSupervisor { } await this.allGone; + // Session over: a launch still in flight goes with it, silently — its + // story already ended with the servers, and a kill-code line after the + // final table would read like a crash. + for (const one of this.oneShots) { + if (!one.exited) { + one.killed = true; + one.child.kill('SIGTERM'); + } + } const apps: SessionResult['apps'] = {}; for (const entry of this.tracked) { const status = @@ -132,6 +217,9 @@ export class DevSupervisor { for (const entry of this.tracked) { if (!this.isGone(entry)) entry.child.kill('SIGINT'); } + for (const one of this.oneShots) { + if (!one.exited) one.child.kill('SIGINT'); + } this.graceTimer = setTimeout( () => this.escalate(), this.opts.graceMs ?? DEFAULT_GRACE_MS @@ -147,6 +235,9 @@ export class DevSupervisor { for (const entry of this.tracked) { if (!this.isGone(entry)) entry.child.kill('SIGTERM'); } + for (const one of this.oneShots) { + if (!one.exited) one.child.kill('SIGTERM'); + } } private isGone(entry: TrackedChild): boolean { @@ -154,13 +245,38 @@ export class DevSupervisor { } private onChunk(entry: TrackedChild, stream: 'out' | 'err', chunk: Buffer) { - // Line-split on \n across chunks; the content passes through UNALTERED - // (ANSI included) — the prefix is the only addition. - const text = entry.pending[stream] + chunk.toString(); + this.streamPrefixed(entry.app.name, entry.pending, stream, chunk); + } + + /** + * Line-split on \n across chunks; the content passes through UNALTERED + * (ANSI included) — the prefix is the only addition. Shared by tracked + * apps and one-shot children: one log pane, one discipline. + */ + private streamPrefixed( + name: string, + pending: { out: string; err: string }, + stream: 'out' | 'err', + chunk: Buffer + ): void { + const text = pending[stream] + chunk.toString(); const lines = text.split('\n'); - entry.pending[stream] = lines.pop() ?? ''; + pending[stream] = lines.pop() ?? ''; for (const line of lines) { - this.out.log(`[${entry.app.name}] ${line}`); + this.out.log(`[${name}] ${line}`); + } + } + + private flushPending( + name: string, + pending: { out: string; err: string } + ): void { + for (const stream of ['out', 'err'] as const) { + const rest = pending[stream]; + if (rest !== '') { + pending[stream] = ''; + this.out.log(`[${name}] ${rest}`); + } } } @@ -173,13 +289,7 @@ export class DevSupervisor { entry.exited = true; if (entry.pollTimer) clearTimeout(entry.pollTimer); entry.exitCode = code; - for (const stream of ['out', 'err'] as const) { - const rest = entry.pending[stream]; - if (rest !== '') { - entry.pending[stream] = ''; - this.out.log(`[${entry.app.name}] ${rest}`); - } - } + this.flushPending(entry.app.name, entry.pending); if (this.shutdownReason !== null) { entry.status = 'exited'; @@ -221,6 +331,7 @@ export class DevSupervisor { if (this.isGone(entry)) return; if (body?.startsWith('packager-status:running')) { entry.status = 'running'; + this.opts.onFirstReady?.(entry.app.name); return; } schedule(Math.min(delay * 2, READINESS_POLL_MAX_MS)); From 2b121189a97c101f28336828870eec6475e8c975 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 19:54:14 +0200 Subject: [PATCH 52/54] feat(repack): ask the wizard whether to launch the app when ready --- .../federation/__tests__/wizard.test.ts | 127 +++++++++++++++++- .../repack/src/commands/federation/wizard.ts | 68 +++++++++- 2 files changed, 187 insertions(+), 8 deletions(-) diff --git a/packages/repack/src/commands/federation/__tests__/wizard.test.ts b/packages/repack/src/commands/federation/__tests__/wizard.test.ts index cac6d6671..b8895e593 100644 --- a/packages/repack/src/commands/federation/__tests__/wizard.test.ts +++ b/packages/repack/src/commands/federation/__tests__/wizard.test.ts @@ -47,15 +47,24 @@ const planned = [ const runWith = async ( clack: ClackStub, - overrides: { config?: FederationConfig; planned?: PlannedApp[] } = {} -): Promise => - runWizard({ + overrides: { + config?: FederationConfig; + planned?: PlannedApp[]; + launch?: boolean; + onOutput?: (chunk: string) => void; + } = {} +): Promise => { + const output = new PassThrough(); + output.on('data', (chunk) => overrides.onOutput?.(String(chunk))); + return runWizard({ config: overrides.config ?? config, planned: overrides.planned ?? planned, + ...(overrides.launch === undefined ? {} : { launch: overrides.launch }), loadClack: async () => clack as never, input: new PassThrough(), - output: new PassThrough(), + output, }); +}; describe('runWizard (clack path)', () => { let clack: ClackStub; @@ -68,6 +77,7 @@ describe('runWizard (clack path)', () => { clack.multiselect.mockResolvedValue(['MiniApp']); clack.select.mockResolvedValue('ios'); clack.confirm + .mockResolvedValueOnce(true) // launch: yes .mockResolvedValueOnce(true) // host port .mockResolvedValueOnce(true) // MiniApp port .mockResolvedValueOnce(false); // standalone: no @@ -77,6 +87,7 @@ describe('runWizard (clack path)', () => { answers: { session: { remotes: ['MiniApp'] }, platform: 'ios', + launch: true, ports: { host: 8081, MiniApp: 8082 }, }, }); @@ -98,10 +109,54 @@ describe('runWizard (clack path)', () => { ).toEqual(['host', 'MiniApp']); }); + it('confirms launch right after the platform step, defaulting to yes', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('ios'); + clack.confirm.mockResolvedValue(true); + await runWith(clack); + // The FIRST confirm is the launch question — it precedes the port loop. + const first = clack.confirm.mock.calls[0] as unknown as [ + { message: string; initialValue?: boolean }, + ]; + expect(first[0].message).toContain('Launch the app on ios'); + expect(first[0].initialValue).toBe(true); + const messages = clack.confirm.mock.calls.map( + (call) => (call as unknown as [{ message: string }])[0].message + ); + expect(messages[1]).toContain('port'); + }); + + it('launch answer "no" records launch false', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('ios'); + clack.confirm + .mockResolvedValueOnce(false) // launch: no + .mockResolvedValue(true); // ports + standalone: defaults + const outcome = await runWith(clack); + expect(outcome.status === 'completed' && outcome.answers.launch).toBe( + false + ); + }); + + it('an explicit launch answer from the flags is not asked again', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('ios'); + clack.confirm.mockResolvedValue(true); // ports + standalone only + const outcome = await runWith(clack, { launch: false }); + const messages = clack.confirm.mock.calls.map( + (call) => (call as unknown as [{ message: string }])[0].message + ); + expect(messages.some((message) => message.includes('Launch'))).toBe(false); + expect(outcome.status === 'completed' && outcome.answers.launch).toBe( + false + ); + }); + it('port override: confirm "no" then text answer wins', async () => { clack.multiselect.mockResolvedValue(['MiniApp']); clack.select.mockResolvedValue('ios'); clack.confirm + .mockResolvedValueOnce(true) // launch: yes .mockResolvedValueOnce(false) // host port: override .mockResolvedValueOnce(true) // MiniApp port .mockResolvedValueOnce(false); // standalone @@ -117,12 +172,30 @@ describe('runWizard (clack path)', () => { clack.multiselect.mockResolvedValue(['MiniApp']); clack.select.mockResolvedValue('all'); clack.confirm.mockResolvedValue(true); // all port confirms - const outcome = await runWith(clack); + let captured = ''; + const outcome = await runWith(clack, { onOutput: (c) => (captured += c) }); expect(outcome.status === 'completed' && outcome.answers.platform).toBe( undefined ); }); + it('platform "all" skips the launch question and explains why', async () => { + clack.multiselect.mockResolvedValue(['MiniApp']); + clack.select.mockResolvedValue('all'); + clack.confirm.mockResolvedValue(true); // ports + standalone only + let captured = ''; + const outcome = await runWith(clack, { onOutput: (c) => (captured += c) }); + const messages = clack.confirm.mock.calls.map( + (call) => (call as unknown as [{ message: string }])[0].message + ); + expect(messages.some((message) => message.includes('Launch'))).toBe(false); + // Silently skipped as a question, but never silently as a behavior. + expect(captured).toContain('single platform'); + expect(outcome.status === 'completed' && 'launch' in outcome.answers).toBe( + false + ); + }); + it('standalone is confirmed for selected remotes that declare it', async () => { clack.multiselect.mockResolvedValue(['MiniApp']); clack.select.mockResolvedValue('all'); @@ -189,6 +262,7 @@ describe('runWizard (readline fallback)', () => { const { outcome, captured } = await runFallback([ 'MiniApp', // remotes 'ios', // platform + 'y', // launch: yes '', // host port: default '8090', // MiniApp port: override 'n', // standalone @@ -196,11 +270,54 @@ describe('runWizard (readline fallback)', () => { const expected: WizardAnswers = { session: { remotes: ['MiniApp'] }, platform: 'ios', + launch: true, ports: { host: 8081, MiniApp: 8090 }, }; expect(outcome).toEqual({ status: 'completed', answers: expected }); expect(captured).toContain('MiniApp'); expect(captured).toContain('8081'); + expect(captured).toContain('Launch the app on ios'); + }); + + it('launch prompt defaults to yes on an empty answer', async () => { + const { outcome } = await runFallback([ + 'MiniApp', // remotes + 'ios', // platform + '', // launch: empty = default yes + '', // host port + '', // MiniApp port + 'n', // standalone + ]); + expect(outcome.status === 'completed' && outcome.answers.launch).toBe(true); + }); + + it('launch "no" records launch false', async () => { + const { outcome } = await runFallback([ + 'MiniApp', // remotes + 'ios', // platform + 'n', // launch: no + '', // host port + '', // MiniApp port + 'n', // standalone + ]); + expect(outcome.status === 'completed' && outcome.answers.launch).toBe( + false + ); + }); + + it('platform "all" never asks launch and explains the skip', async () => { + const { outcome, captured } = await runFallback([ + 'MiniApp', // remotes + '', // platform: all + '', // host port + '', // MiniApp port + 'n', // standalone + ]); + expect(outcome.status === 'completed' && 'launch' in outcome.answers).toBe( + false + ); + expect(captured).toContain('single platform'); + expect(captured).not.toContain('Launch the app on'); }); it('empty answers take the defaults (all remotes, all platforms)', async () => { diff --git a/packages/repack/src/commands/federation/wizard.ts b/packages/repack/src/commands/federation/wizard.ts index 65bc2b91a..06120e736 100644 --- a/packages/repack/src/commands/federation/wizard.ts +++ b/packages/repack/src/commands/federation/wizard.ts @@ -7,6 +7,12 @@ export interface WizardAnswers { session: { remotes: string[]; standaloneRemote?: string }; /** Absent means "all": no per-platform spawn arg or guidance. */ platform?: 'ios' | 'android'; + /** + * Launch the app when the host is ready. Absent when launch was never on + * the table (platform "all" without an explicit flag) — the one launch + * decision is made here and consumed downstream, never re-derived. + */ + launch?: boolean; /** Per-app port as confirmed or overridden, keyed by app name. */ ports: Record; } @@ -61,10 +67,22 @@ const validatePort = (value: string): string | undefined => { return undefined; }; +/** + * Platform "all" takes the launch question off the table — the app can only + * be launched at one platform — but never off the record: the skip is said + * out loud where the user can still act on it. + */ +const LAUNCH_NEEDS_PLATFORM_LINE = + 'Launch skipped: launching the app needs a single platform (ios or android).'; + +const launchMessage = (platform: 'ios' | 'android') => + `Launch the app on ${platform} when the host is ready?`; + async function runClackWizard( clack: ClackLike, config: FederationConfig, - planned: PlannedApp[] + planned: PlannedApp[], + streams: { launch?: boolean; output: NodeJS.WritableStream } ): Promise { const cancelled: WizardOutcome = { status: 'cancelled' }; const declaredRemotes = Object.keys(config.remotes); @@ -99,6 +117,26 @@ async function runClackWizard( ? platformAnswer : undefined; + // Launch question sits right after the platform step: it only exists for + // a narrowed platform, and an explicit --launch/--no-launch is already + // an answer — the wizard never re-asks what the flags decided. + let launch: boolean | undefined = streams.launch; + if (launch === undefined) { + if (platform !== undefined) { + const launchAnswer = await clack.confirm({ + message: launchMessage(platform), + initialValue: true, + }); + if (clack.isCancel(launchAnswer)) { + clack.cancel('Session cancelled.'); + return cancelled; + } + launch = launchAnswer === true; + } else { + streams.output.write(`${LAUNCH_NEEDS_PLATFORM_LINE}\n`); + } + } + const ports: Record = {}; for (const app of planned) { if (app.role === 'remote' && !remotes.includes(app.name)) continue; @@ -149,6 +187,7 @@ async function runClackWizard( answers: { session: standaloneRemote ? { remotes, standaloneRemote } : { remotes }, platform, + ...(launch === undefined ? {} : { launch }), ports, }, }; @@ -207,12 +246,17 @@ function createLineReader( async function runReadlineWizard( config: FederationConfig, planned: PlannedApp[], - streams: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } + streams: { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; + launch?: boolean; + } ): Promise { const rl = createLineReader( streams.input ?? process.stdin, streams.output ?? process.stdout ); + const out = streams.output ?? process.stdout; const cancelled: WizardOutcome = { status: 'cancelled' }; try { const declaredRemotes = Object.keys(config.remotes); @@ -232,6 +276,18 @@ async function runReadlineWizard( ? platformAnswer : undefined; + let launch: boolean | undefined = streams.launch; + if (launch === undefined) { + if (platform !== undefined) { + const answer = (await rl.question(`${launchMessage(platform)} (Y/n): `)) + .trim() + .toLowerCase(); + launch = !(answer === 'n' || answer === 'no'); + } else { + out.write(`${LAUNCH_NEEDS_PLATFORM_LINE}\n`); + } + } + const ports: Record = {}; for (const app of planned) { if (app.role === 'remote' && !remotes.includes(app.name)) continue; @@ -269,6 +325,7 @@ async function runReadlineWizard( answers: { session: standaloneRemote ? { remotes, standaloneRemote } : { remotes }, platform, + ...(launch === undefined ? {} : { launch }), ports, }, }; @@ -292,6 +349,8 @@ export async function runWizard(input: { config: FederationConfig; /** First-pass plan: its per-app ports are the wizard's defaults. */ planned: PlannedApp[]; + /** An explicit --launch/--no-launch answer: passed through, never re-asked. */ + launch?: boolean; loadClack?: () => Promise; input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; @@ -303,6 +362,9 @@ export async function runWizard(input: { clack = null; } return clack - ? runClackWizard(clack, input.config, input.planned) + ? runClackWizard(clack, input.config, input.planned, { + ...(input.launch === undefined ? {} : { launch: input.launch }), + output: input.output ?? process.stdout, + }) : runReadlineWizard(input.config, input.planned, input); } From 268b4427c9346b1b533f7870157aa69940ead7c9 Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 19:58:19 +0200 Subject: [PATCH 53/54] feat(repack): launch the app on the device once federation-dev is ready --- .../commands/__tests__/federationDev.test.ts | 175 ++++++++++++++++++ .../src/commands/__tests__/options.test.ts | 26 +++ .../repack/src/commands/federation-dev.ts | 64 ++++++- .../repack/src/commands/federation/devPlan.ts | 7 + packages/repack/src/commands/options.ts | 16 ++ packages/repack/src/commands/types.ts | 8 + 6 files changed, 287 insertions(+), 9 deletions(-) diff --git a/packages/repack/src/commands/__tests__/federationDev.test.ts b/packages/repack/src/commands/__tests__/federationDev.test.ts index 084850513..ac854a41b 100644 --- a/packages/repack/src/commands/__tests__/federationDev.test.ts +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -716,4 +716,179 @@ describe('federation-dev live session', () => { expect(firstRun[0]!.event).toBe('plan'); expect(execaMock).not.toHaveBeenCalled(); }); + + describe('app launch (--launch / wizard yes)', () => { + // Servers answer /status on OS-assigned ports so readiness happens for + // real without touching well-known ports; the launch child itself is + // the mocked execa — no device, no gradle, no xcode. + const startLaunchWorkspace = () => + Promise.all([startServer(0), startServer(0)]).then( + ([hostPort, remotePort]) => { + liveWorkspace = fs.mkdtempSync(path.join(FIXTURES, 'launch-')); + fs.writeFileSync( + path.join(liveWorkspace, 'repack-federation.json'), + JSON.stringify({ + host: { manifest: './build/host', root: '.', port: hostPort }, + remotes: { + MiniApp: { + manifest: './build/mini', + root: '.', + port: remotePort, + }, + }, + }) + ); + process.chdir(liveWorkspace); + return { hostPort, remotePort }; + } + ); + + /** execa calls whose argv is an app-launch command, with child indexes. */ + const launchCalls = () => + execaMock.mock.calls + .map((call, index) => ({ + argv: ( + call as unknown as [string, string[], Record] + )[1], + index, + })) + .filter((entry) => + entry.argv.some((a) => /^run-(ios|android)$/.test(a)) + ); + + it('--launch --platform android spawns run-android exactly once on first host readiness', async () => { + const { hostPort } = await startLaunchWorkspace(); + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + platform: 'android', + launch: true, + interactive: false, + }); + await waitFor( + () => launchCalls().length === 1, + 12000, + () => `launch calls: ${launchCalls().length}` + ); + const call = execaMock.mock.calls[launchCalls()[0]!.index] as unknown as [ + string, + string[], + Record, + ]; + // Same execa discipline as the supervisor's children. + expect(call[0]).toBe(process.execPath); + expect(call[1]).toEqual([ + expect.stringMatching(/react-native[\\/]cli\.js$/), + 'run-android', + '--no-packager', + ]); + expect(call[2].cwd).toBe(fs.realpathSync(liveWorkspace)); + expect(call[2].shell).toBeFalsy(); + expect(call[2].stdin).toBe('ignore'); + // One-shot discipline: readiness keeps being observed (the remote + // also flips running), yet the launch never respawns. + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(launchCalls()).toHaveLength(1); + // The launch reached the mocked child as a plain execa spawn; no + // packager child (start) beyond host + MiniApp. + const startCalls = execaMock.mock.calls.filter((c) => + (c as unknown as [string, string[]])[1].includes('start') + ); + expect(startCalls).toHaveLength(2); + expect(hostPort).toBeGreaterThan(0); + for (const child of children) child.emit('exit', 0, null); + await command; + expect(exitSpy).toHaveBeenLastCalledWith(0); + }, 30000); + + it('default without any launch flag never spawns a run command', async () => { + await startLaunchWorkspace(); + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + platform: 'android', + interactive: false, + }); + await waitFor(() => execaMock.mock.calls.length === 2); + // Long enough for the first readiness tick to have fired. + await new Promise((resolve) => setTimeout(resolve, 1200)); + expect(launchCalls()).toHaveLength(0); + for (const child of children) child.emit('exit', 0, null); + await command; + }, 30000); + + it('a wizard "yes" drives the same launch without any launch flag', async () => { + const { hostPort, remotePort } = await startLaunchWorkspace(); + const originalIsTTY = process.stdout.isTTY; + process.stdout.isTTY = true; + try { + jest.spyOn(wizard, 'runWizard').mockResolvedValue({ + status: 'completed', + answers: { + session: { remotes: ['MiniApp'] }, + platform: 'android', + launch: true, + ports: { host: hostPort, MiniApp: remotePort }, + }, + } as never); + const command = federationDev([], cliConfig, {}); + await waitFor( + () => launchCalls().length === 1, + 12000, + () => `launch calls: ${launchCalls().length}` + ); + expect(launchCalls()[0]!.argv).toContain('run-android'); + for (const child of children) child.emit('exit', 0, null); + await command; + } finally { + process.stdout.isTTY = originalIsTTY; + } + }, 30000); + + it('--launch with no narrowed platform exits 2 naming --platform, spawning nothing', async () => { + await federationDev([], cliConfig, { + apps: 'MiniApp', + launch: true, + interactive: false, + }); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output()).toContain('--platform'); + expect(execaMock).not.toHaveBeenCalled(); + }); + + it('a failed launch streams through [launch] and never fails the session', async () => { + await startLaunchWorkspace(); + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + platform: 'android', + launch: true, + interactive: false, + }); + await waitFor(() => launchCalls().length === 1); + const launchIndex = launchCalls()[0]!.index; + children[launchIndex]!.emit('exit', 1, null); + // The dev servers are healthy: the user quitting ends the session 0 + // even though the launch child failed — servers own the exit code. + for (const child of children) child.emit('exit', 0, null); + await command; + expect(output()).toContain('[launch] exited with code 1'); + expect(exitSpy).toHaveBeenLastCalledWith(0); + }, 30000); + + it('shutdown kills an in-flight launch child with the session', async () => { + await startLaunchWorkspace(); + const command = federationDev([], cliConfig, { + apps: 'MiniApp', + platform: 'android', + launch: true, + interactive: false, + }); + await waitFor(() => launchCalls().length === 1); + const launchChild = children[launchCalls()[0]!.index]!; + // One Ctrl-C: the supervisor's ordered shutdown covers the launch too. + process.emit('SIGINT'); + expect(launchChild.kill).toHaveBeenCalledWith('SIGINT'); + for (const child of children) child.emit('exit', 0, null); + await command; + expect(exitSpy).toHaveBeenLastCalledWith(0); + }, 30000); + }); }); diff --git a/packages/repack/src/commands/__tests__/options.test.ts b/packages/repack/src/commands/__tests__/options.test.ts index 8fd549d1a..18c8ad0c3 100644 --- a/packages/repack/src/commands/__tests__/options.test.ts +++ b/packages/repack/src/commands/__tests__/options.test.ts @@ -1,5 +1,6 @@ import { bundleCommandOptions, + federationDevCommandOptions, federationInitCommandOptions, startCommandOptions, } from '../options.js'; @@ -56,3 +57,28 @@ describe('federation-init command options', () => { } }); }); + +describe('federation-dev launch options', () => { + test('exposes --launch, --no-launch and --device', () => { + const names = federationDevCommandOptions.map((option) => option.name); + expect(names).toContain('--launch'); + expect(names).toContain('--no-launch'); + expect(names).toContain('--device '); + }); + + test('--launch and --no-launch are boolean flags; --device passes the id through', () => { + for (const name of ['--launch', '--no-launch']) { + const option = federationDevCommandOptions.find( + (candidate) => candidate.name === name + ) as { name: string; parse?: unknown } | undefined; + // Boolean commander flags: no parse, no default — the command reads + // presence as true / false / absent (wizard decides the absent case). + expect(option).toBeDefined(); + expect(option?.parse).toBeUndefined(); + } + const device = federationDevCommandOptions.find( + (candidate) => candidate.name === '--device ' + ) as { parse?: (value: string) => unknown } | undefined; + expect(device?.parse?.('emulator-5554')).toBe('emulator-5554'); + }); +}); diff --git a/packages/repack/src/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts index 3d88e90bb..0f990e88f 100644 --- a/packages/repack/src/commands/federation-dev.ts +++ b/packages/repack/src/commands/federation-dev.ts @@ -14,6 +14,7 @@ import { import { devHeader } from './federation/devHeader.js'; import type { PlanInput, PlannedApp } from './federation/devPlan.js'; import { buildPlan } from './federation/devPlan.js'; +import { buildLaunchPlan } from './federation/launchPlan.js'; import { isPortBusy, planPorts } from './federation/portPlanner.js'; import { resolveReactNativeBin } from './federation/rnBin.js'; import { RunnerConsole } from './federation/runnerConsole.js'; @@ -242,6 +243,7 @@ export async function federationDev( overrides: { port: args.port, platform: args.platform === 'android' ? 'android' : args.platform, + launch: args.launch, }, ports: {}, rnCliForRoot, @@ -265,7 +267,11 @@ export async function federationDev( args.interactive !== false && process.stdout.isTTY ) { - const outcome = await runWizard({ config, planned: effective }); + const outcome = await runWizard({ + config, + planned: effective, + launch: planBase.overrides.launch, + }); if (outcome.status === 'cancelled') { // Cancel is a clean no-op, not a failure (init prompts precedent). process.exit(0); @@ -282,6 +288,9 @@ export async function federationDev( if (answers.platform !== undefined) { planBase.overrides.platform = answers.platform; } + if (answers.launch !== undefined) { + planBase.overrides.launch = answers.launch; + } try { effective = buildPlan({ ...planBase, ports: answers.ports }); } catch (error) { @@ -332,6 +341,23 @@ export async function federationDev( } const plan = buildPlan({ ...planBase, ports }); + const host = plan.find((app) => app.role === 'host')!; + // The effective platform lives in the final plan (flags and wizard + // answers both land there as `--platform

` on every child) — reading + // args directly would print the flag's value over a wizard selection. + const platformFlagIndex = host.spawn.args.indexOf('--platform'); + const platform = + platformFlagIndex >= 0 ? host.spawn.args[platformFlagIndex + 1] : undefined; + + // Launch needs exactly one platform to run- against — the same + // platform the final plan carries, so a wizard selection counts here too. + if (planBase.overrides.launch === true && platform === undefined) { + usageError( + '--launch needs a single platform: pass --platform ios or --platform ' + + 'android (or --no-launch to serve only).' + ); + return; + } // From here on the sink is the one stdout owner (D5 row H): plan, logs, // status block and JSON contracts all route through it, nothing else @@ -357,13 +383,6 @@ export async function federationDev( // `runAdbReverse` helper exactly once (device discovery lives inside it); // remote ports are printed guidance only — the runner never executes adb // for them (threat row "adb execution"). - const host = plan.find((app) => app.role === 'host')!; - // The effective platform lives in the final plan (flags and wizard - // answers both land there as `--platform

` on every child) — reading - // args directly would print the flag's value over a wizard selection. - const platformFlagIndex = host.spawn.args.indexOf('--platform'); - const platform = - platformFlagIndex >= 0 ? host.spawn.args[platformFlagIndex + 1] : undefined; await runAdbReverse({ port: host.port as number }); for (const app of plan) { if (app.role === 'remote') { @@ -383,7 +402,34 @@ export async function federationDev( // The keymap disclosure (D5 row F): exactly these keys do something. runnerConsole.persist(['Keys: q quit | d open debugger | Ctrl-C quit']); - const supervisor = new DevSupervisor(plan, runnerConsole, { probeStatus }); + // Readiness-gated one-shot app launch: the target is the standalone + // remote's project in a standalone session, the host's otherwise, and it + // spawns the FIRST time THAT app's dev server answers — never again, + // whatever readiness does later. + const launchTarget = + planBase.overrides.launch === true && + (platform === 'ios' || platform === 'android') + ? buildLaunchPlan({ + plan, + platform, + ...(args.device === undefined ? {} : { device: args.device }), + rnCliForRoot, + }) + : undefined; + let launchSpawned = false; + const supervisor = new DevSupervisor(plan, runnerConsole, { + probeStatus, + ...(launchTarget === undefined + ? {} + : { + onFirstReady: (appName: string) => { + if (appName === launchTarget.triggerApp && !launchSpawned) { + launchSpawned = true; + supervisor.spawnOneShot(launchTarget); + } + }, + }), + }); // One Ctrl-C asks for the supervisor's ordered shutdown; the second one // escalates inside the supervisor (SIGINT → grace → SIGTERM). const onSigint = () => supervisor.shutdown('interrupt'); diff --git a/packages/repack/src/commands/federation/devPlan.ts b/packages/repack/src/commands/federation/devPlan.ts index 1f29b2dae..c3a2cf972 100644 --- a/packages/repack/src/commands/federation/devPlan.ts +++ b/packages/repack/src/commands/federation/devPlan.ts @@ -31,6 +31,13 @@ export interface PlanInput { port?: number; platform?: 'ios' | 'android'; configChoices?: Record; + /** + * The single launch decision: flags seed it, the wizard rewrites it — + * exactly like the other overrides. `buildPlan` itself ignores it (the + * launch child is not a `start` argv); `federation-dev` consumes it + * after the final plan, so wizard and flag runs share one source. + */ + launch?: boolean; }; /** From the port planner; `'auto'` only in dry-run for unmanaged apps. */ ports: Record; diff --git a/packages/repack/src/commands/options.ts b/packages/repack/src/commands/options.ts index 6b51e3331..68ed0bf8f 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -173,6 +173,22 @@ export const federationDevCommandOptions = [ name: '--platform ', description: 'App platform to print run guidance for: "ios" or "android"', }, + { + name: '--launch', + description: + 'Launch the app on the device once the session is ready (requires a single --platform; the wizard asks when neither launch flag is given on a TTY)', + }, + { + name: '--no-launch', + description: + 'Never launch the app — serve only (the default without a TTY or with --no-interactive)', + }, + { + name: '--device ', + description: + 'Device id passed through verbatim to react-native run- (only meaningful with --launch)', + parse: (val: string) => val, + }, { name: '--port ', description: diff --git a/packages/repack/src/commands/types.ts b/packages/repack/src/commands/types.ts index 3c77a98a2..d3b1ea475 100644 --- a/packages/repack/src/commands/types.ts +++ b/packages/repack/src/commands/types.ts @@ -66,7 +66,15 @@ export interface FederationDoctorArguments { export interface FederationDevArguments { /** Comma-separated remote names; absent means every declared remote. */ apps?: string | string[]; + /** App platform for child compile scope and run guidance; validated by the command. */ platform?: string; + /** + * --launch / --no-launch: true launches the app on first readiness, + * false never, absent defers to the wizard (TTY) or means no (CI). + */ + launch?: boolean; + /** Device id forwarded verbatim to run-. */ + device?: string; /** Host dev-server port; parsed Number, range-checked by the command. */ port?: number; /** Reassign busy declared ports instead of failing with a conflict. */ From f1e39af7e2d4e7ebea697544876f916fe2da1a7f Mon Sep 17 00:00:00 2001 From: Edu Date: Tue, 22 Sep 2026 20:02:31 +0200 Subject: [PATCH 54/54] docs(website): document app auto-launch for federation-dev --- agent_context/federation-tools/design.md | 4 ++ website/src/latest/api/cli/federation-dev.mdx | 44 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/agent_context/federation-tools/design.md b/agent_context/federation-tools/design.md index aad5f062c..ca477b7a9 100644 --- a/agent_context/federation-tools/design.md +++ b/agent_context/federation-tools/design.md @@ -384,6 +384,10 @@ Deltas from the design above, all deliberate: removed; docs state the shipped behavior — a dead child (host included) never ends the session; the user quits explicitly. +### App auto-launch (post-PR addition) + +- **Readiness-gated one-shot launch**: on an explicit choice (`--launch`, or the wizard's confirm — single platform only, flags and wizard converging on `PlanInput.overrides.launch`), the supervisor spawns `run- --no-packager [--device ]` from the target app's root — the standalone remote's in a standalone session, the host's otherwise — the FIRST time that app's `/status` answers, streams it as `[launch]` through the same log pane, and kills it with the children on shutdown or session end. It is a first-class `spawnOneShot` on `DevSupervisor` but deliberately outside the tracked set: no status row, no respawn on later readiness flips, and it can never affect the session exit codes. `--no-packager` always: the session's dev servers ARE the packager — a second one would race for the port and serve outside the session. + ## Referenced surface (verified 2026-09) - `packages/repack/src/plugins/ModuleFederationPluginV1.ts` / `V2.ts` — no diff --git a/website/src/latest/api/cli/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx index 2a344a65c..36fdc704b 100644 --- a/website/src/latest/api/cli/federation-dev.mdx +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -17,7 +17,7 @@ import { PackageManagerTabs } from '@theme'; bun: 'bun react-native federation-dev', }} /> -Run it from anywhere inside the workspace — the nearest `repack-federation.json` up the directory tree is the one used, and all app roots, configs and ports are resolved against that file's directory. +Run it from anywhere inside the workspace — the nearest `repack-federation.json` up the directory tree is the one used, and all app roots, configs and ports are resolved against that file's directory. On your terminal the flow is: pick remotes and platform (or accept the defaults), say whether to launch the app once the host is ready — then the session starts, and with launch on, the app builds and opens on your device by itself. See [Launching the app](#launching-the-app). ## What it runs @@ -39,8 +39,9 @@ On a TTY, with neither `--apps` nor `--no-interactive` given, `federation-dev` o 1. **Remotes** — multi-select which declared remotes to run (all selected by default; the host always runs). 2. **Platform** — `ios`, `android` or *all*. -3. **Ports** — confirms each app's planned port; answering *no* asks for a replacement. -4. **Standalone** — offered **only** for selected remotes that declare `"standalone": true`; at most one remote runs standalone. +3. **Launch** — asked **only** when a single platform was picked: *Launch the app on ios when the host is ready?* (default yes). With *all*, launching is off the table and the wizard says why instead. An explicit `--launch` / `--no-launch` is already an answer and is never re-asked. +4. **Ports** — confirms each app's planned port; answering *no* asks for a replacement. +5. **Standalone** — offered **only** for selected remotes that declare `"standalone": true`; at most one remote runs standalone. The wizard is an input source only: its answers feed the exact same plan resolution the flags drive, so a wizard run and the equivalent flag run produce identical plans. Cancelling exits 0 without spawning anything. @@ -72,6 +73,14 @@ Reassign busy ports to OS-assigned free ones instead of failing with a conflict. Launches that remote with `--standalone`. Refused (exit 2) unless the remote declares `"standalone": true` in `repack-federation.json` — the file is the single source of standalone capability. +### `--launch` / `--no-launch` + +Explicit control over starting the app on the device once the session is ready (see [Launching the app](#launching-the-app)). `--launch` requires a narrowed platform — the flag's or the wizard's — and exits 2 without one. Without a TTY (or with `--no-interactive`) the default is **no launch**, keeping CI semantics unchanged; on a TTY with neither flag, the wizard asks whenever a single platform was picked. + +### `--device ` + +Device or simulator id forwarded verbatim to `run-` (only meaningful with `--launch`). Device selection is delegated to the React Native CLI: an unknown or ambiguous id surfaces that CLI's own error, verbatim, in the `[launch]` log stream. + ### `--no-interactive` Skip the wizard even on a TTY. The session is then the deterministic default: host plus every declared remote, planned ports, no standalone. @@ -110,6 +119,32 @@ Every other key is a deliberate no-op. The terminal (raw mode, cursor state) is A dead child never ends the session: if any app crashes — the host included — the others keep serving and the runner keeps printing until **you** quit (`q`/Ctrl-C); the final status table marks the dead app `failed` and the session exits 1. +## Launching the app + +Serving the workspace is only half of `run-ios`/`run-android`. On an explicit choice, `federation-dev` also puts the app on your device once the session is serving: the first time the app's dev server answers readiness (the **host** — or the **standalone remote**, in a standalone session), the runner spawns a one-shot child from that app's project root, with that app's own `react-native` CLI: + +```bash +react-native run-ios --no-packager [--device ] # or run-android +``` + +- `--no-packager` is always passed: the session's dev servers **are** the packager. A second one started by the RN CLI would race for the port and serve bundles outside your federation session. +- All build output (gradle / xcodebuild / install / launch) streams into the same log pane, prefixed `[launch]`. An unknown or ambiguous `--device` id relays the RN CLI's own error verbatim in that stream. +- The launch is one-shot: it spawns **once** per session, never respawns on later readiness flips, and if you quit before it finishes it is killed with the dev-server children (second-Ctrl-C escalation included). +- The launch child **never** affects session exit codes. A failed build prints `[launch] exited with code N` (the real error is in the streamed lines above it) while the servers keep serving; the session still ends on the apps' own health, when you quit. +- The manual run-guidance line still prints, so launching yourself remains possible with the flags off. + +How the launch decision is made — one value, flags and wizard feeding the same field: + +| Situation | Result | +| --- | --- | +| `--launch` + a single platform (flag or wizard) | launch on first readiness | +| `--no-launch` | never launch | +| TTY, neither flag, wizard picks `ios`/`android` | wizard asks (default yes) | +| TTY, neither flag, wizard picks *all* | no launch — the wizard says why | +| No TTY / `--no-interactive` / `--apps` given | no launch (CI default unchanged) | + +`--launch` without a narrowed platform exits 2 — there is no `run-all`. + ## adb and run guidance - The **host** port is reversed automatically through the react-native CLI's audited adb helper, once, for all connected devices. @@ -122,7 +157,7 @@ A dead child never ends the session: if any app crashes — the host included | --- | --- | | 0 | Success — or a cancelled wizard, or a completed dry run | | 1 | Port conflict (no `--auto-ports`), or a child failed/crashed during the session | -| 2 | Usage or config error — unknown app, bad `--platform`/`--port`, missing or invalid `repack-federation.json`, refused `--standalone`. Nothing is spawned | +| 2 | Usage or config error — unknown app, bad `--platform`/`--port`, `--launch` without a single platform, missing or invalid `repack-federation.json`, refused `--standalone`. Nothing is spawned | ## CI examples @@ -134,6 +169,7 @@ react-native federation-dev --dry-run --no-interactive react-native federation-dev --dry-run --json --no-interactive # headless session: MiniApp only, custom host port, never prompts +# (no device launch — launching is off by default without a TTY) react-native federation-dev --apps MiniApp --platform ios --port 8090 --no-interactive # a busy 8081 on shared runners should not fail the job