React TypeScript で chrome 拡張機能を開発する時のトラブルシューティング (2025年)

# 概要

chrome 拡張機能を開発するうえで、vite を活用して React with TypeScript で開発しようと考えることは、近年では当たり前と言ってもよいと思います。実際、様々な記事で開発環境の作り方が紹介されています。

https://qiita.com/mi___y/items/3f7bc09e9177e023110d

https://zenn.dev/sui_water/articles/7d7daef8f4d057

https://tech.revcomm.co.jp/build-chrome-extension-with-typescript

基本的に、上記の記事を参考にして開発すればよいのですが、私の環境では特に build がうまく動作しませんでした。そこで本記事では、記事執筆時点の最新バージョンでもうまく動作する開発環境及び build の設定について紹介しようと思います。

# 前提知識

- chrome 拡張機能の文脈で利用される、次の用語がわかること
    - content_scripts
    - popup, options
    - service_worker など
- vite が概ね何をしているかがわかること
- JavaScript, TypeScript 及び React 自体の知見
  - (書き終わってから気が付いたけど、reactあんまり関係ない記事になってしまった...w)

# 環境

本記事では npm を利用しています。

```
$ node -v      
v22.14.0

$ npm -v 
11.1.0
```

また、特に重要と思われるライブラリのバージョンも示します。

- "vite": "^5.4.6"
- "@crxjs/vite-plugin": "^2.0.0-beta.31"
- "react": "^19.0.0"


なお筆者は windows 環境です (wsl を利用しない)。

# トラブルシューティング & Tips

ということで、最初に引用した記事の方法だけではうまくいかなかった点とその解消方法を置いておきます。

## 大前提

**@crxjs/vite-plugin は必ず beta バージョンを指定してインストールしましょう！！**

```bash
npm install --save-dev @crxjs/vite-plugin@beta
```

これは @crxjs/vite-plugin が長らく v1 のみしか stable としてリリースされていないためです。1.x ではもはや最近の vite で動作しません。

## `npm run dev` したけど拡張機能が起動しない

拡張機能のエラーログに、次のような CORS 云々で怒られるパターン。

```
Access to script at 'http://localhost:5173/@crx/client-worker' from origin 'chrome-extension://gbfkgllkhphjnpfpgfgmpahkbekgeenh' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
```

```
WebSocket connection to 'ws://localhost:5173/' failed: Error during WebSocket handshake: Unexpected response code: 400 
```
※ ポート番号は各々で異なると思います。


原因: vite のセキュリティ対策にライブラリが追いついていない
対策: vite.config.json に下記を追加する (暫定対応)

```javascript
  plugins: [...],
  ...
  legacy: {
    skipWebSocketTokenCheck: true,
  },
```

対応 issue: https://github.com/crxjs/chrome-extension-tools/issues/971 など

↓の個人ブログっぽいものに救われました...

https://www.masaakiota.net/2025/02/02/%E3%80%90crxjs-vite-plugin%E3%80%91cors%E3%80%81websocket%E3%80%81%E8%AB%B8%E3%80%85%E3%82%A8%E3%83%A9%E3%83%BC/

↓の設定を追加するとも書いてありましたが、これについては最新の @crxjs/vite-plugin なら `npm run dev` の際に自動的に挿入されているっぽいので不要です。

```json
{
  "host_permissions": [
    "<all_urls>"
  ]
}
```

# build 時に content_scripts に import が含まれてしまって動作しない

ファイル分割をして content scripts を開発するケースで起きます。これは大抵の拡張機開発チュートリアルで起きないので、見逃されがちですね...

原因: vite は import 構文が使える前提で build してしまう。一方で content scripts は browser に直接読み込ませて動作させる都合上、import 構文が利用できないため、エラーになります。

これは @crxjs/vite-plugin 側の原因に思えます。修正されるまでは、次のセクションで提案するように、**build には @crxjs/vite-plugin を利用しないことを提案します**。
...というより、全て @crxjs/vite-plugin を使わない方向に倒してもよいかもしれません...

# build できない

content_scripts が build できないケースがあります。

https://qiita.com/naokikobashi/items/9becd14d3693d6f760f5

ただし、筆者とは異なるエラーでした (エラーメッセージどこかに行ってしまった...)。
そこで少なくとも build については @crxjs/vite-plugin を使わない方向で検討した結果、次の記事を参考にしてやり方を確立しました。

https://tech.revcomm.co.jp/build-chrome-extension-with-typescript

