From 9c7c41515d759bd7ff57cbaf4dfe6847a0ab6087 Mon Sep 17 00:00:00 2001 From: Ethan Smith Date: Tue, 18 Nov 2025 17:48:06 -0500 Subject: [PATCH] fix: add support for promise-based handlers --- README.md | 25 ++++-- src/Application.ts | 24 +++++- src/index.ts | 22 +++++ tests/integration-tests.test.ts | 138 +++++++++++++++++++++++++++++++- 4 files changed, 200 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2d4747c..8006372 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,7 @@ familiar will accelerate your development, allowing you to focus on your busines ## Usage -Here's a simple example to get you up and running quickly (assumes your execution environment -is Node 12.x): +Here's a simple example to get you up and running quickly: `npm i @silvermine/lambda-express` @@ -65,19 +64,31 @@ app.get('/my-endpoint', async (request: Request, response: Response) => { response.send('Hello world!'); }); -export const handler = (event: RequestEvent, context: Context, callback: Callback): void => { - app.run(event, context, callback); -}; +// Use the helper function to create an async handler (Recommended for Node.js 18+) +export const handler = createAsyncHandler(app); -export default handler; +// Or use runAsync directly: +// +// export const handler = async (event: RequestEvent, context: Context): Promise => { +// return app.runAsync(event, context); +// }; + +// Or for Node.js <24.x, use the callback style: +// +// export const handler = (event: RequestEvent, context: Context, callback: Callback): void => { +// app.run(event, context, callback); +// }; ``` At this point you should be able to compile, bundle, and deploy this Lambda. Assuming you have configured APIGW or ALB to forward traffic to your Lambda, you will now have a very basic working API! +**Note:** Both handler styles (async/await and callback) are supported for Node.js <24.x. +However, the async/await style is recommended for Node.js 18+ and is required for Node.js +24+. + ## License This software is released under the MIT license. See [the license file](LICENSE) for more details. - diff --git a/src/Application.ts b/src/Application.ts index e002d08..deb3fb2 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -1,6 +1,6 @@ import { Callback, Context } from 'aws-lambda'; import Router from './Router'; -import { RequestEvent, HandlerContext } from './request-response-types'; +import { RequestEvent, HandlerContext, ResponseResult } from './request-response-types'; import { isUndefined, StringUnknownMap, Writable } from '@silvermine/toolbox'; import { Request, Response } from '.'; import { isErrorWithStatusCode } from './interfaces'; @@ -85,6 +85,9 @@ export default class Application extends Router { * @param evt The event provided to the Lambda handler * @param context The context provided to the Lambda handler * @param cb The callback provided to the Lambda handler + * + * @deprecated Use `runAsync()` instead. Callback-style handlers are deprecated in + * Node.js 24. */ public run(evt: RequestEvent, context: Context, cb: Callback): void { const req = new Request(this, evt, this._createHandlerContext(context)), @@ -100,6 +103,25 @@ export default class Application extends Router { }); } + /** + * Run the app for a Lambda invocation using async/await pattern. + * + * @param evt The event provided to the Lambda handler + * @param context The context provided to the Lambda handler + * @returns A Promise that resolves with the response result or rejects with an error + */ + public runAsync(evt: RequestEvent, context: Context): Promise { + return new Promise((resolve, reject) => { + this.run(evt, context, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result as ResponseResult); + } + }); + }); + } + private _createHandlerContext(context: Context): HandlerContext { const newContext: Writable = { functionName: context.functionName, diff --git a/src/index.ts b/src/index.ts index c934c56..7b1280c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ import Application from './Application'; import Request from './Request'; import Response from './Response'; import Router from './Router'; +import { Context } from 'aws-lambda'; +import { RequestEvent, ResponseResult } from './request-response-types'; export { Application, @@ -10,6 +12,26 @@ export { Router, }; +/** + * Creates an async Lambda handler function from an Application instance. + * + * @param app The Application instance to use for handling requests + * @returns An async Lambda handler function + * + * @example + * ```typescript + * const app = new Application(); + * app.get('/hello', (req, res) => res.send('Hello World!')); + * + * export const handler = createAsyncHandler(app); + * ``` + */ +export function createAsyncHandler(app: Application): (event: RequestEvent, context: Context) => Promise { + return async (event: RequestEvent, context: Context): Promise => { + return app.runAsync(event, context); + }; +} + // We need to export only types that are used in public interfaces (e.g. those used in // concrete classes like Application, Request, Response, Router, exported above). export { diff --git a/tests/integration-tests.test.ts b/tests/integration-tests.test.ts index 3d463b2..c7c91a3 100644 --- a/tests/integration-tests.test.ts +++ b/tests/integration-tests.test.ts @@ -7,7 +7,7 @@ import { apiGatewayRequestRawQuery, } from './samples'; import { spy, SinonSpy, assert } from 'sinon'; -import { Application, Request, Response, Router } from '../src'; +import { Application, Request, Response, Router, createAsyncHandler } from '../src'; import { RequestEvent, ResponseResult } from '../src/request-response-types'; import { NextCallback, IRoute, IRouter, ErrorWithStatusCode } from '../src/interfaces'; import { expect } from 'chai'; @@ -925,4 +925,140 @@ describe('integration tests', () => { expect(cb.firstCall.args[1].body).to.eql('URIError handled by error handler'); }); + describe('runAsync method', () => { + + it('returns a Promise that resolves with the response result', async () => { + app.get('/hello', (_req: Request, resp: Response): void => { + resp.json({ message: 'Hello World' }); + }); + + const evt = makeRequestEvent('/hello'), + result = await app.runAsync(evt, handlerContext()); + + expect(result.statusCode).to.eql(200); + expect(result.body).to.eql(JSON.stringify({ message: 'Hello World' })); + expect(result.isBase64Encoded).to.eql(false); + expect(result.multiValueHeaders['Content-Type']).to.eql([ 'application/json; charset=utf-8' ]); + }); + + it('returns a Promise that rejects when an error occurs', async () => { + app.get('/error', (_req: Request, _resp: Response): void => { // eslint-disable-line @typescript-eslint/no-unused-vars + const err: ErrorWithStatusCode = new Error('Test error') as ErrorWithStatusCode; + + err.statusCode = 500; + throw err; + }); + + const evt = makeRequestEvent('/error'); + + let errorThrown = false; + + try { + await app.runAsync(evt, handlerContext()); + } catch(err) { + errorThrown = true; + } + + // Since the error is caught by the last resort handler, it should resolve, not + // reject + expect(errorThrown).to.eql(false); + }); + + it('works with async middleware and handlers', async () => { + app.use(async (_req: Request, resp: Response, next: NextCallback): Promise => { + resp.append('X-Async-Middleware', 'ran'); + next(); + }); + + app.get('/async', async (_req: Request, resp: Response): Promise => { + resp.json({ async: true }); + }); + + const evt = makeRequestEvent('/async'), + result = await app.runAsync(evt, handlerContext()); + + expect(result.statusCode).to.eql(200); + expect(result.body).to.eql(JSON.stringify({ async: true })); + expect(result.multiValueHeaders['X-Async-Middleware']).to.eql([ 'ran' ]); + }); + + it('works with ALB events', async () => { + app.get('/alb-test', (_req: Request, resp: Response): void => { + resp.send('ALB response'); + }); + + const evt = makeRequestEvent('/alb-test', albRequest()), + result = await app.runAsync(evt, handlerContext()); + + expect(result.statusCode).to.eql(200); + expect(result.statusDescription).to.eql('200 OK'); + expect(result.body).to.eql('ALB response'); + }); + + it('returns 404 when no matching route is found', async () => { + const evt = makeRequestEvent('/nonexistent'), + result = await app.runAsync(evt, handlerContext()); + + expect(result.statusCode).to.eql(404); + }); + + }); + + describe('createAsyncHandler helper', () => { + + it('creates an async handler that works correctly', async () => { + app.get('/test', (_req: Request, resp: Response): void => { + resp.json({ test: 'success' }); + }); + + const handler = createAsyncHandler(app), + evt = makeRequestEvent('/test'), + result = await handler(evt, handlerContext()); + + expect(result.statusCode).to.eql(200); + expect(result.body).to.eql(JSON.stringify({ test: 'success' })); + }); + + it('creates a handler that works with middleware', async () => { + app.use((_req: Request, resp: Response, next: NextCallback): void => { + resp.append('X-Middleware', 'executed'); + next(); + }); + + app.get('/middleware-test', (_req: Request, resp: Response): void => { + resp.send('OK'); + }); + + const handler = createAsyncHandler(app), + evt = makeRequestEvent('/middleware-test'), + result = await handler(evt, handlerContext()); + + expect(result.statusCode).to.eql(200); + expect(result.body).to.eql('OK'); + expect(result.multiValueHeaders['X-Middleware']).to.eql([ 'executed' ]); + }); + + it('creates a handler that properly handles errors', async () => { + app.get('/error-test', (_req: Request, _resp: Response): void => { // eslint-disable-line @typescript-eslint/no-unused-vars + throw new Error('Test error'); + }); + + const handler = createAsyncHandler(app), + evt = makeRequestEvent('/error-test'), + result = await handler(evt, handlerContext()); + + // Error is caught by last resort handler + expect(result.statusCode).to.eql(500); + }); + + it('creates a handler that returns 404 for non-existent routes', async () => { + const handler = createAsyncHandler(app), + evt = makeRequestEvent('/does-not-exist'), + result = await handler(evt, handlerContext()); + + expect(result.statusCode).to.eql(404); + }); + + }); + });