import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; /** A newer render call replaced this one before it finished (or before it started). */ export class Superseded extends Error { constructor() { super("Render superseded by a newer request"); this.name = "Superseded"; } } /** The watchdog fired: the worker was terminated and replaced. */ export class RenderTimeout extends Error { constructor(ms: number) { super(`The PDF render did not finish within ${Math.round(ms / 1000)} s and was stopped`); this.name = "RenderTimeout"; } } /** The worker could not start (script, WASM or font failure). Rendering must move to the main thread. */ export class WorkerUnavailable extends Error { constructor(detail: string) { super(`The render worker could not start: ${detail}`); this.name = "WorkerUnavailable"; } } /** The render itself threw inside the worker. */ export class RenderFailed extends Error { constructor(message: string, workerStack?: string) { super(message); this.name = "RenderFailed"; if (workerStack) this.stack = workerStack; } } /** The part of Worker the client uses, so tests can supply a fake. */ export interface WorkerLike { postMessage(message: WorkerRequest): void; terminate(): void; onmessage: ((event: { data: WorkerMessage }) => void) | null; onerror: ((event: { message?: string }) => void) | null; } export interface RenderClient { /** Resolves with the newest request's result; an older call rejects with Superseded. */ render(job: RenderJob): Promise; /** * Wraps JPEG pages into an image-only PDF. This is a separate lane from `render`: renders are * latest-wins, but an assemble is an export step that must complete, so it is never superseded by * a render (or by another assemble). It waits for the job in flight and runs before any queued render. */ assemble(job: AssembleJob): Promise; dispose(): void; readonly mode: "worker" | "main"; } export interface RenderClientOptions { workerFactory?: () => WorkerLike; /** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */ timeoutMs?: number; } const defaultWorkerFactory = (): WorkerLike => new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike; interface RenderSlot { kind: "render"; job: RenderJob; resolve: (r: RenderResult) => void; reject: (e: unknown) => void; } interface AssembleSlot { kind: "assemble"; job: AssembleJob; resolve: (r: AssembleResult) => void; reject: (e: unknown) => void; } type Slot = RenderSlot | AssembleSlot; type Running = Slot & { /** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */ superseded: boolean; timer: ReturnType; }; const requestFor = (slot: Slot): WorkerRequest => slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job }; /** * Renders in a module worker. Only the newest render matters: a new call rejects the render in flight * (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the * running job finishes at most one render, the newest, runs. Assemble jobs are the exception: they * queue in order, are never superseded, and go ahead of a queued render. The handshake is the * worker's `ready` message; a start-up error rejects with WorkerUnavailable and there is no * timer-based fallback. */ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient { const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts; let worker: WorkerLike | null = null; let ready = false; let unavailable: WorkerUnavailable | null = null; let disposed = false; let running: Running | null = null; let pending: RenderSlot | null = null; const assembles: AssembleSlot[] = []; const stopWorker = () => { const w = worker; worker = null; ready = false; if (w) { w.onmessage = null; w.onerror = null; w.terminate(); } }; const settleRunning = (outcome: (slot: Running) => void) => { const cur = running; if (!cur) return; running = null; clearTimeout(cur.timer); if (!cur.superseded) outcome(cur); }; const fail = (err: WorkerUnavailable) => { unavailable = err; stopWorker(); settleRunning((s) => s.reject(err)); rejectQueued(err); }; const rejectQueued = (err: unknown) => { pending?.reject(err); pending = null; for (const a of assembles.splice(0)) a.reject(err); }; const pump = () => { if (!ready || !worker || running || disposed) return; let slot: Slot | null = assembles.shift() ?? null; if (!slot) { slot = pending; pending = null; } if (!slot) return; running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) }; worker.postMessage(requestFor(slot)); }; const spawn = () => { let w: WorkerLike; try { w = workerFactory(); } catch (e) { fail(new WorkerUnavailable(e instanceof Error ? e.message : String(e))); return; } worker = w; ready = false; w.onmessage = (event) => { if (w !== worker) return; const msg = event.data; if (msg.type === "ready") { ready = true; pump(); } else if (msg.type === "result") { if (running?.kind !== "render" || running.job.id !== msg.result.id) return; settleRunning((s) => (s as RenderSlot).resolve(msg.result)); pump(); } else if (msg.type === "assembled") { if (running?.kind !== "assemble" || running.job.id !== msg.result.id) return; settleRunning((s) => (s as AssembleSlot).resolve(msg.result)); pump(); } else if (msg.id === null) { if (!ready) fail(new WorkerUnavailable(msg.message)); } else if (running?.job.id === msg.id) { settleRunning((s) => s.reject(new RenderFailed(msg.message, msg.stack))); pump(); } }; w.onerror = (event) => { if (w !== worker) return; const detail = event.message || "script error"; if (!ready) return fail(new WorkerUnavailable(detail)); // A crash mid-job: replace the worker so later jobs have a live one. settleRunning((s) => s.reject(new RenderFailed(detail))); stopWorker(); spawn(); }; }; function onTimeout() { settleRunning((s) => s.reject(new RenderTimeout(timeoutMs))); stopWorker(); spawn(); } return { mode: "worker", render(job) { if (disposed) return Promise.reject(new Error("The render client was disposed")); if (unavailable) return Promise.reject(unavailable); return new Promise((resolve, reject) => { pending?.reject(new Superseded()); if (running && running.kind === "render" && !running.superseded) { running.superseded = true; running.reject(new Superseded()); } pending = { kind: "render", job, resolve, reject }; if (!worker) spawn(); pump(); }); }, assemble(job) { if (disposed) return Promise.reject(new Error("The render client was disposed")); if (unavailable) return Promise.reject(unavailable); return new Promise((resolve, reject) => { assembles.push({ kind: "assemble", job, resolve, reject }); if (!worker) spawn(); pump(); }); }, dispose() { if (disposed) return; disposed = true; const err = new Error("The render client was disposed"); settleRunning((s) => s.reject(err)); rejectQueued(err); stopWorker(); }, }; } /** Renders one job on this thread. */ export type MainExecutor = (job: RenderJob) => Promise; // Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable. const runOnMainThread: MainExecutor = async (job) => { const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]); await initBrowserRendering(); // Let pending input and paint run before the long synchronous layout. await new Promise((r) => setTimeout(r, 0)); const t0 = performance.now(); const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs }); return { id: job.id, bytes: out.bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms: Math.round(performance.now() - t0), }; }; /** Assembles one image-only PDF on this thread. */ export type MainAssembleExecutor = (job: AssembleJob) => Promise; const assembleOnMainThread: MainAssembleExecutor = async (job) => (await import("./assembleJob")).runAssembleJob(job); /** * Same interface and lanes as the worker client (latest-wins renders, never-superseded assembles), * on the main thread. Nothing can interrupt it, so there is no watchdog. */ export function createMainThreadClient( execute: MainExecutor = runOnMainThread, executeAssemble: MainAssembleExecutor = assembleOnMainThread, ): RenderClient { let disposed = false; let running: (Slot & { superseded: boolean }) | null = null; let pending: RenderSlot | null = null; const assembles: AssembleSlot[] = []; const pump = async () => { if (running || disposed) return; let next: Slot | null = assembles.shift() ?? null; if (!next) { next = pending; pending = null; } if (!next) return; const slot = { ...next, superseded: false }; running = slot; try { if (slot.kind === "render") { const result = await execute(slot.job); if (!slot.superseded) slot.resolve(result); } else { slot.resolve(await executeAssemble(slot.job)); } } catch (e) { if (!slot.superseded) slot.reject(e); } running = null; void pump(); }; return { mode: "main", render(job) { if (disposed) return Promise.reject(new Error("The render client was disposed")); return new Promise((resolve, reject) => { pending?.reject(new Superseded()); if (running && running.kind === "render" && !running.superseded) { running.superseded = true; running.reject(new Superseded()); } pending = { kind: "render", job, resolve, reject }; void pump(); }); }, assemble(job) { if (disposed) return Promise.reject(new Error("The render client was disposed")); return new Promise((resolve, reject) => { assembles.push({ kind: "assemble", job, resolve, reject }); void pump(); }); }, dispose() { disposed = true; const err = new Error("The render client was disposed"); pending?.reject(err); pending = null; for (const a of assembles.splice(0)) a.reject(err); }, }; } /** * Worker first; the first WorkerUnavailable switches to the main thread for good. `mode` tells the UI * which one is active (main thread: the preview is paused while typing). */ export function createAutoClient( opts: RenderClientOptions & { mainExecutor?: MainExecutor; mainAssembleExecutor?: MainAssembleExecutor } = {}, ): RenderClient { const primary = createRenderClient(opts); let main: RenderClient | null = null; const fallback = () => { main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor); primary.dispose(); return main; }; return { get mode() { return main ? "main" : "worker"; }, render(job) { if (main) return main.render(job); return primary.render(job).catch((err) => { if (!(err instanceof WorkerUnavailable)) throw err; return fallback().render(job); }); }, assemble(job) { if (main) return main.assemble(job); return primary.assemble(job).catch((err) => { if (!(err instanceof WorkerUnavailable)) throw err; return fallback().assemble(job); }); }, dispose() { primary.dispose(); main?.dispose(); }, }; }