tokmon 0.22.7 → 0.23.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.
Files changed (57) hide show
  1. package/README.md +1 -0
  2. package/dist/{bootstrap-ink-OHPQZKGB.js → bootstrap-ink-UD3KDTOB.js} +1113 -818
  3. package/dist/{chunk-EMQL5P5J.js → chunk-AON6GE7D.js} +368 -280
  4. package/dist/{chunk-TNIADJ3W.js → chunk-DJPUYMZM.js} +49 -16
  5. package/dist/chunk-HV6AYQDB.js +192 -0
  6. package/dist/chunk-HXDAOOSI.js +154 -0
  7. package/dist/{chunk-I4USANOF.js → chunk-PGQK7MOE.js} +879 -391
  8. package/dist/cli.js +93 -54
  9. package/dist/{config-WLRQHLVJ.js → config-5CNJN3P7.js} +1 -1
  10. package/dist/daemon-AW3YHFSJ.js +193 -0
  11. package/dist/daemon-handle-EYAXNNKZ.js +126 -0
  12. package/dist/server-4CDZMLH6.js +10 -0
  13. package/dist/web/assets/Area-DGo9K-U3.js +2 -0
  14. package/dist/web/assets/analytics-BOhbhV5Z.js +2 -0
  15. package/dist/web/assets/breakdown-CO04nSN-.js +4 -0
  16. package/dist/web/assets/chart-bNxdfXxA.js +69 -0
  17. package/dist/web/assets/explore-sZnmjzAj.js +22 -0
  18. package/dist/web/assets/index-DSj-Bxov.css +1 -0
  19. package/dist/web/assets/index-DnDr1VUr.js +83 -0
  20. package/dist/web/assets/models-Cl6D8VVF.js +2 -0
  21. package/dist/web/assets/overview-C18nvaAK.js +2 -0
  22. package/dist/web/assets/panel-DTjMnHaz.js +1 -0
  23. package/dist/web/assets/primitives-Dj_9YxTF.js +1 -0
  24. package/dist/web/assets/settings-sheet-0BGb5Ahv.js +1 -0
  25. package/dist/web/assets/share-sheet-BHtt-OmT.js +2 -0
  26. package/dist/web/assets/timeline-DmzB8Z3W.js +1 -0
  27. package/dist/web/assets/use-dialog-trap-DeXFtxx8.js +1 -0
  28. package/dist/web/index.html +3 -2
  29. package/package.json +9 -5
  30. package/dist/daemon-IP3UCTPB.js +0 -213
  31. package/dist/daemon-handle-HLSKLMWU.js +0 -142
  32. package/dist/server-GA2FKYDQ.js +0 -9
  33. package/dist/web/assets/breakdown-DU2j1sQ8.js +0 -4
  34. package/dist/web/assets/index-BCaPGYQA.css +0 -1
  35. package/dist/web/assets/index-ygZyXDLT.js +0 -173
  36. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  37. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  38. package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  39. package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  40. package/dist/web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  41. package/dist/web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  42. package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  43. package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  44. package/dist/web/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  45. package/dist/web/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  46. package/dist/web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  47. package/dist/web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  48. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  49. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  50. package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  51. package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  52. package/dist/web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  53. package/dist/web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  54. package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  55. package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  56. package/dist/web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  57. package/dist/web/assets/timeline-CO2B2c-0.js +0 -1
@@ -1,6 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ appVersion,
4
+ browserUrl,
5
+ findWebRoot,
6
+ send,
7
+ sendJson,
8
+ serveStatic
9
+ } from "./chunk-HXDAOOSI.js";
2
10
  import {
3
11
  PROVIDERS,
12
+ TOKMON_CAPABILITIES,
13
+ TOKMON_PROTOCOL_VERSION,
4
14
  TOKMON_WS_METHODS,
5
15
  TOKMON_WS_PATH,
6
16
  TokmonRpcGroup,
@@ -11,7 +21,7 @@ import {
11
21
  namedHex,
12
22
  resolveTimezone,
13
23
  withTimeout
14
- } from "./chunk-I4USANOF.js";
24
+ } from "./chunk-PGQK7MOE.js";
15
25
  import {
16
26
  cacheDir,
17
27
  expandHome,
@@ -19,11 +29,11 @@ import {
19
29
  normalizeConfig,
20
30
  saveConfig,
21
31
  snapshotCacheFile
22
- } from "./chunk-TNIADJ3W.js";
32
+ } from "./chunk-DJPUYMZM.js";
23
33
 
24
34
  // src/web/server.ts
25
35
  import { createServer } from "http";
26
- import { randomBytes } from "crypto";
36
+ import { randomBytes, timingSafeEqual as timingSafeEqual2 } from "crypto";
27
37
 
28
38
  // src/web/data.ts
