Skip to content

feat(repack): interactive federation dev runner - #1467

Open
gedu wants to merge 54 commits into
callstack:mainfrom
gedu:feat/federation-dev-runner
Open

gedu wants to merge 54 commits into
callstack:mainfrom
gedu:feat/federation-dev-runner

Conversation

@gedu

@gedu gedu commented Sep 22, 2026

Copy link
Copy Markdown

What it does

One command starts a whole module-federation dev session, host plus any mini-app, each on its own port:

react-native federation-dev            # wizard: pick remotes, platform, ports → live session
react-native federation-dev --dry-run  # print the plan, spawn nothing
react-native federation-dev --apps MiniApp --platform ios --launch  # also start the app on the device when ready
react-native federation-dev --apps MiniApp --platform ios --no-interactive  # CI-parity, no prompts

Everything it needs is the repack-federation.json workspace map from #1466, which this PR extends with two additive optional fields: config per app and port on the host.

The live session is a terminal runner: an append-only log pane with [app] prefixes (nothing above ever rewrites — clean scroll and copy), a fixed-height status block at the bottom (app/port/url/health, polled over each server's /status), a disclosed keymap (q quit, d open host debugger, Ctrl-C for the ordered shutdown), and a plain no-chrome mode off-TTY so CI output stays grep-able. --json is a pure NDJSON contract (plan once, status on change) and never mixes chrome into stdout.

How the runner works

Child-per-app: the supervisor spawns each selected app with its own react-native start --bundler <detected per app>. A crashed child is a red row; siblings keep serving and the session ends nonzero only after everything is gone. Declared ports win; --auto-ports probes busy ports before spawning.

With --launch (or the wizard asking, when a single platform is chosen), the runner also starts the app itself: once the host server goes running it spawns one supervised run-<platform> --no-packager [--device] from the app's own root — for standalone sessions, the standalone app's project — streaming gradle/Xcode output prefixed [launch] in the log pane. Device selection is delegated to the RN CLI.

Test plan

  • Manual walkthrough left for reviewers: the TTY wizard itself (multiselect → platform → ports → standalone confirm) and a mid-session child kill — both are pinned by unit tests but easiest to feel live: pnpm start:dry then pnpm start in apps/tester-federation.
  • Device walkthrough (no CI runner has devices): in apps/tester-federation with one device/simulator connected, pnpm start, pick a platform, confirm the launch question → gradle/Xcode output streams under [launch] and the app comes up against the host dev server.

Reviewer notes

  • New runtime dependency: @clack/prompts@^0.9.1 in packages/repack — the same library packages/init already uses; wanted explicit sign-off for the core package. It is confined to the wizard behind a small injectable seam with a readline fallback, so dropping it later touches one module.
  • Intentional behaviors worth knowing: a dead host leaves the session serving (the runner never auto-quits your servers); --port overrides the host port only; during long cold builds a row stays starting while the child's prefixed logs stream real progress.

gedu added 30 commits September 21, 2026 11:48
…uild checks

- additive getSharedConfiguration() on ModuleFederationPluginV1/V2: returns
  the constructor shared option verbatim (pre deep-import injection)
- extractShared.ts: evaluate an app's bundler config in-process
  (loadProjectConfig + synthetic env), duck-type the plugin instance, and
  resolve its shared option into virtual manifest entries per app root
- dryRun.ts: runDryRun reuses the generalized checkSharedDeps codes over
  virtual entries; MISSING_SHARED_PROVIDER (warning) from the both-declared
  package.json heuristic; UNBUILT_CAVEAT suffix on every finding message so
  the locked --format json shape survives
- federation-doctor --dry-run wiring: no builds, no manifest fetches, no
  MISSING_REMOTE_MANIFEST; ConfigEvalError -> message + exit 2, never a
  stack; exit codes 0/1/2 identical across modes
- dry-run-workspace fixtures with per-app mini node_modules pins + CJS
  stub configs; .gitignore negations so fixture node_modules get committed
- federation-doctor.mdx: --dry-run option docs

Focused: jest -- extractShared dryRun federationDoctor doctor => 83/83.
Full: 49 suites / 520 tests; pnpm typecheck, pnpm lint:ci clean.
…oling-side refusal

- --standalone boolean flag on start and bundle commands
- EnvOptions.argv (new additive public type): getEnvOptions always sets
  argv = { standalone: <flag> }, so configs read env.argv?.standalone
  safely; reaches defineRspackConfig/defineWebpackConfig evaluation
  through the same { ...env, platform } spread as --platform — no second
  injection point, nothing ever persisted
- assertStandaloneSupported in configFile.ts: refuses only when
  repack-federation.json exists AND declares the matching remote entry
  without standalone: true (standalone: false = unsupported); no file or
  unmatched root proceeds; malformed file refuses as CLIError, no stack
- start/bundle call the guard up front when --standalone is requested —
  refusal is tooling-side only, the bundler runtime never reads the
  workspace map
- docs: --standalone sections on start/bundle CLI pages

Focused: jest -- getEnvOptions configFile options standalone => 53/53.
Full: 50 suites / 536 tests; pnpm typecheck, pnpm lint:ci clean.
…dvisories

- scanFeatures.ts: regex/state-machine scanner (no babel, zero new deps)
  returning { dependencies, advisories }; a code-state walk masks comments
  and string/template content (escape-tracked), collects static
  import-from / bare import / export-from / require('lit') specifiers,
  maps subpaths to package roots, ignores react/jsx-runtime noise, filters
  relative and @/-alias imports, skips __tests__/ and *.test.* files and
  unsupported extensions
- every dynamic pattern (import(), computed/template require) yields an
  honesty advisory naming file:line and stating the set is NOT exhaustive
  — manifest dynamicImportDetected semantics, never a silent pass
- features-store fixtures cover static imports, scoped packages, requires,
  dynamic template-literal requires, commented/fake imports in strings and
  comments, relative imports and test-file skips
- biome/tsconfig exclusions for src/commands/federation/__fixtures__:
  fixture sources are input data with fake imports and line-significant
  formatting — never linted, formatted or typechecked
  (features-store/__tests__ uses .jsx so jest testMatch never collects it)

Focused: jest -- scanFeatures => 11/11.
Full: 51 suites / 547 tests; pnpm typecheck, pnpm lint:ci clean.
…e-write) with prompts

