scrapely 1.2.17 → 1.2.19

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/dist/actor.js CHANGED
@@ -1062,9 +1062,16 @@ class Actor {
1062
1062
  label: 'PUT status message',
1063
1063
  retryWindowMs: options?.retryWindowMs,
1064
1064
  }, async (response) => (await response.json().catch(() => ({})))).catch((err) => {
1065
- throw new Error(`Failed to update status message: ${describeRunApiError(err)}`);
1065
+ const error = new Error(`Failed to update status message: ${describeRunApiError(err)}`);
1066
+ // A progress update is best effort: if the API still fails after the
1067
+ // retry window (e.g. a long 503), log it and carry on instead of
1068
+ // crashing the run. Terminal updates keep throwing.
1069
+ if (isStatusMessageTerminal)
1070
+ throw error;
1071
+ console.warn(`[Scrapely] ${error.message} (continuing)`);
1072
+ return undefined;
1066
1073
  });
1067
- return (body.data ?? {});
1074
+ return (body?.data ?? {});
1068
1075
  }
1069
1076
  /**
1070
1077
  * Creates a proxy configuration and returns a promise resolving to an instance
@@ -234,6 +234,8 @@ export declare function pushDataAndCharge<T>({ chargingManager, items, eventName
234
234
  /**
235
235
  * @internal True when a dataset push certainly stored nothing: the storage API
236
236
  * answered with an error status, or the request never reached it. Timeouts and
237
- * resets after the request was sent are not proof (the write may have landed).
237
+ * resets after the request was sent are not proof (the write may have landed),
238
+ * and neither is any error of a push one of whose earlier attempts may have
239
+ * been applied (`outcomeUnknown`, set by the storage client's retries).
238
240
  */
239
241
  export declare function pushCertainlyFailed(err: unknown): boolean;
package/dist/charging.js CHANGED
@@ -25,7 +25,7 @@ const PENDING_MAX_ENTRIES = 1024;
25
25
  // so the retries here can honour deadlines: 429, 408, 5xx, timeouts and
26
26
  // network errors are re-sent with the same idempotency key, with exponential
27
27
  // backoff and jitter, never sooner than the server's Retry-After, within the API
28
- // retry window (2 minutes, SCRAPELY_API_RETRY_WINDOW_SECS) or the caller's
28
+ // retry window (5 minutes, SCRAPELY_API_RETRY_WINDOW_SECS) or the caller's
29
29
  // deadline. Then the charge stays pending / the void is re-queued and retried
30
30
  // before the next push and at exit, the same as the Python SDK.
31
31
  const POST_ATTEMPTS = 64;
@@ -1182,9 +1182,17 @@ const NOT_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOST
1182
1182
  /**
1183
1183
  * @internal True when a dataset push certainly stored nothing: the storage API
1184
1184
  * answered with an error status, or the request never reached it. Timeouts and
1185
- * resets after the request was sent are not proof (the write may have landed).
1185
+ * resets after the request was sent are not proof (the write may have landed),
1186
+ * and neither is any error of a push one of whose earlier attempts may have
1187
+ * been applied (`outcomeUnknown`, set by the storage client's retries).
1186
1188
  */
1187
1189
  function pushCertainlyFailed(err) {
1190
+ for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
1191
+ if (e.outcomeUnknown === true)
1192
+ return false;
1193
+ }
1194
+ if ((0, api_storage_client_js_1.isConnectFailure)(err))
1195
+ return true;
1188
1196
  for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
1189
1197
  const status = httpStatusOf(e);
1190
1198
  if (status !== undefined)
