scrapely 1.2.3 → 1.2.5

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 (86) hide show
  1. package/README.md +119 -1
  2. package/dist/actor.d.ts +703 -0
  3. package/dist/actor.js +1441 -0
  4. package/dist/browser-configuration.d.ts +58 -0
  5. package/dist/browser-configuration.js +128 -0
  6. package/dist/browser-locations.d.ts +5 -0
  7. package/dist/browser-locations.js +5469 -0
  8. package/dist/charging.d.ts +153 -0
  9. package/dist/charging.js +782 -0
  10. package/dist/charging_client.d.ts +18 -0
  11. package/dist/charging_client.js +49 -0
  12. package/dist/configuration.d.ts +121 -0
  13. package/dist/configuration.js +128 -0
  14. package/dist/crypto/secrets.d.ts +44 -0
  15. package/dist/crypto/secrets.js +164 -0
  16. package/dist/events/events-client.d.ts +59 -0
  17. package/dist/events/events-client.js +120 -0
  18. package/dist/events/scrapely-event-manager.d.ts +13 -0
  19. package/dist/events/scrapely-event-manager.js +61 -0
  20. package/dist/index.d.ts +29 -0
  21. package/dist/index.js +95 -0
  22. package/dist/log-env.d.ts +1 -0
  23. package/dist/log-env.js +12 -0
  24. package/dist/manifest.d.ts +30 -0
  25. package/dist/manifest.js +47 -0
  26. package/dist/patch-browser-pool.d.ts +66 -0
  27. package/dist/patch-browser-pool.js +827 -0
  28. package/dist/proxy-configuration.d.ts +118 -0
  29. package/dist/proxy-configuration.js +88 -0
  30. package/dist/request-list.d.ts +57 -0
  31. package/dist/request-list.js +79 -0
  32. package/dist/runner/docker-manager.d.ts +88 -0
  33. package/dist/runner/docker-manager.js +283 -0
  34. package/dist/runner/index.d.ts +2 -0
  35. package/dist/runner/index.js +7 -0
  36. package/dist/runner/timeout-manager.d.ts +43 -0
  37. package/dist/runner/timeout-manager.js +90 -0
  38. package/dist/scrapely-proxy-configuration.d.ts +99 -0
  39. package/dist/scrapely-proxy-configuration.js +559 -0
  40. package/dist/storage/api-storage-client.d.ts +199 -0
  41. package/dist/storage/api-storage-client.js +910 -0
  42. package/dist/storage/billing-tracker.d.ts +163 -0
  43. package/dist/storage/billing-tracker.js +260 -0
  44. package/dist/storage/dataset-client.d.ts +43 -0
  45. package/dist/storage/dataset-client.js +232 -0
  46. package/dist/storage/dataset-collection-client.d.ts +8 -0
  47. package/dist/storage/dataset-collection-client.js +75 -0
  48. package/dist/storage/dataset-push-script.d.ts +18 -0
  49. package/dist/storage/dataset-push-script.js +38 -0
  50. package/dist/storage/dragonfly-client.d.ts +94 -0
  51. package/dist/storage/dragonfly-client.js +167 -0
  52. package/dist/storage/index.d.ts +11 -0
  53. package/dist/storage/index.js +25 -0
  54. package/dist/storage/key-value-store-client.d.ts +61 -0
  55. package/dist/storage/key-value-store-client.js +306 -0
  56. package/dist/storage/kv-store-collection-client.d.ts +8 -0
  57. package/dist/storage/kv-store-collection-client.js +76 -0
  58. package/dist/storage/kvs-iteration.d.ts +17 -0
  59. package/dist/storage/kvs-iteration.js +126 -0
  60. package/dist/storage/log-manager.d.ts +114 -0
  61. package/dist/storage/log-manager.js +244 -0
  62. package/dist/storage/patch-storage.d.ts +11 -0
  63. package/dist/storage/patch-storage.js +177 -0
  64. package/dist/storage/request-queue-client.d.ts +93 -0
  65. package/dist/storage/request-queue-client.js +522 -0
  66. package/dist/storage/rq-collection-client.d.ts +8 -0
  67. package/dist/storage/rq-collection-client.js +89 -0
  68. package/dist/storage/rq-list-requests.d.ts +59 -0
  69. package/dist/storage/rq-list-requests.js +114 -0
  70. package/dist/storage/rq-lock-script.d.ts +56 -0
  71. package/dist/storage/rq-lock-script.js +127 -0
  72. package/dist/storage/run-metadata.d.ts +98 -0
  73. package/dist/storage/run-metadata.js +256 -0
  74. package/dist/storage/s3-client.d.ts +103 -0
  75. package/dist/storage/s3-client.js +302 -0
  76. package/dist/storage/ttl-manager.d.ts +70 -0
  77. package/dist/storage/ttl-manager.js +115 -0
  78. package/dist/storage/utils.d.ts +3 -0
  79. package/dist/storage/utils.js +19 -0
  80. package/dist/storage-identifiers.d.ts +20 -0
  81. package/dist/storage-identifiers.js +6 -0
  82. package/dist/utils.d.ts +54 -0
  83. package/dist/utils.js +63 -0
  84. package/dist/validator.d.ts +24 -0
  85. package/dist/validator.js +158 -0
  86. package/package.json +90 -9
