scrapely 1.2.39 → 1.2.41

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.41] - 2026-11-08
8
+
9
+ - An atomic multi-event push (`Actor.pushData(rows, { charges })`) whose charge the limit covers only in part no longer logs "Charging 5 instances of 'audio-minute' event would exceed the end of the account balance - only 3 events were charged". A row is saved only when all of its events are charged, so the SDK gives those 3 back and nothing is charged: the line was untrue. It now says what happened: "Saving this item needs 5 'audio-minute' event(s), but the account balance covers only 3: it was not saved and nothing was charged for it." When some rows still fit, it names how many were saved and charged in full, and when giving a charge back is not confirmed it says so instead of "nothing was charged". A plain `Actor.charge()` that comes back short keeps its line: those events do stay charged.
10
+ - `ChargeResult.coveredCounts` (from `pushData(rows, { charges })`): for each event whose charge came back short, how many events the limit covered (given back, not charged), e.g. `{ 'audio-minute': 3 }`. Actors can cut a row to what the limit covers without reading the log. What is charged and saved does not change.
11
+ - A spending limit of $0 (`maxTotalChargeUsd: 0`) is a real limit, the same as on the platform. It used to be read as "no limit". A missing or non-numeric value still means no limit.
12
+ - A plain `Actor.pushData(items)` to the default dataset of a pay-per-event Actor whose dataset item event is priced goes through the charged push: rows past the spending limit are not saved, and it returns a `ChargeResult`. Before, all rows were saved and only the ones within the limit were paid.
13
+ - A dataset push that fails with a `503 push-outcome-unknown` (the server could not tell whether it was stored) keeps its charge instead of voiding it.
14
+ - `utils.enqueueLinks({ limit })`: enqueue at most `limit` links per page; links are added to the queue in batches of 100.
15
+ - Dragonfly storage clients: named datasets, key-value stores and request queues are looked up per owner (`name:{userId}:{name}`, as the server stores them), a storage is created only with a known owner, and keys created later (dataset items, records, the log truncation marker) take their storage's expiry. Actor runs on the platform use the API storage client and are not affected.
16
+
17
+ ## [1.2.40] - 2026-10-30
18
+
19
+ - Actors on puppeteer 25 (ESM-only) are sent to the remote browser when puppeteer is loaded with `require()` before the SDK on a run without the preload. The SDK only recognised puppeteer's old `lib/cjs` build, so such a `puppeteer.launch()` tried to start a local browser; the named `launch` export (`const { launch } = require('puppeteer')`) is covered too. puppeteer 24 and older behave as before.
20
+
7
21
  ## [1.2.39] - 2026-10-29
8
22
 
9
23
  - A refused charge names the limit that refused it. When a charge is refused or cut short, the platform now says why (HTTP 402 `error.data.reason`, and `limitedBy` on a partial acceptance): the run's item limit (`maxItems`), its maximum total charge (`maxTotalChargeUsd`) or the account balance. The SDK used to call all of them the spending limit. Now its log lines name the real limit; `ChargingManager.getChargeLimitReason()` returns `'max-items'`, `'max-charge'` or `'insufficient-funds'` and `getChargeLimitMessage()` the stop message ("Stopped: reached the item limit you set (800 items)", "Stopped: reached the maximum charge you set ($3.00)", "Stopped: your account has no credit left for this run"); and once a limit was reached, a status message (`Actor.setStatusMessage`, `Actor.exit`, `Actor.fail`) whose clause blames the maximum charge gets that clause replaced by the real stop message. Platforms that do not send the reason yet: inferred from the 402 message and `ACTOR_MAX_PAID_DATASET_ITEMS`. What is charged and saved does not change.
package/dist/actor.js CHANGED
@@ -603,6 +603,17 @@ class Actor {
603
603
  }
604
604
  const { Dataset } = await import('@crawlee/core');
605
605
  const dataset = await Dataset.open();
