以下の内容はhttps://blog.inorinrinrin.com/entry/2026/02/14/171844より取得しました。


TypeScript製ライブラリ VerifyFetch で「切れない」ファイルダウンロードを作ってみる

Webブラウザで数KBのJSONを取得するなら標準の fetch で十分ですが、例えばブラウザ上のWeb Viewerで画像を数十枚表示するなどのユースケースに標準のfetchで対応しようとする場合以下のような問題がつきまとうことが多いでしょう。

  1. 再開不能: ネットワーク瞬断やリロードでダウンロード進捗が0に戻る
  2. 整合性不明: ダウンロードが完了しない限りファイルが破損なくダウンロードできているかわからない
  3. メモリ逼迫: 全データをメモリに展開する必要があり、クラッシュのリスクがある

これらの問題を解決するためのライブラリとして、今回は VerifyFetch を使ってみようと思います。

github.com

VerifyFetchの特徴

先述の3つの問題への対処として、VerifyFetchは3つの機能を提供しています。

1. 分割ダウンロードと都度検証

VerifyFetchでは、あらかじめサーバーサイドでマニフェストと呼ばれるファイルを作成します。そのためのAPIとして、generateChunkedHashesを提供しています。以下のコードは、large-file.datという50MBのファイルに対するマニフェストを作成するサンプルです。

import { generateChunkedHashes } from "verifyfetch";
import fs from "fs/promises";

async function main() {
    const filePath = "public/large-file.dat";
    const outputPath = "public/vf.manifest.json";

    console.log(`Reading ${filePath}...`);
    const data = await fs.readFile(filePath);

    console.log("Generating chunked hashes...");
    const chunkedInfo = await generateChunkedHashes(data);

    const manifest = {
        url: "/large-file.dat",
        chunked: chunkedInfo,
    };

    console.log(`Writing manifest to ${outputPath}...`);
    await fs.writeFile(outputPath, JSON.stringify(manifest, null, 2));
    console.log("Done.");
}

main().catch(console.error);

この結果、以下のようなファイルが作成されます。

