ts-server-lib 0.0.48

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.
Files changed (46) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +8 -0
  3. package/db/TSJournal.d.ts +108 -0
  4. package/db/TSJournal.js +229 -0
  5. package/db/TSMongo.d.ts +103 -0
  6. package/db/TSMongo.js +516 -0
  7. package/db/TSRQW.d.ts +625 -0
  8. package/db/TSRQW.js +1204 -0
  9. package/db/TSRedis.d.ts +530 -0
  10. package/db/TSRedis.js +1368 -0
  11. package/db/TSRedisTB.d.ts +80 -0
  12. package/db/TSRedisTB.js +178 -0
  13. package/package.json +85 -0
  14. package/ussd/TSUssdMenu.d.ts +139 -0
  15. package/ussd/TSUssdMenu.js +368 -0
  16. package/ussd/TSUssdScreen.d.ts +58 -0
  17. package/ussd/TSUssdScreen.js +218 -0
  18. package/ussd/index.d.ts +3 -0
  19. package/ussd/index.js +19 -0
  20. package/ussd/providers/AfricasTalking.d.ts +3 -0
  21. package/ussd/providers/AfricasTalking.js +17 -0
  22. package/ussd/providers/AirtelDRC.d.ts +9 -0
  23. package/ussd/providers/AirtelDRC.js +31 -0
  24. package/ussd/providers/OrangeDRC.d.ts +5 -0
  25. package/ussd/providers/OrangeDRC.js +213 -0
  26. package/ussd/providers/VodacomDRC.d.ts +9 -0
  27. package/ussd/providers/VodacomDRC.js +48 -0
  28. package/ussd/providers/_.d.ts +55 -0
  29. package/ussd/providers/_.js +83 -0
  30. package/ussd/providers/index.d.ts +13 -0
  31. package/ussd/providers/index.js +56 -0
  32. package/utils/TSFifo.d.ts +109 -0
  33. package/utils/TSFifo.js +145 -0
  34. package/utils/TSFile.d.ts +36 -0
  35. package/utils/TSFile.js +244 -0
  36. package/utils/TSHash.d.ts +19 -0
  37. package/utils/TSHash.js +71 -0
  38. package/utils/TSRequest.d.ts +248 -0
  39. package/utils/TSRequest.js +689 -0
  40. package/utils/TSStub.d.ts +159 -0
  41. package/utils/TSStub.js +296 -0
  42. package/utils/abort.d.ts +18 -0
  43. package/utils/abort.js +97 -0
  44. package/utils/mime.json +11358 -0
  45. package/utils/object-keys.d.ts +39 -0
  46. package/utils/object-keys.js +52 -0
