Files
flights_web/docs/superpowers/plans/2026-04-14-phase-1a2-mf-builds.md
T
gnezim e200256fdc 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+.
2026-04-14 22:18:17 +03:00

39 KiB
Raw Blame History

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.tsloadRemoteModule / 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 24 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
SCRATCH=$(mktemp -d -t modernjs-mf-spike-XXXX)
cd "$SCRATCH"
echo "Scratch dir: $SCRATCH"
  • Step 2: Bootstrap a minimal Modern.js project
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
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:

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:

export default function Hello() {
  return <div>Hello from MF remote</div>;
}

Create module-federation.config.ts:

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
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:

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:

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
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:

# 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
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
pnpm add @modern-js/app-tools@$MODERNJS_VERSION @modern-js/plugin-ssr@$MODERNJS_VERSION
  • Step 2: Install the MF 2.0 plugin + runtime
pnpm add @module-federation/modern-js-v3@$MF_MODERNJS_VERSION @module-federation/enhanced@$MF_MODERNJS_VERSION
  • Step 3: Install React 18 + type definitions
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:

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
pnpm typecheck

Expected: exit 0.

  • Step 6: Commit
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
// 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
pnpm typecheck && pnpm lint

Expected: both exit 0.

  • Step 3: Commit
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
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
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
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
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.

// 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
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:

export default function OnlineBoardRemote(_props: OnlineBoardRemoteProps): JSX.Element {

Choose one style and apply consistently across all four expose files.

  • Step 7: Commit
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

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
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
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

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
pnpm typecheck

Expected: exit 0.

  • Step 3: Commit
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:

"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:

"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
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
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:

// 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
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):

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:

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
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
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
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
pnpm typecheck && pnpm lint

Expected: both exit 0.

  • Step 6: Commit
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

rm -rf dist
  • Step 2: Build standalone
pnpm build:standalone

Expected: exit 0. dist/standalone/ exists.

  • Step 3: Inspect standalone output
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
pnpm build:remote

Expected: exit 0. dist/remote/ exists.

  • Step 5: Inspect remote output
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:

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)
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
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

rm -rf node_modules dist
pnpm install --frozen-lockfile

Expected: exit 0; no lockfile drift.

  • Step 2: All quality gates
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
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
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
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
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?