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

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

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

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

終わりが分からない無限のデータに関しても単一のHTTPリクエストで送信することは今まで不可能だった。
例えば「終わりが分からない」というのはブラウザ上で録画・録音しながらリアルタイムにWebサーバーに送信し続けるレコーダーなどが考えられる。こういった場合はWebSocketWebRTCなどの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>
})
            
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()のストリーミングアップロードに関して主要なブラウザが関心があるかどうか。
以下を見ると、FirefoxSafariでも実装されそう。

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

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にする必要がある(トークンを使う方法もある)。

テキストチャットを作る
もしかすると世界一シンプルかもしれないブラウザでできる簡易テキストチャット。日本語や絵文字送れる。
左側が送る人、右側が受け取る人。もう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,
});
            
allowHTTP1ForStreamingUpload: trueGoogle 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を受信して表示することも、コマンドラインから画面共有してブラウザ表示することでもできるようになった。

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

音声通話・ビデオ通話などなど
Webブラウザ標準で音声やinカメラなどからのMediaStreamを取得できる。
嬉しいことに、多くのモバイルでのブラウザでも対応している。
そのため、上記のconst mediaStream = を変えるだけで同じコードで画面共有以外にも音声通話・ビデオ通話することもできる。
js

              // 音声
navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true } })
            

js

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

以下がコード。

映像にフィルタをつける

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();
}
            

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

またcanvasは色々できる。
WebGLもcanvasで描画する。three.js – JavaScript 3D libraryを見るとcanvasの可能性を感じると思う。
WebでARVRを実現する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すること。
実際のコードは以下にある。

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,
});
            
無限にランダムなバイト列を圧縮したバイト列を送信している。

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

暗号化や可逆圧縮に限らず、巨大な動画のクライアントサイドでエンコードをしながらアップロードしたりなどもできるはず。ffmpegEmscriptenでブラウザで動くようにするプロジェクトはある。そういうプロジェクトで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);
    }
  });
}
            

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


fetch()がReadableStreamをアップロードできるかの判定
以下のようにしてこの機能に対応しているブラウザかどうか判定できる。
js

              const supportsRequestStreams = !new Request('', {
  body: new ReadableStream(),
  method: 'POST',
}).headers.has('Content-Type');
            

上記は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で転送できる。

サンプルコードの使い方
このページは以下のサンプルコードをリンクした。
READMEにあるように、リポジトリのルートでpython3 -m http.serverなどして、https://localhost:8000にブラウザで開くことを想定している。
またhttps://ppng.io/hogehogeのhogehogeの部分は実行するために自分用に変えるか
Piping Serverを自前でホストする方法をいくつかPiping Server自体を自前で立てるのが良いと思う。

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

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

いままでcurlコマンドで当たり前のようにできていたストリーミングしながらアップロードがWebブラウザでもできるようになったので嬉しい。stableでのリリースが楽しみ。