apify-client 3.0.0-beta.71 → 3.0.0-beta.72

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.
@@ -27,9 +27,10 @@ export declare const httpClientOptionsShape: {
27
27
  export type HttpResponseHeaders = Record<string, string | string[] | undefined>;
28
28
  /**
29
29
  * A request body as the transport receives it: already serialized, and compressed when that paid off. A `Readable`
30
- * is passed through untouched and is only available in Node.js.
30
+ * is passed through untouched and is only available in Node.js. A `Blob` body reaches a Node.js transport as a
31
+ * fresh `Readable` over its bytes for every attempt, and a transport in any other runtime as the `Blob` itself.
31
32
  */
32
- export type HttpRequestBody = string | Buffer | ArrayBuffer | ArrayBufferView | Readable;
33
+ export type HttpRequestBody = string | Buffer | ArrayBuffer | ArrayBufferView | Readable | Blob;
33
34
  /**
34
35
  * A response body as the transport hands it back: the raw bytes, a `Readable` when the request asked for a streamed
35
36
  * response, or `undefined` when the response carried no body.
@@ -63,8 +64,9 @@ export interface ApifyRequestConfig {
63
64
  * Request body. A plain object or array is serialized to JSON and sent as `application/json`, or form-encoded
64
65
  * when a `Content-Type: application/x-www-form-urlencoded` header asks for it. A string, binary value
65
66
  * (`Buffer`, `ArrayBuffer`, typed array) or `Readable` is sent as it is, and `URLSearchParams` is sent
66
- * form-encoded. A `Blob`, `File`, `FormData` or web `ReadableStream` is rejected with a `TypeError`, since
67
- * serializing one would send an empty body.
67
+ * form-encoded. A `Blob` or `File`, such as one from `fs.openAsBlob()`, is streamed without being held in
68
+ * memory, under its own `type` when no `Content-Type` header is set. A `FormData` or web `ReadableStream` is
69
+ * rejected with a `TypeError`, since serializing one would send an empty body.
68
70
  */
69
71
  data?: unknown;
70
72
  /**
@@ -290,8 +292,10 @@ export declare abstract class HttpClient {
290
292
  * Network errors the transport classifies as retryable, rate limits (HTTP 429) and server errors (HTTP 5xx)
291
293
  * are retried up to {@link maxRetries} times. Any other error status is thrown as {@link ApifyApiError} right
292
294
  * away. A request whose body is a `Readable` is never retried, since part of the stream has already been
293
- * consumed by the time the failure shows. Aborting `config.signal` ends the attempt in flight, skips the
294
- * remaining retries and rejects the call with the signal's `reason`.
295
+ * consumed by the time the failure shows. A `Blob` body is read again for every attempt, so it is retried like
296
+ * any other, unless reading the blob itself fails: that error is thrown right away, with the transport error
297
+ * as its `cause` when that error does not already wrap it. Aborting `config.signal` ends the attempt in flight,
298
+ * skips the remaining retries and rejects the call with the signal's `reason`.
295
299
  *
296
300
  * @template T - Type of the parsed response body.
297
301
  * @param config - The request to make.
@@ -9,7 +9,7 @@ import { InvalidResponseBodyError } from '../invalid_response_body_error.js';
9
9
  import { Statistics } from '../statistics.js';
10
10
  import { DEFAULT_TIMEOUT_LONG_SECS, DEFAULT_TIMEOUT_MAX_SECS, DEFAULT_TIMEOUT_MEDIUM_SECS, DEFAULT_TIMEOUT_SHORT_SECS, } from '../timeouts.js';
11
11
  import { runtime } from '#runtime';
12
- import { asArray, getEnv, isBuffer, isCompressibleContentType, isStream, MIN_COMPRESS_BYTES, parseArgument, sleep, toBytes, version, } from '../utils.js';
12
+ import { asArray, getEnv, isBlob, isBuffer, isCompressibleContentType, isStream, MIN_COMPRESS_BYTES, parseArgument, sleep, toBytes, version, } from '../utils.js';
13
13
  export const DEFAULT_MAX_RETRIES = 8;
14
14
  export const DEFAULT_MIN_DELAY_BETWEEN_RETRIES_MILLIS = 500;
15
15
  const RATE_LIMIT_EXCEEDED_STATUS_CODE = 429;
@@ -29,7 +29,7 @@ export const httpClientOptionsShape = {
29
29
  };
30
30
  const httpClientOptionsSchema = z.looseObject(httpClientOptionsShape).partial();
31
31
  /** Objects whose payload does not live in their own enumerable keys, so JSON serialization loses it. */
32
- const UNSERIALIZABLE_OBJECT_TAGS = new Set(['Blob', 'File', 'FormData', 'ReadableStream']);
32
+ const UNSERIALIZABLE_OBJECT_TAGS = new Set(['FormData', 'ReadableStream']);
33
33
  /**
34
34
  * Base class for the HTTP clients used by {@link ApifyClient}.
35
35
  *
@@ -183,8 +183,10 @@ export class HttpClient {
183
183
  * Network errors the transport classifies as retryable, rate limits (HTTP 429) and server errors (HTTP 5xx)
184
184
  * are retried up to {@link maxRetries} times. Any other error status is thrown as {@link ApifyApiError} right
185
185
  * away. A request whose body is a `Readable` is never retried, since part of the stream has already been
186
- * consumed by the time the failure shows. Aborting `config.signal` ends the attempt in flight, skips the
187
- * remaining retries and rejects the call with the signal's `reason`.
186
+ * consumed by the time the failure shows. A `Blob` body is read again for every attempt, so it is retried like
187
+ * any other, unless reading the blob itself fails: that error is thrown right away, with the transport error
188
+ * as its `cause` when that error does not already wrap it. Aborting `config.signal` ends the attempt in flight,
189
+ * skips the remaining retries and rejects the call with the signal's `reason`.
188
190
  *
189
191
  * @template T - Type of the parsed response body.
190
192
  * @param config - The request to make.
@@ -219,6 +221,11 @@ export class HttpClient {
219
221
  headers = mergeHeaders(headers, { 'Content-Encoding': this.httpCompressor.contentEncoding });
220
222
  deleteHeader(headers, 'content-length');
221
223
  }
224
+ // A blob reaches a Node.js transport as a stream, which would go out chunked without a known length.
225
+ // Browsers set the header themselves and refuse to let a page set it.
226
+ if (isBlob(body) && runtime.isNode) {
227
+ headers = mergeHeaders(headers, { 'Content-Length': String(body.size) });
228
+ }
222
229
  return { headers, body };
223
230
  }
224
231
  /**
@@ -303,13 +310,20 @@ export class HttpClient {
303
310
  const { config, url, headers, body, attempt, stopRetrying } = options;
304
311
  this.stats.requests++;
305
312
  const requestIsStream = isStream(config.data);
313
+ // The transport reports a failure of the blob as an error of its own, which may pass as transient.
314
+ let sourceFailure;
315
+ const attemptBody = isBlob(body)
316
+ ? runtime.openBlobBody(body, (error) => {
317
+ sourceFailure = { error };
318
+ })
319
+ : body;
306
320
  let response;
307
321
  try {
308
322
  response = await this.sendRequest({
309
323
  method: config.method,
310
324
  url,
311
325
  headers,
312
- body,
326
+ body: attemptBody,
313
327
  timeoutMillis: this.computeTimeoutMillis(attempt, config.timeoutSecs),
314
328
  stream: config.responseType === 'stream',
315
329
  signal: config.signal,
@@ -318,9 +332,20 @@ export class HttpClient {
318
332
  catch (err) {
319
333
  // Every HTTP library reports an abort with an error of its own, so the caller gets the reason instead.
320
334
  config.signal?.throwIfAborted();
335
+ if (sourceFailure) {
336
+ // Sending the body again cannot fix its source.
337
+ stopRetrying();
338
+ throw withCause(sourceFailure.error, err);
339
+ }
321
340
  this.#handleRequestError(err, config, stopRetrying);
322
341
  throw err;
323
342
  }
343
+ finally {
344
+ // A transport may stop reading the body early, for example on an error response sent before the whole
345
+ // body arrived, which would leave the blob's file open.
346
+ if (attemptBody !== body && isStream(attemptBody))
347
+ attemptBody.destroy();
348
+ }
324
349
  // A failed streaming request carries the API error body in the stream, so read it and let
325
350
  // `ApifyApiError` parse it like any other error body. A body that cannot be read leaves the error
326
351
  // without a message, which beats losing the status code to a stream error.
@@ -451,7 +476,7 @@ function deleteHeader(headers, name) {
451
476
  /**
452
477
  * Views a serialized body as the bytes to compress, or `undefined` when compressing it would not pay off: the
453
478
  * caller already labeled the body with a `Content-Encoding`, its content type carries its own compression, it is
454
- * a stream or smaller than {@link MIN_COMPRESS_BYTES}, or the runtime has no compression to offer.
479
+ * a stream or a blob, it is smaller than {@link MIN_COMPRESS_BYTES}, or the runtime has no compression to offer.
455
480
  */
456
481
  function compressibleBytes(body, headers) {
457
482
  if (body === undefined || !runtime.isNode)
@@ -463,6 +488,22 @@ function compressibleBytes(body, headers) {
463
488
  const bytes = toBytes(body);
464
489
  return bytes && bytes.byteLength >= MIN_COMPRESS_BYTES ? bytes : undefined;
465
490
  }
491
+ /**
492
+ * Sets `cause` on an error that has none, and returns the error. A `cause` that already wraps the error, as an axios
493
+ * error wraps the error of its request body, is left out, since linking it would make the chain circular.
494
+ */
495
+ function withCause(error, cause) {
496
+ if (!(error instanceof Error) || error.cause !== undefined)
497
+ return error;
498
+ const seen = new Set();
499
+ for (let link = cause; link instanceof Error && !seen.has(link); link = link.cause) {
500
+ if (link === error)
501
+ return error;
502
+ seen.add(link);
503
+ }
504
+ error.cause = cause;
505
+ return error;
506
+ }
466
507
  function headerValue(value) {
467
508
  return Array.isArray(value) ? value[0] : value;
468
509
  }
@@ -481,6 +522,11 @@ function serializeBody(data, headers, stringifyFunctions) {
481
522
  return undefined;
482
523
  if (isStream(data))
483
524
  return data;
525
+ if (isBlob(data)) {
526
+ if (getHeader(headers, 'content-type') === undefined && data.type)
527
+ headers['Content-Type'] = data.type;
528
+ return data;
529
+ }
484
530
  if (isBuffer(data)) {
485
531
  // The axios Node.js adapter refuses a body that is neither a `Buffer`, an `ArrayBuffer` nor a string.
486
532
  if (!runtime.isNode || Buffer.isBuffer(data))
@@ -210,15 +210,18 @@ export declare class KeyValueStoreClient extends ResourceClient {
210
210
  * to be valid JSON already, or {@link getRecord} fails to parse it back.
211
211
  *
212
212
  * **Note about streams:** If the value is a stream object (has `.pipe` and `.on` methods),
213
- * the upload cannot be retried on failure or follow redirects. For reliable uploads,
214
- * buffer the entire stream into memory first.
213
+ * the upload cannot be retried on failure or follow redirects. For reliable uploads of a file,
214
+ * pass a `Blob` from `fs.openAsBlob()` instead: it is read again for every attempt and never held
215
+ * in memory whole.
215
216
  *
216
217
  * @param record - The record to store
217
218
  * @param record.key - Record key (unique identifier)
218
- * @param record.value - Record value (a JSON-serializable value, Buffer, ArrayBuffer, typed array, or Readable)
219
+ * @param record.value - Record value (a JSON-serializable value, Buffer, ArrayBuffer, typed array, Blob, or
220
+ * Readable)
219
221
  * @param record.contentType - Optional MIME type. Auto-detected if not provided:
220
222
  * - Objects: `'application/json; charset=utf-8'`
221
223
  * - Strings: `'text/plain; charset=utf-8'`
224
+ * - Blobs: their `type`, or `'application/octet-stream'` when it is empty
222
225
  * - Binary values and streams: `'application/octet-stream'`
223
226
  *
224
227
  * Worth setting for media and archives: the client skips compressing a body
@@ -349,10 +352,10 @@ export interface KeyValueClientGetRecordOptions extends TimeoutOptions {
349
352
  * A value that `setRecord` accepts.
350
353
  *
351
354
  * Anything JSON-serializable, or binary content the client uploads as bytes instead of JSON-encoding
352
- * it: a `Buffer`, an `ArrayBuffer`, a typed array, or a readable stream. `Buffer` is a `Uint8Array`, so
353
- * `TypedArray` covers it.
355
+ * it: a `Buffer`, an `ArrayBuffer`, a typed array, a `Blob`, or a readable stream. `Buffer` is a
356
+ * `Uint8Array`, so `TypedArray` covers it.
354
357
  */
355
- export type KeyValueStoreRecordValue = JsonValue | ArrayBuffer | TypedArray | Readable;
358
+ export type KeyValueStoreRecordValue = JsonValue | ArrayBuffer | TypedArray | Blob | Readable;
356
359
  /**
357
360
  * Represents a record (key-value pair) in a Key-Value Store.
358
361
  *
@@ -5,7 +5,7 @@ import { ResourceClient } from '../base/resource_client.js';
5
5
  import * as schemas from '../schemas.js';
6
6
  import { timeoutOptionsSchema, timeoutOptionsShape } from '../timeouts.js';
7
7
  import { runtime } from '#runtime';
8
- import { anyObjectSchema, applyQueryParamsToUrl, catchNotFoundOrThrow, isBuffer, isStream, minForLimitParam, parseArgument, parseResponse, } from '../utils.js';
8
+ import { anyObjectSchema, applyQueryParamsToUrl, catchNotFoundOrThrow, isBlob, isBuffer, isStream, minForLimitParam, parseArgument, parseResponse, } from '../utils.js';
9
9
  const listKeysOptionsSchema = z.strictObject({
10
10
  limit: z.number().min(0).optional(),
11
11
  chunkSize: z.number().positive().optional(),
@@ -364,15 +364,18 @@ export class KeyValueStoreClient extends ResourceClient {
364
364
  * to be valid JSON already, or {@link getRecord} fails to parse it back.
365
365
  *
366
366
  * **Note about streams:** If the value is a stream object (has `.pipe` and `.on` methods),
367
- * the upload cannot be retried on failure or follow redirects. For reliable uploads,
368
- * buffer the entire stream into memory first.
367
+ * the upload cannot be retried on failure or follow redirects. For reliable uploads of a file,
368
+ * pass a `Blob` from `fs.openAsBlob()` instead: it is read again for every attempt and never held
369
+ * in memory whole.
369
370
  *
370
371
  * @param record - The record to store
371
372
  * @param record.key - Record key (unique identifier)
372
- * @param record.value - Record value (a JSON-serializable value, Buffer, ArrayBuffer, typed array, or Readable)
373
+ * @param record.value - Record value (a JSON-serializable value, Buffer, ArrayBuffer, typed array, Blob, or
374
+ * Readable)
373
375
  * @param record.contentType - Optional MIME type. Auto-detected if not provided:
374
376
  * - Objects: `'application/json; charset=utf-8'`
375
377
  * - Strings: `'text/plain; charset=utf-8'`
378
+ * - Blobs: their `type`, or `'application/octet-stream'` when it is empty
376
379
  * - Binary values and streams: `'application/octet-stream'`
377
380
  *
378
381
  * Worth setting for media and archives: the client skips compressing a body
@@ -435,18 +438,20 @@ export class KeyValueStoreClient extends ResourceClient {
435
438
  if (contentEncoding !== undefined && !runtime.isNode) {
436
439
  throw new Error('The contentEncoding option can only be used in Node.js environment.');
437
440
  }
438
- const isValueStreamOrBuffer = isStream(value) || isBuffer(value);
441
+ const isBinaryValue = isStream(value) || isBuffer(value) || isBlob(value);
439
442
  // The client forwards the header without inspecting the body, so a string or a value serialized to JSON, which
440
443
  // cannot hold compressed bytes, would be stored under an encoding that misdescribes it.
441
444
  const declaresCompression = contentEncoding !== undefined && contentEncoding.trim().toLowerCase() !== 'identity';
442
- if (declaresCompression && !isValueStreamOrBuffer) {
445
+ if (declaresCompression && !isBinaryValue) {
443
446
  throw new TypeError(`Cannot upload a value of type ${typeof value} with Content-Encoding: ${contentEncoding}. ` +
444
447
  'An encoding other than identity declares the value is already compressed, so pass the ' +
445
- 'compressed bytes as a Buffer, an ArrayBuffer, a typed array, or a readable stream.');
448
+ 'compressed bytes as a Buffer, an ArrayBuffer, a typed array, a Blob, or a readable stream.');
446
449
  }
447
450
  // To allow saving Objects to JSON without providing content type
448
451
  if (!contentType) {
449
- if (isValueStreamOrBuffer)
452
+ if (isBlob(value))
453
+ contentType = value.type || 'application/octet-stream';
454
+ else if (isBinaryValue)
450
455
  contentType = 'application/octet-stream';
451
456
  else if (typeof value === 'string')
452
457
  contentType = 'text/plain; charset=utf-8';
@@ -454,7 +459,7 @@ export class KeyValueStoreClient extends ResourceClient {
454
459
  contentType = 'application/json; charset=utf-8';
455
460
  }
456
461
  const isContentTypeJson = contentType.startsWith('application/json');
457
- if (isContentTypeJson && !isValueStreamOrBuffer && typeof value !== 'string') {
462
+ if (isContentTypeJson && !isBinaryValue && typeof value !== 'string') {
458
463
  try {
459
464
  value = JSON.stringify(value, null, 2);
460
465
  }
@@ -1,4 +1,5 @@
1
1
  import os from 'node:os';
2
+ import { Readable } from 'node:stream';
2
3
  import { brotliCompress, constants, gzip } from 'node:zlib';
3
4
  /**
4
5
  * The Node.js implementation of {@link Runtime}. Deno and Bun resolve the `node` condition too and
@@ -46,4 +47,34 @@ export const runtime = {
46
47
  });
47
48
  return { httpAgent: agent, httpsAgent: agent };
48
49
  },
50
+ openBlobBody(blob, onSourceError) {
51
+ return Readable.from(readBlob(blob, onSourceError), { objectMode: false });
52
+ },
49
53
  };
54
+ /**
55
+ * Yields the bytes of a blob. Ending the iteration early, which destroying the `Readable` around it does, cancels
56
+ * the read and releases a file the blob reads from. Only a failing read is reported: an error the `Readable` is
57
+ * destroyed with is thrown in at the `yield` and belongs to the transport. A `for await` loop would surface both at
58
+ * the same place, so the loop calls `next()` itself to catch only the read.
59
+ */
60
+ async function* readBlob(blob, onSourceError) {
61
+ const chunks = blob.stream()[Symbol.asyncIterator]();
62
+ try {
63
+ while (true) {
64
+ let chunk;
65
+ try {
66
+ chunk = await chunks.next();
67
+ }
68
+ catch (error) {
69
+ onSourceError(error);
70
+ throw error;
71
+ }
72
+ if (chunk.done)
73
+ return;
74
+ yield chunk.value;
75
+ }
76
+ }
77
+ finally {
78
+ await chunks.return?.();
79
+ }
80
+ }
@@ -1,5 +1,6 @@
1
1
  import type http from 'node:http';
2
2
  import type https from 'node:https';
3
+ import type { Readable } from 'node:stream';
3
4
  /**
4
5
  * What to compress a request body with, as the built-in `HttpCompressor` implementations ask for it.
5
6
  * @internal
@@ -49,4 +50,11 @@ export interface Runtime {
49
50
  createHttpAgents(options: {
50
51
  timeoutMillis: number;
51
52
  }): Promise<HttpAgents | undefined>;
53
+ /**
54
+ * Opens a `Blob` request body for one attempt. The Node.js implementation reads the blob into a `Readable` and
55
+ * reports an error the blob raises while it is read to `onSourceError` before the stream fails with it, so the
56
+ * pipeline can tell a failing source apart from a failing connection. The Web API one returns the blob itself,
57
+ * which `fetch` and `XMLHttpRequest` read on their own, and never calls `onSourceError`.
58
+ */
59
+ openBlobBody(blob: Blob, onSourceError: (error: unknown) => void): Readable | Blob;
52
60
  }
@@ -15,4 +15,7 @@ export const runtime = {
15
15
  async createHttpAgents() {
16
16
  return undefined;
17
17
  },
18
+ openBlobBody(blob) {
19
+ return blob;
20
+ },
18
21
  };
package/dist/utils.d.ts CHANGED
@@ -101,6 +101,11 @@ export declare function splitIntoJsonArrayBatches<T extends {
101
101
  maxByteLength: number;
102
102
  }): T[][];
103
103
  export declare function isBuffer(value: unknown): value is Buffer | ArrayBuffer | TypedArray;
104
+ /**
105
+ * Whether a value is a `Blob`, a `File` included. Unlike a stream, a blob can be read again for every attempt of a
106
+ * request, so a request with a blob body can be retried.
107
+ */
108
+ export declare function isBlob(value: unknown): value is Blob;
104
109
  export declare function isStream(value: unknown): value is Readable;
105
110
  /**
106
111
  * Helper class to create async iterators from paginated list endpoints.
package/dist/utils.js CHANGED
@@ -236,6 +236,13 @@ export function isBuffer(value) {
236
236
  return !isTagged(value, 'DataView');
237
237
  return isTagged(value, 'ArrayBuffer');
238
238
  }
239
+ /**
240
+ * Whether a value is a `Blob`, a `File` included. Unlike a stream, a blob can be read again for every attempt of a
241
+ * request, so a request with a blob body can be retried.
242
+ */
243
+ export function isBlob(value) {
244
+ return isTagged(value, 'Blob') || isTagged(value, 'File');
245
+ }
239
246
  function isTagged(value, tag) {
240
247
  return Object.prototype.toString.call(value) === `[object ${tag}]`;
241
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apify-client",
3
- "version": "3.0.0-beta.71",
3
+ "version": "3.0.0-beta.72",
4
4
  "description": "Apify API client for JavaScript",
5
5
  "engines": {
6
6
  "node": ">=22.13.0"