dsh-usage-badge 0.1.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.
package/lib/index.js ADDED
@@ -0,0 +1,543 @@
1
+ /**
2
+ * dsh-usage-badge — host half.
3
+ *
4
+ * Folds every DSH session log into per-day cost totals and serves them to the
5
+ * browser half over a small JSON API. It owns no model-visible surface: nothing
6
+ * here adds a prompt section, a tool, or a session event.
7
+ *
8
+ * Why an HTTP route instead of a Typert remote: a third-party plugin cannot
9
+ * easily ship the generated invocation descriptors strict mode wants, and the
10
+ * Typert source-plane fallback is documented as a development path. Named
11
+ * `ctx.webServer` routes need no codegen and no gateway wiring, which is what
12
+ * keeps this plugin dependency-free. The routes are unauthenticated like the rest
13
+ * of the loopback UI, so the two write paths additionally require a loopback peer.
14
+ *
15
+ * @module dsh-usage-badge
16
+ */
17
+
18
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+
21
+ import { createAggregator, resolveDshHome } from './fold.js'
22
+ import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
23
+ import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
24
+ import {
25
+ DEFAULT_TEMPLATE,
26
+ bucketCost,
27
+ bucketHourlyCosts,
28
+ dayContext,
29
+ dayKey,
30
+ normalizePricing,
31
+ timeOfUseState,
32
+ totalCurrencyOf,
33
+ } from './pricing.js'
34
+
35
+ /** Cordis service this plugin requires; the host half is meaningless without it. */
36
+ export const inject = ['webServer']
37
+
38
+ /** Plugin name as it appears in the loader tree. */
39
+ export const name = 'usage-badge'
40
+
41
+ /** Route prefix owned by this plugin. */
42
+ const ROUTE_PREFIX = '/usage-badge'
43
+
44
+ /**
45
+ * Directory under `$DSH_HOME/storages` holding every file this plugin owns, so its
46
+ * cache and price table are never mistaken for harness data in the shared root.
47
+ */
48
+ const DATA_DIR_NAME = 'usage-badge'
49
+
50
+ /** Calendar days retained in the payload; covers the 12-month view plus slack. */
51
+ const DAYS_KEPT = 400
52
+
53
+ /** A snapshot younger than this is reused instead of rebuilt on every poll. */
54
+ const SNAPSHOT_TTL_MS = 2000
55
+
56
+ /**
57
+ * How long a fetched price list is reused. Opening the dialog asks for a fresh
58
+ * copy every time, and this window only coalesces the requests that arrive within
59
+ * a few seconds of each other so a reopened panel cannot hammer the docs site.
60
+ */
61
+ const OFFICIAL_TTL_MS = 20000
62
+
63
+ /** Largest accepted request body, so a bad client cannot buffer unbounded memory. */
64
+ const MAX_BODY_BYTES = 256 * 1024
65
+
66
+ const log = (message) => console.log(`[dsh-usage-badge] ${message}`)
67
+
68
+ /** Round to a fixed number of decimals. */
69
+ const round = (value, decimals) => {
70
+ const factor = 10 ** decimals
71
+ return Math.round((Number(value) || 0) * factor) / factor
72
+ }
73
+
74
+ /** A fresh 24-slot cost/token series. */
75
+ const newHourSeries = () =>
76
+ Array.from({ length: 24 }, (_, hour) => ({
77
+ hour,
78
+ amount: 0,
79
+ requests: 0,
80
+ input: 0,
81
+ cacheRead: 0,
82
+ cacheWrite: 0,
83
+ output: 0,
84
+ }))
85
+
86
+ /** Add one bucket's token/request totals (and cost) into a row. */
87
+ function addTokens(target, source, amount) {
88
+ target.amount += amount
89
+ target.requests += source.requests || 0
90
+ target.input += source.input || 0
91
+ target.cacheRead += source.cacheRead || 0
92
+ target.cacheWrite += source.cacheWrite || 0
93
+ target.output += source.output || 0
94
+ }
95
+
96
+ /**
97
+ * Fold one day's route buckets into day totals and provider rows.
98
+ *
99
+ * `date` is passed in rather than read off the bucket because the day's cost
100
+ * depends on facts the bucket does not carry: its weekday, and whether the holiday
101
+ * calendar marks it a holiday or a 调休 working day.
102
+ *
103
+ * `withHourly` materializes the 24-hour series for the day *and* for every
104
+ * provider row, which costs one pass over the day's per-request records — so it
105
+ * is requested only for the day being charted, and that same pass supplies the
106
+ * day total instead of costing the buckets twice.
107
+ */
108
+ function summarizeDay(dayObj, pricing, date, withHourly, rules) {
109
+ const day = dayContext(date, rules)
110
+ const providers = new Map()
111
+ const totals = { amount: 0, requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
112
+ const dayHours = withHourly ? newHourSeries() : null
113
+
114
+ for (const bucket of dayObj?.values() ?? []) {
115
+ const providerName = String(bucket.provider ?? '').trim() || 'unknown'
116
+ let row = providers.get(providerName)
117
+ if (!row) {
118
+ row = { provider: providerName, amount: 0, requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
119
+ if (withHourly) row.hourly = newHourSeries()
120
+ providers.set(providerName, row)
121
+ }
122
+
123
+ const costs = dayHours ? bucketHourlyCosts(bucket, pricing, day) : null
124
+ let amount = 0
125
+ if (costs) for (let hour = 0; hour < 24; hour++) amount += costs[hour]
126
+ else amount = bucketCost(bucket, pricing, day)
127
+
128
+ addTokens(row, bucket, amount)
129
+ addTokens(totals, bucket, amount)
130
+
131
+ if (!costs) continue
132
+ for (let hour = 0; hour < 24; hour++) {
133
+ row.hourly[hour].amount += costs[hour]
134
+ dayHours[hour].amount += costs[hour]
135
+ const source = bucket.hourly?.[hour]
136
+ if (!source) continue
137
+ for (const target of [row.hourly[hour], dayHours[hour]]) {
138
+ target.requests += source.requests || 0
139
+ target.input += source.input || 0
140
+ target.cacheRead += source.cacheRead || 0
141
+ target.cacheWrite += source.cacheWrite || 0
142
+ target.output += source.output || 0
143
+ }
144
+ }
145
+ }
146
+
147
+ const roundSeries = (series) => series?.map((slot) => ({ ...slot, amount: round(slot.amount, 4) })) ?? null
148
+
149
+ return {
150
+ totals: {
151
+ amount: round(totals.amount, 4),
152
+ requests: totals.requests,
153
+ input: totals.input,
154
+ cacheRead: totals.cacheRead,
155
+ cacheWrite: totals.cacheWrite,
156
+ output: totals.output,
157
+ },
158
+ providers: [...providers.values()]
159
+ .sort((a, b) => b.amount - a.amount || a.provider.localeCompare(b.provider))
160
+ .map((row) => ({ ...row, amount: round(row.amount, 4), hourly: roundSeries(row.hourly) })),
161
+ hourly: roundSeries(dayHours),
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Recover the `provider|model` route behind a provider row, so a per-model
167
+ * pricing rule can be evaluated for the period indicator. A provider row is
168
+ * already collapsed across models, so that provider's heaviest model stands in
169
+ * for the provider as a whole.
170
+ */
171
+ function dominantRouteOf(dayObj, providerName) {
172
+ let best = null
173
+ for (const bucket of dayObj?.values() ?? []) {
174
+ if ((String(bucket.provider ?? '').trim() || 'unknown') !== providerName) continue
175
+ const weight = bucket.input + bucket.cacheRead + bucket.cacheWrite + bucket.output
176
+ if (!best || weight > best.weight) best = { weight, model: bucket.model ?? null }
177
+ }
178
+ return best?.model ?? null
179
+ }
180
+
181
+ /** Build the payload the browser half renders. */
182
+ function buildSnapshot(aggregator, pricing, rules, status) {
183
+ const now = Date.now()
184
+ const today = dayKey(now)
185
+ const dayObjs = aggregator.days
186
+ const dates = [...dayObjs.keys()].sort().reverse().slice(0, DAYS_KEPT)
187
+
188
+ const todayObj = dayObjs.get(today)
189
+ const todaySummary = summarizeDay(todayObj, pricing, today, true, rules)
190
+
191
+ const days = dates.map((date) => {
192
+ // The day's own classification travels with its row: the panel marks holidays
193
+ // on the chart, and it is the only way to read a past day's holiday status
194
+ // back from a snapshot.
195
+ const context = dayContext(date, rules)
196
+ const marks = { dayClass: context.class, dayName: context.name }
197
+ if (date === today) return { date, ...todaySummary.totals, providers: todaySummary.providers, ...marks }
198
+ const summary = summarizeDay(dayObjs.get(date), pricing, date, false, rules)
199
+ return { date, ...summary.totals, providers: summary.providers, ...marks }
200
+ })
201
+
202
+ // The badge is always CNY: a CNY total needs no rate, a USD total is converted
203
+ // with the live exchange rate.
204
+ const totalCurrency = totalCurrencyOf(pricing)
205
+ const badgeCny =
206
+ totalCurrency === 'cny'
207
+ ? todaySummary.totals.amount
208
+ : todaySummary.totals.amount * (Number(pricing.exchangeRate) || 1)
209
+
210
+ // Period indicator: today's dominant route's rule, so a per-model override is
211
+ // reflected rather than only the global default row.
212
+ const dominant = todaySummary.providers[0]
213
+ const band = timeOfUseState(
214
+ pricing,
215
+ dominant ? dominant.provider : null,
216
+ dominant ? dominantRouteOf(todayObj, dominant.provider) : null,
217
+ dayContext(today, rules),
218
+ )
219
+
220
+ return {
221
+ generatedAt: now,
222
+ currency: totalCurrency,
223
+ exchangeRate: pricing.exchangeRate,
224
+ badge: { date: today, amount: round(badgeCny, 2), currency: 'cny' },
225
+ today: { date: today, ...todaySummary.totals, hourly: todaySummary.hourly, providers: todaySummary.providers },
226
+ days,
227
+ band,
228
+ calendar: describeCalendar(rules, now),
229
+ status: { ...status, sessions: aggregator.sessionCount },
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Read a pricing document, preferring the plugin's own directory.
235
+ *
236
+ * `paths` is ordered: the canonical file first, then the pre-isolation location in
237
+ * the shared storages root. The fallback exists so an install that predates the
238
+ * move keeps working, and so a harness-side `usage-pricing.json` is still honored
239
+ * if one reappears — the first write migrates it into the plugin's directory.
240
+ *
241
+ * @returns {{raw:object, path:string|null}} the parsed document and where it came
242
+ * from; `path` is null when neither file exists or parses.
243
+ */
244
+ function readRawPricing(paths) {
245
+ for (const path of paths) {
246
+ try {
247
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
248
+ if (raw && typeof raw === 'object') return { raw, path }
249
+ } catch {
250
+ // Missing or corrupt: fall through to the next candidate.
251
+ }
252
+ }
253
+ return { raw: {}, path: null }
254
+ }
255
+
256
+ /** Normalize a read result, falling back to the template when nothing was found. */
257
+ function readPricing(paths) {
258
+ return normalizePricing(readRawPricing(paths).raw)
259
+ }
260
+
261
+ /** Read a request body, bounded. */
262
+ function readBody(req) {
263
+ return new Promise((resolvePromise, rejectPromise) => {
264
+ const chunks = []
265
+ let size = 0
266
+ req.on('data', (chunk) => {
267
+ size += chunk.length
268
+ if (size > MAX_BODY_BYTES) {
269
+ rejectPromise(new Error('request body too large'))
270
+ req.destroy()
271
+ return
272
+ }
273
+ chunks.push(chunk)
274
+ })
275
+ req.on('end', () => resolvePromise(Buffer.concat(chunks).toString('utf8')))
276
+ req.on('error', rejectPromise)
277
+ })
278
+ }
279
+
280
+ function sendJson(res, status, payload) {
281
+ const body = Buffer.from(JSON.stringify(payload), 'utf8')
282
+ res.writeHead(status, {
283
+ 'content-type': 'application/json; charset=utf-8',
284
+ 'content-length': String(body.length),
285
+ 'cache-control': 'no-store',
286
+ })
287
+ res.end(body)
288
+ }
289
+
290
+ /** Whether a socket belongs to this machine, so a LAN peer cannot rewrite config. */
291
+ function isLoopback(req) {
292
+ const address = req.socket?.remoteAddress ?? ''
293
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
294
+ }
295
+
296
+ /** Atomically replace a file with `text`. */
297
+ function writeFileAtomic(path, text) {
298
+ const temp = `${path}.tmp-${process.pid}-${Date.now()}`
299
+ writeFileSync(temp, text)
300
+ try {
301
+ renameSync(temp, path)
302
+ } catch {
303
+ // Windows cannot rename over an existing file; the complete temp file is
304
+ // already on disk, so removing the old one first loses nothing.
305
+ try {
306
+ unlinkSync(path)
307
+ } catch {
308
+ // absent is fine
309
+ }
310
+ renameSync(temp, path)
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Merge an accepted config patch into the raw user document. Only the fields this
316
+ * plugin prices with are touched, and only when the client sent them, so keys it
317
+ * does not understand survive the round trip.
318
+ */
319
+ function mergeConfig(raw, patch) {
320
+ const next = raw && typeof raw === 'object' ? { ...raw } : {}
321
+ if (patch.exchangeRate !== undefined) next.exchangeRate = Number(patch.exchangeRate) || next.exchangeRate
322
+ if (patch.totalCurrency !== undefined) next.totalCurrency = patch.totalCurrency === 'usd' ? 'usd' : 'cny'
323
+ if (patch.multiplier !== undefined) next.multiplier = Number(patch.multiplier) || 1
324
+ if (patch.default && typeof patch.default === 'object') next.default = { ...(next.default ?? {}), ...patch.default }
325
+ if (patch.overrides && typeof patch.overrides === 'object') next.overrides = patch.overrides
326
+ if (patch.timeOfUse !== undefined) next.timeOfUse = patch.timeOfUse
327
+ if (patch.contextMultiplier !== undefined) next.contextMultiplier = patch.contextMultiplier
328
+ if (patch.holidays !== undefined) next.holidays = patch.holidays
329
+ return next
330
+ }
331
+
332
+ /**
333
+ * Host plugin body.
334
+ *
335
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context carrying `webServer`.
336
+ */
337
+ export function apply(ctx) {
338
+ const home = resolveDshHome()
339
+ const sessionsRoot = join(home, 'sessions')
340
+ // Everything this plugin owns lives in one directory of its own. Writing into
341
+ // the shared storages root would leave files a user cannot attribute: the root
342
+ // already holds harness caches and, on some installs, other tools' settings.
343
+ const dataDir = join(home, 'storages', DATA_DIR_NAME)
344
+ const pricingPath = join(dataDir, 'pricing.json')
345
+ const cachePath = join(dataDir, 'cache.json')
346
+ /** Pre-isolation location of the price table, honored as a migration fallback. */
347
+ const legacyPricingPath = join(home, 'storages', 'usage-pricing.json')
348
+ const pricingPaths = [pricingPath, legacyPricingPath]
349
+
350
+ const aggregator = createAggregator({ sessionsRoot, cachePath, log })
351
+ aggregator.loadCache()
352
+
353
+ const { path: pricingSourcePath } = readRawPricing(pricingPaths)
354
+ if (pricingSourcePath === legacyPricingPath) {
355
+ log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
356
+ }
357
+ let pricing = readPricing(pricingPaths)
358
+ let rules = resolveHolidayRules(pricing.holidays)
359
+ let snapshot = null
360
+ let inFlight = null
361
+ let official = null
362
+
363
+ /** Serialize concurrent refreshes so a burst of polls folds the logs once. */
364
+ function refreshOnce() {
365
+ if (!inFlight) {
366
+ inFlight = (async () => {
367
+ const status = await aggregator.refresh()
368
+ // Re-read pricing every pass so an edit applies without a restart.
369
+ pricing = readPricing(pricingPaths)
370
+ rules = resolveHolidayRules(pricing.holidays)
371
+ return status
372
+ })().finally(() => {
373
+ inFlight = null
374
+ })
375
+ }
376
+ return inFlight
377
+ }
378
+
379
+ async function getSnapshot() {
380
+ const status = await refreshOnce()
381
+ // Nothing changed and the previous payload is still fresh: reuse it rather
382
+ // than re-costing every day on each poll.
383
+ if (snapshot && status.folded === 0 && Date.now() - snapshot.generatedAt < SNAPSHOT_TTL_MS) return snapshot
384
+ snapshot = buildSnapshot(aggregator, pricing, rules, status)
385
+ return snapshot
386
+ }
387
+
388
+ /** Fetch a price list, reusing a very recent one so a reopen cannot hammer the site. */
389
+ async function officialPricing(source, { refresh = false } = {}) {
390
+ const key = OFFICIAL_SOURCES[source] ? source : 'zh-cn'
391
+ if (!refresh && official && official.key === key && Date.now() - official.at < OFFICIAL_TTL_MS) return official.value
392
+ const value = await fetchOfficialPricing({ source: key })
393
+ official = { key, at: Date.now(), value }
394
+ return value
395
+ }
396
+
397
+ /** Write a pricing document through and refresh everything derived from it. */
398
+ function commitPricing(next) {
399
+ mkdirSync(dataDir, { recursive: true })
400
+ writeFileAtomic(pricingPath, JSON.stringify(next, null, 2) + '\n')
401
+ pricing = normalizePricing(next)
402
+ rules = resolveHolidayRules(pricing.holidays)
403
+ snapshot = null // force a rebuild on the next read
404
+ }
405
+
406
+ async function warm() {
407
+ try {
408
+ const status = await refreshOnce()
409
+ // Persist straight away rather than waiting for the debounce: the initial
410
+ // fold is the expensive one, and its timer is unref'd, so a short-lived
411
+ // host would otherwise pay the full cold scan on every start.
412
+ if (status.folded > 0) aggregator.saveCache()
413
+ log(
414
+ `ready: ${aggregator.sessionCount} session(s) over ${status.files} log(s), folded ${status.folded} in ${status.ms}ms` +
415
+ (rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
416
+ )
417
+ if (rules.missingYears?.length) {
418
+ log(`holiday calendar has no data for ${rules.missingYears.join(', ')} — those days are treated as ordinary weekdays`)
419
+ }
420
+ } catch (error) {
421
+ log(`initial fold failed: ${error?.message ?? error}`)
422
+ }
423
+ }
424
+
425
+ async function handle(req, res) {
426
+ const url = new URL(req.url ?? '/', 'http://localhost')
427
+ const route = url.pathname.slice(ROUTE_PREFIX.length) || '/'
428
+
429
+ try {
430
+ if (route === '/summary' && (req.method === 'GET' || req.method === 'HEAD')) {
431
+ sendJson(res, 200, await getSnapshot())
432
+ return
433
+ }
434
+
435
+ if (route === '/config' && req.method === 'GET') {
436
+ const calendar = describeCalendar(rules)
437
+ // The bundled table is reported separately from the resolved rules: a
438
+ // deployment with the calendar off still needs to see what data is
439
+ // available before deciding to switch it on.
440
+ const bundled = loadBundledTable()
441
+ sendJson(res, 200, {
442
+ effective: pricing,
443
+ template: DEFAULT_TEMPLATE,
444
+ calendar,
445
+ bundled: {
446
+ years: [...bundled.years].sort(),
447
+ holidayCount: bundled.holidays.size,
448
+ workdayCount: bundled.workdays.size,
449
+ source: bundled.source,
450
+ note: bundled.note,
451
+ },
452
+ holidayTable: {
453
+ years: [...bundled.years].sort(),
454
+ holidays: [...rules.holidays.keys()].sort(),
455
+ // Recorded for audit only: the published policy makes every weekend
456
+ // off-peak, so a 调休 working day does not change any price.
457
+ workdays: [...bundled.workdays].sort(),
458
+ source: rules.source,
459
+ },
460
+ paths: {
461
+ dataDir,
462
+ pricing: pricingPath,
463
+ cache: cachePath,
464
+ legacyPricing: legacyPricingPath,
465
+ // Which file the effective table was actually read from; null means
466
+ // neither exists, so the built-in template is in force.
467
+ pricingSource: readRawPricing(pricingPaths).path,
468
+ },
469
+ })
470
+ return
471
+ }
472
+
473
+ if (route === '/config' && (req.method === 'PUT' || req.method === 'POST')) {
474
+ if (!isLoopback(req)) {
475
+ sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
476
+ return
477
+ }
478
+ const patch = JSON.parse((await readBody(req)) || '{}')
479
+ commitPricing(mergeConfig(readRawPricing(pricingPaths).raw, patch))
480
+ log('pricing config updated')
481
+ sendJson(res, 200, { ok: true, effective: pricing, calendar: describeCalendar(rules) })
482
+ return
483
+ }
484
+
485
+ // The published price list, parsed. `refresh=1` is what "fetch it live when
486
+ // the panel opens" sends; without it a very recent result is reused.
487
+ if (route === '/official-pricing' && req.method === 'GET') {
488
+ const source = url.searchParams.get('source') ?? 'zh-cn'
489
+ const parsed = await officialPricing(source, { refresh: url.searchParams.get('refresh') === '1' })
490
+ sendJson(res, 200, { ...parsed, sources: OFFICIAL_SOURCES })
491
+ return
492
+ }
493
+
494
+ if (route === '/official-pricing/apply' && (req.method === 'PUT' || req.method === 'POST')) {
495
+ if (!isLoopback(req)) {
496
+ sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
497
+ return
498
+ }
499
+ const body = JSON.parse((await readBody(req)) || '{}')
500
+ // Apply the copy the user was looking at, reusing the recent fetch rather
501
+ // than pulling a possibly different page under them.
502
+ const parsed = await officialPricing(body.source ?? 'zh-cn')
503
+ const base = readRawPricing(pricingPaths)
504
+ const { document, applied } = applyOfficialPricing(base.raw, parsed)
505
+ // Report the shape change: applying the official list must only touch the
506
+ // rows it names, so a shrinking override set means the base document was not
507
+ // read and the user's own rows are about to be lost. That is worth a loud
508
+ // line rather than a silent overwrite.
509
+ const before = Object.keys(base.raw?.overrides ?? {}).length
510
+ const after = Object.keys(document.overrides ?? {}).length
511
+ const warning =
512
+ after < before
513
+ ? ` WARNING: override rows dropped ${before} -> ${after}; base read from ${base.path ?? 'no file (defaults)'}`
514
+ : ''
515
+ commitPricing(document)
516
+ log(
517
+ `official prices applied: ${applied.models.join(', ')}${applied.aliases.length ? ` (+ ${applied.aliases.join(', ')})` : ''};` +
518
+ ` override rows ${before} -> ${after}` +
519
+ warning,
520
+ )
521
+ sendJson(res, 200, { ok: true, applied, effective: pricing, calendar: describeCalendar(rules) })
522
+ return
523
+ }
524
+
525
+ sendJson(res, 404, { error: `no such usage-badge route: ${route}` })
526
+ } catch (error) {
527
+ log(`request failed: ${error?.message ?? error}`)
528
+ sendJson(res, 500, { error: String(error?.message ?? error) })
529
+ }
530
+ }
531
+
532
+ ctx.effect(
533
+ () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: handle }),
534
+ 'usage-badge: summary, config and official-pricing routes',
535
+ )
536
+
537
+ ctx.effect(() => {
538
+ void warm()
539
+ return () => aggregator.saveCache()
540
+ }, 'usage-badge: warm the fold and persist the cache on unload')
541
+
542
+ log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
543
+ }