๐ 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