From 2a58636131f0b79972cf9e0cc6ba66cb5c78d61b Mon Sep 17 00:00:00 2001 From: svozza Date: Sat, 8 Nov 2025 23:53:58 +0000 Subject: [PATCH] feat(event-handler): add first-class support for binary responses --- packages/event-handler/src/rest/Router.ts | 40 +++++-- packages/event-handler/src/rest/converters.ts | 89 +++++++-------- packages/event-handler/src/rest/utils.ts | 56 +++++++++ packages/event-handler/src/types/rest.ts | 17 ++- .../unit/rest/Router/basic-routing.test.ts | 107 ++++++++++++++++++ .../unit/rest/Router/error-handling.test.ts | 40 +++++++ .../tests/unit/rest/converters.test.ts | 12 +- 7 files changed, 295 insertions(+), 66 deletions(-) diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts index 9687afdcf5..d06964f3c3 100644 --- a/packages/event-handler/src/rest/Router.ts +++ b/packages/event-handler/src/rest/Router.ts @@ -48,9 +48,12 @@ import { Route } from './Route.js'; import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; import { composeMiddleware, + getBase64EncodingFromHeaders, + getBase64EncodingFromResult, HttpResponseStream, isAPIGatewayProxyEventV1, isAPIGatewayProxyEventV2, + isBinaryResult, isExtendedAPIGatewayProxyResult, resolvePrefixedPath, } from './utils.js'; @@ -260,29 +263,27 @@ class Router { const route = this.routeRegistry.resolve(method, path); const handlerMiddleware: Middleware = async ({ reqCtx, next }) => { + let handlerRes: HandlerResponse; if (route === null) { - const notFoundRes = await this.handleError( + handlerRes = await this.handleError( new NotFoundError(`Route ${path} for method ${method} not found`), { ...reqCtx, scope: options?.scope } ); - reqCtx.res = handlerResultToWebResponse( - notFoundRes, - reqCtx.res.headers - ); } else { const handler = options?.scope == null ? route.handler : route.handler.bind(options.scope); - const handlerResult = await handler(reqCtx); + handlerRes = await handler(reqCtx); + } - reqCtx.res = handlerResultToWebResponse( - handlerResult, - reqCtx.res.headers - ); + if (getBase64EncodingFromResult(handlerRes)) { + reqCtx.isBase64Encoded = true; } + reqCtx.res = handlerResultToWebResponse(handlerRes, reqCtx.res.headers); + await next(); }; @@ -313,10 +314,16 @@ class Router { ...requestContext, scope: options?.scope, }); + + if (getBase64EncodingFromResult(res)) { + requestContext.isBase64Encoded = true; + } + requestContext.res = handlerResultToWebResponse( res, requestContext.res.headers ); + return requestContext; } } @@ -353,7 +360,12 @@ class Router { options?: ResolveOptions ): Promise { const reqCtx = await this.#resolve(event, context, options); - return webResponseToProxyResult(reqCtx.res, reqCtx.responseType); + const isBase64Encoded = + reqCtx.isBase64Encoded ?? + getBase64EncodingFromHeaders(reqCtx.res.headers); + return webResponseToProxyResult(reqCtx.res, reqCtx.responseType, { + isBase64Encoded, + }); } /** @@ -434,7 +446,11 @@ class Router { try { const { scope, ...reqCtx } = options; const body = await handler.apply(scope ?? this, [error, reqCtx]); - if (body instanceof Response || isExtendedAPIGatewayProxyResult(body)) { + if ( + body instanceof Response || + isExtendedAPIGatewayProxyResult(body) || + isBinaryResult(body) + ) { return body; } if (!body.statusCode) { diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index bed552cff7..c210f75ae7 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -7,18 +7,18 @@ import type { APIGatewayProxyStructuredResultV2, } from 'aws-lambda'; import type { - CompressionOptions, ExtendedAPIGatewayProxyResult, ExtendedAPIGatewayProxyResultBody, HandlerResponse, ResponseType, ResponseTypeMap, V1Headers, + WebResponseToProxyResultOptions, } from '../types/rest.js'; -import { COMPRESSION_ENCODING_TYPES } from './constants.js'; import { InvalidHttpMethodError } from './errors.js'; import { isAPIGatewayProxyEventV2, + isBinaryResult, isExtendedAPIGatewayProxyResult, isHttpMethod, isNodeReadableStream, @@ -213,41 +213,29 @@ const webHeadersToApiGatewayHeaders = ( : { headers: Record }; }; +const responseBodyToBase64 = async (response: Response) => { + const buffer = await response.arrayBuffer(); + return Buffer.from(buffer).toString('base64'); +}; + /** * Converts a Web API Response object to an API Gateway V1 proxy result. * * @param response - The Web API Response object + * @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content) * @returns An API Gateway V1 proxy result */ const webResponseToProxyResultV1 = async ( - response: Response + response: Response, + isBase64Encoded?: boolean ): Promise => { const { headers, multiValueHeaders } = webHeadersToApiGatewayV1Headers( response.headers ); - // Check if response contains compressed/binary content - const contentEncoding = response.headers.get( - 'content-encoding' - ) as CompressionOptions['encoding']; - let body: string; - let isBase64Encoded = false; - - if ( - contentEncoding && - [ - COMPRESSION_ENCODING_TYPES.GZIP, - COMPRESSION_ENCODING_TYPES.DEFLATE, - ].includes(contentEncoding) - ) { - // For compressed content, get as buffer and encode to base64 - const buffer = await response.arrayBuffer(); - body = Buffer.from(buffer).toString('base64'); - isBase64Encoded = true; - } else { - // For text content, use text() - body = await response.text(); - } + const body = isBase64Encoded + ? await responseBodyToBase64(response) + : await response.text(); const result: APIGatewayProxyResult = { statusCode: response.status, @@ -267,10 +255,12 @@ const webResponseToProxyResultV1 = async ( * Converts a Web API Response object to an API Gateway V2 proxy result. * * @param response - The Web API Response object + * @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content) * @returns An API Gateway V2 proxy result */ const webResponseToProxyResultV2 = async ( - response: Response + response: Response, + isBase64Encoded?: boolean ): Promise => { const headers: Record = {}; const cookies: string[] = []; @@ -283,25 +273,9 @@ const webResponseToProxyResultV2 = async ( } } - const contentEncoding = response.headers.get( - 'content-encoding' - ) as CompressionOptions['encoding']; - let body: string; - let isBase64Encoded = false; - - if ( - contentEncoding && - [ - COMPRESSION_ENCODING_TYPES.GZIP, - COMPRESSION_ENCODING_TYPES.DEFLATE, - ].includes(contentEncoding) - ) { - const buffer = await response.arrayBuffer(); - body = Buffer.from(buffer).toString('base64'); - isBase64Encoded = true; - } else { - body = await response.text(); - } + const body = isBase64Encoded + ? await responseBodyToBase64(response) + : await response.text(); const result: APIGatewayProxyStructuredResultV2 = { statusCode: response.status, @@ -319,12 +293,18 @@ const webResponseToProxyResultV2 = async ( const webResponseToProxyResult = ( response: Response, - responseType: T + responseType: T, + options?: WebResponseToProxyResultOptions ): Promise => { + const isBase64Encoded = options?.isBase64Encoded ?? false; if (responseType === 'ApiGatewayV1') { - return webResponseToProxyResultV1(response) as Promise; + return webResponseToProxyResultV1(response, isBase64Encoded) as Promise< + ResponseTypeMap[T] + >; } - return webResponseToProxyResultV2(response) as Promise; + return webResponseToProxyResultV2(response, isBase64Encoded) as Promise< + ResponseTypeMap[T] + >; }; /** @@ -385,6 +365,19 @@ const handlerResultToWebResponse = ( } const headers = new Headers(resHeaders); + + if (isBinaryResult(response)) { + const body = + response instanceof Readable + ? (Readable.toWeb(response) as ReadableStream) + : response; + + return new Response(body, { + status: 200, + headers, + }); + } + headers.set('Content-Type', 'application/json'); if (isExtendedAPIGatewayProxyResult(response)) { diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 1b7c197171..4351bd07e1 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -7,6 +7,7 @@ import { import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda'; import type { CompiledRoute, + CompressionOptions, ExtendedAPIGatewayProxyResult, HandlerResponse, HttpMethod, @@ -16,6 +17,7 @@ import type { ValidationResult, } from '../types/rest.js'; import { + COMPRESSION_ENCODING_TYPES, HttpVerbs, PARAM_PATTERN, SAFE_CHARS, @@ -156,6 +158,16 @@ export const isWebReadableStream = ( ); }; +export const isBinaryResult = ( + value: unknown +): value is ArrayBuffer | Readable | ReadableStream => { + return ( + value instanceof ArrayBuffer || + isNodeReadableStream(value) || + isWebReadableStream(value) + ); +}; + /** * Type guard to check if the provided result is an API Gateway Proxy result. * @@ -318,3 +330,47 @@ export const HttpResponseStream = return underlyingStream; } }; + +export const getBase64EncodingFromResult = (result: HandlerResponse) => { + if (isBinaryResult(result)) { + return true; + } + if (isExtendedAPIGatewayProxyResult(result)) { + return ( + result.body instanceof ArrayBuffer || + isNodeReadableStream(result.body) || + isWebReadableStream(result.body) + ); + } + return false; +}; + +export const getBase64EncodingFromHeaders = (headers: Headers): boolean => { + const contentEncoding = headers.get( + 'content-encoding' + ) as CompressionOptions['encoding']; + + if ( + contentEncoding != null && + [ + COMPRESSION_ENCODING_TYPES.GZIP, + COMPRESSION_ENCODING_TYPES.DEFLATE, + ].includes(contentEncoding) + ) { + return true; + } + + const contentType = headers.get('content-type'); + if (contentType != null) { + const type = contentType.split(';')[0].trim(); + if ( + type.startsWith('image/') || + type.startsWith('audio/') || + type.startsWith('video/') + ) { + return true; + } + } + + return false; +}; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index ceb4c9a3ee..c5652d0e70 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -29,6 +29,7 @@ type RequestContext = { res: Response; params: Record; responseType: ResponseType; + isBase64Encoded?: boolean; }; type ErrorResolveOptions = RequestContext & ResolveOptions; @@ -69,14 +70,20 @@ interface CompiledRoute { type DynamicRoute = Route & CompiledRoute; -type ExtendedAPIGatewayProxyResultBody = string | Readable | ReadableStream; +type BinaryResult = ArrayBuffer | Readable | ReadableStream; + +type ExtendedAPIGatewayProxyResultBody = BinaryResult | string; type ExtendedAPIGatewayProxyResult = Omit & { body: ExtendedAPIGatewayProxyResultBody; cookies?: string[]; }; -type HandlerResponse = Response | JSONObject | ExtendedAPIGatewayProxyResult; +type HandlerResponse = + | Response + | JSONObject + | ExtendedAPIGatewayProxyResult + | BinaryResult; type RouteHandler = ( reqCtx: RequestContext @@ -230,7 +237,12 @@ type CompressionOptions = { threshold?: number; }; +type WebResponseToProxyResultOptions = { + isBase64Encoded?: boolean; +}; + export type { + BinaryResult, ExtendedAPIGatewayProxyResult, ExtendedAPIGatewayProxyResultBody, CompiledRoute, @@ -259,4 +271,5 @@ export type { CompressionOptions, NextFunction, V1Headers, + WebResponseToProxyResultOptions, }; diff --git a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts index 555c33c6e8..01695dda7d 100644 --- a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts @@ -1,3 +1,4 @@ +import { Readable } from 'node:stream'; import context from '@aws-lambda-powertools/testing-utils/context'; import { describe, expect, it, vi } from 'vitest'; import { InvalidEventError } from '../../../../src/rest/errors.js'; @@ -273,3 +274,109 @@ describe('Class: Router - V2 Cookies Support', () => { }); }); }); + +describe.each([ + { version: 'V1', createEvent: createTestEvent }, + { version: 'V2', createEvent: createTestEventV2 }, +])('Class: Router - Binary Result ($version)', ({ createEvent }) => { + it('handles ArrayBuffer as direct return type', async () => { + // Prepare + const app = new Router(); + const { buffer } = new TextEncoder().encode('binary data'); + app.get('/binary', () => buffer); + + // Act + const result = await app.resolve(createEvent('/binary', 'GET'), context); + + // Assess + expect(result.body).toBe(Buffer.from(buffer).toString('base64')); + expect(result.isBase64Encoded).toBe(true); + }); + + it('handles Readable stream as direct return type', async () => { + // Prepare + const app = new Router(); + const data = Buffer.concat([Buffer.from('chunk1'), Buffer.from('chunk2')]); + const stream = Readable.from([ + Buffer.from('chunk1'), + Buffer.from('chunk2'), + ]); + app.get('/stream', () => stream); + + // Act + const result = await app.resolve(createEvent('/stream', 'GET'), context); + + // Assess + expect(result.body).toBe(data.toString('base64')); + expect(result.isBase64Encoded).toBe(true); + }); + + it('handles ReadableStream as direct return type', async () => { + // Prepare + const app = new Router(); + const data = new TextEncoder().encode('data'); + const webStream = new ReadableStream({ + start(controller) { + controller.enqueue(data); + controller.close(); + }, + }); + app.get('/webstream', () => webStream); + + // Act + const result = await app.resolve(createEvent('/webstream', 'GET'), context); + + // Assess + expect(result.body).toBe(Buffer.from(data).toString('base64')); + expect(result.isBase64Encoded).toBe(true); + }); + + it.each([['image/png'], ['image/jpeg'], ['audio/mpeg'], ['video/mp4']])( + 'sets isBase64Encoded for %s content-type', + async (contentType) => { + // Prepare + const app = new Router(); + app.get( + '/media', + () => + new Response('binary data', { + headers: { 'content-type': contentType }, + }) + ); + + // Act + const result = await app.resolve(createEvent('/media', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: Buffer.from('binary data').toString('base64'), + headers: { 'content-type': contentType }, + isBase64Encoded: true, + }); + } + ); + + it('does not set isBase64Encoded for text content-types', async () => { + // Prepare + const app = new Router(); + app.get( + '/text', + () => + new Response('text data', { + headers: { 'content-type': 'text/plain' }, + }) + ); + + // Act + const result = await app.resolve(createEvent('/text', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: 'text data', + headers: { 'content-type': 'text/plain' }, + isBase64Encoded: false, + }); + }); +}); diff --git a/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts b/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts index 6e3ac82e66..b6af2525d4 100644 --- a/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts @@ -587,6 +587,46 @@ describe.each([ errorName: 'Error', }); }); + + it('handles BinaryResult from error handlers', async () => { + // Prepare + const app = new Router(); + const { buffer } = new TextEncoder().encode('error binary data'); + + class CustomError extends Error {} + + app.errorHandler(CustomError, async () => buffer); + app.get('/error', () => { + throw new CustomError('test error'); + }); + + // Act + const result = await app.resolve(createEvent('/error', 'GET'), context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.isBase64Encoded).toBe(true); + expect(result.body).toBe(Buffer.from(buffer).toString('base64')); + }); + + it('sets isBase64Encoded when notFound handler returns BinaryResult', async () => { + // Prepare + const app = new Router(); + const buffer = new TextEncoder().encode('not found binary'); + + app.notFound(async () => buffer.buffer); + + // Act + const result = await app.resolve( + createEvent('/nonexistent', 'GET'), + context + ); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.isBase64Encoded).toBe(true); + expect(result.body).toBe(Buffer.from(buffer.buffer).toString('base64')); + }); }); describe('Class: Router - proxyEventToWebRequest Error Handling', () => { beforeEach(() => { diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 050a5731d4..ffc735e834 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -641,7 +641,7 @@ describe('Converters', () => { expect(result.body).toBe(''); }); - it('handles compressed response body', async () => { + it('respects isBase64Encoded option', async () => { // Prepare const response = new Response('Hello World', { status: 200, @@ -651,7 +651,9 @@ describe('Converters', () => { }); // Act - const result = await webResponseToProxyResult(response, 'ApiGatewayV1'); + const result = await webResponseToProxyResult(response, 'ApiGatewayV1', { + isBase64Encoded: true, + }); // Assess expect(result.isBase64Encoded).toBe(true); @@ -763,7 +765,7 @@ describe('Converters', () => { expect(result.body).toBe(''); }); - it('handles compressed response body', async () => { + it('respects isBase64Encoded option', async () => { // Prepare const response = new Response('Hello World', { status: 200, @@ -773,7 +775,9 @@ describe('Converters', () => { }); // Act - const result = await webResponseToProxyResult(response, 'ApiGatewayV2'); + const result = await webResponseToProxyResult(response, 'ApiGatewayV2', { + isBase64Encoded: true, + }); // Assess expect(result.isBase64Encoded).toBe(true);