{
  "url": "/large-file.dat",
  "chunked": {
    "root": "sha256-hZdsFAJ7IxJmmGc6vd87yRxp0uO+Qwdj/VyCew6VvO8=",
    "chunkSize": 1048576,
    "hashes": [
      "sha256-bQr9R3b7zx8lUt2+AXadihrLpyidXPQlW2DCsTSElp8=",
      "sha256-Tpan8NLdrJqzFw3ty7j3pK/JN0QL5dW0ICwWD64Jz9s=",
      "sha256-KPcMNIOVmkjqZYDRB3+v+uxpoHbOPRUq3IZ23QpiajQ=",
      "sha256-HPtu7eAPGuNOlAALuEBHYgwOQ27qsyn9cD1GEYlCV6Q=",
      "sha256-Eic95JD6pGeB+F4YSI14dsCg1Y5B9GS6GjNVoViuvGI=",
      "sha256-TXgmw5fJzNmt1qOkAOQTmql0R0MQmCjPKehadxytZF8=",
      "sha256-69fBRWmoOLFRi2LWZzfwuyoWmPTvgyINycAWIu1n7js=",
      "sha256-Ug6s9cQTsh8uF//Dz+il4VE9hBTYKQRK4QtqulXu0eE=",
      "sha256-aRdJhtZpphtnVGZC507w2bSyC+ZoVt0zOP1YSkbR/pY=",
      "sha256-V7uoRJ8ea8GkYY+Y0pxLn9LFd31oUb1e3kOjhTKz+Zo=",
      "sha256-IVXu0E3xCtrJbeD/+uhZKHClLTkLs2dcLnB+lugPfJo=",
      "sha256-mDtGSQK8A4cTI1X6yXAYpgR9q3jAk/+lhKo7Lt6yv7g=",
      "sha256-L2z4mQ/6yxzZs7DolSKGJ6ova4iszFOllIkzSmiF3Is=",
      "sha256-8rzihS9fY5PHB4wk51k23bUcOiQ04bmoti1eJO3NvKI=",
      "sha256-zwatRRvu5azwL1tx6w5jJBOjF+EAVBTuWza35ak0dAM=",
      "sha256-aWYLDphFKIufpguThJzlgsC8tha4u3AchlJFg7LvhwI=",
      "sha256-HieeGiMVmcO5MRO+bOhkk5oQFzrGSKrGqdkkDUHEC6g=",
      "sha256-sGwnZ0ZCd82VAVxalrFwI87JUs9i599fM4DohealFb0=",
      "sha256-Z9oQLaHST255fxw7xx6ctF/nisNwAkLHQKD3t72ESqw=",
      "sha256-cXJQbduTZhhtIFulwncBtceJJ5achTsciXdm40NYa1M=",
      "sha256-dbJOCndq4DUyh5DPRpc5jCDlaMus6s2aVYDIG1YP6oc=",
      "sha256-f2/KNeKKGXwK416groWO4lboQkgDVNuGugrqxPAbcd0=",
      "sha256-g0DR0qoxbcMy7HtK05k6ib0OUvqvg2l/h32KSIB3pOI=",
      "sha256-JuL4OnqB2mdFdMDDgWYXVcmfDG32u12CFRUv+qBa/WQ=",
      "sha256-WkFol6BlcVeLgbFWyYLTpupmWiw0bUHg3zpws1AyWmk=",
      "sha256-loM7jeDmZjO+LxJV3uYs/17R1k7kC62Jkg/m3fucA5s=",
      "sha256-ibMZokxDUhbCa2FqGqvpzV6nGUThYtmBKDZbBQMvGtY=",
      "sha256-T3dkYIjtu1cgRDTXbPclaJNis+t71O56D8nDWPwc8No=",
      "sha256-k9s0+OU+jEWrxwy+FEGaW/SM2jdkAqQzKH36FyH0ZC0=",
      "sha256-F12qrXAR40as/GHTUIZIR2IZ3xRh1T8J1DCW0OZBh4g=",
      "sha256-hhcFqAP8fWuv1jiLpW4pBXBXKFgo70iXv2IS5d0+Kuk=",
      "sha256-5NmGGiL44bWEGGHJ3iymUDQzOFpKhcRFZ6immrs7jKw=",
      "sha256-l3v/mZBx/sg7scubIguWk9+O1P4Idd6pWWKu5N64uac=",
      "sha256-eG2NyV4APsYfjRNfKudynagKQkLdUClpOV2SIVmA3Bk=",
      "sha256-F8a7crRr7nhBQtw+rzzxVNOcrGiDtOle5rl1y/uYHeA=",
      "sha256-oNxtqT8JvxY9NuNHtzED5PetNmUKNUn0MRzqAwI/FwE=",
      "sha256-Wd1eQnRxoK+dcqrt/vHJ0ez6Id1fd1ri8jXiUC4CiQ0=",
      "sha256-6v1voVyGr07iebH0J/dvuTU8NyekLQ3RQ+Qnr0pRxKA=",
      "sha256-Muab0gbDyQbktRy+FnuZQ2aH5ri1l/h1Gp+8JEwnF2U=",
      "sha256-vdmztQ3wlUGMqKhjc+A9fbI5CRYPPnhT2K072nfGt54=",
      "sha256-w7dZjLVlBsQ50k4PtqWEes8gTAvpwR8u2ihT2LVQwMg=",
      "sha256-DduQQ//Nt/DZC894Y9Xbr9+EwF8r4HcV6G3ntwdBBbc=",
      "sha256-S6RerNGaoNT+8MjpLXDW3hPB9rlnrgEkMB3zyFUyNDY=",
      "sha256-CUFe2BqMcbJpD8lU1xqoRaNYzLs1oquZsFFv3Zh53Ig=",
      "sha256-GhNa3dWAP2VzzKeglNxJWSiDJds+Duk53mAXEl7dZI4=",
      "sha256-LpvMAA5/OybmYeBmolUU3PSplFalJNa74nuGXFQWmnA=",
      "sha256-ZaQs6H+PnATkWFHPiRxCYlERspFzhJHRHs9RrAH7Lzo=",
      "sha256-hekQs1LRpF/ilGfN+UKdopn+9WTgTCIrhZkalPSNYHA=",
      "sha256-Jm0dAfIj1yC4eFgz9sujx41DVkUPUgmxbOIyxMAgnDo=",
      "sha256-UQHBasOxFqLfV+FRaTbdbhNDAu80GXuiHywsFsxBzw8="
    ]
  }
}