@@ -24,11 +24,15 @@ export declare class ApiStorageClientError extends Error {
24
24
  */
25
25
  export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
26
26
  /** Default time budget for retrying one API call (429, 503, network errors). */
27
- export declare const DEFAULT_API_RETRY_WINDOW_MS = 120000;
27
+ export declare const DEFAULT_API_RETRY_WINDOW_MS = 300000;
28
+ /** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
29
+ export declare function apiDedupesKeyedWrites(url?: string): boolean;
30
+ /** Test hook: forget what was learned about the API, or force an answer for every origin. */
31
+ export declare function _resetKeyedWriteSupport(value?: boolean): void;
28
32
  /**
29
33
  * How long one API call keeps retrying before it gives up:
30
34
  * SCRAPELY_API_RETRY_WINDOW_SECS when set, else {@link BaseApiClient.retryWindowMs}
31
- * (2 minutes by default).
35
+ * (5 minutes by default).
32
36
  */
33
37
  export declare function apiRetryWindowMs(): number;
34
38
  /**
@@ -51,6 +55,13 @@ export interface RetryingFetchOptions {
51
55
  retryWindowMs?: number;
52
56
  /** Short label for log lines, e.g. "POST /datasets/x/items". */
53
57
  label: string;
58
+ /**
59
+ * Idempotency-Key for a write (one per logical write, the same on every
60
+ * attempt). Once the API is known to de-duplicate keyed writes, the write
61
+ * is retried like an idempotent call: a re-send of an applied write returns
62
+ * the original answer instead of applying it again.
63
+ */
64
+ idempotencyKey?: string;
54
65
  }
