Skip to content

Commit 2d6c9b2

Browse files
feat(javascript): Implement gzip compression (#6052)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cdcf800 commit 2d6c9b2

File tree

14 files changed

+172
-8
lines changed

14 files changed

+172
-8
lines changed

clients/algoliasearch-client-javascript/packages/client-common/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"test": "tsc --noEmit && vitest --run",
3535
"test:bundle": "publint . && attw --pack ."
3636
},
37+
"dependencies": {
38+
"fflate": "0.8.2"
39+
},
3740
"devDependencies": {
3841
"@arethetypeswrong/cli": "0.18.2",
3942
"@types/node": "24.12.0",
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { gunzipSync } from 'fflate';
2+
import { beforeEach, describe, expect, test } from 'vitest';
3+
import { createMemoryCache, createNullCache } from '../../cache';
4+
import { createNullLogger } from '../../logger';
5+
import { createTransporter } from '../../transporter';
6+
import { COMPRESSION_THRESHOLD } from '../../transporter/compress';
7+
import type { AlgoliaAgent, EndRequest } from '../../types';
8+
9+
// A payload large enough to exceed COMPRESSION_THRESHOLD
10+
const largePayload = { data: 'x'.repeat(COMPRESSION_THRESHOLD + 1) };
11+
12+
const algoliaAgent: AlgoliaAgent = {
13+
value: 'test',
14+
add: () => algoliaAgent,
15+
};
16+
17+
function makeTransporter(compression?: 'gzip', onRequest?: (req: EndRequest) => void) {
18+
return createTransporter({
19+
hosts: [{ url: 'localhost', accept: 'readWrite', protocol: 'https' }],
20+
hostsCache: createNullCache(),
21+
baseHeaders: {},
22+
baseQueryParameters: {},
23+
algoliaAgent,
24+
logger: createNullLogger(),
25+
timeouts: { connect: 1000, read: 2000, write: 3000 },
26+
...(compression ? { compression } : {}),
27+
requester: {
28+
send: async (req) => {
29+
onRequest?.(req);
30+
return { status: 200, content: '{}', isTimedOut: false };
31+
},
32+
},
33+
requestsCache: createMemoryCache(),
34+
responsesCache: createMemoryCache(),
35+
});
36+
}
37+
38+
describe('compression', () => {
39+
let captured: EndRequest | undefined;
40+
41+
beforeEach(() => {
42+
captured = undefined;
43+
});
44+
45+
test('does not compress when compression is not configured', async () => {
46+
const transporter = makeTransporter(undefined, (req) => {
47+
captured = req;
48+
});
49+
50+
await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {}, data: { foo: 'bar' } });
51+
52+
expect(captured).toBeDefined();
53+
expect(captured!.headers['content-encoding']).toBeUndefined();
54+
expect(typeof captured!.data).toBe('string');
55+
expect(captured!.data).toBe('{"foo":"bar"}');
56+
});
57+
58+
test('compresses POST body when compression is gzip', async () => {
59+
const transporter = makeTransporter('gzip', (req) => {
60+
captured = req;
61+
});
62+
63+
await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {}, data: largePayload });
64+
65+
expect(captured).toBeDefined();
66+
expect(captured!.headers['content-encoding']).toBe('gzip');
67+
expect(captured!.data).toBeInstanceOf(Uint8Array);
68+
69+
const decompressed = new TextDecoder().decode(gunzipSync(captured!.data as Uint8Array));
70+
expect(decompressed).toBe(JSON.stringify(largePayload));
71+
});
72+
73+
test('compresses PUT body when compression is gzip', async () => {
74+
const transporter = makeTransporter('gzip', (req) => {
75+
captured = req;
76+
});
77+
78+
await transporter.request({ method: 'PUT', path: '/test', queryParameters: {}, headers: {}, data: largePayload });
79+
80+
expect(captured).toBeDefined();
81+
expect(captured!.headers['content-encoding']).toBe('gzip');
82+
expect(captured!.data).toBeInstanceOf(Uint8Array);
83+
84+
const decompressed = new TextDecoder().decode(gunzipSync(captured!.data as Uint8Array));
85+
expect(decompressed).toBe(JSON.stringify(largePayload));
86+
});
87+
88+
test('does not compress POST when body is below threshold', async () => {
89+
const transporter = makeTransporter('gzip', (req) => {
90+
captured = req;
91+
});
92+
93+
await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {}, data: { foo: 'bar' } });
94+
95+
expect(captured).toBeDefined();
96+
expect(captured!.headers['content-encoding']).toBeUndefined();
97+
expect(typeof captured!.data).toBe('string');
98+
});
99+
100+
test('does not compress GET requests', async () => {
101+
const transporter = makeTransporter('gzip', (req) => {
102+
captured = req;
103+
});
104+
105+
await transporter.request({ method: 'GET', path: '/test', queryParameters: {}, headers: {} });
106+
107+
expect(captured).toBeDefined();
108+
expect(captured!.headers['content-encoding']).toBeUndefined();
109+
expect(captured!.data).toBeUndefined();
110+
});
111+
112+
test('does not compress POST when body is empty', async () => {
113+
const transporter = makeTransporter('gzip', (req) => {
114+
captured = req;
115+
});
116+
117+
await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {} });
118+
119+
expect(captured).toBeDefined();
120+
expect(captured!.headers['content-encoding']).toBeUndefined();
121+
expect(captured!.data).toBeUndefined();
122+
});
123+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { gzipSync } from 'fflate';
2+
3+
export const COMPRESSION_THRESHOLD = 750;
4+
5+
/**
6+
* Compresses a string using gzip via fflate.
7+
* Works in both Node.js and browsers with no platform-specific code.
8+
*/
9+
export function compress(data: string): Uint8Array {
10+
return gzipSync(new TextEncoder().encode(data));
11+
}

