@crawlee/core 4.0.0-beta.123 → 4.0.0-beta.125

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.
@@ -1,4 +1,5 @@
1
1
  import type { CrawleeLogger } from '../log.js';
2
+ import type { SyncStateConversion } from '../recoverable_state.js';
2
3
  import { KeyValueStore } from '../storages/key_value_store.js';
3
4
  import { ErrorTracker } from './error_tracker.js';
4
5
  /**
@@ -13,8 +14,11 @@ export interface PersistenceOptions {
13
14
  }
14
15
  /**
15
16
  * The statistics surface a crawler depends on: recording per-request outcomes, tracking errors, and driving the
16
- * capture lifecycle for a run. Injected via the crawler's `statistics` option, so a custom implementation (or a
17
- * {@link Statistics} subclass tracking extra fields) can be plugged in without subclassing the crawler.
17
+ * capture lifecycle for a run. Injected via the crawler's `statistics` option, so a custom implementation can be
18
+ * plugged in without subclassing the crawler.
19
+ *
20
+ * `StateExtension` describes the custom fields tracked alongside the built-in {@link StatisticState} ones - see
21
+ * {@link StatisticsOptions.stateExtension}.
18
22
  *
19
23
  * The owned-only mutators the crawler uses to *own* a default it built - `reset()`/`resetStore()` - are deliberately
20
24
  * absent: an injected instance is borrowed, and the crawler never wipes it. Those live on the concrete
@@ -22,13 +26,13 @@ export interface PersistenceOptions {
22
26
  *
23
27
  * @category Crawlers
24
28
  */
