Large peer downloads (~178MB) failed with a generic 'Operation failed', and
the download path had three stacked problems:
- The FIPS reqwest client used a hard-coded 20s total timeout regardless of the
caller's .timeout(), so a big transfer over the mesh aborted at 20s before
the Tor fallback could help. Honor the per-request timeout (client_with_timeout).
- The peer-content proxy buffered the whole file into node memory via
resp.bytes() before sending a byte, and capped the transfer at 60s. Stream
the body through with hyper::Body::wrap_stream (constant memory) and raise the
timeout to 900s; bump the nginx peer-content read timeout to match.
- Free downloads pulled the file as base64 over RPC, doubling it in node memory
and the browser — fatal for large files. Download free files by streaming
from /api/peer-content straight to disk, after a 1-byte Range probe that
surfaces the real reason (peer offline on mesh and Tor) instead of a generic
failure. Paid downloads now return the real error through the {error} channel
the UI already displays.
Adds the reqwest 'stream' feature for bytes_stream().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3aea8c5bfa
commit
e456c9701b
@@ -530,21 +530,19 @@ async function downloadFile(item: CatalogItem) {
|
||||
purchaseError.value = `Payment failed: ${result.error}`
|
||||
}
|
||||
} else {
|
||||
// Free / peers-only download
|
||||
const result = await rpcClient.call<{ data?: string; error?: string; price_sats?: number }>({
|
||||
method: 'content.download-peer',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
if (result?.error === 'payment_required') {
|
||||
purchaseError.value = `This content requires payment: ${result.price_sats ?? 0} sats`
|
||||
// Free / peers-only download: stream straight from the Range-capable
|
||||
// proxy (B3) to disk instead of pulling the whole file as a base64 blob
|
||||
// over RPC. The old base64-over-RPC path buffered the entire file in node
|
||||
// memory AND the browser, which failed outright for large files (#38).
|
||||
// A tiny probe first surfaces the real server error (peer unreachable on
|
||||
// mesh and Tor) instead of a generic failure (#30).
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
const probe = await probePeerContent(streamUrl)
|
||||
if (probe !== true) {
|
||||
purchaseError.value = probe
|
||||
return
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
triggerDownload(result.data, item)
|
||||
}
|
||||
streamDownload(streamUrl, item)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||
@@ -640,6 +638,51 @@ watch(videoPlayerUrl, (url) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Probe the streaming proxy with a 1-byte Range request so we can report the
|
||||
* real failure reason (peer offline on both mesh and Tor) before kicking off a
|
||||
* browser-managed download that can't surface HTTP status. Returns `true` on
|
||||
* success, or a human-readable error string. Aborts immediately on success so
|
||||
* a large body is never drained.
|
||||
*/
|
||||
async function probePeerContent(url: string): Promise<true | string> {
|
||||
const controller = new AbortController()
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Range: 'bytes=0-0' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (res.ok || res.status === 206) {
|
||||
controller.abort()
|
||||
return true
|
||||
}
|
||||
let msg = `Peer returned an error (${res.status})`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.error) msg = String(body.error)
|
||||
} catch {
|
||||
// non-JSON error body — keep the status-based message
|
||||
}
|
||||
return msg
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : 'Could not reach the peer — it may be offline'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a free file to disk via the Range-capable proxy. The browser manages
|
||||
* the transfer with constant memory (no base64, no in-memory Blob), so large
|
||||
* files download reliably (#38). Same-origin, so the session cookie rides along.
|
||||
*/
|
||||
function streamDownload(url: string, item: CatalogItem) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = item.filename.split('/').pop() || item.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
function triggerDownload(base64Data: string, item: CatalogItem) {
|
||||
const blob = new Blob(
|
||||
[Uint8Array.from(atob(base64Data), c => c.charCodeAt(0))],
|
||||
|
||||
Reference in New Issue
Block a user