diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 10:05:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 10:05:30 +0200 |
| commit | fc148e185c01b2e25361c7625a67d310d2e1d288 (patch) | |
| tree | 63d5b8f290a424cec235c47f55e42c290b4a3e46 /packages/meshbay-hub/src/meshbay_hub/static/downloads.js | |
| parent | 7000082b748bf608484f94d30d63b2c6b83e6814 (diff) | |
| download | meshbay-fc148e185c01b2e25361c7625a67d310d2e1d288.tar.gz | |
fix(spa): repair a page the download worker cannot serve
Downloads on Firefox failed with "the worker did not answer the download within
15s", every time, for one operator, while the same profile driven from here
succeeded every time. Their own test sequence found it: a freshly started
browser downloaded four files out of four, twice; one Ctrl+F5 and every
attempt afterwards failed; restart, fine again; Ctrl+F5 before any attempt and
the very first one failed.
A document fetched by a hard reload is loaded with the service worker bypassed.
It can still be claimed afterwards, so `navigator.serviceWorker.controller`
comes back and every check in `_claimController` passes — but the navigations
that document starts keep missing the worker, and the hidden iframe a streamed
download needs is a navigation. On Firefox and Safari that is the only way to
write a file too large to hold in memory, so the download cannot happen at all,
for the life of the page.
Being controlled is not being servable, so priming now asks the question
directly instead of inferring it: a four-byte stream and a hidden iframe,
exactly as a real download would, torn down completely so nothing lands in the
download folder. When it goes unanswered the page reloads once, ordinarily,
which puts it back under the worker. The flag lives in sessionStorage rather
than a variable because it has to survive the reload it triggers, and because a
page that is still unservable afterwards must stop rather than loop.
Also stops telling people to change browser. The message said "use the desktop
app, or Chrome or Edge" for a state an ordinary reload undoes, on the one path
Firefox has no alternative to; all ten catalogues now say to reload first.
The hard reloads were on my instruction: the SPA's HTML is served `no-store`,
so a plain reload has always picked up a new build and Ctrl+F5 was never needed.
Hub suite 834 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/downloads.js | 90 |
1 files changed, 88 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 7e4802a..b1eade6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -200,6 +200,8 @@ export const BLOB_LIMIT = 512 * 1024 * 1024; // ── Streaming to disk without the File System Access API ──────────────────── const SW_PATH = '/sw.js'; +// Kept in step with sw.js's own PREFIX. +const PREFIX_PATH = '/_mbdl/'; // On Firefox and Safari this worker is not a nicety, it is the only unbounded // way to write a download to disk: the File System Access API does not exist @@ -365,7 +367,91 @@ async function _claimController(budgetMs) { */ export function primeServiceWorker() { if (!STREAMS_VIA_SW) return; - serviceWorker().catch(() => {}); + serviceWorker().then((worker) => worker && _repairIfBypassed(worker)) + .catch(() => {}); +} + +// How long to wait for the worker to answer the one-byte self-test below. +// Milliseconds when it works; a page that cannot stream at all is worth four +// seconds to find out about, once, at boot. +const SELF_TEST_BUDGET_MS = 4000; +// Set for the life of this tab, so the repair below can happen at most once and +// can never become a reload loop. +const REPAIRED_KEY = 'meshbay.sw-repaired'; + +/** + * Can this document actually have a download served, or only talk to the worker? + * + * Being controlled is not the same thing, and the gap between them is a real + * failure people hit. A document fetched by a **hard** reload — Ctrl+F5, + * Ctrl+Shift+R — is loaded with the service worker bypassed. It can still be + * claimed afterwards, so `navigator.serviceWorker.controller` comes back and + * every check in `_claimController` passes; but the navigations that document + * starts keep missing the worker, and the hidden iframe a streamed download + * needs *is* a navigation. Every download then fails with "the worker did not + * answer", for the life of that page — on Firefox and Safari, the only path + * there is for a file too large to hold in memory. + * + * Reported after an operator was told, by this author, to hard-reload after + * each deployment: four downloads out of four worked on a freshly started + * browser, and the first attempt after a Ctrl+F5 failed, every time. + * + * This asks the question directly rather than inferring it: a four-byte stream + * and a hidden iframe, exactly as a real download would. + */ +async function _canServeDownloads(worker) { + const id = `selftest-${Math.random().toString(36).slice(2, 10)}`; + let readable, writable; + try { + ({ readable, writable } = new TransformStream()); + } catch { + return true; // No transferable streams: a different failure, not this one. + } + const chan = new MessageChannel(); + const serving = new Promise((resolve) => { + chan.port1.onmessage = (e) => { + if (e.data && e.data.type === 'mbdl-serving') resolve(true); + }; + }); + try { + worker.postMessage({ type: 'mbdl', id, filename: 'meshbay-selftest.bin', + size: 4, readable, port: chan.port2 }, + [readable, chan.port2]); + } catch { + return true; // Same: not the bypass this is looking for. + } + const frame = document.createElement('iframe'); + frame.hidden = true; + frame.src = `${PREFIX_PATH}${id}`; + document.body.appendChild(frame); + const served = await Promise.race([ + serving, + new Promise((r) => setTimeout(() => r(false), SELF_TEST_BUDGET_MS)), + ]); + frame.remove(); + try { chan.port1.close(); } catch { /* already gone */ } + // Never completed, so the browser has nothing to save and no file appears. + try { await writable.abort('self-test'); } catch { /* already gone */ } + return served; +} + +/** + * An ordinary reload puts the document back under the worker, so do that once. + * + * Only at boot, where nothing is in flight and the reload costs a flicker. + * Guarded by a session flag rather than a variable: the point is to survive the + * reload it triggers, and to stop rather than loop if reloading does not help. + */ +async function _repairIfBypassed(worker) { + let repaired = false; + try { repaired = sessionStorage.getItem(REPAIRED_KEY) === '1'; } catch { /* blocked */ } + if (repaired) return; + if (await _canServeDownloads(worker)) return; + console.warn('[MeshBay] this page cannot be served by the download worker — ' + + 'reloading once to put it back under the worker\u2019s control ' + + '(a hard reload leaves a page in this state)'); + try { sessionStorage.setItem(REPAIRED_KEY, '1'); } catch { /* blocked */ } + location.reload(); } async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) { @@ -458,7 +544,7 @@ async function _attemptStreamedDownload(filename, size, attempt, const frame = document.createElement('iframe'); frame.hidden = true; - frame.src = `/_mbdl/${id}`; + frame.src = `${PREFIX_PATH}${id}`; document.body.appendChild(frame); const answered = await Promise.race([ |