Files
moreminimore-vibe/worker/proxy_server.js
Will Chen bc8166c274 Fix proxy server injection for gzip content (#190)
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.
2025-05-19 14:11:55 -07:00

291 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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