Files
flights_web/scripts/dev-server.mjs
T
gnezim 06ab9b6ea3
ci-deploy / build-deploy-test (push) Successful in 1m12s
chore: add .last-run.json to .gitignore
- Add test-results/.last-run.json to .gitignore
- Remove from git tracking
- Update Makefile dev target port (8080, not 8081)
- Add debug logging to dev-server.mjs API proxy
2026-04-29 20:34:59 +03:00

240 lines
9.3 KiB
JavaScript

/**
* Development server with same-origin API proxy.
* Equivalent to Angular's proxy.conf.json + ng serve.
*
* Port 8080 (browser-facing):
* /api/* → curl → https://flights.test.aeroflot.ru (bypasses WAF via curl TLS)
* /flights/* → curl → https://flights.test.aeroflot.ru
* /map/* → curl → https://flights.test.aeroflot.ru (binary JPEG tiles)
* /* → localhost:8081 (Modern.js SSR + HMR)
*/
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
import { execFile, spawn } from "node:child_process";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
const PUBLIC_PORT = 8080;
const MODERNJS_PORT = 8081;
const API_TARGET = "https://flights.test.aeroflot.ru";
// Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` +
// 307-to-self) only runs once per dev-server lifetime, not per request.
// We keep one jar per transport (direct vs. HTTPS_PROXY) because the WAF
// anchors the cookie to the upstream edge node that minted it; mixing
// jars across transports re-triggers the challenge on every request.
const COOKIE_JAR_DIRECT = resolve(tmpdir(), `aeroflot-dev-cookies-direct-${process.pid}.txt`);
const COOKIE_JAR_PROXY = resolve(tmpdir(), `aeroflot-dev-cookies-proxy-${process.pid}.txt`);
// --- Start Modern.js on internal port ---
console.log(`Starting Modern.js on :${MODERNJS_PORT}...`);
// Resolve the modern binary directly to avoid DEP0190 (shell: true with spawn)
const modernBin = resolve("node_modules", ".bin", "modern");
const modernProcess = existsSync(modernBin)
? spawn(modernBin, ["dev"], {
stdio: "inherit",
env: { ...process.env, PORT: String(MODERNJS_PORT) },
})
: spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], {
stdio: "inherit",
env: { ...process.env, PORT: String(MODERNJS_PORT) },
});
modernProcess.on("error", (err) => {
console.error("Modern.js failed:", err);
process.exit(1);
});
await new Promise((r) => setTimeout(r, 18000));
const app = express();
// --- Mock fallback for /api/appSettings when WAF blocks requests ---
app.get("/api/appSettings", (req, res) => {
res.json({
showDebugVersion: "False",
uiOptions: {
isTestVersion: "",
filter: {
schedule: { searchFrom: "30d", searchTo: "30d", timeStep: "" },
onlineboard: { searchFrom: "2d", searchTo: "14d", timeStep: "" },
},
buttons: {
buyTicket: { period: { min: "2h", max: "72h" } },
flightStatus: { availableFrom: "24h", visible: "" },
},
},
});
});
// --- Tile proxy via curl (binary JPEG, pipe stdout straight to response) ---
const TILE_CONTENT_TYPES = {
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
app.use("/map", (req, res) => {
const targetUrl = `${API_TARGET}${req.originalUrl}`;
const ext = req.path.substring(req.path.lastIndexOf("."));
const contentType = TILE_CONTENT_TYPES[ext] ?? "application/octet-stream";
res.status(200);
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
const commonHeaders = [
"-H", `Accept: image/webp,image/jpeg,image/*,*/*`,
"-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`,
"-H", `Referer: ${API_TARGET}/`,
];
// Tile transport is binary — fall back to the proxy path only if the
// direct attempt emits no data (indicating WAF deny / throttle).
const tryTile = (noproxy) => {
const args = [
"-sS",
"-f", // fail on HTTP 4xx/5xx
"-L", // follow Ngenix 307-to-self cookie challenge
...(noproxy ? ["--noproxy", "*"] : []),
"-c", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY,
"-b", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY,
...commonHeaders,
targetUrl,
];
return spawn("/usr/bin/curl", args, { stdio: ["ignore", "pipe", "pipe"] });
};
const child = tryTile(true);
let gotData = false;
child.stdout.on("data", (chunk) => { gotData = true; res.write(chunk); });
child.on("exit", (code) => {
if (code === 0) { res.end(); return; }
if (gotData) { res.end(); return; }
// Direct failed with no data — retry through the system HTTPS_PROXY.
const retry = tryTile(false);
retry.stdout.pipe(res);
retry.on("exit", () => res.end());
});
});
// --- API proxy via curl (bypasses WAF TLS fingerprinting) ---
// Two transports: (1) direct with `--noproxy '*'` — fast path when the
// upstream is publicly reachable; (2) through the system HTTPS_PROXY
// (e.g. a local gost VPN tunnel) — required when the direct IP hits a
// WAF throttle (403 deny page). Try direct first; on 4xx/5xx or
// network error, retry through the proxy. Each transport keeps its own
// cookie jar because the WAF cookie is bound to the edge node that
// minted it.
app.use(["/api", "/flights"], (req, res) => {
console.log(`API proxy called for: ${req.originalUrl}`);
const targetUrl = `${API_TARGET}${req.originalUrl}`;
console.log(`targetUrl: ${targetUrl}`);
const buildArgs = (noproxy) => [
"-s",
"-L",
...(noproxy ? ["--noproxy", "*"] : []),
"-c", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY,
"-b", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY,
"-H", `Accept: ${req.headers.accept || "application/json"}`,
"-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`,
"-H", `Accept-Language: ${req.headers["accept-language"] || "ru"}`,
"-w", "\n%{http_code}",
targetUrl,
];
if (req.method === "POST") {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
const bodyArgs = body ? ["-X", "POST", "-d", body, "-H", "Content-Type: application/json"] : ["-X", "POST"];
execCurlWithFallback(buildArgs, bodyArgs, res);
});
} else {
execCurlWithFallback(buildArgs, [], res);
}
});
function execCurlWithFallback(buildArgs, extraArgs, res) {
console.log(`execCurlWithFallback called`);
runCurl([...extraArgs, ...buildArgs(true)], (direct) => {
console.log(`execCurlWithFallback: direct result, isSuccessful=${isSuccessfulUpstream(direct)}`);
if (isSuccessfulUpstream(direct)) {
respondWithCurlResult(direct, res);
return;
}
// Direct hit a WAF deny / throttle / network failure — retry through
// the system HTTPS_PROXY (gost VPN tunnel on this host).
runCurl([...extraArgs, ...buildArgs(false)], (proxy) => {
respondWithCurlResult(isSuccessfulUpstream(proxy) ? proxy : direct, res);
});
});
}
function runCurl(args, cb) {
console.log(`runCurl: args=${JSON.stringify(args).substring(0, 200)}...`);
execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => {
console.log(`runCurl callback: err=${!!err}, stdout length=${stdout ? stdout.length : 0}, stderr length=${stderr ? stderr.length : 0}`);
console.log(`runCurl stdout preview: ${stdout ? stdout.substring(0, 200) : 'null'}`);
cb({ err, stdout: stdout ?? "" });
});
}
function isSuccessfulUpstream({ err, stdout }) {
if (err) return false;
const lastNewline = stdout.lastIndexOf("\n");
const statusStr = lastNewline >= 0 ? stdout.substring(lastNewline + 1).trim() : "";
const status = parseInt(statusStr) || 0;
return status >= 200 && status < 400;
}
function respondWithCurlResult({ err, stdout }, res) {
console.log(`respondWithCurlResult: err=${!!err}, stdout length=${stdout ? stdout.length : 0}`);
if (err) {
res.status(502).json({ error: err.message });
return;
}
// stdout = body + "\n" + statusCode (from -w "\n%{http_code}"). When
// upstream returns an empty body the stdout starts with "\n", so a
// `> 0` check mis-treats the status as body and falls back to 200 —
// silently hiding real 4xx/5xx responses. Use `>= 0` and split
// unconditionally when the newline is present.
const lastNewline = stdout.lastIndexOf("\n");
const body = lastNewline >= 0 ? stdout.substring(0, lastNewline) : stdout;
const statusStr = lastNewline >= 0 ? stdout.substring(lastNewline + 1).trim() : "200";
const status = parseInt(statusStr) || 200;
const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("[");
console.log(`isJson=${isJson}, status=${status}, body length=${body.length}`);
console.log(`body preview: ${body.substring(0, 200)}`);
res.status(status);
res.set("Content-Type", isJson ? "application/json" : "text/html");
res.set("Access-Control-Allow-Origin", "*");
res.send(body);
}
// --- Everything else → Modern.js ---
const modernProxy = createProxyMiddleware({
target: `http://localhost:${MODERNJS_PORT}`,
changeOrigin: false,
ws: true,
logLevel: "silent",
});
app.use(modernProxy);
const server = app.listen(PUBLIC_PORT, () => {
console.log(`\n ✓ Dev server: http://localhost:${PUBLIC_PORT}`);
console.log(` /api/* → curl → ${API_TARGET}`);
console.log(` /* → Modern.js :${MODERNJS_PORT}\n`);
});
// Forward WebSocket upgrades to Modern.js HMR server explicitly,
// preventing reconnection spam from http-proxy-middleware's built-in ws handling.
server.on("upgrade", modernProxy.upgrade);
process.on("SIGINT", () => { modernProcess.kill(); process.exit(); });
process.on("SIGTERM", () => { modernProcess.kill(); process.exit(); });