computeInitPlan computes everything federation-init would write without
writing it: key-level merges over package.json / repack-federation.json /
host remotes surgery (anchored, manual-steps degradation), create-only
versionless rspack+webpack configs consuming defineShared with the remote
role and env.argv-derived mode, host-vs-remote divergence and --yes
pin-rewrite plans. applyPlan writes exactly plan.files; prompt helpers
(apply [y/N], divergence [a]lign/[i]gnore/[c]ancel) take an injected ask.

Evidence:
- Focused: pnpm --filter @callstack/repack test -- initPlan initApply diff
  -> 40 passed (3 suites); full suite 54 suites / 587 tests green.
- Gates: pnpm typecheck and pnpm lint:ci clean on this tree.
- Runtime harness: N/A at unit level — pure plan/merge/diff engine; the
  real end-to-end run lands with the flat command into a scratch copy of
  tester-federation (task 10.1 harness).
- Rollback: revert this commit — init/{plan,merge,templates,diff,apply,
  prompt}.ts, the three suites and the init-workspace fixtures are new
  files; nothing imports them yet (scanner untouched, no command wired).
react-native federation-init <feature-folder> --name <remote> composes
scan -> computeInitPlan -> formatPlanDiff -> prompt gate -> apply on the
workspace resolved from repack-federation.json. Command-level refusals
are exit 2 with clear messages and zero writes: missing --name/feature
folder, no/malformed workspace config, --standalone on a targeted remote
that does not declare it (assertStandaloneSupported), and manual steps
blocking --yes. Interactive runs show diffs first, then [a/i/c] on
divergence and apply [y/N]; --yes auto-aligns and reports each
pkg: old -> new pin rewrite.

Evidence:
- Focused: pnpm --filter @callstack/repack test -- index options
  federationInit -> 37 passed (5 suites); full suite 55 suites /
  602 tests green. pnpm typecheck and pnpm lint:ci clean.
