diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 17:23:10 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 17:23:10 +0200 |
| commit | dd3927a661273734493f65a593755b95aecf5f09 (patch) | |
| tree | b40b9a0939e79c65990f4664211025b032c9bafa /packages/meshbay-hub/src/meshbay_hub/static/downloads.js | |
| parent | 4066c754a613deb965472853fe69727d68be593e (diff) | |
| download | meshbay-dd3927a661273734493f65a593755b95aecf5f09.tar.gz | |
feat(groups): remove a member, and keep gigabytes out of the tab
**Removing a member.** The owner can do it from the Members tab, and it
is two halves in the order that fails safe: the node stops serving the
group key first (an operator-signed request, so a paired browser only),
then the hub drops the membership row. The other order would leave
someone able to reach a node that still serves them.
It is a membership, not an account. The user row is never written: their
other groups, their files and their pinned identity survive, because one
group's owner must not be able to erase someone from the hub. It is also
per group — a node hosting two loses them from one — and it does not take
back the key they already unwrapped, which is what rotating the GEK is
for. The confirmation and the panel both say so.
**Downloads and streaming through the disk, in both browsers.** The audit
this started as found two ways to put gigabytes in a tab.
Firefox and Safari have no File System Access API, so every download
there was collected in memory. A service worker fixes it: the page keeps
the writable half of a transferred stream, the worker answers a made-up
URL with the readable half and a Content-Disposition header, and the
browser writes it to disk as it arrives, with real backpressure. The
worker caches nothing and falls through on every request that is not one
of these downloads. A zip announces no Content-Length, since the archive
is larger than the files in it and a length we miss truncates the file.
Video was worse and affected both browsers. The node pushed ffmpeg's
whole output as fast as it was produced while the player consumed a
segment at a time, so the queue held the film — and appending all of it
hit the SourceBuffer's cap, where the handler logged the error and
dropped the segment, leaving a hole in the middle of the film with
nothing to show for it. Streaming is credit-based now, 24 segments of
256 KB in flight, verified against the live node: three credits, three
segments, then silence until more are granted. The player evicts what is
more than a minute behind the playhead and retries a refused segment
rather than dropping it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/downloads.js | 81 |
1 files changed, 78 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 a71f289..7f48fed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -171,8 +171,83 @@ export async function openTarget(filename) { } /** - * Below this, a download with no granted folder is collected in memory and - * handed to the browser, which saves it without asking. Above it that would - * mean holding gigabytes in a tab, so it is worth one Save As dialog instead. + * Below this, a download with no granted folder and no service worker is + * collected in memory and handed to the browser. Above it that would mean + * holding gigabytes in a tab, so it is worth one Save As dialog instead. */ export const BLOB_LIMIT = 512 * 1024 * 1024; + +// ── Streaming to disk without the File System Access API ──────────────────── + +const SW_PATH = '/sw.js'; +let _swReady = null; + +export const STREAMS_VIA_SW = typeof window !== 'undefined' + && 'serviceWorker' in navigator + && typeof TransformStream === 'function' + && window.isSecureContext; + +async function serviceWorker() { + if (!STREAMS_VIA_SW) return null; + if (!_swReady) { + _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' }) + .then(() => navigator.serviceWorker.ready) + .then(reg => reg.active || navigator.serviceWorker.controller) + .catch(err => { + console.warn('[MeshBay] service worker unavailable:', err.message); + return null; + }); + } + return _swReady; +} + +/** + * A sink the browser writes to disk, for Firefox and anything else without the + * File System Access API. + * + * The page keeps the writable half of a stream and gives the readable half to + * the service worker, which answers a made-up URL with it. Navigating a hidden + * iframe there turns it into an ordinary download: written as it arrives, with + * the browser's own progress, and nothing held in the tab. Backpressure is + * real — `writer.write()` waits when the browser is behind. + * + * Returns {writable, name} shaped like the File System Access one, or null if + * this browser cannot do it either. + */ +export async function openStreamedDownload(filename, size = 0) { + const worker = await serviceWorker(); + if (!worker) return null; + + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const { readable, writable } = new TransformStream(); + + try { + worker.postMessage({ type: 'mbdl', id, filename, size, readable }, [readable]); + } catch (err) { + // Transferable streams are what makes the backpressure work; without them + // this would be a memory buffer wearing a stream's clothes. + console.warn('[MeshBay] streams cannot be transferred here:', err.message); + return null; + } + + const frame = document.createElement('iframe'); + frame.hidden = true; + frame.src = `/_mbdl/${id}`; + document.body.appendChild(frame); + + const writer = writable.getWriter(); + return { + name: filename, + writable: { + write: (bytes) => writer.write(bytes), + close: async () => { + await writer.close(); + setTimeout(() => frame.remove(), 2000); + }, + abort: async (reason) => { + try { await writer.abort(reason); } catch { /* already gone */ } + frame.remove(); + }, + }, + }; +} |