Webブラウザ上で純粋なHTTPだけで単方向リアルタイム通信を可能にするHTTPのストリーミングアップロードが遂にやってくる
(このページの内容は技術者向けです)
Web標準のHTTPクライアントfetch()でストリーミングしながらアップロードできるようになる。
数行で画面共有したり、世界一シンプルかもしれないテキストチャットなども紹介したい。
https://gyazo.com/37a9d37fee6aa495f17bbe83857d913e
なぜHTTPでのストリーミングアップロード?
巨大なデータの暗号化・圧縮
終わりが決まっていない無限のデータ
などをサーバーにアップロードすることがある。
今までも<input type="file">から取得したFile(Blob)が巨大でも純粋なHTTPで送信できていた。
だが、このファイルを圧縮したりクライアントサイドで暗号化しようとすると全部メモリ上に展開する必要がある。そのため巨大なファイルの圧縮や暗号化したものを単一のHTTPリクエストで送信することが不可能だった。
終わりが分からない無限のデータに関しても単一のHTTPリクエストで送信することは今まで不可能だった。
例えば「終わりが分からない」というのはブラウザ上で録画・録音しながらリアルタイムにWebサーバーに送信し続けるレコーダーなどが考えられる。こういった場合はWebSocketやWebRTCなどのWebの技術を使う選択肢になると思う。 そして最も重要なのはこれらは組み合わせることができること。例えば録画・録音しながら圧縮しつつ暗号化してリアルタイムに送信することができる。ストリームは時間的にも空間的に効率の良い技術。
なぜHTTPか?
HTTPはとてもシンプル。
HTTPは非常に多くの場所で使われている。iOS標準のShortchutアプリやMicrosoft Flowなどの自動化アプリやスマート家電の通信やDocker(/var/run/docker.sock )などWebブラウザに限らずHTTPは使われている。そのいう点でHTTPは他のデバイスやソフトウェアと連携しやすいインターフェースだと考えてる。 HTTP/1.1は成熟して枯れた技術で、
HTTPは新しい技術がとりまれてこれからも互換性を保ちつつ発展している。
そしてWebブラウザは多くのデバイスにすでにインストールされている。このWebブラウザでHTTPのボディをストリーミングしてアップロードする機能が搭載されることでさまざな用途での可能性が広がる。
どういう機能なのか?
ひとことでいうと、以下ができるようになった。
code:js
fetch(myUrl, {
method: 'POST',
body: <ここにReadableStream>,
// Chromium 104から必要
duplex: 'half',
})
fetchのbody: のところにReadableStreamが使えるようになる。
fetch()
fetch()はブラウザ標準で使えるHTTPのリクエストをするクライアント。HTTPクライアントだとaxiosは人気のようだがfetch()は外部のライブラリ使用せず最初から使えるWeb標準の関数(広まって欲しい)。またXMLHttpRequestよりもモダンなAPIになっている。
ReadableStream
ブラウザで使えるストリーム。
例えば以下で無限の乱数バイト列を出し続けるストリームが作れる。
code:js
// 無限の乱数バイト列
new ReadableStream({
pull(ctrl) {
ctrl.enqueue(window.crypto.getRandomValues(new Uint32Array(128)));
}
})
身近なところでは(await fetch(...)).bodyの型がReadableStreamになっている(HTTPレスポンスのボディ)。
主要ブラウザベンダーの関心
このfetch()のストリーミングアップロードに関して主要なブラウザが関心があるかどうか。
https://gyazo.com/aa7d3bba8a99e677573925bccfc2175a
MDNでの記述
以下のようにMDNでもbodyにReadableStreamが使えるようにだいぶ前から書かれていた。だが調べた限りそれを実装しているメジャーなブラウザは一つもなかった()。 https://gyazo.com/594dbd91d89f84e471f876d264ccc4d8
Google Chromeで実際に使う
現在Google ChromeのBetaまで使えるようになっている。(Version 85.0.4183.38 (Official Build) beta (64-bit)で確認) 使用するには、chrome://flags/にアクセスして以下の「Experimental Web Platform features」をEnabledにする必要がある(トークンを使う方法もある)。
https://gyazo.com/561cca07e57978fbc8e5b816111dc672
テキストチャットを作る
もしかすると世界一シンプルかもしれないブラウザでできる簡易テキストチャット。日本語や絵文字送れる。
左側が送る人、右側が受け取る人。もう1組作れば右側から送ることもできる。
https://gyazo.com/ef19138393e00f7f370461fb909dbf07
以下がコード。
<input>の入力をReadableStreamにして、それをfetch()でPOSTするだけ。標準ライブラリのみで実現。
code: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 = '';
}
}
}
});
method: 'POST',
body: readableStream,
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
// Chromium 104から必要
duplex: 'half',
// Chromium 104からは以下は無視され、HTTP/1.1サーバーだとエラー。HTTP/2, HTTP/3サーバーが必須
allowHTTP1ForStreamingUpload: true,
});
そのため上記のデモのように、受信側のクライアントはただhttps://ppng.io/mytextをブラウザ開いているだけ。受信側のコードを書く必要はなかった。
上記のコードはreadableStream.pipeThrough(new TextEncoderStream())を使うとよりストリームを使っている感じになる。(フル: ) 画面共有を作る
以下のように画面がvideo_player.htmlを開いているブラウザに共有できている。これも標準ライブラリのみを使っている。
https://gyazo.com/37a9d37fee6aa495f17bbe83857d913e
以下が画面を送りたい側のコード。
以下のほとんどはMediaStreamをReadableStreamに変換するコードが占めている。
code:js
(async () => {
// Get display
const mediaStream = await navigator.mediaDevices.getDisplayMedia({video: true});
// Convert MediaStream to ReadableStream
const readableStream = mediaStreamToReadableStream(mediaStream, 100);
method: 'POST',
body: readableStream,
// Chromium 104から必要
duplex: 'half',
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タグのみ。
code:html
つまりPOST /myvideoしているので/myvideoをvideoタグで指定すれば画面を見ることができる。
コマンドラインとの高い親和性
上記はvideoタグで閲覧した。その代わりにffplayを使えばコマンドライン上で閲覧することができる。
code:bash
https://youtu.be/oORveGAFrt0
fetch()でReadableStreamがPOSTできるようになって、WebブラウザからのPOSTを受信して表示することも、コマンドラインから画面共有してブラウザ表示することでもできるようになった。
今までcurlでできていたことがWebブラウザでもできるようになり、互換性・対称性が高まったと思う。
音声通話・ビデオ通話などなど
Webブラウザ標準で音声やinカメラなどからのMediaStreamを取得できる。
嬉しいことに、多くのモバイルでのブラウザでも対応している。
そのため、上記のconst mediaStream = を変えるだけで同じコードで画面共有以外にも音声通話・ビデオ通話することもできる。
code:js
// 音声
navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true } })
code:js
// ビデオ + 音声
navigator.mediaDevices.getUserMedia({ video: true, audio: { echoCancellation: true } })
以下がコード。
映像にフィルタをつける
https://youtu.be/VcKJR8D8IFA
HTMLのcanvasからも.captureStream()でMediaStreamを取得できる。
以下の関数はインメモリでvideoやcanvasを作って引数のMediaStreamを加工してMediaStreamを返す。
code: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は色々できる。
これらcanvasに描画したものをMediaStreamで取得してリアルタイムで送信できる。
エンドーツーエンド暗号化で画面共有
E2E暗号化することでサーバーを信用しなくても安全に通信ができる。そしてこれはクライアントサイドで暗号化することが必須。 https://youtu.be/lxpxeB_0UDk
以下の関数で任意のreadableStreamをpasswordで暗号化できる。
code:js
// Encrypt ReadableStream with password by OpenPGP
async function encryptStream(readableStream, password) {
const options = {
message: openpgp.message.fromBinary(readableStream),
armor: false
};
const ciphertext = await openpgp.encrypt(options);
return ciphertext.message.packets.write();
}
目的はhttps://localhost:8080/e2ee_screen_share/swvideo#myvideo"と指定すると復号された動画がHTTPでGETすること。
実際のコードは以下にある。
画面共有に限らず今まで紹介した例やこれからの例のすべてでこのE2E暗号化と組み合わせることができる。 つまりE2E暗号化で画面共有・音声通話・ビデオ通話・チャット・ファイル転送などなどできる。 いままでのfetch()ではクライアントサイドで暗号化するときはデータをすべてメモリ上に展開する必要があった。だが今回のfetch()の機能によりストリームの暗号化ができるようになりWebブラウザでのE2E暗号化での可能性が広がった。 圧縮
https://gyazo.com/b80ce27065ec3e9ed79c2195d49b6376
ChromeではreadableStream.pipeThrough(new CompressionStream('gzip'))とすればgzipの圧縮もできる。以下はコード例。
code:js
const readableStream = new ReadableStream({
pull(ctrl) {
// random bytes
ctrl.enqueue(window.crypto.getRandomValues(new Uint32Array(128)));
}
}).pipeThrough(new CompressionStream('gzip'))
method: 'POST',
body: readableStream,
// Chromium 104から必要
duplex: 'half',
allowHTTP1ForStreamingUpload: true,
});
無限にランダムなバイト列を圧縮したバイト列を送信している。
ReadableStreamから得たバイト列を圧縮する実装をすればgzipに限らず色々な圧縮ができると思う。
暗号化や可逆圧縮に限らず、巨大な動画のクライアントサイドでエンコードをしながらアップロードしたりなどもできるはず。ffmpegをEmscriptenでブラウザで動くようにするプロジェクトはある。そういうプロジェクトでReadableStreamな動画がエンコード出来れば実現可能だろう。 HTTPのアップロードの読み取りの進捗
https://gyazo.com/24d9ddd32f147e0fe681d807a2bc1734
それをReadableStreamがアップロードできることで"多少"可能にすることができるようになった。
以下のようにchunk.byteLengthを数えるやりかた。
code: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);
}
});
}
注意点は、あくまでも読み取ったバイト数であり、アップロード済みのバイト数ではないこと。
同じことをTransformStreamではなくReadableStreamを使った実装例: 任意のプロトコル
任意のReadableStreamを流し込める。任意のバイト列でも転送できる。つまり任意のプロトコルのバイト列を流し込むこともできる。
つまり、WebブラウザサイドでSSHクライアントを実装できれば、原理上HTTPだけでSSHができるなどの可能性がある。その他にもVNCクラインとが作れれば、リモート操作などもできるかもしれない。
現在のChromeでは双方向は制限されている
const res = await fetch(...)のres.bodyもReadableStreamになっている。アップロードが完了するまでPromiseがresolveせずawaitし続ける様子。
単方向を2つを双方向を実現できるとも思う。HTTP/2であれば同じTCPソケットに複数のHTTPリクエストがまとまり、2つHTTPリクエストするのも悪くないように思う。 追記: duplex: 'half'を入れることで今後を考えた設計になっている。現在のChromeでduplex: 'half'を入れることを必須にしていることで今後をfullをサポートしたときに壊れないWebを実現している。
まとめ
fetch()でReadableStreamをアップロード出来るようになった。
ReadableStreamが使えることで、すべてをメモリ上に展開せずに済み、巨大・無限のデータを転送できる。
ReadableStreamは圧縮・暗号化など加工することができる。
<canvas>や画面や音声やカメラなどをReadableStreamにしてHTTPで転送できる。
サンプルコードの使い方
このページは以下のサンプルコードをリンクした。
READMEにあるように、リポジトリのルートでpython3 -m http.serverなどして、にブラウザで開くことを想定している。 またhttps://ppng.io/hogehogeのhogehogeの部分は実行するために自分用に変えるか
おまけ
HTTPは1つのリクエストだけでも1110TB転送できたりする。REST APIやWebページ閲覧のように短いHTTPリクエストだけでないHTTPの力が広まって欲しい。
いままでcurlコマンドで当たり前のようにできていたストリーミングしながらアップロードがWebブラウザでもできるようになったので嬉しい。stableでのリリースが楽しみ。