Infra for serving sandpack locally

This commit is contained in:
Will Chen
2025-04-12 00:09:21 -07:00
parent a4a763a06d
commit 4b641a3303
3 changed files with 395 additions and 17 deletions

View File

@@ -442,22 +442,20 @@ const SandpackIframe = ({ reloadKey }: { reloadKey: number }) => {
if (keyRef.current === reloadKey) return; if (keyRef.current === reloadKey) return;
keyRef.current = reloadKey; keyRef.current = reloadKey;
if (!iframeRef.current || !app || !selectedAppId) return; if (!selectedAppId) return;
const sandboxConfig = await IpcClient.getInstance().getAppSandboxConfig( const sandboxConfig = await IpcClient.getInstance().getAppSandboxConfig(
selectedAppId selectedAppId
); );
if (!iframeRef.current || !app) return;
const sandpackConfig: SandboxSetup = mapSandpackConfig(sandboxConfig); const sandpackConfig: SandboxSetup = mapSandpackConfig(sandboxConfig);
const options: ClientOptions = { const options: ClientOptions = {
// bundlerURL: "https://sandpack.dyad.sh/", bundlerURL: "http://localhost:31111",
showOpenInCodeSandbox: false, showOpenInCodeSandbox: false,
showLoadingScreen: true, showLoadingScreen: true,
showErrorScreen: true, showErrorScreen: true,
externalResources: [ externalResources: ["https://cdn.tailwindcss.com"],
// "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
"https://cdn.tailwindcss.com",
],
}; };
let client: SandpackClient | undefined; let client: SandpackClient | undefined;

View File

@@ -31,25 +31,103 @@ import {
import { ALLOWED_ENV_VARS } from "../../constants/models"; import { ALLOWED_ENV_VARS } from "../../constants/models";
import { getEnvVar } from "../utils/read_env"; import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings"; 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({ async function executeApp({
appPath, appPath,
appId, appId,
event, event, // Keep event for local-node case
}: { }: {
appPath: string; appPath: string;
appId: number; appId: number;
event: Electron.IpcMainInvokeEvent; event: Electron.IpcMainInvokeEvent;
}): Promise<void> { }): Promise<void> {
// Return type is void, communication happens via event.sender.send
const settings = readSettings(); const settings = readSettings();
if (settings.runtimeMode === "web-sandbox") { if (settings.runtimeMode === "web-sandbox") {
// 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; 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") { 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 }); await executeAppLocalNode({ appPath, appId, event });
return; return;
} }
throw new Error("Invalid runtime mode"); throw new Error(`Invalid runtime mode: ${settings.runtimeMode}`);
} }
async function executeAppLocalNode({ async function executeAppLocalNode({
appPath, appPath,
@@ -350,19 +428,23 @@ export function registerAppHandlers() {
ipcMain.handle("stop-app", async (_, { appId }: { appId: number }) => { ipcMain.handle("stop-app", async (_, { appId }: { appId: number }) => {
console.log( 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 () => { return withLock(appId, async () => {
const appInfo = runningApps.get(appId); const appInfo = runningApps.get(appId);
if (!appInfo) { if (!appInfo) {
console.log( 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 { return {
success: true, 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, event: Electron.IpcMainInvokeEvent,
{ appId }: { appId: number } { appId }: { appId: number }
) => { ) => {
// Static server worker is NOT terminated here anymore
return withLock(appId, async () => { return withLock(appId, async () => {
try { try {
// First stop the app if it's running // First stop the app if it's running
@@ -413,7 +497,7 @@ export function registerAppHandlers() {
if (appInfo) { if (appInfo) {
const { process, processId } = appInfo; const { process, processId } = appInfo;
console.log( 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 // Use the killProcess utility to stop the process
@@ -421,6 +505,10 @@ export function registerAppHandlers() {
// Remove from running apps // Remove from running apps
runningApps.delete(appId); runningApps.delete(appId);
} else {
console.log(
`App ${appId} not running in local-node mode, proceeding to start.`
);
} }
// Now start the app again // Now start the app again
@@ -433,9 +521,11 @@ export function registerAppHandlers() {
} }
const appPath = getDyadAppPath(app.path); 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 }; return { success: true };
} catch (error) { } catch (error) {
@@ -717,6 +807,8 @@ export function registerAppHandlers() {
); );
ipcMain.handle("delete-app", async (_, { appId }: { appId: number }) => { ipcMain.handle("delete-app", async (_, { appId }: { appId: number }) => {
// Static server worker is NOT terminated here anymore
return withLock(appId, async () => { return withLock(appId, async () => {
// Check if app exists // Check if app exists
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
@@ -731,10 +823,14 @@ export function registerAppHandlers() {
if (runningApps.has(appId)) { if (runningApps.has(appId)) {
const appInfo = runningApps.get(appId)!; const appInfo = runningApps.get(appId)!;
try { try {
console.log(`Stopping local-node app ${appId} before deletion.`); // Adjusted log
await killProcess(appInfo.process); await killProcess(appInfo.process);
runningApps.delete(appId); runningApps.delete(appId);
} catch (error: any) { } 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 // Continue with deletion even if stopping fails
} }
} }
@@ -876,6 +972,14 @@ export function registerAppHandlers() {
); );
ipcMain.handle("reset-all", async () => { 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 // Stop all running apps first
const runningAppIds = Array.from(runningApps.keys()); const runningAppIds = Array.from(runningApps.keys());
for (const appId of runningAppIds) { for (const appId of runningAppIds) {

View File

@@ -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