nwtgck: Webブラウザ上で純粋なHTTPだけで単方向リアルタイム通信を可能にするHTTPのストリーミングアップロードが遂にやってくる

Web標準のHTTPクライアント fetch() でストリーミングしながらアップロードできるようになる。
数行で画面共有したり、世界一シンプルかもしれないテキストチャットなども紹介したい。

なぜHTTPでのストリーミングアップロード?
巨大なデータの暗号化・圧縮
終わりが決まっていない無限のデータ
などをサーバーにアップロードすることがある。

今までも <input type="file"> から取得したFile(Blob)が巨大でも純粋なHTTPで送信できていた。
だが、このファイルを圧縮したりクライアントサイドで暗号化しようとすると 全部メモリ上に展開する必要 がある。そのため巨大なファイルの圧縮や暗号化したものを単一のHTTPリクエストで送信することが不可能だった。
任意のストリームをエンドツーエンド暗号化(E2E暗号化)をすることも可能になる。

終わりが分からない 無限のデータに関しても単一のHTTPリクエストで送信することは今まで不可能 だった。
例えば「終わりが分からない」というのはブラウザ上で録画・録音しながらリアルタイムにWebサーバーに送信し続けるレコーダーなどが考えられる。こういった場合はWebSocketやWebRTCなどのWebの技術を使う選択肢になると思う。

そして最も重要なのは これらは組み合わせることができる こと。例えば録画・録音しながら圧縮しつつ暗号化してリアルタイムに送信することができる。 ストリームは時間的にも空間的に効率の良い技術

なぜHTTPか?
HTTPはとてもシンプル。
いざとなればncコマンドやtelnetコマンドなどで手で書くこともできる(HTTP/1.1)。

HTTPは非常に多くの場所で使われている。iOS標準のShortchutアプリやMicrosoft Flowなどの自動化アプリやスマート家電の通信やDocker(/var/run/docker.sock )などWebブラウザに限らずHTTPは使われている。そのいう点でHTTPは他のデバイスやソフトウェアと連携しやすいインターフェースだと考えてる。

HTTP/1.1は成熟して枯れた技術で、
TCPを使いつつもパフォーマンスが向上したHTTP/2や
UDP上で設計されたQUICを使うHTTP/3など
HTTPは新しい技術がとりまれてこれからも互換性を保ちつつ発展している。
パフォーマンスだけではなく、HTTP/3が利用しているQUICには「コネクションマイグレーション」といったIPアドレスが変わっても接続し続けるような機能などもあるらしい。

そしてWebブラウザは多くのデバイスにすでにインストールされている。このWebブラウザでHTTPのボディをストリーミングしてアップロードする機能が搭載されることでさまざな用途での可能性が広がる。

どういう機能なのか?
ひとことでいうと、以下ができるようになった
js
fetch(myUrl, {
  method: 'POST',
  body: <ここにReadableStream>
})
仕様: https://fetch.spec.whatwg.org/#concept-body-stream
fetchの body: のところに ReadableStream が使えるようになる。
いままで調べた限りXMLHttpRequestにもストリーミングアップロードの機能はなかった。つまりPolyfillも不可能だった。WebSocketを使うなど素のHTTPではない方法でサーバーサイドでどうにかするしかなかった。

fetch()
fetch() ブラウザ標準で使えるHTTPのリクエストをするクライアント 。HTTPクライアントだとaxiosは人気のようだが fetch() は外部のライブラリ使用せず最初から使えるWeb標準の関数(広まって欲しい)。また XMLHttpRequest よりもモダンなAPIになっている。

ReadableStream
ブラウザで使えるストリーム
例えば以下で無限の乱数バイト列を出し続けるストリームが作れる。
js
// 無限の乱数バイト列
new ReadableStream({
  pull(ctrl) {
    ctrl.enqueue(window.crypto.getRandomValues(new Uint32Array(128)));
  }
})

身近なところでは (await fetch(...)).body の型がReadableStreamになっている(HTTPレスポンスのボディ)。

