๐ src/downloader/worker/mu-worker.ts
D-OPEN SOVEREIGN
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library โ The World Library
4 *
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 *
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15// MuPDF render worker โ renders PDF pages to Cache Storage as plain PNG (ยง7).
16//
17// mupdf is NOT bundled into this file. It is loaded at runtime via a dynamic
18// import from the URL adjacent to this worker script (same public/ directory).
19// This sidesteps esbuild's inability to bundle WASM loaders that contain Node.js
20// environment shims. The loader resolves mupdf-wasm.wasm via import.meta.url,
21// which correctly points to the worker's own URL at runtime.
22import { matchPdf, matchPage, putPage } from '../cache-store';
23import { getBookData, storeBookData } from '../meta-db';
24import type { MuWorkerCommand, WorkerMessage, BookData } from '../worker-protocol';
25
26function post(msg: WorkerMessage): void {
27 (self as unknown as Worker).postMessage(msg);
28}
29
30// โโโ Lazy mupdf handle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
31
32// eslint-disable-next-line @typescript-eslint/no-explicit-any
33let mupdf: any = null;
34
35async function initMupdf(mupdfUrl: string): Promise<void> {
36 if (mupdf) return;
37 // Dynamic URL import โ runtime resolution, esbuild leaves this untouched.
38 // The URL is derived from the worker's own origin so the browser fetches
39 // mupdf.js from the same public/ directory as mu-worker-v2.js.
40 // eslint-disable-next-line @typescript-eslint/no-explicit-any
41 const mod = await import(/* @vite-ignore */ mupdfUrl) as any;
42 // mupdf.js exports named bindings; accept both default-export wrappers and named.
43 mupdf = mod.default ?? mod;
44 if (typeof mupdf === 'function') {
45 // Some builds export a factory โ call it to get the module object.
46 mupdf = await mupdf();
47 }
48}
49
50// โโโ Active document instance cache (ยง7.2) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
51
52interface ActiveDoc {
53 txId: string;
54 doc: any; // mupdf.Document instance
55 idleTimer: ReturnType<typeof setTimeout>;
56}
57
58let activeDoc: ActiveDoc | null = null;
59const IDLE_TIMEOUT_MS = 8_000;
60
61// Placeholder passed to getOrOpenDoc when the requested doc is already the active
62// one โ in that branch the bytes are never read (the open document is reused).
63const EMPTY_BUFFER = new ArrayBuffer(0);
64
65function closeActiveDoc(): void {
66 if (activeDoc) {
67 clearTimeout(activeDoc.idleTimer);
68 activeDoc.doc.destroy();
69 activeDoc = null;
70 }
71}
72
73function getOrOpenDoc(txId: string, pdfBytes: ArrayBuffer): any {
74 if (activeDoc?.txId === txId) {
75 clearTimeout(activeDoc.idleTimer);
76 activeDoc.idleTimer = setTimeout(closeActiveDoc, IDLE_TIMEOUT_MS);
77 return activeDoc.doc;
78 }
79 closeActiveDoc();
80 const doc = mupdf.Document.openDocument(pdfBytes, 'application/pdf');
81 activeDoc = {
82 txId,
83 doc,
84 idleTimer: setTimeout(closeActiveDoc, IDLE_TIMEOUT_MS),
85 };
86 return doc;
87}
88
89// โโโ ProcessPages handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
90
91async function handleProcessPages(txId: string, from: number, to: number): Promise<void> {
92 try {
93 if (_initPromise) {
94 await _initPromise;
95 }
96 if (!mupdf) throw new Error('MuInit not received yet');
97
98 let doc: any;
99 if (activeDoc?.txId === txId) {
100 doc = getOrOpenDoc(txId, EMPTY_BUFFER);
101 } else {
102 const cached = await matchPdf(txId);
103 if (!cached) throw new Error('PDF not in cache');
104 const pdfBytes = await cached.arrayBuffer();
105 doc = getOrOpenDoc(txId, pdfBytes);
106 }
107
108 const localDims: Array<{ i: number; w: number; h: number }> = [];
109
110 for (let i = from; i <= to; i++) {
111 const alreadyCached = await matchPage(txId, i);
112 if (alreadyCached) {
113 let page: any = null;
114 try {
115 page = doc.loadPage(i);
116 const b = page.getBounds();
117 localDims.push({ i, w: b[2] - b[0], h: b[3] - b[1] });
118 } finally {
119 page?.destroy();
120 }
121 post({ type: 'MuPageDone', txId, page: i });
122 continue;
123 }
124
125 let page: any = null;
126 try {
127 page = doc.loadPage(i);
128 const bounds = page.getBounds();
129 localDims.push({ i, w: bounds[2] - bounds[0], h: bounds[3] - bounds[1] });
130
131 const pixmap = page.toPixmap(mupdf.Matrix.identity, mupdf.ColorSpace.DeviceRGB, false, true);
132 const pngBytes = pixmap.asPNG();
133 pixmap.destroy();
134
135 await putPage(txId, i, new Response(pngBytes, {
136 headers: { 'Content-Type': 'image/png' },
137 }));
138
139 post({ type: 'MuPageDone', txId, page: i });
140 } catch (err: unknown) {
141 post({ type: 'MuPageError', txId, page: i, error: err instanceof Error ? err.message : String(err) });
142 } finally {
143 page?.destroy();
144 }
145 }
146
147 let finalBookData: BookData | null = null;
148 await navigator.locks.request(`mu-worker-${txId}`, async () => {
149 const existing = await getBookData(txId);
150 const dimensions: Array<{ width: number; height: number }> = existing
151 ? [...existing.dimensions]
152 : [];
153
154 for (const d of localDims) {
155 while (dimensions.length <= d.i) dimensions.push({ width: 0, height: 0 });
156 dimensions[d.i] = { width: d.w, height: d.h };
157 }
158
159 finalBookData = { txId, dimensions, parsed: true };
160 await storeBookData(finalBookData);
161 });
162
163 if (finalBookData) {
164 post({ type: 'MuPdfData', txId, bookData: finalBookData });
165 }
166 } catch (err: unknown) {
167 const msg = err instanceof Error ? err.message : String(err);
168 for (let i = from; i <= to; i++) {
169 post({ type: 'MuPageError', txId, page: i, error: msg });
170 }
171 }
172}
173
174// โโโ Message router โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
175
176let _initPromise: Promise<void> | null = null;
177
178// FIFO-serializes ProcessPages handling. handleProcessPages() reads/writes the
179// single shared `activeDoc` (open mupdf document instance) without any locking
180// of its own โ running two calls for different txIds concurrently lets one
181// close/replace the other's activeDoc mid-render (mupdf "document destroyed"
182// errors). Chaining onto _msgChain guarantees only one is ever in flight.
183let _msgChain: Promise<void> = Promise.resolve();
184
185(self as unknown as Worker).onmessage = (e: MessageEvent<MuWorkerCommand>) => {
186 const cmd = e.data;
187 if (cmd.type === 'MuInit') {
188 if (!_initPromise) {
189 _initPromise = initMupdf(cmd.mupdfUrl).catch(err => {
190 console.error('[mu-worker] MuInit failed:', err);
191 throw err;
192 });
193 }
194 return;
195 }
196 if (cmd.type === 'ProcessPages') {
197 // .catch() keeps _msgChain permanently resolved โ handleProcessPages()
198 // already catches its own errors internally, but if that ever changed,
199 // a rejected _msgChain would silently stop running every future command
200 // (a `.then()` on a rejected promise never invokes its handler).
201 _msgChain = _msgChain
202 .then(() => handleProcessPages(cmd.txId, cmd.from, cmd.to))
203 .catch(err => console.error('[mu-worker] unexpected ProcessPages failure:', err));
204 }
205};
206