fix: resume campaign progress safely

This commit is contained in:
2026-07-29 01:13:57 +09:00
parent 90de2a3559
commit 6f2b6e42a4
31 changed files with 9354 additions and 148 deletions

View 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));
}