From e200256fdcc41a613504346fd413f0dc4dcbce49 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 22:18:17 +0300 Subject: [PATCH] Add Phase 1A-2 MF 2.0 + dual build targets implementation plan 10 tasks led by a timeboxed Modern.js + MF 2.0 spike that pins versions and validates the dual-build approach before committing to it. Covers module-federation.config.ts with 4 feature exposes, React 18 singleton, BUILD_TARGET branching in modern.config.ts, and a typed loadRemoteModule wrapper around @module-federation/enhanced runtime for consuming other customer remotes in Phase 2+. --- .../plans/2026-04-14-phase-1a2-mf-builds.md | 1091 +++++++++++++++++ 1 file changed, 1091 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-phase-1a2-mf-builds.md diff --git a/docs/superpowers/plans/2026-04-14-phase-1a2-mf-builds.md b/docs/superpowers/plans/2026-04-14-phase-1a2-mf-builds.md new file mode 100644 index 00000000..75d74371 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1a2-mf-builds.md @@ -0,0 +1,1091 @@ +# Phase 1A-2 — Module Federation 2.0 + Dual Build Targets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land Modern.js + Module Federation 2.0 on top of the 1A-1 skeleton so that `pnpm build:standalone` produces a Node SSR artifact and `pnpm build:remote` produces a CDN-static MF 2.0 remote with `mf-manifest.json`, and ship a typed `loadRemoteModule` helper that Phase 2+ feature code uses to consume other customer remotes. + +**Architecture:** One Modern.js project with a **single source tree** and **two build outputs**. `modern.config.ts` reads `BUILD_TARGET` and branches: `standalone` produces Node SSR server + client bundle in `dist/standalone/`, `remote` produces static chunks + `mf-manifest.json` in `dist/remote/` with the SSR plugin disabled. The MF plugin (`@module-federation/modern-js-v3`) is loaded in both targets so the four feature exposes are part of both artifacts' output, but only the `remote` target is deployed to CDN. Runtime consumption of *other* customer remotes happens through `src/mf/remote-loader.ts`, a thin typed wrapper around `@module-federation/enhanced/runtime`. + +**Tech Stack:** Modern.js 2.x (`@modern-js/app-tools`, `@modern-js/plugin-ssr`), Rspack (via Modern.js), `@module-federation/modern-js-v3`, `@module-federation/enhanced`, React 18 (installed here — first time in the project). + +**Scope boundaries (what 1A-2 does NOT do):** +- No routes beyond a minimal stub `src/routes/page.tsx` for the standalone build to have *something* to render — **1F-layout** replaces this with the real root layout + error routes + smoke route. +- No ESLint boundary rules — that's **1A-3**. +- No feature logic; the four expose wrappers are empty placeholders until Phase 2. +- No real CI build matrix — **1B** wires that. +- No Docker / deploy — **1I**. + +**Prerequisites (1A-1 must be complete):** +- `package.json` with `zod`, TypeScript, Vitest 3, ESLint 9 flat config all installed. +- `tsconfig.json` strict with `@/*` and `@phase0/*` aliases, `exactOptionalPropertyTypes: true`. +- `eslint.config.js` flat config baseline. +- `vitest.config.ts` with `@/` alias. +- `src/env/index.ts` + `src/host-contract.ts` + type-only `src/observability/{logger,analytics}/types.ts`. +- Five empty frozen barrels under `src/features/*` and `src/ui/`. +- Branch `plan/react-rewrite` checked out. + +**Deliverables:** + +1. `docs/superpowers/phase-1/modernjs-mf-spike.md` — spike report with pinned version matrix and known gotchas (Task 1). +2. Installed deps at the pinned versions: `@modern-js/app-tools`, `@modern-js/plugin-ssr`, `@module-federation/modern-js-v3`, `@module-federation/enhanced`, `react@^18.2.0`, `react-dom@^18.2.0`, `@types/react`, `@types/react-dom`. +3. `modern.config.ts` with `BUILD_TARGET` branching. +4. `module-federation.config.ts` declaring 4 feature exposes + shared dep policy. +5. `src/routes/page.tsx` — minimal stub root route for the standalone build (replaced by 1F-layout). +6. `src/mf/host-entry.ts` — remote build entry that bootstraps React into a host-provided mount. +7. `src/mf/expose/{OnlineBoard,Schedule,FlightsMap,PopularRequests}.tsx` — four thin stub wrappers consuming `HostContract` from `@/host-contract`. +8. `src/mf/remote-loader.ts` — `loadRemoteModule` / `registerRemote` wrapper with its test. +9. `package.json` scripts wired to actual Modern.js commands (`dev`, `build:standalone`, `build:remote`, `build:both`). +10. `dist/standalone/` containing Node server + client bundle. +11. `dist/remote/mf-manifest.json` + JS chunks on CDN-static layout. + +**Exit gate:** + +- Spike doc committed with a pinned version matrix. +- `pnpm build:both` exits 0 and produces both `dist/standalone/` and `dist/remote/mf-manifest.json`. +- `mf-manifest.json` validates as JSON and contains all four expose entries with the names `./OnlineBoard`, `./Schedule`, `./FlightsMap`, `./PopularRequests`. +- `pnpm typecheck && pnpm lint && pnpm test` all exit 0. +- `remote-loader` integration test loads a fake test remote and returns the exposed module. +- `ClientApp/`, `Startup.cs`, `Program.cs`, `Aeroflot.Flights.Web.csproj`, `Dockerfile`, `wwwroot/` untouched. + +**Spike-dependent values.** The exact versions of `@modern-js/app-tools` and `@module-federation/modern-js-v3` are determined by Task 1. Subsequent tasks reference these as **`$MODERNJS_VERSION`** and **`$MF_MODERNJS_VERSION`** — substitute the pinned values from the spike doc before running `pnpm add`. If the spike reveals that any of the config shapes below are wrong (e.g., plugin name drifts, option renames), update those shapes in the spike doc and apply the same drift to the tasks that follow. + +--- + +## File structure reference + +| File | Responsibility | Task | +|---|---|---| +| `docs/superpowers/phase-1/modernjs-mf-spike.md` | Spike report + version matrix + gotchas | 1 | +| `package.json` | Modify: add Modern.js + MF + React deps, wire build scripts | 2, 7 | +| `src/routes/page.tsx` | Minimal stub root route for standalone SSR | 3 | +| `src/mf/host-entry.ts` | Remote build entry point | 4 | +| `src/mf/expose/OnlineBoard.tsx` | Expose wrapper (stub) | 4 | +| `src/mf/expose/Schedule.tsx` | Expose wrapper (stub) | 4 | +| `src/mf/expose/FlightsMap.tsx` | Expose wrapper (stub) | 4 | +| `src/mf/expose/PopularRequests.tsx` | Expose wrapper (stub) | 4 | +| `module-federation.config.ts` | MF plugin config (exposes, shared, name) | 5 | +| `modern.config.ts` | Modern.js build config with BUILD_TARGET branching | 6 | +| `src/mf/remote-loader.ts` | Typed wrapper around @module-federation/enhanced/runtime | 8 | +| `src/mf/remote-loader.test.ts` | Unit + integration tests for the wrapper | 8 | + +**Decomposition rationale.** `modern.config.ts` owns framework-level concerns (SSR on/off, output dir, plugins). `module-federation.config.ts` owns MF-specific config (exposes, shared, remotes). Splitting them is not a preference — it's how the `@module-federation/modern-js-v3` plugin is designed. The expose wrappers are deliberately empty: they exist solely so the MF plugin has something to point at, and every feature's Phase 2 sub-plan fills its wrapper when the feature ports. + +--- + +## Task 1 — Modern.js + MF 2.0 spike (GATE) + +**Files:** +- Create: `docs/superpowers/phase-1/modernjs-mf-spike.md` + +**Purpose.** This is a **timeboxed 2–4 hour exploration**. Its job is to answer two questions before the rest of 1A-2 commits to anything: + +1. **Can a Modern.js 2.x project with `@module-federation/modern-js-v3` produce (a) a Node SSR artifact when `BUILD_TARGET=standalone` and (b) a CDN-static `mf-manifest.json` + chunks artifact when `BUILD_TARGET=remote`, from the same source tree, using a single `modern.config.ts` that branches on the env var?** +2. **What are the pinned versions of `@modern-js/app-tools`, `@modern-js/plugin-ssr`, `@module-federation/modern-js-v3`, `@module-federation/enhanced`, and Rspack that work together today?** + +If the answer to (1) is "no, we need a split tooling approach" (e.g., Modern.js for standalone + pure Rsbuild + MF plugin for remote), **stop 1A-2 and escalate to the user** before continuing — this changes the whole sub-plan. + +**The spike is exploratory work in a scratch directory.** Do NOT commit spike code into `src/` — the scratch project is throwaway. Only the final report in `docs/superpowers/phase-1/modernjs-mf-spike.md` gets committed. + +- [ ] **Step 1: Create a scratch directory outside the repo** + +```bash +SCRATCH=$(mktemp -d -t modernjs-mf-spike-XXXX) +cd "$SCRATCH" +echo "Scratch dir: $SCRATCH" +``` + +- [ ] **Step 2: Bootstrap a minimal Modern.js project** + +```bash +pnpm create @modern-js/app@latest modernjs-mf-spike --no-git +cd modernjs-mf-spike +``` + +Select the following when prompted (if prompted): +- Template: `app-tools` / "standard application" +- Language: TypeScript +- Framework: React +- SSR: yes +- Package manager: pnpm + +Verify the generated `package.json` has `@modern-js/app-tools` as a dep — note the exact version. + +- [ ] **Step 3: Install the MF plugin and runtime** + +```bash +pnpm add @module-federation/modern-js-v3@latest @module-federation/enhanced@latest +``` + +Record the resolved versions from `pnpm-lock.yaml`. + +- [ ] **Step 4: Configure the plugin in modern.config.ts** + +Edit `modern.config.ts` to register the plugin: + +```typescript +import { appTools, defineConfig } from "@modern-js/app-tools"; +import { moduleFederationPlugin } from "@module-federation/modern-js-v3"; + +export default defineConfig({ + plugins: [appTools(), moduleFederationPlugin()], + server: { + ssr: { mode: "stream" }, + }, +}); +``` + +- [ ] **Step 5: Create a `module-federation.config.ts` exposing a hello-world component** + +Create `src/components/Hello.tsx`: + +```tsx +export default function Hello() { + return
Hello from MF remote
; +} +``` + +Create `module-federation.config.ts`: + +```typescript +import { createModuleFederationConfig } from "@module-federation/modern-js-v3"; + +export default createModuleFederationConfig({ + name: "spike", + filename: "remoteEntry.js", + exposes: { + "./Hello": "./src/components/Hello.tsx", + }, + shared: { + react: { singleton: true, requiredVersion: "^18.2.0" }, + "react-dom": { singleton: true, requiredVersion: "^18.2.0" }, + }, +}); +``` + +- [ ] **Step 6: Build and inspect output** + +```bash +pnpm run build +ls -la dist/ +find dist -name "mf-manifest.json" -print +find dist -name "remoteEntry.js" -print +``` + +Expected: `mf-manifest.json` exists and contains a `remotes.exposes` entry with `./Hello`. Record the exact path under `dist/` where it lands. + +- [ ] **Step 7: Try setting `BUILD_TARGET=remote` to produce a static-only build** + +Attempt a branching config. Edit `modern.config.ts` to disable SSR when `BUILD_TARGET=remote`: + +```typescript +import { appTools, defineConfig } from "@modern-js/app-tools"; +import { moduleFederationPlugin } from "@module-federation/modern-js-v3"; + +const isRemote = process.env.BUILD_TARGET === "remote"; + +export default defineConfig({ + plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()], + server: isRemote ? {} : { ssr: { mode: "stream" } }, + output: { + distPath: { root: isRemote ? "dist/remote" : "dist/standalone" }, + }, + ...(isRemote ? { runtime: {} } : {}), +}); +``` + +Build both ways: + +```bash +pnpm run build # standalone +BUILD_TARGET=remote pnpm run build # remote +ls -la dist/ +``` + +Expected: `dist/standalone/` and `dist/remote/` both exist; `dist/remote/` contains `mf-manifest.json` + static chunks; `dist/standalone/` contains Node server + client bundle. + +**If this step fails**, document WHY in the spike report and consider whether to: +(a) run the same build twice with different post-build copy scripts (instead of config branching), or +(b) split into two projects (Modern.js for standalone, raw Rsbuild + MF plugin for remote). + +- [ ] **Step 8: Verify the emitted `mf-manifest.json`** + +```bash +cat dist/remote/mf-manifest.json | head -40 +``` + +Record the exact JSON shape in the spike report — especially the `metaData`, `exposes`, and `shared` sections. Subsequent tasks reference this shape. + +- [ ] **Step 9: Write the spike report** + +Create `docs/superpowers/phase-1/modernjs-mf-spike.md` in the **main repo** (not the scratch directory). Content template: + +```markdown +# Modern.js + MF 2.0 spike report + +**Date:** +**Goal:** Validate that Modern.js 2.x + `@module-federation/modern-js-v3` can produce both standalone SSR and CDN-static remote artifacts from a single source tree, and pin versions for 1A-2. + +## Pinned version matrix + +| Package | Version | Source | +|---|---|---| +| `@modern-js/app-tools` | `2.x.y` | pnpm-lock.yaml of scratch project | +| `@modern-js/plugin-ssr` | `2.x.y` | resolved | +| `@module-federation/modern-js-v3` | `0.x.y` | resolved | +| `@module-federation/enhanced` | `0.x.y` | resolved | +| `react` | `^18.2.0` | design spec §2.3 | +| `react-dom` | `^18.2.0` | design spec §2.3 | +| Rspack (via Modern.js) | `1.x.y` | resolved transitively | + +## Dual-build feasibility + +- [ ] Single `modern.config.ts` with `BUILD_TARGET` branching produces both targets — **YES / NO** +- [ ] `dist/standalone/` contains Node SSR server — **YES / NO** +- [ ] `dist/remote/mf-manifest.json` contains all exposed modules — **YES / NO** + +If any NO: describe the blocker and the alternative path. + +## Emitted mf-manifest.json shape + +```json + +``` + +## Gotchas discovered + +1. +2. +3. ... + +## Known incompatibilities + +- + +## Decision + +**GO / STOP.** If STOP: reason + escalation note. +``` + +Fill in every placeholder. Leave no `<...>` blanks. + +- [ ] **Step 10: Commit the spike report** + +```bash +cd /path/to/Aeroflot.Flights.Web # back to main repo +git add docs/superpowers/phase-1/modernjs-mf-spike.md +git commit -m "Land Modern.js + MF 2.0 spike report with pinned versions" +``` + +- [ ] **Step 11: Clean up the scratch directory** + +```bash +rm -rf "$SCRATCH" +``` + +**Exit criteria for Task 1.** Spike report committed with **GO** decision. If STOP: escalate. + +--- + +## Task 2 — Install Modern.js + MF + React at pinned versions + +**Files:** +- Modify: `package.json`, `pnpm-lock.yaml` + +Substitute `$MODERNJS_VERSION` and `$MF_MODERNJS_VERSION` with the values from Task 1's spike report. + +- [ ] **Step 1: Install Modern.js core + SSR plugin** + +```bash +pnpm add @modern-js/app-tools@$MODERNJS_VERSION @modern-js/plugin-ssr@$MODERNJS_VERSION +``` + +- [ ] **Step 2: Install the MF 2.0 plugin + runtime** + +```bash +pnpm add @module-federation/modern-js-v3@$MF_MODERNJS_VERSION @module-federation/enhanced@$MF_MODERNJS_VERSION +``` + +- [ ] **Step 3: Install React 18 + type definitions** + +```bash +pnpm add react@^18.2.0 react-dom@^18.2.0 +pnpm add -D @types/react@^18.2.0 @types/react-dom@^18.2.0 +``` + +- [ ] **Step 4: Verify tsconfig.json's `jsx: "react-jsx"` setting is still intact** + +(Set in 1A-1.) Run: + +```bash +grep '"jsx"' tsconfig.json +``` + +Expected: `"jsx": "react-jsx",`. If missing, the 1A-1 baseline drifted — restore it before continuing. + +- [ ] **Step 5: Confirm typecheck still passes with no files using React yet** + +```bash +pnpm typecheck +``` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "Install Modern.js, MF 2.0 plugin, and React 18 at pinned versions" +``` + +--- + +## Task 3 — Create minimal stub route for the standalone build + +**Files:** +- Create: `src/routes/page.tsx` + +The standalone Modern.js build needs at least one route to compile. 1F-layout replaces this with the real root layout; this task only creates a "hello world" placeholder. + +- [ ] **Step 1: Write `src/routes/page.tsx`** + +```tsx +// Placeholder route for 1A-2. Replaced by 1F-layout with the real root layout +// (src/routes/layout.tsx + src/routes/[lang]/layout.tsx + error routes + smoke route). +export default function Home() { + return ( +
+

