Some dev servers like next.js use Gzip which breaks our content injection so we avoid getting gzipped response when we need to do the shim injection.
291 lines
8.7 KiB
JavaScript
291 lines
8.7 KiB
JavaScript
/**
|
||
* proxy.js – zero-dependency worker-based HTTP/WS forwarder
|
||
*/
|
||
|
||
const {
|
||
Worker,
|
||
isMainThread,
|
||
parentPort,
|
||
workerData,
|
||
} = require("worker_threads");
|
||
|
||
const http = require("http");
|
||
const https = require("https");
|
||
|
||
const { URL } = require("url");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
|
||
/* ─────────────────── configuration (main thread only) ─────────────────── */
|
||
|
||
const LISTEN_HOST = "localhost";
|
||
|
||
if (isMainThread) {
|
||
// Stand-alone mode: fork the worker and pass through the env as-is
|
||
const w = new Worker(__filename, {
|
||
workerData: {
|
||
targetOrigin: process.env.TARGET_URL, // may be undefined
|
||
},
|
||
});
|
||
|
||
w.on("message", (m) => console.log("[proxy-worker]", m));
|
||
w.on("error", (e) => console.error("[proxy-worker] error:", e));
|
||
w.on("exit", (c) => console.log("[proxy-worker] exited", c));
|
||
console.log("proxy worker launching …");
|
||
return; // do not execute the rest of the file in the main thread
|
||
}
|
||
|
||
/* ──────────────────────────── worker code ─────────────────────────────── */
|
||
|
||
const LISTEN_PORT = process.env.LISTEN_PORT || workerData.port;
|
||
let rememberedOrigin = null; // e.g. "http://localhost:5173"
|
||
|
||
/* ---------- pre-configure rememberedOrigin from env or workerData ------- */
|
||
{
|
||
const fixed = process.env.TARGET_URL || workerData?.targetOrigin;
|
||
if (fixed) {
|
||
try {
|
||
rememberedOrigin = new URL(fixed).origin;
|
||
parentPort?.postMessage(
|
||
`[proxy-worker] fixed upstream: ${rememberedOrigin}`,
|
||
);
|
||
} catch {
|
||
throw new Error(
|
||
`Invalid TARGET_URL "${fixed}". Must be absolute http/https URL.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------- optional resources for HTML injection ---------------------- */
|
||
|
||
let stacktraceJsContent = null;
|
||
let dyadShimContent = null;
|
||
|
||
try {
|
||
const stackTraceLibPath = path.join(
|
||
__dirname,
|
||
"..",
|
||
"node_modules",
|
||
"stacktrace-js",
|
||
"dist",
|
||
"stacktrace.min.js",
|
||
);
|
||
stacktraceJsContent = fs.readFileSync(stackTraceLibPath, "utf-8");
|
||
parentPort?.postMessage("[proxy-worker] stacktrace.js loaded.");
|
||
} catch (error) {
|
||
parentPort?.postMessage(
|
||
`[proxy-worker] Failed to read stacktrace.js: ${error.message}`,
|
||
);
|
||
}
|
||
|
||
try {
|
||
const dyadShimPath = path.join(__dirname, "dyad-shim.js");
|
||
dyadShimContent = fs.readFileSync(dyadShimPath, "utf-8");
|
||
parentPort?.postMessage("[proxy-worker] dyad-shim.js loaded.");
|
||
} catch (error) {
|
||
parentPort?.postMessage(
|
||
`[proxy-worker] Failed to read dyad-shim.js: ${error.message}`,
|
||
);
|
||
}
|
||
|
||
/* ---------------------- helper: need to inject? ------------------------ */
|
||
function needsInjection(pathname) {
|
||
return pathname.endsWith("index.html") || pathname === "/";
|
||
}
|
||
|
||
function injectHTML(buf) {
|
||
let txt = buf.toString("utf8");
|
||
// These are strings that were used since the first version of the dyad shim.
|
||
// If the dyad shim is used from legacy apps which came pre-baked with the shim
|
||
// as a vite plugin, then do not inject the shim twice to avoid weird behaviors.
|
||
if (txt.includes("window-error") && txt.includes("unhandled-rejection")) {
|
||
return buf;
|
||
}
|
||
|
||
const scripts = [];
|
||
|
||
if (stacktraceJsContent)
|
||
scripts.push(`<script>${stacktraceJsContent}</script>`);
|
||
else
|
||
scripts.push(
|
||
'<script>console.warn("[proxy-worker] stacktrace.js was not injected.");</script>',
|
||
);
|
||
|
||
if (dyadShimContent) scripts.push(`<script>${dyadShimContent}</script>`);
|
||
else
|
||
scripts.push(
|
||
'<script>console.warn("[proxy-worker] dyad shim was not injected.");</script>',
|
||
);
|
||
|
||
const allScripts = scripts.join("\n");
|
||
|
||
const headRegex = /<head[^>]*>/i;
|
||
if (headRegex.test(txt)) {
|
||
txt = txt.replace(headRegex, `$&\n${allScripts}`);
|
||
} else {
|
||
txt = allScripts + "\n" + txt;
|
||
parentPort?.postMessage(
|
||
"[proxy-worker] Warning: <head> tag not found – scripts prepended.",
|
||
);
|
||
}
|
||
return Buffer.from(txt, "utf8");
|
||
}
|
||
|
||
/* ---------------- helper: build upstream URL from request -------------- */
|
||
function buildTargetURL(clientReq) {
|
||
// Support the old "?url=" mechanism
|
||
const parsedLocal = new URL(clientReq.url, `http://${LISTEN_HOST}`);
|
||
const urlParam = parsedLocal.searchParams.get("url");
|
||
if (urlParam) {
|
||
const abs = new URL(urlParam);
|
||
if (!/^https?:$/.test(abs.protocol))
|
||
throw new Error("only http/https targets allowed");
|
||
rememberedOrigin = abs.origin; // remember for later
|
||
return abs;
|
||
}
|
||
|
||
if (!rememberedOrigin)
|
||
throw new Error(
|
||
"No upstream configured. Use ?url=… once or set TARGET_URL env var.",
|
||
);
|
||
|
||
// Forward to the remembered origin keeping path & query
|
||
return new URL(clientReq.url, rememberedOrigin);
|
||
}
|
||
|
||
/* ----------------------------------------------------------------------- */
|
||
/* 1. Plain HTTP request / response */
|
||
/* ----------------------------------------------------------------------- */
|
||
|
||
const server = http.createServer((clientReq, clientRes) => {
|
||
let target;
|
||
try {
|
||
target = buildTargetURL(clientReq);
|
||
} catch (err) {
|
||
clientRes.writeHead(400, { "content-type": "text/plain" });
|
||
return void clientRes.end("Bad request: " + err.message);
|
||
}
|
||
|
||
const isTLS = target.protocol === "https:";
|
||
const lib = isTLS ? https : http;
|
||
|
||
/* Copy request headers but rewrite Host / Origin / Referer */
|
||
const headers = { ...clientReq.headers, host: target.host };
|
||
if (headers.origin) headers.origin = target.origin;
|
||
if (headers.referer) {
|
||
try {
|
||
const ref = new URL(headers.referer);
|
||
headers.referer = target.origin + ref.pathname + ref.search;
|
||
} catch {
|
||
delete headers.referer;
|
||
}
|
||
}
|
||
if (needsInjection) {
|
||
// Request uncompressed content from upstream
|
||
delete headers["accept-encoding"];
|
||
}
|
||
|
||
if (headers["if-none-match"] && needsInjection(target.pathname))
|
||
delete headers["if-none-match"];
|
||
|
||
const upOpts = {
|
||
protocol: target.protocol,
|
||
hostname: target.hostname,
|
||
port: target.port || (isTLS ? 443 : 80),
|
||
path: target.pathname + target.search,
|
||
method: clientReq.method,
|
||
headers,
|
||
};
|
||
|
||
const upReq = lib.request(upOpts, (upRes) => {
|
||
const inject = needsInjection(target.pathname);
|
||
|
||
if (!inject) {
|
||
clientRes.writeHead(upRes.statusCode, upRes.headers);
|
||
return void upRes.pipe(clientRes);
|
||
}
|
||
|
||
const chunks = [];
|
||
upRes.on("data", (c) => chunks.push(c));
|
||
upRes.on("end", () => {
|
||
try {
|
||
const merged = Buffer.concat(chunks);
|
||
const patched = injectHTML(merged);
|
||
|
||
const hdrs = {
|
||
...upRes.headers,
|
||
"content-length": Buffer.byteLength(patched),
|
||
};
|
||
// If we injected content, it's no longer encoded in the original way
|
||
delete hdrs["content-encoding"];
|
||
// Also, remove ETag as content has changed
|
||
delete hdrs["etag"];
|
||
|
||
clientRes.writeHead(upRes.statusCode, hdrs);
|
||
clientRes.end(patched);
|
||
} catch (e) {
|
||
clientRes.writeHead(500, { "content-type": "text/plain" });
|
||
clientRes.end("Injection failed: " + e.message);
|
||
}
|
||
});
|
||
});
|
||
|
||
clientReq.pipe(upReq);
|
||
upReq.on("error", (e) => {
|
||
clientRes.writeHead(502, { "content-type": "text/plain" });
|
||
clientRes.end("Upstream error: " + e.message);
|
||
});
|
||
});
|
||
|
||
/* ----------------------------------------------------------------------- */
|
||
/* 2. WebSocket / generic Upgrade tunnelling */
|
||
/* ----------------------------------------------------------------------- */
|
||
|
||
server.on("upgrade", (req, socket, _head) => {
|
||
let target;
|
||
try {
|
||
target = buildTargetURL(req);
|
||
} catch (err) {
|
||
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n" + err.message);
|
||
return socket.destroy();
|
||
}
|
||
|
||
const isTLS = target.protocol === "https:";
|
||
const headers = { ...req.headers, host: target.host };
|
||
if (headers.origin) headers.origin = target.origin;
|
||
|
||
const upReq = (isTLS ? https : http).request({
|
||
protocol: target.protocol,
|
||
hostname: target.hostname,
|
||
port: target.port || (isTLS ? 443 : 80),
|
||
path: target.pathname + target.search,
|
||
method: "GET",
|
||
headers,
|
||
});
|
||
|
||
upReq.on("upgrade", (upRes, upSocket, upHead) => {
|
||
socket.write(
|
||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||
Object.entries(upRes.headers)
|
||
.map(([k, v]) => `${k}: ${v}`)
|
||
.join("\r\n") +
|
||
"\r\n\r\n",
|
||
);
|
||
if (upHead && upHead.length) socket.write(upHead);
|
||
|
||
upSocket.pipe(socket).pipe(upSocket);
|
||
});
|
||
|
||
upReq.on("error", () => socket.destroy());
|
||
upReq.end();
|
||
});
|
||
|
||
/* ----------------------------------------------------------------------- */
|
||
|
||
server.listen(LISTEN_PORT, LISTEN_HOST, () => {
|
||
parentPort?.postMessage(
|
||
`proxy-server-start url=http://${LISTEN_HOST}:${LISTEN_PORT}`,
|
||
);
|
||
});
|