322 lines
7.3 KiB
JavaScript
322 lines
7.3 KiB
JavaScript
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(
|
|
/<script\b[^>]*\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));
|
|
}
|