☰
复刻Codex浏览器插件-实现篇:Monorepo下Service Worker与Chrome扩展骨架配置TaoToken
2026/9/29 3:59:41 网站建设 项目流程

1. 为什么我要把 Codex 插件塞进 Monorepo

Codex 浏览器插件本质上是一个 Chrome 扩展,它要做的事情很明确:让 AI Agent 能远程指挥你本地的浏览器,完成导航、点击、输入、抓取文本这些原子操作。听起来不复杂,但真正动手写的时候,第一个卡住我的不是 Service Worker 的生命周期,也不是 WebSocket 的连接管理,而是项目结构。

我第一版用的是 Python 写本地代理、TypeScript 写扩展,两个目录各自为政。结果就是:通讯协议两边对不上,Agent 在补全代码时拿不到另一侧的上下文,写出来的东西基本跑不起来。最典型的是消息信封的字段名,一边叫browserId,另一边写成browser_id,调试的时候只能一个 case 一个 case 地改,效率极低。

所以这一版我做了两个决定:第一,全部用 TypeScript;第二,用 Monorepo 把 CLI、扩展、本地代理、WebSocket 服务端放在同一个仓库里,共享协议定义放在packages/shared。这样 Agent 在编码时能直接看到协议类型,很多低级错误在写的时候就被类型系统拦住了。

这篇文章交付的是可复制的骨架:Monorepo 目录结构、Chrome MV3 的 manifest、Service Worker 的连接逻辑、以及接入 TaoToken 统一 Key/API 通道的配置文件。最后我会演示一次本地加载验证,让你跑通插件的基础链路。

适合谁看:已经写过 Chrome 扩展、想用 Monorepo 重构工程结构的开发者;或者正在做 AI Agent 控制浏览器这类项目、需要一套清晰骨架的人。如果你还没碰过 Chrome 扩展,建议先补一下 MV3 的基础概念再回来。

2. TaoToken 前置:统一 Key 与 API 通道

在讲代码之前,先把 TaoToken 的接入位置说清楚。这个插件项目里,凡是需要调用大模型能力的地方——比如 Agent 生成操作指令、或者对页面内容做语义理解——都走 TaoToken 的统一通道。这样做的好处是:Key 只需要配一次,模型切换、额度管理、调用日志都在一个地方看。

你需要准备的东西:

  • 一个 TaoToken 账号,登录后进入控制台
  • 在 API Keys 页面创建一个 Key,复制出来
  • 确认你要用的模型名称(比如 Claude 系列、GPT 系列,具体以控制台可用列表为准)

TaoToken 的 API 入口是https://taotoken.net/api,兼容 OpenAI 风格的请求格式。也就是说,你原来用openaiSDK 写的代码,只需要把baseURL换掉、apiKey换成 TaoToken 的 Key,其余调用方式基本不用动。

注意:Key 不要硬编码进扩展源码。Chrome 扩展的代码是可以被用户解包的,硬编码等于把 Key 公开。正确做法是让本地代理持有 Key,扩展通过本地 WebSocket 向代理请求,代理再去调 TaoToken。

这个设计和我后面要讲的 Service Worker 睡眠问题是连在一起的:扩展的 Service Worker 会休眠,但本地代理是常驻进程,把 Key 和网络请求放在代理侧,稳定性会好很多。

如果你还没创建 Key,可以先到控制台把 Key 建好,后面配置文件里要用。模型对话能力可以在模型对话页面先验证一下 Key 是否可用,确认能正常返回再往下走。

3. Monorepo 目录骨架与可复制配置

3.1 目录结构

我用的包管理器是 pnpm workspace,结构如下:

browser-bridge/ ├── pnpm-workspace.yaml ├── package.json ├── tsconfig.base.json ├── apps/ │ ├── cli/ # AI Agent 命令行入口 │ ├── extension/ # Chrome MV3 扩展 │ ├── local-proxy/ # 常驻本地代理 │ └── websocket/ # WS 服务端 └── packages/ └── shared/ # 共享协议与类型

pnpm-workspace.yaml内容:

packages: - "apps/*" - "packages/*"

根package.json里加几个脚本,方便一次性构建:

