diff --git a/README.md b/README.md index 3917132..a0943ed 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ least one seed per datacenter. | Socket pool tuning | Yes | No | | TLS session cache tuning | Yes | No | | Gzip request compression | Yes | Only with `CompressionStream` | +| Gzip/deflate response compression | Yes | Only with `DecompressionStream` | Unsupported edge combinations throw at construction time with clear errors. @@ -154,6 +155,11 @@ new AlternatorDynamoDBClient({ gzipLevel: -1, }, + responseCompression: { + enabled: true, + encodings: [ResponseCompressionGzip], + }, + headerOptimization: { enabled: true, allowedHeaders: ["Host", "X-Amz-Target", "Content-Length", "Accept-Encoding", "Content-Encoding"], @@ -229,6 +235,28 @@ 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. +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: + +```ts +import { + ResponseCompressionDeflate, + ResponseCompressionGzip, +} from "@scylladb/alternator-client"; + +new AlternatorDynamoDBClient({ + seeds: ["scylla-0.internal"], + responseCompression: { + enabled: true, + encodings: [ResponseCompressionGzip, ResponseCompressionDeflate], + }, +}); +``` + +When enabled, the client sends `Accept-Encoding` and transparently decodes +`gzip` and `deflate` response bodies before the AWS SDK deserializes them. + Key-route affinity supports these modes: ```ts diff --git a/src/client.ts b/src/client.ts index ab0660e..76d0730 100644 --- a/src/client.ts +++ b/src/client.ts @@ -118,6 +118,7 @@ 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 fc412a6..8f047cd 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -1,5 +1,11 @@ +import { HttpResponse } from "@smithy/protocol-http"; import { bodyToBytes } from "./body.js"; -import type { AlternatorRuntime, NormalizedCompressionOptions } from "./types.js"; +import { ResponseCompressionDeflate, ResponseCompressionGzip } from "./types.js"; +import type { + AlternatorResponseCompression, + AlternatorRuntime, + NormalizedCompressionOptions, +} from "./types.js"; export async function compressBody( body: unknown, @@ -44,3 +50,245 @@ export async function compressBody( contentLength: compressed.byteLength, }; } + +export function applyResponseCompressionRequestHeaders( + headers: Record, + encodings: readonly AlternatorResponseCompression[], +): Record { + const acceptEncoding = responseCompressionAcceptEncoding(encodings); + if (acceptEncoding === "") { + return copyDefinedHeaders(headers); + } + + const currentAcceptEncoding = getHeader(headers, "accept-encoding")?.trim(); + if (currentAcceptEncoding && currentAcceptEncoding.toLowerCase() !== "identity") { + return copyDefinedHeaders(headers); + } + + return { + ...removeHeaders(headers, ["accept-encoding"]), + "accept-encoding": acceptEncoding, + }; +} + +export function responseCompressionAcceptEncoding( + encodings: readonly AlternatorResponseCompression[], +): string { + const seen = new Set(); + const parts: AlternatorResponseCompression[] = []; + + for (const encoding of encodings) { + if (seen.has(encoding)) { + continue; + } + seen.add(encoding); + parts.push(encoding); + } + + return parts.join(", "); +} + +export async function decompressResponse( + response: HttpResponse, + runtime: AlternatorRuntime, +): Promise { + const encoding = responseCompressionContentEncoding(getHeader(response.headers, "content-encoding")); + const body: unknown = response.body; + if (!encoding || body === undefined || body === null) { + return response; + } + + const decodedBody = await decompressResponseBody(body, runtime, encoding); + return new HttpResponse({ + statusCode: response.statusCode, + ...(response.reason !== undefined ? { reason: response.reason } : {}), + headers: removeHeaders(response.headers, ["content-encoding", "content-length"]), + body: decodedBody, + }); +} + +async function decompressResponseBody( + body: unknown, + runtime: AlternatorRuntime, + encoding: AlternatorResponseCompression, +): Promise { + if (runtime === "edge") { + return decompressWebResponseBody(body, encoding); + } + return decompressNodeResponseBody(body, encoding); +} + +async function decompressNodeResponseBody( + body: unknown, + encoding: AlternatorResponseCompression, +): Promise { + const zlib = await import("node:zlib"); + + if (isNodePipeableBody(body)) { + const decoder = encoding === ResponseCompressionGzip + ? zlib.createGunzip() + : zlib.createInflate(); + return body.pipe(decoder); + } + + const bytes = await bodyToAsyncBytes(body); + return encoding === ResponseCompressionGzip + ? zlib.gunzipSync(bytes) + : zlib.inflateSync(bytes); +} + +async function decompressWebResponseBody( + body: unknown, + encoding: AlternatorResponseCompression, +): Promise { + if (typeof DecompressionStream === "undefined") { + throw new Error("response compression requires DecompressionStream support in edge runtime"); + } + + const stream = await bodyToReadableStream(body); + return stream.pipeThrough(new DecompressionStream(encoding)); +} + +async function bodyToReadableStream(body: unknown): Promise { + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) { + return body; + } + if (typeof Blob !== "undefined" && body instanceof Blob) { + return body.stream(); + } + + const bytes = await bodyToAsyncBytes(body); + return new Blob([bytesToArrayBuffer(bytes)]).stream(); +} + +async function bodyToAsyncBytes(body: unknown): Promise { + const bytes = bodyToBytes(body); + if (bytes) { + return bytes; + } + if (typeof Blob !== "undefined" && body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()); + } + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) { + return new Uint8Array(await new Response(body).arrayBuffer()); + } + if (isTransformableByteBody(body)) { + return body.transformToByteArray(); + } + if (isAsyncIterable(body)) { + const chunks: Uint8Array[] = []; + for await (const chunk of body) { + chunks.push(chunkToBytes(chunk)); + } + return concatBytes(chunks); + } + + throw new Error("compressed response body is not readable"); +} + +function responseCompressionContentEncoding( + value: string | undefined, +): AlternatorResponseCompression | undefined { + switch (value?.trim().toLowerCase()) { + case ResponseCompressionGzip: + return ResponseCompressionGzip; + case ResponseCompressionDeflate: + return ResponseCompressionDeflate; + default: + return undefined; + } +} + +function copyDefinedHeaders(headers: Record): Record { + const nextHeaders: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + nextHeaders[name] = value; + } + } + return nextHeaders; +} + +function removeHeaders( + headers: Record, + names: readonly string[], +): Record { + const removed = new Set(names.map((name) => name.toLowerCase())); + const nextHeaders: Record = {}; + + for (const [name, value] of Object.entries(headers)) { + if (value === undefined || removed.has(name.toLowerCase())) { + continue; + } + nextHeaders[name] = value; + } + + return nextHeaders; +} + +function getHeader(headers: Record, name: string): string | undefined { + const lowerName = name.toLowerCase(); + for (const [headerName, value] of Object.entries(headers)) { + if (headerName.toLowerCase() === lowerName) { + return value; + } + } + return undefined; +} + +function chunkToBytes(chunk: unknown): Uint8Array { + const bytes = bodyToBytes(chunk); + if (bytes) { + return bytes; + } + return new TextEncoder().encode(String(chunk)); +} + +function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { + const size = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +function isNodePipeableBody(body: unknown): body is { + pipe(destination: NodeJS.WritableStream): NodeJS.ReadableStream; +} { + return ( + isObject(body) && + "pipe" in body && + typeof (body as { pipe?: unknown }).pipe === "function" + ); +} + +function isTransformableByteBody(body: unknown): body is { + transformToByteArray(): Promise; +} { + return ( + isObject(body) && + "transformToByteArray" in body && + typeof (body as { transformToByteArray?: unknown }).transformToByteArray === "function" + ); +} + +function isAsyncIterable(body: unknown): body is AsyncIterable { + return ( + isObject(body) && + Symbol.asyncIterator in body && + typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function" + ); +} + +function isObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} diff --git a/src/config.ts b/src/config.ts index 14186b3..e88ed75 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,12 +2,15 @@ import { routing } from "./routing.js"; import { normalizeLogger } from "./logger.js"; import { normalizeUserAgent } from "./user-agent.js"; import type { RoutingRule } from "./routing.js"; +import { ResponseCompressionDeflate, ResponseCompressionGzip } from "./types.js"; import type { AlternatorConnectionOptions, AlternatorDynamoDBClientConfig, AlternatorKeyRouteAffinityType, + AlternatorResponseCompression, AlternatorRuntime, NormalizedAlternatorConfig, + NormalizedResponseCompressionOptions, } from "./types.js"; const DEFAULT_ALLOWED_HEADERS = [ @@ -17,6 +20,10 @@ const DEFAULT_ALLOWED_HEADERS = [ "Accept-Encoding", "Content-Encoding", ] as const; +const DEFAULT_RESPONSE_COMPRESSION_ENCODINGS = [ + ResponseCompressionGzip, + ResponseCompressionDeflate, +] as const; export const DEFAULT_REGION = "us-east-1"; export const DEFAULT_SCHEME = "http"; @@ -48,6 +55,7 @@ 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); @@ -61,6 +69,7 @@ export function normalizeConfig(input: AlternatorDynamoDBClientConfig): Normaliz routing: routingRule, runtime, compression, + responseCompression, headerOptimization, userAgent, keyRouteAffinity, @@ -204,6 +213,56 @@ function normalizeCompression(input: AlternatorDynamoDBClientConfig["compression return normalized; } +function normalizeResponseCompression( + input: AlternatorDynamoDBClientConfig["responseCompression"], +): NormalizedResponseCompressionOptions { + if (input === undefined || input === false) { + return { enabled: false, encodings: [] }; + } + + const encodings = responseCompressionEncodings(input) + .map(normalizeResponseCompressionEncoding) + .filter((encoding, index, values) => values.indexOf(encoding) === index); + + return { + enabled: encodings.length > 0, + encodings, + }; +} + +function responseCompressionEncodings( + input: NonNullable, +): readonly unknown[] { + if (input === true) { + return DEFAULT_RESPONSE_COMPRESSION_ENCODINGS; + } + if (!isRecord(input)) { + throw new TypeError("responseCompression must be a boolean or options object"); + } + 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.enabled !== true) { + return []; + } + return input.encodings ?? DEFAULT_RESPONSE_COMPRESSION_ENCODINGS; +} + +function normalizeResponseCompressionEncoding(encoding: unknown): AlternatorResponseCompression { + switch (encoding) { + case ResponseCompressionGzip: + case ResponseCompressionDeflate: + return encoding; + default: + throw new TypeError('responseCompression encodings must be "gzip" or "deflate"'); + } +} + function normalizeHeaderOptimization(input: AlternatorDynamoDBClientConfig["headerOptimization"], noAuth: boolean) { if (typeof input === "boolean") { return { diff --git a/src/index.ts b/src/index.ts index 88ca8d0..a54a0d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ export { AlternatorDynamoDBClient } from "./client.js"; export { routing } from "./routing.js"; +export { + ResponseCompressionDeflate, + ResponseCompressionGzip, +} from "./types.js"; export type { AlternatorCompressionOptions, AlternatorConnectionOptions, @@ -11,6 +15,8 @@ export type { AlternatorLogger, AlternatorNode, AlternatorPartitionKeyByTable, + AlternatorResponseCompression, + AlternatorResponseCompressionOptions, AlternatorRuntime, AlternatorScheme, AlternatorRequestCompressor, @@ -19,6 +25,7 @@ export type { AlternatorUserAgentConfig, AlternatorUserAgentOptions, AlternatorUserAgentTransformer, + ResponseCompression, } from "./types.js"; export type { ClusterRoutingRule, diff --git a/src/middleware.ts b/src/middleware.ts index 7dfc965..9866e55 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,9 @@ import { HttpRequest } from "@smithy/protocol-http"; import type { FinalizeRequestMiddleware, HandlerExecutionContext } from "@smithy/types"; -import { compressBody } from "./compression.js"; +import { + applyResponseCompressionRequestHeaders, + compressBody, +} from "./compression.js"; import { hostForUrl } from "./config.js"; import type { AlternatorDiscovery } from "./discovery.js"; import type { KeyRouteAffinityPlanner } from "./affinity.js"; @@ -72,6 +75,13 @@ export function createAlternatorPostSigningMiddleware; +type GenericHttpHandler = HttpHandler>; export function assertRuntimeSupport(config: NormalizedAlternatorConfig): void { if (config.runtime !== "edge") { @@ -28,6 +34,9 @@ export function assertRuntimeSupport(config: NormalizedAlternatorConfig): void { if (config.compression.enabled && !config.compression.compressor && typeof CompressionStream === "undefined") { throw new Error("Alternator edge runtime gzip compression requires CompressionStream support"); } + if (config.responseCompression.enabled && typeof DecompressionStream === "undefined") { + throw new Error("Alternator edge runtime response compression requires DecompressionStream support"); + } } export function createRequestHandler( @@ -35,7 +44,7 @@ export function createRequestHandler( config: NormalizedAlternatorConfig, ): HttpHandlerUserInput { if (input.requestHandler) { - return input.requestHandler; + return withResponseCompression(input.requestHandler, config); } if (config.runtime === "edge") { @@ -48,10 +57,10 @@ export function createRequestHandler( if (config.connection?.keepAlive !== undefined) { fetchOptions.keepAlive = config.connection.keepAlive; } - return new FetchHttpHandler(fetchOptions); + return withResponseCompression(new FetchHttpHandler(fetchOptions), config); } - return new LazyNodeHttpHandler(() => buildNodeHandlerOptions(config)); + return withResponseCompression(new LazyNodeHttpHandler(() => buildNodeHandlerOptions(config)), config); } class LazyNodeHttpHandler implements Handler { @@ -104,6 +113,68 @@ class LazyNodeHttpHandler implements Handler { } } +class ResponseCompressionHttpHandler implements GenericHttpHandler { + readonly metadata: RequestHandlerMetadata; + + constructor( + private readonly delegate: GenericHttpHandler, + private readonly runtime: NormalizedAlternatorConfig["runtime"], + ) { + this.metadata = delegate.metadata ?? { handlerProtocol: "http/1.1" }; + } + + async handle( + request: HttpRequest, + options?: HttpHandlerOptions, + ): Promise<{ response: HttpResponse }> { + const result = await this.delegate.handle(request, options); + return { + response: await decompressResponse(result.response, this.runtime), + }; + } + + destroy(): void { + this.delegate.destroy?.(); + } + + updateHttpClientConfig( + key: keyof Record, + value: Record[typeof key], + ): void { + this.delegate.updateHttpClientConfig(key, value); + } + + httpHandlerConfigs(): Record { + return this.delegate.httpHandlerConfigs(); + } +} + +function withResponseCompression( + requestHandler: HttpHandlerUserInput, + config: NormalizedAlternatorConfig, +): HttpHandlerUserInput { + if (!config.responseCompression.enabled) { + return requestHandler; + } + if (!isHttpHandler(requestHandler)) { + throw new TypeError("responseCompression requires requestHandler to be an HTTP handler instance"); + } + return new ResponseCompressionHttpHandler(requestHandler, config.runtime); +} + +function isHttpHandler(requestHandler: HttpHandlerUserInput): requestHandler is GenericHttpHandler { + return ( + typeof requestHandler === "object" && + requestHandler !== null && + "handle" in requestHandler && + typeof (requestHandler as { handle?: unknown }).handle === "function" && + "updateHttpClientConfig" in requestHandler && + typeof (requestHandler as { updateHttpClientConfig?: unknown }).updateHttpClientConfig === "function" && + "httpHandlerConfigs" in requestHandler && + typeof (requestHandler as { httpHandlerConfigs?: unknown }).httpHandlerConfigs === "function" + ); +} + async function buildNodeHandlerOptions( config: NormalizedAlternatorConfig, ): Promise { diff --git a/src/types.ts b/src/types.ts index 843a85c..5bc5e6a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,20 @@ export interface AlternatorCompressionOptions { compressor?: AlternatorRequestCompressor; } +export const ResponseCompressionGzip = "gzip" as const; +export const ResponseCompressionDeflate = "deflate" as const; + +export type AlternatorResponseCompression = + | typeof ResponseCompressionGzip + | typeof ResponseCompressionDeflate; + +export type ResponseCompression = AlternatorResponseCompression; + +export interface AlternatorResponseCompressionOptions { + enabled?: boolean; + encodings?: readonly AlternatorResponseCompression[]; +} + export interface AlternatorRequestCompressorResult { readonly body: Uint8Array; readonly contentEncoding: string; @@ -112,6 +126,7 @@ export interface AlternatorDynamoDBClientConfig extends BaseDynamoDBClientConfig runtime?: AlternatorRuntime; requestHandler?: DynamoDBClientConfig["requestHandler"]; compression?: boolean | AlternatorCompressionOptions; + responseCompression?: boolean | AlternatorResponseCompressionOptions; headerOptimization?: boolean | AlternatorHeaderOptimizationOptions; userAgent?: AlternatorUserAgentConfig; keyRouteAffinity?: boolean | AlternatorKeyRouteAffinityOptions; @@ -128,6 +143,11 @@ export interface NormalizedCompressionOptions { readonly compressor?: AlternatorRequestCompressor; } +export interface NormalizedResponseCompressionOptions { + readonly enabled: boolean; + readonly encodings: readonly AlternatorResponseCompression[]; +} + export interface NormalizedHeaderOptimizationOptions { readonly enabled: boolean; readonly allowedHeaders: readonly string[]; @@ -158,6 +178,7 @@ export interface NormalizedAlternatorConfig { readonly routing: RoutingRule; readonly runtime: AlternatorRuntime; readonly compression: NormalizedCompressionOptions; + readonly responseCompression: NormalizedResponseCompressionOptions; readonly headerOptimization: NormalizedHeaderOptimizationOptions; readonly userAgent: NormalizedUserAgentOptions; readonly keyRouteAffinity: NormalizedKeyRouteAffinityOptions; diff --git a/test/config.test.ts b/test/config.test.ts index ee6d1d5..2946384 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,6 +1,10 @@ import { ListTablesCommand } from "@aws-sdk/client-dynamodb"; import { describe, expect, it } from "vitest"; -import { AlternatorDynamoDBClient } from "../src/index.js"; +import { + AlternatorDynamoDBClient, + ResponseCompressionDeflate, + ResponseCompressionGzip, +} from "../src/index.js"; import { RecordingHandler } from "./helpers.js"; describe("AlternatorDynamoDBClient config", () => { @@ -190,6 +194,62 @@ describe("AlternatorDynamoDBClient config", () => { ).toBe(-1); }); + it("normalizes and validates response compression encodings", () => { + expect( + new AlternatorDynamoDBClient({ + seeds: ["localhost"], + responseCompression: true, + }).alternatorConfig.responseCompression, + ).toEqual({ + enabled: true, + encodings: [ResponseCompressionGzip, ResponseCompressionDeflate], + }); + + expect( + new AlternatorDynamoDBClient({ + seeds: ["localhost"], + responseCompression: { + enabled: true, + encodings: [ + ResponseCompressionDeflate, + ResponseCompressionDeflate, + ResponseCompressionGzip, + ], + }, + }).alternatorConfig.responseCompression, + ).toEqual({ + enabled: true, + encodings: [ResponseCompressionDeflate, ResponseCompressionGzip], + }); + + expect( + new AlternatorDynamoDBClient({ + seeds: ["localhost"], + responseCompression: false, + }).alternatorConfig.responseCompression.enabled, + ).toBe(false); + + expect( + new AlternatorDynamoDBClient({ + seeds: ["localhost"], + responseCompression: { + encodings: [ResponseCompressionGzip], + }, + }).alternatorConfig.responseCompression.enabled, + ).toBe(false); + + expect( + () => + new AlternatorDynamoDBClient({ + seeds: ["localhost"], + responseCompression: { + enabled: true, + encodings: ["br"], + } as never, + }), + ).toThrow(/responseCompression/); + }); + it("validates key route affinity type", () => { for (const type of ["bad", "", 42]) { expect( diff --git a/test/integration-test/alternator-client.test.ts b/test/integration-test/alternator-client.test.ts index 07b2830..aa455bb 100644 --- a/test/integration-test/alternator-client.test.ts +++ b/test/integration-test/alternator-client.test.ts @@ -1,12 +1,20 @@ -import { ListTablesCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"; +import { GetItemCommand, ListTablesCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"; import { describe, expect, it } from "vitest"; -import { routing } from "../../src/index.js"; +import { + ResponseCompressionDeflate, + ResponseCompressionGzip, + routing, +} from "../../src/index.js"; import { describeIntegration, integrationConfig, integrationEndpoints } from "./config.js"; import { buildClient, captureCommandRequests, commandHeaders, + createStringHashTable, largePayload, + putStringItem, + safeDeleteTable, + uniqueTableName, } from "./helpers.js"; describeIntegration.each(integrationEndpoints())( @@ -164,6 +172,45 @@ describeIntegration.each(integrationEndpoints())( } }); + it.each([ + [ResponseCompressionGzip], + [ResponseCompressionDeflate], + ])("reads %s-compressed responses", async (encoding) => { + const tableName = uniqueTableName(`js_response_compression_${encoding}`); + const client = buildClient(endpoint, { + responseCompression: { + enabled: true, + encodings: [encoding], + }, + maxAttempts: 1, + }); + const captured = captureCommandRequests(client); + + try { + await safeDeleteTable(client, tableName); + await createStringHashTable(client, tableName); + await putStringItem(client, tableName, "123", { + data: { S: largePayload().repeat(20) }, + }); + + const response = await client.send( + new GetItemCommand({ + TableName: tableName, + Key: { + pk: { S: "123" }, + }, + ConsistentRead: true, + }), + ); + + expect(response.Item?.data?.S).toContain("This is a test value"); + expect(commandHeaders(captured, "GetItemCommand")["accept-encoding"]).toBe(encoding); + } finally { + await safeDeleteTable(client, tableName); + client.destroy(); + } + }); + it("filters wire headers using the configured whitelist", async () => { const client = buildClient(endpoint, { headerOptimization: { diff --git a/test/middleware.test.ts b/test/middleware.test.ts index c11ac5b..7955905 100644 --- a/test/middleware.test.ts +++ b/test/middleware.test.ts @@ -1,7 +1,19 @@ -import { ListTablesCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"; -import { gunzipSync } from "node:zlib"; +import { + ListTablesCommand, + PutItemCommand, + type ServiceInputTypes, + type ServiceOutputTypes, +} from "@aws-sdk/client-dynamodb"; +import { HttpRequest, HttpResponse } from "@smithy/protocol-http"; +import type { FinalizeRequestMiddleware } from "@smithy/types"; +import { Readable } from "node:stream"; +import { deflateSync, gunzipSync, gzipSync } from "node:zlib"; import { describe, expect, it, vi } from "vitest"; -import { AlternatorDynamoDBClient } from "../src/index.js"; +import { + AlternatorDynamoDBClient, + ResponseCompressionDeflate, + ResponseCompressionGzip, +} from "../src/index.js"; import { alternatorUserAgentToken } from "../src/user-agent.js"; import { commandRequests, jsonResponse, RecordingHandler } from "./helpers.js"; @@ -145,6 +157,92 @@ describe("Alternator middleware", () => { expect(json.TableName).toBe("users"); }); + it.each([ + [ResponseCompressionGzip, gzipSync], + [ResponseCompressionDeflate, deflateSync], + ])("requests and decodes %s response compression", async (encoding, compress) => { + const handler = new RecordingHandler((request) => { + if (request.path === "/localnodes") { + return ["node-a"]; + } + return compressedJsonResponse( + { + TableNames: ["compressed"], + }, + encoding, + compress, + ); + }); + const client = new AlternatorDynamoDBClient({ + seeds: ["seed"], + requestHandler: handler, + discovery: { background: false }, + responseCompression: { + enabled: true, + encodings: [encoding], + }, + }); + + const response = await client.send(new ListTablesCommand({})); + + expect(response.TableNames).toEqual(["compressed"]); + expect(commandRequests(handler)[0]?.headers["accept-encoding"]).toBe(encoding); + }); + + it("replaces identity Accept-Encoding when response compression is enabled", async () => { + const handler = new RecordingHandler(() => ({ TableNames: [] })); + const client = new AlternatorDynamoDBClient({ + seeds: ["seed"], + requestHandler: handler, + discovery: { background: false }, + responseCompression: { + enabled: true, + encodings: [ResponseCompressionGzip], + }, + }); + + const identityMiddleware: FinalizeRequestMiddleware = + (next) => (args) => { + if (HttpRequest.isInstance(args.request)) { + args.request.headers["accept-encoding"] = "identity"; + } + return next(args); + }; + + client.middlewareStack.addRelativeTo(identityMiddleware, { + relation: "before", + toMiddleware: "alternatorPostSigningMiddleware", + name: "setIdentityAcceptEncoding", + }); + + await client.send(new ListTablesCommand({})); + + expect(commandRequests(handler)[0]?.headers["accept-encoding"]).toBe(ResponseCompressionGzip); + }); + + it("adds response Accept-Encoding after SigV4 signing", async () => { + const handler = new RecordingHandler(() => ({ TableNames: [] })); + const client = new AlternatorDynamoDBClient({ + seeds: ["seed"], + requestHandler: handler, + discovery: { background: false }, + credentials: { + accessKeyId: "key", + secretAccessKey: "secret", + }, + responseCompression: { + enabled: true, + encodings: [ResponseCompressionGzip], + }, + }); + + await client.send(new ListTablesCommand({})); + + const headers = commandRequests(handler)[0]?.headers ?? {}; + expect(headers["accept-encoding"]).toBe(ResponseCompressionGzip); + expect(signedHeaderNames(headers.authorization)).not.toContain("accept-encoding"); + }); + it("uses header whitelisting when enabled", async () => { const handler = new RecordingHandler(() => ({ TableNames: [] })); const client = new AlternatorDynamoDBClient({ @@ -271,6 +369,23 @@ describe("Alternator middleware", () => { }); }); +function compressedJsonResponse( + payload: unknown, + contentEncoding: string, + compress: (input: string) => Uint8Array, +): HttpResponse { + const body = compress(JSON.stringify(payload)); + return new HttpResponse({ + statusCode: 200, + headers: { + "content-type": "application/x-amz-json-1.0", + "content-encoding": contentEncoding, + "content-length": String(body.byteLength), + }, + body: Readable.from([body]), + }); +} + function signedHeaderNames(authorization: string | undefined): string[] { const match = authorization?.match(/(?:^|,\s*)SignedHeaders=([^,\s]+)/); return match?.[1]?.split(";").filter(Boolean) ?? []; diff --git a/test/type-usage.test.ts b/test/type-usage.test.ts index 99e3250..06815a6 100644 --- a/test/type-usage.test.ts +++ b/test/type-usage.test.ts @@ -5,7 +5,12 @@ import { } from "@aws-sdk/client-dynamodb"; import { PutCommand } from "@aws-sdk/lib-dynamodb"; import { describe, expectTypeOf, it } from "vitest"; -import { AlternatorDynamoDBClient, routing } from "../src/index.js"; +import { + AlternatorDynamoDBClient, + ResponseCompressionDeflate, + ResponseCompressionGzip, + routing, +} from "../src/index.js"; import { AlternatorDynamoDBDocumentClient } from "../src/document.js"; import { RecordingHandler } from "./helpers.js"; @@ -27,6 +32,10 @@ describe("public type usage", () => { enabled: true, gzipLevel: -1, }, + responseCompression: { + enabled: true, + encodings: [ResponseCompressionGzip, ResponseCompressionDeflate], + }, userAgent: (userAgent) => `${userAgent} app/1.0.0`, keyRouteAffinity: { type: "read-before-write",