summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/sw.js
blob: 309ecc143f2d568c5956dadc6a0886bf5555661d (plain) (blame)
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 }));
});