issueAndArchive keeps an issued invoice even when rendering or archiving fails (Issued - not archived, with a Render & archive action). The export menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is the invoice number alone; flatten failures offer Retry or searchable export and never silently lower the resolution. History re-exports the original as issued by default and warns when a re-render's layout fingerprint differs. The render client gains a FIFO assemble lane that previews cannot supersede. A Diagnostics panel runs fixtures in the real worker and compares against goldens generated from Node.
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
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<RenderResult>;
|
|
/**
|
|
* 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<AssembleResult>;
|
|
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<typeof setTimeout>;
|
|
};
|
|
|
|
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<RenderResult>((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<AssembleResult>((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<RenderResult>;
|
|
|
|
// 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<void>((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<AssembleResult>;
|
|
|
|
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<RenderResult>((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<AssembleResult>((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();
|
|
},
|
|
};
|
|
}
|