個人開発

スマホからPCへQRで画像直送ツールを作った — WebRTC + PeerJS Cloud で運用コスト 0

スマートフォンと机に並べられた印画写真、その奥にラップトップ
Photo by Moe Magners on Pexels

何を作ったか

てちこまツール — QR送信 を追加しました。

PCでこのページを開くとQRコードが表示されます。スマホのカメラでそのQRを読み取ると送信ページが開いて、画像やファイルを選んで送るとPCに直接届く、というだけのツールです。

スマホで撮った写真をパッとPCで開きたい、というだけのときに「クラウドに上げてDL」とか「LINEに自分宛てで送ってPCのLINEで開く」とか、毎回ひと手間挟むのがもどかしくて作りました。

なぜ作ったか

似たことができるサービスはたくさんあります。Snapdrop、PairDrop、Send Anywhere、AirDrop (Apple同士)、Phone Link (Win + Android) などなど。便利なんですが、自分のユースケースだとどれもちょっとずつ噛み合わないんですよね。

  • Snapdrop / PairDrop: 同じネットワーク内のデバイスを自動検出してくれるけど、PC側もスマホ側もブラウザを開いておく必要がある。スマホでブラウザを開く手間 + URL を打つ or ブックマーク
  • Send Anywhere: 6桁コードをスマホ入力 → そこそこ手間
  • AirDrop: Apple同士限定
  • LINE Keepメモ: 結局LINEを開いて自分のトークを開いて…とアクションが多い

PCで開いたページのQRを読むだけ」という体験に絞れば、スマホ側のアクションは「カメラ向ける → 画像選ぶ → 送信」の3ステップで済みます。これだけのために作りました。

設計判断: 運用コスト 0 にするための制約

このツールはてちこまツール (tools.techikoma.com) のラインナップに加えるので、運用コストは増やしたくないという縛りがありました。既存ツールはすべて Cloudflare Workers の Static Assets だけで動いていて月額0円なので、この水準は維持したい。

ファイル転送ツールなので素直に組むと:

  1. サーバー経由でリレー: スマホがサーバーにアップ → PCがDL。実装は楽だがストレージ料金が出る
  2. WebRTC で P2P 直送: シグナリングサーバーが必要。ファイル本体はサーバーを通らない
  3. WebRTC + TURN: NAT越えが厳しいケース (異なるキャリア網など) も救えるが、TURN は帯域コストが出る

採用したのは 2。理由は単純で、ファイル本体は P2P で流せるなら流したほうがプライバシー的にもコスト的にもクリーンだからです。

シグナリングサーバーは自前で立てたら Cloudflare Durable Objects + Workers Paid プランが必要 ($5/月) なので、今回は PeerJS Cloud という10年以上稼働している無料の公開シグナリングサーバーに乗っかりました。peerjs.com ドメインで運用されている、PeerJS という WebRTC ラッパーライブラリの公式ホストです。

TURN サーバーは諦めました。NAT越えできない環境では諦めて「同じWi-Fi推奨」とUIで明示する方針です。Wi-Fi で繋がっていれば大体はSTUNだけで通るので、実用上の影響は許容範囲という判断です。

結果、本ツールの運用コスト内訳は:

  • ホスティング: 既存 Cloudflare Workers 無料枠
  • シグナリング: PeerJS Cloud (無料)
  • TURN: なし
  • 合計: 0円

その代わり外部サービス (PeerJS Cloud) への依存が一つ増えますが、ファイル本体はそこを通らないので、PeerJS Cloud が落ちても困るのは「最初の接続確立」の間だけです。仮に長期で停止するならその時に自前シグナリングへ切り替えればいいので、リスクは限定的だと割り切りました。

実装ハイライト

PeerJS でほぼコードを書かずに WebRTC を扱う

WebRTC を素で書くと SDP のやり取り・ICE 候補の交換・DataChannel のセットアップでそれなりの量のコードになりますが、PeerJS はそのあたりを丸ごとラップしてくれます。

PC 側 (受信):

import Peer from 'peerjs';
import { generateRoomId, toPeerId } from '../../lib/qrsend/room-id';

const roomId = generateRoomId();           // 8文字の英数字ID
const peer = new Peer(toPeerId(roomId));   // PeerJS Cloud に接続して ID を予約

peer.on('open', () => {
  // QR コードに `https://.../qrsend/r/#${roomId}` を埋めて表示
});

peer.on('connection', (conn) => {
  conn.on('data', (raw) => {
    // メタデータかバイナリチャンクが届く
  });
});

スマホ側 (送信) はこれだけ:

const peer = new Peer();                    // 自分には適当なID
peer.on('open', () => {
  const conn = peer.connect(toPeerId(roomId));
  conn.on('open', () => {
    conn.send({ type: 'meta', name, size, mime, id });  // JSON
    for await (const chunk of sliceIntoChunks(file)) {
      conn.send(chunk);                                    // ArrayBuffer
    }
    conn.send({ type: 'end', id });
  });
});

PeerJS の DataConnection は JSON とバイナリを同じ send() で混ぜて流せるので、「ファイルメタを先に送る → チャンクを順次 → 完了マーカー」というシンプルなプロトコルで済みます。複数ファイルなら meta → chunks → end を繰り返すだけ。

ルームIDの設計

PC側は QR に埋め込む URL が短いほうがありがたいので、ID は8文字の英数字にしました。Crockford Base32 に近い文字セット (I/L/O/U を除外、大文字統一) を使って、視認性で混同しないようにしてあります。

const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

32^8 = 約1兆通りなので、同時に数万ピアが稼働しても衝突確率は実用上ゼロ。万一PeerJS Cloud側で「そのID使用中」と弾かれた場合は、UIに「別のIDで作り直す」ボタンを置いて再生成できるようにしました。

URL は hash で渡す

スマホ側のページは https://tools.techikoma.com/qrsend/r/#XXXXXXXX という形にして、ルームIDを URL hash で渡しています。クエリパラメータでもよかったんですが、

  • hash はサーバーに送られない (Worker のアクセスログにルームIDが残らない)
  • リファラに乗らない (スマホ→PCの逆方向で見られても、ルームIDは漏れない)

の2点でhash優先にしました。Astro の動的ルーティング ([id].astro) は静的ビルド時に全パターン列挙が必要で QR 用途と相性が悪いので、/qrsend/r/index.astro 単一ページ + hash でルームID受け取り、という構成です。

バックプレッシャー制御

WebRTC の DataChannel は bufferedAmount が大きくなりすぎると詰まるので、送信側で bufferedAmountLowThreshold を見ながら待つようにしています:

async function waitForBufferDrain(conn: DataConnection): Promise<void> {
  const dc = (conn as any).dataChannel;
  const HIGH_WATER = 1 * 1024 * 1024;       // 1MB
  if (!dc || dc.bufferedAmount < HIGH_WATER) return;
  await new Promise<void>((resolve) => {
    dc.bufferedAmountLowThreshold = HIGH_WATER / 2;
    dc.addEventListener('bufferedamountlow', resolve, { once: true });
  });
}

64KB チャンクで送っているので、よほど小さいファイル以外はこの制御に入ります。これがないと数十MB級のファイルで Chrome のDataChannelが詰まって送信が止まることがあります。

ファイルが届いたあとの体験

PC 側で受信完了したファイルは Blob 化して URL.createObjectURL() で表示します。画像は受信完了と同時にサムネイル表示され、それ以外のファイルは拡張子バッジが出るだけにしました。

ボタンは「保存」だけ。ブラウザのダウンロードに流すのが一番確実なので余計なことはしていません。

NAT 越えの妥協について

TURN なしの構成は、同じWi-Fi同士なら大体は通ります。ただ、

  • スマホがLTE/5G、PCが自宅Wi-Fi
  • 企業ネットの厳しい NAT
  • VPN 越し

みたいな環境だと、STUN だけでは穴あけに失敗して接続が確立できないことがあります。今回はその場合は諦めて、UIに「同じWi-Fi推奨」「異なる回線では失敗する場合があります」と明示する方針にしました。

頻繁に失敗するようなら、Cloudflare Calls TURN (Workers Paid 加入で月1TB無料枠) を後付けする余地は残してあります。webrtc-config.ts で iceServers を集約しているので、TURN credentials を差し込むだけで切り替えられる作りです。

テスト

ロジック層 (ID生成、チャンク化、バイト整形、プロトコル判定) は Vitest で 41 ケース書きました。generateRoomId のような確率的なコードは、Math.random を差し替え可能にしておくと決定的にテストできるのでオススメです。

export function generateRoomId(
  randomFn: () => number = Math.random,
  length: number = ROOM_ID_LENGTH,
): string { ... }

E2E は Playwright でページレンダリングと主要DOMの存在確認まで。WebRTC の実通信は Playwright だと2ブラウザ間の通信を同時に立てる手間が大きいので、ここは実機で動かして確認しました。

まとめ

  • 「PCのQRをスマホで読むだけ」のミニマル体験で割り切れば、PeerJS Cloud + STUN だけで運用コスト0でいける
  • TURN を諦める代わりに「同じWi-Fi推奨」の説明をUIで明示する
  • ファイル本体は P2P 直送なのでサーバーに残らない、という安心感もおまけで付いてくる

スマホで撮った写真をPCに送りたいときに、ぜひ使ってみてください。

てちこまツール — QR送信