# Modern.js v2 → v3 Upgrade 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:** Upgrade Modern.js from 2.70.8 to 3.1.4, resolving the React Router v6→v7 future flag warning and staying on a supported framework version.

**Architecture:** The upgrade touches three layers: (1) package dependencies, (2) build/runtime config format changes, (3) server middleware rewrite from express-style to Hono. The app's React code, routing structure, and feature code remain unchanged — `@modern-js/runtime/router` imports and file-based routing are compatible with v3.

**Tech Stack:** Modern.js 3.1.4, Rspack 2.0, Rsbuild 2.0, React Router 7, Module Federation 2.3.3, Hono (server middleware), React 18.2

---

## File Map

| Action | File | Responsibility |
|--------|------|---------------|
| Modify | `package.json` | Bump `@modern-js/*` to 3.1.4, swap MF plugin |
| Modify | `modern.config.ts` | Remove `bundler: "rspack"` param, remove `runtime.router` |
| Modify | `module-federation.config.ts` | Change import to `@module-federation/modern-js-v3` |
| Create | `src/modern.runtime.ts` | New home for `runtime.router` config |
| Rewrite | `src/server/middleware/csp.ts` | Hono MiddlewareHandler signature |
| Rewrite | `src/server/middleware/csp.test.ts` | Update mocks for Hono context |
| Rewrite | `src/server/middleware/security-headers.ts` | Hono MiddlewareHandler signature |
| Rewrite | `src/server/middleware/nonce-stream-transform.ts` | Keep as-is (pure stream util, no framework coupling) |
| Rewrite | `src/server/routes/health.ts` | Hono MiddlewareHandler signature |
| Rewrite | `src/server/routes/health.test.ts` | Update mocks for Hono context |
| Create | `src/server/modern.server.ts` | Wire all middleware via `defineServerConfig` |

---

### Task 1: Update dependencies in package.json

**Files:**
- Modify: `package.json`

- [ ] **Step 1: Update @modern-js packages**

```bash
pnpm add @modern-js/app-tools@3.1.4 @modern-js/runtime@3.1.4
```

- [ ] **Step 2: Swap Module Federation plugin**

```bash
pnpm remove @module-federation/modern-js
pnpm add @module-federation/modern-js-v3@latest
```

- [ ] **Step 3: Verify @module-federation/enhanced compatibility**

```bash
pnpm add @module-federation/enhanced@2.3.3
```

- [ ] **Step 4: Install @modern-js/server-runtime (needed for server middleware)**

```bash
pnpm add @modern-js/server-runtime@3.1.4
```

- [ ] **Step 5: Run pnpm install and verify no peer dep conflicts**

```bash
pnpm install
```

Expected: Clean install, no unresolved peer dependency errors.

- [ ] **Step 6: Commit**

```bash
git add package.json pnpm-lock.yaml
git commit -m "Upgrade Modern.js 2.70.8 → 3.1.4, swap MF plugin to modern-js-v3"
```

---

### Task 2: Update modern.config.ts

**Files:**
- Modify: `modern.config.ts`

- [ ] **Step 1: Remove bundler parameter and runtime.router**

Change from:
```typescript
import { appTools, defineConfig } from "@modern-js/app-tools";
import { moduleFederationPlugin } from "@module-federation/modern-js";

const buildTarget = process.env["BUILD_TARGET"];
const isRemote = buildTarget === "remote";

export default defineConfig({
  plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()],
  source: {
    entriesDir: "./src",
  },
  runtime: {
    router: true,
  },
  server: {
    ssr: {
      mode: "stream",
    },
  },
  tools: {
    cssLoader: {
      url: false,
    },
  },
  output: {
    distPath: { root: isRemote ? "dist/remote" : "dist/standalone" },
  },
});
```

To:
```typescript
import { appTools, defineConfig } from "@modern-js/app-tools";
import { moduleFederationPlugin } from "@module-federation/modern-js-v3";

const buildTarget = process.env["BUILD_TARGET"];
const isRemote = buildTarget === "remote";

export default defineConfig({
  plugins: [appTools(), moduleFederationPlugin()],
  source: {
    entriesDir: "./src",
  },
  server: {
    ssr: {
      mode: "stream",
    },
  },
  tools: {
    cssLoader: {
      url: false,
    },
  },
  output: {
    distPath: { root: isRemote ? "dist/remote" : "dist/standalone" },
  },
});
```

Three changes: (a) `appTools()` — no `bundler` param, Rspack is now the only option, (b) import from `@module-federation/modern-js-v3`, (c) `runtime.router` removed — moves to `modern.runtime.ts`.

