p5.js Web Editor で編集中のコードをローカルに投げる Chrome 拡張機能を作った

# はじめに

本記事では表題の通りの Chrome 拡張機能を作った解説を行います。
対象者は以下の全てに当てはまる人です。

- どうしても [p5.js Web Editor](https://editor.p5js.org/) で編集したコードをローカルに送付する必要がある
- でも JavaScript を毎回貼り付けるのは面倒

別にスケッチを作るたびに 適当なJavaScript をページにコピペすればいいので、その手間すら惜しい(私のような)人向けです。

なお、**「CodeMirror(5系)が含まれるページ」** や **「クライアントサイドレンダリングのページ」** や **「別オリジンでのiframeが含まれるページ」** での拡張機能の書き方についての言及もあるので合わせてお読みください。

# コード
https://github.com/Snowman-s/discord-ai-chatbot/tree/master/ext/p5js_web_editor_ext

# 背景

もともと [Gemini + A.I.VOICE を使ったAIチャットボット](https://github.com/Snowman-s/discord-ai-chatbot)を作っていました。
構成はおよそ以下みたいな感じです。

```mermaid
flowchart LR
A(Discord におけるユーザーの発話)
B(Google Speech-To-Text)
C(Gemini)
D("A.I.VOICE <br>(A.I.VOICE API経由)")
E(ユーザー)
A-->B-->C-->D-->E
```

p5.js Web Editor(以下、エディタ) で作業するときの通話に使いたいと思い、コードを共有しようと考えました。

```mermaid
flowchart LR
A(Discord におけるユーザーの発話)
B(Google Speech-To-Text)
C(Gemini)
D("A.I.VOICE <br>(A.I.VOICE Editor API経由)")
E(ユーザー)
F{{"p5.js Web Editorの<br>コード+キャンバス<br>(拡張機能+WebSocket経由)"}}
A-->B-->C-->D-->E
F-->C
```

A.I.VOICE Editor APIの都合上、ローカルに送付する必要があるので、拡張機能を実装するのが必要なのでした。

# そして

今回の拡張機能で重要になってくるポイントは3点です。

まず
- **「ローカルへのデータ送信方法」**

それから「はじめに」でも述べた、
- **「CodeMirrorが含まれるページ」**
- **「クライアントサイドレンダリングのページ」**
- **「別オリジンでのiframeが含まれるページ」** 

です。

## ローカルへのデータ送信方法

まぁデータ送信については標準機能ですからそんなに難しいことはないです。

およそHTTP通信かWebSocket 通信が考えられますが、今回は通信量が多いことから WebSocketとしました。

```typescript: ローカル側
// 受け取る側 (ローカルで動いているプログラムの方)
const wss = new WebSocket.Server({
  port: 8080
});

wss.on("connection", (ws) => {
  console.log("クライアントが接続");

  ws.on("message", async (msg, isBinary) => {
    // ...
  }
});
```

```content.js
// content.js (拡張機能の方)
const WS_URL = "ws://localhost:8080";
let ws = new WebSocket(WS_URL);

// ...

ws.send(JSON.stringify(
  {/* ... */}
));
```

## CodeMirror(5系) が含まれるページ

[Code Mirror](https://codemirror.net/5/) とは、HTML上にプログラムコード編集用の要素を設置するためのJavaScript ライブラリです。現在はバージョン6系が最新なようですが、p5.js WebEditor では[5系を使って](https://github.com/processing/p5.js-web-editor/blob/develop/package.json#L180)います。

5系では、`CodeMirror`という名前の変数がJavaScript上で格納され、それにアクセスする形でコードを取得します。

TypeScript で言うなら...

```typescript
const code:string = document.querySelector(".CodeMirror").CodeMirror.getValue();
```

です。

### 発生問題

さて、拡張機能で`CodeMirror`にアクセスする場合の問題として、以下があります。

**「この`CodeMirror`変数は、拡張機能のJavaScirptからはアクセスできない」** 

どういうことかというと、拡張機能のJavaScriptはページのJavaScriptと[分離された環境](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts?hl=ja#isolated_world)で実行されるので、変数にアクセスができないのです。

**以下のコードはエラーとなります。**

```typescript:content.js
// CodeMirror が undefined なのでエラーとなる
const code:string = document.querySelector(".CodeMirror").CodeMirror.getValue();
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496507/ea29bd10-36a6-44a1-b48f-871c1cb34e31.png)

### 解決

これを解決するためには、**JavaScriptをページ上に注入** してやる必要があります。

```typescript:content.js
// inject the page script
const script = document.createElement("script");
script.src = chrome.runtime.getURL("injected.js");
document.getElementsByTagName("body")[0].appendChild(script);
// inject.js からはCodeMirror変数にアクセス可能
```

ついでに `manifest.json` の設定を少しいじるのも必要です。

```jsonc: manifest.json
{

  // ......

  // "injected.js" を登録する必要がある
  "content_scripts": [{
    "matches": ["https://editor.p5js.org/*"],
    "js": ["content.js", "injected.js"],
    "run_at": "document_idle"
  }]

  // ......

  // "injected.js" を "content.js" から見えるようにする
  "web_accessible_resources": [{
    "resources": ["injected.js"],
    "matches": ["https://editor.p5js.org/*"]
  }],

  // ......
}
```

:::note warn
[Scripting API](https://developer.chrome.com/docs/extensions/reference/api/scripting?hl=ja#method-executeScript) を使うことも可能そうですが、現在`content.js`側から[直接現在TabのIdを得ることはできない](https://github.com/w3c/webextensions/issues/469) ことから、処理が煩雑になるゆえ、この方が楽でしょう。
:::

## クライアントサイドレンダリングのページ

### 発生問題
[p5.js Web Editorのindexのソース](https://github.com/processing/p5.js-web-editor/blob/develop/client/index.jsx)を見てもらうと分かるように、p5.js は React で書かれています。
そのため、通常の`content_scripts`が走るタイミングでは、中身がありません。

```typescript:injected.js
// この時点では、まだReact描画中
const cm = document.querySelector(".CodeMirror");
// false
console.log(cm && cm.CodeMirror);
```

### 解決

これを解決するためには、[MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver) を使って、完全にデータがセットされるまで待ちます。

```typescript: injected.js
const observer = new MutationObserver(() => {
  const cmEl = document.querySelector(".CodeMirror");
  if (cmEl && cmEl.CodeMirror) {
    observer.disconnect();

    // ...cmEl.CodeMirror を使った処理...
  }
});

observer.observe(document.body, { childList: true, subtree: true });
```

## 別オリジンiframeへのアクセス

p5.js WebEditor では、CodeMirror の実行結果を`<iframe>`に投影します。
ここに投影されるCanvas の表示もローカルに送りたいところです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496507/264686a2-3c0f-4c7c-b73c-13ba6e36db6d.png)

### 発生問題

実は、この `<iframe>` の [src](https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/iframe#src) は `https://editor.p5js.org/` ではなく、**`https://preview.p5js.org`** です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496507/517f0f3a-5684-4b2c-b58d-2a91c8bce81f.png)

**この場合、この`<iframe>`の中身は外から参照できません。** 
どうやら [SOP](https://developer.mozilla.org/ja/docs/Web/Security/Same-origin_policy) (Same Origin Policy)というものに引っかかるようです。(完全に調べ切れてはいません)。

### 解決

これを解決するためには、`manifest.json` を編集することで、**`https://preview.p5js.org` ページ上で直接JavaScriptを実行します。**

```jsonc: manifest.json
{
  // ......

  "content_scripts": [
    {
      "matches": ["https://editor.p5js.org/*"],
      "js": ["content.js", "injected.js"],
      "run_at": "document_idle"
    },
    
    // 追加
  
    {
      "matches": ["https://preview.p5js.org/*"],
      "js": ["content_iframe.js"],
      "run_at": "document_idle",

      // これがないとiframeを見てくれない
      "all_frames": true
    }
  ],

  // ......
}
```

```javascript: content_iframe.js  
// キャンバスを取得
// あ、そうそう。なぜかiframeは二段重ねになっています。
// だけど、内側のiframeは同一オリジンなのでアクセス可能です。
let canvas = 
  window.document.body.querySelector('iframe')
    .contentDocument.body.querySelector('canvas');

canvas.toBlob(blob => {
  // Canvasの描画内容 (blob) を取得
});

```

# おわりに
以上が、本拡張機能を実装するのにあたって山となった部分です。
他の詳しい実装は GitHubをご覧ください。
もっと良い実装方法があればぜひコメントなどで教えてください。

https://github.com/Snowman-s/discord-ai-chatbot/tree/master/ext/p5js_web_editor_ext

# 余談
Canvasの画像をGeminiに送ったら、目に見えて応答が悪くなった。
トークン数の減りも早くなるしやめようかな。