25
- export interface IStatistics {
29
+ export interface IStatistics<StateExtension extends object = {}> {
26
30
  /** Tracker for errors on the final retry of a request. */
27
31
  readonly errorTracker: ErrorTracker;
28
32
  /** Tracker for errors on retries prior to the final one. */
29
33
  readonly errorTrackerRetry: ErrorTracker;
30
34
  /** The live statistics state the crawler reads for status messages and the final summary. */
31
- readonly state: StatisticState;
35
+ readonly state: StatisticState & StateExtension;
32
36
  /** Retries histogram - index `i` holds the number of requests that finished after `i` retries. */
33
37
  readonly requestRetryHistogram: number[];
34
38
  /** Marks a request as started, so its duration can be measured on finish/fail. */
@@ -78,9 +82,12 @@ export interface CalculatedStatistics {
78
82
  * under the key `CRAWLEE_CRAWLER_STATISTICS_*`, persists between
79
83
  * migrations and abort/resurrect
80
84
  *
85
+ * Custom fields are tracked by passing {@link StatisticsOptions.stateExtension|`stateExtension`} - the extra fields are then part
86
+ * of {@link Statistics.state|`state`}, persisted and restored along with the built-in ones.
87
+ *
81
88
  * @category Crawlers
82
89
  */
83
- export declare class Statistics implements IStatistics {
90
+ export declare class Statistics<StateExtension extends object = {}, PersistedStateExtension extends object = StateExtension> implements IStatistics<StateExtension> {
84
91
  #private;
85
92
  private static id;
86
93
  /**
@@ -95,33 +102,26 @@ export declare class Statistics implements IStatistics {
95
102
  * Statistic instance id.
96
103
  */
97
104
  readonly id: string;
98
- protected readonly persistStateKey: string;
99
105
  private readonly log;
100
106
  /**
101
107
  * Current statistic state used for doing calculations on {@link Statistics.calculate} calls
102
108
  */
103
- get state(): StatisticState;
109
+ get state(): StatisticState & StateExtension;
104
110
  /**
105
111
  * Contains the current retries histogram. Index 0 means 0 retries, index 2, 2 retries, and so on
106
112
  */
107
113
  get requestRetryHistogram(): number[];
108
114
  /**
109
115
  * Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
110
- * persistence or error snapshots, share it across sequential runs, or subclass it to track extra fields.
116
+ * persistence or error snapshots, share it across sequential runs, or track extra fields via `state`.
111
117
  */
112
- constructor(options?: StatisticsOptions);
118
+ constructor(options?: StatisticsOptions<StateExtension, PersistedStateExtension>);
113
119
  /**
114
120
  * Set the current statistic instance to pristine values.
115
121
  *
116
122
  * The persisted record is left alone - use {@link Statistics.resetStore} to clear that as well.
117
123
  */
118
124
  reset(): void;
119
- /**
120
- * The pristine state a new instance starts with and {@link Statistics.reset} restores.
121
- *
122
- * A subclass tracking extra fields declares their initial values here.
123
- */
124
- protected defaultState(): StatisticState;
125
125
  /**
126
126
  * Clear the persisted statistics record, leaving the in-memory state alone.
127
127
  *
@@ -174,27 +174,16 @@ export declare class Statistics implements IStatistics {
174
174
  * crawler calls this from its migration handler, where a rejection would go unhandled.
175
175
  */
176
176
  persistState(): Promise<void>;
177
- /**
178
- * Rebuilds the state from a persisted record.
179
- *
180
- * A subclass tracking extra fields restores them here, on top of the result of `super.deserializeState()`.
181
- */
182
- protected deserializeState(persistedState: StatisticPersistedState): StatisticState;
183
- /**
184
- * Builds the record written to the key value store, merging in the derived aggregates so that a consumer
185
- * reading the record does not have to reconstruct them.
186
- */
187
- protected serializeState(state: StatisticState): StatisticPersistedState;
188
177
  /**
189
178
  * Make this class serializable when called with `JSON.stringify(statsInstance)` directly
190
179
  * or through `keyValueStore.setValue('KEY', statsInstance)`
191
180
  */
192
- toJSON(): StatisticPersistedState;
181
+ toJSON(): StatisticPersistedState & PersistedStateExtension;
193
182
  }
194
183
  /**
195
184
  * Configuration for the {@link Statistics} instance used by the crawler
196
185
  */
197
- export interface StatisticsOptions {
186
+ export interface StatisticsOptions<StateExtension extends object = {}, PersistedStateExtension extends object = StateExtension> {
198
187
  /**
199
188
  * Interval in seconds to log the current statistics
200
189
  * @default 60
@@ -233,6 +222,57 @@ export interface StatisticsOptions {
233
222
  * if crawler creation order changes.
234
223
  */
235
224
  id?: string;
225
+ /**
226
+ * Custom fields to track alongside the built-in {@link StatisticState} ones. They become part of
227
+ * {@link Statistics.state|`state`} (typed as such), are persisted with the rest of the state, and are
228
+ * restored on migration or resurrect.
229
+ *
230
+ * ```ts
231
+ * const statistics = new Statistics({ stateExtension: { defaultState: { productsFound: 0 } } });
232
+ * statistics.state.productsFound++;
233
+ * ```
234
+ */
235
+ stateExtension?: StatisticStateExtensionOptions<StateExtension, PersistedStateExtension>;
236
+ }
237
+ /**
238
+ * How the custom fields of {@link StatisticsOptions.stateExtension} are initialized and converted to and from the
239
+ * persisted record - the same three things {@link RecoverableStateOptions} asks for, scoped to the custom half
240
+ * of the statistics state.
241
+ */
242
+ export interface StatisticStateExtensionOptions<StateExtension extends object, PersistedStateExtension extends object = StateExtension> {
243
+ /**
244
+ * The values the fields start with, and the ones {@link Statistics.reset} restores. A plain value is
245
+ * deep-copied with `structuredClone` each time it is used; pass a factory for a state `structuredClone` cannot
246
+ * rebuild.
247
+ *
248
+ * Can be omitted when `deserialize` supplies its own defaults, which is then the single place the fields are
249
+ * declared - see the example on {@link StatisticStateExtensionOptions.deserialize|`deserialize`}.
250
+ */
251
+ defaultState?: StateExtension | (() => StateExtension);
252
+ /**
253
+ * Rebuilds the custom fields from the persisted record, and the place to validate them before trusting them.
254
+ * Receives the whole record, so it has to supply a value for every field - `.default()` in a schema, or
255
+ * {@link StatisticStateExtensionOptions.defaultState|`defaultState`} alongside a conversion that copes with
256
+ * a missing field itself.
257
+ *
258
+ * ```ts
259
+ * const statistics = new Statistics({
260
+ * stateExtension: { deserialize: z.object({ productsFound: z.number().default(0) }) },
261
+ * });
262
+ * ```
263
+ *
264
+ * Without it, the declared fields are restored as they were persisted - which is a record off the key-value
265
+ * store taken at its word, `productsFound` included in whatever type it happens to hold.
266
+ *
267
+ * A conversion that rejects the record costs the custom fields their persisted values (they start from the
268
+ * defaults, with a warning) and nothing else.
269
+ */
270
+ deserialize?: SyncStateConversion<unknown, StateExtension>;
271
+ /**
272
+ * Converts the custom fields to the JSON-serializable form they are persisted in. Not needed for fields that
273
+ * already are one - pair it with `deserialize` for the fields that are not.
274
+ */
275
+ serialize?: SyncStateConversion<StateExtension, PersistedStateExtension>;
236
276
  }
237
277
  /**
238
278
  * Format of the persisted stats.
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { RecoverableState } from '../recoverable_state.js';
2
+ import { convertStateSync, RecoverableState } from '../recoverable_state.js';
3
3
  import { serviceLocator } from '../service_locator.js';
4
4
  import { KeyValueStore } from '../storages/key_value_store.js';
5
5
  import { parseArgument, schemas, validators } from '../validators.js';
@@ -27,6 +27,7 @@ const statisticsOptionsSchema = z.strictObject({
27
27
  persistenceOptions: schemas.anyObject.default(() => ({ enable: true })),
28
28
  saveErrorSnapshots: z.boolean().default(false),
29
29
  id: z.union([schemas.anyNumber, z.string()]).optional(),
30
+ stateExtension: schemas.anyObject.default(() => ({})),
30
31
  });
31
32
  const errorTrackerConfig = {
32
33
  showErrorCode: true,
@@ -44,12 +45,14 @@ const errorTrackerConfig = {
44
45
  * `null`. Both {@link Statistics.serializeState} and {@link Statistics.deserializeState} run through this,
45
46
  * which is what keeps them describing the same record.
46
47
  *
47
- * Nothing is optional on purpose: `serializeState` has always written every field, so a record missing one is not
48
- * one of ours and is discarded whole rather than partially trusted - a counter restored as a string would poison
49
- * every later increment.
48
+ * Nothing is optional on purpose: the record has always carried every field, so one missing a field is not one of
49
+ * ours and is discarded whole rather than partially trusted - a counter restored as a string would poison every
50
+ * later increment.
51
+ *
52
+ * Custom fields are not this schema's business either way: they are added to the record after the encode and
53
+ * validated by their own conversion on the way back, so the keys it does not know about are simply dropped here.
50
54
  */
51
- const persistedStatisticState = z
52
- .object({
55
+ const persistedStatisticState = z.object({
53
56
  requestsFinished: z.number(),
54
57
  requestsFailed: z.number(),
55
58
  requestsRetries: z.number(),
@@ -75,10 +78,7 @@ const persistedStatisticState = z
75
78
  requestsWithStatusCode: z.record(z.string(), z.number()),
76
79
  errors: z.record(z.string(), z.unknown()),
77
80
  retryErrors: z.record(z.string(), z.unknown()),
78
- })
79
- // A subclass tracking extra fields spreads them into the record; they are none of this schema's business,
80
- // but they must not be dropped on the way through it.
81
- .catchall(z.unknown());
81
+ });
82
82
  /** `Infinity` is what the statistics use for "nothing to average yet"; JSON has only `null` for it. */
83
83
  function finiteOrNull(value) {
84
84
  return Number.isFinite(value) ? value : null;
@@ -88,8 +88,7 @@ function finiteOrNull(value) {
88
88
  *
89
89
  * Built per instance rather than kept as a constant because a record carries three things the state does not: the
90
90
  * instance `id`, the derived aggregates of the overridable {@link Statistics.calculate}, and - on the way back -
91
- * the fields that are rebuilt from {@link Statistics.defaultState} rather than restored, the error trackers
92
- * among them.
91
+ * the fields that are rebuilt from the instance's default state rather than restored, the error trackers among them.
93
92
  *
94
93
  * The model side is deliberately opaque: zod rebuilds what it validates, and `state.errors` has to stay the very
95
94
  * object the error trackers write into, not a copy of it.
@@ -153,6 +152,9 @@ function buildStatisticStateCodec(statistics) {
153
152
  * under the key `CRAWLEE_CRAWLER_STATISTICS_*`, persists between
154
153
  * migrations and abort/resurrect
155
154
  *
155
+ * Custom fields are tracked by passing {@link StatisticsOptions.stateExtension|`stateExtension`} - the extra fields are then part
156
+ * of {@link Statistics.state|`state`}, persisted and restored along with the built-in ones.
157
+ *
156
158
  * @category Crawlers
157
159
  */
158
160
  export class Statistics {
@@ -170,9 +172,12 @@ export class Statistics {
170
172
  * Statistic instance id.
171
173
  */
172
174
  id;
173
- persistStateKey;
175
+ #persistStateKey;
174
176
  #stateCodec;
175
177
  #recoverableState;
178
+ #stateExtension;
179
+ #defaultStateExtension;
180
+ #stateExtensionKeys;
176
181
  #logIntervalMillis;
177
182
  #logMessage;
178
183
  #requestsInProgress = new Map();
@@ -192,31 +197,40 @@ export class Statistics {
192
197
  }
193
198
  /**
194
199
  * Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
195
- * persistence or error snapshots, share it across sequential runs, or subclass it to track extra fields.
200
+ * persistence or error snapshots, share it across sequential runs, or track extra fields via `state`.
196
201
  */
197
202
  constructor(options = {}) {
198
- const { logIntervalSecs, logMessage, log, keyValueStore, persistenceOptions, saveErrorSnapshots, id } = parseArgument(options, statisticsOptionsSchema);
203
+ const { logIntervalSecs, logMessage, log, keyValueStore, persistenceOptions, saveErrorSnapshots, id, stateExtension, } = parseArgument(options, statisticsOptionsSchema);
199
204
  this.id = id ?? String(Statistics.id++);
200
- this.persistStateKey = `CRAWLEE_CRAWLER_STATISTICS_${this.id}`;
205
+ this.#persistStateKey = `CRAWLEE_CRAWLER_STATISTICS_${this.id}`;
201
206
  this.log = (log ?? serviceLocator.getLogger()).child({ prefix: 'Statistics' });
202
207
  this.errorTracker = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
203
208
  this.errorTrackerRetry = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
204
209
  this.#logIntervalMillis = logIntervalSecs * 1000;
205
210
  this.#logMessage = logMessage;
206
- // Late-bound on purpose - both hooks are override points, and a subclass's must be the ones that run.
211
+ this.#stateExtension = stateExtension;
212
+ this.#defaultStateExtension = this.#resolveDefaultStateExtension(this.#stateExtension);
213
+ this.#stateExtensionKeys = Object.keys(this.#defaultStateExtension());
214
+ for (const key of this.#stateExtensionKeys) {
215
+ if (key in this.#builtInDefaultState()) {
216
+ throw new Error(`The custom statistics field \`${String(key)}\` collides with a built-in one - it would shadow ` +
217
+ 'the value the crawler tracks. Rename it in `stateExtension`.');
218
+ }
219
+ }
220
+ // `calculate()` is late-bound on purpose - it is an override point, and a subclass's must be the one that runs.
207
221
  this.#stateCodec = buildStatisticStateCodec({
208
222
  statsId: this.id,
209
- defaultState: () => this.defaultState(),
223
+ defaultState: () => this.#defaultState(),
210
224
  calculate: () => this.calculate(),
211
225
  });
212
226
  this.#recoverableState = new RecoverableState({
213
- persistStateKey: this.persistStateKey,
227
+ persistStateKey: this.#persistStateKey,
214
228
  persistenceEnabled: persistenceOptions.enable,
215
229
  keyValueStore,
216
230
  logger: this.log,
217
- defaultState: () => this.defaultState(),
218
- serialize: (state) => this.serializeState(state),
219
- deserialize: (persistedState) => this.deserializeState(persistedState),
231
+ defaultState: () => this.#defaultState(),
232
+ serialize: (state) => this.#serializeState(state),
233
+ deserialize: (persistedState) => this.#deserializeState(persistedState),
220
234
  });
221
235
  // initialize by "resetting"
222
236
  this.reset();
@@ -232,12 +246,44 @@ export class Statistics {
232
246
  this.#recoverableState.reset();
233
247
  this.#requestsInProgress.clear();
234
248
  }
249
+ /** The pristine state a new instance starts with and {@link Statistics.reset} restores. */
250
+ #defaultState() {
251
+ return {
252
+ ...this.#builtInDefaultState(),
253
+ ...this.#defaultStateExtension(),
254
+ };
255
+ }
235
256
  /**
236
- * The pristine state a new instance starts with and {@link Statistics.reset} restores.
257
+ * The factory behind the custom half of the default state - it has to hand out a fresh object every time, or a
258
+ * `reset()` would write through to the defaults of the next one.
237
259
  *
238
- * A subclass tracking extra fields declares their initial values here.
260
+ * With no `defaultState` given, the defaults are what `deserialize` makes of an empty record. That keeps a
261
+ * single declaration of the custom fields - a schema with a `.default()` per field is enough - and the defaults
262
+ * cannot then disagree with the conversion that has to accept them back.
239
263
  */
240
- defaultState() {
264
+ #resolveDefaultStateExtension(options) {
265
+ const { defaultState, deserialize } = options;
266
+ if (typeof defaultState === 'function') {
267
+ return defaultState;
268
+ }
269
+ if (defaultState !== undefined) {
270
+ return () => structuredClone(defaultState);
271
+ }
272
+ if (deserialize === undefined) {
273
+ return () => ({});
274
+ }
275
+ return () => {
276
+ try {
277
+ return convertStateSync(deserialize, {}, this.#persistStateKey);
278
+ }
279
+ catch (error) {
280
+ throw new Error('Could not derive the default values of the custom statistics fields from `stateExtension.deserialize` - ' +
281
+ 'give every field a default, or declare `stateExtension.defaultState` explicitly.', { cause: error });
282
+ }
283
+ };
284
+ }
285
+ /** The built-in half of {@link Statistics.defaultState}, before any custom fields are merged over it. */
286
+ #builtInDefaultState() {
241
287
  return {
242
288
  requestsFinished: 0,
243
289
  requestsFailed: 0,
@@ -390,28 +436,58 @@ export class Statistics {
390
436
  async persistState() {
391
437
  await this.#recoverableState
392
438
  .persistState()
393
- .catch((error) => this.log.warning(`Failed to persist the statistics to ${this.persistStateKey}`, { error }));
439
+ .catch((error) => this.log.warning(`Failed to persist the statistics to ${this.#persistStateKey}`, { error }));
394
440
  }
395
- /**
396
- * Rebuilds the state from a persisted record.
397
- *
398
- * A subclass tracking extra fields restores them here, on top of the result of `super.deserializeState()`.
399
- */
400
- deserializeState(persistedState) {
401
- // The cast is the index signature the `catchall` puts on the schema and an interface cannot have - the
402
- // record is an unvalidated blob off the key-value store either way, which is what the decode is for.
441
+ /** Rebuilds the state from a persisted record. */
442
+ #deserializeState(persistedState) {
443
+ // The cast covers the custom fields, whose type is open here - and the record is an unvalidated blob off the
444
+ // key-value store either way, which is what the decode is for. Their keys are not in the schema, so the
445
+ // decode drops them; `#restoreStateExtension` is what brings them back.
403
446
  const restored = z.safeDecode(this.#stateCodec, persistedState);
404
447
  if (!restored.success) {
405
448
  // Statistics are bookkeeping - a record that cannot be made sense of is worth a warning and a fresh
406
449
  // start, not a failed crawl.
407
450
  this.log.warning('Received invalid state from Key-value store, starting the statistics from scratch.', {
408
- persistStateKey: this.persistStateKey,
451
+ persistStateKey: this.#persistStateKey,
409
452
  issues: restored.error.issues,
410
453
  });
411
- return this.defaultState();
454
+ return this.#defaultState();
455
+ }
456
+ this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.#persistStateKey });
457
+ return { ...this.#defaultState(), ...restored.data, ...this.#restoreStateExtension(persistedState) };
458
+ }
459
+ /**
460
+ * The custom {@link StatisticsOptions.stateExtension|`stateExtension`} fields as they were persisted - the codec only rebuilds
461
+ * the built-in ones.
462
+ *
463
+ * Given a `deserialize`, the whole record goes through it, so a field the record does not carry - one declared
464
+ * after the record was written - comes back as whatever default the conversion gives it. Without one, the
465
+ * declared keys are copied over as they were persisted and a missing one keeps the default it already has.
466
+ */
467
+ #restoreStateExtension(persistedState) {
468
+ const { deserialize } = this.#stateExtension;
469
+ if (deserialize === undefined) {
470
+ const restored = {};
471
+ for (const key of this.#stateExtensionKeys) {
472
+ // The record is an unvalidated blob and there is no conversion to check it with, so this is the
473
+ // caller's word for it - the state ends up holding whatever was written. The two halves only line
474
+ // up at all because the fields are persisted as they are without a `serialize`.
475
+ const persistedValue = persistedState[key];
476
+ if (persistedValue !== undefined) {
477
+ restored[key] = persistedValue;
478
+ }
479
+ }
480
+ return restored;
481
+ }
482
+ try {
483
+ return convertStateSync(deserialize, persistedState, this.#persistStateKey);
484
+ }
485
+ catch (error) {
486
+ // Same policy as a built-in field that cannot be made sense of, but scoped to the custom ones - a
487
+ // corrupt counter of your own is no reason to throw away the crawler's.
488
+ this.log.warning('Received invalid custom statistics fields from Key-value store, starting those from scratch.', { persistStateKey: this.#persistStateKey, error });
489
+ return {};
412
490
  }
413
- this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.persistStateKey });
414
- return { ...this.defaultState(), ...restored.data };
415
491
  }
416
492
  #stopLogging() {
417
493
  if (this.#logInterval) {
@@ -423,14 +499,38 @@ export class Statistics {
423
499
  * Builds the record written to the key value store, merging in the derived aggregates so that a consumer
424
500
  * reading the record does not have to reconstruct them.
425
501
  */
426
- serializeState(state) {
427
- return z.encode(this.#stateCodec, state);
502
+ #serializeState(state) {
503
+ const { builtIn, extension } = this.#splitState(state);
504
+ return {
505
+ ...z.encode(this.#stateCodec, builtIn),
506
+ ...this.#serializeStateExtension(extension),
507
+ };
508
+ }
509
+ /** The custom {@link StatisticsOptions.stateExtension|`stateExtension`} fields as they go into the record. */
510
+ #serializeStateExtension(extension) {
511
+ const { serialize } = this.#stateExtension;
512
+ if (serialize === undefined) {
513
+ return extension;
514
+ }
515
+ // Unlike the way back, a failure here throws: the value is the caller's own, and a record written from a
516
+ // state that does not match its own declaration is not worth having.
517
+ return convertStateSync(serialize, extension, this.#persistStateKey);
518
+ }
519
+ /** Separates the declared custom fields from the built-in ones, so that each half goes through its own conversion. */
520
+ #splitState(state) {
521
+ const builtIn = { ...state };
522
+ const extension = {};
523
+ for (const key of this.#stateExtensionKeys) {
524
+ extension[key] = state[key];
525
+ delete builtIn[key];
526
+ }
527
+ return { builtIn, extension };
428
528
  }
429
529
  /**
430
530
  * Make this class serializable when called with `JSON.stringify(statsInstance)` directly
431
531
  * or through `keyValueStore.setValue('KEY', statsInstance)`
432
532
  */
433
533
  toJSON() {
434
- return this.serializeState(this.state);
534
+ return this.#serializeState(this.state);
435
535
  }
436
536
  }
@@ -18,7 +18,8 @@ export class EventManager {
18
18
  #persistStateIntervalMillis;
19
19
  constructor(options) {
20
20
  this.#persistStateIntervalMillis = options.persistStateIntervalMillis;
21
- this.events.setMaxListeners(50);
21
+ // One MIGRATING listener per RequestQueue, and ThrottlingRequestManager opens one per domain.
22
+ this.events.setMaxListeners(150);
22
23
  }
23
24
  /**
24
25
  * Initializes the event manager by starting the `persistState` event interval.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/core",
3
- "version": "4.0.0-beta.123",
3
+ "version": "4.0.0-beta.125",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -52,11 +52,12 @@
52
52
  "@apify/log": "^2.5.18",
53
53
  "@apify/timeout": "^0.4.4",
54
54
  "@apify/utilities": "^2.15.5",
55
- "@crawlee/fs-storage": "4.0.0-beta.123",
56
- "@crawlee/http-client": "4.0.0-beta.123",
57
- "@crawlee/types": "4.0.0-beta.123",
58
- "@crawlee/utils": "4.0.0-beta.123",
55
+ "@crawlee/fs-storage": "4.0.0-beta.125",
56
+ "@crawlee/http-client": "4.0.0-beta.125",
57
+ "@crawlee/types": "4.0.0-beta.125",
58
+ "@crawlee/utils": "4.0.0-beta.125",
59
59
  "@sapphire/async-queue": "^1.5.5",
60
+ "@standard-schema/spec": "^1.0.0",
60
61
  "@vladfrangu/async_event_emitter": "^2.4.6",
61
62
  "content-type": "^1.0.5",
62
63
  "csv-stringify": "^6.5.2",
@@ -77,5 +78,5 @@
77
78
  }
78
79
  }
79
80
  },
80
- "gitHead": "f77648095c6a3f5ed8815c7620ea765db430ae44"
81
+ "gitHead": "04a7212dd4abe4a5f94f5fc9632acb6089819c8b"
81
82
  }
@@ -11,6 +11,21 @@ import type { StandardSchemaV1 } from '@standard-schema/spec';
11
11
  * other one.
12
12
  */
13
13
  export type StateConversion<TFrom, TTo> = ((value: TFrom) => Awaitable<TTo>) | StandardSchemaV1<TFrom, TTo>;
14
+ /**
15
+ * A {@link StateConversion} for a caller that cannot await one - {@link Statistics}, whose `toJSON()` is
16
+ * synchronous, being the reason this exists.
17
+ *
18
+ * Only the function arm can be narrowed here: a Standard Schema is free to validate asynchronously, so a schema
19
+ * that does is rejected when it runs rather than when it is passed.
20
+ */
21
+ export type SyncStateConversion<TFrom, TTo> = ((value: TFrom) => TTo) | StandardSchemaV1<TFrom, TTo>;
22
+ /**
23
+ * Applies a {@link SyncStateConversion}, throwing a {@link StateValidationError} for a schema that rejects
24
+ * the value.
25
+ *
26
+ * @internal
27
+ */
28
+ export declare function convertStateSync<TFrom, TTo>(conversion: SyncStateConversion<TFrom, TTo>, value: TFrom, persistStateKey: string): TTo;
14
29
  export interface RecoverableStatePersistenceOptions {
15
30
  /**
16
31
  * The key under which the state is stored in the KeyValueStore
@@ -1,6 +1,25 @@
1
1
  import { addTimeoutToPromise, storage as timeoutStorage } from '@apify/timeout';
2
2
  import { EventType, KeyValueStore, serviceLocator, StateValidationError } from '@crawlee/core';
3
3
  const DEFAULT_PERSISTENCE_TIMEOUT_MILLIS = 60_000;
4
+ /**
5
+ * Applies a {@link SyncStateConversion}, throwing a {@link StateValidationError} for a schema that rejects
6
+ * the value.
7
+ *
8
+ * @internal
9
+ */
10
+ export function convertStateSync(conversion, value, persistStateKey) {
11
+ if (typeof conversion === 'function') {
12
+ return conversion(value);
13
+ }
14
+ const result = conversion['~standard'].validate(value);
15
+ if ('then' in result) {
16
+ throw new Error(`The state conversion for '${persistStateKey}' validated asynchronously, which this caller cannot await.`);
17
+ }
18
+ if (result.issues) {
19
+ throw new StateValidationError(persistStateKey, result.issues);
20
+ }
21
+ return result.value;
22
+ }
4
23
  /**
5
24
  * A class for managing persistent recoverable state using a plain JavaScript object.
6
25
  *