- [ ] **Step 2: Commit**

```bash
git add modern.config.ts
git commit -m "Update modern.config.ts for v3: remove bundler param, drop runtime.router"
```

---

### Task 3: Update module-federation.config.ts

**Files:**
- Modify: `module-federation.config.ts`

- [ ] **Step 1: Change import source**

Change from:
```typescript
import { createModuleFederationConfig } from "@module-federation/modern-js";
```

To:
```typescript
import { createModuleFederationConfig } from "@module-federation/modern-js-v3";
```

The rest of the config (name, exposes, shared) stays identical.

- [ ] **Step 2: Commit**

```bash
git add module-federation.config.ts
git commit -m "Point MF config at modern-js-v3 plugin"
```

---

### Task 4: Create src/modern.runtime.ts

**Files:**
- Create: `src/modern.runtime.ts`

- [ ] **Step 1: Create runtime config file**

```typescript
import { defineRuntimeConfig } from "@modern-js/runtime";

export default defineRuntimeConfig({
  router: true,
});
```

This is where `runtime.router: true` now lives (was in `modern.config.ts`).

- [ ] **Step 2: Verify typecheck passes**

```bash
pnpm typecheck
```

Expected: No errors related to `defineRuntimeConfig`.

- [ ] **Step 3: Commit**

```bash
git add src/modern.runtime.ts
git commit -m "Add modern.runtime.ts with router config (v3 requirement)"
```

---

### Task 5: Verify build and dev server work

**Files:** None — this is a smoke-test checkpoint.

- [ ] **Step 1: Run typecheck**

```bash
pnpm typecheck
```

Expected: Pass. The `@modern-js/runtime/router` imports (`useParams`, `useNavigate`, `Outlet`, `Link`, `redirect`) should still resolve — they are stable exports in v3.

- [ ] **Step 2: Run the dev build**

```bash
pnpm dev 2>&1 | head -50
```

Expected: Dev server starts without crash. If `tools.cssLoader` is not recognized, change it to `tools.cssExtract` or remove it — but it should still work since Rsbuild 2.0 supports `cssLoader`.

- [ ] **Step 3: Run unit tests**

```bash
pnpm test
```

Expected: All tests pass. The server middleware tests will still pass because they test standalone functions with mocked req/res, not framework integration.

- [ ] **Step 4: Run standalone build**

```bash
pnpm build:standalone
```

Expected: Build completes. Output in `dist/standalone/`.

- [ ] **Step 5: Run remote build**

```bash
pnpm build:remote
```

Expected: Build completes. Output in `dist/remote/`. `mf-manifest.json` emitted.

- [ ] **Step 6: Commit (if any fixes were needed)**

```bash
git add -A
git commit -m "Fix build issues from Modern.js v3 upgrade"
```

---

### Task 6: Rewrite CSP middleware to Hono

**Files:**
- Modify: `src/server/middleware/csp.ts`
- Modify: `src/server/middleware/csp.test.ts`

- [ ] **Step 1: Write failing test for Hono-style CSP middleware**

Replace `src/server/middleware/csp.test.ts` with:

