aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sw.js49
2 files changed, 79 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
index f620e15..cfb0051 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -216,6 +216,17 @@ const SW_CONTROL_BUDGET_MS = 15000;
const SW_SERVED_BUDGET_MS = 15000;
// A transient miss gets a second go with a fresh id and a fresh iframe.
const SW_ATTEMPTS = 2;
+// How often the page pokes the worker while a download is being written.
+// Firefox terminates a service worker that has had no event for roughly thirty
+// seconds, and a streaming response does not count as activity — so a download
+// that takes longer than that lost its reader half way through. Ten seconds
+// leaves a wide margin and costs one empty message.
+const SW_KEEPALIVE_MS = 10000;
+// And the ping stops on its own once nothing has been written for this long.
+// Well past any real gap between chunks, and short enough that an abandoned
+// target does not ping for ever. Bounded because the alternative is a timer
+// whose lifetime depends on every caller remembering to close its sink.
+const SW_KEEPALIVE_IDLE_MS = 120000;
// Holds a *successful* controller, or an in-flight attempt. Never a failure —
// see serviceWorker(). The previous version cached the rejected/null result
@@ -399,6 +410,24 @@ async function _attemptStreamedDownload(filename, size, attempt,
}
_lastFailure = '';
+ // Every few seconds for as long as this download is being written. Well
+ // inside the ~30 s Firefox allows an idle worker, and cheap: one postMessage
+ // with no payload. Cleared by close() and abort() below, so a finished
+ // download leaves no timer behind.
+ // Self-limiting, and that is not belt-and-braces: a target can be opened and
+ // then never written to — a transfer cancelled while it waits for a slot
+ // never runs, so nothing calls close() or abort() — and an interval nobody
+ // clears pings for the life of the page. It also kept the Node test process
+ // alive for ever, which is the same defect wearing a louder symptom (the
+ // MessagePort above did exactly this a few hours earlier).
+ let lastWrite = Date.now();
+ const keepAlive = setInterval(() => {
+ if (Date.now() - lastWrite > SW_KEEPALIVE_IDLE_MS) {
+ clearInterval(keepAlive);
+ return;
+ }
+ try { worker.postMessage({ type: 'mbdl-ping' }); } catch { /* gone */ }
+ }, SW_KEEPALIVE_MS);
// The port has delivered the one message it exists for. Closing it matters:
// an open MessagePort is a live handle, and one was leaked per download for
// the life of the page. (It is also what hung the Node harness in
@@ -410,12 +439,14 @@ async function _attemptStreamedDownload(filename, size, attempt,
return {
name: filename,
writable: {
- write: (bytes) => writer.write(bytes),
+ write: (bytes) => { lastWrite = Date.now(); return writer.write(bytes); },
close: async () => {
+ clearInterval(keepAlive);
await writer.close();
setTimeout(() => frame.remove(), 2000);
},
abort: async (reason) => {
+ clearInterval(keepAlive);
try { await writer.abort(reason); } catch { /* already gone */ }
frame.remove();
},
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