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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
48
src/ipc/utils/port_utils.ts
Normal file
48
src/ipc/utils/port_utils.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
55
src/ipc/utils/start_proxy_server.ts
Normal file
55
src/ipc/utils/start_proxy_server.ts
Normal 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
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user