Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,15 @@ property to get only a subset of results. Other props are also available, depend

## Bundled environments

The package includes a pre-built browser bundle that is automatically resolved by bundlers targeting browser environments. You can also import it explicitly via
The package includes a pre-built browser bundle that bundlers targeting browsers resolve automatically. You can also import it explicitly:

```typescript
import { ApifyClient } from 'apify-client/browser';
```

For edge runtimes like Cloudflare Workers, you may need to enable Node compatibility (e.g. `node_compat = true` in `wrangler.toml`). Note that some Node-specific features (streaming, proxy support) are not available in the bundle.
Only two parts of the client need Node.js built-ins: the HTTP agents and request compression. The `node` condition selects the Node.js implementation of those, and every other target gets one built on Web APIs. Log streaming, proxy support, and request compression are only available in Node.js.

For details, see [Bundled environments](https://docs.apify.com/api/client/js/docs/concepts/bundled-environments).

## API Reference

Expand Down
25 changes: 21 additions & 4 deletions docs/02_concepts/05_bundled-environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,35 @@ sidebar_label: Bundled environments
description: 'Use the Apify API client for JavaScript in browsers, Cloudflare Workers, and other edge runtimes.'
---

import ApiLink from '@theme/ApiLink';

:::warning Non-Node.js environments

This applies only to non-Node.js environments (browsers, Cloudflare Workers, edge runtimes). If you're running on Node.js, you can skip this section.
This page applies only to non-Node.js environments (browsers, Cloudflare Workers, edge runtimes). If you're running on Node.js, you can skip it.

:::

The package ships a pre-built browser bundle that is automatically resolved when your bundler targets a browser environment. If it isn't picked up automatically, you can import it directly:
## Browser bundle

The package ships a pre-built browser bundle at `dist/bundle.js`. A bundler that targets the browser picks it up through the `browser` condition of the package's `exports` field. If yours doesn't, import it directly:

```js
import { ApifyClient } from 'apify-client/browser';
```

For Cloudflare Workers or other edge runtimes that don't provide Node built-ins, you may need to enable Node compatibility in your runtime config (e.g. `node_compat = true`.
The bundle is self-contained and needs no polyfill configuration.

## Bundling the ES module build yourself

The client's own code runs on Web APIs. The parts that need Node.js built-ins, the keep-alive HTTP agents with proxy support and request body compression, live in a single module that the `#runtime` entry of the package's `imports` field selects at bundle time. The `node` condition gets the Node.js implementation, every other target gets the Web API one. A bundler targeting a browser, Cloudflare Workers, or another edge runtime therefore never sees `node:zlib`, `node:os`, `node:util`, or `proxy-agent`.

Reaching the ES module build takes a bundler that doesn't set the `browser` condition, which resolves `apify-client` to the pre-built bundle. esbuild's `neutral` platform sets no conditions, and webpack and Vite let you list them through `resolve.conditionNames` and `resolve.conditions`.

## Features that need Node.js

These features rely on Node.js APIs and aren't available in the browser bundle or in the Web API runtime:

Note that some Node-specific features (streaming APIs, proxy) are not available in the browser bundle.
- Log streaming with <ApiLink to="class/LogClient#stream">`LogClient.stream()`</ApiLink> and <ApiLink to="class/RunClient#getStreamedLog">`RunClient.getStreamedLog()`</ApiLink>, and the `stream` option of <ApiLink to="class/KeyValueStoreClient#getRecord">`KeyValueStoreClient.getRecord()`</ApiLink>, which all return a Node.js `Readable` stream.
- Proxy support through the `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables.
- Request body compression.
- The `User-Agent` header, which browsers don't let a page set.
35 changes: 35 additions & 0 deletions docs/04_upgrading/upgrading_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,38 @@ export HTTPS_PROXY=http://proxy.example.com:3128
```

The same `proxy-agent` upgrade removes the `[DEP0169] DeprecationWarning` about `url.parse()` that Node.js 24 and newer printed on the client's first request.

## Node.js-only features follow the bundler's target

Four features need Node.js APIs: log streaming with <ApiLink to="class/LogClient#stream">`LogClient.stream()`</ApiLink> and <ApiLink to="class/RunClient#getStreamedLog">`RunClient.getStreamedLog()`</ApiLink>, the `stream` option of <ApiLink to="class/KeyValueStoreClient#getRecord">`KeyValueStoreClient.getRecord()`</ApiLink>, proxy support, and request body compression.

In v2 the client detected the runtime when one of them was used, so a Node.js application bundled for a browser or a neutral target kept them all. In v3 the implementation is picked when the import is resolved, so the bundler's conditions decide. Without the `node` condition, `getRecord({ stream: true })` throws, `getStreamedLog()` returns `undefined`, and requests go out unproxied, uncompressed, and without the client's `User-Agent` header.

Enable the `node` condition when you bundle for Node.js. esbuild sets it through `platform: 'node'`, webpack through `resolve.conditionNames`, and Vite through `resolve.conditions`.

A test runner that resolves the `browser` condition decides the same way. Jest's `jsdom` environment resolves it, so a Node.js test suite running under it loses them unless you list the `node` condition:

```js
// jest.config.js
export default {
testEnvironment: 'jsdom',
testEnvironmentOptions: { customExportConditions: ['node'] },
};
```

Running on Node.js without a bundler is unaffected, and the pre-built browser bundle behaves as it did in v2. For what bundling for a non-Node.js target takes, see [Bundled environments](../02_concepts/05_bundled-environments.md).

### Response bodies are decoded by `TextDecoder`

In Node.js, v2 decoded response bodies with `Buffer` and v3 decodes them with `TextDecoder`. The two support different charsets, so a `content-type` header carrying one can be handled differently:

- `iso-8859-1` and other charsets only `TextDecoder` knows decode to a string, where v2 handed back raw bytes.
- `hex` and `base64`, which only `Buffer` knows, come back as raw bytes, where v2 decoded the body as if it were in that encoding.
- `ascii` is read as an alias for `windows-1252`, so a byte above `0x7F` decodes to the character that encoding gives it, where v2 masked the byte down to seven bits.
- A leading UTF-8 byte order mark is stripped from every decoded body, so a JSON body carrying one parses instead of throwing, and a `text/*` record such as a BOM-prefixed CSV comes back without it.

Code that depended on one of these has to convert the value itself. A body with no charset or with a UTF-8 one is unaffected, apart from the byte order mark, and that covers everything the Apify API sends.

### Request compression covers more body types

v2 compressed a request body only when it was a string or a `Buffer`. A `Uint8Array`, another typed array, or an `ArrayBuffer` is compressed as well once it reaches the same 1 kB threshold, so a request carrying one gains a `content-encoding` header. The API accepts both encodings the client sends, `br` and `gzip`, so nothing needs to change on your side.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
"types": "dist/index.d.ts",
"browser": "dist/bundle.js",
"unpkg": "dist/bundle.js",
"imports": {
"#runtime": {
"node": "./dist/runtime/node.js",
"default": "./dist/runtime/web.js"
}
},
"exports": {
"./package.json": "./package.json",
"./browser": "./dist/bundle.js",
Expand Down
39 changes: 13 additions & 26 deletions rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
import { defineConfig, rspack } from '@rsbuild/core';
import { defineConfig } from '@rsbuild/core';
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';

import { version } from './package.json';

const MAX_BUNDLE_BYTES = 360 * 1024;

const nodeOnlyModules = /^proxy-agent$/;
const unusedInBrowserBuiltins = ['os', 'zlib', 'util'];
const builtinAliases = Object.fromEntries(
unusedInBrowserBuiltins.flatMap((m) => [
[m, false],
[`node:${m}`, false],
]),
);
const MAX_BUNDLE_BYTES = 350 * 1024;

// eslint-disable-next-line import/no-default-export
export default defineConfig({
source: {
entry: {
Apify: './src/index.ts',
},
define: {
VERSION: JSON.stringify(version),
BROWSER_BUILD: true,
},
resolve: {
alias: {
// The `imports` field of `package.json` maps this to `dist`; bundle the source instead.
'#runtime': './src/runtime/web.ts',
},
// `tsconfig.json` maps `#runtime` to the Node.js implementation for type-checking, and the default
// strategy lets that mapping win over the alias.
aliasStrategy: 'prefer-alias',
},
output: {
distPath: { js: '.' },
Expand Down Expand Up @@ -61,7 +55,7 @@ export default defineConfig({
...config.optimization,
splitChunks: false,
};
// A regression guard, not a target: the bundle sits at ~335 kB, so this only fails the
// A regression guard, not a target: the bundle sits at ~325 kB, so this only fails the
// build on an unnoticed jump. A `zod` minor is the likeliest cause, since it is a runtime
// dependency on a caret range - bumping this constant is the expected response. The
// generated response schemas growing with the OpenAPI specification is the other.
Expand All @@ -72,18 +66,11 @@ export default defineConfig({
// The source map is many times the size of the bundle and ships separately.
assetFilter: (filename) => filename === 'bundle.js',
};
config.plugins = [...(config.plugins ?? []), new rspack.IgnorePlugin({ resourceRegExp: nodeOnlyModules })];
config.resolve = {
...config.resolve,
alias: {
...config.resolve?.alias,
...builtinAliases,
},
};
config.devtool = 'source-map';
},
},
mode: 'production',
// @apify/utilities dynamically imports `crypto` on missing `SubtleCrypto` (but browsers have it).
// The client's own code needs no polyfills, so these cover its dependencies. `node:crypto` is excluded,
// because the client only calls the helpers built on Web Crypto.
plugins: [pluginNodePolyfill({ overrides: { crypto: false } })],
});
3 changes: 1 addition & 2 deletions src/apify_api_error.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { AxiosResponse } from 'axios';
import type { LiteralUnion } from 'type-fest';

import { isomorphicBufferToString } from './body_parser.js';
import type { ApifyApiErrorType } from './models.js';
import { isBuffer } from './utils.js';

Expand Down Expand Up @@ -91,7 +90,7 @@ export class ApifyApiError extends Error {
// the body buffer needs to parse to get the correct error.
if (isBuffer(responseData)) {
try {
responseData = JSON.parse(isomorphicBufferToString(response.data, 'utf-8'));
responseData = JSON.parse(new TextDecoder().decode(response.data));
} catch {
// This can happen. The data in the response body are malformed.
}
Expand Down
4 changes: 2 additions & 2 deletions src/apify_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { WebhookCollectionClient } from './resource_clients/webhook_collection.j
import { WebhookDispatchClient } from './resource_clients/webhook_dispatch.js';
import { WebhookDispatchCollectionClient } from './resource_clients/webhook_dispatch_collection.js';
import { Statistics } from './statistics.js';
import { parseArgument } from './utils.js';
import { getEnv, parseArgument } from './utils.js';

const DEFAULT_TIMEOUT_SECS = 360;

Expand Down Expand Up @@ -554,7 +554,7 @@ export class ApifyClient {
* @since Added in 2.7.0
*/
async setStatusMessage(message: string, options?: SetStatusMessageOptions): Promise<void> {
const runId = process.env[ACTOR_ENV_VARS.RUN_ID];
const runId = getEnv(ACTOR_ENV_VARS.RUN_ID);
if (!runId) {
throw new Error(`Environment variable ${ACTOR_ENV_VARS.RUN_ID} is not set!`);
}
Expand Down
51 changes: 20 additions & 31 deletions src/body_parser.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,56 @@
import contentTypeParser from 'content-type';
import type { JsonArray, JsonObject } from 'type-fest';

import { isNode } from './utils.js';

const CONTENT_TYPE_JSON = 'application/json';
const STRINGIFIABLE_CONTENT_TYPE_RXS = [new RegExp(`^${CONTENT_TYPE_JSON}`, 'i'), /^application\/.*xml$/i, /^text\//i];

/**
* Parses a Buffer or ArrayBuffer using the provided content type header.
* Parses a binary response body using the provided content type header.
*
* - application/json is returned as a parsed object.
* - application/*xml and text/* are returned as strings.
* - everything else is returned as original body.
*
* If the header includes a charset, the body will be stringified only
* if the charset represents a known encoding to Node.js or Browser.
* if the charset is an encoding `TextDecoder` knows.
*/
export function maybeParseBody(
body: Buffer | ArrayBuffer,
body: ArrayBuffer | ArrayBufferView,
contentTypeHeader: string,
): string | Buffer | ArrayBuffer | JsonObject | JsonArray {
let contentType;
let charset: BufferEncoding;
): string | ArrayBuffer | ArrayBufferView | JsonObject | JsonArray {
let contentType: string;
let charset: string | undefined;
try {
const result = contentTypeParser.parse(contentTypeHeader);
contentType = result.type;
charset = result.parameters.charset as BufferEncoding;
charset = result.parameters.charset;
} catch {
// can't parse, keep original body
return body;
}

// If we can't successfully parse it, we return
if (!isContentTypeStringifiable(contentType)) return body;

// If we can't successfully decode it, we return
// the original buffer rather than a mangled string.
if (!areDataStringifiable(contentType, charset)) return body;
const dataString = isomorphicBufferToString(body, charset);
const decoder = createDecoder(charset);
if (!decoder) return body;
const dataString = decoder.decode(body);

return contentType === CONTENT_TYPE_JSON ? JSON.parse(dataString) : dataString;
}

export function isomorphicBufferToString(buffer: Buffer | ArrayBuffer, encoding: BufferEncoding): string {
if (buffer.constructor.name !== ArrayBuffer.name) {
return buffer.toString(encoding);
function createDecoder(charset?: string): TextDecoder | undefined {
try {
// No charset: hope that it's utf-8.
return new TextDecoder(charset || 'utf-8');
} catch {
// `TextDecoder` throws a `RangeError` for a label it does not know.
return undefined;
}

// Browser decoding only works with UTF-8.
const utf8decoder = new TextDecoder();
return utf8decoder.decode(new Uint8Array(buffer));
}

function isCharsetStringifiable(charset: string) {
if (!charset) return true; // hope that it's utf-8
if (isNode()) return Buffer.isEncoding(charset);
const normalizedCharset = charset.toLowerCase().replace('-', '');
// Browsers only support decoding utf-8 buffers.
return normalizedCharset === 'utf8';
}

function isContentTypeStringifiable(contentType: string) {
if (!contentType) return false; // keep buffer
return STRINGIFIABLE_CONTENT_TYPE_RXS.some((rx) => rx.test(contentType));
}

function areDataStringifiable(contentType: string, charset: string) {
return isContentTypeStringifiable(contentType) && isCharsetStringifiable(charset);
}
Loading
Loading