Skip to content

Commit eda40f8

Browse files
committed
feat(event-handler): add first-class support for binary responses
1 parent 2f70018 commit eda40f8

File tree

7 files changed

+229
-66
lines changed

7 files changed

+229
-66
lines changed

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ import { Route } from './Route.js';
4848
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
4949
import {
5050
composeMiddleware,
51+
getBase64EncodingFromHeaders,
52+
getBase64EncodingFromResult,
5153
HttpResponseStream,
5254
isAPIGatewayProxyEventV1,
5355
isAPIGatewayProxyEventV2,
56+
isBinaryResult,
5457
isExtendedAPIGatewayProxyResult,
5558
resolvePrefixedPath,
5659
} from './utils.js';
@@ -260,29 +263,27 @@ class Router {
260263
const route = this.routeRegistry.resolve(method, path);
261264

262265
const handlerMiddleware: Middleware = async ({ reqCtx, next }) => {
266+
let handlerRes: HandlerResponse;
263267
if (route === null) {
264-
const notFoundRes = await this.handleError(
268+
handlerRes = await this.handleError(
265269
new NotFoundError(`Route ${path} for method ${method} not found`),
266270
{ ...reqCtx, scope: options?.scope }
267271
);
268-
reqCtx.res = handlerResultToWebResponse(
269-
notFoundRes,
270-
reqCtx.res.headers
271-
);
272272
} else {
273273
const handler =
274274
options?.scope == null
275275
? route.handler
276276
: route.handler.bind(options.scope);
277277

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

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

285+
reqCtx.res = handlerResultToWebResponse(handlerRes, reqCtx.res.headers);
286+
286287
await next();
287288
};
288289

@@ -313,10 +314,16 @@ class Router {
313314
...requestContext,
314315
scope: options?.scope,
315316
});
317+
318+
if (getBase64EncodingFromResult(res)) {
319+
requestContext.isBase64Encoded = true;
320+
}
321+
316322
requestContext.res = handlerResultToWebResponse(
317323
res,
318324
requestContext.res.headers
319325
);
326+
320327
return requestContext;
321328
}
322329
}
@@ -353,7 +360,12 @@ class Router {
353360
options?: ResolveOptions
354361
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2> {
355362
const reqCtx = await this.#resolve(event, context, options);
356-
return webResponseToProxyResult(reqCtx.res, reqCtx.responseType);
363+
const isBase64Encoded =
364+
reqCtx.isBase64Encoded ??
365+
getBase64EncodingFromHeaders(reqCtx.res.headers);
366+
return webResponseToProxyResult(reqCtx.res, reqCtx.responseType, {
367+
isBase64Encoded,
368+
});
357369
}
358370

359371
/**
@@ -434,7 +446,11 @@ class Router {
434446
try {
435447
const { scope, ...reqCtx } = options;
436448
const body = await handler.apply(scope ?? this, [error, reqCtx]);
437-
if (body instanceof Response || isExtendedAPIGatewayProxyResult(body)) {
449+
if (
450+
body instanceof Response ||
451+
isExtendedAPIGatewayProxyResult(body) ||
452+
isBinaryResult(body)
453+
) {
438454
return body;
439455
}
440456
if (!body.statusCode) {

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

Lines changed: 41 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@ import type {
77
APIGatewayProxyStructuredResultV2,
88
} from 'aws-lambda';
99
import type {
10-
CompressionOptions,
1110
ExtendedAPIGatewayProxyResult,
1211
ExtendedAPIGatewayProxyResultBody,
1312
HandlerResponse,
1413
ResponseType,
1514
ResponseTypeMap,
1615
V1Headers,
16+
WebResponseToProxyResultOptions,
1717
} from '../types/rest.js';
18-
import { COMPRESSION_ENCODING_TYPES } from './constants.js';
1918
import { InvalidHttpMethodError } from './errors.js';
2019
import {
2120
isAPIGatewayProxyEventV2,
21+
isBinaryResult,
2222
isExtendedAPIGatewayProxyResult,
2323
isHttpMethod,
2424
isNodeReadableStream,
@@ -213,41 +213,29 @@ const webHeadersToApiGatewayHeaders = <T extends ResponseType>(
213213
: { headers: Record<string, string> };
214214
};
215215

216+
const responseBodyToBase64 = async (response: Response) => {
217+
const buffer = await response.arrayBuffer();
218+
return Buffer.from(buffer).toString('base64');
219+
};
220+
216221
/**
217222
* Converts a Web API Response object to an API Gateway V1 proxy result.
218223
*
219224
* @param response - The Web API Response object
225+
* @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content)
220226
* @returns An API Gateway V1 proxy result
221227
*/
222228
const webResponseToProxyResultV1 = async (
223-
response: Response
229+
response: Response,
230+
isBase64Encoded?: boolean
224231
): Promise<APIGatewayProxyResult> => {
225232
const { headers, multiValueHeaders } = webHeadersToApiGatewayV1Headers(
226233
response.headers
227234
);
228235

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-
}
236+
const body = isBase64Encoded
237+
? await responseBodyToBase64(response)
238+
: await response.text();
251239

252240
const result: APIGatewayProxyResult = {
253241
statusCode: response.status,
@@ -267,10 +255,12 @@ const webResponseToProxyResultV1 = async (
267255
* Converts a Web API Response object to an API Gateway V2 proxy result.
268256
*
269257
* @param response - The Web API Response object
258+
* @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content)
270259
* @returns An API Gateway V2 proxy result
271260
*/
272261
const webResponseToProxyResultV2 = async (
273-
response: Response
262+
response: Response,
263+
isBase64Encoded?: boolean
274264
): Promise<APIGatewayProxyStructuredResultV2> => {
275265
const headers: Record<string, string> = {};
276266
const cookies: string[] = [];
@@ -283,25 +273,9 @@ const webResponseToProxyResultV2 = async (
283273
}
284274
}
285275

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-
}
276+
const body = isBase64Encoded
277+
? await responseBodyToBase64(response)
278+
: await response.text();
305279

306280
const result: APIGatewayProxyStructuredResultV2 = {
307281
statusCode: response.status,
@@ -319,12 +293,18 @@ const webResponseToProxyResultV2 = async (
319293

320294
const webResponseToProxyResult = <T extends ResponseType>(
321295
response: Response,
322-
responseType: T
296+
responseType: T,
297+
options?: WebResponseToProxyResultOptions
323298
): Promise<ResponseTypeMap[T]> => {
299+
const isBase64Encoded = options?.isBase64Encoded ?? false;
324300
if (responseType === 'ApiGatewayV1') {
325-
return webResponseToProxyResultV1(response) as Promise<ResponseTypeMap[T]>;
301+
return webResponseToProxyResultV1(response, isBase64Encoded) as Promise<
302+
ResponseTypeMap[T]
303+
>;
326304
}
327-
return webResponseToProxyResultV2(response) as Promise<ResponseTypeMap[T]>;
305+
return webResponseToProxyResultV2(response, isBase64Encoded) as Promise<
306+
ResponseTypeMap[T]
307+
>;
328308
};
329309

330310
/**
@@ -385,6 +365,19 @@ const handlerResultToWebResponse = (
385365
}
386366

387367
const headers = new Headers(resHeaders);
368+
369+
if (isBinaryResult(response)) {
370+
const body =
371+
response instanceof Readable
372+
? (Readable.toWeb(response) as ReadableStream)
373+
: response;
374+
375+
return new Response(body, {
376+
status: 200,
377+
headers,
378+
});
379+
}
380+
388381
headers.set('Content-Type', 'application/json');
389382

390383
if (isExtendedAPIGatewayProxyResult(response)) {

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';
88
import type {
99
CompiledRoute,
10+
CompressionOptions,
1011
ExtendedAPIGatewayProxyResult,
1112
HandlerResponse,
1213
HttpMethod,
@@ -16,6 +17,7 @@ import type {
1617
ValidationResult,
1718
} from '../types/rest.js';
1819
import {
20+
COMPRESSION_ENCODING_TYPES,
1921
HttpVerbs,
2022
PARAM_PATTERN,
2123
SAFE_CHARS,
@@ -156,6 +158,16 @@ export const isWebReadableStream = (
156158
);
157159
};
158160

161+
export const isBinaryResult = (
162+
value: unknown
163+
): value is ArrayBuffer | Readable | ReadableStream => {
164+
return (
165+
value instanceof ArrayBuffer ||
166+
isNodeReadableStream(value) ||
167+
isWebReadableStream(value)
168+
);
169+
};
170+
159171
/**
160172
* Type guard to check if the provided result is an API Gateway Proxy result.
161173
*
@@ -318,3 +330,31 @@ export const HttpResponseStream =
318330
return underlyingStream;
319331
}
320332
};
333+
334+
export const getBase64EncodingFromResult = (result: HandlerResponse) => {
335+
if (isBinaryResult(result)) {
336+
return true;
337+
}
338+
if (isExtendedAPIGatewayProxyResult(result)) {
339+
return (
340+
result.body instanceof ArrayBuffer ||
341+
isNodeReadableStream(result.body) ||
342+
isWebReadableStream(result.body)
343+
);
344+
}
345+
return false;
346+
};
347+
348+
export const getBase64EncodingFromHeaders = (headers: Headers): boolean => {
349+
const contentEncoding = headers.get(
350+
'content-encoding'
351+
) as CompressionOptions['encoding'];
352+
353+
return (
354+
contentEncoding != null &&
355+
[
356+
COMPRESSION_ENCODING_TYPES.GZIP,
357+
COMPRESSION_ENCODING_TYPES.DEFLATE,
358+
].includes(contentEncoding)
359+
);
360+
};

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type RequestContext = {
2929
res: Response;
3030
params: Record<string, string>;
3131
responseType: ResponseType;
32+
isBase64Encoded?: boolean;
3233
};
3334

3435
type ErrorResolveOptions = RequestContext & ResolveOptions;
@@ -69,14 +70,20 @@ interface CompiledRoute {
6970

7071
type DynamicRoute = Route & CompiledRoute;
7172

72-
type ExtendedAPIGatewayProxyResultBody = string | Readable | ReadableStream;
73+
type BinaryResult = ArrayBuffer | Readable | ReadableStream;
74+
75+
type ExtendedAPIGatewayProxyResultBody = BinaryResult | string;
7376

7477
type ExtendedAPIGatewayProxyResult = Omit<APIGatewayProxyResult, 'body'> & {
7578
body: ExtendedAPIGatewayProxyResultBody;
7679
cookies?: string[];
7780
};
7881

79-
type HandlerResponse = Response | JSONObject | ExtendedAPIGatewayProxyResult;
82+
type HandlerResponse =
83+
| Response
84+
| JSONObject
85+
| ExtendedAPIGatewayProxyResult
86+
| BinaryResult;
8087

8188
type RouteHandler<TReturn = HandlerResponse> = (
8289
reqCtx: RequestContext
@@ -230,7 +237,12 @@ type CompressionOptions = {
230237
threshold?: number;
231238
};
232239

240+
type WebResponseToProxyResultOptions = {
241+
isBase64Encoded?: boolean;
242+
};
243+
233244
export type {
245+
BinaryResult,
234246
ExtendedAPIGatewayProxyResult,
235247
ExtendedAPIGatewayProxyResultBody,
236248
CompiledRoute,
@@ -259,4 +271,5 @@ export type {
259271
CompressionOptions,
260272
NextFunction,
261273
V1Headers,
274+
WebResponseToProxyResultOptions,
262275
};

0 commit comments

Comments
 (0)