Skip to content

Commit 13dbcdc

Browse files
authored
feat(event-handler): add first-class support for binary responses (#4723)
1 parent 2f70018 commit 13dbcdc

File tree

8 files changed

+349
-89
lines changed

8 files changed

+349
-89
lines changed

packages/event-handler/src/rest/Router.ts

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ import { Route } from './Route.js';
4848
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
4949
import {
5050
composeMiddleware,
51+
getBase64EncodingFromHeaders,
52+
getBase64EncodingFromResult,
53+
getStatusCode,
5154
HttpResponseStream,
5255
isAPIGatewayProxyEventV1,
5356
isAPIGatewayProxyEventV2,
57+
isBinaryResult,
5458
isExtendedAPIGatewayProxyResult,
5559
resolvePrefixedPath,
5660
} from './utils.js';
@@ -260,29 +264,30 @@ class Router {
260264
const route = this.routeRegistry.resolve(method, path);
261265

262266
const handlerMiddleware: Middleware = async ({ reqCtx, next }) => {
267+
let handlerRes: HandlerResponse;
263268
if (route === null) {
264-
const notFoundRes = await this.handleError(
269+
handlerRes = await this.handleError(
265270
new NotFoundError(`Route ${path} for method ${method} not found`),
266271
{ ...reqCtx, scope: options?.scope }
267272
);
268-
reqCtx.res = handlerResultToWebResponse(
269-
notFoundRes,
270-
reqCtx.res.headers
271-
);
272273
} else {
273274
const handler =
274275
options?.scope == null
275276
? route.handler
276277
: route.handler.bind(options.scope);
277278

278-
const handlerResult = await handler(reqCtx);
279+
handlerRes = await handler(reqCtx);
280+
}
279281

280-
reqCtx.res = handlerResultToWebResponse(
281-
handlerResult,
282-
reqCtx.res.headers
283-
);
282+
if (getBase64EncodingFromResult(handlerRes)) {
283+
reqCtx.isBase64Encoded = true;
284284
}
285285

286+
reqCtx.res = handlerResultToWebResponse(handlerRes, {
287+
statusCode: getStatusCode(handlerRes),
288+
resHeaders: reqCtx.res.headers,
289+
});
290+
286291
await next();
287292
};
288293

@@ -300,10 +305,10 @@ class Router {
300305

301306
// middleware result takes precedence to allow short-circuiting
302307
if (middlewareResult !== undefined) {
303-
requestContext.res = handlerResultToWebResponse(
304-
middlewareResult,
305-
requestContext.res.headers
306-
);
308+
requestContext.res = handlerResultToWebResponse(middlewareResult, {
309+
statusCode: getStatusCode(middlewareResult),
310+
resHeaders: requestContext.res.headers,
311+
});
307312
}
308313

309314
return requestContext;
@@ -313,10 +318,16 @@ class Router {
313318
...requestContext,
314319
scope: options?.scope,
315320
});
316-
requestContext.res = handlerResultToWebResponse(
317-
res,
318-
requestContext.res.headers
319-
);
321+
322+
if (getBase64EncodingFromResult(res)) {
323+
requestContext.isBase64Encoded = true;
324+
}
325+
326+
requestContext.res = handlerResultToWebResponse(res, {
327+
statusCode: getStatusCode(res, HttpStatusCodes.INTERNAL_SERVER_ERROR),
328+
resHeaders: requestContext.res.headers,
329+
});
330+
320331
return requestContext;
321332
}
322333
}
@@ -353,7 +364,12 @@ class Router {
353364
options?: ResolveOptions
354365
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2> {
355366
const reqCtx = await this.#resolve(event, context, options);
356-
return webResponseToProxyResult(reqCtx.res, reqCtx.responseType);
367+
const isBase64Encoded =
368+
reqCtx.isBase64Encoded ??
369+
getBase64EncodingFromHeaders(reqCtx.res.headers);
370+
return webResponseToProxyResult(reqCtx.res, reqCtx.responseType, {
371+
isBase64Encoded,
372+
});
357373
}
358374

359375
/**
@@ -434,7 +450,11 @@ class Router {
434450
try {
435451
const { scope, ...reqCtx } = options;
436452
const body = await handler.apply(scope ?? this, [error, reqCtx]);
437-
if (body instanceof Response || isExtendedAPIGatewayProxyResult(body)) {
453+
if (
454+
body instanceof Response ||
455+
isExtendedAPIGatewayProxyResult(body) ||
456+
isBinaryResult(body)
457+
) {
438458
return body;
439459
}
440460
if (!body.statusCode) {

packages/event-handler/src/rest/converters.ts

Lines changed: 48 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,20 @@ import type {
77
APIGatewayProxyStructuredResultV2,
88
} from 'aws-lambda';
99
import type {
10-
CompressionOptions,
1110
ExtendedAPIGatewayProxyResult,
1211
ExtendedAPIGatewayProxyResultBody,
1312
HandlerResponse,
13+
HttpStatusCode,
1414
ResponseType,
1515
ResponseTypeMap,
1616
V1Headers,
17+
WebResponseToProxyResultOptions,
1718
} from '../types/rest.js';
18-
import { COMPRESSION_ENCODING_TYPES } from './constants.js';
19+
import { HttpStatusCodes } from './constants.js';
1920
import { InvalidHttpMethodError } from './errors.js';
2021
import {
2122
isAPIGatewayProxyEventV2,
23+
isBinaryResult,
2224
isExtendedAPIGatewayProxyResult,
2325
isHttpMethod,
2426
isNodeReadableStream,
@@ -213,41 +215,29 @@ const webHeadersToApiGatewayHeaders = <T extends ResponseType>(
213215
: { headers: Record<string, string> };
214216
};
215217

218+
const responseBodyToBase64 = async (response: Response) => {
219+
const buffer = await response.arrayBuffer();
220+
return Buffer.from(buffer).toString('base64');
221+
};
222+
216223
/**
217224
* Converts a Web API Response object to an API Gateway V1 proxy result.
218225
*
219226
* @param response - The Web API Response object
227+
* @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content)
220228
* @returns An API Gateway V1 proxy result
221229
*/
222230
const webResponseToProxyResultV1 = async (
223-
response: Response
231+
response: Response,
232+
isBase64Encoded?: boolean
224233
): Promise<APIGatewayProxyResult> => {
225234
const { headers, multiValueHeaders } = webHeadersToApiGatewayV1Headers(
226235
response.headers
227236
);
228237

229-
// Check if response contains compressed/binary content
230-
const contentEncoding = response.headers.get(
231-
'content-encoding'
232-
) as CompressionOptions['encoding'];
233-
let body: string;
234-
let isBase64Encoded = false;
235-
236-
if (
237-
contentEncoding &&
238-
[
239-
COMPRESSION_ENCODING_TYPES.GZIP,
240-
COMPRESSION_ENCODING_TYPES.DEFLATE,
241-
].includes(contentEncoding)
242-
) {
243-
// For compressed content, get as buffer and encode to base64
244-
const buffer = await response.arrayBuffer();
245-
body = Buffer.from(buffer).toString('base64');
246-
isBase64Encoded = true;
247-
} else {
248-
// For text content, use text()
249-
body = await response.text();
250-
}
238+
const body = isBase64Encoded
239+
? await responseBodyToBase64(response)
240+
: await response.text();
251241

252242
const result: APIGatewayProxyResult = {
253243
statusCode: response.status,
@@ -267,10 +257,12 @@ const webResponseToProxyResultV1 = async (
267257
* Converts a Web API Response object to an API Gateway V2 proxy result.
268258
*
269259
* @param response - The Web API Response object
260+
* @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content)
270261
* @returns An API Gateway V2 proxy result
271262
*/
272263
const webResponseToProxyResultV2 = async (
273-
response: Response
264+
response: Response,
265+
isBase64Encoded?: boolean
274266
): Promise<APIGatewayProxyStructuredResultV2> => {
275267
const headers: Record<string, string> = {};
276268
const cookies: string[] = [];
@@ -283,25 +275,9 @@ const webResponseToProxyResultV2 = async (
283275
}
284276
}
285277

286-
const contentEncoding = response.headers.get(
287-
'content-encoding'
288-
) as CompressionOptions['encoding'];
289-
let body: string;
290-
let isBase64Encoded = false;
291-
292-
if (
293-
contentEncoding &&
294-
[
295-
COMPRESSION_ENCODING_TYPES.GZIP,
296-
COMPRESSION_ENCODING_TYPES.DEFLATE,
297-
].includes(contentEncoding)
298-
) {
299-
const buffer = await response.arrayBuffer();
300-
body = Buffer.from(buffer).toString('base64');
301-
isBase64Encoded = true;
302-
} else {
303-
body = await response.text();
304-
}
278+
const body = isBase64Encoded
279+
? await responseBodyToBase64(response)
280+
: await response.text();
305281

306282
const result: APIGatewayProxyStructuredResultV2 = {
307283
statusCode: response.status,
@@ -319,12 +295,18 @@ const webResponseToProxyResultV2 = async (
319295

320296
const webResponseToProxyResult = <T extends ResponseType>(
321297
response: Response,
322-
responseType: T
298+
responseType: T,
299+
options?: WebResponseToProxyResultOptions
323300
): Promise<ResponseTypeMap[T]> => {
301+
const isBase64Encoded = options?.isBase64Encoded ?? false;
324302
if (responseType === 'ApiGatewayV1') {
325-
return webResponseToProxyResultV1(response) as Promise<ResponseTypeMap[T]>;
303+
return webResponseToProxyResultV1(response, isBase64Encoded) as Promise<
304+
ResponseTypeMap[T]
305+
>;
326306
}
327-
return webResponseToProxyResultV2(response) as Promise<ResponseTypeMap[T]>;
307+
return webResponseToProxyResultV2(response, isBase64Encoded) as Promise<
308+
ResponseTypeMap[T]
309+
>;
328310
};
329311

330312
/**
@@ -365,13 +347,15 @@ function addProxyEventHeaders(
365347
* Handles APIGatewayProxyResult, Response objects, and plain objects.
366348
*
367349
* @param response - The handler response (APIGatewayProxyResult, Response, or plain object)
368-
* @param resHeaders - Optional headers to be included in the response
350+
* @param options - Optional configuration with statusCode and resHeaders
369351
* @returns A Web API Response object
370352
*/
371353
const handlerResultToWebResponse = (
372354
response: HandlerResponse,
373-
resHeaders?: Headers
355+
options?: { statusCode?: HttpStatusCode; resHeaders?: Headers }
374356
): Response => {
357+
const statusCode = options?.statusCode ?? HttpStatusCodes.OK;
358+
const resHeaders = options?.resHeaders;
375359
if (response instanceof Response) {
376360
if (resHeaders === undefined) return response;
377361
const headers = new Headers(resHeaders);
@@ -385,6 +369,19 @@ const handlerResultToWebResponse = (
385369
}
386370

387371
const headers = new Headers(resHeaders);
372+
373+
if (isBinaryResult(response)) {
374+
const body =
375+
response instanceof Readable
376+
? (Readable.toWeb(response) as ReadableStream)
377+
: response;
378+
379+
return new Response(body, {
380+
status: statusCode,
381+
headers,
382+
});
383+
}
384+
388385
headers.set('Content-Type', 'application/json');
389386

390387
if (isExtendedAPIGatewayProxyResult(response)) {
@@ -400,7 +397,7 @@ const handlerResultToWebResponse = (
400397
headers,
401398
});
402399
}
403-
return Response.json(response, { headers });
400+
return Response.json(response, { headers, status: statusCode });
404401
};
405402

406403
/**

packages/event-handler/src/rest/utils.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ import {
77
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';
88
import type {
99
CompiledRoute,
10+
CompressionOptions,
1011
ExtendedAPIGatewayProxyResult,
1112
HandlerResponse,
1213
HttpMethod,
14+
HttpStatusCode,
1315
Middleware,
1416
Path,
1517
ResponseStream,
1618
ValidationResult,
1719
} from '../types/rest.js';
1820
import {
21+
COMPRESSION_ENCODING_TYPES,
22+
HttpStatusCodes,
1923
HttpVerbs,
2024
PARAM_PATTERN,
2125
SAFE_CHARS,
@@ -156,6 +160,16 @@ export const isWebReadableStream = (
156160
);
157161
};
158162

163+
export const isBinaryResult = (
164+
value: unknown
165+
): value is ArrayBuffer | Readable | ReadableStream => {
166+
return (
167+
value instanceof ArrayBuffer ||
168+
isNodeReadableStream(value) ||
169+
isWebReadableStream(value)
170+
);
171+
};
172+
159173
/**
160174
* Type guard to check if the provided result is an API Gateway Proxy result.
161175
*
@@ -318,3 +332,56 @@ export const HttpResponseStream =
318332
return underlyingStream;
319333
}
320334
};
335+
336+
export const getBase64EncodingFromResult = (result: HandlerResponse) => {
337+
if (isBinaryResult(result)) {
338+
return true;
339+
}
340+
if (isExtendedAPIGatewayProxyResult(result)) {
341+
return isBinaryResult(result);
342+
}
343+
return false;
344+
};
345+
346+
export const getBase64EncodingFromHeaders = (headers: Headers): boolean => {
347+
const contentEncoding = headers.get(
348+
'content-encoding'
349+
) as CompressionOptions['encoding'];
350+
351+
if (
352+
contentEncoding != null &&
353+
[
354+
COMPRESSION_ENCODING_TYPES.GZIP,
355+
COMPRESSION_ENCODING_TYPES.DEFLATE,
356+
].includes(contentEncoding)
357+
) {
358+
return true;
359+
}
360+
361+
const contentType = headers.get('content-type');
362+
if (contentType != null) {
363+
const type = contentType.split(';')[0].trim();
364+
if (
365+
type.startsWith('image/') ||
366+
type.startsWith('audio/') ||
367+
type.startsWith('video/')
368+
) {
369+
return true;
370+
}
371+
}
372+
373+
return false;
374+
};
375+
376+
export const getStatusCode = (
377+
result: HandlerResponse,
378+
fallback: HttpStatusCode = HttpStatusCodes.OK
379+
): HttpStatusCode => {
380+
if (result instanceof Response) {
381+
return result.status as HttpStatusCode;
382+
}
383+
if (isExtendedAPIGatewayProxyResult(result)) {
384+
return result.statusCode as HttpStatusCode;
385+
}
386+
return fallback;
387+
};

0 commit comments

Comments
 (0)