Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions packages/event-handler/src/rest/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -353,7 +360,12 @@ class Router {
options?: ResolveOptions
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2> {
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,
});
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
89 changes: 41 additions & 48 deletions packages/event-handler/src/rest/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -213,41 +213,29 @@ const webHeadersToApiGatewayHeaders = <T extends ResponseType>(
: { headers: Record<string, string> };
};

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<APIGatewayProxyResult> => {
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,
Expand All @@ -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<APIGatewayProxyStructuredResultV2> => {
const headers: Record<string, string> = {};
const cookies: string[] = [];
Expand All @@ -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,
Expand All @@ -319,12 +293,18 @@ const webResponseToProxyResultV2 = async (

const webResponseToProxyResult = <T extends ResponseType>(
response: Response,
responseType: T
responseType: T,
options?: WebResponseToProxyResultOptions
): Promise<ResponseTypeMap[T]> => {
const isBase64Encoded = options?.isBase64Encoded ?? false;
if (responseType === 'ApiGatewayV1') {
return webResponseToProxyResultV1(response) as Promise<ResponseTypeMap[T]>;
return webResponseToProxyResultV1(response, isBase64Encoded) as Promise<
ResponseTypeMap[T]
>;
}
return webResponseToProxyResultV2(response) as Promise<ResponseTypeMap[T]>;
return webResponseToProxyResultV2(response, isBase64Encoded) as Promise<
ResponseTypeMap[T]
>;
};

/**
Expand Down Expand Up @@ -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)) {
Expand Down
56 changes: 56 additions & 0 deletions packages/event-handler/src/rest/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';
import type {
CompiledRoute,
CompressionOptions,
ExtendedAPIGatewayProxyResult,
HandlerResponse,
HttpMethod,
Expand All @@ -16,6 +17,7 @@ import type {
ValidationResult,
} from '../types/rest.js';
import {
COMPRESSION_ENCODING_TYPES,
HttpVerbs,
PARAM_PATTERN,
SAFE_CHARS,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative approach here is to check if we have don't a text-based content type, i.e, if the content type is not one of text/*, application/json, application/xml, application/javascript or application/x-www-form-urlencoded then we base64 encode it. My worry is that there are other non binary types I might be missing here.

if (contentType != null) {
const type = contentType.split(';')[0].trim();
if (
type.startsWith('image/') ||
type.startsWith('audio/') ||
type.startsWith('video/')
) {
return true;
}
}

return false;
};
17 changes: 15 additions & 2 deletions packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type RequestContext = {
res: Response;
params: Record<string, string>;
responseType: ResponseType;
isBase64Encoded?: boolean;
};

type ErrorResolveOptions = RequestContext & ResolveOptions;
Expand Down Expand Up @@ -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<APIGatewayProxyResult, 'body'> & {
body: ExtendedAPIGatewayProxyResultBody;
cookies?: string[];
};

type HandlerResponse = Response | JSONObject | ExtendedAPIGatewayProxyResult;
type HandlerResponse =
| Response
| JSONObject
| ExtendedAPIGatewayProxyResult
| BinaryResult;

type RouteHandler<TReturn = HandlerResponse> = (
reqCtx: RequestContext
Expand Down Expand Up @@ -230,7 +237,12 @@ type CompressionOptions = {
threshold?: number;
};

type WebResponseToProxyResultOptions = {
isBase64Encoded?: boolean;
};

export type {
BinaryResult,
ExtendedAPIGatewayProxyResult,
ExtendedAPIGatewayProxyResultBody,
CompiledRoute,
Expand Down Expand Up @@ -259,4 +271,5 @@ export type {
CompressionOptions,
NextFunction,
V1Headers,
WebResponseToProxyResultOptions,
};
Loading
Loading