主要ブラウザベンダーの関心
この fetch() のストリーミングアップロードに関して主要なブラウザが関心があるかどうか。
以下を見ると、Firefox、 Safari でも実装されそう。
Uploading a Request made from a ReadableStream body by yutakahirano · Pull Request #425 · whatwg/fetch
Service Worker WG TPAC 2019 - Google Docs

MDNでの記述
以下のようにMDNでも bodyReadableStream が使えるようにだいぶ前から書かれていた。だが調べた限りそれを実装しているメジャーなブラウザは一つもなかった( https://github.com/whatwg/fetch/pull/425#issuecomment-462634914 )。
WindowOrWorkerGlobalScope.fetch() - Web APIs | MDN

Google Chromeで実際に使う
現在Google ChromeのBetaまで使えるようになっている。(Version 85.0.4183.38 (Official Build) beta (64-bit)で確認)

Chrome Betaのインストール: Google Chrome Beta - Google Chrome
使用するには、 chrome://flags/ にアクセスして以下の「Experimental Web Platform features」をEnabledにする必要がある(トークンを使う方法もある)。
Streaming requests with the fetch API

テキストチャットを作る
もしかすると世界一シンプルかもしれないブラウザでできる簡易テキストチャット。日本語や絵文字送れる。
左側が送る人、右側が受け取る人。もう1組作れば右側から送ることもできる。

以下がコード。
<input> の入力をReadableStreamにして、それをfetch()でPOSTするだけ。標準ライブラリのみで実現。
js
const readableStream = new ReadableStream({
  start(ctrl) {
    const encoder = new TextEncoder();
    window.myinput.onkeyup = (ev) => {
      if (ev.key === 'Enter') {
        ctrl.enqueue(encoder.encode(ev.target.value+'\n'));
        ev.target.value = '';
      }
    }
  }
});

fetch("https://ppng.io/mytext", {
  method: 'POST',
  body: readableStream,
  headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
  allowHTTP1ForStreamingUpload: true,
});
フル: https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/text_stream.html
allowHTTP1ForStreamingUpload: true はGoogle Chromeの一時的なプロパティ。これはHTTP/1.1でもこの機能を利用するため。(https://github.com/chromium/chromium/commit/4c75c0c9f730589ad8d6c33af919d6b105be1462#diff-0f684d35848d8674d6bd9c5673588856)

POST先の https://ppng.io/ はPiping Serverというサーバーになっている。
Piping Serverを使うと POST /hogehoge したデータが GET /hogehoge で取得できる。
そのため上記のデモのように、受信側のクライアントはただ https://ppng.io/mytext をブラウザ開いているだけ。受信側のコードを書く必要はなかった。

自前でPiping Serverを立てて検証したいときはDockerがあれば docker run -p 8181:8080 nwtgck/piping-server で出来る。その他の方法:「Piping Serverを自前でホストする方法をいくつか」

上記のコードは readableStream.pipeThrough(new TextEncoderStream()) を使うとよりストリームを使っている感じになる。(フル: https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/text_stream_with_text_encoder_stream.html

画面共有を作る
以下のように画面がvideo_player.htmlを開いているブラウザに共有できている。これも標準ライブラリのみを使っている。

以下が画面を送りたい側のコード。
以下のほとんどはMediaStreamをReadableStreamに変換するコードが占めている。
js
(async () => {
  // Get display
  const mediaStream = await navigator.mediaDevices.getDisplayMedia({video: true});
  // Convert MediaStream to ReadableStream
  const readableStream = mediaStreamToReadableStream(mediaStream, 100);

  fetch("https://ppng.io/myvideo", {
    method: 'POST',
    body: readableStream,
    allowHTTP1ForStreamingUpload: true,
  });
})();

// Convert MediaStream to ReadableStream
function mediaStreamToReadableStream(mediaStream, timeslice) {
  return new ReadableStream({
    start(ctrl){
      const recorder = new MediaRecorder(mediaStream);
      recorder.ondataavailable = async (e) => {
        ctrl.enqueue(new Uint8Array(await e.data.arrayBuffer()));
      };
      recorder.start(timeslice);
    }
  });
}

上記でやっていることは、
navigator.mediaDevices.getDisplayMedia({video: true}) で画面の映像のMediaStreamを手に入れる。
そのMediaStreamをReadableStreamに変換してfetch()でPOSTする。

以下は画面を見る側のコード。videoタグのみ。
html
<video src="https://ppng.io/myvideo" autoplay muted></video>
さっきのテキストチャットと同様にPiping Serverを使っている。
つまり POST /myvideo しているので /myvideo をvideoタグで指定すれば画面を見ることができる。

コマンドラインとの高い親和性
上記はvideoタグで閲覧した。その代わりにffplayを使えばコマンドライン上で閲覧することができる。
以下のデモのようにcurlコマンドとffplayコマンドだけで、画面共有ができる。
bash
curl https://ppng.io/myvideo | ffplay -

fetch()でReadableStreamがPOSTできるようになって、WebブラウザからのPOSTを受信して表示することも、コマンドラインから画面共有してブラウザ表示することでもできるようになった。
関連:「Piping Serverとffmpegで画面共有」

今までcurlでできていたことがWebブラウザでもできるようになり、互換性・対称性が高まった と思う。

音声通話・ビデオ通話などなど
Webブラウザ標準で音声やinカメラなどからのMediaStreamを取得できる。
嬉しいことに、多くのモバイルでのブラウザでも対応している。
https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia
そのため、上記の const mediaStream = を変えるだけで同じコードで画面共有以外にも音声通話・ビデオ通話することもできる。
js
// 音声
navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true } })

