JavaScript以外で作るChrome拡張機能

こんにちは、[Qiita学生対抗アドベントカレンダー23日目](https://qiita.com/advent-calendar/2022/student)を担当します。logiteca7(ろじ)です。

# はじめに

WebAssemblyを使って、簡単な**JavaScript以外の言語を使ったChrome拡張機能**を開発しました。
**今回の記事ではその手法の解説と次の開発目標についてお話したい**と思います。
また今回はRubyを使いましたが、理論上どんな言語でも出来るはずです。（多分Pythonも出来る）

動くデモはこちら↓（リポジトリにインストール方法が書いてあります。）

https://github.com/aaaa777/ruby-chrome-extention-demo.git

それではいってみましょう！

# 前提知識

### Webassemblyとは
**ブラウザ上で実行できるアセンブリ。**
ブラウザで動くJavaScript（以下JS）以外の言語の１つ。

https://qiita.com/ShuntaShirai/items/3ac92412720789576f22

### Chrome拡張とは
**Chromeにインストールして使うプラグイン**のこと。
Chrome以外のFirefoxや他ブラウザもサポートしているためブラウザ拡張と呼ばれることもある。

### Content Scriptとは
指定したサイトで**任意のJSを読み込むChrome拡張の機能**

# 技術概要

今年、ついにRubyが**WebAssemblyに対応**し、ブラウザ上でCRubyが動くようになりました。
↓既にRubyインタプリタのオンラインデモも公開されています。

https://irb-wasm.vercel.app

で、仕組みをざっくり説明すると次の図。

![Ruby-extension-ページ2.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303575/b1995aed-f2a9-66f9-877d-381ffa72384b.png)

このようにWebAssembly上でRubyが動いているイメージです。（一部操作はJSを介する必要がある）
僕はこれを使って**Chrome拡張機能でもRubyが利用できる**ようになるのではないかと考えました。


<!--今までもブラウザでRubyを動かすためにRuby->JSトランスパイラの**Opal**や、**mrubyとLLVM**を利用した試みがありましたが、実行速度が遅かったりCRubyのライブラリが動かなかったり、確かに動くが使いこなすには**機能が足りない**ような印象がありました。今回のRubyのWebAssembly対応では、**実用的な実行速度**で、**VFS**を使ってRubyのライブラリを1つのファイルにバンドル出来て、**JSをRubyで操作出来る**（！）ようになっているので、めちゃくちゃ使いやすくなっています。こんなんもう**ruby.wasm活用してみたくなるやん！**-->

# デモ画像

ということで今回は拡張機能のContent Scriptを使い、２つのRubyスクリプトを読み込ませてみました。

１つ目はCSSセレクタで**最初に見つかったh1タグの中身を書き換える**もの。

```ruby:src/content_script1.rb
# コンソールにメッセージ表示
puts "Hello, this is content_script1!"

# 見出しの文字を変える
document = JS.global[:document]
document[:querySelector].call(:call, document, "h1")[:innerText] = JS.try_convert("Rubyで動いたよ!")
# ホントは`JS.global[:document].querySelector("h1")[:innerText] = "something"`って書けます。
```

２つ目は**bodyのbackgroundColorをredに変える**コード。

```ruby:src/content_script2.rb
# コンソールにメッセージ表示
puts "Hello, this is content_script2!"

# 背景色を赤にする
JS.global[:document][:body][:style][:backgroundColor] = JS.try_convert("red")
```
**実行結果**

![unloosen-demo1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303575/f60b7892-69b9-1681-15ed-958a2328d661.gif)


うまくいきました、背景色の変更とコンソールのメッセージが確認できます。

他にも**fetch APIを使ったり、addEventListenerを呼んだり、Promiseのawaitもできます。**



# ファイル構成の解説

デモのリポジトリのファイル構成は以下のようになっています。

https://github.com/aaaa777/ruby-chrome-extention-demo.git

```
/
|- ruby.wasm
|- manifest.json
|- unloosen.config.json
|- package.json
|- dist/
|  |- content-script.umd.js
|  |- index.umd.js
|- src/
   |- content-script.js
   |- index.js
   |- content_script1.rb
   |- content_script1.rb
...他のファイル省略
```

それでは重要なファイルについて解説していきたいと思います。

### index.js

index.jsの中身は**コンパイル、RubyVMの起動、Rubyコードの実行ツールが入ったスクリプト**です。
ブラウザは配布されたWASMを直接実行することは出来ないため、一度JSでブラウザのPC用にコンパイルしてからRubyVMを実行します。

```js:index.js
import { DefaultRubyVM } from "ruby-head-wasm-wasi/dist/browser.umd.js";
export var UnloosenRubyVM;

// メッセージ出力
export const printInitMessage = () => {
    evalRubyCode(`
    puts <<-"INF"
Ruby Browser Extension by logiteca7/aaaa777
Ruby version: #{RUBY_DESCRIPTION}
    INF
`);
};

// ファイルのパスをchrome拡張用のURL形式に変換
const buildExtensionURL = (filepath) => {
    return new URL(chrome.runtime.getURL(filepath));
}

// RubyVMを初期化する関数
export const initRubyVM = async () => {
    const response = await fetch(buildExtensionURL("ruby.wasm"));
    const buffer = await response.arrayBuffer();
    const module = await WebAssembly.compile(buffer);
    const { vm } = await DefaultRubyVM(module);

    UnloosenRubyVM = vm;
};

// URLからrubyファイルを実行
export const evalRubyFileFromURL = async (url) => {
    await fetch(url)
        .then((response) => response.text())
        .then((text) => evalRubyCode(text));
};

// Chrome拡張のrubyファイルを実行
export const evalRubyFileFromExtension = async (filepath) => {
    await evalRubyFileFromURL(buildExtensionURL(filepath));
}

// 文字列のrubyコードを実行
export const evalRubyCode = async (code) => {
    UnloosenRubyVM.eval(code);
}
```

#### ruby-head-wasm-wasi/dist/browser.umd.js について

`index.js`の一文目の`import "ruby-head-wasm-wasi/dist/browser.umd.js"`はHTMLに埋め込んでRubyを実行するスクリプトです。
（RubyをWebassembly対応させた）kateiさん作で、RubyVMの起動に利用させて頂きました。

↓リンクからexploreタブの`/ruby-head-wasm-wasi/dist/browser.umd.js`でソースが確認できます。
<!-- https://www.npmjs.com/package/ruby-head-wasm-wasi?activeTab=explore -->

https://www.npmjs.com/package/ruby-head-wasm-wasi?activeTab=explore

### content-script.js

Chrome拡張機能のうち、特定のサイトでJSを読み込ませる機能をContent Scriptといいます。
このJSが読み込まれると、後述の`unloosen.config.json`に従ってRubyスクリプトをロードします。
`index.js`の関数を利用しました。

実行時はwebpackで`dist/content-script.umd.js`に纏めたものを使っています。

```js:content-script.js
import { initRubyVM, evalRubyFileFromExtension, evalRubyCode, evalRubyFileFromURL, printInitMessage } from "./index.js";

// rubyVMの起動
await initRubyVM();
printInitMessage();

// unloosen.config.jsonを読みこむ
fetch(chrome.runtime.getURL("unloosen.config.json"))
    .then((response) => response.json())
    .then((json) => {

        // content_scriptからURLとマッチしたcontent_scriptsを返す処理
        return json["content_scripts"].filter((rule) => {

            // 簡易matchesフィルタ
            // <all_urls>かそうでないかのみチェックする
            return rule.matches.filter((match) => {
                return match === "<all_urls>"
            }).length;

        })
    })
    .then((content_script) => {
        content_script.map((rule) => {
            
            // rbプロパティの配列にあるスクリプトを全て非同期実行
            rule.rb.map(async (ruby_script) => {
                await evalRubyFileFromURL(chrome.runtime.getURL(ruby_script));
            })
        });
    });
```

この２つのファイルは`npm install && npm run build`でビルドできます。

### manifest.json

**WASMを読み込むJSや拡張機能のアクセス権を定義**する、Chrome拡張の設定ファイルです。
content_scriptsでRubyVMを起動するスクリプトを起動するのがミソです。
この例では`http://www.example.com`と`https://www.google.com`のサイトで拡張機能が有効になります。
また、Webassemblyを動かすためには`content_secrity_policy`の設定が必須です。

```json:manifest.json
{
    "manifest_version": 3,
    "name": "ruby-demo-extension",
    "description": "ruby demo extension",
    "version": "0.0.1",
    "content_scripts": [
        {
            "js": ["dist/content-script.umd.js"],
            "matches": [
                "http://www.example.com/",
                "https://www.google.com/*"
            ]
        }
    ],
    "content_security_policy": {
        "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
    },
    "web_accessible_resources": [
        {
          "resources": ["*"],
          "matches": ["<all_urls>"]
        }
    ]
}
```

### unloosen.config.json

content script.jsが読み込まれた後に**読み込むRubyスクリプトを設定するファイル**です。
Chrome拡張機能の仕様ファイルではなく、僕が設置しました。
ちなみにUnloosenは今開発中の拡張機能フレームワークのコードネームだったりします。

```json:unloosen.config.json
{
    "content_scripts": [
        {
            "rb": ["src/content_script1.rb"],
            "matches": ["<all_urls>"]
        },
        {
            "rb": ["src/content_script2.rb"],
            "matches": ["<all_urls>"]
        }
    ]
}
```

### まとめ

ということで、実行するRubyファイルはこのような順序で参照されています。

![Ruby-extension-ページ3.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303575/5340de0f-04ce-7010-eee1-e130f5f2011c.png)



# 現在開発中のフレームワーク

ここまでのテストでRubyで拡張機能の開発ができることがわかったので、次は**Chrome拡張のRubyフレームワークを作ってみよう**かなと思ってます。

![Ruby-extension.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303575/37ab9f39-ca57-d01e-ae12-136d955b0d30.png)

現時点ではこんな感じでルーティング機能のついたものを考えてます。

### タスク

- **VFSを使ってライブラリをruby.wasmに組み込む**形で配布出来るようにする。
- ロードした時URLから読み込むスクリプトを分ける。（**ルーティング**機能）
- **Sinatraライクな書き方**でイベントフック出来るようにする。
- ruby.wasmのバージョン上げる（JSの関数呼び出しがなんかおかしい）

暇な時に進めていきます。

# その他雑記
### 雑記①
ちなみにこの拡張機能はあの~~悪名高き~~**Manifest Version 3（MV3）で動く**ように作りました。
MV3でJSのevalは使えないですが、WASM上のrubyではevalが使えます。（MV3でeval使えなくした意味とは？）

### 雑記②
WASM対応の功労者、kateiさんは実は同い年っぽい・・・
JSを利用する仕組みやirbのデモサイトのソースコードが美しかったので、いつかこれくらい書けるようになりたいと思うのでした。

# 最後に

今回僕はRubyで拡張機能を作ってみましたが、他言語でも同じように調査してみるつもりです。
ここまで読んで頂きありがとうございました。