@@ -0,0 +1,522 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DragonflyRequestQueueClient = void 0;
4
+ const utils_js_1 = require("./utils.js");
5
+ const billing_tracker_js_1 = require("./billing-tracker.js");
6
+ const rq_lock_script_js_1 = require("./rq-lock-script.js");
7
+ const rq_list_requests_js_1 = require("./rq-list-requests.js");
8
+ const node_crypto_1 = require("node:crypto");
9
+ class DragonflyRequestQueueClient {
10
+ client;
11
+ id;
12
+ infoKey;
13
+ requestsKey;
14
+ pendingKey;
15
+ lockedKey;
16
+ lockOwnersKey;
17
+ clientKey;
18
+ constructor(client, id, options) {
19
+ this.client = client;
20
+ this.id = id;
21
+ this.clientKey = options?.clientKey || (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 16);
22
+ this.infoKey = client.key('rq', id, 'info');
23
+ this.requestsKey = client.key('rq', id, 'requests');
24
+ this.pendingKey = client.key('rq', id, 'pending');
25
+ this.lockedKey = client.key('rq', id, 'locked');
26
+ this.lockOwnersKey = client.key('rq', id, 'lockOwners');
27
+ }
28
+ estimateStoredRequestBytes(raw) {
29
+ return raw ? Buffer.byteLength(raw, 'utf8') : 0;
30
+ }
31
+ /**
32
+ * Track a write operation for billing.
33
+ */
34
+ async trackWrite(count = 1) {
35
+ if (this.client.billingContext) {
36
+ const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
37
+ await billingTracker.trackWrite(this.client.billingContext.userId, count);
38
+ await this.client.redis.hincrby(this.infoKey, 'writeCount', count);
39
+ // Register this queue as accessed for per-run aggregation
40
+ await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'rqs_accessed'), this.id);
41
+ }
42
+ }
43
+ /**
44
+ * Track a read operation for billing.
45
+ */
46
+ async trackRead(count = 1, bytesTransferred = 0) {
47
+ if (this.client.billingContext) {
48
+ const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
49
+ await billingTracker.trackRead(this.client.billingContext.userId, count);
50
+ await this.client.redis.hincrby(this.infoKey, 'readCount', count);
51
+ // Track internal data transfer (bytes returned to actor)
52
+ if (bytesTransferred > 0) {
53
+ await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, bytesTransferred);
54
+ }
55
+ // Register this queue as accessed for per-run aggregation
56
+ await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'rqs_accessed'), this.id);
57
+ }
58
+ }
59
+ /**
60
+ * Track storage bytes for billing.
61
+ */
62
+ async trackStorageBytes(bytes) {
63
+ if (this.client.billingContext) {
64
+ const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
65
+ await billingTracker.trackStorageBytes(this.client.billingContext.runId, this.client.billingContext.userId, bytes);
66
+ }
67
+ }
68
+ /**
69
+ * Track clientKey access for hadMultipleClients (per Apify spec).
70
+ * Returns the current hadMultipleClients flag after update.
71
+ */
72
+ async trackClientKey(clientKey) {
73
+ if (clientKey) {
74
+ const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
75
+ if (existingClientKey && existingClientKey !== clientKey) {
76
+ await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
77
+ }
78
+ else if (!existingClientKey) {
79
+ await this.client.redis.hset(this.infoKey, 'clientKey', clientKey);
80
+ }
81
+ }
82
+ else {
83
+ const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
84
+ if (existingClientKey) {
85
+ await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
86
+ }
87
+ }
88
+ const flag = await this.client.redis.hget(this.infoKey, 'hadMultipleClients');
89
+ return flag === '1';
90
+ }
91
+ async get() {
92
+ const info = await this.client.redis.hgetall(this.infoKey);
93
+ if (!info || !info.id)
94
+ return undefined;
95
+ return {
96
+ id: info.id,
97
+ name: info.name || undefined,
98
+ userId: info.userId,
99
+ createdAt: new Date(info.createdAt),
100
+ modifiedAt: new Date(info.modifiedAt),
101
+ accessedAt: new Date(info.accessedAt),
102
+ totalRequestCount: parseInt(info.totalRequestCount || '0', 10),
103
+ handledRequestCount: parseInt(info.handledRequestCount || '0', 10),
104
+ pendingRequestCount: parseInt(info.pendingRequestCount || '0', 10),
105
+ hadMultipleClients: info.hadMultipleClients === '1',
106
+ stats: {
107
+ readCount: parseInt(info.readCount || '0', 10),
108
+ writeCount: parseInt(info.writeCount || '0', 10),
109
+ deleteCount: parseInt(info.deleteCount || '0', 10),
110
+ headItemReadCount: parseInt(info.headItemReadCount || '0', 10),
111
+ storageBytes: parseInt(info.storageBytes || '0', 10),
112
+ },
113
+ };
114
+ }
115
+ async update(newFields) {
116
+ if (newFields.name !== undefined) {
117
+ await this.client.redis.hset(this.infoKey, 'name', newFields.name);
118
+ const indexKey = this.client.key('rq:index');
119
+ const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
120
+ if (raw) {
121
+ const data = JSON.parse(raw);
122
+ if (data.name)
123
+ await this.client.redis.hdel(indexKey, `name:${data.name}`);
124
+ data.name = newFields.name;
125
+ await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
126
+ await this.client.redis.hset(indexKey, `name:${newFields.name}`, JSON.stringify(data));
127
+ }
128
+ }
129
+ await this.client.redis.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
130
+ return { name: newFields.name };
131
+ }
132
+ async delete() {
133
+ const info = await this.client.redis.hgetall(this.infoKey);
134
+ await this.client.redis.del(this.infoKey, this.requestsKey, this.pendingKey, this.lockedKey, this.lockOwnersKey);
135
+ const indexKey = this.client.key('rq:index');
136
+ await this.client.redis.hdel(indexKey, `id:${this.id}`);
137
+ if (info?.name)
138
+ await this.client.redis.hdel(indexKey, `name:${info.name}`);
139
+ }
140
+ async addRequest(request, options) {
141
+ const requestId = request.id || (0, utils_js_1.uniqueKeyToRequestId)(request.uniqueKey);
142
+ const existing = await this.client.redis.hget(this.requestsKey, requestId);
143
+ if (existing) {
144
+ const parsed = JSON.parse(existing);
145
+ return {
146
+ requestId,
147
+ wasAlreadyPresent: true,
148
+ wasAlreadyHandled: !!parsed.handledAt,
149
+ };
150
+ }
151
+ const stored = { ...request, id: requestId };
152
+ const score = options?.forefront ? 0 : Date.now();
153
+ const pipeline = this.client.redis.pipeline();
154
+ pipeline.hset(this.requestsKey, requestId, JSON.stringify(stored));
155
+ pipeline.zadd(this.pendingKey, String(score), requestId);
156
+ pipeline.hincrby(this.infoKey, 'totalRequestCount', 1);
157
+ pipeline.hincrby(this.infoKey, 'pendingRequestCount', 1);
158
+ pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
159
+ await pipeline.exec();
160
+ // Track write and storage bytes for billing
161
+ const bytes = billing_tracker_js_1.BillingTracker.estimateRequestBytes(stored);
162
+ await Promise.all([
163
+ this.trackWrite(1),
164
+ this.trackStorageBytes(bytes),
165
+ ]);
166
+ // Track internal data transfer (bytes written to storage)
167
+ if (this.client.billingContext) {
168
+ const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
169
+ await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, bytes);
170
+ }
171
+ return { requestId, wasAlreadyPresent: false, wasAlreadyHandled: false };
172
+ }
173
+ async batchAddRequests(requests, options) {
174
+ const processedRequests = [];
175
+ const unprocessedRequests = [];
176
+ for (const request of requests) {
177
+ try {
178
+ const result = await this.addRequest(request, options);
179
+ processedRequests.push({
180
+ uniqueKey: request.uniqueKey,
181
+ requestId: result.requestId,
182
+ wasAlreadyPresent: result.wasAlreadyPresent,
183
+ wasAlreadyHandled: result.wasAlreadyHandled,
184
+ });
185
+ }
186
+ catch {
187
+ unprocessedRequests.push({
188
+ uniqueKey: request.uniqueKey,
189
+ url: request.url,
190
+ method: request.method,
191
+ });
192
+ }
193
+ }
194
+ return { processedRequests, unprocessedRequests };
195
+ }
196
+ async getRequest(id) {
197
+ const raw = await this.client.redis.hget(this.requestsKey, id);
198
+ if (!raw)
199
+ return undefined;
200
+ // Track read for billing with bytes transferred
201
+ const bytesTransferred = Buffer.byteLength(raw, 'utf8');
202
+ await this.trackRead(1, bytesTransferred);
203
+ return JSON.parse(raw);
204
+ }
205
+ async updateRequest(request, options) {
206
+ const existing = await this.client.redis.hget(this.requestsKey, request.id);
207
+ if (!existing) {
208
+ return this.addRequest(request, options);
209
+ }
210
+ const old = JSON.parse(existing);
211
+ const wasHandled = !!old.handledAt;
212
+ const isNowHandled = !!request.handledAt;
213
+ const updatedRequest = { ...old, ...request };
214
+ const oldBytes = this.estimateStoredRequestBytes(existing);
215
+ const newBytes = billing_tracker_js_1.BillingTracker.estimateRequestBytes(updatedRequest);
216
+ const deltaBytes = newBytes - oldBytes;
217
+ await this.client.redis.hset(this.requestsKey, request.id, JSON.stringify(updatedRequest));
218
+ if (!wasHandled && isNowHandled) {
219
+ const pipeline = this.client.redis.pipeline();
220
+ pipeline.zrem(this.pendingKey, request.id);
221
+ pipeline.zrem(this.lockedKey, request.id);
222
+ pipeline.hdel(this.lockOwnersKey, request.id);
223
+ pipeline.hincrby(this.infoKey, 'handledRequestCount', 1);
224
+ pipeline.hincrby(this.infoKey, 'pendingRequestCount', -1);
225
+ pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
226
+ await pipeline.exec();
227
+ }
228
+ else if (wasHandled && !isNowHandled) {
229
+ const score = options?.forefront ? 0 : Date.now();
230
+ const pipeline = this.client.redis.pipeline();
231
+ pipeline.zadd(this.pendingKey, String(score), request.id);
232
+ pipeline.hincrby(this.infoKey, 'handledRequestCount', -1);
233
+ pipeline.hincrby(this.infoKey, 'pendingRequestCount', 1);
234
+ pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
235
+ await pipeline.exec();
236
+ }
237
+ if (deltaBytes !== 0) {
238
+ await this.trackStorageBytes(deltaBytes);
239
+ }
240
+ return {
241
+ requestId: request.id,
242
+ wasAlreadyPresent: true,
243
+ wasAlreadyHandled: wasHandled,
244
+ };
245
+ }
246
+ async deleteRequest(id) {
247
+ const raw = await this.client.redis.hget(this.requestsKey, id);
248
+ if (!raw)
249
+ return;
250
+ const request = JSON.parse(raw);
251
+ const releasedBytes = this.estimateStoredRequestBytes(raw);
252
+ const pipeline = this.client.redis.pipeline();
253
+ pipeline.hdel(this.requestsKey, id);
254
+ pipeline.zrem(this.pendingKey, id);
255
+ pipeline.zrem(this.lockedKey, id);
256
+ pipeline.hdel(this.lockOwnersKey, id);
257
+ pipeline.hincrby(this.infoKey, 'totalRequestCount', -1);
258
+ if (request.handledAt) {
259
+ pipeline.hincrby(this.infoKey, 'handledRequestCount', -1);
260
+ }
261
+ else {
262
+ pipeline.hincrby(this.infoKey, 'pendingRequestCount', -1);
263
+ }
264
+ pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
265
+ await pipeline.exec();
266
+ if (releasedBytes > 0) {
267
+ await this.trackStorageBytes(-releasedBytes);
268
+ }
269
+ }
270
+ async listHead(options) {
271
+ const limit = options?.limit ?? 100;
272
+ const now = Date.now();
273
+ // Two round-trips total (same pattern as listRequestsPage): one
274
+ // pipeline for the pending head + all actively locked IDs (replaces
275
+ // the per-id ZSCORE loop), then a single HMGET for the request bodies
276
+ // (replaces the per-id HGET loop).
277
+ const pipeline = this.client.redis.pipeline();
278
+ // ioredis 6 types: stop must be string | Buffer (not number)
279
+ pipeline.zrange(this.pendingKey, 0, String(limit - 1));
280
+ // The locked ZSET is scored by lockUntil ms, so one exclusive-bound
281
+ // range read equals the old per-id ZSCORE > now check.
282
+ pipeline.zrangebyscore(this.lockedKey, `(${now}`, '+inf');
283
+ const results = await pipeline.exec();
284
+ const at = (i) => {
285
+ const value = results?.[i]?.[1];
286
+ return Array.isArray(value) ? value : [];
287
+ };
288
+ const ids = at(0);
289
+ const activeLocks = new Set(at(1));
290
+ // Filter out locked items
291
+ const items = [];
292
+ const candidates = ids.filter((id) => !activeLocks.has(id)).slice(0, limit);
293
+ if (candidates.length > 0) {
294
+ const raws = await this.client.redis.hmget(this.requestsKey, ...candidates);
295
+ for (const raw of raws) {
296
+ if (!raw)
297
+ continue;
298
+ const req = JSON.parse(raw);
299
+ items.push({
300
+ id: req.id,
301
+ retryCount: req.retryCount ?? 0,
302
+ uniqueKey: req.uniqueKey,
303
+ url: req.url,
304
+ method: req.method ?? 'GET',
305
+ });
306
+ }
307
+ }
308
+ // Track reads for billing with bytes transferred
309
+ if (items.length > 0) {
310
+ const totalBytes = items.reduce((sum, item) => sum + JSON.stringify(item).length, 0);
311
+ await this.trackRead(items.length, totalBytes);
312
+ }
313
+ const modifiedAt = await this.client.redis.hget(this.infoKey, 'modifiedAt');
314
+ const hadMultipleClients = await this.trackClientKey(this.clientKey);
315
+ return {
316
+ limit,
317
+ queueModifiedAt: modifiedAt ? new Date(modifiedAt) : (0, utils_js_1.nowISO)(),
318
+ hadMultipleClients,
319
+ items,
320
+ };
321
+ }
322
+ /**
323
+ * List all requests in the queue with optional filtering and pagination.
324
+ * Supports cursor-based pagination (preferred) and deprecated exclusiveStartId.
325
+ * @param options.limit - Max number of requests to return (default 100, max 10000)
326
+ * @param options.cursor - Base64-encoded cursor from previous response.
327
+ * If the cursor's request was deleted, listing resumes just past its
328
+ * sorted position (it never restarts from the first page).
329
+ * @param options.exclusiveStartId - DEPRECATED: Skip requests up to this ID
330
+ * @param options.filter - Filter by state: 'locked' or 'pending'
331
+ * @param options.clientKey - Client identifier for hadMultipleClients tracking
332
+ */
333
+ async listRequests(options) {
334
+ const limit = Math.min(options?.limit ?? 100, 10000);
335
+ const now = Date.now();
336
+ // Validate that filter is not used with deprecated exclusiveStartId (per Apify spec)
337
+ if (options?.filter && options.filter.length > 0 && options?.exclusiveStartId) {
338
+ throw new Error('filter parameter is not compatible with deprecated exclusiveStartId parameter');
339
+ }
340
+ // Track clientKey for hadMultipleClients (per Apify spec)
341
+ if (options?.clientKey) {
342
+ const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
343
+ if (existingClientKey && existingClientKey !== options.clientKey) {
344
+ await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
345
+ }
346
+ else if (!existingClientKey) {
347
+ await this.client.redis.hset(this.infoKey, 'clientKey', options.clientKey);
348
+ }
349
+ }
350
+ else {
351
+ // No clientKey provided - treat as new client per Apify spec
352
+ const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
353
+ if (existingClientKey) {
354
+ await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
355
+ }
356
+ }
357
+ // Decode cursor to get starting position
358
+ let startId;
359
+ if (options?.cursor) {
360
+ try {
361
+ const decoded = JSON.parse(Buffer.from(options.cursor, 'base64').toString('utf8'));
362
+ startId = decoded.requestId;
363
+ }
364
+ catch {
365
+ // Invalid cursor, start from beginning
366
+ }
367
+ }
368
+ else if (options?.exclusiveStartId) {
369
+ // Deprecated but still supported
370
+ startId = options.exclusiveStartId;
371
+ }
372
+ // Fetch, filter and paginate via the shared helper. Two round-trips
373
+ // total: one pipeline (HKEYS + only the ZSET reads the filter needs)
374
+ // and one HMGET of the page's request bodies.
375
+ const { items, hasMore } = await (0, rq_list_requests_js_1.listRequestsPage)(this.client.redis, {
376
+ requestsKey: this.requestsKey,
377
+ pendingKey: this.pendingKey,
378
+ lockedKey: this.lockedKey,
379
+ }, {
380
+ now,
381
+ limit,
382
+ startId,
383
+ filter: options?.filter,
384
+ });
385
+ // Track reads for billing
386
+ if (items.length > 0) {
387
+ const totalBytes = items.reduce((sum, item) => sum + JSON.stringify(item).length, 0);
388
+ await this.trackRead(items.length, totalBytes);
389
+ }
390
+ // Build response
391
+ const result = {
392
+ items,
393
+ count: items.length,
394
+ limit,
395
+ };
396
+ // Set exclusiveStartId for backward compatibility
397
+ if (startId) {
398
+ result.exclusiveStartId = startId;
399
+ }
400
+ // Set cursor in response if one was provided
401
+ if (options?.cursor) {
402
+ result.cursor = options.cursor;
403
+ }
404
+ // Set nextCursor if there are more results
405
+ if (hasMore && items.length > 0) {
406
+ const lastItem = items[items.length - 1];
407
+ result.nextCursor = Buffer.from(JSON.stringify({ requestId: lastItem.id })).toString('base64');
408
+ }
409
+ return result;
410
+ }
411
+ async listAndLockHead(options) {
412
+ const limit = Math.min(options.limit ?? 25, 25);
413
+ const now = Date.now();
414
+ const lockUntil = now + options.lockSecs * 1000;
415
+ const clientKey = options.clientKey || this.clientKey;
416
+ const runId = this.client.billingContext?.runId || null;
417
+ const ownerValue = runId ? `${clientKey}:${runId}` : clientKey;
418
+ const { lockedIds, activeLockCount } = await (0, rq_lock_script_js_1.listAndLockHeadAtomic)(this.client.redis, {
419
+ pendingKey: this.pendingKey,
420
+ lockedKey: this.lockedKey,
421
+ lockOwnersKey: this.lockOwnersKey,
422
+ requestsKey: this.requestsKey,
423
+ }, {
424
+ now,
425
+ lockUntil,
426
+ limit,
427
+ ownerValue,
428
+ });
429
+ const items = [];
430
+ if (lockedIds.length > 0) {
431
+ // Single HMGET instead of one HGET round-trip per locked ID
432
+ const raws = await this.client.redis.hmget(this.requestsKey, ...lockedIds);
433
+ for (const raw of raws) {
434
+ if (!raw)
435
+ continue;
436
+ const req = JSON.parse(raw);
437
+ items.push({
438
+ id: req.id,
439
+ retryCount: req.retryCount ?? 0,
440
+ uniqueKey: req.uniqueKey,
441
+ url: req.url,
442
+ method: req.method ?? 'GET',
443
+ });
444
+ }
445
+ }
446
+ if (items.length > 0) {
447
+ const totalBytes = items.reduce((sum, item) => sum + JSON.stringify(item).length, 0);
448
+ await this.trackRead(items.length, totalBytes);
449
+ }
450
+ const modifiedAt = await this.client.redis.hget(this.infoKey, 'modifiedAt');
451
+ const hadMultipleClients = await this.trackClientKey(clientKey);
452
+ return {
453
+ limit,
454
+ queueModifiedAt: modifiedAt ? new Date(modifiedAt) : (0, utils_js_1.nowISO)(),
455
+ hadMultipleClients,
456
+ lockSecs: options.lockSecs,
457
+ items,
458
+ // From the atomic lock script: active locks held by ANY client
459
+ // (including this call's own), per the Apify spec.
460
+ queueHasLockedRequests: activeLockCount > 0,
461
+ };
462
+ }
463
+ async prolongRequestLock(id, options) {
464
+ // One pipeline for the three reads instead of sequential round-trips
465
+ const pipeline = this.client.redis.pipeline();
466
+ pipeline.hget(this.requestsKey, id);
467
+ pipeline.zscore(this.lockedKey, id);
468
+ pipeline.hget(this.lockOwnersKey, id);
469
+ const results = await pipeline.exec();
470
+ const raw = results?.[0]?.[1] ?? null;
471
+ const lockScore = results?.[1]?.[1] ?? null;
472
+ const owner = results?.[2]?.[1] ?? null;
473
+ if (!raw) {
474
+ throw new Error(`Request with ID ${id} not found in queue ${this.id}`);
475
+ }
476
+ const request = JSON.parse(raw);
477
+ if (request.handledAt) {
478
+ throw new Error(`Request with ID ${id} has already been handled in queue ${this.id}`);
479
+ }
480
+ const now = Date.now();
481
+ if (!lockScore || parseInt(lockScore, 10) <= now) {
482
+ throw new Error(`Request with ID ${id} is not locked in queue ${this.id}`);
483
+ }
484
+ const runId = this.client.billingContext?.runId || null;
485
+ if (owner && !(0, rq_lock_script_js_1.matchesLockOwner)(owner, this.clientKey, runId)) {
486
+ throw new Error(`Request with ID ${id} is not locked by this client in queue ${this.id}`);
487
+ }
488
+ const lockUntil = now + options.lockSecs * 1000;
489
+ await this.client.redis.zadd(this.lockedKey, String(lockUntil), id);
490
+ return { lockExpiresAt: new Date(lockUntil) };
491
+ }
492
+ async deleteRequestLock(id, options) {
493
+ await this.client.redis.zrem(this.lockedKey, id);
494
+ await this.client.redis.hdel(this.lockOwnersKey, id);
495
+ if (options?.forefront) {
496
+ await this.client.redis.zadd(this.pendingKey, '0', id);
497
+ }
498
+ }
499
+ /**
500
+ * Unlock all requests locked by a specific clientKey.
501
+ * This is useful for batch unlocking when a client is done processing.
502
+ * @param options.clientKey - The client key to unlock requests for
503
+ * @param options.forefront - If true, move unlocked requests to the front of the queue
504
+ * @param options.runId - Optional run ID to only unlock requests for a specific run
505
+ */
506
+ async unlockRequests(options) {
507
+ // Single atomic round-trip (UNLOCK_REQUESTS_LUA): owner matching, lock
508
+ // removal and optional forefront re-queueing all happen server-side,
509
+ // replacing the previous HGETALL + 2-3 round-trips per locked request.
510
+ const unlockedCount = await (0, rq_lock_script_js_1.unlockRequestsAtomic)(this.client.redis, {
511
+ lockedKey: this.lockedKey,
512
+ lockOwnersKey: this.lockOwnersKey,
513
+ pendingKey: this.pendingKey,
514
+ }, {
515
+ clientKey: options.clientKey,
516
+ forefront: options.forefront,
517
+ runId: options.runId,
518
+ });
519
+ return { unlockedCount };
520
+ }
521
+ }
522
+ exports.DragonflyRequestQueueClient = DragonflyRequestQueueClient;
@@ -0,0 +1,8 @@
1
+ import type { RequestQueueCollectionClient, RequestQueueInfo, PaginatedList } from '@crawlee/types';
2
+ import type { DragonflyClient } from './dragonfly-client.js';
3
+ export declare class DragonflyRequestQueueCollectionClient implements RequestQueueCollectionClient {
4
+ private client;
5
+ constructor(client: DragonflyClient);
6
+ getOrCreate(name?: string): Promise<RequestQueueInfo>;
7
+ list(): Promise<PaginatedList<RequestQueueInfo>>;
8
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DragonflyRequestQueueCollectionClient = void 0;
4
+ const utils_js_1 = require("./utils.js");
5
+ class DragonflyRequestQueueCollectionClient {
6
+ client;
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+ async getOrCreate(name) {
11
+ const indexKey = this.client.key('rq:index');
12
+ if (name) {
13
+ const existing = await this.client.redis.hget(indexKey, `name:${name}`);
14
+ if (existing)
15
+ return JSON.parse(existing);
16
+ }
17
+ const id = (0, utils_js_1.generateId)();
18
+ const now = (0, utils_js_1.nowISO)();
19
+ // Real userId when billing context is active; legacy placeholder otherwise.
20
+ // Matches the KVS collection client convention — the server's storage usage
21
+ // and TTL jobs match on this field to attribute storages to users, so an
22
+ // empty value would orphan the queue from per-user accounting.
23
+ const userId = this.client.billingContext?.userId ?? '1';
24
+ const data = {
25
+ id,
26
+ name,
27
+ userId,
28
+ createdAt: now,
29
+ modifiedAt: now,
30
+ accessedAt: now,
31
+ totalRequestCount: 0,
32
+ handledRequestCount: 0,
33
+ pendingRequestCount: 0,
34
+ hadMultipleClients: false,
35
+ stats: { readCount: 0, writeCount: 0, deleteCount: 0, headItemReadCount: 0, storageBytes: 0 },
36
+ };
37
+ await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
38
+ if (name)
39
+ await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
40
+ const infoKey = this.client.key('rq', id, 'info');
41
+ const requestsKey = this.client.key('rq', id, 'requests');
42
+ const pendingKey = this.client.key('rq', id, 'pending');
43
+ const lockedKey = this.client.key('rq', id, 'locked');
44
+ const lockOwnersKey = this.client.key('rq', id, 'lockOwners');
45
+ await this.client.redis.hset(infoKey, {
46
+ id,
47
+ name: name ?? '',
48
+ userId,
49
+ totalRequestCount: '0',
50
+ handledRequestCount: '0',
51
+ pendingRequestCount: '0',
52
+ hadMultipleClients: '0',
53
+ createdAt: now.toISOString(),
54
+ modifiedAt: now.toISOString(),
55
+ accessedAt: now.toISOString(),
56
+ });
57
+ // Set 24h fallback TTL on all RQ keys (safety net for orphaned RQs from crashed containers)
58
+ // RQs should be explicitly deleted after run completes, this TTL is just a fallback
59
+ const ttl = this.client.ttlManager.getTtl('rq', this.client.userPlan);
60
+ await this.client.ttlManager.setExpiry(this.client.redis, [infoKey, requestsKey, pendingKey, lockedKey, lockOwnersKey], ttl);
61
+ return data;
62
+ }
63
+ async list() {
64
+ const indexKey = this.client.key('rq:index');
65
+ const all = await this.client.redis.hgetall(indexKey);
66
+ const items = [];
67
+ const seen = new Set();
68
+ for (const [key, val] of Object.entries(all)) {
69
+ if (!key.startsWith('id:'))
70
+ continue;
71
+ const parsed = JSON.parse(val);
72
+ if (seen.has(parsed.id))
73
+ continue;
74
+ seen.add(parsed.id);
75
+ const infoKey = this.client.key('rq', parsed.id, 'info');
76
+ // Skip and clean up stale index entries whose keys have expired via TTL
77
+ const exists = await this.client.redis.exists(infoKey);
78
+ if (!exists) {
79
+ await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
80
+ if (parsed.name)
81
+ await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
82
+ continue;
83
+ }
84
+ items.push(parsed);
85
+ }
86
+ return { total: items.length, count: items.length, offset: 0, limit: items.length, items };
87
+ }
88
+ }
89
+ exports.DragonflyRequestQueueCollectionClient = DragonflyRequestQueueCollectionClient;
@@ -0,0 +1,59 @@
1
+ import type Redis from 'ioredis';
2
+ /** Full request object as returned by listRequests endpoint */
3
+ export interface RequestQueueRequest {
4
+ id: string;
5
+ retryCount: number;
6
+ uniqueKey: string;
7
+ url: string;
8
+ method: string;
9
+ loadedUrl?: string | null;
10
+ payload?: string | null;
11
+ noRetry?: boolean;
12
+ errorMessages?: string[] | null;
13
+ headers?: Record<string, string> | null;
14
+ userData?: Record<string, unknown> | null;
15
+ handledAt?: string | null;
16
+ }
17
+ /** Per-queue Redis keys read by listRequestsPage(). */
18
+ export interface RqListRequestsKeys {
19
+ requestsKey: string;
20
+ pendingKey: string;
21
+ lockedKey: string;
22
+ }
23
+ export interface RqListRequestsParams {
24
+ now: number;
25
+ limit: number;
26
+ /** Cursor / deprecated exclusiveStartId: all IDs <= this one are skipped. */
27
+ startId?: string;
28
+ filter?: readonly ('locked' | 'pending')[];
29
+ }
30
+ export interface RqListRequestsPage {
31
+ items: RequestQueueRequest[];
32
+ /** True when IDs remain past this page (drives nextCursor). */
33
+ hasMore: boolean;
34
+ }
35
+ /**
36
+ * Index of the first element strictly greater than `target` in a
37
+ * lexicographically sorted array (binary search).
38
+ *
39
+ * Unlike indexOf(), it returns the correct position even when `target` itself
40
+ * is absent, so a cursor item deleted between pages advances pagination past
41
+ * its old position instead of restarting from page 1 (which used to loop
42
+ * pagination clients back to the first page forever). This also matches the
43
+ * documented exclusiveStartId semantics: "all requests up to this one
44
+ * (including) are skipped from the result".
45
+ */
46
+ export declare function upperBoundSorted(sortedIds: readonly string[], target: string): number;
47
+ /**
48
+ * One page of request-queue listing: filtering + cursor pagination in two
49
+ * round-trips, shared by the storage client and the API server route.
50
+ *
51
+ * Round-trip 1 — a single pipeline:
52
+ * - HKEYS: all request IDs (field names only, not the bodies);
53
+ * - ZRANGEBYSCORE locked (now +inf — only when filtering by 'locked'. The
54
+ * locked ZSET is scored by lockUntil ms, so one exclusive-bound range read
55
+ * exactly replaces the old per-id ZSCORE loop (score > now);
56
+ * - ZRANGE pending 0 -1 — only when filtering by 'pending'.
57
+ * Round-trip 2 — one HMGET for the page's request bodies.
58
+ */
59
+ export declare function listRequestsPage(redis: Redis, keys: RqListRequestsKeys, params: RqListRequestsParams): Promise<RqListRequestsPage>;