magpie-page-token-widget 0.1.0 → 0.2.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/collector.mjs CHANGED
@@ -1,123 +1,431 @@
1
- import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { resolveQuotas, normalizeUsage, project, shanghaiDay, sanitizeCachedLabels, providerFamily, accountKey, number } from './normalize.mjs';
4
-
5
- export class Collector {
6
- constructor({ upstream, magpieDir, snapshotFile, fetcher = fetch, clock = Date.now, display = {} }) {
7
- Object.assign(this, { upstream, magpieDir, snapshotFile, fetcher, clock });
8
- this.display = display;
9
- this.snapshot = { version: 1, parts: {}, failures: {} };
10
- this.pending = null;
11
- // The address of every account, kept in memory only: it is never written
12
- // to the snapshot and never leaves this machine.
13
- this.addresses = new Map();
14
- // The last reading of every account, kept in memory only as well.
15
- this.usage = new Map();
16
- }
17
- async load() {
18
- try {
19
- const saved = JSON.parse(await readFile(this.snapshotFile, 'utf8'));
20
- if (saved.version === 1 && saved.parts && saved.failures) {
21
- for (const key of Object.keys(saved.parts)) {
22
- saved.parts[key].data = sanitizeCachedLabels(saved.parts[key].data, key);
23
- saved.failures[key] = true; // Await live revalidation after every process restart.
24
- }
25
- this.snapshot = saved;
26
- }
27
- } catch { /* A new or damaged cache must not prevent startup. */ }
28
- }
29
- async request(endpoint, timeout = 15_000) {
30
- const result = await this.fetcher(new URL(endpoint, this.upstream), {
31
- signal: AbortSignal.timeout(timeout), headers: { Accept: 'application/json' }, redirect: 'error',
32
- });
33
- if (!result.ok) throw new Error('Magpie unavailable');
34
- return result.json();
35
- }
36
- async quotaCache() {
37
- try { return JSON.parse(await readFile(path.join(this.magpieDir, 'quotas.json'), 'utf8')); }
38
- catch { return {}; }
39
- }
40
- async quotas() {
41
- const [response, cache] = await Promise.allSettled([
42
- this.request('/v1/magpie/quotas?kept=1'), this.quotaCache(),
43
- ]);
44
- const raw = response.status === 'fulfilled' ? response.value : null;
45
- const cards = resolveQuotas(raw, cache.status === 'fulfilled' ? cache.value : {}, this.snapshot.parts.quotas?.data || []);
46
- await this.accountUsage(raw, cards);
47
- return cards;
48
- }
49
- // An account's own usage is asked for by the address Magpie reports for it.
50
- // The address goes into the query of the local gateway and only the totals
51
- // come back, so it never leaves this machine and is never stored.
52
- async accountUsage(raw, cards) {
53
- const fresh = new Map();
54
- for (const card of raw?.data || []) {
55
- const family = providerFamily(card?.provider);
56
- const address = typeof (card?.user ?? card?.account) === 'string' ? (card.user ?? card.account).trim() : '';
57
- if (!family || !address.includes('@')) continue;
58
- // The key of the account is the one of its card, so a query can never
59
- // reach the reading of another account.
60
- const key = accountKey(family, address);
61
- if (!key) continue;
62
- const known = fresh.get(key);
63
- // Two accounts of one vendor that share the same name cannot be told
64
- // apart: their usage stays unknown instead of being taken from the wrong
65
- // one.
66
- if (known === null) continue;
67
- if (known && known !== address) { fresh.set(key, null); continue; }
68
- fresh.set(key, address);
69
- }
70
- // The addresses of the last successful reading are kept in memory only, so
71
- // the usage of an account survives a quota outage. They are never written
72
- // to the snapshot.
73
- if (fresh.size) this.addresses = new Map([...this.addresses, ...fresh]);
74
- await Promise.all(cards.filter(card => card.account).map(async card => {
75
- const address = this.addresses.get(card.id);
76
- if (!address) return;
77
- try {
78
- // A short timeout: the reading of the quotas must not wait long for a
79
- // usage query that only fills in one number.
80
- const data = await this.request('/v1/magpie/usage/requests?period=today&limit=1&account=' + encodeURIComponent(address), 5000);
81
- if (!['input', 'output', 'cache_read', 'cache_write', 'calls'].every(key =>
82
- typeof data[key] === 'number' && Number.isFinite(data[key]))) throw new Error('Not a usage answer');
83
- const total = number(data.input) + number(data.output) + number(data.cache_read) + number(data.cache_write);
84
- const usage = { today: { totalTokens: total, requests: number(data.calls) } };
85
- card.accountUsage = usage;
86
- this.usage.set(card.id, usage);
87
- } catch {
88
- // The last reading of this account is kept: a number that was read a
89
- // minute ago is better than no number at all.
90
- card.accountUsage = card.accountUsage || this.usage.get(card.id) || null;
91
- }
92
- }));
93
- }
94
- refresh() {
95
- if (this.pending) return this.pending;
96
- this.pending = this.collect().finally(() => { this.pending = null; });
97
- return this.pending;
98
- }
99
- async collect() {
100
- const jobs = {
101
- quotas: () => this.quotas(),
102
- today: async () => normalizeUsage(await this.request('/v1/magpie/usage/requests?period=today&limit=1')),
103
- week: async () => normalizeUsage(await this.request('/v1/magpie/usage/requests?period=7d&limit=1')),
104
- };
105
- await Promise.all(Object.entries(jobs).map(async ([key, job]) => {
106
- try {
107
- const periodDay = shanghaiDay(this.clock());
108
- const data = await job();
109
- this.snapshot.parts[key] = { observedAt: new Date(this.clock()).toISOString(), periodDay, data };
110
- if (key === 'quotas' && data.every(card => card.stale)) this.snapshot.failures[key] = true;
111
- else delete this.snapshot.failures[key];
112
- } catch { this.snapshot.failures[key] = true; }
113
- }));
114
- try {
115
- await mkdir(path.dirname(this.snapshotFile), { recursive: true });
116
- await writeFile(this.snapshotFile + '.tmp', JSON.stringify(this.snapshot), { mode: 0o600 });
117
- await rename(this.snapshotFile + '.tmp', this.snapshotFile);
118
- } catch { console.warn('Snapshot could not be saved; serving the in-memory snapshot.'); }
119
- }
120
- overview() { return project(this.snapshot, this.clock(), this.display); }
121
- // The console shows every account, not only the ones on the home screen.
122
- overviewWith(display) { return project(this.snapshot, this.clock(), display); }
123
- }
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { writeAtomic } from './atomic.mjs';
4
+ import {
5
+ resolveQuotas, normalizeUsage, normalizeUsageSummary, sanitizeCachedLabels,
6
+ sanitizeCachedSummary, shanghaiDay, project, providerFamily, accountKey,
7
+ } from './normalize.mjs';
8
+
9
+ // The snapshot of the service is good for a minute. A reading that arrives
10
+ // later waits for the refresh that is running, and waits at most a few
11
+ // seconds for it.
12
+ const SNAPSHOT_TTL_MS = 60_000;
13
+ const MAX_WAIT_MS = 2_500;
14
+ // The plain quota answer is asked for at most once every half a minute, so a
15
+ // service that keeps finding nothing does not hammer the gateway.
16
+ const PLAIN_QUOTA_GAP_MS = 30_000;
17
+ // After ten minutes without a reader the service slows down; after half an
18
+ // hour it stops asking on its own and waits to be asked.
19
+ const IDLE_AFTER_MS = 10 * 60_000;
20
+ const IDLE_STOP_MS = 30 * 60_000;
21
+ const IDLE_CHECK_MS = 5 * 60_000;
22
+
23
+ // How often the service reads on its own. The user may pick a fixed rhythm
24
+ // instead; the smart one keeps the reading fresh while somebody is watching.
25
+ export const REFRESH_STRATEGIES = {
26
+ smart: {
27
+ id: 'smart', label: '智能更新', intervalMs: SNAPSHOT_TTL_MS,
28
+ idleMs: IDLE_AFTER_MS, stopMs: IDLE_STOP_MS, idleIntervalMs: IDLE_CHECK_MS, checkMs: IDLE_CHECK_MS,
29
+ },
30
+ '1m': { id: '1m', label: '每分钟', intervalMs: 60_000 },
31
+ '2m': { id: '2m', label: '每两分钟', intervalMs: 120_000 },
32
+ '5m': { id: '5m', label: '每五分钟', intervalMs: 300_000 },
33
+ };
34
+
35
+ export const refreshStrategy = value => (REFRESH_STRATEGIES[value] ? value : 'smart');
36
+
37
+ const sleep = ms => new Promise(resolve => { setTimeout(resolve, ms).unref?.(); });
38
+
39
+ // On the disk the accounts are written as the name of the account alone: the
40
+ // whole address stays in the memory of the service and never reaches the
41
+ // phone.
42
+ const snapshotForDisk = (snapshot) => {
43
+ const parts = { ...snapshot.parts };
44
+ const today = parts.today;
45
+ if (today?.data?.accounts) {
46
+ parts.today = {
47
+ ...today,
48
+ data: {
49
+ ...today.data,
50
+ accounts: today.data.accounts.map((row) => ({
51
+ ...row,
52
+ account: String(row.account || '').split('@')[0],
53
+ })),
54
+ },
55
+ };
56
+ }
57
+ return { ...snapshot, parts };
58
+ };
59
+
60
+ export class Collector {
61
+ constructor({ upstream, magpieDir, snapshotFile, fetcher = fetch, clock = Date.now, display = {}, strategy = 'smart', ttlMs = SNAPSHOT_TTL_MS, maxWaitMs = MAX_WAIT_MS, requestTimeoutMs = 15_000 }) {
62
+ this.upstream = upstream;
63
+ this.magpieDir = magpieDir;
64
+ this.snapshotFile = snapshotFile;
65
+ this.fetcher = fetcher;
66
+ this.clock = clock;
67
+ this.display = display;
68
+ this.strategy = refreshStrategy(strategy);
69
+ this.ttlMs = ttlMs;
70
+ this.maxWaitMs = maxWaitMs;
71
+ this.requestTimeoutMs = requestTimeoutMs;
72
+ this.snapshot = { version: 1, parts: {}, failures: {} };
73
+ // The whole address of an account is kept in the memory of the service
74
+ // only. On the disk it is written as the name of the account alone,
75
+ // which is what the console shows anyway.
76
+ this.addresses = new Map();
77
+ // The last good reading of every account, so a reading that fails keeps
78
+ // the numbers of the last good one.
79
+ this.usage = new Map();
80
+ this.pending = null;
81
+ this.lastCollectedAt = 0;
82
+ this.lastPlainQuotaAt = 0;
83
+ this.lastRequestAt = 0;
84
+ this.expired = false;
85
+ this.timer = null;
86
+ this.stopped = true;
87
+ }
88
+
89
+ // A snapshot that was written by an earlier run is read back, but it is
90
+ // marked as old: a fresh reading has to replace it.
91
+ async load() {
92
+ try {
93
+ const saved = JSON.parse(await readFile(this.snapshotFile, 'utf8'));
94
+ if (!saved || saved.version !== 1 || !saved.parts || !saved.failures) return;
95
+ for (const key of ['quotas', 'today', 'week']) {
96
+ const part = saved.parts[key];
97
+ if (!part || !part.data) continue;
98
+ part.data = key === 'today' ? sanitizeCachedSummary(part.data) : sanitizeCachedLabels(part.data, key);
99
+ saved.failures[key] = true;
100
+ }
101
+ this.snapshot = saved;
102
+ } catch { /* Without a snapshot the first reading writes one. */ }
103
+ }
104
+
105
+ async request(endpoint, timeout = this.requestTimeoutMs) {
106
+ const response = await this.fetcher(new URL(endpoint, this.upstream), {
107
+ signal: AbortSignal.timeout(timeout),
108
+ headers: { Accept: 'application/json' },
109
+ redirect: 'error',
110
+ });
111
+ if (!response.ok) throw new Error('Magpie unavailable');
112
+ return response.json();
113
+ }
114
+
115
+ // The quota readings Magpie has already written down. They are the same
116
+ // numbers the kept answer carries, and they are here for the case where the
117
+ // gateway is busy.
118
+ async quotaCache() {
119
+ try {
120
+ return JSON.parse(await readFile(path.join(this.magpieDir, 'quotas.json'), 'utf8'));
121
+ } catch { return {}; }
122
+ }
123
+
124
+ // The plain answer is the one that asks the vendors. It is only asked for
125
+ // when the kept answer holds nothing at all and nothing is kept anywhere
126
+ // else, and never more than once every half a minute.
127
+ plainQuotaAllowed() {
128
+ return this.clock() - this.lastPlainQuotaAt >= PLAIN_QUOTA_GAP_MS;
129
+ }
130
+
131
+ async plainQuota() {
132
+ this.lastPlainQuotaAt = this.clock();
133
+ try {
134
+ return await this.request('/v1/magpie/quotas');
135
+ } catch { return null; }
136
+ }
137
+
138
+ // The quota of the subscriptions. The kept answer is the one Magpie has
139
+ // already read, so it costs nothing. The plain answer is the fallback of a
140
+ // cold start, when the kept answer is empty and nothing is known yet.
141
+ async quotas() {
142
+ const cache = await this.quotaCache();
143
+ const previous = this.snapshot.parts.quotas?.data || [];
144
+ let rows = null;
145
+ let failure = null;
146
+ try {
147
+ rows = await this.request('/v1/magpie/quotas?kept=1');
148
+ } catch (error) {
149
+ failure = error;
150
+ }
151
+ try {
152
+ return { cards: resolveQuotas(rows, cache, previous), rows };
153
+ } catch (error) {
154
+ const empty = !failure && Array.isArray(rows?.data) && rows.data.length === 0;
155
+ if (empty && this.plainQuotaAllowed()) {
156
+ const plain = await this.plainQuota();
157
+ if (plain) {
158
+ try {
159
+ return { cards: resolveQuotas(plain, cache, previous), rows: plain };
160
+ } catch { /* The plain answer holds nothing usable either. */ }
161
+ }
162
+ }
163
+ // The old snapshot stays as it is: the reader keeps the last numbers it
164
+ // saw, marked as old.
165
+ throw failure || error;
166
+ }
167
+ }
168
+
169
+ // The address of every account of every vendor. Two accounts of one vendor
170
+ // that share the name before the "@" are marked as unknown, so they are
171
+ // never asked about and never swap places.
172
+ rememberAddresses(rows) {
173
+ const fresh = new Map();
174
+ for (const row of Array.isArray(rows?.data) ? rows.data : []) {
175
+ const family = providerFamily(row?.provider);
176
+ const address = typeof row?.user === 'string' ? row.user.trim() : '';
177
+ if (!family || !address.includes('@')) continue;
178
+ const key = accountKey(family, address);
179
+ if (!key) continue;
180
+ const known = fresh.get(key);
181
+ if (known === undefined) fresh.set(key, address);
182
+ else if (known !== address) fresh.set(key, null);
183
+ }
184
+ if (!fresh.size) return;
185
+ for (const [key, value] of fresh) this.addresses.set(key, value);
186
+ }
187
+
188
+ // The reading of today: the totals, the hours, the vendors and the accounts
189
+ // all come with one answer of the gateway.
190
+ async todayUsage(cards = []) {
191
+ try {
192
+ return normalizeUsageSummary(await this.request('/v1/magpie/usage?period=today'));
193
+ } catch (error) {
194
+ // An older Magpie does not know this answer. The old way is used then:
195
+ // the totals of the day, and one answer per account.
196
+ return this.legacyToday(cards);
197
+ }
198
+ }
199
+
200
+ // The way of the older Magpie: the totals of the day come from the requests
201
+ // of the day, and the numbers of an account are asked for one by one. It is
202
+ // only used when the newer answer is not there.
203
+ async legacyToday(cards) {
204
+ const raw = await this.request('/v1/magpie/usage/requests?period=today&limit=1');
205
+ const accounts = [];
206
+ for (const card of cards) {
207
+ if (!card.account) continue;
208
+ const address = this.addresses.get(card.id);
209
+ if (!address) continue;
210
+ // Every account stands on its own: one that fails does not take the
211
+ // numbers of the others with it.
212
+ let one = null;
213
+ try {
214
+ one = await this.request(
215
+ `/v1/magpie/usage/requests?period=today&limit=1&account=${encodeURIComponent(address)}`,
216
+ 5000,
217
+ );
218
+ } catch { one = null; }
219
+ if (!one) continue;
220
+ accounts.push({
221
+ provider: card.provider,
222
+ account: address,
223
+ calls: one.calls,
224
+ input: one.input,
225
+ output: one.output,
226
+ cache_read: one.cache_read,
227
+ cache_write: one.cache_write,
228
+ });
229
+ }
230
+ return normalizeUsageSummary({
231
+ ...raw,
232
+ providerKeys: Array.isArray(raw.by?.provider) ? raw.by.provider : [],
233
+ accounts,
234
+ });
235
+ }
236
+
237
+ // The reading of the week keeps the answer of the requests, because it is
238
+ // the one that carries the models, the agents and the computers.
239
+ async weekUsage() {
240
+ return normalizeUsage(await this.request('/v1/magpie/usage/requests?period=7d&limit=1'));
241
+ }
242
+
243
+ // The usage of an account belongs to the account it was taken for: the
244
+ // vendor and the whole address have to match. A name before the "@" is
245
+ // never enough.
246
+ attachUsage(cards, summary) {
247
+ for (const row of Array.isArray(summary?.accounts) ? summary.accounts : []) {
248
+ const family = row.family || providerFamily(row.provider);
249
+ const address = typeof row.account === 'string' ? row.account.trim() : '';
250
+ if (!family || !address.includes('@')) continue;
251
+ const key = accountKey(family, address);
252
+ if (!key) continue;
253
+ if (this.addresses.get(key) !== address) continue;
254
+ const usage = { today: { totalTokens: row.totalTokens, requests: row.requests } };
255
+ this.usage.set(key, usage);
256
+ const card = cards.find(entry => entry.id === key);
257
+ if (card) card.accountUsage = usage;
258
+ }
259
+ for (const card of cards) {
260
+ if (!card.accountUsage && card.account) card.accountUsage = this.usage.get(card.id) || null;
261
+ }
262
+ }
263
+
264
+ async collect() {
265
+ const now = this.clock();
266
+ const periodDay = shanghaiDay(now);
267
+ // The quota and the week are asked for at the same time. The reading of
268
+ // the day comes after the quota, because on an older Magpie it needs to
269
+ // know which account belongs to which vendor.
270
+ const [quotas, week] = await Promise.allSettled([
271
+ this.quotas(), this.weekUsage(),
272
+ ]);
273
+ if (quotas.status === 'fulfilled') this.rememberAddresses(quotas.value.rows);
274
+ const cards = quotas.status === 'fulfilled' ? quotas.value.cards : [];
275
+ const today = await this.todayUsage(cards).then(
276
+ value => ({ status: 'fulfilled', value }),
277
+ reason => ({ status: 'rejected', reason }),
278
+ );
279
+ const observedAt = new Date(this.clock()).toISOString();
280
+ // Every part stands on its own: the quota of a failure never touches the
281
+ // reading of today, and a day that fails never touches the week.
282
+ if (quotas.status === 'fulfilled') {
283
+ const { cards: fresh } = quotas.value;
284
+ if (today.status === 'fulfilled') this.attachUsage(fresh, today.value);
285
+ else for (const card of fresh) if (!card.accountUsage && card.account) card.accountUsage = this.usage.get(card.id) || null;
286
+ this.snapshot.parts.quotas = { observedAt, periodDay, data: fresh };
287
+ this.snapshot.failures.quotas = !fresh.some(card => !card.stale);
288
+ } else {
289
+ this.snapshot.failures.quotas = true;
290
+ }
291
+ if (today.status === 'fulfilled') {
292
+ this.snapshot.parts.today = { observedAt, periodDay, data: today.value };
293
+ this.snapshot.failures.today = false;
294
+ } else {
295
+ this.snapshot.failures.today = true;
296
+ }
297
+ if (week.status === 'fulfilled') {
298
+ this.snapshot.parts.week = { observedAt, periodDay, data: week.value };
299
+ this.snapshot.failures.week = false;
300
+ } else {
301
+ this.snapshot.failures.week = true;
302
+ }
303
+ this.lastCollectedAt = this.clock();
304
+ await this.save();
305
+ return this.snapshot;
306
+ }
307
+
308
+ async save() {
309
+ try {
310
+ await mkdir(path.dirname(this.snapshotFile), { recursive: true });
311
+ await writeAtomic(this.snapshotFile, JSON.stringify(snapshotForDisk(this.snapshot), null, 2), { mode: 0o600 });
312
+ } catch (error) {
313
+ console.log(`Snapshot could not be saved; serving the in-memory snapshot. ${error?.message || error}`);
314
+ }
315
+ }
316
+
317
+ // Only one reading runs at a time. Every request that asks for a refresh
318
+ // joins the one that is running.
319
+ refresh() {
320
+ if (this.pending) return this.pending;
321
+ const job = this.collect()
322
+ .catch(() => { /* The parts that failed are already marked. */ })
323
+ .finally(() => { this.pending = null; });
324
+ this.pending = job;
325
+ return job;
326
+ }
327
+
328
+ fresh() {
329
+ return this.lastCollectedAt > 0 && this.clock() - this.lastCollectedAt <= this.ttlMs;
330
+ }
331
+
332
+ // Somebody is looking at the numbers. The console of the computer counts as
333
+ // well, otherwise a computer without a phone would never read anything
334
+ // again after the first reading.
335
+ noteActivity() {
336
+ this.lastRequestAt = this.clock();
337
+ }
338
+
339
+ // The answer of the service. A reading that is old is waited for, but never
340
+ // for long: when the wait runs out the old numbers are given back, marked
341
+ // as old, and the reading continues in the background.
342
+ async ensureFresh() {
343
+ this.lastRequestAt = this.clock();
344
+ if (this.fresh()) {
345
+ this.expired = false;
346
+ return this.overview();
347
+ }
348
+ const job = this.refresh();
349
+ const outcome = await Promise.race([
350
+ job.then(() => 'done', () => 'done'),
351
+ sleep(this.maxWaitMs).then(() => 'late'),
352
+ ]);
353
+ this.expired = outcome === 'late';
354
+ return this.overview();
355
+ }
356
+
357
+ overview() {
358
+ return project(this.snapshot, this.clock(), this.display, { expired: this.expired });
359
+ }
360
+
361
+ // Every subscription the gateway knows, whatever the display choices are.
362
+ // The console shows the whole list so the user can pick a part of it.
363
+ allSubscriptions() {
364
+ const cards = this.snapshot.parts.quotas?.data || [];
365
+ return cards.map(card => ({
366
+ id: card.id,
367
+ provider: card.provider,
368
+ label: card.label,
369
+ account: card.account || null,
370
+ alias: card.alias || null,
371
+ }));
372
+ }
373
+
374
+ setDisplay(display) {
375
+ this.display = display;
376
+ }
377
+
378
+ // The rhythm of the reading. The user may pick a fixed one; the smart one
379
+ // keeps the numbers fresh while somebody is watching and slows down when
380
+ // nobody is.
381
+ setStrategy(value) {
382
+ this.strategy = refreshStrategy(value);
383
+ this.schedule();
384
+ }
385
+
386
+ start() {
387
+ this.stopped = false;
388
+ this.schedule();
389
+ }
390
+
391
+ // The reading that is still on its way is waited for, so that the service
392
+ // does not go away in the middle of a write.
393
+ async stop() {
394
+ this.stopped = true;
395
+ clearTimeout(this.timer);
396
+ this.timer = null;
397
+ if (this.pending) {
398
+ try { await this.pending; } catch { /* The parts that failed are already marked. */ }
399
+ }
400
+ }
401
+
402
+ schedule() {
403
+ clearTimeout(this.timer);
404
+ if (this.stopped) return;
405
+ this.timer = setTimeout(() => {
406
+ this.timer = null;
407
+ this.tick();
408
+ this.schedule();
409
+ }, this.nextDelay());
410
+ this.timer.unref?.();
411
+ }
412
+
413
+ nextDelay() {
414
+ const strategy = REFRESH_STRATEGIES[this.strategy];
415
+ if (!strategy.idleMs) return strategy.intervalMs;
416
+ const idle = this.clock() - this.lastRequestAt;
417
+ if (idle >= strategy.stopMs) return strategy.checkMs;
418
+ if (idle >= strategy.idleMs) return strategy.idleIntervalMs;
419
+ return strategy.intervalMs;
420
+ }
421
+
422
+ tick() {
423
+ const strategy = REFRESH_STRATEGIES[this.strategy];
424
+ const idle = this.clock() - this.lastRequestAt;
425
+ if (strategy.idleMs && idle >= strategy.stopMs) return;
426
+ if (this.clock() - this.lastCollectedAt < strategy.intervalMs) return;
427
+ void this.refresh();
428
+ }
429
+ }
430
+
431
+ export const createCollector = options => new Collector(options);
package/lib/forward.mjs CHANGED
@@ -10,8 +10,8 @@ import { call } from './tailnet.mjs';
10
10
  // through the tailnet can never be mistaken for one from this computer.
