import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { openLoopbackUrl } from './open-artifact.mjs'; import { resolveOutputPath } from '../renderers/shared/output-path.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const cliPath = path.join(here, 'archify.mjs'); const loopbackHost = '127.0.0.1'; const defaultDebounceMs = 400; const defaultPollMs = 800; const defaultStopGraceMs = 3000; const defaultStopKillMs = 750; const diagramTypes = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']); function sha256(value) { return createHash('sha256').update(value).digest('hex'); } function sourceDigest(inputPath) { try { const bytes = fs.readFileSync(inputPath); return { hash: sha256(bytes), bytes, missing: false }; } catch (error) { return { hash: `unreadable:${error.code || 'unknown'}`, bytes: null, missing: true }; } } function initialAuthoredOutput(inputPath) { try { const source = JSON.parse(fs.readFileSync(inputPath, 'utf8')); if (typeof source?.meta?.output === 'string' && source.meta.output) { return source.meta.output; } } catch { // An invalid initial source still gets a status shell. Its output target is // fixed to the same fallback that `deliver` would use after repair. } return undefined; } function previewPage() { return ` Archify Live Preview
Archify Preview Checking · generation 1
Waiting for the first verified diagram. Invalid input will stay here with an exact diagnostic.
`; } function compactMessage(value) { let text = String(value || 'Preview build failed without a diagnostic.').trim(); const lines = text.split(/\r?\n/); const errorLine = lines.findIndex((line) => /^Error:\s/.test(line)); if (errorLine > 0) text = lines.slice(errorLine).join('\n'); const relevant = text.split(/\r?\n/); const stackLine = relevant.findIndex((line, index) => index > 0 && /^\s*at\s/.test(line)); if (stackLine > 0) text = relevant.slice(0, stackLine).join('\n'); return text.length > 6000 ? `${text.slice(0, 6000)}\n… diagnostic truncated` : text; } function redactDiagnostic(value, paths) { let text = compactMessage(value); for (const [absolutePath, replacement] of paths) { if (!absolutePath) continue; text = text.split(absolutePath).join(replacement); } return text; } function safeJson(value) { return JSON.stringify(value).replace(/ { resolveClosed = resolve; }); function publicState() { return JSON.parse(JSON.stringify(state)); } function sendState(res) { res.write(`event: state\ndata: ${safeJson(publicState())}\n\n`); } function broadcast() { for (const res of clients) sendState(res); } const page = Buffer.from(previewPage()); const server = http.createServer((req, res) => { const expectedHost = `${loopbackHost}:${port}`; if (req.headers.host !== expectedHost) { res.writeHead(403, responseHeaders('text/plain; charset=utf-8')); res.end('Forbidden host'); return; } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { ...responseHeaders('text/plain; charset=utf-8'), Allow: 'GET, HEAD' }); res.end('Method not allowed'); return; } let url; try { url = new URL(req.url, `http://${expectedHost}`); } catch { res.writeHead(400, responseHeaders('text/plain; charset=utf-8')); res.end('Bad request'); return; } if (url.pathname === '/') { res.writeHead(200, { ...responseHeaders('text/html; charset=utf-8'), 'Content-Security-Policy': "default-src 'none'; frame-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'", 'Content-Length': page.byteLength, }); if (req.method === 'HEAD') res.end(); else res.end(page); return; } if (url.pathname === '/state') { const body = Buffer.from(`${safeJson(publicState())}\n`); res.writeHead(200, { ...responseHeaders('application/json; charset=utf-8'), 'Content-Length': body.byteLength }); if (req.method === 'HEAD') res.end(); else res.end(body); return; } if (url.pathname === '/artifact.html') { if (!artifactBuffer) { res.writeHead(404, responseHeaders('text/plain; charset=utf-8')); res.end('No verified artifact yet'); return; } res.writeHead(200, { ...responseHeaders('text/html; charset=utf-8'), 'Content-Length': artifactBuffer.byteLength }); if (req.method === 'HEAD') res.end(); else res.end(artifactBuffer); return; } if (url.pathname === '/events' && req.method === 'GET') { res.writeHead(200, { ...responseHeaders('text/event-stream; charset=utf-8'), Connection: 'keep-alive', }); res.write('retry: 1000\n\n'); clients.add(res); sendState(res); req.on('close', () => clients.delete(res)); return; } res.writeHead(404, responseHeaders('text/plain; charset=utf-8')); res.end('Not found'); }); try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, loopbackHost, () => { server.off('error', reject); port = server.address().port; resolve(); }); }); } catch (error) { try { server.close(); } catch {} fs.rmSync(stagingDirectory, { recursive: true, force: true }); throw error; } const url = `http://${loopbackHost}:${port}/`; function finishStop() { if (stopped || child || !serverClosed) return; stopped = true; clearTimeout(debounceTimer); clearInterval(pollTimer); clearTimeout(stopGraceTimer); clearTimeout(stopKillTimer); try { fs.rmSync(stagingDirectory, { recursive: true, force: true }); } finally { resolveClosed(); } } function signalActiveChild(signal) { if (!child || child.exitCode !== null || child.signalCode !== null) return; try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal); else child.kill(signal); } catch (error) { if (error.code === 'ESRCH') return; try { child.kill(signal); } catch {} } } function closeServer() { if (serverClosing) return; serverClosing = true; for (const res of clients) res.end(); clients.clear(); server.close(() => { serverClosed = true; finishStop(); }); server.closeIdleConnections?.(); } function startBoundedChildDrain() { if (!child || stopGraceTimer || stopKillTimer) return; stopGraceTimer = setTimeout(() => { stopGraceTimer = undefined; if (!child) return finishStop(); signalActiveChild('SIGTERM'); stopKillTimer = setTimeout(() => { stopKillTimer = undefined; signalActiveChild('SIGKILL'); }, stopKillMs); }, stopGraceMs); } async function stop({ force = false } = {}) { if (!stopping) { stopping = true; clearTimeout(debounceTimer); clearInterval(pollTimer); watcher?.close(); closeServer(); } if (child && force) { clearTimeout(stopGraceTimer); clearTimeout(stopKillTimer); stopGraceTimer = undefined; stopKillTimer = undefined; signalActiveChild('SIGKILL'); } else if (child) { startBoundedChildDrain(); } else { finishStop(); } return closed; } function publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath) { const repairDetails = receipt?.diagnostics ?.slice(0, 12) .map((entry) => { const fix = entry.supportedFixes?.length ? `\nFix: ${entry.supportedFixes.join('; ')}` : ''; return `[${entry.code}] ${entry.message}${fix}`; }) || []; const checkerDetails = receipt?.checker?.checks ?.filter((check) => !check.ok) .flatMap((check) => check.details || []) .filter(Boolean) .slice(0, 12) || []; const diagnostic = [ receipt?.error, ...(repairDetails.length ? repairDetails : checkerDetails), ].filter(Boolean).join('\n') || stderr || stdout; state.status = 'needs-fix'; state.failure = { stage: receipt?.stage || 'render', message: redactDiagnostic( diagnostic, [ [inputPath, ''], [outputPath, ''], [snapshotPath, ''], [candidatePath, ''], [stagingDirectory, ''], [path.resolve(here, '..'), ''], [path.resolve(options.cwd || process.cwd()), ''], ...(options.repoRoot ? [[path.resolve(options.repoRoot), '']] : []), ], ), }; broadcast(); } function commitCandidate(candidatePath, receipt, generationHash) { let candidate; try { candidate = fs.readFileSync(candidatePath); const digest = sha256(candidate); if (digest !== receipt?.artifact?.sha256) { throw new Error('Verified candidate bytes do not match the delivery receipt.'); } resolveOutputPath(outputRequest); const sameArtifact = state.lastVerified?.sha256 === digest; let outputMatches = false; if (sameArtifact) { try { outputMatches = sha256(fs.readFileSync(outputPath)) === digest; } catch {} } const currentSource = sourceDigest(inputPath); if (currentSource.hash !== generationHash) { return { committed: false, supersededBy: currentSource }; } if (!sameArtifact || !outputMatches) fs.renameSync(candidatePath, outputPath); artifactBuffer = candidate; lastGoodSourceHash = generationHash; state.status = 'verified'; if (!sameArtifact) { state.revision += 1; state.lastVerified = { sha256: digest, bytes: candidate.byteLength, checksPassed: receipt.validation.checksPassed, checkCount: receipt.validation.checkCount, compositionProfile: receipt.validation.compositionProfile, compositionStatus: receipt.validation.compositionStatus, }; } state.failure = null; broadcast(); return { committed: true, supersededBy: null }; } catch (error) { publishFailure({ stage: 'commit', error: `Could not publish the verified preview: ${error.message}` }, '', '', candidatePath); return { committed: false, supersededBy: null }; } } function beginBuild(digest, epoch) { if (stopping || child) return; activeHash = digest.hash; activeEpoch = epoch; state.generation += 1; state.status = 'checking'; state.failure = null; broadcast(); const candidatePath = path.join(stagingDirectory, `generation-${state.generation}.html`); const snapshotPath = path.join(stagingDirectory, `generation-${state.generation}.json`); if (digest.bytes !== null) { try { fs.writeFileSync(snapshotPath, digest.bytes, { flag: 'wx', mode: 0o600 }); } catch (error) { publishFailure( { stage: 'prepare', error: `Could not snapshot the observed input: ${error.message}` }, '', '', candidatePath, snapshotPath, ); return; } } const args = [options.deliveryCli || cliPath, 'deliver', type, snapshotPath, candidatePath, '--json']; if (options.quality) args.push('--quality', options.quality); if (options.repoRoot) args.push('--repo-root', path.resolve(options.repoRoot)); let stdout = ''; let stderr = ''; child = spawn(process.execPath, args, { cwd: options.cwd || process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', }); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('error', (error) => { stderr += error.message; }); child.on('close', (code) => { const receipt = parseReceipt(stdout); const generationEpoch = activeEpoch; const generationHash = activeHash; const stale = generationEpoch !== sourceEpoch; let supersededBy = null; child = null; clearTimeout(stopGraceTimer); clearTimeout(stopKillTimer); stopGraceTimer = undefined; stopKillTimer = undefined; if (!stopping && !stale && code === 0 && receipt?.ok) { ({ supersededBy } = commitCandidate(candidatePath, receipt, generationHash)); } else if (!stopping && !stale) { publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath); } try { fs.rmSync(candidatePath, { force: true }); } catch {} try { fs.rmSync(snapshotPath, { force: true }); } catch {} if (stopping) { finishStop(); } else if (pendingBuild || stale || supersededBy) { pendingBuild = false; if (supersededBy && sourceEpoch === generationEpoch) sourceEpoch += 1; const digest = supersededBy || sourceDigest(inputPath); queueStableBuild(digest.hash, true); } }); } function queueStableBuild(hash, immediate = false) { queuedHash = hash; clearTimeout(debounceTimer); const launch = () => { if (stopping) return; const digest = sourceDigest(inputPath); if (digest.hash !== queuedHash) { queueStableBuild(digest.hash); return; } if (digest.hash === lastGoodSourceHash) { if (state.status !== 'verified' && state.lastVerified) { state.status = 'verified'; state.failure = null; broadcast(); } return; } if (child) { pendingBuild = true; return; } beginBuild(digest, sourceEpoch); }; debounceTimer = setTimeout(launch, immediate ? 0 : debounceMs); } function observeSource({ immediate = false } = {}) { const digest = sourceDigest(inputPath); if (!immediate && digest.hash === queuedHash) return; sourceEpoch += 1; queueStableBuild(digest.hash, immediate); } if (options.watch !== false) { try { watcher = fs.watch(path.dirname(inputPath), (event, filename) => { if (!filename || filename.toString() === path.basename(inputPath)) observeSource(); }); } catch (error) { await stop(); throw new Error(`Could not watch the input directory: ${error.message}`); } } pollTimer = setInterval(() => observeSource(), pollMs); let opener = null; if (shouldOpen) { try { opener = openLoopbackUrl(url); } catch { opener = { requested: true, status: 'unsupported', target: url, method: null }; } } observeSource({ immediate: true }); return { url, input: inputPath, output: outputPath, opener, state: publicState, stop, closed, }; } export async function runPreview(options) { const preview = await startPreview(options); console.log(`preview ${preview.url}`); console.log(`watching ${preview.input}`); console.log(`output ${preview.output}`); if (preview.opener && preview.opener.status !== 'opened') { console.error(`Could not open the preview (${preview.opener.status}). Open it manually: ${preview.url}`); } let signalCount = 0; const stop = () => { signalCount += 1; if (signalCount === 1) { console.log('\nstopping preview…'); preview.stop(); } else { console.log('\nforcing preview shutdown…'); preview.stop({ force: true }); } }; process.on('SIGINT', stop); process.on('SIGTERM', stop); await preview.closed; process.off('SIGINT', stop); process.off('SIGTERM', stop); }