generated at
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でも body ReadableStream が使えるようにだいぶ前から書かれていた。だが調べた限りそれを実装しているメジャーなブラウザは一つもなかった(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: 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を受信して表示することも、コマンドラインから画面共有してブラウザ表示することでもできるようになった。

今まで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でのリリースが楽しみ。