rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
231 lines (230 loc) • 10.9 kB
JavaScript
import debug from "debug";
import { setTimeout as sleep } from "node:timers/promises";
import { $, $sh } from "../../lib/$.mjs";
import { poll } from "./poll.mjs";
const DEV_SERVER_CHECK_TIMEOUT = process.env.RWSDK_DEV_SERVER_CHECK_TIMEOUT
? parseInt(process.env.RWSDK_DEV_SERVER_CHECK_TIMEOUT, 10)
: 5 * 60 * 1000;
const log = debug("rwsdk:e2e:dev");
/**
* Run the local development server and return the URL
*/
export async function runDevServer(packageManager = "pnpm", cwd) {
console.log("🚀 Starting development server...");
// Function to stop the dev server - defined early so we can use it in error handling
let devProcess = null;
let isErrorExpected = false;
const stopDev = async () => {
isErrorExpected = true;
if (!devProcess || !devProcess.pid) {
log("No dev process to stop or PID is missing");
return;
}
console.log("Stopping development server...");
if (process.platform === "win32") {
try {
await $sh(`taskkill /pid ${devProcess.pid} /f /t`);
}
catch (err) {
log("Failed to kill process tree with taskkill:", err);
}
}
else {
// On Unix-like systems, we kill the entire process group by sending a signal
// to the negative PID. This is the equivalent of the `/t` flag for `taskkill` on Windows.
// This relies on `detached: true` being set in the execa options, which makes
// the child process the leader of a new process group.
try {
process.kill(-devProcess.pid, "SIGKILL");
}
catch (e) {
log("Failed to kill process group. This may happen if the process already exited. %O", e);
}
}
await devProcess.catch(() => {
// We expect this promise to reject when the process is killed,
// so we catch and ignore the error.
});
console.log("Development server stopped");
};
try {
// Check if we're in CI mode
const inCIMode = process.env.CI === "true" || process.env.CI === "1";
// Start dev server with stdout pipe to capture URL
// Create environment variables object
const env = {
...process.env,
NODE_ENV: "development",
};
// Disable colors when running in CI mode to make URL parsing more reliable
if (inCIMode) {
log("Running in CI mode, disabling colors for dev server output");
env.NO_COLOR = "1";
env.FORCE_COLOR = "0";
}
// Map package manager names to actual commands
const getPackageManagerCommand = (pm) => {
switch (pm) {
case "yarn-classic":
return "yarn";
default:
return pm;
}
};
const pm = getPackageManagerCommand(packageManager);
// Use the provided cwd if available
devProcess = $(pm, ["run", "dev"], {
all: true,
// On Windows, detached: true prevents stdio from being captured.
// On Unix, it's required for reliable cleanup by killing the process group.
detached: process.platform !== "win32",
cleanup: true, // Let execa handle cleanup
forceKillAfterTimeout: 2000, // Force kill if graceful shutdown fails
cwd: cwd || process.cwd(), // Use provided directory or current directory
env, // Pass the updated environment variables
stdio: "pipe", // Ensure streams are piped
});
devProcess.catch((error) => {
if (!isErrorExpected) {
// Don't re-throw. The error will be handled gracefully by the polling
// logic in `waitForUrl`, which will detect that the process has exited.
// Re-throwing here would cause an unhandled promise rejection.
log("Dev server process exited unexpectedly: %O", error);
}
});
log("Development server process spawned in directory: %s", cwd || process.cwd());
// Store chunks to parse the URL
let url = "";
let allOutput = "";
// Listen for all output to get the URL
const handleOutput = (data, source) => {
const output = data.toString();
// Raw output for debugging
process.stdout.write(`[dev:${source}] ` + output);
allOutput += output; // Accumulate all output
if (!url) {
// Multiple patterns to catch different package manager outputs
const patterns = [
// Standard Vite output: "Local: http://localhost:5173/"
/Local:\s*(?:\u001b\[\d+m)?(https?:\/\/localhost:\d+)/i,
// Alternative Vite output: "➜ Local: http://localhost:5173/"
/[➜→]\s*Local:\s*(?:\u001b\[\d+m)?(https?:\/\/localhost:\d+)/i,
// Unicode-safe arrow pattern
/[\u27A1\u2192\u279C]\s*Local:\s*(?:\u001b\[\d+m)?(https?:\/\/localhost:\d+)/i,
// Direct URL pattern: "http://localhost:5173"
/(https?:\/\/localhost:\d+)/i,
// Port-only pattern: "localhost:5173"
/localhost:(\d+)/i,
// Server ready messages
/server.*ready.*localhost:(\d+)/i,
/dev server.*localhost:(\d+)/i,
];
for (const pattern of patterns) {
const match = output.match(pattern);
if (match) {
if (match[1] && match[1].startsWith("http")) {
url = match[1];
break;
}
else if (match[1] && /^\d+$/.test(match[1])) {
url = `http://localhost:${match[1]}`;
break;
}
}
}
}
};
// Listen to all possible output streams
log("Setting up stream listeners. Available streams: all=%s, stdout=%s, stderr=%s", !!devProcess.all, !!devProcess.stdout, !!devProcess.stderr);
devProcess.all?.on("data", (data) => handleOutput(data, "all"));
devProcess.stdout?.on("data", (data) => handleOutput(data, "stdout"));
devProcess.stderr?.on("data", (data) => handleOutput(data, "stderr"));
// Also try listening to the raw process output
if (devProcess.child) {
log("Setting up child process stream listeners");
devProcess.child.on("spawn", () => {
log("Child process spawned successfully.");
});
devProcess.child.on("error", (err) => {
log("Child process error: %O", err);
});
devProcess.child.on("exit", (code, signal) => {
log("Child process exited with code %s and signal %s", code, signal);
});
devProcess.child.stdout?.on("data", (data) => handleOutput(data, "child.stdout"));
devProcess.child.stderr?.on("data", (data) => handleOutput(data, "child.stderr"));
}
// Wait for URL with timeout
const waitForUrl = async () => {
const start = Date.now();
const timeout = 60000; // 60 seconds
while (Date.now() - start < timeout) {
if (url) {
return url;
}
// Fallback: check accumulated output if stream listeners aren't working
if (!url && allOutput) {
log("Checking accumulated output for URL patterns: %s", allOutput.replace(/\n/g, "\\n"));
const patterns = [
/Local:\s*(?:\u001b\[\d+m)?(https?:\/\/localhost:\d+)/i,
/[➜→]\s*Local:\s*(?:\u001b\[\d+m)?(https?:\/\/localhost:\d+)/i,
/[\u27A1\u2192\u279C]\s*Local:\s*(?:\u001b\[\d+m)?(https?:\/\/localhost:\d+)/i,
/(https?:\/\/localhost:\d+)/i,
/localhost:(\d+)/i,
];
for (const pattern of patterns) {
const match = allOutput.match(pattern);
if (match) {
if (match[1] && match[1].startsWith("http")) {
url = match[1];
log("Found URL in accumulated output with pattern %s: %s", pattern.source, url);
return url;
}
else if (match[1] && /^\d+$/.test(match[1])) {
url = `http://localhost:${match[1]}`;
log("Found URL in accumulated output with port pattern %s: %s", pattern.source, url);
return url;
}
}
}
}
// Check if the process is still running
if (devProcess.exitCode !== null) {
log("ERROR: Development server process exited with code %d. Final output: %s", devProcess.exitCode, allOutput);
throw new Error(`Development server process exited with code ${devProcess.exitCode}`);
}
await sleep(500); // Check every 500ms
}
log("ERROR: Timed out waiting for dev server URL. Final accumulated output: %s", allOutput);
throw new Error("Timed out waiting for dev server URL");
};
// Wait for the URL
const serverUrl = await waitForUrl();
console.log(`✅ Development server started at ${serverUrl}`);
// Poll the URL to ensure it's live before proceeding
await poll(async () => {
try {
const response = await fetch(serverUrl, {
signal: AbortSignal.timeout(1000),
});
// We consider any response (even 4xx or 5xx) as success,
// as it means the worker is routable.
return response.status > 0;
}
catch (e) {
return false;
}
}, {
timeout: DEV_SERVER_CHECK_TIMEOUT,
});
return { url: serverUrl, stopDev };
}
catch (error) {
// Make sure to try to stop the server on error
log("Error during dev server startup: %O", error);
await stopDev().catch((e) => {
log("Failed to stop dev server during error handling: %O", e);
});
throw error;
}
}