```typescript
import { describe, expect, it, vi } from "vitest";
import { cspMiddleware, CspNonceContext } from "./csp.js";

function createMockContext() {
  const headers = new Map<string, string>();
  return {
    req: {
      path: "/",
      header: (name: string) => undefined as string | undefined,
    },
    header: (name: string, value: string) => {
      headers.set(name, value);
    },
    set: (key: string, value: unknown) => {
      (ctx as Record<string, unknown>)[key] = value;
    },
    get: (key: string) => (ctx as Record<string, unknown>)[key],
    _headers: headers,
  };
  // Self-reference for set/get
  var ctx = arguments.callee ? undefined : undefined;
}

// Better approach: use a simple object
function createHonoContext() {
  const headers = new Map<string, string>();
  const vars: Record<string, unknown> = {};
  const c = {
    req: { path: "/" },
    header: (name: string, value: string) => { headers.set(name, value); },
    set: (key: string, value: unknown) => { vars[key] = value; },
    get: (key: string) => vars[key],
    _testHeaders: headers,
    _testVars: vars,
  };
  return c;
}

describe("cspMiddleware", () => {
  it("sets Content-Security-Policy header with a nonce", async () => {
    const middleware = cspMiddleware();
    const c = createHonoContext();
    const next = vi.fn().mockResolvedValue(undefined);

    await middleware(c as any, next);

    const csp = c._testHeaders.get("Content-Security-Policy");
    expect(csp).toContain("'nonce-");
    expect(next).toHaveBeenCalled();
  });

  it("generates unique nonce per call", async () => {
    const middleware = cspMiddleware();
    const nonces: string[] = [];

    for (let i = 0; i < 5; i++) {
      const c = createHonoContext();
      await middleware(c as any, vi.fn().mockResolvedValue(undefined));
      const csp = c._testHeaders.get("Content-Security-Policy") ?? "";
      const match = csp.match(/nonce-([^']+)/);
      if (match?.[1]) nonces.push(match[1]);
    }

    const unique = new Set(nonces);
    expect(unique.size).toBe(5);
  });

  it("uses Content-Security-Policy-Report-Only when reportOnly is true", async () => {
    const middleware = cspMiddleware({ reportOnly: true });
    const c = createHonoContext();
    const next = vi.fn().mockResolvedValue(undefined);

    await middleware(c as any, next);

    expect(c._testHeaders.has("Content-Security-Policy-Report-Only")).toBe(true);
  });

  it("stores nonce in context via c.set('cspNonce', ...)", async () => {
    const middleware = cspMiddleware();
    const c = createHonoContext();

    await middleware(c as any, vi.fn().mockResolvedValue(undefined));

    const nonce = c._testVars["cspNonce"];
    expect(typeof nonce).toBe("string");
    expect((nonce as string).length).toBeGreaterThan(0);
  });
});

describe("CspNonceContext", () => {
  it("has a default value of empty string", () => {
    expect(CspNonceContext).toBeDefined();
    expect((CspNonceContext as unknown as { _currentValue: string })._currentValue).toBe("");
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
pnpm vitest run src/server/middleware/csp.test.ts
```

Expected: FAIL — `cspMiddleware` still returns express-style `(req, res, next)`.

- [ ] **Step 3: Rewrite csp.ts to Hono style**

Replace `src/server/middleware/csp.ts` with:

```typescript
import { createContext } from "react";
import crypto from "node:crypto";

export interface CspMiddlewareOptions {
  reportOnly?: boolean;
}

/**
 * React context exposing the per-request CSP nonce.
 * Default is "" — client-side components read empty string (no-op).
 */
export const CspNonceContext = createContext<string>("");

/**
 * Hono-style middleware that:
 * 1. Generates a per-request nonce
 * 2. Sets the CSP header (or Report-Only variant)
 * 3. Stores nonce in Hono context via c.set('cspNonce', nonce)
 */
export function cspMiddleware(options?: CspMiddlewareOptions) {
  const headerName = options?.reportOnly
    ? "Content-Security-Policy-Report-Only"
    : "Content-Security-Policy";

  return async (
    c: { header: (name: string, value: string) => void; set: (key: string, value: unknown) => void },
    next: () => Promise<void>,
  ): Promise<void> => {
    const nonce = crypto.randomUUID();

    const policy = [
      `default-src 'self'`,
      `script-src 'self' 'nonce-${nonce}'`,
      `style-src 'self' 'unsafe-inline'`,
      `img-src 'self' data: https:`,
      `font-src 'self'`,
      `connect-src 'self' https:`,
      `frame-ancestors 'self'`,
      `base-uri 'self'`,
      `form-action 'self'`,
    ].join("; ");

    c.header(headerName, policy);
    c.set("cspNonce", nonce);

    await next();
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

```bash
pnpm vitest run src/server/middleware/csp.test.ts
```

Expected: All 5 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/server/middleware/csp.ts src/server/middleware/csp.test.ts
git commit -m "Rewrite CSP middleware from express-style to Hono for Modern.js v3"
```

---

### Task 7: Rewrite security-headers middleware to Hono

**Files:**
- Modify: `src/server/middleware/security-headers.ts`

- [ ] **Step 1: Rewrite to Hono style**

Replace `src/server/middleware/security-headers.ts` with:

```typescript
/**
 * Hono-style middleware that sets standard security headers.
 */
export function securityHeadersMiddleware() {
  return async (
    c: { header: (name: string, value: string) => void },
    next: () => Promise<void>,
  ): Promise<void> => {
    c.header(
      "Strict-Transport-Security",
      "max-age=63072000; includeSubDomains; preload",
    );
    c.header("X-Content-Type-Options", "nosniff");
    c.header("X-Frame-Options", "SAMEORIGIN");
    c.header("Referrer-Policy", "strict-origin-when-cross-origin");
    c.header(
      "Permissions-Policy",
      "geolocation=(), camera=(), microphone=()",
    );
    c.header("Cross-Origin-Opener-Policy", "same-origin");
    c.header("Cross-Origin-Resource-Policy", "cross-origin");

    await next();
  };
}
```

