# 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 <div>Hello from MF remote</div>;
}
```

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:** <YYYY-MM-DD>
**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
<paste exact structure from dist/remote/mf-manifest.json>
```

## Gotchas discovered

1. <gotcha 1 — e.g., "moduleFederationPlugin() must come AFTER appTools() or Rspack panics">
2. <gotcha 2>
3. ...

## Known incompatibilities

- <any package that refuses to install at the pinned version — note what gave way>

## 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 (
    <main>
      <h1>Aeroflot Flights — React skeleton</h1>
      <p>This page is a 1A-2 placeholder. Real routes land in 1F-layout.</p>
    </main>
  );
}
```

- [ ] **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 <OnlineBoardRoot hostContract={hostContract} />;`
 * where `OnlineBoardRoot` comes from `@/features/online-board`.
 */
export interface OnlineBoardRemoteProps {
  hostContract: HostContract;
}

export default function OnlineBoardRemote(props: OnlineBoardRemoteProps): JSX.Element {
  void props;
  return (
    <div data-mf-expose="OnlineBoard">
      <p>Online Board remote — stub. Populated in Phase 2.</p>
    </div>
  );
}
```

- [ ] **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 (
    <div data-mf-expose="Schedule">
      <p>Schedule remote — stub. Populated in Phase 3.</p>
    </div>
  );
}
```

- [ ] **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 (
    <div data-mf-expose="FlightsMap">
      <p>Flights Map remote — stub. Populated in Phase 4.</p>
    </div>
  );
}
```

- [ ] **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 (
    <div data-mf-expose="PopularRequests">
      <p>Popular Requests remote — stub. Populated in Phase 5.</p>
    </div>
  );
}
```

- [ ] **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/<name> 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<T = unknown> {
  name: string;    // remote name (e.g. "customer-ui")
  module: string;  // exposed module path (e.g. "./Header")
}

export function loadRemoteModule<T>(ref: RemoteModuleRef<T>): Promise<T>;
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<T = unknown> {
  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<T>(ref: RemoteModuleRef<T>): Promise<T> {
  const key = `${ref.name}/${ref.module.replace(/^\.\//, "")}`;
  const result = await loadRemote<T>(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<T>`, `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?**
