dsh-simple-usage-info 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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Chinese public holiday source.
3
+ *
4
+ * DeepSeek's peak window excludes Chinese public holidays, and the statutory
5
+ * arrangement is republished every November and is not derivable — the observed
6
+ * days move with the lunar calendar and the annual 调休 adjustments. So instead
7
+ * of maintaining a table by hand, this fetches one that is already maintained:
8
+ *
9
+ * https://github.com/NateScarlet/holiday-cn
10
+ *
11
+ * That repository scrapes the State Council announcements daily in CI and
12
+ * publishes one JSON file per year:
13
+ *
14
+ * { "year": 2026, "papers": ["https://www.gov.cn/..."],
15
+ * "days": [ { "name": "元旦", "date": "2026-01-01", "isOffDay": true }, ... ] }
16
+ *
17
+ * `isOffDay: true` is a statutory day off; `isOffDay: false` is a 调休 makeup
18
+ * workday. `lib/holidays.js` stays as an offline fallback for when the fetch
19
+ * fails. The fetcher is injected so it can be exercised without a network.
20
+ *
21
+ * @module dsh-simple-usage-info/holiday-source
22
+ */
23
+
24
+ /** Where the maintained data lives; `{year}` is substituted. */
25
+ export const DEFAULT_HOLIDAY_URL = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json'
26
+
27
+ /** How long one successful snapshot stays fresh. */
28
+ export const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000
29
+
30
+ /** Remote request timeout. */
31
+ export const DEFAULT_TIMEOUT_MS = 10000
32
+
33
+ /** Sorted, de-duplicated union. */
34
+ function union(lists) {
35
+ return [...new Set(lists.flat())].sort()
36
+ }
37
+
38
+ /**
39
+ * Read one year's document.
40
+ * @param options - the injected fetch, template, timeout, and clock.
41
+ * @param year - the calendar year to read.
42
+ * @returns the year's days, or `undefined` when the notice is not published yet.
43
+ * @throws Error when the request fails or the body is unusable.
44
+ */
45
+ async function loadYear(options, year) {
46
+ const url = options.urlTemplate.replace('{year}', String(year))
47
+ const response = await options.fetchImpl(url, {
48
+ headers: { accept: 'application/json' },
49
+ signal: AbortSignal.timeout(options.timeoutMs)
50
+ })
51
+ if (response.ok !== true) throw new Error(`${url} answered HTTP ${response.status}`)
52
+ const body = await response.json()
53
+ const days = Array.isArray(body?.days) ? body.days : []
54
+ // holiday-cn publishes an empty placeholder before the State Council notice
55
+ // lands, and an empty `days` means "not announced", never "no holidays".
56
+ if (days.length === 0) return undefined
57
+ return {
58
+ year: Number(body.year ?? year),
59
+ holidays: days.filter((day) => day.isOffDay === true).map((day) => String(day.date)),
60
+ makeupWorkdays: days.filter((day) => day.isOffDay !== true).map((day) => String(day.date)),
61
+ papers: Array.isArray(body.papers) ? body.papers.map(String) : []
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Build a lazily-refreshed holiday source.
67
+ *
68
+ * `refresh()` never rejects: a failed year is recorded and the previous snapshot
69
+ * is kept, so the caller can always render something.
70
+ * @param options - configuration and injectable seams.
71
+ * @returns the source handle.
72
+ */
73
+ export function createHolidaySource(options) {
74
+ const settings = {
75
+ urlTemplate: options?.urlTemplate ?? DEFAULT_HOLIDAY_URL,
76
+ ttlMs: options?.ttlMs ?? DEFAULT_TTL_MS,
77
+ timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
78
+ fetchImpl: options?.fetchImpl ?? globalThis.fetch,
79
+ now: options?.now ?? Date.now,
80
+ log: options?.log ?? (() => {})
81
+ }
82
+
83
+ /** The last successful snapshot, if any. */
84
+ let snapshot
85
+ /** The in-flight refresh, so concurrent callers share one round trip. */
86
+ let inflight = null
87
+ /** When the last refresh finished, successful or not. */
88
+ let attemptedAt = 0
89
+
90
+ /** Whether a remote source is configured at all. */
91
+ const enabled = () => settings.urlTemplate !== '' && typeof settings.fetchImpl === 'function'
92
+
93
+ /**
94
+ * Whether a refresh is worth starting for these years.
95
+ * @param years - the years the caller needs covered.
96
+ * @returns true when the snapshot is missing, stale, or short of a year.
97
+ */
98
+ function stale(years) {
99
+ if (!enabled()) return false
100
+ if (snapshot === undefined) return true
101
+ if (settings.now() - attemptedAt > settings.ttlMs) return true
102
+ return years.some((year) => !snapshot.years.includes(year))
103
+ }
104
+
105
+ /**
106
+ * Fetch every requested year and publish one merged snapshot. A disabled
107
+ * source resolves to the current snapshot without touching the network, so a
108
+ * caller that forgets to check {@link stale} still cannot fetch.
109
+ * @param years - the years to cover.
110
+ * @returns the snapshot after the attempt (unchanged when every year failed).
111
+ */
112
+ function refresh(years) {
113
+ if (!enabled()) return Promise.resolve(snapshot)
114
+ if (inflight !== null) return inflight
115
+ inflight = (async () => {
116
+ const loaded = []
117
+ const failures = []
118
+ for (const year of years) {
119
+ try {
120
+ const one = await loadYear(settings, year)
121
+ if (one !== undefined) loaded.push(one)
122
+ } catch (error) {
123
+ failures.push(`${year}: ${String(error?.message ?? error)}`)
124
+ }
125
+ }
126
+ attemptedAt = settings.now()
127
+ if (loaded.length > 0) {
128
+ snapshot = {
129
+ holidays: union(loaded.map((one) => one.holidays)),
130
+ makeupWorkdays: union(loaded.map((one) => one.makeupWorkdays)),
131
+ years: [...new Set(loaded.map((one) => one.year))].sort((left, right) => left - right),
132
+ papers: union(loaded.map((one) => one.papers)),
133
+ fetchedAt: settings.now()
134
+ }
135
+ }
136
+ if (failures.length > 0) {
137
+ settings.log(
138
+ `usage-info: holiday refresh incomplete (${failures.join('; ')})` +
139
+ (snapshot === undefined ? '; falling back to the bundled table' : '; keeping the last snapshot')
140
+ )
141
+ }
142
+ inflight = null
143
+ return snapshot
144
+ })()
145
+ return inflight
146
+ }
147
+
148
+ return {
149
+ /** The current snapshot, or undefined before the first success. */
150
+ peek: () => snapshot,
151
+ stale,
152
+ refresh
153
+ }
154
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * dsh-simple-usage-info — bundled Chinese public holiday table types.
3
+ * @module dsh-simple-usage-info/holidays
4
+ */
5
+
6
+ /** Every calendar year the bundled table covers. */
7
+ export declare const COVERED_YEARS: readonly number[]
8
+
9
+ /** Every announced public holiday date across the covered years, sorted. */
10
+ export declare function bundledHolidays(): string[]
11
+
12
+ /** Every announced 调休 makeup workday across the covered years, sorted. */
13
+ export declare function bundledMakeupWorkdays(): string[]
14
+
15
+ /**
16
+ * The State Council citation for one year's arrangement.
17
+ * @returns the notice reference, or undefined when the year is not covered.
18
+ */
19
+ export declare function noticeFor(year: number): string | undefined
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Chinese public holidays — the offline fallback table.
3
+ *
4
+ * The primary source is fetched at runtime from a list that is already
5
+ * maintained (`lib/holiday-source.js` → NateScarlet/holiday-cn). This table is
6
+ * what the plugin uses when that fetch has never succeeded, so an offline host
7
+ * still prices holidays correctly instead of silently treating them as ordinary
8
+ * weekdays.
9
+ *
10
+ * The dates are Chinese calendar days; because every peak window (01:00–10:00
11
+ * UTC) sits inside one Chinese day (09:00–18:00 CST), looking them up by UTC
12
+ * date is equivalent.
13
+ *
14
+ * The 2026 entry below is the State Council announcement (Guo Ban Fa Ming Dian
15
+ * [2025] No. 7), verified date-for-date against holiday-cn's 2026.json: 33 days
16
+ * off and 6 makeup workdays, identical.
17
+ *
18
+ * Being a fallback rather than the source of truth, it needs no annual
19
+ * maintenance — a year it does not cover simply reports itself as uncovered when
20
+ * the fetch is also unavailable.
21
+ *
22
+ * @module dsh-simple-usage-info/holidays
23
+ */
24
+
25
+ /**
26
+ * One year's announced arrangement.
27
+ * `ranges` are inclusive `YYYY-MM-DD` spans of days off; `makeupWorkdays` are
28
+ * weekend days the notice designates as working days.
29
+ */
30
+ const NOTICES = {
31
+ 2026: {
32
+ notice: 'Guo Ban Fa Ming Dian [2025] No. 7 (4 November 2025)',
33
+ ranges: [
34
+ // New Year's Day, 3 days.
35
+ ['2026-01-01', '2026-01-03'],
36
+ // Spring Festival, 9 days.
37
+ ['2026-02-15', '2026-02-23'],
38
+ // Qingming Festival, 3 days.
39
+ ['2026-04-04', '2026-04-06'],
40
+ // Labor Day, 5 days.
41
+ ['2026-05-01', '2026-05-05'],
42
+ // Dragon Boat Festival, 3 days.
43
+ ['2026-06-19', '2026-06-21'],
44
+ // Mid-Autumn Festival, 3 days.
45
+ ['2026-09-25', '2026-09-27'],
46
+ // National Day, 7 days.
47
+ ['2026-10-01', '2026-10-07']
48
+ ],
49
+ makeupWorkdays: [
50
+ '2026-01-04', // Sunday, for New Year's Day
51
+ '2026-02-14', // Saturday, for Spring Festival
52
+ '2026-02-28', // Saturday, for Spring Festival
53
+ '2026-05-09', // Saturday, for Labor Day
54
+ '2026-09-20', // Sunday, for National Day
55
+ '2026-10-10' // Saturday, for National Day
56
+ ]
57
+ }
58
+ }
59
+
60
+ /** Every calendar year this table covers. */
61
+ export const COVERED_YEARS = Object.keys(NOTICES).map(Number).sort()
62
+
63
+ /** `YYYY-MM-DD` for a UTC date. */
64
+ function dayKey(year, month, day) {
65
+ const pad = (value) => String(value).padStart(2, '0')
66
+ return `${year}-${pad(month + 1)}-${pad(day)}`
67
+ }
68
+
69
+ /**
70
+ * Expand one inclusive `YYYY-MM-DD`..`YYYY-MM-DD` span.
71
+ * @param from - first day off.
72
+ * @param to - last day off.
73
+ * @returns every date in the span.
74
+ */
75
+ function expandRange(from, to) {
76
+ const start = Date.parse(`${from}T00:00:00Z`)
77
+ const end = Date.parse(`${to}T00:00:00Z`)
78
+ const dates = []
79
+ for (let at = start; at <= end; at += 86400000) {
80
+ const date = new Date(at)
81
+ dates.push(dayKey(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
82
+ }
83
+ return dates
84
+ }
85
+
86
+ /**
87
+ * Every announced public holiday date across the covered years.
88
+ * @returns a sorted list of `YYYY-MM-DD`.
89
+ */
90
+ export function bundledHolidays() {
91
+ return COVERED_YEARS.flatMap((year) => NOTICES[year].ranges.flatMap(([from, to]) => expandRange(from, to))).sort()
92
+ }
93
+
94
+ /**
95
+ * Every announced makeup workday across the covered years.
96
+ * @returns a sorted list of `YYYY-MM-DD`.
97
+ */
98
+ export function bundledMakeupWorkdays() {
99
+ return COVERED_YEARS.flatMap((year) => [...NOTICES[year].makeupWorkdays]).sort()
100
+ }
101
+
102
+ /**
103
+ * The citation for one year's arrangement.
104
+ * @param year - calendar year.
105
+ * @returns the notice reference, or undefined when uncovered.
106
+ */
107
+ export function noticeFor(year) {
108
+ return NOTICES[year]?.notice
109
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * dsh-simple-usage-info — host (Node) half type surface.
3
+ * @module dsh-simple-usage-info
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis'
6
+
7
+ /** Cordis plugin name. */
8
+ export declare const name = 'dsh-simple-usage-info'
9
+ /** Host services required before the balance route can register. */
10
+ export declare const inject: string[]
11
+ /** The one route path this plugin owns, shared with the browser half. */
12
+ export declare const BALANCE_PATH = '/usage/balance'
13
+ /** Plugin configuration schema. */
14
+ export declare const Config: unknown
15
+
16
+ /** One currency's balance, exactly as the DeepSeek API reports it. */
17
+ export interface DeepSeekBalanceInfo {
18
+ readonly currency: 'CNY' | 'USD'
19
+ readonly total_balance: string
20
+ readonly granted_balance: string
21
+ readonly topped_up_balance: string
22
+ }
23
+
24
+ /** The DeepSeek `GET /user/balance` document. */
25
+ export interface DeepSeekBalance {
26
+ readonly is_available: boolean
27
+ readonly balance_infos: readonly DeepSeekBalanceInfo[]
28
+ }
29
+
30
+ /** What `GET /usage/balance` answers. Both branches carry the pricing block. */
31
+ export type BalancePayload =
32
+ | {
33
+ readonly ok: true
34
+ readonly balance: DeepSeekBalance
35
+ readonly pricing: import('./pricing.js').PricingReading
36
+ readonly fetchedAt: number
37
+ }
38
+ | {
39
+ readonly ok: false
40
+ readonly error: { readonly code: string; readonly message: string }
41
+ readonly pricing: import('./pricing.js').PricingReading
42
+ readonly fetchedAt: number
43
+ }
44
+
45
+ /** Host plugin body: one cached, read-only balance route. */
46
+ export declare function apply(ctx: Context, config: unknown): void
package/lib/index.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * dsh-simple-usage-info — host (Node) half.
3
+ *
4
+ * Registers one exact read-only HTTP route on the Web GUI's server. The route
5
+ * resolves the same credential the shipped `deepseek-official` model route uses
6
+ * (`DEEPSEEK_API_KEY`, through the credentials service) and asks the public
7
+ * DeepSeek balance API for the account balance, then answers the browser half
8
+ * with plain JSON. The key never leaves this process.
9
+ *
10
+ * Route: GET /usage/balance
11
+ * 200 { ok: true, balance: { is_available, balance_infos: [...] }, pricing, fetchedAt }
12
+ * 200 { ok: false, error: { code, message }, pricing, fetchedAt }
13
+ * 403 when the request's Host header is not a loopback host
14
+ * 405 for any other method.
15
+ *
16
+ * `pricing` is computed fresh on every request from the server clock; only the
17
+ * upstream balance call is cached.
18
+ *
19
+ * The route is registered directly on the WebServer, so it sits outside the
20
+ * Web GUI's `/api` authentication fence. It answers loopback hosts only (and
21
+ * anything when the operator deliberately binds `0.0.0.0`), which blocks a
22
+ * malicious page from reading the balance through DNS rebinding, and it never
23
+ * returns key material — only the numbers the DeepSeek API reports.
24
+ *
25
+ * The route is deliberately cache-backed: the browser half polls it, and
26
+ * `refreshMs` bounds how often the upstream API is actually called.
27
+ *
28
+ * @module dsh-simple-usage-info
29
+ */
30
+ import z from '@deepseek-ai/schemastery'
31
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
32
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
33
+ import { normalizeSchedule, readPricing, withHolidayData } from './pricing.js'
34
+ import { createHolidaySource, DEFAULT_HOLIDAY_URL } from './holiday-source.js'
35
+
36
+ /** Cordis plugin name. */
37
+ export const name = 'dsh-simple-usage-info'
38
+
39
+ /** The WebServer must exist before the balance route can register. */
40
+ export const inject = ['webServer']
41
+
42
+ /**
43
+ * The one route path this plugin owns. The browser half hard-codes the same
44
+ * constant, so it is exported rather than configurable.
45
+ */
46
+ export const BALANCE_PATH = '/usage/balance'
47
+
48
+ /** Plugin configuration, validated by Schemastery. */
49
+ export const Config = z.object({
50
+ /** DeepSeek API origin; the balance endpoint lives at its root. */
51
+ baseURL: z.string().default('https://api.deepseek.com'),
52
+ /** Credential-reference name holding the API key. */
53
+ apiKeyEnv: z.string().role('credential-ref').default('DEEPSEEK_API_KEY'),
54
+ /** How long one upstream balance answer stays fresh. */
55
+ refreshMs: z.number().default(60000),
56
+ /** Upstream request timeout. */
57
+ timeoutMs: z.number().default(15000),
58
+ /** Peak-rate windows as `HH:MM-HH:MM` UTC; everything else is off-peak. */
59
+ peakWindows: z.array(z.string()).default(['01:00-04:00', '06:00-10:00']),
60
+ /** Peak weekdays, 0 = Sunday through 6 = Saturday. */
61
+ peakDays: z.array(z.number()).default([1, 2, 3, 4, 5]),
62
+ /** Extra `YYYY-MM-DD` UTC dates priced off-peak in full, on top of the fetched holidays. */
63
+ holidays: z.array(z.string()).default([]),
64
+ /** Extra `YYYY-MM-DD` UTC makeup workdays; only meaningful with `makeupWorkdaysArePeak`. */
65
+ makeupWorkdays: z.array(z.string()).default([]),
66
+ /**
67
+ * Treat 调休 makeup workdays as peak days. The published rule says "Monday
68
+ * through Friday", which makes them off-peak, so this defaults off.
69
+ */
70
+ makeupWorkdaysArePeak: z.boolean().default(false),
71
+ /**
72
+ * Where to read the maintained Chinese holiday data; `{year}` is substituted.
73
+ * Leave the URL empty to stay offline and use the bundled table.
74
+ */
75
+ holidayURL: z.string().default(DEFAULT_HOLIDAY_URL),
76
+ /** How long one fetched holiday snapshot stays fresh. */
77
+ holidayRefreshMs: z.number().default(43200000),
78
+ /** Merge the bundled State Council holiday table as a fallback; false leaves only `holidays`. */
79
+ useBundledHolidays: z.boolean().default(true),
80
+ /** Off-peak discount, as a percentage of the peak rate. */
81
+ offPeakDiscountPercent: z.number().default(50)
82
+ })
83
+
84
+ /** A forced refresh may never hit the network more often than this. */
85
+ const MIN_REFRESH_MS = 5000
86
+
87
+ /** Hostnames that can only mean the local machine. */
88
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
89
+
90
+ /**
91
+ * Whether a request's `Host` header is one this route answers.
92
+ *
93
+ * The route lives outside the GUI's `/api` auth fence, so without this check a
94
+ * malicious page could use DNS rebinding to read the balance from
95
+ * `http://127.0.0.1:<port>/usage/balance`. A deliberate `0.0.0.0` bind is the
96
+ * operator's stated intent to expose the server, so that posture answers any
97
+ * host.
98
+ * @param hostHeader - the raw `Host` header.
99
+ * @param bindHost - the WebServer's configured bind host.
100
+ * @returns true when the request may be answered.
101
+ */
102
+ function hostAllowed(hostHeader, bindHost) {
103
+ if (bindHost === '0.0.0.0') return true
104
+ if (typeof hostHeader !== 'string' || hostHeader === '') return false
105
+ const closing = hostHeader.startsWith('[') ? hostHeader.indexOf(']') : -1
106
+ const hostname = closing >= 0 ? hostHeader.slice(0, closing + 1) : hostHeader.split(':', 1)[0]
107
+ return LOOPBACK_HOSTS.has(hostname.toLowerCase())
108
+ }
109
+
110
+ /** Write one JSON response. `no-store` keeps browsers from caching a balance. */
111
+ function sendJson(res, status, body) {
112
+ res.statusCode = status
113
+ res.setHeader('content-type', 'application/json; charset=utf-8')
114
+ res.setHeader('cache-control', 'no-store')
115
+ res.end(JSON.stringify(body))
116
+ }
117
+
118
+ /** An error carrying a stable machine-readable code for the browser half. */
119
+ function taggedError(code, message) {
120
+ const error = new Error(message)
121
+ error.code = code
122
+ return error
123
+ }
124
+
125
+ /**
126
+ * Resolve the API key exactly the way `@deepseek-ai/dsh-llm-deepseek-api-key`
127
+ * does: the credentials seam first (which itself layers the launch environment),
128
+ * then the raw launch environment when no seam is mounted.
129
+ * @param ctx - host plugin context.
130
+ * @param config - validated plugin configuration.
131
+ * @returns the secret key value.
132
+ */
133
+ async function resolveApiKey(ctx, config) {
134
+ const ref = credentialRef(config.apiKeyEnv)
135
+ const credentials = ctx.get('credentials')
136
+ if (credentials !== undefined) {
137
+ const hit = await credentials.resolve(ref)
138
+ if (hit !== undefined && hit.value.length > 0) return hit.value
139
+ }
140
+ const ambient = launchEnvironmentOf(ctx).get(ref)
141
+ if (ambient !== undefined && ambient.value.length > 0) return ambient.value
142
+ throw taggedError(
143
+ 'missing-credential',
144
+ `no API key behind ${String(ref)}; store it through the credentials service or export it before launching dsh`
145
+ )
146
+ }
147
+
148
+ /**
149
+ * Read the account balance from the public DeepSeek API.
150
+ * @param ctx - host plugin context.
151
+ * @param config - validated plugin configuration.
152
+ * @returns the parsed balance document.
153
+ */
154
+ async function fetchBalance(ctx, config) {
155
+ const key = await resolveApiKey(ctx, config)
156
+ const url = `${config.baseURL.replace(/\/+$/, '')}/user/balance`
157
+ const response = await fetch(url, {
158
+ method: 'GET',
159
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
160
+ signal: AbortSignal.timeout(config.timeoutMs)
161
+ })
162
+ if (!response.ok) {
163
+ const detail = await response.text().catch(() => '')
164
+ const code = response.status === 401 || response.status === 403 ? 'invalid-credential' : 'upstream-error'
165
+ throw taggedError(code, `DeepSeek answered HTTP ${response.status}${detail === '' ? '' : ` — ${detail.slice(0, 200)}`}`)
166
+ }
167
+ return await response.json()
168
+ }
169
+
170
+ /**
171
+ * Host plugin body: one cached, read-only balance route.
172
+ * @param ctx - host plugin context.
173
+ * @param config - validated plugin configuration.
174
+ */
175
+ export function apply(ctx, config) {
176
+ // A malformed schedule is a configuration error, so fail here rather than
177
+ // answering every request with a broken pricing block.
178
+ const schedule = normalizeSchedule(config)
179
+ /** The maintained holiday data, refreshed in the background. */
180
+ const holidays = createHolidaySource({
181
+ urlTemplate: config.holidayURL,
182
+ ttlMs: config.holidayRefreshMs,
183
+ timeoutMs: config.timeoutMs,
184
+ log: (message) => ctx.logger?.warn?.(message)
185
+ })
186
+ /**
187
+ * The calendar years a reading can land in. holiday-cn files a date under the
188
+ * year of the State Council paper rather than the date, so December can depend
189
+ * on the next year's document and both must be present.
190
+ */
191
+ const neededYears = () => {
192
+ const year = new Date().getUTCFullYear()
193
+ return [year, year + 1]
194
+ }
195
+ // Warm the cache at load time; a failure just leaves the bundled table in use.
196
+ if (holidays.stale(neededYears())) void holidays.refresh(neededYears())
197
+
198
+ /** Cache: last good payload, its timestamp, and the in-flight read. */
199
+ const cache = { payload: undefined, at: 0, inflight: undefined }
200
+ const ttl = Math.max(MIN_REFRESH_MS, config.refreshMs)
201
+
202
+ /**
203
+ * Answer from cache, or re-read upstream. Concurrent callers share one read,
204
+ * and a failed read never replaces the last good payload.
205
+ * @param force - bypass the freshness window (still floored by MIN_REFRESH_MS).
206
+ * @returns the payload the route serializes.
207
+ */
208
+ const read = async (force) => {
209
+ const age = Date.now() - cache.at
210
+ if (cache.payload !== undefined && age < (force ? MIN_REFRESH_MS : ttl)) return cache.payload
211
+ if (cache.inflight !== undefined) return cache.inflight
212
+ cache.inflight = (async () => {
213
+ try {
214
+ const balance = await fetchBalance(ctx, config)
215
+ cache.payload = { ok: true, balance, fetchedAt: Date.now() }
216
+ cache.at = Date.now()
217
+ return cache.payload
218
+ } catch (error) {
219
+ const code = typeof error?.code === 'string' ? error.code : 'request-failed'
220
+ const message = String(error?.message ?? error)
221
+ ctx.logger?.warn?.('usage-info: balance read failed (%s): %s', code, message)
222
+ return { ok: false, error: { code, message }, fetchedAt: Date.now() }
223
+ } finally {
224
+ cache.inflight = undefined
225
+ }
226
+ })()
227
+ return cache.inflight
228
+ }
229
+
230
+ const route = {
231
+ kind: 'exact',
232
+ path: BALANCE_PATH,
233
+ handler: async (req, res) => {
234
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
235
+ res.setHeader('allow', 'GET, HEAD')
236
+ sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'use GET' } })
237
+ return
238
+ }
239
+ if (!hostAllowed(req.headers?.host, ctx.webServer?.host ?? '127.0.0.1')) {
240
+ sendJson(res, 403, { ok: false, error: { code: 'forbidden-host', message: 'this route answers loopback hosts only' } })
241
+ return
242
+ }
243
+ const force = new URL(req.url ?? '/', 'http://localhost').searchParams.get('refresh') === '1'
244
+ const payload = await read(force)
245
+ // Keep the holiday calendar warm without ever blocking a request on it: a
246
+ // stale snapshot is refreshed in the background, and this request answers
247
+ // from whatever is already cached (fetched data, else the bundled table).
248
+ const years = neededYears()
249
+ if (holidays.stale(years)) void holidays.refresh(years)
250
+ // The pricing window is arithmetic on the server clock, so it is read
251
+ // fresh here rather than cached with the upstream balance.
252
+ const body = { ...payload, pricing: readPricing(withHolidayData(schedule, holidays.peek()), Date.now()) }
253
+ if (req.method === 'HEAD') {
254
+ res.statusCode = 200
255
+ res.end()
256
+ return
257
+ }
258
+ sendJson(res, 200, body)
259
+ }
260
+ }
261
+
262
+ ctx.effect(() => ctx.webServer.register(route), `usage-info: ${BALANCE_PATH}`)
263
+ }