Skip to content

Latest commit

 

History

History
170 lines (114 loc) · 9.55 KB

File metadata and controls

170 lines (114 loc) · 9.55 KB

parallel-range-downloader

English · Deutsch · Türkçe

Ein abhängigkeitsfreier paralleler HTTP-Range-Downloader, der unverändert im Browser, in Chrome-MV3-Erweiterungen und unter Node.js 18+ läuft.

import { download } from './src/range-downloader.js';

const { blob, parallel } = await download('https://example.com/large.zip', {
  connections: 8,
  onProgress: ({ percent, megabytesPerSecond }) =>
    console.log(`${percent}% mit ${megabytesPerSecond.toFixed(2)} MB/s`)
});

Motivation

Eine einzelne HTTP-Verbindung ist häufig nicht der Engpass, den das Netz darstellt. Viele Server drosseln den Durchsatz pro Verbindung: Ein Stream bleibt weit unter der verfügbaren Leitungsgeschwindigkeit, während acht parallele Streams sie ausschöpfen. Die Lösung ist bekannt — Byte-Bereiche gleichzeitig anfordern und wieder zusammensetzen — doch genau in den Details beschädigen sich Downloads unbemerkt selbst.

Diese Bibliothek existiert, weil sich diese Details lohnen, einmal sauber festgehalten zu werden:

  • Ein Server kann eine Range-Anfrage mit 200 und dem vollständigen Body beantworten statt mit 206 und dem angeforderten Ausschnitt. Wird das eingefügt, verschiebt sich jedes nachfolgende Byte — und die Datei fällt erst viel später auf, in dem Programm, das sie liest.
  • Ein Chunk kann zu kurz zurückkommen. Nimmt man ihn an, entsteht eine Datei, die vollständig aussieht und es nicht ist.
  • Credentials sind entscheidend. Eine Anfrage, die Cookies benötigt, aber ohne sie gesendet wird, erhält 403 — und eine Prüfung, die nur „Größe unbekannt“ meldet, verbirgt den eigentlichen Grund.

Jeder dieser Fälle wird ausdrücklich behandelt und ist durch einen Test abgedeckt.

Funktionen

  • Parallele Byte-Range-Downloads über einen begrenzten Worker-Pool — die Verbindungszahl ist eine Obergrenze, kein Thread pro Chunk.
  • Automatischer Rückfall. Unterstützt der Server keine Ranges, wird der Download trotzdem einspurig abgeschlossen, statt zu scheitern.
  • Byteweise Überprüfung. Jeder Chunk wird gegen seine erwartete Länge geprüft, das Ergebnis gegen die angegebene Gesamtgröße.
  • Wiederholungen mit Backoff, mit eigenem Budget für Server, die Ranges ignorieren, damit diese nicht die Versuche aufbrauchen, die für echte Netzfehler gedacht sind.
  • Fortschritt mit geglätteter Geschwindigkeit und Restzeit.
  • AbortSignal-Unterstützung durchgängig, einschließlich der Backoff-Pausen.
  • Diagnostizierbare Fehler. Fehler tragen das Protokoll der Prüfversuche (error.attempts) — aus „Download fehlgeschlagen“ wird eine Liste dessen, was versucht wurde und was jeweils zurückkam.
  • Keine Abhängigkeiten. Nur fetch, Blob und AbortSignal.

Installation

Kein Build-Schritt, kein Paketmanager nötig — das Modul ist reines ESM.

git clone https://github.com/tafirnat/parallel-range-downloader.git
cd parallel-range-downloader
npm test          # führt die Tests gegen lokale HTTP-Server aus

Für das eigene Projekt genügt es, src/range-downloader.js zu kopieren oder dieses Repository als Abhängigkeit einzubinden.

Schnellstart

Node.js

