@isaacs/ttlcache has no byte cap (only count). A 100MB hard limit needs lru-cache v10's maxSize + sizeCalculation. Aligns design spec with Phase 1 master plan contract revision.
61 KiB
Aeroflot Flights — Angular → React Rewrite Design
Status: Draft for review Date: 2026-04-14 Owner: gnezim Scope: Full rewrite of the Aeroflot Flights web application from Angular 12 to a dual-mode Modern.js app (standalone SSR site + Module Federation 2.0 remote component), delivered in phases via strangler-fig migration.
0. Context
0.1 Current state
An ASP.NET host (Aeroflot.Flights.Web.csproj, Startup.cs, Program.cs) serves an Angular 12 SPA from ClientApp/. The SPA provides four flows — Online Board (real-time arrivals/departures with SignalR live updates), Schedule (flight timetable search with multi-leg deep links), Flights Map (Leaflet-based route visualization, feature-flagged), and Popular Requests — across nine languages (ru, en, es, fr, it, ja, ko, zh, de) with URL-prefixed locales (/{lang}/…).
Key characteristics of the existing codebase:
- No NgRx. State lives in services exposing RxJS
BehaviorSubjects. - Heavy RxJS in filter and data-source services.
- No Angular Universal. Currently CSR-only.
- PrimeNG 10 for Calendar, Autocomplete, Accordion, Dropdown, DataTable, Tooltip, Dialog, Toast — with Aeroflot-specific SCSS theme overrides.
@ngx-translate+messageformat.jsfor i18n (ICU MessageFormat).- Application Insights for telemetry.
- SignalR
TrackerHubfor live flight updates (SubscribeDate(flightDate, departure, arrival)→ pushedRefreshDateevents). custom-webpack.config.jsrandomizeschunkLoadingGlobal— an early signal that the codebase was authored with micro-frontend embedding in mind.- Custom URL schemes for deep linking, notably the variable-length multi-leg schedule URL (
/schedule/SU0001-2025-01-15/SU0002-2025-01-15/…) handled via a customUrlMatcher. - Tests: ~175 Vitest-equivalent
.spec.tsfiles (modest coverage), 5 Cypress E2E scenarios (currently not passing), no Storybook stories authored.
0.2 Customer requirements (summary)
- Stack: Modern.js (SSR), Module Federation 2.0 with
mf-manifest.json, React 18+ with Concurrent Mode and<Suspense>, no side effects in component bodies,React.lazy()for dynamic imports. Bundler: any MF 2.0-capable (Rspack chosen). - Data: REST API, JSON; rendered data must stay consistent with API.
- Performance: 100 RPS.
- Reliability: Geo-distributed VMs.
- Security: Component must be isolated — no threat to other site components.
- SEO & availability: 24/7/365, ≤6h MTTR; SEO optimization, JSON-LD + OpenGraph; analytics via Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром (Dynatrace).
- Cross-platform: Embeddable in multiple channel apps (Web, PWA); fluid responsive layout.
- Logging & monitoring: Collect frontend logs in a customer-specified format (TBD), ship to the customer's log system; system-event monitoring to a metrics aggregator.
- Module structure: Must match the customer's standard remote-frontend module template (template not yet available — SPEC ASSUMPTION: default to Modern.js MF remote layout, reconcile later).
- Design: Must match customer design system and mockups; must embed other remote components when available. Pixel-perfect parity with the current Angular app is the near-term design target, with a UI adapter boundary for future reskinning.
0.3 Non-goals
- No new UX or features beyond parity with the current Angular app.
- No backend / REST API changes.
- No data-model changes.
- No authentication; the app remains public.
- No Angular ↔ React hybrid runtime. The migration is strangler-fig at the URL-routing level, not at the component level.
1. High-level architecture + repo layout
1.1 Runtime topology
A single Modern.js project produces two deployable artifacts from the same src/:
-
Standalone SSR site — the public entry point (
flights.aeroflot.ruor equivalent). Served by Modern.js's Node SSR runtime. Owns its own routing, meta tags, JSON-LD, analytics. This is what SEO crawlers, direct visitors, and backlinks hit. Satisfies: 1.1.1 (ModernJS SSR), 6 (SEO / JSON-LD / OG / availability), 7 (responsive), 4 (geo-distributed VMs behind a load balancer). -
Module Federation 2.0 remote — a static MF 2.0 remote with
mf-manifest.jsonexposing feature-level entries to channel apps (Web, PWA). Channel apps load and render these entries inside their own SSR/CSR flow. Satisfies: 1.1.2 (MF 2.0 + manifest), 1.1.3 (React 18 + Suspense +React.lazy), 7 (cross-platform embedding).
Both artifacts are built in CI from the same commit. A feature component imported from src/features/online-board/ renders identically in both modes because the feature never knows which mode hosts it — it depends only on a narrow HostContract interface that both modes implement.
1.2 Dependency direction (enforced by ESLint boundaries)
routes/ ─────────┐ (standalone entry only)
▼
features/ ◄──── mf/ (remote entry only)
│
┌────────────┼──────────────┐
▼ ▼ ▼
ui/ shared/ observability/
│ │
▼ ▼
i18n/ (otel, logger,
analytics)
features/ never imports routes/ or mf/. ui/ never imports features/. Both build targets can tree-shake each other's entry code cleanly.
1.3 src/ tree
src/
├── routes/ # Modern.js file-based routing (standalone mode only)
│ ├── [lang]/
│ │ ├── onlineboard/
│ │ │ ├── page.tsx # /{lang}/onlineboard (start)
│ │ │ ├── flight/[params]/page.tsx # /{lang}/onlineboard/flight/SU100-2025-01-15
│ │ │ ├── departure/[params]/page.tsx # /{lang}/onlineboard/departure/SVO-2025-01-15
│ │ │ ├── arrival/[params]/page.tsx # /{lang}/onlineboard/arrival/JFK-2025-01-15
│ │ │ ├── route/[params]/page.tsx # /{lang}/onlineboard/route/SVO-JFK-2025-01-15
│ │ │ └── [params]/page.tsx # /{lang}/onlineboard/SU100-2025-01-15 (details)
│ │ ├── schedule/
│ │ │ ├── page.tsx
│ │ │ ├── route/[params]/page.tsx
│ │ │ ├── route/[params]/[returnParams]/page.tsx
│ │ │ └── [...flights]/page.tsx # catch-all for multi-leg URLs
│ │ ├── flights-map/
│ │ │ ├── page.tsx
│ │ │ └── route/[params]/page.tsx
│ │ ├── popular/page.tsx
│ │ ├── page.tsx # / → redirect to /onlineboard
│ │ └── layout.tsx # locale resolver, canonical, hreflang, analytics
│ ├── error/[code]/page.tsx # 404, 500
│ ├── layout.tsx # root html shell, <head>, i18n provider
│ └── page.tsx # / → redirect to /{default-lang}/onlineboard
│
├── mf/ # MF remote entry (remote mode only)
│ ├── expose/
│ │ ├── OnlineBoard.tsx
│ │ ├── Schedule.tsx
│ │ ├── FlightsMap.tsx
│ │ └── PopularRequests.tsx
│ └── host-contract.ts # TypeScript interface the host must implement
│
├── features/
│ ├── online-board/
│ │ ├── components/ # ported Angular components, JSX, same class names
│ │ ├── hooks/ # useOnlineBoard, useLiveFlights
│ │ ├── api.ts # REST client for this feature
│ │ ├── url.ts # byte-exact URL serializer/parser
│ │ ├── seo.ts # JSON-LD + OG tag builders
│ │ └── index.ts # barrel — only public surface
│ ├── schedule/ (same shape)
│ ├── flights-map/ (same shape)
│ └── popular-requests/(same shape)
│
├── ui/ # PrimeReact adapter + Aeroflot SCSS
│ ├── primitives/ # Button, Input, Calendar, Autocomplete, …
│ ├── layout/ # SearchResultsLayout, DetailsLayout
│ ├── flights/ # 100+ ported flight-display components
│ ├── icons/ # SVG icons as React components
│ ├── seo/ # SeoHead
│ ├── errors/ # ErrorBoundary
│ └── styles/ # ported global SCSS, fonts, theme overrides
│
├── shared/
│ ├── api/ # ApiClient, LRU cache, circuit breaker
│ ├── signalr/ # @microsoft/signalr lifecycle wrapper
│ ├── hooks/ # useLiveFlights, useSearchHistory, useMediaQuery
│ ├── url/ # reusable URL codecs (date, flight-number)
│ └── utils/ # date/tz helpers, formatters
│
├── i18n/
│ ├── config.ts # i18next + react-i18next + i18next-icu setup
│ ├── locales/ # ported JSON files (9 languages)
│ └── resolver.ts # URL-prefix → language, SSR-aware
│
├── observability/
│ ├── logger/ # Logger interface + JSON-lines transport
│ ├── metrics/ # OpenTelemetry setup, OTLP exporter
│ └── analytics/ # facade + adapters (metrica, ctm, variocube, dynatrace)
│
└── env/ # runtime env-var resolution
Feature barrel rule: features/*/index.ts is the only public entry of each feature. Both routes/ and mf/expose/ import exclusively through the barrel. This is what makes feature-by-feature strangler-fig migration safe.
Location rule: ported Angular components from FlightsModule (the 100+ shared flight-display components) land in src/ui/flights/. Feature-specific components land in features/*/components/. The dividing line is "does more than one feature use it?"
1.4 URL language-prefix policy
Language prefix always required. /{lang}/onlineboard/… is the canonical form; missing-prefix URLs (/onlineboard) 301-redirect to the Accept-Language-detected locale. Every in-app link is generated with the prefix. Byte-exact parity with the Angular app's scheme.
2. Module Federation topology + build targets
2.1 Two build targets, one modern.config.ts
The repo has one modern.config.ts that switches behavior on BUILD_TARGET=standalone|remote, producing two distinct artifacts from the same source tree.
Standalone target (BUILD_TARGET=standalone)
- Entry: Modern.js conventional routing from
src/routes/. - Output: SSR Node server + client bundle + static assets.
- Deployed as: Dockerized Node service behind the load balancer.
- Serves:
flights.aeroflot.ru(direct visitors, SEO crawlers, deep links). - Plugins:
@modern-js/plugin-ssr,@modern-js/plugin-i18n.
Remote target (BUILD_TARGET=remote)
- Entry:
src/mf/index.ts(importssrc/mf/expose/*). - Output: Rspack bundle with
mf-manifest.json, ES module chunks, no Node server. - Deployed as: static files on CDN / object storage.
- Served at:
https://<cdn-origin>/mf-manifest.json+ chunk files. - Plugin:
@module-federation/modern-js(official Modern.js adapter for MF 2.0).
Why the remote is static, not SSR-serving. The SSR mandate in requirement 1.1.1 is fulfilled by the standalone mode. The remote mode is for embedding inside channel apps that run their own SSR — hosts call our exposed components at request time and SSR them inside their own process. Our job is to ship JS the host can import and render, not to operate a second SSR tier for embedded use. This is the conventional MF 2.0 pattern and keeps the remote's operational surface minimal.
2.2 mf-manifest.json and exposed modules
Generated by @module-federation/modern-js at build time. Served at https://<cdn-origin>/mf-manifest.json.
Exposes (feature-level, 4 entries):
name: "aeroflot_flights"
exposes:
./OnlineBoard → src/mf/expose/OnlineBoard.tsx
./Schedule → src/mf/expose/Schedule.tsx
./FlightsMap → src/mf/expose/FlightsMap.tsx
./PopularRequests → src/mf/expose/PopularRequests.tsx
Each expose is a thin wrapper: imports the feature barrel, accepts a hostContract prop, renders the feature root. All four use React.lazy() internally for code-splitting within the feature (requirement 1.1.3).
Why feature-level, not page-level or component-level. Page-level would explode the expose count and force the host to understand our internal routing. Component-level would couple the host to our UI adapter and kill independent deploys. Feature-level is the right granularity: one expose per "thing the channel app wants to embed," with internal routing handled inside the exposed component.
2.3 Shared dependencies
shared:
react: { singleton: true, requiredVersion: "^18.2.0" }
react-dom: { singleton: true, requiredVersion: "^18.2.0" }
react-i18next: { singleton: true }
i18next: { singleton: true }
@module-federation/runtime: { singleton: true }
Deliberately NOT shared (bundled inside the remote): primereact/*, src/ui/*, @microsoft/signalr, i18next-icu, locale JSON, @opentelemetry/*, leaflet.
React singleton is non-negotiable — mixing React instances breaks Suspense, Context, and Concurrent Mode in ways that are extremely hard to debug. Requirement 1.1.3 mandates Concurrent Mode, which forces singleton: true.
2.4 Host contract (src/mf/host-contract.ts)
The single interface the host must implement when embedding an exposed component. This is the entire embedding contract.
export interface HostContract {
/** Current language URL prefix, e.g. "ru", "en". Drives i18n + JSON-LD locale. */
locale: string;
/** Absolute base URL of the standalone site, used for canonical links + JSON-LD. */
canonicalOrigin: string;
/** Optional overrides for deep-link navigation inside the host's router.
* If omitted, the remote uses window.location. */
navigate?: (path: string) => void;
/** Optional analytics consent state. If omitted, consent is assumed true. */
consent?: { analytics: boolean; telemetry: boolean };
/** Optional host-provided logger to merge with ours (shipped to same backend). */
logger?: Logger;
}
Everything else — data fetching, SignalR, state, styling, SEO tags — is handled inside the remote.
2.5 Build artifacts and deploy shape
| Mode | Artifact | Deploy target | Runtime |
|---|---|---|---|
| Standalone | dist/standalone/ (Node server + client bundle + assets) |
Dockerized on geo-distributed VMs behind LB (req 4) | Node 20, Modern.js SSR |
| Remote | dist/remote/ (static: mf-manifest.json + JS chunks + SCSS) |
CDN / object storage, long-TTL on chunks, short-TTL on manifest | Static files only |
CI build: BUILD_TARGET=standalone modern build && BUILD_TARGET=remote modern build. Both artifacts produced from the same commit in the same CI job and deployed in lockstep.
SPEC ASSUMPTION (req 9): directory layout, exposed module names (./OnlineBoard etc.), and name: "aeroflot_flights" will be reconciled with the customer's standard remote-frontend template when it arrives. The reconciliation is a rename + manifest tweak, not a structural change.
3. SSR + routing + URL parity
3.1 SSR request lifecycle (standalone mode)
1. LB → geo-routed to nearest Modern.js VM
2. Node → Modern.js SSR entry receives the Request
3. Middleware → resolve language from URL prefix (src/i18n/resolver.ts)
→ attach request-scoped logger, trace id, otel span
→ consent/analytics flags (cookies)
4. Route match → Modern.js file router matches src/routes/[lang]/…
5. Loader → route's loader() runs server-side:
→ calls REST API via shared/api (lang + trace id forwarded)
→ returns { data, seo } to the component
6. Render → React 18 renderToPipeableStream with Suspense boundaries
→ <Head> emits <title>, meta, canonical, hreflang, JSON-LD, OG
→ streaming HTML flushes shell first, feature content as data resolves
7. Hydrate → client boots, i18next picks up SSR locale, features mount
→ useLiveFlights opens SignalR ONLY on client (never during SSR)
8. Response → HTML streamed; Cache-Control per route (§8.2)
3.2 Two SSR invariants, enforced
- No fetching in component bodies. Every request runs in a
loader()or auseEffect— never at module top-level, never in a component body. ESLint rule (react-hooks/exhaustive-deps+ custom rule banningapiClient./fetch(outsideloader/useEffect/ event handlers). Satisfies requirement 1.1.3 explicitly. - No SignalR on the server.
useLiveFlightsis a no-op during SSR and opens the websocket only afteruseEffectruns on the client. SSR delivers a data-complete initial render from REST; the websocket takes over for live updates after hydration.
3.3 Routing — Modern.js file-based
Route-order precedence matters in two cases:
- Online Board:
flight/:p,departure/:p,arrival/:p,route/:pare more specific than the bare:paramsdetails route, so they match first. Modern.js's static-before-dynamic rule handles this. - Schedule: the one-way and round-trip routes (
route/[params]androute/[params]/[returnParams]) are static-prefixed and must match before the catch-all[...flights].
Catch-all for multi-leg URLs. routes/[lang]/schedule/[...flights]/page.tsx captures the tail as an array of segments. The loader parses it with a ported TS serializer (§3.5) and fetches the legs in parallel.
Language guard. src/routes/[lang]/layout.tsx validates params.lang ∈ {ru,en,es,fr,it,ja,ko,zh,de} and issues a 404 otherwise.
3.4 Loaders, Suspense, and React.lazy
Every feature page is a React.lazy() import inside the route component:
// routes/[lang]/onlineboard/[params]/page.tsx
import { lazy, Suspense } from "react";
const FlightDetails = lazy(() =>
import("@/features/online-board").then(m => ({ default: m.FlightDetails }))
);
export async function loader({ params }) {
const parsed = parseFlightDetailsUrl(params.params); // feature/url.ts
const data = await onlineBoardApi.getFlight(parsed); // server-side REST
const seo = buildFlightDetailsJsonLd(data); // feature/seo.ts
return { data, seo, parsed };
}
export default function Page() {
const { data, seo, parsed } = useLoaderData<typeof loader>();
return (
<>
<SeoHead {...seo} />
<Suspense fallback={<FlightDetailsSkeleton />}>
<FlightDetails initialData={data} params={parsed} />
</Suspense>
</>
);
}
All three React-18 invariants from req 1.1.3 are enforced: loader pattern (no body side effects), Suspense boundary with skeleton, React.lazy dynamic import.
3.5 URL parity — ported serializers
Each feature has a url.ts module with pure functions:
// features/online-board/url.ts
export function parseFlightUrlParams(raw: string): FlightQuery; // "SU100-2025-01-15" → {...}
export function buildFlightUrlParams(q: FlightQuery): string; // {...} → "SU100-2025-01-15"
These are ports of the Angular URL resolvers (OnlineBoardFlightNumberUrlParamsResolver, ScheduleRouteResolver, etc.) translated to TypeScript. Byte-exact, pure, unit-tested.
Parity test suite compares their output to the Angular app's for every route the Angular unit tests cover, plus fuzz tests (date-format variants, flight-number formats, multi-leg URLs with 2–5 segments). Merge-blocking CI gate.
3.6 Canonicalization, hreflang, redirects
- Canonical: every feature page emits
<link rel="canonical" href="{canonicalOrigin}/{lang}/{path}">. In MF-remote mode,canonicalOrigincomes fromHostContract.canonicalOriginso the canonical always points at the standalone site, never the embedding host. This preserves SEO equity when embedded. - hreflang: each page emits one
<link rel="alternate" hreflang="{lang}">per language (9 total) +x-default→ru. Generated by a shared helper; CI parity test asserts the set is identical across all 9 language variants of a given route. - Redirects (all 301):
/→/{default-lang}/onlineboard;/{lang}→/{lang}/onlineboard; any missing-prefix URL →Accept-Language-detected equivalent.
3.7 Deferred decisions (resolved in the implementation plan, not here)
- Exact default-language resolution rule for missing-prefix URLs — will be decided against the current Angular behavior captured in Phase 0 access logs.
- Default
canonicalOriginconstant for MF-embedded mode whenHostContract.canonicalOriginis not supplied.
4. Data layer: REST, SignalR, state, hydration
4.1 REST client (src/shared/api/)
A single ApiClient — thin fetch wrapper, not a library.
- Isomorphic: same code on Node (SSR loaders) and browser.
- Language injection: every URL built from
(lang, endpoint, query). - Trace propagation: injects W3C
traceparent/tracestateheaders from the active OTel span. - Timeouts: 5s default, overridable per call.
ApiTimeoutErroron breach. - Retries: GET retries once on network error or 5xx with 200ms jitter. POST/PUT/DELETE never retry.
- Error typing:
ApiHttpError(status),ApiTimeoutError,ApiNetworkError— features switch on class, not on strings. - Circuit breaker: 5 consecutive failures → open for 30s → half-open probe → closed on success.
No Axios. No TanStack Query. No SWR.
4.2 Caching strategy
SSR request cache (per-request, discarded after response).
Map<string, Promise<T>> on the request context. Deduplicates concurrent apiClient.get(url) calls within one SSR render.
Client cache (per-tab, in-memory, TTL-bounded).
Custom ~80-line cache keyed by full URL. TTL: 30s for search results, 5m for static reference data (airports, cities). Replaces the Angular CacheService directly.
Shared server-side LRU cache (per-VM).
lru-cache@^10, in-memory, ~100MB byte cap via maxSize + sizeCalculation (default sizer: JSON.stringify(value).length; callers may pass a pre-computed size hint from the response content-length to avoid O(n) sizing on write). Keyed by (endpoint, queryParams, locale). TTL: 30s for live data, 5m for static data. Per-VM, not shared across VMs — no Redis in the frontend tier (adds failure modes). (@isaacs/ttlcache was considered but lacks byte-based caps — lru-cache@^10, also maintained by isaacs, is the correct primitive for a 100MB hard limit.)
4.3 SSR → client data handoff
- Server loader calls REST, returns
initialData. - Page component passes
initialDataas a prop to the feature root. - Feature root seeds local state from
initialDataon mount;useLiveFlightsthen opens the websocket (if applicable).
No double-fetching. Data is serialized into the HTML via Modern.js's useLoaderData hydration, not a custom window.__INITIAL_STATE__.
4.4 SignalR wrapper (src/shared/signalr/)
Two layers.
Layer 1 — SignalRConnection (pure TS). Singleton per hub URL. Reference-counted subscriptions. Single HubConnection with withAutomaticReconnect([0, 2000, 10000, 30000]). Grace-period close (5s after last unsubscribe) to survive React 18 Strict Mode double-mount and route-change thrash. Emits lifecycle events (connected, disconnected, reconnecting, reconnected) into logger and metrics. Never imported during SSR — dynamic-imported inside useLiveFlights's useEffect.
Layer 2 — useLiveFlights(params, initialFlights) hook.
function useLiveFlights(
params: { date: string; departure?: string; arrival?: string },
initialFlights: Flight[]
): { flights: Flight[]; connectionStatus: "idle"|"connecting"|"live"|"reconnecting"|"offline" };
Behavior:
- Initializes from
initialFlights(SSR payload). - On
useEffectmount: dynamic-importsSignalRConnection, subscribes to the channel. - On
RefreshDatepush: merges into local state with a reducer that preserves UI state (selected flight, expanded rows). - On unmount: unsubscribes. Grace-period close handles Strict Mode double-invoke correctly.
- SSR: returns
{ flights: initialFlights, connectionStatus: "idle" }without touching the SignalR module.
Vitest test simulates two rapid mounts and asserts a single HubConnection.start() call.
4.5 State management
No global store. No Redux, no Zustand, no Jotai.
useReducerfor feature-root filter + result state (ports AngularBehaviorSubjects to a single filter reducer).useStatefor local UI state.useContextfor request-scoped things: locale,HostContract,ApiClient.useSyncExternalStorefor subscribing toSignalRConnectionlifecycle events.
useSearchHistory (~60 lines, in shared/hooks/) replaces the Angular SearchHistoryService: per-language localStorage key, returns [history, push, clear].
4.6 Error handling path
- SSR loader throws
ApiHttpError(404)→ rendersroutes/error/[code]/page.tsxwithcode=404, HTTP response 404. - SSR loader throws
ApiHttpError(5xx)→ same, withcode=500, HTTP 500. - SSR loader throws
ApiTimeoutError→ "data temporarily unavailable" page with HTTP 503 +Retry-After, structured log aterror. - Client-side refetch error → inline error UI (red banner, "Retry" button); logs at
warn;flights.api.errorcounter. Never yanks the user to a full-page error. - SignalR disconnect → UI "offline" badge; keeps showing last-known data; logs at
info;flights.signalr.disconnectcounter.
5. UI adapter + styling + pixel-parity strategy
5.1 The src/ui/ boundary
Every visible DOM element is produced by a component from src/ui/. No feature file imports primereact/* directly, imports Leaflet directly, or writes raw styled form controls. Enforced by ESLint no-restricted-imports + a custom rule banning bare <button> / <input> in feature code.
This is the single swap point for when the customer's real design system arrives: reskin src/ui/ and feature code stays untouched.
5.2 Styling — SCSS Modules, no CSS-in-JS, no Tailwind
- SCSS Modules (
Component.module.scss) for component-scoped styles. Zero runtime cost. - Global SCSS for font-face, PrimeReact theme overrides, reset, CSS custom properties.
- No Tailwind, no styled-components, no emotion. Pixel parity + SSR complexity make them the wrong choice here.
- Class-name preservation. Ported Angular components emit HTML with identical class names. Enforced by VRT (§8.4) — mismatches surface as pixel diffs.
5.3 PrimeReact theming — port, don't rewrite
- Extract the current PrimeNG SCSS overrides from
ClientApp/src/styles/. - Rebase onto PrimeReact's theme structure. PrimeReact and PrimeNG share the "Prime" design system and CSS class-naming conventions (
p-button,p-autocomplete-panel,p-calendar-header) — selector overrides mostly just work. - Variable-rename diff: PrimeReact v10 uses CSS custom properties (
--primary-color) where PrimeNG v10 used SCSS variables ($primary-color). Convert the variable layer, leave selectors intact.
Expected failure cases — 5–15 PrimeReact components will render slightly off vs. their PrimeNG siblings due to DOM-structure differences. The hardest: Calendar (multi-month range picker), Autocomplete (suggestion panel), DataTable, Toast. Absorbed in src/ui/primitives/ wrappers + compensating CSS. Unresolved diffs go to docs/visual-parity-exceptions.md with a screenshot and reason, signed off at phase exit.
5.4 Per-component porting workflow
- Read Angular source (
.ts+.html+.scss+.spec.ts). - Translate template to JSX preserving DOM + class names:
*ngIf→{cond && <…>},*ngFor→.map(…),[class.x]→clsx(),(click)→onClick,{{expr}}→{expr}. - Translate logic: inputs/outputs → props, lifecycle →
useEffect, services → hooks or props, pipes → functions /useMemo. - Port
.scss→.module.scss. - Write Vitest unit test mirroring the Angular spec surface.
- Capture Playwright VRT baseline for the component in isolation.
- Diff against Angular via the parity harness.
Components port in waves by dependency order: primitives → layout → flight displays → feature pages.
5.5 Leaflet — separate adapter
src/features/flights-map/map/MapCanvas.tsx is the only file allowed to import leaflet. Dynamic-imported via React.lazy() so Leaflet's ~150kB doesn't bloat bundles for other features. SSR-safe via <ClientOnly> wrapper that returns null on the server. Tile URLs, marker icons, polyline styling, cluster behavior ported verbatim from the Angular component.
5.6 Fonts, icons, images
- Fonts: ported to
src/ui/styles/_typography.scss+public/fonts/.font-display: swappreserved. Self-hosted. - SVG icons: ported as React components via
@svgr/webpack(Rspack supports it). Typed, tree-shakeable. - Raster images: ported to
public/images/at current Angular paths so<img src>references don't need rewriting.
5.7 Responsiveness
- SCSS media queries port directly.
- Angular
BreakpointObserver→useMediaQueryhook (~15 lines, SSR-safe: returns desktop default on server). - VRT runs at 3 viewports: 375px (mobile), 768px (tablet), 1440px (desktop).
6. i18n + SEO + JSON-LD / OpenGraph
6.1 i18n runtime
Stack: i18next + react-i18next + i18next-icu + i18next-resources-to-backend.
Why: i18next-icu uses ICU MessageFormat — the same grammar as the Angular app's messageformat.js compiler. The 9 existing JSON translation files port with zero content changes. No re-authoring plurals, no translator round-trip.
6.2 Locale resolution
Standalone: Modern.js middleware reads params.lang, validates against {ru,en,es,fr,it,ja,ko,zh,de}, creates a request-scoped i18next instance loaded with only that language's JSON.
Remote: locale comes from HostContract.locale.
Hydration: SSR payload includes serialized locale bundle under window.__I18N__; client i18next boots synchronously before hydration. No flicker, no FOUC.
6.3 JSON file port
- Copy
ClientApp/src/assets/i18n/*.json→src/i18n/locales/{lang}/common.json. - Schema unchanged — keys, nesting, ICU syntax byte-for-byte.
interpolation.escapeValue: falseto match@ngx-translate. Offset by a lint rule in CI that scans for raw HTML /on\w+=/<script>in strings.- Single namespace
commonfor Phase 1. Feature namespaces can be introduced later.
6.4 Locale-aware formatting
- Replace Moment.js with
date-fns+date-fns-tz. Moment is maintenance-only;date-fnstree-shakes, has TS types, supports all 9 locales. - Custom Angular pipe logic (
DurationPipe,TzOffsetPipe,DayChangePipe,TransferTimePipe) ports tosrc/shared/utils/datetime/as pure functions, unit-tested with the fixtures from the Angular spec files. - Numbers:
Intl.NumberFormat. - Timezone data:
@date-fns/tz(bundled tzdata).
High-risk ports: formatTzOffset and formatDayChange. Both have DST edge cases. Contract tests replay real API responses against the Angular output and assert identical strings. CI blocks on any drift.
6.5 SEO — <head> management
No react-helmet / react-helmet-async. Modern.js's built-in <Head> / useHead works correctly with streaming SSR + Suspense.
<SeoHead> emits:
<title>,<meta name="description"><link rel="canonical">(absolute, always points at standalone site)- One
<link rel="alternate" hreflang>per language +x-default→ru - Open Graph (
og:title,og:description,og:url,og:image,og:type,og:locale,og:site_name) - Twitter Card (
twitter:card="summary_large_image",twitter:title,twitter:description,twitter:image) - One or more
<script type="application/ld+json">JSON-LD blocks
6.6 JSON-LD schema coverage
Each feature exports a seo.ts module with typed builders (types from schema-dts).
| Feature | Route | Schema |
|---|---|---|
| Online Board | Flight details | Flight (flightNumber, airline, airports, times, distance, status) |
| Online Board | Search results | ItemList of Flight |
| Schedule | Search results | ItemList of Flight per leg |
| Flights Map | Map page | WebPage + mainEntity: Place |
| Popular Requests | Listing | CollectionPage + ItemList |
| Every page (root) | — | WebSite + SearchAction |
Airport references emit Airport sub-entities with IATA code, name, addressCountry, geoCoordinates. Richer than the current Angular app's JSON-LD, driven by req 6.
Emission rule: JSON-LD is generated inside the loader (server-side) from the same REST data the component renders. Never generated on the client. Guarantees JSON-LD reflects what the crawler sees.
Validation: CI job runs a JSON-LD validator (schema-dts runtime validator or Google Structured Data Testing Tool) against fixture renders. Malformed JSON-LD blocks the build.
6.7 OpenGraph images
- Phase 1: static branded Aeroflot OG image in
public/og/default.png, used as fallback and on start pages. - Phase 2 enhancement: dynamic per-flight OG images via Satori, served from
routes/og/flight/[params]/image.tsx, cacheds-maxage=86400.
6.8 Canonical + hreflang correctness (the two landmines)
- Canonical in embedded mode still points at the standalone site — never the host's URL. The feature's
url.ts+HostContract.canonicalOriginbuild the correct URL regardless of embedding. hreflangmust be reciprocal. A shared helper generates the full set once; SSR renders it identically on every language variant. CI parity test renders all 9 variants and asserts thehreflangsets are byte-identical.
7. Observability — logging, metrics, analytics, errors
7.1 Three separate pipelines
| Pipeline | Producer | Consumer | Transport |
|---|---|---|---|
| Logs | Logger interface |
Customer log system | JSON-lines over HTTP POST (batched) |
| Metrics | @opentelemetry/api |
Metrics aggregator (OTLP-compatible) | OTLP/HTTP |
| Analytics | track(event, props) facade |
Metrica / CTM / Variocube / Dynatrace RUM | Vendor scripts, lazy-loaded |
7.2 Logging
Logger interface:
export interface Logger {
debug(msg: string, fields?: LogFields): void;
info(msg: string, fields?: LogFields): void;
warn(msg: string, fields?: LogFields): void;
error(msg: string, fields?: LogFields & { err?: Error }): void;
child(context: LogFields): Logger;
}
Default transport: JsonLinesHttpTransport. Buffers up to 100 records or 5s; POSTs application/x-ndjson; drops oldest on backpressure; final flush via navigator.sendBeacon (browser) or SIGTERM handler (Node); redacts password, token, authorization, cookie, email, phone fields.
Standard fields on every record via a request-root child():
ts, level, msg, service, mode, env, version,
trace_id, span_id, locale, route,
user_agent (browser), request_id (server)
Customer format hook. Requirement 8's log format is TBD. Default transport today = JsonLinesHttpTransport. When the customer's spec arrives, we write CustomerFormatTransport implements LogTransport and swap it at src/observability/logger/index.ts. Features and middleware don't change. SPEC ASSUMPTION: default transport will be replaced when the format lands.
7.3 Metrics — OpenTelemetry
SDK: @opentelemetry/api + @opentelemetry/sdk-node (server) + @opentelemetry/sdk-web (browser) + OTLP/HTTP exporters.
Auto-instrumentations: fetch / undici, http (server), router transitions.
Custom metrics (Phase 1 minimum):
flights.ssr.request.duration— histogram (labels: route, status, locale)flights.api.request.duration— histogram (labels: endpoint, status, feature)flights.api.error— counter (labels: endpoint, status, feature)flights.signalr.connected— up-down counter (labels: hub)flights.signalr.message.received— counter (labels: channel)flights.signalr.disconnect— counter (labels: reason)flights.feature.render— counter (labels: feature, mode)flights.web_vitals.{lcp,fid,cls,inp,ttfb}— histograms
OTLP endpoint: configurable (OTEL_EXPORTER_OTLP_ENDPOINT). Any OTLP-speaking aggregator works (Dynatrace, Grafana/Mimir, Prometheus-via-adapter).
Sampling: traces 100% in dev/testing, 10% head-based in prod; metrics 100% always; errors always 100%.
7.4 Analytics
Facade:
export interface Analytics {
track(event: string, props?: Record<string, unknown>): void;
page(url: string, props?: Record<string, unknown>): void;
}
Four vendor adapters in src/observability/analytics/adapters/: metrica.ts, ctm.ts, variocube.ts, dynatrace.ts.
Loading rules (strict):
- Never loaded during SSR. Vendor scripts are injected client-side only.
- Lazy-loaded post-hydration.
<AnalyticsLoader>on the root layout waits forrequestIdleCallback(or 2s fallback), then dynamic-imports each enabled adapter. - Consent-gated.
HostContract.consent.analytics === false(or standalone consent cookie = no) → no adapter loads,track()is a no-op. - Per-vendor feature flag.
ANALYTICS_ENABLED: { metrica, ctm, variocube, dynatrace }per env. Disabled vendors tree-shake out. - Fail silently. Load failure emits
flights.analytics.load_failedmetric; feature code never sees an exception.
Dynatrace dual role. Dynatrace (Ключ-Астром) is used as an analytics vendor only (RUM agent for user-behavior). Server-side metrics still go through OpenTelemetry → OTLP (§7.3). Dynatrace's OTLP ingress can still be the OTLP endpoint if the customer wants — configuration, not architecture.
7.5 Error handling layers
- SSR loader errors → Modern.js loader-error boundary →
routes/error/[code]/page.tsxwith HTTP status;error-level log; OTel span status ERROR. - Client React error boundaries — one at root layout, one per feature root. Port the Angular error component as the fallback UI. Log at
errorwitherr+ component stack;flights.react.errorcounter. Offer "Retry" that resets the boundary. - Global unhandled rejections / window errors — installed on client mount, forwarded to logger + metrics.
User-facing vs. unexpected: expected API errors (404 flight not found) → inline feature UI. Unexpected errors (API 500, render crash) → boundary fallback. Features explicitly classify.
7.6 Correlation
One W3C trace_id + span_id across logs, metrics, and analytics. An investigation can pivot from an error in logs → trace → metrics for that endpoint → the user's analytics session.
7.7 Client performance budgets (observability, not hard gates)
| Metric | Budget |
|---|---|
| LCP | < 2.5s p75 |
| INP | < 200ms p75 |
| CLS | < 0.1 p75 |
| TTFB | < 800ms p75 |
Reported via web-vitals → metrics pipeline. Regressions open issues; do not block CI.
7.8 Dev-mode ergonomics
- Logs in dev:
stdout/console.logviaConsoleTransport. - Metrics in dev: OTLP exporter points at a local collector + Grafana (optional docker-compose), auto-disabled if unreachable.
- Analytics in dev: all adapters forced off;
NoopAnalyticskeeps feature code paths exercised.
8. Security, performance, reliability, testing, CI/CD
8.1 Security — component isolation (req 5)
Sandbox controls in code:
- No
eval, nonew Function, nodocument.write. ESLintno-eval+ custom rule banninginnerHTML =in favor of React rendering. - No
postMessagelisteners that skipevent.originvalidation against aHostContract-supplied allowlist. - MF
chunkLoadingGlobalremains randomized (as in the Angularcustom-webpack.config.js). - localStorage namespace: all keys prefixed
aeroflot_flights_, scoped by language. Astorage.tswrapper is the only module allowed to touchwindow.localStorage(ESLint rule).
Content Security Policy (standalone SSR response, per-request nonce):
default-src 'self';
script-src 'self' 'nonce-{per-request}'
https://mc.yandex.ru https://ctm.example
https://variocube.example https://js.dynatrace.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://platform.aeroflot.ru
wss://platform.aeroflot.ru
https://{otlp-endpoint} https://mc.yandex.ru …;
font-src 'self' data:;
frame-ancestors https://*.aeroflot.ru;
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
Nonce is generated in SSR middleware and propagated via React Context to <Head>.
Remote mode doesn't set CSP itself (host's SSR does). Remote-exposed components emit no inline scripts, so the host's CSP doesn't need to allowlist us.
HTTP security headers:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: cross-origin
Supply-chain:
npm audit+osv-scannerevery CI run;high/criticalblock merge.- Dependabot weekly.
npm ciin CI; lockfile integrity required.package.jsonoverridesfor pinning vulnerable transitives.
Secrets: never in the frontend bundle. Log-shipping tokens, OTLP auth headers are server-only env vars.
8.2 Performance (req 3 — 100 RPS)
Target: 100 RPS per VM, p95 < 500ms under load. Aggregate capacity = N × 100 RPS via geo-distribution (req 4).
Mechanisms:
-
Loader-level caching — per-request dedup + per-VM LRU (
lru-cache@^10withmaxSizebyte cap, 100MB cap, 30s TTL live / 5m TTL static). -
HTTP cache headers by route type:
Route type Cache-ControlStart pages public, max-age=60, s-maxage=300, stale-while-revalidate=600Search results public, max-age=30, s-maxage=60, stale-while-revalidate=300Flight details public, max-age=60, s-maxage=120, stale-while-revalidate=600Error pages public, max-age=60OG images public, max-age=3600, s-maxage=86400Static assets (content-hashed) public, max-age=31536000, immutable -
CDN in front — SPEC ASSUMPTION: vendor is customer-chosen (likely Yandex Cloud CDN). Our cache headers are compatible with any standards-compliant CDN.
-
Bundle-size budget (CI-enforced):
- Initial client JS (above-the-fold): ≤ 180 kB gz (excluding React/react-dom singletons)
- Per-feature lazy chunk: ≤ 80 kB gz
- Leaflet chunk (flights-map only): ≤ 180 kB gz
- PrimeReact contribution: ≤ 60 kB gz
- CI runs a bundle analyzer on every PR; diffs against main; violations block merge.
-
Image handling:
loading="lazy"+decoding="async"+ explicitwidth/heightto prevent CLS. -
Streaming SSR: React 18
renderToPipeableStreamflushes shell +<head>first. -
Node runtime tuning: Node 20,
--max-old-space-size=2048,UV_THREADPOOL_SIZE=16, keep-alive HTTP agent for upstream REST.
Load test as a CI gate (nightly, not per-PR). k6 or autocannon hits a deployed staging instance with a production-mix (70% online board, 20% schedule, 5% flights-map, 5% popular), ramping to 150 RPS for 10 min. Thresholds: p95 < 500ms, error rate < 0.1%.
8.3 Reliability & fault tolerance (req 4)
Geographic distribution. Standalone SSR runs on ≥2 VMs in ≥2 AZs/regions behind L7 LB with /health checks. Remote mode is static files on CDN → geo-distributed by default.
/health endpoint. 200 OK if the process is up and the last upstream REST ping succeeded within 60s. 503 if upstream unreachable > 60s (serves stale cache, signals degradation to LB).
Graceful degradation ladder:
- Healthy — SSR + live SignalR + fresh REST.
- Cache-serving — upstream transient fail → LRU serves last-known-good ≤5 min;
warnlog;flights.api.cache_served_stalemetric. - SignalR degraded — hub unreachable → REST-only data, "offline" badge, feature still works.
- Full upstream outage — REST unreachable >5 min + empty cache → error page with
Retry-After. Never a white screen.
MTTR target: ≤60s after process crash (auto-restart via supervisor) — far tighter than req 6's 6h contractual ceiling.
Graceful shutdown. SIGTERM → stop accepting → drain in-flight (30s) → flush log buffer → exit.
Circuit breaker on upstream REST (§4.1) prevents cascade.
8.4 Testing strategy
Stack: Vitest (unit) + Playwright (e2e + VRT). No Karma, no Cypress.
Unit tests (Vitest + React Testing Library). Every pure function in features/*/url.ts, features/*/seo.ts, shared/utils/, observability/logger/, shared/api/. Every component in src/ui/primitives/ and src/ui/flights/ (renders without crash, responds to key props, handles empty/loading/error states). 70% line coverage on src/features/, src/shared/, src/ui/, src/observability/. Hard gate: coverage may not decrease in a PR. The SignalR wrapper includes a Strict-Mode double-mount test.
URL parity tests (Vitest, merge-blocking). Table-driven over URLs from the Phase 0 prod-access-log corpus. fast-check property fuzz tests (generated flight numbers, dates, IATA codes, multi-leg URLs). Any URL regression blocks merge.
Contract tests against real REST API (@contract tag, nightly hard gate, per-PR warn-only). zod schemas validate response shape against TypeScript types.
i18n parity test — render every route in every language, assert no missing-key warnings.
SEO tests — SSR-render each feature, parse with cheerio, assert <title>, <meta name="description">, canonical, full hreflang set, OG tags, JSON-LD parses + validates against schema-dts types, every <script> has a CSP nonce.
Integration tests (Playwright, standalone mode). Ported Cypress scenarios + new SignalR live-update test (mocked hub) + Flights Map load + Popular Requests + language switch + error pages (404, 500).
Visual regression tests (Playwright snapshots) — the pixel-parity gate.
- Baseline source: current Angular prod build.
scripts/refresh-baselines.tshits the Angular prod URL and writes PNGs totests/visual/baselines/. - Matrix for Phase 1: ~10 curated routes × 3 viewports (375/768/1440) × 2 languages (ru, en) = 60 baselines. Expanded per-feature per phase.
- Threshold: ≤ 0.1% pixel diff per image. Per-test
maskoption for dynamic regions (live flight times, map tiles). - CI gate: baseline violation blocks merge. Updates go through a PR regenerating baselines, reviewed explicitly.
- Phase-end re-baselining: when a feature's port is accepted, baselines regenerate from the React build and lock in — future diffs compare against the React baseline.
Performance tests (nightly): k6 / autocannon load test; Lighthouse CI with perf budget; bundle-size tracker graphs.
Accessibility (@axe-core/playwright). WCAG AA violations warn in Phase 1, block in Phase 2. (Angular baseline a11y state is unknown; we don't want to discover the Angular app fails WCAG and block our own rewrite.)
8.5 CI/CD
Provider: GitHub Actions. SPEC ASSUMPTION — if Aeroflot uses GitLab CI or TeamCity, pipelines port directly (pure Docker + npm scripts, no GH-specific glue).
Per-PR pipeline (< 20 min target):
1. Install (pnpm i --frozen-lockfile) ~90s
2. Lint (eslint . --cache) ~30s
3. Type check (tsc --noEmit) ~60s
4. Unit tests (vitest run --coverage) ~120s
5. URL parity tests ~20s
6. SEO tests ~40s
7. Build both targets (standalone + remote) ~180s
8. Bundle-size gate ~10s
9. Integration tests (Playwright) ~240s
10. Visual regression ~180s
11. Security scan (osv-scanner, npm audit) ~60s
Nightly: contract tests vs. staging, load test, Lighthouse CI, Dependabot.
Deploy pipeline (on merge to main):
- Reuse PR-built artifacts (don't rebuild).
- Deploy standalone to canary VM (1 of N), 5% traffic slice.
- Automated canary analysis (15 min): compare error rate, p95 latency,
flights.react.errorto previous-deploy baseline. Auto-rollback on > 2× regression. - Roll out remaining VMs sequentially with health-check waits.
- Deploy remote to CDN with
mf-manifest.jsonversion bump. - Post-deploy smoke test hits the deployed URL.
Environments: development (local), testing (CI-shared), staging (prod-mirror), production (canary-gated). Env config at runtime (src/env/), not build-time — same artifact redeployable across envs.
9. Strangler-fig migration phasing
9.1 Operating model
The current Angular app stays in prod throughout. Each phase delivers a vertical slice that ships to production behind a route-level reverse-proxy toggle: specific URL prefixes route to React, the rest continue to hit Angular. By Phase 6 the Angular app serves zero traffic and is decommissioned.
Proxy routing:
/{lang}/onlineboard/* → React (after Phase 2)
/{lang}/schedule/* → React (after Phase 3)
/{lang}/flights-map/* → React (after Phase 4)
/{lang}/popular/* → React (after Phase 5)
/* → Angular (default until Phase 6)
Every phase cutover is reversible — flipping the proxy rule restores Angular traffic for that route. SPEC ASSUMPTION: the customer controls the edge routing layer; we provide route rules as config, not infra.
9.2 Phases
Phase 0 — Preflight (≈1 week, no production change)
- Capture current URLs from Angular prod access logs → URL parity corpus.
- Capture current JSON-LD / OG / hreflang on ~20 representative routes → SEO parity baselines.
- Capture Playwright VRT baselines of the Angular prod build (60 images per §8.4).
- Inventory PrimeNG components, SCSS tokens, theme overrides →
src/ui/backlog. - Inventory
@ngx-translatekeys actually in use → translation-file port manifest. - Confirm customer decisions flagged as assumptions: module template (§2.5), CDN vendor (§8.2), CI provider (§8.5), log format (§7.2).
Exit gate: Phase 0 deliverables committed as fixtures. No React code yet.
Phase 1 — Foundation (≈4 weeks, no production change)
Build everything needed to ship one feature safely. Nothing ships to users.
In scope:
- Modern.js + Rspack project skeleton; both build targets working end-to-end with
mf-manifest.json. src/ui/adapter layer with PrimeReact primitives + Aeroflot SCSS theme (enough to support Online Board; more added as later phases need).src/i18n/with i18next + i18next-icu, 9 locale files ported.src/shared/api/ApiClient + LRU + circuit breaker + request-scoped dedup.src/shared/signalr/wrapper with Strict-Mode tests (no feature wired).src/observability/— Logger, OTel, analytics facade with all four adapters (stubbed loaders).- Root layout:
<Head>, locale provider, analytics loader, error boundary, canonical + hreflang helper. routes/error/404/500 pages.- URL / SEO / VRT parity harnesses (infra only).
- CI pipeline (§8.5) with bundle-size gate.
- Deploy to
testingwith canary placeholder. - Security hardening: CSP, HTTP headers, supply-chain scan.
- Smoke route
routes/[lang]/smoke/page.tsxexercising the full foundation end-to-end.
Out of scope: any of the 4 features, production traffic, proxy rule change.
Exit gate: both artifacts built in CI; mf-manifest.json consumable by a test host; smoke route SSRs in testing with all observability pipelines emitting correctly; all Phase 1 CI gates green on main; security scan clean.
Phase 2 — Online Board feature (≈5 weeks)
Port the hardest feature first (SignalR live data + deep-linked search + flight details + real-time UI + SEO).
In scope:
src/features/online-board/— Start, Search (arrival / departure / route / flight), Flight Details.useLiveFlightswired to realTrackerHubwith full lifecycle + Strict Mode correctness.- All
routes/[lang]/onlineboard/*routes with loaders, Suspense,React.lazy. - URL parity tests for every
onlineboard/*shape from the Phase 0 corpus → 100% pass required. - SEO parity: canonical, hreflang, OG, JSON-LD (
Flight+ItemList). - Playwright integration tests (4 ported Cypress + SignalR mock + error cases).
- Playwright VRT for all online-board routes × 3 viewports × 2 langs.
- The subset of
src/ui/flights/Online Board uses. - Real analytics vendors enabled in
testing+staging. - Load test for online-board routes at 150 RPS.
Cutover:
- Deploy to
staging; run full test suite + load test + SEO audit. - Canary 5% of
/{lang}/onlineboard/*prod traffic for 24h (request-id hash bucket behind proxy); rest stays on Angular. - Monitor: error rate, p95 latency,
flights.react.error,flights.api.error, SignalR health, Web Vitals, Search Console crawl errors. - If clean: 25% → 50% → 100% over 72h, always reversible.
- Hold at 100% for 1 week. Then retire (not delete) Angular online-board code.
Exit gate: 100% of /{lang}/onlineboard/* on React for 1 week clean; URL parity 100% verified against prod access logs; VRT exceptions signed off.
Phase 3 — Schedule feature (≈4 weeks)
Port the feature with the most complex routing (variable-length multi-flight catch-all) and the heaviest Calendar usage.
In scope:
src/features/schedule/— Start, Search (one-way / round-trip), Flight Details (multi-leg).- Catch-all
routes/[lang]/schedule/[...flights]/page.tsxwith ported serializer. - PrimeReact Calendar range picker + Aeroflot theme overrides (the hardest pixel-parity target — deliberately isolated to this phase so its risk doesn't compound with SignalR from Phase 2).
- Full URL parity including multi-leg fuzz corpus.
- SEO: JSON-LD (
ItemListofFlightper leg). - VRT expanded to all schedule routes.
Cutover: same canary mechanics, scoped to /{lang}/schedule/*.
Exit gate: 100% of /{lang}/schedule/* on React for 1 week clean; Calendar pixel-parity delta ≤ threshold or documented.
Phase 4 — Flights Map feature (≈3 weeks)
Port the Leaflet visualization. Isolated because Leaflet has its own failure modes.
In scope:
src/features/flights-map/— Start + Map Search Results.- Leaflet wrapper (only file allowed to
import "leaflet"). <ClientOnly>boundary.- Tile URLs, marker icons, clustering ported byte-equivalent.
- Feature flag:
env.features.flightsMapmatches Angular'sFeatureFlagGuardbehavior per env. - VRT masks the tile area (time-varying); tests UI chrome only.
- Integration test: click a route, verify marker + polyline rendering.
Cutover: same mechanics.
Exit gate: 100% of /{lang}/flights-map/* on React (where flag on), feature-flag parity verified, 1 week clean.
Phase 5 — Popular Requests + last-cleanup chrome (≈2 weeks)
In scope:
src/features/popular-requests/.- Language switcher (ported from Angular layout).
- Footer / header chrome if not already in Phase 1 root layout.
useSearchHistorywired tolocalStoragewith per-language namespacing.- Any components Phase 0 missed that surface during porting.
Cutover: scoped to /{lang}/popular/*. Chrome changes go live with the Phase 5 deploy across all React-served routes.
Exit gate: 100% of /{lang}/popular/* on React; no route remains Angular-served except the root / redirect and language-prefix fallbacks.
Phase 6 — Cutover + decommission (≈2 weeks)
- Flip default proxy rule:
/*→ React. Angular backend receives zero traffic. - 1 week soak confirming zero Angular hits.
- Tag Angular code in git; archive
ClientApp/(separate branch / archived repo). - Decide ASP.NET host fate: SPEC ASSUMPTION → delete unless it serves something unrelated to the frontend; confirm at Phase 6 kickoff.
- Simplify / remove reverse-proxy split rules.
- Post-mortem → fed into a "lessons learned" appendix of this document.
Exit gate: zero Angular traffic for 1 week; Angular archived; prod error rate ≤ pre-migration baseline.
9.3 Cross-cutting practices
Per-phase parity contract — every exit gate must prove:
- URL parity — every URL the Angular app served for this feature still works byte-exactly.
- SEO parity — JSON-LD / OG / hreflang / canonical equivalent to or richer than Angular's.
- Visual parity — VRT diffs below threshold, or documented exceptions.
Non-negotiable.
Rollback window. Every phase's "flip-back-to-Angular" proxy rule is retained for 2 weeks after reaching 100% React. Gives a hot-rollback window and a longer cold-rollback via redeploy.
Communication rhythm. Each phase kickoff produces a 1-pager (scope, risks, exit gate). Each phase end produces a ≤1-page retro. Stored in docs/superpowers/migration/phase-{n}.md.
Team-size sanity check (not a contract): with 2 full-time frontend engineers, the calendar estimates above ≈ 20 weeks total. With 1 engineer, ~1.8× longer. With 3+, Phases 2/3/4 can partially parallelize, but the critical path stays ~16 weeks due to prod-soak at each gate.
9.4 Explicit non-promises
- No parallel feature development. Phase 2 must reach its exit gate before Phase 3 starts.
- No green-field redesigns. Each phase is a port. New UX is stacked after Phase 6.
- No API changes. REST contract unchanged; any backend evolution is a separate project.
10. Spec assumptions — reconciliation list
These are the explicit assumptions this spec makes about decisions that are not yet confirmed by the customer. Each will be tightened at Phase 0 or when the information arrives.
| # | Assumption | Spec section | Impact if wrong |
|---|---|---|---|
| A1 | Customer's "standard remote-frontend module template" not yet available → design to Modern.js default MF remote layout | §2.5, §1.3 | Directory rename + manifest tweak, not a structural change |
| A2 | CDN vendor is customer-chosen (likely Yandex Cloud CDN); our cache headers are standards-compliant and work with any | §8.2 | Cache header tuning per CDN specifics |
| A3 | CI provider is GitHub Actions; if GitLab CI / TeamCity instead, pipelines port directly | §8.5 | Rewrite of pipeline YAML, no code change |
| A4 | Customer log format is TBD; JsonLinesHttpTransport is the interim transport; a new CustomerFormatTransport will swap in when the format lands |
§7.2 | New transport module, no feature change |
| A5 | The ASP.NET host (Aeroflot.Flights.Web.csproj) can be deleted at Phase 6 unless it serves something unrelated to the frontend |
§9.2 (Phase 6) | May need to keep it running if it has non-frontend responsibilities |
| A6 | Dynatrace RUM is used for analytics only; OTel → OTLP remains the metrics path | §7.4 | None — this is an architectural guarantee, not an assumption about customer intent |
| A7 | Default-language resolution for missing-prefix URLs matches Angular's current behavior | §3.7 | Verified against Phase 0 access-log capture |
11. What this spec satisfies vs. requirements
| Req | Topic | Section(s) |
|---|---|---|
| 1.1.1 | ModernJS SSR | §1.1, §2.1, §3.1 |
| 1.1.2 | MF 2.0 + mf-manifest.json + any MF 2.0 bundler (Rspack) |
§2.1, §2.2 |
| 1.1.3 | React 18 Concurrent Mode, <Suspense>, no body side effects, React.lazy |
§3.2, §3.4, §4.4 |
| 2 | REST API, JSON | §4.1 |
| 2b | Rendered data consistent with API | §4.3, §4.4 |
| 3 | 100 RPS | §8.2 |
| 4 | Geo-distributed VMs, fault tolerance | §8.3 |
| 5 | Security / isolation | §8.1 |
| 6 | 24/7/365, ≤6h MTTR | §8.3 |
| 6 | SEO | §3.6, §6.5, §6.6, §6.8 |
| 6 | JSON-LD + OpenGraph | §6.5, §6.6, §6.7 |
| 6 | Analytics (Metrica / CTM / Variocube / Dynatrace) | §7.4 |
| 7 | Cross-platform Web/PWA embedding | §2.1, §2.4 |
| 7 | Responsive / fluid layout | §5.7 |
| 8 | Logging to customer system | §7.2 |
| 8 | System-event monitoring to aggregator | §7.3 |
| 9 | Customer module template | §2.5 (spec assumption A1) |
| 10 | Design system / mockups | §5 (pixel parity with Angular as Phase 1 target, swap-point for future DS) |
| 10 | Embed other remote components | §2.4, §5.1 |
12. Open items deferred to the implementation plan
The implementation plan (written after this spec is approved) will resolve:
- Default-language rule for missing-prefix URLs (§3.7).
- Default
canonicalOriginconstant for MF-embedded mode whenHostContract.canonicalOriginis absent (§3.7). - Per-route Cache-Control tuning after real traffic measurements (§8.2).
- Phase-1 vs. Phase-2 split of
src/ui/flights/components based on which feature ports them first. - Exact consent-cookie name and schema for the standalone site (§7.4).
- Per-vendor analytics event schema (event names, property conventions) — decided with stakeholder review in Phase 0 / Phase 2.
These are implementation details that don't affect architecture and don't need to be decided in this document.