/** * 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"; import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs"; 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 rewriteAeroflotShellUrls(value); } 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);