Ctrl K
ComunidadGuías…

lib/pdf-server.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// An idle painter exits on its own, so a crashed run does not leave Chromium up forever.

import net from 'node:net';
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import { lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { writeFile } from 'node:fs/promises';
import { getCareerOpsRoot, getProfilePath, rawTrackerPath } from '../path-resolver.mjs';
import { resolveBrowserExecutable } from './browser-launch.mjs';

const MAX_FRAME = 32 * 1024 * 1024;
const IDLE_MS = 30 * 60 * 1000;
const OPT_KEYS = [
  'format', 'baseDir', 'reportNum', 'inputPath', 'workspaceRoot',
  'maxPages', 'strictPages', 'styleTokens', 'kind', 'country',
];

function unavailable(message) {
  const err = new Error(message);
  err.code = 'PDF_SERVER_UNAVAILABLE';
  return err;
}

// On Linux the temp dir is shared by every user. A socket at a guessable path
// there could be planted by someone else, and the client would send it the CV
// HTML. The socket goes in a directory this user owns and only this user can
// open; anything else means paint locally instead.
function privateSocketDir() {
  const dir = join(tmpdir(), `co-pdf-${process.getuid()}`);
  try {
    mkdirSync(dir, { mode: 0o700 });
  } catch (err) {
    if (err.code !== 'EEXIST') throw unavailable(`cannot create ${dir}: ${err.message}`);
  }
  const st = lstatSync(dir);
  if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) {
    throw unavailable(`${dir} is not a private directory owned by this user`);
  }
  return dir;
}

export function sharedPdfSocketPath(
  rootDir,
  workspaceRoot = getCareerOpsRoot(),
  browserRoot = getCareerOpsRoot(),
) {
  if (process.env.CAREER_OPS_PDF_SOCKET) return process.env.CAREER_OPS_PDF_SOCKET;
  // The painter inherits its first client's manifest/profile paths. Another
  // workspace must not send its PDFs to that process and replace its mappings.
  const dataRoot = getCareerOpsRoot();
  // The RESOLVED browser, not the raw variable: a painter auto-detected onto
  // Brave must not be handed work by a client that resolves a different binary,
  // which is the same reasoning that put the explicit override in this key.
  const execPath = resolveBrowserExecutable({ projectRoot: browserRoot })?.path || '';
  const context = [String(rootDir), String(workspaceRoot), String(browserRoot), dataRoot, resolve(rawTrackerPath(dataRoot)), getProfilePath(),
    process.env.CAREER_OPS_PDF_INDEX ? resolve(process.env.CAREER_OPS_PDF_INDEX) : '', execPath];
  const hash = createHash('sha256').update(JSON.stringify(context)).digest('hex').slice(0, 16);
  if (process.platform === 'win32' || typeof process.getuid !== 'function') {
    return `\\\\.\\pipe\\career-ops-pdf-${hash}`;
  }
  // Short names: a unix socket path is capped near 104 bytes, and the macOS
  // temp dir alone is about 50.
  return join(privateSocketDir(), `${hash}.sock`);
}

function isPipe(socketPath) {
  return /^\\\\[.?]\\pipe\\/i.test(socketPath);
}

// A Windows named pipe is not a file, so its pid file lives in the temp dir.
function pidFile(socketPath) {
  if (isPipe(socketPath)) {
    const hash = createHash('sha256').update(socketPath).digest('hex').slice(0, 16);
    return join(tmpdir(), `career-ops-pdf-${hash}.pid`);
  }
  return `${socketPath}.pid`;
}

function removeSocket(socketPath) {
  if (!isPipe(socketPath)) rmSync(socketPath, { force: true });
}

function pickOpts(opts = {}) {
  const out = {};
  for (const key of OPT_KEYS) {
    if (opts[key] !== undefined) out[key] = opts[key];
  }
  return out;
}

function frame(value) {
  const body = Buffer.from(JSON.stringify(value));
  if (body.length > MAX_FRAME) {
    const err = new Error('PDF render request is too large');
    err.code = 'PDF_SERVER_UNAVAILABLE';
    throw err;
  }
  const head = Buffer.alloc(4);
  head.writeUInt32BE(body.length);
  return Buffer.concat([head, body]);
}

function readFrame(socket) {
  return new Promise((resolve, reject) => {
    let buf = Buffer.alloc(0);
    let settled = false;
    const onData = (chunk) => {
      buf = Buffer.concat([buf, chunk]);
      if (buf.length < 4) return;
      const length = buf.readUInt32BE(0);
      if (length > MAX_FRAME) return finish(new Error('PDF render response is too large'));
      if (buf.length < 4 + length) return;
      try {
        finish(null, JSON.parse(buf.subarray(4, 4 + length).toString('utf8')));
      } catch (err) {
        finish(err);
      }
    };
    const onError = (err) => finish(err);
    const onEnd = () => finish(new Error('PDF browser closed the connection'));
    function finish(err, value) {
      if (settled) return;
      settled = true;
      socket.off('data', onData);
      socket.off('error', onError);
      socket.off('end', onEnd);
      if (err) reject(err);
      else resolve(value);
    }
    socket.on('data', onData);
    socket.on('error', onError);
    socket.on('end', onEnd);
  });
}

