scrapely 1.2.3 → 1.2.4

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,232 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DragonflyDatasetClient = void 0;
4
+ const utils_js_1 = require("./utils.js");
5
+ const billing_tracker_js_1 = require("./billing-tracker.js");
6
+ const dataset_push_script_js_1 = require("./dataset-push-script.js");
7
+ function makeAsyncIterableResult(promiseFn) {
8
+ const promise = promiseFn();
9
+ const iterable = {
10
+ [Symbol.asyncIterator]: () => {
11
+ let items = null;
12
+ let index = 0;
13
+ return {
14
+ async next() {
15
+ if (!items)
16
+ items = (await promise).items;
17
+ if (index >= items.length)
18
+ return { done: true, value: undefined };
19
+ return { done: false, value: items[index++] };
20
+ },
21
+ };
22
+ },
23
+ then: promise.then.bind(promise),
24
+ catch: promise.catch.bind(promise),
25
+ finally: promise.finally.bind(promise),
26
+ [Symbol.toStringTag]: 'AsyncIterablePromise',
27
+ };
28
+ return iterable;
29
+ }
30
+ class DragonflyDatasetClient {
31
+ client;
32
+ id;
33
+ infoKey;
34
+ itemsKey;
35
+ constructor(client, id) {
36
+ this.client = client;
37
+ this.id = id;
38
+ this.infoKey = client.key('dataset', id, 'info');
39
+ this.itemsKey = client.key('dataset', id, 'items');
40
+ }
41
+ async get() {
42
+ const info = await this.client.redis.hgetall(this.infoKey);
43
+ if (!info || !info.id)
44
+ return undefined;
45
+ return {
46
+ id: info.id,
47
+ name: info.name || undefined,
48
+ createdAt: new Date(info.createdAt),
49
+ modifiedAt: new Date(info.modifiedAt),
50
+ accessedAt: new Date(info.accessedAt),
51
+ itemCount: parseInt(info.itemCount || '0', 10),
52
+ storageBytes: parseInt(info.storageBytes || '0', 10),
53
+ };
54
+ }
55
+ async update(newFields) {
56
+ const now = (0, utils_js_1.nowISO)();
57
+ if (newFields.name !== undefined) {
58
+ await this.client.redis.hset(this.infoKey, 'name', newFields.name);
59
+ const indexKey = this.client.key('datasets:index');
60
+ const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
61
+ if (raw) {
62
+ const data = JSON.parse(raw);
63
+ if (data.name)
64
+ await this.client.redis.hdel(indexKey, `name:${data.name}`);
65
+ data.name = newFields.name;
66
+ 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));
68
+ }
69
+ }
70
+ await this.client.redis.hset(this.infoKey, 'modifiedAt', now.toISOString());
71
+ return { name: newFields.name };
72
+ }
73
+ async delete() {
74
+ const info = await this.client.redis.hgetall(this.infoKey);
75
+ await this.client.redis.del(this.infoKey, this.itemsKey);
76
+ const indexKey = this.client.key('datasets:index');
77
+ await this.client.redis.hdel(indexKey, `id:${this.id}`);
78
+ if (info?.name)
79
+ await this.client.redis.hdel(indexKey, `name:${info.name}`);
80
+ }
81
+ async downloadItems() {
82
+ const result = await this._listItems();
83
+ return Buffer.from(JSON.stringify(result.items));
84
+ }
85
+ listItems(options) {
86
+ return makeAsyncIterableResult(() => this._listItems(options));
87
+ }
88
+ /**
89
+ * Iterates over dataset entries (index-value pairs) using an async generator.
90
+ * Required by Crawlee's Dataset.entries() method.
91
+ */
92
+ listEntries(options) {
93
+ return makeAsyncIterableResult(async () => {
94
+ const result = await this._listItems(options);
95
+ const entries = result.items.map((item, idx) => [
96
+ (result.offset ?? 0) + idx,
97
+ item,
98
+ ]);
99
+ return {
100
+ total: result.total,
101
+ count: entries.length,
102
+ offset: result.offset,
103
+ limit: result.limit,
104
+ items: entries,
105
+ };
106
+ });
107
+ }
108
+ async _listItems(options) {
109
+ const exists = await this.client.redis.exists(this.infoKey);
110
+ if (!exists)
111
+ return { total: 0, count: 0, offset: 0, limit: 0, items: [] };
112
+ // Item indices are dense (0..N-1: pushes append sequentially and there is
113
+ // no per-item delete), so pages can be addressed by index directly instead
114
+ // of pulling the whole hash on every request (matches the server route).
115
+ const totalCount = await this.client.redis.hlen(this.itemsKey);
116
+ const desc = options?.desc ?? false;
117
+ const offset = options?.offset ?? 0;
118
+ const limit = options?.limit ?? totalCount;
119
+ const pageIndices = [];
120
+ for (let n = 0; n < limit && offset + n < totalCount; n++) {
121
+ pageIndices.push(desc ? totalCount - 1 - offset - n : offset + n);
122
+ }
123
+ let items = pageIndices.length
124
+ ? (await this.client.redis.hmget(this.itemsKey, ...pageIndices.map(String)))
125
+ .filter((raw) => raw !== null)
126
+ .map(raw => JSON.parse(raw))
127
+ : [];
128
+ // Apply filtering options
129
+ items = this._applyFilters(items, options);
130
+ // Track reads for billing - increment both user-level and info hash counters
131
+ if (items.length > 0 && this.client.billingContext) {
132
+ const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
133
+ await billingTracker.trackRead(this.client.billingContext.userId, items.length);
134
+ await this.client.redis.hincrby(this.infoKey, 'readCount', items.length);
135
+ // Track internal data transfer (bytes returned to actor)
136
+ const bytesTransferred = items.reduce((sum, item) => sum + billing_tracker_js_1.BillingTracker.estimateItemBytes(item), 0);
137
+ await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, bytesTransferred);
138
+ // Register this dataset as accessed for per-run aggregation
139
+ await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'datasets_accessed'), this.id);
140
+ }
141
+ return { total: totalCount, count: items.length, offset, limit, desc: options?.desc, items };
142
+ }
143
+ /**
144
+ * Apply filtering options to dataset items.
145
+ */
146
+ _applyFilters(items, options) {
147
+ if (!options)
148
+ return items;
149
+ return items.map(item => {
150
+ let filteredItem = { ...item };
151
+ // Skip hidden fields (fields starting with "#")
152
+ if (options.skipHidden || options.clean) {
153
+ for (const key of Object.keys(filteredItem)) {
154
+ if (key.startsWith('#')) {
155
+ delete filteredItem[key];
156
+ }
157
+ }
158
+ }
159
+ // Filter to only specified fields
160
+ if (options.fields && options.fields.length > 0) {
161
+ const filtered = {};
162
+ for (const field of options.fields) {
163
+ if (field in filteredItem) {
164
+ filtered[field] = filteredItem[field];
165
+ }
166
+ }
167
+ filteredItem = filtered;
168
+ }
169
+ return filteredItem;
170
+ }).filter(item => {
171
+ // Skip empty items
172
+ if (options.skipEmpty || options.clean) {
173
+ return Object.keys(item).length > 0;
174
+ }
175
+ return true;
176
+ });
177
+ }
178
+ async pushItems(items, billingContext) {
179
+ const exists = await this.client.redis.exists(this.infoKey);
180
+ if (!exists)
181
+ throw new Error(`Dataset with id: ${this.id} does not exist.`);
182
+ let parsed;
183
+ if (typeof items === 'string') {
184
+ try {
185
+ parsed = JSON.parse(items);
186
+ }
187
+ catch {
188
+ throw new Error('Data items must be a JSON object, array, or JSON string.');
189
+ }
190
+ if (!Array.isArray(parsed))
191
+ parsed = [parsed];
192
+ }
193
+ else if (Array.isArray(items)) {
194
+ parsed = items.map(i => (typeof i === 'string' ? JSON.parse(i) : i));
195
+ }
196
+ else if (typeof items === 'object' && items !== null) {
197
+ parsed = [items];
198
+ }
199
+ else {
200
+ throw new Error('Data items must be a JSON object, array, or JSON string.');
201
+ }
202
+ for (const item of parsed) {
203
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
204
+ throw new Error('Each item must be a JSON object.');
205
+ }
206
+ }
207
+ // Calculate total bytes for billing
208
+ let totalBytes = 0;
209
+ const serializedItems = [];
210
+ for (let i = 0; i < parsed.length; i++) {
211
+ const serialized = JSON.stringify(parsed[i]);
212
+ serializedItems.push(serialized);
213
+ totalBytes += billing_tracker_js_1.BillingTracker.estimateItemBytes(parsed[i]);
214
+ }
215
+ // Atomic slot allocation: the previous read-then-write of itemCount in
216
+ // separate round trips allowed concurrent pushes to allocate the same
217
+ // slot indices, silently overwriting items.
218
+ await (0, dataset_push_script_js_1.pushItemsAtomic)(this.client.redis, this.infoKey, this.itemsKey, serializedItems, totalBytes);
219
+ // Track billing using client's billing context
220
+ if (this.client.billingContext) {
221
+ const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
222
+ await billingTracker.trackStorageBytes(this.client.billingContext.runId, this.client.billingContext.userId, totalBytes);
223
+ await billingTracker.trackWrite(this.client.billingContext.userId, parsed.length);
224
+ await this.client.redis.hincrby(this.infoKey, 'writeCount', parsed.length);
225
+ // Track internal data transfer (bytes written to storage)
226
+ await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, totalBytes);
227
+ // Register this dataset as accessed for per-run aggregation
228
+ await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'datasets_accessed'), this.id);
229
+ }
230
+ }
231
+ }
232
+ exports.DragonflyDatasetClient = DragonflyDatasetClient;
@@ -0,0 +1,8 @@
1
+ import type { DatasetCollectionClient, DatasetCollectionData, PaginatedList, Dataset } from '@crawlee/types';
2
+ import type { DragonflyClient } from './dragonfly-client.js';
3
+ export declare class DragonflyDatasetCollectionClient implements DatasetCollectionClient {
4
+ private client;
5
+ constructor(client: DragonflyClient);
6
+ getOrCreate(name?: string): Promise<DatasetCollectionData>;
7
+ list(): Promise<PaginatedList<Dataset>>;
8
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DragonflyDatasetCollectionClient = void 0;
4
+ const utils_js_1 = require("./utils.js");
5
+ class DragonflyDatasetCollectionClient {
6
+ client;
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+ async getOrCreate(name) {
11
+ const indexKey = this.client.key('datasets: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
+ const data = {
20
+ id,
21
+ name,
22
+ createdAt: now,
23
+ modifiedAt: now,
24
+ accessedAt: now,
25
+ };
26
+ await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
27
+ if (name)
28
+ await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
29
+ const infoKey = this.client.key('dataset', id, 'info');
30
+ const itemsKey = this.client.key('dataset', id, 'items');
31
+ await this.client.redis.hset(infoKey, {
32
+ id,
33
+ name: name ?? '',
34
+ itemCount: '0',
35
+ createdAt: now.toISOString(),
36
+ modifiedAt: now.toISOString(),
37
+ accessedAt: now.toISOString(),
38
+ });
39
+ // Set TTL for unnamed datasets (named datasets have indefinite retention)
40
+ if (!name) {
41
+ const ttl = this.client.ttlManager.getTtl('dataset', this.client.userPlan, false);
42
+ if (ttl) {
43
+ await this.client.ttlManager.setExpiry(this.client.redis, [infoKey, itemsKey], ttl);
44
+ }
45
+ }
46
+ return data;
47
+ }
48
+ async list() {
49
+ const indexKey = this.client.key('datasets:index');
50
+ const all = await this.client.redis.hgetall(indexKey);
51
+ const items = [];
52
+ const seen = new Set();
53
+ for (const [key, val] of Object.entries(all)) {
54
+ if (!key.startsWith('id:'))
55
+ continue;
56
+ const parsed = JSON.parse(val);
57
+ if (seen.has(parsed.id))
58
+ continue;
59
+ seen.add(parsed.id);
60
+ const infoKey = this.client.key('dataset', parsed.id, 'info');
61
+ // Skip and clean up stale index entries whose keys have expired via TTL
62
+ const exists = await this.client.redis.exists(infoKey);
63
+ if (!exists) {
64
+ await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
65
+ if (parsed.name)
66
+ await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
67
+ continue;
68
+ }
69
+ const itemCount = parseInt(await this.client.redis.hget(infoKey, 'itemCount') || '0', 10);
70
+ items.push({ ...parsed, itemCount });
71
+ }
72
+ return { total: items.length, count: items.length, offset: 0, limit: items.length, items };
73
+ }
74
+ }
75
+ exports.DragonflyDatasetCollectionClient = DragonflyDatasetCollectionClient;
@@ -0,0 +1,18 @@
1
+ import type Redis from 'ioredis';
2
+ /**
3
+ * Atomic dataset push: allocates consecutive item slots starting at the current
4
+ * itemCount, writes the items, and updates itemCount/storageBytes/modifiedAt in
5
+ * a single script.
6
+ *
7
+ * Without this, pushItems did a non-atomic `HGET itemCount` followed by HSETs of
8
+ * `count..count+n` in a pipeline, so two concurrent pushes could read the same
9
+ * count and write overlapping slots — silently overwriting items and skewing
10
+ * itemCount away from HLEN (which also broke the dense-index pagination
11
+ * assumption used by both this client and the server route).
12
+ */
13
+ export declare const PUSH_ITEMS_LUA = "\nlocal infoKey = KEYS[1]\nlocal itemsKey = KEYS[2]\nlocal now = ARGV[1]\nlocal bytes = tonumber(ARGV[2])\nlocal count = tonumber(redis.call('HGET', infoKey, 'itemCount') or '0')\nfor i = 3, #ARGV do\n redis.call('HSET', itemsKey, tostring(count), ARGV[i])\n count = count + 1\nend\nredis.call('HSET', infoKey, 'itemCount', tostring(count))\nredis.call('HINCRBY', infoKey, 'storageBytes', bytes)\nredis.call('HSET', infoKey, 'modifiedAt', now)\nreturn count\n";
14
+ /**
15
+ * Pushes serialized items into the dataset hash, allocating dense consecutive
16
+ * slots atomically. Returns the new itemCount.
17
+ */
18
+ export declare function pushItemsAtomic(redis: Redis, infoKey: string, itemsKey: string, serializedItems: string[], totalBytes: number): Promise<number>;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PUSH_ITEMS_LUA = void 0;
4
+ exports.pushItemsAtomic = pushItemsAtomic;
5
+ /**
6
+ * Atomic dataset push: allocates consecutive item slots starting at the current
7
+ * itemCount, writes the items, and updates itemCount/storageBytes/modifiedAt in
8
+ * a single script.
9
+ *
10
+ * Without this, pushItems did a non-atomic `HGET itemCount` followed by HSETs of
11
+ * `count..count+n` in a pipeline, so two concurrent pushes could read the same
12
+ * count and write overlapping slots — silently overwriting items and skewing
13
+ * itemCount away from HLEN (which also broke the dense-index pagination
14
+ * assumption used by both this client and the server route).
15
+ */
16
+ exports.PUSH_ITEMS_LUA = `
17
+ local infoKey = KEYS[1]
18
+ local itemsKey = KEYS[2]
19
+ local now = ARGV[1]
20
+ local bytes = tonumber(ARGV[2])
21
+ local count = tonumber(redis.call('HGET', infoKey, 'itemCount') or '0')
22
+ for i = 3, #ARGV do
23
+ redis.call('HSET', itemsKey, tostring(count), ARGV[i])
24
+ count = count + 1
25
+ end
26
+ redis.call('HSET', infoKey, 'itemCount', tostring(count))
27
+ redis.call('HINCRBY', infoKey, 'storageBytes', bytes)
28
+ redis.call('HSET', infoKey, 'modifiedAt', now)
29
+ return count
30
+ `;
31
+ /**
32
+ * Pushes serialized items into the dataset hash, allocating dense consecutive
33
+ * slots atomically. Returns the new itemCount.
34
+ */
35
+ async function pushItemsAtomic(redis, infoKey, itemsKey, serializedItems, totalBytes) {
36
+ const result = await redis.eval(exports.PUSH_ITEMS_LUA, 2, infoKey, itemsKey, new Date().toISOString(), String(totalBytes), ...serializedItems);
37
+ return typeof result === 'number' ? result : parseInt(String(result), 10);
38
+ }
@@ -0,0 +1,94 @@
1
+ import Redis from 'ioredis';
2
+ import type { StorageClient, SetStatusMessageOptions, RequestQueueOptions } from '@crawlee/types';
3
+ import { DragonflyDatasetCollectionClient } from './dataset-collection-client.js';
4
+ import { DragonflyDatasetClient } from './dataset-client.js';
5
+ import { DragonflyKeyValueStoreCollectionClient } from './kv-store-collection-client.js';
6
+ import { DragonflyKeyValueStoreClient } from './key-value-store-client.js';
7
+ import { DragonflyRequestQueueCollectionClient } from './rq-collection-client.js';
8
+ import { DragonflyRequestQueueClient } from './request-queue-client.js';
9
+ import { TtlManager, TtlConfig, UserPlan } from './ttl-manager.js';
10
+ export interface BillingContext {
11
+ runId: string;
12
+ userId: string;
13
+ }
14
+ export interface DragonflyClientOptions {
15
+ host: string;
16
+ port?: number;
17
+ password?: string;
18
+ keyPrefix?: string;
19
+ /**
20
+ * Single-tenant escape hatch: allow purge() to delete the entire default
21
+ * `crawlee:` key prefix even when no billingContext.runId is set. The
22
+ * `crawlee:` namespace is shared by every user/run on the platform, so
23
+ * purging it wholesale would delete other tenants' data — hence opt-in.
24
+ */
25
+ allowUnscopedPurge?: boolean;
26
+ /** User plan for TTL determination */
27
+ userPlan?: UserPlan;
28
+ /** Custom TTL configuration */
29
+ ttlConfig?: Partial<TtlConfig>;
30
+ /** Billing context for tracking usage */
31
+ billingContext?: BillingContext;
32
+ }
33
+ export declare class DragonflyClient implements StorageClient {
34
+ readonly redis: Redis;
35
+ readonly keyPrefix: string;
36
+ readonly ttlManager: TtlManager;
37
+ readonly userPlan: UserPlan;
38
+ readonly billingContext?: BillingContext;
39
+ readonly allowUnscopedPurge: boolean;
40
+ stats: {
41
+ rateLimitErrors: number[];
42
+ };
43
+ constructor(options: DragonflyClientOptions);
44
+ key(...parts: string[]): string;
45
+ datasets(): DragonflyDatasetCollectionClient;
46
+ dataset(id: string): DragonflyDatasetClient;
47
+ keyValueStores(): DragonflyKeyValueStoreCollectionClient;
48
+ keyValueStore(id: string): DragonflyKeyValueStoreClient;
49
+ requestQueues(): DragonflyRequestQueueCollectionClient;
50
+ requestQueue(id: string, options?: RequestQueueOptions): DragonflyRequestQueueClient;
51
+ /**
52
+ * Purges storage, mirroring Crawlee's StorageClient.purge() contract: clear
53
+ * only the current run's default storages (dataset, KV store, request queue).
54
+ *
55
+ * Scoping rules — the default `crawlee:` prefix is shared by every user/run
56
+ * on the platform (datasets, request queues, billing, auth tokens all live
57
+ * under it), so purging the whole prefix would delete other tenants' data:
58
+ *
59
+ * 1. With `billingContext.runId`: resolve the run's storageIds from
60
+ * `{prefix}run:{runId}` and delete only those three storage namespaces.
61
+ * 2. Without a run ID, but with a custom (scoped) `keyPrefix`: delete the
62
+ * whole prefix — it is assumed dedicated to this client.
63
+ * 3. Without a run ID and with the shared default prefix: refuse, unless
64
+ * `allowUnscopedPurge` was set explicitly (single-tenant opt-in).
65
+ *
66
+ * Deletion uses SCAN + batched UNLINK (see deleteByPattern) instead of a
67
+ * blocking KEYS walk followed by a single huge DEL.
68
+ */
69
+ purge(): Promise<void>;
70
+ /**
71
+ * Delete the default storages (dataset / KV store / request queue) of a run,
72
+ * resolved from the run's metadata hash (`{prefix}run:{runId}` → storageIds).
73
+ *
74
+ * @returns the number of keys deleted, or null when run metadata is missing
75
+ * (expired or never created) and nothing could be scoped.
76
+ */
77
+ private purgeRunDefaultStorages;
78
+ /**
79
+ * Incrementally delete every key matching `pattern` using SCAN + batched
80
+ * UNLINK. Unlike KEYS + DEL, this never walks the keyspace in one blocking
81
+ * pass, never materializes the full key list in a single reply, and never
82
+ * sends one gigantic delete command; UNLINK also reclaims memory
83
+ * asynchronously. Keys that vanish mid-scan are simply no-ops.
84
+ *
85
+ * @returns number of keys deleted (best effort).
86
+ */
87
+ deleteByPattern(pattern: string, options?: {
88
+ scanCount?: number;
89
+ unlinkBatch?: number;
90
+ }): Promise<number>;
91
+ teardown(): Promise<void>;
92
+ disconnect(): Promise<void>;
93
+ setStatusMessage(message: string, options?: SetStatusMessageOptions): Promise<void>;
94
+ }
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DragonflyClient = void 0;
7
+ const ioredis_1 = __importDefault(require("ioredis"));
8
+ const dataset_collection_client_js_1 = require("./dataset-collection-client.js");
9
+ const dataset_client_js_1 = require("./dataset-client.js");
10
+ const kv_store_collection_client_js_1 = require("./kv-store-collection-client.js");
11
+ const key_value_store_client_js_1 = require("./key-value-store-client.js");
12
+ const rq_collection_client_js_1 = require("./rq-collection-client.js");
13
+ const request_queue_client_js_1 = require("./request-queue-client.js");
14
+ const ttl_manager_js_1 = require("./ttl-manager.js");
15
+ /** The platform-wide storage namespace shared by all users and runs. */
16
+ const PLATFORM_KEY_PREFIX = 'crawlee:';
17
+ /** SCAN COUNT hint for deleteByPattern(): keys examined per scan step. */
18
+ const DEFAULT_SCAN_COUNT = 500;
19
+ /** Max keys per UNLINK call in deleteByPattern(): keeps command buffers small. */
20
+ const DEFAULT_UNLINK_BATCH = 1000;
21
+ class DragonflyClient {
22
+ redis;
23
+ keyPrefix;
24
+ ttlManager;
25
+ userPlan;
26
+ billingContext;
27
+ allowUnscopedPurge;
28
+ stats = { rateLimitErrors: [] };
29
+ constructor(options) {
30
+ const redisOpts = {
31
+ host: options.host,
32
+ port: options.port ?? 6379,
33
+ lazyConnect: false,
34
+ retryStrategy: (times) => Math.min(times * 100, 3000),
35
+ keepAlive: 10000,
36
+ };
37
+ if (options.password)
38
+ redisOpts.password = options.password;
39
+ this.redis = new ioredis_1.default(redisOpts);
40
+ this.redis.on('error', () => { });
41
+ this.keyPrefix = options.keyPrefix ?? PLATFORM_KEY_PREFIX;
42
+ this.userPlan = options.userPlan ?? 'free';
43
+ this.ttlManager = new ttl_manager_js_1.TtlManager(options.ttlConfig);
44
+ this.billingContext = options.billingContext;
45
+ this.allowUnscopedPurge = options.allowUnscopedPurge ?? false;
46
+ }
47
+ key(...parts) {
48
+ return this.keyPrefix + parts.join(':');
49
+ }
50
+ datasets() {
51
+ return new dataset_collection_client_js_1.DragonflyDatasetCollectionClient(this);
52
+ }
53
+ dataset(id) {
54
+ return new dataset_client_js_1.DragonflyDatasetClient(this, id);
55
+ }
56
+ keyValueStores() {
57
+ return new kv_store_collection_client_js_1.DragonflyKeyValueStoreCollectionClient(this);
58
+ }
59
+ keyValueStore(id) {
60
+ return new key_value_store_client_js_1.DragonflyKeyValueStoreClient(this, id);
61
+ }
62
+ requestQueues() {
63
+ return new rq_collection_client_js_1.DragonflyRequestQueueCollectionClient(this);
64
+ }
65
+ requestQueue(id, options) {
66
+ return new request_queue_client_js_1.DragonflyRequestQueueClient(this, id, options);
67
+ }
68
+ /**
69
+ * Purges storage, mirroring Crawlee's StorageClient.purge() contract: clear
70
+ * only the current run's default storages (dataset, KV store, request queue).
71
+ *
72
+ * Scoping rules — the default `crawlee:` prefix is shared by every user/run
73
+ * on the platform (datasets, request queues, billing, auth tokens all live
74
+ * under it), so purging the whole prefix would delete other tenants' data:
75
+ *
76
+ * 1. With `billingContext.runId`: resolve the run's storageIds from
77
+ * `{prefix}run:{runId}` and delete only those three storage namespaces.
78
+ * 2. Without a run ID, but with a custom (scoped) `keyPrefix`: delete the
79
+ * whole prefix — it is assumed dedicated to this client.
80
+ * 3. Without a run ID and with the shared default prefix: refuse, unless
81
+ * `allowUnscopedPurge` was set explicitly (single-tenant opt-in).
82
+ *
83
+ * Deletion uses SCAN + batched UNLINK (see deleteByPattern) instead of a
84
+ * blocking KEYS walk followed by a single huge DEL.
85
+ */
86
+ async purge() {
87
+ const runId = this.billingContext?.runId;
88
+ if (runId && (await this.purgeRunDefaultStorages(runId)) !== null)
89
+ return;
90
+ if (this.keyPrefix === PLATFORM_KEY_PREFIX && !this.allowUnscopedPurge) {
91
+ throw new Error('DragonflyClient.purge() refused: keyPrefix "crawlee:" is shared by all users/runs ' +
92
+ "of the platform, so purging it would delete other tenants' data. Provide a " +
93
+ "billingContext.runId (purges only that run's default storages), pass a scoped " +
94
+ 'keyPrefix, or set allowUnscopedPurge: true for single-tenant deployments.');
95
+ }
96
+ await this.deleteByPattern(`${this.keyPrefix}*`);
97
+ }
98
+ /**
99
+ * Delete the default storages (dataset / KV store / request queue) of a run,
100
+ * resolved from the run's metadata hash (`{prefix}run:{runId}` → storageIds).
101
+ *
102
+ * @returns the number of keys deleted, or null when run metadata is missing
103
+ * (expired or never created) and nothing could be scoped.
104
+ */
105
+ async purgeRunDefaultStorages(runId) {
106
+ const data = await this.redis.hgetall(this.key('run', runId));
107
+ if (!data || !data.storageIds)
108
+ return null;
109
+ let storageIds;
110
+ try {
111
+ storageIds = JSON.parse(data.storageIds);
112
+ }
113
+ catch {
114
+ return null; // corrupt metadata — do not guess, fall back to prefix rules
115
+ }
116
+ let deleted = 0;
117
+ if (storageIds.dataset)
118
+ deleted += await this.deleteByPattern(this.key('dataset', storageIds.dataset, '*'));
119
+ if (storageIds.kvStore)
120
+ deleted += await this.deleteByPattern(this.key('kvs', storageIds.kvStore, '*'));
121
+ if (storageIds.requestQueue)
122
+ deleted += await this.deleteByPattern(this.key('rq', storageIds.requestQueue, '*'));
123
+ return deleted;
124
+ }
125
+ /**
126
+ * Incrementally delete every key matching `pattern` using SCAN + batched
127
+ * UNLINK. Unlike KEYS + DEL, this never walks the keyspace in one blocking
128
+ * pass, never materializes the full key list in a single reply, and never
129
+ * sends one gigantic delete command; UNLINK also reclaims memory
130
+ * asynchronously. Keys that vanish mid-scan are simply no-ops.
131
+ *
132
+ * @returns number of keys deleted (best effort).
133
+ */
134
+ async deleteByPattern(pattern, options) {
135
+ const scanCount = Math.max(1, options?.scanCount ?? DEFAULT_SCAN_COUNT);
136
+ const unlinkBatch = Math.max(1, options?.unlinkBatch ?? DEFAULT_UNLINK_BATCH);
137
+ let deleted = 0;
138
+ let cursor = '0';
139
+ do {
140
+ const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount);
141
+ cursor = nextCursor;
142
+ // A single SCAN reply can exceed COUNT, so chunk the UNLINKs too.
143
+ for (let i = 0; i < keys.length; i += unlinkBatch) {
144
+ const chunk = keys.slice(i, i + unlinkBatch);
145
+ await this.redis.unlink(...chunk);
146
+ deleted += chunk.length;
147
+ }
148
+ } while (cursor !== '0');
149
+ return deleted;
150
+ }
151
+ async teardown() {
152
+ // no-op: crawlee calls this during crawler.run() shutdown
153
+ // we manage the Redis connection lifecycle ourselves via disconnectDragonfly()
154
+ }
155
+ async disconnect() {
156
+ await this.redis.quit();
157
+ }
158
+ async setStatusMessage(message, options) {
159
+ const statusKey = this.key('status');
160
+ await this.redis.hset(statusKey, {
161
+ message,
162
+ level: options?.level ?? 'INFO',
163
+ isTerminal: options?.isStatusMessageTerminal ? '1' : '0',
164
+ });
165
+ }
166
+ }
167
+ exports.DragonflyClient = DragonflyClient;
@@ -0,0 +1,11 @@
1
+ export { DragonflyClient } from './dragonfly-client.js';
2
+ export { TtlManager, type UserPlan, type StorageType, type TtlConfig } from './ttl-manager.js';
3
+ export { DragonflyDatasetClient } from './dataset-client.js';
4
+ export { DragonflyKeyValueStoreClient } from './key-value-store-client.js';
5
+ export { DragonflyRequestQueueClient } from './request-queue-client.js';
6
+ export { DragonflyDatasetCollectionClient } from './dataset-collection-client.js';
7
+ export { DragonflyKeyValueStoreCollectionClient } from './kv-store-collection-client.js';
8
+ export { DragonflyRequestQueueCollectionClient } from './rq-collection-client.js';
9
+ export { RunMetadataManager, type RunMetadata, type RunStatus } from './run-metadata.js';
10
+ export { LogManager, type LogConfig } from './log-manager.js';
11
+ export { BillingTracker, type BillingSummary } from './billing-tracker.js';