js
// ビデオ + 音声
navigator.mediaDevices.getUserMedia({ video: true, audio: { echoCancellation: true } })

以下がコード。
音声通話: https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/simple_phone.html
ビデオ通話: https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/video_chat.html

映像にフィルタをつける

HTMLのcanvasからも .captureStream() でMediaStreamを取得できる。
以下の関数はインメモリでvideoやcanvasを作って引数のMediaStreamを加工してMediaStreamを返す。
セピア調にするためにJSManipulateというライブラリを利用した。
js
// ...略...

// セピア調にするフィルタ
async function sepiaMediaStream(mediaStream) {
  const memVideo = document.createElement('video');
  memVideo.srcObject = mediaStream;
  await memVideo.play();

  const width = memVideo.videoWidth;
  const height = memVideo.videoHeight;
  const srcCanvas = document.createElement('canvas');
  const dstCanvas = document.createElement('canvas');
  srcCanvas.width = dstCanvas.width = width;
  srcCanvas.height = dstCanvas.height = height;
  const srcCtx = srcCanvas.getContext('2d');
  const dstCtx = dstCanvas.getContext('2d');

  (function loop(){
    srcCtx.drawImage(memVideo, 0, 0, width, height);
    const frame = srcCtx.getImageData(0, 0, width, height);

    JSManipulate.sepia.filter(frame);
    dstCtx.putImageData(frame, 0, 0);
    setTimeout(loop, 0);
  })();

  return dstCanvas.captureStream();
}
フル: https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/screen_share_with_filter.html

canvasの可能性
可能性として、カメラからのMediaStreamを加工すれば、SnowやSnap CameraのようなフィルタをWebのクライアントサイドで作ることもできるはず。

またcanvasは色々できる。
WebGLもcanvasで描画する。 three.js – JavaScript 3D library を見るとcanvasの可能性を感じると思う。
個人的には Infinitown | A WebGL Experiment by Little Workshop が好き。
WebでARやVRを実現するWebXRでもcanvasを使われている。
これらcanvasに描画したものをMediaStreamで取得してリアルタイムで送信できる。

エンドーツーエンド暗号化で画面共有
エンドツーエンド(E2E暗号化)をして画面共有する。
E2E暗号化することでサーバーを信用しなくても安全に通信ができる。そしてこれはクライアントサイドで暗号化することが必須。

