├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── serverless.yml ├── src ├── firmware-upgrade.ts ├── http-handler.ts ├── messages │ └── FirmwareUpgradeMessage.ts ├── middleware │ └── withApiClientAuth.ts ├── repositories │ ├── Devices.ts │ ├── ExternalControlSchedule.ts │ ├── WSClients.ts │ ├── dynamodbClient.ts │ └── error.ts ├── requestHandlers │ ├── getSchedule.ts │ ├── getScheduleWeekday.ts │ ├── getSensorReadings.ts │ ├── postAuthenticate.ts │ ├── postSignIn.ts │ ├── postSignUp.ts │ ├── putSchedule.ts │ └── putScheduleWeekday.ts ├── secret.ts ├── utils │ ├── error.ts │ ├── getExternalScheduleUpdateJSONMessage.ts │ ├── getHash.ts │ ├── getQueryParam.ts │ ├── headers.ts │ ├── parseBodyWithJoi.ts │ ├── responseOK.ts │ └── sendWSMessage.ts ├── websocketHandlers │ ├── handleConnect.ts │ ├── handleDisconnect.ts │ ├── handleFirmwareCheck.ts │ ├── handleMsg.ts │ ├── handleRequest.ts │ └── handleSignUp.ts └── ws-handler.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .serverless 2 | node_modules 3 | .build 4 | .vscode 5 | scripts/set-env.sh 6 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Tomasz Tarnowski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Control ESP32 from anywhere in the World - WebSocket Server Project 2 | 3 | This repository has been created as a part of the YouTube video: 4 | [Control ESP32 from ANYWHERE in the World - Step-By-Step Tutorial](https://youtu.be/z53MkVFOnIo) 5 | 6 | This is WebSocket server code that after deploying to AWS API Gateway WebSockets with Serverless Framework acts as an intermediary between ReactJS Web Application and ESP32 Microcontroller. 7 | 8 | In case you are not familiar with Serverless Framework yet I recommend checking this video: 9 | [Getting started with AWS Lambda and Serverless Framework](https://youtu.be/JL_7Odb7GLM) 10 | 11 | ## Prerequisites 12 | 13 | - AWS CLI installed and configured 14 | - [`serverless-framework`](https://github.com/serverless/serverless) 15 | - [`node.js`](https://nodejs.org) 16 | 17 | ## Installation 18 | 19 | Run: 20 | 21 | ```bash 22 | npm install 23 | ``` 24 | 25 | or 26 | 27 | ``` 28 | yarn install 29 | ``` 30 | 31 | ## Deployment 32 | 33 | Run: 34 | 35 | ```bash 36 | yarn deploy 37 | ``` 38 | 39 | or 40 | 41 | ```bash 42 | npm run deploy 43 | ``` 44 | 45 | ## Licence 46 | 47 | MIT. 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "websocket-server-api-gw", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "deploy": "source ./scripts/set-env.sh && serverless deploy", 9 | "deploy-staging": "source ./scripts/set-env.sh && serverless deploy --stage staging", 10 | "logs-ws": "aws logs tail /aws/lambda/aqua-stat-websocket-server-handler-staging-websocketHandler --follow --region=us-west-1", 11 | "logs-http": "aws logs tail /aws/lambda/aqua-stat-websocket-server-handler-staging-httpHandler --follow --region=us-west-1" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@types/aws-lambda": "^8.10.102", 17 | "@types/cookie": "^0.5.1", 18 | "@types/jsonwebtoken": "^8.5.9", 19 | "@types/node": "^18.7.13", 20 | "prettier": "^2.7.1", 21 | "serverless": "^3.22.0", 22 | "serverless-parameters": "^0.1.0", 23 | "serverless-plugin-typescript": "^2.1.2", 24 | "typescript": "^4.7.4" 25 | }, 26 | "dependencies": { 27 | "@aws-sdk/client-apigatewaymanagementapi": "^3.159.0", 28 | "@aws-sdk/client-dynamodb": "^3.190.0", 29 | "@aws-sdk/client-s3": "^3.226.0", 30 | "@types/uuid": "^8.3.4", 31 | "aws-lambda": "^1.0.7", 32 | "cookie": "^0.5.0", 33 | "http-status-codes": "^2.2.0", 34 | "joi": "^17.6.4", 35 | "jsonwebtoken": "^8.5.1", 36 | "uuid": "^9.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Serverless! 2 | # 3 | # This file is the main config file for your service. 4 | # It's very minimal at this point and uses default values. 5 | # You can always add more config options for more control. 6 | # We've included some commented out config examples here. 7 | # Just uncomment any of them to get that config option. 8 | # 9 | # For full config options, check the docs: 10 | # docs.serverless.com 11 | # 12 | # Happy Coding! 13 | 14 | service: aqua-stat-websocket-server-handler 15 | # app and org for use with dashboard.serverless.com 16 | #app: your-app-name 17 | #org: your-org-name 18 | 19 | # You can pin your service to only deploy with a specific Serverless version 20 | # Check out our docs for more details 21 | frameworkVersion: "3" 22 | 23 | provider: 24 | name: aws 25 | runtime: nodejs16.x 26 | lambdaHashingVersion: 20201221 27 | stage: ${opt:stage, 'dev'} 28 | 29 | # you can overwrite defaults here 30 | # stage: dev 31 | region: us-west-1 32 | 33 | # you can add statements to the Lambda function's IAM Role here 34 | iam: 35 | role: 36 | statements: 37 | - Effect: Allow 38 | Action: 39 | - "dynamodb:GetItem" 40 | - "dynamodb:PutItem" 41 | - "dynamodb:DeleteItem" 42 | - "dynamodb:Scan" 43 | Resource: 44 | - { "Fn::GetAtt": ["ExternalControlScheduleTable", "Arn"] } 45 | 46 | - Effect: Allow 47 | Action: 48 | - "dynamodb:GetItem" 49 | - "dynamodb:PutItem" 50 | Resource: 51 | - { "Fn::GetAtt": ["UsersTable", "Arn"] } 52 | 53 | - Effect: Allow 54 | Action: 55 | - "dynamodb:GetItem" 56 | - "dynamodb:PutItem" 57 | - "dynamodb:DeleteItem" 58 | - "dynamodb:Scan" 59 | Resource: 60 | - { "Fn::GetAtt": ["ClientsTable", "Arn"] } 61 | 62 | - Effect: Allow 63 | Action: 64 | - "dynamodb:GetItem" 65 | - "dynamodb:PutItem" 66 | - "dynamodb:DeleteItem" 67 | - "dynamodb:Scan" 68 | Resource: 69 | - { "Fn::GetAtt": ["DevicesTable", "Arn"] } 70 | 71 | - Effect: Allow 72 | Action: 73 | - "dynamodb:Query" 74 | Resource: 75 | Fn::Join: 76 | - "/" 77 | - - { "Fn::GetAtt": ["ClientsTable", "Arn"] } 78 | - "index" 79 | - "*" 80 | 81 | - Effect: Allow 82 | Action: 83 | - "dynamodb:GetItem" 84 | - "dynamodb:PutItem" 85 | - "dynamodb:DeleteItem" 86 | - "dynamodb:Scan" 87 | Resource: 88 | - { "Fn::GetAtt": ["DeviceKeysTable", "Arn"] } 89 | 90 | - Effect: Allow 91 | Action: 92 | - "dynamodb:GetItem" 93 | Resource: 94 | - { "Fn::GetAtt": ["ApiClientsTable", "Arn"] } 95 | 96 | - Effect: Allow 97 | Action: 98 | - "s3:GetObject" 99 | Resource: 100 | Fn::Join: 101 | - "" 102 | - - "arn:aws:s3:::" 103 | - ${self:provider.environment.FIRMWARE_BUCKET_NAME} 104 | - "/*" 105 | 106 | - Effect: Allow 107 | Action: 108 | - "s3:ListBucket" 109 | Resource: 110 | Fn::Join: 111 | - "" 112 | - - "arn:aws:s3:::" 113 | - ${self:provider.environment.FIRMWARE_BUCKET_NAME} 114 | 115 | environment: 116 | ACCOUNT_ID: ${aws:accountId} 117 | REGION: ${aws:region} 118 | EXTERNAL_CONTROL_SCHEDULE_TABLE_NAME: ${self:provider.stage}AquaStatExternalControlSchedule 119 | CLIENTS_TABLE_NAME: ${self:provider.stage}AquaStatWSClients 120 | DEVICE_ID_INDEX_NAME: ${self:provider.stage}DeviceIdIndex 121 | DEVICES_TABLE_NAME: ${self:provider.stage}AquaStatDevices 122 | DEVICE_KEYS_TABLE_NAME: ${self:provider.stage}AquaStatDeviceKeys 123 | USERS_TABLE_NAME: ${self:provider.stage}AquaStatUsers 124 | API_CLIENTS_TABLE_NAME: ${self:provider.stage}AquaStatApiClients 125 | HMAC_API_CLIENTS_SECRET_KEY: ${env:HMAC_API_CLIENTS_SECRET_KEY} 126 | HMAC_USERS_SECRET_KEY: ${env:HMAC_USERS_SECRET_KEY} 127 | FIRMWARE_BUCKET_NAME: ${self:provider.stage}-aquastat-firmware-binaries 128 | WSSAPIGATEWAYENDPOINT: 129 | Fn::Join: 130 | - "" 131 | - - "https://" 132 | - Ref: WebsocketsApi 133 | - ".execute-api." 134 | - Ref: AWS::Region 135 | - ".amazonaws.com/${sls:stage}" 136 | 137 | s3: 138 | firmwareBucket: 139 | name: ${self:provider.environment.FIRMWARE_BUCKET_NAME} 140 | 141 | functions: 142 | firmwareUpgrade: 143 | handler: src/firmware-upgrade.execute 144 | events: 145 | - s3: 146 | bucket: firmwareBucket 147 | event: s3:ObjectCreated:* 148 | rules: 149 | - suffix: .bin 150 | 151 | httpHandler: 152 | handler: src/http-handler.handle 153 | events: 154 | - httpApi: 155 | path: /api/sign-in 156 | method: POST 157 | - httpApi: 158 | path: /api/sign-up 159 | method: POST 160 | - httpApi: 161 | path: /api/authenticate 162 | method: POST 163 | - httpApi: 164 | path: /api/schedule 165 | method: PUT 166 | - httpApi: 167 | path: /api/schedule/weekday/{day} 168 | method: PUT 169 | - httpApi: 170 | path: /api/schedule 171 | method: GET 172 | - httpApi: 173 | path: /api/schedule/weekday/{day} 174 | method: GET 175 | - httpApi: 176 | path: /api/schedule/now 177 | method: PUT 178 | - httpApi: 179 | path: /api/sensor-readings 180 | method: GET 181 | 182 | websocketHandler: 183 | handler: src/ws-handler.handle 184 | events: 185 | - websocket: 186 | route: $connect 187 | - websocket: 188 | route: $disconnect 189 | - websocket: 190 | route: msg 191 | - websocket: 192 | route: signUp 193 | - websocket: 194 | route: firmwareCheck 195 | - websocket: 196 | route: request 197 | - websocket: 198 | route: response 199 | 200 | plugins: 201 | - serverless-plugin-typescript 202 | 203 | resources: 204 | Resources: 205 | ClientsTable: 206 | Type: AWS::DynamoDB::Table 207 | Properties: 208 | TableName: ${self:provider.environment.CLIENTS_TABLE_NAME} 209 | BillingMode: PAY_PER_REQUEST 210 | AttributeDefinitions: 211 | - AttributeName: connectionId 212 | AttributeType: S 213 | - AttributeName: DeviceId 214 | AttributeType: S 215 | KeySchema: 216 | - AttributeName: connectionId 217 | KeyType: HASH 218 | GlobalSecondaryIndexes: 219 | - IndexName: ${self:provider.environment.DEVICE_ID_INDEX_NAME} 220 | KeySchema: 221 | - AttributeName: DeviceId 222 | KeyType: HASH 223 | Projection: 224 | ProjectionType: "ALL" 225 | 226 | DevicesTable: 227 | Type: AWS::DynamoDB::Table 228 | Properties: 229 | TableName: ${self:provider.environment.DEVICES_TABLE_NAME} 230 | BillingMode: PAY_PER_REQUEST 231 | AttributeDefinitions: 232 | - AttributeName: DeviceId 233 | AttributeType: S 234 | KeySchema: 235 | - AttributeName: DeviceId 236 | KeyType: HASH 237 | 238 | UsersTable: 239 | Type: AWS::DynamoDB::Table 240 | Properties: 241 | TableName: ${self:provider.environment.USERS_TABLE_NAME} 242 | BillingMode: PAY_PER_REQUEST 243 | AttributeDefinitions: 244 | - AttributeName: UserEmail 245 | AttributeType: S 246 | KeySchema: 247 | - AttributeName: UserEmail 248 | KeyType: HASH 249 | DeviceKeysTable: 250 | Type: AWS::DynamoDB::Table 251 | Properties: 252 | TableName: ${self:provider.environment.DEVICE_KEYS_TABLE_NAME} 253 | BillingMode: PAY_PER_REQUEST 254 | AttributeDefinitions: 255 | - AttributeName: KeyId 256 | AttributeType: S 257 | KeySchema: 258 | - AttributeName: KeyId 259 | KeyType: HASH 260 | ApiClientsTable: 261 | Type: AWS::DynamoDB::Table 262 | Properties: 263 | TableName: ${self:provider.environment.API_CLIENTS_TABLE_NAME} 264 | BillingMode: PAY_PER_REQUEST 265 | AttributeDefinitions: 266 | - AttributeName: ApiClientId 267 | AttributeType: S 268 | KeySchema: 269 | - AttributeName: ApiClientId 270 | KeyType: HASH 271 | ExternalControlScheduleTable: 272 | Type: AWS::DynamoDB::Table 273 | Properties: 274 | TableName: ${self:provider.environment.EXTERNAL_CONTROL_SCHEDULE_TABLE_NAME} 275 | BillingMode: PAY_PER_REQUEST 276 | AttributeDefinitions: 277 | - AttributeName: DayIndex 278 | AttributeType: N 279 | KeySchema: 280 | - AttributeName: DayIndex 281 | KeyType: HASH 282 | -------------------------------------------------------------------------------- /src/firmware-upgrade.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb"; 2 | import { S3Event } from "aws-lambda"; 3 | import { createFirmwareUpgradeMessage } from "./messages/FirmwareUpgradeMessage"; 4 | import { sendWSMessage } from "./utils/sendWSMessage"; 5 | 6 | const dynamodbClient = new DynamoDBClient({}); 7 | const clientsTable = process.env["CLIENTS_TABLE_NAME"] || ""; 8 | 9 | export const execute = async (event: S3Event) => { 10 | const bucketName = event.Records[0].s3.bucket.name; 11 | const region = event.Records[0].awsRegion; 12 | const path = event.Records[0].s3.object.key; 13 | const parts = path.split("/", 2); 14 | if (parts.length < 2) { 15 | console.warn("no binary uploaded"); 16 | return; 17 | } 18 | const version = parts[0]; 19 | 20 | const output = await dynamodbClient.send(new ScanCommand({ TableName: clientsTable })); 21 | 22 | if (!output.Count || output.Count < 1) { 23 | console.log("no devices connected"); 24 | return; 25 | } 26 | 27 | for (const item of output.Items || []) { 28 | console.log(`upgrading device ${item["deviceId"].S} ${`https://s3.${region}.amazonaws.com/${bucketName}/${path}`}`); 29 | 30 | await sendWSMessage( 31 | item["connectionId"].S as string, 32 | createFirmwareUpgradeMessage(version, `https://s3.${region}.amazonaws.com/${bucketName}/${path}`), 33 | ); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/http-handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import { HttpError } from "./utils/error"; 3 | import postSignIn from "./requestHandlers/postSignIn"; 4 | import putScheduleWeekday from "./requestHandlers/putScheduleWeekday"; 5 | import putSchedule from "./requestHandlers/putSchedule"; 6 | import postAuthenticate from "./requestHandlers/postAuthenticate"; 7 | import postSignUp from "./requestHandlers/postSignUp"; 8 | import getHeaders from "./utils/headers"; 9 | import getSchedule from "./requestHandlers/getSchedule"; 10 | import getScheduleWeekday from "./requestHandlers/getScheduleWeekday"; 11 | import withApiClientAuth from "./middleware/withApiClientAuth"; 12 | import getSensorReadings from "./requestHandlers/getSensorReadings"; 13 | 14 | const endpointToHandler = { 15 | "POST /api/sign-in": postSignIn, 16 | "POST /api/sign-up": postSignUp, 17 | "POST /api/authenticate": postAuthenticate, 18 | "PUT /api/schedule": withApiClientAuth(putSchedule), 19 | "PUT /api/schedule/weekday/{day}": withApiClientAuth(putScheduleWeekday), 20 | "GET /api/schedule": withApiClientAuth(getSchedule), 21 | "GET /api/schedule/weekday/{day}": withApiClientAuth(getScheduleWeekday), 22 | "GET /api/sensor-readings": withApiClientAuth(getSensorReadings), 23 | }; 24 | 25 | export const handle = async (event: APIGatewayProxyEventV2): Promise => { 26 | try { 27 | console.log(event); 28 | const methodAndPath = event.requestContext.routeKey as keyof typeof endpointToHandler; 29 | 30 | if (endpointToHandler[methodAndPath] !== undefined) { 31 | const response = await endpointToHandler[methodAndPath](event); 32 | 33 | return response; 34 | } 35 | 36 | return { 37 | body: JSON.stringify({ 38 | status: "invalid method", 39 | }), 40 | headers: getHeaders(), 41 | statusCode: 405, 42 | }; 43 | } catch (e) { 44 | if (e instanceof HttpError) { 45 | return e.toAPIGatewayProxyResultV2(); 46 | } 47 | 48 | throw e; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/messages/FirmwareUpgradeMessage.ts: -------------------------------------------------------------------------------- 1 | export type FirmwareUpgradeMessage = { 2 | action: "msg"; 3 | type: "upgrade"; 4 | body: { 5 | version: string; 6 | url: string; 7 | }; 8 | }; 9 | 10 | export const createFirmwareUpgradeMessage = (version: string, url: string): string => 11 | JSON.stringify({ 12 | action: "msg", 13 | type: "upgrade", 14 | body: { 15 | version, 16 | url, 17 | }, 18 | } as FirmwareUpgradeMessage); 19 | -------------------------------------------------------------------------------- /src/middleware/withApiClientAuth.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { JwtPayload, verify } from "jsonwebtoken"; 4 | import { API_CLIENTS_HMAC_SECRET } from "../secret"; 5 | import { ErrorType, HttpError } from "../utils/error"; 6 | 7 | const withApiClientAuth = (fn: (event: APIGatewayProxyEventV2) => Promise) => { 8 | return (event: APIGatewayProxyEventV2) => { 9 | const authToken = (event.headers["authorization"] || "").slice(7); 10 | try { 11 | const decodedToken = verify(authToken, API_CLIENTS_HMAC_SECRET) as JwtPayload; 12 | event.headers["x-uid"] = decodedToken["uid"]; 13 | } catch (e) { 14 | throw new HttpError("invalid credentials", StatusCodes.UNAUTHORIZED, ErrorType.INVALID_CREDENTIALS); 15 | } 16 | 17 | return fn(event); 18 | }; 19 | }; 20 | 21 | export default withApiClientAuth; 22 | -------------------------------------------------------------------------------- /src/repositories/Devices.ts: -------------------------------------------------------------------------------- 1 | import { AttributeValue, GetItemCommand, PutItemCommand, ScanCommand } from "@aws-sdk/client-dynamodb"; 2 | import dynamodbClient, { devicesTable } from "./dynamodbClient"; 3 | import { RepositoryError, RepositoryErrorType } from "./error"; 4 | 5 | export type Device = { 6 | deviceId: string; 7 | deviceKey: string; 8 | mode: number; 9 | sensorReadings?: Record; 10 | }; 11 | 12 | export const fetchAllDevices = async (): Promise => { 13 | const output = await dynamodbClient.send( 14 | new ScanCommand({ 15 | TableName: devicesTable, 16 | }), 17 | ); 18 | 19 | if (!output.Items || !output.Count || output.Count < 1) { 20 | return []; 21 | } 22 | 23 | return output.Items.map((item) => mapItemToDevice(item)); 24 | }; 25 | 26 | export const fetchByDeviceId = async (deviceId: string): Promise => { 27 | const output = await dynamodbClient.send( 28 | new GetItemCommand({ 29 | TableName: devicesTable, 30 | Key: { 31 | DeviceId: { 32 | S: deviceId, 33 | }, 34 | }, 35 | }), 36 | ); 37 | 38 | if (!output.Item) { 39 | throw new RepositoryError( 40 | `requested device with id of "${deviceId}" does not exist`, 41 | RepositoryErrorType.NOT_FOUND, 42 | ); 43 | } 44 | 45 | return mapItemToDevice(output.Item); 46 | }; 47 | 48 | const mapItemToDevice = (item: Record): Device => { 49 | const sensorReadings = {} as Record; 50 | 51 | if (item["SensorReadings"] && item["SensorReadings"].M) { 52 | const mapValue = item["SensorReadings"].M; 53 | Object.keys(mapValue).forEach((key) => { 54 | if (mapValue[key].BOOL) { 55 | sensorReadings[key] = mapValue[key].BOOL; 56 | } 57 | if (mapValue[key].N) { 58 | sensorReadings[key] = Number(mapValue[key].N); 59 | } 60 | if (mapValue[key].S) { 61 | sensorReadings[key] = String(mapValue[key].S); 62 | } 63 | }); 64 | } 65 | 66 | return { 67 | deviceId: item["DeviceId"].S as string, 68 | deviceKey: item["DeviceKey"].S as string, 69 | mode: Number(item["Mode"] && item["Mode"].N ? item["Mode"].N : "0"), 70 | sensorReadings, 71 | }; 72 | }; 73 | 74 | export const updateDevice = async (device: Device) => { 75 | if (!(await doesDeviceExist(device.deviceId))) { 76 | throw new RepositoryError("device does not exist", RepositoryErrorType.NOT_FOUND); 77 | } 78 | 79 | return upsertDevice(device); 80 | }; 81 | 82 | export const createDevice = async (device: Device) => { 83 | if (await doesDeviceExist(device.deviceId)) { 84 | throw new RepositoryError("device with this id already exist", RepositoryErrorType.DUPLICATED_CONTENT); 85 | } 86 | 87 | return upsertDevice(device); 88 | }; 89 | 90 | const doesDeviceExist = async (deviceId: string): Promise => { 91 | try { 92 | await fetchByDeviceId(deviceId); 93 | return true; 94 | } catch (e) { 95 | if (e instanceof RepositoryError && e.type === RepositoryErrorType.NOT_FOUND) { 96 | return false; 97 | } 98 | throw e; 99 | } 100 | }; 101 | 102 | const upsertDevice = async (device: Device) => { 103 | const sensorReadings = device.sensorReadings 104 | ? Object.keys(device.sensorReadings).reduce((prev, key) => { 105 | const readings = device.sensorReadings as Record; 106 | 107 | switch (typeof readings[key]) { 108 | case "number": 109 | return { ...prev, [key]: { N: String(readings[key]) } }; 110 | case "string": 111 | return { ...prev, [key]: { S: String(readings[key]) } }; 112 | case "boolean": 113 | return { ...prev, [key]: { BOOL: Boolean(readings[key]) } }; 114 | } 115 | 116 | return prev; 117 | }, {} as Record) 118 | : {}; 119 | 120 | await dynamodbClient.send( 121 | new PutItemCommand({ 122 | Item: { 123 | DeviceId: { S: device.deviceId }, 124 | DeviceKey: { S: device.deviceKey }, 125 | Mode: { N: String(device.mode) }, 126 | SensorReadings: { M: sensorReadings }, 127 | }, 128 | TableName: devicesTable, 129 | }), 130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /src/repositories/ExternalControlSchedule.ts: -------------------------------------------------------------------------------- 1 | import { AttributeValue, GetItemCommand, PutItemCommand, ScanCommand } from "@aws-sdk/client-dynamodb"; 2 | import dynamodbClient, { scheduleTable } from "./dynamodbClient"; 3 | import { RepositoryError, RepositoryErrorType } from "./error"; 4 | 5 | export type TemperatureSlot = { 6 | time: string; 7 | temperature: number; 8 | }; 9 | 10 | export type Schedule = TemperatureSlot[][]; 11 | 12 | export const putScheduleItem = async (dayIndex: number, slots: TemperatureSlot[]) => { 13 | const slotTimes = slots.map(({ time }) => time); 14 | const hasDuplicates = new Set(slotTimes).size !== slotTimes.length; 15 | if (hasDuplicates) { 16 | throw new RepositoryError("duplicated slots", RepositoryErrorType.DUPLICATED_CONTENT); 17 | } 18 | 19 | await dynamodbClient.send( 20 | new PutItemCommand({ 21 | Item: { 22 | DayIndex: { N: String(dayIndex) }, 23 | Slots: { 24 | L: slots.map((slot) => ({ 25 | M: { 26 | Temperature: { N: String(slot.temperature) }, 27 | Time: { S: slot.time }, 28 | }, 29 | })), 30 | }, 31 | }, 32 | TableName: scheduleTable, 33 | }), 34 | ); 35 | }; 36 | 37 | export const fetchScheduleItem = async (dayIndex: number): Promise => { 38 | const result = await dynamodbClient.send( 39 | new GetItemCommand({ 40 | Key: { 41 | DayIndex: { N: String(dayIndex) }, 42 | }, 43 | TableName: scheduleTable, 44 | }), 45 | ); 46 | 47 | if (!result.Item) { 48 | throw new RepositoryError(`schedule item ${dayIndex} not found`, RepositoryErrorType.NOT_FOUND); 49 | } 50 | 51 | return dynamodbItemToSlots(result.Item); 52 | }; 53 | 54 | export const fetchSchedule = async (): Promise => { 55 | const result = await dynamodbClient.send( 56 | new ScanCommand({ 57 | TableName: scheduleTable, 58 | }), 59 | ); 60 | 61 | if (!result.Count || !result.Items || result.Count < 1) { 62 | throw new RepositoryError(`schedule not found`, RepositoryErrorType.NOT_FOUND); 63 | } 64 | 65 | const dayIndexToSlots = [] as [number, TemperatureSlot[]][]; 66 | 67 | result.Items.forEach((item) => { 68 | if (item["DayIndex"].N) { 69 | dayIndexToSlots.push([Number(item["DayIndex"].N), dynamodbItemToSlots(item)]); 70 | } 71 | }); 72 | 73 | return dayIndexToSlots.sort((a, b) => a[0] - b[0]).map((item) => item[1]); 74 | }; 75 | 76 | const dynamodbItemToSlots = (item: Record) => { 77 | const schedule = [] as TemperatureSlot[]; 78 | 79 | item["Slots"].L?.forEach((element) => { 80 | if (element.M && element.M["Temperature"].N && element.M["Time"].S) { 81 | schedule.push({ 82 | temperature: Number(element.M["Temperature"].N), 83 | time: element.M["Time"].S, 84 | }); 85 | } 86 | }); 87 | 88 | return schedule; 89 | }; 90 | -------------------------------------------------------------------------------- /src/repositories/WSClients.ts: -------------------------------------------------------------------------------- 1 | import { AttributeValue, GetItemCommand, QueryCommand, ScanCommand } from "@aws-sdk/client-dynamodb"; 2 | import dynamodbClient, { clientsTable, deviceIdIndexName } from "./dynamodbClient"; 3 | import { RepositoryError, RepositoryErrorType } from "./error"; 4 | 5 | export type Client = { 6 | connectionId: string; 7 | clientType: string; 8 | deviceId: string; 9 | }; 10 | 11 | export const getClientByDeviceId = async (deviceId: string, clientType: string = "device"): Promise => { 12 | const output = await dynamodbClient.send( 13 | new QueryCommand({ 14 | TableName: clientsTable, 15 | ExpressionAttributeNames: { 16 | "#FieldName": "DeviceId", 17 | }, 18 | ExpressionAttributeValues: { 19 | ":Value": { 20 | S: deviceId, 21 | }, 22 | }, 23 | IndexName: deviceIdIndexName, 24 | KeyConditionExpression: "#FieldName = :Value", 25 | }), 26 | ); 27 | 28 | if (!output.Count || output.Count < 1) { 29 | throw new RepositoryError("client not found", RepositoryErrorType.NOT_FOUND); 30 | } 31 | 32 | const item = output.Items?.find((item) => item["clientType"].S === clientType); 33 | 34 | if (!item) { 35 | throw new RepositoryError("client not found", RepositoryErrorType.NOT_FOUND); 36 | } 37 | 38 | return mapItemToClient(item); 39 | }; 40 | 41 | const mapItemToClient = (item: Record): Client => { 42 | return { 43 | connectionId: item["connectionId"].S as string, 44 | clientType: item["clientType"].S as string, 45 | deviceId: item["deviceId"].S as string, 46 | }; 47 | }; 48 | 49 | export const getClientByConnectionid = async (connectionId: string): Promise => { 50 | const output = await dynamodbClient.send( 51 | new GetItemCommand({ 52 | TableName: clientsTable, 53 | Key: { 54 | connectionId: { 55 | S: connectionId, 56 | }, 57 | }, 58 | }), 59 | ); 60 | 61 | if (!output.Item) { 62 | throw new RepositoryError( 63 | `requesed connection with id of "${connectionId}" does not exist`, 64 | RepositoryErrorType.NOT_FOUND, 65 | ); 66 | } 67 | 68 | return mapItemToClient(output.Item); 69 | }; 70 | 71 | export const getAllClients = async () => { 72 | const output = await dynamodbClient.send( 73 | new ScanCommand({ 74 | TableName: clientsTable, 75 | }), 76 | ); 77 | 78 | if (!output.Count || output.Count < 1) { 79 | return []; 80 | } 81 | 82 | const clients = [] as Client[]; 83 | 84 | for (const item of output.Items || []) { 85 | if (item["connectionId"].S && item["clientType"].S) { 86 | clients.push({ 87 | connectionId: item["connectionId"].S, 88 | clientType: item["clientType"].S, 89 | deviceId: item["deviceId"].S || "-", 90 | }); 91 | } 92 | } 93 | 94 | return clients; 95 | }; 96 | -------------------------------------------------------------------------------- /src/repositories/dynamodbClient.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from "@aws-sdk/client-dynamodb"; 2 | 3 | export const usersTableName = process.env["USERS_TABLE_NAME"]; 4 | export const deviceKeysTable = process.env["DEVICE_KEYS_TABLE_NAME"] || ""; 5 | export const clientsTable = process.env["CLIENTS_TABLE_NAME"] || ""; 6 | export const apiClientsTable = process.env["API_CLIENTS_TABLE_NAME"] || ""; 7 | export const scheduleTable = process.env["EXTERNAL_CONTROL_SCHEDULE_TABLE_NAME"] || ""; 8 | export const devicesTable = process.env["DEVICES_TABLE_NAME"] || ""; 9 | export const deviceIdIndexName = process.env["DEVICE_ID_INDEX_NAME"] || ""; 10 | 11 | const dynamodbClient = new DynamoDB({}); 12 | 13 | export default dynamodbClient; 14 | -------------------------------------------------------------------------------- /src/repositories/error.ts: -------------------------------------------------------------------------------- 1 | export enum RepositoryErrorType { 2 | NOT_FOUND = "not_found", 3 | DUPLICATED_CONTENT = "duplicated_content", 4 | } 5 | 6 | export class RepositoryError extends Error { 7 | constructor(public message: string, public type: RepositoryErrorType) { 8 | super(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/requestHandlers/getSchedule.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { RepositoryError, RepositoryErrorType } from "../repositories/error"; 4 | import { fetchSchedule } from "../repositories/ExternalControlSchedule"; 5 | import { ErrorType, HttpError } from "../utils/error"; 6 | import getHeaders from "../utils/headers"; 7 | 8 | const getSchedule = async (event: APIGatewayProxyEventV2): Promise => { 9 | try { 10 | const schedule = await fetchSchedule(); 11 | 12 | return { 13 | statusCode: 200, 14 | headers: getHeaders(), 15 | body: JSON.stringify({ schedule }), 16 | }; 17 | } catch (e) { 18 | if (e instanceof RepositoryError && e.type === RepositoryErrorType.NOT_FOUND) { 19 | throw new HttpError("schedule not found", StatusCodes.NOT_FOUND, ErrorType.NOT_FOUND); 20 | } 21 | 22 | throw e; 23 | } 24 | }; 25 | 26 | export default getSchedule; 27 | -------------------------------------------------------------------------------- /src/requestHandlers/getScheduleWeekday.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { RepositoryError, RepositoryErrorType } from "../repositories/error"; 4 | import { fetchSchedule, fetchScheduleItem } from "../repositories/ExternalControlSchedule"; 5 | import { ErrorType, HttpError } from "../utils/error"; 6 | import getHeaders from "../utils/headers"; 7 | 8 | const getScheduleWeekday = async (event: APIGatewayProxyEventV2): Promise => { 9 | try { 10 | const index = event.pathParameters ? event.pathParameters["day"] : ""; 11 | 12 | if (!index || Number(index) === NaN) { 13 | throw new HttpError("invalid weekday index", StatusCodes.BAD_REQUEST, ErrorType.INVALID_PATH_PARAM); 14 | } 15 | 16 | const slots = await fetchScheduleItem(Number(index)); 17 | 18 | return { 19 | statusCode: 200, 20 | headers: getHeaders(), 21 | body: JSON.stringify({ 22 | weekday: Number(index), 23 | slots, 24 | }), 25 | }; 26 | } catch (e) { 27 | if (e instanceof RepositoryError && e.type === RepositoryErrorType.NOT_FOUND) { 28 | throw new HttpError("schedule not found", StatusCodes.NOT_FOUND, ErrorType.NOT_FOUND); 29 | } 30 | 31 | throw e; 32 | } 33 | }; 34 | 35 | export default getScheduleWeekday; 36 | -------------------------------------------------------------------------------- /src/requestHandlers/getSensorReadings.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import { fetchAllDevices } from "../repositories/Devices"; 3 | import getHeaders from "../utils/headers"; 4 | 5 | const getSensorReadings = async (event: APIGatewayProxyEventV2): Promise => { 6 | const devices = await fetchAllDevices(); 7 | 8 | return { 9 | statusCode: 200, 10 | headers: getHeaders(), 11 | body: JSON.stringify( 12 | devices 13 | .filter((device) => device.sensorReadings !== undefined && device.mode === 3) 14 | .map((device) => ({ 15 | deviceId: device.deviceId, 16 | sensorReadings: device.sensorReadings, 17 | })), 18 | ), 19 | }; 20 | }; 21 | 22 | export default getSensorReadings; 23 | -------------------------------------------------------------------------------- /src/requestHandlers/postAuthenticate.ts: -------------------------------------------------------------------------------- 1 | import { GetItemCommand } from "@aws-sdk/client-dynamodb"; 2 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 3 | import { StatusCodes } from "http-status-codes"; 4 | import { sign } from "jsonwebtoken"; 5 | import dynamodbClient, { apiClientsTable } from "../repositories/dynamodbClient"; 6 | import { API_CLIENTS_HMAC_SECRET } from "../secret"; 7 | import { ErrorType, HttpError } from "../utils/error"; 8 | import getHash from "../utils/getHash"; 9 | import getHeaders from "../utils/headers"; 10 | 11 | type AuthenticateRequestBody = { 12 | clientId: string; 13 | secret: string; 14 | }; 15 | 16 | const postAuthenticate = async (event: APIGatewayProxyEventV2): Promise => { 17 | const reqBody = parseAuthenticateRequestBody(event.body as string); 18 | 19 | const apiClientResult = await dynamodbClient.send( 20 | new GetItemCommand({ 21 | Key: { 22 | ApiClientId: { S: reqBody.clientId }, 23 | }, 24 | TableName: apiClientsTable, 25 | }), 26 | ); 27 | 28 | if (!apiClientResult.Item) { 29 | throw new HttpError("invalid credentials", StatusCodes.UNAUTHORIZED, ErrorType.INVALID_CREDENTIALS); 30 | } 31 | 32 | const clientId = apiClientResult.Item["ApiClientId"].S; 33 | const secret = apiClientResult.Item["Secret"].S as string; 34 | const salt = apiClientResult.Item["Salt"].S as string; 35 | 36 | if (clientId !== reqBody.clientId || secret !== getHash(reqBody.secret, salt)) { 37 | throw new HttpError("invalid credentials", StatusCodes.UNAUTHORIZED, ErrorType.INVALID_CREDENTIALS); 38 | } 39 | 40 | const token = sign( 41 | { 42 | uid: reqBody.clientId, 43 | }, 44 | API_CLIENTS_HMAC_SECRET, 45 | { 46 | expiresIn: "14 days", 47 | }, 48 | ); 49 | 50 | return { 51 | statusCode: 200, 52 | headers: getHeaders(), 53 | body: JSON.stringify({ authToken: token }), 54 | }; 55 | }; 56 | 57 | const parseAuthenticateRequestBody = (body: string): AuthenticateRequestBody => { 58 | try { 59 | const parsed = JSON.parse(body) as AuthenticateRequestBody; 60 | 61 | if (!parsed.clientId || typeof parsed.clientId !== "string") { 62 | throw new HttpError("clientId is invalid", StatusCodes.UNPROCESSABLE_ENTITY, ErrorType.INVALID_CREDENTIALS); 63 | } 64 | 65 | if (!parsed.secret || typeof parsed.secret !== "string") { 66 | throw new HttpError("secret is invalid", StatusCodes.UNPROCESSABLE_ENTITY, ErrorType.INVALID_CREDENTIALS); 67 | } 68 | 69 | return parsed; 70 | } catch (e) { 71 | if (e instanceof SyntaxError) { 72 | throw new HttpError( 73 | "request body must be in a JSON format", 74 | StatusCodes.BAD_REQUEST, 75 | ErrorType.INVALID_REQUEST_BODY, 76 | ); 77 | } 78 | 79 | throw e; 80 | } 81 | }; 82 | 83 | export default postAuthenticate; 84 | -------------------------------------------------------------------------------- /src/requestHandlers/postSignIn.ts: -------------------------------------------------------------------------------- 1 | import { GetItemCommand } from "@aws-sdk/client-dynamodb"; 2 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 3 | import { serialize } from "cookie"; 4 | import { StatusCodes } from "http-status-codes"; 5 | import { sign } from "jsonwebtoken"; 6 | import dynamodbClient, { usersTableName } from "../repositories/dynamodbClient"; 7 | import { USERS_HMAC_SECRET } from "../secret"; 8 | import { ErrorType, HttpError } from "../utils/error"; 9 | import getHash from "../utils/getHash"; 10 | import getHeaders from "../utils/headers"; 11 | 12 | type SignInRequestBody = { 13 | email: string; 14 | password: string; 15 | }; 16 | 17 | const postSignIn = async (event: APIGatewayProxyEventV2): Promise => { 18 | if (event.requestContext.http.method === "OPTIONS") { 19 | return { 20 | statusCode: 200, 21 | headers: getHeaders(), 22 | }; 23 | } 24 | 25 | const getInvalidCredentialsHttpError = () => 26 | new HttpError("invalid email or password", StatusCodes.UNAUTHORIZED, ErrorType.INVALID_CREDENTIALS); 27 | 28 | const reqBody = parseSignInRequestBody(event.body); 29 | 30 | const result = await dynamodbClient.send( 31 | new GetItemCommand({ 32 | Key: { 33 | UserEmail: { S: reqBody.email }, 34 | }, 35 | TableName: usersTableName, 36 | }), 37 | ); 38 | 39 | if (!result.Item) { 40 | throw getInvalidCredentialsHttpError(); 41 | } 42 | 43 | const deviceId = result.Item["DeviceId"].S || ""; 44 | const salt = result.Item["Salt"].S || ""; 45 | 46 | const storedPasswordHash = result.Item["Password"].S || ""; 47 | const reqPasswordHash = getHash(reqBody.password, salt); 48 | 49 | if (reqPasswordHash !== storedPasswordHash) { 50 | throw getInvalidCredentialsHttpError(); 51 | } 52 | 53 | const token = sign( 54 | { 55 | uid: reqBody.email, 56 | deviceId: deviceId, 57 | }, 58 | USERS_HMAC_SECRET, 59 | { 60 | expiresIn: "14 days", 61 | }, 62 | ); 63 | 64 | return { 65 | body: JSON.stringify({ 66 | token, 67 | }), 68 | headers: { 69 | ...getHeaders, 70 | "Set-Cookie": serialize("token", token, { 71 | maxAge: 60 * 60 * 24 * 14, 72 | domain: event.headers["origin"], 73 | // secure: true, 74 | }), 75 | }, 76 | statusCode: 200, 77 | }; 78 | }; 79 | 80 | export const parseSignInRequestBody = (body?: string): SignInRequestBody => { 81 | if (!body) { 82 | throw new Error(); 83 | } 84 | 85 | try { 86 | const parsed = JSON.parse(body) as SignInRequestBody; 87 | 88 | if (!parsed.email || parsed.email === "") { 89 | throw new HttpError( 90 | "email field cannot be empty", 91 | StatusCodes.UNPROCESSABLE_ENTITY, 92 | ErrorType.INVALID_REQUEST_BODY, 93 | ); 94 | } 95 | 96 | if (!parsed.password || parsed.password === "") { 97 | throw new HttpError( 98 | "password field cannot be empty", 99 | StatusCodes.UNPROCESSABLE_ENTITY, 100 | ErrorType.INVALID_REQUEST_BODY, 101 | ); 102 | } 103 | 104 | return { ...parsed, email: parsed.email.toLowerCase() }; 105 | } catch (e) { 106 | if (!(e instanceof HttpError)) { 107 | throw new HttpError("invalid request body", StatusCodes.BAD_REQUEST, ErrorType.INVALID_REQUEST_BODY); 108 | } 109 | 110 | throw e; 111 | } 112 | }; 113 | 114 | export default postSignIn; 115 | -------------------------------------------------------------------------------- /src/requestHandlers/postSignUp.ts: -------------------------------------------------------------------------------- 1 | import { GoneException } from "@aws-sdk/client-apigatewaymanagementapi"; 2 | import { GetItemCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"; 3 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 4 | import { StatusCodes } from "http-status-codes"; 5 | import { TextEncoder } from "util"; 6 | import { v4 } from "uuid"; 7 | import { createDevice } from "../repositories/Devices"; 8 | import dynamodbClient, { clientsTable, deviceKeysTable, usersTableName } from "../repositories/dynamodbClient"; 9 | import { ErrorType, HttpError } from "../utils/error"; 10 | import getHash from "../utils/getHash"; 11 | import getHeaders from "../utils/headers"; 12 | import { apiGatewayManagementApi, sendWSMessage } from "../utils/sendWSMessage"; 13 | import { parseSignInRequestBody } from "./postSignIn"; 14 | 15 | type SignUpRequestBody = { 16 | email: string; 17 | password: string; 18 | secret: string; 19 | }; 20 | 21 | const postSignUp = async (event: APIGatewayProxyEventV2): Promise => { 22 | if (event.requestContext.http.method === "OPTIONS") { 23 | return { 24 | statusCode: 200, 25 | headers: getHeaders(), 26 | }; 27 | } 28 | 29 | const reqBody = parseSignUpRequestBody(event.body); 30 | 31 | const keyResult = await dynamodbClient.send( 32 | new GetItemCommand({ 33 | Key: { 34 | KeyId: { S: reqBody.secret }, 35 | }, 36 | TableName: deviceKeysTable, 37 | }), 38 | ); 39 | 40 | if (!keyResult.Item) { 41 | throw new HttpError("invalid secret", StatusCodes.BAD_REQUEST, ErrorType.INVALID_REQUEST_BODY); 42 | } 43 | 44 | const connectionId = keyResult.Item["connectionId"].S || ""; 45 | const connResult = await dynamodbClient.send( 46 | new GetItemCommand({ 47 | Key: { 48 | connectionId: { S: connectionId }, 49 | }, 50 | TableName: clientsTable, 51 | }), 52 | ); 53 | 54 | if (!connResult.Item) { 55 | throw new HttpError( 56 | "could not connect with AquaStat, account cannot be created", 57 | StatusCodes.GONE, 58 | ErrorType.INVALID_REQUEST_BODY, 59 | ); 60 | } 61 | 62 | await tryToSendPingMessage(connectionId); 63 | 64 | const salt = v4(); 65 | const deviceId = v4().slice(0, 12); 66 | const deviceKey = v4().slice(0, 12); 67 | 68 | await dynamodbClient.send( 69 | new PutItemCommand({ 70 | Item: { 71 | UserEmail: { S: reqBody.email }, 72 | Salt: { S: salt }, 73 | Password: { S: getHash(reqBody.password, salt) }, 74 | DeviceId: { S: deviceId }, 75 | DeviceKey: { S: deviceKey }, 76 | }, 77 | TableName: usersTableName, 78 | }), 79 | ); 80 | 81 | await createDevice({ 82 | deviceId, 83 | deviceKey, 84 | mode: 0, 85 | }); 86 | 87 | await sendWSMessage( 88 | connectionId, 89 | JSON.stringify({ 90 | action: "msg", 91 | type: "signedUp", 92 | deviceId, 93 | deviceKey, 94 | }), 95 | ); 96 | 97 | return { 98 | body: JSON.stringify({ status: "ok" }), 99 | statusCode: 200, 100 | headers: getHeaders(), 101 | }; 102 | }; 103 | 104 | const tryToSendPingMessage = async (connectionId: string) => { 105 | const textEncoder = new TextEncoder(); 106 | 107 | try { 108 | await apiGatewayManagementApi.postToConnection({ 109 | ConnectionId: connectionId, 110 | Data: textEncoder.encode(JSON.stringify({ action: "msg", type: "ping" })), 111 | }); 112 | } catch (e) { 113 | if (e instanceof GoneException) { 114 | throw new HttpError( 115 | "could not connect with AquaStat, account cannot be created", 116 | StatusCodes.GONE, 117 | ErrorType.INVALID_REQUEST_BODY, 118 | ); 119 | } 120 | throw e; 121 | } 122 | }; 123 | 124 | const parseSignUpRequestBody = (body?: string): SignUpRequestBody => { 125 | if (!body) { 126 | throw new Error(); 127 | } 128 | 129 | try { 130 | const signInParsed = parseSignInRequestBody(body); 131 | 132 | const parsed = signInParsed as SignUpRequestBody; 133 | 134 | if (!parsed.secret || parsed.secret === "") { 135 | throw new HttpError( 136 | "secret field cannot be empty", 137 | StatusCodes.UNPROCESSABLE_ENTITY, 138 | ErrorType.INVALID_REQUEST_BODY, 139 | ); 140 | } 141 | 142 | return parsed; 143 | } catch (e) { 144 | if (!(e instanceof HttpError)) { 145 | throw new HttpError("invalid request body", StatusCodes.BAD_REQUEST, ErrorType.INVALID_REQUEST_BODY); 146 | } 147 | 148 | throw e; 149 | } 150 | }; 151 | 152 | export default postSignUp; 153 | -------------------------------------------------------------------------------- /src/requestHandlers/putSchedule.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import { putScheduleItem, TemperatureSlot } from "../repositories/ExternalControlSchedule"; 3 | import parseBodyWithJoi from "../utils/parseBodyWithJoi"; 4 | import { temperatureSlotsSchema } from "./putScheduleWeekday"; 5 | import Joi from "joi"; 6 | import getHeaders from "../utils/headers"; 7 | import { RepositoryError, RepositoryErrorType } from "../repositories/error"; 8 | import { ErrorType, HttpError } from "../utils/error"; 9 | import { StatusCodes } from "http-status-codes"; 10 | import { getAllClients } from "../repositories/WSClients"; 11 | import getExternalScheduleUpdateJSONMessage from "../utils/getExternalScheduleUpdateJSONMessage"; 12 | import { sendWSMessage } from "../utils/sendWSMessage"; 13 | 14 | const array = () => Joi.array(); 15 | const object = (schema?: Joi.PartialSchemaMap | undefined) => Joi.object(schema); 16 | 17 | type PutScheduleRequestBody = { 18 | schedule: TemperatureSlot[][]; 19 | }; 20 | 21 | const putScheduleRequestBodySchema = object({ 22 | schedule: array().items(temperatureSlotsSchema).max(7), 23 | }); 24 | 25 | const putSchedule = async (event: APIGatewayProxyEventV2): Promise => { 26 | const reqBody = await parseBodyWithJoi(event.body, putScheduleRequestBodySchema); 27 | 28 | try { 29 | await Promise.all( 30 | reqBody.schedule.map(async (slots, dayIndex) => { 31 | await putScheduleItem(dayIndex, slots); 32 | }), 33 | ); 34 | 35 | const clients = await getAllClients(); 36 | 37 | await Promise.all( 38 | clients.map(async (client) => { 39 | await Promise.all( 40 | reqBody.schedule.map(async (slots, dayIndex) => { 41 | if (client.clientType !== "device") { 42 | return; 43 | } 44 | 45 | await sendWSMessage(client.connectionId, getExternalScheduleUpdateJSONMessage(dayIndex, slots)); 46 | }), 47 | ); 48 | }), 49 | ); 50 | 51 | return { 52 | statusCode: 200, 53 | headers: getHeaders(), 54 | body: JSON.stringify({ 55 | success: true, 56 | }), 57 | }; 58 | } catch (e) { 59 | if (e instanceof RepositoryError && e.type === RepositoryErrorType.DUPLICATED_CONTENT) { 60 | throw new HttpError(e.message, StatusCodes.UNPROCESSABLE_ENTITY, ErrorType.INVALID_REQUEST_BODY); 61 | } 62 | 63 | throw e; 64 | } 65 | }; 66 | 67 | export default putSchedule; 68 | -------------------------------------------------------------------------------- /src/requestHandlers/putScheduleWeekday.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import { putScheduleItem, TemperatureSlot } from "../repositories/ExternalControlSchedule"; 3 | import parseBodyWithJoi from "../utils/parseBodyWithJoi"; 4 | import Joi from "joi"; 5 | import { ErrorType, HttpError } from "../utils/error"; 6 | import { StatusCodes } from "http-status-codes"; 7 | import getHeaders from "../utils/headers"; 8 | import { RepositoryError, RepositoryErrorType } from "../repositories/error"; 9 | import { getAllClients } from "../repositories/WSClients"; 10 | import getExternalScheduleUpdateJSONMessage from "../utils/getExternalScheduleUpdateJSONMessage"; 11 | import { sendWSMessage } from "../utils/sendWSMessage"; 12 | 13 | const array = () => Joi.array(); 14 | const object = (schema?: Joi.PartialSchemaMap | undefined) => Joi.object(schema); 15 | const number = () => Joi.number(); 16 | const string = () => Joi.string(); 17 | 18 | type PutScheduleWeekdayRequestBody = { 19 | slots: TemperatureSlot[]; 20 | }; 21 | 22 | export const temperatureSlotsSchema = array().items( 23 | object({ 24 | temperature: number().min(80).max(180).required(), 25 | time: string() 26 | .regex(/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/) 27 | .required() 28 | .messages({ "string.pattern.base": "time must match HH:MM format" }), 29 | }), 30 | ); 31 | 32 | const putScheduleWeekdayRequestBodySchema = object({ 33 | slots: temperatureSlotsSchema.required(), 34 | }); 35 | 36 | const putScheduleWeekday = async (event: APIGatewayProxyEventV2): Promise => { 37 | const index = event.pathParameters ? event.pathParameters["day"] : ""; 38 | 39 | if (!index || Number(index) === NaN) { 40 | throw new HttpError("invalid weekday index", StatusCodes.BAD_REQUEST, ErrorType.INVALID_PATH_PARAM); 41 | } 42 | 43 | const reqBody = await parseBodyWithJoi(event.body, putScheduleWeekdayRequestBodySchema); 44 | const dayIndex = Number(index); 45 | 46 | try { 47 | await putScheduleItem(dayIndex, reqBody.slots); 48 | 49 | const clients = await getAllClients(); 50 | 51 | await Promise.all( 52 | clients.map(async (client) => { 53 | if (client.clientType !== "device") { 54 | return; 55 | } 56 | 57 | await sendWSMessage(client.connectionId, getExternalScheduleUpdateJSONMessage(dayIndex, reqBody.slots)); 58 | }), 59 | ); 60 | 61 | return { 62 | statusCode: 200, 63 | headers: getHeaders(), 64 | body: JSON.stringify({ 65 | success: true, 66 | }), 67 | }; 68 | } catch (e) { 69 | if (e instanceof RepositoryError && e.type === RepositoryErrorType.DUPLICATED_CONTENT) { 70 | throw new HttpError(e.message, StatusCodes.UNPROCESSABLE_ENTITY, ErrorType.INVALID_REQUEST_BODY); 71 | } 72 | 73 | throw e; 74 | } 75 | }; 76 | 77 | export default putScheduleWeekday; 78 | -------------------------------------------------------------------------------- /src/secret.ts: -------------------------------------------------------------------------------- 1 | // TODO: add to secret manager 2 | export const USERS_HMAC_SECRET = process.env["HMAC_USERS_SECRET_KEY"] || ""; 3 | export const API_CLIENTS_HMAC_SECRET = process.env["HMAC_API_CLIENTS_SECRET_KEY"] || ""; 4 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyResultV2 } from "aws-lambda"; 2 | import getHeaders from "./headers"; 3 | 4 | export enum ErrorType { 5 | INVALID_REQUEST_BODY = "invalid_request_body", 6 | INVALID_PATH_PARAM = "invalid_path_param", 7 | INVALID_CREDENTIALS = "invalid_credentials", 8 | CONNECTION_GONE = "device_disconnected", 9 | NOT_FOUND = "not_found", 10 | } 11 | 12 | export class HttpError extends Error { 13 | constructor( 14 | public message: string, 15 | public statusCode: number, 16 | public type: ErrorType, 17 | public details: T | undefined = undefined, 18 | ) { 19 | super(message); 20 | } 21 | 22 | toAPIGatewayProxyResultV2(): APIGatewayProxyResultV2 { 23 | return { 24 | statusCode: this.statusCode, 25 | headers: getHeaders(), 26 | body: JSON.stringify({ 27 | status: "error", 28 | type: this.type, 29 | message: this.message, 30 | ...(this.details ? { details: this.details } : {}), 31 | }), 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/getExternalScheduleUpdateJSONMessage.ts: -------------------------------------------------------------------------------- 1 | import { TemperatureSlot } from "../repositories/ExternalControlSchedule"; 2 | 3 | const getExternalScheduleUpdateJSONMessage = (dayIndex: number, slots: TemperatureSlot[]) => { 4 | return JSON.stringify({ 5 | action: "msg", 6 | type: "extSchUpdate", 7 | day: dayIndex, 8 | slots: slots.map((slot) => { 9 | const timeElements = slot.time.split(":"); 10 | 11 | return { 12 | hh: Number(timeElements[0]), 13 | mm: Number(timeElements[1]), 14 | temp: slot.temperature, 15 | }; 16 | }), 17 | }); 18 | }; 19 | 20 | export default getExternalScheduleUpdateJSONMessage; 21 | -------------------------------------------------------------------------------- /src/utils/getHash.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | 3 | const getHash = (password: string, salt: string) => 4 | createHash("md5") 5 | .update(password + salt) 6 | .digest("hex"); 7 | 8 | export default getHash; 9 | -------------------------------------------------------------------------------- /src/utils/getQueryParam.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttarnowski/esp32-websockets-serverless-handler/846fa3fc18001286c48a65e3eaee0972ef3ef49d/src/utils/getQueryParam.ts -------------------------------------------------------------------------------- /src/utils/headers.ts: -------------------------------------------------------------------------------- 1 | const getHeaders = () => ({ 2 | "content-type": "application/json", 3 | Date: new Date().toLocaleString("en-US", { timeZone: "America/Los_Angeles" }), 4 | }); 5 | 6 | export default getHeaders; 7 | -------------------------------------------------------------------------------- /src/utils/parseBodyWithJoi.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from "http-status-codes"; 2 | import Joi, { ValidationError, ValidationErrorItem } from "joi"; 3 | import { ErrorType, HttpError } from "./error"; 4 | 5 | const parseBodyWithJoi = async (body: string | undefined, schema: Joi.AnySchema) => { 6 | try { 7 | const parsed = JSON.parse(body || ""); 8 | return await schema.validateAsync(parsed, { abortEarly: false }); 9 | } catch (e) { 10 | if (e instanceof SyntaxError) { 11 | throw new HttpError( 12 | "request body format must be in JSON format", 13 | StatusCodes.BAD_REQUEST, 14 | ErrorType.INVALID_REQUEST_BODY, 15 | ); 16 | } 17 | 18 | const err = e as ValidationError; 19 | if (!err.isJoi) { 20 | throw e; 21 | } 22 | 23 | throw new HttpError( 24 | err.message, 25 | StatusCodes.UNPROCESSABLE_ENTITY, 26 | ErrorType.INVALID_REQUEST_BODY, 27 | err.details, 28 | ); 29 | } 30 | }; 31 | 32 | export default parseBodyWithJoi; 33 | -------------------------------------------------------------------------------- /src/utils/responseOK.ts: -------------------------------------------------------------------------------- 1 | export const responseOK = { 2 | statusCode: 200, 3 | body: "", 4 | }; 5 | -------------------------------------------------------------------------------- /src/utils/sendWSMessage.ts: -------------------------------------------------------------------------------- 1 | import { ApiGatewayManagementApi, GoneException } from "@aws-sdk/client-apigatewaymanagementapi"; 2 | import { TextEncoder } from "util"; 3 | import { handleDisconnect } from "../websocketHandlers/handleDisconnect"; 4 | 5 | export const apiGatewayManagementApi = new ApiGatewayManagementApi({ 6 | endpoint: process.env["WSSAPIGATEWAYENDPOINT"], 7 | }); 8 | const textEncoder = new TextEncoder(); 9 | 10 | export const sendWSMessage = async (connectionId: string, body: string) => { 11 | try { 12 | await apiGatewayManagementApi.postToConnection({ 13 | ConnectionId: connectionId, 14 | Data: textEncoder.encode(body), 15 | }); 16 | } catch (e) { 17 | if (e instanceof GoneException) { 18 | await handleDisconnect(connectionId); 19 | return; 20 | } 21 | 22 | throw e; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/websocketHandlers/handleConnect.ts: -------------------------------------------------------------------------------- 1 | import { PutItemCommand } from "@aws-sdk/client-dynamodb"; 2 | import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyResult } from "aws-lambda"; 3 | import { verify, JwtPayload } from "jsonwebtoken"; 4 | import dynamodbClient, { clientsTable } from "../repositories/dynamodbClient"; 5 | import { USERS_HMAC_SECRET } from "../secret"; 6 | import { responseOK } from "../utils/responseOK"; 7 | 8 | enum ClientType { 9 | Device = "device", 10 | WebUI = "webui", 11 | } 12 | 13 | const getQueryParam = ( 14 | key: string, 15 | queryParams: APIGatewayProxyEventQueryStringParameters | null, 16 | ): string | undefined => { 17 | if (queryParams === null) { 18 | return undefined; 19 | } 20 | 21 | return queryParams[key]; 22 | }; 23 | 24 | const extractDeviceIdFromToken = (token: string): string => { 25 | const decodedToken = verify(token, USERS_HMAC_SECRET) as JwtPayload; 26 | return decodedToken["deviceId"]; 27 | }; 28 | 29 | export const handleConnect = async ( 30 | connectionId: string, 31 | queryParams: APIGatewayProxyEventQueryStringParameters | null, 32 | ): Promise => { 33 | const clientType = getQueryParam("clientType", queryParams) || ClientType.Device; 34 | const token = getQueryParam("token", queryParams) || ""; 35 | 36 | const deviceId = 37 | clientType === ClientType.Device ? getQueryParam("deviceId", queryParams) || "" : extractDeviceIdFromToken(token); 38 | 39 | // TODO: implement device id/secret auth 40 | 41 | await dynamodbClient.send( 42 | new PutItemCommand({ 43 | TableName: clientsTable, 44 | Item: { 45 | connectionId: { 46 | S: connectionId, 47 | }, 48 | clientType: { 49 | S: clientType, 50 | }, 51 | deviceId: { 52 | S: deviceId, 53 | }, 54 | }, 55 | }), 56 | ); 57 | 58 | return responseOK; 59 | }; 60 | -------------------------------------------------------------------------------- /src/websocketHandlers/handleDisconnect.ts: -------------------------------------------------------------------------------- 1 | import { DeleteItemCommand } from "@aws-sdk/client-dynamodb"; 2 | import { APIGatewayProxyResult } from "aws-lambda"; 3 | import dynamodbClient, { clientsTable } from "../repositories/dynamodbClient"; 4 | import { responseOK } from "../utils/responseOK"; 5 | 6 | export const handleDisconnect = async (connectionId: string): Promise => { 7 | await dynamodbClient.send( 8 | new DeleteItemCommand({ 9 | TableName: clientsTable, 10 | Key: { 11 | connectionId: { 12 | S: connectionId, 13 | }, 14 | }, 15 | }), 16 | ); 17 | 18 | return responseOK; 19 | }; 20 | -------------------------------------------------------------------------------- /src/websocketHandlers/handleFirmwareCheck.ts: -------------------------------------------------------------------------------- 1 | import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"; 2 | import { createFirmwareUpgradeMessage } from "../messages/FirmwareUpgradeMessage"; 3 | import { sendWSMessage } from "../utils/sendWSMessage"; 4 | 5 | const s3Client = new S3Client({}); 6 | const region = process.env["REGION"] || ""; 7 | const firmwareBucketName = process.env["FIRMWARE_BUCKET_NAME"] || ""; 8 | 9 | export const handleFirmwareCheck = async (connectionId: string, body: string) => { 10 | const parsed = JSON.parse(body) as { currentVersion: string }; 11 | if (typeof parsed.currentVersion !== "string") { 12 | throw new Error("invalid firmwareCheck message"); 13 | } 14 | 15 | const output = await s3Client.send( 16 | new ListObjectsV2Command({ 17 | Bucket: firmwareBucketName, 18 | }), 19 | ); 20 | 21 | const latestItem = (output.Contents || []).reduce( 22 | (latestItem, currentItem) => { 23 | const parts = currentItem.Key?.split("/", 2); 24 | if (parts && parts.length > 1 && parts[0] > latestItem.version) { 25 | return { 26 | version: parts[0], 27 | key: currentItem.Key, 28 | }; 29 | } 30 | return latestItem; 31 | }, 32 | { version: parsed.currentVersion, key: undefined } as { version: string; key?: string }, 33 | ); 34 | 35 | if (latestItem && latestItem.version > parsed.currentVersion) { 36 | await sendWSMessage( 37 | connectionId, 38 | createFirmwareUpgradeMessage( 39 | latestItem.version, 40 | `https://s3.${region}.amazonaws.com/${firmwareBucketName}/${latestItem.key}`, 41 | ), 42 | ); 43 | } 44 | 45 | return { 46 | statusCode: 200, 47 | body: "", 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/websocketHandlers/handleMsg.ts: -------------------------------------------------------------------------------- 1 | import { ScanCommand } from "@aws-sdk/client-dynamodb"; 2 | import { APIGatewayProxyResult } from "aws-lambda"; 3 | import { fetchByDeviceId, updateDevice } from "../repositories/Devices"; 4 | import dynamodbClient, { clientsTable } from "../repositories/dynamodbClient"; 5 | import { RepositoryError, RepositoryErrorType } from "../repositories/error"; 6 | import { Client, getClientByConnectionid } from "../repositories/WSClients"; 7 | import { responseOK } from "../utils/responseOK"; 8 | import { sendWSMessage } from "../utils/sendWSMessage"; 9 | 10 | const captureData = async (thisClient: Client, body: string) => { 11 | if (thisClient.clientType !== "device" || thisClient.deviceId === "-") { 12 | return; 13 | } 14 | 15 | try { 16 | const data = JSON.parse(body) as { action: string; type: string } | Record; 17 | if (data.action !== "msg") { 18 | return; 19 | } 20 | 21 | if (data.type === "sensorReadings") { 22 | const msg = data as { body: Record }; 23 | const device = await fetchByDeviceId(thisClient.deviceId); 24 | device.sensorReadings = msg.body; 25 | await updateDevice(device); 26 | } 27 | 28 | if (data.type === "modeChange") { 29 | const msg = data as { mode: number }; 30 | const device = await fetchByDeviceId(thisClient.deviceId); 31 | device.mode = msg.mode; 32 | await updateDevice(device); 33 | } 34 | } catch (e) { 35 | if (e instanceof SyntaxError) { 36 | console.error("could not capture device message, invalid body format", body); 37 | return; 38 | } 39 | 40 | if (e instanceof RepositoryError && e.type === RepositoryErrorType.NOT_FOUND) { 41 | console.warn("could not find device to store capture data", e); 42 | } 43 | 44 | console.error(e); 45 | } 46 | }; 47 | 48 | export const handleMsg = async (thisConnectionId: string, body: string): Promise => { 49 | // TODO: check if device exists otherwise send reset message 50 | 51 | const thisClient = await getClientByConnectionid(thisConnectionId); 52 | const thisConnectionClientType = thisClient.clientType; 53 | const thisConnectionDeviceId = thisClient.deviceId; 54 | 55 | await captureData(thisClient, body); 56 | 57 | const output = await dynamodbClient.send( 58 | new ScanCommand({ 59 | TableName: clientsTable, 60 | }), 61 | ); 62 | 63 | if (output.Count && output.Count > 0) { 64 | for (const item of output.Items || []) { 65 | if ( 66 | item["connectionId"].S !== thisConnectionId && 67 | item["clientType"].S !== thisConnectionClientType && 68 | item["deviceId"].S === thisConnectionDeviceId 69 | ) { 70 | await sendWSMessage(item["connectionId"].S as string, body); 71 | } 72 | } 73 | } else { 74 | await sendWSMessage(thisConnectionId, JSON.stringify({ action: "msg", type: "warning", body: "no recipient" })); 75 | } 76 | 77 | return responseOK; 78 | }; 79 | -------------------------------------------------------------------------------- /src/websocketHandlers/handleRequest.ts: -------------------------------------------------------------------------------- 1 | import { fetchSchedule } from "../repositories/ExternalControlSchedule"; 2 | import getExternalScheduleUpdateJSONMessage from "../utils/getExternalScheduleUpdateJSONMessage"; 3 | import { sendWSMessage } from "../utils/sendWSMessage"; 4 | 5 | export const handleRequest = async (connectionId: string, body: string) => { 6 | const parsed = JSON.parse(body) as { cmd: string }; 7 | 8 | if (parsed.cmd === "fetchExtSch") { 9 | const schedule = await fetchSchedule(); 10 | 11 | await Promise.all( 12 | schedule.map(async (slots, dayIndex) => { 13 | await sendWSMessage(connectionId, getExternalScheduleUpdateJSONMessage(dayIndex, slots)); 14 | }), 15 | ); 16 | } 17 | 18 | return { 19 | statusCode: 200, 20 | body: "", 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/websocketHandlers/handleSignUp.ts: -------------------------------------------------------------------------------- 1 | import { PutItemCommand } from "@aws-sdk/client-dynamodb"; 2 | import { v4 } from "uuid"; 3 | import dynamodbClient, { deviceKeysTable } from "../repositories/dynamodbClient"; 4 | import { responseOK } from "../utils/responseOK"; 5 | import { sendWSMessage } from "../utils/sendWSMessage"; 6 | 7 | export const handleSignUp = async (connectionId: string) => { 8 | const keyId = v4().slice(0, 6); 9 | 10 | await dynamodbClient.send( 11 | new PutItemCommand({ 12 | TableName: deviceKeysTable, 13 | Item: { 14 | KeyId: { 15 | S: keyId, 16 | }, 17 | connectionId: { 18 | S: connectionId, 19 | }, 20 | }, 21 | }), 22 | ); 23 | 24 | await sendWSMessage( 25 | connectionId, 26 | JSON.stringify({ 27 | type: "newSignUp", 28 | qr: `http://aquastat.online/sign-up?key=${keyId}`, 29 | }), 30 | ); 31 | 32 | return responseOK; 33 | }; 34 | -------------------------------------------------------------------------------- /src/ws-handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; 2 | import { handleFirmwareCheck } from "./websocketHandlers/handleFirmwareCheck"; 3 | import { handleConnect } from "./websocketHandlers/handleConnect"; 4 | import { handleDisconnect } from "./websocketHandlers/handleDisconnect"; 5 | import { handleSignUp } from "./websocketHandlers/handleSignUp"; 6 | import { handleRequest } from "./websocketHandlers/handleRequest"; 7 | import { handleMsg } from "./websocketHandlers/handleMsg"; 8 | 9 | export const handle = async (event: APIGatewayProxyEvent): Promise => { 10 | const connectionId = event.requestContext.connectionId as string; 11 | const routeKey = event.requestContext.routeKey as string; 12 | const body = event.body || ""; 13 | console.log(routeKey, body); 14 | 15 | switch (routeKey) { 16 | case "$connect": 17 | return handleConnect(connectionId, event.queryStringParameters); 18 | case "$disconnect": 19 | return handleDisconnect(connectionId); 20 | case "msg": 21 | return handleMsg(connectionId, body); 22 | case "signUp": 23 | return handleSignUp(connectionId); 24 | case "request": 25 | return handleRequest(connectionId, body); 26 | case "firmwareCheck": 27 | return handleFirmwareCheck(connectionId, body); 28 | } 29 | 30 | return { 31 | statusCode: 200, 32 | body: "", 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "preserveConstEnums": true, 4 | "strictNullChecks": true, 5 | "sourceMap": true, 6 | "allowJs": true, 7 | "target": "es2017", 8 | "outDir": ".build", 9 | "moduleResolution": "node", 10 | "lib": ["es2017"], 11 | "rootDir": "./", 12 | "strict": true, 13 | "module": "commonjs", 14 | "esModuleInterop": true 15 | }, 16 | "include": ["**/*"], 17 | "exclude": ["node_modules", "**/*.spec.ts"] 18 | } 19 | --------------------------------------------------------------------------------