nwtgck: Webブラウザ上で純粋なHTTPだけで単方向リアルタイム通信を可能にするHTTPのストリーミングアップロードが遂にやってくる
Web標準のHTTPクライアント fetch()
でストリーミングしながらアップロードできるようになる。
数行で画面共有したり、世界一シンプルかもしれないテキストチャットなども紹介したい。
なぜ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のボディをストリーミングしてアップロードする機能が搭載されることでさまざな用途での可能性が広がる。
どういう機能なのか?
ひとことでいうと、以下ができるようになった。
jsfetch(myUrl, {
method: 'POST',
body: <ここにReadableStream>
})
fetchの body:
のところに ReadableStream
が使えるようになる。
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()
のストリーミングアップロードに関して主要なブラウザが関心があるかどうか。
MDNでの記述
Google Chromeで実際に使う
現在
Google ChromeのBetaまで使えるようになっている。(Version 85.0.4183.38 (Official Build) beta (64-bit)で確認)
使用するには、 chrome://flags/
にアクセスして以下の「Experimental Web Platform features」をEnabledにする必要がある(トークンを使う方法もある)。
テキストチャットを作る
もしかすると世界一シンプルかもしれないブラウザでできる簡易テキストチャット。日本語や絵文字送れる。
左側が送る人、右側が受け取る人。もう1組作れば右側から送ることもできる。
以下がコード。
<input>
の入力をReadableStreamにして、それをfetch()でPOSTするだけ。標準ライブラリのみで実現。
jsconst 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://ppng.io/mytext
をブラウザ開いているだけ。受信側のコードを書く必要はなかった。
画面共有を作る
以下のように画面が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>
つまり POST /myvideo
しているので /myvideo
をvideoタグで指定すれば画面を見ることができる。
コマンドラインとの高い親和性
上記はvideoタグで閲覧した。その代わりにffplayを使えばコマンドライン上で閲覧することができる。
bashcurl 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を返す。
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暗号化することでサーバーを信用しなくても安全に通信ができる。そしてこれはクライアントサイドで暗号化することが必須。
以下の関数で任意の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();
}
目的は https://localhost:8080/e2ee_screen_share/swvideo#myvideo"
と指定すると復号された動画がHTTPでGETすること。
実際のコードは以下にある。
画面共有に限らず今まで紹介した例やこれからの例のすべてでこの
E2E暗号化と組み合わせることができる。
つまり
E2E暗号化で画面共有・音声通話・ビデオ通話・チャット・ファイル転送などなどできる。
いままでの
fetch()
ではクライアントサイドで暗号化するときはデータをすべてメモリ上に展開する必要があった。だが今回のfetch()の機能によりストリームの暗号化ができるようになりWebブラウザでの
E2E暗号化での可能性が広がった。
圧縮
Chromeでは readableStream.pipeThrough(new CompressionStream('gzip'))
とすればgzipの圧縮もできる。以下はコード例。
jsconst 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に限らず色々な圧縮ができると思う。
暗号化や可逆圧縮に限らず、巨大な動画のクライアントサイドでエンコードをしながらアップロードしたりなどもできるはず。
ffmpegを
Emscriptenでブラウザで動くようにするプロジェクトはある。そういうプロジェクトでReadableStreamな動画がエンコード出来れば実現可能だろう。
HTTPのアップロードの読み取りの進捗
それを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をアップロードできるかの判定
以下のようにしてこの機能に対応しているブラウザかどうか判定できる。
jsconst supportsRequestStreams = !new Request('', {
body: new ReadableStream(),
method: 'POST',
}).headers.has('Content-Type');
上記はReadableStreamのアップロードに非対応だと、 "[object ReadableStream]"
がアップロードされてしまうことを利用している様子。その結果おそらく Content-Type: text/plain ...
がつく仕様になっているのだと思う。
任意のプロトコル
任意のReadableStreamを流し込める。任意のバイト列でも転送できる。つまり任意のプロトコルのバイト列を流し込むこともできる。
つまり、WebブラウザサイドでSSHクライアントを実装できれば、原理上HTTPだけでSSHができるなどの可能性がある。その他にもVNCクラインとが作れれば、リモート操作などもできるかもしれない。
現在の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://ppng.io/hogehoge
のhogehogeの部分は実行するために自分用に変えるか
おまけ
HTTPは1つのリクエストだけでも1110TB転送できたりする。REST APIやWebページ閲覧のように短いHTTPリクエストだけでないHTTPの力が広まって欲しい。
いままで
curlコマンドで当たり前のようにできていたストリーミングしながらアップロードがWebブラウザでもできるようになったので嬉しい。stableでのリリースが楽しみ。