// Runtime abstraction for launching headless Chromium across environments. // // Two targets are supported, selected from the environment (never hardcoded): // - "local": desktop Bun bundled server. Uses the Chromium that // playwright-core resolves (a `playwright install chromium` // browser, or an explicit binary via env). // - "serverless": Vercel/Lambda hub. Uses @sparticuz/chromium's bundled, // serverless-optimized Chromium with playwright-core. // // Per the project rules this NEVER silently falls back: if the selected runtime // cannot launch a browser, it throws with actionable guidance instead of trying // the other runtime. Environment variables are read verbatim (no trimming) and // an unrecognized explicit override is a hard error. import type { Browser } from "playwright-core"; export type PdfRuntimeKind = "local" | "serverless"; /** Force a specific runtime, bypassing autodetection. */ const RUNTIME_OVERRIDE_ENV = "SCRIBE_PDF_RUNTIME"; /** Explicit Chromium binary for the local runtime (e.g. a system Chrome). */ const LOCAL_EXECUTABLE_ENV = "SCRIBE_CHROMIUM_EXECUTABLE_PATH"; /** * Thrown when no headless browser could be launched for PDF rendering. Callers * can `instanceof`-check this to degrade gracefully (e.g. return an actionable * "install Google Chrome" response) instead of treating it as a generic 500. */ export class PdfBrowserLaunchError extends Error { constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "PdfBrowserLaunchError"; } } /** * Lazily resolve playwright-core's `chromium`. The desktop server bundle * externalizes playwright-core (it carries platform-specific binaries), so on a * packaged build where the module was not copied in, the first runtime access * surfaces here as a module-load error — e.g. turbopack's "Failed to load * external module playwright-core-…". We convert that into a * {@link PdfBrowserLaunchError} (the same typed sentinel a failed launch * throws) so PDF routes degrade gracefully — the report route returns an * actionable 503, invoices fall back to the libpdf renderer — instead of * leaking a bare 500. Like the `@sparticuz/chromium` import below, this guarded * dynamic import is a deliberate exception to the top-level-imports rule: the * render engine is an optional external that may legitimately be absent. */ // Import seam for runtime-load-failure.test.ts ONLY. bun's mock.module is // process-global and leaks across test files (platform-dependent file order // made runtime.test.ts's playwright-core import undefined on Linux CI), so the // absent-module case is simulated by swapping this thunk instead of mocking // the module registry. type PlaywrightCoreModule = Pick; let importPlaywrightCore: () => Promise = () => import("playwright-core"); export function __setPlaywrightCoreImportForTests( impl: (() => Promise) | null, ): void { importPlaywrightCore = impl ?? (() => import("playwright-core")); } async function loadChromium() { try { return (await importPlaywrightCore()).chromium; } catch (cause) { throw new PdfBrowserLaunchError( "The PDF render engine (playwright-core) could not be loaded in this " + "build, so no browser is available for PDF rendering.", { cause }, ); } } /** * Decide which runtime to use. Resolution order: * 1. `SCRIBE_PDF_RUNTIME` if set — must be exactly "local" or "serverless", * otherwise a hard error (no silent fallback to autodetection). * 2. Serverless if running on Vercel/Lambda (`AWS_LAMBDA_FUNCTION_NAME` set, * or `VERCEL === "1"`). * 3. Local otherwise. */ export function detectRuntime(): PdfRuntimeKind { const override = process.env[RUNTIME_OVERRIDE_ENV]; if (override !== undefined) { if (override === "local" || override === "serverless") { return override; } throw new Error( `${RUNTIME_OVERRIDE_ENV} must be "local" or "serverless", got ${JSON.stringify( override, )}`, ); } if ( process.env.AWS_LAMBDA_FUNCTION_NAME !== undefined || process.env.VERCEL === "1" ) { return "serverless"; } return "local"; } async function launchLocal(): Promise { // Two mutually exclusive modes, decided by whether an explicit binary is // configured via `SCRIBE_CHROMIUM_EXECUTABLE_PATH`: // // A. EXPLICIT path set (operator override). Read verbatim — never trimmed, // never second-guessed. We launch EXACTLY that binary. If it cannot // launch we FAIL LOUD (PdfBrowserLaunchError) and do NOT fall through to // auto-discovery: a wrong env var is a configuration error, and per the // global rule a wrong variable must fail rather than silently fall back. // // B. NO explicit path. We auto-discover via a 3-step chain (this chain only // runs in mode B): // 1. The user's installed Google Chrome via Playwright's "chrome" // channel. The desktop bundle ships NO Chromium, but most desktops // already have Chrome — this makes the HTML->PDF pipeline work with // no bundled browser and no `playwright install`. // 2. A `playwright install chromium` browser (dev machines / CI). // 3. A single final PdfBrowserLaunchError with actionable guidance. const chromium = await loadChromium(); const executablePath = process.env[LOCAL_EXECUTABLE_ENV]; if (executablePath !== undefined) { // Mode A — explicit override. Fail loud, no fall-through. try { return await chromium.launch({ headless: true, executablePath }); } catch (cause) { throw new PdfBrowserLaunchError( `Configured ${LOCAL_EXECUTABLE_ENV} could not launch: ` + `${JSON.stringify(executablePath)}. Point it at a valid Chromium/` + "Chrome executable, or unset it to auto-discover a browser.", { cause }, ); } } // Mode B — auto-discovery. System Google Chrome (no bundled browser required). try { return await chromium.launch({ headless: true, channel: "chrome" }); } catch (chromeCause) { // Playwright-installed Chromium (dev/CI fallback). try { return await chromium.launch({ headless: true }); } catch (chromiumCause) { throw new PdfBrowserLaunchError( "Failed to launch a local browser for PDF rendering. Install Google " + 'Chrome (the desktop pipeline uses it via the "chrome" channel), ' + "or run `bunx playwright install chromium`, or point " + `${LOCAL_EXECUTABLE_ENV} at a Chromium/Chrome binary.`, { cause: chromiumCause ?? chromeCause }, ); } } } async function launchServerless(): Promise { // Platform-gated import: @sparticuz/chromium ships a ~50MB serverless-only // Chromium and must NOT be loaded in the desktop/Bun runtime. This dynamic // import is the one deliberate exception to the top-level-imports rule. let sparticuz: typeof import("@sparticuz/chromium").default; try { sparticuz = (await import("@sparticuz/chromium")).default; } catch (cause) { throw new Error( "Serverless PDF runtime selected but @sparticuz/chromium could not be " + "loaded. Ensure it is installed in the deployment.", { cause }, ); } const chromium = await loadChromium(); const executablePath = await sparticuz.executablePath(); if (!executablePath) { throw new Error( "Serverless PDF runtime selected but @sparticuz/chromium returned no " + "executablePath. The function may lack the bundled Chromium binary.", ); } try { return await chromium.launch({ executablePath, args: sparticuz.args, headless: true, }); } catch (cause) { throw new PdfBrowserLaunchError( "Failed to launch serverless Chromium (@sparticuz/chromium) for PDF " + "rendering.", { cause }, ); } } /** * Launch a Chromium {@link Browser} for the detected (or forced) runtime. The * caller owns the returned browser and must close it. */ export async function launchBrowser( runtime: PdfRuntimeKind = detectRuntime(), ): Promise { // Exhaustive — never treat an unexpected value as "local" (no silent // fallback). A mistyped caller string fails loudly here. switch (runtime) { case "serverless": return launchServerless(); case "local": return launchLocal(); default: throw new Error( `Unknown PDF runtime ${JSON.stringify(runtime)} (expected "local" or "serverless").`, ); } }