/** * Service worker: the only way to stream a download to disk in Firefox. * * Chrome and Edge have the File System Access API — the page opens a file and * writes to it. Firefox and Safari do not, and the alternative there was to * collect the whole download in memory and hand the browser a blob, which is * not an option for a file measured in gigabytes. * * So the page makes up a URL, tells this worker what stream answers it, and * navigates a hidden iframe there. The worker replies with the stream and a * Content-Disposition header, and the browser does what it does with any * download: writes it to disk as it arrives, showing its own progress, with * nothing buffered in the tab. * * It caches nothing and intercepts nothing else. Every request that is not one * of these downloads falls through untouched. */ const PREFIX = '/_mbdl/'; const pending = new Map(); self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); self.addEventListener('message', (event) => { const data = event.data || {}; if (data.type !== 'mbdl' || !data.id || !data.readable) return; pending.set(data.id, { readable: data.readable, filename: data.filename || 'download', size: Number(data.size) || 0, // Answered when the navigation actually reaches us. The page waits for it: // if this worker is never asked for the URL — an uncontrolled page, or a // browser that blocks a download from a hidden iframe — nothing drains the // stream and the page's first write blocks for good. port: data.port || null, }); // A tab that is closed before it navigates would leave a stream here for the // life of the worker. setTimeout(() => pending.delete(data.id), 60000); }); self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (url.origin !== self.location.origin || !url.pathname.startsWith(PREFIX)) { return; // not ours — the network handles it } const entry = pending.get(url.pathname.slice(PREFIX.length)); if (!entry) return; pending.delete(url.pathname.slice(PREFIX.length)); if (entry.port) { try { entry.port.postMessage({ type: 'mbdl-serving', id: url.pathname }); } catch { /* gone */ } } const headers = { 'Content-Type': 'application/octet-stream', // filename* so a name with accents or spaces survives the trip. 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`, 'Cache-Control': 'no-store', }; // Only when it is known. A zip is assembled as it goes and announcing a // length we then miss would truncate the file. if (entry.size > 0) headers['Content-Length'] = String(entry.size); event.respondWith(new Response(entry.readable, { headers })); });