29
39
  async function resolveAccounts(config) {
@@ -103,115 +113,11 @@ function tzFor(config) {
103
113
  return resolveTimezone(config.timezone);
104
114
  }
105
115
 
106
- // src/web/static.ts
107
- import { createReadStream, existsSync, readFileSync } from "fs";
108
- import { stat } from "fs/promises";
109
- import { fileURLToPath } from "url";
110
- import { extname, join, normalize, sep } from "path";
111
- var MIME = {
112
- ".html": "text/html; charset=utf-8",
113
- ".js": "text/javascript; charset=utf-8",
114
- ".mjs": "text/javascript; charset=utf-8",
115
- ".css": "text/css; charset=utf-8",
116
- ".json": "application/json; charset=utf-8",
117
- ".svg": "image/svg+xml",
118
- ".png": "image/png",
119
- ".jpg": "image/jpeg",
120
- ".jpeg": "image/jpeg",
121
- ".gif": "image/gif",
122
- ".ico": "image/x-icon",
123
- ".woff": "font/woff",
124
- ".woff2": "font/woff2",
125
- ".ttf": "font/ttf",
126
- ".otf": "font/otf",
127
- ".map": "application/json; charset=utf-8",
128
- ".webmanifest": "application/manifest+json"
129
- };
130
- function findWebRoot() {
131
- const candidates = ["./web/", "../web/", "../dist/web/", "../../dist/web/"];
132
- for (const rel of candidates) {
133
- try {
134
- const dir = fileURLToPath(new URL(rel, import.meta.url));
135
- if (existsSync(join(dir, "index.html"))) return dir.replace(/[\\/]+$/, "");
136
- } catch {
137
- }
138
- }
139
- return null;
140
- }
141
- function resolveStaticPath(webRoot, urlPath) {
142
- let clean;
143
- try {
144
- clean = decodeURIComponent(urlPath.split("?")[0]);
145
- } catch {
146
- return null;
147
- }
148
- const rel = normalize(clean).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
149
- const full = join(webRoot, rel);
150
- if (full !== webRoot && !full.startsWith(webRoot + sep)) return null;
151
- return full;
152
- }
153
- function send(res, status, type, body) {
154
- res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" });
155
- res.end(body);
156
- }
157
- function sendJson(res, status, data) {
158
- send(res, status, "application/json; charset=utf-8", JSON.stringify(data));
159
- }
160
- function serveStatic(webRoot, urlPath, res) {
161
- const path = urlPath.split("?")[0];
162
- const filePath = resolveStaticPath(webRoot, path === "/" ? "/index.html" : path);
163
- if (!filePath) {
164
- send(res, 403, "text/plain", "forbidden");
165
- return;
166
- }
167
- void stat(filePath).then((st) => {
168
- if (st.isFile()) {
169
- const type = MIME[extname(filePath).toLowerCase()] || "application/octet-stream";
170
- const immutable = filePath.includes(`${sep}assets${sep}`);
171
- res.writeHead(200, {
172
- "Content-Type": type,
173
- "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache"
174
- });
175
- createReadStream(filePath).pipe(res);
176
- } else {
177
- throw new Error("not a file");
178
- }
179
- }).catch(() => {
180
- if (extname(path)) {
181
- send(res, 404, "text/plain", "not found");
182
- return;
183
- }
184
- const indexPath = join(webRoot, "index.html");
185
- if (!existsSync(indexPath)) {
186
- send(res, 404, "text/plain", "not found");
187
- return;
188
- }
189
- res.writeHead(200, { "Content-Type": MIME[".html"], "Cache-Control": "no-cache" });
190
- createReadStream(indexPath).on("error", () => {
191
- try {
192
- res.destroy();
193
- } catch {
194
- }
195
- }).pipe(res);
196
- });
197
- }
198
- function appVersion() {
199
- for (const rel of ["../package.json", "../../package.json"]) {
200
- try {
201
- const p = fileURLToPath(new URL(rel, import.meta.url));
202
- const pkg = JSON.parse(readFileSync(p, "utf-8"));
203
- if (typeof pkg.version === "string") return pkg.version;
204
- } catch {
205
- }
206
- }
207
- return "";
208
- }
209
-
210
116
  // src/web/vite-dev.ts
211
- import { existsSync as existsSync2 } from "fs";
212
- import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
117
+ import { existsSync } from "fs";
118
+ import { fileURLToPath, pathToFileURL } from "url";
213
119
  import { createRequire } from "module";
214
- import { join as join2 } from "path";
120
+ import { join } from "path";
215
121
  function isDevMode() {
216
122
  const forced = process.env.TOKMON_WEB_MODE;
217
123
  if (forced === "dev") return true;
@@ -221,8 +127,8 @@ function isDevMode() {
221
127
  function findWebSource() {
222
128
  for (const rel of ["../../web/", "../web/", "./web/"]) {
223
129
  try {
224
- const dir = fileURLToPath2(new URL(rel, import.meta.url));
225
- if (existsSync2(join2(dir, "vite.config.ts")) && existsSync2(join2(dir, "index.html"))) {
130
+ const dir = fileURLToPath(new URL(rel, import.meta.url));
131
+ if (existsSync(join(dir, "vite.config.ts")) && existsSync(join(dir, "index.html"))) {
226
132
  return dir.replace(/[\\/]+$/, "");
227
133
  }
228
134
  } catch {
@@ -237,12 +143,12 @@ async function createViteDevServer(httpServer, log) {
237
143
  return null;
238
144
  }
239
145
  try {
240
- const req = createRequire(pathToFileURL(join2(root, "package.json")).href);
146
+ const req = createRequire(pathToFileURL(join(root, "package.json")).href);
241
147
  const vitePath = req.resolve("vite");
242
148
  const vite = await import(pathToFileURL(vitePath).href);
243
149
  const dev = await vite.createServer({
244
150
  root,
245
- configFile: join2(root, "vite.config.ts"),
151
+ configFile: join(root, "vite.config.ts"),
246
152
  server: { middlewareMode: true, hmr: { server: httpServer } },
247
153
  appType: "spa",
248
154
  clearScreen: false,
@@ -266,13 +172,90 @@ code{color:#e6b450}</style></head><body>
266
172
  </body></html>`;
267
173
 
268
174
  // src/web/data-engine.ts
269
- import { readFileSync as readFileSync2, writeFileSync, mkdirSync, renameSync } from "fs";
175
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from "fs";
176
+
177
+ // src/web/refresh-queue.ts
178
+ function deferred() {
179
+ let resolve;
180
+ let reject;
181
+ const promise = new Promise((onResolve, onReject) => {
182
+ resolve = onResolve;
183
+ reject = onReject;
184
+ });
185
+ return { promise, resolve, reject };
186
+ }
187
+ function createRefreshQueue(perform, skipAutomatic = () => false) {
188
+ let activePass = null;
189
+ let activeResult = null;
190
+ let queued = null;
191
+ let stopped = false;
192
+ const launch = (result = deferred()) => {
193
+ const pass = Promise.resolve().then(perform);
194
+ activePass = pass;
195
+ activeResult = result;
196
+ const settled = (succeeded, cause) => {
197
+ if (activePass !== pass) return;
198
+ activePass = null;
199
+ activeResult = null;
200
+ const next = queued;
201
+ queued = null;
202
+ if (next && !stopped) {
203
+ void next.promise.then(
204
+ () => {
205
+ if (succeeded) result.resolve();
206
+ else result.reject(cause);
207
+ },
208
+ result.reject
209
+ );
210
+ launch(next);
211
+ return;
212
+ }
213
+ if (next) next.resolve();
214
+ if (succeeded) result.resolve();
215
+ else result.reject(cause);
216
+ };
217
+ void pass.then(() => settled(true), (cause) => settled(false, cause));
218
+ return result.promise;
219
+ };
220
+ const run = (force = false) => {
221
+ if (stopped || !force && skipAutomatic()) return Promise.resolve();
222
+ if (!activePass) return launch();
223
+ if (!force) return activeResult.promise;
224
+ if (!queued) queued = deferred();
225
+ return queued.promise;
226
+ };
227
+ return {
228
+ run,
229
+ stop() {
230
+ stopped = true;
231
+ activeResult?.resolve();
232
+ queued?.resolve();
233
+ queued = null;
234
+ }
235
+ };
236
+ }
237
+ async function settleRefreshTasks(tasks) {
238
+ const results = await Promise.allSettled(tasks);
239
+ const failure = results.find((result) => result.status === "rejected");
240
+ if (failure) throw failure.reason;
241
+ }
242
+
243
+ // src/web/data-engine.ts
270
244
  var TABLE_INTERVAL_MS = 3e5;
271
245
  var PEAK_INTERVAL_MS = 3e5;
272
246
  var IDLE_PAUSE_MS = 6e4;
273
247
  var SNAPSHOT_CACHE_THROTTLE_MS = 2e4;
274
248
  var REVEAL_THROTTLE_MS = 500;
275
249
  var FETCH_TIMEOUT_MS = 3e4;
250
+ function runInBackground(task) {
251
+ void task.catch(() => {
252
+ });
253
+ }
254
+ function throwIfRefreshFailures(scope, failures) {
255
+ if (failures.length > 0) {
256
+ throw new AggregateError(failures, `${scope} refresh failed for ${failures.length} account${failures.length === 1 ? "" : "s"}`);
257
+ }
258
+ }
276
259
  function createDataEngine(opts) {
277
260
  const { version } = opts;
278
261
  let tz = opts.tz;
@@ -324,7 +307,7 @@ function createDataEngine(opts) {
324
307
  });
325
308
  const hydrateFromCache = () => {
326
309
  try {
327
- const cached = JSON.parse(readFileSync2(snapshotCacheFile(), "utf-8"));
310
+ const cached = JSON.parse(readFileSync(snapshotCacheFile(), "utf-8"));
328
311
  if (!cached || !Array.isArray(cached.accounts)) return;
329
312
  for (const a of cached.accounts) {
330
313
  if (a.dashboard || a.table) {
@@ -375,47 +358,35 @@ function createDataEngine(opts) {
375
358
  };
376
359
  let usageAccounts = resolved.filter((r) => r.hasUsage);
377
360
  let billingAccounts = resolved.filter((r) => r.hasBilling);
378
- const makeRefreshLoop = (opts2) => {
379
- let busy = false;
380
- let forcePending = false;
381
- const run = async (force = false) => {
361
+ const makeRefreshLoop = (opts2) => createRefreshQueue(
362
+ async () => {
382
363
  if (stopped) return;
383
- if (busy) {
384
- if (force) forcePending = true;
385
- return;
386
- }
387
- if (!force && idle()) return;
388
364
  const epoch = configEpoch;
389
- busy = true;
390
- try {
391
- for (const r of opts2.accounts()) {
392
- if (stopped) return;
393
- let value = null;
394
- let ok = true;
395
- try {
396
- value = await withTimeout(opts2.fetch(r), FETCH_TIMEOUT_MS);
397
- } catch {
398
- ok = false;
399
- }
400
- if (stopped || epoch !== configEpoch) return;
401
- if (ok) {
402
- opts2.apply(r.account.id, value);
403
- opts2.state.set(r.account.id, "ready");
404
- } else opts2.state.set(r.account.id, "error");
405
- reveal();
406
- }
407
- rebuild();
408
- } finally {
409
- busy = false;
410
- if (forcePending && !stopped) {
411
- forcePending = false;
412
- void run(true);
365
+ const failures = [];
366
+ for (const r of opts2.accounts()) {
367
+ if (stopped) return;
368
+ let value = null;
369
+ let ok = true;
370
+ try {
371
+ value = await withTimeout(opts2.fetch(r), FETCH_TIMEOUT_MS);
372
+ } catch (cause) {
373
+ ok = false;
374
+ failures.push(cause);
413
375
  }
376
+ if (stopped || epoch !== configEpoch) return;
377
+ if (ok) {
378
+ opts2.apply(r.account.id, value);
379
+ opts2.state.set(r.account.id, "ready");
380
+ } else opts2.state.set(r.account.id, "error");
381
+ reveal();
414
382
  }
415
- };
416
- return run;
417
- };
383
+ rebuild();
384
+ throwIfRefreshFailures(opts2.scope, failures);
385
+ },
386
+ idle
387
+ );
418
388
  const refreshSummary = makeRefreshLoop({
389
+ scope: "summary",
419
390
  accounts: () => usageAccounts,
420
391
  fetch: (r) => fetchAccountSummary(r.account, tz),
421
392
  apply: (id, dashboard) => {
@@ -424,6 +395,7 @@ function createDataEngine(opts) {
424
395
  state: summaryState
425
396
  });
426
397
  const refreshTable = makeRefreshLoop({
398
+ scope: "history",
427
399
  accounts: () => usageAccounts,
428
400
  fetch: (r) => fetchAccountTable(r.account, tz),
429
401
  apply: (id, table) => {
@@ -432,6 +404,7 @@ function createDataEngine(opts) {
432
404
  state: tableState
433
405
  });
434
406
  const refreshBilling = makeRefreshLoop({
407
+ scope: "billing",
435
408
  accounts: () => billingAccounts,
436
409
  fetch: (r) => fetchAccountBilling(r.account, tz),
437
410
  apply: (id, result) => {
@@ -439,22 +412,19 @@ function createDataEngine(opts) {
439
412
  },
440
413
  state: billingState
441
414
  });
442
- let peakBusy = false;
443
- const refreshPeak = async (force = false) => {
444
- if (stopped || peakBusy || !hasClaude || !force && idle()) return;
445
- const epoch = configEpoch;
446
- peakBusy = true;
447
- try {
415
+ const refreshPeak = createRefreshQueue(
416
+ async () => {
417
+ if (stopped || !hasClaude) return;
418
+ const epoch = configEpoch;
448
419
  const next = await fetchPeak();
449
420
  if (stopped || epoch !== configEpoch || !hasClaude) return;
450
421
  if (next) {
451
422
  peak = next;
452
423
  rebuild();
453
424
  }
454
- } finally {
455
- peakBusy = false;
456
- }
457
- };
425
+ },
426
+ () => !hasClaude || idle()
427
+ );
458
428
  const clearTimers = () => {
459
429
  clearInterval(summaryTimer);
460
430
  summaryTimer = void 0;
@@ -467,20 +437,20 @@ function createDataEngine(opts) {
467
437
  };
468
438
  const startTimers = () => {
469
439
  summaryTimer = setInterval(() => {
470
- void refreshSummary();
440
+ runInBackground(refreshSummary.run());
471
441
  }, summaryIntervalMs);
472
442
  tableTimer = setInterval(() => {
473
- void refreshTable();
443
+ runInBackground(refreshTable.run());
474
444
  }, TABLE_INTERVAL_MS);
475
445
  billingTimer = setInterval(() => {
476
- void refreshBilling();
446
+ runInBackground(refreshBilling.run());
477
447
  }, billingIntervalMs);
478
448
  summaryTimer.unref?.();
479
449
  tableTimer.unref?.();
480
450
  billingTimer.unref?.();
481
451
  if (hasClaude) {
482
452
  peakTimer = setInterval(() => {
483
- void refreshPeak();
453
+ runInBackground(refreshPeak.run());
484
454
  }, PEAK_INTERVAL_MS);
485
455
  peakTimer.unref?.();
486
456
  }
@@ -489,21 +459,23 @@ function createDataEngine(opts) {
489
459
  return {
490
460
  snapshot: () => current,
491
461
  start() {
492
- void refreshSummary(true);
493
- void refreshTable(true);
494
- void refreshBilling(true);
495
- if (hasClaude) void refreshPeak(true);
462
+ runInBackground(refreshSummary.run(true));
463
+ runInBackground(refreshTable.run(true));
464
+ runInBackground(refreshBilling.run(true));
465
+ if (hasClaude) runInBackground(refreshPeak.run(true));
496
466
  startTimers();
497
467
  },
498
468
  touch() {
499
469
  lastActivity = Date.now();
500
470
  },
501
471
  refresh(scope = "all") {
502
- if (stopped) return;
503
- if (scope === "all" || scope === "summary") void refreshSummary(true);
504
- if (scope === "all" || scope === "table") void refreshTable(true);
505
- if (scope === "all" || scope === "billing") void refreshBilling(true);
506
- if ((scope === "all" || scope === "peak") && hasClaude) void refreshPeak(true);
472
+ if (stopped) return Promise.resolve();
473
+ const tasks = [];
474
+ if (scope === "all" || scope === "summary") tasks.push(refreshSummary.run(true));
475
+ if (scope === "all" || scope === "table") tasks.push(refreshTable.run(true));
476
+ if (scope === "all" || scope === "billing") tasks.push(refreshBilling.run(true));
477
+ if ((scope === "all" || scope === "peak") && hasClaude) tasks.push(refreshPeak.run(true));
478
+ return settleRefreshTasks(tasks);
507
479
  },
508
480
  setConfig(next) {
509
481
  if (stopped) return;
@@ -528,10 +500,10 @@ function createDataEngine(opts) {
528
500
  for (const id of [...map.keys()]) if (!survivors.has(id)) map.delete(id);
529
501
  }
530
502
  rebuild();
531
- void refreshSummary(true);
532
- void refreshTable(true);
533
- void refreshBilling(true);
534
- if (hasClaude) void refreshPeak(true);
503
+ runInBackground(refreshSummary.run(true));
504
+ runInBackground(refreshTable.run(true));
505
+ runInBackground(refreshBilling.run(true));
506
+ if (hasClaude) runInBackground(refreshPeak.run(true));
535
507
  startTimers();
536
508
  },
537
509
  broadcastConfig(config) {
@@ -554,8 +526,8 @@ function createDataEngine(opts) {
554
526
  snapshotSubscribers.add(onSnapshot);
555
527
  lastActivity = Date.now();
556
528
  if (!current || Date.now() - current.generatedAt > summaryIntervalMs) {
557
- void refreshSummary(true);
558
- void refreshTable(true);
529
+ runInBackground(refreshSummary.run(true));
530
+ runInBackground(refreshTable.run(true));
559
531
  }
560
532
  return () => {
561
533
  snapshotSubscribers.delete(onSnapshot);
@@ -574,6 +546,10 @@ function createDataEngine(opts) {
574
546
  stop() {
575
547
  stopped = true;
576
548
  clearTimers();
549
+ refreshSummary.stop();
550
+ refreshTable.stop();
551
+ refreshBilling.stop();
552
+ refreshPeak.stop();
577
553
  snapshotSubscribers.clear();
578
554
  configSubscribers.clear();
579
555
  }
@@ -585,6 +561,32 @@ var MIN_SUMMARY_INTERVAL_MS = 8e3;
585
561
  var BILLING_INTERVAL_FALLBACK_MIN = 5;
586
562
  var summaryIntervalFor = (config) => Math.max(MIN_SUMMARY_INTERVAL_MS, (config.interval || 2) * 1e3);
587
563
  var billingIntervalFor = (config) => Math.max(1, config.billingInterval || BILLING_INTERVAL_FALLBACK_MIN) * 6e4;
564
+ function toConfigState(config) {
565
+ return {
566
+ protocol: {
567
+ version: TOKMON_PROTOCOL_VERSION,
568
+ capabilities: [...TOKMON_CAPABILITIES]
569
+ },
570
+ config
571
+ };
572
+ }
573
+ var ConfigConflictError = class extends Error {
574
+ constructor(state) {
575
+ super(`config changed on the daemon (current revision ${state.config.revision})`);
576
+ this.state = state;
577
+ this.name = "ConfigConflictError";
578
+ }
579
+ state;
580
+ kind = "conflict";
581
+ };
582
+ var ConfigPersistenceError = class extends Error {
583
+ kind = "persistence";
584
+ constructor(cause) {
585
+ super(cause instanceof Error ? cause.message : "config could not be persisted");
586
+ this.name = "ConfigPersistenceError";
587
+ this.cause = cause;
588
+ }
589
+ };
588
590
  async function resolveEngineConfig(config) {
589
591
  return {
590
592
  resolved: await resolveAccounts(config),
@@ -593,27 +595,50 @@ async function resolveEngineConfig(config) {
593
595
  billingIntervalMs: billingIntervalFor(config)
594
596
  };
595
597
  }
596
- async function applyConfigUpdate(engine, state, input) {
597
- const normalized = normalizeConfig(input);
598
+ var updateQueues = /* @__PURE__ */ new WeakMap();
599
+ async function applyConfigUpdateUnlocked(engine, state, input) {
600
+ if (input.expectedRevision !== state.config.revision) {
601
+ throw new ConfigConflictError(toConfigState(state.config));
602
+ }
603
+ const normalized = normalizeConfig({
604
+ ...input.config,
605
+ revision: state.config.revision + 1
606
+ });
607
+ const engineConfig = await resolveEngineConfig(normalized);
608
+ try {
609
+ await saveConfig(normalized);
610
+ } catch (error) {
611
+ throw new ConfigPersistenceError(error);
612
+ }
598
613
  state.config = normalized;
599
- await saveConfig(normalized);
600
- engine.setConfig(await resolveEngineConfig(normalized));
614
+ engine.setConfig(engineConfig);
601
615
  engine.broadcastConfig(normalized);
602
- return normalized;
616
+ return toConfigState(normalized);
617
+ }
618
+ async function applyConfigUpdate(engine, state, input) {
619
+ const previous = updateQueues.get(state) ?? Promise.resolve();
620
+ const operation = previous.catch(() => void 0).then(() => applyConfigUpdateUnlocked(engine, state, input));
621
+ const settled = operation.then(() => void 0, () => void 0);
622
+ updateQueues.set(state, settled);
623
+ void settled.then(() => {
624
+ if (updateQueues.get(state) === settled) updateQueues.delete(state);
625
+ });
626
+ return operation;
603
627
  }
604
628
 
605
629
  // src/web/ws.ts
630
+ import { timingSafeEqual } from "crypto";
606
631
  import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
607
632
  import { NodeWS } from "@effect/platform-node/NodeSocket";
608
633
  import { Effect, Exit, Layer, Queue, Scope, Stream } from "effect";
609
634
  import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
610
635
 
611
636
  // src/web/fs.ts
612
- import { readdir, stat as stat2, realpath } from "fs/promises";
637
+ import { readdir, stat, realpath } from "fs/promises";
613
638
  import { homedir } from "os";
614
- import { join as join3, resolve as resolvePath, isAbsolute, sep as sep2 } from "path";
639
+ import { join as join2, resolve as resolvePath, isAbsolute, sep } from "path";
615
640
  function isContained(root, target) {
616
- return target === root || target.startsWith(root + sep2);
641
+ return target === root || target.startsWith(root + sep);
617
642
  }
618
643
  function parentFor(root, abs) {
619
644
  const parentResolved = resolvePath(abs, "..");
@@ -633,7 +658,7 @@ async function listHomeDirectory(rawPath) {
633
658
  const abs = isContained(root, real) ? real : root;
634
659
  let st;
635
660
  try {
636
- st = await stat2(abs);
661
+ st = await stat(abs);
637
662
  } catch {
638
663
  return { path: abs, parent: parentFor(root, abs), entries: [] };
639
664
  }
@@ -648,7 +673,7 @@ async function listHomeDirectory(rawPath) {
648
673
  for (const d of dirents) {
649
674
  if (d.name.startsWith(".")) continue;
650
675
  let dir = d.isDirectory();
651
- const full = join3(abs, d.name);
676
+ const full = join2(abs, d.name);
652
677
  if (d.isSymbolicLink()) {
653
678
  let real2;
654
679
  try {
@@ -658,7 +683,7 @@ async function listHomeDirectory(rawPath) {
658
683
  }
659
684
  if (!isContained(root, real2)) continue;
660
685
  try {
661
- dir = (await stat2(full)).isDirectory();
686
+ dir = (await stat(full)).isDirectory();
662
687
  } catch {
663
688
  continue;
664
689
  }
@@ -670,41 +695,15 @@ async function listHomeDirectory(rawPath) {
670
695
  }
671
696
 
672
697
  // src/web/ws.ts
673
- var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
674
698
  function header(req, name) {
675
699
  const value = req.headers[name.toLowerCase()];
676
700
  return Array.isArray(value) ? value[0] : value;
677
701
  }
678
- function hostOnly(value) {
679
- if (!value) return null;
680
- let host = value.trim().toLowerCase();
681
- if (!host) return null;
682
- if (host.startsWith("[")) {
683
- const end = host.indexOf("]");
684
- return end === -1 ? host.slice(1) : host.slice(1, end);
685
- }
686
- return host.split(":")[0] ?? null;
687
- }
688
702
  function isLoopbackHost(value) {
689
- const host = hostOnly(value);
690
- return host !== null && LOOPBACK_HOSTS.has(host);
691
- }
692
- function originHost(origin) {
693
- if (!origin || origin === "null") return null;
694
- try {
695
- return new URL(origin).host;
696
- } catch {
697
- return null;
698
- }
699
- }
700
- function isLoopbackOrigin(origin) {
701
- if (!origin || origin === "null") return true;
702
- return isLoopbackHost(originHost(origin) ?? void 0);
703
- }
704
- function isSameOrigin(req) {
705
- const origin = originHost(header(req, "origin"));
706
- if (!origin) return false;
707
- return origin.toLowerCase() === (header(req, "host") ?? "").toLowerCase();
703
+ if (!value) return false;
704
+ const host = value.trim().toLowerCase();
705
+ if (!host) return false;
706
+ return /^(?:127\.0\.0\.1|localhost)(?::\d{1,5})?$/.test(host) || /^\[::1\](?::\d{1,5})?$/.test(host);
708
707
  }
709
708
  function isWsPath(req) {
710
709
  try {
@@ -720,12 +719,15 @@ function wsToken(req) {
720
719
  return null;
721
720
  }
722
721
  }
722
+ function tokenMatches(actual, expected) {
723
+ if (!actual) return false;
724
+ const left = Buffer.from(actual);
725
+ const right = Buffer.from(expected);
726
+ return left.length === right.length && timingSafeEqual(left, right);
727
+ }
723
728
  function isAuthorized(req, token) {
724
- const host = header(req, "host");
725
- const origin = header(req, "origin");
726
- if (!isLoopbackHost(host) || !isLoopbackOrigin(origin)) return false;
727
- if (wsToken(req) === token) return true;
728
- return isSameOrigin(req);
729
+ if (!isLoopbackHost(header(req, "host"))) return false;
730
+ return tokenMatches(wsToken(req), token);
729
731
  }
730
732
  function rejectUpgrade(socket, status = 403, message = "Forbidden") {
731
733
  try {
@@ -761,19 +763,29 @@ function configStream(engine) {
761
763
  yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
762
764
  }), { bufferSize: 16, strategy: "sliding" });
763
765
  }
766
+ function toConfigUpdateFailure(error) {
767
+ if (error instanceof ConfigConflictError) {
768
+ return { kind: "conflict", state: error.state };
769
+ }
770
+ if (error instanceof ConfigPersistenceError) {
771
+ return { kind: "persistence", message: error.message };
772
+ }
773
+ return { kind: "persistence", message: error instanceof Error ? error.message : "config update failed" };
774
+ }
764
775
  async function mountWsRpc(server, deps) {
765
776
  const scope = await Effect.runPromise(Scope.make());
766
777
  const wss = new NodeWS.WebSocketServer({ noServer: true });
767
778
  const handlersLayer = TokmonRpcGroup.toLayer(
768
779
  TokmonRpcGroup.of({
769
- [TOKMON_WS_METHODS.getConfig]: () => Effect.tryPromise(() => Promise.resolve(deps.state.config ?? loadConfig())),
770
- [TOKMON_WS_METHODS.setConfig]: (config) => Effect.tryPromise(() => applyConfigUpdate(deps.engine, deps.state, config)),
771
- [TOKMON_WS_METHODS.refresh]: ({ scope: scope2 }) => Effect.sync(() => {
772
- deps.engine.refresh(scope2);
780
+ [TOKMON_WS_METHODS.getConfig]: () => Effect.tryPromise(() => Promise.resolve(deps.state.config ?? loadConfig()).then(toConfigState)),
781
+ [TOKMON_WS_METHODS.setConfig]: (config) => Effect.tryPromise({
782
+ try: () => applyConfigUpdate(deps.engine, deps.state, config),
783
+ catch: toConfigUpdateFailure
773
784
  }),
785
+ [TOKMON_WS_METHODS.refresh]: ({ scope: scope2 }) => Effect.promise(() => deps.engine.refresh(scope2)),
774
786
  [TOKMON_WS_METHODS.browseFs]: ({ path }) => Effect.tryPromise(() => listHomeDirectory(path)),
775
787
  [TOKMON_WS_METHODS.snapshot]: () => snapshotStream(deps.engine),
776
- [TOKMON_WS_METHODS.config]: () => configStream(deps.engine)
788
+ [TOKMON_WS_METHODS.config]: () => configStream(deps.engine).pipe(Stream.map(toConfigState))
777
789
  })
778
790
  );
779
791
  const httpEffect = await Effect.runPromise(
@@ -794,6 +806,10 @@ async function mountWsRpc(server, deps) {
794
806
  )
795
807
  );
796
808
  const onUpgrade = (req, socket, head) => {
809
+ if (!isLoopbackHost(header(req, "host"))) {
810
+ rejectUpgrade(socket);
811
+ return;
812
+ }
797
813
  if (!isWsPath(req)) return;
798
814
  if (!isAuthorized(req, deps.wsToken)) {
799
815
  rejectUpgrade(socket);
@@ -801,13 +817,36 @@ async function mountWsRpc(server, deps) {
801
817
  }
802
818
  upgradeHandler(req, socket, head);
803
819
  };
804
- server.on("upgrade", onUpgrade);
820
+ server.prependListener("upgrade", onUpgrade);
805
821
  return async () => {
806
822
  server.off("upgrade", onUpgrade);
823
+ for (const client of wss.clients) {
824
+ try {
825
+ client.terminate();
826
+ } catch {
827
+ }
828
+ }
807
829
  await new Promise((resolve) => {
808
- wss.close(() => resolve());
830
+ const timer = setTimeout(resolve, 750);
831
+ timer.unref?.();
832
+ try {
833
+ wss.close(() => {
834
+ clearTimeout(timer);
835
+ resolve();
836
+ });
837
+ } catch {
838
+ clearTimeout(timer);
839
+ resolve();
840
+ }
809
841
  });
810
- await Effect.runPromise(Scope.close(scope, Exit.void));
842
+ await Promise.race([
843
+ Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => {
844
+ }),
845
+ new Promise((resolve) => {
846
+ const timer = setTimeout(resolve, 750);
847
+ timer.unref?.();
848
+ })
849
+ ]);
811
850
  };
812
851
  }
813
852
 
@@ -817,12 +856,10 @@ var DEFAULT_PORT = 4317;
817
856
  var MAX_PORT_TRIES = 20;
818
857
  function isLoopbackHostHeader(value) {
819
858
  if (!value) return false;
820
- let host = value.trim().toLowerCase();
821
- if (host.startsWith("[")) host = host.slice(1, host.indexOf("]") === -1 ? host.length : host.indexOf("]"));
822
- else host = host.split(":")[0];
823
- return host === "127.0.0.1" || host === "localhost" || host === "::1";
859
+ const host = value.trim().toLowerCase();
860
+ return /^(?:127\.0\.0\.1|localhost)(?::\d{1,5})?$/.test(host) || /^\[::1\](?::\d{1,5})?$/.test(host);
824
861
  }
825
- function isLoopbackOrigin2(origin) {
862
+ function isLoopbackOrigin(origin) {
826
863
  if (!origin || origin === "null") return true;
827
864
  try {
828
865
  const u = new URL(origin);
@@ -831,37 +868,59 @@ function isLoopbackOrigin2(origin) {
831
868
  return false;
832
869
  }
833
870
  }
834
- function guardPrivileged(req, res) {
835
- const header2 = (n) => {
871
+ function header2(req, name) {
872
+ const value = req.headers[name];
873
+ return Array.isArray(value) ? value[0] : value;
874
+ }
875
+ function tokenMatches2(given, expected) {
876
+ if (!given) return false;
877
+ const actual = Buffer.from(given);
878
+ const token = Buffer.from(expected);
879
+ return actual.length === token.length && timingSafeEqual2(actual, token);
880
+ }
881
+ function guardHost(req, res) {
882
+ if (isLoopbackHostHeader(header2(req, "host"))) return true;
883
+ sendJson(res, 403, { error: "forbidden" });
884
+ return false;
885
+ }
886
+ function guardPrivileged(req, res, wsToken2) {
887
+ const header3 = (n) => {
836
888
  const v = req.headers[n];
837
889
  return Array.isArray(v) ? v[0] : v;
838
890
  };
839
- if (req.headers["x-tokmon-client"] !== "1") {
891
+ if (!tokenMatches2(header3("x-tokmon-token"), wsToken2)) {
840
892
  sendJson(res, 403, { error: "forbidden" });
841
893
  return false;
842
894
  }
843
- if (!isLoopbackHostHeader(header2("host")) || !isLoopbackOrigin2(header2("origin"))) {
895
+ if (!isLoopbackOrigin(header3("origin"))) {
844
896
  sendJson(res, 403, { error: "forbidden" });
845
897
  return false;
846
898
  }
847
899
  return true;
848
900
  }
849
- function createRouter(engine, state, vite, webRoot) {
901
+ function createRouter(engine, state, vite, webRoot, wsToken2, version) {
850
902
  return (req, res) => {
851
903
  const url = req.url || "/";
852
904
  const path = url.split("?")[0];
853
905
  const method = req.method || "GET";
906
+ if (!guardHost(req, res)) return;
854
907
  if (path === "/api/data") {
855
908
  engine.touch();
856
909
  sendJson(res, 200, engine.snapshot() ?? { pending: true });
857
910
  return;
858
911
  }
859
912
  if (path === "/healthz") {
860
- sendJson(res, 200, { ok: true, ready: engine.snapshot() !== null });
913
+ sendJson(res, 200, {
914
+ ok: true,
915
+ ready: engine.snapshot() !== null,
916
+ version,
917
+ // Discovery requires this proof; public health checks retain their useful 200 response.
918
+ owner: tokenMatches2(header2(req, "x-tokmon-token"), wsToken2)
919
+ });
861
920
  return;
862
921
  }
863
922
  if (path === "/api/config") {
864
- if (!guardPrivileged(req, res)) return;
923
+ if (!guardPrivileged(req, res, wsToken2)) return;
865
924
  if (method === "GET") {
866
925
  sendJson(res, 200, state.config);
867
926
  return;
@@ -888,51 +947,65 @@ async function startWebServer(opts) {
888
947
  const version = appVersion();
889
948
  const summaryIntervalMs = summaryIntervalFor(state.config);
890
949
  const billingIntervalMs = billingIntervalFor(state.config);
891
- const wsToken2 = randomBytes(32).toString("base64url");
950
+ const wsToken2 = opts.wsToken ?? randomBytes(32).toString("base64url");
892
951
  const log = (msg) => {
893
952
  if (opts.log) process.stdout.write(msg + "\n");
894
953
  };
895
954
  const resolved = await resolveAccounts(state.config);
896
955
  const server = createServer();
897
956
  let vite = null;
898
- if (isDevMode()) vite = await createViteDevServer(server, log);
899
- const webRoot = vite ? null : findWebRoot();
900
- if (!vite && !webRoot) log(" \u26A0 no dashboard available \u2014 see the page for build/dev instructions");
901
- const engine = createDataEngine({ version, config: state.config, tz, summaryIntervalMs, billingIntervalMs, resolved });
902
- server.addListener("request", createRouter(engine, state, vite, webRoot));
903
- const closeWsRpc = await mountWsRpc(server, { engine, state, wsToken: wsToken2 });
904
- const port = await listenWithFallback(server, opts.port ?? DEFAULT_PORT);
905
- const serverUrl = `http://${HOST}:${port}`;
906
- if (vite?.warmupRequest) {
907
- try {
908
- await Promise.race([vite.warmupRequest("/src/main.tsx"), delay(5e3)]);
909
- } catch {
957
+ let engine = null;
958
+ let closeWsRpc = null;
959
+ try {
960
+ if (isDevMode()) vite = await createViteDevServer(server, log);
961
+ const webRoot = vite ? null : findWebRoot();
962
+ if (!vite && !webRoot) log(" \u26A0 no dashboard available \u2014 see the page for build/dev instructions");
963
+ engine = createDataEngine({ version, config: state.config, tz, summaryIntervalMs, billingIntervalMs, resolved });
964
+ server.addListener("request", createRouter(engine, state, vite, webRoot, wsToken2, version));
965
+ closeWsRpc = await mountWsRpc(server, { engine, state, wsToken: wsToken2 });
966
+ const port = await listenWithFallback(server, opts.port ?? DEFAULT_PORT);
967
+ const serverUrl = `http://${HOST}:${port}`;
968
+ if (vite?.warmupRequest) {
969
+ try {
970
+ await Promise.race([vite.warmupRequest("/src/main.tsx"), delay(5e3)]);
971
+ } catch {
972
+ }
910
973
  }
911
- }
912
- engine.start();
913
- return {
914
- url: serverUrl,
915
- port,
916
- wsToken: wsToken2,
917
- snapshot: engine.snapshot,
918
- config: () => state.config,
919
- stop: async () => {
920
- engine.stop();
921
- await closeWsRpc().catch(() => {
922
- });
923
- const closeHttp = () => new Promise((resolve) => {
924
- server.close(() => resolve());
974
+ engine.start();
975
+ let stopped = false;
976
+ return {
977
+ url: serverUrl,
978
+ browserUrl: browserUrl(serverUrl, wsToken2),
979
+ port,
980
+ wsToken: wsToken2,
981
+ snapshot: engine.snapshot,
982
+ config: () => state.config,
983
+ stop: async () => {
984
+ if (stopped) return;
985
+ stopped = true;
986
+ engine?.stop();
987
+ await closeWsRpc?.().catch(() => {
988
+ });
925
989
  server.closeAllConnections?.();
926
- });
927
- if (vite) {
990
+ await closeServer(server);
928
991
  try {
929
- await vite.close();
992
+ await vite?.close();
930
993
  } catch {
931
994
  }
932
995
  }
933
- await closeHttp();
996
+ };
997
+ } catch (error) {
998
+ engine?.stop();
999
+ await closeWsRpc?.().catch(() => {
1000
+ });
1001
+ server.closeAllConnections?.();
1002
+ await closeServer(server);
1003
+ try {
1004
+ await vite?.close();
1005
+ } catch {
934
1006
  }
935
- };
1007
+ throw error;
1008
+ }
936
1009
  }
937
1010
  function delay(ms) {
938
1011
  return new Promise((resolve) => {
@@ -940,6 +1013,22 @@ function delay(ms) {
940
1013
  t.unref?.();
941
1014
  });
942
1015
  }
1016
+ function closeServer(server, timeoutMs = 1e3) {
1017
+ return new Promise((resolve) => {
1018
+ const done = () => resolve();
1019
+ const timer = setTimeout(done, timeoutMs);
1020
+ timer.unref?.();
1021
+ try {
1022
+ server.close(() => {
1023
+ clearTimeout(timer);
1024
+ done();
1025
+ });
1026
+ } catch {
1027
+ clearTimeout(timer);
1028
+ done();
1029
+ }
1030
+ });
1031
+ }
943
1032
  function listenWithFallback(server, startPort) {
944
1033
  return new Promise((resolve, reject) => {
945
1034
  if (startPort === 0) {
@@ -969,6 +1058,5 @@ function listenWithFallback(server, startPort) {
969
1058
  }
970
1059
 
971
1060
  export {
972
- appVersion,
973
1061
  startWebServer
974
1062
  };