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.
This commit is contained in:
@@ -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://<domain>/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
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**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`).
|
||||
@@ -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<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"
|
||||
```
|
||||
Reference in New Issue
Block a user