Create a Serverless Service
To setup a new serverless service using AWS Step Function + SQS with Lambda Trigger in this repo, follow these steps:
Install Serverless Framework CLI
- To install Serverless Framework CLI, run the following:
npm i -g serverless
Directory Setup
- Create a new folder with the name of the service inside the
services
directory- e.g: If your service name is
vanity
, then create the new directoryservices/vanity
.
- e.g: If your service name is
- Run the following command and follow the prompt:
npm init --scope @acmutd -w ./services/vanity
info
- When run the above command, or any command afterward, make sure to replace
vanity
with the name of your service.
- Run
Monorepo: sync workplace features
in VSCode command palette.
Install Serverless Function Dependencies
- To install the dependencies that will be used by Serverless Framework, run the following command:
npm i -w @acmutd/vanity -D serverless-iam-roles-per-function serverless-create-global-dynamodb-table serverless-offline serverless-prune-plugin serverless-step-functions
- To install AWS Lambda related dependencies, run the following command:
npm i -w @acmutd/vanity aws-sdk aws-lambda
- In case root
package.json
does not have powertools, run the following command to install them:
npm i @dazn/lambda-powertools-cloudwatchevents-client @dazn/lambda-powertools-correlation-ids @dazn/lambda-powertools-logger @dazn/lambda-powertools-pattern-basic @dazn/lambda-powertools-lambda-client @dazn/lambda-powertools-sns-client @dazn/lambda-powertools-sqs-client @dazn/lambda-powertools-dynamodb-client @dazn/lambda-powertools-kinesis-client
Setup Linting
- To install linting dependencies, run the following command:
npm i -D -w @acmutd/vanity eslint eslint-config-airbnb-base typescript-eslint eslint-plugin-import eslint-import-resolver-alias eslint-plugin-module-resolver @typescript-eslint/eslint-plugin @typescript-eslint/parser
- Create a file named
.eslintrc.json
in your service directory with the following configurations:
{
"extends": ["../../.eslintrc.json"],
"parserOptions": {
"tsconfigRootDir": ".",
"project": "./tsconfig(.*)?.json"
},
"env": {
"node": true
},
"root": true,
"rules": {
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-var-requires": "off",
"global-require": "off",
"react/no-array-index-key": "off",
"react/jsx-props-no-spreading": "off"
},
"settings": {
"import/resolve": {
"typescript": {
"alwaysTryType": true,
"paths": "./tsconfig.json"
},
"alias": {
"map": [
["@src", "./src"],
["@tests", "./tests"]
],
"extensions": [".ts"]
}
}
}
}
- Create a file named
.eslintignore
with the following configuration:
node_modules
.build
dist
generated
*.config.*
- Create a file named
.lintstagedrc.json
with the following configuration:
{ "**/*.{js,ts,tsx}": ["eslint --fix", "prettier --write '**/*.{js,ts,tsx}'"] }
Setup Jest for Testing
- To install Jest dependencies, run the following command:
npm i -w @acmutd/vanity -D jest babel-jest @babel/core @babel/preset-env @babel/preset-typescript
- Create a file named
babel.config.js
with the following configuration:
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};
Configure Scripts
- Configure the
scripts
object in thepackage.json
file as follow:
"scripts": {
"test": "NODE_ENV=test ../../node_modules/.bin/jest --ci --verbose",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"buildtest": "tsc --noEmit",
"sls:offline": "sls offline --httpPort='5000'",
"sls:deploy": "sls deploy",
"sls:remove": "sls remove"
},
info
If your service is dependent on Doppler environment variable, the command for sls:offline
should be the following:
doppler run -- sls offline
Configure Webpack
- To install dependencies for Webpack, run the following command:
npm i -w @acmutd/vanity -D fork-ts-checker-webpack-plugin webpack-node-externals serverless-webpack
- Create a file named
webpack.config.js
with the following configurations:
const path = require('path');
const slsw = require('serverless-webpack');
const nodeExternals = require('webpack-node-externals');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
context: __dirname,
mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
entry: slsw.lib.entries,
devtool: slsw.lib.webpack.isLocal ? 'eval-cheap-module-source-map' : 'source-map',
resolve: {
extensions: ['.mjs', '.js', '.json', '.ts'],
// symlinks: false,
// cacheWithContext: false,
alias: {
'@src': path.resolve(__dirname, './src'),
'@tests': path.resolve(__dirname, './tests'),
},
},
output: {
libraryTarget: 'commonjs',
path: path.join(__dirname, '.webpack'),
filename: '[name].js',
},
// experiments: {
// topLevelAwait: true,
// },
target: 'node',
externalsPresets: { node: true },
externals: [nodeExternals()],
optimization: {
minimize: true,
},
module: {
rules: [
{
test: /\.(tsx?)$/,
loader: 'babel-loader',
exclude: [
[
path.resolve(__dirname, 'node_modules'),
path.resolve(__dirname, '.serverless'),
path.resolve(__dirname, '.webpack'),
],
],
options: {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
plugins: [
'babel-plugin-transform-typescript-metadata',
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
'babel-plugin-parameter-decorator',
['@babel/plugin-proposal-private-methods', { loose: true }],
['@babel/plugin-proposal-private-property-in-object', { loose: true }],
],
},
},
],
},
plugins: [new ForkTsCheckerWebpackPlugin()],
};
Setup Typescript
- Create a
tsconfig.json
file with the following configuration:
{
"compilerOptions": {
"strict": true,
// project options
"lib": ["ES2020"], // specifies which default set of type definitions to use ("DOM", "ES6", etc)
"outDir": "lib", // .js (as well as .d.ts, .js.map, etc.) files will be emitted into this directory.,
"removeComments": true, // Strips all comments from TypeScript files when converting into JavaScript- you rarely read compiled code so this saves space
"target": "ES2020", // Target environment. Most modern browsers support ES6, but you may want to set it to newer or older. (defaults to ES3)
"module": "ESNext",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"],
// Module resolution
"baseUrl": "./", // Lets you set a base directory to resolve non-absolute module names.
"esModuleInterop": true, // fixes some issues TS originally had with the ES6 spec where TypeScript treats CommonJS/AMD/UMD modules similar to ES6 module
"moduleResolution": "node", // Pretty much always node for modern JS. Other option is "classic"
"paths": {
"@src/*": ["src/*"],
"@tests/*": ["tests/*"]
}, // A series of entries which re-map imports to lookup locations relative to the baseUrl
// Source Map
"sourceMap": true, // enables the use of source maps for debuggers and error reporting etc
"sourceRoot": "/", // Specify the location where a debugger should locate TypeScript files instead of relative source locations.
// Strict Checks
"alwaysStrict": true, // Ensures that your files are parsed in the ECMAScript strict mode, and emit “use strict” for each source file.
"allowUnreachableCode": false, // pick up dead code paths
"noImplicitAny": true, // In some cases where no type annotations are present, TypeScript will fall back to a type of any for a variable when it cannot infer the type.
"strictNullChecks": true, // When strictNullChecks is true, null and undefined have their own distinct types and you’ll get a type error if you try to use them where a concrete value is expected.
// Linter Checks
// "noImplicitReturns": true,
"noUncheckedIndexedAccess": true, // accessing index must always check for undefined
// "noUnusedLocals": true, // Report errors on unused local variables.
// "noUnusedParameters": true, // Report errors on unused parameters in functions
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowSyntheticDefaultImports": true
},
"typeRoots": ["./node_modules/@types", "../../node_modules/@types"],
"include": ["./**/*.ts"],
"exclude": [
"node_modules/**/*",
".serverless/**/*",
".webpack/**/*",
"_warmup/**/*",
".vscode/**/*",
"*.config.js"
],
"extends": "../../tsconfig.json"
}
Configure Serverless YML file
- Create the
serverless.yml
file with the following configuration:
service: vanity
custom:
webpack:
webpackConfig: webpack.config.js
includeModules: true
packager: npm
packagerOptions:
lockFile: '../../package-lock.json'
prune:
automatic: true
number: 3
plugins:
- serverless-offline
- serverless-webpack
- serverless-iam-roles-per-function
- serverless-create-global-dynamodb-table
- serverless-prune-plugin
- serverless-step-functions
provider:
name: aws
region: us-east-1
runtime: nodejs14.x
memorySize: 256
Configure a SQS Resource
- Add the following configuration into
serverless.yml
file:
resources:
Resources:
VanitySQS:
Type: AWS::SQS::Queue
Configure a Step Function workflow
- Add the following configuration into
serverless.yml
file:
stepFunctions:
stateMachines:
VanitySF:
name: VanityStepFunction
definition:
StartAt: AddPayloadToSQS
States:
AddPayloadToSQS:
Type: Task
Resource: arn:aws:states:::sqs:sendMessage.waitForTaskToken
Parameters:
QueueUrl:
Ref: VanitySQS
MessageBody:
payload.$: '$'
taskToken.$: '$$.Task.Token'
End: true
Create a Serverless function
- Create file
src/handler.ts
, in which will contain the serverless function that be called when a message is added into the SQS. - An example of the
src/handler.ts
will be as follow:
const baseHandler = async (
event: ValidatedSQSEvent<VanityReqBody>,
context: Context,
callback: Callback
): Promise<void> => {
await Promise.all(
event.Records.map(async (record): Promise<PromiseResult<any, any>> => {
const stepFunction = new aws.StepFunctions();
const { taskToken, payload: vanityReqData } = record.body;
try {
Log.info(`Generating Vanity Link`);
const { success, data } = await buildVanityLink(vanityReqData);
Log.info(`Vanity Link Generation Success Status: ${success ? 'OK' : 'Failed'}`, { data });
Log.info(
`Sending data to Step Function with the following ARN: ${process.env.SENDGRID_ARN!}`
);
if (!process.env.SENDGRID_ARN) {
return stepFunction
.sendTaskFailure({ taskToken, error: 'No ARN for SendGrid Step Function' })
.promise();
}
await stepFunction
.startExecution({
stateMachineArn: process.env.SENDGRID_ARN!,
name: uuid(),
input: JSON.stringify({
from: 'development@acmutd.co',
from_name: 'ACM Development',
to: vanityReqData.email,
template_id: process.env.VANITY_TEMPLATE_ID!,
dynamicSubstitutions: {
preheader: 'Successful Generation of Vanity Link',
subject: 'Vanity Link Confirmation',
vanity_link: `${vanityReqData.subdomain}.${vanityReqData.primaryDomain}/${vanityReqData.slashtag}`,
first_name: vanityReqData.firstName,
last_name: vanityReqData.lastName,
},
}),
})
.promise();
return stepFunction
.sendTaskSuccess({
taskToken,
output: '"Task execution successful"',
})
.promise();
} catch (error) {
console.log(error);
stepFunction.sendTaskFailure({ taskToken }, (err, data) => {
console.log(err);
});
}
})
);
};
export const main = middy(baseHandler).use(sqsJsonBodyParser());
- Add the following into
serverless.yml
:
functions:
vanity:
handler: src/handler.main
iamRoleStatements:
- Effect: 'Allow'
Action:
- states:SendTaskSuccess
- states:SendTaskFailure
- states:StartExecution
Resource: '*'
events:
- sqs:
arn:
Fn::GetAtt: [VanitySQS, Arn]
environment:
URL_ROOT: ${env:URL_ROOT}
REBRANDLY_APIKEY: ${env:REBRANDLY_APIKEY}
REBRANDLY_APIKEY2: ${env:REBRANDLY_APIKEY2}
REBRANDLY_URL: ${env:REBRANDLY_URL}
SENDGRID_ARN: ${env:SENDGRID_ARN}
VANITY_TEMPLATE_ID: ${env:VANITY_TEMPLATE_ID}
Deploy Service
- To deploy service, run the following command:
npm run sls:deploy
Delete Service
- To remove a service from AWS, run the following command:
npm run sls:remove
Full YML Configuration
- After following this tutorial, the
serverless.yml
should look similar to the following:
service: vanity
custom:
webpack:
webpackConfig: webpack.config.js
includeModules: true
packager: npm
packagerOptions:
lockFile: '../../package-lock.json'
prune:
automatic: true
number: 3
plugins:
- serverless-offline
- serverless-webpack
- serverless-iam-roles-per-function
- serverless-create-global-dynamodb-table
- serverless-prune-plugin
- serverless-step-functions
provider:
name: aws
region: us-east-1
runtime: nodejs14.x
memorySize: 256
resources:
Resources:
VanitySQS:
Type: AWS::SQS::Queue
stepFunctions:
stateMachines:
VanitySF:
name: VanityStepFunction
definition:
StartAt: AddPayloadToSQS
States:
AddPayloadToSQS:
Type: Task
Resource: arn:aws:states:::sqs:sendMessage.waitForTaskToken
Parameters:
QueueUrl:
Ref: VanitySQS
MessageBody:
payload.$: '$'
taskToken.$: '$$.Task.Token'
End: true
functions:
vanity:
handler: src/handler.main
iamRoleStatements:
- Effect: 'Allow'
Action:
- states:SendTaskSuccess
- states:SendTaskFailure
- states:StartExecution
Resource: '*'
events:
- sqs:
arn:
Fn::GetAtt: [VanitySQS, Arn]
environment:
URL_ROOT: ${env:URL_ROOT}
REBRANDLY_APIKEY: ${env:REBRANDLY_APIKEY}
REBRANDLY_APIKEY2: ${env:REBRANDLY_APIKEY2}
REBRANDLY_URL: ${env:REBRANDLY_URL}
SENDGRID_ARN: ${env:SENDGRID_ARN}
VANITY_TEMPLATE_ID: ${env:VANITY_TEMPLATE_ID}