{ "name": "browser-bridge", "private": true, "scripts": { "build": "pnpm -r build", "dev:proxy": "pnpm --filter local-proxy dev", "dev:ext": "pnpm --filter extension dev" }, "devDependencies": { "typescript": "^5.4.0" } }

3.2 共享协议包

packages/shared/src/protocol.ts是整个项目的核心,所有通讯都基于这个信封:

export type MessageType = "command" | "response" | "event"; export interface Envelope<T = unknown> { id: string; type: MessageType; browserId: string; payload: T; timestamp: number; } export interface CommandPayload { action: string; selector?: string; text?: string; url?: string; tabId?: number; } export interface ResponsePayload { status: "ok" | "error"; data?: unknown; error?: string; message?: string; } export const DEFAULT_PROXY_PORT = 3001; export const SW_BUFFER_MS = 5000;

这个文件被apps/extension和apps/local-proxy同时引用,协议字段一旦改动,两边都会在编译期报错。这就是 Monorepo 最直接的价值。

3.3 Chrome 扩展 manifest

apps/extension/manifest.json,MV3 格式:

{ "manifest_version": 3, "name": "Browser Bridge", "version": "0.1.0", "description": "AI Agent 远程控制浏览器的本地桥接扩展", "permissions": ["tabs", "activeTab", "scripting", "offscreen"], "host_permissions": ["<all_urls>"], "background": { "service_worker": "background.js", "type": "module" }, "action": { "default_popup": "popup.html", "default_title": "Browser Bridge" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ] }

这里有个关键点:permissions里加了offscreen。这是为了解决 Service Worker 30 秒休眠的问题——后面会详细讲。

3.4 Service Worker 连接骨架

apps/extension/src/background.ts:

import { Envelope, DEFAULT_PROXY_PORT } from "@browser-bridge/shared"; let socket: WebSocket | null = null; let reconnectTimer: number | null = null; function connectToProxy(): void { socket = new WebSocket(`ws://localhost:${DEFAULT_PROXY_PORT}`); socket.onopen = () => { console.log("[SW] connected to local proxy"); sendEvent("sw_online"); }; socket.onmessage = async (event) => { const envelope: Envelope = JSON.parse(event.data); const result = await handleCommand(envelope); socket?.send(JSON.stringify(result)); }; socket.onclose = () => { console.log("[SW] proxy disconnected, retry in 2s"); scheduleReconnect(); }; socket.onerror = () => { socket?.close(); }; } function scheduleReconnect(): void { if (reconnectTimer !== null) return; reconnectTimer = self.setTimeout(() => { reconnectTimer = null; connectToProxy(); }, 2000); } async function handleCommand(envelope: Envelope): Promise<Envelope> { const { payload } = envelope; const cmd = payload as { action: string; tabId?: number }; try { if (cmd.action === "navigate") { const url = (payload as { url: string }).url; await chrome.tabs.update(cmd.tabId!, { url }); return buildResponse(envelope, { status: "ok" }); } if (cmd.action === "pageinfo") { const tab = await chrome.tabs.get(cmd.tabId!); return buildResponse(envelope, { status: "ok", data: { url: tab.url, title: tab.title } }); } // DOM 类命令转发给 content script const resp = await chrome.tabs.sendMessage(cmd.tabId!, envelope); return resp as Envelope; } catch (err) { return buildResponse(envelope, { status: "error", error: "execution_failed", message: (err as Error).message }); } } function buildResponse(req: Envelope, payload: unknown): Envelope { return { id: req.id, type: "response", browserId: req.browserId, payload, timestamp: Date.now() }; } function sendEvent(name: string): void { socket?.send(JSON.stringify({ id: crypto.randomUUID(), type: "event", browserId: "local", payload: { name }, timestamp: Date.now() })); } connectToProxy();

3.5 解决 Service Worker 30 秒休眠

这是我在第一版踩得最深的坑。Chrome MV3 的 Service Worker 在空闲约 30 秒后会被浏览器挂起,WebSocket 连接随之断开。如果你的连接逻辑写在 popup 里,popup 一关连接就没了;写在 Service Worker 里,30 秒后照样断。

解决方案是抽一个offscreen.html,用 offscreen document 维持长连接。offscreen document 的生命周期比 Service Worker 长,适合放这种需要持续运行的任务。

apps/extension/offscreen.html:

<!DOCTYPE html> <html> <head><meta charset="utf-8"></head> <body> <script src="offscreen.js"></script> </body> </html>

apps/extension/src/offscreen.ts:

import { DEFAULT_PROXY_PORT } from "@browser-bridge/shared"; let socket: WebSocket | null = null; function keepAlive(): void { socket = new WebSocket(`ws://localhost:${DEFAULT_PROXY_PORT}`); socket.onopen = () => { console.log("[offscreen] persistent connection established"); }; socket.onmessage = (event) => { // 转发给 Service Worker 处理 chrome.runtime.sendMessage({ target: "sw", data: event.data }); }; socket.onclose = () => { setTimeout(keepAlive, 2000); }; } keepAlive();

然后在 Service Worker 里按需创建 offscreen document:

async function ensureOffscreen(): Promise<void> { const existing = await chrome.offscreen.hasDocument(); if (existing) return; await chrome.offscreen.createDocument({ url: "offscreen.html", reasons: [chrome.offscreen.Reason.BLOBS], justification: "维持与本地代理的长连接,避免 Service Worker 休眠断连" }); }

注意:chrome.offscreen.Reason的取值要和你实际用途匹配,不同 Chrome 版本对 reason 的校验严格程度不一样。如果创建失败,先检查 manifest 里有没有声明offscreen权限。

3.6 TaoToken 配置文件

本地代理侧持有 TaoToken 的 Key,配置文件放在apps/local-proxy/config.json:

{ "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKey": "sk-your-taotoken-key", "defaultModel": "claude-sonnet-4-20250514", "timeoutMs": 30000 }, "proxy": { "port": 3001, "browserIdFile": "./.browser-id" } }

对应的加载代码apps/local-proxy/src/config.ts:

import { readFileSync } from "node:fs"; export interface AppConfig { taotoken: { baseUrl: string; apiKey: string; defaultModel: string; timeoutMs: number; }; proxy: { port: number; browserIdFile: string; }; } export function loadConfig(path = "./config.json"): AppConfig { const raw = readFileSync(path, "utf-8"); const cfg = JSON.parse(raw) as AppConfig; if (!cfg.taotoken.apiKey || cfg.taotoken.apiKey.includes("your-")) { throw new Error("请在 config.json 中填入有效的 TaoToken API Key"); } return cfg; }

调用 TaoToken 的封装apps/local-proxy/src/llm.ts:

import type { AppConfig } from "./config"; export async function chat( cfg: AppConfig, messages: Array<{ role: string; content: string }> ): Promise<string> { const resp = await fetch(`${cfg.taotoken.baseUrl}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${cfg.taotoken.apiKey}` }, body: JSON.stringify({ model: cfg.taotoken.defaultModel, messages, stream: false }), signal: AbortSignal.timeout(cfg.taotoken.timeoutMs) }); if (!resp.ok) { const text = await resp.text(); throw new Error(`TaoToken 请求失败 ${resp.status}: ${text}`); } const data = await resp.json(); return data.choices[0].message.content; }

这样 Key 只存在于本地代理进程里,扩展和 CLI 都不接触 Key,安全性上了一个台阶。

4. 本地加载验证:跑通基础链路

配置写完了,接下来验证。整个过程分三步:启动本地代理、加载扩展、发一条命令看返回。

4.1 启动本地代理

cd apps/local-proxy pnpm install pnpm dev

正常的话终端会输出:

[proxy] local ws server listening on ws://localhost:3001 [proxy] waiting for extension...

4.2 加载扩展到 Chrome

打开chrome://extensions,右上角开启「开发者模式」,点「加载已解压的扩展程序」,选择apps/extension/dist目录(先跑一次pnpm --filter extension build生成 dist)。

加载成功后,扩展卡片上会显示 Service Worker 状态。点「Service Worker」链接可以打开 DevTools 看日志。

4.3 验证连接

扩展加载后,Service Worker 会尝试连接ws://localhost:3001。回到本地代理终端,应该能看到:

[proxy] extension connected [proxy] browser registered: b-xxxx

如果没看到,打开扩展的 Service Worker DevTools,看 Console 里有没有报错。常见的是WebSocket connection failed,说明代理没起来或者端口不对。

4.4 发一条命令

用 CLI 发一条pageinfo命令:

cd apps/cli pnpm dev -- pageinfo --browser b-xxxx --json

预期返回:

{ "status": "ok", "url": "https://example.com", "title": "Example Domain" }

看到这个 JSON,说明整条链路通了:CLI → WS 服务端 → 本地代理 → 扩展 Service Worker → Chrome API → 原路返回。

4.5 验证 TaoToken 通道

再验证一下 TaoToken 的调用是否正常。在本地代理里加一个测试入口:

import { loadConfig } from "./config"; import { chat } from "./llm"; const cfg = loadConfig(); const reply = await chat(cfg, [ { role: "user", content: "用一句话说明什么是浏览器自动化" } ]); console.log("[taotoken]", reply);

跑一下,如果终端打印出模型返回的句子,说明 Key 和 API 通道都没问题。这一步过了,后面 Agent 生成操作指令的能力就有了基础。

5. 本篇常见错排查

5.1 Service Worker 显示 inactive

这是最常见的现象。Chrome 会在空闲后把 Service Worker 标记为 inactive,这是正常行为,不代表扩展坏了。判断是否真的断连,看本地代理终端有没有extension disconnected日志。如果连接稳定,SW 状态在 inactive 和 active 之间切换是没问题的。

如果频繁断连,检查 offscreen document 有没有成功创建。在 Service Worker DevTools 里执行:

chrome.offscreen.hasDocument().then(console.log);

返回true才算创建成功。

5.2 WebSocket 连接被拒绝

报错WebSocket connection to 'ws://localhost:3001/' failed,按顺序排查:

第一,本地代理进程是否在运行,终端有没有监听日志。第二,端口是否被占用,换个端口试试。第三,Chrome 扩展的host_permissions是否包含<all_urls>,虽然 localhost 通常不受限,但某些策略下会拦截。

5.3 content script 收不到消息

chrome.tabs.sendMessage报Could not establish connection,说明目标 tab 里没有注入 content script。原因通常是:页面在扩展加载之前就打开了,content script 没注入。刷新一下页面即可。如果刷新还不行,检查 manifest 里content_scripts的matches是否覆盖了当前页面。

5.4 TaoToken 返回 401

Key 无效或者格式不对。检查config.json里的apiKey是否完整复制,有没有多余空格。另外确认baseUrl是https://taotoken.net/api,不要漏掉/api路径。如果 Key 刚创建,稍等几秒再试,有时候有生效延迟。

5.5 协议字段对不上

这是 Monorepo 要解决的核心问题。如果还是遇到字段不匹配,检查packages/shared是否被正确引用。在apps/extension/package.json和apps/local-proxy/package.json里都要有:

{ "dependencies": { "@browser-bridge/shared": "workspace:*" } }

改完协议后跑一次pnpm -r build,让所有包重新编译,类型错误会在这一步暴露出来。

6. 下一步:把 Key 管好,把链路跑稳

骨架跑通之后,接下来最值得投入的两件事:一是把 TaoToken 的 Key 管理做规范,二是把断线重连和命令缓冲做扎实。

Key 管理方面,建议把本地代理的配置抽成环境变量或者独立的密钥文件,不要提交到 Git。如果你打算长期做这类 Agent 控制浏览器的项目,可以考虑用 Coding Plan 来统一管理编码场景下的模型调用额度,把开发期的调用和运行期的调用分开。

断线重连方面,我在本地代理里加了一个简单的命令缓冲:当扩展的 Service Worker 短暂休眠时,代理最多缓存一条命令、等待 5 秒。如果 5 秒内 SW 醒来,命令照常投递;超时就返回错误给 CLI。这个策略避免了「幽灵执行」——用户重新打开浏览器时,积压的命令突然全部跑起来。

接入文档里有完整的协议说明和错误码定义,遇到字段含义不清楚的时候可以直接查。模型对话页面可以用来快速验证 Key 和模型是否可用,不用每次都跑完整链路。

骨架只是起点,真正让插件好用的是那些边界情况的处理:SW 休眠、tab 关闭、页面还没加载完就发命令、用户手动切换标签页。这些我在后续的实现篇里会继续拆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询