暗号化にはOpenPGP.jsを利用している。
以下の関数で任意のreadableStreamをpasswordで暗号化できる。
js
// Encrypt ReadableStream with password by OpenPGP
async function encryptStream(readableStream, password) {
  const options = {
    message: openpgp.message.fromBinary(readableStream),
    passwords: [password],
    armor: false
  };
  const ciphertext = await openpgp.encrypt(options);
  return ciphertext.message.packets.write();
}

映像を見る側がPWAなどで使われるService Workerを利用している。
目的は https://localhost:8080/e2ee_screen_share/swvideo#myvideo" と指定すると復号された動画がHTTPでGETすること。
実際のコードは以下にある。
https://github.com/nwtgck/piping-server-streaming-upload-htmls/tree/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/e2ee_screen_share
(「ファイルのストリーミング強制保存をクロスオリジンでも実現させるService Workerの裏技ぽい使い方」も似たようなService Workerの使い方)

WebブラウザにはWeb Cryptoがある。これに安全な鍵生成やディフィー・ヘルマン鍵共有をするなども可能。(詳細:Web Cryptoで楕円曲線ディフィー・ヘルマン鍵共有して、暗号化 & 復号)

画面共有に限らず今まで紹介した例やこれからの例のすべてでこのE2E暗号化と組み合わせることができる。
つまりE2E暗号化で画面共有・音声通話・ビデオ通話・チャット・ファイル転送などなどできる。

いままでの fetch() ではクライアントサイドで暗号化するときはデータをすべてメモリ上に展開する必要があった。だが今回のfetch()の機能によりストリームの暗号化ができるようになりWebブラウザでのE2E暗号化での可能性が広がった。

圧縮

Chromeでは readableStream.pipeThrough(new CompressionStream('gzip')) とすればgzipの圧縮もできる。以下はコード例。
js
const readableStream = new ReadableStream({
  pull(ctrl) {
    // random bytes
    ctrl.enqueue(window.crypto.getRandomValues(new Uint32Array(128)));
  }
}).pipeThrough(new CompressionStream('gzip'))

fetch("https://ppng.io/mytext", {
  method: 'POST',
  body: readableStream,
  allowHTTP1ForStreamingUpload: true,
});
https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/gzip_inifinite_stream.html
無限にランダムなバイト列を圧縮したバイト列を送信している。

ReadableStreamから得たバイト列を圧縮する実装をすればgzipに限らず色々な圧縮ができると思う。

暗号化や可逆圧縮に限らず、巨大な動画のクライアントサイドでエンコードをしながらアップロードしたりなどもできるはず。ffmpegをEmscriptenでブラウザで動くようにするプロジェクトはある。そういうプロジェクトでReadableStreamな動画がエンコード出来れば実現可能だろう。

HTTPのアップロードの読み取りの進捗

XMLHTTPRequestにできてfetchにできないことの一つにアップロードの進捗を知る機能がある。
それをReadableStreamがアップロードできることで"多少"可能にすることができるようになった。
以下のようにchunk.byteLengthを数えるやりかた。
js
// 進捗付きにする
const readableStreamWithProgress = readableStream.pipeThrough(progressStream(loaded => {
  const progress = window.progress_bar.value = loaded / file.size * 100;
  window.message.innerText = `${loaded} bytes (${progress.toFixed(2)}%)`;
}));

// ...省略...

function progressStream(callback) {
  let loaded = 0;
  callback(loaded);
  return new TransformStream({
    transform(chunk, ctrl) {
      ctrl.enqueue(chunk);
      loaded += chunk.byteLength;
      callback(loaded);
    }
  });
}
フル: https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/file_upload_progress_with_transformstream.html

注意点は、あくまでも読み取ったバイト数であり、アップロード済みのバイト数ではないこと。

同じことを TransformStream ではなく ReadableStream を使った実装例: https://github.com/nwtgck/piping-server-streaming-upload-htmls/blob/a107dd1fb1bbee9991a9278b10d9eaf88b52c395/file_upload_progress.html

