diff --git a/README.md b/README.md index a0943ed..7ea1ef4 100644 --- a/README.md +++ b/README.md @@ -151,13 +151,15 @@ new AlternatorDynamoDBClient({ }, compression: { - enabled: true, - gzipLevel: -1, - }, - - responseCompression: { - enabled: true, - encodings: [ResponseCompressionGzip], + request: { + enabled: true, + thresholdBytes: 1_024, + gzipLevel: -1, + }, + response: { + enabled: true, + algorithms: [ResponseCompressionGzip], + }, }, headerOptimization: { @@ -231,13 +233,30 @@ new AlternatorDynamoDBClient({ Use `userAgent: false` to remove the header entirely. -Request compression is disabled by default. When enabled, it compresses every -request body with gzip. Use `gzipLevel` to select the zlib level, or provide -`compressor` for a custom compressor. +Request and response compression are disabled by default and configured +separately: + +```ts +compression: { + request: { + enabled: true, + thresholdBytes: 1_024, + gzipLevel: -1, + }, + response: { + enabled: true, + algorithms: [ResponseCompressionGzip], + }, +} +``` + +`compression.request: true` compresses every measurable request body with gzip. +Use `thresholdBytes` to skip smaller request bodies, `gzipLevel` to select the +zlib level, or `compressor` for a custom request compressor. -Response compression is disabled by default. Enable it with -`responseCompression: true` to accept both supported encodings, or pass an -options object with an explicit list: +`compression.response: true` enables the default response algorithm, `gzip`. +Pass an options object with an explicit algorithm list to control the +`Accept-Encoding` value: ```ts import { @@ -247,15 +266,19 @@ import { new AlternatorDynamoDBClient({ seeds: ["scylla-0.internal"], - responseCompression: { - enabled: true, - encodings: [ResponseCompressionGzip, ResponseCompressionDeflate], + compression: { + response: { + enabled: true, + algorithms: [ResponseCompressionGzip, ResponseCompressionDeflate], + }, }, }); ``` When enabled, the client sends `Accept-Encoding` and transparently decodes -`gzip` and `deflate` response bodies before the AWS SDK deserializes them. +`gzip` and `deflate` response bodies before the AWS SDK deserializes them. If +header optimization uses a custom whitelist, keep `Accept-Encoding` and +`Content-Encoding` in that list. Key-route affinity supports these modes: diff --git a/src/client.ts b/src/client.ts index 76d0730..add485f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -62,6 +62,7 @@ export class AlternatorDynamoDBClient extends DynamoDBClient { override: true, }, ); + } getLiveNodes(): AlternatorNode[] { @@ -118,7 +119,6 @@ function buildDynamoConfig( routing: _routing, runtime: _runtime, compression: _compression, - responseCompression: _responseCompression, headerOptimization: _headerOptimization, userAgent: _userAgent, keyRouteAffinity: _keyRouteAffinity, diff --git a/src/compression.ts b/src/compression.ts index 8f047cd..3611b41 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -2,15 +2,15 @@ import { HttpResponse } from "@smithy/protocol-http"; import { bodyToBytes } from "./body.js"; import { ResponseCompressionDeflate, ResponseCompressionGzip } from "./types.js"; import type { - AlternatorResponseCompression, + AlternatorResponseCompressionAlgorithm, AlternatorRuntime, - NormalizedCompressionOptions, + NormalizedRequestCompressionOptions, } from "./types.js"; export async function compressBody( body: unknown, runtime: AlternatorRuntime, - config: NormalizedCompressionOptions, + config: NormalizedRequestCompressionOptions, ): Promise<{ body: Uint8Array; contentEncoding: string; contentLength: number } | undefined> { const bytes = bodyToBytes(body); if (!bytes) { @@ -51,11 +51,11 @@ export async function compressBody( }; } -export function applyResponseCompressionRequestHeaders( +export function applyResponseEncodingHeaders( headers: Record, - encodings: readonly AlternatorResponseCompression[], + algorithms: readonly AlternatorResponseCompressionAlgorithm[], ): Record { - const acceptEncoding = responseCompressionAcceptEncoding(encodings); + const acceptEncoding = responseAcceptEncoding(algorithms); if (acceptEncoding === "") { return copyDefinedHeaders(headers); } @@ -71,18 +71,18 @@ export function applyResponseCompressionRequestHeaders( }; } -export function responseCompressionAcceptEncoding( - encodings: readonly AlternatorResponseCompression[], +export function responseAcceptEncoding( + algorithms: readonly AlternatorResponseCompressionAlgorithm[], ): string { - const seen = new Set(); - const parts: AlternatorResponseCompression[] = []; + const seen = new Set(); + const parts: AlternatorResponseCompressionAlgorithm[] = []; - for (const encoding of encodings) { - if (seen.has(encoding)) { + for (const algorithm of algorithms) { + if (seen.has(algorithm)) { continue; } - seen.add(encoding); - parts.push(encoding); + seen.add(algorithm); + parts.push(algorithm); } return parts.join(", "); @@ -92,7 +92,7 @@ export async function decompressResponse( response: HttpResponse, runtime: AlternatorRuntime, ): Promise { - const encoding = responseCompressionContentEncoding(getHeader(response.headers, "content-encoding")); + const encoding = responseContentEncoding(getHeader(response.headers, "content-encoding")); const body: unknown = response.body; if (!encoding || body === undefined || body === null) { return response; @@ -110,7 +110,7 @@ export async function decompressResponse( async function decompressResponseBody( body: unknown, runtime: AlternatorRuntime, - encoding: AlternatorResponseCompression, + encoding: AlternatorResponseCompressionAlgorithm, ): Promise { if (runtime === "edge") { return decompressWebResponseBody(body, encoding); @@ -120,7 +120,7 @@ async function decompressResponseBody( async function decompressNodeResponseBody( body: unknown, - encoding: AlternatorResponseCompression, + encoding: AlternatorResponseCompressionAlgorithm, ): Promise { const zlib = await import("node:zlib"); @@ -139,7 +139,7 @@ async function decompressNodeResponseBody( async function decompressWebResponseBody( body: unknown, - encoding: AlternatorResponseCompression, + encoding: AlternatorResponseCompressionAlgorithm, ): Promise { if (typeof DecompressionStream === "undefined") { throw new Error("response compression requires DecompressionStream support in edge runtime"); @@ -186,9 +186,7 @@ async function bodyToAsyncBytes(body: unknown): Promise { throw new Error("compressed response body is not readable"); } -function responseCompressionContentEncoding( - value: string | undefined, -): AlternatorResponseCompression | undefined { +function responseContentEncoding(value: string | undefined): AlternatorResponseCompressionAlgorithm | undefined { switch (value?.trim().toLowerCase()) { case ResponseCompressionGzip: return ResponseCompressionGzip; @@ -241,7 +239,16 @@ function chunkToBytes(chunk: unknown): Uint8Array { if (bytes) { return bytes; } - return new TextEncoder().encode(String(chunk)); + switch (typeof chunk) { + case "string": + return new TextEncoder().encode(chunk); + case "bigint": + case "number": + case "boolean": + return new TextEncoder().encode(String(chunk)); + default: + throw new Error("compressed response body chunk is not readable"); + } } function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { diff --git a/src/config.ts b/src/config.ts index baf08f8..52952eb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,12 +4,17 @@ import { normalizeUserAgent } from "./user-agent.js"; import type { RoutingRule } from "./routing.js"; import { ResponseCompressionDeflate, ResponseCompressionGzip } from "./types.js"; import type { + AlternatorCompressionOptions, AlternatorConnectionOptions, AlternatorDynamoDBClientConfig, AlternatorKeyRouteAffinityType, - AlternatorResponseCompression, + AlternatorRequestCompressionConfig, + AlternatorResponseCompressionAlgorithm, + AlternatorResponseCompressionConfig, AlternatorRuntime, NormalizedAlternatorConfig, + NormalizedCompressionOptions, + NormalizedRequestCompressionOptions, NormalizedResponseCompressionOptions, } from "./types.js"; @@ -20,9 +25,8 @@ const DEFAULT_ALLOWED_HEADERS = [ "Accept-Encoding", "Content-Encoding", ] as const; -const DEFAULT_RESPONSE_COMPRESSION_ENCODINGS = [ +const DEFAULT_RESPONSE_COMPRESSION_ALGORITHMS = [ ResponseCompressionGzip, - ResponseCompressionDeflate, ] as const; export const DEFAULT_REGION = "us-east-1"; @@ -55,7 +59,6 @@ export function normalizeConfig(input: AlternatorDynamoDBClientConfig): Normaliz const noAuth = input.credentials === undefined; const routingRule = normalizeRouting(input.routing); const compression = normalizeCompression(input.compression); - const responseCompression = normalizeResponseCompression(input.responseCompression); const headerOptimization = normalizeHeaderOptimization(input.headerOptimization, noAuth); const userAgent = normalizeUserAgent(input.userAgent); const keyRouteAffinity = normalizeKeyRouteAffinity(input.keyRouteAffinity); @@ -69,7 +72,6 @@ export function normalizeConfig(input: AlternatorDynamoDBClientConfig): Normaliz routing: routingRule, runtime, compression, - responseCompression, headerOptimization, userAgent, keyRouteAffinity, @@ -207,69 +209,97 @@ function normalizeRuntime(runtime: AlternatorDynamoDBClientConfig["runtime"]): A return normalized; } -function normalizeCompression(input: AlternatorDynamoDBClientConfig["compression"]) { +function normalizeCompression(input: AlternatorDynamoDBClientConfig["compression"]): NormalizedCompressionOptions { + if (input === undefined) { + return { + request: normalizeRequestCompression(undefined), + response: normalizeResponseCompression(undefined), + }; + } + if (!isRecord(input)) { + throw new TypeError("compression must be an object with request and/or response options"); + } + assertAllowedKeys(input, ["request", "response"], "compression"); + const compression = input as AlternatorCompressionOptions; + return { + request: normalizeRequestCompression(compression.request), + response: normalizeResponseCompression(compression.response), + }; +} + +function normalizeRequestCompression( + input: AlternatorRequestCompressionConfig | undefined, +): NormalizedRequestCompressionOptions { if (typeof input === "boolean") { return { enabled: input, thresholdBytes: 0 }; } - const normalized = { - enabled: input?.enabled ?? false, - thresholdBytes: input?.thresholdBytes ?? 0, - ...(input?.gzipLevel !== undefined ? { gzipLevel: input.gzipLevel } : {}), - ...(input?.compressor ? { compressor: input.compressor } : {}), - }; - if (normalized.gzipLevel !== undefined && (normalized.gzipLevel < -1 || normalized.gzipLevel > 9)) { - throw new TypeError("compression.gzipLevel must be between -1 and 9"); + if (input === undefined) { + return { enabled: false, thresholdBytes: 0 }; } - return normalized; + if (!isRecord(input)) { + throw new TypeError("compression.request must be a boolean or request compression object"); + } + const options = input as Exclude; + const gzipLevel = options.gzipLevel; + if (gzipLevel !== undefined && (gzipLevel < -1 || gzipLevel > 9)) { + throw new TypeError("compression.request.gzipLevel must be between -1 and 9"); + } + return { + enabled: options.enabled ?? false, + thresholdBytes: options.thresholdBytes ?? 0, + ...(gzipLevel !== undefined ? { gzipLevel } : {}), + ...(options.compressor ? { compressor: options.compressor } : {}), + }; } function normalizeResponseCompression( - input: AlternatorDynamoDBClientConfig["responseCompression"], + input: AlternatorResponseCompressionConfig | undefined, ): NormalizedResponseCompressionOptions { if (input === undefined || input === false) { - return { enabled: false, encodings: [] }; + return { enabled: false, algorithms: [] }; } - const encodings = responseCompressionEncodings(input) - .map(normalizeResponseCompressionEncoding) - .filter((encoding, index, values) => values.indexOf(encoding) === index); + const algorithms = configuredResponseAlgorithms(input) + .map(normalizeResponseCompressionAlgorithm) + .filter((algorithm, index, values) => values.indexOf(algorithm) === index); return { - enabled: encodings.length > 0, - encodings, + enabled: algorithms.length > 0, + algorithms, }; } -function responseCompressionEncodings( - input: NonNullable, +function configuredResponseAlgorithms( + input: NonNullable, ): readonly unknown[] { if (input === true) { - return DEFAULT_RESPONSE_COMPRESSION_ENCODINGS; + return DEFAULT_RESPONSE_COMPRESSION_ALGORITHMS; } if (!isRecord(input)) { - throw new TypeError("responseCompression must be a boolean or options object"); + throw new TypeError("compression.response must be a boolean or options object"); } + assertAllowedKeys(input, ["enabled", "algorithms"], "compression.response"); if (input.enabled === false) { return []; } - if (input.encodings !== undefined) { - if (!Array.isArray(input.encodings)) { - throw new TypeError("responseCompression.encodings must be an array"); + if (input.algorithms !== undefined) { + if (!Array.isArray(input.algorithms)) { + throw new TypeError("compression.response.algorithms must be an array"); } } if (input.enabled !== true) { return []; } - return input.encodings ?? DEFAULT_RESPONSE_COMPRESSION_ENCODINGS; + return input.algorithms ?? DEFAULT_RESPONSE_COMPRESSION_ALGORITHMS; } -function normalizeResponseCompressionEncoding(encoding: unknown): AlternatorResponseCompression { - switch (encoding) { +function normalizeResponseCompressionAlgorithm(algorithm: unknown): AlternatorResponseCompressionAlgorithm { + switch (algorithm) { case ResponseCompressionGzip: case ResponseCompressionDeflate: - return encoding; + return algorithm; default: - throw new TypeError('responseCompression encodings must be "gzip" or "deflate"'); + throw new TypeError('compression.response algorithms must be "gzip" or "deflate"'); } } @@ -389,6 +419,15 @@ function assertNonEmptyString(value: unknown, label: string): asserts value is s } } +function assertAllowedKeys(value: Record, keys: readonly string[], label: string): void { + const allowed = new Set(keys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + throw new TypeError(`${label}.${key} is not a supported option`); + } + } +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/src/index.ts b/src/index.ts index a54a0d9..44b4dce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,11 @@ export type { AlternatorLogger, AlternatorNode, AlternatorPartitionKeyByTable, + AlternatorRequestCompressionConfig, + AlternatorRequestCompressionOptions, AlternatorResponseCompression, + AlternatorResponseCompressionAlgorithm, + AlternatorResponseCompressionConfig, AlternatorResponseCompressionOptions, AlternatorRuntime, AlternatorScheme, diff --git a/src/middleware.ts b/src/middleware.ts index 5644991..1db2459 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { HttpRequest } from "@smithy/protocol-http"; import type { FinalizeRequestMiddleware, HandlerExecutionContext } from "@smithy/types"; import { - applyResponseCompressionRequestHeaders, + applyResponseEncodingHeaders, compressBody, } from "./compression.js"; import { hostForUrl } from "./config.js"; @@ -49,7 +49,7 @@ export function createAlternatorRequestMiddleware { await client.send(new ListTablesCommand({})); expect(handler.requests.at(-1)?.hostname).toBe("localhost"); + expect(client.alternatorConfig.compression).toEqual({ + request: { + enabled: false, + thresholdBytes: 0, + }, + response: { + enabled: false, + algorithms: [], + }, + }); }); it("rejects unsupported edge-only runtime combinations", () => { @@ -202,6 +212,19 @@ describe("AlternatorDynamoDBClient config", () => { }); it("validates gzip compression level against zlib range", () => { + expect( + () => + new AlternatorDynamoDBClient({ + seeds: ["localhost"], + compression: { + request: { + enabled: true, + gzipLevel: -2, + }, + }, + }), + ).toThrow(/compression\.request\.gzipLevel/); + expect( () => new AlternatorDynamoDBClient({ @@ -209,75 +232,83 @@ describe("AlternatorDynamoDBClient config", () => { compression: { enabled: true, gzipLevel: -2, - }, + } as never, }), - ).toThrow(/gzipLevel/); + ).toThrow(/compression\.enabled/); expect( new AlternatorDynamoDBClient({ seeds: ["localhost"], compression: { - enabled: true, - gzipLevel: -1, + request: { + enabled: true, + gzipLevel: -1, + }, }, - }).alternatorConfig.compression.gzipLevel, + }).alternatorConfig.compression.request.gzipLevel, ).toBe(-1); }); - it("normalizes and validates response compression encodings", () => { + it("normalizes and validates response compression algorithms", () => { expect( new AlternatorDynamoDBClient({ seeds: ["localhost"], - responseCompression: true, - }).alternatorConfig.responseCompression, + compression: { response: true }, + }).alternatorConfig.compression.response, ).toEqual({ enabled: true, - encodings: [ResponseCompressionGzip, ResponseCompressionDeflate], + algorithms: [ResponseCompressionGzip], }); expect( new AlternatorDynamoDBClient({ seeds: ["localhost"], - responseCompression: { - enabled: true, - encodings: [ - ResponseCompressionDeflate, - ResponseCompressionDeflate, - ResponseCompressionGzip, - ], + compression: { + response: { + enabled: true, + algorithms: [ + ResponseCompressionDeflate, + ResponseCompressionDeflate, + ResponseCompressionGzip, + ], + }, }, - }).alternatorConfig.responseCompression, + }).alternatorConfig.compression.response, ).toEqual({ enabled: true, - encodings: [ResponseCompressionDeflate, ResponseCompressionGzip], + algorithms: [ResponseCompressionDeflate, ResponseCompressionGzip], }); expect( new AlternatorDynamoDBClient({ seeds: ["localhost"], - responseCompression: false, - }).alternatorConfig.responseCompression.enabled, + compression: { response: false }, + }).alternatorConfig.compression.response.enabled, ).toBe(false); expect( new AlternatorDynamoDBClient({ seeds: ["localhost"], - responseCompression: { - encodings: [ResponseCompressionGzip], + compression: { + response: { + algorithms: [ResponseCompressionGzip], + }, }, - }).alternatorConfig.responseCompression.enabled, + }).alternatorConfig.compression.response.enabled, ).toBe(false); expect( () => new AlternatorDynamoDBClient({ seeds: ["localhost"], - responseCompression: { - enabled: true, - encodings: ["br"], + compression: { + response: { + enabled: true, + algorithms: ["br"], + }, } as never, }), - ).toThrow(/responseCompression/); + ).toThrow(/compression\.response/); }); it("validates key route affinity type", () => { diff --git a/test/integration-test/alternator-client.test.ts b/test/integration-test/alternator-client.test.ts index aa455bb..c006d3d 100644 --- a/test/integration-test/alternator-client.test.ts +++ b/test/integration-test/alternator-client.test.ts @@ -143,8 +143,10 @@ describeIntegration.each(integrationEndpoints())( it("compresses large requests and leaves small requests uncompressed by threshold", async () => { const client = buildClient(endpoint, { compression: { - enabled: true, - thresholdBytes: 100, + request: { + enabled: true, + thresholdBytes: 100, + }, }, maxAttempts: 1, }); @@ -178,9 +180,11 @@ describeIntegration.each(integrationEndpoints())( ])("reads %s-compressed responses", async (encoding) => { const tableName = uniqueTableName(`js_response_compression_${encoding}`); const client = buildClient(endpoint, { - responseCompression: { - enabled: true, - encodings: [encoding], + compression: { + response: { + enabled: true, + algorithms: [encoding], + }, }, maxAttempts: 1, }); @@ -297,8 +301,10 @@ describeIntegration.each(integrationEndpoints())( it("keeps compression headers when header optimization and compression are combined", async () => { const client = buildClient(endpoint, { compression: { - enabled: true, - thresholdBytes: 100, + request: { + enabled: true, + thresholdBytes: 100, + }, }, headerOptimization: { enabled: true, diff --git a/test/integration-test/tls-config.test.ts b/test/integration-test/tls-config.test.ts index f8b21bb..bebb26c 100644 --- a/test/integration-test/tls-config.test.ts +++ b/test/integration-test/tls-config.test.ts @@ -93,8 +93,10 @@ describeIntegration.each(httpsIntegrationEndpoints())( rejectUnauthorized: false, }, compression: { - enabled: true, - thresholdBytes: 100, + request: { + enabled: true, + thresholdBytes: 100, + }, }, headerOptimization: { enabled: true, diff --git a/test/middleware.test.ts b/test/middleware.test.ts index b0db702..5de6948 100644 --- a/test/middleware.test.ts +++ b/test/middleware.test.ts @@ -189,7 +189,7 @@ describe("Alternator middleware", () => { seeds: ["seed"], requestHandler: handler, discovery: { background: false }, - compression: true, + compression: { request: true }, }); await client.send( @@ -230,9 +230,11 @@ describe("Alternator middleware", () => { seeds: ["seed"], requestHandler: handler, discovery: { background: false }, - responseCompression: { - enabled: true, - encodings: [encoding], + compression: { + response: { + enabled: true, + algorithms: [encoding], + }, }, }); @@ -248,9 +250,11 @@ describe("Alternator middleware", () => { seeds: ["seed"], requestHandler: handler, discovery: { background: false }, - responseCompression: { - enabled: true, - encodings: [ResponseCompressionGzip], + compression: { + response: { + enabled: true, + algorithms: [ResponseCompressionGzip], + }, }, }); @@ -283,9 +287,11 @@ describe("Alternator middleware", () => { accessKeyId: "key", secretAccessKey: "secret", }, - responseCompression: { - enabled: true, - encodings: [ResponseCompressionGzip], + compression: { + response: { + enabled: true, + algorithms: [ResponseCompressionGzip], + }, }, }); diff --git a/test/type-usage.test.ts b/test/type-usage.test.ts index 06815a6..131d3d5 100644 --- a/test/type-usage.test.ts +++ b/test/type-usage.test.ts @@ -29,12 +29,14 @@ describe("public type usage", () => { allowedHeaders: ["Host", "X-Amz-Target"], }, compression: { - enabled: true, - gzipLevel: -1, - }, - responseCompression: { - enabled: true, - encodings: [ResponseCompressionGzip, ResponseCompressionDeflate], + request: { + enabled: true, + gzipLevel: -1, + }, + response: { + enabled: true, + algorithms: [ResponseCompressionGzip, ResponseCompressionDeflate], + }, }, userAgent: (userAgent) => `${userAgent} app/1.0.0`, keyRouteAffinity: {