aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/sw.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/sw.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sw.js49
1 files changed, 47 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
index 0dd87d8..309ecc1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/sw.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
@@ -17,6 +17,35 @@
*/
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());
@@ -24,6 +53,23 @@ 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
@@ -64,8 +110,7 @@ self.addEventListener('fetch', (event) => {
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)}`,
+ 'Content-Disposition': contentDisposition(entry.filename),
'Cache-Control': 'no-store',
};
// Only when it is known. A zip is assembled as it goes and announcing a