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

269
worker/dyad-shim.js Normal file
View File

@@ -0,0 +1,269 @@
(function () {
console.debug("dyad-shim.js loaded via proxy v0.6.0");
const isInsideIframe = window.parent !== window;
if (!isInsideIframe) return;
let previousUrl = window.location.href;
const PARENT_TARGET_ORIGIN = "*";
// --- History API Overrides ---
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
const handleStateChangeAndNotify = (originalMethod, state, title, url) => {
const oldUrlForMessage = previousUrl;
let newUrl;
try {
newUrl = url
? new URL(url, window.location.href).href
: window.location.href;
} catch (e) {
console.error("Could not parse URL", e);
newUrl = window.location.href;
}
const navigationType =
originalMethod === originalPushState ? "pushState" : "replaceState";
try {
// Pass the original state directly
originalMethod.call(history, state, title, url);
previousUrl = window.location.href;
window.parent.postMessage(
{
type: navigationType,
payload: { oldUrl: oldUrlForMessage, newUrl: newUrl },
},
PARENT_TARGET_ORIGIN,
);
} catch (e) {
console.error(
`[vite-dev-plugin] Error calling original ${navigationType}: `,
e,
);
window.parent.postMessage(
{
type: "navigation-error",
payload: {
operation: navigationType,
message: e.message,
error: e.toString(),
stateAttempted: state,
urlAttempted: url,
},
},
PARENT_TARGET_ORIGIN,
);
}
};
history.pushState = function (state, title, url) {
handleStateChangeAndNotify(originalPushState, state, title, url);
};
history.replaceState = function (state, title, url) {
handleStateChangeAndNotify(originalReplaceState, state, title, url);
};
// --- Listener for Back/Forward Navigation (popstate event) ---
window.addEventListener("popstate", () => {
const currentUrl = window.location.href;
previousUrl = currentUrl;
});
// --- Listener for Commands from Parent ---
window.addEventListener("message", (event) => {
if (
event.source !== window.parent ||
!event.data ||
typeof event.data !== "object"
)
return;
if (event.data.type === "navigate") {
const direction = event.data.payload?.direction;
if (direction === "forward") history.forward();
else if (direction === "backward") history.back();
}
});
// --- Sourcemapped Error Handling ---
function sendSourcemappedErrorToParent(error, sourceType) {
if (typeof window.StackTrace === "undefined") {
console.error("[vite-dev-plugin] StackTrace object not found.");
// Send simplified raw data if StackTrace isn't available
window.parent.postMessage(
{
type: sourceType,
payload: {
message: error?.message || String(error),
stack:
error?.stack || "<no stack available - StackTrace.js missing>",
},
},
PARENT_TARGET_ORIGIN,
);
return;
}
window.StackTrace.fromError(error)
.then((stackFrames) => {
const sourcemappedStack = stackFrames
.map((sf) => sf.toString())
.join("\n");
const payload = {
message: error?.message || String(error),
stack: sourcemappedStack,
};
window.parent.postMessage(
{
type: "iframe-sourcemapped-error",
payload: { ...payload, originalSourceType: sourceType },
},
PARENT_TARGET_ORIGIN,
);
})
.catch((mappingError) => {
console.error(
"[vite-dev-plugin] Error during stacktrace sourcemapping:",
mappingError,
);
const payload = {
message: error?.message || String(error),
// Provide the raw stack or an indication of mapping failure
stack: error?.stack
? `Sourcemapping failed: ${mappingError.message}\n--- Raw Stack ---\n${error.stack}`
: `Sourcemapping failed: ${mappingError.message}\n<no raw stack available>`,
};
window.parent.postMessage(
{
type: "iframe-sourcemapped-error",
payload: { ...payload, originalSourceType: sourceType },
},
PARENT_TARGET_ORIGIN,
);
});
}
window.addEventListener("error", (event) => {
let error = event.error;
if (!(error instanceof Error)) {
window.parent.postMessage(
{
type: "window-error",
payload: {
message: error.toString(),
stack: "<no stack available - an improper error was thrown>",
},
},
PARENT_TARGET_ORIGIN,
);
return;
}
sendSourcemappedErrorToParent(error, "window-error");
});
window.addEventListener("unhandledrejection", (event) => {
let error = event.reason;
if (!(error instanceof Error)) {
window.parent.postMessage(
{
type: "unhandled-rejection",
payload: {
message: event.reason.toString(),
stack:
"<no stack available - an improper error was thrown (promise)>",
},
},
PARENT_TARGET_ORIGIN,
);
return;
}
sendSourcemappedErrorToParent(error, "unhandled-rejection");
});
(function watchForViteErrorOverlay() {
// --- Configuration for the observer ---
// We only care about direct children being added or removed.
const config = {
childList: true, // Observe additions/removals of child nodes
subtree: false, // IMPORTANT: Do *not* observe descendants, only direct children
};
// --- Callback function executed when mutations are observed ---
const observerCallback = function (mutationsList) {
// Iterate through all mutations that just occurred
for (const mutation of mutationsList) {
// We are only interested in nodes that were added
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
// Check each added node
for (const node of mutation.addedNodes) {
// Check if it's an ELEMENT_NODE (type 1) and has the correct ID
if (
node.nodeType === Node.ELEMENT_NODE &&
node.tagName === "vite-error-overlay".toUpperCase()
) {
reportViteErrorOverlay(node);
}
}
}
}
};
function reportViteErrorOverlay(node) {
console.log(`Detected vite error overlay: ${node}`);
try {
window.parent.postMessage(
{
type: "build-error-report",
payload: {
message: node.shadowRoot.querySelector(".message").textContent,
file: node.shadowRoot.querySelector(".file").textContent,
frame: node.shadowRoot.querySelector(".frame").textContent,
},
},
PARENT_TARGET_ORIGIN,
);
} catch (error) {
console.error("Could not report vite error overlay", error);
}
}
// --- Wait for DOM ready logic ---
if (document.readyState === "loading") {
// The document is still loading, wait for DOMContentLoaded
document.addEventListener("DOMContentLoaded", () => {
if (!document.body) {
console.error(
"document.body does not exist - something very weird happened",
);
return;
}
const node = document.body.querySelector("vite-error-overlay");
if (node) {
reportViteErrorOverlay(node);
}
const observer = new MutationObserver(observerCallback);
observer.observe(document.body, config);
});
console.log(
"Document loading, waiting for DOMContentLoaded to set up observer.",
);
} else {
if (!document.body) {
console.error(
"document.body does not exist - something very weird happened",
);
return;
}
// The DOM is already interactive or complete
console.log("DOM already ready, setting up observer immediately.");
const observer = new MutationObserver(observerCallback);
observer.observe(document.body, config);
}
})();
})();

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