node examples/node-cli/download.js https://example.com/large.zip out.zip --connections 8
[########################################] 100%  512.0 MB/512.0 MB  11.83 MB/s  ETA 00:00
Saved out.zip (512.0 MB) in 43.2s via 8 parallel connections.

Browser / Erweiterung

import { download } from './range-downloader.js';

const controller = new AbortController();
document.querySelector('#cancel').onclick = () => controller.abort();

const { blob } = await download(url, {
  connections: 8,
  chunkSize: 10 * 1024 * 1024,
  signal: controller.signal,
  onProgress: ({ percent }) => (progressBar.style.width = `${percent}%`)
});

const objectUrl = URL.createObjectURL(blob);
// ... an den Download übergeben, danach immer:
URL.revokeObjectURL(objectUrl);

API

download(url, options?)

Prüft die URL und lädt dann parallel, wenn der Server es zulässt, sonst einspurig. Das ist der Einstiegspunkt für die meisten Anwendungsfälle.

Liefert { blob, size, parallel, attempts }; parallel zeigt an, welcher Weg genommen wurde.

probeRange(url, options?)

Ermittelt Dateigröße und Range-Unterstützung, ohne die Datei zu laden.

Liefert { size, rangeSupported, init, attempts }. Ein size von 0 bedeutet, dass der Server überhaupt nicht gelesen werden konnte — attempts nennt den Grund.

Verwendet wird ein Ein-Byte-Range-GET statt HEAD, denn viele Server beantworten HEAD anders als GET (oder lehnen es ab), und die Frage „lieferst du Ranges“ ist nur verlässlich beantwortet, wenn tatsächlich eine Range angefordert wurde. HEAD bleibt als Rückfallebene erhalten.

downloadParallel(url, options)

Die Engine selbst, wenn die Größe bereits bekannt ist. Benötigt options.size. Liefert einen Blob.

Optionen

Option Standard Gilt für Bedeutung
size downloadParallel Gesamtgröße in Bytes. Erforderlich.
chunkSize 10 MiB download, parallel Bytes pro Range-Anfrage.
connections 8 download, parallel Maximale gleichzeitige Anfragen.
retries 4 download, parallel Versuche pro Chunk vor dem Abbruch.
onProgress download, parallel Wird nach jedem fertigen Chunk aufgerufen.
fetchInit {} alle Wird in jedes fetch übernommen (Header, Mode, ...).
credentialModes include, dann omit probe, download Zu testende Credentials-Modi, in dieser Reihenfolge.
signal alle AbortSignal.

Progress

{
  downloadedBytes, totalBytes, percent,
  bytesPerSecond, megabytesPerSecond, etaSeconds,
  completedChunks, totalChunks
}

Die Geschwindigkeit wird über 500-ms-Fenster geglättet; die rohen Werte pro Chunk schwanken für eine Anzeige viel zu stark.

DownloadError

Wird bei jedem Fehler aus diesem Modul geworfen. Trägt cause, sofern vorhanden, und attempts — das Protokoll der Prüfversuche —, wenn der Fehler bei der Prüfung entstand.

Funktionsweise

  1. Prüfen. Ein Ein-Byte-Range-GET klärt Gesamtgröße, 206-Unterstützung und den Credentials-Modus, auf den der Server antwortet. Dieser Modus wird für alle Chunk-Anfragen wiederverwendet: Die falsche Wahl ist der Unterschied zwischen vollem Tempo und einem stillen 403.
  2. Pool. min(connections, chunkCount) Worker holen sich Chunk-Indizes von einem gemeinsamen Zähler. Jeder lädt seinen Bereich, prüft die Länge und legt den Puffer an seinem Index ab — die Reihenfolge hängt damit nie von der Fertigstellungsreihenfolge ab.
  3. Verifizieren. Vor dem Zusammensetzen muss jeder Platz belegt sein, danach muss die Blob-Größe der angegebenen Gesamtgröße entsprechen. Schlägt eine der Prüfungen fehl, wird ein Fehler geworfen, statt eine plausibel wirkende Datei zurückzugeben.
  4. Freigeben. Beim Erzeugen des Blob werden die Daten kopiert, daher werden die Quellpuffer sofort verworfen — das halbiert den Spitzenspeicher für den Rest des Laufs. Auch im Fehlerfall werden sie verworfen: Ein gescheiterter 4-GB-Download darf keine 4 GB hinter einer abgelehnten Promise festhalten.

Chrome MV3: das Offscreen-Dokument

examples/chrome-mv3/ ist eine lauffähige Erweiterung, die die Architektur zeigt, mit der lange Downloads unter Manifest V3 überhaupt möglich sind.

Ein MV3-Service-Worker wird nach etwa 30 Sekunden Inaktivität beendet — weit weniger, als ein großer Download braucht, und er nimmt die laufenden Puffer mit. Ein Offscreen-Dokument ist ein normaler DOM-Kontext, der so lange lebt, wie die Erweiterung ihn offen hält. Download, Chunk-Puffer und Blob-Aufbau liegen deshalb dort; der Service-Worker beschränkt sich auf Nachrichtenvermittlung und chrome.downloads.

Die Lebensdauer der Object-URL ist die andere Hälfte des Problems. Das Beispiel gibt jede URL erst frei, wenn chrome.downloads.onChanged das Ende des Speicherns meldet: Früheres Freigeben bricht den Speichervorgang ab, gar kein Freigeben hält die gesamte Datei für die Lebensdauer des Dokuments im Speicher.

Das Beispiel bindet eine eigene Kopie der Engine ein, da eine Erweiterung nicht außerhalb ihres Wurzelverzeichnisses importieren kann. Nach Änderungen an src/ bitte npm run sync:example ausführen.

Grenzen

Vor dem Einsatz zu wissen:

  • Die gesamte Datei wird im Speicher gehalten, bevor sie zurückgegeben wird. Für einige hundert Megabyte ist das unproblematisch, für zweistellige Gigabyte-Größen falsch. Jeden Chunk direkt auf die Festplatte zu schreiben würde diese Grenze aufheben, aber eine deutlich größere API erfordern.
  • Kein Fortsetzen über Sitzungen hinweg. Wiederholungen fangen einen fehlgeschlagenen Chunk innerhalb eines Laufs ab; stirbt der Prozess, ist nichts gespeichert.
  • Parallelität hilft gegen Drosselung pro Verbindung, nicht gegen eine tatsächlich ausgelastete Leitung. Füllt eine Verbindung die Leitung bereits, machen acht sie nicht breiter.
  • credentials ist ein Browser-Konzept. Unter Node hat es keine Wirkung, und der erste Modus gewinnt schlicht.

Tests

npm test

Sieben Tests laufen gegen kurzlebige lokale HTTP-Server und decken den regulären Weg sowie die relevanten Fehlerfälle ab: ein Server, der Ranges ignoriert, ein Server, der zu kurze Chunks liefert, eingestreute 503-Antworten und ein Abbruch mitten im Download. Die korrekte Zusammensetzung wird per SHA-256 gegen die Ausgangsdaten geprüft, nicht nur über die Größe.

Lizenz

MIT — siehe LICENSE.