Replace the HTML preview with a worker-rendered PDF preview
Rendering moves into a module Web Worker behind a latest-wins RenderClient with a watchdog and a handshake-based main-thread fallback. The preview shows the real PDF through pdf.js at 2x with zoom, page indicator, audit issues popover and a focus modal. A4/Letter and margin controls are frozen into each invoice at issue and can be saved as the default (migration M3). react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB to 0.5 MB. The HTML twin and its styles are removed.
This commit is contained in:
@@ -24,6 +24,7 @@ const vendor: Settings = {
|
||||
defaultHsnSac: "",
|
||||
signatoryName: "",
|
||||
signatoryDesignation: "",
|
||||
renderPrefs: "",
|
||||
};
|
||||
|
||||
const bank: BankAccount = {
|
||||
@@ -77,6 +78,7 @@ const base: Invoice = {
|
||||
cancelledAt: null,
|
||||
cancelReason: "",
|
||||
archivedPdfSha256: null,
|
||||
renderPrefs: "",
|
||||
items,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAutoClient,
|
||||
createMainThreadClient,
|
||||
createRenderClient,
|
||||
RenderFailed,
|
||||
RenderTimeout,
|
||||
Superseded,
|
||||
WorkerUnavailable,
|
||||
type WorkerLike,
|
||||
} from "./client";
|
||||
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
class FakeWorker implements WorkerLike {
|
||||
onmessage: WorkerLike["onmessage"] = null;
|
||||
onerror: WorkerLike["onerror"] = null;
|
||||
sent: WorkerRequest[] = [];
|
||||
terminated = false;
|
||||
postMessage(message: WorkerRequest) {
|
||||
this.sent.push(message);
|
||||
}
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
emit(data: WorkerMessage) {
|
||||
this.onmessage?.({ data });
|
||||
}
|
||||
ready() {
|
||||
this.emit({ type: "ready" });
|
||||
}
|
||||
reply(id: number) {
|
||||
this.emit({ type: "result", result: result(id) });
|
||||
}
|
||||
}
|
||||
|
||||
const result = (id: number): RenderResult => ({
|
||||
id,
|
||||
bytes: new Uint8Array([id]),
|
||||
pages: 1,
|
||||
issues: [],
|
||||
fingerprint: `fp${id}`,
|
||||
ms: 5,
|
||||
});
|
||||
const job = (id: number): RenderJob => ({ id, model: {} as RenderJob["model"], prefs: {} as RenderJob["prefs"] });
|
||||
|
||||
function setup(timeoutMs = 1000) {
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = createRenderClient({
|
||||
timeoutMs,
|
||||
workerFactory: () => {
|
||||
const w = new FakeWorker();
|
||||
workers.push(w);
|
||||
return w;
|
||||
},
|
||||
});
|
||||
return { client, workers };
|
||||
}
|
||||
|
||||
const settled = (p: Promise<unknown>) => p.then(() => "ok", (e) => e);
|
||||
|
||||
describe("RenderClient", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("sends nothing until the worker is ready, then resolves with the result", async () => {
|
||||
const { client, workers } = setup();
|
||||
const p = client.render(job(1));
|
||||
expect(workers).toHaveLength(1);
|
||||
expect(workers[0].sent).toHaveLength(0);
|
||||
workers[0].ready();
|
||||
expect(workers[0].sent.map((m) => m.job.id)).toEqual([1]);
|
||||
workers[0].reply(1);
|
||||
expect((await p).fingerprint).toBe("fp1");
|
||||
});
|
||||
|
||||
it("a new call supersedes the one in flight, and only the newest queued job runs next", async () => {
|
||||
const s = setup();
|
||||
const a = s.client.render(job(1));
|
||||
s.workers[0].ready();
|
||||
const b = s.client.render(job(2));
|
||||
const c = s.client.render(job(3));
|
||||
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||
expect(await settled(b)).toBeInstanceOf(Superseded);
|
||||
// The worker is still busy with job 1: nothing else was posted.
|
||||
expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1]);
|
||||
s.workers[0].reply(1); // result of the superseded job is discarded
|
||||
expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1, 3]);
|
||||
s.workers[0].reply(3);
|
||||
expect((await c).id).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects the superseded in-flight promise immediately", async () => {
|
||||
const { client, workers } = setup();
|
||||
const a = client.render(job(1));
|
||||
workers[0].ready();
|
||||
const b = client.render(job(2));
|
||||
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||
workers[0].reply(1);
|
||||
workers[0].reply(2);
|
||||
expect((await b).id).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces a render error from the worker and keeps going", async () => {
|
||||
const { client, workers } = setup();
|
||||
const a = client.render(job(1));
|
||||
workers[0].ready();
|
||||
workers[0].emit({ type: "error", id: 1, message: "boom", stack: "at worker" });
|
||||
const err = await settled(a);
|
||||
expect(err).toBeInstanceOf(RenderFailed);
|
||||
expect((err as Error).message).toBe("boom");
|
||||
const b = client.render(job(2));
|
||||
workers[0].reply(2);
|
||||
expect((await b).id).toBe(2);
|
||||
});
|
||||
|
||||
it("watchdog: terminates, respawns, rejects with RenderTimeout, then serves the next job", async () => {
|
||||
const { client, workers } = setup(1000);
|
||||
const a = client.render(job(1));
|
||||
workers[0].ready();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(await settled(a)).toBeInstanceOf(RenderTimeout);
|
||||
expect(workers[0].terminated).toBe(true);
|
||||
expect(workers).toHaveLength(2);
|
||||
|
||||
const b = client.render(job(2));
|
||||
workers[1].ready();
|
||||
expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]);
|
||||
workers[1].reply(2);
|
||||
expect((await b).id).toBe(2);
|
||||
});
|
||||
|
||||
it("watchdog on a superseded job still replaces the worker and then runs the queued job", async () => {
|
||||
const { client, workers } = setup(1000);
|
||||
const a = client.render(job(1));
|
||||
workers[0].ready();
|
||||
const b = client.render(job(2));
|
||||
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(workers[0].terminated).toBe(true);
|
||||
workers[1].ready();
|
||||
expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]);
|
||||
workers[1].reply(2);
|
||||
expect((await b).id).toBe(2);
|
||||
});
|
||||
|
||||
it("a start-up error before ready rejects with WorkerUnavailable, and so do later calls", async () => {
|
||||
const { client, workers } = setup();
|
||||
const a = client.render(job(1));
|
||||
workers[0].emit({ type: "error", id: null, message: "fonts missing" });
|
||||
const err = await settled(a);
|
||||
expect(err).toBeInstanceOf(WorkerUnavailable);
|
||||
expect((err as Error).message).toContain("fonts missing");
|
||||
expect(workers[0].terminated).toBe(true);
|
||||
expect(await settled(client.render(job(2)))).toBeInstanceOf(WorkerUnavailable);
|
||||
expect(workers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a worker script error before ready is WorkerUnavailable; there is no timer fallback", async () => {
|
||||
const { client, workers } = setup(1000);
|
||||
const a = client.render(job(1));
|
||||
// A silent worker that never says ready just waits: no timer turns that into a failure.
|
||||
vi.advanceTimersByTime(60_000);
|
||||
let done = false;
|
||||
void settled(a).then(() => (done = true));
|
||||
await Promise.resolve();
|
||||
expect(done).toBe(false);
|
||||
workers[0].onerror?.({ message: "SyntaxError" });
|
||||
expect(await settled(a)).toBeInstanceOf(WorkerUnavailable);
|
||||
});
|
||||
|
||||
it("a factory that throws is WorkerUnavailable", async () => {
|
||||
const client = createRenderClient({
|
||||
workerFactory: () => {
|
||||
throw new Error("no Worker here");
|
||||
},
|
||||
});
|
||||
expect(await settled(client.render(job(1)))).toBeInstanceOf(WorkerUnavailable);
|
||||
});
|
||||
|
||||
it("dispose terminates the worker and rejects what is outstanding", async () => {
|
||||
const { client, workers } = setup();
|
||||
const a = client.render(job(1));
|
||||
workers[0].ready();
|
||||
const b = client.render(job(2));
|
||||
client.dispose();
|
||||
expect(workers[0].terminated).toBe(true);
|
||||
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||
expect(String(await settled(b))).toContain("disposed");
|
||||
expect(String(await settled(client.render(job(3))))).toContain("disposed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("main-thread client and fallback", () => {
|
||||
it("runs jobs serially with latest-wins", async () => {
|
||||
const gates: Array<() => void> = [];
|
||||
const seen: number[] = [];
|
||||
const client = createMainThreadClient(
|
||||
(j) =>
|
||||
new Promise((resolve) => {
|
||||
seen.push(j.id);
|
||||
gates.push(() => resolve(result(j.id)));
|
||||
}),
|
||||
);
|
||||
expect(client.mode).toBe("main");
|
||||
const a = client.render(job(1));
|
||||
const b = client.render(job(2));
|
||||
const c = client.render(job(3));
|
||||
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||
expect(await settled(b)).toBeInstanceOf(Superseded);
|
||||
gates[0]();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(seen).toEqual([1, 3]);
|
||||
gates[1]();
|
||||
expect((await c).id).toBe(3);
|
||||
});
|
||||
|
||||
it("the auto client moves to the main thread when the worker cannot start", async () => {
|
||||
const client = createAutoClient({
|
||||
workerFactory: () => {
|
||||
throw new Error("no worker");
|
||||
},
|
||||
mainExecutor: async (j) => result(j.id),
|
||||
});
|
||||
expect(client.mode).toBe("worker");
|
||||
expect((await client.render(job(7))).id).toBe(7);
|
||||
expect(client.mode).toBe("main");
|
||||
expect((await client.render(job(8))).id).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { 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>;
|
||||
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 Slot {
|
||||
job: RenderJob;
|
||||
resolve: (r: RenderResult) => void;
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
interface Running extends Slot {
|
||||
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
|
||||
superseded: boolean;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders in a module worker. Only the newest request matters: a new call rejects the one 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 job, the newest, runs. 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: Slot | null = null;
|
||||
|
||||
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));
|
||||
pending?.reject(err);
|
||||
pending = null;
|
||||
};
|
||||
|
||||
const pump = () => {
|
||||
if (!ready || !worker || running || !pending || disposed) return;
|
||||
const slot = pending;
|
||||
pending = null;
|
||||
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
|
||||
worker.postMessage({ type: "render", job: slot.job });
|
||||
};
|
||||
|
||||
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?.job.id !== msg.result.id) return;
|
||||
settleRunning((s) => s.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.superseded) {
|
||||
running.superseded = true;
|
||||
running.reject(new Superseded());
|
||||
}
|
||||
pending = { 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));
|
||||
pending?.reject(err);
|
||||
pending = null;
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */
|
||||
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient {
|
||||
let disposed = false;
|
||||
let running: (Slot & { superseded: boolean }) | null = null;
|
||||
let pending: Slot | null = null;
|
||||
|
||||
const pump = async () => {
|
||||
if (running || !pending || disposed) return;
|
||||
const slot = { ...pending, superseded: false };
|
||||
pending = null;
|
||||
running = slot;
|
||||
try {
|
||||
const result = await execute(slot.job);
|
||||
if (!slot.superseded) slot.resolve(result);
|
||||
} 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.superseded) {
|
||||
running.superseded = true;
|
||||
running.reject(new Superseded());
|
||||
}
|
||||
pending = { job, resolve, reject };
|
||||
void pump();
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
const err = new Error("The render client was disposed");
|
||||
pending?.reject(err);
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } = {},
|
||||
): RenderClient {
|
||||
const primary = createRenderClient(opts);
|
||||
let main: RenderClient | null = null;
|
||||
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;
|
||||
main ??= createMainThreadClient(opts.mainExecutor);
|
||||
primary.dispose();
|
||||
return main.render(job);
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
primary.dispose();
|
||||
main?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
|
||||
|
||||
export interface RenderJob {
|
||||
id: number;
|
||||
model: RenderModel;
|
||||
prefs: RenderPrefsV1;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
id: number;
|
||||
bytes: Uint8Array;
|
||||
pages: number;
|
||||
issues: AuditIssue[];
|
||||
fingerprint: string;
|
||||
/** Wall time of the render itself, measured where it ran. */
|
||||
ms: number;
|
||||
}
|
||||
|
||||
export type WorkerRequest = { type: "render"; job: RenderJob };
|
||||
|
||||
export type WorkerMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "result"; result: RenderResult }
|
||||
/** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */
|
||||
| { type: "error"; id: number | null; message: string; stack?: string };
|
||||
@@ -0,0 +1,50 @@
|
||||
// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
|
||||
import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
|
||||
import { initBrowserRendering } from "./browserInit";
|
||||
import { renderInvoicePdf } from "./invoice";
|
||||
import type { WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
interface WorkerScope {
|
||||
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
|
||||
postMessage(message: WorkerMessage, options?: { transfer: Transferable[] }): void;
|
||||
}
|
||||
const scope = self as unknown as WorkerScope;
|
||||
|
||||
installStreamAsyncIterator();
|
||||
|
||||
const post = (message: WorkerMessage, transfer: Transferable[] = []) => scope.postMessage(message, { transfer });
|
||||
|
||||
const failure = (id: number | null, err: unknown): WorkerMessage => ({
|
||||
type: "error",
|
||||
id,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
});
|
||||
|
||||
const started = initBrowserRendering();
|
||||
|
||||
scope.onmessage = async (event) => {
|
||||
const request = event.data;
|
||||
if (request?.type !== "render") return;
|
||||
const { job } = request;
|
||||
try {
|
||||
await started.catch(() => initBrowserRendering());
|
||||
const t0 = performance.now();
|
||||
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
// Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first.
|
||||
const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength;
|
||||
const bytes = whole ? out.bytes : out.bytes.slice();
|
||||
post(
|
||||
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
|
||||
[bytes.buffer],
|
||||
);
|
||||
} catch (err) {
|
||||
post(failure(job.id, err));
|
||||
}
|
||||
};
|
||||
|
||||
started.then(
|
||||
() => post({ type: "ready" }),
|
||||
(err) => post(failure(null, err)),
|
||||
);
|
||||
@@ -50,6 +50,7 @@ const baseVendor: Settings = {
|
||||
defaultHsnSac: "",
|
||||
signatoryName: "",
|
||||
signatoryDesignation: "",
|
||||
renderPrefs: "",
|
||||
};
|
||||
|
||||
export const FIXTURE_BANK: BankAccount = {
|
||||
@@ -210,6 +211,7 @@ export function buildFixture(spec: FixtureSpec): BuiltFixture {
|
||||
cancelledAt: null,
|
||||
cancelReason: "",
|
||||
archivedPdfSha256: null,
|
||||
renderPrefs: "",
|
||||
items,
|
||||
...spec.invoice,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user