@maci0/dsh-quota-check 0.12.2
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/LICENSE +21 -0
- package/README.md +220 -0
- package/cordis.patch.yml +13 -0
- package/icon.svg +5 -0
- package/lib/client.js +471 -0
- package/lib/host.js +12 -0
- package/lib/index.js +469 -0
- package/lib/local-usage.js +453 -0
- package/lib/probes.js +848 -0
- package/lib/types/host.d.ts +128 -0
- package/lib/types/index.d.ts +113 -0
- package/lib/types/local-usage.d.ts +45 -0
- package/lib/types/probes.d.ts +126 -0
- package/lib/types/util.d.ts +14 -0
- package/lib/util.js +28 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +104 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-quota-check: one statusbar figure for the provider the current session
|
|
3
|
+
* is using: the remaining balance on a pure API-billing route, or the plan
|
|
4
|
+
* quota on a subscription route.
|
|
5
|
+
*
|
|
6
|
+
* Two halves, one endpoint:
|
|
7
|
+
*
|
|
8
|
+
* - this host half owns the provider credential and the outbound request, and
|
|
9
|
+
* serves one JSON reply from `GET /quota-check?provider=<id>`. That is the
|
|
10
|
+
* only way a browser half can reach a provider key: the key stays in this
|
|
11
|
+
* process, and the browser only ever sees the formatted figure.
|
|
12
|
+
* - `lib/client.js` registers the chip in `conversation.composer.dock` (the
|
|
13
|
+
* statusbar row directly under the composer and its model selector) and
|
|
14
|
+
* asks this route for the provider the session's model selection names.
|
|
15
|
+
*
|
|
16
|
+
* Which endpoints answer is `probes.ts`; the credential and the reading are
|
|
17
|
+
* resolved once per provider and cached for {@link DEFAULT_CACHE_SECONDS}, so
|
|
18
|
+
* a rerender, a session switch, or a second tab never multiplies provider
|
|
19
|
+
* traffic.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-quota-check
|
|
22
|
+
*/
|
|
23
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
24
|
+
import { localRequests } from './local-usage.js';
|
|
25
|
+
import { resolveProbe } from './probes.js';
|
|
26
|
+
import { record } from './util.js';
|
|
27
|
+
/** Plugin name as it appears in the loader. */
|
|
28
|
+
export const name = 'quota-check';
|
|
29
|
+
/**
|
|
30
|
+
* The route carrier, and the trust fence the route checks first: without
|
|
31
|
+
* `connection` nothing would refuse a cross-origin or unauthenticated caller,
|
|
32
|
+
* so the plugin waits for it rather than serving unfenced.
|
|
33
|
+
*/
|
|
34
|
+
export const inject = ['webServer', 'connection'];
|
|
35
|
+
/** The route the browser half reads. */
|
|
36
|
+
export const ROUTE = '/quota-check';
|
|
37
|
+
/** Seconds one provider's reading is served without re-asking the provider. */
|
|
38
|
+
export const DEFAULT_CACHE_SECONDS = 60;
|
|
39
|
+
/** Per-request ceiling for a provider call, in milliseconds. */
|
|
40
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
41
|
+
/** Seconds the browser half waits between re-reads, unless configured otherwise. */
|
|
42
|
+
export const DEFAULT_REFRESH_SECONDS = 300;
|
|
43
|
+
/** Live report entries one host keeps before it starts evicting. */
|
|
44
|
+
const MAX_REPORTS = 512;
|
|
45
|
+
/** Longest provider route id the route accepts. */
|
|
46
|
+
const MAX_PROVIDER_ID_LENGTH = 128;
|
|
47
|
+
/**
|
|
48
|
+
* Shape of a provider route id. The harness's built-in routes are lowercase
|
|
49
|
+
* and hyphenated (`deepseek-official`, `zai-coding-cn`); a profile's own route
|
|
50
|
+
* keys are user-chosen, so `_`, `.` and capitals are admitted too (the z.ai
|
|
51
|
+
* probe already recognizes `z_ai`).
|
|
52
|
+
*/
|
|
53
|
+
const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
54
|
+
/**
|
|
55
|
+
* Field defaults and bounds with no volatility wrapper. {@link resolveRow}
|
|
56
|
+
* parses a plain row through this schema, so its output is plain values; the
|
|
57
|
+
* loader-facing {@link Config} below is the same shape with every field made
|
|
58
|
+
* `volatile()`. The pair is asserted equal in the suite.
|
|
59
|
+
*/
|
|
60
|
+
const ValueSchema = Schema.object({
|
|
61
|
+
cacheSeconds: Schema.number().min(0).max(3_600).default(DEFAULT_CACHE_SECONDS),
|
|
62
|
+
timeoutMs: Schema.number().min(1).max(60_000).default(DEFAULT_TIMEOUT_MS),
|
|
63
|
+
refreshSeconds: Schema.number().min(10).max(3_600).default(DEFAULT_REFRESH_SECONDS),
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* Row schema as Cordis resolves it: what this plugin's `config` is validated
|
|
67
|
+
* against, and where each default lives. Every value here is a deployment
|
|
68
|
+
* choice (the cadences and the deadline vary by machine), so none is a
|
|
69
|
+
* constant only this plugin could change, and all three are editable from the
|
|
70
|
+
* Plugins page.
|
|
71
|
+
*/
|
|
72
|
+
export const Config = Schema.object({
|
|
73
|
+
cacheSeconds: Schema.number().min(0).max(3_600).default(DEFAULT_CACHE_SECONDS).volatile(),
|
|
74
|
+
timeoutMs: Schema.number().min(1).max(60_000).default(DEFAULT_TIMEOUT_MS).volatile(),
|
|
75
|
+
refreshSeconds: Schema.number().min(10).max(3_600).default(DEFAULT_REFRESH_SECONDS).volatile(),
|
|
76
|
+
});
|
|
77
|
+
/**
|
|
78
|
+
* Read one configured field as a plain value.
|
|
79
|
+
*
|
|
80
|
+
* The loader hands a `volatile()` field a live reference; a direct caller (a
|
|
81
|
+
* test, another plugin composing this one) hands the value itself. Both are
|
|
82
|
+
* accepted, so one read path serves both.
|
|
83
|
+
* @param value - the configured value, live or plain.
|
|
84
|
+
* @returns the current plain value, or `undefined` when a reference holds none.
|
|
85
|
+
*/
|
|
86
|
+
function readLive(value) {
|
|
87
|
+
if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
|
|
88
|
+
// A scalar snapshot is the value; the generic cannot narrow that itself.
|
|
89
|
+
return value.get();
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Turn a row (live references or plain values) into validated plain options.
|
|
95
|
+
* @param row - the configured row.
|
|
96
|
+
* @returns the resolved options, defaults filled by the schema.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveRow(row = {}) {
|
|
99
|
+
return ValueSchema({
|
|
100
|
+
cacheSeconds: readLive(row.cacheSeconds),
|
|
101
|
+
timeoutMs: readLive(row.timeoutMs),
|
|
102
|
+
refreshSeconds: readLive(row.refreshSeconds),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* What a provider route's base URL must start with: an http(s) scheme and a
|
|
107
|
+
* host. A scheme-less `box:20128` parses as a URL whose origin is "null".
|
|
108
|
+
*/
|
|
109
|
+
const BASE_URL_PATTERN = /^https?:\/\/[^\s/?#]+/iu;
|
|
110
|
+
/** Whether a configured base URL is an absolute http(s) URL. */
|
|
111
|
+
function isHttpUrl(baseURL) {
|
|
112
|
+
return BASE_URL_PATTERN.test(baseURL) && URL.canParse(baseURL);
|
|
113
|
+
}
|
|
114
|
+
/** Write a JSON reply whose body is already serialized. */
|
|
115
|
+
function sendBody(res, status, body) {
|
|
116
|
+
res.statusCode = status;
|
|
117
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
118
|
+
res.setHeader('cache-control', 'no-store');
|
|
119
|
+
res.end(body);
|
|
120
|
+
}
|
|
121
|
+
/** Write a JSON reply; readings are live facts and are never cached by the browser. */
|
|
122
|
+
function sendJson(res, status, payload) {
|
|
123
|
+
sendBody(res, status, JSON.stringify(payload));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Query parameters of a request target.
|
|
127
|
+
*
|
|
128
|
+
* Only the query matters here, so only the query is parsed: building a `URL`
|
|
129
|
+
* for every read pays for an origin the route never looks at.
|
|
130
|
+
* @param url - request target, path and query.
|
|
131
|
+
* @returns the decoded parameters.
|
|
132
|
+
*/
|
|
133
|
+
function searchParamsOf(url) {
|
|
134
|
+
// The fragment is not part of the request target a `URL` would parse either,
|
|
135
|
+
// and a target whose `#` precedes its `?` must not have the fragment read as
|
|
136
|
+
// query text.
|
|
137
|
+
const hash = url.indexOf('#');
|
|
138
|
+
const target = hash < 0 ? url : url.slice(0, hash);
|
|
139
|
+
const start = target.indexOf('?');
|
|
140
|
+
if (start < 0)
|
|
141
|
+
return new URLSearchParams();
|
|
142
|
+
return new URLSearchParams(target.slice(start + 1));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Resolve one provider route's profile: the settings namespace the LLM registry
|
|
146
|
+
* points at, drilled to the route's own path.
|
|
147
|
+
* @param ctx - host context.
|
|
148
|
+
* @param providerId - route id.
|
|
149
|
+
* @returns the fields this plugin reads, empty when nothing resolves.
|
|
150
|
+
*/
|
|
151
|
+
function providerConfigOf(ctx, providerId) {
|
|
152
|
+
const llm = ctx.get('llm');
|
|
153
|
+
let entry;
|
|
154
|
+
try {
|
|
155
|
+
entry = llm?.listConfigurableProviders().find(candidate => candidate.provider === providerId);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
ctx.logger.warn(`quota-check: could not read the provider directory (${String(error)})`);
|
|
159
|
+
}
|
|
160
|
+
if (entry === undefined)
|
|
161
|
+
return {};
|
|
162
|
+
const settings = ctx.get('settings');
|
|
163
|
+
const descriptor = settings?.describe().find(candidate => candidate.ns === entry.settingsNs);
|
|
164
|
+
let section = descriptor?.value;
|
|
165
|
+
for (const segment of entry.settingsPath)
|
|
166
|
+
section = record(section)?.[segment];
|
|
167
|
+
const fields = record(section) ?? {};
|
|
168
|
+
return {
|
|
169
|
+
...entry.displayName === undefined ? {} : { displayName: entry.displayName },
|
|
170
|
+
...typeof fields['baseURL'] === 'string' ? { baseURL: fields['baseURL'] } : {},
|
|
171
|
+
...typeof fields['apiKeyEnv'] === 'string' ? { apiKeyEnv: fields['apiKeyEnv'] } : {},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the provider credential: the configured reference first, then the
|
|
176
|
+
* references the probe itself names.
|
|
177
|
+
* @param ctx - host context.
|
|
178
|
+
* @param config - the route's resolved profile.
|
|
179
|
+
* @param envNames - fallback references the probe names.
|
|
180
|
+
* @returns the key, or `undefined` when none is configured.
|
|
181
|
+
*/
|
|
182
|
+
async function apiKeyOf(ctx, config, envNames) {
|
|
183
|
+
const credentials = ctx.get('credentials');
|
|
184
|
+
for (const ref of [config.apiKeyEnv, ...envNames]) {
|
|
185
|
+
if (ref === undefined || ref.length === 0)
|
|
186
|
+
continue;
|
|
187
|
+
const resolved = await credentials?.resolve(ref);
|
|
188
|
+
const stored = resolved?.value;
|
|
189
|
+
if (stored !== undefined && stored.length > 0)
|
|
190
|
+
return stored;
|
|
191
|
+
const ambient = process.env[ref];
|
|
192
|
+
if (ambient !== undefined && ambient.length > 0)
|
|
193
|
+
return ambient;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Build one provider's reading.
|
|
199
|
+
* @param ctx - host context.
|
|
200
|
+
* @param providerId - route id the browser half asked about.
|
|
201
|
+
* @param timeoutMs - per-request provider deadline.
|
|
202
|
+
* @param refreshMs - re-read cadence the browser half is told to use.
|
|
203
|
+
* @returns the report, never throwing: a failure is an `error` report.
|
|
204
|
+
*/
|
|
205
|
+
async function buildReport(ctx, providerId, timeoutMs, refreshMs) {
|
|
206
|
+
let displayName = providerId;
|
|
207
|
+
try {
|
|
208
|
+
// Resolved per miss, not cached: the miss is network-bound, and a profile
|
|
209
|
+
// cache would hide a settings edit (baseURL, displayName) for its whole TTL.
|
|
210
|
+
const config = providerConfigOf(ctx, providerId);
|
|
211
|
+
displayName = config.displayName ?? providerId;
|
|
212
|
+
const base = { provider: providerId, displayName, fetchedAt: Date.now(), refreshMs };
|
|
213
|
+
// The route's baseURL belongs to another plugin's row, so it is checked
|
|
214
|
+
// here, at first use: no origin is guessed for a malformed one, and no
|
|
215
|
+
// credential is resolved or sent.
|
|
216
|
+
if (config.baseURL !== undefined && !isHttpUrl(config.baseURL)) {
|
|
217
|
+
return {
|
|
218
|
+
...base,
|
|
219
|
+
status: 'error',
|
|
220
|
+
message: 'this route\'s baseURL is not an absolute http(s) URL; fix the provider row',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const probe = resolveProbe(providerId, config.baseURL);
|
|
224
|
+
if (probe === undefined) {
|
|
225
|
+
return {
|
|
226
|
+
...base,
|
|
227
|
+
status: 'unsupported',
|
|
228
|
+
message: 'no balance or quota endpoint is known for this provider',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const envNames = probe.envNames ?? [];
|
|
232
|
+
const local = probe.local;
|
|
233
|
+
let requests;
|
|
234
|
+
if (local === undefined) {
|
|
235
|
+
const key = await apiKeyOf(ctx, config, envNames);
|
|
236
|
+
if (key === undefined) {
|
|
237
|
+
// A missing credential is a broken setup on a route that names its
|
|
238
|
+
// vendor; on a guessed route it only means the host is not that product.
|
|
239
|
+
return probe.tentative === true
|
|
240
|
+
? {
|
|
241
|
+
...base,
|
|
242
|
+
status: 'unsupported',
|
|
243
|
+
message: 'no balance or quota endpoint this plugin recognizes, and no credential to read for it',
|
|
244
|
+
}
|
|
245
|
+
: {
|
|
246
|
+
...base,
|
|
247
|
+
status: 'error',
|
|
248
|
+
message: `no credential is configured for this route (${config.apiKeyEnv ?? envNames.join(' / ')})`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (probe.requests !== undefined) {
|
|
252
|
+
// The endpoint names ids only the provider knows: read its listing
|
|
253
|
+
// first, then ask each one it offers.
|
|
254
|
+
requests = await probe.requests({ key, timeoutMs });
|
|
255
|
+
}
|
|
256
|
+
else if (probe.url === undefined) {
|
|
257
|
+
return { ...base, status: 'error', message: 'this probe declares neither an endpoint nor a listing' };
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
requests = [{ url: probe.url, headers: { authorization: `Bearer ${key}`, accept: 'application/json' } }];
|
|
261
|
+
}
|
|
262
|
+
if (requests.length === 0) {
|
|
263
|
+
return {
|
|
264
|
+
...base,
|
|
265
|
+
status: 'error',
|
|
266
|
+
message: `${config.baseURL ?? providerId} listed no endpoint to ask`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
requests = await localRequests(local, { timeoutMs });
|
|
272
|
+
if (requests.length === 0) {
|
|
273
|
+
return {
|
|
274
|
+
...base,
|
|
275
|
+
status: 'error',
|
|
276
|
+
message: `no usable ${local} CLI credential is on this machine`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
let outcome = await fetchAll(requests, timeoutMs);
|
|
281
|
+
// A live-looking token can still be refused: rotate once and retry, which is
|
|
282
|
+
// what the CLI's own fetcher does. Cursor has no refresh path, so a retry
|
|
283
|
+
// there would only repeat the same refused request.
|
|
284
|
+
if (local !== undefined && local !== 'cursor' && !outcome.ok && outcome.refused) {
|
|
285
|
+
const retried = await localRequests(local, { forceRefresh: true, timeoutMs });
|
|
286
|
+
if (retried.length > 0)
|
|
287
|
+
outcome = await fetchAll(retried, timeoutMs);
|
|
288
|
+
}
|
|
289
|
+
if (!outcome.ok) {
|
|
290
|
+
// A 404 on a probe that only guessed the endpoint means the host does not
|
|
291
|
+
// publish it: absent data, not a reading that failed.
|
|
292
|
+
if (probe.tentative === true && outcome.status === 404) {
|
|
293
|
+
return {
|
|
294
|
+
...base,
|
|
295
|
+
status: 'unsupported',
|
|
296
|
+
message: `${requests[0]?.url ?? providerId} is not on this host`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
...base,
|
|
301
|
+
status: 'error',
|
|
302
|
+
message: `${requests[0]?.url ?? providerId} answered HTTP ${String(outcome.status)}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const reading = probe.parse(outcome.payloads);
|
|
306
|
+
if (reading === null) {
|
|
307
|
+
return { ...base, status: 'error', message: 'the provider response carried no balance or quota figure' };
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
...base,
|
|
311
|
+
status: 'ok',
|
|
312
|
+
kind: probe.kind,
|
|
313
|
+
text: reading.text,
|
|
314
|
+
...reading.remaining === undefined ? {} : { remaining: reading.remaining },
|
|
315
|
+
lines: reading.lines,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// Another service's exception text (credentials, settings, a parser) may
|
|
320
|
+
// name host paths or internals, so it stays in the host log.
|
|
321
|
+
ctx.logger.warn(`quota-check: the ${providerId} reading failed (${String(error)})`);
|
|
322
|
+
return {
|
|
323
|
+
provider: providerId,
|
|
324
|
+
displayName,
|
|
325
|
+
fetchedAt: Date.now(),
|
|
326
|
+
refreshMs,
|
|
327
|
+
status: 'error',
|
|
328
|
+
message: 'the reading failed; the host log names the cause',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Run every request of one probe in order, tolerating a partial answer: Grok
|
|
334
|
+
* reports two meters and one of them may be unavailable, so only a round where
|
|
335
|
+
* nothing answered is a failure.
|
|
336
|
+
* @param requests - requests built from the provider's credential.
|
|
337
|
+
* @param timeoutMs - per-request deadline.
|
|
338
|
+
* @returns the decoded bodies, the last status, and the refusal flag.
|
|
339
|
+
*/
|
|
340
|
+
async function fetchAll(requests, timeoutMs) {
|
|
341
|
+
const payloads = [];
|
|
342
|
+
let status = 0;
|
|
343
|
+
let refused = false;
|
|
344
|
+
for (const request of requests) {
|
|
345
|
+
try {
|
|
346
|
+
const response = await fetch(request.url, {
|
|
347
|
+
headers: request.headers,
|
|
348
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
349
|
+
});
|
|
350
|
+
status = response.status;
|
|
351
|
+
if (response.status === 401 || response.status === 403)
|
|
352
|
+
refused = true;
|
|
353
|
+
payloads.push(response.ok ? await response.json().catch(() => undefined) : undefined);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
payloads.push(undefined);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { ok: payloads.some(payload => payload !== undefined), status, refused, payloads };
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Mount the host half.
|
|
363
|
+
* @param ctx - host context carrying the route carrier.
|
|
364
|
+
* @param config - this plugin's row configuration.
|
|
365
|
+
*/
|
|
366
|
+
export function apply(ctx, row = {}) {
|
|
367
|
+
// Read the row at every use: each field is volatile, so a save from the
|
|
368
|
+
// Plugins card has to reach the next reading rather than a mount-time copy.
|
|
369
|
+
const live = () => resolveRow(row);
|
|
370
|
+
live();
|
|
371
|
+
const cache = new Map();
|
|
372
|
+
const inflight = new Map();
|
|
373
|
+
// Bumped by a settings write. A read that started before one belongs to the
|
|
374
|
+
// old row (its deadline and the `refreshMs` it reports), so it must not fill
|
|
375
|
+
// the cache the write just emptied.
|
|
376
|
+
let generation = 0;
|
|
377
|
+
/**
|
|
378
|
+
* Keep the report map at its cap: once it holds `MAX_REPORTS`, the oldest key
|
|
379
|
+
* goes before the next insert. `Map` iterates in insertion order, so the first
|
|
380
|
+
* key is the one inserted longest ago; an entry whose lifetime has passed is
|
|
381
|
+
* already a miss on read, so it needs no sweep here.
|
|
382
|
+
*/
|
|
383
|
+
const pruneReports = () => {
|
|
384
|
+
if (cache.size < MAX_REPORTS)
|
|
385
|
+
return;
|
|
386
|
+
const oldest = cache.keys().next().value;
|
|
387
|
+
if (oldest !== undefined)
|
|
388
|
+
cache.delete(oldest);
|
|
389
|
+
};
|
|
390
|
+
/**
|
|
391
|
+
* Serve one provider's serialized reading: the cached body, or a fresh one.
|
|
392
|
+
*
|
|
393
|
+
* The body is serialized once, when the reading is taken, so a hit answers
|
|
394
|
+
* with the bytes written then instead of serializing the same report again on
|
|
395
|
+
* every poll.
|
|
396
|
+
*/
|
|
397
|
+
const reportFor = (providerId, refresh) => {
|
|
398
|
+
const { cacheSeconds, timeoutMs, refreshSeconds } = live();
|
|
399
|
+
const hit = cache.get(providerId);
|
|
400
|
+
if (!refresh && hit !== undefined && Date.now() - hit.at < cacheSeconds * 1_000) {
|
|
401
|
+
return Promise.resolve(hit.body);
|
|
402
|
+
}
|
|
403
|
+
const running = inflight.get(providerId);
|
|
404
|
+
if (running !== undefined)
|
|
405
|
+
return running;
|
|
406
|
+
const started = generation;
|
|
407
|
+
const pending = buildReport(ctx, providerId, timeoutMs, refreshSeconds * 1_000).then((report) => {
|
|
408
|
+
const body = JSON.stringify(report);
|
|
409
|
+
// A write that landed mid-read emptied the cache; this body belongs to
|
|
410
|
+
// the row before it, so it is answered but not kept.
|
|
411
|
+
if (started === generation) {
|
|
412
|
+
pruneReports();
|
|
413
|
+
cache.set(providerId, { at: Date.now(), body });
|
|
414
|
+
}
|
|
415
|
+
return body;
|
|
416
|
+
}).finally(() => {
|
|
417
|
+
// Only this read's own entry: after a write, a later poll may have
|
|
418
|
+
// installed its own read under the same key.
|
|
419
|
+
if (inflight.get(providerId) === pending)
|
|
420
|
+
inflight.delete(providerId);
|
|
421
|
+
});
|
|
422
|
+
inflight.set(providerId, pending);
|
|
423
|
+
return pending;
|
|
424
|
+
};
|
|
425
|
+
const handler = async (req, res) => {
|
|
426
|
+
const rejection = ctx.connection.requestRejection(req);
|
|
427
|
+
if (rejection !== undefined) {
|
|
428
|
+
res.statusCode = rejection;
|
|
429
|
+
res.end();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
|
|
433
|
+
res.setHeader('allow', 'GET');
|
|
434
|
+
sendJson(res, 405, { status: 'error', message: 'this route answers GET only' });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const params = searchParamsOf(String(req.url ?? ROUTE));
|
|
438
|
+
const providerId = params.get('provider') ?? '';
|
|
439
|
+
if (providerId.length === 0) {
|
|
440
|
+
sendJson(res, 400, { status: 'error', message: 'a provider query parameter is required' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Checked before the id keys the cache or reaches any lookup.
|
|
444
|
+
if (providerId.length > MAX_PROVIDER_ID_LENGTH || !PROVIDER_ID_PATTERN.test(providerId)) {
|
|
445
|
+
sendJson(res, 400, {
|
|
446
|
+
status: 'error',
|
|
447
|
+
message: `the provider query parameter must be a route id: letters, digits, ".", "_" or "-", at most ${String(MAX_PROVIDER_ID_LENGTH)} characters`,
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const body = await reportFor(providerId, params.get('refresh') === '1');
|
|
452
|
+
sendBody(res, 200, body);
|
|
453
|
+
};
|
|
454
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: ROUTE, handler }), `quota-check: GET ${ROUTE}`);
|
|
455
|
+
// A settings write moves the live references in place. Dropping the served
|
|
456
|
+
// readings makes the next poll show the effect of the edit instead of a body
|
|
457
|
+
// cached under the previous window, and the log line records what it became.
|
|
458
|
+
ctx.on('loader/volatile-update', () => {
|
|
459
|
+
cache.clear();
|
|
460
|
+
// A poll that starts after the write must read with the new row, so the
|
|
461
|
+
// reads the old row started stop being handed out. Their callers still get
|
|
462
|
+
// an answer.
|
|
463
|
+
inflight.clear();
|
|
464
|
+
generation += 1;
|
|
465
|
+
const { cacheSeconds, timeoutMs, refreshSeconds } = live();
|
|
466
|
+
ctx.logger.warn(`quota-check: configuration updated: ${cacheSeconds}s cache, ${timeoutMs}ms deadline,`
|
|
467
|
+
+ ` ${refreshSeconds}s browser refresh`);
|
|
468
|
+
});
|
|
469
|
+
}
|