コンテンツへスキップ
HexaTransfer
ブログへ戻る
技術詳解

Service Workerでファイルをキャッシュしてオフライン転送

キャッシュ戦略、バックグラウンド同期、PWAパターンを活用し、Service Workerでオフラインファイル転送を実現する方法を解説します。

Service Workerを使えば、ネットワークが切れてもファイル転送アプリを動作し続けられます。Cache APIでHTMLシェルとJSをキャッシュし、Background Sync APIで失敗したアップロードをキューに入れ、途中までのチャンクをIndexedDBに保存し、接続が戻ったらすべて再送します。ワーカーは独立したスレッドで動作し、スコープ内のfetchイベントをインターセプトし、タブを閉じても生き続けます。アップロードツールの正解は、アプリシェルにはstale-while-revalidate戦略、転送中のチャンクにはIndexedDBバックのキューイング、15分ごとに成功するまでリトライするBackground Sync登録の組み合わせです。

Service Workerが転送アプリに何をもたらすか

最大の利点は、navigator.serviceWorkerがタブのリロード・オフライン・スリープを乗り越えて生き続けることです。不安定な車内Wi-Fiで2GBのアップロードを始めた場合、成功済みチャンクはそのまま維持され、転送中のものは再接続時に再試行され、ブラウザがメモリ回収のためタブを終了しても状態を復元できる必要があります。特定のタブから独立して動作するService Workerが、これらすべてを可能にします。

APIは3つの構成要素を提供します。URLでレスポンスを保存するCache、構造化データ(チャンクキュー・セッション状態)用のIndexedDB、そしてデバイスがオンラインになったときに発火する再試行をスケジュールするSyncManagerです。

ワーカーの登録とバージョニング

アプリ読み込み時に1回登録し、更新を明示的に処理します。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js', { scope: '/' })
    .then((reg) => reg.addEventListener('updatefound', () => {
      const sw = reg.installing;
      sw.addEventListener('statechange', () => {
        if (sw.state === 'installed' && navigator.serviceWorker.controller) {
          // 新バージョン準備完了 — ユーザーにリフレッシュを促す
        }
      });
    }));
}

キャッシュキーにバージョンを付けてください(transfer-v7)。新しいデプロイで古いアセットを無効化し、古いJavaScriptが残らないようにします。よくあるバグ:index.htmlが永遠にキャッシュされ、ユーザーが更新を受け取れず、Slackで何週間もデバッグする羽目になります。ビルドハッシュでキャッシュを固定し、activateイベントでクリーンアップしてください。

アプリシェルとユーザーデータのキャッシュ戦略

リソースの種類によって最適な戦略は異なります。

  • アプリシェル(HTML・CSS・JS・アイコン):ネットワークフォールバック付きのcache-first。即時読み込み、オフラインでも動作。
  • APIメタデータ(/shares/:id:キャッシュフォールバック付きのnetwork-first、TTL 60秒。オンライン時は新鮮、オフライン時も利用可能。
  • ファイルのバイト列:絶対にキャッシュしない。ファイルは数GBになることが多く、Cache APIにはオリジンクォータがある(通常は空きディスクの60%)。
  • CDNからのフォント:stale-while-revalidate。高速かつ自動更新。

fetchハンドラーでの実装例:

self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url);
  if (url.pathname.startsWith('/assets/')) {
    e.respondWith(cacheFirst(e.request, 'shell-v7'));
  } else if (url.pathname.startsWith('/api/shares/')) {
    e.respondWith(networkFirst(e.request, 'api-v1', 60));
  }
});

バイナリファイルのアップロードリクエストは絶対にインターセプトしないでください。e.request.method === 'PUT'を確認して早期リターンしてください。GBサイズのPUTをワーカー経由でプロキシするとメモリが壊滅します。

Background Syncで失敗したアップロードをキューイング

SyncManagerが安定したアップロードの鍵です。チャンクのPUTが失敗したら、IndexedDBに保存してsyncを登録します。

// ページコード
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('flush-uploads');
// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'flush-uploads') {
    event.waitUntil(flushPendingUploads());
  }
});

ブラウザはネットワーク復帰時にsyncイベントを発火し、最大約24時間の指数バックオフで再試行します。ChromeとEdgeはフルサポート、SafariはSafari 17.5で「Background Fetch」フラグ付きの一部サポートが追加されましたがユーザー許可が必要です。Safariではvisibilitychangeで次にページが表示されたときにリトライするフォールバックを実装してください。

Background Fetch APIは大容量ファイル操作専用の別ツールです。タブを閉じた後もブラウザUIに進捗通知を表示するため、500MB超のアップロードで使う価値があります。

