Files
flights_web/docs/superpowers/plans/2026-04-16-modernjs-v3-upgrade.md
T
gnezim 8005356db5 docs: parity report markdown + auto-memory plan
Companion markdown to the comparison-report/visual/report.html with
the same coverage matrix and per-page findings. Useful for git-based
review without serving the HTML.

Also adds AGENTS.md (subagent role definitions for future sessions)
and the modernjs-v3-upgrade plan stub from the earlier scoping.
2026-04-19 22:06:05 +03:00

22 KiB

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

pnpm add @modern-js/app-tools@3.1.4 @modern-js/runtime@3.1.4
  • Step 2: Swap Module Federation plugin
pnpm remove @module-federation/modern-js
pnpm add @module-federation/modern-js-v3@latest
  • Step 3: Verify @module-federation/enhanced compatibility
pnpm add @module-federation/enhanced@2.3.3
  • Step 4: Install @modern-js/server-runtime (needed for server middleware)
pnpm add @modern-js/server-runtime@3.1.4
  • Step 5: Run pnpm install and verify no peer dep conflicts
pnpm install

Expected: Clean install, no unresolved peer dependency errors.

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

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:

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

import { createModuleFederationConfig } from "@module-federation/modern-js";

To:

import { createModuleFederationConfig } from "@module-federation/modern-js-v3";

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

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

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

Expected: No errors related to defineRuntimeConfig.

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

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

  • Step 5: Run remote build
pnpm build:remote

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

  • Step 6: Commit (if any fixes were needed)
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:

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

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
pnpm vitest run src/server/middleware/csp.test.ts

Expected: All 5 tests PASS.

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

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

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

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
pnpm vitest run src/server/routes/health.test.ts

Expected: All 5 tests PASS.

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

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

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

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

Expected: PASS.

  • Step 2: Run full test suite
pnpm test

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

  • Step 3: Run lint
pnpm lint

Expected: PASS.

  • Step 4: Run dev server and verify no React Router warning
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
pnpm build:standalone

Expected: Clean build.

  • Step 6: Run remote build and verify mf-manifest.json
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
git add -A
git commit -m "Complete Modern.js v3 upgrade — all checks passing"