606
+ const isDefault = dataset.id === this.config.get('defaultDatasetId');
607
+ // A priced default-dataset item event: rows are trimmed to the budget and
608
+ // counted like a charged push, so the spending limit is seen locally.
609
+ if (eventName === undefined
610
+ && !this.usesPushDataInterception(dataset)
611
+ && this.initialized
612
+ && isDefault
613
+ && this.chargingManager._isPayPerEvent()
614
+ && this.chargingManager._isPaidPerItem(undefined, true)) {
615
+ return this.pushDataWithExplicitCharging(dataset, item, undefined);
616
+ }
606
617
  if (eventName === undefined && !this.usesPushDataInterception(dataset)) {
607
618
  // Charge-limit safety net (also for local storage): this push is not
608
619
  // charged, so past the run's spending limit it is not stored.
@@ -84,7 +84,24 @@ export interface ChargeResult {
84
84
  eventChargeLimitReached: boolean;
85
85
  chargedCount: number;
86
86
  chargeableWithinLimit: Record<string, number>;
87
+ /**
88
+ * `pushData(rows, { charges })` only: for each event whose charge came back
89
+ * short, how many events the limit (account balance, maxTotalChargeUsd,
90
+ * maxItems) covered. A row is saved only when all of its events are charged,
91
+ * so those events were given back, not charged. Empty when no charge came
92
+ * back short.
93
+ */
94
+ coveredCounts?: Record<string, number>;
95
+ }
96
+ /** @internal A charge of an atomic push that the limit accepted only in part (then given back). */
97
+ export interface ShortCharge {
98
+ eventName: string;
99
+ requested: number;
100
+ covered: number;
101
+ limit: string;
87
102
  }
103
+ /** @internal Log what an atomic push whose charge came back short really did. */
104
+ export declare function logAtomicRefusal(shorts: ShortCharge[], rowsPushed: number, rowsSaved: number, givenBack: boolean): void;
88
105
  export interface ActorPricingInfo {
89
106
  pricingModel?: ActorRunPricingInfo['pricingModel'];
90
107
  maxTotalChargeUsd: number;
@@ -110,6 +127,12 @@ export declare class ChargingManager {
110
127
  private lastLimitMaxItems;
111
128
  /** Default-dataset rows refused after the limit (one warning, then a count). */
112
129
  private refusedDefaultDatasetItems;
130
+ /**
131
+ * @internal Set by pushDataWithCharges while it runs (it holds the operation
132
+ * lock): charges that come back short are collected here instead of being
133
+ * logged as "only N events were charged", because the atomic push gives them back.
134
+ */
135
+ _shortCharges: ShortCharge[] | undefined;
113
136
  /**
114
137
  * Prepaid-row balance: priced events charged through charge() minus rows
115
138
  * pushed to the default dataset without a per-row charge. An Actor that
package/dist/charging.js CHANGED
@@ -10,6 +10,7 @@ exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
10
10
  exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
11
11
  exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
12
12
  exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
13
+ exports.logAtomicRefusal = logAtomicRefusal;
13
14
  exports.mergeChargeResults = mergeChargeResults;
14
15
  exports.assertItemsSerializable = assertItemsSerializable;
15
16
  exports.pushDataAndCharge = pushDataAndCharge;
@@ -234,6 +235,32 @@ function hasDefaultDatasetItemEventInPricing(perEventPrices) {
234
235
  function isSyntheticEvent(eventName) {
235
236
  return isDefaultDatasetItemEvent(eventName);
236
237
  }
238
+ function itemsPhrase(count) {
239
+ return count === 1
240
+ ? { what: 'this item', were: 'it was', them: 'it' }
241
+ : { what: `these ${count} items`, were: 'they were', them: 'them' };
242
+ }
243
+ /** @internal Log what an atomic push whose charge came back short really did. */
244
+ function logAtomicRefusal(shorts, rowsPushed, rowsSaved, givenBack) {
245
+ const first = shorts[0];
246
+ if (!first)
247
+ return;
248
+ const limit = first.limit.replace(/^the end of /, '');
249
+ const { what, were, them } = itemsPhrase(rowsPushed);
250
+ const head = `[Scrapely] Saving ${what} needs ${first.requested} '${first.eventName}' event(s), but ${limit} covers only ${first.covered}`;
251
+ if (rowsSaved <= 0) {
252
+ console.log(givenBack
253
+ ? `${head}: ${were} not saved and nothing was charged for ${them}.`
254
+ : `${head}: ${were} not saved, but the ${first.covered} event(s) charged for ${them} could not be given back yet (see the warning about it).`);
255
+ return;
256
+ }
257
+ const left = rowsPushed - rowsSaved;
258
+ const rest = itemsPhrase(left);
259
+ const tail = givenBack
260
+ ? `nothing was charged for ${rest.them}`
261
+ : `what was charged for ${rest.them} could not be given back yet (see the warning about it)`;
262
+ console.log(`${head}: saved the first ${rowsSaved} item(s), charged in full; the other ${left} ${rest.were.split(' ').pop()} not saved and ${tail}.`);
263
+ }
237
264
  function mergeChargeResults(a, b) {
238
265
  return {
239
266
  eventChargeLimitReached: a.eventChargeLimitReached || b.eventChargeLimitReached,
@@ -244,6 +271,17 @@ function mergeChargeResults(a, b) {
244
271
  ])),
245
272
  };
246
273
  }
274
+ /**
275
+ * The run's spending limit (maxTotalChargeUsd), or Infinity when uncapped.
276
+ * 0 is a real limit (no fees), the same as on the platform; only a missing
277
+ * or non-numeric value means uncapped.
278
+ */
279
+ function spendingLimitUsd(value) {
280
+ if (value == null || value === '')
281
+ return Infinity;
282
+ const usd = Number(value);
283
+ return Number.isFinite(usd) && usd >= 0 ? usd : Infinity;
284
+ }
247
285
  /**
248
286
  * Pay-per-event charging for platform runs.
249
287
  * Reports charges via POST /v2/actor-runs/:runId/charge (Redis → worker billing).
@@ -262,6 +300,12 @@ class ChargingManager {
262
300
  lastLimitMaxItems;
263
301
  /** Default-dataset rows refused after the limit (one warning, then a count). */
264
302
  refusedDefaultDatasetItems = 0;
303
+ /**
304
+ * @internal Set by pushDataWithCharges while it runs (it holds the operation
305
+ * lock): charges that come back short are collected here instead of being
306
+ * logged as "only N events were charged", because the atomic push gives them back.
307
+ */
308
+ _shortCharges;
265
309
  /**
266
310
  * Prepaid-row balance: priced events charged through charge() minus rows
267
311
  * pushed to the default dataset without a per-row charge. An Actor that
@@ -577,7 +621,7 @@ class ChargingManager {
577
621
  constructor(configuration, apifyClient) {
578
622
  this.configuration = configuration;
579
623
  this.apifyClientSource = apifyClient;
580
- this.maxTotalChargeUsd = configuration.get('maxTotalChargeUsd') || Infinity;
624
+ this.maxTotalChargeUsd = spendingLimitUsd(configuration.get('maxTotalChargeUsd'));
581
625
  this.isAtHome = !!configuration.get('isAtHome');
582
626
  this.actorRunId = configuration.get('actorRunId') || '';
583
627
  }
@@ -619,7 +663,7 @@ class ChargingManager {
619
663
  return {
620
664
  pricingInfo: actorPricingInfo ? JSON.parse(actorPricingInfo) : undefined,
621
665
  chargedEventCounts: JSON.parse(chargedEventCounts),
622
- maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd') || Infinity,
666
+ maxTotalChargeUsd: spendingLimitUsd(this.configuration.get('maxTotalChargeUsd')),
623
667
  };
624
668
  }
625
669
  if (this.isAtHome) {
@@ -633,13 +677,13 @@ class ChargingManager {
633
677
  return {
634
678
  pricingInfo: run.pricingInfo,
635
679
  chargedEventCounts: run.chargedEventCounts,
636
- maxTotalChargeUsd: run.options?.maxTotalChargeUsd || Infinity,
680
+ maxTotalChargeUsd: spendingLimitUsd(run.options?.maxTotalChargeUsd),
637
681
  };
638
682
  }
639
683
  return {
640
684
  pricingInfo: undefined,
641
685
  chargedEventCounts: {},
642
- maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd') || Infinity,
686
+ maxTotalChargeUsd: spendingLimitUsd(this.configuration.get('maxTotalChargeUsd')),
643
687
  };
644
688
  }
645
689
  /**
@@ -1328,8 +1372,16 @@ class ChargingManager {
1328
1372
  });
1329
1373
  }
1330
1374
  if (chargedCount < count) {
1331
- const subject = count === 1 ? 'instance' : 'instances';
1332
- console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
1375
+ if (this._shortCharges) {
1376
+ // Inside an atomic push: this charge is given back unless the row is
1377
+ // saved, so "only N events were charged" would not be true there.
1378
+ // pushDataWithCharges logs what really happened instead.
1379
+ this._shortCharges.push({ eventName: normalizedEventName, requested: count, covered: chargedCount, limit: this.limitName() });
1380
+ }
1381
+ else {
1382
+ const subject = count === 1 ? 'instance' : 'instances';
1383
+ console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
1384
+ }
1333
1385
  }
1334
1386
  return {
1335
1387
  eventChargeLimitReached: this.calculateMaxEventChargeCountWithinLimit(normalizedEventName) <= 0,
@@ -1814,6 +1866,10 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1814
1866
  // An item the storage would refuse fails here, before it is charged.
1815
1867
  assertItemsSerializable(rowsIn);
1816
1868
  validateItems?.(rowsIn);
1869
+ // Charges of this push that came back short: given back, and logged once the
1870
+ // outcome is known (logAtomicRefusal), not as "only N events were charged".
1871
+ const shorts = [];
1872
+ let givenBack = true;
1817
1873
  const undo = async (ops) => {
1818
1874
  for (const op of ops) {
1819
1875
  if (op.charged <= 0)
@@ -1823,61 +1879,75 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1823
1879
  else if (!op.operationId)
1824
1880
  chargingManager.applyAcceptedCount(op.name, op.charged, 0);
1825
1881
  // Cancel / void; a refused void stays billed and becomes credit.
1826
- else
1827
- await voidOrCredit(chargingManager, op.name, op.operationId, op.charged, op.uncertain);
1882
+ else if ((await voidOrCredit(chargingManager, op.name, op.operationId, op.charged, op.uncertain)) !== 'voided')
1883
+ givenBack = false;
1828
1884
  }
1829
1885
  };
1886
+ // Per event: how many events the limit covered in its last short answer.
1887
+ const coveredCounts = () => Object.fromEntries(shorts.map((sc) => [sc.eventName, sc.covered]));
1830
1888
  const order = Object.entries(perRow).sort(([a], [b]) => Number(isSyntheticEvent(a)) - Number(isSyntheticEvent(b)) || a.localeCompare(b));
1831
1889
  let ops = [];
1832
- for (let attempt = 0; attempt < 6 && rows > 0; attempt++) {
1833
- ops = [];
1834
- let shortTo = null;
1835
- for (const [name, n] of order) {
1836
- const want = n * rows;
1837
- chargingManager._takeLastAcceptedOperationId?.();
1838
- let got;
1839
- if (takeVoidCredit(chargingManager, name, want) > 0) {
1840
- got = want;
1841
- ops.push({ name, charged: want, uncertain: false, credit: true });
1842
- }
1843
- else {
1844
- try {
1845
- const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
1846
- got = r.chargedCount;
1847
- ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
1890
+ chargingManager._shortCharges = shorts;
1891
+ try {
1892
+ for (let attempt = 0; attempt < 6 && rows > 0; attempt++) {
1893
+ ops = [];
1894
+ let shortTo = null;
1895
+ for (const [name, n] of order) {
1896
+ const want = n * rows;
1897
+ chargingManager._takeLastAcceptedOperationId?.();
1898
+ let got;
1899
+ if (takeVoidCredit(chargingManager, name, want) > 0) {
1900
+ got = want;
1901
+ ops.push({ name, charged: want, uncertain: false, credit: true });
1848
1902
  }
1849
- catch (err) {
1850
- if (!(err instanceof ChargeUncertainError)) {
1851
- await undo(ops);
1852
- throw err;
1903
+ else {
1904
+ try {
1905
+ const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
1906
+ got = r.chargedCount;
1907
+ ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
1908
+ }
1909
+ catch (err) {
1910
+ if (!(err instanceof ChargeUncertainError)) {
1911
+ await undo(ops);
1912
+ throw err;
1913
+ }
1914
+ // Not confirmed: stays pending and is re-posted at exit, so it counts as accepted.
1915
+ got = err.count;
1916
+ ops.push({ name, charged: got, operationId: err.operationId, uncertain: true });
1853
1917
  }
1854
- // Not confirmed: stays pending and is re-posted at exit, so it counts as accepted.
1855
- got = err.count;
1856
- ops.push({ name, charged: got, operationId: err.operationId, uncertain: true });
1918
+ }
1919
+ if (got < want) {
1920
+ shortTo = Math.floor(got / n);
1921
+ break;
1857
1922
  }
1858
1923
  }
1859
- if (got < want) {
1860
- shortTo = Math.floor(got / n);
1924
+ if (shortTo === null)
1861
1925
  break;
1862
- }
1926
+ // An event came back short: void this attempt and retry with the whole
1927
+ // rows the budget covers at the FULL per-row cost (sizing from the short
1928
+ // event alone came back short again: appstore 64df0a3608d74af88, 0 saved).
1929
+ // At least one single-row try: the last affordable row may still fit.
1930
+ const attemptOps = ops.map((op) => ({ name: op.name, charged: op.charged }));
1931
+ await undo(ops);
1932
+ ops = [];
1933
+ rows = Math.min(rows - 1, Math.max(chargingManager._rowsAfterShort(attemptOps, perRow), 1));
1863
1934
  }
1864
- if (shortTo === null)
1865
- break;
1866
- // An event came back short: void this attempt and retry with the whole
1867
- // rows the budget covers at the FULL per-row cost (sizing from the short
1868
- // event alone came back short again: appstore 64df0a3608d74af88, 0 saved).
1869
- // At least one single-row try: the last affordable row may still fit.
1870
- const attemptOps = ops.map((op) => ({ name: op.name, charged: op.charged }));
1871
- await undo(ops);
1872
- ops = [];
1873
- rows = Math.min(rows - 1, Math.max(chargingManager._rowsAfterShort(attemptOps, perRow), 1));
1935
+ }
1936
+ finally {
1937
+ chargingManager._shortCharges = undefined;
1874
1938
  }
1875
1939
  if (rows <= 0 || ops.length === 0) {
1876
1940
  await undo(ops);
1941
+ logAtomicRefusal(shorts, rowsIn.length, 0, givenBack);
1877
1942
  chargingManager._markChargeLimitHit();
1878
1943
  if (isDefaultDataset)
1879
1944
  chargingManager._noteRefusedDefaultDatasetItems(rowsIn.length);
1880
- return { eventChargeLimitReached: true, chargedCount: 0, chargeableWithinLimit: chargingManager._chargeableWithinLimit() };
1945
+ return {
1946
+ eventChargeLimitReached: true,
1947
+ chargedCount: 0,
1948
+ chargeableWithinLimit: chargingManager._chargeableWithinLimit(),
1949
+ coveredCounts: coveredCounts(),
1950
+ };
1881
1951
  }
1882
1952
  const kept = batch ? rowsIn : rowsIn.slice(0, rows);
1883
1953
  if (kept.length < rowsIn.length) {
@@ -1894,10 +1964,12 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1894
1964
  await undo(ops);
1895
1965
  throw pushErr;
1896
1966
  }
1967
+ logAtomicRefusal(shorts, rowsIn.length, kept.length, givenBack);
1897
1968
  return {
1898
1969
  eventChargeLimitReached: kept.length < rowsIn.length || chargingManager.isChargeLimitReached(),
1899
1970
  chargedCount: kept.length,
1900
1971
  chargeableWithinLimit: chargingManager._chargeableWithinLimit(),
1972
+ coveredCounts: coveredCounts(),
1901
1973
  };
1902
1974
  });
1903
1975
  }
@@ -1919,6 +1991,9 @@ function pushCertainlyFailed(err) {
1919
1991
  for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
1920
1992
  if (e.outcomeUnknown === true)
1921
1993
  return false;
1994
+ // The server could not tell whether the push was stored (a 503 with this type).
1995
+ if (apiErrorTypeOf(e) === 'push-outcome-unknown')
1996
+ return false;
1922
1997
  }
1923
1998
  if ((0, api_storage_client_js_1.isConnectFailure)(err))
1924
1999
  return true;
@@ -20,6 +20,7 @@ const PBKDF2_ITERATIONS = 100_000;
20
20
  const PBKDF2_HASH = 'sha256';
21
21
  const SALT_LENGTH = 32;
22
22
  const KEY_LENGTH = 32;
23
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
23
24
  // ─── Private Key Decryption ───────────────────────────────────────────────────
24
25
  /**
25
26
  * Read the private key file from disk and decrypt it with the run passphrase.
@@ -101,6 +102,9 @@ function decryptAllSecrets(encryptedSecrets, privateKeyPem) {
101
102
  function mergeDecryptedInput(input, decryptedSecrets) {
102
103
  const merged = { ...input };
103
104
  for (const [fieldName, value] of Object.entries(decryptedSecrets)) {
105
+ // Never walk into or write onto an object's prototype chain.
106
+ if (fieldName.split('.').some((part) => UNSAFE_KEYS.has(part)))
107
+ continue;
104
108
  if (fieldName.includes('.')) {
105
109
  // Handle nested paths
106
110
  const parts = fieldName.split('.');
@@ -791,8 +791,31 @@ function patchRawPuppeteerModule(mod, moduleName) {
791
791
  // read-only export binding — the default export is still patched
792
792
  }
793
793
  }
794
+ // ESM-only puppeteer (v25+) loaded through require(): the named
795
+ // `launch` is a read-only binding to the original method, which hands
796
+ // off to a BrowserLauncher. Patch that class so it redirects too.
797
+ if (mod.launch === origBoundLaunch)
798
+ patchPuppeteerBrowserLauncherClass(mod.BrowserLauncher, moduleName);
794
799
  }
795
800
  }
801
+ /**
802
+ * Patch puppeteer's BrowserLauncher class (base of ChromeLauncher and
803
+ * FirefoxLauncher), which every PuppeteerNode launch goes through. Calls that
804
+ * already went through a patched launch decide the same way again (no CDP URL
805
+ * → local launch). Idempotent per class.
806
+ */
807
+ function patchPuppeteerBrowserLauncherClass(BrowserLauncher, moduleName) {
808
+ const proto = BrowserLauncher?.prototype;
809
+ if (!proto || typeof proto.launch !== 'function')
810
+ return;
811
+ if (Object.prototype.hasOwnProperty.call(proto, '_scrapelyRawCdpPatched'))
812
+ return;
813
+ const origLaunch = proto.launch;
814
+ proto.launch = function browserLauncherLaunchPatched(launchOptions) {
815
+ return puppeteerLaunchPatched(this.puppeteer, (options) => origLaunch.call(this, options), moduleName, launchOptions);
816
+ };
817
+ proto._scrapelyRawCdpPatched = true;
818
+ }
796
819
  /**
797
820
  * Requests that load (a part of) some copy of @crawlee/browser-pool:
798
821
  * - the bare package name (require from @crawlee/playwright etc.),
@@ -804,8 +827,11 @@ const BROWSER_POOL_REQUEST_RE = /^@crawlee\/browser-pool$|[/\\]@crawlee[/\\]brow
804
827
  const BROWSER_POOL_FILENAME_RE = /[/\\]@crawlee[/\\]browser-pool[/\\](index|playwright[/\\]playwright-plugin|puppeteer[/\\]puppeteer-plugin)\.js$/;
805
828
  /** playwright / playwright-core package entry, by filename (ESM imports and require.cache). */
806
829
  const PLAYWRIGHT_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](playwright|playwright-core)[/\\]index\.js$/;
