diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5cf33478 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# AGENTS.md — Aeroflot.Flights.Web + +## Quick Start + +```bash +# Install dependencies +pnpm install + +# Development servers +pnpm dev # Modern.js on :8081 +pnpm dev:full # Proxy on :8080 (API forwarding via curl to bypass WAF) + +# Build targets +pnpm build:standalone # SSR server at dist/standalone/ +pnpm build:remote # MF remote at dist/remote/ (with mf-manifest.json) +pnpm build:both # Both targets + +# Quality checks (run in order) +pnpm typecheck +pnpm lint +pnpm test +pnpm test:coverage +pnpm bundle-size # CI gate: total gzip ≤ 2000 kB +pnpm check-coverage # CI gate: line coverage ≥ 65% + +# E2E tests +pnpm test:e2e # React app (port 8080) +pnpm test:e2e:angular # Angular app (port 4203) +``` + +## Architecture + +**Stack:** Modern.js 2.70.8 (Rspack), React 18.2, Module Federation 2.3.3 + +**Source structure:** +- `src/routes/` — File-based routing (React Router v7) +- `src/features/` — Feature modules (online-board, schedule, flights-map, popular-requests) +- `src/ui/` — Reusable UI primitives (never import routes/mf from here) +- `src/shared/` — Cross-cutting concerns (API client, SignalR, storage, hooks) +- `src/observability/` — Logger, metrics (OTel), analytics +- `src/i18n/` — Internationalization (9 languages) + +**Entry points:** +- `src/routes/page.tsx` — Root redirect to `/{lang}/onlineboard` +- `src/routes/layout.tsx` — Root layout with global providers +- `src/routes/[lang]/layout.tsx` — Locale-scoped layout with i18n + +**Module Federation:** +- Remote name: `aeroflot_flights` +- Exposes: `./OnlineBoard`, `./Schedule`, `./FlightsMap`, `./PopularRequests` +- mf-manifest.json at `https:///mf-manifest.json` + +## Critical Constraints + +1. **Side-effect-free components** — No `fetch` outside `useEffect`. API calls via `ApiClient` from context. + +2. **Dynamic imports** — Use `React.lazy()` for code splitting. Wrap browser-only components (Leaflet) in `ClientOnly`. + +3. **SSR safety** — SignalR (`@microsoft/signalr`) is dynamically imported. Never import in SSR bundle (routes/, server/). + +4. **Layered architecture** — Enforced by ESLint: + - `features/` → cannot import `routes/` or `mf/` + - `ui/` → cannot import `features/`, `routes/`, `mf/` + - `shared/` → cannot import `features/`, `routes/`, `mf/`, `observability/` + - `observability/` → cannot import `features/`, `routes/`, `mf/` + +5. **Restricted imports** (ESLint rules): + - OTel SDK → use `@/observability/metrics/otel` (getMeter/getTracer) + - react-i18next → use `@/i18n/provider` + - localStorage/sessionStorage → use `@/shared/storage` + +6. **Environment variables** — Read via `getEnv()` from `@/env/index.ts`. SSR injects `window.__ENV__`. + +## Dev Server Details + +- **Port 8081** — Modern.js dev server (SSR + HMR) +- **Port 8080** — `pnpm dev:full` proxy with: + - `/api/*` and `/flights/*` → `https://flights.test.aeroflot.ru` (via curl to bypass WAF) + - `/*` → Modern.js on 8081 + +## Testing + +- **Unit tests** — Vitest (globals enabled, V8 coverage) +- **E2E tests** — Playwright (baseURL: `http://localhost:8080`) +- **Angular E2E** — Separate config for legacy app (port 4203) + +**Coverage gates:** +- Line coverage ≥ 65% +- Bundle size (gzip) ≤ 2000 kB for remote target + +## Build Artifacts + +- `dist/standalone/` — SSR server (Node entrypoint) +- `dist/remote/` — MF remote with `mf-manifest.json` +- `dist/remote/static/js/` — JS bundles for bundle-size gate + +## Deployment + +- **Standalone** — Dockerfile.react (Node 24-slim, serves SSR) +- **Remote** — Dockerfile.remote (nginx serving static files) +- CI/CD: `.github/workflows/ci.yml`, `.github/workflows/deploy.yml` + +## Known Constraints + +- Modern.js 3.x blocked by `@module-federation/modern-js` ESM incompatibilities +- React Router v7 future flags enabled to suppress deprecation warnings +- Legacy Angular 12 SPA in `ClientApp/` (read-only reference) + +## Commit Rules + +- No `Co-Authored-By` lines +- English commit messages, focus on "why" not "what" +- Commit autonomously when stable; ask before pushing/force-pushing diff --git a/docs/parity-report/REPORT.md b/docs/parity-report/REPORT.md new file mode 100644 index 00000000..1e7ba0c7 --- /dev/null +++ b/docs/parity-report/REPORT.md @@ -0,0 +1,296 @@ +# Visual Parity Report — Angular vs React (full coverage) + +**Date:** 2026-04-19 +**Tool:** Playwright MCP (`@playwright/mcp`) +**Angular reference:** https://flights.test.aeroflot.ru/{locale}/... +**React under test:** http://localhost:8080/{locale}/... (`pnpm dev:full` proxy) +**Locales tested:** `ru-ru` (primary), `en-en` (sample) +**Viewports:** desktop 1440×900, mobile 375×812 +**Test scenarios:** +- Route SVO ↔ LED, 19 Apr 2026 (today) +- Flight SU 6497, 19 Apr 2026 +- Departure ex-SVO / Arrival in LED +- Schedule one-way SVO→LED, week of 19–26 Apr 2026 +- Flights map (no filter) + +Screenshots in `screenshots-v2/`. Legend: 🟢 visually equivalent · 🟡 minor diffs (spacing, dev chrome) · 🟠 functional/content gap · 🔴 major regression. + +--- + +## At a glance + +| # | Page | URL pattern | Desktop | Mobile | EN | +|---|---|---|---|---|---| +| 1 | Onlineboard start | `/{locale}/onlineboard` | 🟢 | 🟢 | 🟢 | +| 2 | Onlineboard route results | `/{locale}/onlineboard/route/{dep}-{arr}-{date}` | 🟢 | 🟢 | 🟡 | +| 3 | Onlineboard departure-only | `/{locale}/onlineboard/departure/{dep}-{date}` | 🟢 | — | — | +| 4 | Onlineboard arrival-only | `/{locale}/onlineboard/arrival/{arr}-{date}` | 🟢 | — | — | +| 5 | Onlineboard flight-number search | `/{locale}/onlineboard/flight/{carrier}{num}-{date}` | 🟢 | — | — | +| 6 | Flight details (single flight) | `/{locale}/onlineboard/{carrier}{num}-{date}` | 🟠 | 🟠 | 🟠 | +| 7 | Schedule start | `/{locale}/schedule` | 🟢 | — | — | +| 8 | Schedule route results (one-way) | `/{locale}/schedule/route/{dep}-{arr}-{from}-{to}` | 🔴 | — | — | +| 9 | Schedule route results (round-trip) | `/{locale}/schedule/route/.../...` | 🔴 | — | — | +| 10 | Schedule flight details | `/{locale}/schedule/{flightId}` | not tested | — | — | +| 11 | Flights map | `/{locale}/flights-map` | 🟡 | 🟡 | — | +| 12 | Popular requests panel (inline) | embedded on 1, 7 | 🟢 | 🟢 | 🟢 | +| 13 | Error pages (404/500) | `/error/{code}` | not tested | — | — | +| 14 | Smoke / health | `/{locale}/smoke` | not user-facing | — | — | + +--- + +## URL surface + +Both apps now use the same BCP-47-like URL contract: +`/{xx-xx}/...` where both halves are the language code (`/ru-ru/`, `/en-en/`, `/zh-zh/`, `/ja-ja/`, `/de-de/`, …). +Bare short codes (`/ru/onlineboard`) auto-redirect to canonical (`/ru-ru/onlineboard`) on the React side; Angular config redirects the same way. ✅ URL parity. + +--- + +## 1. Onlineboard — start page + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-onlineboard-start.png) | ![](screenshots-v2/react-onlineboard-start.png) | + +**Diffs:** +- Angular shows test-env chrome React hides (orange "Тестовая версия", "rc/2026-04-06", dev counter "383", chat-widget bubble). Out of scope. +- Date input placeholder: Angular `ДД.ММ.ГГГГ` vs React `Сегодня` (filled when date is today). React's behaviour matches Angular's loaded state once a date is picked. +- Form-row vertical spacing slightly tighter in React (~6 px less between inputs). +- 4 info tiles, popular-requests block, breadcrumb — pixel-equivalent. + +🟢 Equivalent. + +## 2. Onlineboard — route results (`/onlineboard/route/SVO-LED-20260419`) + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-onlineboard-route.png) | ![](screenshots-v2/react-onlineboard-route.png) | + +**Diffs:** +- Both auto-expand the same closest-to-now flight (SU 6579) with the `Время` + `Посадка` accordion. Layout, columns, and label order match. +- Angular shows the actual arrival time (20:22) as primary with the scheduled time (20:25) struck-through; React shows 20:25 primary with 20:22 in the expanded panel. Both convey the same data, slightly different emphasis. +- Date-tab strip (`18 апр – 24 апр`) and current-day highlight identical. +- React keeps the filter "Дата рейса" / "Время рейса" rows visible after submit; Angular hides them. Minor. + +🟢 Equivalent on layout; 🟡 minor on time-emphasis treatment. + +## 3. Onlineboard — departure-only (`/onlineboard/departure/SVO-20260419`) + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-onlineboard-departure.png) | ![](screenshots-v2/react-onlineboard-departure.png) | + +**Diffs:** +- Angular's `Город прилета` field defaults to `Все направления`; React leaves it empty. Functionally equivalent (both query "all destinations from SVO"); cosmetic. +- Both list flights to varied destinations (Сыктывкар, Махачкала, Ереван, Санкт-Петербург, …) with the same time/airline/airport columns. +- Same card-expansion behaviour. + +🟢 Equivalent. + +## 4. Onlineboard — arrival-only (`/onlineboard/arrival/LED-20260419`) + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-onlineboard-arrival.png) | ![](screenshots-v2/react-onlineboard-arrival.png) | + +**Diffs:** +- Same `Все направления` placeholder difference as #3. +- Boarding section in Angular labelled `Высадка` (deboarding); React labels it `Посадка` (boarding) but with the same fields. Consider switching React to `Высадка` for arrival-direction flights to match Angular wording. +- Status pill colours match: green check-mark (`Прибыл`), blue plane (`Вылетел`). + +🟢 Equivalent on layout; 🟡 boarding/deboarding label. + +## 5. Onlineboard — flight-number search (`/onlineboard/flight/SU6497-20260419`) + +Equivalent to the route-results page in chrome (same `OnlineBoardSearchPage` shell). Both render a single-card list filtered to the flight number, with the same expanded card. H1 heading reads `Рейс: SU 6497, Сегодня` on both. 🟢 Equivalent. + +## 6. Flight details — full page (`/onlineboard/SU6497-20260419`) + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-flight-details.png) | ![](screenshots-v2/react-flight-details.png) | + +**Diffs:** +- **H1 wording matches**: `Информация о рейсе: SU 6497, Москва - Санкт-Петербург`. +- **Carrier logo (RU)**: Angular renders the RU variant `РОССИЯ` (Cyrillic); React serves the EN variant `ROSSIYA` (Latin) even on the Russian site. **🔴 Bug** — `BoardDetailsHeader`'s `OperatorLogo` is still passing locale="en" or the asset fallback is wrong. The fix that landed for `FlightCard` didn't reach the details-page header. +- **Sidebar mini-list**: Angular shows arrows pointing right between origin/destination; React's mini-list item is plainer (just times + cities, no arrows). Angular uses the localised `Прибыл` time; React shows blank status row in the mini-list. +- **`Регистрация` row times**: Angular shows `16:00 ⁻¹` (with a small superscript day-change marker for the day-before start) plus `15:15` end time; React shows the same. ✅ +- **`Посадка` (boarding) — in flight-details accordion**: matching `Закончена` status, same start/end times. +- **`Высадка` (deboarding)** row: matching status + times. +- **`Последнее обновление` stamp**: both show fresh client-side load timestamp. ✅ +- **Footer note** `* Время прилета и расстояния являются расчётными...`: present on both. + +🟠 — H1 + accordion match Angular; the carrier-logo locale slip is the only visible regression. + +## 7. Schedule — start page + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-schedule-start.png) | ![](screenshots-v2/react-schedule-start.png) | + +**Diffs:** +- Date-range field: Angular shows placeholder `ДД.ММ.ГГГГ - ДД.ММ.ГГГГ`; React fills with the default current-week range `19.04.2026 - 26.04.2026` (calendar shows actual values). React's behaviour matches Angular's after the calendar mounts. +- 4 info tiles, popular-requests block, "Расписание рейсов Аэрофлота" SEO body — equivalent. + +🟢 Equivalent. + +## 8. Schedule — route results, one-way (`/schedule/route/SVO-LED-20260419-20260426`) + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-schedule-route.png) | ![](screenshots-v2/react-schedule-route.png) | + +**Diffs (major):** +- **Day-grouping headers**: Angular groups flights by day (`Воскресенье 19 Апреля`); React shows a flat list with no day grouping. +- **Code-share + multi-leg expansion**: Angular shows multi-carrier flights bundled (`SU 1942, SU 6532` with two airline logos), then a route diagram (4ч.25мин. + 2ч.20мин. transfer + 5ч.10мин.) and the legs broken out below. React renders each leg as a separate row with no diagram, no transfer pill, and no parent-flight grouping. +- **Column headers**: Angular has a sortable header row (`РЕЙС` / `АВИАКОМПАНИЯ, БОРТ` / `ВЫЛЕТ` / `ВРЕМЯ В ПУТИ` / `ПРИЛЕТ`) with sort arrows on `ВЫЛЕТ` and `ПРИЛЕТ`; React shows no headers. +- **Date-range tab strip**: Angular shows weekly windows (`13 апр - 19 апр`, `20 апр - 26 апр`, …); React shows daily tabs (`17 апр.`, `18 апр.`, `19 апреля`, …). +- **Aircraft model**: Angular shows `Airbus A321` / `Airbus A319` per leg; React doesn't show aircraft model in the row. +- **Action buttons**: Angular has both `Статус рейса` and `Детали рейса` per flight; React has only `Детали рейса`. +- **`Вы искали` (search history)** sidebar accordion: Angular has one; React has it elsewhere or not at all on this page. + +🔴 **Major regression** — schedule results page is the largest gap in the entire app. The React page is essentially the onlineboard list reskinned, missing all of: day grouping, multi-leg/code-share grouping, transfer diagram, sortable headers, weekly date tabs, aircraft column, and the secondary action button. + +## 9. Schedule — route results, round-trip + +Not separately captured in this run. Same structural gaps as #8 are expected (Angular shows two side-by-side panels for outbound + return, each with full grouping). 🔴 + +## 10. Schedule — flight details + +Not captured. Angular has a dedicated detail page reachable via `Детали рейса`/`Статус рейса` buttons; React's catch-all route handles this URL but renders the onlineboard details page. Worth a dedicated capture pass. + +## 11. Flights map + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-flights-map.png) | ![](screenshots-v2/react-flights-map.png) | + +**Diffs:** +- Origin marker: Angular shows red dot on `Москва` (geolocation result). React shows no marker — `useGeolocationDefault()` either silently failed or the headless Playwright didn't grant permission. In a real browser with geo enabled both should show the dot. +- Default zoom/extent: Angular includes Санкт-Петербург at top of viewport; React's viewport is shifted south, cutting off SPb. Both use Leaflet center `[53,45]` zoom `5`; the diff is viewport-width and tile-loading order, not code. +- Swap arrows: vertical blue `↑↓` on both. ✅ +- City labels: text-only with text-shadow halo on both. ✅ +- Form panel: no `Найдите свой маршрут` header on either. ✅ +- Date input: React still has the filled-blue calendar tile, Angular has an outline icon. Minor. + +🟡 Minor — geo marker + slight zoom drift. + +## 12. Popular requests panel + +Embedded inline on Onlineboard start (#1) and Schedule start (#7). Renders 4 items in 2 columns; each is `Маршрут: City - City`, `Номер рейса: SU XXXX`, `Вылет: City`, or `Прилет: City`. Both apps use a service singleton (Angular `*FiltersStateService`) / sessionStorage prefill (React) to hand off click data without polluting the URL — confirmed in earlier patches. 🟢 + +## 13. Error pages + +The React app has `/error/{code}/page.tsx` covering 404/500/etc. with a localised header + search box. Angular has `/error-pages/error-page` with the same shape. Not captured this round (need a deliberately broken URL); structurally aligned per the code. + +## 14. Smoke / health + +`/{locale}/smoke` is React-only — used by health-check probes. No Angular equivalent expected. + +--- + +## English (`/en-en/`) — locale parity sample + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-en-onlineboard-route.png) | ![](screenshots-v2/react-en-onlineboard-route.png) | + +**Diffs:** +- All form labels (`Flight number`, `Route`, `City of departure`, `City of destination`, `Flight date`, `Search`) — match. +- Date tabs (`18 Apr`, `April 19`, `20 Apr`, …): Angular uses `apr` lowercase weekday-relative form; React capitalised `Apr`. Minor. +- Status pills (`Arrived`, `In flight`, `Scheduled`) — match. +- Carrier logos — both render the EN variants `ROSSIYA` / `AEROFLOT`. ✅ +- Boarding row labels (`Status`, `Start time`, `End time`/`Close time`): Angular uses `Close Time`, React uses `End time`. Cosmetic. + +🟢 Equivalent. EN translation now complete (was 21 keys empty before — all filled). + +--- + +## Mobile (375×812) + +### Onlineboard start + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-mobile-onlineboard-start.png) | ![](screenshots-v2/react-mobile-onlineboard-start.png) | + +**Diffs:** +- Both surface the 3-day quick-pick row (`19 Вс. Апреля / 20 Пн. Апреля / 21 Вт. Апреля`) above the manual `ДД.ММ.ГГГГ` input. ✅ React's selected-day chip uses solid blue fill; Angular leaves all three white. Minor. +- Tabs (`Онлайн-Табло` / `Расписание` / `Карта полетов`) — match. +- Form fields, swap arrow, accordion behaviour — match. + +🟢 Equivalent. + +### Flight details + +| Angular | React | +|---|---| +| ![](screenshots-v2/angular-mobile-flight-details.png) | ![](screenshots-v2/react-mobile-flight-details.png) | + +**Diffs:** +- **Carrier logo**: Angular `РОССИЯ` (RU); React `ROSSIYA` (EN). Same bug as desktop #6. **🔴** +- Both show the date-picker (`19 Апреля 2026` / `Сегодня`) and the back-to-board button. +- Both show the same flight-progress timeline (16:00 → 17:23 with `Прибыл`, strikethrough scheduled `17:25`). +- Both show the same `По расписанию` / `Фактическое` time blocks. +- Last-update stamp (`Последнее обновление: 19:09 19.04.2026`) — both fresh client-load time. ✅ +- React shows an extra wrapped header with "SU 6497 / 16:00 / 17:23 / Москва / Санкт-Петербург" above the main accordion — duplicates info that's already in the main card. Angular doesn't have this duplicate. + +🟠 — Carrier logo locale + duplicate sidebar-card on mobile. + +--- + +## Cross-cutting findings + +### What's already at parity ✅ +- URL contract (BCP-47 `/xx-xx/`, both halves matching). +- 9-language i18n coverage (RU complete, EN complete after this round, others have file-level coverage from the prior project). +- API client locale (now mutable, follows `[lang]/layout.tsx`) — cities/airports/statuses respond in the active language. +- Onlineboard start, route, departure, arrival, flight-search pages — visually equivalent. +- Schedule start page — equivalent. +- Popular requests panel + cross-page prefill via sessionStorage. +- Mobile day-quick-pick (3-day chip row). +- Flights map: swap arrows, city labels, form panel header, leaflet tooltip styling. +- Last-update stamp using client-side load time (matches Angular's `flight.lastUpdate = new Date()`). +- Share menu (translated labels + brand-icon-on-top layout). +- Footer note `* Время в системе - МЕСТНОЕ.` on search results. +- Boarding-status leading dot (grey for `Уточняется`). + +### Open gaps 🟠/🔴 + +| Severity | Page | Gap | +|---|---|---| +| 🔴 | Schedule results (#8, #9) | No day grouping, no code-share/multi-leg bundling, no transfer-diagram, no sortable headers, no weekly date tabs, no aircraft column, missing `Статус рейса` button. Schedule is a markedly different feature in Angular than what React currently ships. | +| 🔴 | Flight-details carrier logo (#6, mobile) | `BoardDetailsHeader` still serves the EN logo variant on Russian pages. `FlightCard` was fixed in the i18n migration, but the details-page header was missed. One-line fix in `BoardDetailsHeader.tsx` to pass `useLocale().language`. | +| 🟠 | Flight-details mobile (#6) | React renders an extra summary-card above the main details card that Angular doesn't have. | +| 🟠 | Onlineboard arrival (#4) | Boarding-row label says `Посадка` for arrival-direction flights; Angular says `Высадка`. | +| 🟡 | Onlineboard departure/arrival (#3, #4) | Empty `Город прилета` / `Город вылета` field instead of Angular's `Все направления` placeholder. | +| 🟡 | Flights map | Default origin red dot (geo-driven, not code). | +| 🟡 | Flights map calendar icon | Filled blue tile vs Angular outline icon (PrimeReact theme). | +| 🟡 | Onlineboard route filter | Date / time inputs stay visible after submit; Angular hides them. | +| 🟡 | EN tabs label casing | `Apr` vs `apr` weekday header. | + +### Items intentionally divergent +- React `[lang]/smoke` route — health probe, not present in Angular. +- React `[lang]/popular` route was deleted to match Angular's 404 (popular requests are inline only). +- React in-page test/dev chrome (`Тестовая версия` badge, env counter, chat widget) — Angular reflects the test-env deployment; React's `dev:full` is dev-only. + +--- + +## Recommended priorities + +**P0 — content correctness on the Russian site:** +1. Fix `BoardDetailsHeader` to pass `useLocale().language` to `OperatorLogo` so RU pages get `РОССИЯ` not `ROSSIYA` (desktop + mobile #6). + +**P1 — feature gaps on schedule:** +2. Schedule results page (#8) — port Angular's day-grouped, multi-leg / code-share bundled, weekly-date-tab layout. This is the single biggest UX gap remaining in the React app. +3. Schedule round-trip results — same approach, two-pane. + +**P2 — small parity wins:** +4. Mobile flight-details: drop the duplicate summary card above the main details card (#6 mobile). +5. Onlineboard arrival page: rename boarding row to `Высадка` when direction = arrival (#4). +6. Departure / arrival pages: prefill the opposite-direction field with `Все направления` placeholder (#3, #4). + +**P3 — polish:** +7. Onlineboard route filter: hide `Дата рейса` / `Время рейса` rows post-submit. +8. Flights map: outline calendar icon instead of filled blue tile. +9. EN onlineboard date tabs: lowercase short month name to match Angular (`apr` not `Apr`). diff --git a/docs/superpowers/plans/2026-04-16-modernjs-v3-upgrade.md b/docs/superpowers/plans/2026-04-16-modernjs-v3-upgrade.md new file mode 100644 index 00000000..90d63d2b --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-modernjs-v3-upgrade.md @@ -0,0 +1,822 @@ +# 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(); + 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)[key] = value; + }, + get: (key: string) => (ctx as Record)[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(); + const vars: Record = {}; + 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(""); + +/** + * 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, + ): Promise => { + 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, + ): Promise => { + 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(); + 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, + ): Promise => { + try { + await Promise.race([ + apiClient.get("/health"), + new Promise((_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" +```