「ネットワーク越しでパイプしたり、あらゆるデバイス間でデータ転送したい! - Qiita」で登場したPiping Serverを使ったWebアプリです。
ネット上でも気に入っている人も見かけ、スマホ<=>PC間などでの活用してる声が多めでした。TypeScript + Vue + Vuetifyで手軽にファイルを転送するWebアプリが出来ました。
Piping UI: https://piping-ui.org と呼んでます。
以下のデモはiPhone => PCへの送信です。
以下で機能の紹介と少し技術的なことに触れていきたいと思います。
GitHubリポジトリ: https://github.com/nwtgck/piping-ui-web
- アカウント登録なし
- 事前の設定なし
- 特別なアプリのインストールなし
- curl(CLI)との相性の良いPiping Server経由
- Progressive Web Apps (PWA)
- ダークテーマ対応
- 英語/日本語
- 複数ファイル転送
- サーバーURLとパスの記憶と自動補完
- 画像/動画のプレビュー
- 静的ホスト - Netlifyでビルド/デプロイ
- パスワードでE2E暗号化
アップロードとダウンロードを同時に
この転送ツールの一番の特徴が、アップロード中にダウンロード出来ることだと思います。そのため送信者のアップロードの完了を待つ必要もなくダウンロードが可能です。特に大きいファイルの転送の際に発揮する機能です。データがサーバーに保存されることはなくデータをずっと流し込むことができ、前回の実験では少なくとも868テラバイト流し込むことができました。
Piping Serverで無限にデータを送り続ける実験を47日前に開始しました。現在も進行中で、その結果では現在868テラバイトのデータが転送できています。
引用元: 「ネットワーク越しでパイプしたり、あらゆるデバイス間でデータ転送したい! - Qiita」
Piping ServerのBack pressure(背圧制御)が上手いこと効いて、受信者が遅ければアップロードしすぎないように調節されます。
参考: Piping ServerでBack pressure(背圧制御)が効いてそうなことが分かるデモ動画 - TypeScript版とRust(Hyper)版 - nwtgck / Ryo Ota
Service Workerを使ってクロスオリジンでストリーミングしながらダウンロードする技術に関して後述したいと思います。
ダークテーマ対応
最近のmacOSやiOSなどではOSでダークモードを優先するようにOSで設定出来るようになってます。デフォルトのテーマはこのマシンの設定をJavaScriptで読み込んでいます。具体的にはwindow.matchMedia('(prefers-color-scheme: dark)').matches
を使っています。Vuetify 2.xだとthis.$vuetify.theme.dark = true
でダークテーマに変更可能で、<v-app :dark="isDark">
のようにdark属性は使わなくて良くなったようです。
参考:
- WebアプリでmacOSのテーマ切り替えを取得する方法 - Qiita
- javascript - How to over ride css prefers-color-scheme setting - Stack Overflow
- window.matchMedia - Web API | MDN
PWAのアップデート検知とアップデートボタン
Progressive Web App (PWA)の更新があることを検知して更新ボタンを表示します。TweetDeckにこの機能があってずっと実現したかったことでした。ユーザーがインストール不要で使える手軽なPWAのアップデートをスムーズに行えてとても良いです。
「Give Users Control Over App Updates in Vue CLI 3 PWAs」を参考にして実現しました。この記事ではGoogleが作っているWorkboxを使ったVueでのPWAの更新ボタンを生やせるようにする方法が紹介されています。
Piping UIでPWAの更新ボタンに対応した変更はこのコミット「https://github.com/nwtgck/piping-ui-web/commit/e17639cb2237d3261136ecaf2b20f43cab69a623」です。上記の記事で書かれている変更すべきファイルをざっと把握するときに使えるかもしれません。
以下のような流れで、更新ボタンが現れます。
-
src/registerServiceWorker.ts
のupdated()
で更新を検知してCustomEvent
の'swUpdated'を出す => -
src/App.vue
で'swUpdated'のハンドラーのthis.showRefreshUI
が呼ばれる => -
showRefreshUI
内でthis.pwa.updateExists = true
に更新され、App.vue
内の更新ボタンが表示される
複数ファイルを転送
同時に複数のファイルを転送できます。複数のファイルは.zipで一つにまとめられて送信されます。
.zipにする処理もクライントサイドで完結するためにJSZipを使っています。XMLHttpRequest
やfetch
のアップロード時にストリーミングして送信できるようになれば、.zipで圧縮しながら送信するみたいなzip
コマンドとcurl
コマンドを組み合わせて送信できてたようなことが出来るようになります。
現在のブラウザでは、任意のReadableStreamをfetch
でアップロードできる方法が用意されていないようです。ですが嬉しいことに「Uploading a Request made from a ReadableStream body by yutakahirano · Pull Request #425 · whatwg/fetch」で、近いうちにChromiumにこの機能が搭載される予定があると中の人がコメントしています。この機能の実現にはすごく期待していて、圧縮や暗号化がストリーミングしながら出来るため時間的にもメモリ的にも効率性が非常に高いと思っています。
URLをクリック可能に
1行のURLに限らず、テキスト内に含まれるすべてのURLがクリック可能になります。Linkifyを使っています。この機能も含めPiping UI全体的に「ふぁメモ」さんの「スマホ間でURLや画像の受け渡し (未完成) - ふぁメモ」の内容を取り入れています。この記事ではApple公式のアプリ「ショートカット」を使ってPiping Server経由でファイルやテキストを送る方法も紹介されています。
転送パスの自動補完
転送に使うパス(path)やサーバーのURLはLocal Storageに記憶されて今まで使ったものは補完が効きます。長いパスでも記憶させれば使いやすくなります。
English/日本語対応とTypeScriptの型の活用
Androidのstrings.xml
に少し影響を受けた命名になってます。デフォルトの言語にある項目(フィールド)が他の言語にない場合は実行時にコンパイルエラーとして検出したり、パラメータを受け取って文字列を作り上げるときに順番を間違えにくくしたりする工夫をしました。
具体的には以下のように定義しています。
const en = {
language: 'Language',
dark_theme: 'Dark Theme',
pwa_update: 'Update',
version: `Version: ${VERSION}`,
...
xhr_status_error: (p: {status: number, response: string}) => {
return `Error (${p.status}): "${p.response}"`;
},
...
};
const defaultStr = en;
const ja: typeof defaultStr = {
language: '言語 (Language)',
dark_theme: 'ダークテーマ',
pwa_update: 'Update',
version: `バージョン: ${VERSION}`,
...
xhr_status_error: (p: {status: number, response: string}) => {
return `エラー (${p.status}): "${p.response}"`;
},
...
};
const ja: typeof defaultStr
となっているため、ja
はen
にある項目がなかったり、余計な項目があるとコンパイル時にエラーとして検出可能です。またキー名だけではなく値の型も検査されるため、値が文字列でなくパラメータを受け取る関数であってもシグネチャが異なれば実行前にコンパイルエラーとして検出可能です。
以下のstrings()
関数を使って各言語のときの文字列を返すようにします。strings()
は純粋関数のため、この言語切替の仕組みはVueなどとは切り離されていて、他のフレームワークへの移行などもしやすいのでないかと思っています。
export function strings(language: string): typeof defaultStr {
if(language.startsWith("en")) {
return en;
} else if(language.startsWith("ja")) {
return ja;
} else {
return defaultStr;
}
}
Vueの中では以下のようにして使っています。
@Component
export default class MyComponent extends Vue {
private get strings() {
return strings(globalStore.language);
}
}
<template>
内で{{ strings['cancel'] }}
とすれば、英語なら{{ "Cancel" }}
に日本語なら{{ "キャンセル" }}
になるイメージです。
this.strings['cancel']
はパラメータなしの文字列ですが、引数を与える必要がある文字列も以下のように使うことが出来ます。
this.strings['xhr_status_error']({
status: this.xhr.status,
response: this.xhr.responseText
});
<script lang="ts">
内に書いた場合はきちっとコンパイルエラーがあれば検出出来るので安全なのではないかと思っています。
"https://"を自動補完
地味ですが便利です。中央集権を避け、手軽に他のサーバーURLに追加したり切り替えられるようにしたいです。
OSSライセンス一覧
一覧はsrc/license.json
として生成されます。なるべく自動生成されるものをGit管理するのは避けたいため、npmスクリプトの"posntinstall"
でlicense-checkerを使ってsrc/license.json
を生成しています。TypeScriptのコンパイラオプションで"resolveJsonModule": true
を指定するとimport licenses from '@/licenses.json
できるようになるため、これでライセンス情報のJSONを読み込みます。
参考: How to Import json into TypeScript - By
転送の仕組み
Web標準で使えるfetch()
(fetch)を使って手軽にPiping Serverを経由してデータを転送できます。
// 送信
fetch("https://ppng.ml/mypath1", {
method: 'POST',
body: 'hello!'
});
// 受信
fetch("https://ppng.ml/mypath1")
.then(res => res.text())
.then(text => console.log(text));
送信者の'hello!'
の代わりにBlob
にしたり、<input type="file">
からのFile
を入れたりすれば、テキストに限らず色んなデータをHTTPのPOSTとGETで手軽に送ることが出来ます。
受信側もres.text()
の代わりにres.blob()
やres.arrayBuffer()
でバイナリを受け取れ、res.body
(ReadableStream
を返す)を使うことでストリーミングしながらのバイナリデータを読み取ることもできます。
クロスオリジンでストリーミングしながらダウンロードする
やりたいことは、同一オリジンでないAccess-Control-Allow-Origin: *
なファイルをブラウザで表示させずに保存させたいです。<a download>
を動的生成してダウンロードできそうですが、"download"
属性は同一オリジンでないとダウンロード出来ないようです。
参考: https://developer.mozilla.org/ja/docs/Web/HTML/Element/a#Attributes
そこでこのブラウザの制限を回避するためにService Workerを使います。StreamSaver.jsで使われているテクニックです。Firefox Sendでも使われていた思います。最小構成で再現した方法は「ファイルのストリーミング強制保存をクロスオリジンでも実現させるService Workerの裏技ぽい使い方 - nwtgck / Ryo Ota」にまとめました。
Piping UIでのこの実装はこのコミット「https://github.com/nwtgck/piping-ui-web/commit/31a8f7331d61aa0981337fe5df58fb731ac62f52」です。上記の記事との違いはPiping UIではこのService Workerを使った方法に対応しているか確認するためのパスを用意したり、PWAのWorkboxを使ったものに適用したところです。
このストリーミングしながらのダウンロードに対応できるブラウザだと、以下の画像のようにダウンロードのURLがhttps://piping-ui.org/sw-download?url=...&filename=...
のようになります。
Piping UIはNetlifyにデプロイされた静的なファイルだけで構成されるWebアプリですが、/sw-download
はService Workerがリバースプロキシのようになってリクエストをさばいて適切にPiping Server経由でデータをダウンロードします。
vue-cli-service serve
だとService Workerが働かないので(webpack-dev-serverも同じかも)、ファイル監視してビルドする仕組みを作ってそれを使ってます (https://github.com/nwtgck/watch-build-serve-node)。検索しても見つからなかったので、こういう用途を満たすものがあったらそれに移行したいと思っています。
curlユーザーとの互換性
Piping UIではこの互換性をすごく大事にしました。curl
やwget
などが使える環境とブラウザを繋ぎやすくしたかったからです。送信は... | curl -T - <URL>
, 受信はcurl <URL>
でシンプルなままです。
curlコマンドでの活用法は「ネットワーク越しでパイプしたり、あらゆるデバイス間でデータ転送したい! - Qiita」にまとめています。
curlとの互換 - チャット/通話/画面共有/エンドツーエンド暗号化
上記のcurl
との互換性の話です。
HTTPはボディをストリーミング可能です。ですが、残念ながら現在のブラウザ上で動くJavaScriptはストリームに弱いと感じてます。任意のReadableStream
がアップロード出来なかったり、前述したストリーミングしながらダウンロードする機能の話にも関連して弱いと感じてます。繰り返しになりますが、嬉しいことに「Uploading a Request made from a ReadableStream body by yutakahirano · Pull Request #425 · whatwg/fetch」で、近いうちにChromiumにこの機能が搭載される予定があると中の人がコメントしていてストリーミングしながらのアップロードも近い将来にブラウザ上で実現できそうです。
以下のWebアプリたちは、ブラウザ上で小さなチャンクに分けてPiping Serverを経由することで擬似的にストリーミングして実現しています。
- Web上でPiping Server経由のエンドツーエンド暗号化したファイル転送 - nwtgck / Ryo Ota
- Piping Server上でエンドツーエンド暗号化チャット - nwtgck / Ryo Ota
- 暗号化してセキュアに画面共有する - エンドーツーエンド暗号化/Piping Server経由 - nwtgck / Ryo Ota
- Real-time Voice Messaging over HTTP/HTTPS for Web Browser via Piping Server
- エンドツーエンド暗号化でお絵かき/手書きチャット - Piping Server経由 - nwtgck / Ryo Ota
Webアプリ間でエンドツーエンド暗号化してファイルを転送したり、チャットしたり、画面共有します。将来、fetch
で任意のReadableStream
がアップロードできるようになると、Webアプリ間だけではなくてcurl
などのCLIとの連携が可能になるのではないかと思っています。
おわりに
Piping Serverの実装は前の記事の時と比べてほとんど同じです。Piping ServerをPiping UIや途中で出てきたWebアプリを実装するために特殊な実装をしたりはしていません。あらゆるデータをストリーミングできれば、ファイル転送に限らず、チャットも通話も画面共有も実現できそうです。
「より少ない機能で、より多くのことを実現」出来ればいいなと思っています。
おまけ: gifのプログレスバー表示
最後におまけです。以下を使ってプログレスバー付きのgifを生成してます。
https://github.com/nwtgck/gif-progress