これはファイル全体のハッシュと、1MBごとにファイルを分割した際のハッシュです。

クライアント側では、本体のダウンロードの前にまずこのマニフェストファイルをfetchします。そして本題となる後続のダウンロードの際にはマニフェストであらかじめ決められたチャンクごとに、マニフェストのハッシュ値とダウンロードしたデータのハッシュ値を計算し、整合性を確認します。不整合があれば再フェッチを行い、整合していればダウンロードを継続します。

最終的にファイルをすべて読み込んだのち、rootのハッシュ値とダウンロードしたデータから算出したハッシュが合致するかを検証し、データの整合性を担保します。

2. IndexedDBへの永続化

VerifyFetchでは途中までダウンロードしたデータをIndexedDBへ保存します。これにより、メモリ上に展開されるデータは常にチャンクサイズ分だけのデータとなります。

またこれにより、ダウンロード中にリロードが発生した場合でも、途中からデータの取得を再開することができます。

ダウンロードの中断と再開をfetchと比較してみる

ではここからは、fetchと比較する形でデータのダウンロードの中断と再開の挙動を検証してみようと思います。今回の検証環境はこちらにあります。

github.com

ダウンロードを遅くするためのサーバーを用意する

今回はHonoを使ってGET /large-file.datのエンドポイントを提供するサーバーをサクッと作りました。実装の肝は、rangethrottleです。

app.get("/large-file.dat", async (c) => {
    const filePath = "./public/large-file.dat";
    const stats = statSync(filePath);
    const fileSize = stats.size;
    const range = c.req.header("range");

    let start = 0;
    let end = fileSize - 1;
    let status = 200;
    let headers = {
        "Content-Type": "application/octet-stream",
        "Accept-Ranges": "bytes",
    };

    if (range) {
        const parts = range.replace(/bytes=/, "").split("-");
        start = parseInt(parts[0], 10);
        end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;

        if (start >= fileSize) {
            c.status(416);
            return c.text("Requested Range Not Satisfiable");
        }

        const chunksize = end - start + 1;
        headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
        headers["Content-Length"] = chunksize.toString();
        status = 206;
    } else {
        headers["Content-Length"] = fileSize.toString();
    }

    const fileStream = createReadStream(filePath, { start, end });

    const BPS = 1024 * 1024;
    const throttledStream = fileStream.pipe(new Throttle(BPS));

    const readable = Readable.toWeb(throttledStream);

    return c.newResponse(readable, status, headers);
});

Range Requests (RFC 7233)

tex2e.github.io

VerifyFetch が「続きからダウンロード」を行うためには、サーバーが Range Requests (RFC 7233) に対応している必要があります。クライアントが Range: bytes=5000000-(5MB目からくれ)と要求してきた際、サーバーはファイルの該当箇所を切り出して 206 Partial Content を返す実装を行っています。

Throttle

通常のローカルサーバーの実装ではダウンロード完了までが早すぎて「途中中断」の検証ができません。そこで throttle パッケージを使い、擬似的に転送速度を絞ります。

github.com

throttle パッケージは、Transform Streamという機能を使いデータの流れを 指定した速度(バイト/秒) に制限します。今回は1MB/sに速度を絞ることで、large-file.datのダウンロードに最低50秒かかるように制限しています。

// 1. ファイルを読み込むストリームを作成
const fileStream = createReadStream(filePath, { start, end });

// 2. 帯域制限を設定:1MB/s
// これにより 50MB のファイルは「必ず50秒」かかるようになる
const BPS = 1024 * 1024;
const throttledStream = fileStream.pipe(new Throttle(BPS));

fetchとVerifyFetchを実行するクライアントを用意する

それぞれダウンロード開始・中断・再開ができるようにしています。中断や再開にはsignalを使います。それぞれの実装は以下の通りです。

import {
    verifyFetchResumable,
    clearOldDownloads,
    getDownloadProgress,
} from "/verifyfetch/index.js";

