import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; const localHostnames = new Set([ 'localhost', '127.0.0.1', '0.0.0.0', '::1' ]); const defaultStartupTimeoutMs = 45_000; const defaultFetchTimeoutMs = 1_500; const ownedServerStabilityMs = 300; const gracefulShutdownTimeoutMs = 4_000; const forcedShutdownTimeoutMs = 4_000; const maxCapturedOutputLength = 16_000; /** * Starts an owned Vite preview for local URLs. A pre-existing process on the * requested port is deliberately never reused: --strictPort makes ownership * unambiguous and the child must remain alive after the built page responds. * * Explicit non-local URLs are useful for opt-in production verification and * are only probed; they never create a local process. */ export async function startVerificationPreview({ targetUrl, explicitUrl = false, label = 'browser verification', cwd = process.cwd(), startupTimeoutMs = defaultStartupTimeoutMs, fetchTimeoutMs = defaultFetchTimeoutMs }) { const parsed = new URL(targetUrl); if (!localHostnames.has(parsed.hostname)) { if (!explicitUrl) { throw new Error( `${label}: refusing to start a local preview for non-local URL ${targetUrl}` ); } await assertExternalBuildReachable( targetUrl, label, fetchTimeoutMs ); return undefined; } if (!parsed.port) { throw new Error( `${label}: local verification URLs must use an explicit port (${targetUrl}).` ); } const distHtmlPath = path.resolve(cwd, 'dist', 'index.html'); const distHtml = await readFile(distHtmlPath, 'utf8').catch( (error) => { throw new Error( `${label}: ${distHtmlPath} is unavailable. Build the release candidate before running browser verification.`, { cause: error } ); } ); const assetSignature = builtAssetSignature( distHtml, `${label}: local dist` ); const viteCliPath = path.resolve( cwd, 'node_modules', 'vite', 'bin', 'vite.js' ); const child = spawn( process.execPath, [ viteCliPath, 'preview', '--host', '127.0.0.1', '--port', parsed.port, '--strictPort' ], { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true } ); const output = { stdout: '', stderr: '', spawnError: undefined }; child.stdout?.on('data', (chunk) => { output.stdout = appendCapturedOutput( output.stdout, chunk ); }); child.stderr?.on('data', (chunk) => { output.stderr = appendCapturedOutput( output.stderr, chunk ); }); child.on('error', (error) => { output.spawnError = error; }); const deadline = Date.now() + startupTimeoutMs; while (Date.now() < deadline) { if (processHasExited(child)) { throw previewStartupError( label, targetUrl, child, output ); } if ( await builtPageResponds( targetUrl, assetSignature, fetchTimeoutMs ) ) { // A process already occupying the port can answer the first probe while // Vite is still reporting EADDRINUSE. Requiring our strict-port child to // survive a short stabilization window closes that race. await delay(ownedServerStabilityMs); if (processHasExited(child)) { throw previewStartupError( label, targetUrl, child, output ); } return child; } await delay(200); } await stopVerificationPreview(child); throw new Error( `${label}: owned Vite preview did not serve the current dist at ${targetUrl}.${formatCapturedOutput(output)}` ); } export async function stopVerificationPreview(child) { if (!child || processHasExited(child)) { return; } child.kill('SIGTERM'); if ( await waitForProcessExit( child, gracefulShutdownTimeoutMs ) ) { return; } child.kill('SIGKILL'); if ( await waitForProcessExit( child, forcedShutdownTimeoutMs ) ) { return; } throw new Error( `Owned Vite preview process ${child.pid ?? 'unknown'} did not exit after forced shutdown.` ); } async function assertExternalBuildReachable( url, label, fetchTimeoutMs ) { let response; try { response = await fetch(url, { signal: AbortSignal.timeout(fetchTimeoutMs) }); } catch (error) { throw new Error( `${label}: no external server responded at ${url} within ${fetchTimeoutMs}ms.`, { cause: error } ); } if (!response.ok) { throw new Error( `${label}: external server returned HTTP ${response.status} at ${url}.` ); } const html = await response.text(); builtAssetSignature(html, `${label}: external page`); } async function builtPageResponds( url, assetSignature, fetchTimeoutMs ) { try { const response = await fetch(url, { signal: AbortSignal.timeout(fetchTimeoutMs) }); if (!response.ok) { return false; } const html = await response.text(); return ( !html.includes('/src/') && html.includes(assetSignature) ); } catch { return false; } } function builtAssetSignature(html, label) { if (html.includes('/src/')) { throw new Error( `${label} points at source modules instead of a built release.` ); } const match = html.match( /]*\bsrc=["']([^"']*\/assets\/[^"']+\.js)["'][^>]*>/i ); if (!match) { throw new Error( `${label} does not contain a production JavaScript asset.` ); } return match[1]; } function previewStartupError( label, targetUrl, child, output ) { const cause = output.spawnError ? `\nSpawn error: ${output.spawnError.message}` : ''; return new Error( `${label}: owned Vite preview exited before becoming ready at ${targetUrl} ` + `(exit ${child.exitCode ?? child.signalCode ?? 'unknown'}).` + cause + formatCapturedOutput(output) ); } function formatCapturedOutput(output) { const sections = []; if (output.stdout.trim()) { sections.push(`stdout:\n${output.stdout.trim()}`); } if (output.stderr.trim()) { sections.push(`stderr:\n${output.stderr.trim()}`); } return sections.length > 0 ? `\n${sections.join('\n')}` : ''; } function appendCapturedOutput(current, chunk) { const appended = current + chunk.toString(); return appended.length <= maxCapturedOutputLength ? appended : appended.slice(-maxCapturedOutputLength); } function processHasExited(child) { return ( child.exitCode !== null || child.signalCode !== null ); } function waitForProcessExit(child, timeoutMs) { if (processHasExited(child)) { return Promise.resolve(true); } return new Promise((resolve) => { let settled = false; const finish = (exited) => { if (settled) { return; } settled = true; clearTimeout(timeout); child.off('exit', onExit); resolve(exited); }; const onExit = () => finish(true); const timeout = setTimeout( () => finish(processHasExited(child)), timeoutMs ); child.once('exit', onExit); if (processHasExited(child)) { finish(true); } }); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }