1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
|
/**
* 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*=<charset>'<lang>'<value>`. 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,
});
// 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 }));
});
|