11
11
  export const targetFor = port => '127.0.0.1:' + port;
12
12
 
13
- export async function serveState() {
14
- const result = await call(['serve', 'status', '--json']);
13
+ export async function serveState(runner) {
14
+ const result = await call(['serve', 'status', '--json'], 15000, runner);
15
15
  if (!result.ok) return { ok: false, error: result.error };
16
16
  try {
17
17
  return { ok: true, data: JSON.parse(result.stdout) };
@@ -23,9 +23,20 @@ export async function serveState() {
23
23
  // The status of Tailscale lists the published ports under "TCP".
24
24
  export const publishedFor = (status, port) => status?.data?.TCP?.[String(port)] || null;
25
25
 
26
- export async function planForward(publishedPort, phonePort) {
27
- const status = await serveState();
28
- if (!status.ok) return { ok: false, error: status.error };
26
+ // What the plugin writes down about the forwarding it made, so a forwarding
27
+ // that was made by hand is never taken away and a forwarding of an older
28
+ // version can be found again after a restart.
29
+ export const forwardRecord = (publishedPort, phonePort, extra = {}) => ({
30
+ publishedPort,
31
+ phonePort,
32
+ target: targetFor(phonePort),
33
+ createdAt: new Date().toISOString(),
34
+ ...extra,
35
+ });
36
+
37
+ // The plan of the forwarding. It never changes anything by itself: it says
38
+ // what has to be run.
39
+ export function planForward(publishedPort, phonePort, status, owned = null) {
29
40
  const existing = publishedFor(status, publishedPort);
30
41
  const target = targetFor(phonePort);
31
42
  // An older version of this plugin pointed the published port at the console
@@ -38,30 +49,40 @@ export async function planForward(publishedPort, phonePort) {
38
49
  const commands = existing && existing.TCPForward === target
39
50
  ? []
40
51
  : [['serve', '--bg', '--tcp=' + publishedPort, 'tcp://' + target]];
41
- return { ok: true, commands, ready: true };
52
+ return {
53
+ ok: true,
54
+ commands,
55
+ ready: true,
56
+ target,
57
+ // A forwarding that was already there and points at us is taken over and
58
+ // written down, so it is known to be ours afterwards.
59
+ adopted: Boolean(existing) && !owned,
60
+ legacy: Boolean(existing) && existing.TCPForward === legacy,
61
+ };
42
62
  }
43
63
 
44
- export async function applyForward(publishedPort, phonePort) {
45
- const plan = await planForward(publishedPort, phonePort);
64
+ export async function applyForward(publishedPort, phonePort, runner, owned = null) {
65
+ const status = await serveState(runner);
66
+ if (!status.ok) return { ok: false, error: status.error };
67
+ const plan = planForward(publishedPort, phonePort, status, owned);
46
68
  if (!plan.ok) return plan;
47
69
  for (const command of plan.commands) {
48
- const result = await call(command);
70
+ const result = await call(command, 15000, runner);
49
71
  if (!result.ok) return { ok: false, error: result.error };
50
72
  }
51
- return { ok: true };
73
+ return { ok: true, target: plan.target, adopted: plan.adopted, legacy: plan.legacy };
52
74
  }
53
75
 
54
- // Only a forwarding that points at this service is removed.
55
- export async function removeForward(publishedPort, phonePort) {
56
- const status = await serveState();
76
+ // Only a forwarding that is written down as ours, and that still points where
77
+ // it was pointed, is removed.
78
+ export async function removeForward(publishedPort, phonePort, runner, owned = null) {
79
+ const status = await serveState(runner);
57
80
  const existing = status.ok ? publishedFor(status, publishedPort) : null;
58
- const ours = existing && (
59
- existing.TCPForward === targetFor(phonePort) ||
60
- existing.TCPForward === targetFor(publishedPort)
61
- );
62
- if (!ours) {
81
+ const recorded = owned && owned.publishedPort === publishedPort ? owned : null;
82
+ const target = recorded?.target || null;
83
+ if (!existing || !target || existing.TCPForward !== target) {
63
84
  return { ok: false, error: '没有找到属于这个插件的转发。' };
64
85
  }
65
- const result = await call(['serve', '--tcp=' + publishedPort, 'off']);
86
+ const result = await call(['serve', '--tcp=' + publishedPort, 'off'], 15000, runner);
66
87
  return result.ok ? { ok: true } : { ok: false, error: result.error };
67
88
  }