const FILE_URL = "/large-file.dat";
const MANIFEST_URL = "/vf.manifest.json";

// --- Standard Fetch State ---
let fetchController = null;
let fetchBytesReceived = 0;
let fetchTotalBytes = 0;
let fetchChunks = [];
let fetchIsPaused = false;

// --- VerifyFetch State ---
let verifyController = null;
let verifyIsPaused = false;
let verifyManifest = null;

// UI Elements
const fetchStatus = document.getElementById("fetch-status");
const fetchProgress = document.getElementById("fetch-progress");
const fetchBtnStart = document.getElementById("fetch-btn-start");
const fetchBtnPause = document.getElementById("fetch-btn-pause");
const fetchBtnResume = document.getElementById("fetch-btn-resume");

const verifyStatus = document.getElementById("verify-status");
const verifyProgress = document.getElementById("verify-progress");
const verifyBtnStart = document.getElementById("verify-btn-start");
const verifyBtnPause = document.getElementById("verify-btn-pause");
const verifyBtnResume = document.getElementById("verify-btn-resume");

// --- Helper: Update UI ---
function updateFetchUI(status, progress) {
    if (status) fetchStatus.textContent = status;
    if (progress !== undefined) fetchProgress.value = progress;

    fetchBtnStart.disabled = fetchController || fetchIsPaused;
    fetchBtnPause.disabled = !fetchController;
    fetchBtnResume.disabled = !fetchIsPaused;
}

function updateVerifyUI(status, progress) {
    if (status) verifyStatus.textContent = status;
    if (progress !== undefined) verifyProgress.value = progress;

    verifyBtnStart.disabled = verifyController || verifyIsPaused; // Disable start if running or paused (use resume)
    verifyBtnPause.disabled = !verifyController;
    verifyBtnResume.disabled = !verifyIsPaused;
}

// --- Standard Fetch Implementation ---

