├── .nvmrc ├── .prettierignore ├── architecture.png ├── lambdas ├── sdk │ ├── index.ts │ ├── chat.ts │ ├── realTime.ts │ └── ddb.ts ├── handlers │ ├── verify.ts │ ├── disconnect.ts │ ├── delete.ts │ ├── createChatToken.ts │ ├── castVote.ts │ ├── list.ts │ ├── publishVotes.ts │ ├── join.ts │ ├── create.ts │ ├── update.ts │ └── updateStatus.ts ├── clients.ts ├── constants.ts ├── utils.ts ├── types.ts └── helpers.ts ├── CODE_OF_CONDUCT.md ├── .gitignore ├── scripts ├── post-deploy.js ├── seed │ ├── deleteSeed.js │ ├── fruits.json │ └── seed.js ├── publish │ ├── generateLaunchStackUrl.js │ └── pre-publish.js ├── clients.js └── utils.js ├── .prettierrc ├── postman ├── RT-demo.postman_environment.json └── RT-demo.postman_collection.json ├── LICENSE ├── tsconfig.json ├── package.json ├── lib ├── policies.ts ├── utils.ts ├── Constructs │ ├── IntegratedProxyLambda.ts │ └── CronScheduleTrigger.ts ├── schemas.ts └── real-time-stack.ts ├── bin └── stack.ts ├── cdk.json ├── CONTRIBUTING.md ├── Makefile └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-real-time-serverless-demo/HEAD/architecture.png -------------------------------------------------------------------------------- /lambdas/sdk/index.ts: -------------------------------------------------------------------------------- 1 | export * as chatSdk from './chat'; 2 | export * as ddbSdk from './ddb'; 3 | export * as realTimeSdk from './realTime'; 4 | -------------------------------------------------------------------------------- /lambdas/handlers/verify.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | 3 | import { createSuccessResponse } from '../utils'; 4 | 5 | export const handler: APIGatewayProxyHandlerV2 = async () => { 6 | return createSuccessResponse({ body: 'OK' }); 7 | }; 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | dist 5 | temp_out.json 6 | scripts/**/output.json 7 | scripts/**/publish.*.env 8 | scripts/**/launch.*.json 9 | !scripts/**/*.js 10 | 11 | # CDK asset staging directory 12 | .cdk.staging 13 | cdk.out 14 | 15 | # Misc 16 | **/.DS_Store 17 | .eslintcache 18 | .DS_Store 19 | log.txt 20 | npm-debug.log* 21 | -------------------------------------------------------------------------------- /scripts/post-deploy.js: -------------------------------------------------------------------------------- 1 | const qrcode = require('qrcode-terminal'); 2 | 3 | const { getCustomerCode } = require('./utils'); 4 | 5 | async function runPostDeploy() { 6 | const { cid, apiKey } = await getCustomerCode(); 7 | 8 | console.info(`\n ⭐️ Customer ID: ${cid}`); 9 | console.info(`\n 🔑 API key: ${apiKey}`); 10 | console.info('\n 🔎 Authentication QR code:'); 11 | qrcode.generate(`${cid}-${apiKey}`, { small: true }); 12 | } 13 | 14 | runPostDeploy(); 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx"], 7 | "options": { "parser": "babel-ts" } 8 | }, 9 | { 10 | "files": "*.js", 11 | "options": { "parser": "babel" } 12 | }, 13 | { 14 | "files": "*.json", 15 | "options": { "parser": "json" } 16 | }, 17 | { 18 | "files": "*.md", 19 | "options": { "parser": "markdown" } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /postman/RT-demo.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "c218ede8-0b92-4748-ae80-5b787d79f1c1", 3 | "name": "RT-demo", 4 | "values": [ 5 | { 6 | "key": "cid", 7 | "value": "", 8 | "type": "default", 9 | "enabled": true 10 | }, 11 | { 12 | "key": "api_key", 13 | "value": "3BHNNy7AjEpwXPSHq4l6qw9IwFkLeFGnG15KEL8SeY9TyGoSzHaD4zguvkqFRDfF", 14 | "type": "default", 15 | "enabled": true 16 | }, 17 | { 18 | "key": "hostId", 19 | "value": "", 20 | "type": "default", 21 | "enabled": true 22 | }, 23 | { 24 | "key": "type", 25 | "value": "video", 26 | "type": "default", 27 | "enabled": true 28 | } 29 | ], 30 | "_postman_variable_scope": "environment", 31 | "_postman_exported_at": "2023-04-05T20:02:40.594Z", 32 | "_postman_exported_using": "Postman/10.12.0" 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to 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 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /lambdas/clients.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 3 | import { IvschatClient } from '@aws-sdk/client-ivschat'; 4 | import { IVSRealTimeClient } from '@aws-sdk/client-ivs-realtime'; 5 | 6 | export const ivsRealTimeClient = new IVSRealTimeClient({ maxAttempts: 12 }); 7 | export const ivsChatClient = new IvschatClient({ maxAttempts: 12 }); 8 | export const ddbDocClient = DynamoDBDocumentClient.from( 9 | new DynamoDBClient({}), 10 | { 11 | marshallOptions: { 12 | convertClassInstanceToMap: false, // Whether to convert typeof object to map attribute 13 | convertEmptyValues: false, // Whether to automatically convert empty strings, blobs, and sets to `null` 14 | removeUndefinedValues: true // Whether to remove undefined values while marshalling 15 | }, 16 | unmarshallOptions: { 17 | wrapNumbers: false // Whether to return numbers as a string instead of converting them to native JavaScript numbers 18 | } 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /scripts/seed/deleteSeed.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring'); 2 | const { getCustomerCode, retryWithConstantBackoff } = require('../utils'); 3 | 4 | async function getDemoItems(cid, apiKey) { 5 | const url = `https://${cid}.cloudfront.net`; 6 | const searchParams = querystring.stringify({ createdFor: 'demo' }); 7 | 8 | const result = await fetch(`${url}?${searchParams}`, { 9 | headers: { 'x-api-key': apiKey } 10 | }); 11 | const { stages } = await result.json(); 12 | 13 | return stages; 14 | } 15 | 16 | async function deleteDemoItem(cid, apiKey, hostId) { 17 | await fetch(`https://${cid}.cloudfront.net`, { 18 | method: 'DELETE', 19 | headers: { 'x-api-key': apiKey }, 20 | body: JSON.stringify({ hostId }) 21 | }); 22 | } 23 | 24 | async function deleteSeed() { 25 | const { cid, apiKey } = await getCustomerCode(); 26 | const demoItems = await getDemoItems(cid, apiKey); 27 | 28 | await Promise.all( 29 | demoItems.map(({ hostId }) => 30 | retryWithConstantBackoff(() => deleteDemoItem(cid, apiKey, hostId)) 31 | ) 32 | ); 33 | } 34 | 35 | deleteSeed(); 36 | -------------------------------------------------------------------------------- /scripts/publish/generateLaunchStackUrl.js: -------------------------------------------------------------------------------- 1 | const { writeFileSync } = require('fs'); 2 | 3 | const { getStackName, getAwsConfig } = require('../utils'); 4 | const manifest = require('../../cdk.out/manifest.json'); 5 | 6 | async function generateLaunchStackUrls() { 7 | const stackName = getStackName(); 8 | const { region } = await getAwsConfig(); 9 | const { properties } = manifest.artifacts[stackName]; 10 | const [bucket, ...keyPath] = properties.stackTemplateAssetObjectUrl 11 | .replace('s3://', '') 12 | .split('/'); 13 | const templateKey = keyPath.join('/'); 14 | 15 | const templateUrl = `https://${bucket}.s3.${region}.amazonaws.com/${templateKey}`; 16 | const launchStackUrl = `https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/new?stackName=${stackName}&templateURL=${templateUrl}`; 17 | 18 | const outputFilename = `scripts/publish/launch.${region}.json`; 19 | writeFileSync(outputFilename, JSON.stringify({ launchStackUrl }, null, 2)); 20 | 21 | console.info(launchStackUrl); 22 | console.info('\nOutput:', outputFilename); 23 | } 24 | 25 | generateLaunchStackUrls(); 26 | -------------------------------------------------------------------------------- /scripts/seed/fruits.json: -------------------------------------------------------------------------------- 1 | [ 2 | "apple", 3 | "apricot", 4 | "avocado", 5 | "banana", 6 | "bilberry", 7 | "blackberry", 8 | "blueberry", 9 | "breadfruit", 10 | "cantaloupe", 11 | "cherimoya", 12 | "cherry", 13 | "clementine", 14 | "cloudberry", 15 | "coconut", 16 | "cranberry", 17 | "cucumber", 18 | "currant", 19 | "damson", 20 | "date", 21 | "dragonfruit", 22 | "durian", 23 | "eggplant", 24 | "elderberry", 25 | "feijoa", 26 | "fig", 27 | "gooseberry", 28 | "grape", 29 | "grapefruit", 30 | "guava", 31 | "honeydew", 32 | "huckleberry", 33 | "jackfruit", 34 | "jambul", 35 | "jujube", 36 | "lemon", 37 | "lime", 38 | "lychee", 39 | "mandarine", 40 | "mango", 41 | "mulberry", 42 | "nectarine", 43 | "olive", 44 | "orange", 45 | "papaya", 46 | "passionfruit", 47 | "peach", 48 | "pear", 49 | "persimmon", 50 | "pineapple", 51 | "plum", 52 | "pomegranate", 53 | "pomelo", 54 | "quince", 55 | "raisin", 56 | "rambutan", 57 | "raspberry", 58 | "satsuma", 59 | "starfruit", 60 | "strawberry", 61 | "tamarillo", 62 | "tangerine", 63 | "tomato", 64 | "watermelon" 65 | ] 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "alwaysStrict": true, 6 | "checkJs": true, 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "incremental": true, 12 | "inlineSourceMap": true, 13 | "inlineSources": true, 14 | "isolatedModules": true, 15 | "lib": ["ES2022"], 16 | "module": "commonjs", 17 | "moduleResolution": "Node10", 18 | "noEmit": true, 19 | "noErrorTruncation": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "noImplicitReturns": true, 23 | "noImplicitThis": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "outDir": "dist", 27 | "resolveJsonModule": true, 28 | "skipLibCheck": true, 29 | "strict": true, 30 | "strictNullChecks": true, 31 | "strictPropertyInitialization": false, 32 | "target": "ES2022", 33 | "typeRoots": ["./node_modules/@types"] 34 | }, 35 | "exclude": ["node_modules", "cdk.out", "scripts", "dist/**/*"] 36 | } 37 | -------------------------------------------------------------------------------- /scripts/clients.js: -------------------------------------------------------------------------------- 1 | const { CloudFormationClient } = require('@aws-sdk/client-cloudformation'); 2 | const { fromIni } = require('@aws-sdk/credential-providers'); 3 | const { IAMClient } = require('@aws-sdk/client-iam'); 4 | const { parseArgs } = require('util'); 5 | const { S3Client } = require('@aws-sdk/client-s3'); 6 | const { SecretsManagerClient } = require('@aws-sdk/client-secrets-manager'); 7 | const { STSClient } = require('@aws-sdk/client-sts'); 8 | 9 | const { values: args } = parseArgs({ 10 | options: { 11 | profile: { type: 'string' } 12 | }, 13 | strict: false 14 | }); 15 | const { profile: awsProfile } = args; 16 | 17 | let clientConfig = {}; 18 | if (awsProfile) { 19 | const credentialsProvider = fromIni({ profile: awsProfile }); 20 | clientConfig = { credentials: credentialsProvider }; 21 | } 22 | 23 | // AWS clients 24 | const cloudFormationClient = new CloudFormationClient(clientConfig); 25 | const iamClient = new IAMClient(clientConfig); 26 | const s3Client = new S3Client(clientConfig); 27 | const secretsManagerClient = new SecretsManagerClient(clientConfig); 28 | const stsClient = new STSClient(clientConfig); 29 | 30 | module.exports = { 31 | cloudFormationClient, 32 | iamClient, 33 | s3Client, 34 | secretsManagerClient, 35 | stsClient 36 | }; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amazon-ivs-real-time-demo", 3 | "version": "0.1.0", 4 | "bin": { 5 | "amazon-ivs-real-time-demo": "bin/stack.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "cdk": "cdk" 11 | }, 12 | "devDependencies": { 13 | "@types/aws-lambda": "^8.10.131", 14 | "@types/node": "^20.11.5", 15 | "aws-cdk": "^2.122.0", 16 | "cdk-assets": "^2.122.0", 17 | "cdk-nag": "^2.28.14", 18 | "dotenv": "^16.3.2", 19 | "esbuild": "^0.25.0", 20 | "ts-node": "^10.9.2", 21 | "typescript": "^5.3.3" 22 | }, 23 | "dependencies": { 24 | "@aws-sdk/client-cloudformation": "^3.496.0", 25 | "@aws-sdk/client-dynamodb": "^3.496.0", 26 | "@aws-sdk/client-iam": "^3.496.0", 27 | "@aws-sdk/client-ivs-realtime": "^3.496.0", 28 | "@aws-sdk/client-ivschat": "^3.496.0", 29 | "@aws-sdk/client-s3": "^3.496.0", 30 | "@aws-sdk/client-secrets-manager": "^3.496.0", 31 | "@aws-sdk/client-sts": "^3.496.0", 32 | "@aws-sdk/credential-providers": "^3.496.0", 33 | "@aws-sdk/lib-dynamodb": "^3.496.0", 34 | "@aws-sdk/util-dynamodb": "^3.496.0", 35 | "aws-cdk-lib": "^2.189.1", 36 | "constructs": "^10.3.0", 37 | "cron-parser": "^4.9.0", 38 | "qrcode-terminal": "^0.12.0", 39 | "source-map-support": "^0.5.21" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/policies.ts: -------------------------------------------------------------------------------- 1 | import { aws_iam as iam } from 'aws-cdk-lib'; 2 | 3 | export const tagResourcesPolicy = new iam.PolicyStatement({ 4 | effect: iam.Effect.ALLOW, 5 | actions: ['ivs:TagResource', 'ivschat:TagResource'], 6 | resources: ['*'] 7 | }); 8 | 9 | export const createResourcesPolicy = new iam.PolicyStatement({ 10 | effect: iam.Effect.ALLOW, 11 | actions: ['ivs:CreateStage', 'ivschat:CreateRoom'], 12 | resources: ['*'] 13 | }); 14 | 15 | export const createTokensPolicy = new iam.PolicyStatement({ 16 | effect: iam.Effect.ALLOW, 17 | actions: ['ivs:CreateParticipantToken', 'ivschat:CreateChatToken'], 18 | resources: ['*'] 19 | }); 20 | 21 | export const getResourcesPolicy = new iam.PolicyStatement({ 22 | effect: iam.Effect.ALLOW, 23 | actions: ['ivs:ListStages', 'ivschat:ListRooms'], 24 | resources: ['*'] 25 | }); 26 | 27 | export const sendEventPolicy = new iam.PolicyStatement({ 28 | effect: iam.Effect.ALLOW, 29 | actions: ['ivschat:SendEvent'], 30 | resources: ['*'] 31 | }); 32 | 33 | export const disconnectUsersPolicy = new iam.PolicyStatement({ 34 | effect: iam.Effect.ALLOW, 35 | actions: ['ivs:DisconnectParticipant', 'ivschat:DisconnectUser'], 36 | resources: ['*'] 37 | }); 38 | 39 | export const deleteResourcesPolicy = new iam.PolicyStatement({ 40 | effect: iam.Effect.ALLOW, 41 | actions: ['ivs:DeleteStage', 'ivschat:DeleteRoom'], 42 | resources: ['*'] 43 | }); 44 | -------------------------------------------------------------------------------- /lambdas/constants.ts: -------------------------------------------------------------------------------- 1 | import { StageMode } from './types'; 2 | 3 | export const RESOURCE_TAGS = { stack: process.env.STACK as string }; 4 | 5 | /** 6 | * Configurations 7 | */ 8 | export const AUDIO_ROOM_SIZE = 12; // participants 9 | 10 | export const PARTICIPANT_TOKEN_DURATION_IN_MINUTES = 20160; // 14 days (max) 11 | export const CHAT_TOKEN_SESSION_DURATION_IN_MINUTES = 180; // 3 hours (max) 12 | export const IDLE_TIME_UNTIL_STALE_IN_SECONDS = 3600; // 1 hour 13 | export const UPDATE_STATUS_INTERVAL_IN_SECONDS = 3; // Constraint: 1-59 14 | 15 | export const ALLOWED_FILTER_ATTRIBUTES = [ 16 | 'mode', 17 | 'status', 18 | 'type', 19 | 'createdFor' 20 | ]; 21 | 22 | export const SUMMARY_ATTRIBUTES = [ 23 | 'createdAt', 24 | 'createdFor', 25 | 'hostAttributes', 26 | 'hostId', 27 | 'mode', 28 | 'seats', 29 | 'stageArn', 30 | 'status', 31 | 'type' 32 | ]; 33 | 34 | export const SIMPLE_MODE_NAMES = { 35 | [StageMode.NONE]: '', 36 | [StageMode.PK]: 'PK', 37 | [StageMode.GUEST_SPOT]: 'Guest Spot' 38 | }; 39 | 40 | /** 41 | * Exceptions 42 | */ 43 | export const BAD_INPUT_EXCEPTION = 'BadInputException'; 44 | export const BAD_PARAMS_EXCEPTION = 'BadParamsException'; 45 | export const INVALID_STAGE_UPDATE_EXCEPTION = 'InvalidStageUpdateException'; 46 | export const RESTRICTED_FILTER_EXCEPTION = 'RestrictedFilterException'; 47 | export const USER_NOT_FOUND_EXCEPTION = 'UserNotFoundException'; 48 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_dynamodb as dynamodb, 3 | aws_lambda_nodejs as lambda, 4 | aws_logs as logs, 5 | Duration, 6 | RemovalPolicy 7 | } from 'aws-cdk-lib'; 8 | import { Runtime } from 'aws-cdk-lib/aws-lambda'; 9 | import { join } from 'path'; 10 | 11 | const getLambdaEntryPath = (functionName: string) => 12 | join(__dirname, '../lambdas/handlers', `${functionName}.ts`); 13 | 14 | export const getDefaultLambdaProps = ( 15 | entryFunctionName?: string 16 | ): lambda.NodejsFunctionProps => ({ 17 | bundling: { 18 | /** 19 | * By default, when using the NODEJS_18_X runtime, @aws-sdk/* is included in externalModules 20 | * since it is already available in the Lambda runtime. However, to ensure that the latest 21 | * @aws-sdk version is used, which contains the @aws-sdk/client-ivs-realtime package, we 22 | * remove @aws-sdk/* from externalModules so that we bundle it instead. 23 | */ 24 | externalModules: [], 25 | minify: true 26 | }, 27 | memorySize: 256, 28 | runtime: Runtime.NODEJS_18_X, 29 | timeout: Duration.minutes(1), 30 | maxEventAge: Duration.minutes(1), 31 | logRetention: logs.RetentionDays.THREE_MONTHS, 32 | ...(entryFunctionName && { entry: getLambdaEntryPath(entryFunctionName) }) 33 | }); 34 | 35 | export const getDefaultTableProps = ( 36 | partitionKey: dynamodb.Attribute 37 | ): dynamodb.TableProps => ({ 38 | partitionKey, 39 | removalPolicy: RemovalPolicy.DESTROY, 40 | billingMode: dynamodb.BillingMode.PAY_PER_REQUEST 41 | }); 42 | -------------------------------------------------------------------------------- /lambdas/handlers/disconnect.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 3 | 4 | import { USER_NOT_FOUND_EXCEPTION } from '../constants'; 5 | import { chatSdk, ddbSdk, realTimeSdk } from '../sdk'; 6 | import { createErrorResponse, createSuccessResponse } from '../utils'; 7 | import { DisconnectEventBody } from '../types'; 8 | 9 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 10 | const { body = '{}' } = event; 11 | const { hostId, userId, participantId }: DisconnectEventBody = 12 | JSON.parse(body); 13 | 14 | console.info('EVENT', JSON.stringify(event)); 15 | 16 | try { 17 | const { Item: RealTimeRecordItem } = await ddbSdk.getRealTimeRecord(hostId); 18 | 19 | if (!RealTimeRecordItem) { 20 | return createErrorResponse({ 21 | code: 404, 22 | name: USER_NOT_FOUND_EXCEPTION, 23 | message: `No host exists with the ID ${hostId}` 24 | }); 25 | } 26 | 27 | const realTimeRecord = unmarshall(RealTimeRecordItem); 28 | const { chatRoomArn, stageArn } = realTimeRecord; 29 | 30 | console.info( 31 | `Disconnecting user "${userId}"`, 32 | JSON.stringify(realTimeRecord) 33 | ); 34 | 35 | await Promise.all([ 36 | chatSdk.disconnectChatUser(chatRoomArn, userId), 37 | realTimeSdk.disconnectParticipant(stageArn, participantId) 38 | ]); 39 | } catch (error) { 40 | return createErrorResponse({ error }); 41 | } 42 | 43 | return createSuccessResponse(); 44 | }; 45 | -------------------------------------------------------------------------------- /lambdas/handlers/delete.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 3 | 4 | import { chatSdk, ddbSdk, realTimeSdk } from '../sdk'; 5 | import { createErrorResponse, createSuccessResponse } from '../utils'; 6 | import { DeleteEventBody, RealTimeRecord } from '../types'; 7 | import { USER_NOT_FOUND_EXCEPTION } from '../constants'; 8 | 9 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 10 | const { body = '{}' } = event; 11 | const { hostId }: DeleteEventBody = JSON.parse(body); 12 | 13 | console.info('EVENT', JSON.stringify(event)); 14 | 15 | try { 16 | const { Item: RealTimeRecordItem } = await ddbSdk.getRealTimeRecord(hostId); 17 | 18 | if (!RealTimeRecordItem) { 19 | return createErrorResponse({ 20 | code: 404, 21 | name: USER_NOT_FOUND_EXCEPTION, 22 | message: `No host exists with the ID ${hostId}` 23 | }); 24 | } 25 | 26 | const realTimeRecord = unmarshall(RealTimeRecordItem) as RealTimeRecord; 27 | const { chatRoomArn, stageArn } = realTimeRecord; 28 | 29 | console.info('Deleting record', JSON.stringify(realTimeRecord)); 30 | 31 | // Delete the record references to the stage/room resources first 32 | await ddbSdk.deleteRealTimeRecord(hostId); 33 | await ddbSdk.deleteVotesRecord(hostId); 34 | await Promise.all([ 35 | realTimeSdk.deleteStage(stageArn), 36 | chatSdk.deleteRoom(chatRoomArn) 37 | ]); 38 | } catch (error) { 39 | return createErrorResponse({ error }); 40 | } 41 | 42 | return createSuccessResponse({ code: 204 }); 43 | }; 44 | -------------------------------------------------------------------------------- /lambdas/handlers/createChatToken.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { 3 | ChatTokenCapability, 4 | CreateChatTokenResponse 5 | } from '@aws-sdk/client-ivschat'; 6 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 7 | 8 | import { USER_NOT_FOUND_EXCEPTION } from '../constants'; 9 | import { chatSdk, ddbSdk } from '../sdk'; 10 | import { CreateChatTokenBody } from '../types'; 11 | import { createErrorResponse, createSuccessResponse } from '../utils'; 12 | 13 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 14 | const { body = '{}' } = event; 15 | const { hostId, userId, attributes }: CreateChatTokenBody = JSON.parse(body); 16 | let response: CreateChatTokenResponse; 17 | 18 | console.info('EVENT', JSON.stringify(event)); 19 | 20 | try { 21 | const { Item: RealTimeRecordItem } = await ddbSdk.getRealTimeRecord(hostId); 22 | 23 | if (!RealTimeRecordItem) { 24 | return createErrorResponse({ 25 | code: 404, 26 | name: USER_NOT_FOUND_EXCEPTION, 27 | message: `No host exists with the ID ${hostId}` 28 | }); 29 | } 30 | 31 | const realTimeRecord = unmarshall(RealTimeRecordItem); 32 | const { chatRoomArn } = realTimeRecord; 33 | 34 | console.info( 35 | `Creating chat token for user "${userId}"`, 36 | JSON.stringify(realTimeRecord) 37 | ); 38 | 39 | response = await chatSdk.createChatToken({ 40 | userId, 41 | attributes, 42 | chatRoomArn, 43 | capabilities: [ChatTokenCapability.SEND_MESSAGE] 44 | }); 45 | } catch (error) { 46 | return createErrorResponse({ error }); 47 | } 48 | 49 | console.info('RESPONSE', JSON.stringify(response)); 50 | 51 | return createSuccessResponse({ body: response }); 52 | }; 53 | -------------------------------------------------------------------------------- /lambdas/handlers/castVote.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { 3 | ConditionalCheckFailedException, 4 | ReturnValue, 5 | UpdateItemCommand 6 | } from '@aws-sdk/client-dynamodb'; 7 | import { convertToAttr, unmarshall } from '@aws-sdk/util-dynamodb'; 8 | 9 | import { CastVoteBody } from '../types'; 10 | import { createErrorResponse, createSuccessResponse } from '../utils'; 11 | import { ddbDocClient } from '../clients'; 12 | import { ddbSdk } from '../sdk'; 13 | 14 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 15 | const { body = '{}' } = event; 16 | const { hostId, vote }: CastVoteBody = JSON.parse(body); 17 | 18 | console.info('EVENT', JSON.stringify(event)); 19 | 20 | try { 21 | const { Attributes = {} } = await ddbDocClient.send( 22 | new UpdateItemCommand({ 23 | TableName: ddbSdk.votesTableName, 24 | Key: { hostId: convertToAttr(hostId) }, 25 | UpdateExpression: `ADD #tally.#${vote} :count`, 26 | ConditionExpression: `attribute_exists(#hostId) and attribute_exists(#tally.#${vote})`, 27 | ExpressionAttributeNames: { 28 | '#hostId': 'hostId', 29 | '#tally': 'tally', 30 | [`#${vote}`]: vote 31 | }, 32 | ReturnValues: ReturnValue.UPDATED_NEW, 33 | ExpressionAttributeValues: { ':count': convertToAttr(1) } 34 | }) 35 | ); 36 | 37 | console.info('Updated vote tally', unmarshall(Attributes)); 38 | } catch (error) { 39 | if (error instanceof ConditionalCheckFailedException) { 40 | return createErrorResponse({ 41 | code: 400, 42 | name: error.name, 43 | message: `The provided hostId (${hostId}) and/or vote (${vote}) is not associated with an active voting session` 44 | }); 45 | } 46 | 47 | return createErrorResponse({ error }); 48 | } 49 | 50 | return createSuccessResponse({ code: 204 }); 51 | }; 52 | -------------------------------------------------------------------------------- /lambdas/handlers/list.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 3 | 4 | import { 5 | ALLOWED_FILTER_ATTRIBUTES, 6 | RESTRICTED_FILTER_EXCEPTION, 7 | SUMMARY_ATTRIBUTES 8 | } from '../constants'; 9 | import { createErrorResponse, createSuccessResponse } from '../utils'; 10 | import { ddbSdk } from '../sdk'; 11 | import { ListResponse, RealTimeRecord } from '../types'; 12 | 13 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 14 | const { queryStringParameters } = event; 15 | const filters = queryStringParameters || {}; 16 | const response: ListResponse = { stages: [] }; 17 | 18 | console.info('EVENT', JSON.stringify(event)); 19 | 20 | const filterKeys = Object.keys(filters); 21 | const restrictedFilterKey = filterKeys.find( 22 | (filterKey) => !ALLOWED_FILTER_ATTRIBUTES.includes(filterKey) 23 | ); 24 | if (restrictedFilterKey) { 25 | return createErrorResponse({ 26 | code: 400, 27 | name: RESTRICTED_FILTER_EXCEPTION, 28 | message: `Restricted filter key provided: ${restrictedFilterKey}` 29 | }); 30 | } 31 | 32 | try { 33 | const { Items: RealTimeRecordItems = [] } = await ddbSdk.getRealTimeRecords( 34 | { attributesToGet: SUMMARY_ATTRIBUTES, filters } 35 | ); 36 | 37 | if (RealTimeRecordItems.length) { 38 | const unmarshalledItems = RealTimeRecordItems.map( 39 | (item) => unmarshall(item) as RealTimeRecord 40 | ); 41 | response.stages = unmarshalledItems.sort((item1, item2) => { 42 | if (item1.createdAt > item2.createdAt) return 1; 43 | if (item1.createdAt < item2.createdAt) return -1; 44 | return 0; 45 | }); 46 | } 47 | } catch (error) { 48 | return createErrorResponse({ error }); 49 | } 50 | 51 | console.info('RESPONSE', JSON.stringify(response)); 52 | 53 | return createSuccessResponse({ body: response }); 54 | }; 55 | -------------------------------------------------------------------------------- /lambdas/utils.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyResultV2 } from 'aws-lambda'; 2 | 3 | const DEFAULT_RESPONSE_HEADERS = { 4 | 'Access-Control-Allow-Headers': 'Content-Type', 5 | 'Access-Control-Allow-Origin': '*', 6 | 'Content-Type': 'application/json' 7 | }; 8 | 9 | class ResponseError extends Error { 10 | public readonly code; 11 | 12 | constructor( 13 | code: number = 500, 14 | name: string = 'UnexpectedError', 15 | message: string = 'Unexpected error occurred' 16 | ) { 17 | super(message); 18 | this.code = code; 19 | this.name = name; 20 | } 21 | } 22 | 23 | export const createSuccessResponse = ({ 24 | body = {}, 25 | code = 200 26 | }: { 27 | body?: object | string; 28 | code?: number; 29 | } = {}): APIGatewayProxyResultV2 => ({ 30 | statusCode: code, 31 | headers: DEFAULT_RESPONSE_HEADERS, 32 | body: typeof body === 'string' ? body : JSON.stringify(body) 33 | }); 34 | 35 | export const createErrorResponse = ({ 36 | code = 500, 37 | name, 38 | message, 39 | error 40 | }: { 41 | code?: number; 42 | name?: string; 43 | message?: string; 44 | error?: unknown; 45 | } = {}): APIGatewayProxyResultV2 => { 46 | const responseError = error || new ResponseError(code, name, message); 47 | console.error(responseError); // log the response error and data to CloudWatch 48 | 49 | return { 50 | statusCode: code, 51 | headers: DEFAULT_RESPONSE_HEADERS, 52 | body: JSON.stringify( 53 | responseError, 54 | Object.getOwnPropertyNames(responseError).filter((key) => key !== 'stack') 55 | ) 56 | }; 57 | }; 58 | 59 | export const getElapsedTimeInSeconds = (fromDate: string) => { 60 | if (!fromDate) return 0; 61 | 62 | const elapsedTimeMs = Date.now() - new Date(fromDate).getTime(); 63 | const elapsedTimeSeconds = elapsedTimeMs / 1000; 64 | 65 | return elapsedTimeSeconds; 66 | }; 67 | 68 | export const exhaustiveSwitchGuard = (value: never): never => { 69 | throw new Error( 70 | `ERROR! Reached forbidden guard function with unexpected value: ${JSON.stringify( 71 | value 72 | )}` 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /bin/stack.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { 4 | App, 5 | Aspects, 6 | Aws, 7 | DefaultStackSynthesizer, 8 | Environment 9 | } from 'aws-cdk-lib'; 10 | import { AwsSolutionsChecks } from 'cdk-nag'; 11 | import * as dotenv from 'dotenv'; 12 | import path from 'path'; 13 | 14 | import { RealTimeStack } from '../lib/real-time-stack'; 15 | 16 | const app = new App(); 17 | 18 | // Runtime context values 19 | const stackName: string = app.node.tryGetContext('stackName'); 20 | const alarmsEmail: string | undefined = app.node.tryGetContext('alarmsEmail'); 21 | const terminationProtection: boolean = JSON.parse( 22 | app.node.tryGetContext('terminationProtection') || 'false' 23 | ); 24 | const nag: boolean = JSON.parse(app.node.tryGetContext('nag') || 'false'); 25 | const publish: boolean = JSON.parse( 26 | app.node.tryGetContext('publish') || 'false' 27 | ); 28 | 29 | // Environment 30 | const region = process.env.CDK_DEFAULT_REGION; 31 | let env: Environment = { 32 | account: process.env.CDK_DEFAULT_ACCOUNT, 33 | region 34 | }; 35 | 36 | // Synthesizer 37 | let synthesizer: DefaultStackSynthesizer | undefined; 38 | 39 | if (publish) { 40 | const publishEnvPath = path.resolve( 41 | __dirname, 42 | `../scripts/publish/publish.${region}.env` 43 | ); 44 | dotenv.config({ path: publishEnvPath }); 45 | 46 | synthesizer = new DefaultStackSynthesizer({ 47 | fileAssetsBucketName: process.env.FILE_ASSETS_BUCKET_NAME, 48 | fileAssetPublishingRoleArn: process.env.FILE_ASSET_PUBLISHING_ROLE_ARN, 49 | generateBootstrapVersionRule: false, 50 | bucketPrefix: `${stackName}/` 51 | }); 52 | 53 | // account-agnostic environment 54 | env = { account: Aws.ACCOUNT_ID, region }; 55 | } 56 | 57 | new RealTimeStack(app, stackName, { 58 | env, 59 | synthesizer, 60 | terminationProtection, 61 | alarmsEmail 62 | }); 63 | 64 | // Check the CDK app for best practices by using a combination of rule packs 65 | if (nag) { 66 | const awsSolutionsChecks = new AwsSolutionsChecks({ verbose: true }); 67 | Aspects.of(app).add(awsSolutionsChecks); 68 | } 69 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/stack.ts", 3 | "watch": { 4 | "include": ["**"], 5 | "exclude": [ 6 | "README.md", 7 | "cdk*.json", 8 | "**/*.d.ts", 9 | "**/*.js", 10 | "tsconfig.json", 11 | "package*.json", 12 | "yarn.lock", 13 | "node_modules", 14 | "test" 15 | ] 16 | }, 17 | "context": { 18 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 19 | "@aws-cdk/core:checkSecretUsage": true, 20 | "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], 21 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 22 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 23 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 24 | "@aws-cdk/aws-iam:minimizePolicies": true, 25 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 26 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 27 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 28 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 29 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 30 | "@aws-cdk/core:enablePartitionLiterals": true, 31 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 32 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 33 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 34 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 35 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 36 | "@aws-cdk/aws-route53-patters:useCertificate": true, 37 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 38 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 39 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 40 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 41 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 42 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 43 | "@aws-cdk/aws-redshift:columnId": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/Constructs/IntegratedProxyLambda.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_apigateway as apigw, 3 | aws_lambda_nodejs as lambda 4 | } from 'aws-cdk-lib'; 5 | import { Construct } from 'constructs'; 6 | 7 | import { getDefaultLambdaProps } from '../utils'; 8 | 9 | interface IntegratedProxyLambdaProps { 10 | api: apigw.RestApi; 11 | handler: lambda.NodejsFunctionProps & { entryFunctionName: string }; 12 | resources?: { method: string; path?: string }[]; 13 | requestValidation?: { 14 | requestValidator: apigw.RequestValidator; 15 | schema?: apigw.JsonSchema; 16 | requestParameters?: { [param: string]: boolean }; 17 | }; 18 | } 19 | 20 | export default class IntegratedProxyLambda extends Construct { 21 | public readonly lambdaFunction: lambda.NodejsFunction; 22 | 23 | constructor(scope: Construct, id: string, props: IntegratedProxyLambdaProps) { 24 | super(scope, id); 25 | 26 | const { api, handler, resources = [], requestValidation } = props; 27 | const { entryFunctionName, ...handlerProps } = handler; 28 | 29 | this.lambdaFunction = new lambda.NodejsFunction( 30 | this, 31 | 'IntegratedProxyLambda', 32 | { ...getDefaultLambdaProps(entryFunctionName), ...handlerProps } 33 | ); 34 | const lambdaIntegration = new apigw.LambdaIntegration(this.lambdaFunction, { 35 | proxy: true, 36 | allowTestInvoke: false 37 | }); 38 | 39 | for (let { method, path = '/' } of resources) { 40 | const resource = path 41 | .split('/') 42 | .filter((part) => part) 43 | .reduce((res, pathPart) => { 44 | return res.getResource(pathPart) || res.addResource(pathPart); 45 | }, api.root); 46 | 47 | const requestModels: { [param: string]: apigw.IModel } = {}; 48 | if (requestValidation?.schema) { 49 | requestModels['application/json'] = api.addModel( 50 | `Model-${method}-${path}`, 51 | { schema: requestValidation.schema } 52 | ); 53 | } 54 | 55 | resource.addMethod(method, lambdaIntegration, { 56 | apiKeyRequired: true, 57 | requestModels, 58 | requestParameters: requestValidation?.requestParameters, 59 | requestValidator: requestValidation?.requestValidator 60 | }); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lambdas/handlers/publishVotes.ts: -------------------------------------------------------------------------------- 1 | import { AttributeValue } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBStreamHandler, StreamRecord } from 'aws-lambda'; 3 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 4 | 5 | import { exhaustiveSwitchGuard } from '../utils'; 6 | import { sendVoteEvent } from '../helpers'; 7 | import { VotesRecord } from '../types'; 8 | import { ddbSdk } from '../sdk'; 9 | 10 | export const handler: DynamoDBStreamHandler = async (event) => { 11 | const { eventName, dynamodb } = event.Records[event.Records.length - 1]; 12 | const { NewImage, OldImage } = dynamodb as StreamRecord; 13 | 14 | const newImage = 15 | NewImage && unmarshall(NewImage as Record); 16 | 17 | const oldImage = 18 | OldImage && unmarshall(OldImage as Record); 19 | 20 | switch (eventName) { 21 | case 'INSERT': { 22 | console.info('VOTING SESSION STARTED', JSON.stringify(newImage)); 23 | await sendVoteEvent(newImage as VotesRecord, 'stage:VOTE_START'); 24 | break; 25 | } 26 | case 'MODIFY': { 27 | console.info('VOTES CASTED', JSON.stringify(newImage)); 28 | await sendVoteEvent(newImage as VotesRecord, 'stage:VOTE'); 29 | break; 30 | } 31 | case 'REMOVE': { 32 | try { 33 | const { Item: RealTimeRecordItem } = await ddbSdk.getRealTimeRecord( 34 | (oldImage as VotesRecord).hostId 35 | ); 36 | 37 | if (RealTimeRecordItem) { 38 | console.info('VOTING SESSION ENDED', JSON.stringify(oldImage)); 39 | await sendVoteEvent(oldImage as VotesRecord, 'stage:VOTE_END'); 40 | } 41 | } catch (error) { 42 | console.error(error); 43 | /** 44 | * If an error occurred while fetching the RealTimeRecord, we will still 45 | * try to send the VOTE_END event but there is no guarantee that it will 46 | * be delivered if the chat room is queued for deletion. 47 | */ 48 | console.info('VOTING SESSION ENDED', JSON.stringify(oldImage)); 49 | await sendVoteEvent(oldImage as VotesRecord, 'stage:VOTE_END'); 50 | } 51 | 52 | break; 53 | } 54 | default: { 55 | if (eventName) { 56 | exhaustiveSwitchGuard(eventName); 57 | } 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /lambdas/handlers/join.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 3 | 4 | import { USER_NOT_FOUND_EXCEPTION } from '../constants'; 5 | import { chatSdk, ddbSdk, realTimeSdk } from '../sdk'; 6 | import { createErrorResponse, createSuccessResponse } from '../utils'; 7 | import { 8 | JoinEventBody, 9 | JoinResponse, 10 | RealTimeRecord, 11 | StageMode, 12 | StageType 13 | } from '../types'; 14 | 15 | const region = process.env.AWS_REGION as string; 16 | 17 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 18 | const { body = '{}' } = event; 19 | const { hostId, userId, attributes }: JoinEventBody = JSON.parse(body); 20 | let response: JoinResponse = { region, metadata: {} }; 21 | 22 | console.info('EVENT', JSON.stringify(event)); 23 | 24 | try { 25 | const { Item: RealTimeRecordItem } = await ddbSdk.getRealTimeRecord(hostId); 26 | 27 | if (!RealTimeRecordItem) { 28 | return createErrorResponse({ 29 | code: 404, 30 | name: USER_NOT_FOUND_EXCEPTION, 31 | message: `No host exists with the ID ${hostId}` 32 | }); 33 | } 34 | 35 | const realTimeRecord = unmarshall(RealTimeRecordItem) as RealTimeRecord; 36 | const { stageArn, chatRoomArn, hostAttributes, type, mode } = 37 | realTimeRecord; 38 | 39 | console.info(`Joining as "${userId}"`, JSON.stringify(realTimeRecord)); 40 | 41 | if (type === StageType.VIDEO && mode === StageMode.PK) { 42 | const { Item: VotesItem } = await ddbSdk.getVotesRecord(hostId, [ 43 | 'tally', 44 | 'startedAt' 45 | ]); 46 | 47 | if (VotesItem) { 48 | response.metadata.activeVotingSession = unmarshall(VotesItem); 49 | } 50 | } 51 | 52 | const participantToken = await realTimeSdk.createParticipantToken({ 53 | userId, 54 | stageArn, 55 | attributes 56 | }); 57 | 58 | await chatSdk.sendEvent({ 59 | chatRoomArn, 60 | eventName: 'stage:JOIN', 61 | attributes: { 62 | userId, 63 | userAttributes: JSON.stringify(attributes || {}), 64 | message: `${userId} joined` 65 | } 66 | }); 67 | 68 | response = { ...response, ...participantToken, hostAttributes }; 69 | } catch (error) { 70 | return createErrorResponse({ error }); 71 | } 72 | 73 | console.info('RESPONSE', JSON.stringify(response)); 74 | 75 | return createSuccessResponse({ body: response }); 76 | }; 77 | -------------------------------------------------------------------------------- /lambdas/handlers/create.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 3 | 4 | import { chatSdk, ddbSdk, realTimeSdk } from '../sdk'; 5 | import { createErrorResponse, createSuccessResponse } from '../utils'; 6 | import { CreateEventBody, CreateResponse, RealTimeRecord } from '../types'; 7 | 8 | const region = process.env.AWS_REGION as string; 9 | 10 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 11 | const { body = '{}', pathParameters } = event; 12 | const { cid, hostId, hostAttributes, type }: CreateEventBody = 13 | JSON.parse(body); 14 | let response: CreateResponse; 15 | 16 | console.info('EVENT', JSON.stringify(event)); 17 | 18 | try { 19 | const { Item: RealTimeRecordItem } = await ddbSdk.getRealTimeRecord(hostId); 20 | 21 | if (RealTimeRecordItem) { 22 | // Return a host participant token if a RealTime record already exists for this hostId 23 | const realTimeRecord = unmarshall(RealTimeRecordItem) as RealTimeRecord; 24 | const { stageArn, createdFor } = realTimeRecord; 25 | 26 | console.info( 27 | `Record EXISTS for host "${hostId}" - creating host participant token`, 28 | JSON.stringify(realTimeRecord) 29 | ); 30 | 31 | const hostParticipantToken = await realTimeSdk.createParticipantToken({ 32 | stageArn, 33 | userId: hostId, 34 | attributes: hostAttributes 35 | }); 36 | 37 | response = { region, createdFor, hostParticipantToken }; 38 | } else { 39 | console.info( 40 | `Record DOES NOT EXIST for host "${hostId}" - creating new resources` 41 | ); 42 | 43 | // Create new Stage and Room resources 44 | const createdFor = pathParameters?.proxy?.split('/')[0]; 45 | const tags = createdFor ? { createdFor } : undefined; 46 | const [createStageResult, createRoomResult] = await Promise.all([ 47 | realTimeSdk.createStage({ cid, hostId, hostAttributes, tags }), 48 | chatSdk.createRoom({ cid, hostId, tags }) 49 | ]); 50 | 51 | // Add a new record to the RealTime DDB Table 52 | await ddbSdk.createRealTimeRecord({ 53 | type, 54 | hostId, 55 | createdFor, 56 | hostAttributes, 57 | roomArn: createRoomResult.arn as string, 58 | stageArn: createStageResult.stage.arn as string, 59 | hostParticipantId: createStageResult.hostParticipantToken.participantId 60 | }); 61 | 62 | response = { 63 | region, 64 | createdFor, 65 | hostParticipantToken: createStageResult.hostParticipantToken 66 | }; 67 | } 68 | } catch (error) { 69 | return createErrorResponse({ error }); 70 | } 71 | 72 | console.info('RESPONSE', JSON.stringify(response)); 73 | 74 | return createSuccessResponse({ code: 201, body: response }); 75 | }; 76 | -------------------------------------------------------------------------------- /scripts/seed/seed.js: -------------------------------------------------------------------------------- 1 | const { parseArgs } = require('util'); 2 | const { writeFileSync } = require('fs'); 3 | 4 | const { getCustomerCode, retryWithConstantBackoff } = require('../utils'); 5 | const fruits = require('./fruits.json'); 6 | 7 | const MAX_SEED_COUNT = 10; 8 | const COMMON_HOST_ATTRIBUTES = { 9 | avatarColLeft: '#eb5f07', 10 | avatarColRight: '#d91515', 11 | avatarColBottom: '#ff9900' 12 | }; 13 | 14 | const { values: args } = parseArgs({ 15 | options: { 16 | type: { type: 'string', default: 'video' }, 17 | count: { type: 'string', default: '1' } 18 | }, 19 | strict: false 20 | }); 21 | 22 | function chooseRandomDistinctItems(srcArray, count) { 23 | if (srcArray.length < count) { 24 | throw new Error( 25 | `Cannot choose ${count} distinct items from array with length ${srcArray.length}.` 26 | ); 27 | } 28 | 29 | const copy = srcArray.slice(0); 30 | const items = []; 31 | 32 | for (let i = 0; i < count; i++) { 33 | const randomIndex = Math.floor(Math.random() * copy.length); 34 | const randomItem = copy[randomIndex]; 35 | items.push(randomItem); 36 | copy.splice(randomIndex, 1); 37 | } 38 | 39 | return items; 40 | } 41 | 42 | async function createDemoItem(cid, apiKey, hostId) { 43 | const { type } = args; 44 | const hostAttributes = { ...COMMON_HOST_ATTRIBUTES, username: hostId }; 45 | 46 | const result = await fetch(`https://${cid}.cloudfront.net/create/demo`, { 47 | method: 'POST', 48 | headers: { 'x-api-key': apiKey }, 49 | body: JSON.stringify({ cid, hostId, type, hostAttributes }) 50 | }); 51 | const { hostParticipantToken } = await result.json(); 52 | 53 | return hostParticipantToken.token; 54 | } 55 | 56 | async function seed() { 57 | const { cid, apiKey } = await getCustomerCode(); 58 | 59 | const demoItems = {}; 60 | const count = Number(args.count); 61 | const boundedCount = Math.min(Math.max(count, 0), MAX_SEED_COUNT); 62 | const randomDemoIds = chooseRandomDistinctItems(fruits, boundedCount); 63 | 64 | for (let i = 0; i < boundedCount; i++) { 65 | const demoId = randomDemoIds[i]; 66 | const demoIdCapitalized = demoId.charAt(0).toUpperCase() + demoId.slice(1); 67 | const hostId = `Demo${demoIdCapitalized}${i}`; 68 | 69 | demoItems[hostId] = await retryWithConstantBackoff(() => 70 | createDemoItem(cid, apiKey, hostId) 71 | ); 72 | } 73 | 74 | randomDemoIds.map((demoId, i) => { 75 | const demoIdCapitalized = demoId.charAt(0).toUpperCase() + demoId.slice(1); 76 | const hostId = `Demo${demoIdCapitalized}${i}`; 77 | 78 | return createDemoItem(cid, apiKey, hostId); 79 | }); 80 | 81 | const outputFilename = 'scripts/seed/output.json'; 82 | writeFileSync(outputFilename, JSON.stringify(demoItems, null, 2)); 83 | 84 | console.info(demoItems); 85 | console.info('\nOutput:', outputFilename); 86 | } 87 | 88 | seed(); 89 | -------------------------------------------------------------------------------- /lib/schemas.ts: -------------------------------------------------------------------------------- 1 | import { aws_apigateway as apigw } from 'aws-cdk-lib'; 2 | 3 | const createRequestSchema: apigw.JsonSchema = { 4 | title: 'CreateRequest', 5 | type: apigw.JsonSchemaType.OBJECT, 6 | properties: { 7 | cid: { type: apigw.JsonSchemaType.STRING }, 8 | hostAttributes: { type: apigw.JsonSchemaType.OBJECT }, 9 | hostId: { type: apigw.JsonSchemaType.STRING }, 10 | type: { type: apigw.JsonSchemaType.STRING } 11 | }, 12 | required: ['cid', 'hostId', 'type'] 13 | }; 14 | 15 | const joinRequestSchema: apigw.JsonSchema = { 16 | title: 'JoinRequest', 17 | type: apigw.JsonSchemaType.OBJECT, 18 | properties: { 19 | attributes: { type: apigw.JsonSchemaType.OBJECT }, 20 | hostId: { type: apigw.JsonSchemaType.STRING }, 21 | userId: { type: apigw.JsonSchemaType.STRING } 22 | }, 23 | required: ['hostId', 'userId'] 24 | }; 25 | 26 | const deleteRequestSchema: apigw.JsonSchema = { 27 | title: 'DeleteRequest', 28 | type: apigw.JsonSchemaType.OBJECT, 29 | properties: { 30 | hostId: { type: apigw.JsonSchemaType.STRING } 31 | }, 32 | required: ['hostId'] 33 | }; 34 | 35 | const disconnectRequestSchema: apigw.JsonSchema = { 36 | title: 'DisconnectRequest', 37 | type: apigw.JsonSchemaType.OBJECT, 38 | properties: { 39 | hostId: { type: apigw.JsonSchemaType.STRING }, 40 | participantId: { type: apigw.JsonSchemaType.STRING }, 41 | userId: { type: apigw.JsonSchemaType.STRING } 42 | }, 43 | required: ['hostId', 'participantId', 'userId'] 44 | }; 45 | 46 | const updateRequestSchema: apigw.JsonSchema = { 47 | title: 'UpdateRequest', 48 | type: apigw.JsonSchemaType.OBJECT, 49 | properties: { 50 | hostId: { type: apigw.JsonSchemaType.STRING }, 51 | userId: { type: apigw.JsonSchemaType.STRING }, 52 | mode: { type: apigw.JsonSchemaType.STRING }, 53 | seats: { 54 | type: apigw.JsonSchemaType.ARRAY, 55 | items: { type: apigw.JsonSchemaType.STRING } 56 | } 57 | }, 58 | required: ['hostId'] 59 | }; 60 | 61 | const createChatTokenRequestSchema: apigw.JsonSchema = { 62 | title: 'CreateChatTokenRequest', 63 | type: apigw.JsonSchemaType.OBJECT, 64 | properties: { 65 | attributes: { type: apigw.JsonSchemaType.OBJECT }, 66 | hostId: { type: apigw.JsonSchemaType.STRING }, 67 | userId: { type: apigw.JsonSchemaType.STRING } 68 | }, 69 | required: ['hostId', 'userId'] 70 | }; 71 | 72 | const castVoteRequestSchema: apigw.JsonSchema = { 73 | title: 'CastVoteRequest', 74 | type: apigw.JsonSchemaType.OBJECT, 75 | properties: { 76 | hostId: { type: apigw.JsonSchemaType.STRING }, 77 | vote: { type: apigw.JsonSchemaType.STRING } 78 | }, 79 | required: ['hostId', 'vote'] 80 | }; 81 | 82 | const schemas = { 83 | castVoteRequestSchema, 84 | createChatTokenRequestSchema, 85 | createRequestSchema, 86 | deleteRequestSchema, 87 | disconnectRequestSchema, 88 | joinRequestSchema, 89 | updateRequestSchema 90 | }; 91 | 92 | export default schemas; 93 | -------------------------------------------------------------------------------- /lambdas/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateChatTokenResponse, 3 | CreateRoomCommandOutput 4 | } from '@aws-sdk/client-ivschat'; 5 | import { ParticipantToken } from '@aws-sdk/client-ivs-realtime'; 6 | 7 | export enum StageConfig { 8 | STATUS = 'STATUS', 9 | TYPE = 'TYPE', 10 | MODE = 'MODE' 11 | } 12 | 13 | export enum StageStatus { 14 | IDLE = 'IDLE', 15 | ACTIVE = 'ACTIVE' 16 | } 17 | 18 | export enum StageType { 19 | VIDEO = 'VIDEO', 20 | AUDIO = 'AUDIO' 21 | } 22 | 23 | export enum StageMode { 24 | NONE = 'NONE', 25 | GUEST_SPOT = 'GUEST_SPOT', 26 | PK = 'PK' 27 | } 28 | 29 | export enum UpdateType { 30 | MODE = 'MODE', 31 | SEATS = 'SEATS' 32 | } 33 | 34 | export type Room = CreateRoomCommandOutput; 35 | 36 | export type ChatToken = CreateChatTokenResponse; 37 | 38 | export interface RealTimeRecord { 39 | hostId: string; // partition key 40 | hostAttributes: Record; 41 | createdAt: string; 42 | createdFor?: string; 43 | stageArn: string; 44 | chatRoomArn: string; 45 | type: StageType; 46 | mode: StageMode; 47 | status: StageStatus; 48 | lastStatusUpdatedAt: string; 49 | seats?: string[]; 50 | } 51 | 52 | export interface VotesRecord { 53 | hostId: string; // partition key 54 | tally: Record; 55 | chatRoomArn: string; 56 | startedAt: string; 57 | } 58 | 59 | /** 60 | * Create Types 61 | */ 62 | export interface CreateEventBody { 63 | cid: string; 64 | hostAttributes?: Record; 65 | hostId: string; 66 | type: StageType; 67 | } 68 | 69 | export interface CreateResponse { 70 | hostParticipantToken: ParticipantToken; 71 | region: string; 72 | createdFor?: string; 73 | } 74 | 75 | /** 76 | * Join Types 77 | */ 78 | export interface JoinEventBody { 79 | hostId: string; 80 | userId: string; 81 | attributes?: Record; 82 | } 83 | 84 | export interface JoinResponse extends ParticipantToken { 85 | region: string; 86 | metadata: Record; 87 | hostAttributes?: Record; 88 | } 89 | 90 | /** 91 | * List Types 92 | */ 93 | 94 | export interface ListResponse { 95 | stages: Partial[]; 96 | } 97 | 98 | /** 99 | * CreateChatToken Types 100 | */ 101 | 102 | export interface CreateChatTokenBody { 103 | hostId: string; 104 | userId: string; 105 | attributes?: Record; 106 | } 107 | 108 | /** 109 | * Delete Types 110 | */ 111 | export interface DeleteEventBody { 112 | hostId: string; 113 | } 114 | 115 | /** 116 | * Disconnect Types 117 | */ 118 | export interface DisconnectEventBody { 119 | hostId: string; 120 | userId: string; 121 | participantId: string; 122 | } 123 | 124 | /** 125 | * Update Types 126 | */ 127 | export interface UpdateEventBody { 128 | hostId: string; 129 | userId?: string; 130 | [key: string]: any; 131 | } 132 | 133 | /** 134 | * CastVote Types 135 | */ 136 | export interface CastVoteBody { 137 | hostId: string; 138 | vote: string; 139 | } 140 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | const { DescribeStacksCommand } = require('@aws-sdk/client-cloudformation'); 2 | const { GetCallerIdentityCommand } = require('@aws-sdk/client-sts'); 3 | const { GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager'); 4 | const { parseArgs } = require('util'); 5 | 6 | const { 7 | cloudFormationClient, 8 | secretsManagerClient, 9 | stsClient 10 | } = require('./clients'); 11 | 12 | const { values: args } = parseArgs({ 13 | options: { stackName: { type: 'string' } }, 14 | strict: false 15 | }); 16 | 17 | async function getAwsConfig() { 18 | const getCallerIdentityCommand = new GetCallerIdentityCommand({}); 19 | 20 | const [{ Account }, region] = await Promise.all([ 21 | stsClient.send(getCallerIdentityCommand), 22 | stsClient.config.region() 23 | ]); 24 | 25 | return { account: Account, region }; 26 | } 27 | 28 | function getStackName() { 29 | return args.stackName; 30 | } 31 | 32 | async function getApiKey(secretName) { 33 | if (!secretName) { 34 | return; 35 | } 36 | 37 | const { SecretString } = await secretsManagerClient.send( 38 | new GetSecretValueCommand({ SecretId: secretName }) 39 | ); 40 | 41 | if (SecretString) { 42 | const secretValue = JSON.parse(SecretString); 43 | 44 | return secretValue.apiKey; 45 | } 46 | } 47 | 48 | async function getStackOutputs() { 49 | const stackName = getStackName(); 50 | const { Stacks } = await cloudFormationClient.send( 51 | new DescribeStacksCommand({ StackName: stackName }) 52 | ); 53 | const [stack] = Stacks; 54 | const outputs = stack.Outputs.reduce( 55 | (acc, output) => ({ 56 | ...acc, 57 | [output.OutputKey]: output.OutputValue 58 | }), 59 | {} 60 | ); 61 | 62 | return outputs; 63 | } 64 | 65 | async function getCustomerCode() { 66 | const { domainName, secretName } = await getStackOutputs(); 67 | 68 | const [cid] = domainName?.split('.'); 69 | if (!cid) { 70 | throw new Error('Failed to retrieve the customer ID'); 71 | } 72 | 73 | const apiKey = await getApiKey(secretName); 74 | if (!apiKey) { 75 | throw new Error('Failed to retrieve the API key'); 76 | } 77 | 78 | return { cid, apiKey }; 79 | } 80 | 81 | const retryWithConstantBackoff = ( 82 | promiseFn, 83 | { maxRetries = 5, delay = 200 } = {} 84 | ) => { 85 | const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 86 | 87 | const retry = async (retries) => { 88 | try { 89 | // backoff 90 | if (retries > 0) await waitFor(delay); 91 | // evaluate 92 | const result = await promiseFn(); 93 | 94 | return result; 95 | } catch (error) { 96 | if (retries < maxRetries) { 97 | // retry 98 | const nextRetries = retries + 1; 99 | 100 | return retry(nextRetries); 101 | } else { 102 | // fail 103 | console.warn('Max retries reached. Bubbling the error up.'); 104 | throw error; 105 | } 106 | } 107 | }; 108 | 109 | return retry(0); 110 | }; 111 | 112 | module.exports = { 113 | getApiKey, 114 | getAwsConfig, 115 | getCustomerCode, 116 | getStackName, 117 | getStackOutputs, 118 | retryWithConstantBackoff 119 | }; 120 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lambdas/sdk/chat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatTokenCapability, 3 | CreateChatTokenCommand, 4 | CreateRoomCommand, 5 | DeleteRoomCommand, 6 | DisconnectUserCommand, 7 | ListRoomsCommand, 8 | RoomSummary, 9 | SendEventCommand 10 | } from '@aws-sdk/client-ivschat'; 11 | 12 | import { 13 | CHAT_TOKEN_SESSION_DURATION_IN_MINUTES, 14 | RESOURCE_TAGS 15 | } from '../constants'; 16 | import { ChatToken, Room } from '../types'; 17 | import { ivsChatClient } from '../clients'; 18 | 19 | export const createRoom = async ({ 20 | cid, 21 | hostId, 22 | tags 23 | }: { 24 | cid: string; 25 | hostId: string; 26 | tags?: Record; 27 | }): Promise => 28 | await ivsChatClient.send( 29 | new CreateRoomCommand({ 30 | name: `${cid}-${hostId}-Room`, 31 | tags: { 32 | ...RESOURCE_TAGS, 33 | ...tags, 34 | cid, 35 | createdAt: new Date().toISOString() 36 | } 37 | }) 38 | ); 39 | 40 | export const createChatToken = async ({ 41 | userId, 42 | chatRoomArn, 43 | capabilities, 44 | attributes 45 | }: { 46 | userId: string; 47 | chatRoomArn: string; 48 | capabilities?: (ChatTokenCapability | string)[]; 49 | attributes?: Record; 50 | }): Promise => { 51 | const { token, sessionExpirationTime, tokenExpirationTime } = 52 | await ivsChatClient.send( 53 | new CreateChatTokenCommand({ 54 | userId, 55 | attributes, 56 | capabilities, // Default: None (the capability to view messages is implicitly included in all requests) 57 | roomIdentifier: chatRoomArn, 58 | sessionDurationInMinutes: CHAT_TOKEN_SESSION_DURATION_IN_MINUTES 59 | }) 60 | ); 61 | 62 | return { token, sessionExpirationTime, tokenExpirationTime }; 63 | }; 64 | 65 | export const sendEvent = ({ 66 | chatRoomArn, 67 | eventName, 68 | attributes 69 | }: { 70 | chatRoomArn: string; 71 | eventName: string; 72 | attributes?: Record; 73 | }) => 74 | ivsChatClient.send( 75 | new SendEventCommand({ roomIdentifier: chatRoomArn, eventName, attributes }) 76 | ); 77 | 78 | export const disconnectChatUser = ( 79 | chatRoomArn: string, 80 | userId: string, 81 | reason: string = 'Disconnected by host' 82 | ) => 83 | ivsChatClient.send( 84 | new DisconnectUserCommand({ roomIdentifier: chatRoomArn, userId, reason }) 85 | ); 86 | 87 | export const deleteRoom = (chatRoomArn: string) => 88 | ivsChatClient.send(new DeleteRoomCommand({ identifier: chatRoomArn })); 89 | 90 | export const getRoomSummaries = async (cid: string, maxRetries: number = 3) => { 91 | if (!cid) return []; 92 | 93 | let retries = 0; 94 | let totalRooms: RoomSummary[] = []; 95 | 96 | await (async function listStages(token?: string) { 97 | try { 98 | const { rooms, nextToken } = await ivsChatClient.send( 99 | new ListRoomsCommand({ maxResults: 50, nextToken: token }) 100 | ); 101 | 102 | if (rooms) totalRooms = totalRooms.concat(rooms); 103 | 104 | if (nextToken) await listStages(nextToken); 105 | } catch (error) { 106 | console.error(error); 107 | if (retries < maxRetries) { 108 | retries++; 109 | await new Promise((resolve) => setTimeout(resolve, 200)); // wait 200ms 110 | await listStages(token); 111 | } 112 | } 113 | })(); 114 | 115 | const cidRooms = totalRooms.filter( 116 | ({ tags }) => tags?.stack === RESOURCE_TAGS.stack && tags?.cid === cid 117 | ); 118 | 119 | return cidRooms; 120 | }; 121 | -------------------------------------------------------------------------------- /lambdas/handlers/update.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from 'aws-lambda'; 2 | import { unmarshall } from '@aws-sdk/util-dynamodb'; 3 | 4 | import { 5 | BAD_INPUT_EXCEPTION, 6 | BAD_PARAMS_EXCEPTION, 7 | INVALID_STAGE_UPDATE_EXCEPTION, 8 | USER_NOT_FOUND_EXCEPTION 9 | } from '../constants'; 10 | import { ddbSdk } from '../sdk'; 11 | import { createErrorResponse, createSuccessResponse } from '../utils'; 12 | import { 13 | RealTimeRecord, 14 | StageMode, 15 | StageType, 16 | UpdateEventBody, 17 | UpdateType 18 | } from '../types'; 19 | import { updateMode, updateSeats } from '../helpers'; 20 | 21 | const stageModes = Object.values(StageMode); 22 | 23 | export const handler: APIGatewayProxyHandlerV2 = async (event) => { 24 | const { body = '{}', pathParameters } = event; 25 | const { hostId, userId, ...data }: UpdateEventBody = JSON.parse(body); 26 | const updateType = pathParameters?.proxy?.split('/')[0]; // mode OR seats 27 | const isModeUpdate = !!(updateType?.toUpperCase() === UpdateType.MODE); 28 | const isSeatsUpdate = !!(updateType?.toUpperCase() === UpdateType.SEATS); 29 | 30 | console.info('EVENT', JSON.stringify(event)); 31 | 32 | // Check that a recognized update type was passed into the {proxy+} path (i.e. "mode" or "seats") 33 | if (!isModeUpdate && !isSeatsUpdate) { 34 | return createErrorResponse({ 35 | code: 400, 36 | name: BAD_PARAMS_EXCEPTION, 37 | message: 38 | 'Missing or incorrect update type parameter. Endpoint should follow the format: update/{mode|seats}' 39 | }); 40 | } 41 | 42 | // Check that the supporting data payload was passed into the update request 43 | if (!data[updateType]) { 44 | return createErrorResponse({ 45 | code: 400, 46 | name: BAD_INPUT_EXCEPTION, 47 | message: `Missing data to update - input should contain a(n) "${updateType}" data property` 48 | }); 49 | } 50 | 51 | // If this is a mode update, check that a recognized mode was passed into the update request 52 | if (isModeUpdate && !stageModes.includes(data.mode.toUpperCase())) { 53 | const mode = data[updateType]; 54 | const modesStr = stageModes.join(', '); 55 | 56 | return createErrorResponse({ 57 | code: 400, 58 | name: BAD_INPUT_EXCEPTION, 59 | message: `Unknown stage mode provided: ${mode}. Stage mode must be one of: ${modesStr}` 60 | }); 61 | } 62 | 63 | try { 64 | const { Item: RealTimeRecordItem } = await ddbSdk.getRealTimeRecord(hostId); 65 | 66 | // Check that a host exists with the given hostId 67 | if (!RealTimeRecordItem) { 68 | return createErrorResponse({ 69 | code: 404, 70 | name: USER_NOT_FOUND_EXCEPTION, 71 | message: `No host exists with the ID ${hostId}` 72 | }); 73 | } 74 | 75 | const realTimeRecord = unmarshall(RealTimeRecordItem) as RealTimeRecord; 76 | 77 | if ( 78 | (isModeUpdate && realTimeRecord.type === StageType.AUDIO) || 79 | (isSeatsUpdate && realTimeRecord.type === StageType.VIDEO) 80 | ) { 81 | return createErrorResponse({ 82 | code: 400, 83 | name: INVALID_STAGE_UPDATE_EXCEPTION, 84 | message: `Cannot update the ${updateType} for a(n) ${realTimeRecord.type} stage type` 85 | }); 86 | } 87 | 88 | console.info(`Updating ${updateType}`, JSON.stringify(realTimeRecord)); 89 | 90 | if (isModeUpdate) { 91 | await updateMode({ mode: data.mode, record: realTimeRecord, userId }); 92 | } 93 | 94 | if (isSeatsUpdate) { 95 | await updateSeats({ seats: data.seats, record: realTimeRecord, userId }); 96 | } 97 | } catch (error) { 98 | return createErrorResponse({ error }); 99 | } 100 | 101 | return createSuccessResponse(); 102 | }; 103 | -------------------------------------------------------------------------------- /lib/Constructs/CronScheduleTrigger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_events as events, 3 | aws_events_targets as eventsTargets, 4 | aws_lambda_nodejs as lambda, 5 | aws_stepfunctions as stepFunctions, 6 | aws_stepfunctions_tasks as stepFunctionsTasks, 7 | Duration, 8 | Stack 9 | } from 'aws-cdk-lib'; 10 | import { Construct } from 'constructs'; 11 | import { IEventSource } from 'aws-cdk-lib/aws-lambda'; 12 | import { parseExpression } from 'cron-parser'; 13 | 14 | interface CronOptionsWithSecond extends events.CronOptions { 15 | /** 16 | * The second to invoke the Lambda at 17 | * @default - "0"; invoke at the first second of every minute 18 | */ 19 | second?: string; 20 | } 21 | 22 | interface CronScheduleTriggerProps { 23 | cronSchedule: CronOptionsWithSecond; 24 | } 25 | 26 | export class CronScheduleTrigger extends Construct implements IEventSource { 27 | constructor( 28 | scope: Construct, 29 | id: string, 30 | private props: CronScheduleTriggerProps 31 | ) { 32 | super(scope, id); 33 | } 34 | 35 | bind(target: lambda.NodejsFunction) { 36 | const { second, ...restCronSchedule } = this.props.cronSchedule; 37 | const secondOfCronSchedule = second == null ? '0' : second; 38 | const scopedId = target.node.id; 39 | const stackName = Stack.of(this); 40 | const defaultRuleProps = { 41 | ruleName: `${stackName}-${scopedId}-Rule`, 42 | schedule: events.Schedule.cron(restCronSchedule) 43 | }; 44 | 45 | if (secondOfCronSchedule === '0') { 46 | // Schedule is not sub-minute, so there is no need for a StepFunctions State Machine; a simple event schedule will suffice 47 | new events.Rule(this, 'EventCronScheduleRule', { 48 | ...defaultRuleProps, 49 | targets: [new eventsTargets.LambdaFunction(target)] 50 | }); 51 | 52 | return; 53 | } 54 | 55 | const wait = new stepFunctions.Wait(this, 'Wait-State', { 56 | time: stepFunctions.WaitTime.secondsPath('$') 57 | }); 58 | const invoke = new stepFunctionsTasks.LambdaInvoke( 59 | this, 60 | 'LambdaInvoke-Task', 61 | { 62 | lambdaFunction: target, 63 | payload: stepFunctions.TaskInput.fromObject({}) 64 | } 65 | ); 66 | const waitThenInvoke = new stepFunctions.Choice( 67 | this, 68 | 'WaitThenInvoke-Choice' 69 | ) 70 | .when( 71 | stepFunctions.Condition.numberGreaterThan('$', 0), 72 | wait.next(invoke) 73 | ) 74 | .otherwise(invoke); 75 | 76 | const seconds: number[] = []; 77 | const cronSchedule = parseExpression(secondOfCronSchedule + ' * * * * *'); 78 | cronSchedule.fields.second.forEach((s) => { 79 | if (seconds.length === 0 || s !== seconds[seconds.length - 1]) 80 | seconds.push(s); 81 | }); 82 | 83 | const createLoopItems = new stepFunctions.Pass( 84 | this, 85 | 'LoopItems-PassState', 86 | { 87 | result: stepFunctions.Result.fromArray( 88 | seconds.map((s, i) => (i === 0 ? s : s - seconds[i - 1])) 89 | ) 90 | } 91 | ); 92 | const loop = new stepFunctions.Map(this, 'Loop-MapState', { 93 | maxConcurrency: 1 94 | }).iterator(waitThenInvoke); 95 | 96 | const loopChain = createLoopItems.next(loop); 97 | const stateMachine = new stepFunctions.StateMachine(this, 'StateMachine', { 98 | definitionBody: stepFunctions.DefinitionBody.fromChainable(loopChain), 99 | stateMachineName: `${stackName}-${scopedId}-StateMachine`, 100 | stateMachineType: stepFunctions.StateMachineType.EXPRESS, 101 | timeout: Duration.seconds(90) 102 | }); 103 | 104 | new events.Rule(this, 'EventCronScheduleRule', { 105 | ...defaultRuleProps, 106 | targets: [new eventsTargets.SfnStateMachine(stateMachine)] 107 | }); 108 | } 109 | } 110 | 111 | export default CronScheduleTrigger; 112 | -------------------------------------------------------------------------------- /lambdas/sdk/realTime.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateParticipantTokenCommand, 3 | CreateStageCommand, 4 | DeleteStageCommand, 5 | DisconnectParticipantCommand, 6 | ListStagesCommand, 7 | ParticipantToken, 8 | ParticipantTokenCapability, 9 | Stage, 10 | StageSummary 11 | } from '@aws-sdk/client-ivs-realtime'; 12 | 13 | import { ivsRealTimeClient } from '../clients'; 14 | import { 15 | PARTICIPANT_TOKEN_DURATION_IN_MINUTES, 16 | RESOURCE_TAGS 17 | } from '../constants'; 18 | 19 | export const createStage = async ({ 20 | cid, 21 | hostId, 22 | hostAttributes, 23 | tags 24 | }: { 25 | cid: string; 26 | hostId: string; 27 | hostAttributes?: Record; 28 | tags?: Record; 29 | }): Promise<{ stage: Stage; hostParticipantToken: ParticipantToken }> => { 30 | const { stage, participantTokens } = await ivsRealTimeClient.send( 31 | new CreateStageCommand({ 32 | name: `${cid}-${hostId}-Stage`, 33 | tags: { 34 | ...RESOURCE_TAGS, 35 | ...tags, 36 | cid, 37 | createdAt: new Date().toISOString() 38 | }, 39 | participantTokenConfigurations: [ 40 | { 41 | userId: hostId, 42 | capabilities: [ 43 | ParticipantTokenCapability.PUBLISH, 44 | ParticipantTokenCapability.SUBSCRIBE 45 | ], 46 | attributes: hostAttributes, 47 | duration: PARTICIPANT_TOKEN_DURATION_IN_MINUTES 48 | } 49 | ] 50 | }) 51 | ); 52 | const [{ token, participantId, duration }] = 53 | participantTokens as ParticipantToken[]; 54 | 55 | return { 56 | stage: stage!, 57 | hostParticipantToken: { token, participantId, duration } 58 | }; 59 | }; 60 | 61 | export const createParticipantToken = async ({ 62 | userId, 63 | stageArn, 64 | capabilities, 65 | attributes 66 | }: { 67 | userId: string; 68 | stageArn: string; 69 | capabilities?: (ParticipantTokenCapability | string)[]; 70 | attributes?: Record; 71 | }): Promise => { 72 | const { participantToken } = await ivsRealTimeClient.send( 73 | new CreateParticipantTokenCommand({ 74 | userId, 75 | stageArn, 76 | attributes, 77 | capabilities, // Default: PUBLISH, SUBSCRIBE 78 | duration: PARTICIPANT_TOKEN_DURATION_IN_MINUTES 79 | }) 80 | ); 81 | const { 82 | token, 83 | participantId, 84 | duration = PARTICIPANT_TOKEN_DURATION_IN_MINUTES 85 | } = participantToken as ParticipantToken; 86 | 87 | return { token, participantId, duration }; 88 | }; 89 | 90 | export const disconnectParticipant = ( 91 | stageArn: string, 92 | participantId: string, 93 | reason: string = 'Disconnected by host' 94 | ) => 95 | ivsRealTimeClient.send( 96 | new DisconnectParticipantCommand({ stageArn, participantId, reason }) 97 | ); 98 | 99 | export const deleteStage = (stageArn: string) => 100 | ivsRealTimeClient.send(new DeleteStageCommand({ arn: stageArn })); 101 | 102 | export const getStageSummaries = async ( 103 | cid: string, 104 | maxRetries: number = 3 105 | ) => { 106 | if (!cid) return []; 107 | 108 | let retries = 0; 109 | let totalStages: StageSummary[] = []; 110 | 111 | await (async function listStages(token?: string) { 112 | try { 113 | const { stages, nextToken } = await ivsRealTimeClient.send( 114 | new ListStagesCommand({ maxResults: 100, nextToken: token }) 115 | ); 116 | 117 | if (stages) totalStages = totalStages.concat(stages); 118 | 119 | if (nextToken) await listStages(nextToken); 120 | } catch (error) { 121 | console.error(error); 122 | if (retries < maxRetries) { 123 | retries++; 124 | await new Promise((resolve) => setTimeout(resolve, 200)); // wait 200ms 125 | await listStages(token); 126 | } 127 | } 128 | })(); 129 | 130 | const cidStages = totalStages.filter( 131 | ({ tags }) => tags?.stack === RESOURCE_TAGS.stack && tags?.cid === cid 132 | ); 133 | 134 | return cidStages; 135 | }; 136 | -------------------------------------------------------------------------------- /lambdas/helpers.ts: -------------------------------------------------------------------------------- 1 | import { convertToAttr } from '@aws-sdk/util-dynamodb'; 2 | import { UpdateItemCommand } from '@aws-sdk/client-dynamodb'; 3 | 4 | import { AUDIO_ROOM_SIZE, SIMPLE_MODE_NAMES } from './constants'; 5 | import { chatSdk, ddbSdk } from './sdk'; 6 | import { ddbDocClient } from './clients'; 7 | import { RealTimeRecord, VotesRecord } from './types'; 8 | import { StageMode } from './types'; 9 | 10 | export const updateMode = async ({ 11 | mode, 12 | record, 13 | userId 14 | }: { 15 | mode: string; 16 | record: RealTimeRecord; 17 | userId?: string; // userId should be provided if the mode has been changed to PK or GUEST_SPOT 18 | }) => { 19 | const { hostId, mode: prevMode, chatRoomArn } = record; 20 | const nextMode = mode.toUpperCase() as StageMode; 21 | const eventAttributes: Record = { mode: nextMode }; 22 | 23 | // Return early if the update does not result in a change 24 | if (nextMode === prevMode) return; 25 | 26 | // Determine the correct message and notice to send via the chat event 27 | if (nextMode === StageMode.NONE) { 28 | eventAttributes.notice = `${hostId} stopped ${SIMPLE_MODE_NAMES[prevMode]} mode`; 29 | } else { 30 | eventAttributes.notice = `${hostId} started ${SIMPLE_MODE_NAMES[nextMode]} mode`; 31 | eventAttributes.message = `${userId || 'User'} is on stage`; 32 | } 33 | 34 | return Promise.all([ 35 | // Send a chat event containing the next mode 36 | chatSdk.sendEvent({ 37 | chatRoomArn, 38 | eventName: 'stage:MODE', 39 | attributes: eventAttributes 40 | }), 41 | // Update the RealTime table to reflect the new mode 42 | ddbDocClient.send( 43 | new UpdateItemCommand({ 44 | TableName: ddbSdk.realTimeTableName, 45 | Key: { hostId: convertToAttr(hostId) }, 46 | UpdateExpression: `SET #mode = :nextMode`, 47 | ExpressionAttributeNames: { '#mode': 'mode' }, 48 | ExpressionAttributeValues: { ':nextMode': convertToAttr(nextMode) } 49 | }) 50 | ), 51 | // If PK-mode started, create a Votes record; otherwise, delete any associated Votes record 52 | nextMode === StageMode.PK && !!userId 53 | ? ddbSdk.createVotesRecord({ 54 | hostId, 55 | candidateIds: [hostId, userId], 56 | chatRoomArn 57 | }) 58 | : ddbSdk.deleteVotesRecord(hostId) 59 | ]); 60 | }; 61 | 62 | export const updateSeats = async ({ 63 | seats, 64 | record, 65 | userId 66 | }: { 67 | seats: string; 68 | record: RealTimeRecord; 69 | userId?: string; // userId should be provided if the seats are updated with a NEW user 70 | }) => { 71 | const { hostId, seats: prevSeats, chatRoomArn } = record; 72 | 73 | const nextSeats = [...seats]; 74 | nextSeats.splice(AUDIO_ROOM_SIZE); // cut the seats down to the max AUDIO_ROOM_SIZE 75 | nextSeats.push(...Array(AUDIO_ROOM_SIZE - nextSeats.length).fill('')); // fill any remaining seats with empty strings 76 | 77 | // Return early if the update does not result in a change 78 | if (JSON.stringify(nextSeats) === JSON.stringify(prevSeats)) return; 79 | 80 | const newSeatMember = nextSeats.find((seat) => !prevSeats?.includes(seat)); 81 | const eventAttributes: Record = { 82 | seats: JSON.stringify(nextSeats) 83 | }; 84 | 85 | if (newSeatMember) { 86 | eventAttributes.message = `${userId || 'User'} is on stage`; 87 | } 88 | 89 | return Promise.all([ 90 | // Send a chat event containing the next seats 91 | chatSdk.sendEvent({ 92 | chatRoomArn, 93 | eventName: 'stage:SEATS', 94 | attributes: eventAttributes 95 | }), 96 | // Update the RealTime table to reflect the new seats 97 | ddbDocClient.send( 98 | new UpdateItemCommand({ 99 | TableName: ddbSdk.realTimeTableName, 100 | Key: { hostId: convertToAttr(hostId) }, 101 | UpdateExpression: `SET #seats = :nextSeats`, 102 | ExpressionAttributeNames: { '#seats': 'seats' }, 103 | ExpressionAttributeValues: { 104 | ':nextSeats': convertToAttr(nextSeats, { 105 | convertEmptyValues: false, 106 | removeUndefinedValues: false 107 | }) 108 | } 109 | }) 110 | ) 111 | ]); 112 | }; 113 | 114 | export const sendVoteEvent = (record: VotesRecord, eventName: string) => { 115 | const { chatRoomArn, tally } = record; 116 | 117 | const attributes: Record = ( 118 | Object.keys(tally) as (keyof typeof tally)[] 119 | ).reduce( 120 | (acc, candidateId) => ({ 121 | ...acc, 122 | [candidateId]: tally[candidateId].toString() 123 | }), 124 | {} 125 | ); 126 | 127 | return chatSdk.sendEvent({ attributes, chatRoomArn, eventName }); 128 | }; 129 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help app install bootstrap synth deploy destroy clean 4 | 5 | # General ENV variables 6 | # 7 | # - AWS_PROFILE: named AWS CLI profile used to deploy 8 | # default: none - default profile is used 9 | # 10 | # - STACK: stack name 11 | # default: IVSRealTimeDemo 12 | # 13 | # - ALARMS_EMAIL: the email that will be receiving CloudWatch alarm notifications 14 | # default: none 15 | # 16 | # - TERM_PROTECTION: when set to true, enables stack termination protection 17 | # default: false 18 | # 19 | # - NAG: enables application security and compliance checks 20 | # default: false 21 | # 22 | # 23 | # Seeder ENV variables 24 | # 25 | # - COUNT: the number of items to seed 26 | # default: 1, max: 10 27 | # 28 | # - TYPE: the type of item to seed ("video" or "audio") 29 | # default: video 30 | # 31 | # 32 | # Publish ENV variables 33 | # 34 | # - FILE_ASSETS_BUCKET_NAME_PREFIX: the name prefix used to create or retrieve the S3 bucket to which file assets are saved. 35 | # This prefix is prepended with the AWS region to create the complete bucket name. 36 | 37 | AWS_PROFILE_FLAG = --profile $(AWS_PROFILE) 38 | STACK ?= IVSRealTimeDemo 39 | TERM_PROTECTION ?= false 40 | NAG ?= false 41 | 42 | CDK_OPTIONS = $(if $(AWS_PROFILE),$(AWS_PROFILE_FLAG)) \ 43 | $(if $(ALARMS_EMAIL),--context alarmsEmail=$(ALARMS_EMAIL)) \ 44 | --context stackName=$(STACK) \ 45 | --context terminationProtection=$(TERM_PROTECTION) \ 46 | --context nag=$(NAG) 47 | 48 | SCRIPT_OPTIONS = $(if $(AWS_PROFILE),$(AWS_PROFILE_FLAG)) \ 49 | --stackName $(STACK) 50 | 51 | # Seeder options 52 | COUNT ?= 1 53 | TYPE ?= video 54 | 55 | help: ## Shows this help message 56 | @echo "\n$$(tput bold)Available Rules:$$(tput sgr0)\n" 57 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST)\ 58 | | sort \ 59 | | awk \ 60 | 'BEGIN {FS = ":.*?## "}; \ 61 | {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' 62 | @echo "\n$$(tput bold)IMPORTANT!$$(tput sgr0)\n" 63 | @echo "If AWS_PROFILE is not exported as an environment variable or provided through the command line, then the default AWS profile is used. \n" | fold -s 64 | @echo " Option 1: export AWS_PROFILE=user1\n" 65 | @echo " Option 2: make AWS_PROFILE=user1\n" 66 | 67 | app: install bootstrap deploy ## Installs NPM dependencies, bootstraps, and deploys the stack 68 | 69 | install: ## Installs NPM dependencies 70 | @echo "⚙️ Installing NPM dependencies..." 71 | npm install 72 | 73 | bootstrap: ## Deploys the CDK Toolkit staging stack 74 | @echo "🥾 Bootstrapping..." 75 | npx cdk bootstrap $(CDK_OPTIONS) 76 | 77 | synth: ## Synthesizes the CDK app and produces a cloud assembly in cdk.out 78 | @echo "🧪 Synthesizing..." 79 | npx cdk synth $(STACK) $(CDK_OPTIONS) 80 | 81 | deploy: ## Deploys the stack 82 | @echo "🚀 Deploying $(STACK)..." 83 | npx cdk deploy $(STACK) $(CDK_OPTIONS) 84 | @echo "🛠️ Running post-deploy tasks..." 85 | node scripts/post-deploy.js $(SCRIPT_OPTIONS) 86 | @echo "\n$$(tput bold) ✅ $(STACK) Deployed Successfully $$(tput sgr0)" 87 | 88 | output: ## Retrieves the CloudFormation stack outputs 89 | @echo "🧲 Retrieving stack outputs for $(STACK)..." 90 | aws cloudformation describe-stacks --stack-name $(STACK) --query 'Stacks[].Outputs' --output=table 91 | 92 | destroy: clean ## Destroys the stack and cleans up 93 | @echo "🧨 Destroying $(STACK)..." 94 | npx cdk destroy $(STACK) $(CDK_OPTIONS) 95 | 96 | clean: ## Deletes the cloud assembly directory (cdk.out) 97 | @echo "🧹 Cleaning..." 98 | rm -rf dist cdk.out 99 | 100 | seed: ## Creates a specified number of randomly generated demo items 101 | @echo "🌱 Seeding..." 102 | node scripts/seed/seed.js $(SCRIPT_OPTIONS) --count $(COUNT) --type $(TYPE) 103 | 104 | delete-seed: ## Deletes all seeded items 105 | @echo "🧨 Deleting all seeded items..." 106 | node scripts/seed/deleteSeed.js $(SCRIPT_OPTIONS) 107 | 108 | publish: guard-FILE_ASSETS_BUCKET_NAME_PREFIX ## Publishes stack file assets to an S3 bucket and generate a launch stack URL 109 | @echo "🛠️ Preparing resources..." 110 | node scripts/publish/pre-publish.js $(SCRIPT_OPTIONS) --fileAssetsBucketNamePrefix $(FILE_ASSETS_BUCKET_NAME_PREFIX) 111 | @echo "🧪 Synthesizing stack..." 112 | npx cdk synth $(STACK) $(CDK_OPTIONS) --context publish=true --quiet 113 | @echo "🚀 Publishing file assets..." 114 | npx cdk-assets publish --path cdk.out/$(STACK).assets.json 115 | @echo "\n$$(tput bold)✅ LAUNCH STACK URL: $$(tput sgr0)\n\ 116 | \033[36m`node scripts/publish/generateLaunchStackUrl.js $(SCRIPT_OPTIONS)`\033[0m" 117 | 118 | guard-%: 119 | @ if [ "${${*}}" = "" ]; then \ 120 | echo "Environment variable $* not set"; \ 121 | exit 1; \ 122 | fi -------------------------------------------------------------------------------- /lambdas/handlers/updateStatus.ts: -------------------------------------------------------------------------------- 1 | import { convertToAttr, unmarshall } from '@aws-sdk/util-dynamodb'; 2 | import { ScheduledHandler } from 'aws-lambda'; 3 | import { UpdateItemCommand } from '@aws-sdk/client-dynamodb'; 4 | 5 | import { chatSdk, ddbSdk, realTimeSdk } from '../sdk'; 6 | import { ddbDocClient } from '../clients'; 7 | import { getElapsedTimeInSeconds } from '../utils'; 8 | import { IDLE_TIME_UNTIL_STALE_IN_SECONDS } from '../constants'; 9 | import { RealTimeRecord, StageStatus } from '../types'; 10 | 11 | const { DISTRIBUTION_DOMAIN_NAME: distributionDomainName } = 12 | process.env as Record; 13 | const [cid] = distributionDomainName.split('.'); 14 | 15 | export const handler: ScheduledHandler = async () => { 16 | try { 17 | // Get all item records and stage summaries for this customer 18 | const [{ Items }, stageSummaries, roomSummaries] = await Promise.all([ 19 | ddbSdk.getRealTimeRecords({ 20 | attributesToGet: [ 21 | 'hostId', 22 | 'status', 23 | 'stageArn', 24 | 'chatRoomArn', 25 | 'createdAt', 26 | 'lastStatusUpdatedAt' 27 | ] 28 | }), 29 | realTimeSdk.getStageSummaries(cid), 30 | chatSdk.getRoomSummaries(cid) 31 | ]); 32 | 33 | if (!Items) return; 34 | 35 | const stageSummaryMap = new Map( 36 | stageSummaries.map(({ arn, ...restData }) => [arn, restData]) 37 | ); 38 | 39 | const stageArnsSet = new Set(); 40 | const chatRoomArnsSet = new Set(); 41 | const unmarshalledItems = Items.map>((item) => { 42 | const unmarshalledItem = unmarshall(item); 43 | const { stageArn, chatRoomArn } = unmarshalledItem; 44 | stageArn && stageArnsSet.add(stageArn); 45 | chatRoomArn && chatRoomArnsSet.add(chatRoomArn); 46 | 47 | return unmarshalledItem; 48 | }); 49 | 50 | // Delete any Stages that are not associated with an item to prevent hitting Stage limits 51 | for (let { arn, tags } of stageSummaries) { 52 | try { 53 | const { createdAt } = tags || {}; 54 | 55 | if ( 56 | !stageArnsSet.has(arn) && 57 | createdAt && 58 | getElapsedTimeInSeconds(createdAt) > 60 // created more than 1 min ago 59 | ) { 60 | await realTimeSdk.deleteStage(arn as string); 61 | } 62 | } catch (error) { 63 | // swallow the error to continue processing items 64 | console.error(error); 65 | } 66 | } 67 | 68 | // Delete any Rooms that are not associated with an item to prevent hitting Room limits 69 | for (let { arn, tags } of roomSummaries) { 70 | try { 71 | const { createdAt } = tags || {}; 72 | if ( 73 | !chatRoomArnsSet.has(arn) && 74 | createdAt && 75 | getElapsedTimeInSeconds(createdAt) > 60 // created more than 1 min ago 76 | ) { 77 | await chatSdk.deleteRoom(arn as string); 78 | } 79 | } catch (error) { 80 | // swallow the error to continue processing items 81 | console.error(error); 82 | } 83 | } 84 | 85 | for (let item of unmarshalledItems) { 86 | const { hostId, status, stageArn, chatRoomArn, lastStatusUpdatedAt } = 87 | item; 88 | const summary = stageSummaryMap.get(stageArn); 89 | const isActive = !!summary?.activeSessionId; 90 | const currentStatus = isActive ? StageStatus.ACTIVE : StageStatus.IDLE; 91 | 92 | // Update the Stage status if it has changed 93 | if (status !== currentStatus) { 94 | try { 95 | await ddbDocClient.send( 96 | new UpdateItemCommand({ 97 | TableName: ddbSdk.realTimeTableName, 98 | Key: { hostId: convertToAttr(hostId) }, 99 | UpdateExpression: 100 | 'SET #status = :status, #lastStatusUpdatedAt = :lastStatusUpdatedAt', 101 | ConditionExpression: 'attribute_exists(#hostId)', 102 | ExpressionAttributeNames: { 103 | '#hostId': 'hostId', 104 | '#status': 'status', 105 | '#lastStatusUpdatedAt': 'lastStatusUpdatedAt' 106 | }, 107 | ExpressionAttributeValues: { 108 | ':status': convertToAttr(currentStatus), 109 | ':lastStatusUpdatedAt': convertToAttr(new Date().toISOString()) 110 | } 111 | }) 112 | ); 113 | } catch (error) { 114 | // swallow the error to continue processing remaining items 115 | console.error(error); 116 | } 117 | 118 | continue; 119 | } 120 | 121 | // Delete item resources if the stage has remained IDLE for longer than IDLE_TIME_UNTIL_STALE_IN_MINUTES 122 | if ( 123 | !isActive && 124 | getElapsedTimeInSeconds(lastStatusUpdatedAt as string) > 125 | IDLE_TIME_UNTIL_STALE_IN_SECONDS 126 | ) { 127 | console.info('Deleting IDLE item', JSON.stringify(item)); 128 | 129 | try { 130 | // Delete the record references to the stage/room resources first 131 | await ddbSdk.deleteRealTimeRecord(hostId as string); 132 | await ddbSdk.deleteVotesRecord(hostId as string); 133 | await Promise.all([ 134 | chatSdk.deleteRoom(chatRoomArn as string), 135 | realTimeSdk.deleteStage(stageArn as string) 136 | ]); 137 | } catch (error) { 138 | console.error(error); 139 | // swallow the error to continue processing remaining items 140 | } 141 | } 142 | } 143 | } catch (error) { 144 | console.error(error); 145 | } 146 | }; 147 | -------------------------------------------------------------------------------- /lambdas/sdk/ddb.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AttributeValue, 3 | DeleteItemCommand, 4 | GetItemCommand, 5 | PutItemCommand, 6 | ScanCommand 7 | } from '@aws-sdk/client-dynamodb'; 8 | import { convertToAttr, marshall } from '@aws-sdk/util-dynamodb'; 9 | 10 | import { AUDIO_ROOM_SIZE } from '../constants'; 11 | import { ddbDocClient } from '../clients'; 12 | import { 13 | RealTimeRecord, 14 | StageConfig, 15 | StageMode, 16 | StageStatus, 17 | StageType, 18 | VotesRecord 19 | } from '../types'; 20 | 21 | export const { 22 | REAL_TIME_TABLE_NAME: realTimeTableName, 23 | VOTES_TABLE_NAME: votesTableName, 24 | CREATED_FOR_REALTIME_INDEX_NAME: createdForRealTimeIndexName 25 | } = process.env as Record; 26 | 27 | export const createRealTimeRecord = ({ 28 | hostId, 29 | stageArn, 30 | roomArn, 31 | type, 32 | createdFor, 33 | hostParticipantId, 34 | hostAttributes = {} 35 | }: { 36 | hostId: string; 37 | stageArn: string; 38 | roomArn: string; 39 | type: StageType; 40 | createdFor?: string; 41 | hostParticipantId?: string; 42 | hostAttributes?: Record; 43 | }) => { 44 | const now = new Date().toISOString(); 45 | 46 | let seats: string[] | undefined; 47 | if (type.toUpperCase() === StageType.AUDIO) { 48 | seats = Array(AUDIO_ROOM_SIZE).fill(''); 49 | seats[0] = hostParticipantId || ''; // Pre-assign the host to seat index 0 50 | } 51 | 52 | const record: RealTimeRecord = { 53 | hostId, 54 | hostAttributes, 55 | stageArn: stageArn, 56 | chatRoomArn: roomArn, 57 | createdFor, 58 | createdAt: now, 59 | lastStatusUpdatedAt: now, 60 | seats, 61 | mode: StageMode.NONE, 62 | type: type.toUpperCase() as StageType, 63 | status: StageStatus.IDLE 64 | }; 65 | 66 | return ddbDocClient.send( 67 | new PutItemCommand({ 68 | TableName: realTimeTableName, 69 | Item: marshall(record, { removeUndefinedValues: true }) 70 | }) 71 | ); 72 | }; 73 | 74 | export const getRealTimeRecord = (hostId: string) => 75 | ddbDocClient.send( 76 | new GetItemCommand({ 77 | TableName: realTimeTableName, 78 | Key: { hostId: convertToAttr(hostId) } 79 | }) 80 | ); 81 | 82 | export const getRealTimeRecords = ({ 83 | attributesToGet = [], 84 | filters = {} 85 | }: { 86 | attributesToGet?: string[]; 87 | filters?: { [key: string]: string | undefined }; 88 | } = {}) => { 89 | let expressionAttributeNames: Record = {}; 90 | let expressionAttributeValues: Record = {}; 91 | 92 | // Filters 93 | const filterExpressions: string[] = []; 94 | for (let filterKey in filters) { 95 | // StageConfig filter values must be transformed into the stored format (i.e. uppercase) 96 | const stageConfigFilterRegex = new RegExp(filterKey, 'i'); 97 | const isStageConfigFilterValue = !!Object.values(StageConfig).find( 98 | (value) => value.match(stageConfigFilterRegex) 99 | ); 100 | const filterValue = isStageConfigFilterValue 101 | ? filters[filterKey]?.toUpperCase() 102 | : filters[filterKey]; 103 | 104 | if (filterValue) { 105 | filterExpressions.push(`#${filterKey} = :${filterValue}`); 106 | expressionAttributeNames[`#${filterKey}`] = filterKey; 107 | expressionAttributeValues[`:${filterValue}`] = convertToAttr(filterValue); 108 | } 109 | } 110 | let filterExpression = filterExpressions.join(' AND '); 111 | 112 | // Projections 113 | const projectionExpression = attributesToGet 114 | .map((attr) => `#${attr}`) 115 | .join(','); 116 | attributesToGet.forEach( 117 | (attr) => (expressionAttributeNames[`#${attr}`] = attr) 118 | ); 119 | 120 | return ddbDocClient.send( 121 | new ScanCommand({ 122 | TableName: realTimeTableName, 123 | IndexName: 124 | 'createdFor' in filters ? createdForRealTimeIndexName : undefined, 125 | FilterExpression: filterExpression.length ? filterExpression : undefined, 126 | ProjectionExpression: projectionExpression.length 127 | ? projectionExpression 128 | : undefined, 129 | ExpressionAttributeNames: Object.keys(expressionAttributeNames).length 130 | ? expressionAttributeNames 131 | : undefined, 132 | ExpressionAttributeValues: Object.keys(expressionAttributeValues).length 133 | ? expressionAttributeValues 134 | : undefined 135 | }) 136 | ); 137 | }; 138 | 139 | export const deleteRealTimeRecord = (hostId: string) => 140 | ddbDocClient.send( 141 | new DeleteItemCommand({ 142 | TableName: realTimeTableName, 143 | Key: { hostId: convertToAttr(hostId) } 144 | }) 145 | ); 146 | 147 | export const createVotesRecord = ({ 148 | hostId, 149 | candidateIds, 150 | chatRoomArn 151 | }: { 152 | hostId: string; 153 | candidateIds: string[]; 154 | chatRoomArn: string; 155 | }) => { 156 | const startingTally = candidateIds.reduce>( 157 | (tally, candidateId) => ({ ...tally, [candidateId]: 0 }), 158 | {} 159 | ); 160 | 161 | const record: VotesRecord = { 162 | hostId, 163 | startedAt: new Date().toISOString(), 164 | chatRoomArn, 165 | tally: startingTally 166 | }; 167 | 168 | return ddbDocClient.send( 169 | new PutItemCommand({ 170 | TableName: votesTableName, 171 | Item: marshall(record) 172 | }) 173 | ); 174 | }; 175 | 176 | export const getVotesRecord = (hostId: string, attributesToGet?: string[]) => 177 | ddbDocClient.send( 178 | new GetItemCommand({ 179 | TableName: votesTableName, 180 | Key: { hostId: convertToAttr(hostId) }, 181 | ProjectionExpression: attributesToGet?.join(',') 182 | }) 183 | ); 184 | 185 | export const deleteVotesRecord = (hostId: string) => 186 | ddbDocClient.send( 187 | new DeleteItemCommand({ 188 | TableName: votesTableName, 189 | Key: { hostId: convertToAttr(hostId) } 190 | }) 191 | ); 192 | -------------------------------------------------------------------------------- /scripts/publish/pre-publish.js: -------------------------------------------------------------------------------- 1 | const { 2 | AttachRolePolicyCommand, 3 | CreatePolicyCommand, 4 | CreateRoleCommand, 5 | EntityAlreadyExistsException, 6 | GetRoleCommand 7 | } = require('@aws-sdk/client-iam'); 8 | const { 9 | CreateBucketCommand, 10 | PutBucketPolicyCommand, 11 | PutPublicAccessBlockCommand, 12 | BucketLocationConstraint 13 | } = require('@aws-sdk/client-s3'); 14 | const { parseArgs } = require('util'); 15 | const { writeFileSync } = require('fs'); 16 | 17 | const { getAwsConfig } = require('../utils'); 18 | const { iamClient, s3Client } = require('../clients'); 19 | 20 | const { values: args } = parseArgs({ 21 | options: { fileAssetsBucketNamePrefix: { type: 'string' } }, 22 | strict: false 23 | }); 24 | 25 | async function runPrePublish() { 26 | const { account, region } = await getAwsConfig(); 27 | const fileAssetsBucketName = `${args.fileAssetsBucketNamePrefix}-${region}`; 28 | const fileAssetPublishingRoleName = `${fileAssetsBucketName}-file-publishing-role`; 29 | const fileAssetPublishingRolePolicyName = `${fileAssetsBucketName}-file-publishing-role-policy`; 30 | 31 | let fileAssetPublishingRoleArn; 32 | 33 | try { 34 | // Create S3 bucket 35 | await s3Client.send( 36 | new CreateBucketCommand({ 37 | Bucket: fileAssetsBucketName, 38 | CreateBucketConfiguration: { 39 | LocationConstraint: BucketLocationConstraint[region] 40 | } 41 | }) 42 | ); 43 | 44 | // Allow public bucket policies for the S3 bucket 45 | await s3Client.send( 46 | new PutPublicAccessBlockCommand({ 47 | Bucket: fileAssetsBucketName, 48 | PublicAccessBlockConfiguration: { 49 | BlockPublicAcls: true, 50 | BlockPublicPolicy: false, 51 | IgnorePublicAcls: true, 52 | RestrictPublicBuckets: false 53 | } 54 | }) 55 | ); 56 | 57 | // Allow public GetObject permissions to the S3 bucket 58 | await s3Client.send( 59 | new PutBucketPolicyCommand({ 60 | Bucket: fileAssetsBucketName, 61 | Policy: JSON.stringify({ 62 | Version: '2012-10-17', 63 | Statement: { 64 | Sid: 'AllowPublicGetObject', 65 | Effect: 'Allow', 66 | Principal: '*', 67 | Action: 's3:GetObject', 68 | Resource: [ 69 | `arn:aws:s3:::${fileAssetsBucketName}`, 70 | `arn:aws:s3:::${fileAssetsBucketName}/*` 71 | ] 72 | } 73 | }) 74 | }) 75 | ); 76 | } catch (error) { 77 | if (error.name !== 'BucketAlreadyOwnedByYou') { 78 | throw error; 79 | } 80 | } 81 | 82 | try { 83 | // Create file asset publishing role 84 | const { Role: fileAssetPublishingRole } = await iamClient.send( 85 | new CreateRoleCommand({ 86 | RoleName: fileAssetPublishingRoleName, 87 | MaxSessionDuration: 3600, 88 | Description: `Role for publishing CloudFormation file assets to ${fileAssetsBucketName}`, 89 | AssumeRolePolicyDocument: JSON.stringify({ 90 | Version: '2008-10-17', 91 | Statement: [ 92 | { 93 | Effect: 'Allow', 94 | Action: 'sts:AssumeRole', 95 | Principal: { AWS: `arn:aws:iam::${account}:root` } 96 | } 97 | ] 98 | }) 99 | }) 100 | ); 101 | 102 | // Create file asset publishing role policy 103 | const { Policy: fileAssetPublishingPolicy } = await iamClient.send( 104 | new CreatePolicyCommand({ 105 | PolicyName: fileAssetPublishingRolePolicyName, 106 | PolicyDocument: JSON.stringify({ 107 | Version: '2012-10-17', 108 | Statement: [ 109 | { 110 | Action: [ 111 | 's3:GetObject*', 112 | 's3:GetBucket*', 113 | 's3:GetEncryptionConfiguration', 114 | 's3:List*', 115 | 's3:DeleteObject*', 116 | 's3:PutObject*', 117 | 's3:Abort*' 118 | ], 119 | Effect: 'Allow', 120 | Resource: [ 121 | `arn:aws:s3:::${fileAssetsBucketName}`, 122 | `arn:aws:s3:::${fileAssetsBucketName}/*` 123 | ] 124 | }, 125 | { 126 | Action: [ 127 | 'kms:Decrypt', 128 | 'kms:DescribeKey', 129 | 'kms:Encrypt', 130 | 'kms:ReEncrypt*', 131 | 'kms:GenerateDataKey*' 132 | ], 133 | Effect: 'Allow', 134 | Resource: `arn:aws:kms:${region}:${account}:key/AWS_MANAGED_KEY` 135 | } 136 | ] 137 | }) 138 | }) 139 | ); 140 | 141 | // Attach the policy to the role 142 | await iamClient.send( 143 | new AttachRolePolicyCommand({ 144 | RoleName: fileAssetPublishingRole.RoleName, 145 | PolicyArn: fileAssetPublishingPolicy.Arn 146 | }) 147 | ); 148 | 149 | fileAssetPublishingRoleArn = fileAssetPublishingRole.Arn; 150 | } catch (error) { 151 | if (error instanceof EntityAlreadyExistsException) { 152 | const { Role: fileAssetPublishingRole } = await iamClient.send( 153 | new GetRoleCommand({ RoleName: fileAssetPublishingRoleName }) 154 | ); 155 | 156 | fileAssetPublishingRoleArn = fileAssetPublishingRole.Arn; 157 | } else throw error; 158 | } 159 | 160 | if (fileAssetPublishingRoleArn) { 161 | const fileAssetPublishingConfig = { 162 | FILE_ASSETS_BUCKET_NAME: fileAssetsBucketName, 163 | FILE_ASSET_PUBLISHING_ROLE_ARN: fileAssetPublishingRoleArn 164 | }; 165 | 166 | const fileAssetPublishingConfigEnvStr = Object.entries( 167 | fileAssetPublishingConfig 168 | ).reduce((str, [key, value]) => (str += `${key}=${value}\n`), ''); 169 | 170 | writeFileSync( 171 | `scripts/publish/publish.${region}.env`, 172 | fileAssetPublishingConfigEnvStr 173 | ); 174 | } else { 175 | throw new Error( 176 | 'Failed to create or retrieve a file asset publishing role ARN.' 177 | ); 178 | } 179 | } 180 | 181 | runPrePublish(); 182 | -------------------------------------------------------------------------------- /postman/RT-demo.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "c8e5653b-a173-4664-8e4e-953ef7d079ea", 4 | "name": "RT-demo", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Create", 10 | "request": { 11 | "auth": { 12 | "type": "apikey" 13 | }, 14 | "method": "POST", 15 | "header": [ 16 | { 17 | "key": "x-api-key", 18 | "value": "{{api_key}}", 19 | "type": "default" 20 | } 21 | ], 22 | "body": { 23 | "mode": "raw", 24 | "raw": "{\n \"cid\": \"{{cid}}\",\n \"hostId\": \"{{hostId}}\",\n \"hostAttributes\": {\n \"avatar\": \"avtr\"\n },\n \"type\": \"{{type}}\"\n}", 25 | "options": { 26 | "raw": { 27 | "language": "json" 28 | } 29 | } 30 | }, 31 | "url": { 32 | "raw": "https://{{cid}}.cloudfront.net/create", 33 | "protocol": "https", 34 | "host": [ 35 | "{{cid}}", 36 | "cloudfront", 37 | "net" 38 | ], 39 | "path": [ 40 | "create" 41 | ] 42 | } 43 | }, 44 | "response": [] 45 | }, 46 | { 47 | "name": "Join", 48 | "request": { 49 | "method": "POST", 50 | "header": [ 51 | { 52 | "key": "x-api-key", 53 | "value": "{{api_key}}", 54 | "type": "default" 55 | } 56 | ], 57 | "body": { 58 | "mode": "raw", 59 | "raw": "{\n \"hostId\": \"{{hostId}}\",\n \"userId\": \"\",\n \"attributes\": {\n \"avatar\": \"\"\n }\n}", 60 | "options": { 61 | "raw": { 62 | "language": "json" 63 | } 64 | } 65 | }, 66 | "url": { 67 | "raw": "https://{{cid}}.cloudfront.net/join", 68 | "protocol": "https", 69 | "host": [ 70 | "{{cid}}", 71 | "cloudfront", 72 | "net" 73 | ], 74 | "path": [ 75 | "join" 76 | ] 77 | } 78 | }, 79 | "response": [] 80 | }, 81 | { 82 | "name": "CreateChatToken", 83 | "request": { 84 | "method": "POST", 85 | "header": [ 86 | { 87 | "key": "x-api-key", 88 | "value": "{{api_key}}", 89 | "type": "default" 90 | } 91 | ], 92 | "body": { 93 | "mode": "raw", 94 | "raw": "{\n \"hostId\": \"{{hostId}}\",\n \"userId\": \"\",\n \"attributes\": {\n \"avatar\": \"\"\n }\n}", 95 | "options": { 96 | "raw": { 97 | "language": "json" 98 | } 99 | } 100 | }, 101 | "url": { 102 | "raw": "https://{{cid}}.cloudfront.net/chatToken/create", 103 | "protocol": "https", 104 | "host": [ 105 | "{{cid}}", 106 | "cloudfront", 107 | "net" 108 | ], 109 | "path": [ 110 | "chatToken", 111 | "create" 112 | ] 113 | } 114 | }, 115 | "response": [] 116 | }, 117 | { 118 | "name": "CastVote", 119 | "request": { 120 | "method": "POST", 121 | "header": [ 122 | { 123 | "key": "x-api-key", 124 | "value": "{{api_key}}", 125 | "type": "default" 126 | } 127 | ], 128 | "body": { 129 | "mode": "raw", 130 | "raw": "{\n \"hostId\": \"{{hostId}}\", \n \"vote\": \"\"\n}", 131 | "options": { 132 | "raw": { 133 | "language": "json" 134 | } 135 | } 136 | }, 137 | "url": { 138 | "raw": "https://{{cid}}.cloudfront.net/castVote", 139 | "protocol": "https", 140 | "host": [ 141 | "{{cid}}", 142 | "cloudfront", 143 | "net" 144 | ], 145 | "path": [ 146 | "castVote" 147 | ] 148 | } 149 | }, 150 | "response": [] 151 | }, 152 | { 153 | "name": "Disconnect", 154 | "request": { 155 | "method": "PUT", 156 | "header": [ 157 | { 158 | "key": "x-api-key", 159 | "value": "{{api_key}}", 160 | "type": "default" 161 | } 162 | ], 163 | "body": { 164 | "mode": "raw", 165 | "raw": "{\n \"hostId\": \"{{hostId}}\",\n \"userId\": \"\",\n \"participantId\": \"\"\n}", 166 | "options": { 167 | "raw": { 168 | "language": "json" 169 | } 170 | } 171 | }, 172 | "url": { 173 | "raw": "https://{{cid}}.cloudfront.net/disconnect", 174 | "protocol": "https", 175 | "host": [ 176 | "{{cid}}", 177 | "cloudfront", 178 | "net" 179 | ], 180 | "path": [ 181 | "disconnect" 182 | ] 183 | } 184 | }, 185 | "response": [] 186 | }, 187 | { 188 | "name": "UpdateMode", 189 | "request": { 190 | "method": "PUT", 191 | "header": [ 192 | { 193 | "key": "x-api-key", 194 | "value": "{{api_key}}", 195 | "type": "default" 196 | } 197 | ], 198 | "body": { 199 | "mode": "raw", 200 | "raw": "{\n \"hostId\": \"{{hostId}}\",\n \"userId\": \"\",\n \"mode\": \"guest_spot\"\n}", 201 | "options": { 202 | "raw": { 203 | "language": "json" 204 | } 205 | } 206 | }, 207 | "url": { 208 | "raw": "https://{{cid}}.cloudfront.net/update/mode", 209 | "protocol": "https", 210 | "host": [ 211 | "{{cid}}", 212 | "cloudfront", 213 | "net" 214 | ], 215 | "path": [ 216 | "update", 217 | "mode" 218 | ] 219 | } 220 | }, 221 | "response": [] 222 | }, 223 | { 224 | "name": "UpdateSeats", 225 | "request": { 226 | "method": "PUT", 227 | "header": [ 228 | { 229 | "key": "x-api-key", 230 | "value": "{{api_key}}", 231 | "type": "default" 232 | } 233 | ], 234 | "body": { 235 | "mode": "raw", 236 | "raw": "{\n \"hostId\": \"{{hostId}}\",\n \"userId\": \"\",\n \"seats\": [\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\"\n ]\n}", 237 | "options": { 238 | "raw": { 239 | "language": "json" 240 | } 241 | } 242 | }, 243 | "url": { 244 | "raw": "https://{{cid}}.cloudfront.net/update/seats", 245 | "protocol": "https", 246 | "host": [ 247 | "{{cid}}", 248 | "cloudfront", 249 | "net" 250 | ], 251 | "path": [ 252 | "update", 253 | "seats" 254 | ] 255 | } 256 | }, 257 | "response": [] 258 | }, 259 | { 260 | "name": "List", 261 | "request": { 262 | "method": "GET", 263 | "header": [ 264 | { 265 | "key": "x-api-key", 266 | "value": "{{api_key}}", 267 | "type": "default" 268 | } 269 | ], 270 | "url": { 271 | "raw": "https://{{cid}}.cloudfront.net", 272 | "protocol": "https", 273 | "host": [ 274 | "{{cid}}", 275 | "cloudfront", 276 | "net" 277 | ], 278 | "query": [ 279 | { 280 | "key": "status", 281 | "value": "active", 282 | "disabled": true 283 | } 284 | ] 285 | } 286 | }, 287 | "response": [] 288 | }, 289 | { 290 | "name": "Verify", 291 | "request": { 292 | "method": "GET", 293 | "header": [ 294 | { 295 | "key": "x-api-key", 296 | "value": "{{api_key}}", 297 | "type": "default" 298 | } 299 | ], 300 | "url": { 301 | "raw": "https://{{cid}}.cloudfront.net/verify", 302 | "protocol": "https", 303 | "host": [ 304 | "{{cid}}", 305 | "cloudfront", 306 | "net" 307 | ], 308 | "path": [ 309 | "verify" 310 | ] 311 | } 312 | }, 313 | "response": [] 314 | }, 315 | { 316 | "name": "Delete", 317 | "request": { 318 | "method": "DELETE", 319 | "header": [ 320 | { 321 | "key": "x-api-key", 322 | "value": "{{api_key}}", 323 | "type": "default" 324 | } 325 | ], 326 | "body": { 327 | "mode": "raw", 328 | "raw": "{\n \"hostId\": \"{{hostId}}\"\n}", 329 | "options": { 330 | "raw": { 331 | "language": "json" 332 | } 333 | } 334 | }, 335 | "url": { 336 | "raw": "https://{{cid}}.cloudfront.net", 337 | "protocol": "https", 338 | "host": [ 339 | "{{cid}}", 340 | "cloudfront", 341 | "net" 342 | ] 343 | } 344 | }, 345 | "response": [] 346 | } 347 | ] 348 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon IVS Real-time Serverless Demo 2 | 3 | A serverless demo intended as an educational tool to demonstrate how you can build an application that enables real-time social UGC (User Generated Content) use cases using Amazon IVS Stages and Amazon IVS Chat. This README includes instructions for deploying the Amazon IVS Real-time Serverless demo to an AWS Account. 4 | 5 | _IMPORTANT NOTE: Deploying this demo application in your AWS account will create and consume AWS resources, which will cost money._ 6 | 7 | ## Prerequisites 8 | 9 | - [AWS CLI Version 2](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) 10 | - [NodeJS](https://nodejs.org/en/) and `npm` (npm is usually installed with NodeJS) 11 | - If you have [node version manager](https://github.com/nvm-sh/nvm) installed, run `nvm use` to sync your node version with this project 12 | - Access to an AWS Account with at least the following permissions: 13 | - Create IAM Roles 14 | - Create Lambda Functions 15 | - Create Secret Manager Secrets 16 | - Create Amazon IVS Stages and Chat Rooms 17 | - Create Amazon DynamoDB Tables 18 | - Create EventBridge Rules 19 | - Create Step Functions State Machines 20 | 21 | For configuration specifics, refer to the [AWS CLI User Guide](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html). 22 | 23 | ## Architecture 24 | 25 | ![Architecture Diagram](architecture.png) 26 | 27 | ## One-click deploy 28 | 29 | 1. Click the **Launch stack** button that corresponds to the region that is geographically closest to you 30 | 31 | | **North America** | | 32 | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 33 | | us-east-1 (N. Virginia) | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/new?stackName=IVSRealTimeDemo&templateURL=https://ivs-demos-cf-stacks-us-east-1.s3.us-east-1.amazonaws.com/IVSRealTimeDemo/c259cc716702fb68118582cb7983c4b978a724c247d63128a7fdeeb9ee363154.json) | 34 | | us-west-2 (Oregon) | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/new?stackName=IVSRealTimeDemo&templateURL=https://ivs-demos-cf-stacks-us-west-2.s3.us-west-2.amazonaws.com/IVSRealTimeDemo/2773150ae36e62fffc8e2a3a0fadd60524056886b003fcefc2a44b4fc1ae9660.json) | 35 | 36 | | **Europe** | | 37 | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 38 | | eu-west-1 (Ireland) | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=eu-west-1#/stacks/new?stackName=IVSRealTimeDemo&templateURL=https://ivs-demos-cf-stacks-eu-west-1.s3.eu-west-1.amazonaws.com/IVSRealTimeDemo/feca40d75d8f0d7af2cd4387eaf8f930453911aefcfe1e35809ef61143abd76b.json) | 39 | | eu-central-1 (Frankfurt) | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=eu-central-1#/stacks/new?stackName=IVSRealTimeDemo&templateURL=https://ivs-demos-cf-stacks-eu-central-1.s3.eu-central-1.amazonaws.com/IVSRealTimeDemo/cdde254ef04a0fc5b0c8f9345708019ab42c599a639f89164c6de220aa61be1d.json) | 40 | 41 | | **Asia Pacific** | | 42 | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 43 | | ap-south-1 (Mumbai) | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=ap-south-1#/stacks/new?stackName=IVSRealTimeDemo&templateURL=https://ivs-demos-cf-stacks-ap-south-1.s3.ap-south-1.amazonaws.com/IVSRealTimeDemo/b13a2ce2a30cb7f33ef578a70f1ed56684bd6058a96670064a4dcd63ea3bf09d.json) | 44 | | ap-northeast-1 (Tokyo) | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=ap-northeast-1#/stacks/new?stackName=IVSRealTimeDemo&templateURL=https://ivs-demos-cf-stacks-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/IVSRealTimeDemo/0baf2b1cd628662e8faa07e37d18b6869a513ab462586b8828e009dd4f92b23d.json) | 45 | | ap-northeast-2 (Seoul) | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=ap-northeast-2#/stacks/new?stackName=IVSRealTimeDemo&templateURL=https://ivs-demos-cf-stacks-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/IVSRealTimeDemo/032f776d5412e9c5bffabc2f49d483e83d97bcaacefb355e6c0a1012840ea276.json) | 46 | 47 | 2. Follow the steps on the **Create stack** page. You may optionally specify a different Stack name. 48 | 3. After completing all steps, select **Submit** to launch and deploy the stack. 49 | 50 | ### Use a one-click deploy backend with the client applications 51 | 52 | When the deployment successfully completes, save the following values in the **Outputs** tab to generate your `Authentication code`. You will need to enter this code when prompted by the mobile apps: 53 | 54 | - `domainName` (the first part of this is your `domainId`) 55 | - `secretUrl` (used to retrieve your `apiKey`) 56 | 57 | Open the `secretUrl` in your web browser and click the **Retrieve secret value** button in the **Secret value** section. Once the secrets are visible, copy the `apiKey` value. 58 | 59 | To generate your `Authentication code`, join the `domainId` and `apiKey` with a dash: `${domainId}-${apiKey}`. 60 | 61 | > For example, if your domainName is `d0abcdefghijk.cloudfront.net` and apiKey is `AbCDEFgHIJKLmnOPQrsTUV`, your authentication code is `d0abcdefghijk-AbCDEFgHIJKLmnOPQrsTUV`. 62 | 63 | On your mobile device, simply enter this value when prompted by the app. 64 | 65 | ## Deploy from the command line 66 | 67 | 1. If this is your first time deploying the backend stack, run the following command: 68 | 69 | ``` 70 | make app STACK=YOUR_STACK_NAME (default: IVSRealTimeDemo) 71 | ``` 72 | 73 | Otherwise, run the following command to skip the `install` and `bootstrap` processes and go straight into deploying: 74 | 75 | ``` 76 | make deploy STACK=YOUR_STACK_NAME (default: IVSRealTimeDemo) 77 | ``` 78 | 79 | See [Commands](#commands) for a comprehensive list of the `app` and `deploy` options. 80 | 81 | 2. Press `y` when prompted to acknowledge and proceed with the deployment 82 | 83 | ### Use the command line deployed backend with the client applications 84 | 85 | When the deployment successfully completes, copy the `⭐️ Domain ID` and `🔑 API key` values outputted in your terminal session. On your mobile device, simply enter these values when prompted by the app. 86 | 87 | Alternatively, you may use the mobile app to scan the `🔎 Authentication QR code`. Doing so will automatically paste the customer ID and API key into the app and sign you in immediately. 88 | 89 | ## Tearing down the backend stack 90 | 91 | To delete a deployed backend stack, run the following command: 92 | 93 | ``` 94 | make destroy STACK=YOUR_STACK_NAME 95 | ``` 96 | 97 | See [Commands](#commands) for a comprehensive list of the `destroy` option. 98 | 99 | _Note: resources created after the deployment will not be deleted. Such resources may include Stages and Chat Rooms._ 100 | 101 | ## Server Error Alarms 102 | 103 | When deploying the stack using the command-line, a CloudWatch alarm will be triggered when the API returns 5 or more server errors (5XX) within 10 minutes. To receive email notifications when an alarm is triggered, you must pass an `ALARMS_EMAIL` option to the `make app` or `make deploy` commands. For example, 104 | 105 | ``` 106 | make deploy ALARMS_EMAIL=youremail@example.com 107 | ``` 108 | 109 | Once the stack has been deployed, you will receive an email from AWS prompting you to confirm the SNS topic subscription to receive email notifications. 110 | 111 | ## Commands 112 | 113 | | | **Description** | **Options** | 114 | | --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | 115 | | **app** | Installs NPM dependencies, bootstraps, and deploys the stack | `AWS_PROFILE`, `AWS_REGION`, `STACK`, `NAG` | 116 | | **install** | Installs NPM dependencies | - | 117 | | **bootstrap** | Deploys the CDK Toolkit staging stack | `AWS_PROFILE`, `AWS_REGION`, `STACK`, `NAG` | 118 | | **synth** | Synthesizes the CDK app and produces a cloud assembly in cdk.out | `AWS_PROFILE`, `AWS_REGION`, `STACK`, `NAG` | 119 | | **deploy** | Deploys the stack | `AWS_PROFILE`, `AWS_REGION`, `STACK`, `NAG`, `ALARMS_EMAIL`, `TERM_PROTECTION` | 120 | | **output** | Retrieves the CloudFormation stack outputs | `STACK` | 121 | | **destroy** | Destroys the stack and cleans up | `AWS_PROFILE`, `AWS_REGION`, `STACK`, `NAG` | 122 | | **clean** | Deletes the cloud assembly directory (cdk.out) | - | 123 | | **seed** | Creates a specified number of randomly generated demo items | `AWS_PROFILE`, `AWS_REGION`, `STACK`, `COUNT`, `TYPE` | 124 | | **delete-seed** | Deletes all seeded items | `AWS_PROFILE`, `AWS_REGION`, `STACK` | 125 | | **publish** | Publishes stack file assets to an S3 bucket and generate a launch stack URL | `AWS_PROFILE`, `AWS_REGION`, `STACK`, `NAG`, `FILE_ASSETS_BUCKET_NAME_PREFIX` | 126 | | **help** | Shows a help message with all the available make rules | - | 127 | 128 | ### Command options 129 | 130 | `AWS_PROFILE` - named AWS CLI profile used for commands that interact with AWS. Defaults to `default`. 131 | 132 | `AWS_REGION` - the AWS region used for commands that interact with AWS. Defaults to the region associated with your `default` AWS CLI profile. 133 | 134 | `STACK` - the stack name. Defaults to `IVSRealTimeDemo`. 135 | 136 | `ALARMS_EMAIL` - the email that will be receiving CloudWatch alarm notifications. 137 | 138 | `TERM_PROTECTION` - set to `true` to enable stack termination protection. Defaults to `false`. 139 | 140 | `NAG` - set to `true` to enable application security and compliance checks. Defaults to `false`. 141 | 142 | `COUNT` - the number of demo items to seed (maximum is `10`). Defaults to `1`. 143 | 144 | `TYPE` - the type of demo items to seed (either `video` or `audio`). Defaults to `video`. 145 | 146 | `FILE_ASSETS_BUCKET_NAME_PREFIX` - the name prefix used to create or retrieve the S3 bucket to which file assets are saved from the `publish` command. This prefix is prepended with the AWS region to create the complete bucket name. Required when running the `publish` command. 147 | -------------------------------------------------------------------------------- /lib/real-time-stack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_apigateway as apigw, 3 | aws_cloudfront as cloudfront, 4 | aws_cloudfront_origins as origins, 5 | aws_cloudwatch_actions as cwActions, 6 | aws_dynamodb as dynamodb, 7 | aws_lambda_event_sources as eventSources, 8 | aws_lambda_nodejs as lambda, 9 | aws_logs as logs, 10 | aws_secretsmanager as secretsManager, 11 | aws_sns as sns, 12 | aws_sns_subscriptions as snsSub, 13 | CfnOutput, 14 | Duration, 15 | RemovalPolicy, 16 | Stack, 17 | StackProps 18 | } from 'aws-cdk-lib'; 19 | import { Construct } from 'constructs'; 20 | import { StartingPosition } from 'aws-cdk-lib/aws-lambda'; 21 | 22 | import { 23 | createResourcesPolicy, 24 | createTokensPolicy, 25 | deleteResourcesPolicy, 26 | disconnectUsersPolicy, 27 | getResourcesPolicy, 28 | sendEventPolicy, 29 | tagResourcesPolicy 30 | } from './policies'; 31 | import { getDefaultLambdaProps, getDefaultTableProps } from './utils'; 32 | import { UPDATE_STATUS_INTERVAL_IN_SECONDS } from '../lambdas/constants'; 33 | import CronScheduleTrigger from './Constructs/CronScheduleTrigger'; 34 | import IntegratedProxyLambda from './Constructs/IntegratedProxyLambda'; 35 | import schemas from './schemas'; 36 | 37 | const CREATED_FOR_REALTIME_INDEX_NAME = 'CreatedForRealTimeIndex'; 38 | 39 | interface RealTimeStackProps extends StackProps { 40 | alarmsEmail?: string; 41 | } 42 | 43 | export class RealTimeStack extends Stack { 44 | constructor(scope: Construct, id: string, props: RealTimeStackProps) { 45 | super(scope, id, props); 46 | const { alarmsEmail } = props; 47 | 48 | /** 49 | * Regional API Gateway REST API with API key 50 | */ 51 | const api = new apigw.RestApi(this, 'RegionalRestAPI', { 52 | restApiName: this.createResourceName('RegionalRestAPI'), 53 | endpointTypes: [apigw.EndpointType.REGIONAL], 54 | deployOptions: { stageName: 'prod' }, 55 | apiKeySourceType: apigw.ApiKeySourceType.HEADER, 56 | defaultCorsPreflightOptions: { allowOrigins: apigw.Cors.ALL_ORIGINS } 57 | }); 58 | const requestValidator = api.addRequestValidator('RequestValidator', { 59 | requestValidatorName: this.createResourceName('RequestValidator'), 60 | validateRequestBody: true, 61 | validateRequestParameters: true 62 | }); 63 | const usagePlan = api.addUsagePlan('ApiKeyUsagePlan', { 64 | apiStages: [{ api, stage: api.deploymentStage }], 65 | name: this.createResourceName('UsagePlan') 66 | }); 67 | 68 | // Set up a CloudWatch Alarm for API server errors (5xx) 69 | const serverErrorsMetric = api.metricServerError(); 70 | const serverErrorsAlarm = serverErrorsMetric.createAlarm( 71 | this, 72 | 'ServerErrorsAlarm', 73 | { 74 | threshold: 5, 75 | evaluationPeriods: 2, 76 | datapointsToAlarm: 1, 77 | alarmName: this.createResourceName('ServerErrorsAlarm') 78 | } 79 | ); 80 | 81 | if (alarmsEmail) { 82 | const serverErrorsTopic = new sns.Topic(this, 'ServerErrorsTopic'); 83 | const serverErrorsAction = new cwActions.SnsAction(serverErrorsTopic); 84 | const serverErrorsEmailSub = new snsSub.EmailSubscription(alarmsEmail); 85 | serverErrorsTopic.addSubscription(serverErrorsEmailSub); 86 | serverErrorsAlarm.addAlarmAction(serverErrorsAction); 87 | } 88 | 89 | // Generate a random secret string that will be used as the API key 90 | const secret = new secretsManager.Secret(this, 'Secret', { 91 | generateSecretString: { 92 | secretStringTemplate: JSON.stringify({ 93 | apiUrl: api.url, 94 | apiName: api.restApiName 95 | }), 96 | generateStringKey: 'apiKey', 97 | excludePunctuation: true, 98 | passwordLength: 20 // API keys must be at east 20 characters in length 99 | }, 100 | removalPolicy: RemovalPolicy.DESTROY, 101 | description: `API key value used for the REST API in the ${this.stackName} stack` 102 | }); 103 | 104 | const apiKeyValue = secret.secretValueFromJson('apiKey').unsafeUnwrap(); 105 | const apiKey = new apigw.ApiKey(this, 'ApiKey', { 106 | apiKeyName: this.createResourceName('ApiKey'), 107 | stages: [api.deploymentStage], 108 | value: apiKeyValue 109 | }); 110 | usagePlan.addApiKey(apiKey); 111 | 112 | /** 113 | * Customer-facing CloudFront Distribution with Regional API Gateway REST API origin 114 | */ 115 | const { 116 | AllowedMethods, 117 | CachedMethods, 118 | CachePolicy, 119 | ResponseHeadersPolicy, 120 | ViewerProtocolPolicy, 121 | OriginRequestPolicy 122 | } = cloudfront; 123 | 124 | const distribution = new cloudfront.Distribution(this, 'Distribution', { 125 | defaultBehavior: { 126 | allowedMethods: AllowedMethods.ALLOW_ALL, 127 | cachedMethods: CachedMethods.CACHE_GET_HEAD, 128 | cachePolicy: new CachePolicy(this, 'CachePolicy', { 129 | defaultTtl: Duration.seconds(1), 130 | comment: 'Default cache policy for the IVSRealTimeDemo distribution' 131 | }), 132 | origin: new origins.RestApiOrigin(api), 133 | originRequestPolicy: OriginRequestPolicy.fromOriginRequestPolicyId( 134 | this, 135 | 'AllViewerExceptHostHeader', 136 | 'b689b0a8-53d0-40ab-baf2-68738e2966ac' 137 | ), 138 | responseHeadersPolicy: ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS, 139 | viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS 140 | } 141 | }); 142 | 143 | /** 144 | * DynamoDB Tables and Indexes 145 | * 146 | * 1. RealTime Table - stores Stage and Chat Room data into "RealTime" records 147 | * 2. Votes Table - stores votes casted towards participants of a PK-mode session 148 | * 3. CreatedFor RealTime (sparse) Index - stores Stage and Chat Room data with a designated createdFor attribute (i.e. createdFor = "demo") 149 | */ 150 | const hostIdAttr: dynamodb.Attribute = { 151 | name: 'hostId', 152 | type: dynamodb.AttributeType.STRING 153 | }; 154 | 155 | const realTimeTable = new dynamodb.Table(this, 'RealTimeTable', { 156 | ...getDefaultTableProps(hostIdAttr), 157 | tableName: this.createResourceName('RealTimeTable') 158 | }); 159 | 160 | const votesTable = new dynamodb.Table(this, 'VotesTable', { 161 | ...getDefaultTableProps(hostIdAttr), 162 | tableName: this.createResourceName('VotesTable'), 163 | stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES 164 | }); 165 | 166 | realTimeTable.addGlobalSecondaryIndex({ 167 | indexName: CREATED_FOR_REALTIME_INDEX_NAME, 168 | partitionKey: { name: 'createdFor', type: dynamodb.AttributeType.STRING } 169 | }); 170 | 171 | /** 172 | * Verify Lambda (Proxy) - verifies that a CID exists 173 | */ 174 | new IntegratedProxyLambda(this, 'VerifyLambda', { 175 | api, 176 | resources: [{ method: 'GET', path: 'verify' }], 177 | handler: { 178 | entryFunctionName: 'verify', 179 | functionName: this.createResourceName('Verify'), 180 | description: 'Verifies that a CID exists' 181 | } 182 | }); 183 | 184 | /** 185 | * Create Lambda (Proxy) - creates Stage and Chat Room resources, including an initial host participant token 186 | */ 187 | const createLambda = new IntegratedProxyLambda(this, 'CreateLambda', { 188 | api, 189 | resources: [ 190 | { method: 'POST', path: 'create' }, 191 | { method: 'POST', path: 'create/{proxy+}' } 192 | ], 193 | requestValidation: { 194 | requestValidator, 195 | schema: schemas.createRequestSchema 196 | }, 197 | handler: { 198 | entryFunctionName: 'create', 199 | functionName: this.createResourceName('Create'), 200 | initialPolicy: [ 201 | createResourcesPolicy, 202 | createTokensPolicy, 203 | tagResourcesPolicy 204 | ], 205 | environment: { 206 | STACK: this.stackName, 207 | REAL_TIME_TABLE_NAME: realTimeTable.tableName 208 | }, 209 | description: 210 | 'Creates Stage and Chat Room resources, including an initial host participant token' 211 | } 212 | }); 213 | realTimeTable.grantReadWriteData(createLambda.lambdaFunction); 214 | 215 | /** 216 | * Join Lambda (Proxy) - creates participant and chat room tokens 217 | */ 218 | const joinLambda = new IntegratedProxyLambda(this, 'JoinLambda', { 219 | api, 220 | resources: [{ method: 'POST', path: 'join' }], 221 | requestValidation: { 222 | requestValidator, 223 | schema: schemas.joinRequestSchema 224 | }, 225 | handler: { 226 | entryFunctionName: 'join', 227 | functionName: this.createResourceName('Join'), 228 | initialPolicy: [createTokensPolicy, sendEventPolicy], 229 | environment: { 230 | STACK: this.stackName, 231 | REAL_TIME_TABLE_NAME: realTimeTable.tableName, 232 | VOTES_TABLE_NAME: votesTable.tableName 233 | }, 234 | description: 'Creates a participant tokens' 235 | } 236 | }); 237 | realTimeTable.grantReadData(joinLambda.lambdaFunction); 238 | votesTable.grantReadData(joinLambda.lambdaFunction); 239 | 240 | /** 241 | * List Lambda (Proxy) - retrieves summary information about all RealTime records 242 | */ 243 | const listLambda = new IntegratedProxyLambda(this, 'ListLambda', { 244 | api, 245 | resources: [{ method: 'GET' }], 246 | handler: { 247 | entryFunctionName: 'list', 248 | functionName: this.createResourceName('List'), 249 | environment: { 250 | STACK: this.stackName, 251 | REAL_TIME_TABLE_NAME: realTimeTable.tableName, 252 | CREATED_FOR_REALTIME_INDEX_NAME 253 | }, 254 | description: 'Retrieves summary information about all customer records' 255 | } 256 | }); 257 | realTimeTable.grantReadData(listLambda.lambdaFunction); 258 | 259 | /** 260 | * Delete Lambda (Proxy) - deletes Stage and Chat Room resources, and the associated RealTime record 261 | */ 262 | const deleteLambda = new IntegratedProxyLambda(this, 'DeleteLambda', { 263 | api, 264 | resources: [{ method: 'DELETE' }], 265 | requestValidation: { 266 | requestValidator, 267 | schema: schemas.deleteRequestSchema 268 | }, 269 | handler: { 270 | entryFunctionName: 'delete', 271 | functionName: this.createResourceName('Delete'), 272 | initialPolicy: [deleteResourcesPolicy], 273 | environment: { 274 | STACK: this.stackName, 275 | REAL_TIME_TABLE_NAME: realTimeTable.tableName, 276 | VOTES_TABLE_NAME: votesTable.tableName 277 | }, 278 | description: 279 | 'Deletes Stage and Chat Room resources, and the associated RealTime record' 280 | } 281 | }); 282 | realTimeTable.grantReadWriteData(deleteLambda.lambdaFunction); 283 | votesTable.grantWriteData(deleteLambda.lambdaFunction); 284 | 285 | /** 286 | * Disconnect Lambda (Proxy) - disconnects a participant from the Stage and Chat Room 287 | */ 288 | const disconnectLambda = new IntegratedProxyLambda( 289 | this, 290 | 'DisconnectLambda', 291 | { 292 | api, 293 | resources: [{ method: 'PUT', path: 'disconnect' }], 294 | requestValidation: { 295 | requestValidator, 296 | schema: schemas.disconnectRequestSchema 297 | }, 298 | handler: { 299 | entryFunctionName: 'disconnect', 300 | functionName: this.createResourceName('Disconnect'), 301 | initialPolicy: [disconnectUsersPolicy], 302 | environment: { 303 | STACK: this.stackName, 304 | REAL_TIME_TABLE_NAME: realTimeTable.tableName 305 | }, 306 | description: 'Disconnects a participant from the Stage and Chat Room' 307 | } 308 | } 309 | ); 310 | realTimeTable.grantReadData(disconnectLambda.lambdaFunction); 311 | 312 | /** 313 | * Update Lambda (Proxy) - updates the RealTime DDB record and sends update chat events 314 | */ 315 | const updateLambda = new IntegratedProxyLambda(this, 'UpdateLambda', { 316 | api, 317 | resources: [{ method: 'PUT', path: 'update/{proxy+}' }], 318 | requestValidation: { 319 | requestValidator, 320 | schema: schemas.updateRequestSchema 321 | }, 322 | handler: { 323 | entryFunctionName: 'update', 324 | functionName: this.createResourceName('Update'), 325 | initialPolicy: [sendEventPolicy], 326 | environment: { 327 | STACK: this.stackName, 328 | REAL_TIME_TABLE_NAME: realTimeTable.tableName, 329 | VOTES_TABLE_NAME: votesTable.tableName 330 | }, 331 | description: 332 | 'Updates the RealTime DDB record and sends update chat events' 333 | } 334 | }); 335 | realTimeTable.grantReadWriteData(updateLambda.lambdaFunction); 336 | votesTable.grantWriteData(updateLambda.lambdaFunction); 337 | 338 | /** 339 | * Create Chat Token Lambda (Proxy) - creates a chat token (intended to be used as the token provider for the Amazon IVS Chat Client Messaging SDK) 340 | */ 341 | const createChatTokenLambda = new IntegratedProxyLambda( 342 | this, 343 | 'CreateChatTokenLambda', 344 | { 345 | api, 346 | resources: [{ method: 'POST', path: 'chatToken/create' }], 347 | requestValidation: { 348 | requestValidator, 349 | schema: schemas.createChatTokenRequestSchema 350 | }, 351 | handler: { 352 | entryFunctionName: 'createChatToken', 353 | functionName: this.createResourceName('CreateChatToken'), 354 | initialPolicy: [createTokensPolicy], 355 | environment: { 356 | STACK: this.stackName, 357 | REAL_TIME_TABLE_NAME: realTimeTable.tableName 358 | }, 359 | description: 360 | 'Creates a chat token (intended to be used as the token provider for the Amazon IVS Chat Client Messaging SDK)' 361 | } 362 | } 363 | ); 364 | realTimeTable.grantReadData(createChatTokenLambda.lambdaFunction); 365 | 366 | /** 367 | * Cast Vote Lambda (Proxy) - casts a vote towards a participant of a PK-mode session 368 | */ 369 | const castVoteLambda = new IntegratedProxyLambda(this, 'CastVoteLambda', { 370 | api, 371 | resources: [{ method: 'POST', path: 'castVote' }], 372 | requestValidation: { 373 | requestValidator, 374 | schema: schemas.castVoteRequestSchema 375 | }, 376 | handler: { 377 | entryFunctionName: 'castVote', 378 | functionName: this.createResourceName('CastVote'), 379 | environment: { 380 | STACK: this.stackName, 381 | VOTES_TABLE_NAME: votesTable.tableName 382 | }, 383 | description: 'Casts a vote towards a participant of a PK-mode session', 384 | logRetention: logs.RetentionDays.THREE_DAYS 385 | } 386 | }); 387 | votesTable.grantWriteData(castVoteLambda.lambdaFunction); 388 | 389 | /** 390 | * Publish Votes Lambda - sends a chat event containing the latest vote tally to all viewers 391 | */ 392 | const publishVotesLambda = new lambda.NodejsFunction( 393 | this, 394 | 'PublishVotesLambda', 395 | { 396 | ...getDefaultLambdaProps('publishVotes'), 397 | functionName: this.createResourceName('PublishVotes'), 398 | initialPolicy: [sendEventPolicy], 399 | environment: { 400 | STACK: this.stackName, 401 | REAL_TIME_TABLE_NAME: realTimeTable.tableName 402 | }, 403 | logRetention: logs.RetentionDays.THREE_DAYS, 404 | description: 405 | 'Sends a chat event containing the latest vote tally to all viewers' 406 | } 407 | ); 408 | realTimeTable.grantReadData(publishVotesLambda); 409 | 410 | publishVotesLambda.addEventSource( 411 | new eventSources.DynamoEventSource(votesTable, { 412 | batchSize: 100, 413 | bisectBatchOnError: true, 414 | maxBatchingWindow: Duration.seconds(0), 415 | maxRecordAge: Duration.minutes(1), 416 | parallelizationFactor: 1, 417 | reportBatchItemFailures: false, 418 | /** 419 | * DynamoDB Streams are guaranteed to process records in order. 420 | * As a side effect of this, when a record fails to process, it is 421 | * retried until it is successfully processed or it expires from the 422 | * stream before any records after it in the stream are processed. 423 | */ 424 | retryAttempts: 2, 425 | startingPosition: StartingPosition.TRIM_HORIZON, 426 | tumblingWindow: Duration.seconds(0) 427 | }) 428 | ); 429 | 430 | /** 431 | * Update Status Lambda (CRON) - periodically updates the Stage status (IDLE | ACTIVE) 432 | * and deletes stale resources. Resources are considered 433 | * stale if the associated Stage has remained IDLE for a 434 | * pre-determined amount of time. 435 | */ 436 | const updateStatusLambda = new lambda.NodejsFunction( 437 | this, 438 | 'UpdateStatusLambda', 439 | { 440 | ...getDefaultLambdaProps('updateStatus'), 441 | functionName: this.createResourceName('UpdateStatus'), 442 | initialPolicy: [getResourcesPolicy, deleteResourcesPolicy], 443 | environment: { 444 | STACK: this.stackName, 445 | DISTRIBUTION_DOMAIN_NAME: distribution.domainName, 446 | REAL_TIME_TABLE_NAME: realTimeTable.tableName, 447 | VOTES_TABLE_NAME: votesTable.tableName 448 | }, 449 | logRetention: logs.RetentionDays.ONE_DAY, 450 | description: 451 | 'Updates the status of all stages and deletes stale resources' 452 | } 453 | ); 454 | realTimeTable.grantReadWriteData(updateStatusLambda); 455 | votesTable.grantWriteData(updateStatusLambda); 456 | 457 | updateStatusLambda.addEventSource( 458 | new CronScheduleTrigger(this, 'StatusUpdate-CronScheduleTrigger', { 459 | cronSchedule: { 460 | second: `0-59/${UPDATE_STATUS_INTERVAL_IN_SECONDS}`, // Invoke UpdateStatus every UPDATE_STATUS_INTERVAL_IN_SECONDS seconds 461 | minute: '*' 462 | } 463 | }) 464 | ); 465 | 466 | // Outputs 467 | new CfnOutput(this, 'domainName', { value: distribution.domainName }); 468 | new CfnOutput(this, 'secretName', { value: secret.secretName }); 469 | new CfnOutput(this, 'secretUrl', { 470 | value: `https://${this.region}.console.aws.amazon.com/secretsmanager/secret?name=${secret.secretName}` 471 | }); 472 | } 473 | 474 | private createResourceName = (name: string) => `${this.stackName}-${name}`; 475 | } 476 | --------------------------------------------------------------------------------