IndexedDBへの途中経過の保存

IndexedDBは永続的なスクラッチスペースです。小さなデータベースを一度開き、セッションのメタデータとチャンクのオフセットを保存します。

const db = await openDB('transfers', 1, {
  upgrade(db) {
    db.createObjectStore('sessions', { keyPath: 'id' });
    db.createObjectStore('chunks', { keyPath: ['sessionId', 'index'] });
  }
});
await db.put('sessions', {
  id, fileName, fileSize, fileFingerprint, createdAt: Date.now(),
  completedIndexes: [], partUrls
});

生のチャンクバイトは保存しないでください。チャンクはFileハンドルから取得し、IndexedDBが構造化クローン参照として保持します。この参照はリロードをまたいで有効です。2GBのバイト列をデータベースに複製せずに済みます。

オリジンのストレージ制限に注意してください。デスクトップChromeは空きディスクの約60%、iOS Safariはオリジンあたり1GB(以降は退避圧力がかかる)です。navigator.storage.persist()を呼び出してブラウザが自動退避を避ける「persistent」バケットを取得してください。

オフライン/オンライン遷移の処理

ページとService Workerの両方でonlineofflineイベントを監視します。

// ページ
window.addEventListener('online', () => {
  ui.showBanner('オンラインに戻りました — アップロードを再開します');
  navigator.serviceWorker.controller?.postMessage({ type: 'resume' });
});
window.addEventListener('offline', () => {
  ui.showBanner('オフライン — アップロードを一時停止中');
});

navigator.onLineは企業のキャプティブポータルでは信頼できないことで知られています。ローカルネットワーク接続はあるがインターネットはない状態でもtrueを返します。確実な検出には、3秒タイムアウト付きのfetch('/ping', { cache: 'no-store' })を使ってください。

本物のPWAにする

display: standalone・アイコンセット・start_url: /を含むmanifest.jsonを用意してください。iOSにはapple-touch-iconリンクを追加します。ファイルハンドリングを宣言してOSがアプリと特定の拡張子を関連付けられるようにします。

{
  "name": "Hex Transfer",
  "file_handlers": [{
    "action": "/share-target",
    "accept": { "application/*": [".pdf", ".zip", ".docx"] }
  }]
}

Web Share Targetと組み合わせることで、OSの共有シートからアプリに直接ファイルを共有できます。Chrome AndroidとデスクトップChromiumでは、宣言したファイルタイプのデフォルトハンドラーとしてPWAを登録できます。ブラウザページがネイティブのアップロードツールのように振る舞うアプリになります。

オフラインシナリオのテスト

自動オフラインテストは不安定なため、手動で3つのシナリオをテストしてください。

  1. 高速Wi-Fiで500MBのアップロードを開始し、30%時点で機内モードに切り替え、30秒待ってからWi-Fiをオンに戻す。ユーザー操作なしに中断箇所から再開するはずです。
  2. アップロードを開始し、60%でタブを閉じ、2分待ってから再度開く。セッションの再開を提案するはずです。
  3. モバイルでアップロードを開始し、画面をロックして5分放置する。ロック解除時にBackground Syncが発火して転送を完了するはずです。

Chrome DevToolsの「Offline」チェックボックスとApplication > Service Workers > Update on reloadは欠かせません。Networkパネルの「Throttling」プロファイルでFast 3GやSlow 3Gをシミュレートしてエラー表示の挙動を確認できます。

HexaTransferのWebアプリはService Workerによるアプリシェルキャッシュとアップロード中のセッション状態にIndexedDBを活用しているため、リロードや一時的なオフラインでもアップロードの進捗が失われません。hexatransfer.com で試せます — 無料、アカウント不要、最大10GB。

知っておくべき落とし穴

Service Workerには初心者が引っかかりやすい落とし穴がいくつかあります。HTTPSでのみ動作する(localhostは例外)、キャッシュクォータはブラウザによって大きく異なる、iOS SafariはBackground Sync用にワーカーを確実に起動しない、DevToolsが古いワーカーを積極的にキャッシュする(開発中は必ず「Bypass for network」をクリックする)、importScriptsはインストール中に同期実行されるため低速なサードパーティスクリプトはフェッチしない。ワーカーの起動・クライアントへのクレーム・オフラインページの配信を確認する小さな統合テストを書いてください。そのテスト1本で本番で遭遇する回帰の80%を検出できます。

エンドツーエンド暗号化で大容量ファイルを安全に送信

エンドツーエンド暗号化で最大10GBのファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。

ファイルを送信