- [ ] **Step 2: Commit**

```bash
git add src/server/middleware/security-headers.ts
git commit -m "Rewrite security-headers middleware to Hono style"
```

---

### Task 8: Rewrite health check to Hono

**Files:**
- Modify: `src/server/routes/health.ts`
- Modify: `src/server/routes/health.test.ts`

- [ ] **Step 1: Write failing test for Hono-style health handler**

Replace `src/server/routes/health.test.ts` with:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { healthMiddleware } from "./health.js";
import type { ApiClient } from "@/shared/api/client.js";

function createMockApiClient(
  behavior: "ok" | "error" | "timeout" = "ok",
): ApiClient {
  const client = {
    get: vi.fn(),
    post: vi.fn(),
  } as unknown as ApiClient;

  if (behavior === "ok") {
    vi.mocked(client.get).mockResolvedValue({ status: "ok" });
  } else if (behavior === "error") {
    vi.mocked(client.get).mockRejectedValue(new Error("upstream down"));
  } else {
    vi.mocked(client.get).mockImplementation(
      () => new Promise(() => {}), // never resolves
    );
  }

  return client;
}

function createHonoContext() {
  let statusCode = 200;
  let body: unknown = undefined;
  const headers = new Map<string, string>();
  const c = {
    req: { path: "/health" },
    header: (name: string, value: string) => { headers.set(name, value); },
    status: (code: number) => { statusCode = code; },
    json: (data: unknown) => {
      body = data;
      return new Response(JSON.stringify(data), { status: statusCode });
    },
    _testStatus: () => statusCode,
    _testBody: () => body,
  };
  return c;
}