55
66
  export interface RetryingFetchInit {
56
67
  method: string;
@@ -59,15 +70,17 @@ export interface RetryingFetchInit {
59
70
  }
60
71
  /**
61
72
  * fetch() with the API retry policy. Retries, with exponential backoff and
62
- * jitter that honours Retry-After, until the retry window (2 minutes by
73
+ * jitter that honours Retry-After, until the retry window (5 minutes by
63
74
  * default) runs out:
64
75
  * - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
65
76
  * - 503 from the limiter or HAProxy: always; nothing was forwarded.
66
- * - 500/502/503/504, timeouts and dropped connections: idempotent calls only.
77
+ * - 500/502/503/504, timeouts and dropped connections: idempotent calls, and
78
+ * writes with an Idempotency-Key once the API is known to de-duplicate them.
67
79
  * - Refused/unresolvable connections: always; the request never left.
68
- * A write (dataset push, KV PUT, metamorph) whose request may have reached the
69
- * API is never re-sent: there is no idempotency key for it, so a re-send could
70
- * store the same rows twice. Such failures surface to the caller instead.
80
+ * Any other write (KV PUT, metamorph, a keyed push to an API that did not say
81
+ * it de-duplicates) whose request may have reached the API is never re-sent:
82
+ * a re-send could apply it twice. Such failures surface to the caller instead,
83
+ * marked `outcomeUnknown` when an attempt may have been applied.
71
84
  * Non-2xx answers that are not retried throw ApiStorageClientError.
72
85
  */
73
86
  export declare function fetchWithRetries<R>(url: string, init: RetryingFetchInit, options: RetryingFetchOptions, decode: (response: Response) => Promise<R>): Promise<R>;
@@ -81,6 +94,8 @@ interface ApiRequestOptions {
81
94
  doNotRetryTimeouts?: boolean;
82
95
  /** Override the method-based idempotency (e.g. get-or-create by name is safe to repeat). */
83
96
  idempotent?: boolean;
97
+ /** Idempotency-Key for a write; see {@link RetryingFetchOptions.idempotencyKey}. */
98
+ idempotencyKey?: string;
84
99
  }
85
100
  /**
86
101
  * Base class for API-based storage clients.
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.BaseApiClient = exports.DEFAULT_API_RETRY_WINDOW_MS = exports.ApiStorageClientError = void 0;
4
4
  exports.isRetryableResponse = isRetryableResponse;
5
+ exports.apiDedupesKeyedWrites = apiDedupesKeyedWrites;
6
+ exports._resetKeyedWriteSupport = _resetKeyedWriteSupport;
5
7
  exports.apiRetryWindowMs = apiRetryWindowMs;
6
8
  exports.serverRetryDelayMs = serverRetryDelayMs;
7
9
  exports.isConnectFailure = isConnectFailure;
@@ -161,12 +163,72 @@ function isRetryableResponse(status, idempotent, errorType, bodyText) {
161
163
  return errorType === 'rate-limit-unavailable' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
162
164
  }
163
165
  /** Default time budget for retrying one API call (429, 503, network errors). */
164
- exports.DEFAULT_API_RETRY_WINDOW_MS = 120_000;
166
+ exports.DEFAULT_API_RETRY_WINDOW_MS = 300_000;
167
+ /**
168
+ * Response header the API sets on every dataset push that sent an
169
+ * Idempotency-Key, once it de-duplicates such pushes. An older API ignores the
170
+ * key, and re-sending a push there could store the rows twice, so ambiguous
171
+ * failures of keyed writes are retried only while the latest keyed answer from
172
+ * that API origin carried this header. A 2xx without it (an older instance,
173
+ * e.g. mid-deploy or after a rollback) switches the retries off again.
174
+ */
175
+ const IDEMPOTENCY_SUPPORT_HEADER = 'x-scrapely-idempotency';
176
+ const keyedWritesDeduped = new Map();
177
+ /** Test override for every origin (undefined: learn from answers). */
178
+ let keyedWritesDedupedOverride;
179
+ function originOf(url) {
180
+ try {
181
+ return new URL(url).origin;
182
+ }
183
+ catch {
184
+ return url;
185
+ }
186
+ }
187
+ /** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
188
+ function apiDedupesKeyedWrites(url) {
189
+ if (keyedWritesDedupedOverride !== undefined)
190
+ return keyedWritesDedupedOverride;
191
+ if (url !== undefined)
192
+ return keyedWritesDeduped.get(originOf(url)) === true;
193
+ return [...keyedWritesDeduped.values()].pop() === true;
194
+ }
195
+ /** Test hook: forget what was learned about the API, or force an answer for every origin. */
196
+ function _resetKeyedWriteSupport(value) {
197
+ keyedWritesDeduped.clear();
198
+ keyedWritesDedupedOverride = value === true ? true : undefined;
199
+ }
200
+ function noteKeyedAnswer(url, response) {
201
+ const origin = originOf(url);
202
+ if (response.headers.has(IDEMPOTENCY_SUPPORT_HEADER)) {
203
+ keyedWritesDeduped.delete(origin);
204
+ keyedWritesDeduped.set(origin, true);
205
+ }
206
+ else if (response.ok) {
207
+ // Stored by a handler that ignored the key: this API (instance) does not de-duplicate.
208
+ keyedWritesDeduped.delete(origin);
209
+ keyedWritesDeduped.set(origin, false);
210
+ if (keyedWritesDedupedOverride === true)
211
+ keyedWritesDedupedOverride = undefined;
212
+ }
213
+ }
214
+ /** API error types of a keyed write: still being stored (retry), or stored-or-not unknown (final). */
215
+ const KEY_IN_PROGRESS = 'idempotency-key-in-progress';
216
+ const OUTCOME_UNKNOWN = 'push-outcome-unknown';
217
+ /**
218
+ * Marks an error whose request may have been applied by an earlier attempt
219
+ * (timeout, dropped connection or 5xx before the final answer), so callers
220
+ * don't treat it as "nothing was stored".
221
+ */
222
+ function markOutcomeUnknown(err) {
223
+ if (err && typeof err === 'object')
224
+ err.outcomeUnknown = true;
225
+ return err;
226
+ }
165
227
  const MAX_BACKOFF_MS = 30_000;
166
228
  /**
167
229
  * How long one API call keeps retrying before it gives up:
168
230
  * SCRAPELY_API_RETRY_WINDOW_SECS when set, else {@link BaseApiClient.retryWindowMs}
169
- * (2 minutes by default).
231
+ * (5 minutes by default).
170
232
  */
171
233
  function apiRetryWindowMs() {
172
234
  const env = Number(process.env.SCRAPELY_API_RETRY_WINDOW_SECS);
@@ -206,7 +268,21 @@ function fetchErrorCode(err) {
206
268
  /** fetch() failed before the request reached the server. */
207
269
  function isConnectFailure(err) {
208
270
  const code = fetchErrorCode(err);
209
- return code !== undefined && CONNECT_FAILURE_CODES.has(code);
271
+ if (code !== undefined && CONNECT_FAILURE_CODES.has(code))
272
+ return true;
273
+ // A TCP connect that timed out (`connect ETIMEDOUT`, or Node's happy-eyeballs
274
+ // AggregateError of connect attempts): nothing was sent. An ETIMEDOUT on an
275
+ // established socket (syscall "read") is not, so the syscall decides.
276
+ for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
277
+ if (e.code === 'ETIMEDOUT' || e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH' || e.code === 'ENETUNREACH') {
278
+ if (e.syscall === 'connect')
279
+ return true;
280
+ const attempts = Array.isArray(e.errors) ? e.errors : [];
281
+ if (attempts.length > 0 && attempts.every((a) => a?.syscall === 'connect'))
282
+ return true;
283
+ }
284
+ }
285
+ return false;
210
286
  }
211
287
  function describeFetchError(err) {
212
288
  return fetchErrorCode(err) ?? err?.message ?? String(err);
@@ -217,22 +293,32 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
217
293
  const sharedRateLimiter = new AdaptiveRateLimiter();
218
294
  /**
219
295
  * fetch() with the API retry policy. Retries, with exponential backoff and
220
- * jitter that honours Retry-After, until the retry window (2 minutes by
296
+ * jitter that honours Retry-After, until the retry window (5 minutes by
221
297
  * default) runs out:
222
298
  * - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
223
299
  * - 503 from the limiter or HAProxy: always; nothing was forwarded.
224
- * - 500/502/503/504, timeouts and dropped connections: idempotent calls only.
300
+ * - 500/502/503/504, timeouts and dropped connections: idempotent calls, and
301
+ * writes with an Idempotency-Key once the API is known to de-duplicate them.
225
302
  * - Refused/unresolvable connections: always; the request never left.
226
- * A write (dataset push, KV PUT, metamorph) whose request may have reached the
227
- * API is never re-sent: there is no idempotency key for it, so a re-send could
228
- * store the same rows twice. Such failures surface to the caller instead.
303
+ * Any other write (KV PUT, metamorph, a keyed push to an API that did not say
304
+ * it de-duplicates) whose request may have reached the API is never re-sent:
305
+ * a re-send could apply it twice. Such failures surface to the caller instead,
306
+ * marked `outcomeUnknown` when an attempt may have been applied.
229
307
  * Non-2xx answers that are not retried throw ApiStorageClientError.
230
308
  */
231
309
  async function fetchWithRetries(url, init, options, decode) {
232
- const { idempotent, timeoutMs, label } = options;
233
- const retryTimeouts = options.retryTimeouts !== false && idempotent;
310
+ const { timeoutMs, label, idempotencyKey } = options;
311
+ const headers = idempotencyKey ? { ...init.headers, 'Idempotency-Key': idempotencyKey } : init.headers;
312
+ // Re-sending is safe for idempotent calls, and for keyed writes once the
313
+ // API showed it de-duplicates them (learned from any keyed answer).
314
+ const safeToResend = () => options.idempotent || (idempotencyKey !== undefined && apiDedupesKeyedWrites(url));
315
+ const retryTimeouts = () => options.retryTimeouts !== false && safeToResend();
234
316
  const deadline = Date.now() + Math.max(0, options.retryWindowMs ?? apiRetryWindowMs());
235
317
  let attempt = 0;
318
+ // An attempt of a non-idempotent call may have been applied (timeout,
319
+ // dropped connection, 5xx): the final error must not claim "nothing applied".
320
+ let maybeApplied = false;
321
+ const fail = (err) => (maybeApplied && !options.idempotent ? markOutcomeUnknown(err) : err);
236
322
  /** Sleeps before the next attempt; false once the retry window is used up. */
237
323
  const backoff = async (why, retryAfter) => {
238
324
  // Retrying sooner than asked would only be refused again: give up when
@@ -250,8 +336,10 @@ async function fetchWithRetries(url, init, options, decode) {
250
336
  const controller = new AbortController();
251
337
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
252
338
  try {
253
- const response = await fetch(url, { method: init.method, headers: init.headers, body: init.body, signal: controller.signal });
339
+ const response = await fetch(url, { method: init.method, headers, body: init.body, signal: controller.signal });
254
340
  sharedRateLimiter.updateFromHeaders(response.headers);
341
+ if (idempotencyKey)
342
+ noteKeyedAnswer(url, response);
255
343
  if (response.ok) {
256
344
  try {
257
345
  // Decoded inside the retry loop: a body cut off after
@@ -261,19 +349,20 @@ async function fetchWithRetries(url, init, options, decode) {
261
349
  catch (bodyErr) {
262
350
  // The request was applied (2xx); a write whose answer
263
351
  // timed out while being read must not be sent again.
352
+ maybeApplied = true;
264
353
  if (bodyErr.name === 'AbortError') {
265
- if (!idempotent || !retryTimeouts) {
266
- const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body${idempotent ? '' : '; the request was applied'})`);
354
+ if (!retryTimeouts()) {
355
+ const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body${options.idempotent ? '' : '; the request was applied'})`);
267
356
  timeoutError.isTimeout = true;
268
- throw timeoutError;
357
+ throw fail(timeoutError);
269
358
  }
270
359
  if (await backoff(`response body timed out after ${timeoutMs}ms`, null))
271
360
  continue;
272
- throw bodyErr;
361
+ throw fail(bodyErr);
273
362
  }
274
- if (idempotent && await backoff(`response body failed (${describeFetchError(bodyErr)})`, null))
363
+ if (safeToResend() && await backoff(`response body failed (${describeFetchError(bodyErr)})`, null))
275
364
  continue;
276
- throw bodyErr;
365
+ throw fail(bodyErr);
277
366
  }
278
367
  }
279
368
  const text = await response.text().catch(() => '');
@@ -284,11 +373,27 @@ async function fetchWithRetries(url, init, options, decode) {
284
373
  catch {
285
374
  errorBody = undefined;
286
375
  }
287
- if (isRetryableResponse(response.status, idempotent, errorBody?.error?.type, text)
376
+ const errorType = errorBody?.error?.type;
377
+ const apiError = new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
378
+ if (idempotencyKey && errorType === OUTCOME_UNKNOWN) {
379
+ // Stored or not, nobody knows: the API refuses repeats of this key.
380
+ maybeApplied = true;
381
+ throw fail(apiError);
382
+ }
383
+ // A 5xx other than the limiter's / HAProxy's 503 may come after the write was applied.
384
+ if (!options.idempotent && response.status >= 500 && !isRetryableResponse(response.status, false, errorType, text)) {
385
+ maybeApplied = true;
386
+ }
387
+ const resend = safeToResend();
388
+ // 409 for a keyed write: an earlier attempt is still being stored.
389
+ const inProgress = idempotencyKey !== undefined && response.status === 409 && errorType === KEY_IN_PROGRESS;
390
+ if ((inProgress || isRetryableResponse(response.status, resend, errorType, text))
288
391
  && await backoff(`answered ${response.status}`, response.headers.get('retry-after'))) {
289
392
  continue;
290
393
  }
291
- throw new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
394
+ if (inProgress)
395
+ maybeApplied = true;
396
+ throw fail(apiError);
292
397
  }
293
398
  catch (err) {
294
399
  // HTTP errors (already decided above) are final.
@@ -296,19 +401,25 @@ async function fetchWithRetries(url, init, options, decode) {
296
401
  throw err;
297
402
  if (err?.isTimeout)
298
403
  throw err;
404
+ if (err?.outcomeUnknown)
405
+ throw err;
299
406
  if (err.name === 'AbortError') {
407
+ maybeApplied = true;
300
408
  const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
301
409
  timeoutError.isTimeout = true;
302
- if (retryTimeouts && await backoff(`timed out after ${timeoutMs}ms`, null))
410
+ if (retryTimeouts() && await backoff(`timed out after ${timeoutMs}ms`, null))
303
411
  continue;
304
- throw timeoutError;
412
+ throw fail(timeoutError);
305
413
  }
306
414
  // fetch() failed without a response. A refused/unresolvable
307
415
  // connection never reached the API, so it is safe for every
308
- // method; anything else (reset mid-request) only for idempotent ones.
309
- if ((idempotent || isConnectFailure(err)) && await backoff(`failed (${describeFetchError(err)})`, null))
416
+ // method; anything else (reset mid-request) only when re-sending is safe.
417
+ const connectFailure = isConnectFailure(err);
418
+ if (!connectFailure)
419
+ maybeApplied = true;
420
+ if ((safeToResend() || connectFailure) && await backoff(`failed (${describeFetchError(err)})`, null))
310
421
  continue;
311
- throw err;
422
+ throw fail(err);
312
423
  }
313
424
  finally {
314
425
  clearTimeout(timeoutId);
@@ -377,7 +488,7 @@ class BaseApiClient {
377
488
  headers;
378
489
  // How long one call keeps retrying 429/503/network errors before it gives
379
490
  // up (SCRAPELY_API_RETRY_WINDOW_SECS overrides it). Long enough that a
380
- // throttled run slows down instead of failing, and covers a main redeploy.
491
+ // throttled run slows down instead of failing, and covers an API redeploy.
381
492
  static retryWindowMs = exports.DEFAULT_API_RETRY_WINDOW_MS;
382
493
  // Default timeout for requests (30 seconds)
383
494
  static defaultTimeoutMs = 30000;
@@ -413,6 +524,7 @@ class BaseApiClient {
413
524
  timeoutMs,
414
525
  retryTimeouts: options?.doNotRetryTimeouts !== true,
415
526
  label: `${method} ${path}`,
527
+ idempotencyKey: options?.idempotencyKey,
416
528
  }, decode);
417
529
  }
418
530
  async request(method, path, body, contentType, options) {
@@ -570,7 +682,9 @@ class ApiDatasetClient extends BaseApiClient {
570
682
  // must not store unbilled rows in its default dataset.
571
683
  if (this.id === (0, charging_js_1.defaultDatasetIdFromEnv)() && (0, charging_js_1.refuseDefaultDatasetPush)(parsed.length))
572
684
  return;
573
- await this.request('POST', `/datasets/${this.id}/items`, parsed);
685
+ // One key per push, the same on every attempt: the API stores a
686
+ // re-sent push once, so a push whose connection dropped can be retried.
687
+ await this.request('POST', `/datasets/${this.id}/items`, parsed, undefined, { idempotencyKey: (0, node_crypto_1.randomUUID)() });
574
688
  }
575
689
  }
576
690
  exports.ApiDatasetClient = ApiDatasetClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.17",
3
+ "version": "1.2.19",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -24,6 +24,7 @@
24
24
  "test:charging": "npx tsx --test tests/charging-pending.test.ts",
25
25
  "test:charge-limit-guard": "npx tsx --test tests/charge-limit-guard.test.ts",
26
26
  "test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
27
+ "test:push-idempotency": "npx tsx --test tests/push-idempotency.test.ts",
27
28
  "test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
28
29
  "test:charging-client": "npx tsx --test tests/charging-client.test.ts",
29
30
  "test:proxy": "npx tsx --test tests/proxy-configuration.test.ts",