CLOVER🍀

That was when it all began.

AWS SAMのTypeScriptサポートが、いつの間にかGAになっていたという話

これは、なにをしたくて書いたもの?

前に、プレビュー版のAWS SAMのTypeScriptサポートを試してみました。

AWS SAMのTypeScriptサポート(プレビュー)をLocalStackで試す - CLOVER🍀

そして、ふとAWS SAMのTypeScriptに関するページを見ると、「プレビュー」という文字がなくなっていたことに気づきまして。

esbuild による Node.js Lambda関数の構築 - AWS Serverless Application Model

いつの間にか、プレビューではなくなりました?

というわけで、確認してみました。

AWS SAMのTypeScriptサポート

ドキュメント上はTypeScriptのサポートと書いているわけではなく、esbuildのサポートということになっています。

esbuild による Node.js Lambda関数の構築 - AWS Serverless Application Model

esbuildがTypeScriptを使えるので、esbuildサポートのGAに伴いTypeScriptも正式使えるようになったという感じですね。

Node.js AWS Lambda 関数を構築してパッケージ化する場合、AWS SAM CLI と esbuild JavaScript バンドラーを使用できます。esbuild バンドラーは TypeScript で記述されて Lambda 関数をサポートします。

プレビューの時と同じように、MetadataのBuildMethodにesbuildを指定すれば使えるようです。

アナウンスもありました。

AWS SAM CLI esbuild のサポート、一般提供を開始

前にプレビューを試した後の、1ヶ月後ですね…。もう1年経っています…。

バージョンとしては、1.56.0からのサポートのようです。

Release Release 1.56.0 - esbuild GA, Event-based Metrics · aws/aws-sam-cli · GitHub

今回は、こちらをLocalStackを使って試してみます。

環境

今回の環境は、こちら。

$ python3 -V
Python 3.10.12


$ localstack --version
2.2.0

起動。

$ localstack start

AWS CLIおよびAWS SAM CLIの、LocalStack提供版。

$ awslocal --version
aws-cli/2.13.22 Python/3.11.5 Linux/5.15.0-84-generic exe/x86_64.ubuntu.22 prompt/off


$ samlocal --version
SAM CLI, version 1.82.0

アプリケーションを作成する際に使用する、Node.jsのバージョン。

$ node --version
v18.18.0


$ npm --version
9.8.1

AWS Lambda関数としても、Node.js 18で動作させます。

TypeScriptサポートを使ったAWS SAMプロジェクトを作成する

では、TypeScriptを使ったAWS SAMプロジェクトを作成します。

Node.js 18のTypeScriptのテンプレートはこちら。

$ curl -s https://raw.githubusercontent.com/aws/aws-sam-cli-app-templates/master/manifest-v2.json | jq '."nodejs18.x"[] | select(.appTemplate | test("typescript"))'
{
  "directory": "nodejs18.x/hello-ts",
  "displayName": "Hello World Example TypeScript",
  "dependencyManager": "npm",
  "appTemplate": "hello-world-typescript",
  "packageType": "Zip",
  "useCaseName": "Hello World Example"
}
{
  "directory": "nodejs18.x/hello-ts-pt",
  "displayName": "Hello World Example TypeScript With Powertools for AWS Lambda",
  "dependencyManager": "npm",
  "appTemplate": "hello-world-powertools-typescript",
  "packageType": "Zip",
  "useCaseName": "Hello World Example with Powertools for AWS Lambda"
}

今回はhello-world-typescriptを使いましょう。Amazon API Gateway+AWS Lambdaの構成です。

$ samlocal init --name sam-typescript-support-ga-hello-world --runtime nodejs18.x --app-template hello-world-typescript --package-type Zip --no-tracing --no-application-insights

プロジェクト内に移動。

$ cd sam-typescript-support-ga-hello-world

生成されたディレクトリおよびファイルを確認。

$ tree -a
.
├── .gitignore
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── .eslintignore
│   ├── .eslintrc.js
│   ├── .npmignore
│   ├── .prettierrc.js
│   ├── app.ts
│   ├── jest.config.ts
│   ├── package.json
│   ├── tests
│   │   └── unit
│   │       └── test-handler.test.ts
│   └── tsconfig.json
├── samconfig.toml
└── template.yaml

4 directories, 14 files

主なファイルの中身を確認してみます。

samconfig.toml

# More information about the configuration file can be found here:
# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html
version = 0.1

[default]
[default.global.parameters]
stack_name = "sam-typescript-support-ga-hello-world"

[default.build.parameters]
cached = true
parallel = true

[default.validate.parameters]
lint = true

[default.deploy.parameters]
capabilities = "CAPABILITY_IAM"
confirm_changeset = true
resolve_s3 = true

[default.package.parameters]
resolve_s3 = true

[default.sync.parameters]
watch = true

[default.local_start_api.parameters]
warm_containers = "EAGER"

[default.local_start_lambda.parameters]
warm_containers = "EAGER"

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-typescript-support-ga-hello-world

  Sample SAM Template for sam-typescript-support-ga-hello-world

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get
    Metadata: # Manage esbuild properties
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints:
        - app.ts

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn
  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt HelloWorldFunctionRole.Arn

hello-world/package.json

{
  "name": "hello_world",
  "version": "1.0.0",
  "description": "hello world sample for NodeJS",
  "main": "app.js",
  "repository": "https://github.com/awslabs/aws-sam-cli/tree/develop/samcli/local/init/templates/cookiecutter-aws-sam-hello-nodejs",
  "author": "SAM CLI",
  "license": "MIT",
  "scripts": {
    "unit": "jest",
    "lint": "eslint '*.ts' --quiet --fix",
    "compile": "tsc",
    "test": "npm run compile && npm run unit"
  },
  "dependencies": {
    "esbuild": "^0.14.14"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.92",
    "@types/jest": "^29.2.0",
    "@types/node": "^18.11.4",
    "@typescript-eslint/eslint-plugin": "^5.10.2",
    "@typescript-eslint/parser": "^5.10.2",
    "eslint": "^8.8.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "jest": "^29.2.1",
    "prettier": "^2.5.1",
    "ts-jest": "^29.0.5",
    "ts-node": "^10.9.1",
    "typescript": "^4.8.4"
  }
}

hello-world/tsconfig.json

{
    "compilerOptions": {
      "target": "es2020",
      "strict": true,
      "preserveConstEnums": true,
      "noEmit": true,
      "sourceMap": false,
      "module":"es2015",
      "moduleResolution":"node",
      "esModuleInterop": true,
      "skipLibCheck": true,
      "forceConsistentCasingInFileNames": true,
    },
    "exclude": ["node_modules", "**/*.test.ts"]
  }

hello-world/.prettierrc.js

module.exports = {
    semi: true,
    trailingComma: "all",
    singleQuote: true,
    printWidth: 120,
    tabWidth: 4
  };

hello-world/jest.config.ts

/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 */

export default {
    transform: {
        '^.+\\.ts?$': 'ts-jest',
    },
    clearMocks: true,
    collectCoverage: true,
    coverageDirectory: 'coverage',
    coverageProvider: 'v8',
    testMatch: ['**/tests/unit/*.test.ts'],
};

hello-world/.npmignore

tests/*

hello-world/.eslintrc.js

module.exports = {
    parser: "@typescript-eslint/parser",
    parserOptions: {
      ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
      sourceType: "module"
    },
    extends: [
      "plugin:@typescript-eslint/recommended", // recommended rules from the @typescript-eslint/eslint-plugin
      "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
    ],
    rules: {
      // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
      // e.g. "@typescript-eslint/explicit-function-return-type": "off",
    }
  };

hello-world/.eslintignore

node_modules
.aws-sam

hello-world/app.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

/**
 *
 * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
 * @param {Object} event - API Gateway Lambda Proxy Input Format
 *
 * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
 * @returns {Object} object - API Gateway Lambda Proxy Output Format
 *
 */

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'hello world',
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'some error happened',
            }),
        };
    }
};

hello-world/tests/unit/test-handler.test.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { lambdaHandler } from '../../app';
import { expect, describe, it } from '@jest/globals';

describe('Unit test for app handler', function () {
    it('verifies successful response', async () => {
        const event: APIGatewayProxyEvent = {
            httpMethod: 'get',
            body: '',
            headers: {},
            isBase64Encoded: false,
            multiValueHeaders: {},
            multiValueQueryStringParameters: {},
            path: '/hello',
            pathParameters: {},
            queryStringParameters: {},
            requestContext: {
                accountId: '123456789012',
                apiId: '1234',
                authorizer: {},
                httpMethod: 'get',
                identity: {
                    accessKey: '',
                    accountId: '',
                    apiKey: '',
                    apiKeyId: '',
                    caller: '',
                    clientCert: {
                        clientCertPem: '',
                        issuerDN: '',
                        serialNumber: '',
                        subjectDN: '',
                        validity: { notAfter: '', notBefore: '' },
                    },
                    cognitoAuthenticationProvider: '',
                    cognitoAuthenticationType: '',
                    cognitoIdentityId: '',
                    cognitoIdentityPoolId: '',
                    principalOrgId: '',
                    sourceIp: '',
                    user: '',
                    userAgent: '',
                    userArn: '',
                },
                path: '/hello',
                protocol: 'HTTP/1.1',
                requestId: 'c6af9ac6-7b61-11e6-9a41-93e8deadbeef',
                requestTimeEpoch: 1428582896000,
                resourceId: '123456',
                resourcePath: '/hello',
                stage: 'dev',
            },
            resource: '',
            stageVariables: {},
        };
        const result: APIGatewayProxyResult = await lambdaHandler(event);

        expect(result.statusCode).toEqual(200);
        expect(result.body).toEqual(
            JSON.stringify({
                message: 'hello world',
            }),
        );
    });
});

events/event.json

{
  "body": "{\"message\": \"hello world\"}",
  "resource": "/{proxy+}",
  "path": "/path/to/resource",
  "httpMethod": "POST",
  "isBase64Encoded": false,
  "queryStringParameters": {
    "foo": "bar"
  },
  "pathParameters": {
    "proxy": "/path/to/resource"
  },
  "stageVariables": {
    "baz": "qux"
  },
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
    "Accept-Encoding": "gzip, deflate, sdch",
    "Accept-Language": "en-US,en;q=0.8",
    "Cache-Control": "max-age=0",
    "CloudFront-Forwarded-Proto": "https",
    "CloudFront-Is-Desktop-Viewer": "true",
    "CloudFront-Is-Mobile-Viewer": "false",
    "CloudFront-Is-SmartTV-Viewer": "false",
    "CloudFront-Is-Tablet-Viewer": "false",
    "CloudFront-Viewer-Country": "US",
    "Host": "1234567890.execute-api.us-east-1.amazonaws.com",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Custom User Agent String",
    "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
    "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
    "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
    "X-Forwarded-Port": "443",
    "X-Forwarded-Proto": "https"
  },
  "requestContext": {
    "accountId": "123456789012",
    "resourceId": "123456",
    "stage": "prod",
    "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
    "requestTime": "09/Apr/2015:12:34:56 +0000",
    "requestTimeEpoch": 1428582896000,
    "identity": {
      "cognitoIdentityPoolId": null,
      "accountId": null,
      "cognitoIdentityId": null,
      "caller": null,
      "accessKey": null,
      "sourceIp": "127.0.0.1",
      "cognitoAuthenticationType": null,
      "cognitoAuthenticationProvider": null,
      "userArn": null,
      "userAgent": "Custom User Agent String",
      "user": null
    },
    "path": "/prod/path/to/resource",
    "resourcePath": "/{proxy+}",
    "httpMethod": "POST",
    "apiId": "1234567890",
    "protocol": "HTTP/1.1"
  }
}

ポイントは、template.yamlのこちらですね。

    Metadata: # Manage esbuild properties
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints:
        - app.ts

デプロイしてみる

とりあえずデプロイしてみましょう。

package-lock.jsonがなかったので、最初にnpm installしておきます。

$ cd hello-world
$ npm i
$ cd ..

ビルド。

$ samlocal build

問題なく終了。

Starting Build use cache
Manifest file is changed (new hash: 2188d26bd084f69dde27148437a18531) or dependency folder (.aws-sam/deps/0af770b6-5c88-49f1-9a49-c86cac821b1e) is missing for (HelloWorldFunction), downloading dependencies and copying/building source
Building codeuri: /path/to/sam-typescript-support-ga-hello-world/hello-world runtime: nodejs18.x metadata: {'BuildMethod': 'esbuild', 'BuildProperties': {'Minify': True, 'Target': 'es2020', 'Sourcemap': True, 'EntryPoints': ['app.ts']}} architecture: x86_64 functions: HelloWorldFunction
Running NodejsNpmEsbuildBuilder:CopySource
Running NodejsNpmEsbuildBuilder:NpmInstall
Running NodejsNpmEsbuildBuilder:EsbuildBundle
Running NodejsNpmEsbuildBuilder:CleanUp
Running NodejsNpmEsbuildBuilder:MoveDependencies

Sourcemap set without --enable-source-maps, adding --enable-source-maps to function HelloWorldFunction NODE_OPTIONS

You are using source maps, note that this comes with a performance hit! Set Sourcemap to false and remove NODE_OPTIONS: --enable-source-maps to disable source maps.


Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided

以前はここで、こんな感じでベータ機能を有効にするかどうかを聞かれていました。

Your template contains a resource with logical ID "ServerlessRestApi", which is a reserved logical ID in AWS SAM. It could result in unexpected behaviors and is not recommended.
Using esbuild for bundling Node.js and TypeScript is a beta feature.
Please confirm if you would like to proceed with using esbuild to build your function.
You can also enable this beta feature with 'sam build --beta-features'. [y/N]:

デプロイ。

$ samlocal deploy --region us-east-1

完了しました。

Waiting for changeset to be created..

CloudFormation stack changeset
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Operation                                      LogicalResourceId                              ResourceType                                   Replacement
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Add                                          HelloWorldFunctionHelloWorldPermissionProd     AWS::Lambda::Permission                        N/A
+ Add                                          HelloWorldFunctionRole                         AWS::IAM::Role                                 N/A
+ Add                                          ServerlessRestApiProdStage                     AWS::ApiGateway::Stage                         N/A
+ Add                                          ServerlessRestApiDeployment47fc2d5f9d          AWS::ApiGateway::Deployment                    N/A
+ Add                                          ServerlessRestApi                              AWS::ApiGateway::RestApi                       N/A
+ Add                                          HelloWorldFunction                             AWS::Lambda::Function                          N/A
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Changeset created successfully. arn:aws:cloudformation:us-east-1:000000000000:changeSet/samcli-deploy1695906178/cd54aa61


Previewing CloudFormation changeset before deployment
======================================================
Deploy this changeset? [y/N]: y

2023-09-28 22:03:01 - Waiting for stack create/update to complete

CloudFormation events from stack operations (refresh every 5.0 seconds)
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ResourceStatus                                 ResourceType                                   LogicalResourceId                              ResourceStatusReason
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
CREATE_COMPLETE                                AWS::IAM::Role                                 HelloWorldFunctionRole                         -
CREATE_COMPLETE                                AWS::Lambda::Function                          HelloWorldFunction                             -
CREATE_COMPLETE                                AWS::ApiGateway::RestApi                       ServerlessRestApi                              -
CREATE_COMPLETE                                AWS::Lambda::Permission                        HelloWorldFunctionHelloWorldPermissionProd     -
CREATE_COMPLETE                                AWS::ApiGateway::Deployment                    ServerlessRestApiDeployment47fc2d5f9d          -
CREATE_COMPLETE                                AWS::ApiGateway::Stage                         ServerlessRestApiProdStage                     -
CREATE_COMPLETE                                AWS::CloudFormation::Stack                     sam-typescript-support-ga-hello-world          -
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

CloudFormation outputs from deployed stack
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 HelloWorldApi
Description         API Gateway endpoint URL for Prod stage for Hello World function
Value               https://omw1670rqy.execute-api.amazonaws.com:4566/Prod/hello/

Key                 HelloWorldFunction
Description         Hello World Lambda Function ARN
Value               arn:aws:lambda:us-east-1:000000000000:function:sam-typescript-support-ga-hello-wor-HelloWorldFunction-27e97f91

Key                 HelloWorldFunctionIamRole
Description         Implicit IAM Role created for Hello World function
Value               arn:aws:iam::000000000000:role/sam-typescript-support-ga-hello-HelloWorldFunctionRole-d021ce86
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Successfully created/updated stack - sam-typescript-support-ga-hello-world in us-east-1

ところで、以前のAWS SAMとLocalStackの組み合わせだとsamlocal deployでは先にAmazon S3バケットを作成しておかないと
いけなかったり、2回目のデプロイがうまくいかなかったりしたのですが、今回はそういう悩みは解消されていました。

確認してみましょう。

$ REST_API_ID=$(awslocal apigateway get-rest-apis --query 'reverse(sort_by(items[], &createdDate))[0].id' --output text)
$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello
{"message":"hello world"}

OKですね。

テストも確認しておきます。

$ cd hello-world
$ npm test

> hello_world@1.0.0 test
> npm run compile && npm run unit


> hello_world@1.0.0 compile
> tsc


> hello_world@1.0.0 unit
> jest

 PASS  tests/unit/test-handler.test.ts
  Unit test for app handler
    ✓ verifies successful response (4 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |   76.66 |       75 |     100 |   76.66 |
 app.ts   |   76.66 |       75 |     100 |   76.66 | 20-26
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.398 s
Ran all test suites.

少しカスタマイズする

ここからは、少し内容をカスタマイズしてみましょう。

パッケージを最新化。

$ DEV_PACKAGES=$(cat package.json | jq '.devDependencies | keys | .[]' | perl -wp -e 's!\n! !g; s!"!!g')
$ npm uninstall $DEV_PACKAGES
$ npm i -D $DEV_PACKAGES

Node.jsの型定義はv18にしておきます。

$ npm uninstall @types/node
$ npm i -D @types/node@v18

esbuildも最新化。

$ npm uninstall esbuild
$ npm i esbuild

esbuildはdevDependenciesでいいのでは?という気がしないでもないのですが、sam build時に実行されるnpm installには
--productionオプションがついているので、devPackagesにesbuildを入れてしまうとsam buildで失敗してしまいます。

Jestはesbuild-jestで動作させるようにしましょう。

$ npm i -D esbuild-jest
$ npm uninstall ts-jest

ts-nodeもいいかな、と思います。

$ npm uninstall ts-node

依存関係はこうなりました。

  "devDependencies": {
    "@types/aws-lambda": "^8.10.122",
    "@types/jest": "^29.5.5",
    "@types/node": "^18.18.1",
    "@typescript-eslint/eslint-plugin": "^6.7.3",
    "@typescript-eslint/parser": "^6.7.3",
    "esbuild-jest": "^0.5.0",
    "eslint": "^8.50.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "jest": "^29.7.0",
    "prettier": "^3.0.3",
    "typescript": "^5.2.2"
  },
  "dependencies": {
    "esbuild": "^0.19.4"
  }

Jestの設定ファイルをJavaScriptにして、

$ mv jest.config.ts jest.config.js

でesbuild-jestを使うように変更。

jest.config.js

module.exports = {
    transform: {
        '^.+\\.ts?$': 'esbuild-jest',
    },
    clearMocks: true,
    collectCoverage: true,
    coverageDirectory: 'coverage',
    coverageProvider: 'v8',
    testMatch: ['**/tests/unit/*.test.ts'],
};

なんとなく、ソースコードのディレクトリも移動。

$ mkdir src
$ mv app.ts src

package.jsonのscriptsはこんな感じにしました。

  "scripts": {
    "lint": "eslint '{src,tests}/**.{ts,js}' --fix",
    "typecheck": "tsc",
    "typecheck:watch": "tsc --watch",
    "test": "jest"
  },

tsconfig.jsonも変更。

{
    "compilerOptions": {
      "target": "esnext",
      "strict": true,
      "noEmit": true,
      "sourceMap": false,
      "module":"commonjs",
      "moduleResolution":"node",
      "baseUrl": "./src",
      "forceConsistentCasingInFileNames": true,
      "noFallthroughCasesInSwitch": true,
      "noImplicitOverride": true,
      "noImplicitReturns": true,
      "noPropertyAccessFromIndexSignature": true,
      "esModuleInterop": true,
      "skipLibCheck": true,
      "forceConsistentCasingInFileNames": true
    },
    "exclude": ["node_modules", "**/*.test.ts"]
  }

ソースコードも、変化がわかるように修正。

src/app.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

/**
 *
 * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
 * @param {Object} event - API Gateway Lambda Proxy Input Format
 *
 * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
 * @returns {Object} object - API Gateway Lambda Proxy Output Format
 *
 */

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        const word =
            event.queryStringParameters && event.queryStringParameters['word']
                ? event.queryStringParameters['word']
                : 'TypeScript';

        return {
            statusCode: 200,
            body: JSON.stringify({
                message: `Hello AWS SAM with ${word}`,
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'some error happened',
            }),
        };
    }
};

テストも変更します。

tests/unit/test-handler.test.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { lambdaHandler } from '../../src/app';
import { expect, describe, it } from '@jest/globals';

describe('Unit test for app handler', function () {
    it('verifies successful response', async () => {
        const event: APIGatewayProxyEvent = {
            httpMethod: 'get',
            body: '',
            headers: {},
            isBase64Encoded: false,
            multiValueHeaders: {},
            multiValueQueryStringParameters: {},
            path: '/hello',
            pathParameters: {},
            queryStringParameters: {},
            requestContext: {
                accountId: '123456789012',
                apiId: '1234',
                authorizer: {},
                httpMethod: 'get',
                identity: {
                    accessKey: '',
                    accountId: '',
                    apiKey: '',
                    apiKeyId: '',
                    caller: '',
                    clientCert: {
                        clientCertPem: '',
                        issuerDN: '',
                        serialNumber: '',
                        subjectDN: '',
                        validity: { notAfter: '', notBefore: '' },
                    },
                    cognitoAuthenticationProvider: '',
                    cognitoAuthenticationType: '',
                    cognitoIdentityId: '',
                    cognitoIdentityPoolId: '',
                    principalOrgId: '',
                    sourceIp: '',
                    user: '',
                    userAgent: '',
                    userArn: '',
                },
                path: '/hello',
                protocol: 'HTTP/1.1',
                requestId: 'c6af9ac6-7b61-11e6-9a41-93e8deadbeef',
                requestTimeEpoch: 1428582896000,
                resourceId: '123456',
                resourcePath: '/hello',
                stage: 'dev',
            },
            resource: '',
            stageVariables: {},
        };
        const result: APIGatewayProxyResult = await lambdaHandler(event);

        expect(result.statusCode).toEqual(200);
        expect(result.body).toEqual(
            JSON.stringify({
                message: 'Hello AWS SAM with TypeScript',
            }),
        );
    });
});

テストが通ることを確認。

$ npm test

上位ディレクトリに戻って、

$ cd ..

ソースコードの置き場所を変更したので、template.yamlのEntryPointsを見直し。

    Metadata: # Manage esbuild properties
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "esnext"
        Sourcemap: true
        EntryPoints:
        - src/app.ts

あとはビルドしてデプロイです。

$ samlocal build
$ samlocal deploy --region us-east-1

確認。

$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello
{"message":"Hello AWS SAM with TypeScript"}


$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello?word=NodeJs
{"message":"Hello AWS SAM with NodeJs"}

OKですね。

おわりに

AWS SAMのTypeScriptサポートがいつの間にかGAになっていたので、簡単に確認してみました。

簡単にTypeScriptが使えるのは良いですね。

個人的には、あとはAWS SAMでAmazon API Gatewayを使うAWS Lambda関数を作成すると、AWS SAMがAmazon API Gatewayを
作成してしまうことを避けられない(既存のAmazon API Gatewayを使えない)問題が解決してくれるといいなと思うのですが。

この話はずっと残ったままなので、変わらないんでしょうね。

Can not link an existing API gateway as an event for a lambda resource using AWS SAM · Issue #2075 · aws/serverless-application-model · GitHub

Possibility to get AWS::Serverless::RestApi passed a template variable to Template files with Functions only · aws/serverless-application-model · Discussion #2734 · GitHub