├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE.txt ├── README.md ├── bin └── presence.ts ├── blogpost ├── Post.md ├── images │ ├── Presence API_whitebg.png │ ├── Presence_API.png │ ├── Presence_API_Events.png │ ├── demo1.png │ ├── demo2.png │ └── overview.png └── schema1.graphql ├── cdk.json ├── jest.config.js ├── lib ├── presence-stack.ts └── schema.ts ├── package-lock.json ├── package.json ├── presencedemo ├── .gitignore ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html └── src │ ├── App.vue │ ├── api-config.sample.js │ ├── components │ ├── Player.vue │ └── PlayerList.vue │ ├── graphql │ └── operations.js │ ├── index.css │ └── main.js ├── src ├── functions │ ├── disconnect │ │ └── disconnect.js │ ├── heartbeat │ │ └── heartbeat.js │ ├── on_disconnect │ │ ├── on_disconnect.js │ │ ├── package-lock.json │ │ └── package.json │ ├── status │ │ └── status.js │ └── timeout │ │ └── timeout.js └── layer │ └── nodejs │ ├── package-lock.json │ └── package.json ├── test ├── functions │ ├── disconnect.test.ts │ ├── heartbeat.test.ts │ ├── on_disconnect.test.ts │ ├── status.test.ts │ └── timeout.test.ts ├── integration │ ├── api.test.ts │ └── apiclient.ts ├── mocks │ ├── aws-appsync.ts │ └── aws-sdk.ts └── stack │ └── presence.test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Main ignore 2 | *.js 3 | !jest.config.js 4 | *.d.ts 5 | node_modules 6 | .DS_Store 7 | 8 | # Keep js files for lambda and demo 9 | !/src/**/*.js 10 | !/presencedemo/**/*.js 11 | 12 | # CDK asset staging directory 13 | .cdk.staging 14 | cdk.out 15 | template.yaml 16 | 17 | # Do not update the config files 18 | /presence.json 19 | /presencedemo/src/api-config.js 20 | 21 | # Parcel default cache directory 22 | .parcel-cache 23 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | AWS AppSync Presence API 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Presence API using AWS AppSync, AWS Lambda, Amazon Elasticache and Amazon EventBridge 2 | 3 | This repository contains code to deploy a Presence API using AWS AppSync, AWS Lambda, Amazon Elasticache and Amazon EventBridge. The main purpose is to give an example of API developement using those elements, and it is kept as simple as possible. 4 | Presence APIs are often used for game backend, their main goal is to maintain player's connection state: offline or online as they connect or disconnect to the back end. In this case, it is also built to notify clients in real time about changes in connection states. And finally, in order to be extended or integrated with other possible backend services, it uses an **Event Sourcing** pattern for communication. 5 | 6 | You can also find more details on the infrastructure in the [associated blog post](./blogpost/Post.md). 7 | 8 | ## Prerequisites 9 | To use this repository, you will need an [AWS Account](https://aws.amazon.com/free/), as well as AWS credentials properly setup to access your account resources from the code (see ["Configuring the AWS CLI"](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html)). 10 | 11 | 12 | ## Installation 13 | The infrastructure is defined using [AWS Cloud Development Kit](https://aws.amazon.com/cdk/). You might need to install it globally if you do not have it yet: 14 | `npm install -g aws-cdk` 15 | 16 | To use the repository you can fork it or `git clone` it locally. Once you have the repository, you will have to install the require packages in the different directories: 17 | 18 | ```bash 19 | $ npm install 20 | $ cd src/layer/nodejs && npm install 21 | $ cd ../../functions/on_disconnect && npm install 22 | ``` 23 | > The AWS Cloud Development Kit is currently still being updated frequently, and new versions sometimes introduce breaking changes. The last version tested for this repository is `1.68.0`. 24 | 25 | > If you haven't use the AWS CDK yet, you might need to *bootstrap* your environment by running the `cdk bootstrap` command in your account and region. See [bootstrapping](https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html) in the CDK documentation for more details. 26 | 27 | Along with the typescript `npm run build` and `npm run watch` commands (see: [Working with the AWS CDK in TypeScript](https://docs.aws.amazon.com/cdk/latest/guide/work-with-cdk-typescript.html)), the npm package comes with a few additional commands : 28 | - `npm run deploy`: launches the build command (typescript transpilation) followed by the `cdk deploy` command. 29 | - `npm run test-stack`: build and launches the stack unit tests 30 | - `npm run test-fn`: build and launches the lambda function unit tests 31 | - `npm run test-api`: build and launches the api integration tests 32 | 33 | See below for more information on the unit tests. 34 | 35 | ## Architecture 36 | Here is the architecture deployed by the CDK scripts: 37 | 38 | ![Architecture](blogpost/images/Presence_API_Events.png) 39 | 40 | It is composed of: 41 | - An [Amazon VPC](https://aws.amazon.com/vpc) or Virtual Private Cloud to contain and isolate the resources at network level with: 42 | - Two private **subnets**: one for the Redis cluster, one for the Lambda function. 43 | - Two **security groups**, one for each subnet. 44 | - An [Amazon Elasticache](https://aws.amazon.com/elasticache) Redis cluster to store the presence data. 45 | - A set of [AWS Lambda](https://aws.amazon.com/lambda) functions: 46 | - Four of them are placed inside a subnet to have access to the Redis store. 47 | - One is outside the VPC, so it can easily access the GraphQL endpoint. 48 | - An [AWS AppSync](https://aws.amazon.com/appsync) deployment to implement the GraphQL endpoint. 49 | - An [Amazon EventBridge](https://aws.amazon.com/eventbridge) endpoint (or private link) to allow Lmabda function in the VPC to send events. 50 | 51 | There are two different scripts to build the stack in the `lib` folder: 52 | - `schema.ts` describes the GraphQl schema as code first 53 | - `presence-stack.ts` describes the main stack, including the previous schema 54 | 55 | Use either `npm run deploy` or `cdk deploy` to deploy the infrastructure in your account. The second command assumes you have built the typescript files before running it. 56 | 57 | Once the deployment is ready, the `npm run deploy` command includes the `--outputs-file presence.json` option, which will contain the necessary information to access the GraphQL API endpoint. 58 | 59 | ## Lambda functions 60 | The source code of the Lambda functions is stored in subfolders of the `src` folder. The functions are written in plain Javascript instead of Typescript for simplicity: using Typescript would require an additional build step for the assets. 61 | The `src` folder also contains a `layer` subfolder with the common modules used by most of the functions to access the Redis cluster. 62 | The `on_disconnect` function includes its own node modules as it's the only one accessing the AppSync api. 63 | 64 | ## Tests 65 | There are some tests available in the `/test` subfolder, mostly given out as example. They are not intented for full coverage of the code. They are build using the [Jest](https://jestjs.io/en/) framework. This subfolder also contains a `mock` to gather simple mocking implementations of some AWS services (such as AppSync) used for unit tests. The three type of tests are: 66 | - **stack**: test the stack output from the CDK commands. 67 | - **functions**: unit tests to test the lambda functions handler mainly. 68 | - **integration**: integration tests that can be run against a test or staging environment 69 | 70 | The `integration` test subfolder also contains an `apiclient` that is a sample implementation of the presence API. 71 | 72 | > Regarding the integration test, the notification tests are relying on some `delay` to make sure the notifications are sent back. You can modify the `delayTime` value inside the code in case some tests fail to check if it's due to network latency. 73 | > Running the integration tests, the **jest CLI** might display an error due to *asynchronous operations that weren't stopped*. The `aws-appsync` library does not provide a function to close its connection to the API, the connection being closed after some idle time. 74 | 75 | ## Presence Demo 76 | The `presencedemo` folder contains the code of a small web site to demonstrate usage of the API. The website is developed using [Vue.js](https://v3.vuejs.org/). As a prerequisite, the [Vue CLI](https://cli.vuejs.org/guide/installation.html) should be installed in your environment. You can test it locally following those steps: 77 | 1. Install dependencies 78 | The website uses the `aws-amplify` modules, and more precisely the `@aws-amplify/api` one to call the AWS AppSync GraphQL endpoint. Launch the `npm install` command in the folder to install them. 79 | 2. Configure the API 80 | Create a file called `api-config.js` in the `presencedemo/src` folder, copy the content from the `api-config.sample.js` file located in the same folder, and then modify the configuration using information that can be found in the `presence.json` file created when deploying the CDK stack: 81 | ```javascript 82 | export default { 83 | 'aws_appsync_graphqlEndpoint': 'https://**************************.appsync-api.**-****-*.amazonaws.com/graphql', // <-- Your endpoint 84 | 'aws_appsync_region': 'eu-west-1', // <-- Your region 85 | 'aws_appsync_authenticationType': 'API_KEY', 86 | 'aws_appsync_apiKey': 'da2-**************************', // <-- Your API Key for test 87 | } 88 | ``` 89 | 3. Run the local server 90 | You can launch the local server using the `npm run serve` command in the demo folder. It should make the web site available locally through an URL like `http://localhost:8080/`. 91 | 92 | To test it, you can open multiple browser windows, and add players. Each time you add one player, the page will first get the player status, and then subscribe to status notifications for this player id. 93 | 94 | ![Demo1](blogpost/images/demo1.png) 95 | 96 | If you click **connect** for one of the players in a window, it will appear **online** and start sending heartbeat, once every 10 seconds. It should also appear as **online (remote)** on the other windows. 97 | 98 | ![Demo2](blogpost/images/demo2.png) 99 | 100 | You can then click **disconnect** to disconnect the user: it might appear as **online (remote)** for a very short time before the page receives the status change notification, and propagate to other opened pages. 101 | 102 | If you click on **Stop heartbeat**, the page will stop sending heartbeats for this player. This allows testing the timeout functions, after some time, the opened pages should receive the notification of the disconnection and swith the status back to **online**. 103 | 104 | If you want to see how the API is called and used, the corresponding code is within the `src/components/Player.vue` file. 105 | 106 | ## Security 107 | 108 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 109 | 110 | ## License 111 | 112 | This library is licensed under the MIT-0 License. See the LICENSE file. 113 | 114 | -------------------------------------------------------------------------------- /bin/presence.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import { PresenceStack } from '../lib/presence-stack'; 5 | 6 | const app = new cdk.App(); 7 | new PresenceStack(app, 'PresenceStack'); 8 | -------------------------------------------------------------------------------- /blogpost/Post.md: -------------------------------------------------------------------------------- 1 | # Building a Presence API using AWS AppSync, AWS Lambda, Amazon Elasticache and Amazon EventBridge 2 | 3 | ## Introduction 4 | When developing a video game, whether a single player or multiplayer one, social and competitive features help create a network effect and increase players' engagement. These features usually require a backend API. Among them, presence information let players know about online status changes of other players, to be able to challenge them quickly, or invite them for a game session. 5 | 6 | AWS provides developers with a wide spectrum of choice to develop backend services, from plain Amazon EC2 instances based servers to containers and serverless. Among those, [AWS AppSync](https://aws.amazon.com/appsync) simplifies application development by letting you create a flexible API to securely access, manipulate, and combine data from one or more data sources. AppSync is a managed service that uses GraphQL to make it easy for applications to get exactly the data they need. One interesting feature of Amazon AppSync is real time updates, with which you can notify players about changes on the backend. 7 | 8 | To subscribe to and get notifications, a game client connects to an AWS AppSync endpoint via a websocket connection. As of today, AWS AppSync does not provide events related to client connections or disconnections. This post describes a solution to build a Presence API using [AWS AppSync](https://aws.amazon.com/appsync), [AWS Lambda](https://aws.amazon.com/lambda), [Amazon Elasticache](https://aws.amazon.com/elasticache) and [Amazon EventBridge](https://aws.amazon.com/eventbridge/). 9 | 10 | ## Defining a Presence API in GraphQL 11 | For this example, we will keep the API as simple as possible. Each player will be associated with a `status` that could take two different values, `"online"` or `"offline"`. Our API provide three basic operations: 12 | * `connect` **mutation**: to be called when a player opens it websocket connection 13 | * `disconnect` **mutation**: to be called when the player gracefully quits the game (and closes her connection) 14 | * `status` **query**: to retrieve the current status of one player 15 | 16 | There is one additional and more elaborate use case. The player can be disconnected from the backend for different reasons: client crashes, network interruptions, or even intentionnally to cheat. Still, we want other players to be informed of the disconnection even when the `disconnect` operation is not called. To do so, the game client sends regular signals to the backend, called `heartbeat`, and a threshold (or timeout) is set to consider if the player is still online or not. As game clients perform reconnection attempts when disconnected, it's important to carefully define both the heartbeat interval and the threshold to avoid the blinking player effect, whose status switches quickly from connected to disconnected. 17 | 18 | Finally, we will have some **subscriptions** added to our API for players to receive notifications when another player's status changes. The `@aws_subscribe` annotation is specific to AWS AppSync and specify the mutations that will trigger the notification. The final schema of the AWS AppSync looks like this: 19 | ```graphql 20 | enum Status { 21 | online 22 | offline 23 | } 24 | type Presence { 25 | id: ID! 26 | status: Status! 27 | } 28 | type Mutation { 29 | connect(id: ID!): Presence 30 | disconnect(id: ID!): Presence 31 | disconnected(id: ID!): Presence 32 | } 33 | type Query { 34 | heartbeat(id: ID!): Presence 35 | status(id: ID!): Presence 36 | } 37 | type Subscription { 38 | onStatus(id: ID!): Presence 39 | @aws_subscribe(mutations: ["connect","disconnect","disconnected"]) 40 | } 41 | ``` 42 | ## Presence data storage 43 | AWS AppSync enables developers to decouple the GraphQL schema that is accessed by their client applications from the data source, allowing them to choose the right data source for their workload. 44 | ![AppSync System Overview](https://docs.aws.amazon.com/appsync/latest/devguide/images/systemoverview.png) 45 | *AppSync Architecture (Ref: [AWS AppSync System Overview and Architecture](https://docs.aws.amazon.com/appsync/latest/devguide/system-overview-and-architecture.html))* 46 | For presence data, knowing if a player is still online can be translated into *the last hearbeat did not happen before the given timeout*. This information is similar to session information you would have on a website, and Amazon Elasticache is a good fit for this use case. (Ref: [Session Management](https://aws.amazon.com/caching/session-management/)). The key/value cache could store a player id as the key, and the heartbeat as the value. We also want to be able to quickly retrieve sessions that have expired during a time interval, which explains the choice of [Redis Sorted Sets](https://redis.io/topics/data-types), using operations such as `ZADD`, `ZSCORE`, `ZRANGEBYSCORE`, or `ZREMRANGEBYSCORE`. 47 | 48 | ## Architecture Overview 49 | ![Architecture](images/Presence_API.png) 50 | 51 | The infrastructure is defined using [AWS Cloud Development Kit](https://aws.amazon.com/cdk/), an open source software development framework to model and provision your cloud application resources using familiar programming languages, in this case typescript. AWS CDK provides high level constructs, allowing developers to describe infrastructure with a few lines of code, while following the recommended best practices for security, reliability or performance. It also gives the possibility to use more advanced programming features such as functions or loops. 52 | 53 | ### Network overview 54 | Elasticache Redis Cluster are deployed within an Amazon VPC. As seen in the diagram, this VPC is divided into three subnet groups: 55 | - the **Redis** subnet group: fully private for the cluster deployment 56 | - the **Lambda** subnet group: in order to access the Redis endpoints, the lambdas functions have to be deployed inside the same VPC. 57 | - a **public** subnet group: the `timeout` lambda function requires access to the AppSync endpoint to call mutations. As of today, AWS AppSync does not provide [private link](https://aws.amazon.com/fr/privatelink/), so the function has to access AppSync through a [NAT Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html), which in turn requires public subnets. 58 | 59 | For high availability, we use a multi-AZ (Availability Zone) deployment, which requires definitions for one subnet ressource per AZ and group in our stack, as well as a route table to handle traffic from the Lambda subnets to the internet. Fortunately, this is where AWS CDK comes in handy with the `Vpc`construct: 60 | ```typescript 61 | this.vpc = new EC2.Vpc(this, 'PresenceVPC', { 62 | cidr: "10.42.0.0/16", 63 | subnetConfiguration: [ 64 | // Subnet group for Redis 65 | { 66 | cidrMask: 24, 67 | name: "Redis", 68 | subnetType: EC2.SubnetType.ISOLATED 69 | }, 70 | // Subnet group for Lambda functions 71 | { 72 | cidrMask: 24, 73 | name: "Lambda", 74 | subnetType: EC2.SubnetType.PRIVATE 75 | }, 76 | // Public subnets required for the NAT Gateway 77 | { 78 | cidrMask: 24, 79 | name: "Public", 80 | subnetType: EC2.SubnetType.PUBLIC 81 | } 82 | ] 83 | }); 84 | ``` 85 | The `Vpc` construct takes care of creating subnets in different AZs, and, by choosing the right combination of `SubnetType`, create other necessary resources, such as the NAT Gateway, and route tables. 86 | We can then create both security groups inside the VPC to allow incoming traffic on Redis group only from the security group attached to the Lambda functions, as well as create a multi-AZ Redis cluster with a read replica. 87 | 88 | ### AWS AppSync API 89 | The next part of the stack setup concerns the AWS AppSync API. 90 | 91 | Using Lambda functions, we can take advantage of [Direct Lambda Resolvers](https://aws.amazon.com/blogs/mobile/appsync-direct-lambda/) feature for AWS AppSync. For each query and mutation, a resolver is created with the corresponding lambda data source, and attached to the relevant schema field. 92 | The Lambda functions code is rather simple, they can access the queries and mutations arguments directly from the event argument, and perform the corresponding Redis operation. As our functions have to access the Redis cluster, we use a [lambda layer](https://aws.amazon.com/blogs/compute/using-lambda-layers-to-simplify-your-development-process/) containing the `redis` module. Here is the heartbeat function code for example: 93 | ```javascript 94 | const redis = require('redis'); 95 | const { promisify } = require('util'); 96 | const redisEndpoint = process.env.REDIS_HOST; 97 | const redisPort = process.env.REDIS_PORT; 98 | const presence = redis.createClient(redisPort, redisEndpoint); 99 | const zadd = promisify(presence.zadd).bind(presence); 100 | 101 | /** 102 | * Heartbeat handler: 103 | * use zadd on the redis sorted set to add one entry 104 | * 105 | * @param {object} event 106 | */ 107 | exports.handler = async function(event) { 108 | const id = event && event.arguments && event.arguments.id; 109 | if (undefined === id || null === id) throw new Error("Missing argument 'id'"); 110 | const timestamp = Date.now(); 111 | try { 112 | await zadd("presence", timestamp, id); 113 | } catch (error) { 114 | return error; 115 | } 116 | return { id: id, status: "online" }; 117 | } 118 | ``` 119 | The `ZADD` redis command can both add a new entry in the set, or update the entry score if it exists. Therefore, the corresponding lambda data source can also be used by both the connect mutation and the heartbeat query. 120 | If you look at the CDK code that creates the resolvers or datasources, there is nothing related to the creation of IAM role to give permissions to AppSync to call the functions: this is also automatically handled by the CDK constructs. 121 | 122 | ### Handling expired connection 123 | The process of handling expired connection follows the steps annotated in the above diagram: 124 | 1. Triggered at regular intervals, the `timeout` function retrieves expired connections and remove them from the sorted set 125 | 2. It performs one AppSync `disconnected` **mutation** per disconnection through the NAT Gateway 126 | 3. AppSync triggers notification for each disconnection to inform subscribed players 127 | 128 | We need to modify the GraphQL schema with this additional `disconnected` **mutation**: 129 | ```graphql 130 | type Mutation { 131 | connect(id: ID!): Presence 132 | disconnect(id: ID!): Presence 133 | disconnected(id: ID!): Presence 134 | @aws_iam 135 | } 136 | 137 | type Subscription { 138 | onStatus(id: ID!): Presence 139 | @aws_subscribe(mutations: ["connect","disconnect","disconnected"]) 140 | } 141 | ``` 142 | The `@aws_iam` annotation informs AWS AppSync that this specific **mutation** requires AWS IAM authentication, through a specific role that the lambda function will assume. You can learn more about AWS AppSync multiple authorization modes [in this article](https://aws.amazon.com/blogs/mobile/supporting-backend-and-internal-processes-with-aws-appsync-multiple-authorization-types/). 143 | 144 | Finally, here is the code for the `timeout` function: 145 | ```javascript 146 | const redis = require('redis'); 147 | const { promisify } = require('util'); 148 | const timeout = parseInt(process.env.TIMEOUT); 149 | const graphqlEndpoint = process.env.GRAPHQL_ENDPOINT; 150 | 151 | // Initialize Redis client 152 | const redisEndpoint = process.env.REDIS_HOST; 153 | const redisPort = process.env.REDIS_PORT; 154 | const presence = redis.createClient(redisPort, redisEndpoint); 155 | 156 | // Initialize GraphQL client 157 | const AWS = require('aws-sdk/global'); 158 | const AUTH_TYPE = require('aws-appsync').AUTH_TYPE; 159 | const AWSAppSyncClient = require('aws-appsync').default; 160 | const gql = require('graphql-tag'); 161 | const config = { 162 | url: graphqlEndpoint, 163 | region: process.env.AWS_REGION, 164 | auth: { 165 | type: AUTH_TYPE.AWS_IAM, 166 | credentials: AWS.config.credentials, 167 | }, 168 | disableOffline: true 169 | }; 170 | const gqlClient = new AWSAppSyncClient(config); 171 | // The mutation query 172 | const mutation = gql` 173 | mutation expired($id: ID!) { 174 | expired(id: $id) 175 | } 176 | `; 177 | 178 | exports.handler = async function() { 179 | const timestamp = Date.now() - timeout; 180 | // Use a transaction to both retrieve the list of ids and remove them. 181 | const transaction = presence.multi(); 182 | transaction.zrangebyscore("presence", "-inf", timestamp); 183 | transaction.zremrangebyscore("presence", "-inf", timestamp); 184 | const execute = promisify(transaction.exec).bind(transaction); 185 | try { 186 | const [ids] = await execute(); 187 | if (!ids.length) return { expired: 0 }; 188 | // Create and send all mutations to AppSync 189 | const promises = ids.map( 190 | (id) => gqlClient.mutate({ mutation, variables: {id} }) 191 | ); 192 | await Promise.all(promises); 193 | } catch (error) { 194 | return error; 195 | } 196 | } 197 | ``` 198 | 199 | Finally, in order to trigger notification in AppSync, we use a specific **mutation** named `disconnected`. This mutation is attached to a [local resolver](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-local-resolvers.html): it just forward the result of the request mapping template to the response mapping template, without leaving AppSync, while triggering notifications to subscribed clients. 200 | 201 | ## Event based evolution 202 | Now we have a working Presence API. However, it was defined without the context of other backend APIs such as a friend or challenge API. Those other APIs might also be interested to know if a player has been disconnected, to perform some updates or clean up on their side. 203 | 204 | Another issue with this first version is that there are two differentiated paths to disconnect the user, one using the `disconnect` **mutation** on the API, one through the `disconnected` **mutation** from the timeout function. When users disconnect themselves, other services won't be notified. To be consistent, we modify the `disconnect` function to send a disconnection event to our event bus as well. Here is the evolved architecture: 205 | 206 | ![Architecture](images/Presence_API_Events.png) 207 | 208 | 1. Amazon EventBridge triggers the `timeout` function 209 | 2. The function retrieves and removes expired connections 210 | 3. The function sends events to the custom event bus (as the `disconnect` function does too) 211 | 4. The event bus triggers lambda function `on_disconnect` set as target 212 | 5. The `on_disconnect` function sends a `disconnected` mutation to AppSync 213 | 6. AppSync notifies clients that subscribed to this mutation 214 | 215 | Also note that the `heartbeat` function is now sending **connect** events to the Amazon EventBridge bus, that can be used by other backend services as well. 216 | 217 | ### Network evolution 218 | One interesting point in the diagram, is that lambda functions are not directly connected to AppSync anymore, which removes the need to have private / public subnets and a NAT Gateway. And as Amazon EventBridge supports [interface VPC Endpoint](https://docs.aws.amazon.com/eventbridge/latest/userguide/eventbridge-and-interface-VPC.html), we add one to our VPC so that Lmambda function inside the VPC can access the service directly: 219 | ```typescript 220 | // Add an interface endpoint for EventBus 221 | this.vpc.addInterfaceEndpoint("eventsEndPoint", { 222 | service: InterfaceVpcEndpointAwsService.CLOUDWATCH_EVENTS, 223 | subnets: this.vpc.selectSubnets({subnetGroupName: "Lambda"}) 224 | }) 225 | ``` 226 | 227 | ### Event Rules and Targets 228 | The next step is to define events and the rule that trigger them. The stack creates an event rule attached to the custom event bus: 229 | ```typescript 230 | // Rule for disconnection event 231 | new AwsEvents.Rule(this, "PresenceExpiredRule", { 232 | eventBus: presenceBus, 233 | description: "Rule for presence disconnection", 234 | eventPattern: { 235 | detailType: ["presence.disconnected"], 236 | source: ["api.presence"] 237 | }, 238 | targets: [new AwsEventsTargets.LambdaFunction(this.getFn("on_disconnect"))], 239 | enabled: true 240 | }); 241 | ``` 242 | The important points here are: 243 | * The **eventPattern**: it defines the events that will trigger this rule, in this case all events that will have their `detailType` and `source` both match one of those in the rule definition, all other event fields are ignored. 244 | * The **targets**: the `on_disconnect` function is added as a target to the rule. 245 | Amazon EventBridge rules allow for multiple targets to be triggered by a single rule, which will allow the usage of a fan-out model, where the event can trigger other target for other services. 246 | 247 | What remains to do is to change the code of our `timeout` and `disconnect` functions to send events to Amazon EventBridge. Here is the main handler for the `timeout` function as an example: 248 | ```javascript 249 | exports.handler = async function() { 250 | const timestamp = Date.now() - timeout; 251 | const transaction = presence.multi(); 252 | transaction.zrangebyscore("presence", "-inf", timestamp); 253 | transaction.zremrangebyscore("presence", "-inf", timestamp); 254 | const execute = promisify(transaction.exec).bind(transaction); 255 | try { 256 | const [ids] = await execute(); 257 | if (!ids.length) return { expired: 0 }; 258 | // putEvents is limited to 10 events per call 259 | let promises = []; 260 | while ( ids.length ) { 261 | const Entries = ids.splice(0, 10).map( (id) => { 262 | return { 263 | Detail: JSON.Stringify({id}), 264 | DetailType: "presence.disconnected", 265 | Source: "api.presence", 266 | EventBusName: eventBus, 267 | Time: Date.now() 268 | } 269 | }); 270 | promises.push(eventBridge.putEvents({ Entries }).promise()); 271 | } 272 | await Promise.all(promises); 273 | return { expired: ids.length }; 274 | } catch (error) { 275 | return error; 276 | } 277 | } 278 | ``` 279 | 280 | ### Deploying the sample 281 | You can get the full source code from the github samples here: [GITHUB REPO LINK]. More details and deployment instructions are included in the README file. The repository deploys the event version of the architecture. 282 | 283 | ## Conclusion 284 | If you already have an AppSync based API for your backend, you can easily add a presence API to it with a set of simple lambda functions and a Redis Cluster. The simple version of the API could be used if there is no need to connect with or decouple it from your existing services. 285 | The event based version of this API allows hooking other existing services from your backend, by registering an additional target to the existing rule. As rule targets can be of [many different types](https://docs.aws.amazon.com/eventbridge/latest/userguide/eventbridge-targets.html), including Amazon EC2 instances, Amazon ECS instances, Amazon API Gateway REST API endpoints, it could also be use to extend other kind of existing backends. 286 | Finally, Amazon EventBridge proposes a feature to [archive and replay events](https://aws.amazon.com/about-aws/whats-new/2020/11/amazon-eventbridge-introduces-support-for-event-replay/), which makes it even more useful, for example to replay a series of events to debug or review interactions between players. -------------------------------------------------------------------------------- /blogpost/images/Presence API_whitebg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-appsync-presence-api/01766ed7ae320bc36d8208935a9dd86b0e6f10e5/blogpost/images/Presence API_whitebg.png -------------------------------------------------------------------------------- /blogpost/images/Presence_API.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-appsync-presence-api/01766ed7ae320bc36d8208935a9dd86b0e6f10e5/blogpost/images/Presence_API.png -------------------------------------------------------------------------------- /blogpost/images/Presence_API_Events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-appsync-presence-api/01766ed7ae320bc36d8208935a9dd86b0e6f10e5/blogpost/images/Presence_API_Events.png -------------------------------------------------------------------------------- /blogpost/images/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-appsync-presence-api/01766ed7ae320bc36d8208935a9dd86b0e6f10e5/blogpost/images/demo1.png -------------------------------------------------------------------------------- /blogpost/images/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-appsync-presence-api/01766ed7ae320bc36d8208935a9dd86b0e6f10e5/blogpost/images/demo2.png -------------------------------------------------------------------------------- /blogpost/images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-appsync-presence-api/01766ed7ae320bc36d8208935a9dd86b0e6f10e5/blogpost/images/overview.png -------------------------------------------------------------------------------- /blogpost/schema1.graphql: -------------------------------------------------------------------------------- 1 | enum Status { 2 | online 3 | offline 4 | } 5 | type Presence { 6 | id: ID! 7 | status: Status! 8 | } 9 | type Mutation { 10 | connect(id: ID!): Presence 11 | disconnect(id: ID!): Presence 12 | } 13 | type Query { 14 | heartbeat(id: ID!): Presence 15 | status(id: ID!): Presence 16 | } 17 | type Subscription { 18 | onStatus(id: ID!): Presence 19 | @aws_subscribe(mutations: ["connect","disconnect"]) 20 | } 21 | 22 | type Mutation { 23 | connect(id: ID!): Presence 24 | disconnect(id: ID!): Presence 25 | disconnected(id: ID!): Presence 26 | @aws_iam 27 | } 28 | ... 29 | type Subscription { 30 | onStatus(id: ID!): Presence 31 | @aws_subscribe(mutations: ["connect","disconnect","disconnected"]) 32 | } 33 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node bin/presence.ts", 3 | "context": { 4 | "@aws-cdk/core:enableStackNameDuplicates": "true", 5 | "aws-cdk:enableDiffNoFail": "true", 6 | "@aws-cdk/core:stackRelativeExports": "true" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/test'], 3 | testMatch: ['**/*.test.ts', 'integration/*.test.js'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /lib/presence-stack.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | // Nodejs imports 20 | import * as path from "path"; 21 | 22 | // CDK imports 23 | import * as CDK from '@aws-cdk/core'; 24 | import * as EC2 from '@aws-cdk/aws-ec2'; 25 | import * as IAM from '@aws-cdk/aws-iam'; 26 | import * as ElastiCache from '@aws-cdk/aws-elasticache'; 27 | import * as Lambda from '@aws-cdk/aws-lambda'; 28 | import * as AppSync from '@aws-cdk/aws-appsync'; 29 | import * as AwsEvents from '@aws-cdk/aws-events'; 30 | import * as AwsEventsTargets from '@aws-cdk/aws-events-targets'; 31 | 32 | // Local imports: schema creation function 33 | import { PresenceSchema } from './schema'; 34 | 35 | // Interface used as parameter to create resolvers for our API 36 | // @see function createResolver 37 | interface ResolverOptions { 38 | source: string | AppSync.BaseDataSource, 39 | requestMappingTemplate?: AppSync.MappingTemplate, 40 | responseMappingTemplate?: AppSync.MappingTemplate 41 | } 42 | 43 | /** 44 | * class PresenceStack 45 | * 46 | * This is the main stack of our Application 47 | */ 48 | export class PresenceStack extends CDK.Stack { 49 | 50 | // Internal variables 51 | private vpc : EC2.Vpc; 52 | private lambdaSG : EC2.SecurityGroup; 53 | private redisCluster : ElastiCache.CfnReplicationGroup; 54 | private redisLayer : Lambda.LayerVersion; 55 | private redisPort : number = 6379; 56 | readonly api : AppSync.GraphqlApi; 57 | 58 | // Lambda functions for our stacks are store by name 59 | // for further explicit access 60 | private functions : { [key : string] : Lambda.Function } = {}; 61 | 62 | /** 63 | * Adds a Lambda Function to an internal list of functions indexed by their name. 64 | * The function code is assumed to be located in a subfolder related to that name 65 | * and using `${name}.js` file as entry point. 66 | * 67 | * Functions that require access to redis will have the "Redis Layer" attached, 68 | * containing a node module for Redis access, be placed inside the VPC, 69 | * and have environment variables set to access the Redis cluster. 70 | * 71 | * The CDK Lambda.Code construct takes care of bundling the code 72 | * (including local modules if any, like for `on_disconnect` to call AppSync endpoint), 73 | * and uploading it as Asset to S3. 74 | * 75 | * @param name string : the name given to the function 76 | * @param useRedis boolean : whether the lambda uses redis or not, if so it requires layer / VPC / env variables 77 | */ 78 | private addFunction(name: string, useRedis: boolean = true) : void { 79 | const props = useRedis ? { 80 | vpc: this.vpc, 81 | vpcSubnets: this.vpc.selectSubnets({subnetGroupName: "Lambda"}), 82 | securityGroups: [this.lambdaSG] 83 | } : {}; 84 | const fn = new Lambda.Function(this, name, { 85 | ...props, 86 | code: Lambda.Code.fromAsset(path.resolve(__dirname, `../src/functions/${name}/`)), 87 | runtime: Lambda.Runtime.NODEJS_12_X, 88 | handler: `${name}.handler` 89 | }); 90 | // Specific elements to add for redis access 91 | if (useRedis) { 92 | fn.addLayers(this.redisLayer); 93 | fn.addEnvironment("REDIS_HOST", this.redisCluster.attrPrimaryEndPointAddress); 94 | fn.addEnvironment("REDIS_PORT", this.redisCluster.attrPrimaryEndPointPort); 95 | } 96 | // Store the function for further internal access 97 | this.functions[name] = fn; 98 | }; 99 | 100 | /** 101 | * Retrieve one of the Lambda function by its name 102 | * 103 | * @param name : string 104 | */ 105 | private getFn(name: string) : Lambda.Function { 106 | return this.functions[name]; 107 | }; 108 | 109 | /** 110 | * Helper function to create a resolver. 111 | * 112 | * A resolver attaches a Data Source to a specific field in the schema. 113 | * The ResolverOptions might also include request mapping and response mapping templates 114 | * It returns the attached DataSource for possible reuse. 115 | * 116 | * @param typeName : string the type (e.g. Query, Mutation or any other type) 117 | * @param fieldName : string the resolvable fields 118 | * @param options ResolverOptions 119 | * 120 | * @returns AppSync.BaseDataSource 121 | */ 122 | private createResolver(typeName: string, fieldName: string, options: ResolverOptions) 123 | : AppSync.BaseDataSource { 124 | let source = (typeof(options.source) === 'string') ? 125 | this.api.addLambdaDataSource(`${options.source}DS`, this.getFn(options.source)) : 126 | options.source; 127 | source.createResolver({ typeName, fieldName, ...options }); 128 | return source; 129 | }; 130 | 131 | /** 132 | * Stack constructor 133 | * 134 | * @param scope 135 | * @param id 136 | * @param props 137 | */ 138 | constructor(scope: CDK.Construct, id: string, props?: CDK.StackProps) { 139 | super(scope, id, props); 140 | 141 | /** 142 | * Network: 143 | * 144 | * Here we define a VPC with two subnet groups. 145 | * The CDK automatically creates subnets in at least 2 AZs by default 146 | * You can change the behavior using the `maxAzs` parameter. 147 | * 148 | * Subnet types can be: 149 | * - ISOLATED: fully isolated (example: used for Redis Cluster or lambda functions accessing it) 150 | * - PRIVATE: could be used for a Lambda function that would require internet access through a NAT Gateway 151 | * - PUBLIC: required if there is a PRIVATE subnet to setup a NAT Gateway 152 | * 153 | **/ 154 | this.vpc = new EC2.Vpc(this, 'PresenceVPC', { 155 | cidr: "10.42.0.0/16", 156 | subnetConfiguration: [ 157 | // Subnet group for Redis 158 | { 159 | cidrMask: 24, 160 | name: "Redis", 161 | subnetType: EC2.SubnetType.ISOLATED 162 | }, 163 | // Subnet group for Lambda functions 164 | { 165 | cidrMask: 24, 166 | name: "Lambda", 167 | subnetType: EC2.SubnetType.ISOLATED 168 | } 169 | ] 170 | }); 171 | 172 | // Create two different security groups: 173 | // One for the redis cluster, one for the lambda function. 174 | // This is to allow traffic only from our functions to the redis cluster 175 | const redisSG = new EC2.SecurityGroup(this, "redisSg", { 176 | vpc: this.vpc, 177 | description: "Security group for Redis Cluster" 178 | }); 179 | this.lambdaSG = new EC2.SecurityGroup(this, "lambdaSg", { 180 | vpc: this.vpc, 181 | description: "Security group for Lambda functions" 182 | }); 183 | // Redis SG accepts TCP connections from the Lambda SG on Redis port. 184 | redisSG.addIngressRule( 185 | this.lambdaSG, 186 | EC2.Port.tcp(this.redisPort) 187 | ); 188 | 189 | /** 190 | * Redis cache cluster 191 | * Uses T3 small instances to start withs 192 | * 193 | * Note those are level 1 constructs in CDK. 194 | * So props like `cacheSubnetGroupName` have misleading names and require a name 195 | * in CloudFormation sense, which is actually a "ref" for reference. 196 | */ 197 | const redisSubnets = new ElastiCache.CfnSubnetGroup(this, "RedisSubnets", { 198 | cacheSubnetGroupName: "RedisSubnets", 199 | description: "Subnet Group for Redis Cluster", 200 | subnetIds: this.vpc.selectSubnets({ subnetGroupName: "Redis"}).subnetIds 201 | }); 202 | this.redisCluster = new ElastiCache.CfnReplicationGroup(this, "PresenceCluster", { 203 | replicationGroupDescription: "PresenceReplicationGroup", 204 | cacheNodeType: "cache.t3.small", 205 | engine: "redis", 206 | numCacheClusters: 2, 207 | automaticFailoverEnabled: true, 208 | multiAzEnabled: true, 209 | cacheSubnetGroupName: redisSubnets.ref, 210 | securityGroupIds: [redisSG.securityGroupId], 211 | port: this.redisPort 212 | }); 213 | 214 | /** 215 | * Lambda functions creation: 216 | * 217 | * - Define the layer to add nodejs redis module 218 | * - Add the functions 219 | */ 220 | this.redisLayer = new Lambda.LayerVersion(this, "redisModule", { 221 | code: Lambda.Code.fromAsset(path.join(__dirname, '../src/layer/')), 222 | compatibleRuntimes: [Lambda.Runtime.NODEJS_12_X], 223 | layerVersionName: "presenceLayer" 224 | }); 225 | // Use arrow function to keep "this" scope 226 | ['heartbeat','status','disconnect','timeout'].forEach( 227 | (fn) => { this.addFunction(fn); } 228 | ); 229 | // On disconnect function does not require access to redis 230 | this.addFunction("on_disconnect", false); 231 | 232 | /** 233 | * The GraphQL API 234 | * 235 | * Default authorization is set to use API_KEY. This is good for development and test, 236 | * in production, we recommend using a COGNITO or OPEN_ID user based authentification. 237 | * 238 | * We also force the API key to expire after 7 days starting from the last deployment 239 | */ 240 | this.api = new AppSync.GraphqlApi(this, "PresenceAPI", { 241 | name: "PresenceAPI", 242 | authorizationConfig: { 243 | defaultAuthorization: { 244 | authorizationType: AppSync.AuthorizationType.API_KEY, 245 | apiKeyConfig: { 246 | name: "PresenceKey", 247 | expires: CDK.Expiration.after(CDK.Duration.days(7)) 248 | } 249 | }, 250 | additionalAuthorizationModes: [ 251 | { authorizationType: AppSync.AuthorizationType.IAM } 252 | ] 253 | }, 254 | schema: PresenceSchema(), 255 | logConfig: { fieldLogLevel: AppSync.FieldLogLevel.ALL } 256 | }); 257 | 258 | // Configure sources and resolvers 259 | const heartbeatDS = this.createResolver("Query", "heartbeat", {source: "heartbeat"}); 260 | this.createResolver("Query", "status", {source: "status"}); 261 | this.createResolver("Mutation", "connect", {source: heartbeatDS} ); // Note: reusing heartbeat lambda here 262 | this.createResolver("Mutation", "disconnect", {source: "disconnect"} ); 263 | 264 | // The "disconnected" mutation is called on disconnection, and 265 | // is the one AppSync client will subscribe too. 266 | // It uses a NoneDataSource with simple templates passing its argument, 267 | // so that it could trigger the notifications. 268 | const noneDS = this.api.addNoneDataSource("disconnectedDS"); 269 | const requestMappingTemplate = AppSync.MappingTemplate.fromString(` 270 | { 271 | "version": "2017-02-28", 272 | "payload": { 273 | "id": "$context.arguments.id", 274 | "status": "offline" 275 | } 276 | } 277 | `); 278 | const responseMappingTemplate = AppSync.MappingTemplate.fromString(` 279 | $util.toJson($context.result) 280 | `); 281 | this.createResolver("Mutation", "disconnected", { 282 | source: noneDS, 283 | requestMappingTemplate, 284 | responseMappingTemplate 285 | }); 286 | 287 | /** 288 | * Event bus 289 | * 290 | * We could use the Default Bus with EventBridge, but a custom bus 291 | * might be better for further extensions. 292 | */ 293 | const presenceBus = new AwsEvents.EventBus(this, "PresenceBus"); 294 | // Rule to trigger lambda timeout every minute 295 | new AwsEvents.Rule(this, "PresenceTimeoutRule", { 296 | schedule: AwsEvents.Schedule.cron({minute:"*"}), 297 | targets: [new AwsEventsTargets.LambdaFunction(this.getFn("timeout"))], 298 | enabled: true 299 | }); 300 | // Rule for disconnection event: triggers the on_disconnect 301 | // lambda function, according to the given pattern 302 | new AwsEvents.Rule(this, "PresenceDisconnectRule", { 303 | eventBus: presenceBus, 304 | description: "Rule for presence disconnection", 305 | eventPattern: { 306 | detailType: ["presence.disconnected"], 307 | source: ["api.presence"] 308 | }, 309 | targets: [new AwsEventsTargets.LambdaFunction(this.getFn("on_disconnect"))], 310 | enabled: true 311 | }); 312 | // Add an interface endpoint for EventBridge: this allow 313 | // the lambda inside the VPC to call EventBridge without requiring a NAT Gateway 314 | // It also requires a security group that allows TCP 80 communications from the Lambdas security groups. 315 | const eventsEndPointSG = new EC2.SecurityGroup(this, "eventsEndPointSG", { 316 | vpc: this.vpc, 317 | description: "EventBrige interface endpoint SG" 318 | }); 319 | eventsEndPointSG.addIngressRule(this.lambdaSG, EC2.Port.tcp(80)); 320 | this.vpc.addInterfaceEndpoint("eventsEndPoint", { 321 | service: EC2.InterfaceVpcEndpointAwsService.CLOUDWATCH_EVENTS, 322 | subnets: this.vpc.selectSubnets({subnetGroupName: "Lambda"}), 323 | securityGroups: [eventsEndPointSG] 324 | }); 325 | 326 | /** 327 | * Finalize configuration for lambda functions 328 | * 329 | * - Add environment variables to access api 330 | * - Add IAM policy statement for GraphQL access 331 | * - Add IAM policy statement for event bus access (putEvents) 332 | * - Add the timeout 333 | */ 334 | const allowEventBridge = new IAM.PolicyStatement({ effect: IAM.Effect.ALLOW }); 335 | allowEventBridge.addActions("events:PutEvents"); 336 | allowEventBridge.addResources(presenceBus.eventBusArn); 337 | 338 | this.getFn("timeout").addEnvironment("TIMEOUT", "10000") 339 | .addEnvironment("EVENT_BUS", presenceBus.eventBusName) 340 | .addToRolePolicy(allowEventBridge); 341 | 342 | this.getFn('disconnect') 343 | .addEnvironment("EVENT_BUS", presenceBus.eventBusName) 344 | .addToRolePolicy(allowEventBridge); 345 | 346 | this.getFn("heartbeat") 347 | .addEnvironment("EVENT_BUS", presenceBus.eventBusName) 348 | .addToRolePolicy(allowEventBridge); 349 | 350 | const allowAppsync = new IAM.PolicyStatement({ effect: IAM.Effect.ALLOW }); 351 | allowAppsync.addActions("appsync:GraphQL"); 352 | allowAppsync.addResources(this.api.arn + "/*"); 353 | this.getFn("on_disconnect") 354 | .addEnvironment("GRAPHQL_ENDPOINT", this.api.graphqlUrl) 355 | .addToRolePolicy(allowAppsync); 356 | 357 | /** 358 | * The CloudFormation stack output 359 | * 360 | * Contains: 361 | * - the GraphQL API Endpoint 362 | * - The API Key for the integration tests (could be removed in production) 363 | * - The region (required to configure AppSync client in integration tests) 364 | * 365 | * Use the `-O, --outputs-file` option with `cdk deploy` to output those in a JSON file 366 | * `npm run deploy` uses this option as default 367 | */ 368 | new CDK.CfnOutput(this, "presence-api", { 369 | value: this.api.graphqlUrl, 370 | description: "Presence api endpoint", 371 | exportName: "presenceEndpoint" 372 | }); 373 | new CDK.CfnOutput(this, "api-key", { 374 | value: this.api.apiKey || '', 375 | description: "Presence api key", 376 | exportName: "apiKey" 377 | }); 378 | new CDK.CfnOutput(this, "region", { 379 | value: process.env.CDK_DEFAULT_REGION || '', 380 | description: "Presence api region", 381 | exportName: "region" 382 | }); 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /lib/schema.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | /** 20 | * AppSync schema code first definition. 21 | * 22 | * See [Code-First Schema](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-appsync-readme.html#code-first-schema) 23 | */ 24 | import * as AppSync from '@aws-cdk/aws-appsync'; 25 | 26 | /** 27 | * Helper function to define a GraphQl Type from an intermediate type. 28 | * 29 | * @param intermediateType the intermediate type this type derives from 30 | * @param options possible values are `isRequired`, `isList`, `isRequiredList` 31 | */ 32 | function typeFromObject( intermediateType: AppSync.IIntermediateType, options?: AppSync.GraphqlTypeOptions ) : AppSync.GraphqlType { 33 | return AppSync.GraphqlType.intermediate({ intermediateType, ...options }); 34 | } 35 | 36 | /** 37 | * Function called to return the schema 38 | * 39 | * @returns AppSync.Schema 40 | */ 41 | export function PresenceSchema() : AppSync.Schema { 42 | 43 | // Instantiate the schema 44 | const schema = new AppSync.Schema(); 45 | 46 | // A Required ID type ("ID!") 47 | const requiredId = AppSync.GraphqlType.id({isRequired: true}); 48 | 49 | // User defined types: enum for presence state, and required version (i.e. "status!") 50 | const status = new AppSync.EnumType("Status", { 51 | definition: ["online", "offline"] 52 | }); 53 | const requiredStatus = typeFromObject(status, {isRequired: true}); 54 | 55 | // Main type returned by API calls: 56 | // Directives are used to set access through IAM and API KEY 57 | // In production, recommendation would be to use Cognito or Open Id 58 | // (https://docs.aws.amazon.com/appsync/latest/devguide/security.html) 59 | const presence = new AppSync.ObjectType("Presence", { 60 | definition: { 61 | id: requiredId, 62 | status: requiredStatus 63 | }, 64 | directives: [AppSync.Directive.iam(), AppSync.Directive.apiKey()] 65 | }); 66 | const returnPresence = typeFromObject(presence); 67 | 68 | // Add user defined types to the schema 69 | schema.addType(status); 70 | schema.addType(presence); 71 | 72 | // Add queries 73 | schema.addQuery("heartbeat", new AppSync.Field({ 74 | returnType: returnPresence, 75 | args: { id: requiredId } 76 | })); 77 | schema.addQuery("status", new AppSync.Field({ 78 | returnType: returnPresence, 79 | args: { id: requiredId } 80 | })); 81 | 82 | // Add mutations 83 | schema.addMutation("connect", new AppSync.Field({ 84 | returnType: returnPresence, 85 | args: { id: requiredId } 86 | })); 87 | schema.addMutation("disconnect", new AppSync.Field({ 88 | returnType: returnPresence, 89 | args: { id: requiredId } 90 | })); 91 | schema.addMutation("disconnected", new AppSync.Field({ 92 | returnType: returnPresence, 93 | args: { id: requiredId }, 94 | directives: [AppSync.Directive.iam()] 95 | })); 96 | 97 | // Add subscription 98 | schema.addSubscription("onStatus", new AppSync.Field({ 99 | returnType: returnPresence, 100 | args: { id: requiredId }, 101 | directives: [AppSync.Directive.subscribe("connect","disconnected")] 102 | })); 103 | 104 | return schema; 105 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "presence", 3 | "version": "0.1.0", 4 | "bin": { 5 | "presence": "bin/presence.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "test-stack": "jest stack", 12 | "test-fn": "jest functions", 13 | "test-integ": "jest integration", 14 | "cdk": "cdk", 15 | "deploy": "tsc && cdk deploy --outputs-file presence.json" 16 | }, 17 | "devDependencies": { 18 | "@aws-cdk/assert": "^1.68.0", 19 | "@types/graphql": "^14.5.0", 20 | "@types/jest": "^26.0.10", 21 | "@types/node": "10.17.27", 22 | "@types/redis-mock": "^0.17.0", 23 | "aws-appsync": "^4.0.1", 24 | "aws-cdk": "^1.68.0", 25 | "aws-sdk": "^2.772.0", 26 | "graphql-tag": "^2.11.0", 27 | "isomorphic-fetch": "^3.0.0", 28 | "jest": "^26.5.3", 29 | "redis": "^3.0.2", 30 | "redis-mock": "^0.52.0", 31 | "ts-jest": "^26.4.1", 32 | "ts-node": "^8.1.0", 33 | "typescript": "~3.9.7" 34 | }, 35 | "dependencies": { 36 | "@aws-cdk/aws-appsync": "^1.68.0", 37 | "@aws-cdk/aws-ec2": "^1.68.0", 38 | "@aws-cdk/aws-elasticache": "^1.68.0", 39 | "@aws-cdk/aws-events": "^1.68.0", 40 | "@aws-cdk/aws-events-targets": "^1.68.0", 41 | "@aws-cdk/aws-iam": "^1.68.0", 42 | "@aws-cdk/aws-lambda": "^1.68.0", 43 | "@aws-cdk/core": "^1.68.0", 44 | "source-map-support": "^0.5.16" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /presencedemo/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /presencedemo/README.md: -------------------------------------------------------------------------------- 1 | # presencedemo 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /presencedemo/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /presencedemo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "presencedemo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@aws-amplify/api": "^3.2.14", 12 | "aws-amplify": "^3.3.11", 13 | "core-js": "^3.6.5", 14 | "vue": "^3.0.0" 15 | }, 16 | "devDependencies": { 17 | "@vue/cli-plugin-babel": "~4.5.0", 18 | "@vue/cli-plugin-eslint": "~4.5.0", 19 | "@vue/cli-service": "~4.5.0", 20 | "@vue/compiler-sfc": "^3.0.0", 21 | "babel-eslint": "^10.1.0", 22 | "eslint": "^6.7.2", 23 | "eslint-plugin-vue": "^7.0.0-0" 24 | }, 25 | "eslintConfig": { 26 | "root": true, 27 | "env": { 28 | "node": true 29 | }, 30 | "extends": [ 31 | "plugin:vue/vue3-essential", 32 | "eslint:recommended" 33 | ], 34 | "parserOptions": { 35 | "parser": "babel-eslint" 36 | }, 37 | "rules": {} 38 | }, 39 | "browserslist": [ 40 | "> 1%", 41 | "last 2 versions", 42 | "not dead" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /presencedemo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-appsync-presence-api/01766ed7ae320bc36d8208935a9dd86b0e6f10e5/presencedemo/public/favicon.ico -------------------------------------------------------------------------------- /presencedemo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /presencedemo/src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | -------------------------------------------------------------------------------- /presencedemo/src/api-config.sample.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'aws_appsync_graphqlEndpoint': 'https://**************************.appsync-api.**-****-*.amazonaws.com/graphql', // <-- Your endpoint 3 | 'aws_appsync_region': 'eu-west-1', // <-- Your region 4 | 'aws_appsync_authenticationType': 'API_KEY', 5 | 'aws_appsync_apiKey': 'da2-**************************', // <-- Your API Key for test 6 | } -------------------------------------------------------------------------------- /presencedemo/src/components/Player.vue: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | 31 | 32 | -------------------------------------------------------------------------------- /presencedemo/src/components/PlayerList.vue: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | 29 | 30 | 54 | -------------------------------------------------------------------------------- /presencedemo/src/graphql/operations.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | import gql from 'graphql-tag'; 20 | const presenceResult = `{ 21 | id 22 | status 23 | }`; 24 | export default { 25 | getStatus: gql` 26 | query getStatus($id: ID!) { 27 | status(id: $id) ${presenceResult} 28 | }`, 29 | sendHeartbeat: gql` 30 | query heartbeat($id: ID!) { 31 | heartbeat(id: $id) ${presenceResult} 32 | }`, 33 | connect: gql` 34 | mutation connectPlayer($id: ID!) { 35 | connect(id: $id) ${presenceResult} 36 | }`, 37 | disconnect: gql` 38 | mutation disconnectPlayer($id: ID!) { 39 | disconnect(id: $id) ${presenceResult} 40 | }`, 41 | onStatus: gql` 42 | subscription statusChanged($id: ID!) { 43 | onStatus(id: $id) ${presenceResult} 44 | }` 45 | } -------------------------------------------------------------------------------- /presencedemo/src/index.css: -------------------------------------------------------------------------------- 1 | #app { 2 | font-family: Avenir, Helvetica, Arial, sans-serif; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | text-align: center; 6 | color: #2c3e50; 7 | margin-top: 60px; 8 | } 9 | 10 | #app .logo { 11 | height: 20%; 12 | width: 20%; 13 | } 14 | 15 | #app .footer { 16 | position: fixed; 17 | left: 0; 18 | bottom: 0; 19 | width: 100%; 20 | text-align: center; 21 | } 22 | 23 | #app .center { 24 | margin-left: auto; 25 | margin-right: auto; 26 | } 27 | 28 | #app .online { 29 | color: green; 30 | } 31 | 32 | #app .offline { 33 | color: red; 34 | } 35 | 36 | #app .remote { 37 | color: blue; 38 | } -------------------------------------------------------------------------------- /presencedemo/src/main.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | import { createApp } from 'vue' 20 | import App from './App.vue' 21 | import './index.css' 22 | 23 | import Amplify from 'aws-amplify'; 24 | import config from './api-config'; 25 | 26 | Amplify.configure(config); 27 | 28 | createApp(App).mount('#app') 29 | -------------------------------------------------------------------------------- /src/functions/disconnect/disconnect.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | const { promisify } = require('util'); 20 | 21 | const AWS = require('aws-sdk'); 22 | const eventBridge = new AWS.EventBridge(); 23 | 24 | const redis = require('redis'); 25 | const eventBus = process.env.EVENT_BUS; 26 | const redisEndpoint = process.env.REDIS_HOST; 27 | const redisPort = process.env.REDIS_PORT; 28 | const presence = redis.createClient(redisPort, redisEndpoint); 29 | const zrem = promisify(presence.zrem).bind(presence); 30 | 31 | /** 32 | * Disconnect event handler 33 | * 34 | * 1 - Check the `arguments.id` from the event 35 | * 2 - Calls `zrem` to remove the timestamp from the database 36 | * 3 - Send an event if the id was still online 37 | * 38 | * @param {*} event 39 | */ 40 | exports.handler = async function(event) { 41 | const id = event && event.arguments && event.arguments.id; 42 | if (undefined === id || null === id) throw new Error("Missing argument 'id'"); 43 | try { 44 | const removals = await zrem("presence", id); 45 | if (removals != 1) // Id was already removed: bypass event 46 | return {id, status: "offline"}; 47 | // Notify EventBridge 48 | const Entries = [ 49 | { 50 | Detail: JSON.stringify({id}), 51 | DetailType: "presence.disconnected", 52 | Source: "api.presence", 53 | EventBusName: eventBus, 54 | Time: Date.now() 55 | } 56 | ]; 57 | await eventBridge.putEvents({ Entries }).promise(); 58 | return {id, status: "offline"}; 59 | } catch (error) { 60 | return error; 61 | } 62 | } -------------------------------------------------------------------------------- /src/functions/heartbeat/heartbeat.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | const AWS = require('aws-sdk'); 20 | const redis = require('redis'); 21 | const { promisify } = require('util'); 22 | const redisEndpoint = process.env.REDIS_HOST; 23 | const redisPort = process.env.REDIS_PORT; 24 | const presence = redis.createClient(redisPort, redisEndpoint); 25 | const zadd = promisify(presence.zadd).bind(presence); 26 | const eventBridge = new AWS.EventBridge(); 27 | const eventBus = process.env.EVENT_BUS; 28 | 29 | /** 30 | * Heartbeat handler: 31 | * 32 | * 1 - Check `arguments.id` from the event 33 | * 2 - Use zadd to add or update the timestamp 34 | * 3 - If the timestamp was added, send a connection event 35 | * 36 | * @param {object} event 37 | */ 38 | exports.handler = async function(event) { 39 | const id = event && event.arguments && event.arguments.id; 40 | if (undefined === id || null === id) throw new Error("Missing argument 'id'"); 41 | const timestamp = Date.now(); 42 | try { 43 | const result = await zadd("presence", timestamp, id); 44 | if (result === 1 ) // New connection 45 | { 46 | await eventBridge.putEvents({ 47 | Entries: [{ 48 | Detail: JSON.stringify({id}), 49 | DetailType: "presence.connected", 50 | Source: "api.presence", 51 | EventBusName: eventBus, 52 | Time: Date.now() 53 | }] 54 | }).promise(); 55 | } 56 | } catch (error) { 57 | return error; 58 | } 59 | return { id: id, status: "online" }; 60 | } -------------------------------------------------------------------------------- /src/functions/on_disconnect/on_disconnect.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | /** 20 | * The `on_disconnect` function requires an AppSync client 21 | * It is installed as node modules within its own folder 22 | * 23 | * Make sure `npm install` was run in this folder 24 | */ 25 | require('isomorphic-fetch'); // Required for 'aws-appsync' 26 | const AWS = require('aws-sdk/global'); 27 | const AppSync = require('aws-appsync'); 28 | const AppSyncClient = AppSync.default; 29 | const AUTH_TYPE = AppSync.AUTH_TYPE; 30 | const gql = require('graphql-tag'); 31 | 32 | // Retrieve environment value 33 | const graphqlEndpoint = process.env.GRAPHQL_ENDPOINT; 34 | 35 | // Initialize GraphQL client with IAM credentials 36 | const config = { 37 | url: graphqlEndpoint, 38 | region: process.env.AWS_REGION, 39 | auth: { 40 | type: AUTH_TYPE.AWS_IAM, 41 | credentials: AWS.config.credentials 42 | }, 43 | disableOffline: true 44 | }; 45 | const gqlClient = new AppSyncClient(config); 46 | 47 | // Query is the same for all calls 48 | const disconnected = gql` 49 | mutation disconnected($id: ID!) { 50 | disconnected(id: $id) { 51 | id 52 | status 53 | } 54 | } 55 | `; 56 | 57 | /** 58 | * Handler function for disconnection 59 | * 60 | * 1 - Check `arguments.id` from the event 61 | * 2 - Call the `disconnected` mutation on AppSync client 62 | * 63 | * @param {*} event 64 | */ 65 | exports.handler = async function(event) { 66 | // Simply call graphql mutation 67 | const id = event && event.detail && event.detail.id; 68 | if (undefined === id || null === id) throw new Error("Missing argument 'id'"); 69 | try { 70 | const result = await gqlClient.mutate({ 71 | mutation: disconnected, 72 | variables: {id} 73 | }); 74 | return result.data.disconnected; 75 | } catch (error) { 76 | return error; 77 | } 78 | } -------------------------------------------------------------------------------- /src/functions/on_disconnect/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "presence-check", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@babel/runtime": { 8 | "version": "7.11.2", 9 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz", 10 | "integrity": "sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==", 11 | "requires": { 12 | "regenerator-runtime": "^0.13.4" 13 | } 14 | }, 15 | "@redux-offline/redux-offline": { 16 | "version": "2.5.2-native.3", 17 | "resolved": "https://registry.npmjs.org/@redux-offline/redux-offline/-/redux-offline-2.5.2-native.3.tgz", 18 | "integrity": "sha512-xo1M4wFJDJjANn9w6faru0/8rerd28vQpbNTbEe7DX57RyRqSGsDilb0temH/kAg3GheQTlO59ipRum2bcmXvw==", 19 | "requires": { 20 | "@babel/runtime": "^7.5.5", 21 | "redux-persist": "^4.6.0" 22 | } 23 | }, 24 | "@types/async": { 25 | "version": "2.0.50", 26 | "resolved": "https://registry.npmjs.org/@types/async/-/async-2.0.50.tgz", 27 | "integrity": "sha512-VMhZMMQgV1zsR+lX/0IBfAk+8Eb7dPVMWiQGFAt3qjo5x7Ml6b77jUo0e1C3ToD+XRDXqtrfw+6AB0uUsPEr3Q==", 28 | "optional": true 29 | }, 30 | "@types/zen-observable": { 31 | "version": "0.8.1", 32 | "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.1.tgz", 33 | "integrity": "sha512-wmk0xQI6Yy7Fs/il4EpOcflG4uonUpYGqvZARESLc2oy4u69fkatFLbJOeW4Q6awO15P4rduAe6xkwHevpXcUQ==" 34 | }, 35 | "@wry/equality": { 36 | "version": "0.1.11", 37 | "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz", 38 | "integrity": "sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA==", 39 | "requires": { 40 | "tslib": "^1.9.3" 41 | } 42 | }, 43 | "apollo-cache": { 44 | "version": "1.3.5", 45 | "resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.3.5.tgz", 46 | "integrity": "sha512-1XoDy8kJnyWY/i/+gLTEbYLnoiVtS8y7ikBr/IfmML4Qb+CM7dEEbIUOjnY716WqmZ/UpXIxTfJsY7rMcqiCXA==", 47 | "requires": { 48 | "apollo-utilities": "^1.3.4", 49 | "tslib": "^1.10.0" 50 | } 51 | }, 52 | "apollo-cache-inmemory": { 53 | "version": "1.3.12", 54 | "resolved": "https://registry.npmjs.org/apollo-cache-inmemory/-/apollo-cache-inmemory-1.3.12.tgz", 55 | "integrity": "sha512-jxWcW64QoYQZ09UH6v3syvCCl3MWr6bsxT3wYYL6ORi8svdJUpnNrHTcv5qXqJYVg/a+NHhfEt+eGjJUG2ytXA==", 56 | "requires": { 57 | "apollo-cache": "^1.1.22", 58 | "apollo-utilities": "^1.0.27", 59 | "optimism": "^0.6.8" 60 | } 61 | }, 62 | "apollo-client": { 63 | "version": "2.4.6", 64 | "resolved": "https://registry.npmjs.org/apollo-client/-/apollo-client-2.4.6.tgz", 65 | "integrity": "sha512-RsZVMYone7mu3Wj4sr7ehctN8pdaHsP4X1Sv6Ly4gZ/YDetCCVnhbmnk5q7kvDtfoo0jhhHblxgFyA3FLLImtA==", 66 | "requires": { 67 | "@types/async": "2.0.50", 68 | "@types/zen-observable": "^0.8.0", 69 | "apollo-cache": "1.1.20", 70 | "apollo-link": "^1.0.0", 71 | "apollo-link-dedup": "^1.0.0", 72 | "apollo-utilities": "1.0.25", 73 | "symbol-observable": "^1.0.2", 74 | "zen-observable": "^0.8.0" 75 | }, 76 | "dependencies": { 77 | "apollo-cache": { 78 | "version": "1.1.20", 79 | "resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.1.20.tgz", 80 | "integrity": "sha512-+Du0/4kUSuf5PjPx0+pvgMGV12ezbHA8/hubYuqRQoy/4AWb4faa61CgJNI6cKz2mhDd9m94VTNKTX11NntwkQ==", 81 | "requires": { 82 | "apollo-utilities": "^1.0.25" 83 | } 84 | }, 85 | "apollo-utilities": { 86 | "version": "1.0.25", 87 | "resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.0.25.tgz", 88 | "integrity": "sha512-AXvqkhni3Ir1ffm4SA1QzXn8k8I5BBl4PVKEyak734i4jFdp+xgfUyi2VCqF64TJlFTA/B73TRDUvO2D+tKtZg==", 89 | "requires": { 90 | "fast-json-stable-stringify": "^2.0.0" 91 | } 92 | } 93 | } 94 | }, 95 | "apollo-link": { 96 | "version": "1.2.5", 97 | "resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.5.tgz", 98 | "integrity": "sha512-GJHEE4B06oEB58mpRRwW6ISyvgX2aCqCLjpcE3M/6/4e+ZVeX7fRGpMJJDq2zZ8n7qWdrEuY315JfxzpsJmUhA==", 99 | "requires": { 100 | "apollo-utilities": "^1.0.0", 101 | "zen-observable-ts": "^0.8.12" 102 | } 103 | }, 104 | "apollo-link-context": { 105 | "version": "1.0.11", 106 | "resolved": "https://registry.npmjs.org/apollo-link-context/-/apollo-link-context-1.0.11.tgz", 107 | "integrity": "sha512-aEM7zp3O1V4jVIm7me60T7Sw7vCuuGzE9ppE0ttGiud8slUbh7dTAgxirTEg3PjdPQA5ZoLCwqnGb+DzTxu+1g==", 108 | "requires": { 109 | "apollo-link": "^1.2.5" 110 | } 111 | }, 112 | "apollo-link-dedup": { 113 | "version": "1.0.21", 114 | "resolved": "https://registry.npmjs.org/apollo-link-dedup/-/apollo-link-dedup-1.0.21.tgz", 115 | "integrity": "sha512-r+mbfzMxj6m+oSKoNJTrTOTWbG4ysGscBla6ibdyvq/leLiroQw8HP9TtWRxVDtNlfkExEC548fUxr3LUgVssw==", 116 | "requires": { 117 | "apollo-link": "^1.2.14", 118 | "tslib": "^1.9.3" 119 | }, 120 | "dependencies": { 121 | "apollo-link": { 122 | "version": "1.2.14", 123 | "resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.14.tgz", 124 | "integrity": "sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg==", 125 | "requires": { 126 | "apollo-utilities": "^1.3.0", 127 | "ts-invariant": "^0.4.0", 128 | "tslib": "^1.9.3", 129 | "zen-observable-ts": "^0.8.21" 130 | } 131 | } 132 | } 133 | }, 134 | "apollo-link-http": { 135 | "version": "1.5.8", 136 | "resolved": "https://registry.npmjs.org/apollo-link-http/-/apollo-link-http-1.5.8.tgz", 137 | "integrity": "sha512-wkmj9fL5B4QYjw7q7w0GyetfqQKnA0QXGoh+/UK+LXJ+jLEz6JP2eLxrwgpX7o4ID6Og7l1JfeVxJE5fV1j2bg==", 138 | "requires": { 139 | "apollo-link": "^1.2.5", 140 | "apollo-link-http-common": "^0.2.7" 141 | } 142 | }, 143 | "apollo-link-http-common": { 144 | "version": "0.2.16", 145 | "resolved": "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.16.tgz", 146 | "integrity": "sha512-2tIhOIrnaF4UbQHf7kjeQA/EmSorB7+HyJIIrUjJOKBgnXwuexi8aMecRlqTIDWcyVXCeqLhUnztMa6bOH/jTg==", 147 | "requires": { 148 | "apollo-link": "^1.2.14", 149 | "ts-invariant": "^0.4.0", 150 | "tslib": "^1.9.3" 151 | }, 152 | "dependencies": { 153 | "apollo-link": { 154 | "version": "1.2.14", 155 | "resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.14.tgz", 156 | "integrity": "sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg==", 157 | "requires": { 158 | "apollo-utilities": "^1.3.0", 159 | "ts-invariant": "^0.4.0", 160 | "tslib": "^1.9.3", 161 | "zen-observable-ts": "^0.8.21" 162 | } 163 | } 164 | } 165 | }, 166 | "apollo-link-retry": { 167 | "version": "2.2.7", 168 | "resolved": "https://registry.npmjs.org/apollo-link-retry/-/apollo-link-retry-2.2.7.tgz", 169 | "integrity": "sha512-HlpeA09PZ6RL/l/nIYmJ+DjsdQ315HLLiSTLUo/Zq56wDuzlmbbEKUPkK5Sb92nFCwZOgm+TvHCrS6zUF33eQw==", 170 | "requires": { 171 | "@types/zen-observable": "0.8.0", 172 | "apollo-link": "^1.2.5" 173 | }, 174 | "dependencies": { 175 | "@types/zen-observable": { 176 | "version": "0.8.0", 177 | "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz", 178 | "integrity": "sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==" 179 | } 180 | } 181 | }, 182 | "apollo-utilities": { 183 | "version": "1.3.4", 184 | "resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.4.tgz", 185 | "integrity": "sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig==", 186 | "requires": { 187 | "@wry/equality": "^0.1.2", 188 | "fast-json-stable-stringify": "^2.0.0", 189 | "ts-invariant": "^0.4.0", 190 | "tslib": "^1.10.0" 191 | } 192 | }, 193 | "aws-appsync": { 194 | "version": "4.0.1", 195 | "resolved": "https://registry.npmjs.org/aws-appsync/-/aws-appsync-4.0.1.tgz", 196 | "integrity": "sha512-rgYMKrV0VWd4Tm8+DIhnDLZE7vUWFHCStEb7WrIELpQBOwFF6IVJtUWwcJoMgbi+ajp6r/5ou9s4PBA81SpYlg==", 197 | "requires": { 198 | "@redux-offline/redux-offline": "2.5.2-native.3", 199 | "apollo-cache-inmemory": "1.3.12", 200 | "apollo-client": "2.4.6", 201 | "apollo-link": "1.2.5", 202 | "apollo-link-context": "1.0.11", 203 | "apollo-link-http": "1.5.8", 204 | "apollo-link-retry": "2.2.7", 205 | "aws-appsync-auth-link": "^2.0.3", 206 | "aws-appsync-subscription-link": "^2.2.1", 207 | "aws-sdk": "2.518.0", 208 | "debug": "2.6.9", 209 | "graphql": "0.13.0", 210 | "redux": "^3.7.2", 211 | "redux-thunk": "^2.2.0", 212 | "setimmediate": "^1.0.5", 213 | "url": "^0.11.0", 214 | "uuid": "3.x" 215 | } 216 | }, 217 | "aws-appsync-auth-link": { 218 | "version": "2.0.3", 219 | "resolved": "https://registry.npmjs.org/aws-appsync-auth-link/-/aws-appsync-auth-link-2.0.3.tgz", 220 | "integrity": "sha512-CfXLILhhjMZvQ6OqKFAt6nd02T8YpQPyWS2H4Fmoe54RcQvYDBQDH9Gu1H3wFK77Wn866cwU3jd+W93UaZ7YXw==", 221 | "requires": { 222 | "apollo-link": "1.2.5", 223 | "aws-sdk": "^2.518.0", 224 | "debug": "2.6.9" 225 | } 226 | }, 227 | "aws-appsync-subscription-link": { 228 | "version": "2.2.1", 229 | "resolved": "https://registry.npmjs.org/aws-appsync-subscription-link/-/aws-appsync-subscription-link-2.2.1.tgz", 230 | "integrity": "sha512-dqr4P+Zc3oy7ttNzOwBhYuVH7XwEgPKAAOv79DjxzEcQIqSxAn+3HYMxn8gASrFcFNV1H04Cgd1wPxITZsJpnw==", 231 | "requires": { 232 | "apollo-link": "1.2.5", 233 | "apollo-link-context": "1.0.11", 234 | "apollo-link-http": "1.5.8", 235 | "apollo-link-retry": "2.2.7", 236 | "aws-appsync-auth-link": "^2.0.3", 237 | "debug": "2.6.9", 238 | "url": "^0.11.0" 239 | } 240 | }, 241 | "aws-sdk": { 242 | "version": "2.518.0", 243 | "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.518.0.tgz", 244 | "integrity": "sha512-hwtKKf93TFyd3qugDW54ElpkUXhPe+ArPIHadre6IAFjCJiv08L8DaZKLRyclDnKfTavKe+f/PhdSEYo1QUHiA==", 245 | "requires": { 246 | "buffer": "4.9.1", 247 | "events": "1.1.1", 248 | "ieee754": "1.1.8", 249 | "jmespath": "0.15.0", 250 | "querystring": "0.2.0", 251 | "sax": "1.2.1", 252 | "url": "0.10.3", 253 | "uuid": "3.3.2", 254 | "xml2js": "0.4.19" 255 | }, 256 | "dependencies": { 257 | "url": { 258 | "version": "0.10.3", 259 | "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", 260 | "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", 261 | "requires": { 262 | "punycode": "1.3.2", 263 | "querystring": "0.2.0" 264 | } 265 | }, 266 | "uuid": { 267 | "version": "3.3.2", 268 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", 269 | "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" 270 | } 271 | } 272 | }, 273 | "base64-js": { 274 | "version": "1.3.1", 275 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", 276 | "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" 277 | }, 278 | "buffer": { 279 | "version": "4.9.1", 280 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", 281 | "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", 282 | "requires": { 283 | "base64-js": "^1.0.2", 284 | "ieee754": "^1.1.4", 285 | "isarray": "^1.0.0" 286 | } 287 | }, 288 | "debug": { 289 | "version": "2.6.9", 290 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 291 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 292 | "requires": { 293 | "ms": "2.0.0" 294 | } 295 | }, 296 | "events": { 297 | "version": "1.1.1", 298 | "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", 299 | "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" 300 | }, 301 | "fast-json-stable-stringify": { 302 | "version": "2.1.0", 303 | "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", 304 | "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" 305 | }, 306 | "graphql": { 307 | "version": "0.13.0", 308 | "resolved": "https://registry.npmjs.org/graphql/-/graphql-0.13.0.tgz", 309 | "integrity": "sha512-WlO+ZJT9aY3YrBT+H5Kk+eVb3OVVehB9iRD/xqeHdmrrn4AFl5FIcOpfHz/vnBr6Y6JthGMlnFqU8XRnDjSR7A==", 310 | "requires": { 311 | "iterall": "1.1.x" 312 | } 313 | }, 314 | "graphql-tag": { 315 | "version": "2.11.0", 316 | "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.11.0.tgz", 317 | "integrity": "sha512-VmsD5pJqWJnQZMUeRwrDhfgoyqcfwEkvtpANqcoUG8/tOLkwNgU9mzub/Mc78OJMhHjx7gfAMTxzdG43VGg3bA==" 318 | }, 319 | "ieee754": { 320 | "version": "1.1.8", 321 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", 322 | "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" 323 | }, 324 | "immutable-tuple": { 325 | "version": "0.4.10", 326 | "resolved": "https://registry.npmjs.org/immutable-tuple/-/immutable-tuple-0.4.10.tgz", 327 | "integrity": "sha512-45jheDbc3Kr5Cw8EtDD+4woGRUV0utIrJBZT8XH0TPZRfm8tzT0/sLGGzyyCCFqFMG5Pv5Igf3WY/arn6+8V9Q==" 328 | }, 329 | "isarray": { 330 | "version": "1.0.0", 331 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 332 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" 333 | }, 334 | "isomorphic-fetch": { 335 | "version": "3.0.0", 336 | "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", 337 | "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", 338 | "requires": { 339 | "node-fetch": "^2.6.1", 340 | "whatwg-fetch": "^3.4.1" 341 | } 342 | }, 343 | "iterall": { 344 | "version": "1.1.4", 345 | "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.1.4.tgz", 346 | "integrity": "sha512-eaDsM/PY8D/X5mYQhecVc5/9xvSHED7yPON+ffQroBeTuqUVm7dfphMkK8NksXuImqZlVRoKtrNfMIVCYIqaUQ==" 347 | }, 348 | "jmespath": { 349 | "version": "0.15.0", 350 | "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", 351 | "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" 352 | }, 353 | "js-tokens": { 354 | "version": "4.0.0", 355 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 356 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 357 | }, 358 | "json-stringify-safe": { 359 | "version": "5.0.1", 360 | "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", 361 | "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" 362 | }, 363 | "lodash": { 364 | "version": "4.17.20", 365 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", 366 | "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" 367 | }, 368 | "lodash-es": { 369 | "version": "4.17.15", 370 | "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz", 371 | "integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==" 372 | }, 373 | "loose-envify": { 374 | "version": "1.4.0", 375 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 376 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 377 | "requires": { 378 | "js-tokens": "^3.0.0 || ^4.0.0" 379 | } 380 | }, 381 | "ms": { 382 | "version": "2.0.0", 383 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 384 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 385 | }, 386 | "node-fetch": { 387 | "version": "2.6.1", 388 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", 389 | "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" 390 | }, 391 | "optimism": { 392 | "version": "0.6.9", 393 | "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.6.9.tgz", 394 | "integrity": "sha512-xoQm2lvXbCA9Kd7SCx6y713Y7sZ6fUc5R6VYpoL5M6svKJbTuvtNopexK8sO8K4s0EOUYHuPN2+yAEsNyRggkQ==", 395 | "requires": { 396 | "immutable-tuple": "^0.4.9" 397 | } 398 | }, 399 | "punycode": { 400 | "version": "1.3.2", 401 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", 402 | "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" 403 | }, 404 | "querystring": { 405 | "version": "0.2.0", 406 | "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", 407 | "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" 408 | }, 409 | "redux": { 410 | "version": "3.7.2", 411 | "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz", 412 | "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==", 413 | "requires": { 414 | "lodash": "^4.2.1", 415 | "lodash-es": "^4.2.1", 416 | "loose-envify": "^1.1.0", 417 | "symbol-observable": "^1.0.3" 418 | } 419 | }, 420 | "redux-persist": { 421 | "version": "4.10.2", 422 | "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-4.10.2.tgz", 423 | "integrity": "sha512-U+e0ieMGC69Zr72929iJW40dEld7Mflh6mu0eJtVMLGfMq/aJqjxUM1hzyUWMR1VUyAEEdPHuQmeq5ti9krIgg==", 424 | "requires": { 425 | "json-stringify-safe": "^5.0.1", 426 | "lodash": "^4.17.4", 427 | "lodash-es": "^4.17.4" 428 | } 429 | }, 430 | "redux-thunk": { 431 | "version": "2.3.0", 432 | "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", 433 | "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" 434 | }, 435 | "regenerator-runtime": { 436 | "version": "0.13.7", 437 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", 438 | "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" 439 | }, 440 | "sax": { 441 | "version": "1.2.1", 442 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", 443 | "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" 444 | }, 445 | "setimmediate": { 446 | "version": "1.0.5", 447 | "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", 448 | "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" 449 | }, 450 | "symbol-observable": { 451 | "version": "1.2.0", 452 | "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", 453 | "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" 454 | }, 455 | "ts-invariant": { 456 | "version": "0.4.4", 457 | "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz", 458 | "integrity": "sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA==", 459 | "requires": { 460 | "tslib": "^1.9.3" 461 | } 462 | }, 463 | "tslib": { 464 | "version": "1.13.0", 465 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", 466 | "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==" 467 | }, 468 | "url": { 469 | "version": "0.11.0", 470 | "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", 471 | "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", 472 | "requires": { 473 | "punycode": "1.3.2", 474 | "querystring": "0.2.0" 475 | } 476 | }, 477 | "uuid": { 478 | "version": "3.4.0", 479 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", 480 | "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" 481 | }, 482 | "whatwg-fetch": { 483 | "version": "3.4.1", 484 | "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz", 485 | "integrity": "sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ==" 486 | }, 487 | "xml2js": { 488 | "version": "0.4.19", 489 | "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", 490 | "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", 491 | "requires": { 492 | "sax": ">=0.6.0", 493 | "xmlbuilder": "~9.0.1" 494 | } 495 | }, 496 | "xmlbuilder": { 497 | "version": "9.0.7", 498 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", 499 | "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" 500 | }, 501 | "zen-observable": { 502 | "version": "0.8.15", 503 | "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", 504 | "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" 505 | }, 506 | "zen-observable-ts": { 507 | "version": "0.8.21", 508 | "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz", 509 | "integrity": "sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg==", 510 | "requires": { 511 | "tslib": "^1.9.3", 512 | "zen-observable": "^0.8.0" 513 | } 514 | } 515 | } 516 | } 517 | -------------------------------------------------------------------------------- /src/functions/on_disconnect/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "presence-check", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "on_disconnect.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "aws-appsync": "^4.0.1", 13 | "graphql-tag": "^2.11.0", 14 | "isomorphic-fetch": "^3.0.0" 15 | }, 16 | "devDependencies": {} 17 | } 18 | -------------------------------------------------------------------------------- /src/functions/status/status.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | const redis = require('redis'); 20 | const { promisify } = require('util'); 21 | const redisEndpoint = process.env.REDIS_HOST; 22 | const redisPort = process.env.REDIS_PORT; 23 | const presence = redis.createClient(redisPort, redisEndpoint); 24 | const zscore = promisify(presence.zscore).bind(presence); 25 | 26 | /** 27 | * Status event handler 28 | * 29 | * 1 - Check `arguments.id` from the event 30 | * 2 - Calls zscore to check the presence of the id 31 | * 32 | * @param {*} event 33 | */ 34 | exports.handler = async function(event) { 35 | const id = event && event.arguments && event.arguments.id; 36 | if (undefined === id || null === id) throw new Error("Missing argument 'id'"); 37 | try { 38 | const result = await zscore("presence", id); 39 | return { id, status: result ? "online" : "offline" }; 40 | } catch (error) { 41 | return error; 42 | } 43 | } -------------------------------------------------------------------------------- /src/functions/timeout/timeout.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | const AWS = require('aws-sdk'); 20 | const redis = require('redis'); 21 | const { promisify } = require('util'); 22 | const timeout = parseInt(process.env.TIMEOUT); 23 | const eventBus = process.env.EVENT_BUS; 24 | const redisEndpoint = process.env.REDIS_HOST; 25 | const redisPort = process.env.REDIS_PORT; 26 | const presence = redis.createClient(redisPort, redisEndpoint); 27 | const eventBridge = new AWS.EventBridge(); 28 | 29 | /** 30 | * Timeout event handler 31 | * 32 | * 1 - Use `multi` to chain Redis commands 33 | * 2 - Commands are zrangebyscore to retrieve expired id, zremrangebyscore to remove them 34 | * 3 - Send events for each ids 35 | * 36 | */ 37 | exports.handler = async function() { 38 | const timestamp = Date.now() - timeout; 39 | const commands = presence.multi(); 40 | commands.zrangebyscore("presence", "-inf", timestamp); 41 | commands.zremrangebyscore("presence", "-inf", timestamp); 42 | const execute = promisify(commands.exec).bind(commands); 43 | try { 44 | // Multiple commands results are returned as an array of result, one entry per command 45 | // ids list is the result of the first command 46 | const [ids] = await execute(); 47 | if (!ids.length) return { expired: 0 }; 48 | // putEvents is limited to 10 events per call 49 | // Create a promise for each batch of ten events ... 50 | let promises = []; 51 | while ( ids.length ) { 52 | const Entries = ids.splice(0, 10).map( (id) => { 53 | return { 54 | Detail: JSON.stringify({id}), 55 | DetailType: "presence.disconnected", 56 | Source: "api.presence", 57 | EventBusName: eventBus, 58 | Time: Date.now() 59 | } 60 | }); 61 | promises.push(eventBridge.putEvents({ Entries }).promise()); 62 | } 63 | // ... and await for all promises to return 64 | const results = await Promise.all(promises); 65 | // Sum results for all promises and return 66 | const failed = results.reduce( 67 | (sum, result) => sum + result.FailedEntryCount, 68 | 0 69 | ); 70 | const expired = results.reduce( 71 | (sum, result) => sum + (result.Entries.length - result.FailedEntryCount), 72 | 0 73 | ); 74 | return { expired, failed }; 75 | } catch (error) { 76 | return error; 77 | } 78 | } -------------------------------------------------------------------------------- /src/layer/nodejs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "presence-layer", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/color-name": { 8 | "version": "1.1.1", 9 | "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", 10 | "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" 11 | }, 12 | "ansi-regex": { 13 | "version": "5.0.0", 14 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", 15 | "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" 16 | }, 17 | "ansi-styles": { 18 | "version": "4.2.1", 19 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", 20 | "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", 21 | "requires": { 22 | "@types/color-name": "^1.1.1", 23 | "color-convert": "^2.0.1" 24 | } 25 | }, 26 | "assertion-error": { 27 | "version": "1.1.0", 28 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", 29 | "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==" 30 | }, 31 | "balanced-match": { 32 | "version": "1.0.0", 33 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 34 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" 35 | }, 36 | "bluebird": { 37 | "version": "3.7.2", 38 | "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", 39 | "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" 40 | }, 41 | "brace-expansion": { 42 | "version": "1.1.11", 43 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 44 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 45 | "requires": { 46 | "balanced-match": "^1.0.0", 47 | "concat-map": "0.0.1" 48 | } 49 | }, 50 | "browser-stdout": { 51 | "version": "1.3.1", 52 | "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", 53 | "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==" 54 | }, 55 | "camelcase": { 56 | "version": "5.3.1", 57 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", 58 | "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" 59 | }, 60 | "chai": { 61 | "version": "3.5.0", 62 | "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", 63 | "integrity": "sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=", 64 | "requires": { 65 | "assertion-error": "^1.0.1", 66 | "deep-eql": "^0.1.3", 67 | "type-detect": "^1.0.0" 68 | } 69 | }, 70 | "cliui": { 71 | "version": "6.0.0", 72 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", 73 | "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", 74 | "requires": { 75 | "string-width": "^4.2.0", 76 | "strip-ansi": "^6.0.0", 77 | "wrap-ansi": "^6.2.0" 78 | } 79 | }, 80 | "color-convert": { 81 | "version": "2.0.1", 82 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 83 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 84 | "requires": { 85 | "color-name": "~1.1.4" 86 | } 87 | }, 88 | "color-name": { 89 | "version": "1.1.4", 90 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 91 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 92 | }, 93 | "colors": { 94 | "version": "1.4.0", 95 | "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", 96 | "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" 97 | }, 98 | "commander": { 99 | "version": "2.15.1", 100 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", 101 | "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==" 102 | }, 103 | "concat-map": { 104 | "version": "0.0.1", 105 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 106 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" 107 | }, 108 | "core-js": { 109 | "version": "3.6.5", 110 | "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", 111 | "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==" 112 | }, 113 | "debug": { 114 | "version": "3.1.0", 115 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 116 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 117 | "requires": { 118 | "ms": "2.0.0" 119 | } 120 | }, 121 | "decamelize": { 122 | "version": "1.2.0", 123 | "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", 124 | "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" 125 | }, 126 | "deep-eql": { 127 | "version": "0.1.3", 128 | "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", 129 | "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", 130 | "requires": { 131 | "type-detect": "0.1.1" 132 | }, 133 | "dependencies": { 134 | "type-detect": { 135 | "version": "0.1.1", 136 | "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", 137 | "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=" 138 | } 139 | } 140 | }, 141 | "denque": { 142 | "version": "1.4.1", 143 | "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", 144 | "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" 145 | }, 146 | "diff": { 147 | "version": "3.5.0", 148 | "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", 149 | "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" 150 | }, 151 | "emoji-regex": { 152 | "version": "8.0.0", 153 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 154 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" 155 | }, 156 | "escape-string-regexp": { 157 | "version": "1.0.5", 158 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 159 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" 160 | }, 161 | "find-up": { 162 | "version": "4.1.0", 163 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", 164 | "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", 165 | "requires": { 166 | "locate-path": "^5.0.0", 167 | "path-exists": "^4.0.0" 168 | } 169 | }, 170 | "fs.realpath": { 171 | "version": "1.0.0", 172 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 173 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" 174 | }, 175 | "get-caller-file": { 176 | "version": "2.0.5", 177 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 178 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" 179 | }, 180 | "glob": { 181 | "version": "7.1.2", 182 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 183 | "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", 184 | "requires": { 185 | "fs.realpath": "^1.0.0", 186 | "inflight": "^1.0.4", 187 | "inherits": "2", 188 | "minimatch": "^3.0.4", 189 | "once": "^1.3.0", 190 | "path-is-absolute": "^1.0.0" 191 | } 192 | }, 193 | "growl": { 194 | "version": "1.10.5", 195 | "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", 196 | "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==" 197 | }, 198 | "has-flag": { 199 | "version": "3.0.0", 200 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", 201 | "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" 202 | }, 203 | "he": { 204 | "version": "1.1.1", 205 | "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", 206 | "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" 207 | }, 208 | "inflight": { 209 | "version": "1.0.6", 210 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 211 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 212 | "requires": { 213 | "once": "^1.3.0", 214 | "wrappy": "1" 215 | } 216 | }, 217 | "inherits": { 218 | "version": "2.0.4", 219 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 220 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 221 | }, 222 | "is-fullwidth-code-point": { 223 | "version": "3.0.0", 224 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 225 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" 226 | }, 227 | "locate-path": { 228 | "version": "5.0.0", 229 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", 230 | "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 231 | "requires": { 232 | "p-locate": "^4.1.0" 233 | } 234 | }, 235 | "minimatch": { 236 | "version": "3.0.4", 237 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 238 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 239 | "requires": { 240 | "brace-expansion": "^1.1.7" 241 | } 242 | }, 243 | "minimist": { 244 | "version": "0.0.8", 245 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 246 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 247 | }, 248 | "mkdirp": { 249 | "version": "0.5.1", 250 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 251 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 252 | "requires": { 253 | "minimist": "0.0.8" 254 | } 255 | }, 256 | "mocha": { 257 | "version": "5.2.0", 258 | "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", 259 | "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", 260 | "requires": { 261 | "browser-stdout": "1.3.1", 262 | "commander": "2.15.1", 263 | "debug": "3.1.0", 264 | "diff": "3.5.0", 265 | "escape-string-regexp": "1.0.5", 266 | "glob": "7.1.2", 267 | "growl": "1.10.5", 268 | "he": "1.1.1", 269 | "minimatch": "3.0.4", 270 | "mkdirp": "0.5.1", 271 | "supports-color": "5.4.0" 272 | } 273 | }, 274 | "ms": { 275 | "version": "2.0.0", 276 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 277 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 278 | }, 279 | "once": { 280 | "version": "1.4.0", 281 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 282 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 283 | "requires": { 284 | "wrappy": "1" 285 | } 286 | }, 287 | "p-limit": { 288 | "version": "2.3.0", 289 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", 290 | "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", 291 | "requires": { 292 | "p-try": "^2.0.0" 293 | } 294 | }, 295 | "p-locate": { 296 | "version": "4.1.0", 297 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", 298 | "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", 299 | "requires": { 300 | "p-limit": "^2.2.0" 301 | } 302 | }, 303 | "p-try": { 304 | "version": "2.2.0", 305 | "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 306 | "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" 307 | }, 308 | "path-exists": { 309 | "version": "4.0.0", 310 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 311 | "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" 312 | }, 313 | "path-is-absolute": { 314 | "version": "1.0.1", 315 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 316 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" 317 | }, 318 | "redis": { 319 | "version": "3.0.2", 320 | "resolved": "https://registry.npmjs.org/redis/-/redis-3.0.2.tgz", 321 | "integrity": "sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==", 322 | "requires": { 323 | "denque": "^1.4.1", 324 | "redis-commands": "^1.5.0", 325 | "redis-errors": "^1.2.0", 326 | "redis-parser": "^3.0.0" 327 | } 328 | }, 329 | "redis-cli": { 330 | "version": "2.0.0", 331 | "resolved": "https://registry.npmjs.org/redis-cli/-/redis-cli-2.0.0.tgz", 332 | "integrity": "sha512-voiLo09Jm2z7jxxp2WgOPuN65aXPM+FM7HOJXEToqOigQT0+6zts0Jh260EklVRM4QfDAiIZExijcvSWkn65Yg==", 333 | "requires": { 334 | "bluebird": "^3.7.2", 335 | "colors": "^1.4.0", 336 | "core-js": "^3.6.5", 337 | "redis": "^3.0.2", 338 | "redis-splitargs": "^1.0.1", 339 | "yargs": "^15.4.1" 340 | } 341 | }, 342 | "redis-commands": { 343 | "version": "1.6.0", 344 | "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.6.0.tgz", 345 | "integrity": "sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ==" 346 | }, 347 | "redis-errors": { 348 | "version": "1.2.0", 349 | "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", 350 | "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" 351 | }, 352 | "redis-parser": { 353 | "version": "3.0.0", 354 | "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", 355 | "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", 356 | "requires": { 357 | "redis-errors": "^1.0.0" 358 | } 359 | }, 360 | "redis-splitargs": { 361 | "version": "1.0.1", 362 | "resolved": "https://registry.npmjs.org/redis-splitargs/-/redis-splitargs-1.0.1.tgz", 363 | "integrity": "sha512-HzR4b/wj1as/upm1iLCx7ckUGSjOhbtVRldNgNI29LiKS6Zw0PQ+jGC9VtW0AvBrM3/J2O4NKPWcvE3elZhwWQ==", 364 | "requires": { 365 | "chai": "^3.5.0", 366 | "mocha": "^5.2.0" 367 | } 368 | }, 369 | "require-directory": { 370 | "version": "2.1.1", 371 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 372 | "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" 373 | }, 374 | "require-main-filename": { 375 | "version": "2.0.0", 376 | "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", 377 | "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" 378 | }, 379 | "set-blocking": { 380 | "version": "2.0.0", 381 | "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", 382 | "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" 383 | }, 384 | "string-width": { 385 | "version": "4.2.0", 386 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", 387 | "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", 388 | "requires": { 389 | "emoji-regex": "^8.0.0", 390 | "is-fullwidth-code-point": "^3.0.0", 391 | "strip-ansi": "^6.0.0" 392 | } 393 | }, 394 | "strip-ansi": { 395 | "version": "6.0.0", 396 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", 397 | "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", 398 | "requires": { 399 | "ansi-regex": "^5.0.0" 400 | } 401 | }, 402 | "supports-color": { 403 | "version": "5.4.0", 404 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", 405 | "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", 406 | "requires": { 407 | "has-flag": "^3.0.0" 408 | } 409 | }, 410 | "type-detect": { 411 | "version": "1.0.0", 412 | "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", 413 | "integrity": "sha1-diIXzAbbJY7EiQihKY6LlRIejqI=" 414 | }, 415 | "which-module": { 416 | "version": "2.0.0", 417 | "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", 418 | "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" 419 | }, 420 | "wrap-ansi": { 421 | "version": "6.2.0", 422 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", 423 | "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", 424 | "requires": { 425 | "ansi-styles": "^4.0.0", 426 | "string-width": "^4.1.0", 427 | "strip-ansi": "^6.0.0" 428 | } 429 | }, 430 | "wrappy": { 431 | "version": "1.0.2", 432 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 433 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 434 | }, 435 | "y18n": { 436 | "version": "4.0.0", 437 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", 438 | "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" 439 | }, 440 | "yargs": { 441 | "version": "15.4.1", 442 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", 443 | "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", 444 | "requires": { 445 | "cliui": "^6.0.0", 446 | "decamelize": "^1.2.0", 447 | "find-up": "^4.1.0", 448 | "get-caller-file": "^2.0.1", 449 | "require-directory": "^2.1.1", 450 | "require-main-filename": "^2.0.0", 451 | "set-blocking": "^2.0.0", 452 | "string-width": "^4.2.0", 453 | "which-module": "^2.0.0", 454 | "y18n": "^4.0.0", 455 | "yargs-parser": "^18.1.2" 456 | } 457 | }, 458 | "yargs-parser": { 459 | "version": "18.1.3", 460 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", 461 | "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", 462 | "requires": { 463 | "camelcase": "^5.0.0", 464 | "decamelize": "^1.2.0" 465 | } 466 | } 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /src/layer/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "presence-layer", 3 | "version": "1.0.0", 4 | "description": "Layer for presence API", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "chappo", 10 | "license": "ISC", 11 | "dependencies": { 12 | "redis-cli": "^2.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/functions/disconnect.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { promisify } from "util"; 5 | import * as AWS from "../mocks/aws-sdk"; 6 | const redis = require('redis-mock'); 7 | const disconnect = require("../../src/functions/disconnect/disconnect"); 8 | jest.mock('redis', () => redis); 9 | jest.mock('aws-sdk', () => AWS); 10 | 11 | describe("Disconnect function", () => { 12 | test("Exports an handler function", () => { 13 | expect(disconnect).toHaveProperty('handler'); 14 | expect(typeof disconnect.handler).toBe("function"); 15 | }); 16 | 17 | describe("Event parameter: id missing", ()=> { 18 | const missingMessage = "Missing argument 'id'"; 19 | test("Null event", async () => { 20 | await expect(disconnect.handler).rejects.toThrow(missingMessage); 21 | }); 22 | test("Empty event", async () => { 23 | await expect(disconnect.handler({})).rejects.toThrow(missingMessage); 24 | }); 25 | test("No arguments event", async () => { 26 | await expect(disconnect.handler({test: 1})).rejects.toThrow(missingMessage); 27 | }); 28 | test("Empty arguments", async () => { 29 | await expect(disconnect.handler({arguments: {}})).rejects.toThrow(missingMessage); 30 | }); 31 | test("No id in arguments", async () => { 32 | await expect(disconnect.handler({arguments: {test: 1}})).rejects.toThrow(missingMessage); 33 | }); 34 | test("Id passed ok", async () => { 35 | await expect(disconnect.handler({arguments: {id: "test_id"}})).resolves.toMatchObject({id: "test_id"}); 36 | }); 37 | }); 38 | 39 | describe("Disconnected saved", () => { 40 | const testMember = "test_disconnect"; 41 | const client = redis.createClient(); 42 | const events = new AWS.EventBridge(); 43 | // Make sure key is not set 44 | const zscore = promisify(client.zscore).bind(client); 45 | const zadd = promisify(client.zadd).bind(client); 46 | test("ZSCORE not set", async () => { 47 | await expect(zscore("presence", testMember)).resolves.toBeNull(); 48 | }); 49 | test("Disconnnect already offline", async () => { 50 | await expect(disconnect.handler({arguments: {id: testMember}})) 51 | .resolves.toMatchObject({id: testMember, status: "offline"}); 52 | expect(events.putEvents).not.toHaveBeenCalled(); 53 | }); 54 | test("Set score", async () => { 55 | await expect(zadd("presence", 1234, testMember)).resolves.toBe(1); 56 | }); 57 | test("Disconnnect returns offline", async () => { 58 | await expect(disconnect.handler({arguments: {id: testMember}})) 59 | .resolves.toMatchObject({id: testMember, status: "offline"}); 60 | }); 61 | test("Disconnected event sent", () => { 62 | expect(events.putEvents).toHaveBeenCalledTimes(1); 63 | expect(events.putEvents).toHaveBeenCalledWith({ 64 | "Entries": [expect.objectContaining({ 65 | "DetailType":"presence.disconnected", 66 | "Detail": JSON.stringify({id: testMember}) 67 | })] 68 | }); 69 | }); 70 | test("ZSCORE removed", async () => { 71 | await expect(zscore("presence", testMember)).resolves.toBeNull(); 72 | }); 73 | }); 74 | 75 | }); -------------------------------------------------------------------------------- /test/functions/heartbeat.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { promisify } from "util"; 5 | import * as AWS from "../mocks/aws-sdk"; 6 | const redis = require("redis-mock"); 7 | const heartbeat = require("../../src/functions/heartbeat/heartbeat"); 8 | jest.mock('redis', () => redis); 9 | jest.mock('aws-sdk', () => AWS); 10 | 11 | describe("Heartbeat function", () => { 12 | test("Exports an handler function", () => { 13 | expect(heartbeat).toHaveProperty('handler'); 14 | expect(typeof heartbeat.handler).toBe("function"); 15 | }); 16 | 17 | describe("Event parameter: id missing", ()=> { 18 | const missingMessage = "Missing argument 'id'"; 19 | test("Null event", async () => { 20 | await expect(heartbeat.handler).rejects.toThrow(missingMessage); 21 | }); 22 | test("Empty event", async () => { 23 | await expect(heartbeat.handler({})).rejects.toThrow(missingMessage); 24 | }); 25 | test("No arguments event", async () => { 26 | await expect(heartbeat.handler({test: 1})).rejects.toThrow(missingMessage); 27 | }); 28 | test("Empty arguments", async () => { 29 | await expect(heartbeat.handler({arguments: {}})).rejects.toThrow(missingMessage); 30 | }); 31 | test("No id in arguments", async () => { 32 | await expect(heartbeat.handler({arguments: {test: 1}})).rejects.toThrow(missingMessage); 33 | }); 34 | test("Id passed ok", async () => { 35 | await expect(heartbeat.handler({arguments: {id: "test_id"}})).resolves.toMatchObject({id: "test_id"}); 36 | }); 37 | }); 38 | 39 | describe("Heartbeat saved", () => { 40 | const testMember = "test_heartbeat"; 41 | const client = redis.createClient(); 42 | const events = new AWS.EventBridge(); 43 | // Make sure key is not set 44 | const zscore = promisify(client.zscore).bind(client); 45 | test("ZSCORE not set", async () => { 46 | await expect(zscore("presence", testMember)).resolves.toBe(null); 47 | }); 48 | test("Heartbeat return", async () => { 49 | events.putEvents.mockClear(); 50 | await expect(heartbeat.handler({arguments: {id: testMember}})) 51 | .resolves.toMatchObject({id: testMember, status: "online"}); 52 | }); 53 | test("Check EventBridge call", () => { 54 | expect(events.putEvents).toHaveBeenCalledTimes(1); 55 | expect(events.putEvents).toHaveBeenCalledWith({ 56 | "Entries": [expect.objectContaining({ 57 | "DetailType":"presence.connected", 58 | "Detail": JSON.stringify({id: testMember}) 59 | })] 60 | }); 61 | }); 62 | let stamp: number; 63 | test("ZSCORE set", async () => { 64 | const result = await zscore("presence", testMember); 65 | expect(typeof result).toBe('string'); 66 | expect(parseInt(result)).not.toBeNaN(); 67 | stamp = parseInt(result); 68 | }); 69 | test("Heartbeat still online", async () => { 70 | events.putEvents.mockClear(); 71 | await expect(heartbeat.handler({arguments: {id: testMember}})) 72 | .resolves.toMatchObject({id: testMember, status: "online"}); 73 | }); 74 | test("Heartbeat update: no events sent", () => { 75 | expect(events.putEvents).not.toHaveBeenCalled(); 76 | }); 77 | test("ZSCORE updated", async () => { 78 | const result = await zscore("presence", testMember); 79 | expect(typeof result).toBe('string'); 80 | expect(parseInt(result)).not.toBeNaN(); 81 | expect(parseInt(result)).toBeGreaterThan(stamp); 82 | }); 83 | }); 84 | 85 | }); -------------------------------------------------------------------------------- /test/functions/on_disconnect.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import * as AppSync from "../mocks/aws-appsync"; 5 | const on_disconnect = require("../../src/functions/on_disconnect/on_disconnect"); 6 | jest.mock('../../src/functions/on_disconnect/node_modules/aws-appsync', () => AppSync); 7 | 8 | describe("On disconnect function", () => { 9 | test("Exports an handler function", () => { 10 | expect(on_disconnect).toHaveProperty('handler'); 11 | expect(typeof on_disconnect.handler).toBe("function"); 12 | }); 13 | 14 | describe("Event parameter: id missing", ()=> { 15 | const missingMessage = "Missing argument 'id'"; 16 | test("Null event", async () => { 17 | await expect(on_disconnect.handler).rejects.toThrow(missingMessage); 18 | }); 19 | test("Empty event", async () => { 20 | await expect(on_disconnect.handler({})).rejects.toThrow(missingMessage); 21 | }); 22 | test("No detail event", async () => { 23 | await expect(on_disconnect.handler({test: 1})).rejects.toThrow(missingMessage); 24 | }); 25 | test("Empty detail", async () => { 26 | await expect(on_disconnect.handler({detail: {}})).rejects.toThrow(missingMessage); 27 | }); 28 | test("No id in detail", async () => { 29 | await expect(on_disconnect.handler({detail: {test: 1}})).rejects.toThrow(missingMessage); 30 | }); 31 | }); 32 | 33 | describe("Test with id", () => { 34 | const AppSyncClient = AppSync.AppSyncClient; 35 | const client = new AppSyncClient(); 36 | test("Returned value", async () => { 37 | await expect(on_disconnect.handler({detail: {id: "test_id"}})) 38 | .resolves.toMatchObject({id: "test_id", status:"offline"}); 39 | }); 40 | test("AppSync called", () => { 41 | expect(client.mutate).toHaveBeenCalledTimes(1); 42 | }); 43 | }); 44 | }); -------------------------------------------------------------------------------- /test/functions/status.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { promisify } from "util"; 5 | const redis = require('redis-mock'); 6 | const status = require("../../src/functions/status/status"); 7 | jest.mock('redis', () => redis); 8 | 9 | describe("Status function", () => { 10 | test("Exports an handler function", () => { 11 | expect(status).toHaveProperty('handler'); 12 | expect(typeof status.handler).toBe("function"); 13 | }); 14 | 15 | describe("Event parameter: id missing", ()=> { 16 | const missingMessage = "Missing argument 'id'"; 17 | test("Null event", async () => { 18 | await expect(status.handler).rejects.toThrow(missingMessage); 19 | }); 20 | test("Empty event", async () => { 21 | await expect(status.handler({})).rejects.toThrow(missingMessage); 22 | }); 23 | test("No arguments event", async () => { 24 | await expect(status.handler({test: 1})).rejects.toThrow(missingMessage); 25 | }); 26 | test("Empty arguments", async () => { 27 | await expect(status.handler({arguments: {}})).rejects.toThrow(missingMessage); 28 | }); 29 | test("No id in arguments", async () => { 30 | await expect(status.handler({arguments: {test: 1}})).rejects.toThrow(missingMessage); 31 | }); 32 | test("Id passed ok", async () => { 33 | await expect(status.handler({arguments: {id: "test_id"}})).resolves.toMatchObject({id: "test_id"}); 34 | }); 35 | }); 36 | 37 | describe("status check", () => { 38 | const testMember = "test_status"; 39 | const client = redis.createClient(); 40 | // Make sure key is not set 41 | const zadd = promisify(client.zadd).bind(client); 42 | test("status offline", async () => { 43 | await expect(status.handler({arguments: {id: testMember}})) 44 | .resolves.toMatchObject({id: testMember, status: "offline"}); 45 | }); 46 | test("set score", async () => { 47 | await expect(zadd("presence", 1234, testMember)) 48 | .resolves.toBe(1); 49 | }); 50 | test("status online", async () => { 51 | await expect(status.handler({arguments: {id: testMember}})) 52 | .resolves.toMatchObject({id: testMember, status: "online"}); 53 | }); 54 | }); 55 | 56 | }); -------------------------------------------------------------------------------- /test/functions/timeout.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { promisify } from "util"; 5 | import * as AWS from "../mocks/aws-sdk"; 6 | const redis = require('redis-mock'); 7 | 8 | // Set timeout as 120s for tests 9 | process.env.TIMEOUT = "120000"; 10 | const expire = 240000; 11 | const timeout = require("../../src/functions/timeout/timeout"); 12 | jest.mock('redis', () => redis); 13 | jest.mock('aws-sdk', () => AWS); 14 | 15 | describe("Timeout function", () => { 16 | test("Exports an handler function", () => { 17 | expect(timeout).toHaveProperty('handler'); 18 | expect(typeof timeout.handler).toBe('function'); 19 | }); 20 | 21 | // helpers 22 | const client = redis.createClient(); 23 | const zadd = promisify(client.zadd).bind(client); 24 | const events = new AWS.EventBridge(); 25 | test("No disconnection (empty db)", async() => { 26 | await expect(timeout.handler()).resolves.toMatchObject({expired:0}); 27 | }); 28 | 29 | test("No disconnection (with data)", async() => { 30 | const now = Date.now(); 31 | await expect(zadd("presence", [now, "online1", now, "online2", now, "online3"])).resolves.toBe(3); 32 | await expect(timeout.handler()).resolves.toMatchObject({expired:0}); 33 | }); 34 | 35 | test("Disconnections (<10)", async() => { 36 | const past = Date.now() - expire; 37 | await expect(zadd("presence", [past, "offline1", past, "offline2", past, "offline3"])).resolves.toBe(3); 38 | await expect(timeout.handler()).resolves.toMatchObject({expired:3, failed:0}); 39 | }); 40 | 41 | test("Disconnection removed elements", async() => { 42 | await expect(timeout.handler()).resolves.toMatchObject({expired:0}); 43 | }); 44 | 45 | const eventTest = (id : String) => expect.objectContaining({ 46 | "DetailType":"presence.disconnected", 47 | "Detail": JSON.stringify({id: id}) 48 | }); 49 | test("Events sent (<10)", async() => { 50 | expect(events.putEvents).toHaveBeenCalledTimes(1); 51 | expect(events.putEvents).toHaveBeenCalledWith({ 52 | "Entries": expect.arrayContaining([ 53 | eventTest("offline2"), 54 | eventTest("offline1"), 55 | eventTest("offline3") 56 | ]) 57 | }); 58 | }); 59 | 60 | test("Disconnections (>10)", async() => { 61 | events.putEvents.mockClear(); 62 | const past = Date.now() - expire; 63 | const members = []; 64 | for (let i=1; i < 16; i++) { 65 | members.push(past, `offline${i}`); 66 | } 67 | await expect(zadd("presence", members)).resolves.toBe(15); 68 | await expect(timeout.handler()).resolves.toMatchObject({expired:15, failed:0}); 69 | }); 70 | 71 | test("Events sent (>10)", async() => { 72 | expect(events.putEvents).toHaveBeenCalledTimes(2); 73 | const calls = events.putEvents.mock.calls; 74 | expect(calls[0][0]).toHaveProperty("Entries"); 75 | expect(calls[0][0].Entries).toHaveLength(10); 76 | expect(calls[1][0].Entries).toHaveLength(5); 77 | }); 78 | }); -------------------------------------------------------------------------------- /test/integration/api.test.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | import PresenceApi from "./apiclient"; 20 | 21 | describe("API Integration test", () => { 22 | const api = new PresenceApi(); 23 | test("Check stack output", () => { 24 | expect(PresenceApi.getConfig()).toMatchObject({ 25 | "PresenceStack": { 26 | "presenceapi": expect.stringMatching(/https:.*\/graphql/), 27 | "apikey": expect.stringMatching(/.*/) 28 | } 29 | }); 30 | }); 31 | 32 | describe("Simple API Calls", () => { 33 | test('Query status', async () => { 34 | const result = await api.status("test"); 35 | expect(result).toMatchObject({ 36 | id: "test", status: "offline" 37 | }); 38 | }); 39 | 40 | test('Connect and online', async () => { 41 | const connection = await api.connect("connect"); 42 | expect(connection).toMatchObject({ 43 | id: "connect", status: "online" 44 | }); 45 | const presence = await api.status("connect"); 46 | expect(presence).toMatchObject({ 47 | id: "connect", status: "online" 48 | }); 49 | }); 50 | 51 | test('Disconnect and offline', async () => { 52 | const disconnection = await api.disconnect("connect"); 53 | expect(disconnection).toMatchObject({ 54 | id: "connect", status: "offline" 55 | }); 56 | const presence : object = await api.status("connect"); 57 | expect(presence).toMatchObject({ 58 | id: "connect", status: "offline" 59 | }); 60 | }); 61 | 62 | test('Hearbeat and online too', async () => { 63 | const heartbeat : object = await api.heartbeat("heartbeat"); 64 | expect(heartbeat).toMatchObject({ 65 | id: "heartbeat", status: "online" 66 | }); 67 | const presence = await api.status("heartbeat"); 68 | expect(presence).toMatchObject({ 69 | id: "heartbeat", status: "online" 70 | }); 71 | }); 72 | }); 73 | 74 | describe("Notifications tests", () => { 75 | const delayTime = 1000; // Use delay to receive notifications 76 | const observePlayer1 = jest.fn( (data) => data ); 77 | const observePlayer2 = jest.fn( (data) => data ); 78 | const delay = () => new Promise( (resolve, reject) => { setTimeout(resolve, delayTime) } ); 79 | const api = new PresenceApi(); 80 | const player1Sub = api.notify("player1").subscribe({ 81 | next: (notification) => { 82 | expect(notification).toHaveProperty("data"); 83 | observePlayer1(notification.data); 84 | } 85 | }); 86 | const player2Sub = api.notify("player2").subscribe({ 87 | next: (notification) => { 88 | expect(notification).toHaveProperty("data"); 89 | observePlayer2(notification.data); 90 | } 91 | }); 92 | 93 | test("Connect notification", async () => { 94 | await api.connect("player1").then(delay); 95 | expect(observePlayer1).toHaveBeenLastCalledWith( 96 | expect.objectContaining({ 97 | onStatus: expect.objectContaining({ 98 | id: "player1", 99 | status: "online" 100 | }) 101 | }) 102 | ); 103 | }); 104 | 105 | test("Disconnect notification", async () => { 106 | await api.disconnect("player1").then(delay); 107 | expect(observePlayer1).toHaveBeenLastCalledWith( 108 | expect.objectContaining({ 109 | onStatus: expect.objectContaining({ 110 | id: "player1", 111 | status: "offline" 112 | }) 113 | }) 114 | ); 115 | }); 116 | 117 | test("Second player notification", async () => { 118 | await api.connect("player2").then(delay); 119 | //expect(observePlayer1).toHaveBeenCalledTimes(2); 120 | expect(observePlayer2).toHaveBeenLastCalledWith( 121 | expect.objectContaining({ 122 | onStatus: expect.objectContaining({ 123 | id: "player2", 124 | status: "online" 125 | }) 126 | }) 127 | ); 128 | }); 129 | 130 | afterAll(()=>{ 131 | player1Sub.unsubscribe(); 132 | player2Sub.unsubscribe(); 133 | }); 134 | 135 | }); 136 | 137 | }); -------------------------------------------------------------------------------- /test/integration/apiclient.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | require('isomorphic-fetch'); // Required for 'aws-appsync' 20 | 21 | import * as AWSAppSync from "aws-appsync"; 22 | import gql from "graphql-tag"; 23 | 24 | // Prepare all queries 25 | const presenceResult = `{ 26 | id 27 | status 28 | }`; 29 | const getStatus = gql` 30 | query getStatus($id: ID!) { 31 | status(id: $id) ${presenceResult} 32 | } 33 | `; 34 | const sendHeartbeat = gql` 35 | query heartbeat($id: ID!) { 36 | heartbeat(id: $id) ${presenceResult} 37 | } 38 | ` 39 | const connectPlayer = gql` 40 | mutation connectPlayer($id: ID!) { 41 | connect(id: $id) ${presenceResult} 42 | } 43 | ` 44 | const disconnectPlayer = gql` 45 | mutation disconnectPlayer($id: ID!) { 46 | disconnect(id: $id) ${presenceResult} 47 | } 48 | ` 49 | const onChangeStatus = gql` 50 | subscription statusChanged($id: ID!) { 51 | onStatus(id: $id) ${presenceResult} 52 | } 53 | ` 54 | 55 | // Client creation 56 | class Api { 57 | private static _client : AWSAppSync.AWSAppSyncClient; 58 | private static _stackOutput = require("../../presence.json"); 59 | private static initClient() : AWSAppSync.AWSAppSyncClient { 60 | const config : AWSAppSync.AWSAppSyncClientOptions = { 61 | url: Api._stackOutput.PresenceStack.presenceapi, 62 | region: Api._stackOutput.PresenceStack.region, 63 | auth: { 64 | type: AWSAppSync.AUTH_TYPE.API_KEY, 65 | apiKey: Api._stackOutput.PresenceStack.apikey 66 | }, 67 | disableOffline: true 68 | }; 69 | return new AWSAppSync.AWSAppSyncClient(config); 70 | } 71 | 72 | constructor() { 73 | if (!Api._client) Api._client = Api.initClient(); 74 | } 75 | 76 | static getConfig() { 77 | return this._stackOutput; 78 | } 79 | 80 | private _extract(field : string) : any { 81 | return (result: {[f:string]:any}) : any => { 82 | const { __typename, ...data } = result.data[field]; 83 | return data; 84 | } 85 | } 86 | 87 | private async _mutate(id: string, gqlQuery: any, ret: string) { 88 | return Api._client.mutate({ 89 | mutation: gqlQuery, 90 | variables: { id } 91 | }).then( this._extract(ret) ); 92 | } 93 | 94 | private async _query(id: string, gqlQuery: any, ret: string) { 95 | return Api._client.query({ 96 | query: gqlQuery, 97 | variables: { id } 98 | }).then( this._extract(ret) ); 99 | } 100 | 101 | async connect(id: string) { 102 | return this._mutate(id, connectPlayer, "connect"); 103 | }; 104 | 105 | async disconnect(id: string) { 106 | return this._mutate(id, disconnectPlayer, "disconnect"); 107 | }; 108 | 109 | async status(id: string) { 110 | return this._query(id, getStatus, "status"); 111 | }; 112 | 113 | async heartbeat(id: string) { 114 | return this._query(id, sendHeartbeat, "heartbeat"); 115 | } 116 | 117 | notify(id: string) { 118 | return Api._client.subscribe({ 119 | query: onChangeStatus, 120 | variables: { id } 121 | }); 122 | }; 123 | }; 124 | 125 | export default Api; -------------------------------------------------------------------------------- /test/mocks/aws-appsync.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | const mutateMock = jest.fn().mockImplementation( 20 | (data) => { 21 | return Promise.resolve({ 22 | data: { 23 | disconnected: { 24 | id: data.variables.id, 25 | status: "offline" 26 | } 27 | } 28 | }); 29 | } 30 | ); 31 | 32 | enum AUTH_TYPE { 33 | AWS_IAM = "AWS_IAM" 34 | } 35 | 36 | export class AppSyncClient { 37 | mutate = mutateMock 38 | } 39 | 40 | export default AppSyncClient 41 | 42 | export { 43 | AUTH_TYPE 44 | } -------------------------------------------------------------------------------- /test/mocks/aws-sdk.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | const putEventsMock = jest.fn().mockImplementation((event) => { 20 | const entries = (event.Entries || []).map(() => {EventId: 'xxx'}); 21 | return { 22 | promise: jest.fn().mockReturnValue(Promise.resolve({ 23 | Entries: entries, 24 | FailedEntryCount: 0 25 | })) 26 | }; 27 | }); 28 | 29 | export class EventBridge { 30 | putEvents = putEventsMock; 31 | } 32 | -------------------------------------------------------------------------------- /test/stack/presence.test.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | import { expect as expectCDK, haveResource, haveOutput, Capture, countResources, haveResourceLike, objectLike } from '@aws-cdk/assert'; 20 | import * as cdk from '@aws-cdk/core'; 21 | import * as Presence from '../../lib/presence-stack'; 22 | 23 | // Initialize a test stack 24 | const app = new cdk.App(); 25 | const stack = new Presence.PresenceStack(app, 'TestStack'); 26 | 27 | describe('GraphQLAPI and Stack Output', () => { 28 | // THEN 29 | test("GraphQL API exists", () => { 30 | expectCDK(stack).to(haveResource('AWS::AppSync::GraphQLApi')); 31 | }); 32 | const name = stack.getLogicalId(stack.api.node.defaultChild as cdk.CfnElement); 33 | test("Output graphQL url", () => { 34 | expectCDK(stack).to(haveOutput({ 35 | outputName: 'presenceapi', 36 | exportName: 'presenceEndpoint', 37 | outputValue: { 38 | 'Fn::GetAtt': [ 39 | name, 40 | 'GraphQLUrl' 41 | ] 42 | } 43 | })); 44 | }); 45 | }); 46 | 47 | describe("Checking GraphQL schema", () => { 48 | const definition = Capture.aString(); 49 | const testNoSpaces = (s: string) => () => { 50 | const expr = s.replace(/\s+/g,'\\s*') 51 | .replace(/([()\[\]])/g,'\\$1'); 52 | expect(definition.capturedValue).toMatch(new RegExp(expr)); 53 | }; 54 | 55 | test("Schema inlined", () => { 56 | expectCDK(stack).to(haveResource('AWS::AppSync::GraphQLSchema', { 57 | Definition: definition.capture() 58 | })); 59 | }); 60 | 61 | test("Basic types", testNoSpaces(`schema { 62 | query: Query 63 | mutation: Mutation 64 | subscription: Subscription 65 | }`)); 66 | 67 | test("Status enum", testNoSpaces(`enum Status { 68 | online 69 | offline 70 | }`)); 71 | 72 | test("Presence type", testNoSpaces(`type Presence @aws_iam @aws_api_key { 73 | id: ID! 74 | status: Status! 75 | }`)); 76 | 77 | test("Queries", testNoSpaces(`type Query { 78 | heartbeat(id: ID!): Presence 79 | status(id: ID!): Presence 80 | }`)); 81 | 82 | test("Mutations", testNoSpaces(`type Mutation { 83 | connect(id: ID!): Presence 84 | disconnect(id: ID!): Presence 85 | disconnected(id: ID!): Presence 86 | @aws_iam 87 | }`)); 88 | 89 | test("Subscriptions", testNoSpaces(`type Subscription { 90 | onStatus(id: ID!): Presence 91 | @aws_subscribe(mutations: [\"connect\", \"disconnected\"]) 92 | }`)); 93 | 94 | }); 95 | 96 | describe("Lambda functions", () => { 97 | test("Define 5 lambdas", () => { 98 | expectCDK(stack).to(countResources("AWS::Lambda::Function", 5)); 99 | }); 100 | test("Checking some lambdas", () => { 101 | expectCDK(stack).to(haveResourceLike("AWS::Lambda::Function", { 102 | Handler: "timeout.handler", 103 | Environment: { 104 | Variables: objectLike({ TIMEOUT: "10000" }) } 105 | })); 106 | }); 107 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "typeRoots": ["./node_modules/@types"], 21 | "skipLibCheck": true, 22 | }, 23 | "exclude": ["cdk.out"] 24 | } 25 | --------------------------------------------------------------------------------