aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/sw.js
blob: 5f663f9112f74477b5381d918142be0ddf060684 (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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
/**
 * 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,
  });
  // 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 }));
});