Große Dateien in React ohne Speicherprobleme streamen
Warten Sie bei großen React-Downloads jeden Schreibvorgang in eine vom Nutzer ausgewählte Datei ab, statt die Antwort in einem Blob zu sammeln. Diese Anleitung bindet eine vollständige React-App ein, liefert lokal einen bekannten Binärstream mit 256 MiB aus und prüft die Byteanzahl und den SHA-256-Hash der gespeicherten Datei. Der Streaming-Pfad begrenzt die Chunks, die Ihre Anwendung vorhält; Puffer von Browser, Netzwerk und Dateisystem belegen weiterhin Speicher.
Warum herkömmliche Blob-Downloads bei großen Dateien scheitern
Das bekannte Muster fetch → blob → link.click() wartet auf die vollständige Antwort, bevor es
sie an den Browser übergibt. Response.blob()
liest den Antwortkörper vollständig ein. Der Browser wählt den Speicher für den Blob. Das bedeutet
also nicht zwangsläufig eine einzige riesige Allokation im JavaScript-Heap, die gesamte Datei wird
aber dennoch materialisiert.
Verwenden Sie die File System Access API, wenn Sie eine Zielauswahl und eine Fortschrittsanzeige in Ihrer Anwendung in unterstützten Desktop-Chromium-Browsern benötigen. Bieten Sie für große Dateien in Firefox oder Safari einen normalen Link zu einem Attachment-Endpunkt an. Der Browser verwaltet diese Übertragung außerhalb Ihres JavaScript-Chunk-Arrays. Dieses Beispiel enthält außerdem einen bewusst begrenzten Blob-Fallback für kleine Dateien. Hier geht es um das Speichern an neuen Download-Zielen; das Speichern und erneute Öffnen lokaler Datei-Handles ist ein separater Workflow für dauerhaften Dateizugriff.
Daten mit den Fetch- und Streams-APIs streamen
Verwenden Sie Bash, Node.js 24.15 oder neuer aus der gepflegten 24-LTS-Linie sowie Corepack mit Yarn 4.12.0. Installieren Sie Corepack separat, falls Ihre Node-Distribution es nicht mitliefert. Der Pfad mit nativem Auswahldialog wurde unter Linux mit Chromium 145 getestet; die Link- und Blob-Pfade wurden in Firefox getestet. Verwenden Sie ein neues Verzeichnis außerhalb eines bestehenden Projekts oder Workspace. Dieser abgesicherte Erstellungsblock prüft Voraussetzungen und die übergeordnete Paketkonfiguration, bevor er etwas schreibt:
(
command -v node >/dev/null && command -v corepack >/dev/null &&
node --input-type=module -e '
import { existsSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
const [major, minor] = process.versions.node.split(".").map(Number)
if (!(major === 24 && minor >= 15 || major >= 26)) throw Error("Use Node 24.15+ or 26+")
for (let dir = resolve("."); ; dir = dirname(dir)) {
if (["package.json", ".yarnrc.yml", ".pnp.cjs"].some(name => existsSync(join(dir, name)))) {
throw Error("Choose a directory outside an existing project or workspace")
}
if (dirname(dir) === dir) break
}
' && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn --version && mkdir react-stream-demo
) && cd react-stream-demo
Ein bestehendes Verzeichnis react-stream-demo wird abgelehnt. Behalten Sie dessen
Dateien und wählen Sie ein anderes leeres übergeordnetes Verzeichnis, statt es zu löschen. Falls
die Installation später fehlschlägt, wiederholen Sie sie in diesem neuen Projekt; für keinen der
folgenden Schritte müssen Sie es neu erstellen.
Speichern Sie diese Dateien im neuen Verzeichnis. package.json legt die
Versionen der Abhängigkeiten für die Demo fest:
{
"name": "react-stream-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "yarn typecheck && vite build",
"start": "node server.ts"
},
"dependencies": { "react": "19.2.6", "react-dom": "19.2.6" },
"devDependencies": {
"@types/node": "24.10.1",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/wicg-file-system-access": "2023.10.7",
"typescript": "6.0.3",
"vite": "7.3.1"
}
}
Speichern Sie tsconfig.json. Diese Konfiguration ist vollständig und erbt keine
Einstellungen aus extends:
{
"compilerOptions": {
"target": "ES2024",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["node", "react", "react-dom", "wicg-file-system-access"]
},
"include": ["*.ts", "*.tsx"]
}
Speichern Sie .yarnrc.yml, damit Installation, Builds und Start denselben
Linker verwenden:
nodeLinker: node-modules
Speichern Sie index.html; Vite bündelt den TypeScript-Einstiegspunkt:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><meta name="viewport" content="width=device-width, initial-scale=1"><title>React download demo</title></head>
<body><main id="root"></main><script type="module" src="/main.tsx"></script></body>
</html>
Browser-Unterstützung im Überblick
| Browser | Zielauswahl | Pfad für große Dateien hier |
|---|---|---|
| Desktop Chrome und Edge | showSaveFilePicker erkennen | In ausgewählte Datei streamen |
| Firefox und Safari | Kein showSaveFilePicker | Attachment-Link |
Die aktuelle Kompatibilität finden Sie in der MDN-Dokumentation zum Auswahldialog. Die Unterstützung des ursprungsprivaten Dateisystems bedeutet nicht, dass ein Speicherdialog verfügbar ist. Der Auswahldialog benötigt einen sicheren Kontext und eine Nutzergeste; Loopback-HTTP eignet sich für diese lokale Demo. Node 24 ist eine gepflegte LTS-Version; seine native TypeScript-Ausführung reicht für Server und Verifizierer aus, während Vite TSX für den Browser kompiliert.
Speichern Sie downloads.ts. Beide Pfade verwenden dieselbe Leseschleife.
Sie wartet auf den Verbraucher, bevor sie einen weiteren Chunk anfordert, bricht die Antwort bei
Fehlern ab und gibt den Reader frei. Fehlt Content-Length oder bezieht sich der
Wert auf komprimierte Daten, wird der Fortschritt nur in Bytes angezeigt: Fetch stellt decodierte
Bytes des Antwortkörpers bereit, daher ist eine komprimierte
Übertragungslänge kein gültiger Nenner.
export type Progress = { received: number; total: number | null }
export type DownloadResult = { bytes: number; kind: 'saved' | 'handed-off' }
const MAX_BLOB_BYTES = 50 * 1024 * 1024
async function receive(
url: string,
consume: (chunk: Uint8Array<ArrayBuffer>) => Promise<void>,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
limit = Infinity,
): Promise<number> {
signal.throwIfAborted()
const response = await fetch(url, { signal })
if (!response.ok) {
await response.body?.cancel()
throw new Error(`HTTP ${response.status}`)
}
if (!response.body) throw new Error('The response has no readable body')
const reader = response.body.getReader()
const cancelReader = () => { void reader.cancel(signal.reason).catch(() => {}) }
signal.addEventListener('abort', cancelReader, { once: true })
try {
const length = response.headers.get('Content-Length')
const parsed = length === null ? NaN : Number(length)
const encoding = response.headers.get('Content-Encoding')
const total = (!encoding || encoding === 'identity') && Number.isSafeInteger(parsed) && parsed >= 0
? parsed : null
if (total !== null && total > limit) throw new Error('Use the direct download link for this file')
let received = 0
onProgress({ received, total })
while (true) {
signal.throwIfAborted()
const { value, done } = await reader.read()
signal.throwIfAborted()
if (done) break
if (received + value.byteLength > limit) throw new Error('Use the direct download link for this file')
await consume(value)
received += value.byteLength
onProgress({ received, total })
}
if (total !== null && received !== total) throw new Error('Incomplete response')
return received
} finally {
signal.removeEventListener('abort', cancelReader)
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
export async function streamToDisk(
url: string,
filename: string,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
): Promise<DownloadResult> {
signal.throwIfAborted()
const handle = await window.showSaveFilePicker({ suggestedName: filename })
signal.throwIfAborted()
const writable = await handle.createWritable()
try {
const bytes = await receive(url, (chunk) => writable.write(chunk), onProgress, signal)
signal.throwIfAborted()
await writable.close()
signal.throwIfAborted()
return { bytes, kind: 'saved' }
} catch (error) {
await writable.abort(error).catch(() => {})
throw error
}
}
export async function saveWithFallback(
url: string,
filename: string,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
): Promise<DownloadResult> {
const chunks: ArrayBuffer[] = []
const bytes = await receive(url, async (chunk) => {
chunks.push(chunk.slice().buffer)
}, onProgress, signal, MAX_BLOB_BYTES)
signal.throwIfAborted()
const objectUrl = URL.createObjectURL(new Blob(chunks))
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
try {
document.body.appendChild(link)
link.click()
} finally {
link.remove()
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
}
return { bytes, kind: 'handed-off' }
}
Der Blob-Pfad begrenzt die tatsächlich decodierten Bytes auf 50 MiB, auch bei Antworten ohne Längenangabe. Beim Erstellen eines Blobs können zusätzliche Kopien nötig sein. Dies ist daher eine Eingabegrenze der Anwendung, keine Obergrenze für den RAM-Verbrauch des Browsers. Der native Pfad kehrt erst zurück, nachdem der schreibbare Stream geschlossen wurde. Er validiert den Inhalt des Servers nicht; das übernimmt unabhängig davon der Prüfsummenschritt weiter unten.
Einen wiederverwendbaren React-Hook erstellen
Speichern Sie useDownload.ts. Rufen Sie start direkt
in einem Klick-Handler auf: Wenn Sie den Auswahldialog öffnen, bevor Sie auf einen Netzwerkaufruf
warten, bleibt die Nutzeraktivierung erhalten. Die Belegtsperre verhindert überlappende Starts.
Beim Aushängen der Komponente werden aktive Vorgänge abgebrochen, sodass eine alte Übertragung
keine spätere Komponente aktualisieren kann.
import { useEffect, useRef, useState } from 'react'
import { saveWithFallback, streamToDisk } from './downloads.ts'
import type { DownloadResult, Progress } from './downloads.ts'
type UseDownloadReturn = {
progress: Progress | null
result: DownloadResult | null
message: string
busy: boolean
start: (url: string, filename: string) => Promise<void>
cancel: () => void
}
export function useDownload(): UseDownloadReturn {
const [progress, setProgress] = useState<Progress | null>(null)
const [result, setResult] = useState<DownloadResult | null>(null)
const [message, setMessage] = useState('Ready')
const [busy, setBusy] = useState(false)
const active = useRef<AbortController | null>(null)
useEffect(() => () => {
active.current?.abort()
active.current = null
}, [])
async function start(url: string, filename: string): Promise<void> {
if (active.current) return
const controller = new AbortController()
active.current = controller
setBusy(true)
setProgress(null)
setResult(null)
setMessage('Choose a destination or wait for the download…')
const onProgress = (value: Progress) => {
if (active.current === controller && !controller.signal.aborted) setProgress(value)
}
try {
const download = window.isSecureContext && typeof window.showSaveFilePicker === 'function'
? streamToDisk : saveWithFallback
const completed = await download(url, filename, onProgress, controller.signal)
controller.signal.throwIfAborted()
if (active.current !== controller) return
setResult(completed)
setMessage(completed.kind === 'saved'
? `Saved ${completed.bytes.toLocaleString()} bytes. Verify the file below.`
: 'Handed to browser. Check your downloads; disk saving is not confirmed.')
} catch (error) {
if (active.current !== controller) return
setProgress(null)
setMessage(controller.signal.aborted || error instanceof DOMException && error.name === 'AbortError'
? 'Canceled. Saving is not confirmed.'
: 'Download failed. Retry or use the direct download link.')
} finally {
if (active.current === controller) {
active.current = null
setBusy(false)
}
}
}
function cancel(): void { active.current?.abort() }
return { progress, result, message, busy, start, cancel }
}
Speichern Sie main.tsx. Die Option mit 256 MiB nutzt den nativen Pfad;
wählen Sie 1 MiB, um den Blob-Fallback in Firefox auszuprobieren. Der normale Link bleibt für große
Dateien verfügbar.
import { useState } from 'react'
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { useDownload } from './useDownload.ts'
function App(): ReactNode {
const [mib, setMib] = useState('256')
const { progress, message, busy, start, cancel } = useDownload()
const url = `/file?mib=${mib}`
const filename = `fixture-${mib}.bin`
const percent = progress?.total !== null && progress?.total !== undefined && progress.total > 0
? Math.min(99, progress.received / progress.total * 100) : undefined
return (
<section>
<h1>React download demo</h1>
<label>File size (MiB) <select value={mib} disabled={busy} onChange={(event) => setMib(event.target.value)}>
<option value="1">1</option><option value="256">256</option><option value="512">512</option>
</select></label>
<p><button disabled={busy} onClick={() => { void start(url, filename) }}>Save with progress</button>{' '}
<button disabled={!busy} onClick={cancel}>Cancel transfer</button></p>
<p><a href={url}>Direct download</a></p>
{busy && progress !== null ? (
<p><progress aria-label="Download progress" value={percent} max={100} />{' '}
{progress.received.toLocaleString()} bytes received</p>
) : null}
<p role="status">{message}</p>
</section>
)
}
const root = document.getElementById('root')
if (!root) throw new Error('Missing root element')
createRoot(root).render(<App />)
Einen bekannten Binärstream ausliefern
Speichern Sie server.ts. Der Server liefert die erstellte App und ein
unkomprimiertes Attachment vom selben Ursprung aus. Dieses lokale Beispiel benötigt daher weder
CORS noch Authentifizierung. Jeder Block mit 64 KiB enthält ein wiederholtes Binärmuster,
einschließlich Nullbytes. Die Pipe von Node berücksichtigt den Rückstau der Antwort; trennt ein
Client die Verbindung, wird der Erzeuger zerstört. /manifest?mib=256 liefert die
erwartete Byteanzahl und den SHA-256-Hash, ohne die Datei selbst zu senden.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { ServerResponse } from 'node:http'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { setTimeout as delay } from 'node:timers/promises'
const block = Buffer.from(Uint8Array.from({ length: 64 * 1024 }, (_, i) => i % 251))
function manifest(bytes: number) {
const hash = createHash('sha256')
for (let offset = 0; offset < bytes; offset += block.length) {
hash.update(block.subarray(0, Math.min(block.length, bytes - offset)))
}
return { bytes, sha256: hash.digest('hex') }
}
async function serve(url: URL, response: ServerResponse): Promise<void> {
if (url.pathname === '/file' || url.pathname === '/manifest') {
const mib = Number(url.searchParams.get('mib') ?? 256)
if (!Number.isInteger(mib) || mib < 0 || mib > 1024) {
response.writeHead(400).end('Choose an integer size from 0 to 1024 MiB')
return
}
const bytes = mib * 1024 * 1024
if (url.pathname === '/manifest') {
response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(manifest(bytes)))
return
}
response.setHeader('Content-Type', 'application/octet-stream')
response.setHeader('Content-Disposition', `attachment; filename="fixture-${mib}.bin"`)
response.setHeader('Cache-Control', 'no-store')
if (url.searchParams.get('length') !== 'unknown') response.setHeader('Content-Length', bytes)
async function* chunks() {
for (let offset = 0; offset < bytes; offset += block.length) {
yield block.subarray(0, Math.min(block.length, bytes - offset))
await delay(1)
}
}
const source = Readable.from(chunks())
response.on('close', () => source.destroy())
source.on('error', () => response.destroy())
source.pipe(response)
return
}
const asset = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
if (asset !== 'index.html' && !/^assets\/[a-zA-Z0-9._-]+$/.test(asset)) {
response.writeHead(404).end('Not found')
return
}
const body = await readFile(join(import.meta.dirname, 'dist', asset))
response.writeHead(200, { 'Content-Type': asset.endsWith('.js') ? 'text/javascript' : 'text/html' }).end(body)
}
async function main(): Promise<void> {
const server = createServer((request, response) => {
void serve(new URL(request.url ?? '/', 'http://localhost'), response).catch(() => {
if (response.headersSent) response.destroy()
else response.writeHead(500).end('Could not serve the demo. Build it first.')
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(Number(process.env.PORT ?? 8787), '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listening address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => { console.error(error); process.exitCode = 1 })
Installieren Sie lokal mit dem Linker node-modules von Yarn. Führen Sie dann
die Typprüfung und den Build aus und starten Sie den Server:
corepack yarn install
corepack yarn build && corepack yarn start
Öffnen Sie http://127.0.0.1:8787, klicken Sie auf Save with progress
und wählen Sie im nativen Speicherdialog einen neuen Pfad namens
fixture-256.bin. Bestätigen Sie alle Browser-Abfragen zur Schreibberechtigung.
Warten Sie auf den Status, der mit Saved beginnt.
Lassen Sie den Server für die Verifizierung laufen. Strg+C beendet ihn; falls Port 8787 belegt ist,
verwenden Sie PORT=8788 corepack yarn start und den entsprechenden Ursprung im Verifizierer.
Sicherheit, Berechtigungen und Fehlerbehandlung
Der Speicherdialog erfordert eine Nutzeraktivierung. Belassen Sie ihn im Klick-Pfad; warten Sie nicht zuerst auf eine API-Anfrage. Verwenden Sie auf einer bereitgestellten Website HTTPS. Wählen Sie neue Ausgabepfade, deren Dateien entbehrlich sind. Wenn Sie das Ersetzen bestätigen, ist die bestehende Datei bereits vor dem erfolgreichen Abschluss gefährdet: Im getesteten GTK-Dialog unter Linux blieb das bestätigte Ziel leer, nachdem das abschließende Schließen abgelehnt wurde. Das Abbrechen eines fehlgeschlagenen Schreibvorgangs garantiert nicht, dass diese Datei wiederhergestellt wird. Auch ein abgebrochener Speichervorgang an einem neuen Pfad kann eine leere Datei hinterlassen. Ein Abbruch während des abschließenden Dateisystem-Commits kann nicht garantieren, dass bereits festgeschriebene Bytes rückgängig gemacht werden.
Probieren Sie aus, den Auswahldialog abzubrechen, nach Beginn der Fortschrittsanzeige abzubrechen und erneut zu speichern. Weder ein Abbruch noch ein HTTP-, Lese-, Schreibfehler oder ein Fehler beim abschließenden Schließen darf zum Status für erfolgreiches Speichern führen. Der Hook setzt sein vorheriges Ergebnis bei jedem Start zurück. Er öffnet jedes Mal einen neuen Auswahldialog und speichert weder Handles noch einen Berechtigungsstatus, auch nicht über Neuladen oder Browser-Neustarts hinweg. Workflows mit gespeicherten Handles benötigen eigene Berechtigungsprüfungen.
Geben Sie in Ihrer eigenen API bei Fehlern einen aussagekräftigen Status zurück. Ohne
vertrauenswürdige Länge oder Prüfsumme lässt sich eine Antwort, die sauber, aber vorzeitig endet,
nicht von einer kürzeren gültigen Datei unterscheiden.
Konfigurieren Sie für ursprungsübergreifende Fetch-Aufrufe CORS und geben Sie alle Header frei, die
Sie für die Fortschrittsanzeige verwenden. Ein direkter Link kann keinen benutzerdefinierten
Authorization-Header hinzufügen; verwenden Sie Sitzungsauthentifizierung oder eine autorisierte
Download-URL.
Content-Disposition: attachment ist für ursprungsübergreifende
Downloads wichtig; das Attribut download allein reicht nicht aus.
Die tatsächlich gespeicherte Datei prüfen
Speichern Sie verify.ts. Der Verifizierer liest die Datei als Stream vom
Datenträger und vergleicht sowohl Größe als auch Hash mit dem lokalen Fixtures-Manifest. Er prüft
den von Ihnen übergebenen Pfad, nicht eine andere generierte Datei oder den Browser-Status.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
async function main(): Promise<void> {
const [path, mib = '256'] = process.argv.slice(2)
if (!path) throw new Error('Usage: node verify.ts /path/to/fixture-256.bin [MiB]')
const origin = process.env.DOWNLOAD_ORIGIN ?? 'http://127.0.0.1:8787'
const response = await fetch(`${origin}/manifest?mib=${encodeURIComponent(mib)}`)
if (!response.ok) throw new Error(`Manifest HTTP ${response.status}`)
const expected: unknown = await response.json()
if (typeof expected !== 'object' || expected === null || !('bytes' in expected) ||
!('sha256' in expected) || typeof expected.bytes !== 'number' || typeof expected.sha256 !== 'string') {
throw new Error('Invalid manifest')
}
const hash = createHash('sha256')
let bytes = 0
for await (const chunk of createReadStream(path)) {
bytes += chunk.length
hash.update(chunk)
}
const actual = hash.digest('hex')
if (bytes !== expected.bytes || actual !== expected.sha256) throw new Error('Size or SHA-256 mismatch')
console.log(`Verified ${bytes} bytes; SHA-256 ${actual}`)
}
main().catch((error: unknown) => { console.error(error); process.exitCode = 1 })
Wechseln Sie in einem zweiten Terminal in das Projekt und führen Sie den Verifizierer mit dem Pfad Ihrer tatsächlich gespeicherten Datei aus:
node verify.ts "/absolute/path/to/fixture-256.bin" 256
Die erwartete Ausgabe ist Verified 268435456 bytes; SHA-256 …. Eine andere Datei gleicher Länge muss
mit Size or SHA-256 mismatch fehlschlagen. Übergeben Sie für die Option mit 1 MiB
1 als letztes Argument. In Firefox übergibt
Save with progress einen kleinen Blob an den Browser und zeigt
Handed to browser. Check your downloads; disk saving is not confirmed.
Verwenden Sie Direct download für die Datei mit 256 MiB und
verifizieren Sie anschließend auch diese Datei auf dem Datenträger. Weder das Starten eines
Link-Downloads noch das Erstellen einer Objekt-URL bestätigt das Speichern.
Speicherverbrauch im Vergleich
| Ansatz | Anwendungspuffer | Von dieser App erkennbarer Abschluss |
|---|---|---|
response.blob() | Vollständige Antwort vor Übergabe | Blob erstellt |
| In ausgewählte Datei streamen | Chunk-Schreibvorgang vor nächstem Lesen abwarten | Schreibbarer Stream erfolgreich geschlossen |
| Begrenzter Blob-Fallback | Bis zu 50 MiB plus Blob-Kopien | Übergabe an Browser |
| Attachment-Link | Vom Browser verwaltete Übertragung | Download-Oberfläche des Browsers nutzen |
Um das Anwendungsverhalten zu vergleichen, speichern Sie die Optionen mit 256 MiB und 512 MiB und untersuchen Sie die Lese-/Schreibschleife. Im nativen Pfad sammelt sie niemals Chunks an. Langsame Schreibvorgänge verzögern den nächsten Lesevorgang der Anwendung; Fetch und der Browser können weiterhin im Voraus puffern. Messungen des JavaScript-Heaps allein belegen weder eine Obergrenze für den RAM-Verbrauch des gesamten Browsers noch einen Geschwindigkeitsvorteil oder die dauerhafte Speicherung im Dateisystem nach einem Stromausfall.
Fazit
Behalten Sie die Prüfsummenverifizierung bei, wenn Sie die Fixture durch einen echten Export-Endpunkt ersetzen. Verwenden Sie für diese Datei eine unabhängig als vertrauenswürdig bestätigte Prüfsumme des Herausgebers und halten Sie überall dort einen browserverwalteten Attachment-Link bereit, wo der native Auswahldialog fehlt. Diese Demo führt einen neuen Download aus; sie setzt weder eine abgebrochene Übertragung fort noch speichert sie den Zugriff auf frühere Ziele dauerhaft.
