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

View File

@@ -11,8 +11,9 @@ export const previewModeAtom = atom<"preview" | "code">("preview");
export const selectedVersionIdAtom = atom<string | null>(null);
export const appOutputAtom = atom<AppOutput[]>([]);
export const appUrlAtom = atom<
{ appUrl: string; appId: number } | { appUrl: null; appId: null }
>({ appUrl: null, appId: null });
| { appUrl: string; appId: number; originalUrl: string }
| { appUrl: null; appId: null; originalUrl: null }
>({ appUrl: null, appId: null, originalUrl: null });
export const userSettingsAtom = atom<UserSettings | null>(null);
// Atom for storing allow-listed environment variables

View File

@@ -106,7 +106,7 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
// Preview iframe component
export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const { appUrl } = useAtomValue(appUrlAtom);
const { appUrl, originalUrl } = useAtomValue(appUrlAtom);
const setAppOutput = useSetAtom(appOutputAtom);
// State to trigger iframe reload
const [reloadKey, setReloadKey] = useState(0);
@@ -429,8 +429,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
<div className="flex space-x-1">
<button
onClick={() => {
if (appUrl) {
IpcClient.getInstance().openExternalUrl(appUrl);
if (originalUrl) {
IpcClient.getInstance().openExternalUrl(originalUrl);
}
}}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"

View File

@@ -9,6 +9,7 @@ import {
selectedAppIdAtom,
} from "@/atoms/appAtoms";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AppOutput } from "@/ipc/ipc_types";
export function useRunApp() {
const [loading, setLoading] = useState(false);
@@ -18,6 +19,29 @@ export function useRunApp() {
const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom);
const appId = useAtomValue(selectedAppIdAtom);
const setPreviewErrorMessage = useSetAtom(previewErrorMessageAtom);
const processProxyServerOutput = (output: AppOutput) => {
const matchesProxyServerStart = output.message.includes(
"[dyad-proxy-server]started=[",
);
if (matchesProxyServerStart) {
// Extract both proxy URL and original URL using regex
const proxyUrlMatch = output.message.match(
/\[dyad-proxy-server\]started=\[(.*?)\]/,
);
const originalUrlMatch = output.message.match(/original=\[(.*?)\]/);
if (proxyUrlMatch && proxyUrlMatch[1]) {
const proxyUrl = proxyUrlMatch[1];
const originalUrl = originalUrlMatch && originalUrlMatch[1];
setAppUrlObj({
appUrl: proxyUrl,
appId: appId!,
originalUrl: originalUrl!,
});
}
}
};
const runApp = useCallback(async (appId: number) => {
setLoading(true);
try {
@@ -26,7 +50,7 @@ export function useRunApp() {
// Clear the URL and add restart message
if (appUrlObj?.appId !== appId) {
setAppUrlObj({ appUrl: null, appId: null });
setAppUrlObj({ appUrl: null, appId: null, originalUrl: null });
}
setAppOutput((prev) => [
...prev,
@@ -41,11 +65,7 @@ export function useRunApp() {
setApp(app);
await ipcClient.runApp(appId, (output) => {
setAppOutput((prev) => [...prev, output]);
// Check if the output contains a localhost URL
const urlMatch = output.message.match(/(https?:\/\/localhost:\d+\/?)/);
if (urlMatch) {
setAppUrlObj({ appUrl: urlMatch[1], appId });
}
processProxyServerOutput(output);
});
setPreviewErrorMessage(undefined);
} catch (error) {
@@ -100,7 +120,7 @@ export function useRunApp() {
);
// Clear the URL and add restart message
setAppUrlObj({ appUrl: null, appId: null });
setAppUrlObj({ appUrl: null, appId: null, originalUrl: null });
setAppOutput((prev) => [
...prev,
{
@@ -124,13 +144,7 @@ export function useRunApp() {
onHotModuleReload();
return;
}
// Check if the output contains a localhost URL
const urlMatch = output.message.match(
/(https?:\/\/localhost:\d+\/?)/,
);
if (urlMatch) {
setAppUrlObj({ appUrl: urlMatch[1], appId });
}
processProxyServerOutput(output);
},
removeNodeModules,
);

View File

@@ -33,10 +33,14 @@ import log from "electron-log";
import { getSupabaseProjectName } from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server";
import { Worker } from "worker_threads";
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
let proxyWorker: Worker | null = null;
// Needed, otherwise electron in MacOS/Linux will not be able
// to find node/pnpm.
fixPath();
@@ -50,8 +54,13 @@ async function executeApp({
appId: number;
event: Electron.IpcMainInvokeEvent;
}): Promise<void> {
if (proxyWorker) {
proxyWorker.terminate();
proxyWorker = null;
}
await executeAppLocalNode({ appPath, appId, event });
}
async function executeAppLocalNode({
appPath,
appId,
@@ -90,14 +99,27 @@ async function executeAppLocalNode({
runningApps.set(appId, { process, processId: currentProcessId });
// Log output
process.stdout?.on("data", (data) => {
process.stdout?.on("data", async (data) => {
const message = util.stripVTControlCharacters(data.toString());
logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`);
event.sender.send("app:output", {
type: "stdout",
message,
appId,
});
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
if (urlMatch) {
proxyWorker = await startProxy(urlMatch[1], {
onStarted: (proxyUrl) => {
event.sender.send("app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`,
appId,
});
},
});
}
});
process.stderr?.on("data", (data) => {

View File

@@ -0,0 +1,48 @@
import net from "net";
export function findAvailablePort(
minPort: number,
maxPort: number,
): Promise<number> {
return new Promise((resolve, reject) => {
let attempts = 0;
const maxAttempts = 3;
function tryPort() {
if (attempts >= maxAttempts) {
reject(
new Error(
`Failed to find an available port after ${maxAttempts} attempts.`,
),
);
return;
}
attempts++;
const port =
Math.floor(Math.random() * (maxPort - minPort + 1)) + minPort;
const server = net.createServer();
server.once("error", (err: any) => {
if (err.code === "EADDRINUSE") {
// Port is in use, try another one
console.log(`Port ${port} is in use, trying another...`);
server.close(() => tryPort());
} else {
// Other error
server.close(() => reject(err));
}
});
server.once("listening", () => {
server.close(() => {
resolve(port);
});
});
server.listen(port, "localhost");
}
tryPort();
});
}

View File

@@ -0,0 +1,55 @@
// startProxy.js helper to launch proxy.js as a worker
import { Worker } from "worker_threads";
import path from "path";
import { findAvailablePort } from "./port_utils";
import log from "electron-log";
const logger = log.scope("start_proxy_server");
export async function startProxy(
targetOrigin: string,
opts: {
// host?: string;
// port?: number;
// env?: Record<string, string>;
onStarted?: (proxyUrl: string) => void;
} = {},
) {
if (!/^https?:\/\//.test(targetOrigin))
throw new Error("startProxy: targetOrigin must be absolute http/https URL");
const port = await findAvailablePort(50_000, 60_000);
logger.info("Found available port", port);
const {
// host = "localhost",
// env = {}, // additional env vars to pass to the worker
onStarted,
} = opts;
const worker = new Worker(
path.resolve(__dirname, "..", "..", "worker", "proxy_server.js"),
{
env: {
...process.env, // inherit parent env
TARGET_URL: targetOrigin,
},
workerData: {
targetOrigin,
port,
},
},
);
worker.on("message", (m) => {
logger.info("[proxy]", m);
if (typeof m === "string" && m.startsWith("proxy-server-start url=")) {
const url = m.substring("proxy-server-start url=".length);
onStarted?.(url);
}
});
worker.on("error", (e) => logger.error("[proxy] error:", e));
worker.on("exit", (c) => logger.info("[proxy] exit", c));
return worker; // let the caller keep a handle if desired
}

View File

@@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) {
}
/**
* Generates a cute app name like "blue-fox" or "jumping-zebra"
* Generates a cute app name.
*/
export function generateCuteAppName(): string {
const adjectives = [
@@ -144,9 +144,64 @@ export function generateCuteAppName(): string {
"kraken",
];
const verbs = [
"run",
"hop",
"dash",
"zoom",
"skip",
"jump",
"glow",
"play",
"chirp",
"buzz",
"flip",
"flit",
"soar",
"dive",
"swim",
"climb",
"sprint",
"wiggle",
"twirl",
"pounce",
"bop",
"spin",
"hum",
"roll",
"blink",
"skid",
"kick",
"drift",
"bloom",
"burst",
"slide",
"bounce",
"crawl",
"sniff",
"peek",
"scurry",
"nudge",
"snap",
"swoop",
"roam",
"trot",
"dart",
"yawn",
"snore",
"hug",
"nap",
"chase",
"rest",
"wag",
"bob",
"beam",
"cheer",
];
const randomAdjective =
adjectives[Math.floor(Math.random() * adjectives.length)];
const randomAnimal = animals[Math.floor(Math.random() * animals.length)];
return `${randomAdjective}-${randomAnimal}`;
const randomVerb = verbs[Math.floor(Math.random() * verbs.length)];
return `${randomAdjective}-${randomAnimal}-${randomVerb}`;
}