aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/sw.js
blob: 119687ddfe72dc05162ecb647feabee3a7ccdf96 (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
/**
 * 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 }));
});