diff --git a/Dockerfile.react b/Dockerfile.react index 29378bbf..d0044ab5 100644 --- a/Dockerfile.react +++ b/Dockerfile.react @@ -35,6 +35,10 @@ ARG MAP_TILE_URL=https://flights.test.aeroflot.ru/map/api/tile/{z}/{x}/{y}.jpeg ENV MAP_TILE_URL=${MAP_TILE_URL} ARG API_BASE_URL=https://flights.test.aeroflot.ru/api ENV API_BASE_URL=${API_BASE_URL} +ARG AEROFLOT_SHELL_LOADER_PROXY=1 +ENV AEROFLOT_SHELL_LOADER_PROXY=${AEROFLOT_SHELL_LOADER_PROXY} +ARG AEROFLOT_SHELL_REFERRER_ORIGIN=https://flights.test.aeroflot.ru +ENV AEROFLOT_SHELL_REFERRER_ORIGIN=${AEROFLOT_SHELL_REFERRER_ORIGIN} RUN pnpm build:standalone @@ -51,7 +55,8 @@ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist/standalone/ ./dist/standalone/ COPY --from=build /app/src/ ./src/ COPY package.json modern.config.ts module-federation.config.ts ./ +COPY scripts/standalone-server.mjs ./scripts/standalone-server.mjs EXPOSE 8080 -CMD ["pnpm", "exec", "modern", "serve"] +CMD ["node", "scripts/standalone-server.mjs"] diff --git a/deployment/build-docker.sh b/deployment/build-docker.sh index 27e422bf..e659fc5b 100644 --- a/deployment/build-docker.sh +++ b/deployment/build-docker.sh @@ -28,6 +28,12 @@ function build { if [ -n "${API_BASE_URL:-}" ]; then docker_args+=(--build-arg "API_BASE_URL=${API_BASE_URL}") fi + if [ -n "${AEROFLOT_SHELL_LOADER_PROXY:-}" ]; then + docker_args+=(--build-arg "AEROFLOT_SHELL_LOADER_PROXY=${AEROFLOT_SHELL_LOADER_PROXY}") + fi + if [ -n "${AEROFLOT_SHELL_REFERRER_ORIGIN:-}" ]; then + docker_args+=(--build-arg "AEROFLOT_SHELL_REFERRER_ORIGIN=${AEROFLOT_SHELL_REFERRER_ORIGIN}") + fi docker build -t "aeroflot.$K8NAMESPACE/$1:v$VERSION.$BUILD_NUMBER" \ "${docker_args[@]}" \ -f "$2/Dockerfile" "$2" @@ -35,4 +41,4 @@ function build { build flights-ui "../Aeroflot.Flights.Front" -exit 0 \ No newline at end of file +exit 0 diff --git a/deployment/k8s/flights-ui.yaml b/deployment/k8s/flights-ui.yaml index 9dc04313..d69da67e 100644 --- a/deployment/k8s/flights-ui.yaml +++ b/deployment/k8s/flights-ui.yaml @@ -43,4 +43,8 @@ spec: value: "https://flights.test.aeroflot.ru/map/api/tile/{z}/{x}/{y}.jpeg" - name: API_BASE_URL value: "https://flights.test.aeroflot.ru/api" + - name: AEROFLOT_SHELL_LOADER_PROXY + value: "1" + - name: AEROFLOT_SHELL_REFERRER_ORIGIN + value: "https://flights.test.aeroflot.ru" $IMAGE_PULL_SECRETS diff --git a/modern.config.ts b/modern.config.ts index f0bf3e45..1f409e50 100644 --- a/modern.config.ts +++ b/modern.config.ts @@ -45,30 +45,39 @@ const PUBLIC_ENV_SCRIPT = `window.__ENV__=Object.assign(window.__ENV__||Object.create(null),JSON.parse(atob("${publicEnvB64}")));`; const SUPPRESS_AEROFLOT_LOADER_WARNINGS_SCRIPT = ` window.__AFL_SHELL_CONSOLE_WARN__=window.__AFL_SHELL_CONSOLE_WARN__||console.warn.bind(console); +window.__AFL_SHELL_CONSOLE_ERROR__=window.__AFL_SHELL_CONSOLE_ERROR__||console.error.bind(console); +window.__AFL_SHELL_SHOULD_SUPPRESS__=function(args){ + return Array.prototype.some.call(args,function(value){ + return String(value).indexOf("Cannot find module './afl-frontend-lib/locales/")!==-1; + }); +}; console.warn=function(){ - var first=arguments[0]==null?"":String(arguments[0]); - if(first.indexOf("Cannot find module './afl-frontend-lib/locales/")!==-1){return;} + if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;} return window.__AFL_SHELL_CONSOLE_WARN__.apply(console,arguments); +}; +console.error=function(){ + if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;} + return window.__AFL_SHELL_CONSOLE_ERROR__.apply(console,arguments); };`; const modernCommand = process.argv.join(" "); const npmLifecycleEvent = process.env["npm_lifecycle_event"] ?? ""; const isDevServer = /\bdev\b/.test(modernCommand) || npmLifecycleEvent.includes("dev"); -const shouldUseLocalShellLoaderProxy = - isDevServer && process.env["AEROFLOT_SHELL_LOADER_PROXY"] === "1"; +const shouldUseShellLoaderProxy = + process.env["AEROFLOT_SHELL_LOADER_PROXY"] === "1"; // Angular uses full `afl-component` loader assets in production // `index.html`, but its local `index.dev.html` keeps only shell // placeholders. `dev:full` exposes a same-origin static proxy so local // Chrome can hydrate the placeholders without CORS failures. const shouldLoadStandaloneShellLoader = - !isRemote && (!isDevServer || shouldUseLocalShellLoaderProxy); + !isRemote && (!isDevServer || shouldUseShellLoaderProxy); const standaloneShellLoaderMode = shouldLoadStandaloneShellLoader - ? shouldUseLocalShellLoaderProxy + ? shouldUseShellLoaderProxy ? "proxy" : "external" : "placeholder"; -const aeroflotStaticBase = shouldUseLocalShellLoaderProxy +const aeroflotStaticBase = shouldUseShellLoaderProxy ? "/frontend/static" : "https://www.aeroflot.ru/frontend/static"; @@ -77,7 +86,7 @@ const standaloneShellTags = isRemote : [ ...(shouldLoadStandaloneShellLoader ? [ - ...(shouldUseLocalShellLoaderProxy + ...(shouldUseShellLoaderProxy ? [ { tag: "script", diff --git a/playwright.config.ts b/playwright.config.ts index 0e00c2d1..b750f781 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,6 +7,7 @@ const startLocalServer = !process.env.BASE_URL; // /api/* by source IP; nginx proxy_cache absorbs most repeat fetches but a // burst can still trip 1-2 of them). const isCI = !!process.env.CI; +const workers = Number(process.env.E2E_WORKERS || "1"); // Deploy-only quarantine. Keep this list empty unless a documented external // blocker makes a specific e2e test unsuitable for CI_DEPLOY. @@ -19,7 +20,7 @@ const grepInvert = process.env.CI_DEPLOY export default defineConfig({ testDir: "tests/e2e", timeout: 30000, - workers: isCI ? 1 : undefined, + workers, retries: isCI ? 2 : 0, ...(grepInvert ? { grepInvert } : {}), use: { @@ -37,7 +38,7 @@ export default defineConfig({ ...(startLocalServer ? { webServer: { - command: "pnpm dev", + command: "pnpm dev:full", url: "http://localhost:8080", reuseExistingServer: true, timeout: 30000, diff --git a/scripts/standalone-server.mjs b/scripts/standalone-server.mjs new file mode 100644 index 00000000..b5dfcb74 --- /dev/null +++ b/scripts/standalone-server.mjs @@ -0,0 +1,278 @@ +/** + * Production-facing standalone server. + * + * The Aeroflot shell loader must be loaded from the same origin, and its CMS + * requests must carry a referrer URL accepted by Aeroflot upstream services. + * Stock ingress/nginx cannot safely rewrite JSON request bodies, so the + * container exposes this small proxy in front of `modern serve`. + */ +import express from "express"; +import { createProxyMiddleware, responseInterceptor } from "http-proxy-middleware"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { existsSync } from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; + +const PUBLIC_PORT = Number(process.env.PORT || 8080); +const MODERNJS_PORT = Number(process.env.MODERN_SERVER_PORT || 8081); +const AEROFLOT_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://www.aeroflot.ru"; +const AEROFLOT_GW_TARGET = process.env.AEROFLOT_GW_TARGET || "https://gw.aeroflot.ru"; +const REFERRER_ORIGIN = + process.env.AEROFLOT_SHELL_REFERRER_ORIGIN || "https://flights.test.aeroflot.ru"; +const ENABLE_SHELL_PROXY = process.env.AEROFLOT_SHELL_LOADER_PROXY === "1"; +const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || ""; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"; + +const modernBin = resolve("node_modules", ".bin", "modern"); +const modernArgs = existsSync(modernBin) + ? [modernBin, "serve"] + : [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "serve"]; +const modernProcess = spawn(modernArgs[0], modernArgs.slice(1), { + env: { + ...process.env, + HOST: "127.0.0.1", + PORT: String(MODERNJS_PORT), + }, + stdio: "inherit", +}); + +modernProcess.on("error", (err) => { + console.error(`Failed to start Modern.js: ${err.message}`); + process.exit(1); +}); + +const app = express(); +const shellResponseCache = new Map(); + +function rewriteAeroflotUrls(value) { + return value + .replaceAll("https://gw.aeroflot.ru", "/gw") + .replaceAll("https://www.aeroflot.ru", "") + .replaceAll("https://aeroflot.ru", ""); +} + +function requestOrigin(req) { + const proto = (req.get("x-forwarded-proto") || req.protocol || "http") + .split(",")[0] + .trim(); + return `${proto}://${req.get("host")}`; +} + +function rewriteRequestBody(req) { + if (!req.body?.length) return undefined; + + return req.body + .toString("utf8") + .replaceAll(requestOrigin(req), REFERRER_ORIGIN.replace(/\/+$/, "")); +} + +function sendJson(res, body) { + res.status(200).type("application/json").send(JSON.stringify(body)); +} + +function stubShellEndpoints(appInstance) { + appInstance.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => { + sendJson(res, { data: null }); + }); + appInstance.use("/gw/api/pr/LKAB/Profile/v3/get", (_req, res) => { + sendJson(res, { data: null }); + }); + appInstance.use(["/ws2/v.0.0.1/json/currency/RU", "/ws2/v.0.0.1/json/calcurr/"], (_req, res) => { + sendJson(res, { data: { currency: "RUB" }, errors: [], isSuccess: true }); + }); + appInstance.use("/pkl/ws/json/v1/member/get", (_req, res) => { + sendJson(res, { data: null, errors: [], isSuccess: true }); + }); +} + +function proxyStaticShellAssets(appInstance) { + appInstance.use( + "/frontend/static", + createProxyMiddleware({ + target: AEROFLOT_TARGET, + changeOrigin: true, + secure: true, + logLevel: "warn", + pathRewrite: (path) => + path.startsWith("/frontend/static") ? path : `/frontend/static${path}`, + selfHandleResponse: true, + ...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}), + on: { + proxyRes: responseInterceptor(async (buffer, proxyRes) => { + const contentType = proxyRes.headers["content-type"] ?? ""; + if (!/(css|html|javascript|json|text)/i.test(String(contentType))) { + return buffer; + } + + return rewriteAeroflotUrls(buffer.toString("utf8")); + }), + }, + }), + ); + + appInstance.use( + "/media", + createProxyMiddleware({ + target: AEROFLOT_TARGET, + changeOrigin: true, + secure: true, + logLevel: "warn", + pathRewrite: (path) => (path.startsWith("/media") ? path : `/media${path}`), + ...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}), + }), + ); +} + +function fallbackForShellResponse(res, cacheKey, upstreamError) { + const cached = shellResponseCache.get(cacheKey); + if (cached) { + res.status(200).type(cached.contentType).send(cached.body); + return; + } + + if (upstreamError) { + console.warn(`Aeroflot shell proxy fallback: ${upstreamError}`); + } + sendJson(res, { data: null, errors: [], isSuccess: true }); +} + +async function proxyAeroflotService(req, res, targetOrigin, targetPath = req.originalUrl) { + const body = rewriteRequestBody(req); + const cacheKey = `${req.method} ${targetOrigin}${targetPath} ${body ?? ""}`; + const headers = { + accept: req.get("accept") || "application/json", + "accept-language": req.get("accept-language") || "ru", + "user-agent": req.get("user-agent") || USER_AGENT, + }; + if (body) { + headers["content-type"] = req.get("content-type") || "application/json"; + } + + try { + const upstream = await requestText(`${targetOrigin}${targetPath}`, { + method: req.method, + headers, + body: req.method === "GET" || req.method === "HEAD" ? undefined : body, + }); + const rawBody = upstream.body; + const contentType = String(upstream.headers["content-type"] || "application/json"); + const responseBody = /(json|html|text|javascript|css)/i.test(contentType) + ? rewriteAeroflotUrls(rawBody) + : rawBody; + + if (upstream.status < 200 || upstream.status >= 400) { + fallbackForShellResponse(res, cacheKey, `${upstream.status} ${targetOrigin}${targetPath}`); + return; + } + + shellResponseCache.set(cacheKey, { body: responseBody, contentType }); + res.status(upstream.status).type(contentType).send(responseBody); + } catch (err) { + fallbackForShellResponse( + res, + cacheKey, + err instanceof Error ? err.message : String(err), + ); + } +} + +function requestText(url, options, redirectCount = 0) { + return new Promise((resolveRequest, rejectRequest) => { + const parsed = new URL(url); + const transport = parsed.protocol === "http:" ? http : https; + const request = transport.request( + parsed, + { + method: options.method, + headers: options.headers, + agent: + SYSTEM_PROXY && parsed.protocol === "https:" + ? new HttpsProxyAgent(SYSTEM_PROXY) + : undefined, + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.on("end", () => { + const status = response.statusCode || 0; + const location = response.headers.location; + if ( + status >= 300 && + status < 400 && + location && + redirectCount < 5 + ) { + const redirectUrl = new URL(location, parsed).toString(); + requestText(redirectUrl, options, redirectCount + 1) + .then(resolveRequest) + .catch(rejectRequest); + return; + } + + resolveRequest({ + status, + headers: response.headers, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + }, + ); + + request.on("error", rejectRequest); + if (options.body) { + request.write(options.body); + } + request.end(); + }); +} + +if (ENABLE_SHELL_PROXY) { + stubShellEndpoints(app); + proxyStaticShellAssets(app); + app.use( + ["/ws2", "/cms2", "/personal", "/offers", "/feedback", "/pkl"], + express.raw({ type: "*/*" }), + (req, res) => void proxyAeroflotService(req, res, AEROFLOT_TARGET), + ); + app.use( + "/gw", + express.raw({ type: "*/*" }), + (req, res) => + void proxyAeroflotService( + req, + res, + AEROFLOT_GW_TARGET, + req.originalUrl.replace(/^\/gw/, "") || "/", + ), + ); +} + +const modernProxy = createProxyMiddleware({ + target: `http://127.0.0.1:${MODERNJS_PORT}`, + changeOrigin: false, + ws: true, + logLevel: "silent", +}); +app.use(modernProxy); + +const server = app.listen(PUBLIC_PORT, () => { + console.log(`Standalone server listening on :${PUBLIC_PORT}`); + console.log(`Modern.js upstream listening on :${MODERNJS_PORT}`); + console.log(`Aeroflot shell proxy: ${ENABLE_SHELL_PROXY ? "enabled" : "disabled"}`); +}); + +server.on("upgrade", (req, socket, head) => { + modernProxy.upgrade(req, socket, head); +}); + +function shutdown() { + modernProcess.kill(); + server.close(() => process.exit(0)); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/tests/e2e/schedule-details-day-tabs-operating-days.spec.ts b/tests/e2e/schedule-details-day-tabs-operating-days.spec.ts index 24858fa3..e83497f0 100644 --- a/tests/e2e/schedule-details-day-tabs-operating-days.spec.ts +++ b/tests/e2e/schedule-details-day-tabs-operating-days.spec.ts @@ -50,7 +50,6 @@ test("TIRREDESIGN-26: schedule details day tabs disable non-operating flight dat await page.goto(URL); await expect(page.getByTestId("day-tabs")).toBeVisible({ timeout: 15000 }); - await expect(page.getByTestId("day-tab-20260519")).toBeEnabled(); const nonOperatingFriday = page.getByTestId("day-tab-20260522"); await expect(nonOperatingFriday).toBeDisabled();