/** * 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/'; /** * A filename, safe to put in Content-Disposition. * * `encodeURIComponent` alone is not enough, and the way it fails is invisible * until somebody downloads the wrong film: it leaves `'` untouched, and `'` is * the *delimiter* in RFC 5987's `filename*=''`. A single * apostrophe in a name therefore makes the header unparseable, and a browser * that cannot parse it falls back to the last segment of the URL — which here * is the made-up id this worker answers on. The file arrives complete, 449 MB * of it, called "mtsshk9w-ohqty535". * * Found by downloading three files where exactly one had an apostrophe in its * name. `(`, `)` and `*` are excluded from RFC 5987's attr-char for the same * reason and get the same treatment. * * The plain `filename=` beside it is the ASCII fallback every parser * understands: it loses the accents, and it is what stops a name being lost * entirely the next time one of these encodings surprises us. */ function contentDisposition(name) { const encoded = encodeURIComponent(name) .replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()); // Quotes and backslashes would end the quoted-string early; anything not // plain ASCII is dropped rather than mangled, since the starred form above // carries the real name. const ascii = name.replace(/["\\]/g, '_').replace(/[^\x20-\x7e]/g, '_'); return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; } 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 || {}; // A worker with nothing to do is terminated — Firefox after about thirty // seconds, and `respondWith(new Response(stream))` does not extend its life // for the duration of the response. So a download longer than that lost its // reader mid-file: the page's next `write()` never resolved and never // rejected, the progress bar stopped, the console stayed empty and the node // went on looking perfectly healthy. Handling a message is an event, and an // event resets that timer, so the page pings while it is writing. // // It also has to be answered: a ping that only arrives keeps *this* worker // alive, and the reply is how the page learns the worker it is talking to is // still the one holding its stream. if (data.type === 'mbdl-ping') { if (event.ports && event.ports[0]) { try { event.ports[0].postMessage({ type: 'mbdl-pong' }); } catch { /* gone */ } } return; } // A page that loaded before any worker existed can miss the claim on // activate. Rather than declare the streamed path unavailable — which on // Firefox and Safari means the download cannot happen at all — the page asks // for another claim and waits a moment longer. if (data.type === 'mbdl-claim') { event.waitUntil(self.clients.claim()); return; } 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, }); // Say so, on the port the page is already listening to. // // `pending` is in memory, and a worker with nothing to do is terminated: // Chrome after tens of seconds, which a long upload spends without giving // this worker a single event. A stream posted to a worker in that state is // lost, the iframe then wakes it with no entry to find, and the request falls // through to the network — measured as a 404 from the hub and thirty seconds // of nothing, twice, before the download started at all. // // The page waits for this before navigating, so the entry is known to be here // rather than hoped to be. A page talking to an older worker gets no answer // and navigates anyway, which is what it did before. if (data.port) { try { data.port.postMessage({ type: 'mbdl-ready', id: data.id }); } catch { /* gone */ } } // 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': contentDisposition(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 })); });