fetch()がReadableStreamをアップロードできるかの判定
以下のようにしてこの機能に対応しているブラウザかどうか判定できる。
js
const supportsRequestStreams = !new Request('', {
  body: new ReadableStream(),
  method: 'POST',
}).headers.has('Content-Type');
引用元: Streaming requests with the fetch API

上記はReadableStreamのアップロードに非対応だと、 "[object ReadableStream]" がアップロードされてしまうことを利用している様子。その結果おそらく Content-Type: text/plain ... がつく仕様になっているのだと思う。

任意のプロトコル
任意のReadableStreamを流し込める。任意のバイト列でも転送できる。つまり任意のプロトコルのバイト列を流し込むこともできる。

Piping Server を介した双方向パイプによる,任意のネットワークコネクションの確立 - Qiita 」ではHTTPのみで任意のプロトコルをトンネリングできることが示された。実際にSSHでcurlとsocatのみでできている。
つまり、WebブラウザサイドでSSHクライアントを実装できれば、原理上HTTPだけでSSHができるなどの可能性がある。その他にもVNCクラインとが作れれば、リモート操作などもできるかもしれない。

現在のChromeでは双方向は制限されている
Streaming requests with the fetch API 」でも触れられているとおり、現在のGoogle Chrome実装では双方向には対応していない。
const res = await fetch(...)res.body もReadableStreamになっている。アップロードが完了するまでPromiseがresolveせず await し続ける様子。

単方向を2つを双方向を実現できるとも思う。HTTP/2であれば同じTCPソケットに複数のHTTPリクエストがまとまり、2つHTTPリクエストするのも悪くないように思う。

まとめ
fetch()でReadableStreamをアップロード出来るようになった。
ReadableStreamが使えることで、すべてをメモリ上に展開せずに済み、巨大・無限のデータを転送できる。
ReadableStreamは圧縮・暗号化など加工することができる。
<canvas>や画面や音声やカメラなどをReadableStreamにしてHTTPで転送できる。

サンプルコードの使い方
このページは以下のサンプルコードをリンクした。
https://github.com/nwtgck/piping-server-streaming-upload-htmls
READMEにあるように、リポジトリのルートで python3 -m http.server などして、 https://localhost:8000 にブラウザで開くことを想定している。
また https://ppng.io/hogehoge のhogehogeの部分は実行するために自分用に変えるか
Piping Serverを自前でホストする方法をいくつかでPiping Server自体を自前で立てるのが良いと思う。
localでHTTPSするときは「localhostの自己署名SSL証明書をブラウザに信用させてHTTPSで通信する(Mac)」などが使える。

おまけ
HTTP/1.1でストリミーングされるときにTransfer-Encoding: chunkedになる。つまりチャンクごとにバイト数がテキストで挿入される。以下は、それでどれぐらいデータが増えるのか調べたもの。
HTTPのTransfer-Encoding: chunkedした時のデータの増量は0.0174%程度だった

HTTPは1つのリクエストだけでも1110TB転送 できたりする。REST APIやWebページ閲覧のように短いHTTPリクエストだけでないHTTPの力が広まって欲しい。
HTTPで1110TB転送できている記録(Piping Server)

いままでcurlコマンドで当たり前のようにできていたストリーミングしながらアップロードがWebブラウザでもできるようになったので嬉しい。stableでのリリースが楽しみ。
from /nwtgck/Webブラウザ上で純粋なHTTPだけで単方向リアルタイム通信を可能にするHTTPのストリーミングアップロードが遂にやってくる

関連ページとランダムに選ばれたページ

筆者について

jigsaw(ジグソウ、1991年6月12日-)は日本のプログラマ、会社代表。本名は小林貴也(こばやし たかや)。主にウェブ、フロントエンド領域で活動している。カミング・スーン合同会社の代表社員。
さらに詳しく

寄附について

面白かったらBTCETHでの寄附をお待ちしております。
寄附のきろく