From 4b641a330322052805f7e43cf2f5cfe2b44fed78 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Sat, 12 Apr 2025 00:09:21 -0700 Subject: [PATCH] Infra for serving sandpack locally --- .../preview_panel/PreviewIframe.tsx | 10 +- src/ipc/handlers/app_handlers.ts | 126 +++++++- worker/static_file_server.js | 276 ++++++++++++++++++ 3 files changed, 395 insertions(+), 17 deletions(-) create mode 100644 worker/static_file_server.js diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx index 812e203..3849f5f 100644 --- a/src/components/preview_panel/PreviewIframe.tsx +++ b/src/components/preview_panel/PreviewIframe.tsx @@ -442,22 +442,20 @@ const SandpackIframe = ({ reloadKey }: { reloadKey: number }) => { if (keyRef.current === reloadKey) return; keyRef.current = reloadKey; - if (!iframeRef.current || !app || !selectedAppId) return; + if (!selectedAppId) return; const sandboxConfig = await IpcClient.getInstance().getAppSandboxConfig( selectedAppId ); + if (!iframeRef.current || !app) return; const sandpackConfig: SandboxSetup = mapSandpackConfig(sandboxConfig); const options: ClientOptions = { - // bundlerURL: "https://sandpack.dyad.sh/", + bundlerURL: "http://localhost:31111", showOpenInCodeSandbox: false, showLoadingScreen: true, showErrorScreen: true, - externalResources: [ - // "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4", - "https://cdn.tailwindcss.com", - ], + externalResources: ["https://cdn.tailwindcss.com"], }; let client: SandpackClient | undefined; diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index e855393..d73023b 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -31,25 +31,103 @@ import { import { ALLOWED_ENV_VARS } from "../../constants/models"; import { getEnvVar } from "../utils/read_env"; import { readSettings } from "../../main/settings"; +import { Worker } from "worker_threads"; + +// Keep track of the static file server worker +let staticServerWorker: Worker | null = null; +let staticServerPort: number | null = null; +// let staticServerRootDir: string | null = null; // Store the root dir it's serving - Removed async function executeApp({ appPath, appId, - event, + event, // Keep event for local-node case }: { appPath: string; appId: number; event: Electron.IpcMainInvokeEvent; }): Promise { + // Return type is void, communication happens via event.sender.send const settings = readSettings(); if (settings.runtimeMode === "web-sandbox") { - return; + // If server is already running, do nothing. + if (staticServerWorker) { + console.log(`Static server already running on port ${staticServerPort}`); + // No need to send app:output here + return; + } + + // Start the worker if it's not running + console.log(`Starting static file server worker for the first time.`); + // No need to send starting status + + const workerScriptPath = path.resolve( + __dirname, + "../../worker/static_file_server.js" + ); + + // Check if worker script exists + if (!fs.existsSync(workerScriptPath)) { + const errorMsg = `Worker script not found at ${workerScriptPath}. Build process might be incomplete.`; + console.error(errorMsg); + // No need to send error status via event + throw new Error(errorMsg); + } + + staticServerWorker = new Worker(workerScriptPath, { + workerData: { + rootDir: path.join(__dirname, "..", "..", "sandpack-generated"), // Use the appPath of the first app run in this mode + // Optionally pass other config like port preference + // port: 3001 // Example + }, + }); + // staticServerRootDir = appPath; // Removed + + staticServerWorker.on("message", (message) => { + console.log( + `Message from static server worker: ${JSON.stringify(message)}` + ); + if (message.status === "ready" && message.port) { + staticServerPort = message.port; + console.log(`Static file server ready on port ${staticServerPort}`); + // No need to send ready status + } else if (message.status === "error") { + console.error(`Static file server worker error: ${message.message}`); + // No need to send error status + // Terminate the failed worker + staticServerWorker?.terminate(); + staticServerWorker = null; + staticServerPort = null; + } + }); + + staticServerWorker.on("error", (error) => { + console.error(`Static file server worker encountered an error:`, error); + // No need to send error status + staticServerWorker = null; // Worker is likely unusable + staticServerPort = null; + }); + + staticServerWorker.on("exit", (code) => { + console.log(`Static file server worker exited with code ${code}`); + // Clear state if the worker exits unexpectedly + if (staticServerWorker) { + // Check avoids race condition if terminated intentionally + staticServerWorker = null; + staticServerPort = null; + // No need to send exit status + } + }); + + return; // Return void } if (settings.runtimeMode === "local-node") { + // Ensure worker isn't running if switching modes (optional, depends on desired behavior) + // if (staticServerWorker) { await staticServerWorker.terminate(); staticServerWorker = null; staticServerPort = null; } await executeAppLocalNode({ appPath, appId, event }); return; } - throw new Error("Invalid runtime mode"); + throw new Error(`Invalid runtime mode: ${settings.runtimeMode}`); } async function executeAppLocalNode({ appPath, @@ -350,19 +428,23 @@ export function registerAppHandlers() { ipcMain.handle("stop-app", async (_, { appId }: { appId: number }) => { console.log( - `Attempting to stop app ${appId}. Current running apps: ${runningApps.size}` + `Attempting to stop app ${appId} (local-node only). Current running apps: ${runningApps.size}` ); - // Use withLock to ensure atomicity of the stop operation + + // Static server worker is NOT terminated here anymore + + // Use withLock for local-node apps return withLock(appId, async () => { const appInfo = runningApps.get(appId); if (!appInfo) { console.log( - `App ${appId} not found in running apps map. Assuming already stopped.` + `App ${appId} not found in running apps map (local-node). Assuming already stopped or was web-sandbox.` ); + // If no local-node app was running, and we terminated the static server above, return success. return { success: true, - message: "App not running or already stopped.", + message: "App not running in local-node mode.", // Simplified message }; } @@ -406,6 +488,8 @@ export function registerAppHandlers() { event: Electron.IpcMainInvokeEvent, { appId }: { appId: number } ) => { + // Static server worker is NOT terminated here anymore + return withLock(appId, async () => { try { // First stop the app if it's running @@ -413,7 +497,7 @@ export function registerAppHandlers() { if (appInfo) { const { process, processId } = appInfo; console.log( - `Stopping app ${appId} (processId ${processId}) before restart` + `Stopping local-node app ${appId} (processId ${processId}) before restart` // Adjusted log ); // Use the killProcess utility to stop the process @@ -421,6 +505,10 @@ export function registerAppHandlers() { // Remove from running apps runningApps.delete(appId); + } else { + console.log( + `App ${appId} not running in local-node mode, proceeding to start.` + ); } // Now start the app again @@ -433,9 +521,11 @@ export function registerAppHandlers() { } const appPath = getDyadAppPath(app.path); - console.debug(`Starting app ${appId} in path ${app.path}`); + console.debug( + `Executing app ${appId} in path ${app.path} after restart request` + ); // Adjusted log - await executeApp({ appPath, appId, event }); + await executeApp({ appPath, appId, event }); // This will handle starting either mode return { success: true }; } catch (error) { @@ -717,6 +807,8 @@ export function registerAppHandlers() { ); ipcMain.handle("delete-app", async (_, { appId }: { appId: number }) => { + // Static server worker is NOT terminated here anymore + return withLock(appId, async () => { // Check if app exists const app = await db.query.apps.findFirst({ @@ -731,10 +823,14 @@ export function registerAppHandlers() { if (runningApps.has(appId)) { const appInfo = runningApps.get(appId)!; try { + console.log(`Stopping local-node app ${appId} before deletion.`); // Adjusted log await killProcess(appInfo.process); runningApps.delete(appId); } catch (error: any) { - console.error(`Error stopping app ${appId} before deletion:`, error); + console.error( + `Error stopping local-node app ${appId} before deletion:`, + error + ); // Adjusted log // Continue with deletion even if stopping fails } } @@ -876,6 +972,14 @@ export function registerAppHandlers() { ); ipcMain.handle("reset-all", async () => { + // Terminate static server worker if it's running + if (staticServerWorker) { + console.log(`Terminating static server worker on reset-all command.`); + await staticServerWorker.terminate(); + staticServerWorker = null; + staticServerPort = null; + staticServerRootDir = null; + } // Stop all running apps first const runningAppIds = Array.from(runningApps.keys()); for (const appId of runningAppIds) { diff --git a/worker/static_file_server.js b/worker/static_file_server.js new file mode 100644 index 0000000..e98409b --- /dev/null +++ b/worker/static_file_server.js @@ -0,0 +1,276 @@ +import * as http from "http"; +import * as fs from "fs"; +import * as path from "path"; +import * as net from "net"; +import { promisify } from "util"; +import { parentPort, isMainThread, workerData } from "worker_threads"; + +// Promisify file system operations +const statAsync = promisify(fs.stat); +const readFileAsync = promisify(fs.readFile); + +// Configuration interface with types +// export interface ServerConfig { +// port: number; +// rootDir: string; +// cacheMaxAge: number; +// maxPortRetries?: number; +// } + +// Default configuration +const DEFAULT_CONFIG = { + port: 31_111, + rootDir: ".", + cacheMaxAge: 86400, // 1 day in seconds + maxPortRetries: 5, +}; + +// MIME types mapping +const MIME_TYPES = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".ts": "application/typescript", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".txt": "text/plain", + ".pdf": "application/pdf", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + ".otf": "font/otf", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".webp": "image/webp", +}; + +/** + * Checks if a port is available + * @param port Port number to check + * @returns Promise that resolves to true if port is available, false otherwise + */ +const isPortAvailable = (port) => { + return new Promise((resolve) => { + const tester = net + .createServer() + .once("error", () => { + // Port is in use + resolve(false); + }) + .once("listening", () => { + // Port is available + tester.close(() => resolve(true)); + }) + .listen(port); + }); +}; + +/** + * Finds the first available port starting from the specified port + * @param startPort Starting port number + * @param maxRetries Maximum number of ports to try + * @returns Promise resolving to the first available port, or undefined if none found + */ +const findAvailablePort = async (startPort, maxRetries) => { + for (let i = 0; i < maxRetries; i++) { + const port = startPort + i; + // eslint-disable-next-line no-await-in-loop + if (await isPortAvailable(port)) { + return port; + } + } + return undefined; +}; + +/** + * Handles HTTP requests, serving static files with caching + */ +const handleRequest = (config) => async (req, res) => { + try { + if (!req.url) { + res.statusCode = 400; + res.end("Bad Request"); + return; + } + + // Only allow GET requests + if (req.method !== "GET") { + res.statusCode = 405; + res.setHeader("Allow", "GET"); + res.end("Method Not Allowed"); + return; + } + + // Parse URL and sanitize path + const parsedUrl = new URL(req.url, `http://${req.headers.host}`); + let filePath = path.normalize( + path.join(config.rootDir, parsedUrl.pathname) + ); + console.log(`filePath: ${filePath}`, "request", req.url); + + // Handle root path or directory paths, serve index.html + if (filePath === path.normalize(config.rootDir) || filePath.endsWith("/")) { + filePath = path.join(filePath, "index.html"); + } + + // Check if file exists and get its stats + let stats; + try { + console.log(`filePath: ${filePath}`, "stats"); + stats = await statAsync(filePath); + } catch (error) { + // File not found + res.statusCode = 404; + res.end("Not Found"); + return; + } + + // Handle directory requests + if (stats.isDirectory()) { + try { + // Redirect to directory with trailing slash if needed + if (!req.url.endsWith("/")) { + res.statusCode = 301; + res.setHeader("Location", `${req.url}/`); + res.end(); + return; + } + + // Try to serve index.html from directory + filePath = path.join(filePath, "index.html"); + stats = await statAsync(filePath); + } catch (error) { + res.statusCode = 404; + res.end("Not Found"); + return; + } + } + + // Get file extension and MIME type + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || "application/octet-stream"; + + // Handle caching - check if file has been modified + const ifModifiedSince = req.headers["if-modified-since"]; + if (ifModifiedSince) { + const modifiedSinceDate = new Date(ifModifiedSince); + // Check if the file hasn't been modified since the client's last request + if (modifiedSinceDate && stats.mtime <= modifiedSinceDate) { + res.statusCode = 304; // Not Modified + res.end(); + return; + } + } + + // Set cache headers + const lastModified = stats.mtime.toUTCString(); + res.setHeader("Last-Modified", lastModified); + res.setHeader("Cache-Control", `public, max-age=${config.cacheMaxAge}`); + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", stats.size); + + // Read and send file + const fileContent = await readFileAsync(filePath); + res.end(fileContent); + } catch (error) { + console.error(`[Worker ${process.pid}] Server error:`, error); + // Only attempt to send error response if headers haven't been sent + if (!res.headersSent) { + res.statusCode = 500; + res.end("Internal Server Error"); + } + } +}; + +// Create and start the server +// Modified to return the port and not handle process exit +export const startServer = async (userConfig = {}) => { + // Merge default config with user provided config + const config = { ...DEFAULT_CONFIG, ...userConfig }; + + // Try to find an available port + const maxRetries = config.maxPortRetries || 5; + const availablePort = await findAvailablePort(config.port, maxRetries); + + if (!availablePort) { + throw new Error( + `Could not find an available port after trying ${maxRetries} ports starting from ${config.port}` + ); + } + config.port = availablePort; // Update config with the actual port + + // Create server with the handler + const server = http.createServer(handleRequest(config)); + + // Start the server + await new Promise((resolve, reject) => { + server.on("error", (err) => { + console.error(`[Worker ${process.pid}] Server error:`, err); + reject(err); // Reject promise on server error during startup + }); + + server.listen(config.port, () => { + console.log( + `[Worker ${process.pid}] 🚀 Static file server running at http://localhost:${config.port}/` + ); + console.log( + `[Worker ${process.pid}] 📁 Serving files from: ${path.resolve( + config.rootDir + )}` + ); + console.log( + `[Worker ${process.pid}] 🔄 Cache max age: ${config.cacheMaxAge} seconds` + ); + resolve(); + }); + }); + + // Don't handle SIGINT here, let the main thread manage the worker lifecycle + + return { server, port: config.port }; +}; + +// --- Worker Logic --- +if (!isMainThread && parentPort) { + const run = async () => { + try { + if (!workerData || !workerData.rootDir) { + throw new Error("rootDir must be provided in workerData"); + } + const config = { ...DEFAULT_CONFIG, ...workerData }; + const { port } = await startServer(config); + parentPort?.postMessage({ status: "ready", port }); + } catch (error) { + console.error(`[Worker ${process.pid}] Failed to start server:`, error); + parentPort?.postMessage({ status: "error", message: error.message }); + } + }; + + run().catch((err) => { + // Catch unhandled promise rejections during startup + console.error( + `[Worker ${process.pid}] Unhandled error during startup:`, + err + ); + parentPort?.postMessage({ + status: "error", + message: err.message || "Unknown startup error", + }); + }); + + // Keep the worker alive + // The server itself will keep the event loop running +} else if (!isMainThread) { + // Should not happen if used correctly, but good to handle + console.error("Running as worker but parentPort is not available."); + process.exit(1); +} +// If it IS the main thread, exporting startServer allows for potential direct use or testing