Files
flights_web/docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md
T
gnezim f309f62553 Swap server LRU cache to lru-cache v10 for byte-based cap
@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.
2026-04-14 21:37:43 +03:00

61 KiB
Raw Blame History

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.js for i18n (ICU MessageFormat).
  • Application Insights for telemetry.
  • SignalR TrackerHub for live flight updates (SubscribeDate(flightDate, departure, arrival) → pushed RefreshDate events).
  • custom-webpack.config.js randomizes chunkLoadingGlobal — 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 custom UrlMatcher.
  • Tests: ~175 Vitest-equivalent .spec.ts files (modest coverage), 5 Cypress E2E scenarios (currently not passing), no Storybook stories authored.

0.2 Customer requirements (summary)

  1. 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).
  2. Data: REST API, JSON; rendered data must stay consistent with API.
  3. Performance: 100 RPS.
  4. Reliability: Geo-distributed VMs.
  5. Security: Component must be isolated — no threat to other site components.
  6. SEO & availability: 24/7/365, ≤6h MTTR; SEO optimization, JSON-LD + OpenGraph; analytics via Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром (Dynatrace).
  7. Cross-platform: Embeddable in multiple channel apps (Web, PWA); fluid responsive layout.
  8. 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.
  9. 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).
  10. 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/:

  1. Standalone SSR site — the public entry point (flights.aeroflot.ru or 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).

  2. Module Federation 2.0 remote — a static MF 2.0 remote with mf-manifest.json exposing 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 (imports src/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

  1. No fetching in component bodies. Every request runs in a loader() or a useEffect — never at module top-level, never in a component body. ESLint rule (react-hooks/exhaustive-deps + custom rule banning apiClient. / fetch( outside loader / useEffect / event handlers). Satisfies requirement 1.1.3 explicitly.
  2. No SignalR on the server. useLiveFlights is a no-op during SSR and opens the websocket only after useEffect runs 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/:p are more specific than the bare :params details route, so they match first. Modern.js's static-before-dynamic rule handles this.
  • Schedule: the one-way and round-trip routes (route/[params] and route/[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 25 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, canonicalOrigin comes from HostContract.canonicalOrigin so 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-defaultru. 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 canonicalOrigin constant for MF-embedded mode when HostContract.canonicalOrigin is 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 / tracestate headers from the active OTel span.
  • Timeouts: 5s default, overridable per call. ApiTimeoutError on 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

  1. Server loader calls REST, returns initialData.
  2. Page component passes initialData as a prop to the feature root.
  3. Feature root seeds local state from initialData on mount; useLiveFlights then 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:

  1. Initializes from initialFlights (SSR payload).
  2. On useEffect mount: dynamic-imports SignalRConnection, subscribes to the channel.
  3. On RefreshDate push: merges into local state with a reducer that preserves UI state (selected flight, expanded rows).
  4. On unmount: unsubscribes. Grace-period close handles Strict Mode double-invoke correctly.
  5. 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.

  • useReducer for feature-root filter + result state (ports Angular BehaviorSubjects to a single filter reducer).
  • useState for local UI state.
  • useContext for request-scoped things: locale, HostContract, ApiClient.
  • useSyncExternalStore for subscribing to SignalRConnection lifecycle 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) → renders routes/error/[code]/page.tsx with code=404, HTTP response 404.
  • SSR loader throws ApiHttpError(5xx) → same, with code=500, HTTP 500.
  • SSR loader throws ApiTimeoutError → "data temporarily unavailable" page with HTTP 503 + Retry-After, structured log at error.
  • Client-side refetch error → inline error UI (red banner, "Retry" button); logs at warn; flights.api.error counter. Never yanks the user to a full-page error.
  • SignalR disconnect → UI "offline" badge; keeps showing last-known data; logs at info; flights.signalr.disconnect counter.

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

  1. Extract the current PrimeNG SCSS overrides from ClientApp/src/styles/.
  2. 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.
  3. 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 — 515 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

  1. Read Angular source (.ts + .html + .scss + .spec.ts).
  2. Translate template to JSX preserving DOM + class names: *ngIf{cond && <…>}, *ngFor.map(…), [class.x]clsx(), (click)onClick, {{expr}}{expr}.
  3. Translate logic: inputs/outputs → props, lifecycle → useEffect, services → hooks or props, pipes → functions / useMemo.
  4. Port .scss.module.scss.
  5. Write Vitest unit test mirroring the Angular spec surface.
  6. Capture Playwright VRT baseline for the component in isolation.
  7. 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: swap preserved. 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 BreakpointObserveruseMediaQuery hook (~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/*.jsonsrc/i18n/locales/{lang}/common.json.
  • Schema unchanged — keys, nesting, ICU syntax byte-for-byte.
  • interpolation.escapeValue: false to match @ngx-translate. Offset by a lint rule in CI that scans for raw HTML / on\w+= / <script> in strings.
  • Single namespace common for 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-fns tree-shakes, has TS types, supports all 9 locales.
  • Custom Angular pipe logic (DurationPipe, TzOffsetPipe, DayChangePipe, TransferTimePipe) ports to src/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-defaultru
  • 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, cached s-maxage=86400.

6.8 Canonical + hreflang correctness (the two landmines)

  1. Canonical in embedded mode still points at the standalone site — never the host's URL. The feature's url.ts + HostContract.canonicalOrigin build the correct URL regardless of embedding.
  2. hreflang must 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 the hreflang sets 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):

  1. Never loaded during SSR. Vendor scripts are injected client-side only.
  2. Lazy-loaded post-hydration. <AnalyticsLoader> on the root layout waits for requestIdleCallback (or 2s fallback), then dynamic-imports each enabled adapter.
  3. Consent-gated. HostContract.consent.analytics === false (or standalone consent cookie = no) → no adapter loads, track() is a no-op.
  4. Per-vendor feature flag. ANALYTICS_ENABLED: { metrica, ctm, variocube, dynatrace } per env. Disabled vendors tree-shake out.
  5. Fail silently. Load failure emits flights.analytics.load_failed metric; 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

  1. SSR loader errors → Modern.js loader-error boundary → routes/error/[code]/page.tsx with HTTP status; error-level log; OTel span status ERROR.
  2. Client React error boundaries — one at root layout, one per feature root. Port the Angular error component as the fallback UI. Log at error with err + component stack; flights.react.error counter. Offer "Retry" that resets the boundary.
  3. 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.log via ConsoleTransport.
  • 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; NoopAnalytics keeps feature code paths exercised.

8. Security, performance, reliability, testing, CI/CD

8.1 Security — component isolation (req 5)

Sandbox controls in code:

  • No eval, no new Function, no document.write. ESLint no-eval + custom rule banning innerHTML = in favor of React rendering.
  • No postMessage listeners that skip event.origin validation against a HostContract-supplied allowlist.
  • MF chunkLoadingGlobal remains randomized (as in the Angular custom-webpack.config.js).
  • localStorage namespace: all keys prefixed aeroflot_flights_, scoped by language. A storage.ts wrapper is the only module allowed to touch window.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-scanner every CI run; high / critical block merge.
  • Dependabot weekly.
  • npm ci in CI; lockfile integrity required.
  • package.json overrides for 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:

  1. Loader-level caching — per-request dedup + per-VM LRU (lru-cache@^10 with maxSize byte cap, 100MB cap, 30s TTL live / 5m TTL static).

  2. HTTP cache headers by route type:

    Route type Cache-Control
    Start pages public, max-age=60, s-maxage=300, stale-while-revalidate=600
    Search results public, max-age=30, s-maxage=60, stale-while-revalidate=300
    Flight details public, max-age=60, s-maxage=120, stale-while-revalidate=600
    Error pages public, max-age=60
    OG images public, max-age=3600, s-maxage=86400
    Static assets (content-hashed) public, max-age=31536000, immutable
  3. CDN in frontSPEC ASSUMPTION: vendor is customer-chosen (likely Yandex Cloud CDN). Our cache headers are compatible with any standards-compliant CDN.

  4. 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.
  5. Image handling: loading="lazy" + decoding="async" + explicit width/height to prevent CLS.

  6. Streaming SSR: React 18 renderToPipeableStream flushes shell + <head> first.

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

  1. Healthy — SSR + live SignalR + fresh REST.
  2. Cache-serving — upstream transient fail → LRU serves last-known-good ≤5 min; warn log; flights.api.cache_served_stale metric.
  3. SignalR degraded — hub unreachable → REST-only data, "offline" badge, feature still works.
  4. 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.ts hits the Angular prod URL and writes PNGs to tests/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 mask option 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):

  1. Reuse PR-built artifacts (don't rebuild).
  2. Deploy standalone to canary VM (1 of N), 5% traffic slice.
  3. Automated canary analysis (15 min): compare error rate, p95 latency, flights.react.error to previous-deploy baseline. Auto-rollback on > 2× regression.
  4. Roll out remaining VMs sequentially with health-check waits.
  5. Deploy remote to CDN with mf-manifest.json version bump.
  6. 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-translate keys 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 testing with canary placeholder.
  • Security hardening: CSP, HTTP headers, supply-chain scan.
  • Smoke route routes/[lang]/smoke/page.tsx exercising 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.
  • useLiveFlights wired to real TrackerHub with 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:

  1. Deploy to staging; run full test suite + load test + SEO audit.
  2. Canary 5% of /{lang}/onlineboard/* prod traffic for 24h (request-id hash bucket behind proxy); rest stays on Angular.
  3. Monitor: error rate, p95 latency, flights.react.error, flights.api.error, SignalR health, Web Vitals, Search Console crawl errors.
  4. If clean: 25% → 50% → 100% over 72h, always reversible.
  5. 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.tsx with 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 (ItemList of Flight per 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.flightsMap matches Angular's FeatureFlagGuard behavior 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.

In scope:

  • src/features/popular-requests/.
  • Language switcher (ported from Angular layout).
  • Footer / header chrome if not already in Phase 1 root layout.
  • useSearchHistory wired to localStorage with 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:

  1. URL parity — every URL the Angular app served for this feature still works byte-exactly.
  2. SEO parity — JSON-LD / OG / hreflang / canonical equivalent to or richer than Angular's.
  3. 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 canonicalOrigin constant for MF-embedded mode when HostContract.canonicalOrigin is 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.