807
- /** puppeteer / puppeteer-core CJS package entry (default instance), by filename. */
808
- const PUPPETEER_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](puppeteer|puppeteer-core)[/\\]lib[/\\]cjs[/\\]puppeteer[/\\](puppeteer|puppeteer-core)\.js$/;
830
+ /**
831
+ * puppeteer / puppeteer-core package entry (default instance), by filename:
832
+ * `lib/cjs/puppeteer/…` up to v24, `lib/puppeteer/…` in the ESM-only v25+.
833
+ */
834
+ const PUPPETEER_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](puppeteer|puppeteer-core)[/\\]lib[/\\](?:cjs[/\\])?puppeteer[/\\](puppeteer|puppeteer-core)\.js$/;
809
835
  /** puppeteer-core's PuppeteerNode class file (CJS), as requested by node/node.js or cached. */
810
836
  const PUPPETEER_NODE_REQUEST_RE = /(^|[/\\])PuppeteerNode(\.js)?$/;
811
837
  /**
@@ -4,6 +4,7 @@ exports.DragonflyDatasetClient = void 0;
4
4
  const utils_js_1 = require("./utils.js");
5
5
  const billing_tracker_js_1 = require("./billing-tracker.js");
6
6
  const dataset_push_script_js_1 = require("./dataset-push-script.js");
7
+ const ttl_manager_js_1 = require("./ttl-manager.js");
7
8
  function makeAsyncIterableResult(promiseFn) {
8
9
  const promise = promiseFn();
9
10
  const iterable = {
@@ -60,11 +61,13 @@ class DragonflyDatasetClient {
60
61
  const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
61
62
  if (raw) {
62
63
  const data = JSON.parse(raw);
63
- if (data.name)
64
- await this.client.redis.hdel(indexKey, `name:${data.name}`);
64
+ // Name pointers are scoped by owner (name:{userId}:{name}).
65
+ const ownerId = data.userId ?? this.client.billingContext?.userId;
66
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
65
67
  data.name = newFields.name;
66
68
  await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
67
- await this.client.redis.hset(indexKey, `name:${newFields.name}`, JSON.stringify(data));
69
+ if (ownerId && newFields.name)
70
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
68
71
  }
69
72
  }
70
73
  await this.client.redis.hset(this.infoKey, 'modifiedAt', now.toISOString());
@@ -75,8 +78,7 @@ class DragonflyDatasetClient {
75
78
  await this.client.redis.del(this.infoKey, this.itemsKey);
76
79
  const indexKey = this.client.key('datasets:index');
77
80
  await this.client.redis.hdel(indexKey, `id:${this.id}`);
78
- if (info?.name)
79
- await this.client.redis.hdel(indexKey, `name:${info.name}`);
81
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
80
82
  }
81
83
  async downloadItems() {
82
84
  const result = await this._listItems();
@@ -216,6 +218,10 @@ class DragonflyDatasetClient {
216
218
  // separate round trips allowed concurrent pushes to allocate the same
217
219
  // slot indices, silently overwriting items.
218
220
  await (0, dataset_push_script_js_1.pushItemsAtomic)(this.client.redis, this.infoKey, this.itemsKey, serializedItems, totalBytes);
221
+ // The items hash is created by the first push: give it the dataset's expiry.
222
+ const expiry = this.client.redis.pipeline();
223
+ (0, ttl_manager_js_1.queueInheritExpiry)(expiry, this.infoKey, [this.itemsKey]);
224
+ await expiry.exec();
219
225
  // Track billing using client's billing context
220
226
  if (this.client.billingContext) {
221
227
  const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
@@ -9,8 +9,10 @@ class DragonflyDatasetCollectionClient {
9
9
  }
10
10
  async getOrCreate(name) {
11
11
  const indexKey = this.client.key('datasets:index');
12
+ // Every storage belongs to the run's user; the name pointer is scoped by that user.
13
+ const userId = (0, utils_js_1.requireStorageOwner)(this.client.billingContext, 'dataset');
12
14
  if (name) {
13
- const existing = await this.client.redis.hget(indexKey, `name:${name}`);
15
+ const existing = await this.client.redis.hget(indexKey, (0, utils_js_1.ownerNameField)(userId, name));
14
16
  if (existing)
15
17
  return JSON.parse(existing);
16
18
  }
@@ -25,7 +27,7 @@ class DragonflyDatasetCollectionClient {
25
27
  };
26
28
  await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
27
29
  if (name)
28
- await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
30
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
29
31
  const infoKey = this.client.key('dataset', id, 'info');
30
32
  const itemsKey = this.client.key('dataset', id, 'items');
31
33
  await this.client.redis.hset(infoKey, {
@@ -62,8 +64,7 @@ class DragonflyDatasetCollectionClient {
62
64
  const exists = await this.client.redis.exists(infoKey);
63
65
  if (!exists) {
64
66
  await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
65
- if (parsed.name)
66
- await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
67
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId ?? this.client.billingContext?.userId, parsed.name, parsed.id);
67
68
  continue;
68
69
  }
69
70
  const itemCount = parseInt(await this.client.redis.hget(infoKey, 'itemCount') || '0', 10);
@@ -4,6 +4,7 @@ exports.DragonflyKeyValueStoreClient = void 0;
4
4
  const node_stream_1 = require("node:stream");
5
5
  const utils_js_1 = require("./utils.js");
6
6
  const billing_tracker_js_1 = require("./billing-tracker.js");
7
+ const ttl_manager_js_1 = require("./ttl-manager.js");
7
8
  const configuration_js_1 = require("../configuration.js");
8
9
  const kvs_iteration_js_1 = require("./kvs-iteration.js");
9
10
  function makeAsyncIterableListKeys(promiseFn) {
@@ -70,11 +71,13 @@ class DragonflyKeyValueStoreClient {
70
71
  const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
71
72
  if (raw) {
72
73
  const data = JSON.parse(raw);
73
- if (data.name)
74
- await this.client.redis.hdel(indexKey, `name:${data.name}`);
74
+ // Name pointers are scoped by owner (name:{userId}:{name}).
75
+ const ownerId = data.userId ?? this.client.billingContext?.userId;
76
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
75
77
  data.name = newFields.name;
76
78
  await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
77
- await this.client.redis.hset(indexKey, `name:${newFields.name}`, JSON.stringify(data));
79
+ if (ownerId && newFields.name)
80
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
78
81
  }
79
82
  }
80
83
  await this.client.redis.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
@@ -85,8 +88,7 @@ class DragonflyKeyValueStoreClient {
85
88
  await this.client.redis.del(this.infoKey, this.recordsKey, this.metaKey);
86
89
  const indexKey = this.client.key('kvs:index');
87
90
  await this.client.redis.hdel(indexKey, `id:${this.id}`);
88
- if (info?.name)
89
- await this.client.redis.hdel(indexKey, `name:${info.name}`);
91
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
90
92
  }
91
93
  async recordExists(key) {
92
94
  return (await this.client.redis.hexists(this.recordsKey, key)) === 1;
@@ -168,6 +170,8 @@ class DragonflyKeyValueStoreClient {
168
170
  pipeline.hset(this.metaKey, key, JSON.stringify({ contentType }));
169
171
  }
170
172
  pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
173
+ // Records/meta are created by the first write: give them the store's expiry.
174
+ (0, ttl_manager_js_1.queueInheritExpiry)(pipeline, this.infoKey, [this.recordsKey, this.metaKey]);
171
175
  await pipeline.exec();
172
176
  // Track billing using client's billing context
173
177
  if (this.client.billingContext) {
@@ -9,17 +9,15 @@ class DragonflyKeyValueStoreCollectionClient {
9
9
  }
10
10
  async getOrCreate(name) {
11
11
  const indexKey = this.client.key('kvs:index');
12
+ // Every storage belongs to the run's user; the name pointer is scoped by that user.
13
+ const userId = (0, utils_js_1.requireStorageOwner)(this.client.billingContext, 'key-value store');
12
14
  if (name) {
13
- const existing = await this.client.redis.hget(indexKey, `name:${name}`);
15
+ const existing = await this.client.redis.hget(indexKey, (0, utils_js_1.ownerNameField)(userId, name));
14
16
  if (existing)
15
17
  return JSON.parse(existing);
16
18
  }
17
19
  const id = (0, utils_js_1.generateId)();
18
20
  const now = (0, utils_js_1.nowISO)();
19
- // Derive ownership from the active billing context (as the request-queue
20
- // client does); the server authorizes against this persisted field.
21
- // The legacy '1' placeholder applies only when no context is present.
22
- const userId = this.client.billingContext?.userId ?? '1';
23
21
  const data = {
24
22
  id,
25
23
  name,
@@ -30,7 +28,7 @@ class DragonflyKeyValueStoreCollectionClient {
30
28
  };
31
29
  await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
32
30
  if (name)
33
- await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
31
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
34
32
  const infoKey = this.client.key('kvs', id, 'info');
35
33
  const recordsKey = this.client.key('kvs', id, 'records');
36
34
  const metaKey = this.client.key('kvs', id, 'meta');
@@ -68,8 +66,7 @@ class DragonflyKeyValueStoreCollectionClient {
68
66
  const exists = await this.client.redis.exists(infoKey);
69
67
  if (!exists) {
70
68
  await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
71
- if (parsed.name)
72
- await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
69
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId, parsed.name, parsed.id);
73
70
  continue;
74
71
  }
75
72
  items.push(parsed);
@@ -1,5 +1,5 @@
1
1
  import type { DragonflyClient } from './dragonfly-client.js';
2
- import type { UserPlan } from './ttl-manager.js';
2
+ import { type UserPlan } from './ttl-manager.js';
3
3
  /**
4
4
  * Log configuration
5
5
  */
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LogManager = void 0;
4
+ const ttl_manager_js_1 = require("./ttl-manager.js");
4
5
  const run_metadata_js_1 = require("./run-metadata.js");