- Runtime harness: ran the built dist command outside jest
  (node -e require('dist/commands/federationInit.js')) against a scratch
  copy of the init-workspace workspace: printed the full plan diff,
  applied 6 files, and reported divergence + alignment
  ('remote-drift: react: ^9.8.7 -> 9.9.9' + package-manager-install
  instruction). tester-federation end-to-end lands with 11.2: today's
  tester app has no repack-federation.json (11.1 creates it), keeps
  configs under configs/ (outside init's default discovery), and init
  has no --config passthrough by design.
- Rollback: revert this commit — removes the command entry, options,
  args type and doc page; the init/ engine files become inert (no
  other importer).
Replace hardcoded 19.2.3/0.84.1 pins, pkg.dependencies lookups and
react(-native)/package.json proto-defineShared imports in all 8
tester-federation and tester-federation-v2 rspack/webpack configs with
Repack.defineShared(SHARED_DEPS, { context, role, mode }): host configs
role 'host' (eager), mini configs role 'remote' with the runtime-only
mode line via env.argv?.standalone. Exact pins now resolve from the
installed workspace — the old committed literals had drifted (react
19.2.3 vs installed 19.2.8, react-native 0.84.1 vs installed 0.86.0).

Add apps/tester-federation/repack-federation.json as the demo workspace
map (host root+manifest, MiniApp manifest/root/standalone/port) driving
zero-flag federation-doctor.

E2E smoke evidence (real rspack 1.6.0 + webpack 5.105.4 builds, ios):
- host/mini/v2-mini builds: compiled successfully, exit 0 (final configs)
- USE_WEBPACK=1 --bundler webpack host build: compiled successfully
- zero-flag federation-doctor from apps/tester-federation (config file
  drives it): 0 errors, 7 EAGER_ADVISORY warnings, exit 0 — the
  host-eager/remote-lazy pair reports as advisory, not EAGER_MISMATCH
- doctor --pairwise across both mini manifests: 0 errors, exit 0
- standalone flip: react-native webpack-bundle ... --standalone on mini
  -> manifest shows all shared eager=true, git status shows no tracked
  file changed (runtime-only via env.argv, nothing committed)
- manifests agree on every exact pin (doctor reports zero version drift)
…figs

Two defects surfaced by the 11.2 e2e smoke on real tester builds:

1. react-native federation-manifest/federation-init crashed under
   @react-native-community/cli >= 17 (commander wiring): positionals not
   declared in the command name never reach func — argv[0] carries the
   parsed options object, so path.resolve threw ERR_INVALID_ARG_TYPE even
   with --source given. Fix: declare optional positionals in the command
   names ('federation-manifest [source]', 'federation-init
   [feature-folder]') and trust only string argv[0], falling back to
   parsed args otherwise. Reproduced live pre-fix, verified post-fix from
   apps/tester-federation and a scratch init dogfood workspace.

2. federation-doctor --dry-run could not read the shared setup of remotes
   scaffolded by federation-init, which generates rspack.<remote>.mts —
   not a conventional rspack.config.* name. extractAppShared now falls
   back to a tooling-style rspack.<name>.<ext> config when it is the only
   rspack-prefixed candidate (webpack second, ambiguity never guessed).

Strict TDD: RED 1 failing discovery test + 3 new command/registry tests
failing for the wiring reason; GREEN 85/85 focused, full 55 suites/607
tests, typecheck, lint:ci. Live post-fix: zero-flag doctor exit 0 with
EAGER_ADVISORY only; --dry-run on an init-scaffolded scratch workspace
exit 0 (host via conventional name, remote via the new fallback); real
CLI 20 federation-init applied 5 files, idempotent re-run 'Nothing to do'
…ections

- website: new docs/features/federation-workspace.md tying defineShared,
  repack-federation.json, federation-doctor (zero-flag/--dry-run/
  --pairwise/EAGER_ADVISORY) and federation-init into one workflow guide,
  wired into the features _meta.
- agent_context/federation-tools/design.md: PR 4 command-surface
  correction — the init codemod ships as the flat `react-native
  federation-init` command (no repack bin, no subcommand tree, no
  shared.config.ts convention) — plus as-built notes for defineShared,
  the workspace-map schema, doctor extensions, init behavior, and the
  RN CLI >= 17 positional-declaration contract.

API doc completeness verified (not re-edited): define-shared.md,
repack-federation-json.mdx, federation-init.mdx and federation-doctor.mdx
already cover --dry-run/--pairwise/advisory semantics and --standalone
(bundle/start pages).
gedu added 18 commits September 22, 2026 12:59
The wizard's clack prompts rendered with no hint text, so nothing
explained how to move, choose or exit. Every step now embeds its key
legend in the visible message block (clack 0.9.1 has no prompt-level
hint that renders eagerly), the dev banner closes with a global
one-line legend (dim in color mode, plain bytes elsewhere), and the
readline fallback carries equivalent inline legends. Legends are
cosmetic: answers, exit codes and cancel semantics are untouched.
@gedu
gedu requested a review from dannyhw September 22, 2026 16:03
@vercel

vercel Bot commented Sep 22, 2026

Copy link
Copy Markdown

@gedu is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Sep 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1e39af

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@callstack/repack Minor
@callstack/repack-plugin-expo-modules Minor
@callstack/repack-plugin-nativewind Minor
@callstack/repack-plugin-reanimated Minor
@callstack/repack-dev-server Minor
@callstack/repack-init Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gedu gedu self-assigned this Sep 22, 2026
The __fixtures__/rnbin tree sat under node_modules/ paths, so it was
gitignored and invisible on CI: the fixture root missed with ENOENT and
require.resolve walked up to the repo's real react-native, breaking 4
tests. Stand-in installs are now built under os.tmpdir() in beforeAll
(symlink leg skips where symlinks are unavailable), and the dead local
tree plus its config-split-roots pointer are gone.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant