From 794bb0da0dad7962521d90e2b7550b42c558131c Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 23 Dec 2025 10:20:39 -0500 Subject: [PATCH] Add Lambda client utilities for synchronous and asynchronous invocations --- README.md | 29 ++- docs/LAMBDA_CLIENT.md | 292 ++++++++++++++++++++++++++++++ docs/README.md | 3 +- eslint.config.mjs | 2 + package-lock.json | 140 ++++++++++++++ package.json | 1 + src/clients/lambda-client.test.ts | 281 ++++++++++++++++++++++++++++ src/clients/lambda-client.ts | 133 ++++++++++++++ src/index.ts | 7 + 9 files changed, 885 insertions(+), 3 deletions(-) create mode 100644 docs/LAMBDA_CLIENT.md create mode 100644 src/clients/lambda-client.test.ts create mode 100644 src/clients/lambda-client.ts diff --git a/README.md b/README.md index b44444d..3acc784 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ export const handler = async (event: APIGatewayProxyEvent) => { - **📝 Structured Logging** – Pino logger pre-configured for Lambda with automatic AWS request context enrichment - **📤 API Response Helpers** – Standard response formatting for API Gateway with proper HTTP status codes - **⚙️ Configuration Validation** – Environment variable validation with Zod schema support -- **🔌 AWS SDK Clients** – Pre-configured AWS SDK v3 clients including DynamoDB and SNS with singleton patterns +- **🔌 AWS SDK Clients** – Pre-configured AWS SDK v3 clients including DynamoDB, SNS, and Lambda with singleton patterns - **🔒 Full TypeScript Support** – Complete type definitions and IDE autocomplete - **⚡ Lambda Optimized** – Designed for performance in serverless environments @@ -109,6 +109,7 @@ Comprehensive guides and examples are available in the `docs` directory: | **[API Gateway Responses](./docs/API_GATEWAY_RESPONSES.md)** | Format responses for API Gateway with standard HTTP patterns | | **[DynamoDB Client](./docs/DYNAMODB_CLIENT.md)** | Use pre-configured DynamoDB clients with singleton pattern | | **[SNS Client](./docs/SNS_CLIENT.md)** | Publish messages to SNS topics with message attributes | +| **[Lambda Client](./docs/LAMBDA_CLIENT.md)** | Invoke other Lambda functions synchronously or asynchronously | ## Usage @@ -223,7 +224,31 @@ export const handler = async (event: any) => { **→ See [SNS Client Guide](./docs/SNS_CLIENT.md) for detailed configuration and examples** -Additional AWS Clients are coming soon. +#### Lambda Client + +Invoke other Lambda functions synchronously or asynchronously: + +```typescript +import { invokeLambdaSync, invokeLambdaAsync } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + // Synchronous invocation - wait for response + const response = await invokeLambdaSync('my-function-name', { + key: 'value', + data: { nested: true }, + }); + + // Asynchronous invocation - fire and forget + await invokeLambdaAsync('my-async-function', { + eventType: 'process', + data: [1, 2, 3], + }); + + return { statusCode: 200, body: JSON.stringify(response) }; +}; +``` + +**→ See [Lambda Client Guide](./docs/LAMBDA_CLIENT.md) for detailed configuration and examples** ## Examples diff --git a/docs/LAMBDA_CLIENT.md b/docs/LAMBDA_CLIENT.md new file mode 100644 index 0000000..93a0f88 --- /dev/null +++ b/docs/LAMBDA_CLIENT.md @@ -0,0 +1,292 @@ +# Lambda Client Utilities + +The Lambda client utilities provide a reusable singleton instance of `LambdaClient` for use in AWS Lambda functions. These utilities enable you to configure the client once and reuse it across invocations, following AWS best practices for Lambda performance optimization. + +## Overview + +The utility exports the following functions: + +- `initializeLambdaClient()` - Initialize the Lambda client with optional configuration +- `getLambdaClient()` - Get the singleton Lambda client instance +- `invokeLambdaSync()` - Invoke a Lambda function synchronously (RequestResponse) +- `invokeLambdaAsync()` - Invoke a Lambda function asynchronously (Event) +- `resetLambdaClient()` - Reset the client instance (useful for testing) + +## Usage + +### Synchronous Invocation (RequestResponse) + +Use synchronous invocation when you need to wait for the Lambda function to complete and return a response: + +```typescript +import { invokeLambdaSync } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + // Invoke a Lambda function and wait for the response + const response = await invokeLambdaSync('my-function-name', { + key: 'value', + data: { nested: true }, + }); + + return { + statusCode: 200, + body: JSON.stringify(response), + }; +}; +``` + +### Synchronous Invocation with Typed Response + +For better type safety, you can specify the expected response type: + +```typescript +import { invokeLambdaSync } from '@leanstacks/lambda-utils'; + +interface MyFunctionResponse { + result: string; + statusCode: number; + data: Record; +} + +export const handler = async (event: any) => { + const response = await invokeLambdaSync('my-function-name', { + operation: 'getData', + id: '12345', + }); + + console.log(`Function returned: ${response.result}`); + + return { + statusCode: response.statusCode, + body: JSON.stringify(response.data), + }; +}; +``` + +### Asynchronous Invocation (Event) + +Use asynchronous invocation for fire-and-forget scenarios where you don't need to wait for the Lambda function to complete: + +```typescript +import { invokeLambdaAsync } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + // Invoke a Lambda function asynchronously (fire and forget) + await invokeLambdaAsync('my-async-function', { + eventType: 'process', + data: [1, 2, 3], + }); + + // Returns immediately without waiting for the function to complete + return { + statusCode: 202, + body: JSON.stringify({ message: 'Processing initiated' }), + }; +}; +``` + +### Using Function ARN + +You can invoke Lambda functions by name or ARN: + +```typescript +import { invokeLambdaSync } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + // Using function ARN + const response = await invokeLambdaSync('arn:aws:lambda:us-east-1:123456789012:function:my-function', { + key: 'value', + }); + + return { + statusCode: 200, + body: JSON.stringify(response), + }; +}; +``` + +### Using the Lambda Client Directly + +For advanced use cases, you can access the underlying Lambda client: + +```typescript +import { getLambdaClient } from '@leanstacks/lambda-utils'; +import { ListFunctionsCommand } from '@aws-sdk/client-lambda'; + +export const handler = async (event: any) => { + const client = getLambdaClient(); + + const response = await client.send(new ListFunctionsCommand({})); + + return { + statusCode: 200, + body: JSON.stringify(response.Functions), + }; +}; +``` + +### Custom Configuration + +Initialize the Lambda client with custom configuration at the start of your Lambda handler: + +```typescript +import { initializeLambdaClient, invokeLambdaSync } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + // Initialize with custom configuration (only needs to be done once) + initializeLambdaClient({ + region: 'us-west-2', + maxAttempts: 3, + }); + + const response = await invokeLambdaSync('my-function', { key: 'value' }); + + return { + statusCode: 200, + body: JSON.stringify(response), + }; +}; +``` + +## API Reference + +### initializeLambdaClient(config?) + +Initializes the Lambda client with the provided configuration. If the client is already initialized, this will replace it with a new instance. + +**Parameters:** + +- `config` (optional): `LambdaClientConfig` - AWS SDK Lambda client configuration + +**Returns:** `LambdaClient` - The Lambda client instance + +### getLambdaClient() + +Returns the singleton Lambda client instance. If the client has not been initialized, creates one with default configuration. + +**Returns:** `LambdaClient` - The Lambda client instance + +### invokeLambdaSync(functionName, payload) + +Invokes a Lambda function synchronously (RequestResponse). The function waits for the response and returns the payload. + +**Parameters:** + +- `functionName`: `string` - The name or ARN of the Lambda function to invoke +- `payload`: `unknown` - The JSON payload to pass to the Lambda function + +**Returns:** `Promise` - Promise that resolves to the response payload from the Lambda function + +**Throws:** `Error` - If the Lambda invocation fails or returns a function error + +### invokeLambdaAsync(functionName, payload) + +Invokes a Lambda function asynchronously (Event). The function returns immediately without waiting for the Lambda execution to complete. + +**Parameters:** + +- `functionName`: `string` - The name or ARN of the Lambda function to invoke +- `payload`: `unknown` - The JSON payload to pass to the Lambda function + +**Returns:** `Promise` - Promise that resolves when the invocation request is accepted + +**Throws:** `Error` - If the Lambda invocation request fails + +### resetLambdaClient() + +Resets the Lambda client instance. Useful for testing or when you need to reinitialize the client with a different configuration. + +**Returns:** `void` + +## Error Handling + +Both `invokeLambdaSync` and `invokeLambdaAsync` throw errors in the following cases: + +1. **Function Errors**: When the invoked Lambda function returns an error (e.g., unhandled exceptions) +2. **AWS SDK Errors**: When the AWS SDK encounters an error (e.g., network issues, permission errors) + +```typescript +import { invokeLambdaSync } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + try { + const response = await invokeLambdaSync('my-function', { key: 'value' }); + return { + statusCode: 200, + body: JSON.stringify(response), + }; + } catch (error) { + console.error('Lambda invocation failed:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Failed to invoke Lambda function' }), + }; + } +}; +``` + +## Testing + +The utility provides a `resetLambdaClient()` function for testing purposes: + +```typescript +import { resetLambdaClient, initializeLambdaClient } from '@leanstacks/lambda-utils'; +import { mockClient } from 'aws-sdk-client-mock'; +import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda'; + +describe('My Lambda Tests', () => { + const lambdaClientMock = mockClient(LambdaClient); + + beforeEach(() => { + resetLambdaClient(); + lambdaClientMock.reset(); + }); + + it('should invoke Lambda function successfully', async () => { + // Mock the Lambda client response + const responsePayload = { result: 'success' }; + const encoder = new TextEncoder(); + const responseBytes = encoder.encode(JSON.stringify(responsePayload)); + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 200, + Payload: responseBytes, + }); + + // Test your code that uses invokeLambdaSync or invokeLambdaAsync + // ... + }); +}); +``` + +## Best Practices + +1. **Singleton Pattern**: The Lambda client is created once and reused across invocations, improving performance by reducing initialization overhead. + +2. **Error Handling**: Always wrap Lambda invocations in try-catch blocks to handle potential errors gracefully. + +3. **Type Safety**: Use TypeScript generics to specify the expected response type for synchronous invocations: + + ```typescript + const response = await invokeLambdaSync('my-function', payload); + ``` + +4. **Async vs Sync**: Choose the appropriate invocation type: + - Use `invokeLambdaSync` when you need to wait for and process the response + - Use `invokeLambdaAsync` for fire-and-forget scenarios to improve performance + +5. **IAM Permissions**: Ensure your Lambda function has the necessary IAM permissions to invoke other Lambda functions: + ```json + { + "Effect": "Allow", + "Action": ["lambda:InvokeFunction"], + "Resource": "arn:aws:lambda:region:account-id:function:function-name" + } + ``` + +## See Also + +- [AWS SDK for JavaScript - Lambda Client](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-lambda/) +- [AWS Lambda Developer Guide - Invoking Functions](https://docs.aws.amazon.com/lambda/latest/dg/lambda-invocation.html) +- [DynamoDB Client Documentation](./DYNAMODB_CLIENT.md) +- [SNS Client Documentation](./SNS_CLIENT.md) diff --git a/docs/README.md b/docs/README.md index 1869e3b..81f229b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,13 +13,14 @@ Lambda Utilities is a collection of pre-configured tools and helpers designed to - **[API Gateway Responses](./API_GATEWAY_RESPONSES.md)** – Format Lambda responses for API Gateway with standard HTTP status codes and headers - **[DynamoDB Client](./DYNAMODB_CLIENT.md)** – Reusable singleton DynamoDB client instances with custom configuration - **[SNS Client](./SNS_CLIENT.md)** – Reusable singleton SNS client for publishing messages to topics with message attributes +- **[Lambda Client](./LAMBDA_CLIENT.md)** – Reusable singleton Lambda client for invoking other Lambda functions ## Features - 📝 **Structured Logging** – Pino logger pre-configured for Lambda with automatic request context - 📤 **API Response Helpers** – Standard response formatting for API Gateway integration - ⚙️ **Configuration Validation** – Environment variable validation with Zod schema support -- 🔌 **AWS Clients** – Pre-configured AWS SDK v3 clients for DynamoDB and SNS +- 🔌 **AWS Clients** – Pre-configured AWS SDK v3 clients for DynamoDB, SNS, and Lambda - 🔒 **Type Safe** – Full TypeScript support with comprehensive type definitions ## Support diff --git a/eslint.config.mjs b/eslint.config.mjs index cb3dc4e..f642cac 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,8 @@ export default [ beforeEach: 'readonly', afterEach: 'readonly', jest: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', }, }, plugins: { diff --git a/package-lock.json b/package-lock.json index d2a1f23..dd22833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-dynamodb": "3.957.0", + "@aws-sdk/client-lambda": "3.957.0", "@aws-sdk/client-sns": "3.957.0", "@aws-sdk/lib-dynamodb": "3.957.0", "pino": "10.1.0", @@ -40,6 +41,20 @@ "typescript": "5.9.3" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -219,6 +234,61 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-lambda": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.957.0.tgz", + "integrity": "sha512-g7ZXBME+Um6ViY0y7HCGd8U1NlsbyZEQQe3dtw5jRcUl5zNAE76SxYKWkZo9GDdXzNqPJTFTjKlv18m4FyGJIA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/credential-provider-node": "3.957.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/eventstream-serde-browser": "^4.2.7", + "@smithy/eventstream-serde-config-resolver": "^4.3.7", + "@smithy/eventstream-serde-node": "^4.2.7", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-sns": { "version": "3.957.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.957.0.tgz", @@ -2860,6 +2930,76 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.7.tgz", + "integrity": "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.11.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.7.tgz", + "integrity": "sha512-ujzPk8seYoDBmABDE5YqlhQZAXLOrtxtJLrbhHMKjBoG5b4dK4i6/mEU+6/7yXIAkqOO8sJ6YxZl+h0QQ1IJ7g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.7.tgz", + "integrity": "sha512-x7BtAiIPSaNaWuzm24Q/mtSkv+BrISO/fmheiJ39PKRNH3RmH2Hph/bUKSOBOBC9unqfIYDhKTHwpyZycLGPVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.7.tgz", + "integrity": "sha512-roySCtHC5+pQq5lK4be1fZ/WR6s/AxnPaLfCODIPArtN2du8s5Ot4mKVK3pPtijL/L654ws592JHJ1PbZFF6+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.7.tgz", + "integrity": "sha512-QVD+g3+icFkThoy4r8wVFZMsIP08taHVKjE6Jpmz8h5CgX/kk6pTODq5cht0OMtcapUx+xrPzUTQdA+TmO0m1g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/fetch-http-handler": { "version": "5.3.8", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.8.tgz", diff --git a/package.json b/package.json index 4b7a92b..ee0b9f3 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ }, "dependencies": { "@aws-sdk/client-dynamodb": "3.957.0", + "@aws-sdk/client-lambda": "3.957.0", "@aws-sdk/client-sns": "3.957.0", "@aws-sdk/lib-dynamodb": "3.957.0", "pino": "10.1.0", diff --git a/src/clients/lambda-client.test.ts b/src/clients/lambda-client.test.ts new file mode 100644 index 0000000..a63e740 --- /dev/null +++ b/src/clients/lambda-client.test.ts @@ -0,0 +1,281 @@ +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { mockClient } from 'aws-sdk-client-mock'; + +import { + getLambdaClient, + initializeLambdaClient, + invokeLambdaAsync, + invokeLambdaSync, + resetLambdaClient, +} from './lambda-client'; + +// Create a mock for the Lambda client +const lambdaClientMock = mockClient(LambdaClient); + +describe('lambda-client', () => { + beforeEach(() => { + // Reset the Lambda client before each test + resetLambdaClient(); + // Reset all mocks + lambdaClientMock.reset(); + }); + + afterEach(() => { + // Clean up after each test + resetLambdaClient(); + }); + + describe('initializeLambdaClient', () => { + it('should create a new Lambda client with default config', () => { + // Arrange + // Act + const client = initializeLambdaClient(); + + // Assert + expect(client).toBeInstanceOf(LambdaClient); + }); + + it('should create a new Lambda client with custom config', () => { + // Arrange + const config = { region: 'us-west-2' }; + + // Act + const client = initializeLambdaClient(config); + + // Assert + expect(client).toBeInstanceOf(LambdaClient); + }); + + it('should replace existing client when called again', () => { + // Arrange + const firstClient = initializeLambdaClient({ region: 'us-east-1' }); + + // Act + const secondClient = initializeLambdaClient({ region: 'us-west-2' }); + + // Assert + expect(secondClient).toBeInstanceOf(LambdaClient); + expect(secondClient).not.toBe(firstClient); + }); + }); + + describe('getLambdaClient', () => { + it('should return the initialized client', () => { + // Arrange + const initializedClient = initializeLambdaClient(); + + // Act + const client = getLambdaClient(); + + // Assert + expect(client).toBe(initializedClient); + }); + + it('should create a client with default config if not initialized', () => { + // Arrange + // Act + const client = getLambdaClient(); + + // Assert + expect(client).toBeInstanceOf(LambdaClient); + }); + + it('should return the same instance on multiple calls', () => { + // Arrange + // Act + const firstCall = getLambdaClient(); + const secondCall = getLambdaClient(); + + // Assert + expect(firstCall).toBe(secondCall); + }); + }); + + describe('resetLambdaClient', () => { + it('should reset the client instance', () => { + // Arrange + const firstClient = getLambdaClient(); + + // Act + resetLambdaClient(); + const secondClient = getLambdaClient(); + + // Assert + expect(secondClient).toBeInstanceOf(LambdaClient); + expect(secondClient).not.toBe(firstClient); + }); + }); + + describe('invokeLambdaSync', () => { + it('should invoke Lambda function synchronously and return parsed response', async () => { + // Arrange + const functionName = 'test-function'; + const payload = { key: 'value', data: { nested: true } }; + const responsePayload = { result: 'success', statusCode: 200 }; + + // Create a Uint8Array from the JSON string + const responseBytes = new TextEncoder().encode(JSON.stringify(responsePayload)); + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 200, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Payload: responseBytes as any, + }); + + // Act + const result = await invokeLambdaSync(functionName, payload); + + // Assert + expect(result).toEqual(responsePayload); + expect(lambdaClientMock.calls()).toHaveLength(1); + + // Verify the command was called with correct parameters + const call = lambdaClientMock.call(0); + expect(call.args[0].input).toEqual({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + Payload: JSON.stringify(payload), + }); + }); + + it('should return null when response payload is undefined', async () => { + // Arrange + const functionName = 'test-function'; + const payload = { key: 'value' }; + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 200, + Payload: undefined, + }); + + // Act + const result = await invokeLambdaSync(functionName, payload); + + // Assert + expect(result).toBeNull(); + }); + + it('should handle typed response correctly', async () => { + // Arrange + interface MyResponse { + result: string; + statusCode: number; + } + const functionName = 'test-function'; + const payload = { key: 'value' }; + const responsePayload: MyResponse = { result: 'success', statusCode: 200 }; + + const responseBytes = new TextEncoder().encode(JSON.stringify(responsePayload)); + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 200, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Payload: responseBytes as any, + }); + + // Act + const result = await invokeLambdaSync(functionName, payload); + + // Assert + expect(result.result).toBe('success'); + expect(result.statusCode).toBe(200); + }); + + it('should throw error when function error is returned', async () => { + // Arrange + const functionName = 'test-function'; + const payload = { key: 'value' }; + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 200, + FunctionError: 'Unhandled', + }); + + // Act & Assert + await expect(invokeLambdaSync(functionName, payload)).rejects.toThrow('Lambda function error: Unhandled'); + }); + + it('should propagate AWS SDK errors', async () => { + // Arrange + const functionName = 'test-function'; + const payload = { key: 'value' }; + const awsError = new Error('AWS service error'); + + lambdaClientMock.on(InvokeCommand).rejects(awsError); + + // Act & Assert + await expect(invokeLambdaSync(functionName, payload)).rejects.toThrow('AWS service error'); + }); + }); + + describe('invokeLambdaAsync', () => { + it('should invoke Lambda function asynchronously', async () => { + // Arrange + const functionName = 'test-async-function'; + const payload = { eventType: 'process', data: [1, 2, 3] }; + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 202, + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(lambdaClientMock.calls()).toHaveLength(1); + + // Verify the command was called with correct parameters + const call = lambdaClientMock.call(0); + expect(call.args[0].input).toEqual({ + FunctionName: functionName, + InvocationType: 'Event', + Payload: JSON.stringify(payload), + }); + }); + + it('should throw error when function error is returned', async () => { + // Arrange + const functionName = 'test-async-function'; + const payload = { key: 'value' }; + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 202, + FunctionError: 'Unhandled', + }); + + // Act & Assert + await expect(invokeLambdaAsync(functionName, payload)).rejects.toThrow('Lambda function error: Unhandled'); + }); + + it('should propagate AWS SDK errors', async () => { + // Arrange + const functionName = 'test-async-function'; + const payload = { key: 'value' }; + const awsError = new Error('AWS service error'); + + lambdaClientMock.on(InvokeCommand).rejects(awsError); + + // Act & Assert + await expect(invokeLambdaAsync(functionName, payload)).rejects.toThrow('AWS service error'); + }); + + it('should not wait for function execution to complete', async () => { + // Arrange + const functionName = 'test-async-function'; + const payload = { key: 'value' }; + + lambdaClientMock.on(InvokeCommand).resolves({ + StatusCode: 202, + }); + + // Act + const startTime = Date.now(); + await invokeLambdaAsync(functionName, payload); + const duration = Date.now() - startTime; + + // Assert + // The call should return immediately (within reasonable test time) + expect(duration).toBeLessThan(1000); + }); + }); +}); diff --git a/src/clients/lambda-client.ts b/src/clients/lambda-client.ts new file mode 100644 index 0000000..3d3aad6 --- /dev/null +++ b/src/clients/lambda-client.ts @@ -0,0 +1,133 @@ +import { InvokeCommand, LambdaClient, LambdaClientConfig } from '@aws-sdk/client-lambda'; + +/** + * Singleton instance of Lambda client + */ +let lambdaClient: LambdaClient | null = null; + +/** + * Initializes the Lambda client with the provided configuration. + * If the client is already initialized, this will replace it with a new instance. + * + * @param config - Lambda client configuration + * @returns The Lambda client instance + * + * @example + * ```typescript + * // Initialize with default configuration + * initializeLambdaClient(); + * + * // Initialize with custom configuration + * initializeLambdaClient({ region: 'us-east-1' }); + * ``` + */ +export const initializeLambdaClient = (config?: LambdaClientConfig): LambdaClient => { + lambdaClient = new LambdaClient(config || {}); + return lambdaClient; +}; + +/** + * Returns the singleton Lambda client instance. + * If the client has not been initialized, creates one with default configuration. + * + * @returns The Lambda client instance + * + * @example + * ```typescript + * const client = getLambdaClient(); + * ``` + */ +export const getLambdaClient = (): LambdaClient => { + if (!lambdaClient) { + lambdaClient = new LambdaClient({}); + } + return lambdaClient; +}; + +/** + * Resets the Lambda client instance. + * Useful for testing or when you need to reinitialize the client with a different configuration. + */ +export const resetLambdaClient = (): void => { + lambdaClient = null; +}; + +/** + * Invokes a Lambda function synchronously (RequestResponse). + * The function waits for the response and returns the payload. + * + * @param functionName - The name or ARN of the Lambda function to invoke + * @param payload - The JSON payload to pass to the Lambda function + * @returns Promise that resolves to the response payload from the Lambda function + * @throws Error if the Lambda invocation fails or returns a function error + * + * @example + * ```typescript + * interface MyResponse { + * result: string; + * statusCode: number; + * } + * + * const response = await invokeLambdaSync( + * 'my-function-name', + * { key: 'value', data: { nested: true } } + * ); + * console.log(response.result); + * ``` + */ +export const invokeLambdaSync = async (functionName: string, payload: unknown): Promise => { + const client = getLambdaClient(); + + const command = new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + Payload: JSON.stringify(payload), + }); + + const response = await client.send(command); + + // Check for function errors + if (response.FunctionError) { + throw new Error(`Lambda function error: ${response.FunctionError}`); + } + + // Parse the response payload + const responsePayload = response.Payload ? JSON.parse(new TextDecoder().decode(response.Payload)) : null; + + return responsePayload as T; +}; + +/** + * Invokes a Lambda function asynchronously (Event). + * The function returns immediately without waiting for the Lambda execution to complete. + * + * @param functionName - The name or ARN of the Lambda function to invoke + * @param payload - The JSON payload to pass to the Lambda function + * @returns Promise that resolves when the invocation request is accepted + * @throws Error if the Lambda invocation request fails + * + * @example + * ```typescript + * // Fire and forget invocation + * await invokeLambdaAsync( + * 'my-async-function', + * { eventType: 'process', data: [1, 2, 3] } + * ); + * ``` + */ +export const invokeLambdaAsync = async (functionName: string, payload: unknown): Promise => { + const client = getLambdaClient(); + + const command = new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'Event', + Payload: JSON.stringify(payload), + }); + + const response = await client.send(command); + + // Check for function errors (though async invocations typically won't return function errors) + if (response.FunctionError) { + throw new Error(`Lambda function error: ${response.FunctionError}`); + } +}; diff --git a/src/index.ts b/src/index.ts index fe6b14a..df1e9ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,4 +23,11 @@ export { publishToTopic, resetSNSClient, } from './clients/sns-client'; +export { + getLambdaClient, + initializeLambdaClient, + invokeLambdaAsync, + invokeLambdaSync, + resetLambdaClient, +} from './clients/lambda-client'; export { createConfigManager, ConfigManager } from './validation/config';