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+.
39 KiB
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.tsxfor 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.jsonwithzod, TypeScript, Vitest 3, ESLint 9 flat config all installed.tsconfig.jsonstrict with@/*and@phase0/*aliases,exactOptionalPropertyTypes: true.eslint.config.jsflat config baseline.vitest.config.tswith@/alias.src/env/index.ts+src/host-contract.ts+ type-onlysrc/observability/{logger,analytics}/types.ts.- Five empty frozen barrels under
src/features/*andsrc/ui/. - Branch
plan/react-rewritechecked out.
Deliverables:
docs/superpowers/phase-1/modernjs-mf-spike.md— spike report with pinned version matrix and known gotchas (Task 1).- 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. modern.config.tswithBUILD_TARGETbranching.module-federation.config.tsdeclaring 4 feature exposes + shared dep policy.src/routes/page.tsx— minimal stub root route for the standalone build (replaced by 1F-layout).src/mf/host-entry.ts— remote build entry that bootstraps React into a host-provided mount.src/mf/expose/{OnlineBoard,Schedule,FlightsMap,PopularRequests}.tsx— four thin stub wrappers consumingHostContractfrom@/host-contract.src/mf/remote-loader.ts—loadRemoteModule/registerRemotewrapper with its test.package.jsonscripts wired to actual Modern.js commands (dev,build:standalone,build:remote,build:both).dist/standalone/containing Node server + client bundle.dist/remote/mf-manifest.json+ JS chunks on CDN-static layout.
Exit gate:
- Spike doc committed with a pinned version matrix.
pnpm build:bothexits 0 and produces bothdist/standalone/anddist/remote/mf-manifest.json.mf-manifest.jsonvalidates as JSON and contains all four expose entries with the names./OnlineBoard,./Schedule,./FlightsMap,./PopularRequests.pnpm typecheck && pnpm lint && pnpm testall exit 0.remote-loaderintegration 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:
- Can a Modern.js 2.x project with
@module-federation/modern-js-v3produce (a) a Node SSR artifact whenBUILD_TARGET=standaloneand (b) a CDN-staticmf-manifest.json+ chunks artifact whenBUILD_TARGET=remote, from the same source tree, using a singlemodern.config.tsthat branches on the env var? - 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.tsexposing 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=remoteto 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
- <gotcha 1 — e.g., "moduleFederationPlugin() must come AFTER appTools() or Rspack panics">
- <gotcha 2>
- ...
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.jsonscripts 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
modernCLI 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:standaloneruns
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.jsoncontents
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:bothend-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:bothproducing both artifacts) → Tasks 6, 7, 9 mf-manifest.jsonwith 4 exposes → Tasks 4, 5, 9src/mf/remote-loader.tswithloadRemoteModule+registerRemote→ Task 8src/mf/host-entry.ts→ Task 4modern.config.tswith BUILD_TARGET branching → Task 6module-federation.config.tswith 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-contractfrom 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.
HostContractimported from@/host-contractin all four expose wrappers (Task 4) — matches 1A-1.RemoteModuleRef<T>,RemoteEntry,loadRemoteModule,registerRemotein Task 8 match the master plan §1A-2 contract exactly, with one addition: the phantom__typemarker onRemoteModuleRefto preserve the generic at runtime (common TypeScript idiom when a function's generic param otherwise has no input binding).BUILD_TARGETenv var read asprocess.env["BUILD_TARGET"](bracket notation) in Task 6 — required undernoUncheckedIndexedAccess: truefrom 1A-1's tsconfig.
Known uncertainty. Three items depend on the spike's actual findings:
- Plugin package name:
@module-federation/modern-js-v3vs@module-federation/modern-js— Task 5 notes to cross-check. - Whether Modern.js's
output.distPath.rootis the exact option name — Task 6 uses the name from the research but the spike validates. - Whether
modern buildin 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?