Proxy server to inject shim (#178)

things to test:

- [x] allow real URL to open in new window
- [x] packaging in electron?
- [ ] does it work on windows?
- [x] make sure it works with older apps
- [x] what about cache / reuse? - maybe use a bigger range of ports??
This commit is contained in:
Will Chen
2025-05-16 23:28:26 -07:00
committed by GitHub
parent 63e41454c7
commit 5966dd7f4b
15 changed files with 563 additions and 158 deletions

281
worker/proxy_server.js Normal file
View File

@@ -0,0 +1,281 @@
/**
* 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 (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),
};
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}`,
);
});