EN | |

# Token Monitor Hub — Cloudflare Worker > 属于 **[Token Monitor](https://github.com/Javis603/token-monitor)** 项目。这个目录只是 Cloudflare Worker hub;桌面小部件、无头 agent 和完整文档都在主仓库。一键部署会创建一份独立副本,不会自动更新,所以请回主仓库查看新版本。 自托管 Node hub 的即插即用替代品,以 Cloudflare Worker 部署,用 Durable Object 保存设备状态。它讲的是同一套 HTTP 协议(`/api/ingest`、`/api/stats`、`/api/stats/stream`),所以小部件和 agent 无需改动即可使用,只是 Hub URL 不同。 相比 Node hub,用它的理由: - 不需要一直开机的机器,Cloudflare 帮你跑。 - 默认公开 HTTPS。跨网络可达,也能被 iOS 小部件(Widgy、Scriptable)访问。 - 免费额度轻松覆盖小团队的用量。 ## 前置条件 - Cloudflare 账号(免费)。 - Node.js 22+(Wrangler v4 要求 `>=22.0.0`)。 ## 部署 ```bash cd worker npm install npx wrangler login # one-time browser auth npx wrangler secret put TOKEN_MONITOR_SECRET # paste a long random string npx wrangler deploy ``` Wrangler 会打印部署后的 URL,例如: ``` https://token-monitor-hub..workers.dev ``` 把每个 agent 和小部件都指向这个 URL。 ### 一键部署故障排除 Cloudflare 的 **Deploy to Cloudflare** 按钮很方便,但一直有两种间歇性故障: - **部署页报「无法解析 Wrangler 配置文件」**——从 `worker/` 子目录读取配置时出的 岔子。 - **部署后的 Worker 只返回纯文本 `Hello world`**——踩中了 Cloudflare 的已知导入 故障,生成的仓库*没有* Worker 源码(只有 `README.md` + `wrangler.toml`)。它会 显示成功,但背后根本没有代码。把 Workers Builds 重新连到包含完整 `worker/` 目录 的仓库,或直接改用手动部署。 两者都是 CF 侧的问题。故障在同一个浏览器会话里往往有「粘性」,所以先用无痕/隐私 窗口(或换个浏览器)重开部署链接再试。若仍失败,就跳过按钮——上面的手动 `cd worker && npx wrangler deploy` 一定能成:同一份代码,只是没有 CF 那个不稳定的 导入步骤。 ## 本地开发 ```bash npm run dev # wrangler dev — local Worker with a real Durable Object ``` 各端点与生产环境行为一致。如有需要,用 `wrangler secret put TOKEN_MONITOR_SECRET --env dev` 设置一个独立的开发密钥。 ## 配置小部件 设置 → Multi-device Sync: - Hub URL:`https://token-monitor-hub..workers.dev` - Secret:你用 `wrangler secret put` 设置的值 保存。SSE 流连上后,状态标签会从 `Local` 切换为 `Live`。 ## 配置 agent 可以通过项目根目录的 `.env`(从 `.env.example` 复制): ```env TOKEN_MONITOR_HUB_URL=https://token-monitor-hub..workers.dev TOKEN_MONITOR_SECRET= TOKEN_MONITOR_DEVICE_ID= # optional — defaults to hostname ``` 或在启动时内联导出: ```bash TOKEN_MONITOR_HUB_URL=https://token-monitor-hub..workers.dev \ TOKEN_MONITOR_SECRET= \ npm run agent ``` ## 通过 Widgy 或 Scriptable 在 iPhone 上使用 Worker 以开放 CORS 的纯 JSON 暴露 `GET /api/stats`,所以 iOS 小部件运行时可以直接调用。当 `PUBLIC_STATS_ENABLED=1` 时,它也能暴露无需鉴权的 `GET /api/public/stats` 供公开看板使用;该响应会省略每设备记录和账号标识。 开启公开端点: ```bash npx wrangler secret put PUBLIC_STATS_ENABLED # enter 1 ``` 不设置则保持 `/api/public/stats` 关闭。 ### Widgy 选 **async / no main()** 模板。使用 `?secret=` 查询字符串鉴权:Widgy 那个隐藏的 WKWebView 可能会卡在 `Authorization: Bearer` 触发的 CORS 预检上,而 URL 会留在设备本地的 Widgy 配置里,所以密钥不会进入外部日志。 最简版本,只要一个数字: ```js const HUB = 'https://token-monitor-hub..workers.dev'; const SECRET = ''; const url = HUB + '/api/stats?secret=' + SECRET; fetch(url) .then(r => r.json()) .then(s => sendToWidgy( Number(s.periods.today.totalTokens).toLocaleString('en-US') )) .catch(e => sendToWidgy('err:' + e.message)); ``` 改最后那行 `sendToWidgy(...)` 即可选择任意字段: | 想显示 | 把内层表达式替换为 | |--------------------|------------------------------------------------------------| | 今日 tokens | `Number(s.periods.today.totalTokens).toLocaleString('en-US')` | | 今日成本 | `'$' + s.periods.today.costUsd.toFixed(2)` | | 本月 tokens | `Number(s.periods.month.totalTokens).toLocaleString('en-US')` | | 本月成本 | `'$' + s.periods.month.costUsd.toFixed(2)` | | 累计 tokens | `Number(s.periods.allTime.totalTokens).toLocaleString('en-US')` | | 累计成本 | `'$' + s.periods.allTime.costUsd.toFixed(2)` | 带配置块、并对 token 做紧凑 `K / M / B` 格式化的组合版本,适合窄小部件布局: ```js const HUB = 'https://token-monitor-hub..workers.dev'; const SECRET = ''; const PERIOD = 'today'; // 'today' | 'month' | 'allTime' const SHOW = 'tokens+cost'; // 'tokens' | 'cost' | 'tokens+cost' function fmtTokens(n) { if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; return String(Math.round(n)); } function fmtCost(n) { return '$' + (n >= 10 ? n.toFixed(2) : n.toFixed(4)); } fetch(HUB + '/api/stats?secret=' + SECRET) .then(r => r.json()) .then(stats => { const p = stats.periods[PERIOD] || { totalTokens: 0, costUsd: 0 }; const t = fmtTokens(p.totalTokens || 0); const c = fmtCost(p.costUsd || 0); if (SHOW === 'tokens') sendToWidgy(t); else if (SHOW === 'cost') sendToWidgy(c); else sendToWidgy(t + ' · ' + c); }) .catch(e => sendToWidgy('err:' + e.message)); ``` 每个 Widgy 文本元素各用一个脚本:复制它并修改 `PERIOD` / `SHOW` 来驱动不同字段。 ### Scriptable ```js const req = new Request('https://token-monitor-hub..workers.dev/api/stats'); req.headers = { authorization: 'Bearer ' }; const stats = await req.loadJSON(); const todayTokens = stats.periods.today.totalTokens; ``` iOS 小部件没有省电的推送通道,所以运行时会自行每隔几分钟重新拉取。 ## Stats 响应结构 `GET /api/stats` 返回聚合快照。挑你小部件需要的字段即可: ```jsonc { "updatedAt": "2026-05-18T18:02:19.459Z", "periods": { "today": { /* see PeriodSummary below */ }, "month": { /* ... */ }, "allTime": { /* ... */ } }, "limits": { "updatedAt": "2026-05-18T18:02:19.459Z", "providers": [ { "provider": "claude", "accountKey": "sha256:...", "sourceDeviceId": "macbook", "stale": false, "status": "ok", "windows": [ { "kind": "session", "usedPercent": 42, "remainingPercent": 58, "resetsAt": "2026-05-18T21:00:00.000Z" }, { "kind": "weekly", "usedPercent": 20, "remainingPercent": 80, "resetsAt": "2026-05-25T00:00:00.000Z" } ] } ] }, "devices": [ { "deviceId": "macbook", "hostname": "macbook.local", "platform": "darwin-arm64", "updatedAt": "2026-05-18T18:01:50.000Z", "receivedAt": "2026-05-18T18:01:51.012Z", "ageMs": 28447, "stale": false, "periods": { "today": {...}, "month": {...}, "allTime": {...} } } ] } ``` `PeriodSummary`: ```jsonc { "totalTokens": 1234567, // sum across all devices for this period "costUsd": 12.345678, "clients": { "claude": 800000, "codex": 400000, "hermes": 34567 }, "clientCosts": { "claude": 8.12, "codex": 4.10, "hermes": 0.12 }, "models": { "claude-opus-4-7": 600000, "gpt-5-thinking-medium": 400000, ... }, "modelCosts": { "claude-opus-4-7": 7.50, "gpt-5-thinking-medium": 4.00, ... } } ``` 如果想做每设备的小部件,每个设备也在 `devices[i].periods[period]` 下带有自己的每周期数值。当某设备的 `receivedAt` 早于 `STALE_AFTER_MS`(默认 10 分钟)时会被标记为 `stale: true`,想显示「离线」状态时很有用。 `limits.providers` 按 provider 账号聚合。带鉴权的 stats 端点包含用于去重的账号哈希。开启后,`/api/public/stats` 会剥离这些哈希、标签、来源设备 id,以及完整的 `devices` 列表。 ## 端点 | 方法 | 路径 | 鉴权 | 说明 | |--------|----------------------------|--------|--------------------------------------------| | GET | `/api/health` | 无 | 存活探针 + 设备数 | | GET | `/api/public/stats` | 无 | `PUBLIC_STATS_ENABLED=1` 时提供不含 devices/账号 id 的公开聚合统计 | | GET | `/api/stats` | 密钥 | 聚合统计(today / month / allTime) | | GET | `/api/stats/stream` | 密钥 | SSE 流,每次 ingest 都推送 | | GET | `/api/devices` | 密钥 | 原始的每设备记录 | | POST | `/api/ingest` | 密钥 | 更新某个设备的用量摘要 | | DELETE | `/api/devices/{deviceId}` | 密钥 | 删除一条设备记录 | 密钥有三种接受方式(任一即可): 1. `Authorization: Bearer `:agent、小部件,以及任何服务器 / 桌面客户端首选。 2. `x-token-monitor-secret: `:无法设置 `Authorization` 的客户端的后备方案。 3. `?secret=` 查询字符串:针对 iOS 小部件运行时(Widgy、Scriptable)的变通方案,它们的 WKWebView 难以处理 `Authorization` 头的 CORS 预检。只在 URL 留在设备本地的客户端上使用。 密钥是必需的。当 `TOKEN_MONITOR_SECRET` 未设置时,所有数据路由都返回 `503 secret_required`,只有 `/api/health` 和可选开启的 `/api/public/stats` 会响应。请在部署前(或部署时)设置它。 ## 存储与成本 设备记录存放在 Durable Object 的 SQLite 存储里,以 `dev:` 为键。每次 ingest 写一行;读取时在内存中聚合所有行。对于小团队(≤ 10 台设备,每台每分钟一次 ingest),这远在免费计划限额之内: - Worker 请求:≤ 15k/天,免费额度为 100k/天 - DO SQL 读/写:≤ 30k/天,免费额度为数百万 - DO 存储:总共几 KB ## Tail 日志 ```bash npm run tail ``` 实时流式输出 Worker + Durable Object 日志。