perf(web): render resume PDFs in a web worker

This commit is contained in:
Amruth Pillai
2026-09-29 08:21:59 +02:00
parent 5dc67c3bd4
commit ce2f1857f9
4 changed files with 176 additions and 10 deletions
@@ -0,0 +1,69 @@
import type { PdfWorkerRequest, PdfWorkerResponse } from "./pdf-document";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const mocks = vi.hoisted(() => ({ renderHere: vi.fn() }));
vi.mock("@reactive-resume/pdf/browser", () => ({ createResumePdfBlob: mocks.renderHere }));
vi.mock("@/libs/resume/section-title-locale", () => ({ createSectionTitleResolverForLocale: async () => () => "" }));
const pageMap = { pages: [{ width: 595, height: 842 }], nodes: [] };
/** Stands in for the browser's Worker: `reply` decides what it sends back for each request. */
function stubWorker(reply: (request: PdfWorkerRequest, worker: EventTarget) => void) {
class FakeWorker extends EventTarget {
postMessage(request: PdfWorkerRequest) {
queueMicrotask(() => reply(request, this));
}
terminate() {}
}
vi.stubGlobal("Worker", FakeWorker);
}
const respond = (worker: EventTarget, data: PdfWorkerResponse) =>
worker.dispatchEvent(new MessageEvent("message", { data }));
beforeEach(() => {
vi.resetModules();
mocks.renderHere.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("createResumePdfBlob", () => {
it("renders in the worker and hands back its PDF and page map", async () => {
const blob = new Blob(["%PDF"], { type: "application/pdf" });
stubWorker((request, worker) => respond(worker, { id: request.id, blob, pageMap }));
const { createResumePdfBlob } = await import("./pdf-document");
const onPageMap = vi.fn();
await expect(createResumePdfBlob(sampleResumeData, undefined, undefined, { onPageMap })).resolves.toBe(blob);
expect(onPageMap).toHaveBeenCalledWith(pageMap);
expect(mocks.renderHere).not.toHaveBeenCalled();
});
it("passes on the worker's failure, so callers can fall back to the server's PDF", async () => {
stubWorker((request, worker) => respond(worker, { id: request.id, error: "Fonts could not be loaded: Inter" }));
const { createResumePdfBlob } = await import("./pdf-document");
await expect(createResumePdfBlob(sampleResumeData)).rejects.toThrow("Fonts could not be loaded: Inter");
});
it("renders on the main thread when the worker can't start", async () => {
const blob = new Blob(["%PDF"], { type: "application/pdf" });
mocks.renderHere.mockImplementation(({ onPageMap }) => {
onPageMap(pageMap);
return Promise.resolve(blob);
});
stubWorker((_request, worker) => worker.dispatchEvent(new Event("error")));
const { createResumePdfBlob } = await import("./pdf-document");
const onPageMap = vi.fn();
await expect(createResumePdfBlob(sampleResumeData, undefined, undefined, { onPageMap })).resolves.toBe(blob);
expect(onPageMap).toHaveBeenCalledWith(pageMap);
// Later renders skip the broken worker.
await createResumePdfBlob(sampleResumeData);
expect(mocks.renderHere).toHaveBeenCalledTimes(2);
});
});
@@ -1,7 +1,6 @@
import type { PageMap } from "@reactive-resume/pdf/page-map";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
type ResumePdfRenderOptions = {
@@ -13,19 +12,85 @@ type CreateResumePdfBlobExtras = {
onPageMap?: (pageMap: PageMap) => void;
};
type RenderInput = { data: ResumeData; template?: Template | undefined; renderOptions?: ResumePdfRenderOptions };
export type PdfWorkerRequest = RenderInput & { id: number };
export type PdfWorkerResponse =
| { id: number; blob: Blob; pageMap: PageMap | undefined }
| { id: number; error: string };
type Rendered = { blob: Blob; pageMap: PageMap | undefined };
/** The same render on the main thread: where workers aren't available, or the worker failed to start. */
async function renderHere({ data, template, renderOptions }: RenderInput): Promise<Rendered> {
const [{ createResumePdfBlob }, resolveSectionTitle] = await Promise.all([
import("@reactive-resume/pdf/browser"),
createSectionTitleResolverForLocale(data.metadata.page.locale),
]);
let pageMap: PageMap | undefined;
const blob = await createResumePdfBlob({
data,
template,
...(renderOptions ? { renderOptions } : {}),
resolveSectionTitle,
onPageMap: (map) => {
pageMap = map;
},
});
return { blob, pageMap };
}
type Pending = { input: RenderInput; resolve(value: Rendered): void; reject(error: Error): void };
// ponytail: one worker renders in order, so a preview can wait behind one thumbnail (~0.25 s); a second worker
// for thumbnails costs another engine and font set in memory.
let worker: Worker | null | undefined;
let nextId = 0;
const pending = new Map<number, Pending>();
function getWorker(): Worker | null {
if (worker !== undefined) return worker;
worker = null;
if (typeof Worker === "undefined") return worker;
try {
worker = new Worker(new URL("./pdf.worker.ts", import.meta.url), { type: "module", name: "resume-pdf" });
} catch {
return worker;
}
worker.addEventListener("message", ({ data: response }: MessageEvent<PdfWorkerResponse>) => {
const request = pending.get(response.id);
if (!request) return;
pending.delete(response.id);
if ("error" in response) request.reject(new Error(response.error));
else request.resolve({ blob: response.blob, pageMap: response.pageMap });
});
// Every render is caught inside the worker, so an error here means it couldn't start: render here instead.
worker.addEventListener("error", () => {
worker?.terminate();
worker = null;
for (const request of pending.values()) renderHere(request.input).then(request.resolve, request.reject);
pending.clear();
});
return worker;
}
function render(input: RenderInput): Promise<Rendered> {
const target = getWorker();
if (!target) return renderHere(input);
const id = ++nextId;
return new Promise((resolve, reject) => {
pending.set(id, { input, resolve, reject });
target.postMessage({ ...input, id } satisfies PdfWorkerRequest);
});
}
export const createResumePdfBlob = async (
data: ResumeData,
template?: Template,
renderOptions?: ResumePdfRenderOptions,
{ onPageMap }: CreateResumePdfBlobExtras = {},
) => {
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
return createPdfBlob({
data,
template,
...(renderOptions ? { renderOptions } : {}),
resolveSectionTitle: sectionTitleResolver,
onPageMap,
});
const { blob, pageMap } = await render({ data, template, ...(renderOptions ? { renderOptions } : {}) });
if (pageMap) onPageMap?.(pageMap);
return blob;
};
@@ -0,0 +1,26 @@
/// <reference lib="webworker" />
import type { PageMap } from "@reactive-resume/pdf/page-map";
import type { PdfWorkerRequest, PdfWorkerResponse } from "./pdf-document";
import { createResumePdfBlob } from "@reactive-resume/pdf/browser";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
// Renders off the main thread, so typing, scrolling and the gallery stay smooth while a page is laid out.
self.addEventListener("message", async ({ data: request }: MessageEvent<PdfWorkerRequest>) => {
const { id, data, template, renderOptions } = request;
try {
let pageMap: PageMap | undefined;
const blob = await createResumePdfBlob({
data,
template,
renderOptions,
resolveSectionTitle: await createSectionTitleResolverForLocale(data.metadata.page.locale),
onPageMap: (map) => {
pageMap = map;
},
});
self.postMessage({ id, blob, pageMap } satisfies PdfWorkerResponse);
} catch (error) {
self.postMessage({ id, error: error instanceof Error ? error.message : String(error) } satisfies PdfWorkerResponse);
}
});
+6
View File
@@ -45,6 +45,12 @@ export default defineConfig({
},
},
// The PDF worker renders templates with translated section titles, so it needs the catalogs and macros too.
worker: {
format: "es",
plugins: () => [lingui(), babel({ presets: [linguiTransformerBabelPreset()] })],
},
server: {
host: true,
strictPort: true,