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 index 1f442d30..8c88da9d 100644 --- a/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md +++ b/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md @@ -2,7 +2,7 @@ > **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. +> **Do not execute this document directly.** Each sub-plan 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. @@ -11,80 +11,143 @@ - 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. +- 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. +- Security hardening live: CSP with per-request nonce (including the stream-transform nonce injection workaround for React issue #24883), HTTP security headers, dependency scanning green. - Canary deploy pipeline functional (smoke route deployed via canary path with auto-rollback on health-check failure). +- Operational runbook published in `docs/superpowers/phase-1/runbook.md`. +- Responsive baseline assertions passing on root layout + error pages at 320 / 768 / 1280 / 1920 widths. **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+). +**Phase 0 prerequisites — split gate.** Phase 0 produces a customer-confirmation checklist answering assumptions A1–A9. Phase 1 splits the gate: + +- **Hard blockers** (1A-1 does not start until all resolved): **A2** (CDN vendor), **A3** (CI provider), **A5** (ASP.NET host fate), **A6** (metrics endpoint), **A8** (prod URL / access logs), **A9** (Node 24 available on customer deploy VMs — new). +- **Stub-allowed** (Phase 1 ships stubs, swap task pending customer response): **A1** (module template), **A4** (log format), **A7** (analytics vendor credentials). + --- ## 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) | +| **1A-1** | Project skeleton (src tree, tsconfig, eslint base, env, package.json, zod) | §1.3, §2.1 | Medium | `2026-04-14-phase-1a1-skeleton.md` (TBW) | +| **1A-2** | MF 2.0 + dual build targets + RemoteLoader + MF spike | §2.1, §2.2, §2.3, §2.4, §2.5 | Medium | `2026-04-14-phase-1a2-mf-builds.md` (TBW) | +| **1A-3** | ESLint boundaries + layered dependency rules | §1.2 | Small | `2026-04-14-phase-1a3-eslint-boundaries.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) | +| **1F-layout** | Root layout + locale layout + error routes + smoke route + ErrorBoundary + error→HTTP mapper | §1.3, §3.1, §3.3 | Medium | `2026-04-14-phase-1f-layout.md` (TBW) | +| **1F-seo** | SeoHead + hreflang builder + JsonLdRenderer | §3.6, §6.5, §6.6, §6.7, §6.8 | Small | `2026-04-14-phase-1f-seo.md` (TBW) | +| **1G-logger** | Logger types + JSON-lines transport + console transport + provider | §7.1, §7.2 | Medium | `2026-04-14-phase-1g-logger.md` (TBW) | +| **1G-metrics** | OpenTelemetry init (server/browser) + custom metric instruments | §7.3, §7.6, §7.7 | Medium | `2026-04-14-phase-1g-metrics.md` (TBW) | +| **1G-analytics** | Analytics facade + four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace) | §7.4 | Small | `2026-04-14-phase-1g-analytics.md` (TBW) | +| **1H** | Security hardening (CSP + nonce stream transform + headers + storage) | §8.1 | Small | `2026-04-14-phase-1h-security.md` (TBW) | +| **1I** | Deploy pipeline + health + graceful shutdown + runbook | §8.3, §8.5 | Medium | `2026-04-14-phase-1i-deploy.md` (TBW) | -Sizes: **Small** ≈ 5–10 tasks, **Medium** ≈ 10–20 tasks, **Large** ≈ 20–40 tasks. +Sizes: **Small** ≈ 5–10 tasks, **Medium** ≈ 10–20 tasks. (No "Large" sub-plans after the 1A/1F/1G splits.) + +Parity harnesses (URL / SEO / VRT) and real parity tests are **deferred to Phase 2**, to be designed against the first real feature migration rather than against a synthetic smoke route. --- ## 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) │ - └───────────────────────────────────────┘ + ┌─────────────────┐ + │ 1A-1 Skeleton │◄── every sub-plan depends on 1A-1 + └────────┬────────┘ + ▼ + ┌─────────────────┐ + │ 1A-2 MF 2.0 │ + │ + builds + MF │ + │ spike first │ + └────────┬────────┘ + ▼ + ┌─────────────────┐ + │ 1A-3 ESLint │ + │ boundaries │ + └────────┬────────┘ + ┌──────────────────────────┼──────────────────┬──────────────────┐ + ▼ ▼ ▼ ▼ +┌──────┐ ┌────────┐ ┌───────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────┐ +│ 1B │ │ 1C i18n │ │ 1D API │ │ 1E Sig- │ │ 1G-logger │ │ 1F-seo │ +│ CI │ │ │ │ client │ │ nalR │ │ (type-only │ │ (pure │ +│ │ │ │ │ │ │ │ │ file first) │ │ funcs) │ +└──┬───┘ └────┬───┘ └─────┬────┘ └──────────┘ └──────┬───────┘ └────┬────┘ + │ │ │ │ │ + │ │ │ ┌─────────────────────┘ │ + │ │ │ ▼ │ + │ │ │ ┌──────────────┐ │ + │ │ │ │ 1G-metrics │ │ + │ │ │ │ (depends on │ │ + │ │ │ │ 1G-logger) │ │ + │ │ │ └──────┬───────┘ │ + │ │ │ │ │ + │ │ │ ▼ │ + │ │ │ ┌──────────────┐ │ + │ │ │ │ 1G-analytics │ │ + │ │ │ │ (depends on │ │ + │ │ │ │ 1G-logger) │ │ + │ │ │ └──────┬───────┘ │ + │ │ │ │ │ + │ └────────────┴─────────┴────────────────────────────────────┤ + │ ▼ │ + │ ┌──────────────────────────────┐ │ + │ │ 1F-layout (root layout + │◄─────────────────┘ + │ │ error routes + smoke route) │ + │ │ (consumes 1C + 1D + │ + │ │ 1G-logger/metrics/analytics │ + │ │ + 1F-seo) │ + │ └──────┬───────────────────────┘ + │ ▼ + │ ┌──────────────────────────────┐ + │ │ 1H Security hardening │ + │ │ (middleware + nonce stream │ + │ │ transform into 1F-layout) │ + │ └──────┬───────────────────────┘ + │ │ + ▼ ▼ + ┌───────────────────────────────────────┐ + │ 1I Deploy pipeline + runbook │ + │ (consumes 1A-2 + 1B + 1H) │ + └───────────────────────────────────────┘ ``` ### 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. +**Serial (1 engineer):** 1A-1 → 1A-2 → 1A-3 → 1C → 1F-seo → 1G-logger → 1D → 1G-metrics → 1G-analytics → 1F-layout → 1H → 1I → 1B → 1E. -**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. +Rationale: +- 1A-1/2/3 unlock everything. +- 1C is cheap and 1F-layout depends on it. +- 1F-seo is pure functions, no runtime deps on 1C/1D/1G — slot it in early. +- 1G-logger's type-only file (`Logger`, `LogFields`, `LogLevel`) must ship early since 1A-1's `HostContract` depends on it. (See "Logger type extraction" below.) +- 1D depends on 1G-logger's types (for request-scoped child loggers). +- 1G-metrics and 1G-analytics depend on 1G-logger. +- 1F-layout consumes 1C + 1D + all three 1G sub-plans + 1F-seo — it's the integration point. +- 1H modifies files 1F-layout creates, so it follows. +- 1I consumes 1A-2 + 1B + 1H. +- 1B can be slotted earlier if CI-enforced gates become blocking; the order above assumes PR-quality gates are enough until 1I. +- 1E can come last because its only consumer is Phase 2 Online Board. + +**Parallel (2+ engineers):** After 1A-3 ships, the following can proceed in parallel: **1B, 1C, 1D, 1E, 1F-seo, 1G-logger** (and its dependents 1G-metrics/1G-analytics). 1F-layout and later remain 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. +**1A-1 → 1A-2 → 1A-3 → 1C → 1F-layout → 1H → 1I → exit gate** is the critical path for one engineer. 1G-logger / 1G-metrics / 1G-analytics / 1D / 1F-seo all feed 1F-layout but sit off the critical path once 1C ships (they run in parallel with 1C if there's more than one engineer, or in series but interleaved if there's only one). + +--- + +## Logger type extraction (cross-cutting) + +`HostContract` in 1A-1 has an optional `logger?: Logger` field (design spec §2.4). `Logger` is defined in 1G-logger. To avoid a plan-dependency cycle: + +- **1G-logger's first task** ships `src/observability/logger/types.ts` containing **only the type definitions** (`Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). No runtime code, no transports. +- **1A-1** imports `Logger` from `@/observability/logger/types` in its `HostContract` definition. +- Runtime logger implementation (transports, provider, factories) lands later in 1G-logger and does not retroactively affect 1A-1. + +This "type-only file first" pattern is the canonical workaround for plan-order cycles in this master plan; any other sub-plan hitting a similar cycle follows the same pattern. --- @@ -92,17 +155,31 @@ Rationale: 1A unlocks everything. 1B unlocks running tests in CI. 1C and 1D unlo 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 +### 1A-1 — 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. +- **`tsconfig.json`** — strict mode, path aliases (`@/` → `src/`, `@phase0/` → `scripts/phase-0/`), `noUncheckedIndexedAccess`, `isolatedModules`. +- **`.eslintrc.cjs` base** — the baseline config. 1A-3 adds the boundaries rules on top. +- **`package.json` scripts** — `dev`, `build:standalone`, `build:remote`, `build:both`, `test`, `test:coverage`, `lint`, `typecheck`. Pinned **Node 24** via `engines` and `.nvmrc`. +- **`package.json` dependencies owned by 1A-1** — `zod` (for env validation and `storage.ts` schema validation — used by both 1A-1 `src/env/` and 1H `src/shared/storage.ts`, so it lives in the common base). +- **`src/env/index.ts`** — runtime env-var reader returning a Zod-validated typed `Env` object. Other sub-plans read env vars exclusively through this module. +- **`src/host-contract.ts`** — the `HostContract` type, reproduced byte-for-byte from design spec §2.4: + + ```ts + import type { Logger } from "@/observability/logger/types"; + + export interface HostContract { + locale: string; // "ru", "en", ... + canonicalOrigin: string; // "https://flights.aeroflot.ru" + navigate?: (path: string) => void; // optional deep-link nav override + consent?: { analytics: boolean; telemetry: boolean }; // optional, else assumed true + logger?: Logger; // optional host logger merge + } + ``` + +- **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. **Exit-gate rule: the public barrel surface is frozen — no other sub-plan creates new cross-boundary imports outside these barrels.** **TypeScript contracts:** @@ -117,13 +194,67 @@ export interface Env { 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 + ANALYTICS_ENABLED: AnalyticsProviders; // imported from src/observability/analytics/types + 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. +**Exit gate for 1A-1:** +- `pnpm typecheck` and `pnpm lint` green on an empty src tree plus the env module. +- `src/env/index.ts` round-trips a test `Env` object through Zod validation and surfaces a readable error on malformed input. +- **Frozen barrel rule** documented in 1A-1's deliverables — subsequent sub-plans may add exports *to* these barrels but may not create parallel public surfaces. +- **Rename-pass rework task** attached to this sub-plan's exit gate: if A1 (customer module template) resolves after 1A-1 ships, a bounded rename pass moves the src tree to match the template without rewriting internals. The frozen barrel rule ensures this rename is a mechanical operation. + +--- + +### 1A-2 — MF 2.0 + dual build targets + RemoteLoader contracts + +**First task (gate):** **Modern.js + MF 2.0 spike.** A 2–4 hour timeboxed experiment that boots the simplest possible Modern.js + Rspack + `@module-federation/modern-js` end-to-end example (a single exposed hello-world component), documents gotchas in `docs/superpowers/phase-1/modernjs-mf-spike.md`, and produces a **pinned version matrix** (Modern.js X.Y.Z + `@module-federation/modern-js` A.B.C + Rspack P.Q.R). If the spike fails or reveals a hard blocker, 1A-2 halts and the issue escalates to the customer before committing to Modern.js. + +**Exports:** + +- **`modern.config.ts`** — the Modern.js build config with `BUILD_TARGET=standalone|remote` branching. Later sub-plans (1G-metrics, 1H, 1I) modify this file via explicit "modify" tasks — 1A-2 owns the base structure. +- **Dual build targets** — `pnpm build:standalone` produces `dist/standalone/` (Node server + client bundle); `pnpm build:remote` produces `dist/remote/` (static chunks + `mf-manifest.json`). +- **`src/mf/remote-loader.ts`** — Module Federation 2.0 runtime API wrapper so Phase 2+ can consume other customer remotes without touching `modern.config.ts` again: + + ```ts + export interface RemoteModuleRef { + name: string; // remote name (e.g. "customer-ui") + module: string; // exposed module path (e.g. "./Header") + } + + export function loadRemoteModule(ref: RemoteModuleRef): Promise; + export function registerRemote(entry: { name: string; entry: string }): void; + ``` + +- **`src/mf/host-entry.ts`** — the entry point for the remote build target that consumes `HostContract` and bootstraps the React subtree into a host-provided mount point. + +**TypeScript contracts:** `RemoteModuleRef`, `loadRemoteModule`, `registerRemote` (above). `HostContract` is imported from 1A-1 (`src/host-contract.ts`). + +**Exit gate for 1A-2:** +- Spike doc committed with pinned version matrix. +- `pnpm build:both` produces `dist/standalone/` (Node server + client bundle) and `dist/remote/` (static chunks + `mf-manifest.json`) with zero type errors. +- A minimal integration test loads a test remote via `loadRemoteModule` and asserts the returned module. + +--- + +### 1A-3 — ESLint boundaries contracts + +**Exports:** + +- **`.eslintrc.cjs` additions** — `eslint-plugin-boundaries` rules enforcing the layered dependency direction from design spec §1.2: + - `features/` cannot import `routes/` or `mf/` + - `ui/` cannot import `features/` + - `shared/` cannot import `features/`, `routes/`, `mf/`, `observability/` + - `observability/` cannot import `features/`, `routes/`, `mf/` +- **`no-restricted-imports` rules:** + - `@opentelemetry/sdk-metrics` — allowed only in `src/observability/metrics/otel.ts` (keeps module-level instrument exports safe by forcing meter acquisition through `@opentelemetry/api`'s proxy meter). + - `window.localStorage` / `window.sessionStorage` — allowed only in `src/shared/storage.ts`. + - `@microsoft/signalr` — forbidden in any file that's part of the SSR bundle (enforced via file-path pattern). + - `react-i18next` — forbidden outside `src/i18n/provider.tsx` (feature code goes through the re-export). + +**Exit gate for 1A-3:** Each rule has a fabricated violation test in `tests/eslint/` that asserts the rule fires. --- @@ -131,7 +262,7 @@ export function getEnv(): Env; **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/ci.yml`** (or equivalent for the CI provider chosen via 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). @@ -173,9 +304,7 @@ export function getEnv(): Env; 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`). +- **`src/i18n/provider.tsx`** — React Context provider + `` component + `useI18n()` accessor. **Re-exports `useTranslation` from `react-i18next`** so feature code never imports `react-i18next` directly (enforced by 1A-3's ESLint rule). **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. @@ -188,13 +317,20 @@ export function getEnv(): Env; - **`src/shared/api/client.ts`** — the `ApiClient` class: ```ts + export interface ApiClientRetryOptions { + maxRetries?: number; // default 3 (idempotent only) + timeoutFactor?: number; // default 2 (exponential backoff) + statusCodes?: number[]; // default [408, 429, 500, 502, 503, 504] + } + export interface ApiClientOptions { baseUrl: string; locale: Language; traceId?: string; - fetchImpl?: typeof fetch; // for tests + fetchImpl?: typeof fetch; // for tests (ignored on server path where undici is used) defaultTimeoutMs?: number; // default 5000 - circuitBreaker?: CircuitBreakerOptions; + retry?: ApiClientRetryOptions; + logger?: Logger; } export class ApiClient { @@ -204,6 +340,8 @@ export function getEnv(): Env; } ``` + **Server path** uses `undici.RetryAgent` under the hood (statusCodes + timeoutFactor wired into the agent). **Client path** uses `globalThis.fetch` with a thin hand-rolled retry wrapper that applies the same config. + - **`src/shared/api/errors.ts`** — typed error classes: ```ts @@ -213,15 +351,17 @@ export function getEnv(): Env; export class ApiNetworkError extends ApiError { cause?: Error; } ``` -- **`src/shared/api/cache.ts`** — two cache implementations: +- **`src/shared/api/cache.ts`** — three distinct cache types: ```ts + // (1) SSR request-scoped dedup: one-shot, discarded after response export class RequestScopedCache { get(key: string): Promise | undefined; set(key: string, promise: Promise): void; } - export class TtlCache { + // (2) Client-side per-tab in-memory TTL cache (count-capped) + export class ClientMemoryCache { constructor(options: { max: number; defaultTtlMs: number }); get(key: string): T | undefined; set(key: string, value: T, ttlMs?: number): void; @@ -229,8 +369,27 @@ export function getEnv(): Env; clear(): void; size: number; } + + // (3) Shared per-VM LRU cache with BYTE cap (~100MB), backed by lru-cache@^10 + export class ServerLruCache { + constructor(options: { + maxBytes: number; // e.g. 100 * 1024 * 1024 + defaultTtlMs: number; + sizeCalculation?: (value: T, key: string) => number; // default: JSON.stringify length + }); + get(key: string): T | undefined; + set(key: string, value: T, ttlMs?: number): void; + delete(key: string): void; + clear(): void; + calculatedSize: number; // current bytes used + } + + // Key convention used by all three caches + export function cacheKey(endpoint: string, query: Record, locale: Language): string; ``` + Default TTLs from design spec §4.2: **30s for live data, 5 min for static reference data** (wired via call-site overrides). + - **`src/shared/api/circuit-breaker.ts`**: ```ts @@ -247,11 +406,32 @@ export function getEnv(): Env; } ``` +- **`src/shared/api/cached-client.ts`** — **caching decorator** layered above `ApiClient` (not inside it): + + ```ts + export interface CachedClientOptions { + client: ApiClient; + requestScoped?: RequestScopedCache; // SSR only + clientMemory?: ClientMemoryCache; + serverLru?: ServerLruCache; + ttlMs?: number; + } + + export class CachedApiClient { + constructor(options: CachedClientOptions); + get(path: string, query?: Record): Promise; + } + ``` + + Feature code opts into caching by wrapping `ApiClient` in `CachedApiClient`; uncached calls go through the raw `ApiClient`. + - **`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`. +**Dependency on 1G-logger:** `ApiClientOptions.logger?: Logger` consumes the type-only import from `src/observability/logger/types`. 1D must not ship before 1G-logger's type-only file. -**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. +**Package additions (1D):** `undici` (explicit dep, even though Node 24 includes it — pin for deterministic behavior), `lru-cache@^10`. + +**Exit gate for 1D:** Vitest tests cover: success response deserialization; retry on `[408, 429, 500, 502, 503, 504]`; no retry on other 4xx; timeout; `Retry-After` honored on 429/503; circuit breaker open/half-open/closed transitions; request-scoped cache dedup; client-memory TTL eviction; server LRU byte cap eviction under load. --- @@ -270,7 +450,7 @@ export function getEnv(): Env; export class SignalRConnection { constructor(options: HubOptions); - subscribe(channel: string, handler: (message: unknown) => void): () => void; // returns unsubscribe + subscribe(channel: string, handler: (message: unknown) => void): () => void; onStatusChange(handler: (status: ConnectionStatus) => void): () => void; get status(): ConnectionStatus; } @@ -280,39 +460,74 @@ export function getEnv(): Env; export function getSharedConnection(options: HubOptions): SignalRConnection; ``` -- **`src/shared/hooks/useLiveFlights.ts`**: +- **`src/shared/hooks/useLiveFlights.ts`** — **generic** live-data hook (not hardcoded to flight-search params): ```ts - export function useLiveFlights( - params: { date: string; departure?: string; arrival?: string }, - initialData: T[], - config: { hubUrl: string; channelKey: (params: object) => string }, - ): { data: T[]; connectionStatus: ConnectionStatus }; + export function useLiveFlights( + params: TParams, + initialData: TData[], + config: { + hubUrl: string; + channelKey: (params: TParams) => string; + }, + ): { data: TData[]; 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. + SSR-safe: during SSR, returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr` (enforced by 1A-3's ESLint SSR-bundle guard). **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). +- Two rapid `useEffect` mounts (Strict Mode double-invoke simulation) result in exactly one `HubConnection.start()` call. +- Unmount + remount within the grace period reuses the connection; unmount + remount after the grace period creates a fresh one. +- 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 +### 1F-layout — Root layout + routes + error mapper contracts **Exports:** -- **`src/routes/layout.tsx`** — root HTML shell: ``, ``, ``, ``, root ``, root ``, root ``, root `` (from 1G). +- **`src/routes/layout.tsx`** — root HTML shell: ``, ``, ``, ``, root ``, root ``, root ``, root `` (from 1G-analytics). Wrapped by `` (from 1G-logger). -- **`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/[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 (via 1F-seo's `buildHreflangSet`), passes them into ``. Creates a request-scoped logger child and an OTel span (from 1G-metrics). -- **`src/routes/error/[code]/page.tsx`** — error page rendered for `code ∈ {404, 500, 503}`. Ports the existing Angular error component layout. +- **`src/routes/error/[code]/page.tsx`** — error page rendered for `code ∈ {404, 500, 503}`. Ports the existing Angular error component layout. Fully responsive (assertions below). -- **`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/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`. Uses `React.lazy()` + `` + a server loader as a deliberate stress test of the React 18 concurrent + streaming path. + +- **`src/routes/error/map.ts`** — error-to-HTTP mapper consumed by the SSR loader path: + + ```ts + export interface ErrorResponse { + status: 404 | 500 | 503; + headers?: Record; + errorCode: "not_found" | "internal" | "unavailable"; + } + + export function errorToResponse(error: unknown): ErrorResponse; + ``` + + Mapping rules (design spec §4.6): + - `ApiHttpError` with `status === 404` → `{ status: 404, errorCode: "not_found" }` + - `ApiHttpError` with `status` in 500–599 → `{ status: 500, errorCode: "internal" }` + - `ApiTimeoutError` → `{ status: 503, headers: { "Retry-After": "30" }, errorCode: "unavailable" }` + - Unknown → `{ status: 500, errorCode: "internal" }` + +- **`src/ui/errors/ErrorBoundary.tsx`** — React error boundary component. Logs the error via `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-layout. Sub-plans 1G-analytics (analytics loader mount), 1G-logger (provider wrap), and 1H (CSP nonce propagation + stream transform) modify this file via explicit tasks, referencing the 1F-layout-shipped version as the base. + +**Exit gate for 1F-layout:** +- `/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 (generated by 1F-seo). +- Missing `lang` URLs redirect 301 to `/ru/smoke`. +- 404 route returns HTTP 404 with the error page body. +- **Responsive baseline (R2):** Playwright renders `/ru/smoke` and `/ru/error/404` at widths **320, 768, 1280, 1920** — no horizontal scroll, all critical content visible, error page layout stable. Baselines committed under `tests/fixtures/phase-1/responsive/`. + +--- + +### 1F-seo — SeoHead + hreflang + JsonLdRenderer contracts + +**Exports:** - **`src/ui/seo/SeoHead.tsx`** — the `` component from design spec §6.5: @@ -342,25 +557,38 @@ export function getEnv(): Env; ```ts export function buildHreflangSet(args: { canonicalOrigin: string; - pathWithoutLocale: string; // e.g. "/onlineboard/flight/SU100-2025-01-15" + 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. + Per design spec §1.4: `x-default` points to the Russian (`ru`) variant. -**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. +- **`src/shared/seo/json-ld.tsx`** — `schema-dts`-typed JSON-LD renderer helper: -**TypeScript contracts:** the above. Phase 2+ features import `SeoHead` + `buildHreflangSet` + `ErrorBoundary`. + ```ts + import type { Thing } from "schema-dts"; -**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. + export interface JsonLdRendererProps { + data: Thing | Thing[]; + } + + export function JsonLdRenderer(props: JsonLdRendererProps): JSX.Element; + export function serializeJsonLd(data: Thing | Thing[]): string; + ``` + + Phase 2+ feature sub-plans ship typed builders (`buildFlightJsonLd`, `buildFlightSearchResultsJsonLd`, etc.) that consume `JsonLdRenderer` — no infrastructure work needed per feature. + +**Exit gate for 1F-seo:** Unit tests cover `buildHreflangSet` for the 9 languages + `x-default`, `SeoHead` emits the full `` shape, and `JsonLdRenderer` round-trips a typed `Thing` through `serializeJsonLd` → DOM string. --- -### 1G — Observability contracts +### 1G-logger — Logger contracts + +**Sequencing:** The **first task** of 1G-logger is `src/observability/logger/types.ts` shipping **only type definitions**, so 1A-1's `HostContract` can import `Logger` without waiting on the runtime implementation. **Exports:** -- **`src/observability/logger/types.ts`** — the `Logger` interface from design spec §7.2: +- **`src/observability/logger/types.ts`** (type-only, ships first): ```ts export type LogLevel = "debug" | "info" | "warn" | "error"; @@ -385,14 +613,23 @@ export function getEnv(): Env; ``` - **`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/console-transport.ts`** — dev-mode transport. - **`src/observability/logger/root.ts`** — `createRootLogger()` factory that reads env config and picks a transport. +- **`src/observability/logger/provider.tsx`** — React context + `useLogger()` hook. Server: request-scoped child logger. Client: shared root logger. -- **`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. +**A4-trigger task (from requirements gap R3):** When the customer provides the log format (A4 resolution), a follow-on task creates `src/observability/logger/customer-format-transport.ts` implementing the customer-specified format, and updates `createRootLogger()` to pick this transport by default. **No consumer code changes** — the `Logger` interface is stable. This task is attached to 1G-logger's exit gate and fires on A4 resolution (could be during Phase 1, or deferred to early Phase 2). -- **`src/observability/metrics/otel.ts`** — OpenTelemetry setup: +**Exit gate for 1G-logger:** +- Type-only file ships first, verified by 1A-1 successfully importing `Logger`. +- Vitest tests cover: batching + flush; redaction of sensitive fields; transport backpressure drops old records; dev-mode console transport; `child()` context propagation. + +--- + +### 1G-metrics — OpenTelemetry + custom instruments contracts + +**Exports:** + +- **`src/observability/metrics/otel.ts`** — OpenTelemetry setup (the **only** file allowed to import from `@opentelemetry/sdk-metrics`, enforced by 1A-3): ```ts export function initServerOtel(env: Env): void; // called once per Node process @@ -401,32 +638,67 @@ export function getEnv(): Env; export function getTracer(name: string): Tracer; ``` -- **`src/observability/metrics/custom.ts`** — the minimum-set custom metrics from design spec §7.3 as exported instruments: +- **`src/observability/metrics/custom.ts`** — minimum-set custom metrics from design spec §7.3 as exported module-level instruments. **This pattern is safe** because the instruments are created against `@opentelemetry/api`'s proxy meter, which lazy-resolves to the real meter after `initServerOtel`/`initBrowserOtel` runs: ```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 + import { metrics } from "@opentelemetry/api"; + + const meter = metrics.getMeter("flights"); + + export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration"); + export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration"); + export const flightsApiError = meter.createCounter("flights.api.error"); + export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected"); + export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received"); + export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect"); + export const flightsFeatureRender = meter.createCounter("flights.feature.render"); + export const flightsReactError = meter.createCounter("flights.react.error"); + // web-vitals histograms created at init time inside initBrowserOtel, not exported statically ``` -- **`src/observability/analytics/facade.ts`**: +**Shared file ownership flag:** `modern.config.ts` (owned by 1A-2) — 1G-metrics modifies it to wire OTel SDK init + request tracing plugin into the Modern.js middleware chain. + +**Exit gate for 1G-metrics:** +- Integration test: `initServerOtel` runs, a counter is incremented, and the test reader observes the recorded value (proves the proxy meter resolved correctly). +- ESLint rule from 1A-3 blocks a fabricated `import { MeterProvider } from "@opentelemetry/sdk-metrics"` in a file outside `otel.ts`. + +--- + +### 1G-analytics — Analytics facade contracts + +**Exports:** + +- **`src/observability/analytics/types.ts`**: ```ts + export interface AnalyticsProviders { + metrica: boolean; + ctm: boolean; + variocube: boolean; + dynatrace: boolean; + } + export interface AnalyticsProps { [k: string]: unknown; } + export interface AnalyticsEvent { + kind: "track" | "page"; + name: string; // event name or page URL + props: AnalyticsProps; + provider: string; // "metrica" | "ctm" | "variocube" | "dynatrace" + ts: string; + } + export interface Analytics { track(event: string, props?: AnalyticsProps): void; page(url: string, props?: AnalyticsProps): void; } + ``` +- **`src/observability/analytics/facade.ts`**: + + ```ts export function createAnalytics(options: { - enabled: Env["ANALYTICS_ENABLED"]; + enabled: AnalyticsProviders; consent: { analytics: boolean; telemetry: boolean }; logger: Logger; }): Analytics; @@ -443,18 +715,18 @@ export function getEnv(): Env; } ``` - 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). + **Phase 1 ships these as structured stubs** (R8): each stub's `load()`/`track()`/`page()` emits an `AnalyticsEvent` to a **test-observable sink** (`src/observability/analytics/sink.ts`) with the `provider` field set to the adapter's name. Real vendor scripts wire in Phase 2A (alongside the Online Board migration) after A7 resolves. -- **`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/sink.ts`** — test-observable event sink; exports `getRecordedEvents()` and `resetEvents()` for integration tests. In production, the sink is a no-op ring buffer. -- **`src/observability/analytics/provider.tsx`** — React context + `useAnalytics()` hook. On the server, returns a `NoopAnalytics`. On the client, returns the instance created by ``. +- **`src/observability/analytics/loader.tsx`** — `` component that mounts in the root layout (owned by 1F-layout, modified by 1G-analytics to add the mount). Waits for `requestIdleCallback`, then imports enabled adapters and calls `.load()`. -**TypeScript contracts:** the above. `Logger`, `Analytics`, `getMeter`, `getTracer` are the four surfaces every feature imports from. +- **`src/observability/analytics/provider.tsx`** — React context + `useAnalytics()` hook. Server: returns a `NoopAnalytics`. Client: returns the instance 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. +**Exit gate for 1G-analytics:** +- Integration test: smoke route calls `track("smoke.pageview")`, all four stub adapters emit exactly one `AnalyticsEvent` to the sink with matching props. +- `consent.analytics = false` short-circuits before any adapter is invoked. +- Adapter load failure emits `flights.analytics.load_failed` counter. --- @@ -462,18 +734,31 @@ export function getEnv(): Env; **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. +- **`src/server/middleware/csp.ts`** — Modern.js middleware that generates a per-request nonce, sets the `Content-Security-Policy` header (per design spec §8.1), 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; + export const CspNonceContext: React.Context; // default ""; components reading client-side no-op on empty ``` -- **`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: +- **`src/server/middleware/nonce-stream-transform.ts`** — **workaround for React issue #24883.** React 18's `renderToPipeableStream({ nonce })` only applies the nonce to inline `bootstrapScriptContent`, **not to external `bootstrapScripts` src URLs.** This middleware post-processes the SSR HTML stream to inject `nonce="{nonce}"` on every `