function connect(socketPath) {
  return new Promise((resolve, reject) => {
    const socket = net.connect(socketPath);
    const fail = (err) => {
      socket.destroy();
      reject(err);
    };
    socket.once('connect', () => {
      socket.off('error', fail);
      resolve(socket);
    });
    socket.once('error', fail);
  });
}

async function socketIsLive(socketPath) {
  try {
    const socket = await connect(socketPath);
    socket.destroy();
    return true;
  } catch {
    return false;
  }
}

function listen(server, socketPath) {
  return new Promise((resolve, reject) => {
    const onError = (err) => {
      server.off('listening', onListening);
      reject(err);
    };
    const onListening = () => {
      server.off('error', onError);
      resolve();
    };
    server.once('error', onError);
    server.once('listening', onListening);
    server.listen(socketPath);
  });
}

function createLimiter(max) {
  let active = 0;
  const waiting = [];
  return function limit(fn) {
    return new Promise((resolve, reject) => {
      const run = () => {
        active += 1;
        Promise.resolve()
          .then(fn)
          .then(resolve, reject)
          .finally(() => {
            active -= 1;
            const next = waiting.shift();
            if (next) next();
          });
      };
      if (active >= max) waiting.push(run);
      else run();
    });
  };
}

async function claimSocket(server, socketPath) {
  try {
    await listen(server, socketPath);
    return true;
  } catch (err) {
    if (err.code !== 'EADDRINUSE') throw err;
  }
  if (await socketIsLive(socketPath)) return false;
  removeSocket(socketPath);
  try {
    await listen(server, socketPath);
    return true;
  } catch (err) {
    if (err.code === 'EADDRINUSE' && await socketIsLive(socketPath)) return false;
    throw err;
  }
}

/**
 * Listen for paint requests. Returns once the socket accepts connections.
 * `close` stops this listener. It does not stop a painter someone else owns.
 *
 * @param {{
 *   socketPath?: string,
 *   rootDir?: string,
 *   launchBrowser: (options: {headless: boolean}) => Promise<{close?: () => Promise<void>}>
 *   render: (browser: object, html: string, outputPath: string, opts: object) => Promise<{outputPath: string, pageCount: number, size: number}>
 *   idleMs?: number
 * }} options
 */
export async function startPdfServer({ socketPath, rootDir, launchBrowser, render, idleMs = IDLE_MS }) {
  const path = socketPath || sharedPdfSocketPath(rootDir || process.cwd());
  // A client may finish writing its request before it reads the reply. The
  // default closes this side when theirs does, and the reply is then a write
  // on a socket that already ended.
  const server = net.createServer({ allowHalfOpen: true });
  const owned = await claimSocket(server, path);
  if (!owned) {
    return {
      socketPath: path,
      alreadyRunning: true,
      close: async () => {},
      closed: Promise.resolve(),
    };
  }

  let browserPromise = null;
  let lastUse = Date.now();
  let stopped = false;
  let closeResolve = () => {};
  const closed = new Promise((resolve) => { closeResolve = resolve; });
  const limit = createLimiter(4);

  function browser() {
    if (!browserPromise) {
      const pending = Promise.resolve()
        .then(() => launchBrowser({ headless: true }))
        .then((painted) => {
          painted.once?.('disconnected', () => {
            if (browserPromise === pending) browserPromise = null;
          });
          return painted;
        })
        .catch((err) => {
          browserPromise = null;
          throw err;
        });
      browserPromise = pending;
    }
    return browserPromise;
  }

  server.on('connection', (socket) => {
    socket.on('error', () => {});
    lastUse = Date.now();
    limit(async () => {
      let payload;
      try {
        payload = await readFrame(socket);
      } catch {
        socket.destroy();
        return;
      }
      try {
        const painted = await browser();
        const result = await render(painted, payload.html, payload.outputPath, payload.opts || {});
        lastUse = Date.now();
        reply(socket, {
          ok: true,
          outputPath: result.outputPath,
          pageCount: result.pageCount,
          size: result.size,
          // Set only on a warn-only page-budget overflow: the PDF is at the
          // draft and outputPath was not written (#6377).
          ...(result.draftPath ? { draftPath: result.draftPath } : {}),
        });
      } catch (err) {
        reply(socket, { ok: false, error: err?.message || String(err) });
      }
    }).catch(() => {
      if (!socket.destroyed) socket.destroy();
    });
  });

  await writeFile(pidFile(path), String(process.pid));

  const timer = setInterval(() => {
    if (Date.now() - lastUse >= idleMs) close();
  }, 30_000);
  timer.unref();

  async function close() {
    if (stopped) return;
    stopped = true;
    clearInterval(timer);
    await new Promise((resolve) => server.close(() => resolve()));
    if (browserPromise) {
      const painted = await browserPromise.catch(() => null);
      if (painted && typeof painted.close === 'function') await painted.close().catch(() => {});
    }
    // A successor may have bound the path while this listener drained. Leave
    // its socket and pid file alone.
    let ownsPid = false;
    try {
      ownsPid = readFileSync(pidFile(path), 'utf8').trim() === String(process.pid);
    } catch {}
    if (ownsPid && !(await socketIsLive(path))) {
      removeSocket(path);
      rmSync(pidFile(path), { force: true });
    }
    closeResolve();
  }

  return { socketPath: path, alreadyRunning: false, close, closed };
}