describe("healthMiddleware", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  it("returns 200 when upstream is reachable", async () => {
    const apiClient = createMockApiClient("ok");
    const handler = healthMiddleware({ apiClient });
    const c = createHonoContext();

    await handler(c as any, vi.fn().mockResolvedValue(undefined));

    expect(c._testStatus()).toBe(200);
    expect(c._testBody()).toEqual({ status: "ok" });
  });

  it("returns 503 when upstream ping fails", async () => {
    const apiClient = createMockApiClient("error");
    const handler = healthMiddleware({ apiClient });
    const c = createHonoContext();

    await handler(c as any, vi.fn().mockResolvedValue(undefined));

    expect(c._testStatus()).toBe(503);
    expect(c._testBody()).toEqual({
      status: "degraded",
      reason: "upstream_unreachable",
    });
  });

  it("returns 200 if last success is within 60s even if current ping fails", async () => {
    const apiClient = createMockApiClient("ok");
    const handler = healthMiddleware({ apiClient });

    const c1 = createHonoContext();
    await handler(c1 as any, vi.fn().mockResolvedValue(undefined));
    expect(c1._testStatus()).toBe(200);

    // Now make it fail
    vi.mocked(apiClient.get).mockRejectedValue(new Error("fail"));
    vi.advanceTimersByTime(30_000);

    const c2 = createHonoContext();
    await handler(c2 as any, vi.fn().mockResolvedValue(undefined));
    expect(c2._testStatus()).toBe(200);
  });

  it("returns 503 if last success is older than 60s", async () => {
    const apiClient = createMockApiClient("ok");
    const handler = healthMiddleware({ apiClient });

    const c1 = createHonoContext();
    await handler(c1 as any, vi.fn().mockResolvedValue(undefined));
    expect(c1._testStatus()).toBe(200);

    vi.mocked(apiClient.get).mockRejectedValue(new Error("fail"));
    vi.advanceTimersByTime(61_000);

    const c2 = createHonoContext();
    await handler(c2 as any, vi.fn().mockResolvedValue(undefined));
    expect(c2._testStatus()).toBe(503);
  });

  it("respects custom upstreamTimeoutMs", async () => {
    const apiClient = createMockApiClient("timeout");
    const handler = healthMiddleware({
      apiClient,
      upstreamTimeoutMs: 100,
    });

    const c = createHonoContext();
    const promise = handler(c as any, vi.fn().mockResolvedValue(undefined));
    vi.advanceTimersByTime(200);
    await promise;

    expect(c._testStatus()).toBe(503);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
pnpm vitest run src/server/routes/health.test.ts
```

Expected: FAIL — `healthMiddleware` still returns express-style handler.

- [ ] **Step 3: Rewrite health.ts to Hono style**

Replace `src/server/routes/health.ts` with:

```typescript
import type { ApiClient } from "@/shared/api/client.js";

export interface HealthMiddlewareOptions {
  apiClient: ApiClient;
  upstreamTimeoutMs?: number;
}

const STALE_THRESHOLD_MS = 60_000;
const DEFAULT_UPSTREAM_TIMEOUT_MS = 5_000;

/**
 * Hono-style middleware that handles /health requests.
 * Returns 200 if the last successful upstream ping is within 60s, 503 otherwise.
 */
export function healthMiddleware(options: HealthMiddlewareOptions) {
  const { apiClient, upstreamTimeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS } =
    options;

  let lastSuccessTs = 0;

  return async (
    c: { status: (code: number) => void; json: (data: unknown) => Response },
    _next: () => Promise<void>,
  ): Promise<Response> => {
    try {
      await Promise.race([
        apiClient.get("/health"),
        new Promise<never>((_resolve, reject) =>
          setTimeout(
            () => reject(new Error("upstream_timeout")),
            upstreamTimeoutMs,
          ),
        ),
      ]);
      lastSuccessTs = Date.now();
    } catch {
      // ping failed — rely on cached lastSuccessTs
    }

    const age = Date.now() - lastSuccessTs;

    if (lastSuccessTs > 0 && age < STALE_THRESHOLD_MS) {
      c.status(200);
      return c.json({ status: "ok" });
    } else {
      c.status(503);
      return c.json({ status: "degraded", reason: "upstream_unreachable" });
    }
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

```bash
pnpm vitest run src/server/routes/health.test.ts
```

Expected: All 5 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/server/routes/health.ts src/server/routes/health.test.ts
git commit -m "Rewrite health middleware from express-style to Hono"
```

---

### Task 9: Create server/modern.server.ts to wire middleware

**Files:**
- Create: `src/server/modern.server.ts`

Note: The `nonce-stream-transform.ts` file is a pure Node stream utility — it has no framework coupling and needs no changes. It will be used by a `renderMiddleware` that wraps the SSR stream. The `shutdown.ts` file is also framework-independent (it registers a `process.on('SIGTERM')` handler) and needs no changes.

- [ ] **Step 1: Create the server config file**

Create `src/server/modern.server.ts`:

```typescript
import { defineServerConfig } from "@modern-js/server-runtime";
import { cspMiddleware } from "./middleware/csp.js";
import { securityHeadersMiddleware } from "./middleware/security-headers.js";

export default defineServerConfig({
  middlewares: [
    { name: "security-headers", handler: securityHeadersMiddleware() },
    { name: "csp", handler: cspMiddleware() },
  ],
});
```

Note: The health check and nonce stream transform are not wired here yet — they require route-level and render-level integration that was also not wired in v2. This preserves the existing behavior.

- [ ] **Step 2: Verify typecheck passes**

```bash
pnpm typecheck
```

Expected: No type errors. The `MiddlewareHandler` type from `@modern-js/server-runtime` should accept the middleware signatures.

- [ ] **Step 3: Commit**

```bash
git add src/server/modern.server.ts
git commit -m "Wire security + CSP middleware via defineServerConfig for Modern.js v3"
```

---

### Task 10: Full verification

**Files:** None — final smoke test.

- [ ] **Step 1: Run typecheck**

```bash
pnpm typecheck
```

Expected: PASS.

- [ ] **Step 2: Run full test suite**

```bash
pnpm test
```

Expected: All tests pass (including rewritten CSP and health tests).

- [ ] **Step 3: Run lint**

```bash
pnpm lint
```

Expected: PASS.

- [ ] **Step 4: Run dev server and verify no React Router warning**

```bash
timeout 15 pnpm dev 2>&1 | grep -i "react router\|v7_startTransition\|error\|warning" || echo "No warnings found"
```

Expected: The `v7_startTransition` future flag warning is GONE. React Router 7 is now active.

- [ ] **Step 5: Run standalone build**

```bash
pnpm build:standalone
```

Expected: Clean build.

- [ ] **Step 6: Run remote build and verify mf-manifest.json**

```bash
pnpm build:remote && cat dist/remote/mf-manifest.json | head -5
```

Expected: Build succeeds, `mf-manifest.json` is emitted with the 4 exposed modules.

- [ ] **Step 7: Commit any remaining fixes**

```bash
git add -A
git commit -m "Complete Modern.js v3 upgrade — all checks passing"
```
