ua-outages 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/src/client.ts CHANGED
@@ -18,7 +18,7 @@ import type {
18
18
  RegionSchedule,
19
19
  Street,
20
20
  } from './types.js'
21
- import { mapLimit } from './util.js'
21
+ import { abortable, mapLimit } from './util.js'
22
22
 
23
23
  export interface OutageClientOptions extends HttpOptions {
24
24
  /** How long fetched schedules are reused, default 5 min. 0 disables the cache. */
@@ -38,6 +38,8 @@ export interface HealthCheckOptions {
38
38
  staleAfterHours?: number
39
39
  /** Parallel requests, default 6 */
40
40
  concurrency?: number
41
+ /** Cancels the check: no further regions are fetched and the call rejects with the signal's reason */
42
+ signal?: AbortSignal
41
43
  }
42
44
 
43
45
  export interface HealthReport {
@@ -60,7 +62,9 @@ export class OutageClient {
60
62
  private readonly ctx: ProviderContext
61
63
  private readonly providers: ReadonlyMap<RegionId, Provider>
62
64
  private readonly cacheTtlMs: number
63
- private readonly cache = new Map<RegionId, { at: number; value: Promise<RegionSchedule> }>()
65
+ private readonly cache = new Map<RegionId, { at: number; seq: number; value: Promise<RegionSchedule> }>()
66
+ /** Fetches started, so a slower older one never replaces a newer one in the cache */
67
+ private seq = 0
64
68
  private readonly searchCache = new Map<string, { at: number; value: Promise<unknown[]> }>()
65
69
 
66
70
  constructor(opts: OutageClientOptions = {}) {
@@ -90,22 +94,45 @@ export class OutageClient {
90
94
 
91
95
  // --- schedules ---
92
96
 
93
- /** All groups of a region. */
94
- async getSchedules(region: RegionId, opts: { fresh?: boolean } = {}): Promise<RegionSchedule> {
97
+ /**
98
+ * All groups of a region.
99
+ *
100
+ * `signal` cancels the call: it rejects with the signal's reason right away and sends no
101
+ * further requests to the operator (the one in flight is aborted too).
102
+ */
103
+ async getSchedules(region: RegionId, opts: ScheduleOptions = {}): Promise<RegionSchedule> {
95
104
  const p = this.provider(region)
105
+ const { signal } = opts
106
+ signal?.throwIfAborted()
96
107
  const hit = this.cache.get(region)
97
108
  const now = Date.now()
98
- if (!opts.fresh && hit && now - hit.at < this.cacheTtlMs) return hit.value
99
- const value = p.getSchedules(this.ctx)
109
+ if (!opts.fresh && hit && now - hit.at < this.cacheTtlMs) return abortable(hit.value, signal)
110
+ const value = p.getSchedules(this.context(signal))
111
+ const entry = { at: now, seq: ++this.seq, value }
100
112
  if (this.cacheTtlMs > 0) {
101
- this.cache.set(region, { at: now, value })
102
- value.catch(() => this.cache.delete(region))
113
+ if (signal) {
114
+ // a cancellable call isn't shared while it runs, so another caller never inherits its
115
+ // cancellation; what it fetched is cached once it arrives, unless a fetch started later
116
+ // already is
117
+ value.then(
118
+ () => {
119
+ if ((this.cache.get(region)?.seq ?? 0) < entry.seq) this.cache.set(region, entry)
120
+ },
121
+ () => {},
122
+ )
123
+ } else {
124
+ this.cache.set(region, entry)
125
+ value.catch(() => {
126
+ if (this.cache.get(region) === entry) this.cache.delete(region)
127
+ })
128
+ }
103
129
  }
104
- return value
130
+ // a provider may wait on work it shares with other calls: the caller still leaves at once
131
+ return abortable(value, signal)
105
132
  }
106
133
 
107
134
  /** One group. Accepts loose spellings: "3.1", "GPV3.1", "черга 3.1". */
108
- async getSchedule(region: RegionId, group: string, opts: { fresh?: boolean } = {}): Promise<GroupSchedule> {
135
+ async getSchedule(region: RegionId, group: string, opts: ScheduleOptions = {}): Promise<GroupSchedule> {
109
136
  const id = normalizeGroup(group)
110
137
  const all = await this.getSchedules(region, opts)
111
138
  const days = id ? all.schedules[id] : undefined
@@ -128,16 +155,26 @@ export class OutageClient {
128
155
  region: RegionId,
129
156
  query: string,
130
157
  opts: SearchOptions & { district?: string } = {},
158
+ ): Promise<City[]> {
159
+ return this.searchCitiesIn(this.ctx, region, query, opts)
160
+ }
161
+
162
+ private async searchCitiesIn(
163
+ ctx: ProviderContext,
164
+ region: RegionId,
165
+ query: string,
166
+ opts: SearchOptions & { district?: string },
131
167
  ): Promise<City[]> {
132
168
  const p = this.provider(region)
133
169
  if (!p.searchCities) return []
134
170
  const found = await this.rankedSearch(
171
+ ctx,
135
172
  region,
136
173
  'settlement',
137
174
  query,
138
175
  'cities',
139
176
  !!p.remoteSearch?.cities,
140
- (q) => p.searchCities!(this.ctx, q),
177
+ (q) => p.searchCities!(ctx, q),
141
178
  opts.district ? MAX_CANDIDATES : (opts.limit ?? DEFAULT_LIMIT),
142
179
  )
143
180
  if (!opts.district) return found
@@ -156,17 +193,27 @@ export class OutageClient {
156
193
  region: RegionId,
157
194
  query: string,
158
195
  opts: SearchOptions & { city?: City } = {},
196
+ ): Promise<Street[]> {
197
+ return this.searchStreetsIn(this.ctx, region, query, opts)
198
+ }
199
+
200
+ private async searchStreetsIn(
201
+ ctx: ProviderContext,
202
+ region: RegionId,
203
+ query: string,
204
+ opts: SearchOptions & { city?: City },
159
205
  ): Promise<Street[]> {
160
206
  const p = this.addressProvider(region)
161
207
  if (p.searchCities && !opts.city)
162
208
  throw new OutageError('not-found', `${p.info.name}: pick a city first`, { region })
163
209
  return this.rankedSearch(
210
+ ctx,
164
211
  region,
165
212
  'street',
166
213
  query,
167
214
  `streets:${opts.city?.id ?? ''}`,
168
215
  !!p.remoteSearch?.streets,
169
- (q) => p.searchStreets!(this.ctx, q, opts.city),
216
+ (q) => p.searchStreets!(ctx, q, opts.city),
170
217
  opts.limit ?? DEFAULT_LIMIT,
171
218
  )
172
219
  }
@@ -187,9 +234,9 @@ export class OutageClient {
187
234
  async prefetch(region: RegionId, opts: { city?: City } = {}): Promise<void> {
188
235
  const p = this.provider(region)
189
236
  if (p.searchCities && !p.remoteSearch?.cities)
190
- await this.candidates(region, 'cities', '', false, (q) => p.searchCities!(this.ctx, q))
237
+ await this.candidates(this.ctx, region, 'cities', '', false, (q) => p.searchCities!(this.ctx, q))
191
238
  if (opts.city && p.searchStreets && !p.remoteSearch?.streets)
192
- await this.candidates(region, `streets:${opts.city.id}`, '', false, (q) =>
239
+ await this.candidates(this.ctx, region, `streets:${opts.city.id}`, '', false, (q) =>
193
240
  p.searchStreets!(this.ctx, q, opts.city),
194
241
  )
195
242
  }
@@ -199,24 +246,35 @@ export class OutageClient {
199
246
  * or settlement can't be picked with confidence, throws `not-found` listing the suggestions.
200
247
  */
201
248
  async findGroup(region: RegionId, address: AddressQuery): Promise<GroupMatch> {
249
+ return this.findGroupIn(this.ctx, region, address)
250
+ }
251
+
252
+ private async findGroupIn(
253
+ ctx: ProviderContext,
254
+ region: RegionId,
255
+ address: AddressQuery,
256
+ ): Promise<GroupMatch> {
202
257
  const direct = this.provider(region)
203
- if (direct.lookupAddress) return direct.lookupAddress(this.ctx, address)
258
+ if (direct.lookupAddress) return direct.lookupAddress(ctx, address)
204
259
  const p = this.addressProvider(region)
205
260
 
206
261
  let city: City | undefined
207
262
  if (p.searchCities) {
208
263
  if (!address.city)
209
264
  throw new OutageError('not-found', `${p.info.name}: specify a city or village`, { region })
210
- const cities = await this.searchCities(region, address.city, { limit: 10, district: address.district })
265
+ const cities = await this.searchCitiesIn(ctx, region, address.city, {
266
+ limit: 10,
267
+ district: address.district,
268
+ })
211
269
  city = choose(address.city, cities, 'city', region, (c) =>
212
270
  c.district ? `${c.name} (${c.district})` : c.name,
213
271
  )
214
272
  }
215
273
 
216
- const streets = await this.searchStreets(region, address.street, { city, limit: 10 })
274
+ const streets = await this.searchStreetsIn(ctx, region, address.street, { city, limit: 10 })
217
275
  const street = choose(address.street, streets, 'street', region, (s) => s.name)
218
276
 
219
- const houses = await p.searchHouses!(this.ctx, street, address.house)
277
+ const houses = await p.searchHouses!(ctx, street, address.house)
220
278
  const want = normalizeHouse(address.house)
221
279
  const house = houses.find((h) => normalizeHouse(h.number) === want)
222
280
  if (!house)
@@ -230,7 +288,7 @@ export class OutageClient {
230
288
  }`,
231
289
  { region },
232
290
  )
233
- return p.groupOf!(this.ctx, house, street, city)
291
+ return p.groupOf!(ctx, house, street, city)
234
292
  }
235
293
 
236
294
  /**
@@ -239,6 +297,7 @@ export class OutageClient {
239
297
  * query alone finds little, so a typo in one part of the name still lands.
240
298
  */
241
299
  private async rankedSearch<T extends { id: string; name: string }>(
300
+ ctx: ProviderContext,
242
301
  region: RegionId,
243
302
  kind: NameKind,
244
303
  query: string,
@@ -248,13 +307,17 @@ export class OutageClient {
248
307
  limit: number,
249
308
  ): Promise<(T & { score: number })[]> {
250
309
  if (!query.trim()) return []
251
- let pool = await this.candidates(region, scope, query, remote, fetch)
310
+ let pool = await this.candidates(ctx, region, scope, query, remote, fetch)
252
311
  let ranked = rankByName(query, pool, (t) => t.name, kind)
253
312
  // a typo defeats the operator's substring search: retry with fragments of the word
254
313
  if (remote && (ranked[0]?.score ?? 0) < 0.9) {
255
314
  const extra = await Promise.all(
256
315
  searchFragments(query, kind).map((f) =>
257
- this.candidates(region, scope, f, true, fetch).catch(() => []),
316
+ this.candidates(ctx, region, scope, f, true, fetch).catch(() => {
317
+ // a cancelled lookup stops here; any other failure of a fragment is just no extra hits
318
+ ctx.signal?.throwIfAborted()
319
+ return []
320
+ }),
258
321
  ),
259
322
  )
260
323
  const seen = new Map(pool.map((t) => [t.id, t]))
@@ -267,6 +330,7 @@ export class OutageClient {
267
330
 
268
331
  /** Candidate lists, cached briefly: whole directories per scope, server results per term. */
269
332
  private candidates<T>(
333
+ ctx: ProviderContext,
270
334
  region: RegionId,
271
335
  scope: string,
272
336
  query: string,
@@ -275,11 +339,19 @@ export class OutageClient {
275
339
  ): Promise<T[]> {
276
340
  const key = `${region}|${scope}|${remote ? query.trim().toLowerCase() : ''}`
277
341
  const hit = this.searchCache.get(key)
278
- if (hit && Date.now() - hit.at < Math.max(this.cacheTtlMs, 60_000)) return hit.value as Promise<T[]>
342
+ if (hit && Date.now() - hit.at < Math.max(this.cacheTtlMs, 60_000))
343
+ return abortable(hit.value as Promise<T[]>, ctx.signal)
279
344
  const value = fetch(query)
280
- if (this.searchCache.size > 500) this.searchCache.clear()
281
- this.searchCache.set(key, { at: Date.now(), value })
282
- value.catch(() => this.searchCache.delete(key))
345
+ const store = () => {
346
+ if (this.searchCache.size > 500) this.searchCache.clear()
347
+ this.searchCache.set(key, { at: Date.now(), value })
348
+ }
349
+ // a cancellable lookup isn't shared while it runs, so no other caller inherits its cancellation
350
+ if (ctx.signal) value.then(store, () => {})
351
+ else {
352
+ store()
353
+ value.catch(() => this.searchCache.delete(key))
354
+ }
283
355
  return value
284
356
  }
285
357
 
@@ -292,17 +364,25 @@ export class OutageClient {
292
364
  async healthCheck(opts: HealthCheckOptions = {}): Promise<HealthReport> {
293
365
  const ids = opts.regions ?? [...this.providers.keys()]
294
366
  const staleMs = (opts.staleAfterHours ?? 72) * 3600_000
295
- const results = await mapLimit(ids, opts.concurrency ?? 6, async (id): Promise<RegionHealth> => {
296
- const health = await this.checkRegion(id, staleMs)
297
- const p = this.provider(id)
298
- if (!opts.address || !p.sampleAddress || !p.info.capabilities.addressSearch) return health
299
- try {
300
- const m = await this.findGroup(id, p.sampleAddress)
301
- return { ...health, address: { ok: true, group: m.group } }
302
- } catch (e) {
303
- return { ...health, address: { ok: false, message: (e as Error).message } }
304
- }
305
- })
367
+ const { signal } = opts
368
+ const results = await mapLimit(
369
+ ids,
370
+ opts.concurrency ?? 6,
371
+ async (id): Promise<RegionHealth> => {
372
+ const health = await this.checkRegion(id, staleMs, signal)
373
+ const p = this.provider(id)
374
+ if (!opts.address || !p.sampleAddress || !p.info.capabilities.addressSearch) return health
375
+ signal?.throwIfAborted()
376
+ try {
377
+ const m = await abortable(this.findGroupIn(this.context(signal), id, p.sampleAddress), signal)
378
+ return { ...health, address: { ok: true, group: m.group } }
379
+ } catch (e) {
380
+ if (signal?.aborted) throw signal.reason
381
+ return { ...health, address: { ok: false, message: (e as Error).message } }
382
+ }
383
+ },
384
+ signal,
385
+ )
306
386
  return {
307
387
  checkedAt: this.ctx.now().toISOString(),
308
388
  ok: results.every((r) => r.status !== 'error' && (r.address?.ok ?? true)),
@@ -310,7 +390,7 @@ export class OutageClient {
310
390
  }
311
391
  }
312
392
 
313
- private async checkRegion(region: RegionId, staleMs: number): Promise<RegionHealth> {
393
+ private async checkRegion(region: RegionId, staleMs: number, signal?: AbortSignal): Promise<RegionHealth> {
314
394
  const info = this.provider(region).info
315
395
  // e.g. operators that publish the schedule only as an image: nothing to check
316
396
  if (!info.capabilities.schedule)
@@ -324,11 +404,12 @@ export class OutageClient {
324
404
  }
325
405
  const started = performance.now()
326
406
  try {
327
- const s = await this.getSchedules(region, { fresh: true })
407
+ const s = await this.getSchedules(region, { fresh: true, signal })
328
408
  const latencyMs = Math.round(performance.now() - started)
329
409
  const base = { region, latencyMs, groups: s.groups.length, updatedAt: s.updatedAt }
330
410
  const hasData = Object.values(s.schedules).some((days) =>
331
- days.some((d) => d.intervals.length > 0 || d.status === 'no-outages'),
411
+ // anything the operator published counts, a day without outages included
412
+ days.some((d) => d.status !== 'unknown'),
332
413
  )
333
414
  if (!s.groups.length || !hasData)
334
415
  return { ...base, status: 'empty', message: 'No schedule published right now' }
@@ -336,6 +417,7 @@ export class OutageClient {
336
417
  return { ...base, status: 'stale', message: `Last updated ${s.updatedAt}` }
337
418
  return { ...base, status: 'ok' }
338
419
  } catch (e) {
420
+ if (signal?.aborted) throw signal.reason
339
421
  // e.g. a region that publishes only an image the SDK can't read
340
422
  if (e instanceof OutageError && e.code === 'unsupported')
341
423
  return { region, status: 'empty', latencyMs: 0, groups: 0, updatedAt: null, message: e.message }
@@ -352,6 +434,11 @@ export class OutageClient {
352
434
 
353
435
  // --- internals ---
354
436
 
437
+ /** Provider context for one call; with a signal, its requests stop when the call is cancelled */
438
+ private context(signal?: AbortSignal): ProviderContext {
439
+ return signal ? { ...this.ctx, http: this.ctx.http.withSignal(signal), signal } : this.ctx
440
+ }
441
+
355
442
  private provider(id: RegionId): Provider {
356
443
  const p = this.providers.get(id)
357
444
  if (!p) throw new OutageError('unsupported', `Unknown region "${id}"`, { region: id })
@@ -407,6 +494,13 @@ const CONFIDENT = 0.7
407
494
  /** …and only when the runner-up is further behind than this */
408
495
  const TIE = 0.025
409
496
 
497
+ export interface ScheduleOptions {
498
+ /** Skip the cache and fetch from the operator */
499
+ fresh?: boolean
500
+ /** Cancels the call: it rejects with the signal's reason and sends no further requests */
501
+ signal?: AbortSignal
502
+ }
503
+
410
504
  export interface SearchOptions {
411
505
  /** Max results, default 10 */
412
506
  limit?: number
package/src/http.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { OutageError } from './errors.js'
2
2
  import { httpsFetchWithCa } from './https-fetch.js'
3
3
  import type { RegionId } from './types.js'
4
+ import { abortable } from './util.js'
4
5
 
5
6
  export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
6
7
 
@@ -41,6 +42,9 @@ const BLOCK_MARKERS = [
41
42
  'Attention Required!',
42
43
  ]
43
44
 
45
+ /** A bot-protection page rather than the operator's content. Challenge pages are small; real pages may embed the same scripts, so size matters. */
46
+ const isChallenge = (body: string) => body.length < 20_000 && BLOCK_MARKERS.some((m) => body.includes(m))
47
+
44
48
  export class Http {
45
49
  private readonly fetchImpl: FetchLike
46
50
  private readonly customFetch: boolean
@@ -48,6 +52,8 @@ export class Http {
48
52
  private readonly timeoutMs: number
49
53
  private readonly userAgent: string
50
54
  private readonly retries: number
55
+ /** Set on copies made by `withSignal`: every request through them stops when it aborts */
56
+ private signal?: AbortSignal
51
57
 
52
58
  constructor(opts: HttpOptions = {}) {
53
59
  this.fetchImpl = opts.fetch ?? ((input, init) => globalThis.fetch(input, init))
@@ -57,6 +63,17 @@ export class Http {
57
63
  this.retries = opts.retries ?? 1
58
64
  }
59
65
 
66
+ /**
67
+ * The same client (settings, connections) whose requests all stop when `signal` aborts:
68
+ * none starts after that, and the one in flight is cancelled. `undefined` gives an unbound copy.
69
+ */
70
+ withSignal(signal: AbortSignal | undefined): Http {
71
+ const copy = Object.create(Http.prototype) as Http
72
+ Object.assign(copy, this)
73
+ copy.signal = signal
74
+ return copy
75
+ }
76
+
60
77
  async text(url: string, opts: RequestOptions = {}): Promise<string> {
61
78
  return (await this.raw(url, opts)).body
62
79
  }
@@ -67,11 +84,11 @@ export class Http {
67
84
  opts: RequestOptions = {},
68
85
  ): Promise<{ body: string; headers: Headers; status: number }> {
69
86
  const res = await this.request(url, opts)
87
+ const signal = this.signal
70
88
  const body = opts.encoding
71
- ? new TextDecoder(opts.encoding).decode(await res.arrayBuffer())
72
- : await res.text()
73
- // challenge pages are small; real pages may embed the same scripts, so size matters
74
- if (body.length < 20_000 && BLOCK_MARKERS.some((m) => body.includes(m)))
89
+ ? new TextDecoder(opts.encoding).decode(await abortable(res.arrayBuffer(), signal))
90
+ : await abortable(res.text(), signal)
91
+ if (isChallenge(body))
75
92
  throw new OutageError('blocked', `Bot protection answered instead of ${host(url)}`, {
76
93
  region: opts.region,
77
94
  url,
@@ -88,7 +105,8 @@ export class Http {
88
105
  /** Binary body with the response headers (e.g. Last-Modified of an image) */
89
106
  async file(url: string, opts: RequestOptions = {}): Promise<{ bytes: Uint8Array; headers: Headers }> {
90
107
  const res = await this.request(url, opts)
91
- return { bytes: new Uint8Array(await res.arrayBuffer()), headers: res.headers }
108
+ const bytes = await abortable(res.arrayBuffer(), this.signal)
109
+ return { bytes: new Uint8Array(bytes), headers: res.headers }
92
110
  }
93
111
 
94
112
  async json<T = unknown>(url: string, opts: RequestOptions = {}): Promise<T> {
@@ -112,6 +130,7 @@ export class Http {
112
130
  }
113
131
 
114
132
  private async request(url: string, opts: RequestOptions): Promise<Response> {
133
+ const signal = this.signal
115
134
  const headers: Record<string, string> = {
116
135
  'User-Agent': this.userAgent,
117
136
  Accept: 'application/json, text/html;q=0.9, */*;q=0.8',
@@ -127,16 +146,23 @@ export class Http {
127
146
 
128
147
  const fetchImpl = opts.ca && !this.customFetch ? this.fetchWithCa(opts.ca) : this.fetchImpl
129
148
  for (let attempt = 0; ; attempt++) {
149
+ // a cancelled call sends nothing more, retries included
150
+ signal?.throwIfAborted()
151
+ const timeout = AbortSignal.timeout(this.timeoutMs)
130
152
  let res: Response
131
153
  try {
132
- res = await fetchImpl(url, {
133
- method: opts.method ?? (body ? 'POST' : 'GET'),
134
- headers,
135
- body,
136
- signal: AbortSignal.timeout(this.timeoutMs),
137
- redirect: 'follow',
138
- })
154
+ res = await abortable(
155
+ fetchImpl(url, {
156
+ method: opts.method ?? (body ? 'POST' : 'GET'),
157
+ headers,
158
+ body,
159
+ signal: signal ? withTimeout(signal, timeout) : timeout,
160
+ redirect: 'follow',
161
+ }),
162
+ signal,
163
+ )
139
164
  } catch (cause) {
165
+ if (signal?.aborted) throw signal.reason
140
166
  if (attempt < this.retries) continue
141
167
  const timedOut = (cause as Error)?.name === 'TimeoutError'
142
168
  throw new OutageError(
@@ -146,6 +172,17 @@ export class Http {
146
172
  )
147
173
  }
148
174
  if (res.ok || opts.acceptStatus?.includes(res.status)) return res
175
+ // a bot check often comes as a 503: look before retrying, and never ask it twice
176
+ const errorBody = await abortable(res.text(), signal).catch(() => {
177
+ if (signal?.aborted) throw signal.reason
178
+ return ''
179
+ })
180
+ if (isChallenge(errorBody))
181
+ throw new OutageError('blocked', `Bot protection answered instead of ${host(url)}`, {
182
+ region: opts.region,
183
+ url,
184
+ status: res.status,
185
+ })
149
186
  if (res.status >= 500 && attempt < this.retries) continue
150
187
  throw new OutageError(
151
188
  res.status === 403 || res.status === 429 ? 'blocked' : 'http',
@@ -156,6 +193,30 @@ export class Http {
156
193
  }
157
194
  }
158
195
 
196
+ /**
197
+ * Aborts on the caller's signal or the per-request timeout: `AbortSignal.any`, which Node 20 has
198
+ * only from 20.3. The fallback's listeners go away when either fires, and the timeout always
199
+ * fires, so a caller's signal shared by many requests doesn't collect them.
200
+ */
201
+ function withTimeout(caller: AbortSignal, timeout: AbortSignal): AbortSignal {
202
+ if (typeof AbortSignal.any === 'function') return AbortSignal.any([timeout, caller])
203
+ const ctrl = new AbortController()
204
+ if (caller.aborted) ctrl.abort(caller.reason)
205
+ else if (timeout.aborted) ctrl.abort(timeout.reason)
206
+ else {
207
+ const onCaller = () => ctrl.abort(caller.reason)
208
+ const onTimeout = () => ctrl.abort(timeout.reason)
209
+ const cleanup = () => {
210
+ caller.removeEventListener('abort', onCaller)
211
+ timeout.removeEventListener('abort', onTimeout)
212
+ }
213
+ caller.addEventListener('abort', onCaller, { once: true })
214
+ timeout.addEventListener('abort', onTimeout, { once: true })
215
+ ctrl.signal.addEventListener('abort', cleanup, { once: true })
216
+ }
217
+ return ctrl.signal
218
+ }
219
+
159
220
  function host(url: string) {
160
221
  try {
161
222
  return new URL(url).host
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { OutageClient } from './client.js'
2
- export type { HealthCheckOptions, HealthReport, OutageClientOptions, SearchOptions } from './client.js'
2
+ export type {
3
+ HealthCheckOptions,
4
+ HealthReport,
5
+ OutageClientOptions,
6
+ ScheduleOptions,
7
+ SearchOptions,
8
+ } from './client.js'
3
9
  export { OutageError, isOutageError } from './errors.js'
4
10
  export { fuzzyScore, rankByName } from './fuzzy.js'
5
11
  export type { NameKind } from './fuzzy.js'
@@ -63,6 +63,16 @@ export function parseGpvDays(
63
63
  for (const [rawGroup, cells] of Object.entries(groups)) {
64
64
  const group = normalizeGroup(rawGroup)
65
65
  if (!group || !cells || typeof cells !== 'object') continue
66
+ // a group without cells says nothing about its day: unknown, not "power on all day"
67
+ const hours = Object.keys(cells).filter((k) => /^\d+$/.test(k))
68
+ if (!hours.length) {
69
+ ;(out[group] ??= []).push({ date, status: 'unknown', intervals: [] })
70
+ continue
71
+ }
72
+ // a cell value nobody has seen is reported, not read as "on"
73
+ const odd = hours.map((k) => String(cells[k]).toLowerCase()).find((v) => v !== 'yes' && !HALF[v])
74
+ if (odd !== undefined)
75
+ throw new OutageError('parse', `GPV: unknown cell value "${odd}" in ${rawGroup}`, { region })
66
76
  ;(out[group] ??= []).push(makeDay(date, gpvCellsToSlots(cells)))
67
77
  }
68
78
  }
@@ -31,10 +31,13 @@ export async function scheduleFromImages(
31
31
  let lastError = ''
32
32
  let read = false
33
33
  for (const img of images) {
34
+ ctx.signal?.throwIfAborted()
34
35
  let cells
35
36
  try {
36
37
  cells = readScheduleTable(decodePng(await ctx.http.bytes(url(img.path), { region })), layout)
37
38
  } catch (e) {
39
+ // a cancelled call is not an unreadable image
40
+ ctx.signal?.throwIfAborted()
38
41
  if (e instanceof OutageError) throw e
39
42
  lastError = `${img.path}: ${(e as Error).message}`
40
43
  continue
@@ -1,5 +1,5 @@
1
1
  import { OutageError } from '../errors.js'
2
- import { compareGroups, makeDay, normalizeGroup, type MinuteSlot } from '../schedule.js'
2
+ import { compareGroups, makeDay, normalizeGroup, unpublished, type MinuteSlot } from '../schedule.js'
3
3
  import { parseClock, toKyivIso } from '../time.js'
4
4
  import type { AddressQuery, DaySchedule, GroupId, GroupMatch, RegionInfo, RegionSchedule } from '../types.js'
5
5
  import type { Provider, ProviderContext } from './provider.js'
@@ -75,8 +75,11 @@ export function ivanoFrankivskProvider(): Provider {
75
75
  sampleAddress: { city: 'Івано-Франківськ', street: 'Галицька', house: '1' },
76
76
 
77
77
  async getSchedules(ctx) {
78
- const responses = await mapLimit(GROUPS, CONCURRENCY, (g) =>
79
- ctx.http.json<unknown>(`${API}/schedule-by-queue?queue=${g}`, { region }),
78
+ const responses = await mapLimit(
79
+ GROUPS,
80
+ CONCURRENCY,
81
+ (g) => ctx.http.json<unknown>(`${API}/schedule-by-queue?queue=${g}`, { region }),
82
+ ctx.signal,
80
83
  )
81
84
  return parseQueueSchedules(responses, ctx.now())
82
85
  },
@@ -155,7 +158,7 @@ export function parseQueueSchedules(responses: readonly unknown[], now: Date): R
155
158
  }
156
159
 
157
160
  const dates = [...byDate.keys()].sort()
158
- if (!dates.length) return { region, groups: [], schedules: {}, updatedAt: null, fetchedAt: toKyivIso(now) }
161
+ if (!dates.length) return unpublished(region, GROUPS, now)
159
162
 
160
163
  const groups = [...new Set([...GROUPS, ...dates.flatMap((d) => [...byDate.get(d)!.keys()])])].sort(
161
164
  compareGroups,
@@ -198,8 +198,8 @@ export function textSchedules(
198
198
  html: string,
199
199
  now: Date,
200
200
  from: string,
201
- ): Map<string, Record<string, MinuteSlot[]>> {
202
- const out = new Map<string, Record<string, MinuteSlot[]>>()
201
+ ): Map<string, Record<string, MinuteSlot[] | null>> {
202
+ const out = new Map<string, Record<string, MinuteSlot[] | null>>()
203
203
  const start = html.indexOf('class="post"')
204
204
  if (start === -1) return out
205
205
  const end = html.indexOf('Перевірити чергу', start)
@@ -220,11 +220,20 @@ export function textSchedules(
220
220
  return out
221
221
  }
222
222
 
223
- function mergeText(images: RegionSchedule, text: Map<string, Record<string, MinuteSlot[]>>): RegionSchedule {
223
+ function mergeText(
224
+ images: RegionSchedule,
225
+ text: Map<string, Record<string, MinuteSlot[] | null>>,
226
+ ): RegionSchedule {
224
227
  if (!text.size) return images
225
228
  const schedules: Record<string, DaySchedule[]> = { ...images.schedules }
226
229
  for (const [date, groups] of text)
227
- for (const g of STANDARD_GROUPS) schedules[g] = [...(schedules[g] ?? []), makeDay(date, groups[g] ?? [])]
230
+ for (const g of STANDARD_GROUPS) {
231
+ const slots = groups[g]
232
+ // named without times or a "power on" statement: not known (an unlisted group has none)
233
+ const day: DaySchedule =
234
+ slots === null ? { date, status: 'unknown', intervals: [] } : makeDay(date, slots ?? [])
235
+ schedules[g] = [...(schedules[g] ?? []), day]
236
+ }
228
237
  for (const g of Object.keys(schedules)) schedules[g]!.sort((a, b) => a.date.localeCompare(b.date))
229
238
  return { ...images, groups: Object.keys(schedules).sort(compareGroups), schedules }
230
239
  }
@@ -14,7 +14,7 @@ import type {
14
14
  Street,
15
15
  } from '../types.js'
16
16
  import type { Provider, ProviderContext } from './provider.js'
17
- import { cookieHeader, mapLimit } from '../util.js'
17
+ import { SharedTask, cookieHeader, mapLimit } from '../util.js'
18
18
 
19
19
  /**
20
20
  * Kirovohradoblenergo (kiroe.com.ua). The /electricity-blackout page hands out a PHP session
@@ -71,7 +71,7 @@ export interface ApiHouse {
71
71
 
72
72
  export function kirovohradProvider(): Provider {
73
73
  const region = INFO.id
74
- let current: Promise<Session> | null = null
74
+ let current: SharedTask<Session> | null = null
75
75
  let currentAt = 0
76
76
  let cities: Promise<City[]> | null = null
77
77
  // house id → its number and groups, filled by searchHouses so groupOf needs no extra request
@@ -95,15 +95,19 @@ export function kirovohradProvider(): Provider {
95
95
  }
96
96
 
97
97
  const session = (ctx: ProviderContext, renew = false): Promise<Session> => {
98
- if (renew || !current || Date.now() - currentAt > SESSION_TTL_MS) {
98
+ if (renew || !current || current.aborted || Date.now() - currentAt > SESSION_TTL_MS) {
99
99
  currentAt = Date.now()
100
- const p = bootstrap(ctx)
101
- current = p
102
- p.catch(() => {
103
- if (current === p) current = null
100
+ // shared by concurrent calls: one caller cancelling doesn't fail the others, and it is
101
+ // cancelled only when every caller waiting for it has given up
102
+ const task = new SharedTask((signal) =>
103
+ bootstrap({ ...ctx, http: ctx.http.withSignal(signal), signal }),
104
+ )
105
+ current = task
106
+ task.promise.catch(() => {
107
+ if (current === task) current = null
104
108
  })
105
109
  }
106
- return current
110
+ return current.join(ctx.signal)
107
111
  }
108
112
 
109
113
  const post = async (ctx: ProviderContext, s: Session, path: string): Promise<unknown> => {