Webview を使った VSCode Extension

# 本記事の目的

表題の通り、Webview を用いた VSCode Extension の作り方について書いていますが、

VSCode Extension の作り方については以下の公式ドキュメントを読めば分かります。

https://code.visualstudio.com/api/get-started/your-first-extension

また、Webview についてもきちんと解説があります。

https://code.visualstudio.com/api/extension-guides/webview


本記事はほぼ自分用のメモでして、作ってみた結果いくつか注意点等ありましたので、コードをテンプレ化してみた感じです。

# テンプレコード

https://github.com/megmogmog1965/example-vscode-extension-webview

本記事のコードは↑にあります。次の手順でデバッグ実行できます。

1. VSCode 上で `⌘ + Shift + P` を押してコマンドパレットを開きます

2. パレットから `Debug: Start Debugging` を選択します. すると新しい Extension 実行用の VSCode が起動します

3. Extension 実行用の VSCode 上で `⌘ + Shift + P` を押してコマンドパレットを開きます

4. パレットから `example-vscode-extension-webview: Hello World` を選択します

# ディレクトリ構造

ほぼ公式チュートリアルの構造と同じですが、一部都合に併せて改変しています。

```
.
├── out
│   └── extension.js
│
├── src
│   ├── command
│   │   ├── _app.ts
│   │   ├── _utils.ts
│   │   └── *.ts
│   └── extension.ts
│
├── media
│   ├── cat.gif
│   ├── events.js
│   └── style.css
│
├── eslint.config.mjs
├── tsconfig.json
└── package.json
```

| Directory | 説明 |
|:----------|:-----|
| `out/*` | `npm run compile` すると、ここに `tsc` コンパイルの結果が出力されます。 |
| `src/extensions.ts` | VSCode Extension のエントリコードです。 |
| `src/command/*` | webview ⇔ Extension 間の複雑性を縮減するために型化したコードです。 webview 側から呼べる API の定義をしています。 |
| `media/*` | `src/extension.ts` コードで定義した Webview (html) で参照しているリソース類の置き場です。<br/>e.g. `<link>`, `<script>`, `<img>`...|

# 注意・工夫点

## tsconfig.json

```diff:tsconfig.json
  {
    "compilerOptions": {
-     "module": "Node16",  // or "CommonJS"
+     "module": "CommonJS",  
      "target": "ES2022",
      "outDir": "out",
      "lib": [
        "ES2022"
      ],
      "sourceMap": true,
      "rootDir": "src",
      "strict": true,   /* enable all strict type-checking options */
      /* Additional Checks */
      // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
      // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
      // "noUnusedParameters": true,  /* Report errors on unused parameters. */
+     "esModuleInterop": true
    }
  }

```

大事な点ですが、VSCode Extension 自体は `package.json` / `tsconfig.json` の設定で **CommonJS** プロジェクトとして設定する必要があります。