Aeroflot Flights — React skeleton

+

This page is a 1A-2 placeholder. Real routes land in 1F-layout.

+
+ ); +} +``` + +- [ ] **Step 2: Typecheck and lint** + +```bash +pnpm typecheck && pnpm lint +``` +Expected: both exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/page.tsx +git commit -m "Add stub root route for standalone build (replaced by 1F-layout)" +``` + +--- + +## Task 4 — Create MF expose stubs and host entry + +**Files:** +- Create: `src/mf/host-entry.ts` +- Create: `src/mf/expose/OnlineBoard.tsx` +- Create: `src/mf/expose/Schedule.tsx` +- Create: `src/mf/expose/FlightsMap.tsx` +- Create: `src/mf/expose/PopularRequests.tsx` + +Each expose is a **thin wrapper** that accepts a `HostContract` prop, forwards it into the feature barrel (empty in 1A-1), and returns a placeholder UI. Phase 2+ feature sub-plans replace each stub's body with the real feature root once the feature's barrel is populated. + +- [ ] **Step 1: Write `src/mf/expose/OnlineBoard.tsx`** + +```tsx +import type { HostContract } from "@/host-contract"; + +/** + * MF expose wrapper for the Online Board feature. + * Phase 2 (online-board port) replaces the body with: + * `return ;` + * where `OnlineBoardRoot` comes from `@/features/online-board`. + */ +export interface OnlineBoardRemoteProps { + hostContract: HostContract; +} + +export default function OnlineBoardRemote(props: OnlineBoardRemoteProps): JSX.Element { + void props; + return ( +
+

