tokmon 0.19.7 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1051 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ PROVIDERS,
4
+ TOKMON_WS_METHODS,
5
+ TOKMON_WS_PATH,
6
+ TokmonRpcGroup,
7
+ buildAccounts,
8
+ detectProviders,
9
+ fetchPeak,
10
+ resolveTimezone,
11
+ toJsonSafe
12
+ } from "./chunk-MB6LRSEZ.js";
13
+ import {
14
+ cacheDir,
15
+ expandHome,
16
+ loadConfig,
17
+ normalizeConfig,
18
+ saveConfig
19
+ } from "./chunk-MVMPQJ5S.js";
20
+
21
+ // src/web/server.ts
22
+ import { createServer } from "http";
23
+ import { randomBytes } from "crypto";
24
+
25
+ // src/shared/colors.ts
26
+ var FALLBACK_HEX = "#8d9090";
27
+ var NAMED_HEX = {
28
+ green: "#6caa71",
29
+ greenBright: "#79be7e",
30
+ cyan: "#7ccbcd",
31
+ cyanBright: "#84dde0",
32
+ blue: "#6d96b4",
33
+ blueBright: "#67b5ed",
34
+ magenta: "#bd7bcd",
35
+ magentaBright: "#d389e5",
36
+ yellow: "#c4ac62",
37
+ yellowBright: "#d9c074",
38
+ red: "#b45648",
39
+ redBright: "#cf6a5a",
40
+ white: "#dee5eb",
41
+ whiteBright: "#f3f5f5",
42
+ gray: FALLBACK_HEX,
43
+ grey: FALLBACK_HEX
44
+ };
45
+ var PROVIDER_HEX = {
46
+ claude: NAMED_HEX.green,
47
+ codex: NAMED_HEX.cyan,
48
+ cursor: NAMED_HEX.magenta,
49
+ copilot: NAMED_HEX.white,
50
+ pi: NAMED_HEX.blue,
51
+ opencode: NAMED_HEX.yellow,
52
+ antigravity: NAMED_HEX.red,
53
+ gemini: NAMED_HEX.greenBright
54
+ };
55
+ function namedHex(name) {
56
+ if (!name) return FALLBACK_HEX;
57
+ if (name.startsWith("#")) return name;
58
+ return NAMED_HEX[name] ?? FALLBACK_HEX;
59
+ }
60
+ function colorHex(accountColor, providerColorName) {
61
+ if (accountColor) {
62
+ if (accountColor.startsWith("#")) return accountColor;
63
+ const named = NAMED_HEX[accountColor];
64
+ if (named) return named;
65
+ }
66
+ return namedHex(providerColorName);
67
+ }
68
+ var TOKEN_BUCKET = {
69
+ input: NAMED_HEX.blue,
70
+ output: NAMED_HEX.green,
71
+ cacheCreate: NAMED_HEX.yellow,
72
+ cacheRead: NAMED_HEX.cyan
73
+ };
74
+
75
+ // src/web/data.ts
76
+ async function resolveAccounts(config) {
77
+ const detected = await detectProviders();
78
+ const accounts = buildAccounts(config, detected);
79
+ return accounts.map((a) => {
80
+ const p = PROVIDERS[a.providerId];
81
+ return {
82
+ account: a,
83
+ hasUsage: p.hasUsage || !!p.fetchTable,
84
+ hasBilling: p.hasBilling,
85
+ color: colorHex(a.color, PROVIDERS[a.providerId].color)
86
+ };
87
+ });
88
+ }
89
+ async function fetchAccountSummary(account, tz) {
90
+ const p = PROVIDERS[account.providerId];
91
+ if (!p.fetchSummary) return null;
92
+ return p.fetchSummary(account, tz);
93
+ }
94
+ async function fetchAccountTable(account, tz) {
95
+ const p = PROVIDERS[account.providerId];
96
+ if (!p.fetchTable) return null;
97
+ return p.fetchTable(account, tz);
98
+ }
99
+ async function fetchAccountBilling(account) {
100
+ const p = PROVIDERS[account.providerId];
101
+ if (!p.fetchBilling) return null;
102
+ return p.fetchBilling(account);
103
+ }
104
+ function assembleSnapshot(opts) {
105
+ const accounts = opts.resolved.map((r) => {
106
+ const u = opts.usage.get(r.account.id);
107
+ const billing = opts.billing.get(r.account.id) ?? null;
108
+ return {
109
+ id: r.account.id,
110
+ providerId: r.account.providerId,
111
+ name: r.account.name,
112
+ color: r.color,
113
+ homeDir: r.account.homeDir ?? null,
114
+ hasUsage: r.hasUsage,
115
+ hasBilling: r.hasBilling,
116
+ email: billing?.email ?? null,
117
+ displayName: billing?.displayName ?? null,
118
+ plan: billing?.plan ?? null,
119
+ dashboard: u?.dashboard ?? null,
120
+ table: u?.table ?? null,
121
+ billing,
122
+ summaryState: opts.summaryState?.get(r.account.id) ?? "pending",
123
+ billingState: opts.billingState?.get(r.account.id) ?? "pending",
124
+ tableState: opts.tableState?.get(r.account.id) ?? "pending"
125
+ };
126
+ });
127
+ const seen = /* @__PURE__ */ new Set();
128
+ const providers = [];
129
+ for (const r of opts.resolved) {
130
+ if (seen.has(r.account.providerId)) continue;
131
+ seen.add(r.account.providerId);
132
+ providers.push({
133
+ id: r.account.providerId,
134
+ name: PROVIDERS[r.account.providerId].name,
135
+ color: namedHex(PROVIDERS[r.account.providerId].color)
136
+ });
137
+ }
138
+ return toJsonSafe({
139
+ version: opts.version,
140
+ generatedAt: Date.now(),
141
+ tz: opts.tz,
142
+ intervalMs: opts.intervalMs,
143
+ providers,
144
+ accounts,
145
+ seeded: opts.seeded ?? false,
146
+ peak: opts.peak ?? null
147
+ });
148
+ }
149
+ function tzFor(config) {
150
+ return resolveTimezone(config.timezone);
151
+ }
152
+
153
+ // src/web/static.ts
154
+ import { createReadStream, existsSync, readFileSync } from "fs";
155
+ import { stat } from "fs/promises";
156
+ import { fileURLToPath } from "url";
157
+ import { extname, join, normalize, sep } from "path";
158
+ var MIME = {
159
+ ".html": "text/html; charset=utf-8",
160
+ ".js": "text/javascript; charset=utf-8",
161
+ ".mjs": "text/javascript; charset=utf-8",
162
+ ".css": "text/css; charset=utf-8",
163
+ ".json": "application/json; charset=utf-8",
164
+ ".svg": "image/svg+xml",
165
+ ".png": "image/png",
166
+ ".jpg": "image/jpeg",
167
+ ".jpeg": "image/jpeg",
168
+ ".gif": "image/gif",
169
+ ".ico": "image/x-icon",
170
+ ".woff": "font/woff",
171
+ ".woff2": "font/woff2",
172
+ ".ttf": "font/ttf",
173
+ ".otf": "font/otf",
174
+ ".map": "application/json; charset=utf-8",
175
+ ".webmanifest": "application/manifest+json"
176
+ };
177
+ function findWebRoot() {
178
+ const candidates = ["./web/", "../web/", "../dist/web/", "../../dist/web/"];
179
+ for (const rel of candidates) {
180
+ try {
181
+ const dir = fileURLToPath(new URL(rel, import.meta.url));
182
+ if (existsSync(join(dir, "index.html"))) return dir.replace(/[\\/]+$/, "");
183
+ } catch {
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+ function resolveStaticPath(webRoot, urlPath) {
189
+ let clean;
190
+ try {
191
+ clean = decodeURIComponent(urlPath.split("?")[0]);
192
+ } catch {
193
+ return null;
194
+ }
195
+ const rel = normalize(clean).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
196
+ const full = join(webRoot, rel);
197
+ if (full !== webRoot && !full.startsWith(webRoot + sep)) return null;
198
+ return full;
199
+ }
200
+ function send(res, status, type, body) {
201
+ res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" });
202
+ res.end(body);
203
+ }
204
+ function sendJson(res, status, data) {
205
+ send(res, status, "application/json; charset=utf-8", JSON.stringify(data));
206
+ }
207
+ function serveStatic(webRoot, urlPath, res) {
208
+ const path = urlPath.split("?")[0];
209
+ const filePath = resolveStaticPath(webRoot, path === "/" ? "/index.html" : path);
210
+ if (!filePath) {
211
+ send(res, 403, "text/plain", "forbidden");
212
+ return;
213
+ }
214
+ void stat(filePath).then((st) => {
215
+ if (st.isFile()) {
216
+ const type = MIME[extname(filePath).toLowerCase()] || "application/octet-stream";
217
+ const immutable = filePath.includes(`${sep}assets${sep}`);
218
+ res.writeHead(200, {
219
+ "Content-Type": type,
220
+ "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache"
221
+ });
222
+ createReadStream(filePath).pipe(res);
223
+ } else {
224
+ throw new Error("not a file");
225
+ }
226
+ }).catch(() => {
227
+ if (extname(path)) {
228
+ send(res, 404, "text/plain", "not found");
229
+ return;
230
+ }
231
+ const indexPath = join(webRoot, "index.html");
232
+ if (!existsSync(indexPath)) {
233
+ send(res, 404, "text/plain", "not found");
234
+ return;
235
+ }
236
+ res.writeHead(200, { "Content-Type": MIME[".html"], "Cache-Control": "no-cache" });
237
+ createReadStream(indexPath).on("error", () => {
238
+ try {
239
+ res.destroy();
240
+ } catch {
241
+ }
242
+ }).pipe(res);
243
+ });
244
+ }
245
+ function appVersion() {
246
+ for (const rel of ["../package.json", "../../package.json"]) {
247
+ try {
248
+ const p = fileURLToPath(new URL(rel, import.meta.url));
249
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
250
+ if (typeof pkg.version === "string") return pkg.version;
251
+ } catch {
252
+ }
253
+ }
254
+ return "";
255
+ }
256
+
257
+ // src/web/vite-dev.ts
258
+ import { existsSync as existsSync2 } from "fs";
259
+ import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
260
+ import { createRequire } from "module";
261
+ import { join as join2 } from "path";
262
+ function isDevMode() {
263
+ const forced = process.env.TOKMON_WEB_MODE;
264
+ if (forced === "dev") return true;
265
+ if (forced === "prod") return false;
266
+ return import.meta.url.includes("/src/");
267
+ }
268
+ function findWebSource() {
269
+ for (const rel of ["../../web/", "../web/", "./web/"]) {
270
+ try {
271
+ const dir = fileURLToPath2(new URL(rel, import.meta.url));
272
+ if (existsSync2(join2(dir, "vite.config.ts")) && existsSync2(join2(dir, "index.html"))) {
273
+ return dir.replace(/[\\/]+$/, "");
274
+ }
275
+ } catch {
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+ async function createViteDevServer(httpServer, log) {
281
+ const root = findWebSource();
282
+ if (!root) {
283
+ log(" \u26A0 dev mode: web/ source not found");
284
+ return null;
285
+ }
286
+ try {
287
+ const req = createRequire(pathToFileURL(join2(root, "package.json")).href);
288
+ const vitePath = req.resolve("vite");
289
+ const vite = await import(pathToFileURL(vitePath).href);
290
+ const dev = await vite.createServer({
291
+ root,
292
+ configFile: join2(root, "vite.config.ts"),
293
+ server: { middlewareMode: true, hmr: { server: httpServer } },
294
+ appType: "spa",
295
+ clearScreen: false,
296
+ logLevel: "warn"
297
+ });
298
+ log(" \u25C6 dev mode \u2014 Vite HMR attached (edit web/src and it hot-reloads)");
299
+ return dev;
300
+ } catch (e) {
301
+ log(` \u26A0 couldn't start Vite dev server: ${e.message}`);
302
+ log(" run `pnpm --prefix web install` (or `npm run web:install`) for HMR, or `npm run build` for a static bundle");
303
+ return null;
304
+ }
305
+ }
306
+ var MISSING_BUILD_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>tokmon web</title>
307
+ <style>body{background:#0a0d0e;color:#cdd6d8;font:14px ui-monospace,Menlo,monospace;padding:3rem;line-height:1.7}
308
+ code{color:#e6b450}</style></head><body>
309
+ <h1 style="color:#00d787">tokmon web</h1>
310
+ <p>The dashboard isn't available.</p>
311
+ <p><b>Prod:</b> run <code>npm run build</code> (builds <code>dist/web</code>), then <code>tokmon serve</code>.</p>
312
+ <p><b>Dev:</b> run <code>pnpm --prefix web install</code> so the Vite dev server (HMR) can start.</p>
313
+ </body></html>`;
314
+
315
+ // src/web/data-engine.ts
316
+ import { readFileSync as readFileSync2, writeFileSync, mkdirSync, renameSync } from "fs";
317
+ import { join as join3 } from "path";
318
+ var TABLE_INTERVAL_MS = 3e5;
319
+ var PEAK_INTERVAL_MS = 3e5;
320
+ var IDLE_PAUSE_MS = 6e4;
321
+ var SNAPSHOT_CACHE_THROTTLE_MS = 2e4;
322
+ var REVEAL_THROTTLE_MS = 500;
323
+ var snapshotCacheFile = () => join3(cacheDir(), "web-snapshot.json");
324
+ function createDataEngine(opts) {
325
+ const { version } = opts;
326
+ let tz = opts.tz;
327
+ let summaryIntervalMs = opts.summaryIntervalMs;
328
+ let billingIntervalMs = opts.billingIntervalMs;
329
+ let resolved = opts.resolved;
330
+ let currentConfig = opts.config;
331
+ const usage = /* @__PURE__ */ new Map();
332
+ const billing = /* @__PURE__ */ new Map();
333
+ const summaryState = /* @__PURE__ */ new Map();
334
+ const billingState = /* @__PURE__ */ new Map();
335
+ const tableState = /* @__PURE__ */ new Map();
336
+ let peak = null;
337
+ let seeded = false;
338
+ let current = null;
339
+ const snapshotSubscribers = /* @__PURE__ */ new Set();
340
+ const configSubscribers = /* @__PURE__ */ new Set();
341
+ let lastActivity = Date.now();
342
+ let stopped = false;
343
+ let summaryTimer;
344
+ let tableTimer;
345
+ let billingTimer;
346
+ let peakTimer;
347
+ let lastPersist = 0;
348
+ let lastReveal = 0;
349
+ let configEpoch = 0;
350
+ let hasClaude = resolved.some((r) => r.account.providerId === "claude");
351
+ const idle = () => snapshotSubscribers.size === 0 && Date.now() - lastActivity > IDLE_PAUSE_MS;
352
+ const usageEntry = (id) => {
353
+ let u = usage.get(id);
354
+ if (!u) {
355
+ u = { dashboard: null, table: null };
356
+ usage.set(id, u);
357
+ }
358
+ return u;
359
+ };
360
+ const buildSnapshot = () => assembleSnapshot({
361
+ version,
362
+ tz,
363
+ intervalMs: summaryIntervalMs,
364
+ resolved,
365
+ usage,
366
+ billing,
367
+ summaryState,
368
+ billingState,
369
+ tableState,
370
+ seeded,
371
+ peak
372
+ });
373
+ const hydrateFromCache = () => {
374
+ try {
375
+ const cached = JSON.parse(readFileSync2(snapshotCacheFile(), "utf-8"));
376
+ if (!cached || !Array.isArray(cached.accounts)) return;
377
+ for (const a of cached.accounts) {
378
+ if (a.dashboard || a.table) {
379
+ usage.set(a.id, { dashboard: a.dashboard, table: a.table });
380
+ if (a.dashboard) summaryState.set(a.id, "ready");
381
+ if (a.table) tableState.set(a.id, "ready");
382
+ }
383
+ if (a.billing) {
384
+ billing.set(a.id, a.billing);
385
+ billingState.set(a.id, "ready");
386
+ }
387
+ }
388
+ seeded = true;
389
+ current = buildSnapshot();
390
+ } catch {
391
+ }
392
+ };
393
+ const persist = () => {
394
+ if (!current) return;
395
+ if (!current.accounts.some((a) => a.hasUsage && a.table != null)) return;
396
+ if (Date.now() - lastPersist < SNAPSHOT_CACHE_THROTTLE_MS) return;
397
+ lastPersist = Date.now();
398
+ try {
399
+ mkdirSync(cacheDir(), { recursive: true, mode: 448 });
400
+ const tmp = join3(cacheDir(), `web-snapshot.json.${process.pid}.tmp`);
401
+ writeFileSync(tmp, JSON.stringify(current), { mode: 384 });
402
+ renameSync(tmp, snapshotCacheFile());
403
+ } catch {
404
+ }
405
+ };
406
+ const rebuild = () => {
407
+ if (stopped) return;
408
+ seeded = false;
409
+ current = buildSnapshot();
410
+ persist();
411
+ for (const onSnapshot of snapshotSubscribers) {
412
+ try {
413
+ onSnapshot(current);
414
+ } catch {
415
+ }
416
+ }
417
+ };
418
+ const reveal = () => {
419
+ if (stopped) return;
420
+ if (Date.now() - lastReveal < REVEAL_THROTTLE_MS) return;
421
+ lastReveal = Date.now();
422
+ rebuild();
423
+ };
424
+ let usageAccounts = resolved.filter((r) => r.hasUsage);
425
+ let billingAccounts = resolved.filter((r) => r.hasBilling);
426
+ let summaryBusy = false;
427
+ let summaryForcePending = false;
428
+ const refreshSummary = async (force = false) => {
429
+ if (stopped) return;
430
+ if (summaryBusy) {
431
+ if (force) summaryForcePending = true;
432
+ return;
433
+ }
434
+ if (!force && idle()) return;
435
+ const epoch = configEpoch;
436
+ summaryBusy = true;
437
+ try {
438
+ for (const r of usageAccounts) {
439
+ if (stopped) return;
440
+ let dashboard = null;
441
+ let ok = true;
442
+ try {
443
+ dashboard = await fetchAccountSummary(r.account, tz);
444
+ } catch {
445
+ ok = false;
446
+ }
447
+ if (stopped || epoch !== configEpoch) return;
448
+ if (ok) {
449
+ usageEntry(r.account.id).dashboard = dashboard;
450
+ summaryState.set(r.account.id, "ready");
451
+ } else summaryState.set(r.account.id, "error");
452
+ reveal();
453
+ }
454
+ rebuild();
455
+ } finally {
456
+ summaryBusy = false;
457
+ if (summaryForcePending && !stopped) {
458
+ summaryForcePending = false;
459
+ void refreshSummary(true);
460
+ }
461
+ }
462
+ };
463
+ let tableBusy = false;
464
+ let tableForcePending = false;
465
+ const refreshTable = async (force = false) => {
466
+ if (stopped) return;
467
+ if (tableBusy) {
468
+ if (force) tableForcePending = true;
469
+ return;
470
+ }
471
+ if (!force && idle()) return;
472
+ const epoch = configEpoch;
473
+ tableBusy = true;
474
+ try {
475
+ for (const r of usageAccounts) {
476
+ if (stopped) return;
477
+ let table = null;
478
+ let ok = true;
479
+ try {
480
+ table = await fetchAccountTable(r.account, tz);
481
+ } catch {
482
+ ok = false;
483
+ }
484
+ if (stopped || epoch !== configEpoch) return;
485
+ if (ok) {
486
+ usageEntry(r.account.id).table = table;
487
+ tableState.set(r.account.id, "ready");
488
+ } else tableState.set(r.account.id, "error");
489
+ reveal();
490
+ }
491
+ rebuild();
492
+ } finally {
493
+ tableBusy = false;
494
+ if (tableForcePending && !stopped) {
495
+ tableForcePending = false;
496
+ void refreshTable(true);
497
+ }
498
+ }
499
+ };
500
+ let billingBusy = false;
501
+ let billingForcePending = false;
502
+ const refreshBilling = async (force = false) => {
503
+ if (stopped) return;
504
+ if (billingBusy) {
505
+ if (force) billingForcePending = true;
506
+ return;
507
+ }
508
+ if (!force && idle()) return;
509
+ const epoch = configEpoch;
510
+ billingBusy = true;
511
+ try {
512
+ for (const r of billingAccounts) {
513
+ if (stopped) return;
514
+ let result = null;
515
+ let ok = true;
516
+ try {
517
+ result = await fetchAccountBilling(r.account);
518
+ } catch {
519
+ ok = false;
520
+ }
521
+ if (stopped || epoch !== configEpoch) return;
522
+ if (ok) {
523
+ billing.set(r.account.id, result);
524
+ billingState.set(r.account.id, "ready");
525
+ } else billingState.set(r.account.id, "error");
526
+ reveal();
527
+ }
528
+ rebuild();
529
+ } finally {
530
+ billingBusy = false;
531
+ if (billingForcePending && !stopped) {
532
+ billingForcePending = false;
533
+ void refreshBilling(true);
534
+ }
535
+ }
536
+ };
537
+ let peakBusy = false;
538
+ const refreshPeak = async (force = false) => {
539
+ if (stopped || peakBusy || !hasClaude || !force && idle()) return;
540
+ peakBusy = true;
541
+ try {
542
+ const next = await fetchPeak();
543
+ if (next) {
544
+ peak = next;
545
+ rebuild();
546
+ }
547
+ } finally {
548
+ peakBusy = false;
549
+ }
550
+ };
551
+ const clearTimers = () => {
552
+ clearInterval(summaryTimer);
553
+ summaryTimer = void 0;
554
+ clearInterval(tableTimer);
555
+ tableTimer = void 0;
556
+ clearInterval(billingTimer);
557
+ billingTimer = void 0;
558
+ clearInterval(peakTimer);
559
+ peakTimer = void 0;
560
+ };
561
+ const startTimers = () => {
562
+ summaryTimer = setInterval(() => {
563
+ void refreshSummary();
564
+ }, summaryIntervalMs);
565
+ tableTimer = setInterval(() => {
566
+ void refreshTable();
567
+ }, TABLE_INTERVAL_MS);
568
+ billingTimer = setInterval(() => {
569
+ void refreshBilling();
570
+ }, billingIntervalMs);
571
+ summaryTimer.unref?.();
572
+ tableTimer.unref?.();
573
+ billingTimer.unref?.();
574
+ if (hasClaude) {
575
+ peakTimer = setInterval(() => {
576
+ void refreshPeak();
577
+ }, PEAK_INTERVAL_MS);
578
+ peakTimer.unref?.();
579
+ }
580
+ };
581
+ hydrateFromCache();
582
+ return {
583
+ snapshot: () => current,
584
+ start() {
585
+ void refreshSummary(true);
586
+ void refreshTable(true);
587
+ void refreshBilling(true);
588
+ if (hasClaude) void refreshPeak(true);
589
+ startTimers();
590
+ },
591
+ touch() {
592
+ lastActivity = Date.now();
593
+ },
594
+ refresh(scope = "all") {
595
+ if (stopped) return;
596
+ if (scope === "all" || scope === "summary") void refreshSummary(true);
597
+ if (scope === "all" || scope === "table") void refreshTable(true);
598
+ if (scope === "all" || scope === "billing") void refreshBilling(true);
599
+ if ((scope === "all" || scope === "peak") && hasClaude) void refreshPeak(true);
600
+ },
601
+ setConfig(next) {
602
+ if (stopped) return;
603
+ clearTimers();
604
+ configEpoch++;
605
+ tz = next.tz;
606
+ summaryIntervalMs = next.summaryIntervalMs;
607
+ billingIntervalMs = next.billingIntervalMs;
608
+ resolved = next.resolved;
609
+ hasClaude = resolved.some((r) => r.account.providerId === "claude");
610
+ if (!hasClaude) peak = null;
611
+ usageAccounts = resolved.filter((r) => r.hasUsage);
612
+ billingAccounts = resolved.filter((r) => r.hasBilling);
613
+ const survivors = new Set(resolved.map((r) => r.account.id));
614
+ for (const id of [...usage.keys()]) if (!survivors.has(id)) usage.delete(id);
615
+ for (const id of [...billing.keys()]) if (!survivors.has(id)) billing.delete(id);
616
+ for (const map of [summaryState, billingState, tableState]) {
617
+ for (const id of [...map.keys()]) if (!survivors.has(id)) map.delete(id);
618
+ }
619
+ rebuild();
620
+ void refreshSummary(true);
621
+ void refreshTable(true);
622
+ void refreshBilling(true);
623
+ if (hasClaude) void refreshPeak(true);
624
+ startTimers();
625
+ },
626
+ broadcastConfig(config) {
627
+ if (stopped) return;
628
+ currentConfig = config;
629
+ for (const onConfig of configSubscribers) {
630
+ try {
631
+ onConfig(config);
632
+ } catch {
633
+ }
634
+ }
635
+ },
636
+ subscribe(onSnapshot) {
637
+ if (current) {
638
+ try {
639
+ onSnapshot(current);
640
+ } catch {
641
+ }
642
+ }
643
+ snapshotSubscribers.add(onSnapshot);
644
+ lastActivity = Date.now();
645
+ if (!current || Date.now() - current.generatedAt > summaryIntervalMs) {
646
+ void refreshSummary(true);
647
+ void refreshTable(true);
648
+ }
649
+ return () => {
650
+ snapshotSubscribers.delete(onSnapshot);
651
+ };
652
+ },
653
+ subscribeConfig(onConfig) {
654
+ try {
655
+ onConfig(currentConfig);
656
+ } catch {
657
+ }
658
+ configSubscribers.add(onConfig);
659
+ return () => {
660
+ configSubscribers.delete(onConfig);
661
+ };
662
+ },
663
+ stop() {
664
+ stopped = true;
665
+ clearTimers();
666
+ snapshotSubscribers.clear();
667
+ configSubscribers.clear();
668
+ }
669
+ };
670
+ }
671
+
672
+ // src/web/config-control.ts
673
+ var MIN_SUMMARY_INTERVAL_MS = 8e3;
674
+ var BILLING_INTERVAL_FALLBACK_MIN = 5;
675
+ var summaryIntervalFor = (config) => Math.max(MIN_SUMMARY_INTERVAL_MS, (config.interval || 2) * 1e3);
676
+ var billingIntervalFor = (config) => Math.max(1, config.billingInterval || BILLING_INTERVAL_FALLBACK_MIN) * 6e4;
677
+ async function resolveEngineConfig(config) {
678
+ return {
679
+ resolved: await resolveAccounts(config),
680
+ tz: tzFor(config),
681
+ summaryIntervalMs: summaryIntervalFor(config),
682
+ billingIntervalMs: billingIntervalFor(config)
683
+ };
684
+ }
685
+ async function applyConfigUpdate(engine, state, input) {
686
+ const normalized = normalizeConfig(input);
687
+ state.config = normalized;
688
+ await saveConfig(normalized);
689
+ engine.setConfig(await resolveEngineConfig(normalized));
690
+ engine.broadcastConfig(normalized);
691
+ return normalized;
692
+ }
693
+
694
+ // src/web/ws.ts
695
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
696
+ import { NodeWS } from "@effect/platform-node/NodeSocket";
697
+ import { Effect, Exit, Layer, Queue, Scope, Stream } from "effect";
698
+ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
699
+
700
+ // src/web/fs.ts
701
+ import { readdir, stat as stat2, realpath } from "fs/promises";
702
+ import { homedir } from "os";
703
+ import { join as join4, resolve as resolvePath, isAbsolute, sep as sep2 } from "path";
704
+ function isContained(root, target) {
705
+ return target === root || target.startsWith(root + sep2);
706
+ }
707
+ async function listHomeDirectory(rawPath) {
708
+ const root = resolvePath(homedir());
709
+ const expanded = expandHome(rawPath || "~");
710
+ const requested = isAbsolute(expanded) ? resolvePath(expanded) : resolvePath(root, expanded);
711
+ const lexical = isContained(root, requested) ? requested : root;
712
+ let real;
713
+ try {
714
+ real = await realpath(lexical);
715
+ } catch {
716
+ real = lexical;
717
+ }
718
+ const abs = isContained(root, real) ? real : root;
719
+ const st = await stat2(abs);
720
+ if (!st.isDirectory()) throw new Error("not a directory");
721
+ const dirents = await readdir(abs, { withFileTypes: true });
722
+ const entries = [];
723
+ for (const d of dirents) {
724
+ if (d.name.startsWith(".")) continue;
725
+ let dir = d.isDirectory();
726
+ const full = join4(abs, d.name);
727
+ if (d.isSymbolicLink()) {
728
+ let real2;
729
+ try {
730
+ real2 = await realpath(full);
731
+ } catch {
732
+ continue;
733
+ }
734
+ if (!isContained(root, real2)) continue;
735
+ try {
736
+ dir = (await stat2(full)).isDirectory();
737
+ } catch {
738
+ continue;
739
+ }
740
+ }
741
+ entries.push({ name: d.name, path: full, dir });
742
+ }
743
+ entries.sort((a, b) => a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1);
744
+ const parentResolved = resolvePath(abs, "..");
745
+ const parent = abs === root || !isContained(root, parentResolved) ? null : parentResolved;
746
+ return { path: abs, parent, entries };
747
+ }
748
+
749
+ // src/web/ws.ts
750
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
751
+ function header(req, name) {
752
+ const value = req.headers[name.toLowerCase()];
753
+ return Array.isArray(value) ? value[0] : value;
754
+ }
755
+ function hostOnly(value) {
756
+ if (!value) return null;
757
+ let host = value.trim().toLowerCase();
758
+ if (!host) return null;
759
+ if (host.startsWith("[")) {
760
+ const end = host.indexOf("]");
761
+ return end === -1 ? host.slice(1) : host.slice(1, end);
762
+ }
763
+ return host.split(":")[0] ?? null;
764
+ }
765
+ function isLoopbackHost(value) {
766
+ const host = hostOnly(value);
767
+ return host !== null && LOOPBACK_HOSTS.has(host);
768
+ }
769
+ function originHost(origin) {
770
+ if (!origin || origin === "null") return null;
771
+ try {
772
+ return new URL(origin).host;
773
+ } catch {
774
+ return null;
775
+ }
776
+ }
777
+ function isLoopbackOrigin(origin) {
778
+ if (!origin || origin === "null") return true;
779
+ return isLoopbackHost(originHost(origin) ?? void 0);
780
+ }
781
+ function isSameOrigin(req) {
782
+ const origin = originHost(header(req, "origin"));
783
+ if (!origin) return false;
784
+ return origin.toLowerCase() === (header(req, "host") ?? "").toLowerCase();
785
+ }
786
+ function isWsPath(req) {
787
+ try {
788
+ return new URL(req.url ?? "/", "http://127.0.0.1").pathname === TOKMON_WS_PATH;
789
+ } catch {
790
+ return false;
791
+ }
792
+ }
793
+ function wsToken(req) {
794
+ try {
795
+ return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("wsToken");
796
+ } catch {
797
+ return null;
798
+ }
799
+ }
800
+ function isAuthorized(req, token) {
801
+ const host = header(req, "host");
802
+ const origin = header(req, "origin");
803
+ if (!isLoopbackHost(host) || !isLoopbackOrigin(origin)) return false;
804
+ if (wsToken(req) === token) return true;
805
+ return isSameOrigin(req);
806
+ }
807
+ function rejectUpgrade(socket, status = 403, message = "Forbidden") {
808
+ try {
809
+ socket.write(
810
+ `HTTP/1.1 ${status} ${message}\r
811
+ Connection: close\r
812
+ Content-Length: 0\r
813
+ \r
814
+ `
815
+ );
816
+ } catch {
817
+ }
818
+ try {
819
+ socket.destroy();
820
+ } catch {
821
+ }
822
+ }
823
+ function snapshotStream(engine) {
824
+ return Stream.callback((queue) => Effect.gen(function* () {
825
+ const scope = yield* Scope.Scope;
826
+ const unsubscribe = engine.subscribe((snapshot) => {
827
+ if (snapshot != null) Queue.offerUnsafe(queue, snapshot);
828
+ });
829
+ yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
830
+ }), { bufferSize: 16, strategy: "sliding" });
831
+ }
832
+ function configStream(engine) {
833
+ return Stream.callback((queue) => Effect.gen(function* () {
834
+ const scope = yield* Scope.Scope;
835
+ const unsubscribe = engine.subscribeConfig((config) => {
836
+ Queue.offerUnsafe(queue, config);
837
+ });
838
+ yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
839
+ }), { bufferSize: 16, strategy: "sliding" });
840
+ }
841
+ async function mountWsRpc(server, deps) {
842
+ const scope = await Effect.runPromise(Scope.make());
843
+ const wss = new NodeWS.WebSocketServer({ noServer: true });
844
+ const handlersLayer = TokmonRpcGroup.toLayer(
845
+ TokmonRpcGroup.of({
846
+ [TOKMON_WS_METHODS.getConfig]: () => Effect.promise(async () => deps.state.config ?? await loadConfig()),
847
+ [TOKMON_WS_METHODS.setConfig]: (config) => Effect.promise(() => applyConfigUpdate(deps.engine, deps.state, config)),
848
+ [TOKMON_WS_METHODS.refresh]: ({ scope: scope2 }) => Effect.sync(() => {
849
+ deps.engine.refresh(scope2);
850
+ }),
851
+ [TOKMON_WS_METHODS.browseFs]: ({ path }) => Effect.promise(() => listHomeDirectory(path)),
852
+ [TOKMON_WS_METHODS.snapshot]: () => snapshotStream(deps.engine),
853
+ [TOKMON_WS_METHODS.config]: () => configStream(deps.engine)
854
+ })
855
+ );
856
+ const httpEffect = await Effect.runPromise(
857
+ RpcServer.toHttpEffectWebsocket(TokmonRpcGroup, {
858
+ spanPrefix: "tokmon.rpc",
859
+ spanAttributes: {
860
+ "rpc.transport": "websocket",
861
+ "rpc.system": "effect-rpc"
862
+ }
863
+ }).pipe(
864
+ Effect.provide(handlersLayer.pipe(Layer.provideMerge(RpcSerialization.layerJson))),
865
+ Scope.provide(scope)
866
+ )
867
+ );
868
+ const upgradeHandler = await Effect.runPromise(
869
+ NodeHttpServer.makeUpgradeHandler(Effect.succeed(wss), httpEffect, { scope }).pipe(
870
+ Scope.provide(scope)
871
+ )
872
+ );
873
+ const onUpgrade = (req, socket, head) => {
874
+ if (!isWsPath(req)) return;
875
+ if (!isAuthorized(req, deps.wsToken)) {
876
+ rejectUpgrade(socket);
877
+ return;
878
+ }
879
+ upgradeHandler(req, socket, head);
880
+ };
881
+ server.on("upgrade", onUpgrade);
882
+ return async () => {
883
+ server.off("upgrade", onUpgrade);
884
+ await new Promise((resolve) => {
885
+ wss.close(() => resolve());
886
+ });
887
+ await Effect.runPromise(Scope.close(scope, Exit.void));
888
+ };
889
+ }
890
+
891
+ // src/web/server.ts
892
+ var HOST = "127.0.0.1";
893
+ var DEFAULT_PORT = 4317;
894
+ var MAX_PORT_TRIES = 20;
895
+ function isLoopbackHostHeader(value) {
896
+ if (!value) return false;
897
+ let host = value.trim().toLowerCase();
898
+ if (host.startsWith("[")) host = host.slice(1, host.indexOf("]") === -1 ? host.length : host.indexOf("]"));
899
+ else host = host.split(":")[0];
900
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
901
+ }
902
+ function isLoopbackOrigin2(origin) {
903
+ if (!origin || origin === "null") return true;
904
+ try {
905
+ const u = new URL(origin);
906
+ return isLoopbackHostHeader(u.host);
907
+ } catch {
908
+ return false;
909
+ }
910
+ }
911
+ function guardPrivileged(req, res) {
912
+ const header2 = (n) => {
913
+ const v = req.headers[n];
914
+ return Array.isArray(v) ? v[0] : v;
915
+ };
916
+ if (req.headers["x-tokmon-client"] !== "1") {
917
+ sendJson(res, 403, { error: "forbidden" });
918
+ return false;
919
+ }
920
+ if (!isLoopbackHostHeader(header2("host")) || !isLoopbackOrigin2(header2("origin"))) {
921
+ sendJson(res, 403, { error: "forbidden" });
922
+ return false;
923
+ }
924
+ return true;
925
+ }
926
+ function createRouter(engine, state, vite, webRoot) {
927
+ return (req, res) => {
928
+ const url = req.url || "/";
929
+ const path = url.split("?")[0];
930
+ const method = req.method || "GET";
931
+ if (path === "/api/data") {
932
+ engine.touch();
933
+ sendJson(res, 200, engine.snapshot() ?? { pending: true });
934
+ return;
935
+ }
936
+ if (path === "/healthz") {
937
+ sendJson(res, 200, { ok: true, ready: engine.snapshot() !== null });
938
+ return;
939
+ }
940
+ if (path === "/api/config") {
941
+ if (!guardPrivileged(req, res)) return;
942
+ if (method === "GET") {
943
+ sendJson(res, 200, state.config);
944
+ return;
945
+ }
946
+ sendJson(res, 405, { error: "method not allowed" });
947
+ return;
948
+ }
949
+ if (vite) {
950
+ vite.middlewares(req, res, () => {
951
+ send(res, 404, "text/plain", "not found");
952
+ });
953
+ return;
954
+ }
955
+ if (!webRoot) {
956
+ send(res, 503, "text/html; charset=utf-8", MISSING_BUILD_HTML);
957
+ return;
958
+ }
959
+ serveStatic(webRoot, url, res);
960
+ };
961
+ }
962
+ async function startWebServer(opts) {
963
+ const state = { config: opts.config };
964
+ const tz = tzFor(state.config);
965
+ const version = appVersion();
966
+ const summaryIntervalMs = summaryIntervalFor(state.config);
967
+ const billingIntervalMs = billingIntervalFor(state.config);
968
+ const wsToken2 = randomBytes(32).toString("base64url");
969
+ const log = (msg) => {
970
+ if (opts.log) process.stdout.write(msg + "\n");
971
+ };
972
+ const resolved = await resolveAccounts(state.config);
973
+ const server = createServer();
974
+ let vite = null;
975
+ if (isDevMode()) vite = await createViteDevServer(server, log);
976
+ const webRoot = vite ? null : findWebRoot();
977
+ if (!vite && !webRoot) log(" \u26A0 no dashboard available \u2014 see the page for build/dev instructions");
978
+ const engine = createDataEngine({ version, config: state.config, tz, summaryIntervalMs, billingIntervalMs, resolved });
979
+ server.addListener("request", createRouter(engine, state, vite, webRoot));
980
+ const closeWsRpc = await mountWsRpc(server, { engine, state, wsToken: wsToken2 });
981
+ const port = await listenWithFallback(server, opts.port ?? DEFAULT_PORT);
982
+ const serverUrl = `http://${HOST}:${port}`;
983
+ if (vite?.warmupRequest) {
984
+ try {
985
+ await Promise.race([vite.warmupRequest("/src/main.tsx"), delay(5e3)]);
986
+ } catch {
987
+ }
988
+ }
989
+ engine.start();
990
+ return {
991
+ url: serverUrl,
992
+ port,
993
+ wsToken: wsToken2,
994
+ snapshot: engine.snapshot,
995
+ config: () => state.config,
996
+ stop: async () => {
997
+ engine.stop();
998
+ await closeWsRpc().catch(() => {
999
+ });
1000
+ const closeHttp = () => new Promise((resolve) => {
1001
+ server.close(() => resolve());
1002
+ server.closeAllConnections?.();
1003
+ });
1004
+ if (vite) {
1005
+ try {
1006
+ await vite.close();
1007
+ } catch {
1008
+ }
1009
+ }
1010
+ await closeHttp();
1011
+ }
1012
+ };
1013
+ }
1014
+ function delay(ms) {
1015
+ return new Promise((resolve) => {
1016
+ const t = setTimeout(resolve, ms);
1017
+ t.unref?.();
1018
+ });
1019
+ }
1020
+ function listenWithFallback(server, startPort) {
1021
+ return new Promise((resolve, reject) => {
1022
+ if (startPort === 0) {
1023
+ server.once("error", reject);
1024
+ server.listen(0, HOST, () => {
1025
+ const addr = server.address();
1026
+ resolve(typeof addr === "object" && addr ? addr.port : 0);
1027
+ });
1028
+ return;
1029
+ }
1030
+ let port = startPort;
1031
+ let tries = 0;
1032
+ const attempt = () => {
1033
+ server.once("error", (err) => {
1034
+ if (err.code === "EADDRINUSE" && tries < MAX_PORT_TRIES) {
1035
+ tries++;
1036
+ port++;
1037
+ setImmediate(attempt);
1038
+ } else {
1039
+ reject(err);
1040
+ }
1041
+ });
1042
+ server.listen(port, HOST, () => resolve(port));
1043
+ };
1044
+ attempt();
1045
+ });
1046
+ }
1047
+
1048
+ export {
1049
+ appVersion,
1050
+ startWebServer
1051
+ };