Files
flights_web/scripts/standalone-server.mjs
T

277 lines
8.6 KiB
JavaScript

/**
* 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);