|
| 1 | +import { spawn } from "node:child_process"; |
| 2 | +import { access, mkdtemp, rm } from "node:fs/promises"; |
| 3 | +import net from "node:net"; |
| 4 | +import os from "node:os"; |
| 5 | +import path from "node:path"; |
| 6 | +import { fileURLToPath } from "node:url"; |
| 7 | + |
| 8 | +const DEFAULT_HOST = "127.0.0.1"; |
| 9 | +const DEFAULT_SERVICE = "github"; |
| 10 | +const DEFAULT_READY_TIMEOUT_MS = 15_000; |
| 11 | +const DEFAULT_READY_INTERVAL_MS = 100; |
| 12 | +const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; |
| 13 | +const DEFAULT_LOG_LIMIT_BYTES = 64 * 1024; |
| 14 | + |
| 15 | +const harnessDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 16 | +export const repoRoot = path.resolve(harnessDir, "../.."); |
| 17 | + |
| 18 | +export async function allocatePort(host = DEFAULT_HOST) { |
| 19 | + return new Promise((resolve, reject) => { |
| 20 | + const server = net.createServer(); |
| 21 | + server.once("error", reject); |
| 22 | + server.listen(0, host, () => { |
| 23 | + const address = server.address(); |
| 24 | + server.close((err) => { |
| 25 | + if (err) { |
| 26 | + reject(err); |
| 27 | + return; |
| 28 | + } |
| 29 | + if (!address || typeof address === "string") { |
| 30 | + reject(new Error("Port allocation did not return a TCP address")); |
| 31 | + return; |
| 32 | + } |
| 33 | + resolve(address.port); |
| 34 | + }); |
| 35 | + }); |
| 36 | + }); |
| 37 | +} |
| 38 | + |
| 39 | +export function selectRuntime(env = process.env) { |
| 40 | + if (env.EMULATE_SDK_RUNTIME) return env.EMULATE_SDK_RUNTIME; |
| 41 | + if (env.EMULATE_TARGET_URL) return "external"; |
| 42 | + return "typescript"; |
| 43 | +} |
| 44 | + |
| 45 | +export async function startRuntime(options = {}) { |
| 46 | + const env = { ...process.env, ...options.env }; |
| 47 | + const runtime = options.runtime ?? selectRuntime(env); |
| 48 | + if (runtime === "external") { |
| 49 | + const target = connectRuntime({ |
| 50 | + readinessPath: options.readinessPath ?? env.EMULATE_SDK_READY_PATH, |
| 51 | + url: options.url ?? options.targetUrl ?? env.EMULATE_TARGET_URL, |
| 52 | + runtime, |
| 53 | + service: options.service, |
| 54 | + }); |
| 55 | + if (target.readyUrl) { |
| 56 | + await waitForHttp(target.readyUrl, { |
| 57 | + timeoutMs: options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS, |
| 58 | + intervalMs: options.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS, |
| 59 | + requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, |
| 60 | + }); |
| 61 | + } |
| 62 | + return target; |
| 63 | + } |
| 64 | + |
| 65 | + const service = options.service ?? DEFAULT_SERVICE; |
| 66 | + const host = options.host ?? DEFAULT_HOST; |
| 67 | + const port = options.port ?? (await allocatePort(host)); |
| 68 | + const baseUrl = normalizeBaseUrl(options.baseUrl ?? `http://${host}:${port}`); |
| 69 | + const spec = await runtimeCommand({ ...options, baseUrl, env, host, port, runtime, service }); |
| 70 | + const child = spawn(spec.command, spec.args, { |
| 71 | + cwd: spec.cwd, |
| 72 | + env: { |
| 73 | + ...env, |
| 74 | + ...options.processEnv, |
| 75 | + FORCE_COLOR: "0", |
| 76 | + NO_COLOR: "1", |
| 77 | + }, |
| 78 | + stdio: ["ignore", "pipe", "pipe"], |
| 79 | + }); |
| 80 | + const logs = captureLogs(child, options.logLimitBytes ?? DEFAULT_LOG_LIMIT_BYTES); |
| 81 | + const exit = waitForExit(child); |
| 82 | + let stopped = false; |
| 83 | + const stop = async () => { |
| 84 | + if (stopped) return; |
| 85 | + stopped = true; |
| 86 | + try { |
| 87 | + await stopChild(child, exit, options.stopTimeoutMs); |
| 88 | + } finally { |
| 89 | + await spec.cleanup?.(); |
| 90 | + } |
| 91 | + }; |
| 92 | + const readinessPath = options.readinessPath ?? env.EMULATE_SDK_READY_PATH ?? spec.readinessPath; |
| 93 | + const readyUrl = new URL(readinessPath, `${baseUrl}/`).toString(); |
| 94 | + |
| 95 | + try { |
| 96 | + await Promise.race([ |
| 97 | + waitForHttp(readyUrl, { |
| 98 | + timeoutMs: options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS, |
| 99 | + intervalMs: options.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS, |
| 100 | + requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, |
| 101 | + }), |
| 102 | + exit.then((result) => { |
| 103 | + throw runtimeExitError(spec.label, result); |
| 104 | + }), |
| 105 | + ]); |
| 106 | + } catch (err) { |
| 107 | + await stop(); |
| 108 | + throw withRuntimeLogs(err, spec.label, logs); |
| 109 | + } |
| 110 | + |
| 111 | + return { |
| 112 | + baseUrl, |
| 113 | + child, |
| 114 | + logs, |
| 115 | + port, |
| 116 | + readyUrl, |
| 117 | + runtime, |
| 118 | + service, |
| 119 | + stop, |
| 120 | + }; |
| 121 | +} |
| 122 | + |
| 123 | +export function connectRuntime(options = {}) { |
| 124 | + const baseUrl = normalizeBaseUrl(options.url ?? options.targetUrl); |
| 125 | + if (!baseUrl) { |
| 126 | + throw new Error("External runtime selected but no target URL was provided"); |
| 127 | + } |
| 128 | + return { |
| 129 | + baseUrl, |
| 130 | + child: null, |
| 131 | + logs: emptyLogs(), |
| 132 | + port: null, |
| 133 | + readyUrl: options.readinessPath ? new URL(options.readinessPath, `${baseUrl}/`).toString() : null, |
| 134 | + runtime: options.runtime ?? "external", |
| 135 | + service: options.service ?? DEFAULT_SERVICE, |
| 136 | + stop: async () => {}, |
| 137 | + }; |
| 138 | +} |
| 139 | + |
| 140 | +export async function waitForHttp(url, options = {}) { |
| 141 | + const timeoutMs = options.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS; |
| 142 | + const intervalMs = options.intervalMs ?? DEFAULT_READY_INTERVAL_MS; |
| 143 | + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; |
| 144 | + const acceptStatus = options.acceptStatus ?? ((status) => status >= 200 && status < 500); |
| 145 | + const deadline = Date.now() + timeoutMs; |
| 146 | + let lastError = null; |
| 147 | + |
| 148 | + while (Date.now() <= deadline) { |
| 149 | + try { |
| 150 | + const result = await fetchWithTimeout(url, requestTimeoutMs); |
| 151 | + if (acceptStatus(result.status, result.body)) { |
| 152 | + return result; |
| 153 | + } |
| 154 | + lastError = new Error(`GET ${url} returned ${result.status}`); |
| 155 | + } catch (err) { |
| 156 | + lastError = err; |
| 157 | + } |
| 158 | + |
| 159 | + const remaining = deadline - Date.now(); |
| 160 | + if (remaining <= 0) break; |
| 161 | + await sleep(Math.min(intervalMs, remaining)); |
| 162 | + } |
| 163 | + |
| 164 | + const reason = lastError ? ` Last error: ${formatError(lastError)}` : ""; |
| 165 | + throw new Error(`Timed out waiting for ${url}.${reason}`); |
| 166 | +} |
| 167 | + |
| 168 | +async function runtimeCommand(options) { |
| 169 | + if (options.runtime === "typescript") return typeScriptCommand(options); |
| 170 | + if (options.runtime === "go") return goCommand(options); |
| 171 | + throw new Error(`Unsupported runtime: ${options.runtime}`); |
| 172 | +} |
| 173 | + |
| 174 | +async function typeScriptCommand(options) { |
| 175 | + const cliPath = |
| 176 | + options.cliPath ?? options.env.EMULATE_TYPESCRIPT_CLI ?? path.join(repoRoot, "packages/emulate/dist/index.js"); |
| 177 | + await assertExecutableFile(cliPath, "TypeScript CLI"); |
| 178 | + const workingDirectory = await runtimeWorkingDirectory(options); |
| 179 | + return { |
| 180 | + args: [ |
| 181 | + cliPath, |
| 182 | + "start", |
| 183 | + "--port", |
| 184 | + String(options.port), |
| 185 | + "--service", |
| 186 | + options.service, |
| 187 | + "--base-url", |
| 188 | + options.baseUrl, |
| 189 | + ], |
| 190 | + command: process.execPath, |
| 191 | + cwd: workingDirectory.cwd, |
| 192 | + cleanup: workingDirectory.cleanup, |
| 193 | + label: "TypeScript runtime", |
| 194 | + readinessPath: "/rate_limit", |
| 195 | + }; |
| 196 | +} |
| 197 | + |
| 198 | +async function goCommand(options) { |
| 199 | + const binary = options.binary ?? options.env.EMULATE_GO_BINARY; |
| 200 | + if (!binary) { |
| 201 | + throw new Error("Go runtime selected but no binary was provided"); |
| 202 | + } |
| 203 | + await assertExecutableFile(binary, "Go runtime binary"); |
| 204 | + const workingDirectory = await runtimeWorkingDirectory(options); |
| 205 | + return { |
| 206 | + args: ["start", "--port", String(options.port), "--service", options.service, "--base-url", options.baseUrl], |
| 207 | + command: binary, |
| 208 | + cwd: workingDirectory.cwd, |
| 209 | + cleanup: workingDirectory.cleanup, |
| 210 | + label: "Go runtime", |
| 211 | + readinessPath: "/_emulate/health", |
| 212 | + }; |
| 213 | +} |
| 214 | + |
| 215 | +async function runtimeWorkingDirectory(options) { |
| 216 | + if (options.cwd) { |
| 217 | + return { cwd: options.cwd, cleanup: null }; |
| 218 | + } |
| 219 | + const cwd = await mkdtemp(path.join(os.tmpdir(), "emulate-sdk-js-")); |
| 220 | + return { |
| 221 | + cwd, |
| 222 | + cleanup: async () => { |
| 223 | + await rm(cwd, { recursive: true, force: true }); |
| 224 | + }, |
| 225 | + }; |
| 226 | +} |
| 227 | + |
| 228 | +async function assertExecutableFile(filePath, label) { |
| 229 | + try { |
| 230 | + await access(filePath); |
| 231 | + } catch { |
| 232 | + throw new Error(`${label} not found at ${filePath}`); |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +function captureLogs(child, maxBytes) { |
| 237 | + const logs = { |
| 238 | + stderr: "", |
| 239 | + stdout: "", |
| 240 | + text() { |
| 241 | + return formatRuntimeLogs(logs); |
| 242 | + }, |
| 243 | + }; |
| 244 | + child.stdout?.on("data", (chunk) => { |
| 245 | + logs.stdout = appendBounded(logs.stdout, chunk, maxBytes); |
| 246 | + }); |
| 247 | + child.stderr?.on("data", (chunk) => { |
| 248 | + logs.stderr = appendBounded(logs.stderr, chunk, maxBytes); |
| 249 | + }); |
| 250 | + return logs; |
| 251 | +} |
| 252 | + |
| 253 | +function emptyLogs() { |
| 254 | + return { |
| 255 | + stderr: "", |
| 256 | + stdout: "", |
| 257 | + text() { |
| 258 | + return formatRuntimeLogs(this); |
| 259 | + }, |
| 260 | + }; |
| 261 | +} |
| 262 | + |
| 263 | +function appendBounded(current, chunk, maxBytes) { |
| 264 | + const next = current + Buffer.from(chunk).toString("utf8"); |
| 265 | + if (Buffer.byteLength(next) <= maxBytes) return next; |
| 266 | + return Buffer.from(next).subarray(-maxBytes).toString("utf8"); |
| 267 | +} |
| 268 | + |
| 269 | +function formatRuntimeLogs(logs) { |
| 270 | + return [ |
| 271 | + "stdout:", |
| 272 | + logs.stdout.trimEnd() || "(empty)", |
| 273 | + "", |
| 274 | + "stderr:", |
| 275 | + logs.stderr.trimEnd() || "(empty)", |
| 276 | + ].join("\n"); |
| 277 | +} |
| 278 | + |
| 279 | +function waitForExit(child) { |
| 280 | + return new Promise((resolve) => { |
| 281 | + child.once("error", (error) => resolve({ error })); |
| 282 | + child.once("exit", (code, signal) => resolve({ code, signal })); |
| 283 | + }); |
| 284 | +} |
| 285 | + |
| 286 | +function runtimeExitError(label, result) { |
| 287 | + if (result.error) { |
| 288 | + return new Error(`${label} failed to start: ${formatError(result.error)}`); |
| 289 | + } |
| 290 | + return new Error(`${label} exited before it became ready with code ${result.code} and signal ${result.signal}`); |
| 291 | +} |
| 292 | + |
| 293 | +async function stopChild(child, exit, timeoutMs = 5_000) { |
| 294 | + if (child.exitCode !== null || child.signalCode !== null) return; |
| 295 | + child.kill("SIGTERM"); |
| 296 | + const result = await Promise.race([exit, sleep(timeoutMs).then(() => null)]); |
| 297 | + if (result) return; |
| 298 | + child.kill("SIGKILL"); |
| 299 | + await exit; |
| 300 | +} |
| 301 | + |
| 302 | +function withRuntimeLogs(err, label, logs) { |
| 303 | + const message = err instanceof Error ? err.message : String(err); |
| 304 | + return new Error(`${label} readiness failed: ${message}\n${logs.text()}`); |
| 305 | +} |
| 306 | + |
| 307 | +async function fetchWithTimeout(url, timeoutMs) { |
| 308 | + const controller = new AbortController(); |
| 309 | + const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 310 | + try { |
| 311 | + const response = await fetch(url, { signal: controller.signal }); |
| 312 | + return { |
| 313 | + status: response.status, |
| 314 | + body: await response.text(), |
| 315 | + }; |
| 316 | + } finally { |
| 317 | + clearTimeout(timer); |
| 318 | + } |
| 319 | +} |
| 320 | + |
| 321 | +function sleep(ms) { |
| 322 | + return new Promise((resolve) => { |
| 323 | + setTimeout(resolve, ms); |
| 324 | + }); |
| 325 | +} |
| 326 | + |
| 327 | +function normalizeBaseUrl(url) { |
| 328 | + if (!url) return ""; |
| 329 | + return String(url).replace(/\/+$/, ""); |
| 330 | +} |
| 331 | + |
| 332 | +function formatError(err) { |
| 333 | + return err instanceof Error ? err.message : String(err); |
| 334 | +} |
0 commit comments