mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 11:47:54 +02:00
300 lines
9.7 KiB
JavaScript
300 lines
9.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Local PDF export for Plan Canvas.
|
|
*
|
|
* Chromium's print-to-PDF implementation renders the same loopback artifact
|
|
* the reviewer sees, so Markdown, HTML, images, tables, and Mermaid diagrams
|
|
* keep their browser layout. No artifact content is sent to a converter or
|
|
* added as an npm runtime dependency.
|
|
*/
|
|
|
|
const { spawn } = require('child_process');
|
|
const fs = require('fs');
|
|
const net = require('net');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const DEFAULT_EXPORT_TIMEOUT_MS = 45 * 1000;
|
|
const PDF_POLL_MS = 100;
|
|
|
|
function errorWithCode(message, code) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function isExecutable(file, { platform = process.platform, fsImpl = fs } = {}) {
|
|
if (!file) return false;
|
|
try {
|
|
fsImpl.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
return fsImpl.statSync(file).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function pathExecutableNames(platform) {
|
|
if (platform === 'win32') {
|
|
return ['chrome.exe', 'msedge.exe', 'chromium.exe'];
|
|
}
|
|
return ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge-stable', 'microsoft-edge'];
|
|
}
|
|
|
|
function executableCandidates({ env, platform }) {
|
|
const candidates = [];
|
|
if (platform === 'darwin') {
|
|
candidates.push(
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
|
|
);
|
|
if (env.HOME) {
|
|
candidates.push(
|
|
path.join(env.HOME, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
|
path.join(env.HOME, 'Applications/Chromium.app/Contents/MacOS/Chromium'),
|
|
path.join(env.HOME, 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')
|
|
);
|
|
}
|
|
}
|
|
if (platform === 'win32') {
|
|
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
|
|
candidates.push(
|
|
path.join(root, 'Google/Chrome/Application/chrome.exe'),
|
|
path.join(root, 'Microsoft/Edge/Application/msedge.exe'),
|
|
path.join(root, 'Chromium/Application/chrome.exe')
|
|
);
|
|
}
|
|
}
|
|
|
|
const pathEntries = String(env.PATH || '')
|
|
.split(path.delimiter)
|
|
.map(entry => entry.replace(/^"|"$/g, ''))
|
|
.filter(Boolean);
|
|
for (const directory of pathEntries) {
|
|
for (const name of pathExecutableNames(platform)) candidates.push(path.join(directory, name));
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function resolveChromiumExecutable({
|
|
env = process.env,
|
|
platform = process.platform,
|
|
fsImpl = fs
|
|
} = {}) {
|
|
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
|
|
if (override) return isExecutable(override, { platform, fsImpl }) ? override : null;
|
|
return executableCandidates({ env, platform }).find(candidate => isExecutable(candidate, { platform, fsImpl })) || null;
|
|
}
|
|
|
|
function pdfFileName(artifactFile) {
|
|
const original = path.basename(String(artifactFile || 'plan'));
|
|
const stem = original
|
|
.replace(/\.(?:plan\.)?(?:md|markdown|html?|htm)$/i, '')
|
|
.split('')
|
|
.map(character => character.charCodeAt(0) < 32 ? '-' : character)
|
|
.join('')
|
|
.replace(/[<>:"/\\|?*]/g, '-')
|
|
.replace(/[.\s]+$/g, '')
|
|
.trim() || 'plan';
|
|
return `${stem}.pdf`;
|
|
}
|
|
|
|
function assertLoopbackUrl(value) {
|
|
let url;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw errorWithCode('PDF export URL is invalid', 'PDF_EXPORT_INVALID_URL');
|
|
}
|
|
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
|
|
throw errorWithCode('PDF export is restricted to the Plan Canvas loopback server', 'PDF_EXPORT_INVALID_URL');
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
function isCompletePdf(file, fsImpl = fs) {
|
|
let fd;
|
|
try {
|
|
fd = fsImpl.openSync(file, 'r');
|
|
const stat = fsImpl.fstatSync(fd);
|
|
if (!stat.isFile() || stat.size < 12) return false;
|
|
const head = Buffer.alloc(5);
|
|
fsImpl.readSync(fd, head, 0, head.length, 0);
|
|
const tailLength = Math.min(2048, stat.size);
|
|
const tail = Buffer.alloc(tailLength);
|
|
fsImpl.readSync(fd, tail, 0, tailLength, stat.size - tailLength);
|
|
return head.toString('ascii') === '%PDF-' && tail.toString('latin1').includes('%%EOF');
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
if (fd !== undefined) {
|
|
try { fsImpl.closeSync(fd); } catch { /* best-effort probe */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function startDenyProxy(netImpl = net) {
|
|
return new Promise((resolve, reject) => {
|
|
const server = netImpl.createServer(socket => {
|
|
socket.on('error', () => {});
|
|
socket.destroy();
|
|
});
|
|
const onError = error => reject(errorWithCode(
|
|
`Could not isolate PDF renderer network access (${error.message})`,
|
|
'PDF_EXPORT_FAILED'
|
|
));
|
|
server.once('error', onError);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.removeListener('error', onError);
|
|
server.on('error', () => {});
|
|
server.unref();
|
|
resolve({ server, url: `http://127.0.0.1:${server.address().port}` });
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopDenyProxy(server) {
|
|
if (!server) return Promise.resolve();
|
|
return new Promise(resolve => {
|
|
try { server.close(resolve); } catch { resolve(); }
|
|
});
|
|
}
|
|
|
|
async function stopChild(child) {
|
|
if (!child || child.exitCode !== null || child.signalCode) return;
|
|
const closed = new Promise(resolve => child.once('close', resolve));
|
|
try { child.kill('SIGTERM'); } catch { return; }
|
|
await Promise.race([closed, delay(1000)]);
|
|
if (child.exitCode === null && !child.signalCode) {
|
|
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
await Promise.race([closed, delay(500)]);
|
|
}
|
|
}
|
|
|
|
function waitForPdf(child, outputFile, { timeoutMs, fsImpl }) {
|
|
return new Promise((resolve, reject) => {
|
|
let stderr = '';
|
|
let settled = false;
|
|
const finish = error => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearInterval(poll);
|
|
clearTimeout(timeout);
|
|
child.removeListener('error', onError);
|
|
child.removeListener('close', onClose);
|
|
if (error) reject(error);
|
|
else resolve();
|
|
};
|
|
const failure = message => {
|
|
const detail = stderr.trim().slice(-1200);
|
|
return errorWithCode(`${message}${detail ? `: ${detail}` : ''}`, 'PDF_EXPORT_FAILED');
|
|
};
|
|
const onError = error => finish(failure(`Could not start the PDF renderer (${error.message})`));
|
|
const onClose = code => {
|
|
if (isCompletePdf(outputFile, fsImpl)) finish();
|
|
else finish(failure(`PDF renderer exited with status ${code}`));
|
|
};
|
|
if (child.stderr) {
|
|
child.stderr.on('data', chunk => {
|
|
stderr += chunk.toString();
|
|
if (stderr.length > 8000) stderr = stderr.slice(-4000);
|
|
});
|
|
}
|
|
child.once('error', onError);
|
|
child.once('close', onClose);
|
|
const poll = setInterval(() => {
|
|
if (isCompletePdf(outputFile, fsImpl)) finish();
|
|
}, PDF_POLL_MS);
|
|
const timeout = setTimeout(() => finish(failure(`PDF export timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
});
|
|
}
|
|
|
|
async function exportPdf({
|
|
url,
|
|
artifactFile,
|
|
env = process.env,
|
|
platform = process.platform,
|
|
executable = null,
|
|
timeoutMs = DEFAULT_EXPORT_TIMEOUT_MS,
|
|
spawnImpl = spawn,
|
|
fsImpl = fs,
|
|
osImpl = os,
|
|
netImpl = net
|
|
} = {}) {
|
|
const safeUrl = assertLoopbackUrl(url);
|
|
const exportUrl = new URL(safeUrl);
|
|
const browser = executable || resolveChromiumExecutable({ env, platform, fsImpl });
|
|
if (!browser) {
|
|
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
|
|
throw errorWithCode(
|
|
override
|
|
? `PDF renderer not found at ECC_PLAN_CANVAS_CHROME_PATH=${override}`
|
|
: 'PDF export requires Google Chrome, Chromium, or Microsoft Edge; set ECC_PLAN_CANVAS_CHROME_PATH to its executable',
|
|
'PDF_BROWSER_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
const tempDir = fsImpl.mkdtempSync(path.join(osImpl.tmpdir(), 'ecc-plan-canvas-pdf-'));
|
|
const outputFile = path.join(tempDir, 'artifact.pdf');
|
|
const profileDir = path.join(tempDir, 'profile');
|
|
fsImpl.mkdirSync(profileDir, { recursive: true });
|
|
let child = null;
|
|
let denyProxy = null;
|
|
try {
|
|
denyProxy = await startDenyProxy(netImpl);
|
|
const args = [
|
|
'--headless=new',
|
|
'--disable-background-networking',
|
|
'--disable-gpu',
|
|
'--disable-component-update',
|
|
'--disable-default-apps',
|
|
'--disable-extensions',
|
|
'--disable-sync',
|
|
'--disable-quic',
|
|
'--metrics-recording-only',
|
|
'--mute-audio',
|
|
'--no-first-run',
|
|
'--no-default-browser-check',
|
|
'--no-pdf-header-footer',
|
|
'--print-to-pdf-no-header',
|
|
'--hide-scrollbars',
|
|
`--proxy-server=${denyProxy.url}`,
|
|
`--proxy-bypass-list=<-loopback>;${exportUrl.origin}`,
|
|
`--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE ${exportUrl.hostname}`,
|
|
`--user-data-dir=${profileDir}`,
|
|
`--print-to-pdf=${outputFile}`,
|
|
'--virtual-time-budget=5000',
|
|
safeUrl
|
|
];
|
|
child = spawnImpl(browser, args, {
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
shell: false,
|
|
env: { ...env, LANG: 'en_US.UTF-8' }
|
|
});
|
|
await waitForPdf(child, outputFile, { timeoutMs, fsImpl });
|
|
const buffer = fsImpl.readFileSync(outputFile);
|
|
if (!isCompletePdf(outputFile, fsImpl)) {
|
|
throw errorWithCode('PDF renderer produced an incomplete document', 'PDF_EXPORT_FAILED');
|
|
}
|
|
return { buffer, filename: pdfFileName(artifactFile) };
|
|
} finally {
|
|
await stopChild(child);
|
|
await stopDenyProxy(denyProxy && denyProxy.server);
|
|
fsImpl.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_EXPORT_TIMEOUT_MS,
|
|
assertLoopbackUrl,
|
|
exportPdf,
|
|
isCompletePdf,
|
|
pdfFileName,
|
|
resolveChromiumExecutable
|
|
};
|