├── .eslintrc.yml ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── deps └── dynamodb-tables │ ├── Dockerfile │ └── tables.sh ├── docker-compose.yml ├── docs ├── README.md ├── architecture.png ├── architecture.pptx └── screenshot.png ├── infrastructure ├── chat-service.yml ├── cloudfront.yml ├── cluster.yml ├── message-index-drop.yml ├── message-indexer.yml ├── message-search.yml ├── resources.yml ├── teardown.sh └── web.yml ├── package-lock.json ├── package.json └── services ├── message-index-drop ├── app.js ├── package-lock.json └── package.json ├── message-indexer ├── app.js ├── package-lock.json └── package.json ├── message-search ├── app.js ├── package-lock.json └── package.json ├── socket ├── .dockerignore ├── Dockerfile ├── index.js ├── lib │ ├── config.js │ ├── db.js │ ├── dynamodb-client.js │ ├── dynamodb-doc-client.js │ ├── message.js │ ├── presence.js │ ├── redis.js │ └── user.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── static │ │ ├── images │ │ ├── ecs.svg │ │ ├── fargate.svg │ │ ├── lambda.svg │ │ ├── search.svg │ │ └── serverless.svg │ │ ├── main.js │ │ ├── style.css │ │ ├── vue-virtual-scroll.js │ │ ├── vue.js │ │ └── vue.min.js └── server.js ├── test-suite ├── .dockerignore ├── Dockerfile ├── package.json └── test │ ├── config.js │ └── core-api.js └── web ├── Dockerfile └── public ├── favicon.ico ├── index.html └── static ├── images ├── ecs.svg ├── fargate.svg ├── lambda.svg ├── search.svg └── serverless.svg ├── main.js ├── style.css ├── vue-virtual-scroll.js ├── vue.js └── vue.min.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: standard-with-typescript 5 | overrides: [] 6 | parserOptions: 7 | ecmaVersion: latest 8 | sourceType: module 9 | rules: {} 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nathan Peck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | docker compose up -d 3 | 4 | build: 5 | docker compose build client 6 | docker compose up --no-deps -d client 7 | 8 | test: 9 | # Rebuild, recreate, and restart all the application containers 10 | docker compose build client test 11 | docker compose up -d --no-build --remove-orphans --no-deps client 12 | # Run the test container in the foreground so we get pretty color output in the terminal 13 | docker compose run --no-deps test 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fargate.chat 2 | 3 | [](https://fargate.chat) 4 | 5 | A Slack-like chat app built with [Node.js](https://nodejs.org/en/) and [Vue.js](https://vuejs.org/) and deployed using Amazon Web Services. 6 | 7 | This application uses Docker containers in [AWS Fargate](https://aws.amazon.com/fargate/), and [AWS App Runner](https://aws.amazon.com/apprunner/), as well as [AWS Lambda](https://aws.amazon.com/lambda/) functions. 8 | 9 | A sample version of the app is hosted at: https://fargate.chat 10 | 11 | ### Architecture features 12 | 13 | - No EC2 instances. One of the goals of this application architecture is that it is very hands off, nothing to manage or update. Serverless features are turned on wherever possible. 14 | - Fully defined as infrastructure as code, using [AWS CloudFormation](https://aws.amazon.com/cloudformation/) to create all the application resources. 15 | - Included `docker-compose.yml` which stands up a local copy of DynamoDB, and local copy of the socket.io chat server. This allows you to develop locally and see the application at `http://localhost:3000`. The only feature that does not run locally, and is just simulated is the full text chat search. 16 | - Integration test suite container that you can run against a copy of the application, either locally or remotely. 17 | 18 | Read more about [the architecture and services in use](/docs). 19 | 20 | ### Application features 21 | 22 | - User account functionality, including anonymous accounts 23 | - Real time chat message sending over WebSocket protocol, with persistance in DynamoDB 24 | - Live user presence, including announcments when users join or leave 25 | - Unread message indicator 26 | - Typing indicator, including support for multiple typers at once. 27 | - Full text search for chat messages, powered by Amazon OpenSearch Serverless 28 | - Infinite virtual DOM scrolling, for performant feeling even when many thousands of messages have been sent in a room. 29 | 30 | ### Things to note in this app 31 | 32 | - Working configuration for routing WebSocket connections with Socket.io through CloudFront 33 | - Example of how to setup OpenSearch access policies for a Lambda function 34 | - Horizontally scalable Socket.io using the Redis adaptor and Amazon ElastiCache 35 | 36 | ### Setup instructions 37 | 38 | Install if not already installed: 39 | 40 | * Docker: https://docs.docker.com/get-docker/ 41 | * AWS SAM CLI: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html 42 | * Install Node and NPM if not present 43 | 44 | ``` 45 | # Setup some env variables for later 46 | # Can try other regions as long as all required services are in that region 47 | export AWS_REGION=us-east-2 48 | export AWS_ACCOUNT=$(aws sts get-caller-identity --query Account --output text) 49 | 50 | # Install the dependencies off of NPM 51 | (cd services/message-indexer; npm install) 52 | (cd services/message-search; npm install) 53 | 54 | # Setup the base VPC and networking stuff. 55 | sam deploy \ 56 | --region $AWS_REGION \ 57 | --template-file infrastructure/cluster.yml \ 58 | --stack-name chat-cluster \ 59 | --capabilities CAPABILITY_IAM 60 | 61 | # The shared resources like DynamoDB table and OpenSearch Serverless 62 | sam deploy \ 63 | --region $AWS_REGION \ 64 | --template-file infrastructure/resources.yml \ 65 | --stack-name chat-resources \ 66 | --capabilities CAPABILITY_IAM 67 | 68 | # Create an ECR repository to host the container image 69 | aws ecr create-repository \ 70 | --region $AWS_REGION \ 71 | --repository-name fargate-chat 72 | 73 | # Login to the ECR repository 74 | aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com 75 | 76 | # Build the Docker image 77 | docker build -t fargate-chat ./services/socket 78 | 79 | # Upload the built container image to the repository 80 | docker tag fargate-chat:latest $AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/fargate-chat:latest 81 | docker push $AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/fargate-chat:latest 82 | 83 | # Start up the container that runs in AWS Fargate 84 | sam deploy \ 85 | --region $AWS_REGION \ 86 | --template-file infrastructure/chat-service.yml \ 87 | --stack-name chat-service \ 88 | --capabilities CAPABILITY_IAM \ 89 | --parameter-overrides ImageUrl=$AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/fargate-chat:latest 90 | 91 | # Deploy the component which indexes sent chat messages 92 | sam deploy \ 93 | --region $AWS_REGION \ 94 | --template-file infrastructure/message-indexer.yml \ 95 | --stack-name chat-message-indexer \ 96 | --resolve-s3 \ 97 | --capabilities CAPABILITY_IAM 98 | 99 | # Deploy the component which provides search autocomplete and API Gateway 100 | sam deploy \ 101 | --region $AWS_REGION \ 102 | --template-file infrastructure/message-search.yml \ 103 | --stack-name chat-message-search \ 104 | --resolve-s3 \ 105 | --capabilities CAPABILITY_IAM 106 | 107 | # Build the component which hosts static web content 108 | aws ecr create-repository \ 109 | --region $AWS_REGION \ 110 | --repository-name apprunner-web 111 | 112 | # Build the Docker image 113 | docker build -t apprunner-web ./services/web 114 | 115 | # Upload the built container image to the repository 116 | docker tag apprunner-web:latest $AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/apprunner-web:latest 117 | docker push $AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/apprunner-web:latest 118 | 119 | # Launch the web container inside of App Runner 120 | sam deploy \ 121 | --region $AWS_REGION \ 122 | --template-file infrastructure/web.yml \ 123 | --stack-name chat-web \ 124 | --resolve-s3 \ 125 | --parameter-overrides ImageUrl=$AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/apprunner-web:latest \ 126 | --capabilities CAPABILITY_IAM 127 | 128 | # Deploy CloudFront distribution which ties main app and search endpoint together on one domain 129 | sam deploy \ 130 | --region $AWS_REGION \ 131 | --template-file infrastructure/cloudfront.yml \ 132 | --stack-name chat-cloudfront \ 133 | --resolve-s3 \ 134 | --capabilities CAPABILITY_IAM 135 | 136 | # Get the application URL to view it 137 | aws cloudformation describe-stacks \ 138 | --stack-name chat-cloudfront \ 139 | --query "Stacks[0].Outputs[?OutputKey==\`PublicURL\`].OutputValue" \ 140 | --output text 141 | 142 | # Open the URL in the browser window 143 | 144 | ``` 145 | 146 | ## Project layout 147 | 148 | ``` 149 | /deps - Folder used only for local development purposes 150 | /infrastructure - CloudFormation templates used for deployment to AWS 151 | /services - Independent code components that make up the app 152 | /socket - The core Node.js socket.io app, which runs in AWS Fargate 153 | /message-indexer - Lambda function which triggers on DynamoDB updates, to index chat messages 154 | /message-search - Lamdba function triggered by API Gateway, answers search queries 155 | /test-suite - Container used for local tests, runs integ tests against socket service 156 | /message-index-drop - Admin Lambda function which drops the OpenSearch Serverless collection 157 | /web - Frontend service which serves the static web files in production 158 | ``` 159 | 160 | ## Admin and dev actions 161 | 162 | If you want to drop the search index (will be recreated next time you send a message, though older messages will no longer be remembered) 163 | 164 | ``` 165 | sam deploy \ 166 | --region $AWS_REGION \ 167 | --template-file infrastructure/message-index-drop.yml \ 168 | --stack-name chat-index-drop \ 169 | --resolve-s3 \ 170 | --capabilities CAPABILITY_IAM 171 | ``` 172 | 173 | Run a local copy of the socket app for testing: 174 | 175 | ``` 176 | make run # Standup local DynamoDB and local stack 177 | make test # Rebuild and run tests locally 178 | 179 | # Open localhost:3000 in browser to view app 180 | ``` 181 | 182 | Launch the public facing CloudFormation stack with a custom domain name and SSL cert: 183 | 184 | ``` 185 | sam deploy \ 186 | --region $AWS_REGION \ 187 | --template-file infrastructure/cloudfront.yml \ 188 | --stack-name chat-cloudfront \ 189 | --resolve-s3 \ 190 | --capabilities CAPABILITY_IAM \ 191 | --parameter-overrides DomainName=fargate.chat CertArn=arn:aws:acm:us-east-1:228578805541:certificate/45b4037a-2257-48ff-b3ae-b8e3e0523e85 192 | ``` -------------------------------------------------------------------------------- /deps/dynamodb-tables/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.6 2 | RUN apk -v --update add \ 3 | python \ 4 | py-pip \ 5 | groff \ 6 | less \ 7 | mailcap \ 8 | && \ 9 | pip install --upgrade awscli==1.14.5 s3cmd==2.0.1 python-magic && \ 10 | apk -v --purge del py-pip && \ 11 | rm /var/cache/apk/* 12 | VOLUME /root/.aws 13 | VOLUME /project 14 | WORKDIR /project 15 | ADD tables.sh . 16 | ENTRYPOINT ["sh", "tables.sh"] 17 | -------------------------------------------------------------------------------- /deps/dynamodb-tables/tables.sh: -------------------------------------------------------------------------------- 1 | # Local Users table 2 | RESULT=$(aws dynamodb describe-table \ 3 | --region us-east-1 \ 4 | --endpoint-url $DYNAMODB_ENDPOINT \ 5 | --table-name test_Users) 6 | CODE=$? 7 | if [ $? -eq 0 ]; then 8 | aws dynamodb delete-table \ 9 | --region us-east-1 \ 10 | --endpoint-url $DYNAMODB_ENDPOINT \ 11 | --table-name test_Users 12 | fi 13 | aws dynamodb create-table \ 14 | --region us-east-1 \ 15 | --endpoint-url $DYNAMODB_ENDPOINT \ 16 | --table-name test_Users \ 17 | --key-schema AttributeName=username,KeyType=HASH \ 18 | --attribute-definitions AttributeName=username,AttributeType=S \ 19 | --provisioned-throughput ReadCapacityUnits=10,WriteCapacityUnits=10 20 | 21 | # Local Messages table 22 | RESULT=$(aws dynamodb describe-table \ 23 | --region us-east-1 \ 24 | --endpoint-url $DYNAMODB_ENDPOINT \ 25 | --table-name test_Messages) 26 | CODE=$? 27 | if [ $? -eq 0 ]; then 28 | aws dynamodb delete-table \ 29 | --region us-east-1 \ 30 | --endpoint-url $DYNAMODB_ENDPOINT \ 31 | --table-name test_Messages 32 | fi 33 | aws dynamodb create-table \ 34 | --region us-east-1 \ 35 | --endpoint-url $DYNAMODB_ENDPOINT \ 36 | --table-name test_Messages \ 37 | --key-schema AttributeName=room,KeyType=HASH AttributeName=message,KeyType=RANGE \ 38 | --attribute-definitions AttributeName=room,AttributeType=S AttributeName=message,AttributeType=S \ 39 | --provisioned-throughput ReadCapacityUnits=10,WriteCapacityUnits=10 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | networks: 3 | chat: 4 | 5 | services: 6 | # Launch the Redis used for syncing messages between copies of the client app 7 | redis: 8 | image: redis 9 | networks: 10 | - chat 11 | ports: 12 | - 6379:6379 13 | 14 | # Launch a local version of DynamoDB 15 | dynamodb-local: 16 | networks: 17 | - chat 18 | command: "-jar DynamoDBLocal.jar -inMemory -sharedDb -delayTransientStatuses 0" 19 | image: public.ecr.aws/aws-dynamodb-local/aws-dynamodb-local:1.19.0 20 | ports: 21 | - 8000:8000 22 | 23 | # Ephemeral container used for creating the tables in DynamoDB 24 | dynamodb-tables: 25 | depends_on: 26 | - dynamodb-local 27 | networks: 28 | - chat 29 | build: ./deps/dynamodb-tables 30 | environment: 31 | DYNAMODB_ENDPOINT: http://dynamodb-local:8000 32 | AWS_ACCESS_KEY_ID: test 33 | AWS_SECRET_ACCESS_KEY: test 34 | 35 | # The actual client application 36 | client: 37 | depends_on: 38 | - redis 39 | - dynamodb-tables 40 | networks: 41 | - chat 42 | build: ./services/socket 43 | environment: 44 | LOCAL: "true" 45 | ENV_NAME: test 46 | REDIS_ENDPOINT: redis://redis:6379 47 | DYNAMODB_ENDPOINT: http://dynamodb-local:8000 48 | USER_TABLE: test_Users 49 | MESSAGE_TABLE: test_Messages 50 | AWS_REGION: test 51 | AWS_ACCESS_KEY_ID: test 52 | AWS_SECRET_ACCESS_KEY: test 53 | ports: 54 | - 3000:3000 55 | 56 | web: 57 | networks: 58 | - chat 59 | build: ./services/web 60 | ports: 61 | - 3001:80 62 | 63 | # The test suite 64 | test: 65 | depends_on: 66 | - client 67 | networks: 68 | - chat 69 | build: ./services/test-suite 70 | environment: 71 | SELF_URL: http://client:3000 72 | WS_URL: ws://client:3000 73 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 |  4 | 5 | This is a chat application that demonstrates the usage of the following AWS compute services: 6 | 7 | - AWS App Runner 8 | - AWS Elastic Container Service + AWS Fargate 9 | - AWS Lambda 10 | 11 | It also makes use of the following AWS services: 12 | 13 | - Amazon DynamoDB 14 | - Amazon OpenSearch Serverless 15 | - Amazon ElastiCache 16 | - Amazon API Gateway 17 | - Application Load Balancer 18 | - AWS CloudFront 19 | 20 | 21 | ### AWS App Runner 22 | 23 | App Runner is being used to host the static web content for the frontend of the website. Although this single page app does not currently do an server side rendering, this is place where server side rendering would occur. 24 | 25 | ### AWS Elastic Container Service + AWS Fargate 26 | 27 | The core chat feature is powered by a WebSocket application. This 28 | application is hosted in AWS Fargate, and orchestrated by AWS Elastic Container Service. Ingress to the socket application is via an Application Load Balancer. The core socket application stores durable data like user accounts and chat messages in Amazon DynamoDB. It also syncs state between the server containers using ElastiCache pubsub. This includes storing ephemeral data like chat presence and typing status. 29 | 30 | ### AWS Lambda 31 | 32 | Lambda functions are used as an event driven workflow for indexing search messages. As messages are stored in the DynamoDB table, it triggers a DynamoDB Stream event which runs an asynchronous Lambda function which adds the chat message to a search index in Amazon OpenSearch Serverless. Additionally, there is a search endpoint powered by Amazon API Gateway. 33 | 34 | ### Amazon DynamoDB 35 | 36 | This is the main durable store of state for the application. It stores the user accounts and chat messages that are sent. It also provides an event stream which triggers the search indexing Lambda fucntion when chat messages are sent. 37 | 38 | ### Amazon OpenSearch Serverless 39 | 40 | OpenSearch Serverless is an open source, distributed search and analytics suite derived from Elasticsearch. It is used to index all the chat messages to power the search feature of the application. 41 | 42 | ### Amazon ElastiCache 43 | 44 | ElastiCache provides a Redis cluster that is used in pubsub mode for syncing chat messages from one websocket server to another. It also serves as an in-memory data store for chat presence and typing status. 45 | 46 | ### Amazon API Gateway 47 | 48 | This is a serverless ingress for the search endpoint. When a search query is sent it will charge per request sent, but have no overhead cost otherwise. 49 | 50 | ### Application Load Balancer 51 | 52 | The Application Load Balancer is used to distribute incoming requests across the available WebSocket servers. It maintains persistant bidirectional WebSocket connection so that chat clients can push chat messages to the server, and the server can push chat messages out to the clients as they arrive. 53 | 54 | ### AWS CloudFront 55 | 56 | CloudFront is used as the front facing content delivery network for all three components. It routes request for static web content to the App Runner container, WebSocket requests to the AWS Fargate hosted container, and search queries to the API Gateway for the Lambda powered search function. -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathanpeck/socket.io-chat-fargate/c539fd0b1d3cc469a83a6db304716cef97201245/docs/architecture.png -------------------------------------------------------------------------------- /docs/architecture.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathanpeck/socket.io-chat-fargate/c539fd0b1d3cc469a83a6db304716cef97201245/docs/architecture.pptx -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathanpeck/socket.io-chat-fargate/c539fd0b1d3cc469a83a6db304716cef97201245/docs/screenshot.png -------------------------------------------------------------------------------- /infrastructure/chat-service.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Socket.io chat service 3 | Parameters: 4 | EnvironmentName: 5 | Type: String 6 | Default: production 7 | Description: A name for the environment that this cloudformation will be part of. 8 | Used to locate other resources in the same environment. 9 | ServiceName: 10 | Type: String 11 | Default: chat 12 | Description: A name for the service 13 | ImageUrl: 14 | Type: String 15 | Default: nginx 16 | Description: The url of a docker image that contains the application process that 17 | will handle the traffic for this service 18 | ContainerPort: 19 | Type: Number 20 | Default: 3000 21 | Description: What port number the application inside the docker container is binding to 22 | ContainerCpu: 23 | Type: Number 24 | Default: 1024 25 | Description: How much CPU to give the container. 1024 is 1 CPU 26 | ContainerMemory: 27 | Type: Number 28 | Default: 2048 29 | Description: How much memory in megabytes to give the container 30 | Path: 31 | Type: String 32 | Default: "*" 33 | Description: A path on the public load balancer that this service 34 | should be connected to. Use * to send all load balancer 35 | traffic to this service. 36 | Priority: 37 | Type: Number 38 | Default: 1 39 | Description: The priority for the routing rule added to the load balancer. 40 | This only applies if your have multiple services which have been 41 | assigned to different paths on the load balancer. 42 | DesiredCount: 43 | Type: Number 44 | Default: 2 45 | Description: How many copies of the service task to run 46 | 47 | Resources: 48 | # A log group for storing the container logs for this service 49 | LogGroup: 50 | Type: AWS::Logs::LogGroup 51 | Properties: 52 | LogGroupName: !Sub "${EnvironmentName}-service-${ServiceName}" 53 | 54 | # The task definition. This is a simple metadata description of what 55 | # container to run, and what resource requirements it has. 56 | TaskDefinition: 57 | Type: AWS::ECS::TaskDefinition 58 | Properties: 59 | Family: !Ref 'ServiceName' 60 | Cpu: !Ref 'ContainerCpu' 61 | Memory: !Ref 'ContainerMemory' 62 | NetworkMode: awsvpc 63 | RequiresCompatibilities: 64 | - FARGATE 65 | ExecutionRoleArn: 66 | Fn::ImportValue: !Sub "${EnvironmentName}:ECSTaskExecutionRole" 67 | TaskRoleArn: 68 | Fn::ImportValue: !Sub "${EnvironmentName}:ChatServiceRole" 69 | ContainerDefinitions: 70 | - Name: !Ref 'ServiceName' 71 | Cpu: !Ref 'ContainerCpu' 72 | Memory: !Ref 'ContainerMemory' 73 | Image: !Ref 'ImageUrl' 74 | Environment: 75 | - Name: REGION 76 | Value: !Ref 'AWS::Region' 77 | - Name: LOCAL 78 | # This disables a redirect from HTTP to HTTPS, as HTTPS only works when you 79 | # actually buy a domain and setup an SSL certificate 80 | Value: true 81 | - Name: NODE_ENV 82 | Value: production 83 | - Name: REDIS_ENDPOINT 84 | Value: !Sub 85 | - 'redis://${RedisEndpointValue}:6379' 86 | - RedisEndpointValue: 87 | Fn::ImportValue: !Sub "${EnvironmentName}:RedisEndpoint" 88 | - Name: USER_TABLE 89 | Value: 90 | Fn::ImportValue: !Sub "${EnvironmentName}:UsersTable" 91 | - Name: MESSAGE_TABLE 92 | Value: 93 | Fn::ImportValue: !Sub "${EnvironmentName}:MessagesTable" 94 | - Name: DYNAMODB_ENDPOINT 95 | Value: !Sub 'https://dynamodb.${AWS::Region}.amazonaws.com' 96 | - Name: ENV_NAME 97 | Value: !Ref 'EnvironmentName' 98 | PortMappings: 99 | - ContainerPort: !Ref 'ContainerPort' 100 | LogConfiguration: 101 | LogDriver: 'awslogs' 102 | Options: 103 | awslogs-group: !Sub "${EnvironmentName}-service-${ServiceName}" 104 | awslogs-region: !Ref 'AWS::Region' 105 | awslogs-stream-prefix: !Ref 'ServiceName' 106 | 107 | # The service. The service is a resource which allows you to run multiple 108 | # copies of a type of task, and gather up their logs and metrics, as well 109 | # as monitor the number of running tasks and replace any that have crashed 110 | Service: 111 | Type: AWS::ECS::Service 112 | DependsOn: 113 | - HTTPRule 114 | # - HTTPSRule 115 | Properties: 116 | ServiceName: !Ref 'ServiceName' 117 | Cluster: 118 | Fn::ImportValue: !Sub "${EnvironmentName}:ClusterName" 119 | LaunchType: FARGATE 120 | DeploymentConfiguration: 121 | MaximumPercent: 200 122 | MinimumHealthyPercent: 75 123 | DesiredCount: !Ref 'DesiredCount' 124 | NetworkConfiguration: 125 | AwsvpcConfiguration: 126 | AssignPublicIp: ENABLED 127 | SecurityGroups: 128 | - Fn::ImportValue: !Sub "${EnvironmentName}:FargateContainerSecurityGroup" 129 | Subnets: 130 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetOne" 131 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetTwo" 132 | TaskDefinition: !Ref 'TaskDefinition' 133 | LoadBalancers: 134 | - ContainerName: !Ref 'ServiceName' 135 | ContainerPort: !Ref 'ContainerPort' 136 | TargetGroupArn: !Ref 'TargetGroup' 137 | 138 | # A target group. This is used for keeping track of all the tasks, and 139 | # what IP addresses / port numbers they have. You can query it yourself, 140 | # to use the addresses yourself, but most often this target group is just 141 | # connected to an application load balancer, or network load balancer, so 142 | # it can automatically distribute traffic across all the targets. 143 | TargetGroup: 144 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 145 | Properties: 146 | HealthCheckIntervalSeconds: 6 147 | HealthCheckPath: / 148 | Matcher: 149 | HttpCode: 200,301 150 | HealthCheckProtocol: HTTP 151 | HealthCheckTimeoutSeconds: 5 152 | HealthyThresholdCount: 2 153 | TargetType: ip 154 | Name: !Sub "${EnvironmentName}-${ServiceName}" 155 | Port: 80 156 | Protocol: HTTP 157 | UnhealthyThresholdCount: 2 158 | TargetGroupAttributes: 159 | - Key: stickiness.enabled 160 | Value: true 161 | - Key: deregistration_delay.timeout_seconds 162 | Value: 30 163 | VpcId: 164 | Fn::ImportValue: !Sub "${EnvironmentName}:VPCId" 165 | 166 | # Create rules to forward HTTP traffic to the service's 167 | # target group. 168 | HTTPRule: 169 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 170 | Properties: 171 | Actions: 172 | - TargetGroupArn: !Ref 'TargetGroup' 173 | Type: 'forward' 174 | Conditions: 175 | - Field: path-pattern 176 | Values: [!Ref 'Path'] 177 | ListenerArn: 178 | Fn::ImportValue: !Sub "${EnvironmentName}:PublicListenerHTTP" 179 | Priority: !Ref 'Priority' 180 | 181 | # Enable autoscaling for this service 182 | ScalableTarget: 183 | Type: AWS::ApplicationAutoScaling::ScalableTarget 184 | DependsOn: Service 185 | Properties: 186 | ServiceNamespace: 'ecs' 187 | ScalableDimension: 'ecs:service:DesiredCount' 188 | ResourceId: 189 | !Sub 190 | - 'service/${ClusterNameValue}/${ServiceName}' 191 | - ClusterNameValue: 192 | Fn::ImportValue: !Sub "${EnvironmentName}:ClusterName" 193 | MinCapacity: 2 194 | MaxCapacity: 10 195 | RoleARN: 196 | Fn::ImportValue: 197 | !Join [':', [!Ref 'EnvironmentName', 'AutoscalingRole']] 198 | 199 | # Create scaling policies for the service 200 | ScaleDownPolicy: 201 | Type: AWS::ApplicationAutoScaling::ScalingPolicy 202 | DependsOn: ScalableTarget 203 | Properties: 204 | PolicyName: !Sub 'scale-${EnvironmentName}-${ServiceName}-down' 205 | PolicyType: StepScaling 206 | ResourceId: 207 | !Sub 208 | - 'service/${ClusterNameValue}/${ServiceName}' 209 | - ClusterNameValue: 210 | Fn::ImportValue: !Sub "${EnvironmentName}:ClusterName" 211 | ScalableDimension: 'ecs:service:DesiredCount' 212 | ServiceNamespace: 'ecs' 213 | StepScalingPolicyConfiguration: 214 | AdjustmentType: 'ChangeInCapacity' 215 | StepAdjustments: 216 | - MetricIntervalUpperBound: 0 217 | ScalingAdjustment: -1 218 | MetricAggregationType: 'Average' 219 | Cooldown: 60 220 | 221 | ScaleUpPolicy: 222 | Type: AWS::ApplicationAutoScaling::ScalingPolicy 223 | DependsOn: ScalableTarget 224 | Properties: 225 | PolicyName: !Sub 'scale-${EnvironmentName}-${ServiceName}-up' 226 | PolicyType: StepScaling 227 | ResourceId: 228 | !Sub 229 | - 'service/${ClusterNameValue}/${ServiceName}' 230 | - ClusterNameValue: 231 | Fn::ImportValue: !Sub "${EnvironmentName}:ClusterName" 232 | ScalableDimension: 'ecs:service:DesiredCount' 233 | ServiceNamespace: 'ecs' 234 | StepScalingPolicyConfiguration: 235 | AdjustmentType: 'ChangeInCapacity' 236 | StepAdjustments: 237 | - MetricIntervalLowerBound: 0 238 | MetricIntervalUpperBound: 15 239 | ScalingAdjustment: 1 240 | - MetricIntervalLowerBound: 15 241 | MetricIntervalUpperBound: 25 242 | ScalingAdjustment: 2 243 | - MetricIntervalLowerBound: 25 244 | ScalingAdjustment: 3 245 | MetricAggregationType: 'Average' 246 | Cooldown: 60 247 | 248 | # Create alarms to trigger these policies 249 | LowCpuUsageAlarm: 250 | Type: AWS::CloudWatch::Alarm 251 | Properties: 252 | AlarmName: !Sub 'low-cpu-${EnvironmentName}-${ServiceName}' 253 | AlarmDescription: !Sub 'Low CPU utilization for service ${ServiceName} in environment ${EnvironmentName}' 254 | MetricName: CPUUtilization 255 | Namespace: AWS/ECS 256 | Dimensions: 257 | - Name: ServiceName 258 | Value: !Ref 'ServiceName' 259 | - Name: ClusterName 260 | Value: 261 | Fn::ImportValue: !Sub "${EnvironmentName}:ClusterName" 262 | Statistic: Average 263 | Period: 60 264 | EvaluationPeriods: 1 265 | Threshold: 20 266 | ComparisonOperator: LessThanOrEqualToThreshold 267 | AlarmActions: 268 | - !Ref ScaleDownPolicy 269 | 270 | HighCpuUsageAlarm: 271 | Type: AWS::CloudWatch::Alarm 272 | Properties: 273 | AlarmName: !Sub 'high-cpu-${EnvironmentName}-${ServiceName}' 274 | AlarmDescription: !Sub 'High CPU utilization for service ${ServiceName} in environment ${EnvironmentName}' 275 | MetricName: CPUUtilization 276 | Namespace: AWS/ECS 277 | Dimensions: 278 | - Name: ServiceName 279 | Value: !Ref 'ServiceName' 280 | - Name: ClusterName 281 | Value: 282 | Fn::ImportValue: !Sub "${EnvironmentName}:ClusterName" 283 | Statistic: Average 284 | Period: 60 285 | EvaluationPeriods: 1 286 | Threshold: 70 287 | ComparisonOperator: GreaterThanOrEqualToThreshold 288 | AlarmActions: 289 | - !Ref ScaleUpPolicy 290 | -------------------------------------------------------------------------------- /infrastructure/cloudfront.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: A CloudFront distribution that glues the separate 3 | services together into one website 4 | Parameters: 5 | EnvironmentName: 6 | Type: String 7 | Default: production 8 | Description: A name for the environment that this cloudformation will be part of. 9 | 10 | CertArn: 11 | Type: String 12 | Default: 'no custom domain' 13 | Description: Optional ARN of an Amazon Certificate Manager cert that is the 14 | SSL cert for the application 15 | 16 | DomainName: 17 | Type: String 18 | Default: 'no custom domain' 19 | Description: Optional domain name for the application when deployed. 20 | 21 | Conditions: 22 | CustomHttpsDomainEnabled: !Not [ !Equals [ !Ref DomainName, 'no custom domain']] 23 | 24 | Resources: 25 | 26 | # Describes what to cache and for how long 27 | ChatAppCachePolicy: 28 | Type: AWS::CloudFront::CachePolicy 29 | Properties: 30 | CachePolicyConfig: 31 | Name: 'chat-app-default' 32 | DefaultTTL: 0 33 | MaxTTL: 10 34 | MinTTL: 0 35 | ParametersInCacheKeyAndForwardedToOrigin: 36 | CookiesConfig: 37 | CookieBehavior: none 38 | EnableAcceptEncodingGzip: true 39 | HeadersConfig: 40 | HeaderBehavior: whitelist 41 | Headers: 42 | - Sec-WebSocket-Key 43 | - Sec-WebSocket-Version 44 | - Sec-WebSocket-Protocol 45 | - Sec-WebSocket-Accept 46 | - Sec-WebSocket-Extensions 47 | QueryStringsConfig: 48 | QueryStringBehavior: all 49 | 50 | # Describes the different origins and how to map incoming 51 | # traffic to different origin servers 52 | ChatAppDistribution: 53 | Type: AWS::CloudFront::Distribution 54 | Properties: 55 | DistributionConfig: 56 | Aliases: 57 | - !If [ 'CustomHttpsDomainEnabled', !Ref DomainName, !Ref AWS::NoValue ] 58 | ViewerCertificate: 59 | # If no custom domain specified, then use the default cert 60 | CloudFrontDefaultCertificate: !If [ 'CustomHttpsDomainEnabled', !Ref AWS::NoValue, true] 61 | 62 | # Otherwise use out custom cert from Amazon Certificate Manager 63 | AcmCertificateArn: !If [ 'CustomHttpsDomainEnabled', !Ref CertArn, !Ref AWS::NoValue ] 64 | MinimumProtocolVersion: !If [ 'CustomHttpsDomainEnabled', 'TLSv1.2_2021', !Ref AWS::NoValue ] 65 | SslSupportMethod: !If [ 'CustomHttpsDomainEnabled', 'sni-only', !Ref AWS::NoValue ] 66 | DefaultRootObject: 'index.html' 67 | Enabled: true 68 | Origins: 69 | - Id: 'web-static' 70 | DomainName: 71 | Fn::ImportValue: !Sub "${EnvironmentName}:WebEndpoint" 72 | CustomOriginConfig: 73 | OriginProtocolPolicy: 'https-only' 74 | OriginKeepaliveTimeout: 60 75 | - Id: 'socket-app' 76 | DomainName: 77 | Fn::ImportValue: !Sub "${EnvironmentName}:ExternalDnsName" 78 | CustomOriginConfig: 79 | OriginProtocolPolicy: 'http-only' 80 | OriginKeepaliveTimeout: 60 81 | - Id: 'search-gateway' 82 | DomainName: 83 | !Sub 84 | - '${ChatMessageSearchValue}.execute-api.${AWS::Region}.amazonaws.com' 85 | - ChatMessageSearchValue: 86 | Fn::ImportValue: !Sub '${EnvironmentName}:ChatMessageSearchId' 87 | CustomOriginConfig: 88 | OriginProtocolPolicy: 'https-only' 89 | OriginKeepaliveTimeout: 60 90 | # Default behavior sends all static web traffic to the 91 | # AppRunner hosted endpoint 92 | DefaultCacheBehavior: 93 | AllowedMethods: 94 | - GET 95 | - HEAD 96 | - OPTIONS 97 | - PUT 98 | - POST 99 | - PATCH 100 | - DELETE 101 | CachePolicyId: !Ref ChatAppCachePolicy 102 | ViewerProtocolPolicy: redirect-to-https 103 | TargetOriginId: 'web-static' 104 | Compress: true 105 | CacheBehaviors: 106 | # Search traffic goes to API Gateway of the Lambda function 107 | - PathPattern: '/search*' 108 | TargetOriginId: 'search-gateway' 109 | CachePolicyId: !Ref ChatAppCachePolicy 110 | ViewerProtocolPolicy: redirect-to-https 111 | Compress: true 112 | # Socket.io traffic goes to the Fargate hosted container 113 | - PathPattern: '/socket.io*' 114 | TargetOriginId: 'socket-app' 115 | CachePolicyId: !Ref ChatAppCachePolicy 116 | ViewerProtocolPolicy: redirect-to-https 117 | Compress: true 118 | 119 | Outputs: 120 | PublicURL: 121 | Description: The name of the ECS cluster 122 | Value: !GetAtt ChatAppDistribution.DomainName 123 | Export: 124 | Name: !Sub '${EnvironmentName}:PublicURL' -------------------------------------------------------------------------------- /infrastructure/cluster.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: The baseline resources used to create a Fargate environment 3 | to launch containerized applications in. 4 | Parameters: 5 | EnvironmentName: 6 | Type: String 7 | Default: production 8 | Description: A name for the environment that this cloudformation will be part of. 9 | # Uncomment to add an SSL cert to public facing app 10 | #CertificateArn: 11 | # Type: String 12 | # Description: ARN of the Amazon Certificate Manager SSL certificate to use for this app 13 | 14 | Mappings: 15 | # Hard values for the subnet masks. These masks define 16 | # the range of internal IP addresses that can be assigned. 17 | # The VPC can have all IP's from 10.0.0.0 to 10.0.255.255 18 | # There are two subnets which cover the ranges: 19 | # 20 | # 10.0.0.0 - 10.0.0.255 21 | # 10.0.1.0 - 10.0.1.255 22 | # 23 | # If you need more IP addresses (perhaps you have so many 24 | # instances that you run out) then you can customize these 25 | # ranges to add more 26 | SubnetConfig: 27 | VPC: 28 | CIDR: '10.0.0.0/16' 29 | PublicOne: 30 | CIDR: '10.0.0.0/24' 31 | PublicTwo: 32 | CIDR: '10.0.1.0/24' 33 | Resources: 34 | # VPC in which containers will be networked. 35 | # It has two public subnets 36 | # We distribute the subnets across the first two available subnets 37 | # for the region, for high availability. 38 | VPC: 39 | Type: AWS::EC2::VPC 40 | Properties: 41 | EnableDnsSupport: true 42 | EnableDnsHostnames: true 43 | CidrBlock: !FindInMap ['SubnetConfig', 'VPC', 'CIDR'] 44 | 45 | # Two public subnets, where containers can have public IP addresses 46 | PublicSubnetOne: 47 | Type: AWS::EC2::Subnet 48 | Properties: 49 | AvailabilityZone: 50 | Fn::Select: 51 | - 0 52 | - Fn::GetAZs: {Ref: 'AWS::Region'} 53 | VpcId: !Ref 'VPC' 54 | CidrBlock: !FindInMap ['SubnetConfig', 'PublicOne', 'CIDR'] 55 | MapPublicIpOnLaunch: true 56 | PublicSubnetTwo: 57 | Type: AWS::EC2::Subnet 58 | Properties: 59 | AvailabilityZone: 60 | Fn::Select: 61 | - 1 62 | - Fn::GetAZs: {Ref: 'AWS::Region'} 63 | VpcId: !Ref 'VPC' 64 | CidrBlock: !FindInMap ['SubnetConfig', 'PublicTwo', 'CIDR'] 65 | MapPublicIpOnLaunch: true 66 | 67 | # Setup networking resources for the public subnets. Containers 68 | # in the public subnets have public IP addresses and the routing table 69 | # sends network traffic via the internet gateway. 70 | InternetGateway: 71 | Type: AWS::EC2::InternetGateway 72 | GatewayAttachement: 73 | Type: AWS::EC2::VPCGatewayAttachment 74 | DependsOn: InternetGateway 75 | Properties: 76 | VpcId: !Ref 'VPC' 77 | InternetGatewayId: !Ref 'InternetGateway' 78 | PublicRouteTable: 79 | Type: AWS::EC2::RouteTable 80 | Properties: 81 | VpcId: !Ref 'VPC' 82 | PublicRoute: 83 | Type: AWS::EC2::Route 84 | DependsOn: GatewayAttachement 85 | Properties: 86 | RouteTableId: !Ref 'PublicRouteTable' 87 | DestinationCidrBlock: '0.0.0.0/0' 88 | GatewayId: !Ref 'InternetGateway' 89 | PublicSubnetOneRouteTableAssociation: 90 | Type: AWS::EC2::SubnetRouteTableAssociation 91 | Properties: 92 | SubnetId: !Ref PublicSubnetOne 93 | RouteTableId: !Ref PublicRouteTable 94 | PublicSubnetTwoRouteTableAssociation: 95 | Type: AWS::EC2::SubnetRouteTableAssociation 96 | Properties: 97 | SubnetId: !Ref PublicSubnetTwo 98 | RouteTableId: !Ref PublicRouteTable 99 | 100 | # ECS Resources 101 | ECSCluster: 102 | Type: AWS::ECS::Cluster 103 | 104 | # A security group for the containers we will run in Fargate. 105 | # Two rules, allowing network traffic from a public facing load 106 | # balancer and from other members of the security group. 107 | # 108 | # Remove any of the following ingress rules that are not needed. 109 | # If you want to make direct requests to a container using its 110 | # public IP address you'll need to add a security group rule 111 | # to allow traffic from all IP addresses. 112 | FargateContainerSecurityGroup: 113 | Type: AWS::EC2::SecurityGroup 114 | Properties: 115 | GroupDescription: Access to the Fargate containers 116 | VpcId: !Ref 'VPC' 117 | EcsSecurityGroupIngressFromPublicALB: 118 | Type: AWS::EC2::SecurityGroupIngress 119 | Properties: 120 | Description: Ingress from the public ALB 121 | GroupId: !Ref 'FargateContainerSecurityGroup' 122 | IpProtocol: -1 123 | SourceSecurityGroupId: !Ref 'PublicLoadBalancerSG' 124 | EcsSecurityGroupIngressFromSelf: 125 | Type: AWS::EC2::SecurityGroupIngress 126 | Properties: 127 | Description: Ingress from other containers in the same security group 128 | GroupId: !Ref 'FargateContainerSecurityGroup' 129 | IpProtocol: -1 130 | SourceSecurityGroupId: !Ref 'FargateContainerSecurityGroup' 131 | 132 | # Load balancers for getting traffic to containers. 133 | # This sample template creates one load balancer: 134 | # 135 | # - One public load balancer, hosted in public subnets that is accessible 136 | # to the public, and is intended to route traffic to one or more public 137 | # facing services. 138 | 139 | # A public facing load balancer, this is used for accepting traffic from the public 140 | # internet and directing it to public facing microservices 141 | PublicLoadBalancerSG: 142 | Type: AWS::EC2::SecurityGroup 143 | Properties: 144 | GroupDescription: Access to the public facing load balancer 145 | VpcId: !Ref 'VPC' 146 | SecurityGroupIngress: 147 | # Allow access to ALB from anywhere on the internet 148 | - CidrIp: 0.0.0.0/0 149 | IpProtocol: -1 150 | PublicLoadBalancer: 151 | Type: AWS::ElasticLoadBalancingV2::LoadBalancer 152 | Properties: 153 | Scheme: internet-facing 154 | LoadBalancerAttributes: 155 | - Key: idle_timeout.timeout_seconds 156 | Value: '30' 157 | Subnets: 158 | # The load balancer is placed into the public subnets, so that traffic 159 | # from the internet can reach the load balancer directly via the internet gateway 160 | - !Ref PublicSubnetOne 161 | - !Ref PublicSubnetTwo 162 | SecurityGroups: [!Ref 'PublicLoadBalancerSG'] 163 | # A dummy target group is used to setup the ALB to just drop traffic 164 | # initially, before any real service target groups have been added. 165 | DummyTargetGroupPublic: 166 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 167 | Properties: 168 | HealthCheckIntervalSeconds: 6 169 | HealthCheckPath: / 170 | HealthCheckProtocol: HTTP 171 | HealthCheckTimeoutSeconds: 5 172 | HealthyThresholdCount: 2 173 | Name: !Join ['-', [!Ref 'EnvironmentName', 'drop-1']] 174 | Port: 80 175 | Protocol: HTTP 176 | UnhealthyThresholdCount: 2 177 | VpcId: !Ref 'VPC' 178 | PublicLoadBalancerListenerHTTP: 179 | Type: AWS::ElasticLoadBalancingV2::Listener 180 | DependsOn: 181 | - PublicLoadBalancer 182 | Properties: 183 | DefaultActions: 184 | - TargetGroupArn: !Ref 'DummyTargetGroupPublic' 185 | Type: 'forward' 186 | LoadBalancerArn: !Ref 'PublicLoadBalancer' 187 | Port: 80 188 | Protocol: HTTP 189 | # Uncomment to add a HTTPS listener with SSL cert 190 | # PublicLoadBalancerListenerHTTPS: 191 | # Type: AWS::ElasticLoadBalancingV2::Listener 192 | # DependsOn: 193 | # - PublicLoadBalancer 194 | # Properties: 195 | # DefaultActions: 196 | # - TargetGroupArn: !Ref 'DummyTargetGroupPublic' 197 | # Type: 'forward' 198 | # LoadBalancerArn: !Ref 'PublicLoadBalancer' 199 | # Port: 443 200 | # Protocol: HTTPS 201 | # Certificates: 202 | # - CertificateArn: !Ref 'CertificateArn' 203 | 204 | # This is an IAM role which authorizes ECS to manage resources on your 205 | # account on your behalf, such as updating your load balancer with the 206 | # details of where your containers are, so that traffic can reach your 207 | # containers. 208 | ECSRole: 209 | Type: AWS::IAM::Role 210 | Properties: 211 | AssumeRolePolicyDocument: 212 | Statement: 213 | - Effect: Allow 214 | Principal: 215 | Service: [ecs.amazonaws.com] 216 | Action: ['sts:AssumeRole'] 217 | Path: / 218 | Policies: 219 | - PolicyName: ecs-service 220 | PolicyDocument: 221 | Statement: 222 | - Effect: Allow 223 | Action: 224 | # Rules which allow ECS to attach network interfaces to instances 225 | # on your behalf in order for awsvpc networking mode to work right 226 | - 'ec2:AttachNetworkInterface' 227 | - 'ec2:CreateNetworkInterface' 228 | - 'ec2:CreateNetworkInterfacePermission' 229 | - 'ec2:DeleteNetworkInterface' 230 | - 'ec2:DeleteNetworkInterfacePermission' 231 | - 'ec2:Describe*' 232 | - 'ec2:DetachNetworkInterface' 233 | 234 | # Rules which allow ECS to update load balancers on your behalf 235 | # with the information sabout how to send traffic to your containers 236 | - 'elasticloadbalancing:DeregisterInstancesFromLoadBalancer' 237 | - 'elasticloadbalancing:DeregisterTargets' 238 | - 'elasticloadbalancing:Describe*' 239 | - 'elasticloadbalancing:RegisterInstancesWithLoadBalancer' 240 | - 'elasticloadbalancing:RegisterTargets' 241 | Resource: '*' 242 | 243 | # This is a role which is used by the ECS tasks themselves. 244 | ECSTaskExecutionRole: 245 | Type: AWS::IAM::Role 246 | Properties: 247 | AssumeRolePolicyDocument: 248 | Statement: 249 | - Effect: Allow 250 | Principal: 251 | Service: [ecs-tasks.amazonaws.com] 252 | Action: ['sts:AssumeRole'] 253 | Path: / 254 | Policies: 255 | - PolicyName: AmazonECSTaskExecutionRolePolicy 256 | PolicyDocument: 257 | Statement: 258 | - Effect: Allow 259 | Action: 260 | # Allow the ECS Tasks to download images from ECR 261 | - 'ecr:GetAuthorizationToken' 262 | - 'ecr:BatchCheckLayerAvailability' 263 | - 'ecr:GetDownloadUrlForLayer' 264 | - 'ecr:BatchGetImage' 265 | 266 | # Allow the ECS tasks to upload logs to CloudWatch 267 | - 'logs:CreateLogStream' 268 | - 'logs:PutLogEvents' 269 | Resource: '*' 270 | 271 | # A role used by AWS Autoscaling to get the stats for a Fargate 272 | # service, and update it to increase or decrease the number of containers 273 | AutoscalingRole: 274 | Type: AWS::IAM::Role 275 | Properties: 276 | AssumeRolePolicyDocument: 277 | Statement: 278 | - Effect: Allow 279 | Principal: 280 | Service: [application-autoscaling.amazonaws.com] 281 | Action: ['sts:AssumeRole'] 282 | Path: / 283 | Policies: 284 | - PolicyName: service-autoscaling 285 | PolicyDocument: 286 | Statement: 287 | - Effect: Allow 288 | Action: 289 | - 'application-autoscaling:*' 290 | - 'cloudwatch:DescribeAlarms' 291 | - 'cloudwatch:PutMetricAlarm' 292 | - 'ecs:DescribeServices' 293 | - 'ecs:UpdateService' 294 | Resource: '*' 295 | 296 | # These are the values output by the CloudFormation template. Be careful 297 | # about changing any of them, because of them are exported with specific 298 | # names so that the other task related CF templates can use them. 299 | Outputs: 300 | ClusterName: 301 | Description: The name of the ECS cluster 302 | Value: !Ref 'ECSCluster' 303 | Export: 304 | Name: !Sub '${EnvironmentName}:ClusterName' 305 | ExternalUrl: 306 | Description: The url of the external load balancer 307 | Value: !Sub 'http://${PublicLoadBalancer.DNSName}' 308 | Export: 309 | Name: !Sub '${EnvironmentName}:ExternalUrl' 310 | ExternalDnsName: 311 | Description: The DNS name 312 | Value: !GetAtt 'PublicLoadBalancer.DNSName' 313 | Export: 314 | Name: !Sub '${EnvironmentName}:ExternalDnsName' 315 | ECSRole: 316 | Description: The ARN of the ECS role 317 | Value: !GetAtt 'ECSRole.Arn' 318 | Export: 319 | Name: !Sub '${EnvironmentName}:ECSRole' 320 | ECSTaskExecutionRole: 321 | Description: The ARN of the ECS role 322 | Value: !GetAtt 'ECSTaskExecutionRole.Arn' 323 | Export: 324 | Name: !Sub '${EnvironmentName}:ECSTaskExecutionRole' 325 | AutoscalingRole: 326 | Description: The ARN of the ECS role 327 | Value: !GetAtt 'ECSTaskExecutionRole.Arn' 328 | Export: 329 | Name: !Sub '${EnvironmentName}:AutoscalingRole' 330 | HTTPListener: 331 | Description: The ARN of the public load balancer's HTTP Listener 332 | Value: !Ref PublicLoadBalancerListenerHTTP 333 | Export: 334 | Name: !Sub '${EnvironmentName}:PublicListenerHTTP' 335 | # HTTPSListener: 336 | # Description: The ARN of the public load balancer's HTTPS Listener 337 | # Value: !Ref PublicLoadBalancerListenerHTTPS 338 | # Export: 339 | # Name: !Sub '${EnvironmentName}:PublicListenerHTTPS' 340 | VPCId: 341 | Description: The ID of the VPC that this stack is deployed in 342 | Value: !Ref 'VPC' 343 | Export: 344 | Name: !Sub '${EnvironmentName}:VPCId' 345 | PublicSubnetOne: 346 | Description: Public subnet one 347 | Value: !Ref 'PublicSubnetOne' 348 | Export: 349 | Name: !Sub '${EnvironmentName}:PublicSubnetOne' 350 | PublicSubnetTwo: 351 | Description: Public subnet two 352 | Value: !Ref 'PublicSubnetTwo' 353 | Export: 354 | Name: !Sub '${EnvironmentName}:PublicSubnetTwo' 355 | FargateContainerSecurityGroup: 356 | Description: A security group used to allow Fargate containers to receive traffic 357 | Value: !Ref 'FargateContainerSecurityGroup' 358 | Export: 359 | Name: !Sub '${EnvironmentName}:FargateContainerSecurityGroup' 360 | -------------------------------------------------------------------------------- /infrastructure/message-index-drop.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: 3 | - 'AWS::Serverless-2016-10-31' 4 | - 'AWS::LanguageExtensions' 5 | Description: A Lambda function used for admin purposes to drop the OpenSearch serverless collection 6 | Parameters: 7 | EnvironmentName: 8 | Type: String 9 | Default: production 10 | Description: A name for the environment that this cloudformation will be part of. 11 | Used to locate other resources in the same environment. 12 | 13 | Resources: 14 | ChatMessageDropperSecurityGroup: 15 | Type: AWS::EC2::SecurityGroup 16 | Properties: 17 | GroupDescription: "Chat message dropper security group" 18 | VpcId: 19 | Fn::ImportValue: !Sub "${EnvironmentName}:VPCId" 20 | 21 | ChatMessageDropper: 22 | Type: 'AWS::Serverless::Function' 23 | Properties: 24 | Handler: app.handler 25 | Runtime: nodejs14.x 26 | CodeUri: ../services/message-index-drop 27 | Description: Drop the chat message search index 28 | MemorySize: 128 29 | Timeout: 30 30 | Environment: 31 | Variables: 32 | MESSAGE_COLLECTION_ENDPOINT: 33 | Fn::ImportValue: !Sub "${EnvironmentName}:MessagesCollectionEndpoint" 34 | VpcConfig: 35 | SecurityGroupIds: 36 | - !Ref ChatMessageDropperSecurityGroup 37 | SubnetIds: 38 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetOne" 39 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetTwo" 40 | 41 | # Create the access policy which allows the IAM role of the Lambda function to use OpenSearch Serverless 42 | MessageCollectionAccessPolicy: 43 | Type: AWS::OpenSearchServerless::AccessPolicy 44 | Properties: 45 | Name: 'chat-dropper-access-policy' 46 | Description: 'Who can access the indexed chat messages, and how' 47 | Type: 'data' 48 | Policy: 49 | Fn::ToJsonString: 50 | - Description: Access for Lambda function URL 51 | Rules: 52 | - ResourceType: collection 53 | Resource: 54 | - collection/chat-messages 55 | Permission: 56 | - aoss:CreateCollectionItems 57 | - aoss:DeleteCollectionItems 58 | - aoss:UpdateCollectionItems 59 | - aoss:DescribeCollectionItems 60 | - ResourceType: index 61 | Resource: 62 | - index/chat-messages/* 63 | Permission: 64 | - aoss:CreateIndex 65 | - aoss:DeleteIndex 66 | - aoss:UpdateIndex 67 | - aoss:DescribeIndex 68 | - aoss:ReadDocument 69 | - aoss:WriteDocument 70 | Principal: 71 | - !GetAtt ChatMessageDropperRole.Arn 72 | 73 | # Create the network access policy which allows the Lambda function to talk to collection 74 | ChatIndexerAccessToOpenSearchServerless: 75 | Type: AWS::EC2::SecurityGroupIngress 76 | Properties: 77 | IpProtocol: -1 78 | FromPort: 0 79 | ToPort: 65535 80 | SourceSecurityGroupId: !GetAtt 'ChatMessageDropperSecurityGroup.GroupId' 81 | GroupId: 82 | Fn::ImportValue: !Sub '${EnvironmentName}:OpenSearchServerlessGroupId' 83 | -------------------------------------------------------------------------------- /infrastructure/message-indexer.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: 3 | - 'AWS::Serverless-2016-10-31' 4 | - 'AWS::LanguageExtensions' 5 | Description: A Lambda function that receives DynamoDB Stream updates from the chat message table 6 | and indexes the chat messages in OpenSearch Serverless 7 | Parameters: 8 | EnvironmentName: 9 | Type: String 10 | Default: production 11 | Description: A name for the environment that this cloudformation will be part of. 12 | Used to locate other resources in the same environment. 13 | 14 | Resources: 15 | ChatMessageIndexerSecurityGroup: 16 | Type: AWS::EC2::SecurityGroup 17 | Properties: 18 | GroupDescription: "Chat message indexer security group" 19 | VpcId: 20 | Fn::ImportValue: !Sub "${EnvironmentName}:VPCId" 21 | 22 | ChatMessageIndexer: 23 | Type: 'AWS::Serverless::Function' 24 | Properties: 25 | Handler: app.handler 26 | Runtime: nodejs14.x 27 | CodeUri: ../services/message-indexer 28 | Description: An Amazon DynamoDB trigger that indexes messages in the messages table 29 | MemorySize: 128 30 | Timeout: 30 31 | Environment: 32 | Variables: 33 | MESSAGE_COLLECTION_ENDPOINT: 34 | Fn::ImportValue: !Sub "${EnvironmentName}:MessagesCollectionEndpoint" 35 | VpcConfig: 36 | SecurityGroupIds: 37 | - !Ref ChatMessageIndexerSecurityGroup 38 | SubnetIds: 39 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetOne" 40 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetTwo" 41 | Policies: 42 | Statement: 43 | - Effect: "Allow" 44 | Action: 45 | - "aoss:*" 46 | Resource: 47 | - "*" 48 | Events: 49 | MyDynamoDBtable: 50 | Type: DynamoDB 51 | Properties: 52 | Stream: 53 | Fn::ImportValue: !Sub "${EnvironmentName}:MessagesTableStreamArn" 54 | StartingPosition: TRIM_HORIZON 55 | BatchSize: 100 56 | 57 | # Create the access policy which allows the IAM role of the Lambda function to use OpenSearch Serverless 58 | MessageCollectionAccessPolicy: 59 | Type: AWS::OpenSearchServerless::AccessPolicy 60 | Properties: 61 | Name: 'chat-message-access-policy' 62 | Description: 'Who can access the indexed chat messages, and how' 63 | Type: 'data' 64 | Policy: 65 | Fn::ToJsonString: 66 | - Description: Access for Lambda function URL 67 | Rules: 68 | - ResourceType: collection 69 | Resource: 70 | - collection/chat-messages 71 | Permission: 72 | - aoss:CreateCollectionItems 73 | - aoss:DeleteCollectionItems 74 | - aoss:UpdateCollectionItems 75 | - aoss:DescribeCollectionItems 76 | - ResourceType: index 77 | Resource: 78 | - index/chat-messages/* 79 | Permission: 80 | - aoss:CreateIndex 81 | - aoss:DeleteIndex 82 | - aoss:UpdateIndex 83 | - aoss:DescribeIndex 84 | - aoss:ReadDocument 85 | - aoss:WriteDocument 86 | Principal: 87 | - !GetAtt ChatMessageIndexerRole.Arn 88 | 89 | # Create the network access policy which allows the Lambda function to talk to collection 90 | ChatIndexerAccessToOpenSearchServerless: 91 | Type: AWS::EC2::SecurityGroupIngress 92 | Properties: 93 | IpProtocol: -1 94 | FromPort: 0 95 | ToPort: 65535 96 | SourceSecurityGroupId: !GetAtt 'ChatMessageIndexerSecurityGroup.GroupId' 97 | GroupId: 98 | Fn::ImportValue: !Sub '${EnvironmentName}:OpenSearchServerlessGroupId' 99 | -------------------------------------------------------------------------------- /infrastructure/message-search.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: 3 | - 'AWS::Serverless-2016-10-31' 4 | - 'AWS::LanguageExtensions' 5 | Description: A Lambda function which fetches chat message search results from OpenSearch Serverless 6 | Parameters: 7 | EnvironmentName: 8 | Type: String 9 | Default: production 10 | Description: A name for the environment that this cloudformation will be part of. 11 | Used to locate other resources in the same environment. 12 | 13 | Resources: 14 | 15 | # A serverless API Gateway for searching chat messages 16 | ChatMessageSearchApi: 17 | Type: AWS::Serverless::HttpApi 18 | 19 | ChatMessageSearchSecurityGroup: 20 | Type: AWS::EC2::SecurityGroup 21 | Properties: 22 | GroupDescription: "Chat message indexer security group" 23 | VpcId: 24 | Fn::ImportValue: !Sub "${EnvironmentName}:VPCId" 25 | 26 | ChatMessageSearch: 27 | Type: 'AWS::Serverless::Function' 28 | Properties: 29 | Handler: app.handler 30 | Runtime: nodejs14.x 31 | CodeUri: ../services/message-search 32 | Description: An Amazon DynamoDB trigger that indexes messages in the messages table 33 | MemorySize: 128 34 | Timeout: 30 35 | Environment: 36 | Variables: 37 | MESSAGE_COLLECTION_ENDPOINT: 38 | Fn::ImportValue: !Sub "${EnvironmentName}:MessagesCollectionEndpoint" 39 | VpcConfig: 40 | SecurityGroupIds: 41 | - !Ref ChatMessageSearchSecurityGroup 42 | SubnetIds: 43 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetOne" 44 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetTwo" 45 | Policies: 46 | Statement: 47 | - Effect: "Allow" 48 | Action: 49 | - "aoss:*" 50 | Resource: 51 | - "*" 52 | Events: 53 | AllEvents: 54 | Type: HttpApi 55 | Properties: 56 | ApiId: !Ref ChatMessageSearchApi 57 | Path: /search 58 | Method: GET 59 | 60 | # Create the access policy which allows the IAM role of the Lambda function to use OpenSearch Serverless 61 | MessageCollectionAccessPolicy: 62 | Type: AWS::OpenSearchServerless::AccessPolicy 63 | Properties: 64 | Name: 'chat-search-access-policy' 65 | Description: 'Who can access the indexed chat messages, and how' 66 | Type: 'data' 67 | Policy: 68 | Fn::ToJsonString: 69 | - Description: Access for Lambda function URL 70 | Rules: 71 | - ResourceType: collection 72 | Resource: 73 | - collection/chat-messages 74 | Permission: 75 | - aoss:CreateCollectionItems 76 | - aoss:DeleteCollectionItems 77 | - aoss:UpdateCollectionItems 78 | - aoss:DescribeCollectionItems 79 | - ResourceType: index 80 | Resource: 81 | - index/chat-messages/* 82 | Permission: 83 | - aoss:CreateIndex 84 | - aoss:DeleteIndex 85 | - aoss:UpdateIndex 86 | - aoss:DescribeIndex 87 | - aoss:ReadDocument 88 | - aoss:WriteDocument 89 | Principal: 90 | - !GetAtt ChatMessageSearchRole.Arn 91 | 92 | # Create the network access policy which allows the Lambda function to talk to collection 93 | ChatIndexerAccessToOpenSearchServerless: 94 | Type: AWS::EC2::SecurityGroupIngress 95 | Properties: 96 | IpProtocol: -1 97 | FromPort: 0 98 | ToPort: 65535 99 | SourceSecurityGroupId: !GetAtt 'ChatMessageSearchSecurityGroup.GroupId' 100 | GroupId: 101 | Fn::ImportValue: !Sub "${EnvironmentName}:OpenSearchServerlessGroupId" 102 | 103 | Outputs: 104 | ChatMessageSearchId: 105 | Description: The ID of the search API 106 | Value: !GetAtt 'ChatMessageSearchApi.ApiId' 107 | Export: 108 | Name: !Sub '${EnvironmentName}:ChatMessageSearchId' 109 | MessageSearchEndpoint: 110 | Description: The URL of the chat message search endpoint 111 | Value: !GetAtt 'ChatMessageSearchApi.ApiEndpoint' 112 | Export: 113 | Name: !Sub '${EnvironmentName}:MessageSearchEndpoint' 114 | -------------------------------------------------------------------------------- /infrastructure/resources.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Redis, and any other resources that the chat app needs. 3 | Transform: AWS::LanguageExtensions 4 | Parameters: 5 | EnvironmentName: 6 | Type: String 7 | Default: production 8 | Description: The environment name, used for locating outputs from the 9 | prerequisite stacks 10 | Resources: 11 | # Subnet group to control where the Redis gets placed 12 | RedisSubnetGroup: 13 | Type: AWS::ElastiCache::SubnetGroup 14 | Properties: 15 | Description: Group of subnets to place Redis into 16 | SubnetIds: 17 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetOne" 18 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetTwo" 19 | 20 | # Security group to add the Redis cluster to the VPC, 21 | # and to allow the Fargate containers to talk to Redis on port 6379 22 | RedisSecurityGroup: 23 | Type: AWS::EC2::SecurityGroup 24 | Properties: 25 | GroupDescription: "Redis Security Group" 26 | VpcId: 27 | Fn::ImportValue: !Sub "${EnvironmentName}:VPCId" 28 | 29 | RedisIngress: 30 | Type: AWS::EC2::SecurityGroupIngress 31 | Properties: 32 | Description: Ingress from Fargate containers 33 | GroupId: !Ref 'RedisSecurityGroup' 34 | IpProtocol: tcp 35 | FromPort: 6379 36 | ToPort: 6379 37 | SourceSecurityGroupId: 38 | Fn::ImportValue: !Sub "${EnvironmentName}:FargateContainerSecurityGroup" 39 | 40 | # The cluster itself. 41 | Redis: 42 | Type: AWS::ElastiCache::CacheCluster 43 | Properties: 44 | Engine: redis 45 | CacheNodeType: cache.m4.large 46 | NumCacheNodes: 1 47 | CacheSubnetGroupName: !Ref 'RedisSubnetGroup' 48 | VpcSecurityGroupIds: 49 | - !GetAtt 'RedisSecurityGroup.GroupId' 50 | 51 | # Table for storing user info 52 | UsersTable: 53 | Type: AWS::DynamoDB::Table 54 | Properties: 55 | TableName: !Sub '${EnvironmentName}_Users' 56 | AttributeDefinitions: 57 | - AttributeName: username 58 | AttributeType: S 59 | KeySchema: 60 | - AttributeName: username 61 | KeyType: HASH 62 | ProvisionedThroughput: 63 | ReadCapacityUnits: 10 64 | WriteCapacityUnits: 10 65 | 66 | # Table for storing the messages 67 | MessagesTable: 68 | Type: AWS::DynamoDB::Table 69 | Properties: 70 | TableName: !Sub '${EnvironmentName}_Messages' 71 | AttributeDefinitions: 72 | - AttributeName: room 73 | AttributeType: S 74 | - AttributeName: message 75 | AttributeType: S 76 | KeySchema: 77 | - AttributeName: room 78 | KeyType: HASH 79 | - AttributeName: message 80 | KeyType: RANGE 81 | ProvisionedThroughput: 82 | ReadCapacityUnits: 10 83 | WriteCapacityUnits: 10 84 | StreamSpecification: 85 | StreamViewType: NEW_IMAGE 86 | 87 | # A role for the service so it can access the tables 88 | ChatServiceRole: 89 | Type: AWS::IAM::Role 90 | Properties: 91 | AssumeRolePolicyDocument: 92 | Statement: 93 | - Effect: Allow 94 | Principal: 95 | Service: "ecs-tasks.amazonaws.com" 96 | Action: ['sts:AssumeRole'] 97 | Path: / 98 | Policies: 99 | - PolicyName: users-dynamodb-table 100 | PolicyDocument: 101 | Statement: 102 | - Effect: Allow 103 | Action: 104 | - "dynamodb:PutItem" 105 | - "dynamodb:GetItem" 106 | - "dynamodb:Query" 107 | - "dynamodb:Scan" 108 | - "dynamodb:UpdateItem" 109 | - "dynamodb:DeleteItem" 110 | Resource: 111 | - !Sub 'arn:aws:dynamodb:*:*:table/${UsersTable}' 112 | - !Sub 'arn:aws:dynamodb:*:*:table/${MessagesTable}' 113 | 114 | # Describes how indexed chat messages are stored and encrypted 115 | MessageCollectionSecurityPolicy: 116 | Type: AWS::OpenSearchServerless::SecurityPolicy 117 | Properties: 118 | Name: 'chat-messages-security-policy' 119 | Description: Control the security of the message collection 120 | Type: encryption 121 | Policy: 122 | Fn::ToJsonString: 123 | Rules: 124 | - ResourceType: collection 125 | Resource: 126 | - "collection/chat-messages" 127 | AWSOwnedKey: true 128 | 129 | # Describe the actual search index of chat messages 130 | MessageCollection: 131 | Type: AWS::OpenSearchServerless::Collection 132 | DependsOn: 133 | - MessageCollectionSecurityPolicy 134 | Properties: 135 | Description: 'Indexed collection of chat messages' 136 | Name: 'chat-messages' 137 | Type: SEARCH 138 | 139 | # A VPC endpoint allowing resources in the VPC to access the open search collection 140 | OpenSearchServerlessAccess: 141 | Type: AWS::EC2::SecurityGroup 142 | Properties: 143 | GroupDescription: "OpenSearch Security Group" 144 | VpcId: 145 | Fn::ImportValue: !Sub "${EnvironmentName}:VPCId" 146 | 147 | MessageCollectionVpcEndpoint: 148 | Type: AWS::OpenSearchServerless::VpcEndpoint 149 | Properties: 150 | Name: 'open-search-serverless-endpoint' 151 | VpcId: 152 | Fn::ImportValue: !Sub "${EnvironmentName}:VPCId" 153 | SubnetIds: 154 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetOne" 155 | - Fn::ImportValue: !Sub "${EnvironmentName}:PublicSubnetTwo" 156 | SecurityGroupIds: 157 | - !Ref 'OpenSearchServerlessAccess' 158 | OpenSearchServerlessNetworkAccessPolicy: 159 | Type: AWS::OpenSearchServerless::SecurityPolicy 160 | Properties: 161 | Name: access-chat-messages-in-vpc 162 | Description: 'Allows accessing the chat messages collection via the VPC endpoint' 163 | Type: 'network' 164 | Policy: 165 | Fn::ToJsonString: 166 | - Description: Access via VPC endpoint 167 | Rules: 168 | - ResourceType: collection 169 | Resource: 170 | - collection/chat-messages 171 | SourceVPCEs: 172 | - !Ref MessageCollectionVpcEndpoint 173 | 174 | Outputs: 175 | RedisEndpoint: 176 | Description: The endpoint of the redis cluster 177 | Value: !GetAtt 'Redis.RedisEndpoint.Address' 178 | Export: 179 | Name: !Sub "${EnvironmentName}:RedisEndpoint" 180 | ChatServiceRole: 181 | Description: The role of the chat service 182 | Value: !GetAtt 'ChatServiceRole.Arn' 183 | Export: 184 | Name: !Sub "${EnvironmentName}:ChatServiceRole" 185 | UsersTable: 186 | Description: The name of the user table 187 | Value: !Ref 'UsersTable' 188 | Export: 189 | Name: !Sub "${EnvironmentName}:UsersTable" 190 | MessagesTable: 191 | Description: The name of the message table 192 | Value: !Ref 'MessagesTable' 193 | Export: 194 | Name: !Sub "${EnvironmentName}:MessagesTable" 195 | MessagesTableStreamArn: 196 | Description: The ARN of the message table stream 197 | Value: !GetAtt 'MessagesTable.StreamArn' 198 | Export: 199 | Name: !Sub "${EnvironmentName}:MessagesTableStreamArn" 200 | MessageCollectionEndpoint: 201 | Description: The URL of the collection used to submit and search chat messages 202 | Value: !GetAtt 'MessageCollection.CollectionEndpoint' 203 | Export: 204 | Name: !Sub "${EnvironmentName}:MessagesCollectionEndpoint" 205 | OpenSearchServerlessGroupId: 206 | Description: The security group that authorizes communication to open search serverless 207 | Value: !GetAtt OpenSearchServerlessAccess.GroupId 208 | Export: 209 | Name: !Sub "${EnvironmentName}:OpenSearchServerlessGroupId" 210 | -------------------------------------------------------------------------------- /infrastructure/teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This is a cleanup script for the Serverless First Workshop 3 | # Code Repository https://github.com/nathanpeck/socket.io-chat-fargate 4 | 5 | AWS_REGION=$(aws configure get region) 6 | echo "Region has been set to $AWS_REGION" 7 | AWS_ACCOUNT=$(aws sts get-caller-identity --query Account --output text) 8 | echo "Account has been set to $AWS_ACCOUNT" 9 | 10 | # List of stack names to delete 11 | stack_names=("chat-cloudfront" "chat-web" "chat-message-search" "chat-message-indexer" "chat-service" "chat-resources" "chat-cluster") 12 | 13 | # List of ECR repositories to delete 14 | ecr_repos=("fargate-chat" "apprunner-web") 15 | 16 | # Loop through each stack name 17 | for (( i=0; i<${#stack_names[@]}; i++ )) 18 | do 19 | stack_name=${stack_names[$i]} 20 | # Check if stack exists 21 | describe_output=$(aws cloudformation describe-stacks --region $AWS_REGION --stack-name "$stack_name" 2>&1) 22 | status=$? 23 | if [ $status -eq 0 ]; then 24 | # If stack exists, initiate deletion process 25 | echo "Stack $(($i+1))/${#stack_names[@]} being deleted: $stack_name" 26 | delete_output=$(aws cloudformation delete-stack --region $AWS_REGION --stack-name "$stack_name" 2>&1) 27 | status=$? 28 | if [ $status -eq 0 ]; then 29 | # If deletion initiated, check deletion status 30 | echo "Deletion of stack $stack_name initiated." 31 | status_output="" 32 | while [ "$status_output" != "DELETE_COMPLETE" ] 33 | do 34 | status_output=$(aws cloudformation describe-stacks --region $AWS_REGION --stack-name "$stack_name" --query "Stacks[0].StackStatus" --output text 2>&1) 35 | status=$? 36 | if [ $status -ne 0 ]; then 37 | # If error checking status, print error message and break loop 38 | echo "Error checking status of stack $stack_name: $status_output" 39 | break 40 | fi 41 | # Print status bar and wait for 5 seconds before checking status again 42 | printf "." 43 | sleep 5 44 | done 45 | # If deletion successful, print message indicating stack has been deleted 46 | echo " Stack $stack_name deleted." 47 | else 48 | # If error initiating deletion, print error message 49 | echo "Error deleting stack $stack_name: $delete_output" 50 | fi 51 | else 52 | # If stack does not exist, print message indicuating stack does not exist 53 | echo "Stack $stack_name does not exist. Skipping deletion." 54 | fi 55 | done 56 | 57 | # Loop through each ECR repository name 58 | for ecr_repo in "${ecr_repos[@]}" 59 | do 60 | # Check if ECR repository exists 61 | describe_output=$(aws ecr describe-repositories --region $AWS_REGION --repository-name "$ecr_repo" 2>&1) 62 | status=$? 63 | if [ $status -eq 0 ]; then 64 | # If ECR repository exists, initiate deletion process 65 | echo "Deleting ECR repository: $ecr_repo" 66 | delete_output=$(aws ecr delete-repository --region $AWS_REGION --repository-name "$ecr_repo" --force 2>&1) 67 | status=$? 68 | if [ $status -eq 0 ]; then 69 | # If deletion successful, print message indicating ECR repository has been deleted 70 | echo "ECR repository $ecr_repo deleted." 71 | else 72 | # If error initiating deletion, print error message 73 | echo "Error deleting ECR repository $ecr_repo: $delete_output" 74 | fi 75 | else 76 | # If ECR repository does not exist, print message indicating ECR repository does not exist and skip deletion 77 | echo "ECR repository $ecr_repo does not exist. Skipping deletion." 78 | fi 79 | done 80 | -------------------------------------------------------------------------------- /infrastructure/web.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: A static web server hosted in AWS App Runner 3 | Parameters: 4 | EnvironmentName: 5 | Type: String 6 | Default: production 7 | Description: A name for the environment that this cloudformation will be part of. 8 | Used to locate other resources in the same environment. 9 | ImageUrl: 10 | Type: String 11 | Default: nginx 12 | Description: The url of a docker image that contains the application process that 13 | will handle the traffic for this service. Should be on ECR private 14 | 15 | Resources: 16 | 17 | # The role which allows App Runner to pull images from ECR 18 | AppRunnerRole: 19 | Type: AWS::IAM::Role 20 | Properties: 21 | AssumeRolePolicyDocument: 22 | Version: '2008-10-17' 23 | Statement: 24 | - Effect: Allow 25 | Principal: 26 | Service: 27 | - build.apprunner.amazonaws.com 28 | Action: sts:AssumeRole 29 | ManagedPolicyArns: 30 | - arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess 31 | 32 | # The App Runner deployment of the container image from ECR 33 | ChatWeb: 34 | Type: AWS::AppRunner::Service 35 | Properties: 36 | SourceConfiguration: 37 | AuthenticationConfiguration: 38 | AccessRoleArn: !GetAtt AppRunnerRole.Arn 39 | ImageRepository: 40 | ImageRepositoryType: ECR 41 | ImageIdentifier: !Ref ImageUrl 42 | ImageConfiguration: 43 | Port: 80 44 | 45 | Outputs: 46 | WebEndpoint: 47 | Description: The endpoint of the web server 48 | Value: !GetAtt 'ChatWeb.ServiceUrl' 49 | Export: 50 | Name: !Sub '${EnvironmentName}:WebEndpoint' -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root-linter", 3 | "version": "0.1.0", 4 | "devDependencies": { 5 | "eslint": "^8.31.0", 6 | "eslint-config-standard-with-typescript": "^26.0.0", 7 | "eslint-plugin-import": "^2.26.0", 8 | "eslint-plugin-n": "^15.6.0", 9 | "eslint-plugin-promise": "^6.1.1" 10 | } 11 | } -------------------------------------------------------------------------------- /services/message-index-drop/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { defaultProvider } = require('@aws-sdk/credential-provider-node') // V3 SDK. 3 | const { Client } = require('@opensearch-project/opensearch') 4 | const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws') 5 | 6 | const client = new Client({ 7 | ...AwsSigv4Signer({ 8 | region: process.env.AWS_REGION, 9 | service: 'aoss', // 'aoss' for OpenSearch Serverless 10 | // Must return a Promise that resolve to an AWS.Credentials object. 11 | // This function is used to acquire the credentials when the client start and 12 | // when the credentials are expired. 13 | // The Client will refresh the Credentials only when they are expired. 14 | // With AWS SDK V2, Credentials.refreshPromise is used when available to refresh the credentials. 15 | 16 | // Example with AWS SDK V3: 17 | getCredentials: () => { 18 | // Any other method to acquire a new Credentials object can be used. 19 | const credentialsProvider = defaultProvider() 20 | return credentialsProvider() 21 | } 22 | }), 23 | node: process.env.MESSAGE_COLLECTION_ENDPOINT 24 | }) 25 | 26 | exports.handler = async (event) => { 27 | const INDEX_NAME = 'chat-messages' 28 | 29 | let response = await client.indices.delete({ 30 | index: INDEX_NAME 31 | }) 32 | 33 | console.log(response) 34 | } 35 | -------------------------------------------------------------------------------- /services/message-index-drop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "message-indexer", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@aws-sdk/credential-provider-node": "^3.266.1", 13 | "@opensearch-project/opensearch": "^2.2.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /services/message-indexer/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { defaultProvider } = require('@aws-sdk/credential-provider-node') // V3 SDK. 3 | const { Client } = require('@opensearch-project/opensearch') 4 | const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws') 5 | 6 | const client = new Client({ 7 | ...AwsSigv4Signer({ 8 | region: process.env.AWS_REGION, 9 | service: 'aoss', // 'aoss' for OpenSearch Serverless 10 | // Must return a Promise that resolve to an AWS.Credentials object. 11 | // This function is used to acquire the credentials when the client start and 12 | // when the credentials are expired. 13 | // The Client will refresh the Credentials only when they are expired. 14 | // With AWS SDK V2, Credentials.refreshPromise is used when available to refresh the credentials. 15 | 16 | // Example with AWS SDK V3: 17 | getCredentials: () => { 18 | // Any other method to acquire a new Credentials object can be used. 19 | const credentialsProvider = defaultProvider() 20 | return credentialsProvider() 21 | } 22 | }), 23 | node: process.env.MESSAGE_COLLECTION_ENDPOINT 24 | }) 25 | 26 | /** 27 | * Example event coming in from the stream 28 | * 29 | * { 30 | "Records": [ 31 | { 32 | "eventID": "eb3730ac319f06a4552b91a600083d5f", 33 | "eventName": "INSERT", 34 | "eventVersion": "1.1", 35 | "eventSource": "aws:dynamodb", 36 | "awsRegion": "us-west-2", 37 | "dynamodb": { 38 | "ApproximateCreationDateTime": 1675812482, 39 | "Keys": { 40 | "message": { 41 | "S": "1675812482906:3d39a121bf5872" 42 | }, 43 | "room": { 44 | "S": "lambda" 45 | } 46 | }, 47 | "NewImage": { 48 | "avatar": { 49 | "S": "https://www.gravatar.com/avatar/13764f67b6bf4f47581b57800e719ab9?d=retro" 50 | }, 51 | "time": { 52 | "N": "1675812482906" 53 | }, 54 | "message": { 55 | "S": "1675812482906:3d39a121bf5872" 56 | }, 57 | "content": { 58 | "S": "{\"text\":\"Testing the DynamoDB stream\"}" 59 | }, 60 | "room": { 61 | "S": "lambda" 62 | }, 63 | "username": { 64 | "S": "anonymous_83d396" 65 | } 66 | }, 67 | "SequenceNumber": "865100000000015469830115", 68 | "SizeBytes": 249, 69 | "StreamViewType": "NEW_IMAGE" 70 | }, 71 | "eventSourceARN": "arn:aws:dynamodb:us-west-2:228578805541:table/production_Messages/stream/2023-02-07T17:11:32.070" 72 | } 73 | ] 74 | } 75 | */ 76 | 77 | // Global variable ensures that we don't have to check 78 | // for index existing on every single invoke, just the first 79 | let indexExists = false 80 | 81 | exports.handler = async (event) => { 82 | const INDEX_NAME = 'chat-messages' 83 | 84 | if (!indexExists) { 85 | console.log('Ensuring index exists') 86 | indexExists = await client.indices.exists({ 87 | index: INDEX_NAME 88 | }) 89 | 90 | if (!indexExists) { 91 | try { 92 | const settings = { 93 | settings: { 94 | index: { 95 | number_of_shards: 4, 96 | number_of_replicas: 2 97 | }, 98 | mappings: { 99 | properties: { 100 | content: { 101 | type: 'search_as_you_type' 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | const response = await client.indices.create({ 109 | index: INDEX_NAME, 110 | body: settings 111 | }) 112 | console.log(response.body) 113 | } catch (e) { 114 | console.log(e) 115 | } 116 | } 117 | } 118 | 119 | console.log('Adding messages to index') 120 | 121 | // TODO: Rewrite this to use the bulk API for faster performance: 122 | // https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/bulk_examples.html 123 | for (const record of event.Records) { 124 | if (!record.dynamodb.NewImage) { 125 | continue; 126 | } 127 | 128 | const response = await client.index({ 129 | id: record.dynamodb.Keys.room.S + ':' + record.dynamodb.Keys.message.S, 130 | index: INDEX_NAME, 131 | body: { 132 | room: record.dynamodb.Keys.room.S, 133 | message: record.dynamodb.Keys.message.S, 134 | avatar: record.dynamodb.NewImage.avatar.S, 135 | time: record.dynamodb.NewImage.time.S, 136 | content: JSON.parse(record.dynamodb.NewImage.content.S).text, 137 | username: record.dynamodb.NewImage.username.S 138 | } 139 | }) 140 | console.log(response) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /services/message-indexer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "message-indexer", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@aws-sdk/credential-provider-node": "^3.266.1", 13 | "@opensearch-project/opensearch": "^2.2.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /services/message-search/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { defaultProvider } = require('@aws-sdk/credential-provider-node') // V3 SDK. 3 | const { Client } = require('@opensearch-project/opensearch') 4 | const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws') 5 | 6 | const client = new Client({ 7 | ...AwsSigv4Signer({ 8 | region: process.env.AWS_REGION, 9 | service: 'aoss', // 'aoss' for OpenSearch Serverless 10 | // Must return a Promise that resolve to an AWS.Credentials object. 11 | // This function is used to acquire the credentials when the client start and 12 | // when the credentials are expired. 13 | // The Client will refresh the Credentials only when they are expired. 14 | // With AWS SDK V2, Credentials.refreshPromise is used when available to refresh the credentials. 15 | 16 | // Example with AWS SDK V3: 17 | getCredentials: () => { 18 | // Any other method to acquire a new Credentials object can be used. 19 | const credentialsProvider = defaultProvider() 20 | return credentialsProvider() 21 | } 22 | }), 23 | node: process.env.MESSAGE_COLLECTION_ENDPOINT 24 | }) 25 | 26 | /** 27 | * Example event coming in from the stream 28 | * 29 | */ 30 | exports.handler = async (event) => { 31 | console.log(event) 32 | 33 | let searchTerm = '' 34 | 35 | if (event.queryStringParameters && event.queryStringParameters.q) { 36 | searchTerm = event.queryStringParameters.q 37 | } else { 38 | return { 39 | isBase64Encoded: false, 40 | statusCode: 200, 41 | body: 'Expected query parameter `q` which is a string', 42 | headers: { 43 | 'content-type': 'application/json' 44 | } 45 | } 46 | } 47 | 48 | const INDEX_NAME = 'chat-messages' 49 | 50 | console.log(`Searching for search term ${searchTerm}`) 51 | 52 | const response = await client.search({ 53 | index: INDEX_NAME, 54 | body: { 55 | query: { 56 | "multi_match": { 57 | "query": searchTerm, 58 | "type": "bool_prefix", 59 | "fields": [ 60 | "content", 61 | "content._2gram", 62 | "content._3gram" 63 | ] 64 | } 65 | } 66 | } 67 | }) 68 | 69 | if (response.statusCode === 200) { 70 | console.log(response.body.hits) 71 | 72 | const answer = response.body.hits.hits.map(function (hit) { 73 | return { 74 | score: hit._score, 75 | hit: { 76 | room: hit._source.room, 77 | message: hit._source.message, 78 | avatar: hit._source.avatar, 79 | content: { 80 | text: hit._source.content 81 | }, 82 | username: hit._source.username 83 | } 84 | } 85 | }) 86 | 87 | return { 88 | isBase64Encoded: false, 89 | statusCode: 200, 90 | body: JSON.stringify(answer), 91 | headers: { 92 | 'content-type': 'application/json' 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /services/message-search/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "message-indexer", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@aws-sdk/credential-provider-node": "^3.266.1", 13 | "@opensearch-project/opensearch": "^2.2.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /services/socket/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile 3 | package-lock.json 4 | tests 5 | -------------------------------------------------------------------------------- /services/socket/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:20.1 AS build 2 | WORKDIR /srv 3 | ADD package.json . 4 | RUN npm install 5 | 6 | FROM public.ecr.aws/docker/library/node:20.1-slim 7 | COPY --from=build /srv . 8 | ADD . . 9 | EXPOSE 3000 10 | CMD ["node", "index.js"] 11 | -------------------------------------------------------------------------------- /services/socket/index.js: -------------------------------------------------------------------------------- 1 | // Entrypoint 2 | import { server } from './server.js' 3 | import config from './lib/config.js' 4 | 5 | server.listen(config.PORT, function () { 6 | console.log('Server listening at port %d', config.PORT) 7 | }) 8 | 9 | process.on('SIGTERM', function () { 10 | console.log('Received SIGTERM, shutting down server') 11 | server.close() 12 | process.exit(0) 13 | }) 14 | -------------------------------------------------------------------------------- /services/socket/lib/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | ENV_NAME: process.env.ENV_NAME, 3 | 4 | PORT: process.env.PORT || 3000, 5 | REGION: process.env.REGION || 'us-east-1', 6 | 7 | REDIS_ENDPOINT: process.env.REDIS_ENDPOINT, 8 | DYNAMODB_ENDPOINT: process.env.DYNAMODB_ENDPOINT, 9 | 10 | USER_TABLE: process.env.USER_TABLE, 11 | MESSAGE_TABLE: process.env.MESSAGE_TABLE, 12 | 13 | // Controls how often clients ping back and forth 14 | HEARTBEAT_TIMEOUT: 8000, 15 | HEARTBEAT_INTERVAL: 4000 16 | } 17 | 18 | config.SELF_URL = process.env.SELF_URL || 'http://localhost:' + config.PORT 19 | 20 | export default config 21 | -------------------------------------------------------------------------------- /services/socket/lib/db.js: -------------------------------------------------------------------------------- 1 | // This database wrapper helper methods to make interacting with DynamoDB 2 | // a little bit easier. It automatically retires with backoff when there are 3 | // throughput limit exceptions, and it automates making followup calls when 4 | // a batch has unprocessed keys 5 | 6 | import pkg from '@aws-sdk/lib-dynamodb' 7 | import { DynamoDB } from './dynamodb-doc-client.js' 8 | import retry from 'async/retry.js' 9 | 10 | const { 11 | GetCommand, 12 | QueryCommand, 13 | PutCommand, 14 | BatchGetCommand, 15 | BatchWriteCommand, 16 | UpdateCommand, 17 | TransactWriteCommand 18 | } = pkg 19 | 20 | const RETRYABLE_ERROR_CODES = { 21 | // Generic retryable DynamoDB error codes 22 | ItemCollectionSizeLimitExceededException: true, 23 | LimitExceededException: true, 24 | ProvisionedThroughputExceededException: true, 25 | RequestLimitExceeded: true, 26 | ResourceInUseException: true, 27 | ThrottlingException: true, 28 | UnrecognizedClientException: true, 29 | // DAX specific retryable errors 30 | NeedMoreData: true, 31 | 'Not enough data': true 32 | } 33 | 34 | // Errors which are expected possibilities, not to alert on 35 | const EXPECTED_ERROR_CODES = { 36 | ConditionalCheckFailedException: true 37 | } 38 | 39 | export const retryable = function (err) { 40 | if (err.name && EXPECTED_ERROR_CODES[err.name]) { 41 | // This is an expected error, let it throw for higher level handling 42 | return false 43 | } 44 | 45 | // An error code that came back from the API, but 46 | // this was an ephemeral error that we can retry 47 | if (err.name && RETRYABLE_ERROR_CODES[err.name]) { 48 | console.error('Retryable DynamoDB error: ' + err.code) 49 | return true 50 | } 51 | 52 | // Identify error based on type 53 | if (err.name && RETRYABLE_ERROR_CODES[typeof err]) { 54 | console.error('Retryable DynamoDB error: ' + typeof err) 55 | return true 56 | } 57 | 58 | // Identify error based on message 59 | if (err.message && RETRYABLE_ERROR_CODES[err.message]) { 60 | console.error('Retryable DynamoDB error: ' + err.message) 61 | return true 62 | } 63 | 64 | // console.error('Unexpected, unretryable DynamoDB error: ', err) 65 | return false 66 | } 67 | 68 | // Define the policy for whether or not to retry, how long to wait, and how many times 69 | export const getDefaultRetryPolicy = function () { 70 | return { 71 | times: 4, 72 | interval: exponentialBackoff, 73 | errorFilter: retryable 74 | } 75 | } 76 | 77 | // Exponential backoff function 78 | // 50ms, 100ms, 200ms, 400ms, etc 79 | export const exponentialBackoff = function (retryCount) { 80 | return 25 * Math.pow(2, retryCount) 81 | } 82 | 83 | // For fetches that take multiple DynamoDB API calls to fetch 84 | // the full set, this helper function combines the result sets 85 | // into one response. 86 | export const combineResultSets = function (setOne, setTwo) { 87 | const combinedResults = {} 88 | 89 | for (const table in setTwo) { 90 | if (setOne[table]) { 91 | combinedResults[table] = setOne[table].concat(setTwo[table]) 92 | } else { 93 | combinedResults[table] = setTwo[table] 94 | } 95 | } 96 | 97 | return combinedResults 98 | } 99 | 100 | export const batchGet = async function (query) { 101 | return await retry( 102 | getDefaultRetryPolicy(), 103 | async function attemptBatchGet () { 104 | const results = await DynamoDB.send(new BatchGetCommand(query)) 105 | 106 | // All results were fetched, so fast return. 107 | if (!results.UnprocessedKeys || Object.keys(results.UnprocessedKeys).length === 0) { 108 | return results 109 | } 110 | 111 | const moreResults = await batchGet({ 112 | RequestItems: results.UnprocessedKeys 113 | }) 114 | 115 | return combineResultSets(results, moreResults) 116 | } 117 | ) 118 | } 119 | 120 | export const batchPut = async function (query) { 121 | return await retry( 122 | getDefaultRetryPolicy(), 123 | async function attemptBatchGet () { 124 | const results = await DynamoDB.send(new BatchWriteCommand(query)) 125 | 126 | // All results were stored, so fast return. 127 | if (!results.UnprocessedKeys || Object.keys(results.UnprocessedKeys).length === 0) { 128 | return 129 | } 130 | 131 | // Partial success, but there were some unprocessed keys 132 | // we need to retry the put on. 133 | await batchPut({ 134 | RequestItems: results.UnprocessedKeys 135 | }) 136 | } 137 | ) 138 | } 139 | 140 | export const put = async function (query) { 141 | return await retry( 142 | getDefaultRetryPolicy(), 143 | async function attemptPut () { 144 | return await DynamoDB.send(new PutCommand(query)) 145 | } 146 | ) 147 | } 148 | 149 | export const update = async function (query) { 150 | const results = await retry( 151 | getDefaultRetryPolicy(), 152 | async function attemptPut () { 153 | return await DynamoDB.send(new UpdateCommand(query)) 154 | } 155 | ) 156 | 157 | return results.Attributes 158 | } 159 | 160 | export const get = async function (query) { 161 | const results = await retry( 162 | getDefaultRetryPolicy(), 163 | async function attemptGet () { 164 | return await DynamoDB.send(new GetCommand(query)) 165 | } 166 | ) 167 | 168 | return results.Item 169 | } 170 | 171 | export const query = async function (query) { 172 | return await retry( 173 | getDefaultRetryPolicy(), 174 | async function attemptQuery () { 175 | return await DynamoDB.send(new QueryCommand(query)) 176 | } 177 | ) 178 | } 179 | 180 | export const transact = async function (query) { 181 | return await retry( 182 | getDefaultRetryPolicy(), 183 | async function attemptQuery () { 184 | return await DynamoDB.send(new TransactWriteCommand(query)) 185 | } 186 | ) 187 | } 188 | -------------------------------------------------------------------------------- /services/socket/lib/dynamodb-client.js: -------------------------------------------------------------------------------- 1 | // Create service client module using ES6 syntax. 2 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb' 3 | import config from './config.js' 4 | 5 | // Create an Amazon DynamoDB service client object. 6 | const ddbClient = new DynamoDBClient({ 7 | endpoint: config.DYNAMODB_ENDPOINT 8 | }) 9 | export { ddbClient } 10 | -------------------------------------------------------------------------------- /services/socket/lib/dynamodb-doc-client.js: -------------------------------------------------------------------------------- 1 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' 2 | import { ddbClient } from './dynamodb-client.js' 3 | 4 | const marshallOptions = { 5 | // Whether to automatically convert empty strings, blobs, and sets to `null`. 6 | convertEmptyValues: false, // false, by default. 7 | // Whether to remove undefined values while marshalling. 8 | removeUndefinedValues: false, // false, by default. 9 | // Whether to convert typeof object to map attribute. 10 | convertClassInstanceToMap: false // false, by default. 11 | } 12 | 13 | const unmarshallOptions = { 14 | // Whether to return numbers as a string instead of converting them to native JavaScript numbers. 15 | wrapNumbers: false // false, by default. 16 | } 17 | 18 | const translateConfig = { marshallOptions, unmarshallOptions } 19 | 20 | // Create the DynamoDB Document client. 21 | const DynamoDB = DynamoDBDocumentClient.from(ddbClient, translateConfig) 22 | 23 | export { DynamoDB } 24 | -------------------------------------------------------------------------------- /services/socket/lib/message.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import _ from 'lodash' 3 | import config from './config.js' 4 | import * as DB from './db.js' 5 | 6 | /** 7 | * Add a message to a room 8 | * 9 | * @param {object} message 10 | * @param {string} message.room 11 | * @param {string} message.username 12 | * @param {string} message.avatar 13 | * @param {object} message.content 14 | * @param {Date} message.time 15 | **/ 16 | export const add = async function (message) { 17 | try { 18 | const id = message.time + ':' + crypto.randomBytes(7).toString('hex') 19 | 20 | await DB.put({ 21 | TableName: config.MESSAGE_TABLE, 22 | Item: { 23 | room: message.room, 24 | username: message.username, 25 | avatar: message.avatar, 26 | content: JSON.stringify(message.content), 27 | time: message.time, 28 | message: id 29 | } 30 | }) 31 | 32 | return id 33 | } catch (e) { 34 | console.error(e) 35 | 36 | throw new Error('Failed to insert new user in database') 37 | } 38 | } 39 | 40 | /** 41 | * Fetch a list of the messages in a room 42 | * 43 | * @param {object} where 44 | * @param {string} where.room 45 | * @param {string} where.message 46 | **/ 47 | export const listFromRoom = async function (where) { 48 | let messages 49 | 50 | try { 51 | messages = await DB.query({ 52 | TableName: config.MESSAGE_TABLE, 53 | KeyConditionExpression: 'room = :room', 54 | Limit: 20, 55 | ExpressionAttributeValues: { 56 | ':room': where.room 57 | }, 58 | ExclusiveStartKey: where.message ? where : undefined, 59 | ScanIndexForward: false // Always return newest items first 60 | }) 61 | } catch (e) { 62 | console.error(e) 63 | 64 | throw e 65 | } 66 | 67 | return { 68 | next: _.get(messages, 'LastEvaluatedKey'), 69 | messages: messages.Items.map(function (message) { 70 | return { 71 | message: message.message, 72 | avatar: message.avatar, 73 | username: message.username, 74 | content: JSON.parse(message.content), 75 | time: message.time, 76 | room: message.room 77 | } 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /services/socket/lib/presence.js: -------------------------------------------------------------------------------- 1 | import { redis } from './redis.js' 2 | 3 | /** 4 | * Remember a present user with their connection ID 5 | * 6 | * @param {string} connectionId - The ID of the connection 7 | * @param {object} meta - Any metadata about the connection 8 | **/ 9 | export const upsert = async function (connectionId, meta) { 10 | await redis.hSet('presence', connectionId, 11 | JSON.stringify({ 12 | meta, 13 | when: Date.now() 14 | }) 15 | ) 16 | } 17 | 18 | /** 19 | * Remove a presence. Used when someone disconnects 20 | * 21 | * @param {string} connectionId - The ID of the connection 22 | * @param {object} meta - Any metadata about the connection 23 | **/ 24 | export const remove = async function (connectionId) { 25 | await redis.hDel('presence', connectionId) 26 | } 27 | 28 | /** 29 | * Returns a list of present users, minus any expired 30 | * 31 | **/ 32 | export const list = async function () { 33 | const active = [] 34 | const dead = [] 35 | const now = Date.now() 36 | 37 | const presence = await redis.hGetAll('presence') 38 | 39 | for (const connection in presence) { 40 | const details = JSON.parse(presence[connection]) 41 | details.connection = connection 42 | 43 | if (now - details.when > 8000) { 44 | dead.push(details) 45 | } else { 46 | active.push(details) 47 | } 48 | } 49 | 50 | if (dead.length) { 51 | clean(dead) 52 | } 53 | 54 | return active 55 | } 56 | 57 | /** 58 | * Cleans a list of connections by removing expired ones 59 | * 60 | * @param {array} toDelete - A list of expired presences to remove 61 | **/ 62 | const clean = function (toDelete) { 63 | console.log(`Cleaning ${toDelete.length} expired presences`) 64 | for (const presence of toDelete) { 65 | remove(presence.connection) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /services/socket/lib/redis.js: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis' 2 | import config from './config.js' 3 | 4 | export const redis = createClient({ 5 | url: config.REDIS_ENDPOINT 6 | }) 7 | 8 | redis.on('error', err => console.log('Redis Client Error', err)) 9 | 10 | await redis.connect() 11 | 12 | export default redis 13 | -------------------------------------------------------------------------------- /services/socket/lib/user.js: -------------------------------------------------------------------------------- 1 | import config from './config.js' 2 | import bcrypt from 'bcrypt' 3 | import * as DB from './db.js' 4 | 5 | const SALT_ROUNDS = 10 6 | 7 | /** 8 | * Get a user by their username 9 | * 10 | * @param {string} username - Username of the user 11 | * @param {string} password - The user's password 12 | **/ 13 | export const fetchByUsername = async function (username) { 14 | let details = null 15 | 16 | try { 17 | details = await DB.get({ 18 | TableName: config.USER_TABLE, 19 | Key: { 20 | username 21 | } 22 | }) 23 | } catch (e) { 24 | console.error(e) 25 | 26 | throw new Error('Failed to lookup user by username') 27 | } 28 | 29 | return details 30 | } 31 | 32 | /** 33 | * Create a new user with given details 34 | * 35 | * @param {object} details 36 | * @param {string} details.username 37 | * @param {string} details.email 38 | * @param {string} details.password 39 | **/ 40 | export const create = async function (details) { 41 | const existingAccount = await fetchByUsername(details.username) 42 | 43 | if (existingAccount) { 44 | throw new Error('That username is taken already.') 45 | } 46 | 47 | let passwordHash = null 48 | 49 | try { 50 | passwordHash = await bcrypt.hash(details.password, SALT_ROUNDS) 51 | } catch (e) { 52 | console.error(e) 53 | throw e 54 | } 55 | 56 | try { 57 | await DB.put({ 58 | TableName: config.USER_TABLE, 59 | Item: { 60 | username: details.username, 61 | email: details.email, 62 | passwordHash 63 | } 64 | }) 65 | } catch (e) { 66 | console.error(e) 67 | 68 | throw new Error('Failed to insert new user in database') 69 | } 70 | 71 | return 'Success' 72 | } 73 | 74 | /** 75 | * Authenticate a user who submits their username and plaintext password 76 | * 77 | * @param {object} details 78 | * @param {string} details.username 79 | * @param {string} details.password 80 | **/ 81 | export const authenticate = async function (details) { 82 | const account = await fetchByUsername(details.username) 83 | 84 | if (!account) { 85 | throw new Error('No matching account found') 86 | } 87 | 88 | const passed = await bcrypt.compare(details.password, account.passwordHash) 89 | 90 | if (passed) { 91 | return { 92 | username: account.username, 93 | email: account.email 94 | } 95 | } 96 | 97 | return false 98 | } 99 | -------------------------------------------------------------------------------- /services/socket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socket.io-chat", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "description": "A simple chat client using socket.io", 6 | "main": "index.js", 7 | "private": true, 8 | "license": "BSD", 9 | "dependencies": { 10 | "async": "3.2.4", 11 | "@aws-sdk/lib-dynamodb": "3.245.0", 12 | "bcrypt": "5.1.0", 13 | "compression": "1.7.4", 14 | "express": "4.18.2", 15 | "express-sslify": "^1.2.0", 16 | "lodash": "4.17.21", 17 | "redis": "4.6.4", 18 | "socket.io": "4.5.4", 19 | "@socket.io/redis-adapter": "8.0.1" 20 | }, 21 | "scripts": { 22 | "start": "LOCAL=true REGION=us-east-1 ENV_NAME=test REDIS_ENDPOINT=localhost DYNAMODB_ENDPOINT=http://localhost:8000 AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test node index.js" 23 | }, 24 | "eslintConfig": { 25 | "globals": { 26 | "Vue": true 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /services/socket/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathanpeck/socket.io-chat-fargate/c539fd0b1d3cc469a83a6db304716cef97201245/services/socket/public/favicon.ico -------------------------------------------------------------------------------- /services/socket/public/index.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 18 | 19 | 20 |