やり方は次の通りです。
1. content_scripts の build を import を使わない方式にする
1. build の時だけ @crxjs/vite-plugin を利用しないように vite.config.ts を書き換える
1. dev の時は @crxjs/vite-plugin を利用するので、content_sciprts の設定を上書きするようにする

content_scripts 専用の build として次の vite.config.content.ts を用意します。

```vite.config.content.ts
// content script は inlineDynamicImports を true にしたいが、この設定は複数エントリーポイントに対しては使えないので、別途用意する
import { defineConfig } from "vite";

// https://vite.dev/config/
export default defineConfig({
  build: {
    outDir: "dist",
    emptyOutDir: false, // 他のエントリーポイントと同じディレクトリに出力するため、空にしない
    rollupOptions: {
      input: {
        content: "src/content/index.ts",
      },
      output: {
        entryFileNames: "[name].js",
        inlineDynamicImports: true,
        format: "iife",
      },
    },
  },
});
```

で、vite.config.ts を次のように設定します。

```vite.config.ts
import { crx, defineManifest } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig, UserConfig } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";

import prodManifest from "./manifest.prod.json";

// 任意の空いているポート番号
const PORT = 30000;

const devManifest = defineManifest({
  ...prodManifest,
  name: `${prodManifest.name} (dev)`,
  host_permissions: [`http://localhost:${PORT}/*`],
  // js を ts に変更 (実際のフォルダ構造に合わせて変えてください)
  content_scripts: [
    {
      js: ["src/content/index.ts"],
      matches: prodManifest.content_scripts[0].matches,
    },
  ],
});

const videDevConfig: UserConfig = {
  plugins: [react(), vanillaExtractPlugin(), crx({ manifest: devManifest })],
  // 例えデフォルトのポートでも、ここで明示しておいた方がトラブルが発生しにくい模様
  server: {
    port: PORT,
    strictPort: true,
    hmr: {
      port: PORT,
    },
  },
  // ref: https://www.masaakiota.net/2025/02/02/%E3%80%90crxjs-vite-plugin%E3%80%91cors%E3%80%81websocket%E3%80%81%E8%AB%B8%E3%80%85%E3%82%A8%E3%83%A9%E3%83%BC/
  legacy: {
    skipWebSocketTokenCheck: true,
  },
  build: {
    emptyOutDir: true,
    outDir: "dist-dev",
  },
};

const viteBuildConfig: UserConfig = {
  plugins: [
    react(),
    viteStaticCopy({
      targets: [
        {
          src: "manifest.prod.json",
          dest: ".", // NOTE: dest は dist 配下から見たパス。
          rename: "manifest.json",
        },
      ],
      structured: false,
      silent: false,
      watch: {
        reloadPageOnChange: true,
      },
    }),
  ],
  // content_scripts は vite.config.content.ts でビルドする
  build: {
    emptyOutDir: true,
    outDir: "dist",
    rollupOptions: {
      input: {
        popup: prodManifest.action.default_popup,
        options: prodManifest.options_page,
      },
      output: {
        entryFileNames: "[name].js",
        chunkFileNames: "chunks/[name]-[hash].js",
        assetFileNames: "assets/[name]-[hash][extname]",
      },
    },
  },
};

// https://vite.dev/config/
export default defineConfig(({ command }) => {
  if (command === "serve") {
    return videDevConfig;
  }

  return viteBuildConfig;
});
```

ここで manifest.prod.json は様々な記事の manifest.json において、content_scripts の部分が js になっている次のようなものです。 ファイル名は vite.config.content.ts で設定したものを利用してください。

```manifest.prod.json
{
  "manifest_version": 3,
  "name": "Your Extension Name",
  "version": "1.0.0",
  "permissions": ["storage"],
  "options_page": "src/options/index.html",
  "action": {
    "default_popup": "src/popup/index.html"
  },
  "content_scripts": [
    {
      "js": ["content.js"],
      "matches": ["*"]
    }
  ]
}
```

※ この方法では vite-plugin-static-copy が必要なので、適宜インストールしてください。
```bash
npm install -D vite-plugin-static-copy
```

上記設定では、次のような挙動になります。

`npm run dev`の時
- dist-dev フォルダに拡張機能のファイルが生成されます

`npm run dev`の時
- dist フォルダに拡張機能のファイルが生成されます

# まとめ

本記事では、最新の vite を用いた chrome 開発環境における私的トラブルシューティングを紹介しました。
もっと良い方法があれば是非コメントしてください。

~~ちょっとバージョン変わるだけで色んなところが壊れるのは本当にフロントエンドあるあるすぎる...~~