mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
perf(web): render resume PDFs in a web worker
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user