Skip to content

Commit 72a31ef

Browse files
committed
feat: Compress requests using brotli algo
- apify/apify-core#28971 added support for brotli compression to BE. - Pros: higher compression, cons: more CPU intensive. - The code could be running on too old Node.js, so brotli compression is applied only if possible; the code otherwise falls back to gzip. Too small payloads and unsupported types are still not compressed - no change.
1 parent 7dce887 commit 72a31ef

5 files changed

Lines changed: 111 additions & 26 deletions

File tree

src/interceptors.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { JsonObject } from 'type-fest';
55

66
import { maybeParseBody } from './body_parser';
77
import type { ApifyRequestConfig, ApifyResponse } from './http_client';
8-
import { isNode, maybeGzipValue } from './utils';
8+
import { isNode, maybeCompressValue } from './utils';
99

1010
/**
1111
* This error exists for the quite common situation, where only a partial JSON response is received and
@@ -78,14 +78,14 @@ function stringifyWithFunctions(obj: JsonObject) {
7878
});
7979
}
8080

81-
async function maybeGzipRequest(config: ApifyRequestConfig): Promise<ApifyRequestConfig> {
81+
async function maybeCompressRequest(config: ApifyRequestConfig): Promise<ApifyRequestConfig> {
8282
if (config.headers?.['content-encoding']) return config;
8383

84-
const maybeZippedData = await maybeGzipValue(config.data);
85-
if (maybeZippedData) {
84+
const maybeCompressed = await maybeCompressValue(config.data);
85+
if (maybeCompressed) {
8686
config.headers ??= {};
87-
config.headers['content-encoding'] = 'gzip';
88-
config.data = maybeZippedData;
87+
config.headers['content-encoding'] = maybeCompressed.encoding;
88+
config.data = maybeCompressed.data;
8989
}
9090

9191
return config;
@@ -121,7 +121,7 @@ export type RequestInterceptorFunction = Parameters<AxiosInterceptorManager<Apif
121121
export type ResponseInterceptorFunction = Parameters<AxiosInterceptorManager<ApifyResponse>['use']>[0];
122122

123123
export const requestInterceptors: RequestInterceptorFunction[] = [
124-
maybeGzipRequest,
124+
maybeCompressRequest,
125125
serializeRequest,
126126
ensureHeadersPrototype,
127127
];

src/utils.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { WebhookUpdateData } from './resource_clients/webhook';
1313
const NOT_FOUND_STATUS_CODE = 404;
1414
const RECORD_NOT_FOUND_TYPE = 'record-not-found';
1515
const RECORD_OR_TOKEN_NOT_FOUND_TYPE = 'record-or-token-not-found';
16-
const MIN_GZIP_BYTES = 1024;
16+
const MIN_COMPRESS_BYTES = 1024;
1717

1818
/**
1919
* Generic interface for objects that may contain a data property.
@@ -112,28 +112,65 @@ export function stringifyWebhooksToBase64(webhooks: WebhookUpdateData[]): string
112112
let gzipPromisified: ((arg: string | Buffer<ArrayBufferLike>) => Promise<Buffer>) | undefined;
113113

114114
/**
115-
* Gzip provided value, otherwise returns undefined.
115+
* Gzip-compress the provided value.
116116
*/
117-
export async function maybeGzipValue(value: unknown): Promise<Buffer | undefined> {
118-
if (!isNode()) return;
119-
if (typeof value !== 'string' && !Buffer.isBuffer(value)) return;
117+
async function gzipValue(value: string | Buffer<ArrayBufferLike>): Promise<Buffer> {
118+
if (!gzipPromisified) {
119+
const { promisify } = await import('node:util');
120+
const { gzip } = await import('node:zlib');
121+
gzipPromisified = promisify(gzip);
122+
}
120123

121-
// Request compression is not that important so let's
122-
// skip it instead of throwing for unsupported types.
123-
const areDataLargeEnough = Buffer.byteLength(value as string) >= MIN_GZIP_BYTES;
124-
if (areDataLargeEnough) {
125-
if (!gzipPromisified) {
126-
const { promisify } = await import('node:util');
127-
const { gzip } = await import('node:zlib');
128-
gzipPromisified = promisify(gzip);
129-
}
124+
return gzipPromisified(value);
125+
}
126+
127+
// null = confirmed unavailable; undefined = not yet checked
128+
let brotliCompressPromisified: ((arg: string | Buffer<ArrayBufferLike>) => Promise<Buffer>) | null | undefined;
130129

131-
return gzipPromisified(value);
130+
/**
131+
* Brotli-compress the provided value, or return undefined if brotli is unavailable
132+
* (Node.js < v10.16.0), this is a strict defensive guard.
133+
*/
134+
async function maybeBrotliValue(value: string | Buffer<ArrayBufferLike>): Promise<Buffer | undefined> {
135+
if (brotliCompressPromisified === undefined) {
136+
const { promisify } = await import('node:util');
137+
const { brotliCompress } = await import('node:zlib');
138+
brotliCompressPromisified = typeof brotliCompress === 'function' ? promisify(brotliCompress) : null;
139+
}
140+
141+
if (brotliCompressPromisified !== null) {
142+
return brotliCompressPromisified(value);
132143
}
133144

134145
return undefined;
135146
}
136147

148+
export interface CompressedValue {
149+
data: Buffer;
150+
encoding: 'br' | 'gzip';
151+
}
152+
153+
/**
154+
* Compress the passed value using brotli if available or using gzip as a fallback. Returns undefined
155+
* if the data is too small / wrong type.
156+
*/
157+
export async function maybeCompressValue(value: unknown): Promise<CompressedValue | undefined> {
158+
if (!isNode()) return undefined;
159+
160+
// Request compression is not that important so let's
161+
// skip it instead of throwing for unsupported types.
162+
if (typeof value !== 'string' && !Buffer.isBuffer(value)) return undefined;
163+
164+
const areDataLargeEnough = Buffer.byteLength(value) >= MIN_COMPRESS_BYTES;
165+
if (!areDataLargeEnough) return undefined;
166+
167+
const brotli = await maybeBrotliValue(value);
168+
if (brotli) return { data: brotli, encoding: 'br' };
169+
170+
const gzipped = await gzipValue(value);
171+
return { data: gzipped, encoding: 'gzip' };
172+
}
173+
137174
/**
138175
* Helper function slice the items from array to fit the max byte length.
139176
*/

test/datasets.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ describe('Dataset methods', () => {
369369

370370
const expectedHeaders = {
371371
'content-type': 'application/json; charset=utf-8',
372-
'content-encoding': 'gzip',
372+
'content-encoding': 'br',
373373
};
374374

375375
const res = await client.dataset(datasetId).pushItems(data);

test/key_value_stores.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ describe('Key-Value Store methods', () => {
557557
validateRequest({ params: { storeId, key }, body: JSON.parse(value), additionalHeaders: expectedHeaders });
558558
});
559559

560-
test('setRecord() uploads gzipped buffer in node context', async () => {
560+
test('setRecord() uploads compressed buffer in node context', async () => {
561561
const key = 'some-key';
562562
const storeId = 'some-id';
563563
const value = [];
@@ -573,7 +573,7 @@ describe('Key-Value Store methods', () => {
573573
body: value,
574574
additionalHeaders: {
575575
'content-type': contentType,
576-
'content-encoding': 'gzip',
576+
'content-encoding': 'br',
577577
},
578578
});
579579

test/utils.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { WebhookUpdateData } from 'apify-client';
22
import { ApifyApiError } from 'apify-client';
3-
import { describe, expect, test } from 'vitest';
3+
import { describe, expect, test, vi } from 'vitest';
44

55
import * as utils from '../src/utils';
66

@@ -124,6 +124,54 @@ describe('utils.parseDateFields()', () => {
124124
});
125125
});
126126

127+
describe('utils.maybeCompressValue()', () => {
128+
test('returns undefined for small values', async () => {
129+
expect(await utils.maybeCompressValue('small')).toBeUndefined();
130+
});
131+
132+
test('returns undefined for non-string non-Buffer values', async () => {
133+
expect(await utils.maybeCompressValue({ foo: 'bar' })).toBeUndefined();
134+
});
135+
136+
test('compresses large string using brotli in Node.js', async () => {
137+
const largeValue = 'x'.repeat(2048);
138+
const result = await utils.maybeCompressValue(largeValue);
139+
expect(result).not.toBeUndefined();
140+
expect(result!.encoding).toBe('br');
141+
expect(result!.data).toBeInstanceOf(Buffer);
142+
expect(result!.data.length).toBeLessThan(Buffer.byteLength(largeValue));
143+
});
144+
145+
test('compresses large Buffer using brotli in Node.js', async () => {
146+
const largeValue = Buffer.alloc(2048, 'a');
147+
const result = await utils.maybeCompressValue(largeValue);
148+
expect(result).not.toBeUndefined();
149+
expect(result!.encoding).toBe('br');
150+
expect(result!.data).toBeInstanceOf(Buffer);
151+
expect(result!.data.length).toBeLessThan(largeValue.length);
152+
});
153+
154+
test('falls back to gzip when brotli is unavailable', async () => {
155+
vi.resetModules();
156+
vi.doMock('node:zlib', async () => {
157+
const actual = await vi.importActual<typeof import('node:zlib')>('node:zlib');
158+
return { ...actual, brotliCompress: undefined };
159+
});
160+
161+
const { maybeCompressValue } = await import('../src/utils.js');
162+
const largeValue = 'x'.repeat(2048);
163+
const result = await maybeCompressValue(largeValue);
164+
165+
expect(result).not.toBeUndefined();
166+
expect(result!.encoding).toBe('gzip');
167+
expect(result!.data).toBeInstanceOf(Buffer);
168+
expect(result!.data.length).toBeLessThan(Buffer.byteLength(largeValue));
169+
170+
vi.doUnmock('node:zlib');
171+
vi.resetModules();
172+
});
173+
});
174+
127175
describe('utils.stringifyWebhooksToBase64()', () => {
128176
test('works', () => {
129177
const webhooks: WebhookUpdateData[] = [

0 commit comments

Comments
 (0)