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:
281
worker/proxy_server.js
Normal file
281
worker/proxy_server.js
Normal 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}`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user