๐Ÿ“„ 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