5
6
  const DEFAULT_LOG_CONFIG = {
6
7
  maxLogSize: 5 * 1024 * 1024, // 5MB
@@ -89,10 +90,14 @@ class LogManager {
89
90
  // a valid code-point boundary and never store a broken sequence.
90
91
  const alignedOffset = LogManager.alignUtf8Boundary(trailingBuf, 0);
91
92
  const safeTrailing = trailingBuf.subarray(alignedOffset);
92
- // Overwrite the key with only the safe trailing portion
93
- await this.client.redis.set(key, safeTrailing);
94
- // Mark log as truncated (NX — only set once; ignore if already set)
93
+ // Overwrite the key with only the safe trailing portion (keeping the log's expiry)
94
+ await this.client.redis.set(key, safeTrailing, 'KEEPTTL');
95
+ // Mark log as truncated (NX — only set once; ignore if already set). The marker
96
+ // is created here, after setLogTtl ran: it takes the log's expiry.
95
97
  await this.client.redis.set(this.getTruncatedKey(runId), '1', 'NX');
98
+ const expiry = this.client.redis.pipeline();
99
+ (0, ttl_manager_js_1.queueInheritExpiry)(expiry, key, [this.getTruncatedKey(runId)]);
100
+ await expiry.exec();
96
101
  }
97
102
  }
98
103
  /**
@@ -118,7 +123,7 @@ class LogManager {
118
123
  const key = this.getLogKey(runId);
119
124
  const ttl = this.client.ttlManager.getTtl('logs', plan);
120
125
  if (ttl !== null && ttl > 0) {
121
- await this.client.ttlManager.setExpiry(this.client.redis, [key], ttl);
126
+ await this.client.ttlManager.setExpiry(this.client.redis, [key, this.getTruncatedKey(runId)], ttl);
122
127
  }
123
128
  }
124
129
  /**