Swdev: 真の No bundle frontend

6 min read読了の目安(約5700字

みなさん、ブラウザ内で TypeScript が直接動いてくれたらいいなぁ、と思ったことはありませんか? しました。

https://deno.land/x/swdev

これができます。

どのようにうごいてるか

Service Worker は合法 MITM とも言えて、 fetch 時のリクエストを好きに書き換えることができます。

  • 開発時
    • 初回インストール時に Service Worker をインストール
    • コンパイラを内蔵した Service Worker がリクエストの拡張子に応じて js に書き換える
      • Content-Type: text/javascript として SW でキャッシュして返却
      • TypeScript(.ts, .tsx) と Svelte(.svelte + preprocess) に対応
    • WebSocket サーバーを起動。ファイル変更を監視して、変更されたファイル名をブラウザに通知
    • 変更されたファイルを Service Worker に送信して、 SW キャッシュを破棄、リロード
  • 本番
    • 開発時と同じルールで解決する main.bundle.js を生成

開発時の構成を図にしたのがこちら

ファイル監視プロセスは、 deno の deno run --allow-read=/ パーミッションで立ち上がってるので、ディレクトリの外を読めないのでセキュア。

そう、全部 deno で書いてます。 ビルド含めて node.js への依存がゼロ。

Install and Run

swdev@0.2.3 | Deno

# deno install - brew install deno
# See https://deno.land/#installation

$ deno install -qAf --unstable https://deno.land/x/swdev/swdev.ts
$ swdev init myapp
$ cd myapp
$ swdev serve

# production build
$ swdev build #=> main.bundle.js

# このディレクトリを netlify でリリース
# netlify deploy --prod -d .

絶賛開発中なので、色々変わると思いますが、 init して serve して build するところはたぶん一緒だと思います。

Hot reload

エントリポイントの main.tsx は 次のような規約を導入しています。

import App from "./App.svelte";

export default async () => {
  // on start
  const app = new App({ target: document.querySelector(".svelte-root") });
  return async () => {
    // on dispose
    app.$destroy();
  };
};

defalut で関数を与え、その返り値として dispose 処理を書きます。ファイルが変更されるたびに、この dispose が呼ばれてから、この関数が再実行されます。

なぜ作ったか

最近は Snowpack や Vite のような No bundle ツールが流行しようとしてます。これは事前に ESM 用にバンドルしておき、それらをブラウザから Native ESM として参照します。本番は rollup 等でビルドして配布します。

開発時のみモダンブラウザの機能を使う、というのがミソで、これによって開発者に受け入れられつつあるように思います。

参考: Native ESM 時代のフロントエンドビルドツールの動向

自分のこれらのツールに対する問題意識として、これは事前のセットアップが必要で、真の意味で No bundle とは言えない、という認識がありました。本当に無設定で動かしたいと思いました。

動的に service-worker で書き換える、というアイデアは昔から自分の中にありました。これを 3 年前に実装してあります。今なら時代が追いついた気がしていました。

mizchi/trans-loader: webpack-less frontend with service-worker

これでも十分だったのですが、vite 等を見るに、大量のトランスフォームを効率よく実行するにはキャッシュしつつファイル監視して、変更によってキャッシュを破棄する、という実装が必要でした。ここで役に立ったのが deno で、読み込み権限を制限したファイル監視を行うことができます。

現在の制約として、 service worker は同ホストのルートから返却しないといけない、という縛りがあります。そのため、初期化時に service-worker script を生成しています。これは将来的に開発用サーバーに埋め込んでしまうかもしれません。

ServiceWorker のスコープとページコントロールについて - Qiita

本番ビルド

元々、自分は uniroll というブラウザ内で動く rollup 拡張を作っていて、その一部として rollup-plugin-http-resolve という、import React from "https://cdn.esm.sh/react" のようなネットワークを対象にした import をバンドルできるプラグインを作っていました。

mizchi/uniroll: Opinionated universal frontend bundler in browser

uniroll/packages/rollup-plugin-http-resolve at master · mizchi/uniroll

開発時は skypack で esm.sh の ESM CDN を使いつつ、本番用には rollup-plugin-http-resolve を使ってバンドルします。また、deno の FS API を介したファイルローダーの rollup-plugin も書きました。これは後で切り出して deno 用プラグインとして公開します。

https://deno.land/x/swdev@0.2.1/plugins.ts#L106-L138

苦労した点: ESM Cache bursting

Service Worker は同じ URL で import した対象をキャッシュしてしまいます 。hot reload を実装しようとした際、二度目の import は service worker の onfetch まで到達しませんでした。

同プロセス内でこの cache を捨てる方法が調べてもわからず、 Chrome の中の人達に聞いてみても、現状無い、とのことでした。

https://twitter.com/nhiroki_/status/1375605566807019522

https://twitter.com/horo/status/1375484569412009985

この問題に対処するために、 相対パスのリクエストを返却する際に、 ?${Math.random()} のハッシュを付けるように全部書き換えました。

https://deno.land/x/swdev@0.2.1/client-src/swdev-worker.ts#L128-L131

(正規表現の雑な対応なので、あとでimport "..."export {...} from "..."等の他のパターンも対応できるようにします。)

これだと全部のリクエストで キャッシュが飛んでしまうように見えますが、これも service worker の内部で ?... を除いた部分でキャッシュ問い合わせするようにしてるので、ちゃんとビルド済みのキャッシュを引けています。

もうちょっと真面目に本当に頑張るんだったら、ファイル変更時に sha256 等の hash を送って、その対象ファイルを依存に含む依存グラフを計算して書き換える、みたいにすると、ブラウザ上のメモリ効率が良くなる気がしますが、後回しです。今は Chrome の GC に頼ってます。

拡張案: Inline Browser Editor

deno の権限コントロールを生かして、websocket サーバー経由のファイル書き込みを実装しようと考えています。

monaco-editor 等のインラインブラウザを立ち上げ、ファイルを変更すると自分自身で SW に対するキャッシュ破棄命令を発行しつつ、ファイルを書き換えます。これによって、ブラウザ内からホスト環境のファイルに対するコントロールを得ることができます。

この際、悪意あるスクリプトによるディスクトラバーサル等のセキュリティの不安は deno sandbox 機構がある程度守ってくれます。SW のレベルでも、許可するリクエスト先をホワイトリストで絞っておいたほうがいいかもしれませんね。

https://deno.land/manual/getting_started/permissions

また、これを webrtc P2P や ngrok のようなツールを通して、自分の FS 他人と同時に編集できるサーバーを建てることができるのではないか、と考えています。もちろん信頼できる人同士でしか使わない前提です。他人の環境から DDOS とかしようと思えばできるので。

ブラウザ内で完結するエディタは昔から構想していたものなので、ぜひやってみたいですね。

拡張案: Cloudfrare Workers

実際 deno のインストールを要求してることからわかるように、これだけではまだ、「TypeScript をブラウザ上で動かす」のに対して、完全無設定ではありません。真に No Bundle, コンパイラを意識しない環境を作るなら、deno のスクリプトすらインストールさせたくない、と考えました。

そこで考えたのが、 Cloudflare Workers で、 Service Worker と似たインターフェースを持ち、HTTP リクエストをプロキシできます。

Cloudflare Workers®

ファイル実体の解決のために、なんらかのバックエンドを実装する必要がありますが、 service worker すら置かずに swdev を Cloudflare Workers で動かすことで、完全無設定の No Bundle Frontend が実現できます。本番用ビルドをリクエスト時に生成すれば、プロダクションまでサポートできますね。

これによって、特定ホスト下で 「TypeScript がネイティブに動くブラウザ」に見せかけることができます。

で、ここのバックエンドをなにに使おうか考えていて、 組み込みの Workers KV はパフォーマンスに優れているものの、結果整合なのでエディタのような高頻度の書き換えに向いていません。

というわけで、今度出る新機能の Durable Objects なら共同編集なんかも実装できるんじゃないか、と思って期待してます。

Workers Durable Objects Beta: A New Approach to Stateful Serverless

Cloudflare Workers の Durable Objects について

自分はベータ申し込みしていて、2021 Q1 に出るぞ、とメール来てましたが、まだ出てないですね… このへんはリリースされたら考えます。

おわり

簡単なコマンドで使えるので、ぜひ使ってみてください。

機能要望や アイデアがあったら Issue でお願いします。

https://github.com/mizchi/swdev

この記事に贈られたバッジ