import { execSync, spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
copyFileSync,
readFileSync,
readdirSync,
statSync,
rmSync,
} from "node:fs";
import { dirname, join, resolve, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DOCKER_DIR = resolve(__dirname, "../docker");
const PACKAGES_DIR = resolve(__dirname, "../../..");
const BUILD_DIR = resolve(__dirname, "../.build");
// Persistent npm cache shared across all container runs
const NPM_CACHE_DIR = resolve(__dirname, "../.npm-cache");
// Persistent pnpm store shared across all container runs
const PNPM_CACHE_DIR = resolve(__dirname, "../.pnpm-store");
// Persistent bun cache shared across bun container runs
const BUN_CACHE_DIR = resolve(__dirname, "../.bun-cache");
/**
* Recursively collect files from a directory.
* Returns a sorted object mapping relative paths to file contents.
* Skips node_modules and build output directories to avoid OOM.
*/
const SKIP_DIRS = new Set([
"node_modules",
".react-server",
".bun",
".deno",
".vercel",
".netlify",
".cloudflare",
".wrangler",
]);
const SKIP_FILES = new Set(["deno.lock"]);
export function collectFiles(dir, base = dir) {
const result = {};
if (!existsSync(dir)) return result;
for (const entry of readdirSync(dir).toSorted()) {
const fullPath = join(dir, entry);
const relPath = relative(base, fullPath);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
if (SKIP_DIRS.has(entry)) continue;
Object.assign(result, collectFiles(fullPath, base));
} else {
if (SKIP_FILES.has(entry)) continue;
let content = readFileSync(fullPath, "utf-8");
// Normalize platform-specific git config options so snapshots
// generated on macOS (case-insensitive FS) match Linux output.
if (relPath === ".git/config") {
content = content.replace(/^\s*ignorecase\s*=.*\n/gm, "");
}
result[relPath] = content;
}
}
return result;
}
/**
* Run a command and return stdout. Throws on non-zero exit.
*/
export function exec(cmd, options = {}) {
return execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
...options,
}).trim();
}
/**
* Pack workspace packages into tarballs and place them in the build directory.
* Uses pnpm pack to correctly resolve workspace:* protocol references.
*/
export function packPackages() {
const packStart = performance.now();
mkdirSync(BUILD_DIR, { recursive: true });
// Always copy the entrypoint script so changes are picked up without REPACK
copyFileSync(
join(DOCKER_DIR, "entrypoint.sh"),
join(BUILD_DIR, "entrypoint.sh")
);
if (
existsSync(join(BUILD_DIR, "react-server.tgz")) &&
existsSync(join(BUILD_DIR, "create-react-server.tgz")) &&
existsSync(join(BUILD_DIR, "rsc.tgz")) &&
!process.env.REPACK
) {
console.log("Packages already packed (set REPACK=1 to force re-pack).");
return;
}
console.log("Packing @lazarv/rsc...");
const rscDir = join(PACKAGES_DIR, "rsc");
const rscOutput = exec("pnpm pack --pack-destination /tmp", {
cwd: rscDir,
});
const rscTarball = rscOutput.split("\n").pop().trim();
copyFileSync(rscTarball, join(BUILD_DIR, "rsc.tgz"));
console.log("Packing @lazarv/react-server...");
const reactServerDir = join(PACKAGES_DIR, "react-server");
const rsOutput = exec("pnpm pack --pack-destination /tmp", {
cwd: reactServerDir,
});
const rsTarball = rsOutput.split("\n").pop().trim();
copyFileSync(rsTarball, join(BUILD_DIR, "react-server.tgz"));
console.log("Packing @lazarv/create-react-server...");
const createDir = join(PACKAGES_DIR, "create-react-server");
const csOutput = exec("pnpm pack --pack-destination /tmp", {
cwd: createDir,
});
const csTarball = csOutput.split("\n").pop().trim();
copyFileSync(csTarball, join(BUILD_DIR, "create-react-server.tgz"));
console.log("Packages packed successfully.");
const packElapsed = ((performance.now() - packStart) / 1000).toFixed(1);
console.log(`TIMING pack ${packElapsed}s`);
}
/**
* Build a Docker image for the given runtime.
*/
export function buildImage(runtime) {
const buildStart = performance.now();
const dockerfile = join(DOCKER_DIR, `Dockerfile.${runtime}`);
if (!existsSync(dockerfile)) {
throw new Error(`Dockerfile not found: ${dockerfile}`);
}
const tag = `create-react-server-test-${runtime}`;
console.log(`Building Docker image: ${tag}...`);
execSync(`docker build -t ${tag} -f ${dockerfile} .`, {
cwd: BUILD_DIR,
stdio: "inherit",
timeout: 600_000, // 10 minutes
});
console.log(`Docker image built: ${tag}`);
const buildElapsed = ((performance.now() - buildStart) / 1000).toFixed(1);
console.log(`TIMING docker-build-${runtime} ${buildElapsed}s`);
return tag;
}
/**
* Track running Docker container IDs so we can clean them up on process exit
* (e.g. when the user presses Ctrl+C and --rm doesn't fire).
*/
const runningContainers = new Set();
function cleanupContainers() {
for (const id of runningContainers) {
try {
execSync(`docker rm -f ${id}`, { stdio: "ignore", timeout: 10_000 });
} catch {
// best-effort
}
}
runningContainers.clear();
}
process.on("exit", cleanupContainers);
process.on("SIGINT", () => {
cleanupContainers();
process.exit(130);
});
process.on("SIGTERM", () => {
cleanupContainers();
process.exit(143);
});
/**
* Run a test inside a Docker container.
* Mounts a host tmp directory as /output in the container so we can read
* the generated project files directly for snapshot testing.
* Returns { exitCode, stdout, stderr, passed, files, outputDir }
*/
export function runTest(
runtime,
preset,
mode = "all",
{ portOffset = 0, pkgMgr = "npm" } = {}
) {
const runStart = performance.now();
const tag = `create-react-server-test-${runtime}`;
// Use high port range (10000+) to avoid collisions with local services