clients/algoliasearch-client-javascript/packages/client-common/src/transporter/createTransporter.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from '../types';
1212
import { createStatefulHost } from './createStatefulHost';
1313
import { RetryError } from './errors';
14+
import { compress, COMPRESSION_THRESHOLD } from './compress';
1415
import { deserializeFailure, deserializeSuccess, serializeData, serializeHeaders, serializeUrl } from './helpers';
1516
import { isRetryable, isSuccess } from './responses';
1617
import { stackFrameWithoutCredentials, stackTraceWithoutCredentials } from './stackTrace';
@@ -31,6 +32,7 @@ export function createTransporter({
3132
requester,
3233
requestsCache,
3334
responsesCache,
35+
compression,
3436
}: TransporterOptions): Transporter {
3537
async function createRetryableOptions(compatibleHosts: Host[]): Promise<RetryableOptions> {
3638
const statefulHosts = await Promise.all(
@@ -79,9 +81,20 @@ export function createTransporter({
7981
/**
8082
* First we prepare the payload that do not depend from hosts.
8183
*/
82-
const data = serializeData(request, requestOptions);
84+
const serializedData = serializeData(request, requestOptions);
8385
const headers = serializeHeaders(baseHeaders, request.headers, requestOptions.headers);
8486

87+
const shouldCompress =
88+
compression === 'gzip' &&
89+
serializedData !== undefined &&
90+
serializedData.length > COMPRESSION_THRESHOLD &&
91+
(request.method === 'POST' || request.method === 'PUT');
92+
93+
const data = shouldCompress ? compress(serializedData) : serializedData;
94+
if (shouldCompress) {
95+
headers['content-encoding'] = 'gzip';
96+
}
97+
8598
// On `GET`, the data is proxied to query parameters.
8699
const dataQueryParameters: QueryParameters =
87100
request.method === 'GET'

clients/algoliasearch-client-javascript/packages/client-common/src/transporter/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './compress';
12
export * from './createStatefulHost';
23
export * from './createTransporter';
34
export * from './errors';

clients/algoliasearch-client-javascript/packages/client-common/src/types/requester.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export type EndRequest = Pick<Request, 'headers' | 'method'> & {
4141
* The response timeout, in milliseconds.
4242
*/
4343
responseTimeout: number;
44-
data?: string | undefined;
44+
data?: string | Uint8Array | undefined;
4545
};
4646

4747
export type Response = {

clients/algoliasearch-client-javascript/packages/client-common/src/types/transporter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ export type TransporterOptions = {
150150
* The user agent used. Sent on query parameters.
151151
*/
152152
algoliaAgent: AlgoliaAgent;
153+
154+
/**
155+
* The compression algorithm to use when sending POST/PUT request bodies.
156+
* When set to `'gzip'`, request bodies are gzip-compressed and
157+
* `Content-Encoding: gzip` is added to the headers.
158+
* Works on all Node.js versions and browsers with no native API requirements.
159+
*/
160+
compression?: 'gzip';
153161
};
154162

155163
export type Transporter = TransporterOptions & {

clients/algoliasearch-client-javascript/packages/client-common/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default defineConfig({
1111
'src/__tests__/create-iterable-promise.test.ts',
1212
'src/__tests__/logger/null-logger.test.ts',
1313
'src/__tests__/transporter/cache.test.ts',
14+
'src/__tests__/transporter/compression.test.ts',
1415
],
1516
name: 'node',
1617
environment: 'node',

clients/algoliasearch-client-javascript/packages/requester-browser-xhr/src/createXhrRequester.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function createXhrRequester(): Requester {
5959
});
6060
};
6161

62-
baseRequester.send(request.data);
62+
baseRequester.send(request.data as XMLHttpRequestBodyInit | null | undefined);
6363
});
6464
}
6565

clients/algoliasearch-client-javascript/packages/requester-fetch/src/createFetchRequester.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function createFetchRequester({ requesterOptions = {} }: FetchRequesterOp
3232
try {
3333
fetchRes = await fetch(request.url, {
3434
method: request.method,
35-
body: request.data || null,
35+
body: (request.data as BodyInit) || null,
3636
redirect: 'manual',
3737
signal,
3838
...requesterOptions,

0 commit comments

Comments
 (0)