async function startFetch() {
    fetchChunks = [];
    fetchBytesReceived = 0;
    fetchIsPaused = false;
    updateFetchUI("Starting...", 0);

    try {
        fetchController = new AbortController();
        const response = await fetch(FILE_URL, {
            signal: fetchController.signal,
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);

        const contentLength = response.headers.get("Content-Length");
        fetchTotalBytes = contentLength ? parseInt(contentLength, 10) : 0;

        updateFetchUI("Downloading...", 0);
        await readStream(response.body);

        updateFetchUI("Completed!", 100);
        fetchController = null;
    } catch (err) {
        if (err.name === "AbortError") {
            updateFetchUI("Paused");
        } else {
            updateFetchUI(`Error: ${err.message}`);
            fetchController = null;
        }
    }
}

async function pauseFetch() {
    if (fetchController) {
        fetchController.abort();
        fetchController = null;
        fetchIsPaused = true;
        updateFetchUI("Paused");
    }
}

async function resumeFetch() {
    if (!fetchIsPaused) return;
    fetchIsPaused = false;
    updateFetchUI("Resuming...");

    try {
        fetchController = new AbortController();
        const headers = { Range: `bytes=${fetchBytesReceived}-` };
        const response = await fetch(FILE_URL, {
            headers,
            signal: fetchController.signal,
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);

        if (response.status !== 206) {
            console.warn(
                "Server did not return 206 Partial Content. Restarting?",
            );
        }

        updateFetchUI("Downloading...");
        await readStream(response.body);

        updateFetchUI("Completed!", 100);
        fetchController = null;
    } catch (err) {
        if (err.name === "AbortError") {
            updateFetchUI("Paused");
        } else {
            updateFetchUI(`Error: ${err.message}`);
            fetchController = null;
        }
    }
}

async function readStream(readableStream) {
    const reader = readableStream.getReader();

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        fetchChunks.push(value);
        fetchBytesReceived += value.length;

        if (fetchTotalBytes > 0) {
            const percent = (fetchBytesReceived / fetchTotalBytes) * 100;
            updateFetchUI(null, percent);
        }
    }
}

// --- VerifyFetch Implementation ---

async function loadManifest() {
    const res = await fetch(MANIFEST_URL);
    verifyManifest = await res.json();
}

async function runVerifyFetch() {
    if (!verifyManifest) await loadManifest();

    verifyController = new AbortController();
    const signal = verifyController.signal;

    try {
        const fetchImpl = (url, init) => {
            return fetch(url, { ...init, signal });
        };

        await verifyFetchResumable(FILE_URL, {
            chunked: verifyManifest.chunked,
            fetchImpl: fetchImpl,
            onProgress: (progress) => {
                const percent =
                    (progress.bytesVerified / progress.totalBytes) * 100;
                const speed = progress.speed
                    ? (progress.speed / 1024 / 1024).toFixed(2) + " MB/s"
                    : "";
                updateVerifyUI(
                    `Downloading... (${Math.round(percent)}%) - ${speed}`,
                    percent,
                );
            },
            onResume: (state) => {
                console.log("Resumed from state:", state);
                updateVerifyUI("Resumed download");
            },
        });

        updateVerifyUI("Verified & Completed!", 100);
        verifyController = null;
        verifyIsPaused = false;
    } catch (err) {
        if (err.name === "AbortError" || signal.aborted) {
            updateVerifyUI("Paused");
            verifyIsPaused = true;
        } else {
            console.error(err);
            updateVerifyUI(`Error: ${err.message}`);
            verifyController = null;
        }
    }
}

async function startVerify() {
    await clearOldDownloads(0);
    verifyIsPaused = false;
    updateVerifyUI("Starting...", 0);
    await runVerifyFetch();
}

async function pauseVerify() {
    if (verifyController) {
        verifyController.abort();
        verifyController = null;

        verifyIsPaused = true;
        updateVerifyUI("Paused");
    }
}

async function resumeVerify() {
    verifyIsPaused = false;
    updateVerifyUI("Resuming...");
    await runVerifyFetch();
}

async function checkResumeState() {
    try {
        const progress = await getDownloadProgress(FILE_URL);
        if (
            progress &&
            progress.bytesVerified < (progress.totalBytes || Infinity)
        ) {
            verifyIsPaused = true;
            const percent =
                (progress.bytesVerified / progress.totalBytes) * 100;
            updateVerifyUI(`Paused (Resumable)`, percent);
        }
    } catch (e) {
        console.warn("Failed to check resume state:", e);
    }
}

// Event Listeners
fetchBtnStart.addEventListener("click", startFetch);
fetchBtnPause.addEventListener("click", pauseFetch);
fetchBtnResume.addEventListener("click", resumeFetch);

verifyBtnStart.addEventListener("click", startVerify);
verifyBtnPause.addEventListener("click", pauseVerify);
verifyBtnResume.addEventListener("click", resumeVerify);

// Initial UI
updateFetchUI("Ready", 0);
updateVerifyUI("Ready", 0);

// Check if there is a pending download
checkResumeState();

検証

youtu.be

まず両者共に、中断と再開は問題なくできています。

ここで目に見える両者の違いとして、VerifyFetchではダウンロードの進捗と速度が出ていることです。

VerifyFetchではprogress.speedというAPIが提供されているおかげで、ダウンロードの速度を計算することができます。また、fetchはデータ破損を検知できないため進捗は絶対的なものではありません。そのため進捗率(%)の表示を避けています。一方でVerifyFetchはマニフェストを使った 都度検証(Hashing) により、進捗バーは「受信した量」ではなく「保証された量」を正確に反映しています。

そして、中断中にリロードをすると...

fetchは進捗が失われ、再び0からのダウンロードが必要になりました。これは、途中までのダウンロードデータがメモリ上に存在するためです。一方VerifyFetchは進捗が失われることなく、ダウンロードを再開できました。これはIndexedDBに途中までのダウンロードデータが永続化されているからです。

おわりに

標準の fetch でこれらを実装しようとすると、IndexedDB の制御やハッシュ計算、Range リクエストの管理など、膨大なコードが必要になります。VerifyFetchは、それらをカプセル化し、堅牢なファイル転送を容易にする強力なツールと言えるのではないでしょうか。




以上の内容はhttps://blog.inorinrinrin.com/entry/2026/02/14/171844より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14