fix: resume campaign progress safely
This commit is contained in:
321
scripts/verification-preview-server.mjs
Normal file
321
scripts/verification-preview-server.mjs
Normal file
@@ -0,0 +1,321 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user