export async function ensureSharedPdfServer(
  generatePdfPath,
  workspaceRoot = getCareerOpsRoot(),
  browserRoot = getCareerOpsRoot(),
) {
  const socketPath = sharedPdfSocketPath(dirname(generatePdfPath), workspaceRoot, browserRoot);
  if (await socketIsLive(socketPath)) return socketPath;
  const child = spawn(process.execPath, [generatePdfPath, '--pdf-server'], {
    detached: true,
    stdio: 'ignore',
    env: {
      ...process.env,
      CAREER_OPS_PDF_SHARED: '1',
      CAREER_OPS_PDF_SERVER_PROCESS: '1',
      CAREER_OPS_PDF_SOCKET: socketPath,
      CAREER_OPS_PDF_WORKSPACE_ROOT: workspaceRoot,
      CAREER_OPS_PDF_BROWSER_ROOT: browserRoot,
    },
  });
  child.unref();
  const deadline = Date.now() + 15_000;
  while (Date.now() < deadline) {
    if (await socketIsLive(socketPath)) return socketPath;
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
  const err = new Error('shared PDF browser did not start');
  err.code = 'PDF_SERVER_UNAVAILABLE';
  throw err;
}

function reply(socket, value) {
  if (!socket.writable || socket.destroyed) {
    socket.destroy();
    return;
  }
  try {
    socket.end(frame(value));
  } catch {
    socket.destroy();
  }
}

// A painter that is gone, or that drops the connection before it answers,
// is unavailable and the caller paints locally. An { ok: false } answer is a
// real render error and is not retried.
export async function requestPdfRender(socketPath, { html, outputPath, opts }) {
  let socket;
  try {
    socket = await connect(socketPath);
  } catch (err) {
    throw unavailable(`cannot reach the shared PDF browser: ${err.message}`);
  }
  socket.on('error', () => {});
  try {
    socket.write(frame({ html, outputPath, opts: pickOpts(opts) }));
    let response;
    try {
      response = await readFrame(socket);
    } catch (err) {
      throw unavailable(`the shared PDF browser dropped the request: ${err.message}`);
    }
    if (!response?.ok) {
      const err = new Error(response?.error || 'PDF render failed');
      throw err;
    }
    return {
      outputPath: response.outputPath,
      pageCount: response.pageCount,
      size: response.size,
      ...(response.draftPath ? { draftPath: response.draftPath } : {}),
    };
  } finally {
    socket.destroy();
  }
}

export async function renderViaSharedPdfServer(
  html,
  outputPath,
  opts,
  generatePdfPath,
  browserRoot = getCareerOpsRoot(),
) {
  const socketPath = await ensureSharedPdfServer(
    generatePdfPath,
    opts.workspaceRoot || getCareerOpsRoot(),
    browserRoot,
  );
  return requestPdfRender(socketPath, { html, outputPath, opts });
}

export async function stopSharedPdfServer(rootDir) {
  let socketPath;
  let pid = 0;
  try {
    socketPath = sharedPdfSocketPath(rootDir || process.cwd());
    pid = Number(readFileSync(pidFile(socketPath), 'utf8'));
  } catch {
    return;
  }
  if (!Number.isInteger(pid) || pid <= 0) return;
  try { process.kill(pid, 'SIGTERM'); } catch {}
  const deadline = Date.now() + 3_000;
  while (Date.now() < deadline) {
    try {
      process.kill(pid, 0);
    } catch {
      removeSocket(socketPath);
      rmSync(pidFile(socketPath), { force: true });
      return;
    }
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
  try { process.kill(pid, 'SIGKILL'); } catch {}
  removeSocket(socketPath);
  rmSync(pidFile(socketPath), { force: true });
}