540 lines
19 KiB
JavaScript
540 lines
19 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)
|
|
* /tracker/* → https://platform.test.aeroflot.ru (SignalR, same-origin)
|
|
* /* → localhost:8081 (Modern.js SSR + HMR)
|
|
*/
|
|
import express from "express";
|
|
import { createProxyMiddleware, responseInterceptor } from "http-proxy-middleware";
|
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
import { execFile, spawn } from "node:child_process";
|
|
import { resolve } from "node:path";
|
|
import { existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs";
|
|
|
|
const PUBLIC_PORT = 8080;
|
|
const MODERNJS_PORT = 8081;
|
|
const API_TARGET = process.env.API_TARGET || "https://flights.test.aeroflot.ru";
|
|
const TRACKER_TARGET = process.env.TRACKER_TARGET || "https://platform.test.aeroflot.ru";
|
|
const AEROFLOT_STATIC_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://www.aeroflot.ru";
|
|
const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || "";
|
|
const DEBUG_PROXY_BODY = process.env.DEBUG_PROXY_BODY === "1";
|
|
const LOCAL_PUBLIC_ORIGIN = `http://localhost:${PUBLIC_PORT}`;
|
|
const shellStaticAssetCache = new Map();
|
|
|
|
// 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: ["ignore", "inherit", "inherit"],
|
|
env: {
|
|
...process.env,
|
|
PORT: String(MODERNJS_PORT),
|
|
AEROFLOT_SHELL_LOADER_PROXY: "1",
|
|
},
|
|
})
|
|
: spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], {
|
|
stdio: ["ignore", "inherit", "inherit"],
|
|
env: {
|
|
...process.env,
|
|
PORT: String(MODERNJS_PORT),
|
|
AEROFLOT_SHELL_LOADER_PROXY: "1",
|
|
},
|
|
});
|
|
modernProcess.on("error", (err) => {
|
|
console.error("Modern.js failed:", err);
|
|
process.exit(1);
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 18000));
|
|
|
|
const app = express();
|
|
|
|
// --- 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 normalizeTrackerCookie(cookie) {
|
|
if (!cookie.startsWith("signal-id=")) return cookie;
|
|
|
|
const parts = cookie
|
|
.split(";")
|
|
.map((part) => part.trim())
|
|
.filter(
|
|
(part) =>
|
|
part.length > 0 &&
|
|
!part.toLowerCase().startsWith("domain=") &&
|
|
!part.toLowerCase().startsWith("path=") &&
|
|
!part.toLowerCase().startsWith("samesite="),
|
|
);
|
|
|
|
return [...parts, "Path=/tracker/hub", "SameSite=Lax"].join("; ");
|
|
}
|
|
|
|
function applyTrackerCookieHeaders(proxyRes, res) {
|
|
const setCookie = proxyRes.headers["set-cookie"];
|
|
if (Array.isArray(setCookie)) {
|
|
res.setHeader("set-cookie", setCookie.map(normalizeTrackerCookie));
|
|
} else if (typeof setCookie === "string") {
|
|
res.setHeader("set-cookie", normalizeTrackerCookie(setCookie));
|
|
}
|
|
}
|
|
|
|
function isTrackerLongPollTimeout(proxyRes, req) {
|
|
const requestUrl = req.originalUrl ?? req.url ?? "";
|
|
return (
|
|
req.method === "GET" &&
|
|
(requestUrl.startsWith("/tracker/hub?id=") ||
|
|
requestUrl.startsWith("/hub?id=")) &&
|
|
proxyRes.statusCode === 504
|
|
);
|
|
}
|
|
|
|
// --- SignalR TrackerHub proxy ---
|
|
// Browser-direct localhost → platform.test.aeroflot.ru fails CORS. Keep the
|
|
// hub same-origin in development and let proxy-helper / gost route the
|
|
// upstream request through the TIM tunnel when HTTPS_PROXY is set.
|
|
const trackerProxy = createProxyMiddleware({
|
|
target: TRACKER_TARGET,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
ws: true,
|
|
logLevel: "warn",
|
|
pathRewrite: (path) => `/tracker${path}`,
|
|
selfHandleResponse: true,
|
|
on: {
|
|
proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
|
|
applyTrackerCookieHeaders(proxyRes, res);
|
|
|
|
if (isTrackerLongPollTimeout(proxyRes, req)) {
|
|
console.warn(`Tracker long poll timed out upstream, returning empty 200 for ${req.originalUrl ?? req.url}`);
|
|
res.statusCode = 200;
|
|
res.statusMessage = "OK";
|
|
res.setHeader("content-type", "text/plain");
|
|
return "";
|
|
}
|
|
|
|
return buffer;
|
|
}),
|
|
},
|
|
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
|
});
|
|
app.use("/tracker", trackerProxy);
|
|
|
|
// --- Aeroflot frontend loader static proxy ---
|
|
// The loader derives follow-up asset URLs from its own script origin. Serving
|
|
// it through localhost keeps Header/Footer hydration visible in dev Chrome
|
|
// without browser CORS errors from www.aeroflot.ru.
|
|
app.use(
|
|
"/frontend/static",
|
|
createProxyMiddleware({
|
|
target: AEROFLOT_STATIC_TARGET,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
logLevel: "warn",
|
|
pathRewrite: (path) => `/frontend/static${path}`,
|
|
selfHandleResponse: true,
|
|
on: {
|
|
proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
|
|
const cacheKey = req.originalUrl ?? req.url ?? "";
|
|
const contentType = inferShellStaticContentType(
|
|
cacheKey,
|
|
proxyRes.headers["content-type"],
|
|
);
|
|
if (!/(css|html|javascript|json|text)/i.test(String(contentType))) {
|
|
if (proxyRes.statusCode && proxyRes.statusCode < 500) {
|
|
shellStaticAssetCache.set(cacheKey, { body: buffer, contentType });
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
if (proxyRes.statusCode && proxyRes.statusCode >= 500) {
|
|
const cached = shellStaticAssetCache.get(cacheKey);
|
|
if (cached) {
|
|
res.statusCode = 200;
|
|
res.statusMessage = "OK";
|
|
res.setHeader("content-type", cached.contentType);
|
|
return cached.body;
|
|
}
|
|
|
|
const retried = await retryAeroflotStaticAsset(cacheKey);
|
|
if (retried) {
|
|
const body = rewriteAeroflotShellUrls(retried.body);
|
|
shellStaticAssetCache.set(cacheKey, { body, contentType });
|
|
res.statusCode = 200;
|
|
res.statusMessage = "OK";
|
|
res.setHeader("content-type", contentType);
|
|
return body;
|
|
}
|
|
}
|
|
|
|
const body = rewriteAeroflotShellUrls(buffer.toString("utf8"));
|
|
if (!proxyRes.statusCode || proxyRes.statusCode < 500) {
|
|
shellStaticAssetCache.set(cacheKey, { body, contentType });
|
|
}
|
|
return body;
|
|
}),
|
|
},
|
|
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
|
}),
|
|
);
|
|
|
|
function inferShellStaticContentType(requestPath, upstreamContentType) {
|
|
const contentType = String(upstreamContentType ?? "");
|
|
if (/(css|html|javascript|json|text)/i.test(contentType)) return contentType;
|
|
const pathname = requestPath.split("?")[0] ?? "";
|
|
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "application/javascript";
|
|
if (pathname.endsWith(".css")) return "text/css";
|
|
if (pathname.endsWith(".json")) return "application/json";
|
|
if (pathname.endsWith(".html")) return "text/html";
|
|
return contentType;
|
|
}
|
|
|
|
function retryAeroflotStaticAsset(originalUrl) {
|
|
const targetUrl = `${AEROFLOT_STATIC_TARGET}${originalUrl}`;
|
|
const buildArgs = (noproxy) => [
|
|
"-sS",
|
|
"-f",
|
|
"-L",
|
|
...(noproxy ? ["--noproxy", "*"] : []),
|
|
"-H", "Accept: */*",
|
|
"-H", "User-Agent: Mozilla/5.0",
|
|
targetUrl,
|
|
];
|
|
|
|
return new Promise((resolveRetry) => {
|
|
runCurlWithoutStatus(buildArgs(true), (direct) => {
|
|
if (!direct.err && direct.stdout) {
|
|
resolveRetry({ body: direct.stdout });
|
|
return;
|
|
}
|
|
runCurlWithoutStatus(buildArgs(false), (proxy) => {
|
|
resolveRetry(!proxy.err && proxy.stdout ? { body: proxy.stdout } : null);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
app.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => {
|
|
res.status(200).json({ data: null });
|
|
});
|
|
|
|
app.use("/gw/api/pr/LKAB/Profile/v3/get", (_req, res) => {
|
|
res.status(200).json({ data: null });
|
|
});
|
|
|
|
app.use(["/ws2/v.0.0.1/json/currency/RU", "/ws2/v.0.0.1/json/calcurr/"], (_req, res) => {
|
|
res.status(200).json({ data: { currency: "RUB" }, errors: [], isSuccess: true });
|
|
});
|
|
|
|
app.use("/pkl/ws/json/v1/member/get", (_req, res) => {
|
|
res.status(200).json({ data: null, errors: [], isSuccess: true });
|
|
});
|
|
|
|
app.use(
|
|
["/ws2", "/cms2", "/personal", "/offers", "/feedback"],
|
|
express.raw({ type: "*/*" }),
|
|
(req, res) => {
|
|
const targetUrl = `${AEROFLOT_STATIC_TARGET}${req.originalUrl}`;
|
|
const requestBody = req.body?.length
|
|
? req.body.toString("utf8").replaceAll(LOCAL_PUBLIC_ORIGIN, API_TARGET)
|
|
: "";
|
|
|
|
const buildArgs = (noproxy) => [
|
|
"-s",
|
|
"-L",
|
|
...(noproxy ? ["--noproxy", "*"] : []),
|
|
"-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,
|
|
];
|
|
|
|
const bodyArgs =
|
|
req.method === "GET" || req.method === "HEAD"
|
|
? []
|
|
: [
|
|
"-X", req.method,
|
|
"-H", `Content-Type: ${req.headers["content-type"] || "application/json"}`,
|
|
...(requestBody ? ["--data-raw", requestBody] : []),
|
|
];
|
|
|
|
execCurlWithFallback(buildArgs, bodyArgs, res, respondWithAeroflotFrontendResult);
|
|
},
|
|
);
|
|
|
|
app.use(
|
|
"/media",
|
|
createProxyMiddleware({
|
|
target: AEROFLOT_STATIC_TARGET,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
logLevel: "warn",
|
|
pathRewrite: (path) => `/media${path}`,
|
|
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
|
}),
|
|
);
|
|
|
|
function execCurlWithFallback(buildArgs, extraArgs, res, responder = respondWithCurlResult) {
|
|
runCurl([...extraArgs, ...buildArgs(true)], (direct) => {
|
|
if (isSuccessfulUpstream(direct)) {
|
|
responder(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) => {
|
|
responder(isSuccessfulUpstream(proxy) ? proxy : direct, res);
|
|
});
|
|
});
|
|
}
|
|
|
|
function runCurl(args, cb) {
|
|
execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
console.warn(`curl failed: ${err.message}`);
|
|
}
|
|
if (stderr) {
|
|
console.warn(`curl stderr: ${stderr.substring(0, 300)}`);
|
|
}
|
|
cb({ err, stdout: stdout ?? "" });
|
|
});
|
|
}
|
|
|
|
function runCurlWithoutStatus(args, cb) {
|
|
execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
console.warn(`static curl retry failed: ${err.message}`);
|
|
}
|
|
if (stderr) {
|
|
console.warn(`static curl retry stderr: ${stderr.substring(0, 300)}`);
|
|
}
|
|
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;
|
|
if (status < 200 || status >= 400) return false;
|
|
|
|
const body = lastNewline >= 0 ? stdout.substring(0, lastNewline) : stdout;
|
|
const trimmed = body.trimStart();
|
|
// Some upstream edges return the Angular shell as HTTP 200 for API
|
|
// paths on the direct transport. Treat that as a failed API response so
|
|
// the fallback transport can fetch the JSON payload.
|
|
if (trimmed.startsWith("<!DOCTYPE html") || trimmed.startsWith("<html")) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function respondWithCurlResult({ err, stdout }, res) {
|
|
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(
|
|
`proxy response: status=${status}, content=${isJson ? "json" : "html"}, bodyLength=${body.length}, ${summarizeBody(body)}`,
|
|
);
|
|
if (DEBUG_PROXY_BODY) {
|
|
console.log(`body preview: ${body.substring(0, 500)}`);
|
|
}
|
|
|
|
res.status(status);
|
|
res.set("Content-Type", isJson ? "application/json" : "text/html");
|
|
res.set("Access-Control-Allow-Origin", "*");
|
|
res.send(body);
|
|
}
|
|
|
|
function respondWithAeroflotFrontendResult(result, res) {
|
|
if (result.err) {
|
|
res.status(502).json({ error: result.err.message });
|
|
return;
|
|
}
|
|
|
|
const lastNewline = result.stdout.lastIndexOf("\n");
|
|
const rawBody = lastNewline >= 0 ? result.stdout.substring(0, lastNewline) : result.stdout;
|
|
const body = rewriteAeroflotShellUrls(rawBody);
|
|
const statusStr = lastNewline >= 0 ? result.stdout.substring(lastNewline + 1).trim() : "200";
|
|
const status = parseInt(statusStr) || 200;
|
|
const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("[");
|
|
|
|
res.status(status);
|
|
res.set("Content-Type", isJson ? "application/json" : "text/html");
|
|
res.set("Access-Control-Allow-Origin", "*");
|
|
res.send(body);
|
|
}
|
|
|
|
function summarizeBody(body) {
|
|
const trimmed = body.trimStart();
|
|
if (!trimmed) return "body=empty";
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
if (typeof parsed.days === "string") {
|
|
const available = [...parsed.days].filter((d) => d === "1").length;
|
|
return `daysLength=${parsed.days.length}, availableDays=${available}`;
|
|
}
|
|
if (parsed.data && Array.isArray(parsed.data.routes)) {
|
|
return `routes=${parsed.data.routes.length}`;
|
|
}
|
|
return `jsonKeys=${Object.keys(parsed).slice(0, 5).join(",")}`;
|
|
}
|
|
if (Array.isArray(parsed)) {
|
|
return `jsonArrayLength=${parsed.length}`;
|
|
}
|
|
} catch {
|
|
// fall through to non-JSON summary
|
|
}
|
|
|
|
return `bodyPreview=${trimmed.substring(0, 80).replace(/\s+/g, " ")}`;
|
|
}
|
|
|
|
// --- 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(` /tracker/* → proxy → ${TRACKER_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", (req, socket, head) => {
|
|
if (req.url?.startsWith("/tracker")) {
|
|
trackerProxy.upgrade(req, socket, head);
|
|
return;
|
|
}
|
|
modernProxy.upgrade(req, socket, head);
|
|
});
|
|
|
|
process.on("SIGINT", () => { modernProcess.kill(); process.exit(); });
|
|
process.on("SIGTERM", () => { modernProcess.kill(); process.exit(); });
|