@@ -0,0 +1,689 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TSRequest = exports.FEED_XML_PARSER_WORKER_CONFIG = exports.FEED_XML_PARSER_DATA_OPTIONS = exports.feedXmlIsArray = exports.XML_WORKER_MAX_OLD_GEN_MB = exports.XML_WORKER_TASK_TIMEOUT_MS = exports.XML_OFFTHREAD_MIN_BYTES = void 0;
37
+ exports.configureXmlParsing = configureXmlParsing;
38
+ exports.getXmlParsingConfig = getXmlParsingConfig;
39
+ exports.feedXmlParserOptions = feedXmlParserOptions;
40
+ exports.buildFeedXmlParserOptions = buildFeedXmlParserOptions;
41
+ exports.getXmlParseStats = getXmlParseStats;
42
+ exports.resetXmlParseStatsForTests = resetXmlParseStatsForTests;
43
+ exports.closeXmlWorkerPool = closeXmlWorkerPool;
44
+ exports.parseXmlDocument = parseXmlDocument;
45
+ exports.request = request;
46
+ const http = __importStar(require("http"));
47
+ const https = __importStar(require("https"));
48
+ const querystring_1 = require("querystring");
49
+ const fast_xml_parser_1 = require("fast-xml-parser");
50
+ const abort_1 = require("./abort");
51
+ /**
52
+ * Body size at or above which XML parsing is moved off the main thread.
53
+ *
54
+ * `fast-xml-parser` is synchronous and un-interruptible, so a large REST response blocks the event
55
+ * loop for the whole parse. Measured in a sports feed replay: a `matches` schedule sweep (~30 day
56
+ * schedules plus tournament schedules, one 16-tournament chunk holding 14,266 events) produced
57
+ * event-loop stalls of ~364 ms and periodic >1 s AMQP dispatch breaches on every `matches` cron minute.
58
+ * The AMQP path never had this problem because it already parses inside a TSRQW worker; only REST
59
+ * parsed inline.
60
+ *
61
+ * Below this size, offloading is a net loss: the structured-clone round-trip to a worker costs more
62
+ * than parsing a small document. Tuned to sit above ordinary API replies and below the multi-hundred-KB
63
+ * schedule documents that actually stall the loop.
64
+ *
65
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
66
+ * 🔴 **128 KB -> 64 KB, 2026-08-14. The schedule documents were missing the worker by 2.8 KB.**
67
+ *
68
+ * The paragraph above predicted "multi-hundred-KB schedule documents". Measured, they are not: the largest
69
+ * inline document is **125.2 KB**, i.e. **2.8 KB under the old 128 KB cutoff**, so the exact population this
70
+ * threshold exists to offload was taking the inline path every time. The same near-miss was recorded once
71
+ * before at 130,676 bytes against 131,072 (396 bytes under) — twice is a pattern, not a coincidence: UOF
72
+ * schedule responses cluster just below 128 KB.
73
+ *
74
+ * Measured on the sports feed, 6,124 inline parses over one catalog window:
75
+ *
76
+ * mean inline document 5.4 KB -> 1.14 ms <- thousands of small entity fetches
77
+ * MAX inline document 125.2 KB -> 42 ms <- the schedule documents
78
+ * aggregate inline time 6,995 ms (~2 % duty)
79
+ *
80
+ * ⚠ **Do NOT size this from the mean.** 5.4 KB / 1.14 ms makes inline parsing look free, and that average is
81
+ * what hid the problem: it mixes two populations — many tiny documents that dominate the COUNT and a few large
82
+ * ones that dominate the COST. The relevant statistic is `inlineMaxMs` x caller concurrency.
83
+ *
84
+ * Why it mattered: the REST callers fetch N documents through one `Promise.all`, so N parses resolve
85
+ * back-to-back with no yield between them. At N=8 that is 8 x 42 ms = **336 ms** of un-yieldable main-thread
86
+ * time; observed `lagPeakMs` on those stages was 338 / 368 / 548 / 676 / 1,301 / 1,640 ms **with dispatch at
87
+ * zero**. The consuming service's background contract is a 4 ms cooperative slice, and a monolithic
88
+ * `XMLParser().parse()` cannot be sliced at all — so the only way background XML honours that contract is to
89
+ * leave this thread.
90
+ *
91
+ * 64 KB is chosen to move that population and nothing else: it is ~12x the 5.4 KB mean, so small documents keep
92
+ * the inline path where the round trip genuinely costs more than the parse. `TSRequestXml.spec.ts` already
93
+ * bounds this constant to [64 KB, 512 KB]; this sits at that floor deliberately.
94
+ *
95
+ * 🟢 **Safe for the money path by construction:** AMQP messages never reach this function — they parse inside a
96
+ * TSRQW worker pool (see the note above, and `threadFactor` in the atfeed betradar driver). Only REST does, and
97
+ * in the sports feed REST is exclusively the catalog/schedule sync, i.e. background work.
98
+ *
99
+ * Watch `offThread` count after this change: the pool is ONE worker, so if the 64-128 KB band turns out to hold
100
+ * thousands of documents rather than dozens they will serialise behind each other. That is still the right place
101
+ * for them — a queued worker does not block the loop — but it makes the catalog sweep slower, which is the
102
+ * trade being accepted here.
103
+ */
104
+ /**
105
+ * DEFAULT threshold. The authority for this number is this library's own measurement,
106
+ * `test/utils/TSRequestXmlCrossover.spec.ts`, which measures the main-thread BLOCK per mode across a size
107
+ * ladder. Change it against that spec's output, not against reasoning: it has been wrong twice before, in
108
+ * opposite directions, both times from reasoning alone.
109
+ *
110
+ * Two specs constrain it and the effective bound is their INTERSECTION, [64 KB, 128 KB] —
111
+ * `TSRequestXml.spec.ts` asserts [64 KB, 512 KB], the crossover spec asserts [32 KB, 128 KB]. 64 KB therefore
112
+ * sits exactly on the floor: it cannot be lowered by editing this line alone, which is deliberate.
113
+ *
114
+ * A consumer that wants a different value sets it at startup with {@link configureXmlParsing} instead. That
115
+ * accepts a wider range on purpose — probing is a runtime question, while this constant is a shipped default —
116
+ * and it keeps the workload that justifies a change, and the record of it, in the service that measured it.
117
+ */
118
+ exports.XML_OFFTHREAD_MIN_BYTES = 64 * 1024;
119
+ /**
120
+ * Default ceiling for one off-thread parse. Generous next to a ~1 MB document (~250 ms measured), because
121
+ * firing it also retires the worker — see `TSRQWPoolOptions.taskTimeoutMs`.
122
+ */
123
+ exports.XML_WORKER_TASK_TIMEOUT_MS = 30_000;
124
+ /** Hard bounds on a configured threshold. A value outside these is a mistake, not a tuning choice. */
125
+ const XML_OFFTHREAD_MIN_ALLOWED = 16 * 1024;
126
+ const XML_OFFTHREAD_MAX_ALLOWED = 4 * 1024 * 1024;
127
+ let xmlOffThreadMinBytes = exports.XML_OFFTHREAD_MIN_BYTES;
128
+ let xmlWorkerTaskTimeoutMs = exports.XML_WORKER_TASK_TIMEOUT_MS;
129
+ /**
130
+ * Override XML parsing behaviour for THIS process.
131
+ *
132
+ * The defaults above stay the measured-neutral values — 64 KB is the only threshold with evidence behind it
133
+ * (see {@link XML_OFFTHREAD_MIN_BYTES}), so a service that configures nothing gets exactly what was measured.
134
+ * This exists so a service can probe a different value from its OWN startup, without a library edit, a
135
+ * version bump across every consumer, or a hand-patched `node_modules` copy — all three of which were done
136
+ * on 2026-08-16 to test a single number.
137
+ *
138
+ * ⚠ Call this at startup, BEFORE the first large document is parsed. The worker pool is created lazily on
139
+ * first use and reads `workerTaskTimeoutMs` at construction, so a later change to the timeout applies only
140
+ * if the pool is rebuilt. `offThreadMinBytes` is read per call and takes effect immediately.
141
+ *
142
+ * Throws on an out-of-range or non-integer threshold rather than clamping: this value decides which parses
143
+ * hold the event loop, and a silently corrected one would misreport what a measurement actually ran.
144
+ */
145
+ function configureXmlParsing(opts) {
146
+ if (opts.offThreadMinBytes !== undefined) {
147
+ const n = opts.offThreadMinBytes;
148
+ if (!Number.isInteger(n) || n < XML_OFFTHREAD_MIN_ALLOWED || n > XML_OFFTHREAD_MAX_ALLOWED) {
149
+ throw new Error(`configureXmlParsing: offThreadMinBytes must be an integer in ` +
150
+ `[${XML_OFFTHREAD_MIN_ALLOWED}, ${XML_OFFTHREAD_MAX_ALLOWED}], got ${String(n)}`);
151
+ }
152
+ xmlOffThreadMinBytes = n;
153
+ }
154
+ if (opts.workerTaskTimeoutMs !== undefined) {
155
+ const t = opts.workerTaskTimeoutMs;
156
+ if (!Number.isInteger(t) || t < 0) {
157
+ throw new Error(`configureXmlParsing: workerTaskTimeoutMs must be a non-negative integer, got ${String(t)}`);
158
+ }
159
+ xmlWorkerTaskTimeoutMs = t;
160
+ }
161
+ }
162
+ /** The values actually in force — report these with a measurement, never the defaults. */
163
+ function getXmlParsingConfig() {
164
+ return { offThreadMinBytes: xmlOffThreadMinBytes, workerTaskTimeoutMs: xmlWorkerTaskTimeoutMs };
165
+ }
166
+ /**
167
+ * Old-generation ceiling for the single XML parse worker.
168
+ *
169
+ * Bounds a cost that was previously unbounded in two directions at once: an unbounded number of workers
170
+ * (see the pool construction) and an unbounded heap per worker. V8 does not return old-generation pages
171
+ * to the OS, so a long-lived worker's high-water mark IS the process's RSS for its lifetime.
172
+ *
173
+ * 192 MB is ~25x the parsed graph of the largest document observed (~1 MB of XML), so it is a runaway
174
+ * guard rather than a working limit. Raising it should be justified by `offThreadBytes / offThread`, not
175
+ * by a worker failure — a failure here degrades to inline parsing, it does not drop the document.
176
+ */
177
+ exports.XML_WORKER_MAX_OLD_GEN_MB = 192;
178
+ /**
179
+ * THE single definition of the feed `isArray` predicate: array-ify every non-attribute nested node.
180
+ *
181
+ * Everything else derives from this — including the worker copies, via
182
+ * {@link FEED_XML_PARSER_WORKER_CONFIG}. Do not restate this logic anywhere.
183
+ */
184
+ const feedXmlIsArray = (_n, jPath, _l, isAttr) => !isAttr && String(jPath).includes('.');
185
+ exports.feedXmlIsArray = feedXmlIsArray;
186
+ /**
187
+ * Data-only fast-xml-parser options (structured-cloneable — no functions).
188
+ *
189
+ * These are the values the atfeed Betradar AMQP worker had been carrying locally, promoted here so
190
+ * every feed parse — REST and AMQP — shares one configuration and atfeed no longer overrides anything.
191
+ *
192
+ * `ignoreDeclaration: true` was the one genuine divergence: the AMQP worker set it, while
193
+ * `TSRequest.xml` and atfeed's own `recovery-request.ts` parser did not. Without it, a document with an
194
+ * `<?xml …?>` prolog yields an extra `?xml` key in the parsed object, so REST and AMQP produced
195
+ * different shapes for the same markup. Adopting the AMQP value drops that key from REST parses too —
196
+ * a deliberate behaviour change that makes the two paths agree. Nothing reads `?xml`; it is prolog
197
+ * metadata, not content.
198
+ */
199
+ exports.FEED_XML_PARSER_DATA_OPTIONS = {
200
+ ignoreDeclaration: true,
201
+ ignoreAttributes: false,
202
+ attributeNamePrefix: '',
203
+ attributesGroupName: '$',
204
+ textNodeName: '_'
205
+ };
206
+ /**
207
+ * Canonical fast-xml-parser options for provider feed XML (REST **and** AMQP).
208
+ *
209
+ * Use this everywhere a feed document is parsed. Divergence between two parser configurations does not
210
+ * fail loudly — it silently changes the parsed shape, which for odds/settlement payloads means wrong
211
+ * market or outcome data rather than an error.
212
+ */
213
+ function feedXmlParserOptions() {
214
+ return { ...exports.FEED_XML_PARSER_DATA_OPTIONS, isArray: exports.feedXmlIsArray };
215
+ }
216
+ /**
217
+ * Options for a worker that must rebuild the parser itself.
218
+ *
219
+ * A TSRQW callback is stringified into an eval worker, so it closes over nothing, and `isArray` is a
220
+ * function — which structured-clone cannot transfer. Passing the predicate's SOURCE keeps a single
221
+ * definition: the worker reconstructs it with {@link buildFeedXmlParserOptions} rather than restating
222
+ * the logic. Derived via `toString()` so the source can never drift from the function above.
223
+ *
224
+ * Pass this as TSRQW's `workerData` (or merge it into an existing `workerData`).
225
+ */
226
+ exports.FEED_XML_PARSER_WORKER_CONFIG = {
227
+ ...exports.FEED_XML_PARSER_DATA_OPTIONS,
228
+ isArraySource: exports.feedXmlIsArray.toString()
229
+ };
230
+ /**
231
+ * Rebuild the canonical parser options inside a worker from {@link FEED_XML_PARSER_WORKER_CONFIG}.
232
+ *
233
+ * Safe to call from stringified worker code: it only needs the plain config object. `new Function` is
234
+ * used to revive the predicate — no additional risk in a context that is already an eval worker, and
235
+ * TSRQW's wrapper predefines `__name` for the same reason (bundlers inject it into `toString()` output).
236
+ */
237
+ function buildFeedXmlParserOptions(cfg) {
238
+ const { isArraySource, ...data } = cfg;
239
+ const isArray = isArraySource
240
+ ?
241
+ new Function(`"use strict"; return (${isArraySource});`)()
242
+ : exports.feedXmlIsArray;
243
+ return { ...data, isArray };
244
+ }
245
+ let xmlPool = null;
246
+ let xmlPoolUnavailable = false;
247
+ /**
248
+ * Lazily create the shared XML parse pool.
249
+ *
250
+ * Returns null when a pool cannot be created (and remembers that, so we do not retry per request).
251
+ * Every caller must fall back to inline parsing in that case — an unavailable worker must never turn
252
+ * into a failed request.
253
+ */
254
+ async function xmlWorkerPool() {
255
+ if (xmlPool)
256
+ return xmlPool;
257
+ if (xmlPoolUnavailable)
258
+ return null;
259
+ try {
260
+ // Imported lazily: TSRQW pulls in the Redis stack, and TSRequest must stay usable (and cheap) in
261
+ // processes that never parse a large XML document.
262
+ const { TSRQW } = await import('../db/TSRQW.js');
263
+ // Both arrows below must stay `async` even without an `await`: TSRQW calls the factory as
264
+ // `cb(workerData).then(fn => ...)` and each task as `fn(task).then(...)`, so both must return
265
+ // promises. Parsing itself is synchronous — that is the point; it happens on the worker thread.
266
+ /* eslint-disable @typescript-eslint/require-await -- TSRQW factory/handler contract is promise-based */
267
+ xmlPool = TSRQW.worker(async (wd) => {
268
+ /* eslint-disable @typescript-eslint/no-require-imports -- stringified into an eval worker; import() breaks under Vitest SSR */
269
+ const { XMLParser } = require('fast-xml-parser');
270
+ /* eslint-enable @typescript-eslint/no-require-imports */
271
+ // Rebuilt from the workerData config rather than restated here, so the predicate has exactly one
272
+ // definition (feedXmlIsArray) that both threads derive from. Inlined rather than imported because
273
+ // this callback is stringified into the worker and closes over nothing.
274
+ const cfg = (wd ?? {});
275
+ const { isArraySource, ...data } = cfg;
276
+ const isArray = isArraySource
277
+ ? new Function(`"use strict"; return (${isArraySource});`)()
278
+ : undefined;
279
+ const parser = new XMLParser({ ...data, ...(isArray ? { isArray } : {}) });
280
+ return async (task) => parser.parse(String(task?.body ?? ''));
281
+ }, 1, exports.FEED_XML_PARSER_WORKER_CONFIG, {
282
+ // ONE worker, stated as a number. `factor: 1` above reads like "one worker" and is TSRQW's
283
+ // default, but factor is a DIVISOR of the core count — so this pool silently ran
284
+ // availableParallelism() workers: 20 on the measured host, each with an unbounded V8 heap, each
285
+ // parsing ~1 MB schedule documents. RSS climbed 1,065 MB -> 2,015 MB while the main-thread heap
286
+ // stayed flat and GC'd normally, because `process.memoryUsage().heapUsed` does not see worker
287
+ // heaps. One thread is also the right number on merit: parses are serialised behind a single
288
+ // ~1 MB document anyway, and each extra thread is a whole V8 isolate.
289
+ threads: 1,
290
+ // A ceiling so this can never ratchet again, whatever the thread count. Generous next to a
291
+ // ~1 MB document's parsed graph, and overrun is survivable: the worker dies with
292
+ // ERR_WORKER_OUT_OF_MEMORY, TSRQW replaces it, and `parseXmlDocument` falls back to inline.
293
+ resourceLimits: { maxOldGenerationSizeMb: exports.XML_WORKER_MAX_OLD_GEN_MB },
294
+ // Bounds the ONE failure this fallback could not cover. Death by error or OOM rejects the task and
295
+ // degrades to inline; a worker that simply never replies did not, and with a single-worker pool that
296
+ // is not one lost document — the pool holds zero free workers and every later parse queues behind it
297
+ // forever. The timeout makes that case look like any other task failure.
298
+ taskTimeoutMs: xmlWorkerTaskTimeoutMs,
299
+ // Safe here because parsing is a PURE function: re-running it on a fresh worker cannot double a side
300
+ // effect, because there is none. Worth having even though this caller already degrades to inline —
301
+ // an inline retry moves the whole parse back onto the main thread, which is the cost the threshold
302
+ // exists to avoid, so trying another worker first is strictly better.
303
+ maxTaskRetries: 2
304
+ });
305
+ /* eslint-enable @typescript-eslint/require-await */
306
+ return xmlPool;
307
+ }
308
+ catch {
309
+ xmlPoolUnavailable = true;
310
+ return null;
311
+ }
312
+ }
313
+ /**
314
+ * `inlineBytes` / `inlineMaxBytes` exist to SIZE the threshold before anyone changes it.
315
+ *
316
+ * `XML_OFFTHREAD_MIN_BYTES` gates off-threading per document by SIZE, so many small documents all bypass
317
+ * the protection while their aggregate is what costs. Measured on the sports feed: 99 % of parses were
318
+ * inline (22,463 vs 118 off-thread, ~29/s during the schedule sweep) at roughly 8-13 % main-thread duty.
319
+ *
320
+ * Do NOT lower the threshold on that number alone. Off-threading pays structured-clone costs BOTH ways
321
+ * (body out, parsed object graph back) and the pool below is ONE worker, so lowering it can cost more
322
+ * than it saves for small documents. These counters give the size distribution the decision actually
323
+ * needs: `inlineBytes / inline` is the mean inline document, and `inlineMaxBytes` says how close the
324
+ * biggest inline document came to the cut-off.
325
+ *
326
+ * Note on the memory side of that trade, measured 2026-08-05: the parsed graph is cloned BACK to the main
327
+ * thread, so off-threading does not reduce main-thread memory at all — it saves parse CPU and costs a
328
+ * second copy of the graph plus the clone. `offThreadBytes / offThread` (measured ~0.94 MB) is the number
329
+ * to weigh against RSS before widening this path.
330
+ */
331
+ const xmlParseStats = {
332
+ inline: 0,
333
+ offThread: 0,
334
+ fallback: 0,
335
+ inlineBytes: 0,
336
+ inlineMaxBytes: 0,
337
+ offThreadBytes: 0,
338
+ /**
339
+ * WALL TIME of inline parses — the measurement the size counters above cannot substitute for.
340
+ *
341
+ * Sizes say how much data crossed the main thread; only time says whether that cost anything. Without it
342
+ * the threshold decision stays a guess in both directions: on 2026-08-10 the sports feed showed
343
+ * `inlineMaxBytes` 130,676 against a 131,072 cutoff (396 bytes under) while `matches:tournament_schedule`
344
+ * owned the largest event-loop blocks at 872.9 ms — suggestive, but that stage spans fetch, parse AND map
345
+ * building, so the parse could equally have been 5 ms of it. `inlineMs` settles which.
346
+ *
347
+ * Cheap by construction: one `Date.now()` pair around a call that is already the expensive thing.
348
+ */
349
+ inlineMs: 0,
350
+ inlineMaxMs: 0,
351
+ /**
352
+ * Documents HANDED to the pool, counted before the await — the counter whose absence made a stall
353
+ * unreadable.
354
+ *
355
+ * `inline` increments before its parse (arrivals) but `offThread` only after the await resolves
356
+ * (completions), so a queued or lost document appeared in NO counter. On 2026-08-16 `offThread` sat frozen
357
+ * for five minutes and "the pool is wedged" and "nothing was submitted" were indistinguishable from the
358
+ * snapshot — they need opposite fixes. `submitted - offThread - fallback` is the backlog, and `inFlight`
359
+ * is the same quantity as a live gauge.
360
+ */
361
+ submitted: 0,
362
+ inFlight: 0
363
+ };
364
+ /**
365
+ * Counters for how XML documents were parsed.
366
+ *
367
+ * `fallback` is the one to watch: a non-zero and growing value means large documents are being parsed
368
+ * on the main thread despite the threshold, so the event-loop protection is not actually in effect.
369
+ */
370
+ function getXmlParseStats() {
371
+ // The return type is written out explicitly rather than inferred, so it must be extended whenever
372
+ // `xmlParseStats` gains a field. The spread already copies every property at runtime, so a missing entry
373
+ // here does not lose data — it makes the data UNREADABLE to typed callers while looking correct, which is
374
+ // a worse failure than an error. Adding `inlineMs`/`inlineMaxMs` above compiled cleanly without this.
375
+ return { ...xmlParseStats };
376
+ }
377
+ function resetXmlParseStatsForTests() {
378
+ xmlParseStats.inline = 0;
379
+ xmlParseStats.offThread = 0;
380
+ xmlParseStats.fallback = 0;
381
+ xmlParseStats.inlineBytes = 0;
382
+ xmlParseStats.inlineMaxBytes = 0;
383
+ xmlParseStats.offThreadBytes = 0;
384
+ }
385
+ /** Shut the XML parse pool down (tests, graceful shutdown). Safe to call when none exists. */
386
+ function closeXmlWorkerPool() {
387
+ if (!xmlPool)
388
+ return;
389
+ try {
390
+ xmlPool.close();
391
+ }
392
+ catch {
393
+ /* already closing */
394
+ }
395
+ xmlPool = null;
396
+ xmlPoolUnavailable = false;
397
+ }
398
+ /**
399
+ * Parse an XML document, off the main thread when it is large enough to matter.
400
+ *
401
+ * Falls back to inline parsing when the document is small, when no worker pool can be created, or when
402
+ * the worker fails for any reason — the result must be identical either way, so the only difference a
403
+ * caller can observe is whether the event loop was blocked.
404
+ */
405
+ async function parseXmlDocument(body) {
406
+ const bytes = Buffer.byteLength(body);
407
+ if (bytes < xmlOffThreadMinBytes) {
408
+ xmlParseStats.inline++;
409
+ xmlParseStats.inlineBytes += bytes;
410
+ if (bytes > xmlParseStats.inlineMaxBytes)
411
+ xmlParseStats.inlineMaxBytes = bytes;
412
+ // Timed because this call blocks the event loop for its whole duration — see `inlineMs`.
413
+ const startedAt = Date.now();
414
+ const parsed = new fast_xml_parser_1.XMLParser(feedXmlParserOptions()).parse(body);
415
+ const elapsed = Date.now() - startedAt;
416
+ xmlParseStats.inlineMs += elapsed;
417
+ if (elapsed > xmlParseStats.inlineMaxMs)
418
+ xmlParseStats.inlineMaxMs = elapsed;
419
+ return parsed;
420
+ }
421
+ const pool = await xmlWorkerPool();
422
+ if (!pool) {
423
+ xmlParseStats.fallback++;
424
+ return new fast_xml_parser_1.XMLParser(feedXmlParserOptions()).parse(body);
425
+ }
426
+ xmlParseStats.submitted++;
427
+ xmlParseStats.inFlight++;
428
+ try {
429
+ const result = (await pool.work({ body }));
430
+ if (result && typeof result === 'object' && 'data' in result) {
431
+ xmlParseStats.offThread++;
432
+ xmlParseStats.offThreadBytes += bytes;
433
+ return result.data;
434
+ }
435
+ xmlParseStats.fallback++;
436
+ return new fast_xml_parser_1.XMLParser(feedXmlParserOptions()).parse(body);
437
+ }
438
+ catch {
439
+ // A malformed document also lands here, and re-parsing inline is deliberate: the caller then sees
440
+ // fast-xml-parser's own error rather than a worker-shaped one. A task TIMEOUT arrives here too, which
441
+ // is the point of having one: it turns a permanent hang into an inline parse.
442
+ xmlParseStats.fallback++;
443
+ return new fast_xml_parser_1.XMLParser(feedXmlParserOptions()).parse(body);
444
+ }
445
+ finally {
446
+ xmlParseStats.inFlight--;
447
+ }
448
+ }
449
+ class TSRequest {
450
+ static responseBody(response) {
451
+ if (typeof response === 'string')
452
+ return response;
453
+ if (response != null && typeof response === 'object' && 'data' in response) {
454
+ const data = response.data;
455
+ if (data == null)
456
+ return '';
457
+ if (typeof data === 'string')
458
+ return data;
459
+ return String(data);
460
+ }
461
+ return response == null ? '' : String(response);
462
+ }
463
+ static isNonXmlBody(body) {
464
+ const t = body.trimStart();
465
+ return t.startsWith('<!DOCTYPE') || t.startsWith('<html') || t.startsWith('<HTML');
466
+ }
467
+ static debugTrace(url, start, options, response) {
468
+ const ms = Number(new Date()) - Number(start);
469
+ const resp = typeof response === 'string' && response.length > 4000
470
+ ? `${response.slice(0, 4000)}…[truncated ${response.length} chars]`
471
+ : response;
472
+ console.log(`TSRequest:[${ms} ms][${url}]`, JSON.stringify({ REQUEST: options, RESPONSE: resp }));
473
+ }
474
+ static async form(url, options = {}, debug) {
475
+ options.headers = {
476
+ ...{ 'Accept': '*/*', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, ...(options.headers || {})
477
+ };
478
+ options.acceptAll = debug;
479
+ options.bcb = JSON.parse;
480
+ if (options.body !== null && typeof options.body === 'object' && !Buffer.isBuffer(options.body)) {
481
+ options.body = (0, querystring_1.stringify)(options.body);
482
+ }
483
+ const start = new Date();
484
+ return new Promise((resolve, reject) => TSRequest.raw(url, options).then(response => {
485
+ if (debug) {
486
+ TSRequest.debugTrace(url, start, options, response);
487
+ }
488
+ if (response) {
489
+ resolve(response);
490
+ }
491
+ else {
492
+ resolve(response);
493
+ }
494
+ }).catch(error => {
495
+ error.took = `TSRequest:[${Number(new Date()) - Number(start)} ms][${url}]`;
496
+ error.options = options;
497
+ reject(error);
498
+ }));
499
+ }
500
+ static async xml(url, options = {}, debug) {
501
+ const start = new Date();
502
+ options.headers = {
503
+ ...{ 'Accept': 'text/xml', 'Content-Type': 'text/xml; charset=UTF-8' }, ...(options.headers || {})
504
+ };
505
+ options.acceptAll = debug;
506
+ if (options.body !== null && typeof options.body === 'object' && !Buffer.isBuffer(options.body)) {
507
+ options.body = new fast_xml_parser_1.XMLBuilder({ ignoreAttributes: false, attributeNamePrefix: '', attributesGroupName: '$', textNodeName: '_' }).build(options.body);
508
+ }
509
+ if (options.body != null && options.body !== '') {
510
+ Buffer.byteLength(typeof options.body === 'string' ? options.body : String(options.body));
511
+ }
512
+ return new Promise((resolve, reject) => TSRequest.raw(url, options).then((response) => {
513
+ const body = TSRequest.responseBody(response);
514
+ if (debug) {
515
+ TSRequest.debugTrace(url, start, options, body);
516
+ }
517
+ if (TSRequest.isNonXmlBody(body)) {
518
+ reject(TSRequest.error('parse', 404, { body: body.slice(0, 200) }));
519
+ return;
520
+ }
521
+ // Large documents parse in a worker so the event loop is not blocked; small ones stay inline.
522
+ // Output is identical either way — see parseXmlDocument / XML_OFFTHREAD_MIN_BYTES.
523
+ parseXmlDocument(body).then(resolve, reject);
524
+ }).catch(error => {
525
+ error.took = `TSRequest:[${Number(new Date()) - Number(start)} ms][${url}]`;
526
+ error.options = options;
527
+ reject(error);
528
+ }));
529
+ }
530
+ static async json(url, options = {}, debug) {
531
+ options.headers = {
532
+ ...{ 'Accept': 'application/json', 'Content-Type': 'application/json; charset=UTF-8' }, ...(options.headers || {})
533
+ };
534
+ options.acceptAll = debug;
535
+ options.bcb = (response) => {
536
+ try {
537
+ return JSON.parse(response);
538
+ }
539
+ catch (e) {
540
+ console.error(e);
541
+ return response;
542
+ }
543
+ };
544
+ if (options.body !== null && typeof options.body === 'object' && !Buffer.isBuffer(options.body)) {
545
+ options.body = JSON.stringify(options.body);
546
+ }
547
+ const start = new Date();
548
+ return new Promise((resolve, reject) => TSRequest.raw(url, options).then(response => {
549
+ if (debug) {
550
+ TSRequest.debugTrace(url, start, options, response);
551
+ }
552
+ if (response && response.data) {
553
+ resolve(response.data);
554
+ }
555
+ else {
556
+ resolve(response);
557
+ }
558
+ }).catch(error => {
559
+ error.took = `TSRequest:[${Number(new Date()) - Number(start)} ms][${url}]`;
560
+ error.options = options;
561
+ reject(error);
562
+ }));
563
+ }
564
+ static async raw(url, options = {}) {
565
+ const signal = options.signal;
566
+ if (signal?.aborted) {
567
+ return Promise.reject((0, abort_1.abortError)());
568
+ }
569
+ return new Promise((resolve, reject) => {
570
+ let settled = false;
571
+ let req;
572
+ let onAbort;
573
+ const cleanupAbort = () => {
574
+ if (signal && onAbort) {
575
+ signal.removeEventListener('abort', onAbort);
576
+ }
577
+ };
578
+ const finish = (action, value, err) => {
579
+ if (settled) {
580
+ return;
581
+ }
582
+ settled = true;
583
+ cleanupAbort();
584
+ if (action === 'resolve') {
585
+ resolve(value);
586
+ }
587
+ else {
588
+ reject(err);
589
+ }
590
+ };
591
+ if (signal) {
592
+ onAbort = () => {
593
+ const abortErr = (0, abort_1.abortError)();
594
+ try {
595
+ req?.destroy(abortErr);
596
+ }
597
+ catch {
598
+ /* ignore */
599
+ }
600
+ finish('reject', undefined, abortErr);
601
+ };
602
+ signal.addEventListener('abort', onAbort, { once: true });
603
+ }
604
+ const params = TSRequest.url(url);
605
+ const optsForRequest = { ...options };
606
+ delete optsForRequest.signal;
607
+ Object.assign(params, optsForRequest);
608
+ if (!params.method) {
609
+ params.method = 'get';
610
+ }
611
+ if (params.search) {
612
+ params.path = params.path + params.search;
613
+ }
614
+ if (params.qs) {
615
+ params.path = params.path + (params.path.indexOf('?') > -1 ? '&' : '?') + (0, querystring_1.stringify)(params.qs);
616
+ }
617
+ try {
618
+ req = (params.protocol === 'https:' ? https : http).request(params, res => {
619
+ const statusCode = res.statusCode ?? 0;
620
+ if (!params.acceptAll && (statusCode < 200 || statusCode >= 300)) {
621
+ res.resume();
622
+ finish('reject', undefined, TSRequest.error('response', statusCode, { statusMessage: res.statusMessage, headers: res.headers }));
623
+ return;
624
+ }
625
+ const chunks = [];
626
+ res.on('data', chunk => chunks.push(chunk));
627
+ res.on('end', () => {
628
+ try {
629
+ const buffer = Buffer.concat(chunks).toString();
630
+ const body = params.bcb
631
+ ? { kind: 'success', status: statusCode, message: res.statusMessage, headers: res.headers, data: params.bcb(buffer) }
632
+ : buffer;
633
+ finish('resolve', body);
634
+ }
635
+ catch (e) {
636
+ const msg = e instanceof Error ? e.message : String(e);
637
+ finish('reject', undefined, TSRequest.error('parse', !params.acceptAll ? 109 : statusCode, { statusMessage: res.statusMessage, headers: res.headers, error: msg, body: chunks }));
638
+ }
639
+ });
640
+ });
641
+ req.on('error', e => {
642
+ finish('reject', undefined, TSRequest.error('error', 545, e.message));
643
+ });
644
+ req.on('timeout', () => {
645
+ const error = TSRequest.error('timeout', 563, 'Request destroyed');
646
+ req?.destroy(error);
647
+ finish('reject', undefined, error);
648
+ });
649
+ if (options.body) {
650
+ req.write(options.body);
651
+ }
652
+ req.end();
653
+ }
654
+ catch (e) {
655
+ const err = e instanceof Error ? e : new Error(String(e));
656
+ finish('reject', undefined, err);
657
+ }
658
+ });
659
+ }
660
+ static error(kind, status, message) {
661
+ return new Error(JSON.stringify({ kind: kind, status: status, message: message }));
662
+ }
663
+ static url(url) {
664
+ const m = /^(https?:)\/\/(([^:/?#]*)(?::([0-9]+))?)([/]{0,1}[^?#]*)(\?[^#]*|)(#.*|)$/.exec(url);
665
+ return m ? {
666
+ href: url, protocol: m[1], host: m[2], hostname: m[3], port: m[4], path: m[5], pathname: m[5], search: m[6], hash: m[7]
667
+ } : {};
668
+ }
669
+ }
670
+ exports.TSRequest = TSRequest;
671
+ function request(url, { body = undefined, qs = undefined, debug = undefined, ...options } = {}, kind = 'json') {
672
+ if (!options) {
673
+ options = {};
674
+ }
675
+ if (body) {
676
+ options.body = body;
677
+ }
678
+ else if (qs) {
679
+ options.qs = qs;
680
+ options.method = 'get';
681
+ }
682
+ options.rejectUnauthorized = false;
683
+ options.timeout = options.timeout || 18000;
684
+ return new Promise(resolve => TSRequest[kind](url, options, debug).catch((error) => {
685
+ if (debug)
686
+ console.error(error);
687
+ resolve({});
688
+ }).then(data => resolve(data)));
689
+ }