ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

复刻Codex浏览器插件-实现篇:Monorepo下Service Worker与Chrome扩展骨架配置TaoToken

复刻Codex浏览器插件-实现篇:Monorepo下Service Worker与Chrome扩展骨架配置TaoToken 1. 为什么我要把 Codex 插件塞进 MonorepoCodex 浏览器插件本质上是一个 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 EnvelopeT 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 扩展 manifestapps/extension/manifest.jsonMV3 格式{ 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.tsimport { 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): PromiseEnvelope { 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 headmeta charsetutf-8/head body script srcoffscreen.js/script /body /htmlapps/extension/src/offscreen.tsimport { 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 documentasync function ensureOffscreen(): Promisevoid { 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.tsimport { 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.tsimport type { AppConfig } from ./config; export async function chat( cfg: AppConfig, messages: Array{ role: string; content: string } ): Promisestring { 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 返回 401Key 无效或者格式不对。检查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 关闭、页面还没加载完就发命令、用户手动切换标签页。这些我在后续的实现篇里会继续拆。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进