๐Ÿ“„ src/downloader/client/files.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// Public main-thread API (ยง5.2).
16import {
17    matchPdf,
18    matchPreview,
19    pdfKeys,
20    previewKeys,
21    pageKeys as _pageKeys,
22    openPdfCache,
23} from '../cache-store';
24import { CACHES, pdfKey } from '../keys';
25import {
26    getAllBookData,
27    putDownloadRecord,
28    getDownloadRecord,
29    getAllDownloadRecords,
30    openMetaDb,
31} from '../meta-db';
32import type { Thread, RpcResult } from './worker-client';
33import {
34    _setFileState,
35    _hydrateFiles,
36    _hydrateRenders,
37    _setBookData,
38    _setPruning,
39    _clearFileState,
40    _clearRender,
41} from '../state';
42import type { FileState } from '../state';
43import { EnsurePdf as _EnsurePdf, EnsurePageImage as _EnsurePageImage, initOrchestrator } from '../orchestrator';
44import { createFileThread, createMuThread } from './worker-client';
45import { emitProgress, emitDone } from './progress';
46import { DEFAULT_GATEWAYS } from '../fetcher';
47import { ConcurrentProcessor } from '../queue';
48import type { WorkerMessage } from '../worker-protocol';
49
50const DEFAULT_PREVIEW_CONCURRENCY = 6;
51
52export interface InitOptions {
53    gateways?:           string[];
54    requestPersistence?: boolean;
55    concurrency?:        { pdf: number; preview: number };
56    muWorkerTimeout?:    number;
57    // URL of mupdf.js served from the app's public/ directory.
58    // Defaults to '/js/mupdf.js' โ€” must be adjacent to mupdf-wasm.js and mupdf-wasm.wasm.
59    mupdfUrl?:           string;
60}
61
62let _initPromise: Promise<void> | null = null;
63
64// Singleton worker threads โ€” created once, reused by init + GetImage. Spawning a
65// fresh Worker per call (e.g. per cover image) would exhaust the browser's thread
66// pool and leak workers that are never terminated.
67let _fileThread: Thread | null = null;
68let _muThread:   Thread | null = null;
69
70// Bounds how many *different* covers can be downloading at once โ€” e.g. a shelf
71// or search-results list that fires GetImage() for every visible book. Without
72// this, rendering a 30-book list fires 30 simultaneous preview fetches.
73let _previewProcessor: ConcurrentProcessor<RpcResult> | null = null;
74
75function getFileThread(): Thread {
76    // Pre-built /file-worker-v2.js, same as getMuThread(): the exports map
77    // points consumers at dist/, where a source-relative worker URL doesn't
78    // resolve to anything. Only works out of the box because apps copy the
79    // built worker files into public/ (pnpm run cp:workers-v2).
80    if (!_fileThread) _fileThread = createFileThread();
81    return _fileThread;
82}
83
84function getMuThread(): Thread {
85    // mu-worker loads /js/mupdf.js (WASM) via a dynamic import at runtime.
86    // When run as a Vite module worker, Vite blocks public/ file imports.
87    // The pre-built /mu-worker-v2.js runs outside Vite's pipeline, so the
88    // WASM loader works correctly. Keep this on the pre-built path.
89    if (!_muThread) _muThread = createMuThread();
90    return _muThread;
91}
92
93export function preloadWorkersV2(): void {
94    // Eagerly construct the singleton threads so the worker JS downloads at T+0.
95    getFileThread();
96    getMuThread();
97}
98
99export const InitDownloaderV2 = (opts: InitOptions = {}): Promise<void> => {
100    if (_initPromise) return _initPromise;
101
102    _initPromise = _doInit(opts).catch((err) => {
103        _initPromise = null;
104        throw err;
105    });
106    return _initPromise;
107};
108
109async function _doInit(opts: InitOptions): Promise<void> {
110    const {
111        requestPersistence = true,
112        muWorkerTimeout,
113        mupdfUrl = '/js/mupdf.js',
114    } = opts;
115
116    if (requestPersistence && typeof navigator !== 'undefined' && navigator.storage?.persist) {
117        navigator.storage.persist().catch(() => {/* best-effort */});
118    }
119
120    // Ensure IDB is open.
121    await openMetaDb();
122
123    const fileThread = getFileThread();
124    const muThread   = getMuThread();
125
126    _previewProcessor = new ConcurrentProcessor<RpcResult>(opts.concurrency?.preview ?? DEFAULT_PREVIEW_CONCURRENCY);
127
128    // Send MuInit first โ€” the worker loads mupdf dynamically from this URL before
129    // processing any ProcessPages commands. Must arrive before any render request.
130    muThread.send({ type: 'MuInit', mupdfUrl });
131
132    initOrchestrator(fileThread, muThread, {
133        muWorkerTimeout,
134        pdfConcurrency: opts.concurrency?.pdf,
135    });
136
137    // Wire worker messages into state + v1 compat events.
138    fileThread.listen((msg: WorkerMessage) => {
139        if (msg.type === 'DownloadProgress') {
140            _setFileState(msg.txId, { phase: 'downloading', loaded: msg.loaded, total: msg.total });
141            emitProgress(msg.txId, msg.loaded, msg.total);
142        } else if (msg.type === 'DownloadFinished') {
143            _setFileState(msg.txId, { phase: 'done' });
144            emitDone(msg.txId, msg.mime);
145            // NOTE: the file-worker already wrote the authoritative DownloadRecord
146            // (with sizeBytes measured from the decoded body) before posting this
147            // message. Writing here would clobber it with a stale/incorrect value.
148        } else if (msg.type === 'DownloadError') {
149            _setFileState(msg.txId, { phase: 'error', error: msg.error });
150        } else if (msg.type === 'StoragePruneStarted') {
151            _setPruning({ active: true });
152        } else if (msg.type === 'StoragePruned') {
153            _setPruning({ active: false, freedBytes: msg.freedBytes, removedCount: msg.removed });
154            for (const txId of msg.prunedTxIds) {
155                _clearFileState(txId);
156                _clearRender(txId);
157            }
158        }
159    });
160
161    muThread.listen((msg: WorkerMessage) => {
162        if (msg.type === 'MuPdfData') {
163            _setBookData(msg.txId, msg.bookData);
164            // v1 compat event keeps fileId key.
165            if (typeof window !== 'undefined') {
166                window.dispatchEvent(new CustomEvent('WorkerEvent.MuPdfData', {
167                    detail: { ...msg.bookData, fileId: msg.txId },
168                    bubbles: true,
169                }));
170            }
171        }
172    });
173
174    // Bulk hydrate state from cache keys + IDB.
175    const [pdfTxIds, previewTxIds, pageTxIds, allBookData] = await Promise.all([
176        pdfKeys(),
177        previewKeys(),
178        _pageKeys(),
179        getAllBookData(),
180    ]);
181
182    // IDB/cache reconciliation (ยง5.4): cache.keys() is authoritative for "what is
183    // cached", not the downloads store. Any txId in Cache Storage but missing from
184    // downloads (written by another tab, or a crash between cache.put and the IDB
185    // write) gets a synthetic record so the LRU can order/evict it safely.
186    await _reconcileDownloads(pdfTxIds, previewTxIds);
187
188    const fileEntries: Array<[string, FileState]> = [
189        ...pdfTxIds.map((id): [string, FileState]     => [id, { phase: 'done' }]),
190        ...previewTxIds.map((id): [string, FileState] => [id, { phase: 'done' }]),
191    ];
192    _hydrateFiles(fileEntries);
193
194    if (typeof window !== 'undefined') {
195        pdfTxIds.forEach(id => emitDone(id, 'pdf'));
196        previewTxIds.forEach(id => emitDone(id, 'preview'));
197    }
198
199    // Hydrate render state from IDB BookData.
200    for (const bd of allBookData) {
201        _setBookData(bd.txId, bd);
202        const pageEntries: Array<[number, import('../state').FilePhase]> =
203            bd.dimensions.map((_, i) => [i, 'done']);
204        _hydrateRenders([[bd.txId, pageEntries]]);
205    }
206}
207
208// โ”€โ”€โ”€ Read path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
209
210export const GetPdf = async (txId: string): Promise<Response> => {
211    await InitDownloaderV2();
212    const cached = await matchPdf(txId);
213    if (cached) {
214        _touchAccessTime(txId);
215        return cached;
216    }
217    await _EnsurePdf(txId);
218    const resp = await matchPdf(txId);
219    if (!resp) throw new Error(`[downloader_v2] PDF not in cache after download: ${txId}`);
220    return resp;
221};
222
223export const GetImage = async (txId: string): Promise<Response> => {
224    await InitDownloaderV2();
225    const cached = await matchPreview(txId);
226    if (cached) {
227        _touchAccessTime(txId);
228        return cached;
229    }
230    // Trigger preview download on the shared file worker (never spawn per call).
231    // Gated by _previewProcessor: bounds concurrent fan-out across different
232    // books and de-dups repeat calls for the same txId (e.g. the same book
233    // appearing twice in a list, or a component re-render).
234    const result = await _previewProcessor!.add(txId, async () => {
235        return getFileThread().rpc({ type: 'GetFile', txId, mime: 'preview' });
236    });
237    if (!result.ok) throw new Error(`[downloader_v2] preview download failed for ${txId}: ${result.error}`);
238    const resp = await matchPreview(txId);
239    if (!resp) throw new Error(`[downloader_v2] preview not in cache after download: ${txId}`);
240    return resp;
241};
242
243export const EnsurePageImage = async (txId: string, page: number): Promise<Response> => {
244    await InitDownloaderV2();
245    return _EnsurePageImage(txId, page);
246};
247
248export const EnsurePdf = async (txId: string): Promise<void> => {
249    await InitDownloaderV2();
250    return _EnsurePdf(txId);
251};
252
253export const DownloadPdfToDisk = async (txId: string, filename: string): Promise<void> => {
254    await InitDownloaderV2();
255    await _EnsurePdf(txId);
256    const pdfCache = await openPdfCache();
257    const response = await pdfCache.match(pdfKey(txId));
258    if (!response) throw new Error(`[downloader_v2] PDF not in cache after EnsurePdf`);
259
260    const blob = await response.blob();
261    const url  = URL.createObjectURL(blob);
262    const a    = document.createElement('a');
263    a.href     = url;
264    a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
265    document.body.appendChild(a);
266    a.click();
267    document.body.removeChild(a);
268    URL.revokeObjectURL(url);
269};
270
271export const GetObjectUrl = async (resp: Response): Promise<string> => {
272    const blob = await resp.blob();
273    return URL.createObjectURL(blob);
274};
275
276export const revokeObjectUrl = (url: string): void => {
277    URL.revokeObjectURL(url);
278};
279
280export const storageEstimate = (): Promise<StorageEstimate> =>
281    navigator.storage.estimate();
282
283// Write synthetic DownloadRecords for cached txIds absent from the downloads
284// store (ยง5.4). downloadedAt/lastAccessedAt are 0 so reconciled entries sort to
285// the front of the LRU and are evicted first. sizeBytes is read from the cached
286// response's Content-Length so the byte accounting stays accurate; 0 if unknown.
287export async function _reconcileDownloads(pdfTxIds: string[], previewTxIds: string[]): Promise<void> {
288    const existing = new Set((await getAllDownloadRecords()).map(r => r.txId));
289
290    const sizeOf = (resp: Response | undefined): number => {
291        const len = resp?.headers.get('content-length');
292        const n = len ? Number(len) : NaN;
293        return Number.isFinite(n) ? n : 0;
294    };
295
296    const reconcile = async (txId: string, fileType: 'pdf' | 'preview', resp: Response | undefined) => {
297        if (existing.has(txId)) return;
298        await putDownloadRecord({
299            txId,
300            fileType,
301            sizeBytes: sizeOf(resp),
302            downloadedAt: 0,
303            lastAccessedAt: 0,
304        });
305    };
306
307    await Promise.all([
308        ...pdfTxIds.map(async (id)     => reconcile(id, 'pdf',     await matchPdf(id))),
309        ...previewTxIds.map(async (id) => reconcile(id, 'preview', await matchPreview(id))),
310    ]);
311}
312
313// Update lastAccessedAt at most once per hour (ยง5.4).
314function _touchAccessTime(txId: string): void {
315    const now = Date.now();
316    getDownloadRecord(txId).then(rec => {
317        if (!rec) return;
318        if (now - rec.lastAccessedAt < 3_600_000) return;
319        putDownloadRecord({ ...rec, lastAccessedAt: now }).catch(() => {/* best-effort */});
320    }).catch(() => {/* best-effort */});
321}
322