Online Board remote — stub. Populated in Phase 2.

+
+ ); +} +``` + +- [ ] **Step 2: Write `src/mf/expose/Schedule.tsx`** + +```tsx +import type { HostContract } from "@/host-contract"; + +/** + * MF expose wrapper for the Schedule feature. + * Phase 3 (schedule port) replaces the body with the real root. + */ +export interface ScheduleRemoteProps { + hostContract: HostContract; +} + +export default function ScheduleRemote(props: ScheduleRemoteProps): JSX.Element { + void props; + return ( +
+

Schedule remote — stub. Populated in Phase 3.

+
+ ); +} +``` + +- [ ] **Step 3: Write `src/mf/expose/FlightsMap.tsx`** + +```tsx +import type { HostContract } from "@/host-contract"; + +/** + * MF expose wrapper for the Flights Map feature. + * Phase 4 (flights-map port) replaces the body with the real root. + */ +export interface FlightsMapRemoteProps { + hostContract: HostContract; +} + +export default function FlightsMapRemote(props: FlightsMapRemoteProps): JSX.Element { + void props; + return ( +
+

Flights Map remote — stub. Populated in Phase 4.

+
+ ); +} +``` + +- [ ] **Step 4: Write `src/mf/expose/PopularRequests.tsx`** + +```tsx +import type { HostContract } from "@/host-contract"; + +/** + * MF expose wrapper for the Popular Requests feature. + * Phase 5 (popular-requests port) replaces the body with the real root. + */ +export interface PopularRequestsRemoteProps { + hostContract: HostContract; +} + +export default function PopularRequestsRemote(props: PopularRequestsRemoteProps): JSX.Element { + void props; + return ( +
+

Popular Requests remote — stub. Populated in Phase 5.

+
+ ); +} +``` + +- [ ] **Step 5: Write `src/mf/host-entry.ts`** + +This file is the entry point imported by Modern.js when `BUILD_TARGET=remote`. It does NOT render anything itself; the host application imports individual expose modules. `host-entry.ts` exists only to give the remote bundle a module root from which shared-dep metadata flows. + +```typescript +// Remote-mode bundle entry. Does not render — hosts import individual +// /expose/ modules directly via the MF manifest. This file exists +// to give the remote bundle a single module root and a clear place for +// one-time runtime bootstrap hooks (e.g., telemetry init) that must run +// before any expose is loaded. Phase 2+ adds those hooks. + +export const REMOTE_BUILD_MARKER = "aeroflot_flights_remote_v1" as const; +``` + +- [ ] **Step 6: Typecheck and lint** + +```bash +pnpm typecheck && pnpm lint +``` +Expected: both exit 0. If lint warns on `void props;`, the rule is `@typescript-eslint/no-unused-vars` — we've already turned that off in favor of `unused-imports/no-unused-vars` which respects the `_` prefix. Rename the param to `_props` and drop the `void` statement if needed: + +```tsx +export default function OnlineBoardRemote(_props: OnlineBoardRemoteProps): JSX.Element { +``` + +Choose one style and apply consistently across all four expose files. + +- [ ] **Step 7: Commit** + +```bash +git add src/mf/ +git commit -m "Seed MF expose stubs and host-entry for 4 features" +``` + +--- + +## Task 5 — Write `module-federation.config.ts` + +**Files:** +- Create: `module-federation.config.ts` (repo root) + +- [ ] **Step 1: Write `module-federation.config.ts`** + +```typescript +import { createModuleFederationConfig } from "@module-federation/modern-js-v3"; + +/** + * MF 2.0 remote config — exposes 4 feature wrappers to host channel apps. + * Matches design spec §2.2 (exposes list) and §2.3 (shared dependencies). + * + * SPEC ASSUMPTION (requirement 9, A1): the `name` and expose paths match + * the customer's standard template. If A1 resolves with different naming, + * rename here — the barrel-locked src tree absorbs the change (see + * docs/superpowers/phase-1/rename-pass-plan.md). + */ +export default createModuleFederationConfig({ + name: "aeroflot_flights", + filename: "remoteEntry.js", + exposes: { + "./OnlineBoard": "./src/mf/expose/OnlineBoard.tsx", + "./Schedule": "./src/mf/expose/Schedule.tsx", + "./FlightsMap": "./src/mf/expose/FlightsMap.tsx", + "./PopularRequests": "./src/mf/expose/PopularRequests.tsx", + }, + shared: { + react: { singleton: true, requiredVersion: "^18.2.0" }, + "react-dom": { singleton: true, requiredVersion: "^18.2.0" }, + // i18next + react-i18next added in 1C when the i18n runtime lands. + // @module-federation/runtime added by the MF plugin automatically. + }, +}); +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck +``` +Expected: exit 0. + +If TypeScript cannot find `createModuleFederationConfig`, confirm the package name matches Task 2's install — the spike report pins the exact name, which may be `@module-federation/modern-js` or `@module-federation/modern-js-v3` depending on the version. + +- [ ] **Step 3: Commit** + +```bash +git add module-federation.config.ts +git commit -m "Declare MF 2.0 config with 4 feature exposes and React singleton" +``` + +--- + +## Task 6 — Write `modern.config.ts` with `BUILD_TARGET` branching + +**Files:** +- Create: `modern.config.ts` (repo root) + +- [ ] **Step 1: Write `modern.config.ts`** + +```typescript +import { appTools, defineConfig } from "@modern-js/app-tools"; +import { moduleFederationPlugin } from "@module-federation/modern-js-v3"; + +/** + * Modern.js build config with dual-target branching. + * + * BUILD_TARGET=standalone (default): Node SSR server + client bundle → dist/standalone/ + * BUILD_TARGET=remote : static chunks + mf-manifest.json → dist/remote/ + * (SSR plugin disabled; no Node server) + * + * The MF plugin is loaded in both targets. Its behavior is the same in both — + * what differs is whether Modern.js emits a Node server around it. + * + * Plugin order is load-bearing: `appTools()` must come BEFORE + * `moduleFederationPlugin()`. See modernjs-mf-spike.md gotchas. + */ +const isRemote = process.env["BUILD_TARGET"] === "remote"; + +export default defineConfig({ + plugins: [ + appTools({ bundler: "rspack" }), + moduleFederationPlugin(), + ], + server: isRemote + ? {} // no Node server for remote + : { ssr: { mode: "stream" } }, // stream SSR required by MF + Modern.js + output: { + distPath: { + root: isRemote ? "dist/remote" : "dist/standalone", + }, + }, +}); +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck +``` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add modern.config.ts +git commit -m "Add modern.config.ts with standalone/remote BUILD_TARGET branching" +``` + +--- + +## Task 7 — Wire `package.json` scripts to real Modern.js commands + +**Files:** +- Modify: `package.json` + +Replace the 1A-1 `echo ... exit 1` stubs with real `modern` CLI commands. + +- [ ] **Step 1: Edit `package.json` scripts section** + +Open `package.json` and replace the `scripts` block. Before: + +```json +"scripts": { + "dev": "echo \"dev script wired in 1A-2\" && exit 1", + "build:standalone": "echo \"build:standalone wired in 1A-2\" && exit 1", + "build:remote": "echo \"build:remote wired in 1A-2\" && exit 1", + "build:both": "pnpm build:standalone && pnpm build:remote", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0", + "typecheck": "tsc --noEmit" +} +``` + +After: + +```json +"scripts": { + "dev": "modern dev", + "build:standalone": "BUILD_TARGET=standalone modern build", + "build:remote": "BUILD_TARGET=remote modern build", + "build:both": "pnpm build:standalone && pnpm build:remote", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0", + "typecheck": "tsc --noEmit" +} +``` + +**Windows-only caveat.** `BUILD_TARGET=standalone modern build` uses POSIX env-var-inline syntax. If any developer targets Windows without WSL, install `cross-env` as a dev dep and wrap: `"build:standalone": "cross-env BUILD_TARGET=standalone modern build"`. For now, POSIX is assumed (matches `.nvmrc` Node 24 on macOS/Linux). + +- [ ] **Step 2: Smoke-test that `modern` CLI is reachable** + +```bash +pnpm exec modern --version +``` +Expected: prints a Modern.js version. If the CLI is not found, the `@modern-js/app-tools` install from Task 2 is incomplete. + +- [ ] **Step 3: Smoke-test that `pnpm build:standalone` runs** + +```bash +pnpm build:standalone +``` +Expected: exits 0 (or at worst, a non-MF-related error from Modern.js about missing files). Record any error in DONE_WITH_CONCERNS. + +If the build complains about missing `src/routes/layout.tsx`, the stub page from Task 3 should be enough — Modern.js is file-routing from `src/routes/`. If Modern.js requires a `layout.tsx` to exist for file routing, add a trivial one: + +```tsx +// src/routes/layout.tsx (1A-2 stub — replaced by 1F-layout) +import type { ReactNode } from "react"; +export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { + return <>{children}; +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add package.json src/routes/layout.tsx 2>/dev/null || git add package.json +git commit -m "Wire build:standalone/build:remote/dev to Modern.js CLI" +``` + +--- + +## Task 8 — TDD `src/mf/remote-loader.ts` + +**Files:** +- Create: `src/mf/remote-loader.ts` +- Create: `src/mf/remote-loader.test.ts` + +**Contract to implement (from master plan §1A-2):** + +```ts +export interface RemoteModuleRef { + name: string; // remote name (e.g. "customer-ui") + module: string; // exposed module path (e.g. "./Header") +} + +export function loadRemoteModule(ref: RemoteModuleRef): Promise; +export function registerRemote(entry: { name: string; entry: string }): void; +``` + +The wrapper delegates to `@module-federation/enhanced/runtime` (`init`, `loadRemote`, `registerRemotes`) and adds type safety + a single shared `init` call. + +- [ ] **Step 1: Write the failing test** + +Create `src/mf/remote-loader.test.ts`: + +```typescript +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the runtime package so tests don't need a real remote. +vi.mock("@module-federation/enhanced/runtime", () => { + return { + init: vi.fn(), + loadRemote: vi.fn(), + registerRemotes: vi.fn(), + }; +}); + +import { init, loadRemote, registerRemotes } from "@module-federation/enhanced/runtime"; + +describe("remote-loader", () => { + beforeEach(async () => { + const mod = await import("./remote-loader.js"); + mod.__resetRemoteLoaderForTests(); + vi.mocked(init).mockClear(); + vi.mocked(loadRemote).mockClear(); + vi.mocked(registerRemotes).mockClear(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it("registerRemote calls init once for the first remote, then registerRemotes for subsequent", async () => { + const { registerRemote } = await import("./remote-loader.js"); + registerRemote({ name: "customer-ui", entry: "https://host.example/mf-manifest.json" }); + expect(init).toHaveBeenCalledTimes(1); + expect(init).toHaveBeenCalledWith({ + name: "aeroflot_flights_host", + remotes: [{ name: "customer-ui", entry: "https://host.example/mf-manifest.json" }], + }); + + registerRemote({ name: "customer-analytics", entry: "https://host.example/analytics/mf-manifest.json" }); + expect(init).toHaveBeenCalledTimes(1); // still only once + expect(registerRemotes).toHaveBeenCalledTimes(1); + expect(registerRemotes).toHaveBeenCalledWith([ + { name: "customer-analytics", entry: "https://host.example/analytics/mf-manifest.json" }, + ]); + }); + + it("loadRemoteModule delegates to loadRemote with the concatenated key", async () => { + const fakeModule = { default: "hello" }; + vi.mocked(loadRemote).mockResolvedValueOnce(fakeModule); + + const { loadRemoteModule } = await import("./remote-loader.js"); + const result = await loadRemoteModule<{ default: string }>({ + name: "customer-ui", + module: "./Header", + }); + + expect(loadRemote).toHaveBeenCalledWith("customer-ui/Header"); + expect(result).toBe(fakeModule); + }); + + it("loadRemoteModule rejects with a typed error if the module returns null/undefined", async () => { + vi.mocked(loadRemote).mockResolvedValueOnce(null); + const { loadRemoteModule } = await import("./remote-loader.js"); + await expect( + loadRemoteModule({ name: "customer-ui", module: "./NonExistent" }), + ).rejects.toThrow(/customer-ui\/NonExistent/); + }); + + it("loadRemoteModule propagates errors from loadRemote", async () => { + vi.mocked(loadRemote).mockRejectedValueOnce(new Error("boom")); + const { loadRemoteModule } = await import("./remote-loader.js"); + await expect( + loadRemoteModule({ name: "customer-ui", module: "./Broken" }), + ).rejects.toThrow("boom"); + }); +}); +``` + +- [ ] **Step 2: Run the test — MUST FAIL** + +```bash +pnpm test src/mf/remote-loader +``` +Expected: FAIL with "Cannot find module" or similar — `remote-loader.ts` doesn't exist yet. + +- [ ] **Step 3: Write `src/mf/remote-loader.ts`** + +```typescript +import { + init, + loadRemote, + registerRemotes, +} from "@module-federation/enhanced/runtime"; + +export interface RemoteModuleRef { + name: string; + module: string; + /** Phantom marker to preserve the generic parameter through the type system. */ + readonly __type?: T; +} + +export interface RemoteEntry { + name: string; + entry: string; +} + +let initialized = false; +const HOST_NAME = "aeroflot_flights_host" as const; + +/** + * Registers a remote with the MF runtime. On the first call, this also calls + * `init()` to bootstrap the MF runtime with the host identity. Subsequent + * calls use `registerRemotes()` to add remotes without re-initializing. + */ +export function registerRemote(entry: RemoteEntry): void { + if (!initialized) { + init({ + name: HOST_NAME, + remotes: [entry], + }); + initialized = true; + return; + } + registerRemotes([entry]); +} + +/** + * Loads an exposed module from a previously registered remote. The `name` + + * `module` fields are concatenated into MF's `remote/module` lookup key. + * + * Throws if the runtime returns a null/undefined module, which usually means + * the remote manifest does not actually expose the requested path. + */ +export async function loadRemoteModule(ref: RemoteModuleRef): Promise { + const key = `${ref.name}/${ref.module.replace(/^\.\//, "")}`; + const result = await loadRemote(key); + if (result === null || result === undefined) { + throw new Error(`Remote module ${key} loaded as null/undefined`); + } + return result; +} + +/** Test-only: resets the one-shot init flag. */ +export function __resetRemoteLoaderForTests(): void { + initialized = false; +} +``` + +- [ ] **Step 4: Run the tests — ALL MUST PASS** + +```bash +pnpm test src/mf/remote-loader +``` +Expected: all 4 tests pass. + +If a test fails because the mocked `loadRemote` signature doesn't match, check that the real runtime API uses the same function names. If the real package exports differ (e.g., `loadRemote` is a method on an instance, not a module export), adjust the production code minimally and re-run. + +- [ ] **Step 5: Typecheck and lint** + +```bash +pnpm typecheck && pnpm lint +``` +Expected: both exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/mf/remote-loader.ts src/mf/remote-loader.test.ts +git commit -m "Add typed loadRemoteModule wrapper around MF runtime" +``` + +--- + +## Task 9 — Build both targets and verify artifacts + +**Files:** +- None (verification only — builds produce `dist/` which is gitignored) + +- [ ] **Step 1: Clean any previous dist** + +```bash +rm -rf dist +``` + +- [ ] **Step 2: Build standalone** + +```bash +pnpm build:standalone +``` +Expected: exit 0. `dist/standalone/` exists. + +- [ ] **Step 3: Inspect standalone output** + +```bash +ls -la dist/standalone/ +find dist/standalone -name "*.js" -maxdepth 3 | head +find dist/standalone -name "mf-manifest.json" +``` + +Expected: some directory under `dist/standalone/` contains `mf-manifest.json` (Modern.js emits it for the exposes even in standalone mode) plus the Node server files. + +- [ ] **Step 4: Build remote** + +```bash +pnpm build:remote +``` +Expected: exit 0. `dist/remote/` exists. + +- [ ] **Step 5: Inspect remote output** + +```bash +ls -la dist/remote/ +find dist/remote -name "mf-manifest.json" -print +find dist/remote -name "remoteEntry.js" -print +``` + +Expected: `dist/remote/.../mf-manifest.json` exists; no Node server files (no `bundles/main.js` or similar SSR entry). + +- [ ] **Step 6: Validate `mf-manifest.json` contents** + +Find the manifest and inspect it: + +```bash +MANIFEST=$(find dist/remote -name "mf-manifest.json" | head -1) +test -n "$MANIFEST" && echo "Found at $MANIFEST" +cat "$MANIFEST" | python3 -m json.tool | head -60 +``` + +Expected: valid JSON. Verify the `exposes` section contains all four entries with names matching `./OnlineBoard`, `./Schedule`, `./FlightsMap`, `./PopularRequests` (exact MF naming may differ — the spike report records the shape). + +- [ ] **Step 7: Assert manifest contains all 4 exposes (programmatic)** + +```bash +MANIFEST=$(find dist/remote -name "mf-manifest.json" | head -1) +node -e " +const m = JSON.parse(require('fs').readFileSync('$MANIFEST', 'utf8')); +const exposeNames = (m.exposes || []).map(e => e.name || e.path || e); +const required = ['OnlineBoard', 'Schedule', 'FlightsMap', 'PopularRequests']; +const missing = required.filter(r => !exposeNames.some(n => String(n).includes(r))); +if (missing.length) { console.error('MISSING exposes:', missing); process.exit(1); } +console.log('All 4 exposes present:', exposeNames); +" +``` + +Expected: prints "All 4 exposes present" and exits 0. If it fails, the `module-federation.config.ts` from Task 5 is misconfigured — fix it and rebuild. + +- [ ] **Step 8: Run `pnpm build:both` end-to-end** + +```bash +rm -rf dist +pnpm build:both +test -d dist/standalone && echo "standalone OK" +test -d dist/remote && echo "remote OK" +find dist -name "mf-manifest.json" -print +``` + +Expected: both `standalone OK` and `remote OK` print. + +- [ ] **Step 9: No commit required** — the verification produces `dist/` which is gitignored. If the commands above all pass, proceed to Task 10. + +--- + +## Task 10 — Full exit-gate verification + +**Files:** +- None (verification only) + +- [ ] **Step 1: Fresh install from lockfile** + +```bash +rm -rf node_modules dist +pnpm install --frozen-lockfile +``` +Expected: exit 0; no lockfile drift. + +- [ ] **Step 2: All quality gates** + +```bash +pnpm typecheck && echo "TYPECHECK OK" && \ +pnpm lint && echo "LINT OK" && \ +pnpm test && echo "TEST OK" +``` +Expected: three "OK" messages, final exit 0. Env tests (7) + remote-loader tests (4) = 11 total passing. + +- [ ] **Step 3: Dual build** + +```bash +pnpm build:both +test -f "$(find dist/remote -name mf-manifest.json | head -1)" && echo "MANIFEST OK" +test -d dist/standalone && echo "STANDALONE OK" +``` + +Expected: both OK messages print. + +- [ ] **Step 4: Verify ASP.NET + ClientApp untouched** + +```bash +git diff --stat $(git merge-base HEAD main)..HEAD -- ClientApp/ Startup.cs Program.cs Aeroflot.Flights.Web.csproj Dockerfile wwwroot/ +``` +Expected: empty output (no files in those paths changed in this branch's commits since diverging from main). + +- [ ] **Step 5: Verify git status is clean** + +```bash +git status +``` +Expected: clean working tree, except pre-existing untracked docs (`docs/flight-map.md`, `docs/tech.md`) which are unrelated. + +- [ ] **Step 6: Final log check** + +```bash +git log --oneline -20 +``` +Expected: roughly 9 new commits on top of 1A-1, one per task above (spike, install, stub route, mf stubs, MF config, modern config, scripts, remote-loader, and optionally a task-7 stub layout commit if it was needed). + +--- + +## Self-review + +**Spec coverage.** Every item in the master plan §1A-2 "Exports" list maps to a task: +- Modern.js + MF 2.0 spike doc → Task 1 +- Dual build targets (`pnpm build:both` producing both artifacts) → Tasks 6, 7, 9 +- `mf-manifest.json` with 4 exposes → Tasks 4, 5, 9 +- `src/mf/remote-loader.ts` with `loadRemoteModule` + `registerRemote` → Task 8 +- `src/mf/host-entry.ts` → Task 4 +- `modern.config.ts` with BUILD_TARGET branching → Task 6 +- `module-federation.config.ts` with exposes + shared → Task 5 +- React 18 singleton dependency → Task 2 (install) + Task 5 (shared config) + +Design-spec §2.1–§2.5 coverage: +- §2.1 (two build targets, one modern.config.ts) → Task 6 +- §2.2 (mf-manifest.json + 4 exposes) → Tasks 4, 5 +- §2.3 (shared dependencies, React singleton) → Task 5 +- §2.4 (HostContract) → already in `@/host-contract` from 1A-1 — expose stubs import from there in Task 4 +- §2.5 (build artifacts + deploy shape) → Task 9 (verification) + +**Placeholder scan.** `$MODERNJS_VERSION` and `$MF_MODERNJS_VERSION` appear in Task 2 — these are NOT placeholders for the engineer to fill in blindly; they're explicit spike-driven substitutions, the only path to avoid committing to wrong versions before Task 1 determines them. Documented at the top of the plan. + +The "fill in every placeholder" instruction in Task 1 Step 9's markdown template is an instruction to the engineer, not a placeholder in the plan itself. + +**Type consistency.** +- `HostContract` imported from `@/host-contract` in all four expose wrappers (Task 4) — matches 1A-1. +- `RemoteModuleRef`, `RemoteEntry`, `loadRemoteModule`, `registerRemote` in Task 8 match the master plan §1A-2 contract exactly, with one addition: the phantom `__type` marker on `RemoteModuleRef` to preserve the generic at runtime (common TypeScript idiom when a function's generic param otherwise has no input binding). +- `BUILD_TARGET` env var read as `process.env["BUILD_TARGET"]` (bracket notation) in Task 6 — required under `noUncheckedIndexedAccess: true` from 1A-1's tsconfig. + +**Known uncertainty.** Three items depend on the spike's actual findings: +1. Plugin package name: `@module-federation/modern-js-v3` vs `@module-federation/modern-js` — Task 5 notes to cross-check. +2. Whether Modern.js's `output.distPath.root` is the exact option name — Task 6 uses the name from the research but the spike validates. +3. Whether `modern build` in remote mode without SSR plugin actually produces the intended shape — Task 1 Step 7 validates before Task 6 commits to it. + +If any of these drift, the engineer updates the failing task, re-runs, and proceeds. The spike is the first task specifically to catch these before they compound. + +--- + +## Execution handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-04-14-phase-1a2-mf-builds.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints. + +**Which approach?**