Skip to content
Merged
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
2 changes: 0 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,9 @@ jobs:
run: cd clients/algoliasearch-client-javascript && yarn test

- name: Test JavaScript bundle size
if: ${{ startsWith(github.head_ref, 'chore/prepare-release-') }}
run: cd clients/algoliasearch-client-javascript && yarn test:size

- name: Test JavaScript bundle and types
if: ${{ startsWith(github.head_ref, 'chore/prepare-release-') }}
run: cd clients/algoliasearch-client-javascript && yarn test:bundle

- name: Remove previous CTS output
Expand Down
26 changes: 13 additions & 13 deletions clients/algoliasearch-client-javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,55 +27,55 @@
"files": [
{
"path": "packages/algoliasearch/dist/algoliasearch.umd.js",
"maxSize": "14.60KB"
"maxSize": "14.8KB"
},
{
"path": "packages/algoliasearch/dist/lite/builds/browser.umd.js",
"maxSize": "3.95KB"
"maxSize": "4.2KB"
},
{
"path": "packages/abtesting/dist/builds/browser.umd.js",
"maxSize": "4.35KB"
"maxSize": "4.5KB"
},
{
"path": "packages/client-abtesting/dist/builds/browser.umd.js",
"maxSize": "4.20KB"
"maxSize": "4.4KB"
},
{
"path": "packages/client-analytics/dist/builds/browser.umd.js",
"maxSize": "4.85KB"
"maxSize": "5.1KB"
},
{
"path": "packages/composition/dist/builds/browser.umd.js",
"maxSize": "4.75KB"
"maxSize": "5.0KB"
},
{
"path": "packages/client-insights/dist/builds/browser.umd.js",
"maxSize": "3.90KB"
"maxSize": "4.2KB"
},
{
"path": "packages/client-personalization/dist/builds/browser.umd.js",
"maxSize": "4.05KB"
"maxSize": "4.3KB"
},
{
"path": "packages/client-query-suggestions/dist/builds/browser.umd.js",
"maxSize": "4.05KB"
"maxSize": "4.3KB"
},
{
"path": "packages/client-search/dist/builds/browser.umd.js",
"maxSize": "7.35KB"
"maxSize": "7.7KB"
},
{
"path": "packages/ingestion/dist/builds/browser.umd.js",
"maxSize": "6.75KB"
"maxSize": "7.1KB"
},
{
"path": "packages/monitoring/dist/builds/browser.umd.js",
"maxSize": "4.00KB"
"maxSize": "4.3KB"
},
{
"path": "packages/recommend/dist/builds/browser.umd.js",
"maxSize": "4.15KB"
"maxSize": "4.5KB"
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@
"test": "tsc --noEmit && vitest --run",
"test:bundle": "publint . && attw --pack ."
},
"dependencies": {
"fflate": "0.8.2"
},
"dependencies": {},
"devDependencies": {
"@arethetypeswrong/cli": "0.18.2",
"@types/node": "24.12.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
import { gunzipSync } from 'fflate';
import { beforeEach, describe, expect, test } from 'vitest';
import { gunzipSync, gzipSync } from 'node:zlib';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createMemoryCache, createNullCache } from '../../cache';
import { createNullLogger } from '../../logger';
import { createTransporter } from '../../transporter';
import { COMPRESSION_THRESHOLD } from '../../transporter/compress';
import type { AlgoliaAgent, EndRequest } from '../../types';
import type { AlgoliaAgent, EndRequest, Logger } from '../../types';

// A payload large enough to exceed COMPRESSION_THRESHOLD
const largePayload = { data: 'x'.repeat(COMPRESSION_THRESHOLD + 1) };

const algoliaAgent: AlgoliaAgent = {
value: 'test',
add: () => algoliaAgent,
};

function makeTransporter(compression?: 'gzip', onRequest?: (req: EndRequest) => void) {
async function gzipCompress(data: string): Promise<Uint8Array> {
return gzipSync(Buffer.from(data));
}

function makeTransporter(opts: {
compress?: (data: string) => Promise<Uint8Array>;
compression?: 'gzip';
logger?: Logger;
onRequest?: (req: EndRequest) => void;
}) {
return createTransporter({
hosts: [{ url: 'localhost', accept: 'readWrite', protocol: 'https' }],
hostsCache: createNullCache(),
baseHeaders: {},
baseQueryParameters: {},
algoliaAgent,
logger: createNullLogger(),
logger: opts.logger ?? createNullLogger(),
timeouts: { connect: 1000, read: 2000, write: 3000 },
...(compression ? { compression } : {}),
...(opts.compress ? { compress: opts.compress } : {}),
...(opts.compression ? { compression: opts.compression } : {}),
requester: {
send: async (req) => {
onRequest?.(req);
opts.onRequest?.(req);
return { status: 200, content: '{}', isTimedOut: false };
},
},
Expand All @@ -42,28 +51,28 @@ describe('compression', () => {
captured = undefined;
});

test('does not compress when compression is not configured', async () => {
const transporter = makeTransporter(undefined, (req) => {
captured = req;
test('does not compress when compression is not enabled', async () => {
const transporter = makeTransporter({
compress: gzipCompress,
onRequest: (req) => {
captured = req;
},
});

await transporter.request({
method: 'POST',
path: '/test',
queryParameters: {},
headers: {},
data: { foo: 'bar' },
});
await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {}, data: largePayload });

expect(captured).toBeDefined();
expect(captured!.headers['content-encoding']).toBeUndefined();
expect(typeof captured!.data).toBe('string');
expect(captured!.data).toBe('{"foo":"bar"}');
});

test('compresses POST body when compression is gzip', async () => {
const transporter = makeTransporter('gzip', (req) => {
captured = req;
test('compresses POST body when compression is gzip and compress is provided', async () => {
const transporter = makeTransporter({
compress: gzipCompress,
compression: 'gzip',
onRequest: (req) => {
captured = req;
},
});

await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {}, data: largePayload });
Expand All @@ -72,13 +81,17 @@ describe('compression', () => {
expect(captured!.headers['content-encoding']).toBe('gzip');
expect(captured!.data).toBeInstanceOf(Uint8Array);

const decompressed = new TextDecoder().decode(gunzipSync(captured!.data as Uint8Array));
const decompressed = gunzipSync(Buffer.from(captured!.data as Uint8Array)).toString();
expect(decompressed).toBe(JSON.stringify(largePayload));
});

test('compresses PUT body when compression is gzip', async () => {
const transporter = makeTransporter('gzip', (req) => {
captured = req;
test('compresses PUT body when compression is gzip and compress is provided', async () => {
const transporter = makeTransporter({
compress: gzipCompress,
compression: 'gzip',
onRequest: (req) => {
captured = req;
},
});

await transporter.request({ method: 'PUT', path: '/test', queryParameters: {}, headers: {}, data: largePayload });
Expand All @@ -87,13 +100,17 @@ describe('compression', () => {
expect(captured!.headers['content-encoding']).toBe('gzip');
expect(captured!.data).toBeInstanceOf(Uint8Array);

const decompressed = new TextDecoder().decode(gunzipSync(captured!.data as Uint8Array));
const decompressed = gunzipSync(Buffer.from(captured!.data as Uint8Array)).toString();
expect(decompressed).toBe(JSON.stringify(largePayload));
});

test('does not compress POST when body is below threshold', async () => {
const transporter = makeTransporter('gzip', (req) => {
captured = req;
const transporter = makeTransporter({
compress: gzipCompress,
compression: 'gzip',
onRequest: (req) => {
captured = req;
},
});

await transporter.request({
Expand All @@ -110,8 +127,12 @@ describe('compression', () => {
});

test('does not compress GET requests', async () => {
const transporter = makeTransporter('gzip', (req) => {
captured = req;
const transporter = makeTransporter({
compress: gzipCompress,
compression: 'gzip',
onRequest: (req) => {
captured = req;
},
});

await transporter.request({ method: 'GET', path: '/test', queryParameters: {}, headers: {} });
Expand All @@ -121,15 +142,36 @@ describe('compression', () => {
expect(captured!.data).toBeUndefined();
});

test('does not compress POST when body is empty', async () => {
const transporter = makeTransporter('gzip', (req) => {
captured = req;
test('logs warning when compression is gzip but compress method is missing', async () => {
const logger: Logger = { debug: vi.fn(), info: vi.fn(), error: vi.fn() };
const transporter = makeTransporter({
compression: 'gzip',
logger,
onRequest: (req) => {
captured = req;
},
});

await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {} });
await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {}, data: largePayload });

expect(captured).toBeDefined();
expect(captured!.headers['content-encoding']).toBeUndefined();
expect(captured!.data).toBeUndefined();
expect(typeof captured!.data).toBe('string');
expect(logger.info).toHaveBeenCalledWith('Compression is disabled because no compress method is available.');
});

test('silently sends uncompressed when compression is gzip, compress is missing, and null logger', async () => {
const transporter = makeTransporter({
compression: 'gzip',
onRequest: (req) => {
captured = req;
},
});

await transporter.request({ method: 'POST', path: '/test', queryParameters: {}, headers: {}, data: largePayload });

expect(captured).toBeDefined();
expect(captured!.headers['content-encoding']).toBeUndefined();
expect(typeof captured!.data).toBe('string');
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1 @@
import { gzipSync } from 'fflate';

export const COMPRESSION_THRESHOLD = 750;

/**
* Compresses a string using gzip via fflate.
* Works in both Node.js and browsers with no platform-specific code.
*/
export function compress(data: string): Uint8Array {
return gzipSync(new TextEncoder().encode(data));
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
Transporter,
TransporterOptions,
} from '../types';
import { compress, COMPRESSION_THRESHOLD } from './compress';
import { COMPRESSION_THRESHOLD } from './compress';
import { createStatefulHost } from './createStatefulHost';
import { RetryError } from './errors';
import { deserializeFailure, deserializeSuccess, serializeData, serializeHeaders, serializeUrl } from './helpers';
Expand All @@ -32,6 +32,7 @@ export function createTransporter({
requester,
requestsCache,
responsesCache,
compress,
compression,
}: TransporterOptions): Transporter {
async function createRetryableOptions(compatibleHosts: Host[]): Promise<RetryableOptions> {
Expand Down Expand Up @@ -84,13 +85,18 @@ export function createTransporter({
const serializedData = serializeData(request, requestOptions);
const headers = serializeHeaders(baseHeaders, request.headers, requestOptions.headers);

const shouldCompress =
const wantsCompression =
compression === 'gzip' &&
serializedData !== undefined &&
serializedData.length > COMPRESSION_THRESHOLD &&
(request.method === 'POST' || request.method === 'PUT');

const data = shouldCompress ? compress(serializedData) : serializedData;
if (wantsCompression && compress === undefined) {
logger.info('Compression is disabled because no compress method is available.');
}

const shouldCompress = wantsCompression && compress !== undefined;
const data = shouldCompress ? await compress(serializedData) : serializedData;
if (shouldCompress) {
headers['content-encoding'] = 'gzip';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ export type CreateClientOptions = Omit<TransporterOptions, OverriddenTransporter
algoliaAgents: AlgoliaAgentOptions[];
};

export type ClientOptions = Partial<Omit<CreateClientOptions, 'apiKey' | 'appId'>>;
export type ClientOptions = Partial<Omit<CreateClientOptions, 'apiKey' | 'appId' | 'compress'>>;
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,13 @@ export type TransporterOptions = {
algoliaAgent: AlgoliaAgent;

/**
* The compression algorithm to use when sending POST/PUT request bodies.
* When set to `'gzip'`, request bodies are gzip-compressed and
* `Content-Encoding: gzip` is added to the headers.
* Works on all Node.js versions and browsers with no native API requirements.
* An optional function to compress request bodies before sending.
* When provided, POST/PUT bodies exceeding the compression threshold
* will be compressed and `Content-Encoding: gzip` is added to the headers.
* Node builds use node:zlib, browser/worker builds use CompressionStream when available.
*/
compress?: (data: string) => Promise<Uint8Array>;

compression?: 'gzip';
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// {{{generationBanner}}}

{{> client/builds/definition}}
const { compression: _compression, ...browserOptions } = options || {};

return create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}({
appId,
apiKey,{{#hasRegionalHost}}region,{{/hasRegionalHost}}
Expand All @@ -21,7 +23,7 @@
createMemoryCache(),
],
}),
...options,
...browserOptions,
});
}

Expand Down
3 changes: 3 additions & 0 deletions templates/javascript/clients/client/builds/fetch.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnType<typeof create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}>{{#searchHelpers}} & SearchClientNodeHelpers{{/searchHelpers}};

import { gzipSync } from 'node:zlib';

{{#searchHelpers}}
import { createHmac } from 'node:crypto';
{{/searchHelpers}}
Expand All @@ -22,6 +24,7 @@ import { createHmac } from 'node:crypto';
responsesCache: createNullCache(),
requestsCache: createNullCache(),
hostsCache: createMemoryCache(),
compress: async (data: string): Promise<Uint8Array> => gzipSync(Buffer.from(data)),
...options,
}),
{{#searchHelpers}}
Expand Down
Loading
Loading