diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/sw.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/sw.js | 72 |
1 files changed, 70 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 119687d..5f663f9 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,31 @@ 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, @@ -35,6 +89,21 @@ self.addEventListener('message', (event) => { // 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); @@ -56,8 +125,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 |