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