diff --git a/.changeset/federation-manifest.md b/.changeset/federation-manifest.md new file mode 100644 index 000000000..b9cbb0b15 --- /dev/null +++ b/.changeset/federation-manifest.md @@ -0,0 +1,7 @@ +--- +'@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. + +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/.gitignore b/.gitignore index a57347794..71a5e7340 100644 --- a/.gitignore +++ b/.gitignore @@ -389,3 +389,13 @@ packages/**/docs # watchman .watchman-cookie* + +# 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/ +!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/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..ca477b7a9 --- /dev/null +++ b/agent_context/federation-tools/design.md @@ -0,0 +1,401 @@ +# 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.** `federation-manifest `: + pretty-prints a manifest (local file, built output dir, or remote URL). + Trivially useful, first consumer, validates schema ergonomics. +- **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 + 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 + 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"`. +- [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) + +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. + + +## 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. + +## 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). + +## 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. +- **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). + +### 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. + +### 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 + 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/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-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" + } + } +} 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/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 new file mode 100644 index 000000000..60a1d7044 --- /dev/null +++ b/apps/tester-federation/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" + } + } +} 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/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); 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/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 new file mode 100644 index 000000000..ac854a41b --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationDev.test.ts @@ -0,0 +1,894 @@ +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 packageJson from '../../../package.json'; +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'; +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, + '..', + '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 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, ...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-')); + exitSpy = jest + .spyOn(process, 'exit') + .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); +}); + +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 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 + // 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', () => { + // 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. + workspace = writeWorkspace('split-roots', '../pnpmapp'); + process.chdir(workspace); + 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' + ); + const hostCli = path.join( + rnFixtures.appRoot, + 'node_modules', + 'react-native', + 'cli.js' + ); + 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 (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 — 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, options?: unknown) => { + if (root === miniRoot) { + return realResolve(root, { + requireResolve: () => { + throw Object.assign( + new Error("Cannot find module 'react-native/package.json'"), + { code: 'MODULE_NOT_FOUND' } + ); + }, + }); + } + return realResolve(root, options as never); + }); + workspace = writeWorkspace('missing-root', '../noroot'); + process.chdir(workspace); + 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 + // 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(); + }); +}); + +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[]; + 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('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 + // 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 = () => stdoutDocs(); + 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('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(); + stdoutSpy.mockClear(); + await federationDev([], cliConfig, { dryRun: true, json: true }); + const secondRun = stdoutDocs(); + expect(secondRun).toEqual(firstRun); + 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__/federationDoctor.test.ts b/packages/repack/src/commands/__tests__/federationDoctor.test.ts new file mode 100644 index 000000000..4b676cedf --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationDoctor.test.ts @@ -0,0 +1,373 @@ +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); + }); +}); + +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('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, {}); + 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); + }); +}); + +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/__tests__/federationInit.test.ts b/packages/repack/src/commands/__tests__/federationInit.test.ts new file mode 100644 index 000000000..138f9c723 --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationInit.test.ts @@ -0,0 +1,330 @@ +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('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')], + 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__/federationManifest.test.ts b/packages/repack/src/commands/__tests__/federationManifest.test.ts new file mode 100644 index 000000000..76384c740 --- /dev/null +++ b/packages/repack/src/commands/__tests__/federationManifest.test.ts @@ -0,0 +1,123 @@ +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('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, {}); + + 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..68ae9cd82 100644 --- a/packages/repack/src/commands/__tests__/index.test.ts +++ b/packages/repack/src/commands/__tests__/index.test.ts @@ -1,8 +1,12 @@ 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('../federation-dev.js'); +jest.mock('../federationDoctor.js'); +jest.mock('../federationInit.js'); +jest.mock('../federationManifest.js'); jest.mock('../start.js'); const cliConfig: CliConfig = { @@ -47,3 +51,51 @@ 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([ + 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/), + ]) + ); + + 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'); + 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.some((name) => name.startsWith('federation-init'))).toBe( + false + ); + }); +}); diff --git a/packages/repack/src/commands/__tests__/options.test.ts b/packages/repack/src/commands/__tests__/options.test.ts index 8e90c60ba..18c8ad0c3 100644 --- a/packages/repack/src/commands/__tests__/options.test.ts +++ b/packages/repack/src/commands/__tests__/options.test.ts @@ -1,4 +1,9 @@ -import { bundleCommandOptions, startCommandOptions } from '../options.js'; +import { + bundleCommandOptions, + federationDevCommandOptions, + federationInitCommandOptions, + startCommandOptions, +} from '../options.js'; describe.each([ ['start', startCommandOptions], @@ -18,3 +23,62 @@ 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(); + }); +}); + +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(); + } + }); +}); + +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/__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/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/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/commands/federation-dev.ts b/packages/repack/src/commands/federation-dev.ts new file mode 100644 index 000000000..0f990e88f --- /dev/null +++ b/packages/repack/src/commands/federation-dev.ts @@ -0,0 +1,481 @@ +import fs from 'node:fs'; +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 { + assertRemoteStandalone, + ConfigFileInvalidError, + 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 { buildLaunchPlan } from './federation/launchPlan.js'; +import { isPortBusy, planPorts } from './federation/portPlanner.js'; +import { resolveReactNativeBin } from './federation/rnBin.js'; +import { RunnerConsole } from './federation/runnerConsole.js'; +import { + planToJson, + renderPlanTable, + 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'; + +/** + * 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 []; + 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); +} + +/** `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(); +} + +/** + * 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; + } + + // --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( + explicitConfigPath ? { filePath: explicitConfigPath } : {} + ); + } 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; + } + } + + const configDir = path.dirname(filePath); + // 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 + // 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 = { + configPath: filePath, + config, + session: { + remotes: requested.length > 0 ? requested : declaredNames, + standaloneRemote: args.standalone, + }, + overrides: { + port: args.port, + platform: args.platform === 'android' ? 'android' : args.platform, + launch: args.launch, + }, + ports: {}, + rnCliForRoot, + }; + + let effective: PlannedApp[]; + try { + effective = buildPlan(planBase); + } catch (error) { + if (error instanceof CLIError) { + usageError(error.message); + return; + } + 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, + launch: planBase.overrides.launch, + }); + 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; + } + if (answers.launch !== undefined) { + planBase.overrides.launch = answers.launch; + } + 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; + + 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 }); + 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 + // 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; + } + + // 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"). + await runAdbReverse({ port: host.port as number }); + for (const app of plan) { + if (app.role === 'remote') { + runnerConsole.log( + `Remote port: run "adb reverse tcp:${app.port} tcp:${app.port}" on ` + + 'your device to reach it from the app.' + ); + } + } + const runTargets = platform + ? `run-${platform}` + : 'run-ios or run-android (pick your device)'; + runnerConsole.log( + `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. + runnerConsole.persist(['Keys: q quit | d open debugger | Ctrl-C quit']); + + // 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'); + process.on('SIGINT', onSigint); + runnerConsole.armKeymap({ + q: onSigint, + '\u0003': onSigint, + d: () => postOpenDebugger(host.url), + }); + + let lastDoc = ''; + let lastRowsKey = ''; + const statusWatch = setInterval(() => { + 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); + + 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) { + runnerConsole.persist([statusToJson(plan, statuses)]); + } else { + runnerConsole.persist(renderStatusTable(plan, statuses)); + } + process.exit(result.exitCode); +} 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/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/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/__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/__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/__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__/__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__/__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-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__/__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__/__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__/configFile.test.ts b/packages/repack/src/commands/federation/__tests__/configFile.test.ts new file mode 100644 index 000000000..27ba3889b --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/configFile.test.ts @@ -0,0 +1,529 @@ +import fs from 'node:fs'; +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, + 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: '.', + config: 'config.host-app.mts', + port: 8081, + }, + remotes: { + store: { + manifest: './store/build', + root: './apps/store', + config: 'config.store.mts', + standalone: true, + port: 8082, + }, + }, + }) + ).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({ + 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('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 { + 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('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', + }); + 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') + ); + }); +}); + +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/); + }); +}); + +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/__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/__tests__/devPlan.test.ts b/packages/repack/src/commands/federation/__tests__/devPlan.test.ts new file mode 100644 index 000000000..5afdbfa86 --- /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: {}, + rnCliForRoot: () => 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/__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__/doctor.test.ts b/packages/repack/src/commands/federation/__tests__/doctor.test.ts new file mode 100644 index 000000000..46ee285de --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/doctor.test.ts @@ -0,0 +1,543 @@ +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 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'); + + // 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', () => { + 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('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 + ['^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__/dryRun.test.ts b/packages/repack/src/commands/federation/__tests__/dryRun.test.ts new file mode 100644 index 000000000..b78ce222a --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/dryRun.test.ts @@ -0,0 +1,222 @@ +import path from 'node:path'; +import type { FederationManifestSharedEntry } from '../../../plugins/federationManifest/types.js'; +import { resolveFederationWorkspace } from '../configFile.js'; +import { doctorExitCode } from '../doctor.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, + 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' + ); + }); +}); + +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/__tests__/extractShared.test.ts b/packages/repack/src/commands/federation/__tests__/extractShared.test.ts new file mode 100644 index 000000000..b4787f6e5 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/extractShared.test.ts @@ -0,0 +1,132 @@ +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('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 { + await expect(extractAppShared(emptyDir)).rejects.toThrow(ConfigEvalError); + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + }); +}); 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__/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/__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__/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/__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/__tests__/portPlanner.test.ts b/packages/repack/src/commands/federation/__tests__/portPlanner.test.ts new file mode 100644 index 000000000..9e5154487 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/portPlanner.test.ts @@ -0,0 +1,139 @@ +import http from 'node:http'; +import net from 'node:net'; +import type { PlannedApp } from '../devPlan.js'; +import { + classifyDeadChild, + getFreePort, + isPortBusy, + planPorts, +} from '../portPlanner.js'; + +const app = (name: string, port?: number): PlannedApp => ({ + name, + role: name === 'host' ? 'host' : 'remote', + root: `/workspace/${name}`, + bundler: 'rspack', + ...(port === undefined ? {} : { port }), + url: `http://localhost:${port ?? ''}`, + spawn: { file: process.execPath, args: [], cwd: `/workspace/${name}` }, + commandLine: '', +}); + +describe('planPorts', () => { + it('keeps free declared ports verbatim (declared ports win)', async () => { + const result = await planPorts([app('host', 8081), app('MiniApp', 8082)], { + autoPorts: false, + probePort: async () => false, + }); + expect(result.ports).toEqual({ host: 8081, MiniApp: 8082 }); + expect(result.conflicts).toEqual([]); + }); + + it('assigns unmanaged apps a free port without probing', async () => { + const probePort = jest.fn(async (_port: number) => false); + const result = await planPorts([app('host', 8081), app('floater')], { + autoPorts: false, + probePort, + }); + expect(result.ports.host).toBe(8081); + const assigned = result.ports.floater!; + expect(typeof assigned).toBe('number'); + expect(assigned).toBeGreaterThan(0); + // Only declared ports are probed — an unmanaged app has nothing to clash. + expect(probePort.mock.calls.map(([port]) => port)).toEqual([8081]); + }); + + it('reports a busy declared port as a conflict without --auto-ports', async () => { + const result = await planPorts([app('host', 8081), app('MiniApp', 8082)], { + autoPorts: false, + probePort: async (port) => port === 8082, // MiniApp's port is busy + }); + expect(result.conflicts).toEqual([{ app: 'MiniApp', port: 8082 }]); + // The conflicted app gets NO port: the command must exit 1 before any + // spawn, so nothing may carry a "resolved" value forward. + expect(result.ports).toEqual({ host: 8081 }); + }); + + it('reassigns a busy declared port to a free one with --auto-ports', async () => { + const result = await planPorts([app('host', 8081), app('MiniApp', 8082)], { + autoPorts: true, + probePort: async (port) => port === 8082, + }); + expect(result.conflicts).toEqual([]); + expect(result.ports.host).toBe(8081); + expect(result.ports.MiniApp).not.toBe(8082); + expect(typeof result.ports.MiniApp).toBe('number'); + }); +}); + +describe('isPortBusy (real listeners)', () => { + let server: http.Server | net.Server | null = null; + + const closeServer = async () => { + if (server) { + const s = server; + server = null; + await new Promise((resolve) => s.close(() => resolve())); + } + }; + + afterEach(closeServer); + + it('classifies a dev-server-style responder as busy', async () => { + server = http.createServer((_req, res) => + res.end('packager-status:running') + ); + const port = await listen(server); + await expect(isPortBusy(port)).resolves.toBe(true); + }); + + it('classifies a garbage-body responder as busy (threat row "Network probes")', async () => { + server = http.createServer((_req, res) => res.end('\0garbage \f')); + const port = await listen(server); + await expect(isPortBusy(port)).resolves.toBe(true); + }); + + it('classifies a hang-without-response as busy within a hard timeout', async () => { + // Accepts the connection, never answers: the listener itself is the + // positive answer, and the call must still settle on its own clock. + server = http.createServer(() => { + // deliberately never responds + }); + const port = await listen(server); + const started = Date.now(); + await expect(isPortBusy(port)).resolves.toBe(true); + expect(Date.now() - started).toBeLessThan(3000); + }); + + it('classifies a bare TCP listener (no HTTP) as busy', async () => { + server = net.createServer(); + const port = await listen(server); + await expect(isPortBusy(port)).resolves.toBe(true); + }); + + it('reports a free port as not busy', async () => { + const free = await getFreePort(); + await expect(isPortBusy(free)).resolves.toBe(false); + }); +}); + +describe('classifyDeadChild (TOCTOU)', () => { + it('names the app and port with an address-in-use verdict when the port answers /status', () => { + const verdict = classifyDeadChild('MiniApp', 8082, true); + expect(verdict.kind).toBe('address-in-use'); + expect(verdict.message).toContain('MiniApp'); + expect(verdict.message).toContain('8082'); + }); + + it('reports a plain crash when the port is silent', () => { + const verdict = classifyDeadChild('MiniApp', 8082, false); + expect(verdict.kind).toBe('crash'); + expect(verdict.message).toContain('MiniApp'); + }); +}); + +async function listen(server: http.Server | net.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as net.AddressInfo; + return address.port; +} 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..b56c7a7fe --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/rnBin.test.ts @@ -0,0 +1,95 @@ +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'; +import { + canSymlink, + createRnbinFixtures, + type RnbinFixtures, +} from './helpers/rnbinFixtures.js'; + +// 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(fixtures.appRoot); + expect(bin).toBe( + path.join(fixtures.appRoot, 'node_modules', 'react-native', 'cli.js') + ); + expect(fs.existsSync(bin)).toBe(true); + }); + + 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 + // 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(fixtures.appRoot); + expect(bin).not.toContain(shimDir); + expect(bin).toBe( + path.join(fixtures.appRoot, '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 — 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(fixtures.noRoot, { + 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(fixtures.noRoot); + expect(message).not.toMatch(/\n\s+at\s/); + }); +}); 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..3f5cae7c6 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/runnerConsole.test.ts @@ -0,0 +1,245 @@ +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\[/); + }); +}); + +/** 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/__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/__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/__tests__/supervisor.test.ts b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts new file mode 100644 index 000000000..c69bd6597 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/supervisor.test.ts @@ -0,0 +1,305 @@ +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('interrupt'); + for (const child of children) child.emit('exit', 0, null); + jest.useRealTimers(); +}); + +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); + 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); + }); +}); + +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/__tests__/wizard.test.ts b/packages/repack/src/commands/federation/__tests__/wizard.test.ts new file mode 100644 index 000000000..b8895e593 --- /dev/null +++ b/packages/repack/src/commands/federation/__tests__/wizard.test.ts @@ -0,0 +1,350 @@ +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[]; + 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, + }); +}; + +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) // launch: yes + .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', + launch: true, + 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('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 + 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 + 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'); + 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 + 'y', // launch: yes + '', // host port: default + '8090', // MiniApp port: override + 'n', // standalone + ]); + 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 () => { + // 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/configFile.ts b/packages/repack/src/commands/federation/configFile.ts new file mode 100644 index 000000000..6c481451a --- /dev/null +++ b/packages/repack/src/commands/federation/configFile.ts @@ -0,0 +1,442 @@ +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'; + +/** `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; + /** + * 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. */ +export interface FederationRemoteConfig { + manifest: string; + root?: string; + /** Whether this remote supports `--standalone` mode. */ + standalone?: boolean; + /** 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 { + 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?, 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. + */ +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, + config: optionalString, + port: optionalNumber, + }, + '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, + config: optionalString, + }, + 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; + /** Use this exact config file instead of walking up from `cwd`. */ + filePath?: string; + } = {} +): { + filePath: string; + config: FederationConfig; +} | 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; + 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; + /** Declared bundler config, absolute against the config-file directory. */ + config?: 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; + /** Declared bundler config, absolute against the config-file directory. */ + config?: string; +} + +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, config } = loaded.config.host; + workspace.host = { + source: resolveSource(manifest, configDir), + ...(root === undefined ? {} : { root: path.resolve(configDir, root) }), + ...(config === undefined + ? {} + : { config: path.resolve(configDir, config) }), + }; + } + + 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 }), + ...(entry.config === undefined + ? {} + : { config: path.resolve(configDir, entry.config) }), + }) + ); + } + + 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. Thin root-keyed wrapper over + * `assertRemoteStandalone` (the one refuse-rule). + */ +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; + assertRemoteStandalone(loaded.config, loaded.filePath, name); + return; + } +} 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}`; +} diff --git a/packages/repack/src/commands/federation/devPlan.ts b/packages/repack/src/commands/federation/devPlan.ts new file mode 100644 index 000000000..c3a2cf972 --- /dev/null +++ b/packages/repack/src/commands/federation/devPlan.ts @@ -0,0 +1,172 @@ +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; + /** + * 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; + /** + * 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. */ +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 + // 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. + 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; +} diff --git a/packages/repack/src/commands/federation/doctor.ts b/packages/repack/src/commands/federation/doctor.ts new file mode 100644 index 000000000..f0daefdc6 --- /dev/null +++ b/packages/repack/src/commands/federation/doctor.ts @@ -0,0 +1,337 @@ +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; + /** 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[] { + 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.`, + }); + } +} + +/** + * 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). + * + * Exported for `dryRun.ts`, which feeds it manifest-shaped virtual entries + * derived from bundler configs instead of built manifests. + */ +export function checkSharedDeps( + left: FederationManifest, + leftLabel: string, + right: FederationManifest, + rightLabel: string, + findings: DoctorFinding[], + pairKind: DoctorPairKind +): void { + const rightShared = new Map( + sharedOf(right).map((entry) => [entry.name, entry]) + ); + + for (const leftEntry of sharedOf(left)) { + const rightEntry = rightShared.get(leftEntry.name); + if (!rightEntry) continue; + const name = leftEntry.name; + + if (leftEntry.singleton !== rightEntry.singleton) { + findings.push({ + severity: 'error', + code: 'SINGLETON_MISMATCH', + message: `Shared dependency "${name}" is singleton: ${leftEntry.singleton} on ${leftLabel} but ${rightEntry.singleton} on ${rightLabel}.`, + }); + } + 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 || crossRemote ? 'warning' : 'error', + code: conventional || crossRemote ? 'EAGER_ADVISORY' : 'EAGER_MISMATCH', + message: conventional + ? `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 = 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 (${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: ${leftLabel} has ${leftEntry.version}, ${rightLabel} has ${rightEntry.version}. Align the versions (or remove singleton).`, + }); + } + } + + const verdict = rangesIntersect( + leftEntry.requiredVersion, + rightEntry.requiredVersion + ); + if (verdict === false) { + findings.push({ + severity: 'warning', + code: 'SHARED_RANGE_UNRESOLVABLE', + 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 (${leftLabel}: ${leftEntry.requiredVersion}, ${rightLabel}: ${rightEntry.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 { + // 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}"`, meta); + const compared: Array<{ name: string; manifest: FederationManifest }> = []; + for (const remote of input.remotes) { + if (remote.missing || !remote.manifest) { + continue; + } + 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, meta); + + return { findings: [...native, ...shared, ...meta] }; +} + +/** + * 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/dryRun.ts b/packages/repack/src/commands/federation/dryRun.ts new file mode 100644 index 000000000..7f6723cf8 --- /dev/null +++ b/packages/repack/src/commands/federation/dryRun.ts @@ -0,0 +1,167 @@ +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; + // 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, + 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..38188ebbb --- /dev/null +++ b/packages/repack/src/commands/federation/extractShared.ts @@ -0,0 +1,179 @@ +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'; +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); +} + +/** + * `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. + try { + return getConfigFilePath('rspack', root, customPath); + } catch { + // fall through to webpack candidates + } + try { + return getConfigFilePath('webpack', root, customPath); + } catch { + // 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.' + ); +} + +/** + * 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/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]); +} 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/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, + }; +} 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/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).`, + }; +} diff --git a/packages/repack/src/commands/federation/rnBin.ts b/packages/repack/src/commands/federation/rnBin.ts new file mode 100644 index 000000000..ce159539c --- /dev/null +++ b/packages/repack/src/commands/federation/rnBin.ts @@ -0,0 +1,65 @@ +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 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.requireResolve resolution seam for tests only + */ +export function resolveReactNativeBin( + appRoot: string, + options: { requireResolve?: ResolvePackages } = {} +): string { + const { requireResolve = require.resolve } = options; + let packageJsonPath: string; + try { + packageJsonPath = requireResolve('react-native/package.json', { + 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.' + ); + } + + 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); +} diff --git a/packages/repack/src/commands/federation/runnerConsole.ts b/packages/repack/src/commands/federation/runnerConsole.ts new file mode 100644 index 000000000..ca97ba741 --- /dev/null +++ b/packages/repack/src/commands/federation/runnerConsole.ts @@ -0,0 +1,218 @@ +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 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). + * + * 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 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; stdin?: StdinStream }) { + this.stream = options.stdout; + this.stdin = options.stdin; + if (typeof this.stream.on === 'function') { + this.stream.on('resize', this.onResizeEvent); + } + } + + private get isTTY(): boolean { + 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.log(line); + } + + /** + * 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 { + 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 { + 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'. */ + onResize(fn: () => void): void { + this.resizeListeners.push(fn); + } + + /** + * 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(); + } +} 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/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/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')), + }); +} diff --git a/packages/repack/src/commands/federation/supervisor.ts b/packages/repack/src/commands/federation/supervisor.ts new file mode 100644 index 000000000..2760502c9 --- /dev/null +++ b/packages/repack/src/commands/federation/supervisor.ts @@ -0,0 +1,341 @@ +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 } + >; +} + +/** 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; + +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 oneShots: OneShotChild[] = []; + private shutdownReason: 'interrupt' | 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; + /** Fires once per app the first time its probe reports running. */ + onFirstReady?: (appName: string) => void; + } + ) { + 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]) + ); + } + + /** + * 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) { + 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; + // 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 = + 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'): void { + if (this.shutdownReason === null) { + this.shutdownReason = reason; + 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 + ); + 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'); + } + for (const one of this.oneShots) { + if (!one.exited) one.child.kill('SIGTERM'); + } + } + + private isGone(entry: TrackedChild): boolean { + return entry.exited; + } + + private onChunk(entry: TrackedChild, stream: 'out' | 'err', chunk: Buffer) { + 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'); + pending[stream] = lines.pop() ?? ''; + for (const line of lines) { + 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}`); + } + } + } + + 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; + this.flushPending(entry.app.name, entry.pending); + + 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'; + this.opts.onFirstReady?.(entry.app.name); + return; + } + schedule(Math.min(delay * 2, READINESS_POLL_MAX_MS)); + }; + schedule(READINESS_POLL_FIRST_MS); + } +} diff --git a/packages/repack/src/commands/federation/wizard.ts b/packages/repack/src/commands/federation/wizard.ts new file mode 100644 index 000000000..06120e736 --- /dev/null +++ b/packages/repack/src/commands/federation/wizard.ts @@ -0,0 +1,370 @@ +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'; + /** + * 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; +} + +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; +}; + +/** + * 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[], + streams: { launch?: boolean; output: NodeJS.WritableStream } +): 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; + + // 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; + 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, + ...(launch === undefined ? {} : { launch }), + 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; + 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); + 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; + + 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; + 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, + ...(launch === undefined ? {} : { launch }), + 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[]; + /** An explicit --launch/--no-launch answer: passed through, never re-asked. */ + launch?: boolean; + 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, { + ...(input.launch === undefined ? {} : { launch: input.launch }), + output: input.output ?? process.stdout, + }) + : runReadlineWizard(input.config, input.planned, input); +} diff --git a/packages/repack/src/commands/federationDoctor.ts b/packages/repack/src/commands/federationDoctor.ts new file mode 100644 index 000000000..ef178ae8d --- /dev/null +++ b/packages/repack/src/commands/federationDoctor.ts @@ -0,0 +1,176 @@ +import path from 'node:path'; +import { + ConfigFileInvalidError, + resolveFederationWorkspace, +} from './federation/configFile.js'; +import type { DoctorReport } from './federation/doctor.js'; +import { + type DoctorRemoteInput, + doctorExitCode, + doctorReportToJson, + formatDoctorReport, + runDoctor, +} from './federation/doctor.js'; +import { collectDryRunInput, runDryRun } from './federation/dryRun.js'; +import { ConfigEvalError } from './federation/extractShared.js'; +import { + type LoadedManifest, + loadManifest, + ManifestInvalidError, + ManifestNotFoundError, +} from './federation/loadManifest.js'; +import type { CliConfig, FederationDoctorArguments } from './types.js'; + +/** 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 +) { + // 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.' + ); + process.exit(2); + return; + } + + if (workspace.remotes.length === 0) { + console.error( + "Option '--remotes ' is required: pass a comma-separated list " + + 'of remote manifest sources.' + ); + process.exit(2); + 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); + } 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 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: + remoteEntry.name ?? + remote.manifest.name ?? + remote.manifest.id ?? + labelFromSource(source), + manifest: remote.manifest, + }); + } catch (error) { + if (error instanceof ManifestNotFoundError) { + remotes.push({ + name: remoteEntry.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, + pairwise: args.pairwise, + }); + + if (args.format === 'json') { + console.log(doctorReportToJson(report)); + } else { + console.log(formatDoctorReport(report)); + } + + process.exit(doctorExitCode(report)); +} diff --git a/packages/repack/src/commands/federationInit.ts b/packages/repack/src/commands/federationInit.ts new file mode 100644 index 000000000..8369acdb5 --- /dev/null +++ b/packages/repack/src/commands/federationInit.ts @@ -0,0 +1,257 @@ +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; + } + + // 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 ' + + '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/federationManifest.ts b/packages/repack/src/commands/federationManifest.ts new file mode 100644 index 000000000..4c5054c73 --- /dev/null +++ b/packages/repack/src/commands/federationManifest.ts @@ -0,0 +1,55 @@ +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 +) { + // 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 ' + + '--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..e15db4a59 100644 --- a/packages/repack/src/commands/index.ts +++ b/packages/repack/src/commands/index.ts @@ -1,5 +1,16 @@ import { bundle } from './bundle.js'; -import { bundleCommandOptions, startCommandOptions } from './options.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, + startCommandOptions, +} from './options.js'; import { start } from './start.js'; import type { BundleArguments, @@ -8,7 +19,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 +46,52 @@ const commands = [ }, ] as const; +const federationCommands = [ + { + // 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, + }, + { + 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: + 'Check host and remote federation manifests for shared and native module drift.', + options: federationDoctorCommandOptions, + func: federationDoctor, + }, + { + // 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, + func: federationInit, + }, +] 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..68ed0bf8f 100644 --- a/packages/repack/src/commands/options.ts +++ b/packages/repack/src/commands/options.ts @@ -94,6 +94,134 @@ 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 = [ + { + 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. Optional when repack-federation.json provides it', + }, + { + name: '--remotes ', + description: + 'Comma-separated list of remote manifest sources (same shapes as --host). Optional when repack-federation.json provides them', + }, + { + 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', + }, + { + name: '--pairwise', + 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 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 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: '--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: + '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', + }, + { + name: '--config ', + description: + 'Path to a specific repack-federation.json (default: the nearest one up from the current directory)', + }, ]; export const bundleCommandOptions = [ @@ -196,4 +324,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 ee98126e6..d3b1ea475 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,62 @@ export interface StartArguments { config?: string; webpackConfig?: string; bundler?: Bundler; + /** Runtime-only standalone mode; reaches configs via `env.argv`. */ + standalone?: boolean; +} + +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; + /** 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 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. */ + 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; + /** Specific repack-federation.json to run; overrides the walk-up default. */ + config?: string; +} + +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 { diff --git a/packages/repack/src/plugins/ModuleFederationPluginV1.ts b/packages/repack/src/plugins/ModuleFederationPluginV1.ts index 0fc240b6a..48a408a01 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,22 @@ 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; + } + + /** + * 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; } /** @@ -318,5 +342,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..d9ac680d8 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', @@ -138,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 = [] @@ -367,5 +399,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..384e5fdeb 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' }, + }); + 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({ + 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__/__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..386ccdd3a --- /dev/null +++ b/packages/repack/src/plugins/__tests__/federationManifest.test.ts @@ -0,0 +1,587 @@ +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' }, + 'not-installed': { singleton: true }, + }, + FIXTURES_CONTEXT + ) + ).toEqual([ + { + name: 'react', + version: '18.0.0-fixture', + singleton: true, + eager: true, + requiredVersion: '^18.0.0', + }, + { + name: 'not-installed', + version: 'unknown', + singleton: true, + eager: false, + requiredVersion: '*', + }, + ]); + }); + + 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 } }], + 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/__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/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..64c061bae --- /dev/null +++ b/packages/repack/src/plugins/federationManifest/shared.ts @@ -0,0 +1,52 @@ +import { + normalizeSharedEntries, + resolveInstalledVersion, +} from '../../utils/sharedVersionResolver.js'; +import type { FederationManifestSharedEntry } from './types.js'; + +// 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 + * 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(); + + // 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)); + } + 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; +} 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'; 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/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__/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/__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/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'; + } +} 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__/**"] } 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 diff --git a/website/src/latest/api/cli/_meta.json b/website/src/latest/api/cli/_meta.json index 63b9b0d8c..7cf4d60f7 100644 --- a/website/src/latest/api/cli/_meta.json +++ b/website/src/latest/api/cli/_meta.json @@ -9,6 +9,31 @@ "name": "bundle", "label": "Bundle" }, + { + "type": "file", + "name": "federation-manifest", + "label": "Federation manifest" + }, + { + "type": "file", + "name": "federation-dev", + "label": "Federation dev" + }, + { + "type": "file", + "name": "federation-doctor", + "label": "Federation doctor" + }, + { + "type": "file", + "name": "federation-init", + "label": "Federation init" + }, + { + "type": "file", + "name": "repack-federation-json", + "label": "repack-federation.json" + }, { "type": "file", "name": "init", 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/federation-dev.mdx b/website/src/latest/api/cli/federation-dev.mdx new file mode 100644 index 000000000..36fdc704b --- /dev/null +++ b/website/src/latest/api/cli/federation-dev.mdx @@ -0,0 +1,179 @@ +# 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. + +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'; + + + +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 + +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)). +- 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) + +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. **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. + +## 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. 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 ` + +- 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. + +### `--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. + +### `--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 `{`. + +### `--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). + +## 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. + +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. +- **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 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 + +| 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`, `--launch` without a single platform, 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 +# (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 +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/federation-doctor.mdx b/website/src/latest/api/cli/federation-doctor.mdx new file mode 100644 index 000000000..fc7551e48 --- /dev/null +++ b/website/src/latest/api/cli/federation-doctor.mdx @@ -0,0 +1,167 @@ +# 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. + +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. + +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 + +# 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 + +# 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. + +## Options + +### `--host` + +- Type: `string` +- 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 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. When both flags and the config file are absent, the command exits 2 with the required-option message. + +### `--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. + +### `--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. + +### `--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. + +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 (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. + +## 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-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 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. 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..a68147c89 --- /dev/null +++ b/website/src/latest/api/cli/repack-federation-json.mdx @@ -0,0 +1,67 @@ +# 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, 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 + +```json +{ + "host": { + "manifest": "./shell/build", + "root": ".", + "config": "config.host-app.mts", + "port": 8081 + }, + "remotes": { + "store": { + "manifest": "http://localhost:8082", + "root": "./apps/store", + "standalone": true, + "port": 8082, + "config": "config.store.mts" + } + } +} +``` + +| 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 | +| `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; `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`, `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 + +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. 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. 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. diff --git a/website/src/latest/docs/features/_meta.json b/website/src/latest/docs/features/_meta.json index 65f77ab24..265c66b74 100644 --- a/website/src/latest/docs/features/_meta.json +++ b/website/src/latest/docs/features/_meta.json @@ -2,6 +2,8 @@ "module-resolution", "code-splitting", "module-federation", + "federation-manifest", + "federation-workspace", "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. 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.