From b4a41cc801520c5816b502dfd25f871be1e286de Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 18:21:35 +0300 Subject: [PATCH 001/138] Add .nvmrc file and update package.json with main, keywords, license, and description fields --- .nvmrc | 1 + ClientApp/package.json | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..b6a7d89c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16 diff --git a/ClientApp/package.json b/ClientApp/package.json index 7b37a471..f52c22e3 100644 --- a/ClientApp/package.json +++ b/ClientApp/package.json @@ -93,5 +93,9 @@ "Android > 4.3", "iOS > 9", "Edge > 13" - ] + ], + "main": ".eslintrc.js", + "keywords": [], + "license": "ISC", + "description": "" } -- 2.34.1 From 75dbec0737572056cc2290b99f9487634aacad89 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 19:35:31 +0300 Subject: [PATCH 002/138] Add design spec for Angular-to-React MF remote rewrite Captures the dual-mode Modern.js architecture, strangler-fig phasing, parity contracts, and customer-requirement mapping so implementation plans can be derived per phase without re-litigating architectural decisions. --- ...4-aeroflot-flights-react-rewrite-design.md | 1073 +++++++++++++++++ 1 file changed, 1073 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md diff --git a/docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md b/docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md new file mode 100644 index 00000000..507f32a6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md @@ -0,0 +1,1073 @@ +# 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 `BehaviorSubject`s. +- **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 ``, 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, , 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:///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:///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. + +```ts +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 + → emits , 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: + +```tsx +// 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: + +```ts +// 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, `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-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 `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).** +`@isaacs/ttlcache`, in-memory, ~100MB cap. 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). + +### 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.** + +```ts +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 `BehaviorSubject`s 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** — 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 + +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 `BreakpointObserver` → `useMediaQuery` 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/*.json` → `src/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-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`, 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:** + +```ts +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:** + +```ts +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 (`@isaacs/ttlcache`, 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 front** — **SPEC 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. + +#### 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. +- `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. -- 2.34.1 From 013fad6236f39191dd73fb8c605dc8d9652c539c Mon Sep 17 00:00:00 2001 From: gnezim <gnezim@gmail.com> Date: Tue, 14 Apr 2026 19:47:54 +0300 Subject: [PATCH 003/138] Add Phase 0 (Preflight) implementation plan 16 tasks covering URL corpus extraction, SEO + hreflang + VRT baseline capture from Angular prod, PrimeNG/SCSS/i18n inventories, and the customer confirmation checklist. Phase 0 is discovery-only; no production change. Output is the fixture + inventory set Phase 1 sub-plans consume. --- .../plans/2026-04-14-phase-0-preflight.md | 2246 +++++++++++++++++ 1 file changed, 2246 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-phase-0-preflight.md diff --git a/docs/superpowers/plans/2026-04-14-phase-0-preflight.md b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md new file mode 100644 index 00000000..d5ad1217 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md @@ -0,0 +1,2246 @@ +# Phase 0 — Preflight Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Capture everything needed from the current Angular prod app (URL corpus, SEO baselines, VRT baselines, component inventories, translation keys) and confirm customer-side spec assumptions, so Phase 1 foundation work can proceed without blockers. + +**Architecture:** Discovery-only phase. No production change. Produces committed fixtures under `tests/fixtures/phase-0/` and inventory documents under `docs/superpowers/phase-0/` that Phase 1 and later phases depend on. Introduces a minimal top-level `package.json` + `scripts/phase-0/` at the repo root; no framework or runtime is installed beyond what the capture scripts need. + +**Tech Stack:** Node 20, pnpm, TypeScript + `tsx`, Playwright, `cheerio`, `zod`, `schema-dts`, Vitest (for the few pure-function tests in this phase). + +**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` — Phase 0 is described in §9.2 of that document. + +--- + +## Prerequisites — block Phase 0 start until resolved + +These are not tasks; they are gates. Confirm each before running Task 1. + +- [ ] **Production URL access.** The capture scripts fetch pages from the live Angular app. Get the production URL (likely `https://flights.aeroflot.ru` or equivalent). If prod is rate-limited, get an unthrottled staging mirror URL that is byte-equivalent to prod. +- [ ] **Access-log availability decision.** The URL-corpus task is ideally driven by anonymized prod access logs. If those are unavailable, fall back to enumerating Angular route definitions directly (Task 4 supports both modes). +- [ ] **Customer point-of-contact.** Identify who will answer the spec assumptions A1–A5 listed in `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` §10. Task 16 produces the questionnaire; a human must deliver it. + +--- + +## File structure + +Files created by Phase 0: + +**Repo root — minimal project skeleton (extended in Phase 1):** +``` +package.json # minimal — only scripts/phase-0 deps +pnpm-lock.yaml # frozen +pnpm-workspace.yaml # excludes ClientApp/ from workspace +.nvmrc # 20.11.0 +tsconfig.json # root TS config for scripts +.gitignore # ignores dist/, node_modules/, playwright-report/, tests/fixtures/phase-0/vrt-baselines/raw/ +playwright.phase0.config.ts # Phase-0-only Playwright config +``` + +**Phase 0 scripts:** +``` +scripts/phase-0/lib/angular-routes.ts # list of Angular route shapes (source of truth for URL enumeration) +scripts/phase-0/lib/http.ts # small fetch wrapper with retry + throttle +scripts/phase-0/lib/io.ts # read/write JSON + PNG helpers +scripts/phase-0/anonymize-access-logs.ts # optional: raw log → anonymized URL list +scripts/phase-0/extract-url-corpus.ts # writes url-corpus/*.json from routes + optional logs +scripts/phase-0/capture-seo-baselines.ts # fetches pages, extracts <head> + JSON-LD +scripts/phase-0/capture-hreflang-parity.ts # verifies reciprocal hreflang on every language variant +scripts/phase-0/capture-vrt-baselines.ts # Playwright driver for VRT baseline capture +scripts/phase-0/inventory-primeng.ts # scans ClientApp/ for PrimeNG usages +scripts/phase-0/inventory-scss-tokens.ts # extracts SCSS variable + theme override list +scripts/phase-0/inventory-translation-keys.ts # finds @ngx-translate keys actually referenced in templates/TS +``` + +**Committed fixtures:** +``` +tests/fixtures/phase-0/url-corpus/onlineboard.json # enumerated + observed URLs +tests/fixtures/phase-0/url-corpus/schedule.json +tests/fixtures/phase-0/url-corpus/flights-map.json +tests/fixtures/phase-0/url-corpus/popular.json +tests/fixtures/phase-0/seo-baselines/<route-slug>.json # ~20 files +tests/fixtures/phase-0/hreflang-parity/<route-slug>.json +tests/fixtures/phase-0/vrt-baselines/<route>-<viewport>-<lang>.png # ~60 files +``` + +**Committed documents:** +``` +docs/superpowers/phase-0/README.md # index of Phase 0 deliverables +docs/superpowers/phase-0/primeng-backlog.md # PrimeNG component inventory +docs/superpowers/phase-0/scss-theme-manifest.md # SCSS token/theme port list +docs/superpowers/phase-0/translation-keys-used.md # which keys are live +docs/superpowers/phase-0/customer-confirmation-checklist.md # A1–A5 + answers +``` + +--- + +## Task 1: Attach spec commit to a working branch + +The spec commit `75dbec0` currently sits on detached HEAD. Before any other work, move it to a branch. + +**Files:** +- None (git operations only) + +- [ ] **Step 1: Create working branch from current HEAD** + +```bash +git checkout -b feat/react-migration-phase0 +``` + +Expected: `Switched to a new branch 'feat/react-migration-phase0'` + +- [ ] **Step 2: Verify the spec commit is reachable from the new branch** + +```bash +git log --oneline -3 +``` + +Expected first line: `75dbec0 Add design spec for Angular-to-React MF remote rewrite` + +- [ ] **Step 3: Push the branch to origin** + +```bash +git push -u origin feat/react-migration-phase0 +``` + +Expected: new remote branch created; tracking set. + +--- + +## Task 2: Root project scaffolding (package.json + TS config + gitignore) + +Create the minimal project skeleton at the repo root. This is additive — the existing `ClientApp/` Angular app is untouched and explicitly excluded from the workspace. + +**Files:** +- Create: `package.json` +- Create: `pnpm-workspace.yaml` +- Create: `.nvmrc` +- Create: `tsconfig.json` +- Modify: `.gitignore` (create if missing) + +- [ ] **Step 1: Pin Node version** + +Create `.nvmrc` at repo root with exactly: +``` +20.11.0 +``` + +- [ ] **Step 2: Create minimal root `package.json`** + +Create `package.json` at repo root: + +```json +{ + "name": "aeroflot-flights-web", + "private": true, + "version": "0.0.0", + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20.11.0" + }, + "scripts": { + "phase0:url-corpus": "tsx scripts/phase-0/extract-url-corpus.ts", + "phase0:seo": "tsx scripts/phase-0/capture-seo-baselines.ts", + "phase0:hreflang": "tsx scripts/phase-0/capture-hreflang-parity.ts", + "phase0:vrt": "playwright test --config=playwright.phase0.config.ts", + "phase0:inventory:primeng": "tsx scripts/phase-0/inventory-primeng.ts", + "phase0:inventory:scss": "tsx scripts/phase-0/inventory-scss-tokens.ts", + "phase0:inventory:i18n": "tsx scripts/phase-0/inventory-translation-keys.ts", + "test": "vitest run" + }, + "devDependencies": {} +} +``` + +- [ ] **Step 3: Exclude `ClientApp/` from the workspace** + +Create `pnpm-workspace.yaml`: + +```yaml +packages: + - '.' + - '!ClientApp' +``` + +- [ ] **Step 4: Create root `tsconfig.json`** + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@phase0/*": ["scripts/phase-0/*"] + } + }, + "include": ["scripts/**/*.ts", "tests/**/*.ts"], + "exclude": ["ClientApp", "node_modules", "dist"] +} +``` + +- [ ] **Step 5: Update `.gitignore`** + +Add these lines to `.gitignore` (create the file if it doesn't exist at the repo root): + +``` +# Phase 0 + later +node_modules/ +dist/ +.DS_Store +playwright-report/ +test-results/ +.playwright/ + +# Ephemeral capture artifacts (final baselines are committed, raw intermediate files are not) +tests/fixtures/phase-0/vrt-baselines/raw/ +``` + +- [ ] **Step 6: Commit scaffolding** + +```bash +git add package.json pnpm-workspace.yaml .nvmrc tsconfig.json .gitignore +git commit -m "Add root project scaffolding for React rewrite + +Phase 0 introduces a minimal top-level package.json + TS config at the +repo root. ClientApp/ is excluded from the workspace so the Angular app +keeps its own dependency tree during the strangler-fig migration." +``` + +--- + +## Task 3: Install Phase 0 toolchain + +Install the minimal dependency set needed for Phase 0 capture scripts. Do NOT install Modern.js, React, or anything else — that happens in Phase 1A. + +**Files:** +- Modify: `package.json` +- Create: `pnpm-lock.yaml` + +- [ ] **Step 1: Install dev dependencies** + +```bash +pnpm add -D \ + typescript@^5.5.0 \ + tsx@^4.19.0 \ + @types/node@^20.11.0 \ + playwright@^1.47.0 \ + @playwright/test@^1.47.0 \ + cheerio@^1.0.0 \ + zod@^3.23.0 \ + schema-dts@^1.1.2 \ + vitest@^2.1.0 +``` + +Expected: `pnpm-lock.yaml` created; `node_modules/` populated; no warnings about peer dependency conflicts. + +- [ ] **Step 2: Install Playwright browsers** + +```bash +pnpm exec playwright install chromium +``` + +Expected: Chromium downloaded to Playwright's cache. + +- [ ] **Step 3: Verify `tsx` runs a trivial script** + +Create a throwaway sanity check — do not commit: + +```bash +echo 'console.log("tsx works", process.version)' > /tmp/sanity.ts +pnpm exec tsx /tmp/sanity.ts +rm /tmp/sanity.ts +``` + +Expected output: `tsx works v20.x.x` + +- [ ] **Step 4: Commit lockfile + dep additions** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "Install Phase 0 capture toolchain + +Minimal dependency set for Phase 0 scripts: Playwright (VRT + SEO +capture), cheerio (HTML parsing), zod (fixture schema validation), +schema-dts (JSON-LD types), tsx + typescript. No framework yet." +``` + +--- + +## Task 4: URL corpus — enumerate Angular route shapes + +The URL corpus is the source of truth for every URL the Angular app currently serves. It drives SEO capture, VRT capture, and the Phase 2 URL parity tests. Two sources: (a) the static route definitions in `ClientApp/src/`, which gives us every *shape* of URL; (b) optionally, anonymized access logs, which give us *real observed values* for the dynamic segments. + +This task covers (a). Task 5 covers (b). + +**Files:** +- Create: `scripts/phase-0/lib/angular-routes.ts` +- Create: `scripts/phase-0/lib/io.ts` +- Create: `tests/phase-0/lib/angular-routes.test.ts` + +- [ ] **Step 1: Read the Angular route definitions to build the list** + +Before writing code, read these files in `ClientApp/src/app/` to confirm the route shapes: + +```bash +cat ClientApp/src/app/app-routing.module.ts +``` + +```bash +find ClientApp/src/app/features -name '*-routing.module.ts' -exec echo '===' {} ';' -exec cat {} ';' +``` + +Record the route shapes and their param-parsing rules. You'll hand-transcribe them into the next step — the Angular files are the authoritative source. + +- [ ] **Step 2: Write the route-shape catalog** + +Create `scripts/phase-0/lib/angular-routes.ts`: + +```ts +/** + * Catalog of Angular route shapes, authored by hand from the current + * ClientApp/src/app routing modules. This is the source of truth for + * URL enumeration in Phase 0. + * + * Each entry is a template with :params placeholders. The sample values + * below are representative defaults used when access logs are unavailable; + * a real Phase 0 run should prefer observed values from access logs + * (see extract-url-corpus.ts). + */ + +export type RouteFeature = "onlineboard" | "schedule" | "flights-map" | "popular"; + +export interface RouteShape { + feature: RouteFeature; + /** Human-readable route slug used as a fixture filename. */ + slug: string; + /** Template with {placeholders}, no language prefix (added per language at enumeration time). */ + template: string; + /** Representative sample values used when no observed values are available. */ + samples: Array<Record<string, string>>; +} + +export const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const; +export type Language = (typeof LANGUAGES)[number]; + +export const ROUTE_SHAPES: RouteShape[] = [ + { + feature: "onlineboard", + slug: "onlineboard-start", + template: "/onlineboard", + samples: [{}], + }, + { + feature: "onlineboard", + slug: "onlineboard-flight", + template: "/onlineboard/flight/{flightNumber}-{date}", + samples: [ + { flightNumber: "SU100", date: "2025-01-15" }, + { flightNumber: "SU0001", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-departure", + template: "/onlineboard/departure/{airport}-{date}", + samples: [ + { airport: "SVO", date: "2025-01-15" }, + { airport: "LED", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-arrival", + template: "/onlineboard/arrival/{airport}-{date}", + samples: [ + { airport: "JFK", date: "2025-01-15" }, + { airport: "DXB", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-route", + template: "/onlineboard/route/{departure}-{arrival}-{date}", + samples: [ + { departure: "SVO", arrival: "JFK", date: "2025-01-15" }, + { departure: "LED", arrival: "DXB", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-details", + template: "/onlineboard/{flightNumber}-{date}", + samples: [{ flightNumber: "SU100", date: "2025-01-15" }], + }, + { + feature: "schedule", + slug: "schedule-start", + template: "/schedule", + samples: [{}], + }, + { + feature: "schedule", + slug: "schedule-oneway", + template: "/schedule/route/{departure}-{arrival}-{date}", + samples: [{ departure: "SVO", arrival: "JFK", date: "2025-01-15" }], + }, + { + feature: "schedule", + slug: "schedule-roundtrip", + template: "/schedule/route/{departure}-{arrival}-{date}/{returnDeparture}-{returnArrival}-{returnDate}", + samples: [ + { + departure: "SVO", + arrival: "JFK", + date: "2025-01-15", + returnDeparture: "JFK", + returnArrival: "SVO", + returnDate: "2025-01-22", + }, + ], + }, + { + feature: "schedule", + slug: "schedule-multileg", + template: "/schedule/{legs}", + samples: [{ legs: "SU0001-2025-01-15/SU0002-2025-01-15" }], + }, + { + feature: "flights-map", + slug: "flights-map-start", + template: "/flights-map", + samples: [{}], + }, + { + feature: "flights-map", + slug: "flights-map-route", + template: "/flights-map/route/{departure}-{arrival}", + samples: [{ departure: "SVO", arrival: "JFK" }], + }, + { + feature: "popular", + slug: "popular-start", + template: "/popular", + samples: [{}], + }, +]; + +/** Substitute {placeholders} in a template with concrete values. */ +export function renderTemplate(template: string, values: Record<string, string>): string { + return template.replace(/\{(\w+)\}/g, (_, key) => { + const value = values[key]; + if (value === undefined) { + throw new Error(`renderTemplate: missing value for '${key}' in template '${template}'`); + } + return value; + }); +} + +/** Prepend a language prefix and the canonical origin to a rendered route. */ +export function buildUrl(origin: string, lang: Language, path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${origin}/${lang}${normalized}`; +} +``` + +- [ ] **Step 3: Write a unit test for `renderTemplate` + `buildUrl`** + +Create `tests/phase-0/lib/angular-routes.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { renderTemplate, buildUrl } from "@phase0/lib/angular-routes"; + +describe("renderTemplate", () => { + it("substitutes a single placeholder", () => { + expect(renderTemplate("/onlineboard/{flightNumber}-{date}", { flightNumber: "SU100", date: "2025-01-15" })) + .toBe("/onlineboard/SU100-2025-01-15"); + }); + + it("substitutes multiple placeholders of the same name (first occurrence rule is fine because there are no duplicates in our catalog)", () => { + expect(renderTemplate("{a}/{b}", { a: "x", b: "y" })).toBe("x/y"); + }); + + it("throws on a missing value", () => { + expect(() => renderTemplate("/{missing}", {})).toThrow(/missing value for 'missing'/); + }); + + it("leaves a literal path untouched", () => { + expect(renderTemplate("/onlineboard", {})).toBe("/onlineboard"); + }); +}); + +describe("buildUrl", () => { + it("joins origin + lang + path", () => { + expect(buildUrl("https://flights.aeroflot.ru", "ru", "/onlineboard")) + .toBe("https://flights.aeroflot.ru/ru/onlineboard"); + }); + + it("inserts a leading slash if the path lacks one", () => { + expect(buildUrl("https://flights.aeroflot.ru", "en", "onlineboard")) + .toBe("https://flights.aeroflot.ru/en/onlineboard"); + }); +}); +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +pnpm test +``` + +Expected: 6 passing tests, 0 failing. + +- [ ] **Step 5: Create the small JSON I/O helper** + +Create `scripts/phase-0/lib/io.ts`: + +```ts +import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { dirname } from "node:path"; + +export function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +export function readJson<T = unknown>(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +export function fileExists(path: string): boolean { + return existsSync(path); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/phase-0/lib/angular-routes.ts scripts/phase-0/lib/io.ts tests/phase-0/lib/angular-routes.test.ts +git commit -m "Catalog Angular route shapes for Phase 0 URL enumeration + +Hand-transcribed from ClientApp/src/app routing modules. Source of truth +for URL corpus, SEO capture, and VRT capture driver scripts that follow." +``` + +--- + +## Task 5: Optional — anonymize prod access logs into an observed-URL list + +Only run this task if you have access to raw access logs. If not, skip to Task 6 — the URL corpus will be built from the route shapes alone. + +**Files:** +- Create: `scripts/phase-0/anonymize-access-logs.ts` + +- [ ] **Step 1: Write the anonymizer** + +Create `scripts/phase-0/anonymize-access-logs.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Reads raw access log lines from stdin, extracts URL paths that match + * the /{lang}/... pattern, strips query strings, deduplicates, and writes + * the result to tests/fixtures/phase-0/url-corpus/observed.json. + * + * Usage: + * cat raw-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts + * + * Accepts Common Log Format or any format where the path is the 7th + * whitespace-separated field inside a quoted request line like + * "GET /ru/onlineboard/flight/SU100-2025-01-15 HTTP/1.1" + */ + +import { createInterface } from "node:readline"; +import { writeJson } from "./lib/io.js"; +import { LANGUAGES } from "./lib/angular-routes.js"; + +const OUTPUT_PATH = "tests/fixtures/phase-0/url-corpus/observed.json"; +const LANG_PREFIX_RE = new RegExp(`^/(?:${LANGUAGES.join("|")})(/|$)`); + +async function main(): Promise<void> { + const rl = createInterface({ input: process.stdin }); + const seen = new Set<string>(); + + for await (const line of rl) { + const requestMatch = line.match(/"\w+\s+(\S+)\s+HTTP/); + if (!requestMatch) continue; + const pathAndQuery = requestMatch[1]; + if (!pathAndQuery) continue; + const [path] = pathAndQuery.split("?", 1); + if (!path) continue; + if (!LANG_PREFIX_RE.test(path)) continue; + seen.add(path); + } + + const urls = [...seen].sort(); + writeJson(OUTPUT_PATH, { capturedAt: new Date().toISOString(), count: urls.length, urls }); + console.error(`Wrote ${urls.length} unique URLs to ${OUTPUT_PATH}`); +} + +await main(); +``` + +- [ ] **Step 2: Run it against a sample (skip if no logs)** + +```bash +cat path/to/anonymized-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts +``` + +Expected: stderr prints a URL count; `tests/fixtures/phase-0/url-corpus/observed.json` exists and contains a sorted unique list. + +- [ ] **Step 3: Commit the script (not the observed.json output — that comes out of Task 6)** + +```bash +git add scripts/phase-0/anonymize-access-logs.ts +git commit -m "Add Phase 0 access-log anonymizer + +Reads raw access logs from stdin, extracts /{lang}/... paths, strips +query strings, deduplicates. Output feeds into the URL corpus." +``` + +--- + +## Task 6: URL corpus — write + run the corpus extractor + +Builds `tests/fixtures/phase-0/url-corpus/{feature}.json`, one file per feature, combining enumerated routes (from Task 4) + observed URLs (from Task 5 if available). + +**Files:** +- Create: `scripts/phase-0/extract-url-corpus.ts` +- Create: `tests/fixtures/phase-0/url-corpus/onlineboard.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/schedule.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/flights-map.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/popular.json` (generated) + +- [ ] **Step 1: Write the extractor** + +Create `scripts/phase-0/extract-url-corpus.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Builds the URL corpus fixture files for each feature, combining: + * - Enumerated URLs from ROUTE_SHAPES × LANGUAGES × samples + * - Observed URLs from tests/fixtures/phase-0/url-corpus/observed.json (if present) + * + * Output: tests/fixtures/phase-0/url-corpus/{feature}.json + * + * Each output file has the shape: + * { feature, capturedAt, count, urls: [{ path, source, slug? }] } + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, type RouteFeature } from "./lib/angular-routes.js"; +import { writeJson, readJson, fileExists } from "./lib/io.js"; + +interface CorpusEntry { + path: string; + source: "enumerated" | "observed"; + slug?: string; +} + +interface Corpus { + feature: RouteFeature; + capturedAt: string; + count: number; + urls: CorpusEntry[]; +} + +const OBSERVED_PATH = "tests/fixtures/phase-0/url-corpus/observed.json"; + +function enumerate(): Map<RouteFeature, CorpusEntry[]> { + const byFeature = new Map<RouteFeature, CorpusEntry[]>(); + for (const feature of ["onlineboard", "schedule", "flights-map", "popular"] as const) { + byFeature.set(feature, []); + } + for (const shape of ROUTE_SHAPES) { + for (const lang of LANGUAGES) { + for (const sample of shape.samples) { + const rendered = renderTemplate(shape.template, sample); + const path = `/${lang}${rendered}`; + byFeature.get(shape.feature)!.push({ path, source: "enumerated", slug: shape.slug }); + } + } + } + return byFeature; +} + +function assignObservedToFeatures(observed: string[], byFeature: Map<RouteFeature, CorpusEntry[]>): void { + for (const path of observed) { + const feature = classify(path); + if (!feature) continue; + byFeature.get(feature)!.push({ path, source: "observed" }); + } +} + +function classify(path: string): RouteFeature | null { + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/onlineboard(\/|$)/.test(path)) return "onlineboard"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/schedule(\/|$)/.test(path)) return "schedule"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/flights-map(\/|$)/.test(path)) return "flights-map"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/popular(\/|$)/.test(path)) return "popular"; + return null; +} + +function main(): void { + const byFeature = enumerate(); + + if (fileExists(OBSERVED_PATH)) { + const observedDoc = readJson<{ urls: string[] }>(OBSERVED_PATH); + assignObservedToFeatures(observedDoc.urls, byFeature); + console.error(`Merged ${observedDoc.urls.length} observed URLs into the corpus`); + } else { + console.error(`No observed URLs at ${OBSERVED_PATH}; corpus is enumerated-only`); + } + + const capturedAt = new Date().toISOString(); + for (const [feature, urls] of byFeature.entries()) { + const dedup = dedupe(urls); + const corpus: Corpus = { feature, capturedAt, count: dedup.length, urls: dedup }; + const out = `tests/fixtures/phase-0/url-corpus/${feature}.json`; + writeJson(out, corpus); + console.error(`Wrote ${feature} corpus (${dedup.length} URLs) → ${out}`); + } +} + +function dedupe(entries: CorpusEntry[]): CorpusEntry[] { + const byPath = new Map<string, CorpusEntry>(); + for (const entry of entries) { + const existing = byPath.get(entry.path); + if (!existing) { + byPath.set(entry.path, entry); + continue; + } + // Prefer observed over enumerated (observed has real user evidence). + if (existing.source === "enumerated" && entry.source === "observed") { + byPath.set(entry.path, entry); + } + } + return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path)); +} + +main(); +``` + +- [ ] **Step 2: Run the extractor** + +```bash +pnpm phase0:url-corpus +``` + +Expected stderr output (with no observed logs): +``` +No observed URLs at tests/fixtures/phase-0/url-corpus/observed.json; corpus is enumerated-only +Wrote onlineboard corpus (N URLs) → tests/fixtures/phase-0/url-corpus/onlineboard.json +Wrote schedule corpus (N URLs) → tests/fixtures/phase-0/url-corpus/schedule.json +Wrote flights-map corpus (N URLs) → tests/fixtures/phase-0/url-corpus/flights-map.json +Wrote popular corpus (N URLs) → tests/fixtures/phase-0/url-corpus/popular.json +``` + +- [ ] **Step 3: Inspect one corpus file** + +```bash +head -30 tests/fixtures/phase-0/url-corpus/onlineboard.json +``` + +Expected: JSON starting with `{ "feature": "onlineboard", ... }` and a non-empty `urls` array. + +- [ ] **Step 4: Commit corpus fixtures** + +```bash +git add scripts/phase-0/extract-url-corpus.ts tests/fixtures/phase-0/url-corpus/ +git commit -m "Generate Phase 0 URL corpus fixtures from Angular route shapes + +One fixture file per feature. Combines enumerated routes with observed +log URLs where available. Consumed by SEO capture (Task 8) and by the +Phase 2 URL parity test suite." +``` + +--- + +## Task 7: HTTP fetch helper with retry + throttle + +Before the SEO/hreflang capture tasks can hit prod, we need a small shared HTTP helper that handles transient failures and doesn't hammer the origin. + +**Files:** +- Create: `scripts/phase-0/lib/http.ts` +- Create: `tests/phase-0/lib/http.test.ts` + +- [ ] **Step 1: Write the failing test first** + +Create `tests/phase-0/lib/http.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { fetchWithRetry } from "@phase0/lib/http"; + +describe("fetchWithRetry", () => { + const realFetch = globalThis.fetch; + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { + globalThis.fetch = realFetch; + vi.useRealTimers(); + }); + + it("returns the response on first-try success", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); + const res = await fetchWithRetry("https://example.test/a"); + expect(res.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("retries on 5xx and returns the eventual success", async () => { + globalThis.fetch = vi.fn() + .mockResolvedValueOnce(new Response("oops", { status: 503 })) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + const promise = fetchWithRetry("https://example.test/b", { maxRetries: 2, retryDelayMs: 10 }); + await vi.advanceTimersByTimeAsync(10); + const res = await promise; + expect(res.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it("gives up after maxRetries and throws with the last status", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 })); + const promise = fetchWithRetry("https://example.test/c", { maxRetries: 2, retryDelayMs: 1 }); + const pending = expect(promise).rejects.toThrow(/HTTP 500/); + await vi.advanceTimersByTimeAsync(10); + await pending; + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + }); + + it("does not retry on 4xx", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 404 })); + await expect(fetchWithRetry("https://example.test/d", { maxRetries: 3, retryDelayMs: 1 })) + .rejects.toThrow(/HTTP 404/); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +pnpm test tests/phase-0/lib/http.test.ts +``` + +Expected: all 4 tests fail with `Cannot find module '@phase0/lib/http'` or equivalent. + +- [ ] **Step 3: Implement the helper** + +Create `scripts/phase-0/lib/http.ts`: + +```ts +export interface FetchWithRetryOptions { + maxRetries?: number; + retryDelayMs?: number; + throttleMs?: number; + headers?: Record<string, string>; +} + +let lastCallAt = 0; + +export async function fetchWithRetry( + url: string, + opts: FetchWithRetryOptions = {}, +): Promise<Response> { + const maxRetries = opts.maxRetries ?? 2; + const retryDelayMs = opts.retryDelayMs ?? 500; + const throttleMs = opts.throttleMs ?? 0; + const headers = opts.headers ?? { "User-Agent": "aeroflot-flights-phase0/1.0" }; + + if (throttleMs > 0) { + const sinceLast = Date.now() - lastCallAt; + if (sinceLast < throttleMs) { + await sleep(throttleMs - sinceLast); + } + lastCallAt = Date.now(); + } + + let attempt = 0; + let lastStatus = 0; + while (attempt <= maxRetries) { + const res = await fetch(url, { headers }); + if (res.status >= 200 && res.status < 400) return res; + if (res.status >= 400 && res.status < 500) { + throw new Error(`HTTP ${res.status} ${url}`); + } + lastStatus = res.status; + attempt += 1; + if (attempt > maxRetries) break; + await sleep(retryDelayMs * attempt); + } + throw new Error(`HTTP ${lastStatus} ${url} (after ${maxRetries} retries)`); +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +``` + +- [ ] **Step 4: Re-run the test** + +```bash +pnpm test tests/phase-0/lib/http.test.ts +``` + +Expected: 4 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/phase-0/lib/http.ts tests/phase-0/lib/http.test.ts +git commit -m "Add fetch-with-retry helper for Phase 0 capture scripts + +Retries on 5xx, fails fast on 4xx, optional per-call throttle window. +Protects the Angular prod origin from being hammered by capture runs." +``` + +--- + +## Task 8: SEO baseline capture script + +Fetches a curated set of representative pages from Angular prod, extracts `<title>`, `<meta>` tags, `<link rel="canonical">`, `<link rel="alternate">` (hreflang), OpenGraph, Twitter Card, and `<script type="application/ld+json">` content. Writes one fixture file per route. + +**Files:** +- Create: `scripts/phase-0/capture-seo-baselines.ts` +- Create: `scripts/phase-0/lib/seo-extractor.ts` +- Create: `tests/phase-0/lib/seo-extractor.test.ts` + +- [ ] **Step 1: Write the failing extractor test** + +Create `tests/phase-0/lib/seo-extractor.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { extractSeo } from "@phase0/lib/seo-extractor"; + +const SAMPLE_HTML = ` +<!doctype html> +<html lang="ru"> +<head> + <title>Аэрофлот — Онлайн-табло SU100 + + + + + + + + + + + + + + +`; + +describe("extractSeo", () => { + it("extracts title and description", () => { + const seo = extractSeo(SAMPLE_HTML); + expect(seo.title).toBe("Аэрофлот — Онлайн-табло SU100"); + expect(seo.description).toBe("Актуальный статус рейса SU100"); + }); + + it("extracts canonical link", () => { + expect(extractSeo(SAMPLE_HTML).canonical) + .toBe("https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15"); + }); + + it("extracts hreflang set including x-default", () => { + const { hreflang } = extractSeo(SAMPLE_HTML); + expect(hreflang).toEqual({ + ru: "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + en: "https://flights.aeroflot.ru/en/onlineboard/SU100-2025-01-15", + "x-default": "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + }); + }); + + it("extracts OpenGraph tags", () => { + const { openGraph } = extractSeo(SAMPLE_HTML); + expect(openGraph).toEqual({ + title: "SU100 status", + type: "website", + url: "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + image: "https://flights.aeroflot.ru/og/default.png", + }); + }); + + it("extracts parsed JSON-LD blocks", () => { + const { jsonLd } = extractSeo(SAMPLE_HTML); + expect(jsonLd).toHaveLength(1); + expect(jsonLd[0]).toMatchObject({ "@type": "Flight", flightNumber: "SU100" }); + }); + + it("returns nulls for missing fields", () => { + const seo = extractSeo(""); + expect(seo.title).toBeNull(); + expect(seo.description).toBeNull(); + expect(seo.canonical).toBeNull(); + expect(seo.hreflang).toEqual({}); + expect(seo.openGraph).toEqual({}); + expect(seo.jsonLd).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +pnpm test tests/phase-0/lib/seo-extractor.test.ts +``` + +Expected: 6 tests fail with `Cannot find module '@phase0/lib/seo-extractor'`. + +- [ ] **Step 3: Implement the extractor** + +Create `scripts/phase-0/lib/seo-extractor.ts`: + +```ts +import * as cheerio from "cheerio"; + +export interface ExtractedSeo { + title: string | null; + description: string | null; + canonical: string | null; + hreflang: Record; + openGraph: Record; + twitterCard: Record; + jsonLd: unknown[]; +} + +export function extractSeo(html: string): ExtractedSeo { + const $ = cheerio.load(html); + + const title = $("head > title").first().text().trim() || null; + const description = $('head > meta[name="description"]').attr("content") ?? null; + const canonical = $('head > link[rel="canonical"]').attr("href") ?? null; + + const hreflang: Record = {}; + $('head > link[rel="alternate"][hreflang]').each((_, el) => { + const lang = $(el).attr("hreflang"); + const href = $(el).attr("href"); + if (lang && href) hreflang[lang] = href; + }); + + const openGraph: Record = {}; + $('head > meta[property^="og:"]').each((_, el) => { + const property = $(el).attr("property"); + const content = $(el).attr("content"); + if (property && content) openGraph[property.replace(/^og:/, "")] = content; + }); + + const twitterCard: Record = {}; + $('head > meta[name^="twitter:"]').each((_, el) => { + const name = $(el).attr("name"); + const content = $(el).attr("content"); + if (name && content) twitterCard[name.replace(/^twitter:/, "")] = content; + }); + + const jsonLd: unknown[] = []; + $('head > script[type="application/ld+json"]').each((_, el) => { + const raw = $(el).contents().text(); + if (!raw.trim()) return; + try { + jsonLd.push(JSON.parse(raw)); + } catch { + jsonLd.push({ __parseError: true, raw }); + } + }); + + return { title, description, canonical, hreflang, openGraph, twitterCard, jsonLd }; +} +``` + +- [ ] **Step 4: Re-run the test** + +```bash +pnpm test tests/phase-0/lib/seo-extractor.test.ts +``` + +Expected: 6 passing. + +- [ ] **Step 5: Write the driver script** + +Create `scripts/phase-0/capture-seo-baselines.ts`: + +```ts +#!/usr/bin/env tsx +/** + * For each feature corpus + each language, fetches one representative + * URL from Angular prod, extracts SEO tags, and writes one fixture per + * route slug to tests/fixtures/phase-0/seo-baselines/. + * + * Required env: PROD_ORIGIN (e.g. https://flights.aeroflot.ru) + * + * Only captures one URL per slug (not every enumerated variant) — the + * purpose is a shape baseline, not a load test. + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, buildUrl } from "./lib/angular-routes.js"; +import { fetchWithRetry } from "./lib/http.js"; +import { extractSeo } from "./lib/seo-extractor.js"; +import { writeJson } from "./lib/io.js"; + +const origin = process.env.PROD_ORIGIN; +if (!origin) { + console.error("PROD_ORIGIN env var required (e.g. https://flights.aeroflot.ru)"); + process.exit(1); +} + +async function main(): Promise { + const langs = ["ru", "en"] as const; // Two languages per slug is enough for a baseline. + let captured = 0; + let failed = 0; + + for (const shape of ROUTE_SHAPES) { + const sample = shape.samples[0]; + if (!sample) continue; + const rendered = renderTemplate(shape.template, sample); + + for (const lang of langs) { + const url = buildUrl(origin!, lang, rendered); + const fixturePath = `tests/fixtures/phase-0/seo-baselines/${shape.slug}.${lang}.json`; + try { + const res = await fetchWithRetry(url, { throttleMs: 500, maxRetries: 2 }); + const html = await res.text(); + const seo = extractSeo(html); + writeJson(fixturePath, { + capturedAt: new Date().toISOString(), + slug: shape.slug, + lang, + url, + seo, + }); + console.error(` ✔ ${url}`); + captured += 1; + } catch (err) { + console.error(` ✘ ${url} — ${(err as Error).message}`); + failed += 1; + } + } + } + + console.error(`\nCaptured ${captured} SEO baselines, ${failed} failures.`); + if (failed > 0) process.exit(1); +} + +void main(); +``` + +- [ ] **Step 6: Run against Angular prod** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:seo +``` + +Expected: stderr prints ✔ / ✘ per URL; ~26 files written under `tests/fixtures/phase-0/seo-baselines/`. Zero failures. + +**If any fetches fail:** investigate before committing. Common causes: origin gating by `User-Agent` (edit the header in `lib/http.ts`), prod returning 403 for bot-like traffic (work with the customer to whitelist the script's source IP or use a staging mirror), or a URL in the samples that doesn't exist (adjust `ROUTE_SHAPES.samples`). + +- [ ] **Step 7: Spot-check one fixture** + +```bash +cat tests/fixtures/phase-0/seo-baselines/onlineboard-flight.ru.json +``` + +Expected: JSON with a non-null `title`, a `canonical` URL, a non-empty `hreflang` object with at least `ru` and `en` entries, and at least one `jsonLd` block (or empty — that's a gap the React rewrite fills). + +- [ ] **Step 8: Commit** + +```bash +git add scripts/phase-0/capture-seo-baselines.ts scripts/phase-0/lib/seo-extractor.ts tests/phase-0/lib/seo-extractor.test.ts tests/fixtures/phase-0/seo-baselines/ +git commit -m "Capture SEO baselines from Angular prod + +One fixture per route slug × language. Records title, description, +canonical, hreflang set, OpenGraph, Twitter Card, and JSON-LD blocks. +Consumed by the Phase 2+ SEO parity tests — React output must match +or improve on these." +``` + +--- + +## Task 9: Hreflang reciprocal-parity check + +The current Angular app may or may not emit reciprocal `hreflang` sets correctly. Phase 0 captures the *actual* state so the Phase 2+ parity tests can assert "at least as good as Angular, and ideally better." + +**Files:** +- Create: `scripts/phase-0/capture-hreflang-parity.ts` +- Create: `tests/fixtures/phase-0/hreflang-parity/.json` (generated) + +- [ ] **Step 1: Write the script** + +Create `scripts/phase-0/capture-hreflang-parity.ts`: + +```ts +#!/usr/bin/env tsx +/** + * For each route slug, fetches all 9 language variants from Angular prod, + * extracts each variant's hreflang set, and writes a single fixture per + * slug comparing the sets. + * + * Required env: PROD_ORIGIN + * + * The resulting fixture records whether the hreflang sets are identical + * across language variants (they should be — hreflang must be reciprocal). + * Phase 2+ SEO parity tests will assert the React output has reciprocal + * hreflang regardless of what Angular does today. + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, buildUrl } from "./lib/angular-routes.js"; +import { fetchWithRetry } from "./lib/http.js"; +import { extractSeo } from "./lib/seo-extractor.js"; +import { writeJson } from "./lib/io.js"; + +const origin = process.env.PROD_ORIGIN; +if (!origin) { + console.error("PROD_ORIGIN env var required"); + process.exit(1); +} + +async function main(): Promise { + for (const shape of ROUTE_SHAPES) { + const sample = shape.samples[0]; + if (!sample) continue; + const rendered = renderTemplate(shape.template, sample); + + const perLang: Record | null; error?: string }> = {}; + for (const lang of LANGUAGES) { + const url = buildUrl(origin!, lang, rendered); + try { + const res = await fetchWithRetry(url, { throttleMs: 500, maxRetries: 2 }); + const html = await res.text(); + const { hreflang } = extractSeo(html); + perLang[lang] = { url, hreflang }; + } catch (err) { + perLang[lang] = { url, hreflang: null, error: (err as Error).message }; + } + } + + const sets = Object.values(perLang) + .map((v) => v.hreflang) + .filter((h): h is Record => h !== null); + const reciprocal = sets.length > 0 && sets.every((s) => JSON.stringify(sortKeys(s)) === JSON.stringify(sortKeys(sets[0]!))); + + const out = `tests/fixtures/phase-0/hreflang-parity/${shape.slug}.json`; + writeJson(out, { + capturedAt: new Date().toISOString(), + slug: shape.slug, + reciprocal, + perLang, + }); + console.error(` ${reciprocal ? "✔" : "✘"} ${shape.slug} — reciprocal: ${reciprocal}`); + } +} + +function sortKeys>(obj: T): T { + return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) as T; +} + +void main(); +``` + +- [ ] **Step 2: Run against Angular prod** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:hreflang +``` + +Expected: one line per route slug, marked ✔ (reciprocal) or ✘ (drift). A few ✘ are fine at this stage — they document the Angular baseline we need to beat. + +- [ ] **Step 3: Inspect a drift case (if any)** + +```bash +grep -l '"reciprocal": false' tests/fixtures/phase-0/hreflang-parity/ || echo "No drift found" +``` + +If drift is found, note the affected slugs in `docs/superpowers/phase-0/README.md` (Task 17) as "Angular hreflang bugs the React rewrite fixes by default." + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/capture-hreflang-parity.ts tests/fixtures/phase-0/hreflang-parity/ +git commit -m "Capture Angular hreflang reciprocal-parity baseline + +One fixture per route slug records the hreflang sets emitted by every +language variant. The 'reciprocal' flag says whether all variants agree. +Any false results document Angular SEO bugs the React rewrite fixes." +``` + +--- + +## Task 10: Playwright VRT config + baseline capture script + +Captures ~60 reference screenshots from Angular prod: 10 curated routes × 3 viewports (375 / 768 / 1440) × 2 languages (ru, en). Used by the Phase 2+ visual-regression gate to enforce pixel parity. + +**Files:** +- Create: `playwright.phase0.config.ts` +- Create: `scripts/phase-0/capture-vrt-baselines.ts` +- Create: `tests/phase-0/vrt/baseline.spec.ts` + +- [ ] **Step 1: Create the Playwright config** + +Create `playwright.phase0.config.ts` at the repo root: + +```ts +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/phase-0/vrt", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 4, + reporter: [["list"]], + use: { + actionTimeout: 10_000, + navigationTimeout: 30_000, + ignoreHTTPSErrors: false, + userAgent: "aeroflot-flights-phase0-vrt/1.0", + }, + projects: [ + { + name: "mobile", + use: { ...devices["iPhone SE"], viewport: { width: 375, height: 667 } }, + }, + { + name: "tablet", + use: { viewport: { width: 768, height: 1024 }, userAgent: "aeroflot-flights-phase0-vrt/1.0" }, + }, + { + name: "desktop", + use: { viewport: { width: 1440, height: 900 }, userAgent: "aeroflot-flights-phase0-vrt/1.0" }, + }, + ], +}); +``` + +- [ ] **Step 2: Curate the 10 VRT routes** + +The 60-image matrix = 10 routes × 3 viewports × 2 languages. Select representative routes from the URL corpus — aim for coverage of start pages, search results, and detail pages across all four features: + +Create `scripts/phase-0/lib/vrt-routes.ts`: + +```ts +import type { RouteShape } from "./angular-routes.js"; +import { ROUTE_SHAPES, renderTemplate } from "./angular-routes.js"; + +/** Curated subset of routes used for VRT baseline capture. */ +const VRT_SLUGS = [ + "onlineboard-start", + "onlineboard-flight", + "onlineboard-departure", + "onlineboard-arrival", + "onlineboard-route", + "onlineboard-details", + "schedule-start", + "schedule-oneway", + "flights-map-start", + "popular-start", +]; + +export interface VrtRoute { + slug: string; + path: string; // without language prefix +} + +export function getVrtRoutes(): VrtRoute[] { + const byFeatureSlug = new Map(ROUTE_SHAPES.map((s) => [s.slug, s])); + return VRT_SLUGS.map((slug) => { + const shape = byFeatureSlug.get(slug); + if (!shape) throw new Error(`VRT: unknown slug ${slug}`); + const sample = shape.samples[0]; + if (!sample) throw new Error(`VRT: no sample for ${slug}`); + return { slug, path: renderTemplate(shape.template, sample) }; + }); +} +``` + +- [ ] **Step 3: Write the Playwright baseline spec** + +Create `tests/phase-0/vrt/baseline.spec.ts`: + +```ts +import { test, expect } from "@playwright/test"; +import { getVrtRoutes } from "../../../scripts/phase-0/lib/vrt-routes.js"; + +const ORIGIN = process.env.PROD_ORIGIN; +const LANGS = ["ru", "en"] as const; + +if (!ORIGIN) { + throw new Error("PROD_ORIGIN env var required for VRT baseline capture"); +} + +test.describe("Angular prod VRT baselines", () => { + for (const route of getVrtRoutes()) { + for (const lang of LANGS) { + test(`${route.slug} | ${lang}`, async ({ page }, testInfo) => { + const url = `${ORIGIN}/${lang}${route.path}`; + const response = await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + expect(response?.status()).toBeLessThan(400); + + // Give any lazy-loaded fonts + images a beat to settle. + await page.waitForTimeout(1500); + + // Full-page screenshot; masks cover dynamic content (live times, moving map tiles). + const screenshot = await page.screenshot({ + fullPage: true, + animations: "disabled", + }); + + const outPath = `tests/fixtures/phase-0/vrt-baselines/${route.slug}-${testInfo.project.name}-${lang}.png`; + await testInfo.attach("baseline", { body: screenshot, contentType: "image/png" }); + + // Write the baseline file directly (Playwright snapshots are overkill for Phase 0 capture). + const { writeFileSync, mkdirSync } = await import("node:fs"); + const { dirname } = await import("node:path"); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, screenshot); + }); + } + } +}); +``` + +- [ ] **Step 4: Run VRT capture** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:vrt +``` + +Expected: 60 tests pass (10 routes × 3 projects × 2 langs). `tests/fixtures/phase-0/vrt-baselines/` contains 60 PNGs. + +If some pages fail with navigation timeouts or `networkidle` never settles, add a per-slug override that uses `domcontentloaded` instead — Leaflet tile loading and SignalR connection attempts can hold `networkidle` open indefinitely. Adjust `waitUntil` in the spec for the problem routes and re-run. + +- [ ] **Step 5: Verify file count** + +```bash +ls tests/fixtures/phase-0/vrt-baselines/*.png | wc -l +``` + +Expected: `60` + +- [ ] **Step 6: Commit** + +```bash +git add playwright.phase0.config.ts scripts/phase-0/lib/vrt-routes.ts tests/phase-0/vrt/baseline.spec.ts tests/fixtures/phase-0/vrt-baselines/ +git commit -m "Capture 60 Playwright VRT baselines from Angular prod + +10 curated routes × 3 viewports × 2 languages. These PNGs are the +pixel-parity source of truth for the Phase 2+ visual regression gate. +Phase-end re-baselining (§8.4 of the design spec) replaces these with +React-build baselines once each feature ships." +``` + +--- + +## Task 11: PrimeNG component inventory + +Scans `ClientApp/src/` for every `import` from `primeng/*` and every `p-*` tag used in templates. Produces a backlog the Phase 1E (UI adapter) and Phase 2+ work plans pull from. + +**Files:** +- Create: `scripts/phase-0/inventory-primeng.ts` +- Create: `docs/superpowers/phase-0/primeng-backlog.md` (generated) + +- [ ] **Step 1: Write the inventory script** + +Create `scripts/phase-0/inventory-primeng.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Scans ClientApp/src for: + * - imports from 'primeng/*' (in .ts files) + * - tag usages (in .html template files) + * + * Produces a markdown backlog at docs/superpowers/phase-0/primeng-backlog.md + * grouped by PrimeNG module, with the files that reference each one. + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = "ClientApp/src"; +const IMPORT_RE = /from\s+['"]primeng\/(\w[\w-]*)['"]/g; +const TAG_RE = /; +} + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) { + if (name === "node_modules" || name === "dist") continue; + walk(full, out); + } else if (/\.(ts|html)$/.test(name)) { + out.push(full); + } + } + return out; +} + +function main(): void { + const files = walk(ROOT); + const index = new Map(); + + for (const file of files) { + const contents = readFileSync(file, "utf8"); + const relFile = relative(process.cwd(), file); + + if (file.endsWith(".ts")) { + for (const match of contents.matchAll(IMPORT_RE)) { + const mod = match[1]!; + const key = `import:${mod}`; + if (!index.has(key)) index.set(key, { item: mod, kind: "import", files: new Set() }); + index.get(key)!.files.add(relFile); + } + } + if (file.endsWith(".html")) { + for (const match of contents.matchAll(TAG_RE)) { + const tag = `p-${match[1]}`; + const key = `tag:${tag}`; + if (!index.has(key)) index.set(key, { item: tag, kind: "tag", files: new Set() }); + index.get(key)!.files.add(relFile); + } + } + } + + const imports = [...index.values()].filter((r) => r.kind === "import").sort((a, b) => a.item.localeCompare(b.item)); + const tags = [...index.values()].filter((r) => r.kind === "tag").sort((a, b) => a.item.localeCompare(b.item)); + + const lines: string[] = []; + lines.push("# PrimeNG component inventory"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-primeng.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${imports.length} imported modules, ${tags.length} template tags.`); + lines.push(""); + lines.push("## Imported modules"); + lines.push(""); + lines.push("| Module | Files | Phase 1E target |"); + lines.push("|---|---|---|"); + for (const row of imports) { + lines.push(`| \`primeng/${row.item}\` | ${row.files.size} | (TBD — see §5.3 of the design spec) |`); + } + lines.push(""); + lines.push("## Template tags"); + lines.push(""); + lines.push("| Tag | Files |"); + lines.push("|---|---|"); + for (const row of tags) { + lines.push(`| \`<${row.item}>\` | ${row.files.size} |`); + } + lines.push(""); + lines.push("## File detail"); + lines.push(""); + for (const row of [...imports, ...tags]) { + lines.push(`### \`${row.kind === "import" ? `primeng/${row.item}` : `<${row.item}>`}\``); + lines.push(""); + for (const file of [...row.files].sort()) { + lines.push(`- ${file}`); + } + lines.push(""); + } + + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/primeng-backlog.md", lines.join("\n")); + console.error(`Wrote ${imports.length} imports + ${tags.length} tags to docs/superpowers/phase-0/primeng-backlog.md`); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:primeng +``` + +Expected: stderr reports counts; `docs/superpowers/phase-0/primeng-backlog.md` exists with a non-empty table. + +- [ ] **Step 3: Spot-check the output** + +```bash +head -40 docs/superpowers/phase-0/primeng-backlog.md +``` + +Expected: Markdown with an "Imported modules" table listing PrimeNG modules (`calendar`, `autocomplete`, `accordion`, `dropdown`, `table`, `tooltip`, `dialog`, `toast`, etc.). + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/inventory-primeng.ts docs/superpowers/phase-0/primeng-backlog.md +git commit -m "Inventory PrimeNG usages in ClientApp/ + +Produces the Phase 1E UI adapter backlog: every primeng/ import and +every template tag, grouped by referenced file." +``` + +--- + +## Task 12: SCSS token + theme-override inventory + +Extracts every `$variable`, every `:root { --var }`, every selector override of a PrimeNG class (`.p-*`), and every `@import` of SCSS files under `ClientApp/src/styles/`. Produces the port manifest for the `src/ui/styles/` theme porting work in Phase 1E. + +**Files:** +- Create: `scripts/phase-0/inventory-scss-tokens.ts` +- Create: `docs/superpowers/phase-0/scss-theme-manifest.md` (generated) + +- [ ] **Step 1: Write the inventory script** + +Create `scripts/phase-0/inventory-scss-tokens.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Extracts SCSS variables, CSS custom properties, and PrimeNG selector + * overrides from ClientApp/src for the theme-port manifest. + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOTS = ["ClientApp/src/styles", "ClientApp/src/app"]; +const SCSS_VAR_RE = /\$([a-zA-Z][\w-]*)\s*:/g; +const CSS_VAR_RE = /--([a-zA-Z][\w-]*)\s*:/g; +const PRIME_SELECTOR_RE = /(\.p-[a-zA-Z][\w-]*(?:[:\s]+[^{]*)?)\s*\{/g; + +interface Sighting { file: string; count: number; } + +function walk(dir: string, out: string[] = []): string[] { + try { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) { + if (name === "node_modules" || name === "dist") continue; + walk(full, out); + } else if (/\.scss$/.test(name)) { + out.push(full); + } + } + } catch { + // ROOT may not exist; ignore. + } + return out; +} + +function collect(): { + scssVars: Map; + cssVars: Map; + primeSelectors: Map; +} { + const scssVars = new Map(); + const cssVars = new Map(); + const primeSelectors = new Map(); + + const files: string[] = []; + for (const root of ROOTS) walk(root, files); + + for (const file of files) { + const contents = readFileSync(file, "utf8"); + const rel = relative(process.cwd(), file); + count(contents, SCSS_VAR_RE, scssVars, rel); + count(contents, CSS_VAR_RE, cssVars, rel); + count(contents, PRIME_SELECTOR_RE, primeSelectors, rel); + } + + return { scssVars, cssVars, primeSelectors }; +} + +function count(source: string, regex: RegExp, into: Map, file: string): void { + const matches = [...source.matchAll(regex)]; + const localCounts = new Map(); + for (const m of matches) { + const key = (m[1] ?? m[0]).trim(); + localCounts.set(key, (localCounts.get(key) ?? 0) + 1); + } + for (const [key, n] of localCounts.entries()) { + if (!into.has(key)) into.set(key, []); + into.get(key)!.push({ file, count: n }); + } +} + +function render(): string { + const { scssVars, cssVars, primeSelectors } = collect(); + const lines: string[] = []; + lines.push("# SCSS theme-port manifest"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-scss-tokens.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${scssVars.size} SCSS variables, ${cssVars.size} CSS custom properties, ${primeSelectors.size} PrimeNG selector overrides.`); + lines.push(""); + lines.push("## SCSS variables (`$var`)"); + lines.push(""); + lines.push("These port to `src/ui/styles/_tokens.scss` as CSS custom properties (`--var`) per §5.3 of the design spec."); + lines.push(""); + lines.push("| Variable | Defined in |"); + lines.push("|---|---|"); + for (const key of [...scssVars.keys()].sort()) { + const sight = scssVars.get(key)!; + lines.push(`| \`$${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + lines.push("## CSS custom properties (`--var`)"); + lines.push(""); + lines.push("| Property | Defined in |"); + lines.push("|---|---|"); + for (const key of [...cssVars.keys()].sort()) { + const sight = cssVars.get(key)!; + lines.push(`| \`--${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + lines.push("## PrimeNG selector overrides"); + lines.push(""); + lines.push("These port to `src/ui/styles/_theme-primereact.scss`. Most port unchanged because PrimeReact uses the same `.p-*` class taxonomy."); + lines.push(""); + lines.push("| Selector | Defined in |"); + lines.push("|---|---|"); + for (const key of [...primeSelectors.keys()].sort()) { + const sight = primeSelectors.get(key)!; + lines.push(`| \`${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + return lines.join("\n"); +} + +function main(): void { + const markdown = render(); + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/scss-theme-manifest.md", markdown); + console.error("Wrote docs/superpowers/phase-0/scss-theme-manifest.md"); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:scss +``` + +Expected: stderr prints the write confirmation; the manifest file exists. + +- [ ] **Step 3: Spot-check** + +```bash +head -40 docs/superpowers/phase-0/scss-theme-manifest.md +``` + +Expected: totals line with non-zero counts + the "SCSS variables" table. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/inventory-scss-tokens.ts docs/superpowers/phase-0/scss-theme-manifest.md +git commit -m "Inventory SCSS tokens + PrimeNG overrides for Phase 1E + +Extracts every SCSS variable, CSS custom property, and .p-* selector +override from ClientApp/src. Phase 1E uses the manifest as the theme +port backlog." +``` + +--- + +## Task 13: Translation-key usage inventory + +The Angular i18n JSON files contain every translated string ever used. Some keys are dead code. This script scans `ClientApp/src` for keys that are *actually referenced* (via the `translate` pipe, `translate` directive, or `TranslateService` API), so the Phase 1C i18n port can skip dead strings if desired. + +**Files:** +- Create: `scripts/phase-0/inventory-translation-keys.ts` +- Create: `docs/superpowers/phase-0/translation-keys-used.md` (generated) + +- [ ] **Step 1: Write the script** + +Create `scripts/phase-0/inventory-translation-keys.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Scans ClientApp/src for @ngx-translate key references: + * - '{{ "key" | translate }}' in templates + * - '[translate]="..."' attribute in templates + * - translate.get("key") / translate.instant("key") in TS + * + * Compares against the key set in ClientApp/src/assets/i18n/ru.json + * (used as the canonical key list) and produces a markdown report: + * - keys used in code + * - keys present in ru.json but never referenced (dead) + * - keys referenced but missing from ru.json (broken) + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SRC_ROOT = "ClientApp/src/app"; +const RU_JSON = "ClientApp/src/assets/i18n/ru.json"; +const PIPE_RE = /['"]([a-zA-Z][\w.-]*?)['"]\s*\|\s*translate/g; +const SERVICE_RE = /translate\.(?:get|instant|stream)\(['"]([a-zA-Z][\w.-]*?)['"]/g; +const ATTR_RE = /\[translate\]="['"]?([a-zA-Z][\w.-]*?)['"]?"/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) walk(full, out); + else if (/\.(ts|html)$/.test(name)) out.push(full); + } + return out; +} + +function flattenKeys(obj: unknown, prefix = ""): string[] { + if (typeof obj !== "object" || obj === null) return prefix ? [prefix] : []; + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const next = prefix ? `${prefix}.${k}` : k; + if (typeof v === "object" && v !== null) keys.push(...flattenKeys(v, next)); + else keys.push(next); + } + return keys; +} + +function main(): void { + const used = new Set(); + const usedFiles = new Map>(); + + for (const file of walk(SRC_ROOT)) { + const contents = readFileSync(file, "utf8"); + const rel = relative(process.cwd(), file); + const regexes = file.endsWith(".html") ? [PIPE_RE, ATTR_RE] : [PIPE_RE, SERVICE_RE]; + for (const re of regexes) { + for (const m of contents.matchAll(re)) { + const key = m[1]!; + used.add(key); + if (!usedFiles.has(key)) usedFiles.set(key, new Set()); + usedFiles.get(key)!.add(rel); + } + } + } + + const defined = new Set(flattenKeys(JSON.parse(readFileSync(RU_JSON, "utf8")))); + const dead = [...defined].filter((k) => !used.has(k)).sort(); + const broken = [...used].filter((k) => !defined.has(k)).sort(); + const live = [...used].filter((k) => defined.has(k)).sort(); + + const lines: string[] = []; + lines.push("# Translation-key usage inventory"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-translation-keys.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${defined.size} defined in ru.json · ${live.length} live · ${dead.length} dead · ${broken.length} broken references.`); + lines.push(""); + + lines.push(`## Live keys (${live.length})`); + lines.push(""); + lines.push("These must be ported to `src/i18n/locales/*/common.json` in Phase 1C."); + lines.push(""); + lines.push("
Show list"); + lines.push(""); + for (const key of live) lines.push(`- \`${key}\``); + lines.push(""); + lines.push("
"); + lines.push(""); + + lines.push(`## Dead keys (${dead.length})`); + lines.push(""); + lines.push("Present in ru.json but never referenced. The Phase 1C port may skip these, or keep them as insurance against keys used via dynamic composition that this static scan can't catch."); + lines.push(""); + lines.push("
Show list"); + lines.push(""); + for (const key of dead) lines.push(`- \`${key}\``); + lines.push(""); + lines.push("
"); + lines.push(""); + + lines.push(`## Broken references (${broken.length})`); + lines.push(""); + lines.push("Keys used in code but missing from ru.json. These are Angular bugs — the React port should fix them by adding the missing translations."); + lines.push(""); + for (const key of broken) { + const files = [...(usedFiles.get(key) ?? [])].sort(); + lines.push(`- \`${key}\` (in ${files.join(", ")})`); + } + lines.push(""); + + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/translation-keys-used.md", lines.join("\n")); + console.error(`live=${live.length} dead=${dead.length} broken=${broken.length}`); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:i18n +``` + +Expected: stderr prints `live=N dead=M broken=K`. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/phase-0/inventory-translation-keys.ts docs/superpowers/phase-0/translation-keys-used.md +git commit -m "Inventory live vs dead translation keys in Angular i18n + +Compares keys referenced in ClientApp/src against keys defined in +ru.json. Produces live/dead/broken buckets used by the Phase 1C i18n +port to decide what moves and what gets dropped." +``` + +--- + +## Task 14: Customer confirmation checklist + +Produces the questionnaire the customer point-of-contact must answer to unblock Phase 1. Not an automated task — a hand-written document. + +**Files:** +- Create: `docs/superpowers/phase-0/customer-confirmation-checklist.md` + +- [ ] **Step 1: Author the checklist** + +Create `docs/superpowers/phase-0/customer-confirmation-checklist.md`: + +```markdown +# Customer confirmation checklist (Phase 0 blockers) + +This questionnaire collects the customer-side decisions flagged as assumptions in the design spec (`docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` §10). Each must be answered before Phase 1 can start — their answers shape the Modern.js project layout, CI configuration, and logging transport. + +Deliver this document to the customer point-of-contact. Record answers inline below and commit the updated file to close out Phase 0. + +--- + +## A1 — Remote-frontend module template (design spec §2.5) + +**Question.** The design spec defaults to the idiomatic Modern.js + Module Federation 2.0 remote module layout. The customer's "standard remote-frontend module template" (required by customer requirement 9) has not been shared. + +- Is there a published template (repo link, archive, or written conventions) the customer wants us to match? +- If yes, who owns it and what does it specify for: directory layout, exposed module naming, `mf-manifest.json` metadata fields, shared-dependency declarations, artifact packaging? +- If no, does the customer accept that the Modern.js default layout will be used and reconciled later (rename-only migration expected)? + +**Answer:** + +--- + +## A2 — CDN vendor (design spec §8.2) + +**Question.** Standalone SSR cache headers (`Cache-Control` with `s-maxage` + `stale-while-revalidate`) assume a standards-compliant CDN in front of the origin. Remote-mode static artifacts also require a CDN. + +- Which CDN does the customer operate? (Yandex Cloud CDN / Cloudflare / Akamai / other) +- Are there customer-side constraints on cache header values, purge mechanisms, or TLS certificate delivery? +- Who in the customer's infrastructure team owns the CDN configuration? + +**Answer:** + +--- + +## A3 — CI provider (design spec §8.5) + +**Question.** The design spec assumes GitHub Actions for CI. Pipelines port directly to GitLab CI or TeamCity if needed, but environment-specific glue has to be written. + +- Which CI provider does the customer use for frontend projects? +- Are there existing pipeline templates / shared workflows / Docker base images we must adopt? +- Are there customer-mandated scanning tools (SAST / SCA / license scan) beyond `osv-scanner` + `npm audit` the spec already includes? + +**Answer:** + +--- + +## A4 — Frontend log format (design spec §7.2) + +**Question.** Customer requirement 8 states: "frontend logs collected, formed into a file of a customer-specified format (to be provided separately), shipped to the customer's log system." The format specification has not been shared. + +- What log format does the customer require? (JSON-lines / plain text / vendor-specific like Fluentd / CEF / other) +- What is the log-ingestion endpoint? (HTTP POST URL / Kafka topic / Filebeat agent / other) +- Authentication? (bearer token / mTLS / IP allowlist) +- Required fields beyond the standard set (timestamp, level, message, trace id)? +- Any PII handling rules beyond the redaction list in the spec (`password`, `token`, `authorization`, `cookie`, `email`, `phone`)? + +Until this is answered, Phase 1G ships with `JsonLinesHttpTransport` as the default (spec §7.2, assumption A4). The customer format plugs in as a new `LogTransport` implementation at `src/observability/logger/index.ts` without feature-code changes. + +**Answer:** + +--- + +## A5 — ASP.NET host retention (design spec §9.2, Phase 6) + +**Question.** Phase 6 assumes `Aeroflot.Flights.Web.csproj` + `Startup.cs` + `Program.cs` can be deleted once the React app reaches 100% traffic, unless the host serves something unrelated to the frontend. + +- Does the ASP.NET host have responsibilities beyond serving the Angular SPA? (API proxy, SSO, server-side form handling, static asset delivery) +- Who owns the ASP.NET code currently? +- Is there a shared infrastructure component (load balancer, reverse proxy, security headers) that the ASP.NET layer provides and that we must replace before decommissioning? + +This only needs to be answered before Phase 6 starts, but it's cheap to ask now. + +**Answer:** + +--- + +## A6 — Metrics aggregator endpoint (design spec §7.3) + +**Question.** The spec uses OpenTelemetry with OTLP/HTTP export to any aggregator the customer operates. The endpoint URL and auth mechanism aren't in the spec. + +- What is the OTLP ingestion endpoint for the metrics aggregator? (Dynatrace / Grafana Mimir / other) +- Authentication? (API key in `OTEL_EXPORTER_OTLP_HEADERS` / mTLS) +- Are there customer-standard metric naming conventions we must follow? +- Will the customer's aggregator also ingest traces + logs, or are those separate pipelines? + +**Answer:** + +--- + +## A7 — Analytics vendor credentials (design spec §7.4) + +**Question.** Four analytics vendors are listed: Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром (Dynatrace). Each needs a property/container ID and (in some cases) an auth snippet. + +| Vendor | What we need | +|---|---| +| Яндекс.Метрика | Counter ID | +| CTM | Tracking ID + script source | +| Вариокуб | Property ID + script source | +| Ключ-Астром (Dynatrace RUM) | Agent script URL + application ID | + +Per-environment (dev / testing / staging / production) if they differ. + +**Answer:** + +--- + +## A8 — Production URL + access logs (Phase 0 prerequisites) + +**Question.** + +- Production URL for Angular app (used by Phase 0 capture scripts): \_\_\_ +- Staging mirror URL (fallback if prod blocks scraping): \_\_\_ +- Access-log availability: (yes / no — if yes, format and how we fetch them) +- Contact on the customer side if the capture scripts get blocked by WAF / rate limits: \_\_\_ + +**Answer:** + +--- + +## Sign-off + +- [ ] All blockers above answered +- [ ] Any follow-up tickets opened on the customer side +- [ ] Phase 0 capture scripts successfully re-run with confirmed inputs + +**Signed:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_    **Date:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_ +``` + +- [ ] **Step 2: Commit the checklist** + +```bash +git add docs/superpowers/phase-0/customer-confirmation-checklist.md +git commit -m "Add customer confirmation checklist for Phase 0 blockers + +Questionnaire covering spec assumptions A1–A7 plus Phase 0 prerequisites +(prod URL, access logs, escalation contact). Must be filled in by the +customer point-of-contact before Phase 1 can start." +``` + +--- + +## Task 15: Phase 0 README + manifest + +Indexes all Phase 0 deliverables so future engineers can find them without re-reading the plan. + +**Files:** +- Create: `docs/superpowers/phase-0/README.md` + +- [ ] **Step 1: Author the README** + +Create `docs/superpowers/phase-0/README.md`: + +```markdown +# Phase 0 — Preflight deliverables + +This directory indexes everything Phase 0 produced. Phase 1 and later phases consume these artifacts; none of them should be hand-edited after Phase 0 closes — regenerate by re-running the capture scripts if the source changes. + +## What Phase 0 produced + +### Fixtures (consumed by Phase 1+ tests) + +| Artifact | Path | Produced by | +|---|---|---| +| URL corpus (per feature) | `tests/fixtures/phase-0/url-corpus/{feature}.json` | `scripts/phase-0/extract-url-corpus.ts` | +| SEO baselines | `tests/fixtures/phase-0/seo-baselines/*.json` | `scripts/phase-0/capture-seo-baselines.ts` | +| Hreflang parity baselines | `tests/fixtures/phase-0/hreflang-parity/*.json` | `scripts/phase-0/capture-hreflang-parity.ts` | +| VRT baselines (60 PNGs) | `tests/fixtures/phase-0/vrt-baselines/*.png` | `tests/phase-0/vrt/baseline.spec.ts` via `playwright.phase0.config.ts` | + +### Inventory documents (consumed by Phase 1 sub-plans) + +| Document | Consumer | +|---|---| +| `primeng-backlog.md` | Phase 1E (UI adapter) | +| `scss-theme-manifest.md` | Phase 1E (UI adapter) | +| `translation-keys-used.md` | Phase 1C (i18n) | + +### Gate documents + +| Document | Purpose | +|---|---| +| `customer-confirmation-checklist.md` | Phase 0 exit gate — blockers for Phase 1 | + +## How to regenerate + +All scripts assume Node 20 + pnpm. Set `PROD_ORIGIN` for scripts that hit the live Angular app. + +```bash +pnpm install + +# URL corpus (enumerated + optional observed) +pnpm phase0:url-corpus + +# SEO + hreflang (require PROD_ORIGIN) +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:seo +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:hreflang + +# VRT (requires PROD_ORIGIN; slow — captures 60 PNGs) +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:vrt + +# Inventories (read-only scans of ClientApp/src, no network) +pnpm phase0:inventory:primeng +pnpm phase0:inventory:scss +pnpm phase0:inventory:i18n +``` + +## Phase 0 exit gate + +Phase 0 is complete when: + +- [ ] All 4 URL corpus fixtures exist and have non-zero `count` +- [ ] At least 20 SEO baseline fixtures captured without errors +- [ ] 60 VRT baseline PNGs captured without errors +- [ ] All three inventory markdown files generated and non-empty +- [ ] `customer-confirmation-checklist.md` has signed answers for A1–A8 +- [ ] Any "Angular hreflang bugs" from Task 9 are noted in this README (§ below) + +## Angular baseline anomalies + +Issues in the current Angular app discovered during Phase 0, noted here so the Phase 2+ parity gates know to *improve* on them rather than replicate them: + +- (Populate during Task 9 / Task 12 / Task 13 reviews) +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/phase-0/README.md +git commit -m "Add Phase 0 deliverables README + +Indexes URL corpus, SEO/hreflang/VRT baselines, and inventory documents +for Phase 1+ consumers. Documents regeneration commands and the exit +gate checklist." +``` + +--- + +## Task 16: Phase 0 exit gate — verify everything and close out + +Final pass that confirms Phase 0 is complete and produces nothing new — just asserts the state is correct. + +**Files:** none (verification only) + +- [ ] **Step 1: Count URL corpus entries** + +```bash +for f in tests/fixtures/phase-0/url-corpus/*.json; do + echo -n "$f: " + node -e "console.log(require('./$f').count)" +done +``` + +Expected: all 4 files report non-zero counts. + +- [ ] **Step 2: Count SEO baselines** + +```bash +ls tests/fixtures/phase-0/seo-baselines/*.json 2>/dev/null | wc -l +``` + +Expected: at least 20 (ideally 26: 13 route shapes × 2 languages). + +- [ ] **Step 3: Count VRT baselines** + +```bash +ls tests/fixtures/phase-0/vrt-baselines/*.png 2>/dev/null | wc -l +``` + +Expected: `60`. + +- [ ] **Step 4: Confirm inventory docs exist and are non-empty** + +```bash +wc -l docs/superpowers/phase-0/primeng-backlog.md docs/superpowers/phase-0/scss-theme-manifest.md docs/superpowers/phase-0/translation-keys-used.md +``` + +Expected: all three files have > 10 lines. + +- [ ] **Step 5: Run the full unit-test suite one more time** + +```bash +pnpm test +``` + +Expected: all Phase 0 unit tests pass (`angular-routes`, `http`, `seo-extractor`). + +- [ ] **Step 6: Confirm customer checklist exists (answers pending is OK for this step — blocking Phase 1 but not Phase 0 closure)** + +```bash +test -f docs/superpowers/phase-0/customer-confirmation-checklist.md && echo "present" +``` + +Expected: `present` + +- [ ] **Step 7: Tag the Phase 0 closure commit** + +```bash +git tag -a phase-0-complete -m "Phase 0 (Preflight) deliverables captured and committed" +git push origin phase-0-complete +``` + +Expected: annotated tag created on origin. + +- [ ] **Step 8: Open a tracking issue (if using GitHub/GitLab)** + +Copy the customer confirmation checklist into a tracking issue titled "Phase 0 → Phase 1 blockers: customer answers needed" with the file's contents. Assign to the customer point-of-contact. Phase 1A does not start until that issue is closed with signed answers. + +--- + +## Self-review + +**Spec coverage against the design spec §9.2 Phase 0:** + +- [x] URL corpus from access logs — Tasks 5, 6 (supports both observed + enumerated modes) +- [x] JSON-LD / OG / hreflang baselines on representative routes — Tasks 8, 9 +- [x] VRT baselines of Angular prod — Task 10 +- [x] PrimeNG component inventory → `src/ui/primitives/` backlog — Task 11 +- [x] SCSS token + theme inventory → `src/ui/styles/` port list — Task 12 +- [x] `@ngx-translate` keys actually in use → translation-file port manifest — Task 13 +- [x] Customer confirmation of spec assumptions (§2.5, §8.2, §8.5, §7.2) — Task 14 +- [x] Phase 0 deliverables committed as fixtures + indexed — Tasks 15, 16 + +**Placeholder scan.** Searched for TBD/TODO/placeholder patterns in the task bodies above. Found one: the PrimeNG backlog template has a "(TBD — see §5.3 of the design spec)" column in the generated markdown. This is acceptable — it's a column in *generated output* that Phase 1E engineers fill in when they claim a component; it is not a gap in the plan itself. + +**Type consistency.** `RouteShape`, `RouteFeature`, `LANGUAGES`, `VrtRoute`, `ExtractedSeo`, `FetchWithRetryOptions`, `InventoryRow`, `Sighting` — all defined in exactly one place, referenced consistently across tasks. No Task-N-to-Task-M drift. + +**Scope check.** Phase 0 is appropriately sized for one plan file (~16 tasks over ~1 week of work). No decomposition needed. Phase 1 is a separate plan (see next document). -- 2.34.1 From 4ba41597235a120bd50e9fc8c44c92fecc4b3122 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 20:03:44 +0300 Subject: [PATCH 004/138] Add Phase 1 Foundation MASTER plan Index of 10 sub-plans (1A-1J) with dependency graph, exported contracts between sub-plans, shared-file ownership table, spec-coverage matrix, and global exit gate. Each sub-plan gets its own TDD-granular plan document written on demand via the writing-plans skill. --- .../2026-04-14-phase-1-foundation-master.md | 725 ++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md diff --git a/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md b/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md new file mode 100644 index 00000000..1f442d30 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md @@ -0,0 +1,725 @@ +# Phase 1 — Foundation MASTER Plan + +> **This document is a plan INDEX, not an executable plan.** It lists the Phase 1 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries. +> +> **Do not execute this document directly.** Each sub-plan (1A through 1J) is a separate file under `docs/superpowers/plans/` with its own TDD-granular tasks. They are written on demand by re-invoking the `superpowers:writing-plans` skill with a sub-plan-specific prompt. + +**Goal of Phase 1:** Build the complete Modern.js + Module Federation 2.0 foundation on which all four feature migrations (Phases 2–5) will be implemented. Nothing in Phase 1 ships to production users — the output is a working dual-build artifact deployed to the `testing` environment with all observability, security, and CI pipelines live. + +**Phase 1 exit gate** (must pass before Phase 2 starts): + +- Both build targets (standalone SSR + MF 2.0 remote) produce valid artifacts in CI. +- `mf-manifest.json` is served from the `testing` environment and consumable by a test host. +- The smoke route (`/ru/smoke`) renders via SSR in `testing` with all observability pipelines (logger, metrics, analytics) emitting correctly, verified by inspecting the log / metrics / analytics capture endpoints. +- All Phase 1 CI gates pass on `main`: lint, typecheck, unit (70%+ coverage on `src/features/` + `src/shared/` + `src/ui/` + `src/observability/`), bundle size, security scan, URL parity harness (infra only, no real URLs yet), SEO parity harness (infra only), VRT harness wired to Phase 0 baselines. +- Security hardening live: CSP with per-request nonce, HTTP security headers, dependency scanning green. +- Canary deploy pipeline functional (smoke route deployed via canary path with auto-rollback on health-check failure). + +**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md`. Phase 1 implements sections §1–§8 of the spec (everything except the feature ports in §9.2 Phase 2+). + +--- + +## Sub-plan inventory + +| ID | Sub-plan | Spec section | Estimated size | File | +|---|---|---|---|---| +| **1A** | Project skeleton + dual build targets | §1.3, §2.1, §2.2, §2.5 | Large | `docs/superpowers/plans/2026-04-14-phase-1a-skeleton.md` (TBW) | +| **1B** | CI pipeline | §8.5 | Medium | `2026-04-14-phase-1b-ci.md` (TBW) | +| **1C** | i18n runtime + locale port | §6.1–§6.4 | Medium | `2026-04-14-phase-1c-i18n.md` (TBW) | +| **1D** | API client + caches + circuit breaker | §4.1, §4.2 | Medium | `2026-04-14-phase-1d-api-client.md` (TBW) | +| **1E** | SignalR wrapper + `useLiveFlights` hook | §4.4 | Medium | `2026-04-14-phase-1e-signalr.md` (TBW) | +| **1F** | Root layout + error routes + smoke route + SeoHead | §1.3, §3.1, §3.3, §3.6, §6.5, §6.8 | Medium | `2026-04-14-phase-1f-layout.md` (TBW) | +| **1G** | Observability (logger, OTel, analytics) | §7.1–§7.8 | Large | `2026-04-14-phase-1g-observability.md` (TBW) | +| **1H** | Security hardening (CSP, headers, storage) | §8.1 | Small | `2026-04-14-phase-1h-security.md` (TBW) | +| **1I** | Deploy pipeline + health + graceful shutdown | §8.3, §8.5 | Medium | `2026-04-14-phase-1i-deploy.md` (TBW) | +| **1J** | Parity harnesses (URL / SEO / VRT) | §3.5, §8.4 | Medium | `2026-04-14-phase-1j-parity-harness.md` (TBW) | + +Sizes: **Small** ≈ 5–10 tasks, **Medium** ≈ 10–20 tasks, **Large** ≈ 20–40 tasks. + +--- + +## Dependency graph + +``` + ┌────────────────────┐ + │ 1A Skeleton │◄── everything depends on 1A + └─────┬──────────────┘ + ┌──────────────────┬┴──────┬────────────┬────────────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────┐ ┌──────────┐ ┌──────────────┐ + │ 1B CI │ │ 1C i18n │ │1D API│ │1E SignalR│ │1G Observabil.│ + └─────┬────┘ └─────┬────┘ └───┬──┘ └──────────┘ └──────┬───────┘ + │ │ │ │ + │ └──────┬───┘ │ + │ ▼ │ + │ ┌──────────────────────────────┐ │ + │ │ 1F Root layout + routes │◄─────┘ + │ │ (consumes 1C + 1D + 1G) │ + │ └──────┬───────────────────────┘ + │ ▼ + │ ┌──────────────────────────────┐ + │ │ 1H Security hardening │ + │ │ (middleware into 1F) │ + │ └──────┬───────────────────────┘ + │ │ + ▼ ▼ + ┌───────────────────────────────────────┐ + │ 1I Deploy pipeline │ + │ (consumes 1A + 1B + 1H) │ + └───────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────────┐ + │ 1J Parity harnesses │ + │ (consumes 1A + 1B + 1F) │ + └───────────────────────────────────────┘ +``` + +### Execution order + +**Serial (1 engineer):** 1A → 1B → 1C → 1D → 1G → 1E → 1F → 1H → 1I → 1J +Rationale: 1A unlocks everything. 1B unlocks running tests in CI. 1C and 1D unlock 1F. 1G is independent but 1F imports its hooks, so 1G comes before 1F. 1E can come after 1F because it's only consumed in Phase 2. 1H modifies files 1F creates, so it follows 1F. 1I consumes 1A+1B+1H. 1J consumes 1F. + +**Parallel (2+ engineers):** After 1A ships, 1B / 1C / 1D / 1E / 1G can proceed in parallel. 1F and later are sequential because of shared-file constraints. + +### Critical path + +**1A → 1C → 1F → 1H → 1I → exit gate** is the critical path with one engineer. Optimizing this path (by finishing 1A fast and prioritizing 1C + 1F + 1H as a single block) is the only way to shorten the Phase 1 calendar meaningfully. + +--- + +## Contracts — what each sub-plan exports + +This is the section that lets sub-plans be written and reviewed independently. Every sub-plan must produce its contracts without breaking changes once another sub-plan depends on them. Contracts are enforced via TypeScript types — any change to an exported type is a cross-sub-plan review gate. + +### 1A — Project skeleton contracts + +**Exports:** + +- **Project layout** — the `src/` directory tree from design spec §1.3. Every other sub-plan adds files inside this tree. No sub-plan is allowed to create top-level directories outside `src/` / `tests/` / `scripts/` / `docs/` without explicit call-out. +- **`modern.config.ts`** — the Modern.js build config with `BUILD_TARGET=standalone|remote` branching. Later sub-plans (1G, 1H, 1I) modify this file via explicit "modify" tasks — 1A owns the base structure. +- **`tsconfig.json`** — strict mode, path aliases (`@/` → `src/`, `@phase0/` → `scripts/phase-0/`), no-unchecked-indexed-access, isolatedModules. +- **`.eslintrc.cjs`** — including the `boundaries` plugin rules enforcing the layered dependency direction from design spec §1.2 (`features/` cannot import `routes/` or `mf/`; `ui/` cannot import `features/`; etc.). +- **`package.json` scripts** — `dev`, `build:standalone`, `build:remote`, `build:both`, `test`, `test:coverage`, `lint`, `typecheck`. +- **`src/env/index.ts`** — runtime env-var reader returning a typed `Env` object. Other sub-plans read env vars exclusively through this module. +- **Empty feature and UI barrel files** — `src/features/{online-board,schedule,flights-map,popular-requests}/index.ts` and `src/ui/index.ts` exist but export nothing. Phase 2+ populates them. + +**TypeScript contracts:** + +```ts +// src/env/index.ts +export interface Env { + NODE_ENV: "development" | "testing" | "staging" | "production"; + BUILD_TARGET: "standalone" | "remote"; + PROD_ORIGIN: string; + API_BASE_URL: string; + SIGNALR_HUB_URL: string; + OTEL_EXPORTER_OTLP_ENDPOINT?: string; + OTEL_EXPORTER_OTLP_HEADERS?: string; + LOGS_ENDPOINT?: string; + ANALYTICS_ENABLED: { metrica: boolean; ctm: boolean; variocube: boolean; dynatrace: boolean }; + VERSION: string; // git sha, injected at build time +} +export function getEnv(): Env; +``` + +**Exit gate for 1A:** `pnpm build:both` produces `dist/standalone/` (Node server + client bundle) and `dist/remote/` (static chunks + `mf-manifest.json`) with zero type errors and zero ESLint errors. The smoke build (no features wired yet) renders a blank `
` on both targets. + +--- + +### 1B — CI pipeline contracts + +**Exports:** + +- **`.github/workflows/ci.yml`** (or equivalent for the CI provider chosen in Phase 0 assumption A3). Runs on every PR and every push to `main`: install, lint, typecheck, unit tests, build both targets, bundle-size gate, security scan. +- **`.github/workflows/nightly.yml`** — nightly-only: contract tests, load test (stub until Phase 2), Lighthouse CI. +- **`scripts/ci/bundle-size-gate.ts`** — reads the Rspack build stats and compares against budgets in `docs/superpowers/phase-1/bundle-budgets.json`. Fails the build if any budget is exceeded. +- **`scripts/ci/check-coverage-delta.ts`** — reads Vitest coverage JSON and the prior-commit's coverage JSON (from the base branch), fails if coverage decreases. Uses `git show :coverage-summary.json` to read the baseline; tolerates missing baseline (first run). + +**TypeScript contracts:** none exported (scripts are CI-internal). + +**Exit gate for 1B:** A PR with a trivially-broken test fails CI; a PR with a green test passes CI in under 20 minutes. Bundle-size gate flags a fabricated regression. + +--- + +### 1C — i18n runtime contracts + +**Exports:** + +- **`src/i18n/config.ts`** — factory that creates a request-scoped `i18next` instance configured with `i18next-icu`, loaded with a single locale's bundle: + + ```ts + export function createI18nInstance(options: { + locale: Language; + initialResources?: Record>; + }): Promise; + ``` + +- **`src/i18n/resolver.ts`** — locale resolution from URL prefix: + + ```ts + export type Language = "ru"|"en"|"es"|"fr"|"it"|"ja"|"ko"|"zh"|"de"; + export const LANGUAGES: readonly Language[]; + export function isLanguage(x: string): x is Language; + export function resolveLocaleFromPath(pathname: string): Language | null; + export function stripLocaleFromPath(pathname: string): { locale: Language; rest: string } | null; + ``` + +- **`src/i18n/locales/{lang}/common.json`** — 9 files, ported from `ClientApp/src/assets/i18n/*.json` using the Phase 0 translation-key inventory to drop dead keys (optional; by default, all keys port). ICU MessageFormat syntax preserved byte-for-byte. +- **`src/i18n/serializer.ts`** — helpers to serialize the loaded locale bundle into the SSR HTML payload under `window.__I18N__` and rehydrate it on the client: + + ```ts + export function serializeI18nForHydration(i18n: i18n): string; // emits a JSON string + export function hydrateI18nFromWindow(): Promise; // reads window.__I18N__ + ``` + +- **`src/i18n/provider.tsx`** — React Context provider + `` component + `useI18n()` accessor. **Re-exports `useTranslation` from `react-i18next`** so feature code never imports `react-i18next` directly. + +**TypeScript contracts** (the above — downstream sub-plans import from `@/i18n`). + +**Exit gate for 1C:** Vitest test renders a component with `` loaded with `ru` and asserts `t("common.someKey")` returns the Russian value. SSR + hydration roundtrip test using `renderToString` verifies no client-side re-fetch or flash. + +--- + +### 1D — API client contracts + +**Exports:** + +- **`src/shared/api/client.ts`** — the `ApiClient` class: + + ```ts + export interface ApiClientOptions { + baseUrl: string; + locale: Language; + traceId?: string; + fetchImpl?: typeof fetch; // for tests + defaultTimeoutMs?: number; // default 5000 + circuitBreaker?: CircuitBreakerOptions; + } + + export class ApiClient { + constructor(options: ApiClientOptions); + get(path: string, query?: Record): Promise; + post(path: string, body: unknown): Promise; + } + ``` + +- **`src/shared/api/errors.ts`** — typed error classes: + + ```ts + export class ApiError extends Error { constructor(message: string); } + export class ApiHttpError extends ApiError { status: number; body?: unknown; } + export class ApiTimeoutError extends ApiError { timeoutMs: number; } + export class ApiNetworkError extends ApiError { cause?: Error; } + ``` + +- **`src/shared/api/cache.ts`** — two cache implementations: + + ```ts + export class RequestScopedCache { + get(key: string): Promise | undefined; + set(key: string, promise: Promise): void; + } + + export class TtlCache { + constructor(options: { max: number; defaultTtlMs: number }); + get(key: string): T | undefined; + set(key: string, value: T, ttlMs?: number): void; + delete(key: string): void; + clear(): void; + size: number; + } + ``` + +- **`src/shared/api/circuit-breaker.ts`**: + + ```ts + export interface CircuitBreakerOptions { + failureThreshold?: number; // default 5 + openDurationMs?: number; // default 30_000 + } + + export class CircuitBreaker { + constructor(options?: CircuitBreakerOptions); + exec(fn: () => Promise): Promise; + reset(): void; + state: "closed" | "open" | "half-open"; + } + ``` + +- **`src/shared/api/provider.tsx`** — React Context provider + `useApiClient()` hook. SSR-aware: on the server, the client is constructed per-request with the resolved locale; on the client, a single instance is shared across the tab. + +**TypeScript contracts:** the above. All ported feature code (Phase 2+) imports exclusively from `@/shared/api`. + +**Exit gate for 1D:** Vitest tests cover: success response deserialization; retry on 5xx; no retry on 4xx; timeout; circuit breaker open/half-open/closed state transitions; request-scoped cache dedup; TTL eviction. + +--- + +### 1E — SignalR wrapper contracts + +**Exports:** + +- **`src/shared/signalr/connection.ts`** — the reference-counted connection wrapper: + + ```ts + export interface HubOptions { + hubUrl: string; + reconnectDelaysMs?: number[]; // default [0, 2000, 10000, 30000] + gracePeriodMs?: number; // default 5000 + } + + export class SignalRConnection { + constructor(options: HubOptions); + subscribe(channel: string, handler: (message: unknown) => void): () => void; // returns unsubscribe + onStatusChange(handler: (status: ConnectionStatus) => void): () => void; + get status(): ConnectionStatus; + } + + export type ConnectionStatus = "idle" | "connecting" | "live" | "reconnecting" | "offline"; + + export function getSharedConnection(options: HubOptions): SignalRConnection; + ``` + +- **`src/shared/hooks/useLiveFlights.ts`**: + + ```ts + export function useLiveFlights( + params: { date: string; departure?: string; arrival?: string }, + initialData: T[], + config: { hubUrl: string; channelKey: (params: object) => string }, + ): { data: T[]; connectionStatus: ConnectionStatus }; + ``` + + SSR-safe: during SSR, returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr`. + +**TypeScript contracts:** the above. Phase 2 Online Board consumes `useLiveFlights` and the `ConnectionStatus` type. + +**Exit gate for 1E:** + +- Unit test: two rapid `useEffect` mounts (Strict Mode double-invoke simulation) result in exactly one `HubConnection.start()` call. +- Unit test: unmount + remount within the grace period reuses the connection; unmount + remount after the grace period creates a fresh one. +- Unit test: SSR render path does not import `@microsoft/signalr` (asserted by inspecting the SSR bundle stats for the absence of the package). + +--- + +### 1F — Root layout + routes + SeoHead contracts + +**Exports:** + +- **`src/routes/layout.tsx`** — root HTML shell: ``, ``, ``, ``, root ``, root ``, root ``, root `` (from 1G). + +- **`src/routes/[lang]/layout.tsx`** — locale-scoped layout: validates `params.lang`, creates the request-scoped i18next instance (via 1C's `createI18nInstance`), builds the canonical URL + hreflang set, passes them into ``. + +- **`src/routes/error/[code]/page.tsx`** — error page rendered for `code ∈ {404, 500, 503}`. Ports the existing Angular error component layout. + +- **`src/routes/[lang]/smoke/page.tsx`** — smoke route that exercises every foundation subsystem: emits a log at `info`, emits a metric counter, calls `track("smoke.pageview")`, renders `{t("smoke.heading")}`, fetches a dummy API endpoint via `ApiClient.get`, renders `` with canonical + hreflang. Visible in `testing` env at `/ru/smoke` and `/en/smoke`. + +- **`src/ui/seo/SeoHead.tsx`** — the `` component from design spec §6.5: + + ```ts + export interface SeoHeadProps { + title: string; + description: string; + canonical: string; + hreflang: Array<{ lang: Language | "x-default"; href: string }>; + og: { + title: string; description: string; url: string; + image: string; type: "website" | "article"; + locale: string; siteName: string; + }; + twitter?: { + card: "summary" | "summary_large_image"; + title?: string; description?: string; image?: string; + }; + jsonLd?: unknown | unknown[]; + noindex?: boolean; + } + export function SeoHead(props: SeoHeadProps): JSX.Element; + ``` + +- **`src/shared/seo/hreflang.ts`** — reusable reciprocal-hreflang builder: + + ```ts + export function buildHreflangSet(args: { + canonicalOrigin: string; + pathWithoutLocale: string; // e.g. "/onlineboard/flight/SU100-2025-01-15" + }): Array<{ lang: Language | "x-default"; href: string }>; + ``` + +- **`src/ui/errors/ErrorBoundary.tsx`** — React error boundary component. Logs the error (via 1G's `useLogger`), emits the `flights.react.error` metric, shows a fallback UI with a "Retry" button that resets the boundary's state. + +**Shared file ownership flag:** `src/routes/layout.tsx` is owned by 1F. Sub-plans 1G (analytics loader mount) and 1H (CSP nonce propagation) **modify** this file via explicit tasks in their own plans, referencing the 1F-shipped version as the base. + +**TypeScript contracts:** the above. Phase 2+ features import `SeoHead` + `buildHreflangSet` + `ErrorBoundary`. + +**Exit gate for 1F:** `/ru/smoke` and `/en/smoke` render via SSR in the `testing` env. `` contains title, description, canonical, 9 hreflang alternates + x-default, OG tags, one JSON-LD block, one `` description. Missing `lang` URLs redirect 301 to `/ru/smoke`. 404 route returns HTTP 404 with the error page body. + +--- + +### 1G — Observability contracts + +**Exports:** + +- **`src/observability/logger/types.ts`** — the `Logger` interface from design spec §7.2: + + ```ts + export type LogLevel = "debug" | "info" | "warn" | "error"; + export type LogFields = Record; + + 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; + } + + export interface LogTransport { + write(record: LogRecord): void; + flush(): Promise; + } + + export interface LogRecord { + ts: string; level: LogLevel; msg: string; fields: LogFields; + } + ``` + +- **`src/observability/logger/json-lines-transport.ts`** — default `JsonLinesHttpTransport` with batching, backpressure drop, redaction, `sendBeacon` flush. + +- **`src/observability/logger/console-transport.ts`** — dev-mode transport that pipes records to `console[level]`. + +- **`src/observability/logger/root.ts`** — `createRootLogger()` factory that reads env config and picks a transport. + +- **`src/observability/logger/provider.tsx`** — React context + `useLogger()` hook. On the server, the hook returns the request-scoped child logger; on the client, it returns the shared root logger. + +- **`src/observability/metrics/otel.ts`** — OpenTelemetry setup: + + ```ts + export function initServerOtel(env: Env): void; // called once per Node process + export function initBrowserOtel(env: Env): void; // called once per tab + export function getMeter(name: string): Meter; // thin re-export of @opentelemetry/api + export function getTracer(name: string): Tracer; + ``` + +- **`src/observability/metrics/custom.ts`** — the minimum-set custom metrics from design spec §7.3 as exported instruments: + + ```ts + export const flightsSsrRequestDuration: Histogram; + export const flightsApiRequestDuration: Histogram; + export const flightsApiError: Counter; + export const flightsSignalRConnected: UpDownCounter; + export const flightsSignalRMessageReceived: Counter; + export const flightsSignalRDisconnect: Counter; + export const flightsFeatureRender: Counter; + export const flightsReactError: Counter; + // web-vitals histograms created at init time, not exported statically + ``` + +- **`src/observability/analytics/facade.ts`**: + + ```ts + export interface AnalyticsProps { [k: string]: unknown; } + + export interface Analytics { + track(event: string, props?: AnalyticsProps): void; + page(url: string, props?: AnalyticsProps): void; + } + + export function createAnalytics(options: { + enabled: Env["ANALYTICS_ENABLED"]; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; + }): Analytics; + ``` + +- **`src/observability/analytics/adapters/{metrica,ctm,variocube,dynatrace}.ts`** — four adapters implementing: + + ```ts + export interface AnalyticsAdapter { + name: string; + load(): Promise; + track(event: string, props?: AnalyticsProps): void; + page(url: string, props?: AnalyticsProps): void; + } + ``` + + Phase 1 ships these as **stubs that simulate loading and log calls instead of hitting real vendor scripts**. Real vendor scripts are wired in Phase 2A (alongside the Online Board migration) after the customer provides credentials (see Phase 0 task 14, assumption A7). + +- **`src/observability/analytics/loader.tsx`** — `` component that mounts in the root layout (owned by 1F, modified by 1G to add the mount). Waits for `requestIdleCallback`, then imports enabled adapters and calls `.load()`. + +- **`src/observability/analytics/provider.tsx`** — React context + `useAnalytics()` hook. On the server, returns a `NoopAnalytics`. On the client, returns the instance created by ``. + +**TypeScript contracts:** the above. `Logger`, `Analytics`, `getMeter`, `getTracer` are the four surfaces every feature imports from. + +**Exit gate for 1G:** + +- Vitest tests cover: logger batching + flush; redaction of sensitive fields; transport backpressure drops old records; dev-mode console transport; analytics facade fans out to all four stub adapters; consent=false short-circuits; adapter load failure emits `flights.analytics.load_failed` metric. +- Integration test: smoke route (1F) emits one log, one metric, and one analytics event — all three observable in a test harness capturing the pipeline outputs. + +--- + +### 1H — Security hardening contracts + +**Exports:** + +- **`src/server/middleware/csp.ts`** — Modern.js middleware that generates a per-request nonce, sets the `Content-Security-Policy` header with the nonce, and exposes the nonce to the React render tree via a request-scoped context. + + ```ts + export function cspMiddleware(options: { reportOnly?: boolean }): ModernMiddleware; + export const CspNonceContext: React.Context; + ``` + +- **`src/server/middleware/security-headers.ts`** — Modern.js middleware that sets HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy. + +- **`src/shared/storage.ts`** — the only module allowed to touch `window.localStorage` / `window.sessionStorage`. Namespaced keys + redaction on write: + + ```ts + export const storage = { + get(key: string, schema: ZodSchema): T | null, + set(key: string, value: T, schema: ZodSchema): void, + delete(key: string): void, + clear(): void, + }; + ``` + + A zero-dep ESLint rule in `.eslintrc.cjs` forbids `window.localStorage` references anywhere except `src/shared/storage.ts`. + +- **`.eslintrc.cjs` additions** — `no-eval`, `no-implied-eval`, custom rule banning raw `innerHTML` assignments in TSX (handled by the `react/no-danger` rule + a custom augmentation). + +**Shared file ownership flags:** + +- `modern.config.ts` (owned by 1A) — 1H modifies it to register the two middlewares. +- `src/routes/layout.tsx` (owned by 1F) — 1H modifies it to propagate the CSP nonce into ``-emitted inline scripts. + +**TypeScript contracts:** the middleware signatures + `storage` API. + +**Exit gate for 1H:** + +- Test: an SSR render emits a CSP header containing a unique nonce per request, and every inline ` to prevent injection", () => { + const data: Thing = { + "@type": "WebSite", + name: '', + }; + + const result = serializeJsonLd(data); + expect(result).not.toContain(""); + }); +}); + +describe("JsonLdRenderer", () => { + it("renders a "); + expect(html).toContain('"@context":"https://schema.org"'); + expect(html).toContain('"@type":"WebSite"'); + }); + + it("round-trips: serialize → DOM string contains valid JSON-LD", () => { + const data: Thing = { + "@type": "Organization", + name: "Aeroflot PJSC", + url: "https://www.aeroflot.ru", + }; + + const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data })); + + // Extract JSON from the script tag + const match = html.match(/]*>([\s\S]*?)<\/script>/); + expect(match).not.toBeNull(); + + const json = match![1]!.replace(/\\u003c/g, "<"); + const parsed = JSON.parse(json); + expect(parsed["@context"]).toBe("https://schema.org"); + expect(parsed["@type"]).toBe("Organization"); + expect(parsed.name).toBe("Aeroflot PJSC"); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/shared/seo/json-ld +``` + +- [ ] **Step 3: Write implementation** + +Create `src/shared/seo/json-ld.tsx`: + +```tsx +import type { Thing } from "schema-dts"; + +export interface JsonLdRendererProps { + data: Thing | Thing[]; +} + +/** + * Serializes a schema-dts Thing (or array of Things) to a JSON-LD string. + * Adds "@context": "https://schema.org" to each item. + * Escapes sequences to prevent XSS. + */ +export function serializeJsonLd(data: Thing | Thing[]): string { + const withContext = Array.isArray(data) + ? data.map((item) => ({ "@context": "https://schema.org" as const, ...item })) + : { "@context": "https://schema.org" as const, ...data }; + + return JSON.stringify(withContext).replace(/<\//g, "\\u003c/"); +} + +/** + * Renders a ` escaping in `serializeJsonLd` → Task 3 + +**Exit gate alignment:** +- "buildHreflangSet covers 9 langs + x-default" — Task 2 tests +- "SeoHead emits the full shape" — Task 4 component (tested by 1F-layout integration) +- "JsonLdRenderer round-trips a typed Thing through serializeJsonLd → DOM string" — Task 3 tests + +**Type consistency.** `Language` from `@/i18n/resolver` (seeded in 1C). `Thing` from `schema-dts`. `SeoHeadProps` matches the master plan contract exactly. diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md b/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md new file mode 100644 index 00000000..2d1e3d26 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md @@ -0,0 +1,600 @@ +# Phase 1G-analytics — Analytics Facade + Stub Adapters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the analytics facade — a test-observable event sink, four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace), a `createAnalytics()` factory that fans out `track`/`page` calls to enabled adapters with consent gating, plus the `` component and `useAnalytics()` hook — so that 1F-layout and all downstream features can emit analytics events with `analytics.track("search.submit", { query })`. + +**Architecture:** `types.ts` is already seeded (1A-1) with `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, and `AnalyticsAdapter`. Each stub adapter emits `AnalyticsEvent` records to a shared sink (`sink.ts`) for test observability. `facade.ts` accepts enabled providers + consent flags and fans out to adapters. In production, the sink is a no-op ring buffer; in test, `getRecordedEvents()` / `resetEvents()` allow assertions. Real vendor scripts replace the stubs in Phase 2A after A7 resolves. + +**Tech Stack:** No new dependencies. Stubs use no vendor SDKs. + +**Prerequisites:** 1A-1 (skeleton + types.ts seeded), 1A-3 (ESLint boundaries), 1G-logger (Logger types for facade logging). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/observability/analytics/sink.ts` | Test-observable event sink | 1 | +| `src/observability/analytics/sink.test.ts` | Tests | 1 | +| `src/observability/analytics/adapters/metrica.ts` | Yandex.Metrica stub adapter | 2 | +| `src/observability/analytics/adapters/ctm.ts` | CTM stub adapter | 2 | +| `src/observability/analytics/adapters/variocube.ts` | Variocube stub adapter | 2 | +| `src/observability/analytics/adapters/dynatrace.ts` | Dynatrace stub adapter | 2 | +| `src/observability/analytics/facade.ts` | `createAnalytics()` factory | 3 | +| `src/observability/analytics/facade.test.ts` | Tests | 3 | +| `src/observability/analytics/loader.tsx` | `` component | 4 | +| `src/observability/analytics/provider.tsx` | `useAnalytics()` hook | 5 | + +--- + +## Task 1 — TDD `sink.ts` + +**Files:** +- Create: `src/observability/analytics/sink.ts` +- Create: `src/observability/analytics/sink.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/analytics/sink.test.ts`: + +```typescript +import { describe, expect, it, beforeEach } from "vitest"; +import { emitEvent, getRecordedEvents, resetEvents } from "./sink.js"; +import type { AnalyticsEvent } from "./types.js"; + +describe("analytics sink", () => { + beforeEach(() => { + resetEvents(); + }); + + it("records emitted events", () => { + const event: AnalyticsEvent = { + kind: "track", + name: "test.click", + props: { button: "cta" }, + provider: "metrica", + ts: new Date().toISOString(), + }; + + emitEvent(event); + expect(getRecordedEvents()).toHaveLength(1); + expect(getRecordedEvents()[0]).toEqual(event); + }); + + it("records multiple events in order", () => { + emitEvent({ kind: "track", name: "a", props: {}, provider: "ctm", ts: "t1" }); + emitEvent({ kind: "page", name: "/home", props: {}, provider: "dynatrace", ts: "t2" }); + + const events = getRecordedEvents(); + expect(events).toHaveLength(2); + expect(events[0]?.name).toBe("a"); + expect(events[1]?.name).toBe("/home"); + }); + + it("resetEvents clears all recorded events", () => { + emitEvent({ kind: "track", name: "x", props: {}, provider: "variocube", ts: "t" }); + expect(getRecordedEvents()).toHaveLength(1); + resetEvents(); + expect(getRecordedEvents()).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/observability/analytics/sink +``` + +- [ ] **Step 3: Write implementation** + +Create `src/observability/analytics/sink.ts`: + +```typescript +import type { AnalyticsEvent } from "./types.js"; + +let events: AnalyticsEvent[] = []; + +/** + * Emit an analytics event to the test-observable sink. + * In production, this is a no-op ring buffer (capped to prevent memory leaks). + * In test, events are retained for assertion via getRecordedEvents(). + */ +export function emitEvent(event: AnalyticsEvent): void { + events.push(event); + + // Ring buffer: cap at 1000 events to prevent unbounded growth + if (events.length > 1000) { + events = events.slice(-500); + } +} + +/** Returns all recorded events (for test assertions). */ +export function getRecordedEvents(): readonly AnalyticsEvent[] { + return events; +} + +/** Clears all recorded events (for test teardown). */ +export function resetEvents(): void { + events = []; +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/observability/analytics/sink +``` + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/sink.ts src/observability/analytics/sink.test.ts +git commit -m "Add test-observable analytics event sink" +``` + +--- + +## Task 2 — Create 4 stub adapters (no TDD) + +**Files:** +- Create: `src/observability/analytics/adapters/metrica.ts` +- Create: `src/observability/analytics/adapters/ctm.ts` +- Create: `src/observability/analytics/adapters/variocube.ts` +- Create: `src/observability/analytics/adapters/dynatrace.ts` + +- [ ] **Step 1: Write all four adapters** + +Each adapter follows the same pattern. Create `src/observability/analytics/adapters/metrica.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class MetricaAdapter implements AnalyticsAdapter { + readonly name = "metrica"; + + async load(): Promise { + // Stub: real Yandex.Metrica script loads in Phase 2A (after A7 resolves) + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +Create `src/observability/analytics/adapters/ctm.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class CtmAdapter implements AnalyticsAdapter { + readonly name = "ctm"; + + async load(): Promise { + // Stub: real CTM script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +Create `src/observability/analytics/adapters/variocube.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class VariocubeAdapter implements AnalyticsAdapter { + readonly name = "variocube"; + + async load(): Promise { + // Stub: real Variocube script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +Create `src/observability/analytics/adapters/dynatrace.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class DynatraceAdapter implements AnalyticsAdapter { + readonly name = "dynatrace"; + + async load(): Promise { + // Stub: real Dynatrace (Key-Astrom) script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/adapters/ +git commit -m "Add four stub analytics adapters (metrica, ctm, variocube, dynatrace)" +``` + +--- + +## Task 3 — TDD `facade.ts` + +**Files:** +- Create: `src/observability/analytics/facade.ts` +- Create: `src/observability/analytics/facade.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/analytics/facade.test.ts`: + +```typescript +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { createAnalytics } from "./facade.js"; +import { getRecordedEvents, resetEvents } from "./sink.js"; +import type { Logger } from "@/observability/logger/types"; + +function mockLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => mockLogger()), + }; +} + +describe("createAnalytics", () => { + beforeEach(() => { + resetEvents(); + }); + + it("fans out track() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("test.event", { key: "value" }); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["ctm", "dynatrace", "metrica", "variocube"]); + + for (const event of events) { + expect(event.kind).toBe("track"); + expect(event.name).toBe("test.event"); + expect(event.props).toEqual({ key: "value" }); + } + }); + + it("fans out page() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.page("/ru/online-board"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + for (const event of events) { + expect(event.kind).toBe("page"); + expect(event.name).toBe("/ru/online-board"); + } + }); + + it("consent.analytics = false short-circuits before any adapter is invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: false, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("should.not.emit"); + analytics.page("/should/not/emit"); + + expect(getRecordedEvents()).toHaveLength(0); + }); + + it("disabled adapter is not invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: false, variocube: false, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("partial.event"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(2); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["dynatrace", "metrica"]); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/observability/analytics/facade +``` + +- [ ] **Step 3: Write implementation** + +Create `src/observability/analytics/facade.ts`: + +```typescript +import type { Analytics, AnalyticsAdapter, AnalyticsProps, AnalyticsProviders } from "./types.js"; +import type { Logger } from "@/observability/logger/types"; +import { MetricaAdapter } from "./adapters/metrica.js"; +import { CtmAdapter } from "./adapters/ctm.js"; +import { VariocubeAdapter } from "./adapters/variocube.js"; +import { DynatraceAdapter } from "./adapters/dynatrace.js"; + +export interface CreateAnalyticsOptions { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; +} + +const NOOP_ANALYTICS: Analytics = { + track() {}, + page() {}, +}; + +/** + * Creates an Analytics instance that fans out track/page calls to enabled adapters. + * If consent.analytics is false, returns a no-op (short-circuit before any adapter). + */ +export function createAnalytics(options: CreateAnalyticsOptions): Analytics { + const { enabled, consent, logger } = options; + + if (!consent.analytics) { + logger.debug("analytics consent denied, returning no-op"); + return NOOP_ANALYTICS; + } + + const adapters: AnalyticsAdapter[] = []; + if (enabled.metrica) adapters.push(new MetricaAdapter()); + if (enabled.ctm) adapters.push(new CtmAdapter()); + if (enabled.variocube) adapters.push(new VariocubeAdapter()); + if (enabled.dynatrace) adapters.push(new DynatraceAdapter()); + + if (adapters.length === 0) { + logger.debug("no analytics adapters enabled, returning no-op"); + return NOOP_ANALYTICS; + } + + return { + track(event: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.track(event, props); + } catch (err) { + logger.error("analytics adapter track failed", { provider: adapter.name, err: err as Error }); + } + } + }, + + page(url: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.page(url, props); + } catch (err) { + logger.error("analytics adapter page failed", { provider: adapter.name, err: err as Error }); + } + } + }, + }; +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/observability/analytics/facade +``` + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/facade.ts src/observability/analytics/facade.test.ts +git commit -m "Add analytics facade with adapter fan-out and consent gating" +``` + +--- + +## Task 4 — Create `loader.tsx` (no TDD) + +**Files:** +- Create: `src/observability/analytics/loader.tsx` + +- [ ] **Step 1: Write implementation** + +Create `src/observability/analytics/loader.tsx`: + +```tsx +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { Analytics, AnalyticsProviders } from "./types.js"; +import type { Logger } from "@/observability/logger/types"; +import { createAnalytics } from "./facade.js"; +import { AnalyticsContext } from "./provider.js"; + +export interface AnalyticsLoaderProps { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; + children: ReactNode; +} + +/** + * Mounts in the root layout. Waits for idle callback, then initializes + * analytics adapters and provides the Analytics instance to the tree. + */ +export function AnalyticsLoader({ + enabled, + consent, + logger, + children, +}: AnalyticsLoaderProps): JSX.Element { + const [analytics, setAnalytics] = useState(null); + const initRef = useRef(false); + + useEffect(() => { + if (initRef.current) return; + initRef.current = true; + + const init = () => { + const instance = createAnalytics({ enabled, consent, logger }); + setAnalytics(instance); + }; + + if (typeof window !== "undefined" && "requestIdleCallback" in window) { + (window as any).requestIdleCallback(init); + } else { + // Fallback for environments without requestIdleCallback + setTimeout(init, 1); + } + }, [enabled, consent, logger]); + + return ( + + {children} + + ); +} +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/loader.tsx +git commit -m "Add AnalyticsLoader component with idle-callback initialization" +``` + +--- + +## Task 5 — Create `provider.tsx` (no TDD) + +**Files:** +- Create: `src/observability/analytics/provider.tsx` + +- [ ] **Step 1: Write implementation** + +Create `src/observability/analytics/provider.tsx`: + +```tsx +import { createContext, useContext } from "react"; +import type { Analytics } from "./types.js"; + +const NOOP_ANALYTICS: Analytics = { + track() {}, + page() {}, +}; + +/** + * React context for the Analytics instance. + * Exported for use by AnalyticsLoader (which sets the provider value). + */ +export const AnalyticsContext = createContext(null); + +/** + * Returns the Analytics instance from context. + * Server-side and before AnalyticsLoader initializes: returns NoopAnalytics. + * Client-side after init: returns the real facade instance. + */ +export function useAnalytics(): Analytics { + const analytics = useContext(AnalyticsContext); + return analytics ?? NOOP_ANALYTICS; +} +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/provider.tsx +git commit -m "Add useAnalytics hook with server-safe NoopAnalytics fallback" +``` + +--- + +## Task 6 — Exit-gate verification + +- [ ] **Step 1: All gates** + +```bash +pnpm typecheck && pnpm lint && pnpm test +``` + +Expected: all pass. Sink + facade tests verify adapter fan-out, consent short-circuit, and disabled-adapter exclusion. + +- [ ] **Step 2: Git status clean** + +```bash +git status +``` + +--- + +## Self-review + +**Spec coverage.** Master plan §1G-analytics: +- `types.ts` (already seeded in 1A-1) — `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, `AnalyticsAdapter` ✓ +- `sink.ts` with `emitEvent`, `getRecordedEvents`, `resetEvents` → Task 1 +- Four stub adapters (metrica, ctm, variocube, dynatrace) → Task 2 +- `createAnalytics()` with consent gating and adapter fan-out → Task 3 +- `` with `requestIdleCallback` → Task 4 +- `useAnalytics()` with NoopAnalytics server fallback → Task 5 +- Consent short-circuit verified in facade tests → Task 3 + +**Exit gate alignment:** +- "all four stub adapters emit exactly one AnalyticsEvent to the sink" — facade test, Task 3 +- "consent.analytics = false short-circuits" — facade test, Task 3 +- "adapter load failure emits flights.analytics.load_failed counter" — deferred to 1F-layout integration (loader wraps load() in try/catch and increments the metric from 1G-metrics) + +**No new dependencies.** Stubs are pure TypeScript with no vendor SDKs. diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md b/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md new file mode 100644 index 00000000..2d1e1080 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md @@ -0,0 +1,366 @@ +# Phase 1G-metrics — OpenTelemetry + Custom Instruments Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the OpenTelemetry runtime — server and browser initializers, `getMeter`/`getTracer` accessors, and the 8 custom metric instruments — so that 1F-layout, 1D, 1E, and all downstream features can emit structured metrics with `flightsApiError.add(1, { route })` in both SSR and client contexts. + +**Architecture:** `otel.ts` is the **only** file allowed to import from `@opentelemetry/sdk-metrics` and `@opentelemetry/sdk-node` (enforced by 1A-3 ESLint boundaries). It exports `initServerOtel(env)` and `initBrowserOtel(env)` which wire the real `MeterProvider`/`TracerProvider`. `custom.ts` uses `@opentelemetry/api`'s proxy meter to declare instruments at module level — safe because the proxy lazy-resolves after init runs. `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`. + +**Tech Stack:** `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/exporter-metrics-otlp-http`, `web-vitals`. + +**Prerequisites:** 1A-1 (skeleton + `Env` type), 1A-3 (ESLint boundaries), 1G-logger (Logger types). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/observability/metrics/otel.ts` | OTel init + getMeter/getTracer | 2 | +| `src/observability/metrics/otel.test.ts` | Tests | 2 | +| `src/observability/metrics/custom.ts` | 8 custom metric instruments | 3 | + +--- + +## Task 1 — Install OTel dependencies + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install dependencies** + +```bash +pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http web-vitals +``` + +- [ ] **Step 2: Verify installation** + +```bash +pnpm typecheck +``` + +- [ ] **Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "Add OpenTelemetry and web-vitals dependencies for metrics pipeline" +``` + +--- + +## Task 2 — TDD `otel.ts` + +**Files:** +- Create: `src/observability/metrics/otel.ts` +- Create: `src/observability/metrics/otel.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/metrics/otel.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { metrics, trace } from "@opentelemetry/api"; +import { + InMemoryMetricExporter, + AggregationTemporality, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; + +describe("otel", () => { + beforeEach(() => { + // Reset global providers between tests + metrics.disable(); + trace.disable(); + }); + + afterEach(() => { + metrics.disable(); + trace.disable(); + vi.restoreAllMocks(); + }); + + it("initServerOtel registers a MeterProvider and TracerProvider", async () => { + const { initServerOtel } = await import("./otel.js"); + + initServerOtel({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + OTEL_SERVICE_NAME: "flights-test", + NODE_ENV: "test", + } as any); + + // After init, getMeter should return a working meter + const { getMeter, getTracer } = await import("./otel.js"); + const meter = getMeter("test"); + const tracer = getTracer("test"); + + expect(meter).toBeDefined(); + expect(tracer).toBeDefined(); + }); + + it("counter incremented via proxy meter is observable by test reader", async () => { + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + const reader = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 100, + }); + + const { initServerOtelWithReader } = await import("./otel.js"); + initServerOtelWithReader({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + OTEL_SERVICE_NAME: "flights-test", + NODE_ENV: "test", + } as any, reader); + + const counter = metrics.getMeter("flights").createCounter("test.counter"); + counter.add(1, { route: "/smoke" }); + + // Force a collection cycle + await reader.forceFlush(); + + const exported = exporter.getMetrics(); + expect(exported.length).toBeGreaterThan(0); + + const testMetric = exported + .flatMap((rm) => rm.scopeMetrics) + .flatMap((sm) => sm.metrics) + .find((m) => m.descriptor.name === "test.counter"); + + expect(testMetric).toBeDefined(); + + await reader.shutdown(); + }); + + it("getMeter returns a meter from @opentelemetry/api", async () => { + const { getMeter } = await import("./otel.js"); + const meter = getMeter("my-component"); + expect(meter).toBeDefined(); + expect(typeof meter.createCounter).toBe("function"); + expect(typeof meter.createHistogram).toBe("function"); + }); + + it("getTracer returns a tracer from @opentelemetry/api", async () => { + const { getTracer } = await import("./otel.js"); + const tracer = getTracer("my-component"); + expect(tracer).toBeDefined(); + expect(typeof tracer.startSpan).toBe("function"); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/observability/metrics/otel +``` + +- [ ] **Step 3: Write implementation** + +Create `src/observability/metrics/otel.ts`: + +```typescript +import { metrics, trace } from "@opentelemetry/api"; +import type { Meter, Tracer } from "@opentelemetry/api"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import type { MetricReader } from "@opentelemetry/sdk-metrics"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import type { Env } from "@/env"; +import type { Logger } from "@/observability/logger/types"; + +let initialized = false; + +/** + * Initialize OpenTelemetry for the server (Node) process. + * Called once per process at startup. + */ +export function initServerOtel(env: Env): void { + if (initialized) return; + + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + const serviceName = env.OTEL_SERVICE_NAME ?? "flights-web"; + + const metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), + exportIntervalMillis: 15_000, + }); + + const sdk = new NodeSDK({ + serviceName, + traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }), + metricReader, + }); + + sdk.start(); + initialized = true; +} + +/** + * Test-only variant that accepts a custom MetricReader for in-memory assertions. + */ +export function initServerOtelWithReader(env: Env, reader: MetricReader): void { + if (initialized) return; + + const serviceName = (env as Record).OTEL_SERVICE_NAME ?? "flights-test"; + + const meterProvider = new MeterProvider({ + readers: [reader], + }); + + metrics.setGlobalMeterProvider(meterProvider); + initialized = true; +} + +/** + * Initialize OpenTelemetry for the browser. + * Called once per tab via useEffect in the root layout. + * Browser-side uses web-vitals to report CWV as histograms. + */ +export function initBrowserOtel(env: Env): void { + if (initialized) return; + + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!endpoint) return; + + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), + exportIntervalMillis: 30_000, + }), + ], + }); + + metrics.setGlobalMeterProvider(meterProvider); + + // Report web-vitals as OTel histograms + const cwvMeter = meterProvider.getMeter("web-vitals"); + void import("web-vitals").then(({ onCLS, onFID, onLCP, onFCP, onTTFB }) => { + const cls = cwvMeter.createHistogram("web_vitals.cls"); + const fid = cwvMeter.createHistogram("web_vitals.fid"); + const lcp = cwvMeter.createHistogram("web_vitals.lcp"); + const fcp = cwvMeter.createHistogram("web_vitals.fcp"); + const ttfb = cwvMeter.createHistogram("web_vitals.ttfb"); + + onCLS((m) => cls.record(m.value)); + onFID((m) => fid.record(m.value)); + onLCP((m) => lcp.record(m.value)); + onFCP((m) => fcp.record(m.value)); + onTTFB((m) => ttfb.record(m.value)); + }); + + initialized = true; +} + +/** Returns a named Meter from the global MeterProvider. */ +export function getMeter(name: string): Meter { + return metrics.getMeter(name); +} + +/** Returns a named Tracer from the global TracerProvider. */ +export function getTracer(name: string): Tracer { + return trace.getTracer(name); +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/observability/metrics/otel +``` + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/metrics/otel.ts src/observability/metrics/otel.test.ts +git commit -m "Add OTel server/browser initializers with getMeter/getTracer accessors" +``` + +--- + +## Task 3 — Create `custom.ts` (declarative, no TDD) + +**Files:** +- Create: `src/observability/metrics/custom.ts` + +- [ ] **Step 1: Write implementation** + +Create `src/observability/metrics/custom.ts`: + +```typescript +import { metrics } from "@opentelemetry/api"; + +/** + * Module-level metric instruments for the flights remote component. + * Safe to declare at module scope — @opentelemetry/api's proxy meter + * lazy-resolves to the real MeterProvider after initServerOtel/initBrowserOtel runs. + */ +const meter = metrics.getMeter("flights"); + +/** SSR request duration histogram (seconds). */ +export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration"); + +/** Upstream API request duration histogram (seconds). */ +export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration"); + +/** Upstream API error counter (by route, status). */ +export const flightsApiError = meter.createCounter("flights.api.error"); + +/** SignalR active connections gauge. */ +export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected"); + +/** SignalR messages received counter. */ +export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received"); + +/** SignalR disconnection counter (by reason). */ +export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect"); + +/** Feature component render counter (by feature name). */ +export const flightsFeatureRender = meter.createCounter("flights.feature.render"); + +/** Unhandled React error counter (caught by ErrorBoundary). */ +export const flightsReactError = meter.createCounter("flights.react.error"); +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/metrics/custom.ts +git commit -m "Add 8 custom metric instruments using OTel proxy meter" +``` + +--- + +## Task 4 — Exit-gate verification + +- [ ] **Step 1: All gates** + +```bash +pnpm typecheck && pnpm lint && pnpm test +``` + +Expected: all pass. OTel init test proves counter is observable via test reader. + +- [ ] **Step 2: Git status clean** + +```bash +git status +``` + +--- + +## Self-review + +**Spec coverage.** Master plan §1G-metrics: +- `initServerOtel(env)` / `initBrowserOtel(env)` — Task 2 +- `getMeter(name)` / `getTracer(name)` — Task 2 +- 8 custom instruments (`flights.ssr.request.duration`, `flights.api.request.duration`, `flights.api.error`, `flights.signalr.connected`, `flights.signalr.message.received`, `flights.signalr.disconnect`, `flights.feature.render`, `flights.react.error`) — Task 3 +- web-vitals histograms created inside `initBrowserOtel` — Task 2 +- `otel.ts` is the only file importing `@opentelemetry/sdk-metrics` / `@opentelemetry/sdk-node` — enforced by 1A-3 ESLint rule, verified at exit gate + +**Import boundary.** `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`. `custom.ts` imports only from `@opentelemetry/api` (public API, no SDK). + +**Type consistency.** `Meter`, `Tracer` from `@opentelemetry/api`. `Env` from `@/env` (seeded in 1A-1). -- 2.34.1 From ddedddd15d18537c848a243f9b7882598b453ad9 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:07:38 +0300 Subject: [PATCH 058/138] Add OpenTelemetry and web-vitals dependencies for metrics pipeline --- package.json | 6 + pnpm-lock.yaml | 617 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 623 insertions(+) diff --git a/package.json b/package.json index 9244deab..2670bc10 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,11 @@ "@modern-js/app-tools": "2.70.8", "@module-federation/enhanced": "2.3.2", "@module-federation/modern-js": "2.3.2", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/sdk-metrics": "^2.6.1", + "@opentelemetry/sdk-node": "^0.214.0", "i18next": "^23.0.0", "i18next-icu": "^2.0.0", "i18next-resources-to-backend": "^1.0.0", @@ -32,6 +37,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.0.0", + "web-vitals": "^5.2.0", "zod": "^3.23.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe5fe37e..cb82e145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,21 @@ importers: '@module-federation/modern-js': specifier: 2.3.2 version: 2.3.2(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(webpack@5.106.1(@swc/core@1.15.8(@swc/helpers@0.5.21))(esbuild@0.25.5)) + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: ^2.6.1 + version: 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) i18next: specifier: ^23.0.0 version: 23.16.8 @@ -41,6 +56,9 @@ importers: react-i18next: specifier: ^15.0.0 version: 15.7.4(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + web-vitals: + specifier: ^5.2.0 + version: 5.2.0 zod: specifier: ^3.23.0 version: 3.25.76 @@ -1106,6 +1124,15 @@ packages: '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1157,6 +1184,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -1556,6 +1586,174 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/configuration@0.214.0': + resolution: {integrity: sha512-Q+awuEwxhETwIAXuxHvIY5ZMEP0ZqvxLTi9kclrkyVJppEUXYL3Bhiw3jYrxdHYMh0Y0tVInQH9FEZ1aMinvLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.6.1': + resolution: {integrity: sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.1': + resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.214.0': + resolution: {integrity: sha512-SwmFRwO8mi6nndzbsjPgSFg7qy1WeNHRFD+s6uCsdiUDUt3+yzI2qiHE3/ub2f37+/CbeGcG+Ugc8Gwr6nu2Aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.214.0': + resolution: {integrity: sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.214.0': + resolution: {integrity: sha512-IWAVvCO1TlpotRjFmhQFz9RSfQy5BsLtDRBtptSrXZRwfyRPpuql/RMe5zdmu0Gxl3ERDFwOzOqkf3bwy7Jzcw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.214.0': + resolution: {integrity: sha512-0NGxWHVYHgbp51SEzmsP+Hdups81eRs229STcSWHo3WO0aqY6RpJ9csxfyEtFgaNrBDv6UfOh0je4ss/ROS6XA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.214.0': + resolution: {integrity: sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.214.0': + resolution: {integrity: sha512-pJIcghFGhx3VSCgP5U+yZx+OMNj0t+ttnhC8IjL5Wza7vWIczctF6t3AGcVQffi2dEqX+ZHANoBwoPR8y6RMKA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.214.0': + resolution: {integrity: sha512-4TGYoZKebUWVuYkV6r5wS2dUF4zH7EbWFw/Uqz1ZM1tGHQeFT9wzHGXq3iSIXMUrwu5jRdxjfMaXrYejPu2kpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.214.0': + resolution: {integrity: sha512-FWRZ7AWoTryYhthralHkfXUuyO3l7cRsnr49WcDio1orl2a7KxT8aDZdwQtV1adzoUvZ9Gfo+IstElghCS4zfw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.214.0': + resolution: {integrity: sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.214.0': + resolution: {integrity: sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.6.1': + resolution: {integrity: sha512-km2/hD3inLTqtLnUAHDGz7ZP/VOyZNslrC/iN66x4jkmpckwlONW54LRPNI6fm09/musDtZga9EWsxgwnjGUlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.214.0': + resolution: {integrity: sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.214.0': + resolution: {integrity: sha512-IDP6zcyA24RhNZ289MP6eToIZcinlmirHjX8v3zKCQ2ZhPpt5cGwkN91tCth337lqHIgWcTy90uKRiX/SzALDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.214.0': + resolution: {integrity: sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.6.1': + resolution: {integrity: sha512-Dvz9TA6cPqIbxolSzQ5x9br6iQlqdGhVYrm+oYc7pfJ7LaVXz8F0XIqhWbnKB5YvfZ6SUmabBUUxnvHs/9uhxA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.6.1': + resolution: {integrity: sha512-kKFMxBcjBZAC1vBch5mtZ/dJQvcAEKWga+c+q5iGgRLPIE6Mc649zEwMaCIQCzalziMJQiyUadFYMHmELB7AFw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@2.6.1': + resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.214.0': + resolution: {integrity: sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.1': + resolution: {integrity: sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.214.0': + resolution: {integrity: sha512-gl2XvQBJuPjhGcw9SsnQO5qxChAPMuGRPFaD8lqtF+Cey91NgGUQ0sio2vlDFOSm3JOLzc44vL+OAfx1dXuZjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.1': + resolution: {integrity: sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.6.1': + resolution: {integrity: sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -1671,6 +1869,36 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@remix-run/router@1.23.2': resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} @@ -2777,10 +3005,17 @@ packages: resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} engines: {node: '>= 0.10'} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + clean-css@5.3.3: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} engines: {node: '>= 10.0'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -3451,6 +3686,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3646,6 +3885,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@3.0.1: + resolution: {integrity: sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==} + engines: {node: '>=18'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3859,6 +4102,9 @@ packages: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -3877,6 +4123,9 @@ packages: long-timeout@0.1.1: resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -4000,6 +4249,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mrmime@1.0.1: resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} engines: {node: '>=10'} @@ -4637,6 +4889,10 @@ packages: promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -4802,10 +5058,18 @@ packages: renderkid@3.0.0: resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@4.1.8: resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} @@ -5538,6 +5802,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5636,6 +5903,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -5648,6 +5919,14 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6732,6 +7011,18 @@ snapshots: dependencies: tslib: 2.8.1 + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -6793,6 +7084,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 @@ -7700,6 +7993,244 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/configuration@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + yaml: 2.8.3 + + '@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + protobufjs: 7.5.4 + + '@opentelemetry/propagator-b3@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-metrics@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/configuration': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-node@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.40.0': {} + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -7778,6 +8309,29 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@remix-run/router@1.23.2': {} '@rollup/pluginutils@5.3.0(rollup@4.60.1)': @@ -9018,10 +9572,18 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + cjs-module-lexer@2.2.0: {} + clean-css@5.3.3: dependencies: source-map: 0.6.1 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone-deep@4.0.1: dependencies: is-plain-object: 2.0.4 @@ -9795,6 +10357,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10028,6 +10592,13 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@3.0.1: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} inflight@1.0.6: @@ -10229,6 +10800,8 @@ snapshots: dependencies: p-locate: 6.0.0 + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} lodash.memoize@4.1.2: {} @@ -10241,6 +10814,8 @@ snapshots: long-timeout@0.1.1: {} + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -10373,6 +10948,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + module-details-from-path@1.0.4: {} + mrmime@1.0.1: {} mrmime@2.0.1: {} @@ -10968,6 +11545,21 @@ snapshots: dependencies: asap: 2.0.6 + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 24.12.2 + long: 5.3.2 + proxy-from-env@2.1.0: {} public-encrypt@4.0.3: @@ -11172,8 +11764,17 @@ snapshots: lodash: 4.18.1 strip-ansi: 6.0.1 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + reselect@4.1.8: {} resolve-dir@1.0.1: @@ -11904,6 +12505,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-vitals@5.2.0: {} + webidl-conversions@3.0.1: {} webpack-sources@3.3.4: {} @@ -12006,12 +12609,26 @@ snapshots: xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} yaml@2.8.3: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} -- 2.34.1 From 2dc1a1f1949b2952c0e51a75224f89920834199a Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:10:21 +0300 Subject: [PATCH 059/138] Add OTel server/browser initializers with getMeter/getTracer accessors --- eslint.config.js | 2 +- src/observability/metrics/otel.test.ts | 86 +++++++++++++++++++++ src/observability/metrics/otel.ts | 100 +++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/observability/metrics/otel.test.ts create mode 100644 src/observability/metrics/otel.ts diff --git a/eslint.config.js b/eslint.config.js index 214ca8a2..677a9d75 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -118,7 +118,7 @@ export default [ // Neither file needs the other's exemption, so combining ignores is safe. { files: ["src/**/*.{ts,tsx}"], - ignores: ["src/observability/metrics/otel.ts", "src/i18n/provider.tsx"], + ignores: ["src/observability/metrics/otel.ts", "src/observability/metrics/otel.test.ts", "src/i18n/provider.tsx"], rules: { "no-restricted-imports": [ "error", diff --git a/src/observability/metrics/otel.test.ts b/src/observability/metrics/otel.test.ts new file mode 100644 index 00000000..26867566 --- /dev/null +++ b/src/observability/metrics/otel.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { metrics, trace } from "@opentelemetry/api"; +import { + InMemoryMetricExporter, + AggregationTemporality, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; + +describe("otel", () => { + beforeEach(() => { + // Reset global providers and module cache between tests + metrics.disable(); + trace.disable(); + vi.resetModules(); + }); + + afterEach(() => { + metrics.disable(); + trace.disable(); + vi.restoreAllMocks(); + }); + + it("initServerOtel registers a MeterProvider and TracerProvider", async () => { + const { initServerOtel, getMeter, getTracer } = await import("./otel.js"); + + initServerOtel({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + OTEL_SERVICE_NAME: "flights-test", + NODE_ENV: "test", + } as unknown as Parameters[0]); + + const meter = getMeter("test"); + const tracer = getTracer("test"); + + expect(meter).toBeDefined(); + expect(tracer).toBeDefined(); + }); + + it("counter incremented via proxy meter is observable by test reader", async () => { + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + const reader = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 100, + }); + + const { initServerOtelWithReader } = await import("./otel.js"); + initServerOtelWithReader({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + OTEL_SERVICE_NAME: "flights-test", + NODE_ENV: "test", + } as unknown as Parameters[0], reader); + + const counter = metrics.getMeter("flights").createCounter("test.counter"); + counter.add(1, { route: "/smoke" }); + + // Force a collection cycle + await reader.forceFlush(); + + const exported = exporter.getMetrics(); + expect(exported.length).toBeGreaterThan(0); + + const testMetric = exported + .flatMap((rm) => rm.scopeMetrics) + .flatMap((sm) => sm.metrics) + .find((m) => m.descriptor.name === "test.counter"); + + expect(testMetric).toBeDefined(); + + await reader.shutdown(); + }); + + it("getMeter returns a meter from @opentelemetry/api", async () => { + const { getMeter } = await import("./otel.js"); + const meter = getMeter("my-component"); + expect(meter).toBeDefined(); + expect(typeof meter.createCounter).toBe("function"); + expect(typeof meter.createHistogram).toBe("function"); + }); + + it("getTracer returns a tracer from @opentelemetry/api", async () => { + const { getTracer } = await import("./otel.js"); + const tracer = getTracer("my-component"); + expect(tracer).toBeDefined(); + expect(typeof tracer.startSpan).toBe("function"); + }); +}); diff --git a/src/observability/metrics/otel.ts b/src/observability/metrics/otel.ts new file mode 100644 index 00000000..1ca5846d --- /dev/null +++ b/src/observability/metrics/otel.ts @@ -0,0 +1,100 @@ +import { metrics, trace } from "@opentelemetry/api"; +import type { Meter, Tracer } from "@opentelemetry/api"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import type { MetricReader } from "@opentelemetry/sdk-metrics"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import type { Env } from "@/env"; + +let initialized = false; + +/** + * Initialize OpenTelemetry for the server (Node) process. + * Called once per process at startup. + */ +export function initServerOtel(env: Env): void { + if (initialized) return; + + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + const serviceName = (env as unknown as Record).OTEL_SERVICE_NAME ?? "flights-web"; + + const metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), + exportIntervalMillis: 15_000, + }); + + const sdk = new NodeSDK({ + serviceName, + traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }), + metricReader, + }); + + sdk.start(); + initialized = true; +} + +/** + * Test-only variant that accepts a custom MetricReader for in-memory assertions. + */ +export function initServerOtelWithReader(env: Env, reader: MetricReader): void { + if (initialized) return; + + const meterProvider = new MeterProvider({ + readers: [reader], + }); + + metrics.setGlobalMeterProvider(meterProvider); + initialized = true; +} + +/** + * Initialize OpenTelemetry for the browser. + * Called once per tab via useEffect in the root layout. + * Browser-side uses web-vitals to report CWV as histograms. + */ +export function initBrowserOtel(env: Env): void { + if (initialized) return; + + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!endpoint) return; + + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), + exportIntervalMillis: 30_000, + }), + ], + }); + + metrics.setGlobalMeterProvider(meterProvider); + + // Report web-vitals as OTel histograms + const cwvMeter = meterProvider.getMeter("web-vitals"); + void import("web-vitals").then(({ onCLS, onINP, onLCP, onFCP, onTTFB }) => { + const cls = cwvMeter.createHistogram("web_vitals.cls"); + const inp = cwvMeter.createHistogram("web_vitals.inp"); + const lcp = cwvMeter.createHistogram("web_vitals.lcp"); + const fcp = cwvMeter.createHistogram("web_vitals.fcp"); + const ttfb = cwvMeter.createHistogram("web_vitals.ttfb"); + + onCLS((m: { value: number }) => cls.record(m.value)); + onINP((m: { value: number }) => inp.record(m.value)); + onLCP((m: { value: number }) => lcp.record(m.value)); + onFCP((m: { value: number }) => fcp.record(m.value)); + onTTFB((m: { value: number }) => ttfb.record(m.value)); + }); + + initialized = true; +} + +/** Returns a named Meter from the global MeterProvider. */ +export function getMeter(name: string): Meter { + return metrics.getMeter(name); +} + +/** Returns a named Tracer from the global TracerProvider. */ +export function getTracer(name: string): Tracer { + return trace.getTracer(name); +} -- 2.34.1 From 0b25a1a9e7174fac76fd12a716fb56be5c46cf46 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:10:49 +0300 Subject: [PATCH 060/138] Add 8 custom metric instruments using OTel proxy meter --- src/observability/metrics/custom.ts | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/observability/metrics/custom.ts diff --git a/src/observability/metrics/custom.ts b/src/observability/metrics/custom.ts new file mode 100644 index 00000000..04c89669 --- /dev/null +++ b/src/observability/metrics/custom.ts @@ -0,0 +1,32 @@ +import { metrics } from "@opentelemetry/api"; + +/** + * Module-level metric instruments for the flights remote component. + * Safe to declare at module scope — @opentelemetry/api's proxy meter + * lazy-resolves to the real MeterProvider after initServerOtel/initBrowserOtel runs. + */ +const meter = metrics.getMeter("flights"); + +/** SSR request duration histogram (seconds). */ +export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration"); + +/** Upstream API request duration histogram (seconds). */ +export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration"); + +/** Upstream API error counter (by route, status). */ +export const flightsApiError = meter.createCounter("flights.api.error"); + +/** SignalR active connections gauge. */ +export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected"); + +/** SignalR messages received counter. */ +export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received"); + +/** SignalR disconnection counter (by reason). */ +export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect"); + +/** Feature component render counter (by feature name). */ +export const flightsFeatureRender = meter.createCounter("flights.feature.render"); + +/** Unhandled React error counter (caught by ErrorBoundary). */ +export const flightsReactError = meter.createCounter("flights.react.error"); -- 2.34.1 From e95781a0694c4f57d9007bc93f4f2331a32c3967 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:13:13 +0300 Subject: [PATCH 061/138] Add test-observable analytics event sink --- src/observability/analytics/sink.test.ts | 40 ++++++++++++++++++++++++ src/observability/analytics/sink.ts | 27 ++++++++++++++++ src/observability/analytics/types.ts | 7 +++++ 3 files changed, 74 insertions(+) create mode 100644 src/observability/analytics/sink.test.ts create mode 100644 src/observability/analytics/sink.ts diff --git a/src/observability/analytics/sink.test.ts b/src/observability/analytics/sink.test.ts new file mode 100644 index 00000000..52f08a4d --- /dev/null +++ b/src/observability/analytics/sink.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { emitEvent, getRecordedEvents, resetEvents } from "./sink.js"; +import type { AnalyticsEvent } from "./types.js"; + +describe("analytics sink", () => { + beforeEach(() => { + resetEvents(); + }); + + it("records emitted events", () => { + const event: AnalyticsEvent = { + kind: "track", + name: "test.click", + props: { button: "cta" }, + provider: "metrica", + ts: new Date().toISOString(), + }; + + emitEvent(event); + expect(getRecordedEvents()).toHaveLength(1); + expect(getRecordedEvents()[0]).toEqual(event); + }); + + it("records multiple events in order", () => { + emitEvent({ kind: "track", name: "a", props: {}, provider: "ctm", ts: "t1" }); + emitEvent({ kind: "page", name: "/home", props: {}, provider: "dynatrace", ts: "t2" }); + + const events = getRecordedEvents(); + expect(events).toHaveLength(2); + expect(events[0]?.name).toBe("a"); + expect(events[1]?.name).toBe("/home"); + }); + + it("resetEvents clears all recorded events", () => { + emitEvent({ kind: "track", name: "x", props: {}, provider: "variocube", ts: "t" }); + expect(getRecordedEvents()).toHaveLength(1); + resetEvents(); + expect(getRecordedEvents()).toHaveLength(0); + }); +}); diff --git a/src/observability/analytics/sink.ts b/src/observability/analytics/sink.ts new file mode 100644 index 00000000..85b3e258 --- /dev/null +++ b/src/observability/analytics/sink.ts @@ -0,0 +1,27 @@ +import type { AnalyticsEvent } from "./types.js"; + +let events: AnalyticsEvent[] = []; + +/** + * Emit an analytics event to the test-observable sink. + * In production, this is a no-op ring buffer (capped to prevent memory leaks). + * In test, events are retained for assertion via getRecordedEvents(). + */ +export function emitEvent(event: AnalyticsEvent): void { + events.push(event); + + // Ring buffer: cap at 1000 events to prevent unbounded growth + if (events.length > 1000) { + events = events.slice(-500); + } +} + +/** Returns all recorded events (for test assertions). */ +export function getRecordedEvents(): readonly AnalyticsEvent[] { + return events; +} + +/** Clears all recorded events (for test teardown). */ +export function resetEvents(): void { + events = []; +} diff --git a/src/observability/analytics/types.ts b/src/observability/analytics/types.ts index b11aa281..4263ff01 100644 --- a/src/observability/analytics/types.ts +++ b/src/observability/analytics/types.ts @@ -25,3 +25,10 @@ export interface Analytics { track(event: string, props?: AnalyticsProps): void; page(url: string, props?: AnalyticsProps): void; } + +export interface AnalyticsAdapter { + readonly name: AnalyticsEvent["provider"]; + load(): Promise; + track(event: string, props?: AnalyticsProps): void; + page(url: string, props?: AnalyticsProps): void; +} -- 2.34.1 From fe31bbfb652270469e3c33febd2fd4fa5cdda822 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:13:49 +0300 Subject: [PATCH 062/138] Add four stub analytics adapters (metrica, ctm, variocube, dynatrace) --- src/observability/analytics/adapters/ctm.ts | 18 ++++++++++++++++++ .../analytics/adapters/dynatrace.ts | 18 ++++++++++++++++++ .../analytics/adapters/metrica.ts | 18 ++++++++++++++++++ .../analytics/adapters/variocube.ts | 18 ++++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 src/observability/analytics/adapters/ctm.ts create mode 100644 src/observability/analytics/adapters/dynatrace.ts create mode 100644 src/observability/analytics/adapters/metrica.ts create mode 100644 src/observability/analytics/adapters/variocube.ts diff --git a/src/observability/analytics/adapters/ctm.ts b/src/observability/analytics/adapters/ctm.ts new file mode 100644 index 00000000..34562ec2 --- /dev/null +++ b/src/observability/analytics/adapters/ctm.ts @@ -0,0 +1,18 @@ +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class CtmAdapter implements AnalyticsAdapter { + readonly name = "ctm" as const; + + async load(): Promise { + // Stub: real CTM script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} diff --git a/src/observability/analytics/adapters/dynatrace.ts b/src/observability/analytics/adapters/dynatrace.ts new file mode 100644 index 00000000..259953fe --- /dev/null +++ b/src/observability/analytics/adapters/dynatrace.ts @@ -0,0 +1,18 @@ +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class DynatraceAdapter implements AnalyticsAdapter { + readonly name = "dynatrace" as const; + + async load(): Promise { + // Stub: real Dynatrace (Key-Astrom) script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} diff --git a/src/observability/analytics/adapters/metrica.ts b/src/observability/analytics/adapters/metrica.ts new file mode 100644 index 00000000..8ddb2f9c --- /dev/null +++ b/src/observability/analytics/adapters/metrica.ts @@ -0,0 +1,18 @@ +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class MetricaAdapter implements AnalyticsAdapter { + readonly name = "metrica" as const; + + async load(): Promise { + // Stub: real Yandex.Metrica script loads in Phase 2A (after A7 resolves) + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} diff --git a/src/observability/analytics/adapters/variocube.ts b/src/observability/analytics/adapters/variocube.ts new file mode 100644 index 00000000..0eed7192 --- /dev/null +++ b/src/observability/analytics/adapters/variocube.ts @@ -0,0 +1,18 @@ +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class VariocubeAdapter implements AnalyticsAdapter { + readonly name = "variocube" as const; + + async load(): Promise { + // Stub: real Variocube script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} -- 2.34.1 From 515151ed8130a41f6c445b8ad829edf15bddceff Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:15:11 +0300 Subject: [PATCH 063/138] Add analytics facade with adapter fan-out and consent gating --- src/observability/analytics/facade.test.ts | 89 ++++++++++++++++++++++ src/observability/analytics/facade.ts | 63 +++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/observability/analytics/facade.test.ts create mode 100644 src/observability/analytics/facade.ts diff --git a/src/observability/analytics/facade.test.ts b/src/observability/analytics/facade.test.ts new file mode 100644 index 00000000..be86723a --- /dev/null +++ b/src/observability/analytics/facade.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { createAnalytics } from "./facade.js"; +import { getRecordedEvents, resetEvents } from "./sink.js"; +import type { Logger } from "@/observability/logger/types"; + +function mockLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => mockLogger()), + }; +} + +describe("createAnalytics", () => { + beforeEach(() => { + resetEvents(); + }); + + it("fans out track() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("test.event", { key: "value" }); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["ctm", "dynatrace", "metrica", "variocube"]); + + for (const event of events) { + expect(event.kind).toBe("track"); + expect(event.name).toBe("test.event"); + expect(event.props).toEqual({ key: "value" }); + } + }); + + it("fans out page() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.page("/ru/online-board"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + for (const event of events) { + expect(event.kind).toBe("page"); + expect(event.name).toBe("/ru/online-board"); + } + }); + + it("consent.analytics = false short-circuits before any adapter is invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: false, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("should.not.emit"); + analytics.page("/should/not/emit"); + + expect(getRecordedEvents()).toHaveLength(0); + }); + + it("disabled adapter is not invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: false, variocube: false, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("partial.event"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(2); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["dynatrace", "metrica"]); + }); +}); diff --git a/src/observability/analytics/facade.ts b/src/observability/analytics/facade.ts new file mode 100644 index 00000000..9a705996 --- /dev/null +++ b/src/observability/analytics/facade.ts @@ -0,0 +1,63 @@ +import type { Analytics, AnalyticsAdapter, AnalyticsProps, AnalyticsProviders } from "./types.js"; +import type { Logger } from "@/observability/logger/types"; +import { MetricaAdapter } from "./adapters/metrica.js"; +import { CtmAdapter } from "./adapters/ctm.js"; +import { VariocubeAdapter } from "./adapters/variocube.js"; +import { DynatraceAdapter } from "./adapters/dynatrace.js"; + +export interface CreateAnalyticsOptions { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; +} + +const NOOP_ANALYTICS: Analytics = { + track() {}, + page() {}, +}; + +/** + * Creates an Analytics instance that fans out track/page calls to enabled adapters. + * If consent.analytics is false, returns a no-op (short-circuit before any adapter). + */ +export function createAnalytics(options: CreateAnalyticsOptions): Analytics { + const { enabled, consent, logger } = options; + + if (!consent.analytics) { + logger.debug("analytics consent denied, returning no-op"); + return NOOP_ANALYTICS; + } + + const adapters: AnalyticsAdapter[] = []; + if (enabled.metrica) adapters.push(new MetricaAdapter()); + if (enabled.ctm) adapters.push(new CtmAdapter()); + if (enabled.variocube) adapters.push(new VariocubeAdapter()); + if (enabled.dynatrace) adapters.push(new DynatraceAdapter()); + + if (adapters.length === 0) { + logger.debug("no analytics adapters enabled, returning no-op"); + return NOOP_ANALYTICS; + } + + return { + track(event: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.track(event, props); + } catch (err) { + logger.error(`analytics adapter track failed: ${adapter.name}`, { error: String(err) }); + } + } + }, + + page(url: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.page(url, props); + } catch (err) { + logger.error(`analytics adapter page failed: ${adapter.name}`, { error: String(err) }); + } + } + }, + }; +} -- 2.34.1 From 8878dcb6c896d141d54d3508cec84c646485e3bc Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:15:43 +0300 Subject: [PATCH 064/138] Add AnalyticsLoader component with idle-callback initialization --- src/observability/analytics/loader.tsx | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/observability/analytics/loader.tsx diff --git a/src/observability/analytics/loader.tsx b/src/observability/analytics/loader.tsx new file mode 100644 index 00000000..a48fe8c4 --- /dev/null +++ b/src/observability/analytics/loader.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { Analytics, AnalyticsProviders } from "./types.js"; +import type { Logger } from "@/observability/logger/types"; +import { createAnalytics } from "./facade.js"; +import { AnalyticsContext } from "./provider.js"; + +export interface AnalyticsLoaderProps { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; + children: ReactNode; +} + +/** + * Mounts in the root layout. Waits for idle callback, then initializes + * analytics adapters and provides the Analytics instance to the tree. + */ +export function AnalyticsLoader({ + enabled, + consent, + logger, + children, +}: AnalyticsLoaderProps): JSX.Element { + const [analytics, setAnalytics] = useState(null); + const initRef = useRef(false); + + useEffect(() => { + if (initRef.current) return; + initRef.current = true; + + const init = () => { + const instance = createAnalytics({ enabled, consent, logger }); + setAnalytics(instance); + }; + + if (typeof window !== "undefined" && "requestIdleCallback" in window) { + (window as unknown as { requestIdleCallback: (cb: () => void) => void }).requestIdleCallback(init); + } else { + // Fallback for environments without requestIdleCallback + setTimeout(init, 1); + } + }, [enabled, consent, logger]); + + return ( + + {children} + + ); +} -- 2.34.1 From 6a4be0791197b59bc7f4cb98a6418058d07a8c78 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:15:43 +0300 Subject: [PATCH 065/138] Add useAnalytics hook with server-safe NoopAnalytics fallback --- src/observability/analytics/provider.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/observability/analytics/provider.tsx diff --git a/src/observability/analytics/provider.tsx b/src/observability/analytics/provider.tsx new file mode 100644 index 00000000..121b607d --- /dev/null +++ b/src/observability/analytics/provider.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import type { Analytics } from "./types.js"; + +const NOOP_ANALYTICS: Analytics = { + track() {}, + page() {}, +}; + +/** + * React context for the Analytics instance. + * Exported for use by AnalyticsLoader (which sets the provider value). + */ +export const AnalyticsContext = createContext(null); + +/** + * Returns the Analytics instance from context. + * Server-side and before AnalyticsLoader initializes: returns NoopAnalytics. + * Client-side after init: returns the real facade instance. + */ +export function useAnalytics(): Analytics { + const analytics = useContext(AnalyticsContext); + return analytics ?? NOOP_ANALYTICS; +} -- 2.34.1 From ad9b35f72516554882a4123b9019c8d002d47db2 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:17:29 +0300 Subject: [PATCH 066/138] Add schema-dts dependency for typed JSON-LD generation --- package.json | 1 + pnpm-lock.yaml | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/package.json b/package.json index 2670bc10..ca09b367 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.0.0", + "schema-dts": "^2.0.0", "web-vitals": "^5.2.0", "zod": "^3.23.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb82e145..2910195f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: react-i18next: specifier: ^15.0.0 version: 15.7.4(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + schema-dts: + specifier: ^2.0.0 + version: 2.0.0(typescript@5.9.3) web-vitals: specifier: ^5.2.0 version: 5.2.0 @@ -5259,6 +5262,15 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + schema-dts-lib@1.0.0: + resolution: {integrity: sha512-9MEO5vpQH9JdBioUupqluzxSYxPLjhmqRUudk15adUl/ypnRsM2/M1kN3AmVJQeG7nZqcL68H8JlGqQQT6vy9A==} + engines: {node: '>=14.0.0'} + peerDependencies: + typescript: '>=4.9.5' + + schema-dts@2.0.0: + resolution: {integrity: sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw==} + schema-utils@4.3.0: resolution: {integrity: sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==} engines: {node: '>= 10.13.0'} @@ -11967,6 +11979,16 @@ snapshots: dependencies: loose-envify: 1.4.0 + schema-dts-lib@1.0.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + schema-dts@2.0.0(typescript@5.9.3): + dependencies: + schema-dts-lib: 1.0.0(typescript@5.9.3) + transitivePeerDependencies: + - typescript + schema-utils@4.3.0: dependencies: '@types/json-schema': 7.0.15 -- 2.34.1 From 8abe8acf7003da106ac72029e965077fb6602974 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:18:35 +0300 Subject: [PATCH 067/138] Add buildHreflangSet for 9 languages + x-default --- src/shared/seo/hreflang.test.ts | 71 +++++++++++++++++++++++++++++++++ src/shared/seo/hreflang.ts | 32 +++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/shared/seo/hreflang.test.ts create mode 100644 src/shared/seo/hreflang.ts diff --git a/src/shared/seo/hreflang.test.ts b/src/shared/seo/hreflang.test.ts new file mode 100644 index 00000000..6d780c06 --- /dev/null +++ b/src/shared/seo/hreflang.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { buildHreflangSet } from "./hreflang.js"; + +describe("buildHreflangSet", () => { + const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const; + + it("returns entries for all 9 languages plus x-default", () => { + const result = buildHreflangSet({ + canonicalOrigin: "https://www.aeroflot.ru", + pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15", + }); + + expect(result).toHaveLength(10); // 9 languages + x-default + }); + + it("includes all 9 languages", () => { + const result = buildHreflangSet({ + canonicalOrigin: "https://www.aeroflot.ru", + pathWithoutLocale: "/smoke", + }); + + const langs = result.map((entry) => entry.lang); + for (const lang of LANGUAGES) { + expect(langs).toContain(lang); + } + }); + + it("x-default points to the ru variant", () => { + const result = buildHreflangSet({ + canonicalOrigin: "https://www.aeroflot.ru", + pathWithoutLocale: "/smoke", + }); + + const xDefault = result.find((entry) => entry.lang === "x-default"); + expect(xDefault).toBeDefined(); + expect(xDefault?.href).toBe("https://www.aeroflot.ru/ru/smoke"); + }); + + it("builds correct href for each language", () => { + const result = buildHreflangSet({ + canonicalOrigin: "https://www.aeroflot.ru", + pathWithoutLocale: "/onlineboard", + }); + + const en = result.find((entry) => entry.lang === "en"); + expect(en?.href).toBe("https://www.aeroflot.ru/en/onlineboard"); + + const ja = result.find((entry) => entry.lang === "ja"); + expect(ja?.href).toBe("https://www.aeroflot.ru/ja/onlineboard"); + }); + + it("preserves paths with nested segments", () => { + const result = buildHreflangSet({ + canonicalOrigin: "https://www.aeroflot.ru", + pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15", + }); + + const fr = result.find((entry) => entry.lang === "fr"); + expect(fr?.href).toBe("https://www.aeroflot.ru/fr/onlineboard/flight/SU100-2025-01-15"); + }); + + it("handles root path", () => { + const result = buildHreflangSet({ + canonicalOrigin: "https://www.aeroflot.ru", + pathWithoutLocale: "", + }); + + const ru = result.find((entry) => entry.lang === "ru"); + expect(ru?.href).toBe("https://www.aeroflot.ru/ru"); + }); +}); diff --git a/src/shared/seo/hreflang.ts b/src/shared/seo/hreflang.ts new file mode 100644 index 00000000..0d1e746e --- /dev/null +++ b/src/shared/seo/hreflang.ts @@ -0,0 +1,32 @@ +import type { Language } from "@/i18n/resolver"; + +const LANGUAGES: readonly Language[] = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"]; +const X_DEFAULT_LANGUAGE: Language = "ru"; + +export interface HreflangEntry { + lang: Language | "x-default"; + href: string; +} + +/** + * Builds the full set of reciprocal hreflang links for a given path. + * Returns 9 language entries + 1 x-default entry (pointing to ru). + */ +export function buildHreflangSet(args: { + canonicalOrigin: string; + pathWithoutLocale: string; +}): HreflangEntry[] { + const { canonicalOrigin, pathWithoutLocale } = args; + + const entries: HreflangEntry[] = LANGUAGES.map((lang) => ({ + lang, + href: `${canonicalOrigin}/${lang}${pathWithoutLocale}`, + })); + + entries.push({ + lang: "x-default", + href: `${canonicalOrigin}/${X_DEFAULT_LANGUAGE}${pathWithoutLocale}`, + }); + + return entries; +} -- 2.34.1 From 599f35f14a4fa8f7a89ba9f51fb3f223a0cce660 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:20:07 +0300 Subject: [PATCH 068/138] Add JsonLdRenderer and serializeJsonLd with schema-dts typing --- src/shared/seo/json-ld.test.ts | 85 ++++++++++++++++++++++++++++++++++ src/shared/seo/json-ld.tsx | 34 ++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/shared/seo/json-ld.test.ts create mode 100644 src/shared/seo/json-ld.tsx diff --git a/src/shared/seo/json-ld.test.ts b/src/shared/seo/json-ld.test.ts new file mode 100644 index 00000000..978e4ed1 --- /dev/null +++ b/src/shared/seo/json-ld.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createElement } from "react"; +import type { Thing } from "schema-dts"; +import { JsonLdRenderer, serializeJsonLd } from "./json-ld.js"; + +describe("serializeJsonLd", () => { + it("serializes a single Thing to a JSON-LD string", () => { + const data: Thing = { + "@type": "WebSite", + name: "Aeroflot", + url: "https://www.aeroflot.ru", + }; + + const result = serializeJsonLd(data); + const parsed = JSON.parse(result); + + expect(parsed["@context"]).toBe("https://schema.org"); + expect(parsed["@type"]).toBe("WebSite"); + expect(parsed.name).toBe("Aeroflot"); + }); + + it("serializes an array of Things with @context on each", () => { + const data: Thing[] = [ + { "@type": "WebSite", name: "Aeroflot" } as Thing, + { "@type": "Organization", name: "Aeroflot PJSC" } as Thing, + ]; + + const result = serializeJsonLd(data); + const parsed = JSON.parse(result); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[0]["@context"]).toBe("https://schema.org"); + expect(parsed[1]["@context"]).toBe("https://schema.org"); + }); + + it("escapes to prevent injection", () => { + const data: Thing = { + "@type": "WebSite", + name: '', + }; + + const result = serializeJsonLd(data); + expect(result).not.toContain(""); + }); +}); + +describe("JsonLdRenderer", () => { + it("renders a "); + expect(html).toContain('"@context":"https://schema.org"'); + expect(html).toContain('"@type":"WebSite"'); + }); + + it("round-trips: serialize -> DOM string contains valid JSON-LD", () => { + const data: Thing = { + "@type": "Organization", + name: "Aeroflot PJSC", + url: "https://www.aeroflot.ru", + }; + + const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data })); + + // Extract JSON from the script tag + const match = html.match(/]*>([\s\S]*?)<\/script>/); + expect(match).not.toBeNull(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test assertion already guards + const json = match![1]!.replace(/\\u003c/g, "<"); + const parsed = JSON.parse(json); + expect(parsed["@context"]).toBe("https://schema.org"); + expect(parsed["@type"]).toBe("Organization"); + expect(parsed.name).toBe("Aeroflot PJSC"); + }); +}); diff --git a/src/shared/seo/json-ld.tsx b/src/shared/seo/json-ld.tsx new file mode 100644 index 00000000..ad0f15a4 --- /dev/null +++ b/src/shared/seo/json-ld.tsx @@ -0,0 +1,34 @@ +import type { Thing } from "schema-dts"; + +export interface JsonLdRendererProps { + data: Thing | Thing[]; +} + +/** + * Serializes a schema-dts Thing (or array of Things) to a JSON-LD string. + * Adds "@context": "https://schema.org" to each item. + * Escapes sequences to prevent XSS. + */ +export function serializeJsonLd(data: Thing | Thing[]): string { + const addContext = (item: Thing): Record => + Object.assign({ "@context": "https://schema.org" }, item as object); + + const withContext = Array.isArray(data) + ? data.map(addContext) + : addContext(data); + + return JSON.stringify(withContext).replace(/<\//g, "\\u003c/"); +} + +/** + * Renders a "]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toBe(``); + expect(result).not.toContain(NONCE); + }); + + it("handles multiple script tags in one chunk", async () => { + const input = createStream([ + ``, + ]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``); + expect(result).toContain(``); + expect(result).toContain(``, + ]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(`