2024/11 現在、プロジェクトを **[ESM として設定する](https://nodejs.org/api/packages.html#determining-module-system) と動きません！**

これを踏まえて `tsconfig.json` の `"module": "..."` には２つの選択肢があります。

| module | 説明 |
|:-------|:-----|
| Node16 | VSCode Extension の標準はこれで今後一般的に推奨されます。 <br/><br/>しかし一部の npm modules ([e.g. @langchain](https://github.com/langchain-ai/langchainjs/issues/4060)) では `tsc` コンパイル時に後述するエラーが発生してしまいます。 |
| CommonJS | 昔からの挙動をとる互換のための設定で、最近では `Node16` に置き換えられつつあります。<br/><br/>がしかし前述の `Node16` の問題は、こちらを設定すると解消されます。現時点では VSCode Extension は CommonJS module しか参照できないので特に問題はない筈... |

:::note
NPM で公開されている一部の commonjs / ESM 両対応の module では `package.json` の `"exports":` の設定誤りによって `tsc` コンパイル時に次のエラーが発生します。

```
error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls;
  however, the referenced file is an ECMAScript module and cannot be imported with 'require'.
```
:::

また、`"module": "CommonJS"` を選択する際は、明示的に `"esModuleInterop": true` を設定する必要があります。 

これを設定しないと `import fs from 'fs'` の様な default import でコンパイルエラーが発生してしまいます。

> `"module": "Node16"` の場合は default = `true` となるので、VSCode Extension の初期コードでは消されているようです。

https://www.typescriptlang.org/tsconfig/#esModuleInterop

## eslint.config.mjs

VSCode Extension の default コードは古い書き方になっているため、2024/11 現在で推奨されている flat config & [typescript-eslint](https://typescript-eslint.io/) のものに書き換えています。

https://github.com/megmogmog1965/example-vscode-extension-webview/blob/main/eslint.config.mjs

## src/extension.ts

このファイルが VSCode Extension のエントリコードです。
Extension の初期化・登録と、Webview html の生成を行っています。

https://github.com/megmogmog1965/example-vscode-extension-webview/blob/main/src/extension.ts

ここではチュートリアルコードのまま、コード中に文字リテラルとして Webview html を直接記述しています。

リソース類 (`.gif`, `.css`, `.js`) は後述する `media/*` ディレクトリにおいてあり、`src=` 属性でパス参照をします。

html ヘッダでは `<meta http-equiv="Content-Security-Policy" ... />` を設定しており、javascript インラインコード等は禁止しています。

```typescript:src/extension.ts
import * as vscode from 'vscode'
import { commandRouter } from './command/_app'

export function activate(context: vscode.ExtensionContext) {
  // ...省略...
}

function getWebviewContent(cspSource: string, gif: vscode.Uri, script: vscode.Uri, css: vscode.Uri) {
  return `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'none'; img-src ${cspSource} https:; script-src ${cspSource}; style-src ${cspSource};"
    />
    <title>Cat Coding</title>
    <link rel="stylesheet" href="${css.toString()}">
</head>
<body>
    <img src="${gif.toString()}" width="300" />

    <button id="findfiles">List files</button>
    <textarea id="files"></textarea>

    <button id="createfile">Create &quot;hello.txt&quot;</button>

    <script src="${script.toString()}"></script>
</body>
</html>`
}
```

## src/command/*.ts

こちらは完全に独自のファイルです。 Webview (html) から Extension 宛に呼べる API の定義をしています。

[tRPC / zod](https://trpc.io/docs/quickstart#3-using-input-parser-to-validate-procedure-inputs) のインターフェースを真似てみました。

```typescript:_app.ts
import { z } from 'zod'
import { createCommandRouter, CommandFactory } from './_utils'
import type { SendResponse, Command } from './_utils'
import { findFiles } from './findfiles'
import { createFile } from './createfile'

export const commandRouter = createCommandRouter(getHandlers)

function getHandlers(sendResponse: SendResponse): Command[] {
  return [
    // 'findfiles' API 定義.
    CommandFactory
      .command('findfiles')
      .input(z.object({
        dir: z.string(),
      }))
      .query(async (message) => {  // .input(zod) で定義した型の message が来る.
        // process.
        const files = await findFiles(message)

        // send response.
        sendResponse({
          command: message.command,
          result: files,
        })
      }),

    // 'createfile' API 定義.
    CommandFactory
      .command('createfile')
      .input(z.object({}))
      .query(async (message) => {
        await createFile(message)
      }),
  ]
}
```

https://github.com/megmogmog1965/example-vscode-extension-webview/tree/main/src/command

### ※ Webview 本来の仕組み

VSCode Extension では、Webview (html) からは [vscode.postMessage()](https://code.visualstudio.com/api/extension-guides/webview#passing-messages-from-a-webview-to-an-extension) で、逆に Extension からは [webview.postMessage()](https://code.visualstudio.com/api/extension-guides/webview#passing-messages-from-an-extension-to-a-webview) でメッセージ送信できます。

```mermaid
sequenceDiagram
    participant Webview as webview(html)
    participant Extension as VSCode_Extension

    Webview ->>+ Extension: webview.postMessage()
    Extension ->>- Webview: vscode.postMessage()
    Webview ->>+ Extension: webview.postMessage() ...
```

設計として一貫性を持たせないと、コードが複雑化し簡単に循環参照に陥りそうなので本 API 定義コードを書きました。

## media/*

html から参照する公開リソースの置き場です。

公式チュートリアルに従い `media/*` としていますが、他フレームワークではよく `public/*` と命名されることが多いですね。

ここで特に重要なのは `media/event.js` で、このファイルは Webview (html) に `<script src="..."></script>` 埋め込みで参照されています。

この javascript ファイルでは主に Webview (html) の DOM Event 処理をします。

### 1. メッセージ送信 (Webview → Extension)

Webview (html) 上のボタンが押された際の処理をコーディングしています。
`vscode.postMessage()` で Extension 側で定義した API を呼んでいます。

```javascript:media/event.js
const vscode = acquireVsCodeApi()  // eslint-disable-line no-undef

const buttonFindfiles = document.getElementById('findfiles')
const buttonCreatefile = document.getElementById('createfile')

// send findfiles command.
buttonFindfiles.addEventListener('click', () => {
  vscode.postMessage({
    command: 'findfiles',
    params: {
      dir: '.',
    },
  })
})

// send createfile command.
buttonCreatefile.addEventListener('click', () => {
  vscode.postMessage({
    command: 'createfile',
    params: {},
  })
})
```

### 2. メッセージ受信 (Extension → Webview)

逆にこちらのコードでは Extension 側からのメッセージ応答を受信しています。

```javascript:media/event.js
const textarea = document.getElementById('files')

// Handle the message inside the webview
window.addEventListener('message', event => {
  const message = event.data

  // define event handlers.
  const eventHandlers = {
    findfiles: () => {
      textarea.value = message.result.join('\n')
    },
  }

  // call handler.
  eventHandlers[message.command]()
})
```