๐Ÿ“„ src/downloader/sw/sw.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// Service Worker โ€” cache-first for /ar/* and /pages/*. Never intercepts anything else.
16import { CACHES, CACHE_VERSION, pdfKey, previewKey, pageKey } from '../keys';
17import { ConcurrentProcessor } from '../queue';
18declare const __DL2_SW_VERSION__: string;
19const SW_VERSION: string = typeof __DL2_SW_VERSION__ !== 'undefined' ? __DL2_SW_VERSION__ : 'dev';
20
21// Bounds concurrent gateway fetches across different txIds (a shelf/search-page
22// preloading many covers, or several tabs open at once) and de-dups concurrent
23// requests for the same uncached txId onto a single upstream fetch.
24const GATEWAY_CONCURRENCY = 4;
25const gatewayProcessor = new ConcurrentProcessor<'pdf' | 'preview' | 'error' | 'not-found'>(GATEWAY_CONCURRENCY);
26
27const SW = self as unknown as ServiceWorkerGlobalScope;
28
29// โ”€โ”€โ”€ Install โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
30
31SW.addEventListener('install', (_e) => {
32    SW.skipWaiting();
33});
34
35// โ”€โ”€โ”€ Activate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
36
37SW.addEventListener('activate', (e) => {
38    e.waitUntil((async () => {
39        await SW.clients.claim();
40        
41        const VERSION_CACHE_PREFIX = 'dl2-sw-version-';
42        const currentVersionCache = VERSION_CACHE_PREFIX + SW_VERSION;
43        const allCacheNames = await caches.keys();
44        
45        if (!allCacheNames.includes(currentVersionCache)) {
46            console.log('[SW] Upgraded to version', SW_VERSION, '- clearing all caches.');
47            for (const name of allCacheNames) {
48                await caches.delete(name);
49            }
50            await caches.open(currentVersionCache);
51        }
52    })());
53});
54
55// โ”€โ”€โ”€ Fetch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
56
57SW.addEventListener('fetch', (e) => {
58    const req = e.request;
59    if (req.method !== 'GET') return;
60
61    const url = new URL(req.url);
62
63    // Per-request bypass for debugging.
64    if (url.searchParams.get('dl2-bypass') === '1') return;
65
66    // Only intercept same-origin /ar/* and /pages/*.
67    if (url.origin === SW.location.origin) {
68        if (url.pathname.startsWith('/ar/')) {
69            e.respondWith(handleArRequest(req, url));
70            return;
71        }
72        if (url.pathname.startsWith('/pages/')) {
73            e.respondWith(handlePagesRequest(req, url));
74            return;
75        }
76    }
77    // Everything else falls through to the network.
78});
79
80async function handleArRequest(req: Request, url: URL): Promise<Response> {
81    const txId = url.pathname.slice('/ar/'.length);
82
83    // Try PDF cache first.
84    const pdfCache = await caches.open(CACHES.pdf);
85    const pdfHit   = await pdfCache.match(pdfKey(txId));
86    if (pdfHit) return handleRangeRequest(req, pdfHit);
87
88    // Try preview cache.
89    const previewCache = await caches.open(CACHES.preview);
90    const previewHit   = await previewCache.match(previewKey(txId));
91    if (previewHit) return previewHit;
92
93    // Miss: bounded + de-duplicated fetch-and-cache. Concurrent requests for the
94    // same uncached txId (multiple tabs, multiple covers/pages mounting at once)
95    // share one upstream fetch instead of racing N independent ones. Each caller
96    // then re-reads its own Response from the now-populated cache โ€” a Response
97    // body can only be consumed once, so the raw fetched Response is never
98    // handed out directly, and Range slicing stays per-caller since requests
99    // may carry different Range headers.
100    const outcome = await gatewayProcessor.add(txId, () => fetchAndCache(txId, pdfCache, previewCache));
101
102    if (outcome === 'pdf') {
103        const cached = await pdfCache.match(pdfKey(txId));
104        return cached ? handleRangeRequest(req, cached) : new Response('Not Found', { status: 404 });
105    }
106    if (outcome === 'preview') {
107        const cached = await previewCache.match(previewKey(txId));
108        return cached ?? new Response('Not Found', { status: 404 });
109    }
110    if (outcome === 'error') return Response.error();
111    return new Response('Not Found', { status: 404 });
112}
113
114// Fetch-and-cache with CORS enforcement (ยง6.1.1), gateway fallback. Routes to the
115// correct named cache by Content-Type โ€” a PDF stored in the preview cache would
116// never be found by pdfKey() and would lose Range support.
117async function fetchAndCache(
118    txId: string,
119    pdfCache: Cache,
120    previewCache: Cache,
121): Promise<'pdf' | 'preview' | 'error' | 'not-found'> {
122    const gateways = ['https://arweave.net', 'https://permagate.io', 'https://ar-io.dev'];
123    for (const gw of gateways) {
124        try {
125            const corsRequest = new Request(`${gw}/${txId}`, { mode: 'cors' });
126            const response    = await fetch(corsRequest);
127            if (response.type === 'opaque') return 'error';
128            if (!response.ok) continue;
129            const contentType = response.headers.get('content-type') || '';
130            if (contentType.includes('application/pdf')) {
131                await pdfCache.put(pdfKey(txId), response);
132                return 'pdf';
133            }
134            await previewCache.put(previewKey(txId), response);
135            return 'preview';
136        } catch { /* try next gateway */ }
137    }
138    return 'not-found';
139}
140
141async function handlePagesRequest(_req: Request, url: URL): Promise<Response> {
142    // /pages/<encodedTxId>/<page>
143    const parts = url.pathname.split('/').filter(Boolean);
144    if (parts.length < 3) return new Response('Not Found', { status: 404 });
145    const txId = decodeURIComponent(parts[1]);
146    const page = parseInt(parts[2], 10);
147
148    const pagesCache = await caches.open(CACHES.pages);
149    const hit        = await pagesCache.match(pageKey(txId, page));
150    if (hit) return hit;
151
152    // Pages are only produced by the mu-worker; the SW never generates them.
153    return new Response('Not Found', { status: 404 });
154}
155
156export async function handleRangeRequest(
157    request: Request,
158    cachedResponse: Response,
159): Promise<Response> {
160    const rangeHeader = request.headers.get('range');
161    if (!rangeHeader) return cachedResponse;
162
163    const blob = await cachedResponse.blob();
164    const size = blob.size;
165    const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-');
166
167    let start: number;
168    let end: number;
169    if (startStr === '') {
170        // Suffix range: "bytes=-N" โ†’ last N bytes (used by PDF readers for the trailer).
171        const suffix = parseInt(endStr, 10);
172        if (isNaN(suffix)) return cachedResponse;
173        start = Math.max(0, size - suffix);
174        end   = size - 1;
175    } else {
176        start = parseInt(startStr, 10);
177        end   = endStr ? parseInt(endStr, 10) : size - 1;
178    }
179
180    if (isNaN(start) || isNaN(end)) {
181        return new Response('Range Not Satisfiable', {
182            status:  416,
183            headers: { 'Content-Range': `bytes */${size}` },
184        });
185    }
186
187    // RFC 7233 ยง2.1: an end past the resource size is clamped, not rejected โ€”
188    // only a start past the size (or past end) is genuinely unsatisfiable.
189    // PDF readers routinely send `bytes=N-<huge>` to mean "N to EOF".
190    end = Math.min(end, size - 1);
191
192    if (start > end || start >= size) {
193        return new Response('Range Not Satisfiable', {
194            status:  416,
195            headers: { 'Content-Range': `bytes */${size}` },
196        });
197    }
198
199    return new Response(blob.slice(start, end + 1), {
200        status:     206,
201        statusText: 'Partial Content',
202        headers: {
203            'Content-Range':  `bytes ${start}-${end}/${size}`,
204            'Accept-Ranges':  'bytes',
205            'Content-Length': String(end - start + 1),
206            'Content-Type':   cachedResponse.headers.get('Content-Type') ?? 'application/octet-stream',
207        },
208    });
209}
210