Claude Counter 架构拆解Manifest V3 下 Content Script 与 MAIN World 的 postMessage 桥接通信设计【免费下载链接】claude-counterA minimal browser extension that shows token count, cache timer, and usage bars on claude.ai.项目地址: https://gitcode.com/gh_mirrors/cl/claude-counterClaude Counter是一款轻量级 Chrome 浏览器扩展能在 claude.ai 页面顶部实时显示当前对话的 Token 计数、5 分钟缓存倒计时以及 5 小时/每周的使用量进度条。它的核心架构难题在于Manifest V3 扩展的 Content Script 运行在**隔离世界isolated world**中无法直接读取页面 JS 发出的 fetch 请求也无法拿到会话 Cookie。本文拆解 Claude Counter 如何用postMessage在 Content Script 与MAIN World之间搭建一座通信桥让扩展偷听到 API 响应并精准注入 UI——这是所有想拦截页面网络数据的 MV3 扩展都会遇到的经典问题。 先看懂问题MV3 扩展的双世界隔离在 Manifest V3 中浏览器把每个页面分成了两个互不相通的 JS 世界世界能做什么不能做什么isolated worldContent Script 默认所在安全访问 DOM、调用chrome.runtimeAPI看不到页面 JS 对象无法拦截window.fetch读不到会话 CookieMAIN world页面本身拥有完整页面权限fetch、Cookie、SSE 流不能直接调用扩展 APIClaude Counter 需要三样东西全在 MAIN world 里拦截window.fetch—— 捕获 Claude 前端的 completion 请求、对话树请求和 SSE 流带 Cookie 的请求—— 以用户身份调用/api/organizations/{orgId}/usagecrypto.subtle哈希—— 隔离世界里的 Content Script 没有 SubtleCrypto没法对消息做 SHA-256 指纹。解决方案就是本文的主角桥接bridge模式—— 向 MAIN world 注入一个脚本当内应两边用window.postMessage对话。️ 架构总览一份 manifest 两个角色打开 manifest.json整个通信骨架就藏在两段配置里content_scripts: [ { matches: [https://claude.ai/*], js: [src/content/constants.js, src/content/bridge-client.js, src/vendor/o200k_base.js, src/content/tokens.js, src/content/ui.js, src/content/main.js] } ], web_accessible_resources: [ { resources: [src/injected/bridge.js], matches: [https://claude.ai/*] } ]content_scriptsmanifest.json#L31-L44按依赖顺序加载 6 个脚本到隔离世界它们共享一个全局命名空间globalThis.ClaudeCounter下称CCweb_accessible_resourcesmanifest.json#L45-L50把 src/injected/bridge.js 声明为可被页面访问的资源——这是 MAIN world 侧内应的门票没有它页面无法加载该脚本。┌─────────────────── claude.ai 页面 ───────────────────┐ │ │ │ MAIN world postMessage(窗口级广播) │ │ ┌─────────────┐ ◄────────────────────────────────► │ │ │ bridge.js │ { cc:ClaudeCounter, type, … } │ │ │ 包装 fetch/ │ │ │ │ history/SSE │ isolated world │ │ └──────▲──────┘ ┌──────────────────────────────┐ │ │ │script │ bridge-client.js (桥客户端) │ │ │ 注入 script │ main.js / tokens.js / ui.js │ │ └─────────┴─────────┴──────────────────────────────┘ │ 第一步Content Script 把内应注入页面注入动作由 src/content/bridge-client.js 中的injectBridgeOnce()完成核心逻辑在 bridge-client.js#L91-L111const script document.createElement(script); script.src runtime.getURL(src/injected/bridge.js); (document.head || document.documentElement).appendChild(script);三个值得注意的工程细节runtime.getURL()把扩展内部路径解析成chrome-extension://绝对地址配合web_accessible_resources才能被页面加载单例保证bridgeReadyPromise缓存 Promise重复调用不会二次注入就绪门控src/content/main.js#L139 中所有依赖桥的请求都会先await bridgeReady确保内应上线后才发令。 postMessage 协议设计请求-响应 事件两种模式这是整个架构的灵魂。bridge.js 和 bridge-client.js 之间约定了一个极简协议所有消息都带cc: ClaudeCounter标记做身份过滤① 事件推送MAIN → Content单向// bridge.js 中只有两行bridge.js#L52-L54 window.postMessage({ cc: ClaudeCounter, type, payload }, *);事件名都以cc:前缀命名避免和页面自身消息混淆事件触发时机用途cc:generation_start拦截到/completionPOST 请求UI 立刻把缓存条置为等待中cc:conversation抓到对话树响应重新计算 Token 数cc:message_limit从 SSE 流里解析出用量数据更新 5h/7d 进度条cc:urlchangehistory.pushState被调用感知 SPA 路由切换② 请求-响应Content → MAIN → Content客户端发起请求时生成requestId把 Promise 存进_pendingMap超时默认 10 秒bridge-client.js#L54-L74服务端完成后用同一requestId回cc:response客户端据此 resolve/reject。目前支持三种kindhash借用 MAIN world 的crypto.subtle.digest(SHA-256)计算消息指纹供 src/content/tokens.js 做 Token 缓存见后文usage以用户身份credentials: include请求/api/organizations/{orgId}/usageconversation主动拉取对话树作为被动拦截之外的兜底。安全细节值得抄作业两侧监听器都先校验event.source ! window直接丢弃再检查data.cc ! ClaudeCounter防止页面里其他脚本伪造消息、或跨 iframe 消息误伤bridge.js#L130-L133、bridge-client.js#L19-L22。 MAIN World 侧的内应都在拦什么bridge.js 全文只有 183 行但每处都卡得极准抢在框架前包装window.fetchbridge.js#L7。注释写得很直白——Capture original fetch before anyone else can wrap it。React 等框架可能在之后缓存 fetch 的引用包装太晚就漏单了同样抢先包装history.pushState / replaceStatebridge.js#L10-L23每次调用后广播cc:urlchange自定义事件。Content Script 侧再叠加popstate监听前后覆盖 SPA 前进/后退/编程式跳转三种导航路径main.js#L61-L81SSE 流式解析对content-type: text/event-stream的响应执行response.clone()用getReader()逐行读data:事件只挑出message_limit转发bridge.js#L96-L128。注意它克隆的是 Response 本体、原样返回给页面且整体包在 try/catch 里——注释写着 best-effort; dont break claude.ai桥挂了也不能影响用户正常聊天对话树嗅探匹配/chat_conversations/…?tree的 URL解析出orgId和conversationId后把 JSON 转发出去bridge.js#L86-L94。 Content Script 侧事件驱动的 UI 更新main.js 把桥事件接成三条业务线main.js#L217-L219cc:generation_start→ UI 缓存条进入pending态提示新一轮生成中cc:conversation→ 调用CC.tokens.computeConversationMetrics()重算 Tokencc:message_limit→ SSE 带来的是精确未取整的用量小数比 Claude 原生/usage页更准直接刷新进度条。DOM 注入则靠MutationObserver轮询代替定时轮询waitForElement()main.js#L30-L57会在锚点元素出现的第一帧把 UI 挂上去。此外还有一个秒级tick()main.js#L291-L316负责倒计时走表并在 5h/7d 窗口到期时主动刷新——因为 SSE 只在用户发消息时才推数据窗口翻转那一刻必须自己伸手去拉。⚡ 一个隐藏亮点把 SHA-256 指纹也走桥Token 计算是最贵的操作tokens.js 的TokenCache用消息 ID 内容指纹避免重复计数。但隔离世界没有crypto.subtle于是它把待哈希文本通过桥发给 MAIN world 算完再拿回 8 字节前缀tokens.js#L135-L151——又一次印证桥的价值它不只是数据通道还是能力代理。 文件地图与阅读路线想亲手验证上述设计按这个顺序读代码效率最高文件角色manifest.json注入配置总入口先看两个 script 列表src/content/bridge-client.js桥客户端注入 请求-响应 事件订阅src/injected/bridge.jsMAIN world 内应fetch/SSE/history 拦截src/content/main.js业务编排导航感知、用量刷新、秒级 ticksrc/content/tokens.js对话树解析 缓存 Token 计数src/content/ui.js进度条、倒计时、长按提示等纯 UIsrc/content/constants.jsDOM 选择器、5 分钟缓存窗口、200k 上限不想装扩展的用户也可以看 userscript/claude-counter.user.js它是同一套逻辑的用户脚本版本。✅ 总结三条可复用的 MV3 桥接经验能看不到页面 JS就用web_accessible_resources 动态script把逻辑送进 MAIN world隔离世界只留指挥官协议要双向完备用requestId把 Promise 和消息对上请求-响应同时保留单向事件通道推送再给每个request配超时桥死了也不会卡死页面逻辑防御式拦截克隆 Response、全程 try/catch、优先缓存原始 fetch——扩展是寄生者永远不能影响宿主页面的正常行为。看懂了 Claude Counter 这座 183 行的桥你也就掌握了 Manifest V3 下Content Script ↔ MAIN World 通信的标准答案。【免费下载链接】claude-counterA minimal browser extension that shows token count, cache timer, and usage bars on claude.ai.项目地址: https://gitcode.com/gh_mirrors/cl/claude-counter创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考