๐ 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