├── .github ├── dependabot.yml └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── frontend ├── .gitignore ├── README.md ├── index.html ├── package.json ├── public │ └── favicon.ico ├── src │ ├── App.jsx │ ├── components │ │ ├── common │ │ │ ├── InfoBox.jsx │ │ │ ├── InfoBox.module.css │ │ │ ├── Panel.jsx │ │ │ ├── Panel.module.css │ │ │ └── subscriptions.js │ │ ├── geofences │ │ │ ├── DrawControl.jsx │ │ │ ├── DrawnGeofences.jsx │ │ │ ├── GeofencesControl.jsx │ │ │ ├── GeofencesPanel.jsx │ │ │ └── GeofencesPanel.module.css │ │ ├── routing │ │ │ ├── DistanceButton.jsx │ │ │ ├── DistanceControl.jsx │ │ │ └── UserPositionLabel.jsx │ │ └── tracking │ │ │ ├── Marker.jsx │ │ │ ├── Notifications.jsx │ │ │ ├── TrackerButton.jsx │ │ │ ├── TrackerControl.helpers.js │ │ │ └── TrackerControl.jsx │ ├── index.css │ └── main.jsx └── vite.config.js ├── infra ├── .gitignore ├── .npmignore ├── README.md ├── architecture.png ├── bin │ └── pettracker.ts ├── cdk.json ├── lib │ ├── appsync-construct.ts │ ├── auth-construct.ts │ ├── fns │ │ ├── appsync-send-geofence-event │ │ │ ├── .gitignore │ │ │ └── src │ │ │ │ └── index.ts │ │ ├── appsync-update-position │ │ │ ├── .gitignore │ │ │ └── src │ │ │ │ └── index.ts │ │ ├── certificate-handler │ │ │ └── src │ │ │ │ └── index.ts │ │ └── commons │ │ │ ├── powertools.ts │ │ │ └── utils.ts │ ├── functions-construct.ts │ ├── iot-construct.ts │ ├── pettracker-stack.ts │ └── schema.graphql ├── package.json └── tsconfig.json ├── package-lock.json ├── package.json ├── pet_simulator ├── .gitignore ├── package.json ├── src │ ├── index.ts │ └── utils.ts └── tsconfig.json └── scripts ├── convert-template.mjs ├── create-aws-exports.mjs ├── newFile.mjs ├── package.json └── resize.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | commit-message: 13 | prefix: chore 14 | include: scope 15 | 16 | - package-ecosystem: npm 17 | directory: / 18 | labels: [ ] 19 | schedule: 20 | interval: monthly 21 | versioning-strategy: increase 22 | groups: 23 | aws-sdk: 24 | patterns: 25 | - "@aws-sdk/**" 26 | - "@smithy/**" 27 | - "aws-sdk-client-mock" 28 | - "aws-sdk-client-mock-jest" 29 | aws-cdk: 30 | patterns: 31 | - "@aws-cdk/**" 32 | - "aws-cdk-lib" 33 | - "aws-cdk" 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main, workshop ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main, workshop ] 20 | schedule: 21 | - cron: '22 22 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v3 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | 3 | ### Node ### 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | 134 | ### Node Patch ### 135 | # Serverless Webpack directories 136 | .webpack/ 137 | 138 | # Optional stylelint cache 139 | 140 | # SvelteKit build / generate output 141 | .svelte-kit 142 | 143 | .DS_Store -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon Location Service PetTracker Demo 2 | 3 | The **PetTracker Demo** is a cloud native application built using an serverless architecture based on AWS services to show case [AWS IoT](https://aws.amazon.com/iot/) integrations for geospatial use cases in conjuction with the [Amazon Location Services](https://aws.amazon.com/location/) to help Solution Architects around the world to make use of it in their demos and workshops. 4 | 5 | ## Architecture Diagram 6 | 7 | ![PetTracker Architecture](infra/architecture.png "PetTracker Architecture") 8 | 9 | ## How to install 10 | 11 | If you want to build the application, follow this guided workshop: [https://catalog.workshops.aws/how-to-build-a-pet-tracker/en-US](https://catalog.workshops.aws/how-to-build-a-pet-tracker/en-US). 12 | 13 | ## Contributions 14 | 15 | To contribute with improvements and bug fixes, see [CONTRIBUTING](CONTRIBUTING.md). 16 | 17 | ## Security 18 | 19 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 20 | 21 | ## License 22 | 23 | This library is licensed under the MIT-0 License. See the [LICENSE](LICENSE) file. 24 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # AWS Configs 27 | src/aws-exports.js 28 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Setting up this demo 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | Create the react app 6 | 7 | `npx create-react-app mapdemo` 8 | 9 | Make sure that you have Amplify configured in your computer. If not follow the instructions in the documentation. 10 | 11 | [https://docs.amplify.aws/start/getting-started/installation/q/integration/react](https://docs.amplify.aws/start/getting-started/installation/q/integration/react) 12 | 13 | Install bootstrap in your project 14 | 15 | `npm install bootstrap` 16 | 17 | Initialize the react app 18 | 19 | `amplify init` 20 | 21 | Add authentication and push the changes to the cloud 22 | 23 | ``` 24 | amplify add auth 25 | amplify push 26 | ``` 27 | 28 | ### Add a Map 29 | 30 | Create a new map (name it PetTrackerMap) in the Amazon Location service. 31 | 32 | Give permission to access the map by selecting Identity Pool, check the name of the auth role and add this inline policy to the role. Replace the information with your account information. 33 | 34 | `amplify console auth` 35 | 36 | ``` 37 | { 38 | "Version": "2012-10-17", 39 | "Statement": [ 40 | { 41 | "Sid": "map", 42 | "Effect": "Allow", 43 | "Action": "geo:GetMap*", 44 | "Resource": "arn:aws:geo:::map/PetTrackerMap" 45 | } 46 | ] 47 | } 48 | ``` 49 | 50 | ### Add a Tracker 51 | 52 | Create a new tracker (name it PetTracker) in the Amazon Location Service. 53 | 54 | Modify the auth role for the Amplify application by adding this permission. 55 | 56 | ``` 57 | { 58 | "Sid": "tracker", 59 | "Effect": "Allow", 60 | "Action": "geo:GetDevicePositionHistory", 61 | "Resource": "arn:aws:geo:::tracker/PetTracker" 62 | } 63 | ``` 64 | 65 | ### Add a Geofence collection 66 | 67 | Create a new geofence collection (name it PetTrackerGeofenceCollection) in the Amazon Location Service. 68 | 69 | Modify the auth role for the Amplify application by adding this permission. 70 | 71 | ``` 72 | { 73 | "Sid": "geofences", 74 | "Effect": "Allow", 75 | "Action": [ 76 | "geo:ListGeofences", 77 | "geo:BatchPutGeofence" 78 | ], 79 | "Resource": "arn:aws:geo:::geofence-collection/PetTrackerGeofenceCollection" 80 | } 81 | ``` 82 | 83 | ### Add dependencies 84 | 85 | ``` 86 | npm install aws-sdk 87 | npm install @aws-amplify/core 88 | 89 | npm install mapbox-gl@1.0.0 90 | npm install react-map-gl@5.2.11 91 | npm install react-map-gl-draw@0.22.2 92 | ``` 93 | 94 | ## Copy the application modules 95 | 96 | ``` 97 | src/App.js 98 | src/components/Header.js 99 | src/components/Map.js 100 | src/components/Pin.js 101 | ``` 102 | 103 | ## Running the demo 104 | 105 | In the project directory, you can run: 106 | 107 | `npm start` 108 | 109 | Runs the app in the development mode.\ 110 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 111 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Pettracker App 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "2.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite --force", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@aws-amplify/geo": "^3.0.45", 13 | "@aws-amplify/ui-react": "^6.9.2", 14 | "@aws-amplify/ui-react-geo": "^2.0.17", 15 | "aws-amplify": "^6.2.0", 16 | "maplibre-gl-draw": "^1.6.9", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.3.1", 19 | "react-hot-toast": "^2.5.2" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.3.12", 23 | "@types/react-dom": "^18.3.0", 24 | "@vitejs/plugin-react": "^4.2.1", 25 | "eslint": "^8.44.0", 26 | "eslint-plugin-react": "^7.35.0", 27 | "eslint-plugin-react-hooks": "^4.6.0", 28 | "eslint-plugin-react-refresh": "^0.4.1", 29 | "vite": "^6.2.7" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-location-service-pettracker-demo/ae287cb530e6ccedbd831c55f5796f2b38ee985d/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from "react"; 5 | import { MapView } from "@aws-amplify/ui-react-geo"; 6 | import { NavigationControl } from "react-map-gl"; 7 | import { GeofencesControl } from "./components/geofences/GeofencesControl"; 8 | import { TrackerControl } from "./components/tracking/TrackerControl"; 9 | import { DistanceControl } from "./components/routing/DistanceControl"; 10 | 11 | const coordinates = { 12 | longitude: -115.17077150978058, 13 | latitude: 36.12309017212961, 14 | }; 15 | 16 | const App = () => { 17 | return ( 18 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /frontend/src/components/common/InfoBox.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { Card } from "@aws-amplify/ui-react"; 5 | import styles from "./InfoBox.module.css"; 6 | 7 | // Information box 8 | const InfoBox = ({ header, children }) => { 9 | return ( 10 |
11 | 12 |

{header}

13 |
{children}
14 |
15 |
16 | ); 17 | }; 18 | 19 | export default InfoBox; 20 | -------------------------------------------------------------------------------- /frontend/src/components/common/InfoBox.module.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | SPDX-License-Identifier: MIT-0 4 | */ 5 | 6 | .wrapper { 7 | position: absolute; 8 | bottom: 2rem; 9 | left: 2rem; 10 | width: 26rem; 11 | z-index: 3; 12 | user-select: text; 13 | } 14 | 15 | .header { 16 | margin: 0 0 0.5rem 0; 17 | } 18 | 19 | .content { 20 | font-size: 1rem; 21 | } -------------------------------------------------------------------------------- /frontend/src/components/common/Panel.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { Card } from "@aws-amplify/ui-react"; 5 | import styles from "./Panel.module.css"; 6 | 7 | // Popup panel 8 | const Panel = ({ header, footer, children }) => { 9 | return ( 10 |
11 | 12 |
{header}
13 |
{children}
14 |
{footer}
15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Panel; 21 | -------------------------------------------------------------------------------- /frontend/src/components/common/Panel.module.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | SPDX-License-Identifier: MIT-0 4 | */ 5 | 6 | .wrapper { 7 | position: absolute; 8 | top: 2rem; 9 | right: 2rem; 10 | width: 24rem; 11 | z-index: 3; 12 | user-select: text; 13 | } 14 | 15 | /* Increase specificity to override Amplify UI card style */ 16 | .wrapper > .card { 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: space-between; 20 | min-height: 28rem; 21 | } 22 | 23 | .header { 24 | margin-bottom: 1.5rem; 25 | } 26 | 27 | .footer { 28 | display: flex; 29 | justify-content: space-between; 30 | margin-top: 1.5rem; 31 | } 32 | 33 | .content { 34 | display: flex; 35 | flex-direction: column; 36 | flex-grow: 1; 37 | } -------------------------------------------------------------------------------- /frontend/src/components/common/subscriptions.js: -------------------------------------------------------------------------------- 1 | const onUpdatePosition = `subscription OnUpdatePosition { 2 | onUpdatePosition { 3 | deviceId 4 | lat 5 | lng 6 | sampleTime 7 | trackerName 8 | } 9 | }`; 10 | 11 | const onGeofenceEvent = `subscription OnGeofenceEvent { 12 | onGeofenceEvent { 13 | deviceId 14 | geofenceId 15 | lng 16 | lat 17 | sampleTime 18 | type 19 | } 20 | }`; 21 | 22 | export { onUpdatePosition, onGeofenceEvent }; 23 | -------------------------------------------------------------------------------- /frontend/src/components/geofences/DrawControl.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import MapboxDraw from "@mapbox/mapbox-gl-draw"; 5 | import "@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css"; 6 | import { useCallback } from "react"; 7 | import { useControl } from "react-map-gl"; 8 | 9 | // Instantiate mapbox-gl-draw 10 | const draw = new MapboxDraw({ 11 | displayControlsDefault: false, 12 | defaultMode: "draw_polygon", 13 | styles: [ 14 | { 15 | id: "gl-draw-line", 16 | type: "line", 17 | layout: { 18 | "line-cap": "round", 19 | "line-join": "round", 20 | }, 21 | paint: { 22 | "line-color": "#636363", 23 | "line-width": 2, 24 | }, 25 | }, 26 | { 27 | id: "gl-draw-point", 28 | type: "circle", 29 | paint: { 30 | "circle-radius": 6, 31 | "circle-color": "#7AC943", 32 | }, 33 | }, 34 | ], 35 | }); 36 | 37 | // Geofence drawing control using mapbox-gl-draw 38 | const DrawControl = ({ onCreate, onModeChange, onGeofenceCompletable }) => { 39 | const onRender = () => { 40 | //mapbox-gl-draw initiates control with two items 41 | if (draw.getAll().features[0].geometry.coordinates[0].length > 2) { 42 | onGeofenceCompletable(true); 43 | } 44 | }; 45 | 46 | // This is needed because the bundler is not making the passed 47 | // function available to the mapbox-gl-draw library. 48 | const drawCreate = useCallback((e) => onCreate(e), []); 49 | const drawModeChange = useCallback((e) => onModeChange(e), []); 50 | 51 | useControl( 52 | ({ map }) => { 53 | map.on("draw.create", drawCreate); 54 | map.on("draw.modechange", drawModeChange); 55 | map.on("draw.render", onRender); 56 | 57 | return draw; 58 | }, 59 | ({ map }) => { 60 | map.off("draw.create", drawCreate); 61 | map.off("draw.modechange", drawModeChange); 62 | map.off("draw.render", onRender); 63 | } 64 | ); 65 | 66 | return null; 67 | }; 68 | 69 | export default DrawControl; 70 | -------------------------------------------------------------------------------- /frontend/src/components/geofences/DrawnGeofences.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useMemo } from "react"; 5 | import { Source, Layer } from "react-map-gl"; 6 | 7 | // Format geofence data into GeoJSON 8 | const getGeometryJson = (geofences) => { 9 | const features = geofences.map((geofence) => ({ 10 | type: "Feature", 11 | properties: {}, 12 | geometry: { 13 | type: "Polygon", 14 | coordinates: geofence.geometry?.polygon, 15 | }, 16 | })); 17 | 18 | return { 19 | type: "FeatureCollection", 20 | features, 21 | }; 22 | }; 23 | 24 | // Properties for the polygons 25 | const polygons = { 26 | id: "polygons", 27 | type: "fill", 28 | source: "drawn-geofences", 29 | paint: { 30 | "fill-color": "#56585e", 31 | "fill-opacity": 0.2, 32 | }, 33 | }; 34 | 35 | // Drawn geofences on the map 36 | const DrawnGeofences = ({ geofences, geofencesVisible }) => { 37 | const geofenceJson = useMemo(() => getGeometryJson(geofences), [geofences]); 38 | 39 | if (geofencesVisible) { 40 | return ( 41 | 42 | 43 | 44 | ); 45 | } else { 46 | return; 47 | } 48 | }; 49 | 50 | export default DrawnGeofences; 51 | -------------------------------------------------------------------------------- /frontend/src/components/geofences/GeofencesControl.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useEffect, useState, useCallback } from "react"; 5 | import { Geo } from "@aws-amplify/geo"; 6 | import { Button } from "@aws-amplify/ui-react"; 7 | import GeofencesPanel from "./GeofencesPanel"; 8 | import DrawControl from "./DrawControl"; 9 | import InfoBox from "../common/InfoBox"; 10 | import DrawnGeofences from "./DrawnGeofences"; 11 | 12 | // Order polygon vertices in counter-clockwise order since that is the order PutGeofence accepts 13 | const convertCounterClockwise = (vertices) => { 14 | // Determine if polygon is wound counter-clockwise using shoelace formula 15 | let area = 0; 16 | for (let i = 0; i < vertices.length; i++) { 17 | let j = (i + 1) % vertices.length; 18 | area += vertices[i][0] * vertices[j][1]; 19 | area -= vertices[j][0] * vertices[i][1]; 20 | } 21 | 22 | if (area / 2 > 0) { 23 | return vertices; 24 | } else { 25 | // Reverse vertices to counter-clockwise order 26 | return vertices.reverse(); 27 | } 28 | }; 29 | 30 | // Layer in the app that contains Geofences functionalities 31 | export const GeofencesControl = ({}) => { 32 | const [geofences, setGeofences] = useState([]); 33 | const [isLoading, setIsLoading] = useState(false); 34 | const [geofencesVisible, setGeofencesVisible] = useState(true); 35 | const [isAddingGeofence, setIsAddingGeofence] = useState(false); 36 | const [totalGeofences, setTotalGeofences] = useState(); 37 | const [isGeofenceCompletable, setIsGeofenceCompletable] = useState(false); 38 | const [isOpenedPanel, setIsOpenedPanel] = useState(false); 39 | const [isDrawing, setIsDrawing] = useState(false); 40 | 41 | const fetchGeofences = async () => { 42 | setIsLoading(true); 43 | const fetchedGeofences = await Geo.listGeofences(); 44 | setIsLoading(false); 45 | setTotalGeofences(fetchedGeofences.entries.length); 46 | // Limit to only display 10 geofences 47 | setGeofences(fetchedGeofences.entries.reverse().slice(0, 10)); 48 | }; 49 | 50 | useEffect(() => { 51 | if (geofences.length === 0) { 52 | fetchGeofences(); 53 | } 54 | }, []); 55 | 56 | useEffect(() => { 57 | if (isOpenedPanel) { 58 | fetchGeofences(); 59 | } else { 60 | // Exit out of geofence drawing mode when panel is closed 61 | onDrawingChange(false); 62 | setIsGeofenceCompletable(false); 63 | } 64 | }, [isOpenedPanel]); 65 | 66 | // Delete geofences and refreshing geofences being displayed 67 | const handleDeleteGeofences = async (geofences) => { 68 | if (geofences.length > 0) { 69 | await Geo.deleteGeofences(geofences); 70 | fetchGeofences(); 71 | } 72 | }; 73 | 74 | const onDrawingChange = (status) => 75 | status ? setIsDrawing(true) : setIsDrawing(false); 76 | 77 | //Making call to PutGeofence after user complete drawing a polygon using mapbox-gl-draw 78 | const handleCreate = useCallback(async (e) => { 79 | console.log(e); 80 | if (e.features) { 81 | setIsAddingGeofence(true); 82 | try { 83 | const putGeofence = await Geo.saveGeofences([ 84 | { 85 | geofenceId: "Geofence-" + new Date().valueOf(), 86 | geometry: { 87 | polygon: [ 88 | convertCounterClockwise(e.features[0].geometry.coordinates[0]), 89 | ], 90 | }, 91 | }, 92 | ]); 93 | console.log(putGeofence); 94 | if (putGeofence.successes) { 95 | fetchGeofences(); 96 | onDrawingChange(true); 97 | } 98 | } catch (err) { 99 | console.error(err); 100 | alert("There was an error adding the geofence."); 101 | } finally { 102 | setIsAddingGeofence(false); 103 | } 104 | } 105 | }, []); 106 | 107 | // Exit out of drawing geofence mode when a geofence has been created or when escape key has been pressed. 108 | const handleModeChange = useCallback((e) => { 109 | // simple_select mode is entered upon completing a drawn polygon or when the escape key is pressed. 110 | if (e.mode === "simple_select") { 111 | onDrawingChange(false); 112 | setIsGeofenceCompletable(false); 113 | } 114 | }, []); 115 | 116 | // Helps track if the user has began drawing a geofence by plotting a single point 117 | const handleGeofenceCompletable = (completable) => { 118 | setIsGeofenceCompletable(completable); 119 | }; 120 | 121 | return ( 122 | <> 123 |
130 | 151 |
152 | {isOpenedPanel && ( 153 | setIsOpenedPanel(false)} 155 | geofences={geofences} 156 | onDeleteGeofences={handleDeleteGeofences} 157 | onAddGeofence={() => onDrawingChange(true)} 158 | isLoading={isLoading} 159 | geofencesVisible={geofencesVisible} 160 | onToggleGeofences={() => setGeofencesVisible((prev) => !prev)} 161 | totalGeofences={totalGeofences} 162 | /> 163 | )} 164 | {isDrawing && ( 165 | 170 | )} 171 | {(isDrawing || isAddingGeofence) && ( 172 | 173 | {isAddingGeofence ? ( 174 |

Adding geofence...

175 | ) : isGeofenceCompletable ? ( 176 | <> 177 |

Add points to your geofence.

178 |

179 | Click on the initial point to complete the geofence. A geofence 180 | must have at least 3 points. 181 |

182 |

To cancel, click on the Exit button.

183 | 184 | ) : ( 185 |

Click on the map to start drawing a geofence.

186 | )} 187 | 194 |
195 | )} 196 | {geofences?.length > 0 && ( 197 | 201 | )} 202 | 203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /frontend/src/components/geofences/GeofencesPanel.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState } from "react"; 5 | import { 6 | Heading, 7 | Button, 8 | Loader, 9 | Table, 10 | TableCell, 11 | TableBody, 12 | TableRow, 13 | CheckboxField, 14 | } from "@aws-amplify/ui-react"; 15 | import Panel from "../common/Panel"; 16 | import styles from "./GeofencesPanel.module.css"; 17 | 18 | // Popup panel for Geofences 19 | const GeofencesPanel = ({ 20 | onClose, 21 | geofences, 22 | onDeleteGeofences, 23 | onAddGeofence, 24 | isLoading, 25 | onToggleGeofences, 26 | geofencesVisible, 27 | totalGeofences, 28 | }) => { 29 | const [selectedItems, setSelectedItems] = useState([]); 30 | 31 | // Help keep track of which checkboxes are selected 32 | const handleCheckboxChange = (e) => { 33 | if (selectedItems.includes(e.target.value)) { 34 | setSelectedItems(selectedItems.filter((item) => item !== e.target.value)); 35 | } else { 36 | setSelectedItems((prevSelectedItems) => [ 37 | ...prevSelectedItems, 38 | e.target.value, 39 | ]); 40 | } 41 | }; 42 | 43 | const handleDeleteGeofences = () => { 44 | onDeleteGeofences(selectedItems); 45 | setSelectedItems([]); 46 | }; 47 | 48 | return ( 49 | 52 | Geofences 53 |
54 | 61 | 64 |
65 | 66 | } 67 | footer={ 68 | <> 69 | 72 | 75 | 76 | } 77 | > 78 | {isLoading ? ( 79 | 80 | ) : geofences?.length > 0 ? ( 81 | <> 82 | 83 | 84 | {geofences?.map((geofence) => { 85 | return ( 86 | 87 | 88 | 93 | 94 | {geofence.geofenceId} 95 | 96 | ); 97 | })} 98 | 99 |
100 | {totalGeofences > 10 && ( 101 | 102 | Displaying {geofences?.length} of {totalGeofences} geofences 103 | 104 | )} 105 | 106 | ) : ( 107 | geofences?.length === 0 && ( 108 |
109 |
There are no geofences.
110 |
111 | Use the Add button to create a geofence. After you draw one or 112 | more geofences, they will show up here. 113 |
114 |
115 | ) 116 | )} 117 |
118 | ); 119 | }; 120 | 121 | export default GeofencesPanel; 122 | -------------------------------------------------------------------------------- /frontend/src/components/geofences/GeofencesPanel.module.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | SPDX-License-Identifier: MIT-0 4 | */ 5 | 6 | .header { 7 | display: flex; 8 | justify-content: space-between; 9 | } 10 | 11 | .collection { 12 | margin-bottom: 0.5rem; 13 | font-size: 0.9rem; 14 | font-weight: 500; 15 | } 16 | 17 | .empty { 18 | margin-top: auto; 19 | margin-bottom: auto; 20 | text-align: center; 21 | color: #6d6d6d; 22 | } 23 | 24 | .empty__header { 25 | font-size: 1rem; 26 | font-weight: 500; 27 | } 28 | 29 | .empty__body { 30 | margin-top: 0.5rem; 31 | font-size: 0.8rem; 32 | } 33 | 34 | .max { 35 | margin-top: 0.25rem; 36 | text-align: center; 37 | } -------------------------------------------------------------------------------- /frontend/src/components/routing/DistanceButton.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from "react"; 5 | import { Button } from "@aws-amplify/ui-react"; 6 | 7 | export const DistanceButton = ({ distance }) => { 8 | return ( 9 |
16 | 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/components/routing/DistanceControl.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from "react"; 2 | import { Geo } from "@aws-amplify/geo"; 3 | import { fetchAuthSession } from "@aws-amplify/auth"; 4 | import { Hub, Cache } from "aws-amplify/utils"; 5 | import { 6 | CalculateRouteCommand, 7 | LocationClient, 8 | } from "@aws-sdk/client-location"; 9 | import { GeolocateControl } from "react-map-gl"; 10 | import awsmobile from "../../aws-exports"; 11 | import { DistanceButton } from "./DistanceButton"; 12 | import { UserPositionLabel } from "./UserPositionLabel"; 13 | 14 | const checkCredentials = async (cachedCredentials) => { 15 | if (!cachedCredentials || cachedCredentials.expiration === undefined) { 16 | const credentials = await fetchAuthSession(); 17 | Cache.setItem("temporary_credentials", credentials.credentials); 18 | return credentials.credentials; 19 | } 20 | // If credentials are expired or about to expire, refresh them 21 | if ( 22 | (new Date(cachedCredentials.expiration).getTime() - Date.now()) / 1000 < 23 | 60 24 | ) { 25 | const credentials = await fetchAuthSession(); 26 | Cache.setItem("temporary_credentials", credentials); 27 | return credentials; 28 | } 29 | 30 | return cachedCredentials; 31 | }; 32 | 33 | const refreshOrInitLocationClient = async (client) => { 34 | const cachedCredentials = Cache.getItem("temporary_credentials"); 35 | const credentials = await checkCredentials(cachedCredentials); 36 | if (!client || credentials.accessKeyId !== cachedCredentials.accessKeyId) { 37 | client = new LocationClient({ 38 | credentials, 39 | region: Geo.getDefaultMap().region, 40 | }); 41 | 42 | return client; 43 | } 44 | 45 | return client; 46 | }; 47 | 48 | export const DistanceControl = () => { 49 | const locationClientRef = useRef(); 50 | const hubRef = useRef(); 51 | const [userLocation, setUserLocation] = useState(); 52 | const [petLocation, setPetLocation] = useState(); 53 | 54 | const onPetUpdate = useCallback( 55 | async (update) => { 56 | const { 57 | payload: { data }, 58 | } = update; 59 | if (!userLocation) return; 60 | locationClientRef.current = await refreshOrInitLocationClient( 61 | locationClientRef.current 62 | ); 63 | try { 64 | const res = await locationClientRef.current.send( 65 | new CalculateRouteCommand({ 66 | CalculatorName: 67 | awsmobile.geo.amazon_location_service.routeCalculator, 68 | TravelMode: "Walking", 69 | DeparturePosition: [userLocation.lng, userLocation.lat], 70 | DestinationPosition: [data.lng, data.lat], 71 | }) 72 | ); 73 | setPetLocation({ 74 | lng: data.lng, 75 | lat: data.lat, 76 | distance: res.Summary?.Distance, 77 | }); 78 | } catch (err) { 79 | console.error(err); 80 | } 81 | }, 82 | [userLocation] 83 | ); 84 | 85 | useEffect(() => { 86 | hubRef.current = Hub.listen("petUpdates", onPetUpdate); 87 | 88 | // Clean up the hub listener when the component unmounts 89 | return () => hubRef.current(); 90 | }, [userLocation]); 91 | 92 | return ( 93 | <> 94 | { 101 | setUserLocation({ 102 | lng: e.coords.longitude, 103 | lat: e.coords.latitude, 104 | }); 105 | }} 106 | onTrackUserLocationStart={(e) => { 107 | console.log("onTrackStart", e); 108 | }} 109 | /> 110 | {userLocation ? : null} 111 | {petLocation ? : null} 112 | 113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /frontend/src/components/routing/UserPositionLabel.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from "react"; 5 | import { Text } from "@aws-amplify/ui-react"; 6 | 7 | export const UserPositionLabel = ({ position }) => { 8 | return ( 9 |
16 | 21 | Position:{" "} 22 | {position ? `lng: ${position.lng} lat: ${position.lat}` : "N/A"} 23 | 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/tracking/Marker.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, useEffect, useCallback, useRef } from "react"; 5 | import { Marker as MapMarker } from "react-map-gl"; 6 | import { Hub } from "@aws-amplify/core"; 7 | 8 | export const Marker = () => { 9 | const [marker, setMarker] = useState(); 10 | const hubRef = useRef(); 11 | 12 | const onPetUpdate = useCallback(async (update) => { 13 | const { 14 | payload: { data, event }, 15 | } = update; 16 | if (event === "positionUpdate") { 17 | setMarker(data); 18 | } 19 | }, []); 20 | 21 | useEffect(() => { 22 | hubRef.current = Hub.listen("petUpdates", onPetUpdate); 23 | 24 | // Clean up the hub listener when the component unmounts 25 | return () => hubRef.current(); 26 | }, []); 27 | 28 | return ( 29 | <> 30 | {marker ? ( 31 | 32 | ) : null} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /frontend/src/components/tracking/Notifications.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useEffect, useCallback, useRef } from "react"; 5 | import { Card, Text } from "@aws-amplify/ui-react"; 6 | import toast, { Toaster } from "react-hot-toast"; 7 | import { Hub } from "@aws-amplify/core"; 8 | 9 | export const Notifications = () => { 10 | const hubRef = useRef(); 11 | 12 | const onPetUpdate = useCallback(async (update) => { 13 | const { 14 | payload: { data, event }, 15 | } = update; 16 | if (event === "geofenceUpdate") { 17 | const icon = data.type === "EXIT" ? "⬅️" : "➡️"; 18 | const verb = data.type === "EXIT" ? "left" : "entered"; 19 | const message = `Your pet ${verb} geofence ${data.geofenceId}`; 20 | toast.custom( 21 | 22 | 23 | {icon} {message} 24 | 25 | , 26 | { 27 | icon, 28 | duration: 3500, 29 | } 30 | ); 31 | } 32 | }, []); 33 | 34 | useEffect(() => { 35 | hubRef.current = Hub.listen("petUpdates", onPetUpdate); 36 | 37 | // Clean up the hub listener when the component unmounts 38 | return () => hubRef.current(); 39 | }, []); 40 | 41 | return ; 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/src/components/tracking/TrackerButton.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from "react"; 5 | import { Button, Loader } from "@aws-amplify/ui-react"; 6 | 7 | export const TrackerButton = ({ onClick, isSubscribed }) => { 8 | return ( 9 |
16 | 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/components/tracking/TrackerControl.helpers.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { Hub } from "aws-amplify/utils"; 5 | import { generateClient } from "@aws-amplify/api"; 6 | import { onGeofenceEvent, onUpdatePosition } from "../common/subscriptions"; 7 | 8 | const client = generateClient(); 9 | 10 | /** 11 | * Handler for position updates coming from the AppSync subscription 12 | */ 13 | const handlePositionUpdate = ({ data }) => { 14 | const { onUpdatePosition } = data; 15 | console.debug("Position updated", onUpdatePosition); 16 | const { lng, lat } = onUpdatePosition; 17 | Hub.dispatch("petUpdates", { event: "positionUpdate", data: { lng, lat } }); 18 | }; 19 | 20 | /** 21 | * Handler for geofence updates coming from the AppSync subscription 22 | */ 23 | const handleGeofenceEvent = ({ data }) => { 24 | const { onGeofenceEvent } = data; 25 | console.debug("Geofence update received", onGeofenceEvent); 26 | Hub.dispatch("petUpdates", { 27 | event: "geofenceUpdate", 28 | data: onGeofenceEvent, 29 | }); 30 | }; 31 | 32 | /** 33 | * Helper function to unsubscribe from the AppSync subscriptions 34 | */ 35 | const unsubscribe = (subscriptionsRef) => { 36 | // Unsubscribe to the onUpdatePosition mutation 37 | subscriptionsRef.current?.positionUpdates?.unsubscribe(); 38 | console.info("Unsubscribed from onUpdatePosition AppSync mutation"); 39 | // Unsubscribe to the onGeofenceEvent mutation 40 | subscriptionsRef.current?.geofencesUpdates?.unsubscribe(); 41 | console.info("Unsubscribed from onGeofenceEvent AppSync mutation"); 42 | }; 43 | 44 | /** 45 | * Helper function to susbscribe from the AppSync subscriptions 46 | */ 47 | const subscribe = (subscriptionsRef) => { 48 | // Subscribe to the onUpdatePosition mutation 49 | subscriptionsRef.current.positionUpdates = client 50 | .graphql({ query: onUpdatePosition }) 51 | .subscribe({ 52 | next: handlePositionUpdate, 53 | error: (err) => console.error(err), 54 | }); 55 | console.info("Subscribed to onUpdatePosition AppSync mutation"); 56 | 57 | // Subscribe to the onGeofenceEvent mutation 58 | subscriptionsRef.current.geofencesUpdates = client 59 | .graphql({ query: onGeofenceEvent }) 60 | .subscribe({ 61 | next: handleGeofenceEvent, 62 | error: (err) => console.error(err), 63 | }); 64 | console.info("Subscribed to onGeofenceEvent AppSync mutation"); 65 | }; 66 | 67 | export { subscribe, unsubscribe }; 68 | -------------------------------------------------------------------------------- /frontend/src/components/tracking/TrackerControl.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, useRef } from "react"; 5 | import { Marker } from "./Marker"; 6 | import { TrackerButton } from "./TrackerButton"; 7 | import { Notifications } from "./Notifications"; 8 | import { subscribe, unsubscribe } from "./TrackerControl.helpers"; 9 | 10 | export const TrackerControl = () => { 11 | const [isSubscribed, setIsSubscribed] = useState(false); 12 | const subscriptionsRef = useRef({}); 13 | 14 | const handleSubscriptionToggle = () => { 15 | if (isSubscribed) { 16 | // Unsubscribe from all subscriptions 17 | unsubscribe(subscriptionsRef); 18 | // Restore the subscriptionsRef to an empty object & set isSubscribed to false 19 | subscriptionsRef.current = {}; 20 | setIsSubscribed(false); 21 | } else { 22 | subscribe(subscriptionsRef); 23 | // Set the isSubscribed state to true 24 | setIsSubscribed(true); 25 | } 26 | }; 27 | 28 | return ( 29 | <> 30 | 31 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | SPDX-License-Identifier: MIT-0 4 | */ 5 | 6 | @import url('https://fonts.googleapis.com/css2?family=Inter:slnt,wght@-10..0,100..900&display=swap'); 7 | @import "@aws-amplify/ui-react/styles.layer.css"; 8 | @import "@aws-amplify/ui-react-geo/styles.css"; 9 | 10 | body, html, body::after, body::before { 11 | margin: 0; 12 | padding: 0; 13 | } -------------------------------------------------------------------------------- /frontend/src/main.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from "react"; 5 | import ReactDOM from "react-dom/client"; 6 | import App from "./App"; 7 | import "./index.css"; 8 | import { Amplify } from "aws-amplify"; 9 | import awsconfig from "./aws-exports"; 10 | 11 | Amplify.configure(awsconfig); 12 | 13 | ReactDOM.createRoot(document.getElementById("root")).render( 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | host: true, 9 | port: 8080, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /infra/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /infra/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /infra/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `cdk deploy` deploy this stack to your default AWS account/region 13 | * `cdk diff` compare deployed stack with current state 14 | * `cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /infra/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-location-service-pettracker-demo/ae287cb530e6ccedbd831c55f5796f2b38ee985d/infra/architecture.png -------------------------------------------------------------------------------- /infra/bin/pettracker.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "source-map-support/register"; 3 | import { App, Aspects } from "aws-cdk-lib"; 4 | import { PetTracker } from "../lib/pettracker-stack.js"; 5 | import { AwsSolutionsChecks } from "cdk-nag"; 6 | 7 | const app = new App(); 8 | Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); 9 | new PetTracker(app, "PetTracker", {}); 10 | -------------------------------------------------------------------------------- /infra/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx tsx bin/pettracker.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 25 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/core:checkSecretUsage": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 31 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 32 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 33 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 34 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 35 | "@aws-cdk/core:target-partitions": [ 36 | "aws", 37 | "aws-cn" 38 | ] 39 | } 40 | } -------------------------------------------------------------------------------- /infra/lib/appsync-construct.ts: -------------------------------------------------------------------------------- 1 | import { type StackProps, Expiration, CfnOutput, Duration } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | import { 4 | GraphqlApi, 5 | AuthorizationType, 6 | MappingTemplate, 7 | Definition, 8 | } from "aws-cdk-lib/aws-appsync"; 9 | import { NagSuppressions } from "cdk-nag"; 10 | 11 | interface AppSyncConstructProps extends StackProps {} 12 | 13 | export class AppSyncConstruct extends Construct { 14 | api: GraphqlApi; 15 | 16 | constructor(scope: Construct, id: string, props: AppSyncConstructProps) { 17 | super(scope, id); 18 | 19 | this.api = new GraphqlApi(this, "Api", { 20 | name: "PetTracker", 21 | definition: Definition.fromFile("./lib/schema.graphql"), 22 | authorizationConfig: { 23 | defaultAuthorization: { 24 | authorizationType: AuthorizationType.API_KEY, 25 | apiKeyConfig: { 26 | expires: Expiration.after(Duration.days(365)), 27 | }, 28 | }, 29 | additionalAuthorizationModes: [ 30 | { 31 | authorizationType: AuthorizationType.IAM, 32 | }, 33 | ], 34 | }, 35 | }); 36 | 37 | NagSuppressions.addResourceSuppressions(this.api, [ 38 | { 39 | id: "AwsSolutions-ASC3", 40 | reason: 41 | "This API is deployed as part of an AWS workshop and as such it's short-lived. Analyzing the logs is not part of the workshop.", 42 | }, 43 | ]); 44 | 45 | const noneSource = this.api.addNoneDataSource("NoneSource"); 46 | 47 | noneSource.createResolver("update-position-resolver", { 48 | typeName: "Mutation", 49 | fieldName: "updatePosition", 50 | requestMappingTemplate: MappingTemplate.fromString(`{ 51 | "version": "2018-05-29", 52 | "payload": $util.toJson($context.arguments) 53 | }`), 54 | responseMappingTemplate: MappingTemplate.fromString( 55 | `$util.toJson($context.result.input)` 56 | ), 57 | }); 58 | 59 | noneSource.createResolver("send-geofence-event-resolver", { 60 | typeName: "Mutation", 61 | fieldName: "sendGeofenceEvent", 62 | requestMappingTemplate: MappingTemplate.fromString(`{ 63 | "version": "2018-05-29", 64 | "payload": $util.toJson($context.arguments) 65 | }`), 66 | responseMappingTemplate: MappingTemplate.fromString( 67 | `$util.toJson($context.result.input)` 68 | ), 69 | }); 70 | 71 | new CfnOutput(this, "ApiUrl", { 72 | value: this.api.graphqlUrl, 73 | }); 74 | 75 | new CfnOutput(this, "ApiId", { 76 | value: this.api.apiId, 77 | }); 78 | 79 | new CfnOutput(this, "ApiKey", { 80 | value: this.api.apiKey as string, 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /infra/lib/auth-construct.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps, CfnOutput } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | import { IRole, Policy, PolicyStatement } from "aws-cdk-lib/aws-iam"; 4 | import { IdentityPool } from "@aws-cdk/aws-cognito-identitypool-alpha"; 5 | import { NagSuppressions } from "cdk-nag"; 6 | 7 | interface AuthConstructProps extends StackProps {} 8 | 9 | export class AuthConstruct extends Construct { 10 | unauthRole: IRole; 11 | 12 | constructor(scope: Construct, id: string, _props: AuthConstructProps) { 13 | super(scope, id); 14 | 15 | const identityPool = new IdentityPool(this, "IdentityPool", { 16 | allowUnauthenticatedIdentities: true, 17 | }); 18 | const { unauthenticatedRole, identityPoolId } = identityPool; 19 | 20 | NagSuppressions.addResourceSuppressions(identityPool, [ 21 | { 22 | id: "AwsSolutions-COG7", 23 | reason: 24 | "Application uses unauthenticated access (i.e. guest) only, so this setting is needed.", 25 | }, 26 | ]); 27 | 28 | this.unauthRole = unauthenticatedRole; 29 | this.unauthRole.attachInlinePolicy( 30 | new Policy(this, "locationService", { 31 | statements: [ 32 | new PolicyStatement({ 33 | actions: [ 34 | "geo:GetMapGlyphs", 35 | "geo:GetMapSprites", 36 | "geo:GetMapStyleDescriptor", 37 | "geo:GetMapTile", 38 | ], 39 | resources: [ 40 | `arn:aws:geo:${Stack.of(this).region}:${ 41 | Stack.of(this).account 42 | }:map/PetTrackerMap`, 43 | ], 44 | }), 45 | new PolicyStatement({ 46 | actions: [ 47 | "geo:ListGeofences", 48 | "geo:BatchPutGeofence", 49 | "geo:BatchDeleteGeofence", 50 | ], 51 | resources: [ 52 | `arn:aws:geo:${Stack.of(this).region}:${ 53 | Stack.of(this).account 54 | }:geofence-collection/PetTrackerGeofenceCollection`, 55 | ], 56 | }), 57 | new PolicyStatement({ 58 | actions: ["geo:CalculateRoute"], 59 | resources: [ 60 | `arn:aws:geo:${Stack.of(this).region}:${ 61 | Stack.of(this).account 62 | }:route-calculator/PetTrackerRouteCalculator`, 63 | ], 64 | }), 65 | ], 66 | }) 67 | ); 68 | 69 | new CfnOutput(this, "IdentityPoolId", { 70 | value: identityPoolId, 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /infra/lib/fns/appsync-send-geofence-event/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | 146 | -------------------------------------------------------------------------------- /infra/lib/fns/appsync-send-geofence-event/src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import type { EventBridgeEvent } from "aws-lambda"; 5 | import { executeMutation } from "#utils"; 6 | import { logger } from "#powertools"; 7 | 8 | /** 9 | * Details of the event forwarded by EventBridge from the Geofence 10 | */ 11 | type LocationEvent = { 12 | EventType: "ENTER" | "EXIT"; 13 | GeofenceId: string; 14 | DeviceId: string; 15 | SampleTime: string; 16 | Position: [number, number]; 17 | }; 18 | 19 | type Event = EventBridgeEvent<"Location Geofence Event", LocationEvent>; 20 | 21 | export const handler = async (event: Event) => { 22 | logger.debug("Received event", { event }); 23 | 24 | const sendGeofenceEvent = { 25 | query: `mutation SendGeofenceEvent($input: GeofenceEventInput) { 26 | sendGeofenceEvent(input: $input) { 27 | deviceId 28 | lng 29 | lat 30 | sampleTime 31 | geofenceId 32 | type 33 | } 34 | }`, 35 | operationName: "SendGeofenceEvent", 36 | variables: { 37 | input: { 38 | deviceId: event.detail.DeviceId, 39 | lng: event.detail.Position[0], 40 | lat: event.detail.Position[1], 41 | sampleTime: new Date(event.detail.SampleTime).toISOString(), 42 | geofenceId: event.detail.GeofenceId, 43 | type: event.detail.EventType, 44 | }, 45 | }, 46 | }; 47 | 48 | await executeMutation(sendGeofenceEvent); 49 | }; 50 | -------------------------------------------------------------------------------- /infra/lib/fns/appsync-update-position/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | 146 | -------------------------------------------------------------------------------- /infra/lib/fns/appsync-update-position/src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import type { EventBridgeEvent } from "aws-lambda"; 5 | import { executeMutation } from "#utils"; 6 | import { logger } from "#powertools"; 7 | 8 | /** 9 | * Details of the event forwarded by EventBridge from the Tracker device 10 | */ 11 | type LocationEvent = { 12 | EventType: "UPDATE"; 13 | TrackerName: string; 14 | DeviceId: string; 15 | SampleTime: string; 16 | ReceivedTime: string; 17 | Position: [number, number]; 18 | Accuracy?: { 19 | Horizontal: number; 20 | }; 21 | PositionProperties?: { 22 | [key: string]: string; 23 | }; 24 | }; 25 | 26 | type Event = EventBridgeEvent<"Location Device Position Event", LocationEvent>; 27 | 28 | export const handler = async (event: Event) => { 29 | logger.debug("Received event", { event }); 30 | 31 | const updatePosition = { 32 | query: `mutation UpdatePosition($input: PositionEventInput) { 33 | updatePosition(input: $input) { 34 | deviceId 35 | lng 36 | lat 37 | sampleTime 38 | receivedTime 39 | trackerName 40 | type 41 | } 42 | }`, 43 | operationName: "UpdatePosition", 44 | variables: { 45 | input: { 46 | deviceId: event.detail.DeviceId, 47 | lng: event.detail.Position[0], 48 | lat: event.detail.Position[1], 49 | sampleTime: new Date(event.detail.SampleTime).toISOString(), 50 | receivedTime: new Date(event.detail.ReceivedTime).toISOString(), 51 | trackerName: event.detail.TrackerName, 52 | type: event.detail.EventType, 53 | }, 54 | }, 55 | }; 56 | 57 | await executeMutation(updatePosition); 58 | }; 59 | -------------------------------------------------------------------------------- /infra/lib/fns/certificate-handler/src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import type { 5 | CloudFormationCustomResourceEvent, 6 | CloudFormationCustomResourceCreateEvent, 7 | CloudFormationCustomResourceDeleteEvent, 8 | } from "aws-lambda"; 9 | import { 10 | IoTClient, 11 | CreateKeysAndCertificateCommand, 12 | UpdateCertificateCommand, 13 | DeleteCertificateCommand, 14 | } from "@aws-sdk/client-iot"; 15 | import { 16 | SecretsManagerClient, 17 | CreateSecretCommand, 18 | DeleteSecretCommand, 19 | } from "@aws-sdk/client-secrets-manager"; 20 | import { logger } from "#powertools"; 21 | 22 | const SECRET_NAME = "pettracker/iot-cert"; 23 | 24 | const onCreate = async (_event: CloudFormationCustomResourceCreateEvent) => { 25 | const { certificateId, certificatePem, keyPair } = await iot.send( 26 | new CreateKeysAndCertificateCommand({ 27 | setAsActive: true, 28 | }) 29 | ); 30 | 31 | if (!certificateId || !certificatePem || !keyPair) { 32 | throw new Error("Failed to create keys and certificate"); 33 | } 34 | 35 | await secretsManager.send( 36 | new CreateSecretCommand({ 37 | Name: SECRET_NAME, 38 | SecretString: JSON.stringify({ 39 | cert: certificatePem, 40 | keyPair: keyPair.PrivateKey, 41 | }), 42 | }) 43 | ); 44 | 45 | return { 46 | PhysicalResourceId: certificateId, 47 | Data: { 48 | certificateId, 49 | }, 50 | }; 51 | }; 52 | 53 | const onDelete = async (event: CloudFormationCustomResourceDeleteEvent) => { 54 | await secretsManager.send( 55 | new DeleteSecretCommand({ 56 | SecretId: SECRET_NAME, 57 | ForceDeleteWithoutRecovery: true, 58 | }) 59 | ); 60 | const certificateId = event.PhysicalResourceId; 61 | await iot.send( 62 | new UpdateCertificateCommand({ 63 | certificateId, 64 | newStatus: "INACTIVE", 65 | }) 66 | ); 67 | await new Promise((resolve) => setTimeout(resolve, 2_000)); 68 | await iot.send(new DeleteCertificateCommand({ certificateId })); 69 | }; 70 | 71 | const iot = new IoTClient({}); 72 | const secretsManager = new SecretsManagerClient({}); 73 | 74 | export const handler = async (event: CloudFormationCustomResourceEvent) => { 75 | logger.debug("Received event", { event }); 76 | if (event.RequestType === "Create") { 77 | return await onCreate(event); 78 | } else if (event.RequestType === "Update") { 79 | return; 80 | } else if (event.RequestType === "Delete") { 81 | return await onDelete(event); 82 | } 83 | throw new Error("Unknown request type"); 84 | }; 85 | -------------------------------------------------------------------------------- /infra/lib/fns/commons/powertools.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { PT_VERSION as version } from "@aws-lambda-powertools/commons"; 5 | import { Logger } from "@aws-lambda-powertools/logger"; 6 | 7 | const serviceName = "pet-tracker"; 8 | 9 | const logger = new Logger({ 10 | serviceName, 11 | logLevel: "DEBUG", 12 | persistentLogAttributes: { 13 | logger: { 14 | version, 15 | name: "@aws-lambda-powertools/logger", 16 | }, 17 | }, 18 | }); 19 | 20 | export { logger }; 21 | -------------------------------------------------------------------------------- /infra/lib/fns/commons/utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { HttpRequest } from "@smithy/protocol-http"; 5 | import { SignatureV4 } from "@aws-sdk/signature-v4"; 6 | import { URL } from "node:url"; 7 | import { 8 | createHash, 9 | createHmac, 10 | type BinaryLike, 11 | type Hmac, 12 | type KeyObject, 13 | } from "node:crypto"; 14 | import { logger } from "./powertools.js"; 15 | 16 | class Sha256 { 17 | private readonly hash: Hmac; 18 | 19 | public constructor(secret?: unknown) { 20 | this.hash = secret 21 | ? createHmac("sha256", secret as BinaryLike | KeyObject) 22 | : createHash("sha256"); 23 | } 24 | 25 | public digest(): Promise { 26 | const buffer = this.hash.digest(); 27 | 28 | return Promise.resolve(new Uint8Array(buffer.buffer)); 29 | } 30 | 31 | public update(array: Uint8Array): void { 32 | this.hash.update(array); 33 | } 34 | } 35 | 36 | const signer = new SignatureV4({ 37 | credentials: { 38 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 39 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 40 | sessionToken: process.env.AWS_SESSION_TOKEN!, 41 | }, 42 | service: "appsync", 43 | region: process.env.AWS_REGION ?? "", 44 | sha256: Sha256, 45 | }); 46 | 47 | /** 48 | * Error thrown when AppSync returns an error. 49 | * 50 | * It formats the errors returned by AppSync into a single error message. 51 | */ 52 | class AppSyncError extends Error { 53 | constructor( 54 | errors: { 55 | message: string; 56 | errorType: string; 57 | }[] 58 | ) { 59 | const message = errors 60 | .map((e) => `${e.errorType}: ${e.message}`) 61 | .join("\n"); 62 | super(message); 63 | this.name = "AppSyncError"; 64 | } 65 | } 66 | 67 | type Inputs = { 68 | [key: string]: string | number | Inputs; 69 | }; 70 | 71 | /** 72 | * Body of a GraphQL mutation. 73 | */ 74 | export type MutationOperation = { 75 | query: string; 76 | operationName: string; 77 | variables: { input: Inputs }; 78 | }; 79 | 80 | /** 81 | * Executes a GraphQL mutation. 82 | * 83 | * @param mutation GraphQL mutation to execute 84 | */ 85 | const executeMutation = async (mutation: MutationOperation): Promise => { 86 | const APPSYNC_ENDPOINT = process.env.GRAPHQL_URL; 87 | if (!APPSYNC_ENDPOINT) { 88 | throw new Error("GRAPHQL_URL env var is not set"); 89 | } 90 | const url = new URL(APPSYNC_ENDPOINT); 91 | 92 | logger.debug("Executing GraphQL mutation", { details: mutation }); 93 | 94 | const httpRequest = new HttpRequest({ 95 | hostname: url.hostname, 96 | path: url.pathname, 97 | body: JSON.stringify(mutation), 98 | method: "POST", 99 | headers: { 100 | "Content-Type": "application/json", 101 | host: url.hostname, 102 | }, 103 | }); 104 | 105 | const signedHttpRequest = await signer.sign(httpRequest); 106 | 107 | try { 108 | const result = await fetch(url, { 109 | headers: new Headers(signedHttpRequest.headers), 110 | body: signedHttpRequest.body, 111 | method: signedHttpRequest.method, 112 | }); 113 | 114 | if (!result.ok) throw new Error(result.statusText); 115 | 116 | const body = (await result.json()) as { 117 | data: T; 118 | errors: { message: string; errorType: string }[]; 119 | }; 120 | const { data, errors } = body; 121 | 122 | if (errors) { 123 | throw new AppSyncError(errors); 124 | } else if (data === undefined) { 125 | throw new Error("AppSync returned an empty response"); 126 | } 127 | 128 | logger.debug("Mutation executed", { details: data }); 129 | 130 | return data; 131 | } catch (err) { 132 | logger.error("Failed to execute GraphQL mutation", err as Error); 133 | 134 | throw err; 135 | } 136 | }; 137 | 138 | export { executeMutation }; 139 | -------------------------------------------------------------------------------- /infra/lib/functions-construct.ts: -------------------------------------------------------------------------------- 1 | import { Stack, type StackProps, Duration } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | import { Policy, PolicyStatement } from "aws-cdk-lib/aws-iam"; 4 | import { type Function, Runtime } from "aws-cdk-lib/aws-lambda"; 5 | import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; 6 | import { RetentionDays } from "aws-cdk-lib/aws-logs"; 7 | 8 | interface FunctionsConstructProps extends StackProps { 9 | graphqlUrl: string; 10 | } 11 | 12 | export class FunctionsConstruct extends Construct { 13 | certificateHandlerFn: Function; 14 | appsyncUpdatePositionFn: Function; 15 | appsyncSendGeofenceEventFn: Function; 16 | 17 | constructor(scope: Construct, id: string, props: FunctionsConstructProps) { 18 | super(scope, id); 19 | 20 | const { graphqlUrl } = props; 21 | 22 | const sharedConfig = { 23 | handler: "handler", 24 | runtime: Runtime.NODEJS_20_X, 25 | bundling: { 26 | minify: true, 27 | target: "es2022", 28 | sourceMap: true, 29 | }, 30 | logRetention: RetentionDays.ONE_DAY, 31 | timeout: Duration.seconds(30), 32 | }; 33 | 34 | this.certificateHandlerFn = new NodejsFunction(this, "certificateHandler", { 35 | entry: "lib/fns/certificate-handler/src/index.ts", 36 | ...sharedConfig, 37 | }); 38 | this.certificateHandlerFn.role?.attachInlinePolicy( 39 | new Policy(this, "certificateHandlerPolicy", { 40 | statements: [ 41 | new PolicyStatement({ 42 | actions: [ 43 | "secretsmanager:CreateSecret", 44 | "secretsmanager:DeleteSecret", 45 | ], 46 | resources: [ 47 | `arn:aws:secretsmanager:${Stack.of(this).region}:${ 48 | Stack.of(this).account 49 | }:secret:*`, 50 | ], 51 | }), 52 | new PolicyStatement({ 53 | actions: ["iot:CreateKeysAndCertificate"], 54 | resources: [`*`], 55 | }), 56 | new PolicyStatement({ 57 | actions: ["iot:UpdateCertificate", "iot:DeleteCertificate"], 58 | resources: [ 59 | `arn:aws:iot:${Stack.of(this).region}:${ 60 | Stack.of(this).account 61 | }:cert/*`, 62 | ], 63 | }), 64 | ], 65 | }) 66 | ); 67 | 68 | this.appsyncUpdatePositionFn = new NodejsFunction( 69 | this, 70 | "appsyncUpdatePositionFn", 71 | { 72 | entry: "lib/fns/appsync-update-position/src/index.ts", 73 | environment: { 74 | GRAPHQL_URL: graphqlUrl, 75 | NODE_OPTIONS: "--enable-source-maps", 76 | }, 77 | memorySize: 256, 78 | ...sharedConfig, 79 | } 80 | ); 81 | 82 | this.appsyncSendGeofenceEventFn = new NodejsFunction( 83 | this, 84 | "appsyncSendGeofenceEventFn", 85 | { 86 | entry: "lib/fns/appsync-send-geofence-event/src/index.ts", 87 | environment: { 88 | GRAPHQL_URL: graphqlUrl, 89 | NODE_OPTIONS: "--enable-source-maps", 90 | }, 91 | memorySize: 256, 92 | ...sharedConfig, 93 | } 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /infra/lib/iot-construct.ts: -------------------------------------------------------------------------------- 1 | import { CustomResource, Stack, type StackProps } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | import { 4 | CfnPolicy, 5 | CfnPolicyPrincipalAttachment, 6 | CfnThing, 7 | CfnThingPrincipalAttachment, 8 | CfnTopicRule, 9 | } from "aws-cdk-lib/aws-iot"; 10 | import { Provider } from "aws-cdk-lib/custom-resources"; 11 | import { RetentionDays, LogGroup } from "aws-cdk-lib/aws-logs"; 12 | import { type Function } from "aws-cdk-lib/aws-lambda"; 13 | import { 14 | Role, 15 | ServicePrincipal, 16 | PolicyDocument, 17 | PolicyStatement, 18 | } from "aws-cdk-lib/aws-iam"; 19 | 20 | interface IotCoreConstructProps extends StackProps { 21 | certificateHandlerFn: Function; 22 | appsyncUpdatePositionFn: Function; 23 | } 24 | 25 | export class IotCoreConstruct extends Construct { 26 | constructor(scope: Construct, id: string, props: IotCoreConstructProps) { 27 | super(scope, id); 28 | 29 | const { certificateHandlerFn } = props; 30 | 31 | const provider = new Provider(this, "IoTCertProvider", { 32 | onEventHandler: certificateHandlerFn, 33 | logRetention: RetentionDays.ONE_DAY, 34 | }); 35 | 36 | const certificate = new CustomResource(this, "AWS:IoTCert", { 37 | serviceToken: provider.serviceToken, 38 | }); 39 | 40 | // Create an IoT Core Policy 41 | const policy = new CfnPolicy(this, "Policy", { 42 | policyName: "pettracker-policy", 43 | policyDocument: { 44 | Version: "2012-10-17", 45 | Statement: [ 46 | { 47 | Effect: "Allow", 48 | Action: "iot:Connect", 49 | Resource: `arn:aws:iot:${Stack.of(this).region}:${ 50 | Stack.of(this).account 51 | }:client/pettracker`, 52 | }, 53 | { 54 | Effect: "Allow", 55 | Action: "iot:Publish", 56 | Resource: `arn:aws:iot:${Stack.of(this).region}:${ 57 | Stack.of(this).account 58 | }:topic/iot/pettracker`, 59 | }, 60 | ], 61 | }, 62 | }); 63 | 64 | const policyPrincipalAttachment = new CfnPolicyPrincipalAttachment( 65 | this, 66 | "MyCfnPolicyPrincipalAttachment", 67 | { 68 | policyName: policy.policyName as string, 69 | principal: `arn:aws:iot:${Stack.of(this).region}:${ 70 | Stack.of(this).account 71 | }:cert/${certificate.getAttString("certificateId")}`, 72 | } 73 | ); 74 | policyPrincipalAttachment.addDependency(policy); 75 | 76 | // Create an IoT Core Thing 77 | const thing = new CfnThing(this, "Thing", { 78 | thingName: "pettracker", 79 | }); 80 | 81 | // Attach the certificate to the IoT Core Thing 82 | const thingPrincipalAttachment = new CfnThingPrincipalAttachment( 83 | this, 84 | "MyCfnThingPrincipalAttachment", 85 | { 86 | principal: `arn:aws:iot:${Stack.of(this).region}:${ 87 | Stack.of(this).account 88 | }:cert/${certificate.getAttString("certificateId")}`, 89 | thingName: thing.thingName as string, 90 | } 91 | ); 92 | thingPrincipalAttachment.addDependency(thing); 93 | 94 | // CloudWatch Role for IoT Core error logging 95 | const logGroup = new LogGroup(this, "ErrorLogGroup", { 96 | retention: RetentionDays.ONE_DAY, 97 | }); 98 | 99 | // IAM Role for AWS IoT Core to publish to Location Service 100 | const role = new Role(this, "IotTrackerRole", { 101 | assumedBy: new ServicePrincipal("iot.amazonaws.com"), 102 | description: "IAM Role that allows IoT Core to update a Tracker", 103 | inlinePolicies: { 104 | allowTracker: new PolicyDocument({ 105 | statements: [ 106 | new PolicyStatement({ 107 | resources: [ 108 | `arn:aws:geo:${Stack.of(this).region}:${ 109 | Stack.of(this).account 110 | }:tracker/PetTracker`, 111 | ], 112 | actions: ["geo:BatchUpdateDevicePosition"], 113 | }), 114 | ], 115 | }), 116 | }, 117 | }); 118 | logGroup.grantWrite(role); 119 | 120 | // Create an IoT Core Topic Rule that sends IoT Core updates to Location Service 121 | new CfnTopicRule(this, "TopicRule", { 122 | ruleName: "petTrackerRule", 123 | topicRulePayload: { 124 | sql: `SELECT * FROM 'iot/pettracker'`, 125 | awsIotSqlVersion: "2016-03-23", 126 | actions: [ 127 | { 128 | location: { 129 | deviceId: "${deviceId}", 130 | latitude: "${longitude}", 131 | longitude: "${latitude}", 132 | roleArn: role.roleArn, 133 | trackerName: "PetTracker", 134 | }, 135 | }, 136 | ], 137 | errorAction: { 138 | cloudwatchLogs: { 139 | logGroupName: logGroup.logGroupName, 140 | roleArn: role.roleArn, 141 | }, 142 | }, 143 | }, 144 | }); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /infra/lib/pettracker-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, type StackProps, CfnOutput } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | import { AuthConstruct } from "./auth-construct.js"; 4 | import { AppSyncConstruct } from "./appsync-construct.js"; 5 | import { FunctionsConstruct } from "./functions-construct.js"; 6 | import { IotCoreConstruct } from "./iot-construct.js"; 7 | import { NagSuppressions } from "cdk-nag"; 8 | 9 | export class PetTracker extends Stack { 10 | constructor(scope: Construct, id: string, props?: StackProps) { 11 | super(scope, id, props); 12 | 13 | new AuthConstruct(this, "authConstruct", {}); 14 | 15 | const { api } = new AppSyncConstruct(this, "apiConstruct", {}); 16 | 17 | const { 18 | certificateHandlerFn, 19 | appsyncUpdatePositionFn, 20 | appsyncSendGeofenceEventFn, 21 | } = new FunctionsConstruct(this, "functionsConstruct", { 22 | graphqlUrl: api.graphqlUrl, 23 | }); 24 | api.grantMutation(appsyncUpdatePositionFn, "updatePosition"); 25 | api.grantMutation(appsyncSendGeofenceEventFn, "sendGeofenceEvent"); 26 | 27 | new IotCoreConstruct(this, "iotCoreConstruct", { 28 | certificateHandlerFn, 29 | appsyncUpdatePositionFn, 30 | }); 31 | 32 | new CfnOutput(this, "AWSRegion", { 33 | value: Stack.of(this).region, 34 | }); 35 | 36 | // Suppress selected CDK-Nag and provide reason 37 | [ 38 | "/PetTracker/functionsConstruct/certificateHandler/ServiceRole/Resource", 39 | "/PetTracker/functionsConstruct/appsyncUpdatePositionFn/ServiceRole/Resource", 40 | "/PetTracker/functionsConstruct/appsyncSendGeofenceEventFn/ServiceRole/Resource", 41 | ].forEach((resourcePath: string) => { 42 | NagSuppressions.addResourceSuppressionsByPath(this, resourcePath, [ 43 | { 44 | id: "AwsSolutions-IAM4", 45 | reason: 46 | "Intentionally using an AWS managed policy for AWS Lambda - AWSLambdaBasicExecutionRole", 47 | }, 48 | ]); 49 | }); 50 | 51 | NagSuppressions.addResourceSuppressionsByPath( 52 | this, 53 | "/PetTracker/functionsConstruct/certificateHandlerPolicy/Resource", 54 | [ 55 | { 56 | id: "AwsSolutions-IAM5", 57 | reason: 58 | "This CDK Custom resource uses an AWS Lambda function to create an AWS IoT Core certificate & store it in AWS Secrets Manager. The id of the certificate is not known before creation so the policy must have a wildcard. The name of the secret contains a `/` symbol so we are using a wildcard. In any case this function is executed only during deployment.", 59 | }, 60 | { 61 | id: "AwsSolutions-IAM5", 62 | reason: 63 | "IAM Action iot:CreateKeysAndCertificate does not support resource-level permission. Additionally, even if it did, the id of the certificate is not known before creation time.", 64 | }, 65 | ] 66 | ); 67 | 68 | [ 69 | "/PetTracker/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource", 70 | "/PetTracker/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/DefaultPolicy/Resource", 71 | "/PetTracker/iotCoreConstruct/IoTCertProvider/framework-onEvent/ServiceRole/Resource", 72 | "/PetTracker/iotCoreConstruct/IoTCertProvider/framework-onEvent/ServiceRole/DefaultPolicy/Resource", 73 | "/PetTracker/iotCoreConstruct/IoTCertProvider/framework-onEvent/Resource", 74 | ].forEach((resourcePath: string) => { 75 | let id = "AwsSolutions-L1"; 76 | let reason = "Resource created and managed by CDK."; 77 | if (resourcePath.endsWith("ServiceRole/Resource")) { 78 | id = "AwsSolutions-IAM4"; 79 | } else if (resourcePath.endsWith("DefaultPolicy/Resource")) { 80 | id = "AwsSolutions-IAM5"; 81 | reason += 82 | " This type of resource is a singleton fn that interacts with many resources so IAM policies are lax by design to allow this use case."; 83 | } 84 | NagSuppressions.addResourceSuppressionsByPath(this, resourcePath, [ 85 | { 86 | id, 87 | reason, 88 | }, 89 | ]); 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /infra/lib/schema.graphql: -------------------------------------------------------------------------------- 1 | enum GeofenceEventEnum { 2 | ENTER 3 | EXIT 4 | } 5 | 6 | enum PositionEventEnum { 7 | UPDATE 8 | } 9 | 10 | type Accuracy { 11 | horizontal: Float! 12 | } 13 | 14 | interface LocationEventBase { 15 | deviceId: ID 16 | sampleTime: AWSDateTime! 17 | accuracy: Accuracy 18 | lng: Float! 19 | lat: Float! 20 | } 21 | 22 | type GeofenceEvent implements LocationEventBase @aws_iam { 23 | deviceId: ID 24 | sampleTime: AWSDateTime! 25 | accuracy: Accuracy 26 | lng: Float! 27 | lat: Float! 28 | type: GeofenceEventEnum! 29 | geofenceId: String! 30 | } 31 | 32 | type PositionEvent implements LocationEventBase @aws_iam { 33 | deviceId: ID 34 | sampleTime: AWSDateTime! 35 | accuracy: Accuracy 36 | lng: Float! 37 | lat: Float! 38 | type: PositionEventEnum! 39 | trackerName: String! 40 | receivedTime: AWSDateTime! 41 | } 42 | 43 | input AccuracyInput { 44 | horizontal: Float! 45 | } 46 | 47 | input GeofenceEventInput { 48 | deviceId: ID 49 | sampleTime: AWSDateTime! 50 | lng: Float! 51 | lat: Float! 52 | type: GeofenceEventEnum! 53 | geofenceId: String! 54 | } 55 | 56 | input PositionEventInput { 57 | deviceId: ID 58 | sampleTime: AWSDateTime! 59 | lng: Float! 60 | lat: Float! 61 | type: PositionEventEnum! 62 | trackerName: String! 63 | receivedTime: AWSDateTime! 64 | } 65 | 66 | type Query { 67 | placeholder: String 68 | } 69 | 70 | type Mutation { 71 | updatePosition(input: PositionEventInput): PositionEvent @aws_api_key @aws_iam 72 | sendGeofenceEvent(input: GeofenceEventInput): GeofenceEvent 73 | @aws_api_key 74 | @aws_iam 75 | } 76 | 77 | type Subscription { 78 | onUpdatePosition: PositionEvent 79 | @aws_subscribe(mutations: ["updatePosition"]) 80 | @aws_api_key 81 | onGeofenceEvent: GeofenceEvent 82 | @aws_subscribe(mutations: ["sendGeofenceEvent"]) 83 | @aws_api_key 84 | } 85 | -------------------------------------------------------------------------------- /infra/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pettracker", 3 | "version": "3.0.0", 4 | "bin": { 5 | "backend": "bin/pettracker.js" 6 | }, 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com" 10 | }, 11 | "type": "module", 12 | "scripts": { 13 | "build": "tsc", 14 | "watch": "tsc -w", 15 | "cdk": "cdk", 16 | "cdk:synth": "cdk synth", 17 | "cdk:deploy": "cdk deploy", 18 | "cdk:deploy:hot": "cdk deploy --hotswap", 19 | "cdk:destroy": "cdk destroy", 20 | "cdk:bootstrap": "cdk bootstrap" 21 | }, 22 | "devDependencies": { 23 | "@aws-cdk/aws-cognito-identitypool-alpha": "^2.139.1-alpha.0", 24 | "@aws-cdk/aws-iot-actions-alpha": "^2.139.1-alpha.0", 25 | "@aws-cdk/aws-iot-alpha": "^2.139.1-alpha.0", 26 | "@types/aws-lambda": "^8.10.148", 27 | "@types/node": "^22.13.0", 28 | "aws-cdk": "^2.1017.1", 29 | "typescript": "~5.5.4" 30 | }, 31 | "dependencies": { 32 | "@aws-lambda-powertools/logger": "^2.6.0", 33 | "@aws-sdk/client-iot": "^3.821.0", 34 | "@aws-sdk/client-location": "^3.821.0", 35 | "@aws-sdk/client-secrets-manager": "^3.821.0", 36 | "@aws-sdk/signature-v4": "^3.370.0", 37 | "aws-cdk-lib": "^2.199.0", 38 | "cdk-nag": "^2.31.0", 39 | "constructs": "^10.4.2", 40 | "esbuild": "^0.25.0", 41 | "source-map-support": "^0.5.21" 42 | }, 43 | "imports": { 44 | "#utils": "./lib/fns/commons/utils.js", 45 | "#powertools": "./lib/fns/commons/powertools.js" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /infra/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": [ 7 | "es2022" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | "cdk.out" 27 | ] 28 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amazon-location-service-pettracker-demo", 3 | "version": "3.0.0", 4 | "description": "The **PetTracker Demo** is a cloud native application built using an serverless architecture based on AWS services to show case [AWS IoT](https://aws.amazon.com/iot/) integrations for geospatial use cases in conjuction with the [Amazon Location Services](https://aws.amazon.com/location/) to help Solution Architects around the world to make use of it in their demos and workshops.", 5 | "main": "index.js", 6 | "workspaces": [ 7 | "infra", 8 | "frontend", 9 | "pet_simulator", 10 | "scripts" 11 | ], 12 | "scripts": { 13 | "utils:createConfig": "npm run createConfig -w scripts", 14 | "utils:resizeC9EBS": "sh scripts/resize.sh", 15 | "utils:convertCDKtoCfn": "npm run convertCDKtoCfn -w scripts", 16 | "frontend:start": "npm start -w frontend", 17 | "frontend:build": "npm run build -w frontend", 18 | "infra:synth": "npm run cdk:synth -w infra", 19 | "infra:bootstrap": "npm run cdk:bootstrap -w infra", 20 | "infra:deploy": "npm run cdk:deploy -w infra", 21 | "infra:deploy:hot": "npm run cdk:deploy:hot -w infra", 22 | "infra:destroy": "npm run cdk:destroy -w infra", 23 | "sim:start": "npm run simulate -w pet_simulator" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/aws-samples/amazon-location-service-pettracker-demo.git" 28 | }, 29 | "keywords": [ 30 | "location", 31 | "aws", 32 | "amazon", 33 | "iot" 34 | ], 35 | "author": { 36 | "name": "Amazon Web Services", 37 | "url": "https://aws.amazon.com" 38 | }, 39 | "license": "MIT-0", 40 | "bugs": { 41 | "url": "https://github.com/aws-samples/amazon-location-service-pettracker-demo/issues" 42 | }, 43 | "homepage": "https://github.com/aws-samples/amazon-location-service-pettracker-demo#readme" 44 | } -------------------------------------------------------------------------------- /pet_simulator/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | 146 | dist/** -------------------------------------------------------------------------------- /pet_simulator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pet-simulator", 3 | "version": "2.5.0", 4 | "description": "Pet Tracker IoT Core Simulator - This small script simulates an IoT device that could be attached to a pet. It wanders around a point used as seed.", 5 | "type": "module", 6 | "scripts": { 7 | "simulate": "tsx src/index.ts" 8 | }, 9 | "author": { 10 | "name": "Amazon Web Services", 11 | "url": "https://aws.amazon.com" 12 | }, 13 | "license": "MIT-0", 14 | "devDependencies": { 15 | "@types/node": "^22.13.0", 16 | "@types/promise-retry": "^1.1.6", 17 | "tsx": "^4.19.4", 18 | "typescript": "~5.5.4" 19 | }, 20 | "dependencies": { 21 | "@aws-sdk/client-iot": "^3.821.0", 22 | "@aws-sdk/client-secrets-manager": "^3.821.0", 23 | "@aws-lambda-powertools/parameters": "^2.8.0", 24 | "@turf/bbox": "^7.2.0", 25 | "@turf/bbox-polygon": "^7.1.0", 26 | "@turf/buffer": "^7.1.0", 27 | "@turf/helpers": "^6.5.0", 28 | "@turf/random": "^6.5.0", 29 | "aws-iot-device-sdk-v2": "^1.19.5", 30 | "promise-retry": "^2.0.1" 31 | } 32 | } -------------------------------------------------------------------------------- /pet_simulator/src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import Simulator from "./utils.js"; 5 | 6 | const LNG = 36.12309017212961; 7 | const LAT = -115.17077150978058; 8 | const STEP_DISTANCE = 10; // Distance in meters for each step taken by the pet (default 10m / 32 feet) 9 | const STEP_FREQUENCY = 10; // Frequency at which updates will be sent (default 10 seconds) 10 | const IOT_CORE_TOPIC = "iot/pettracker"; 11 | const IOT_CERT_SECRET_ID = "pettracker/iot-cert"; 12 | 13 | const sim = new Simulator( 14 | `pettracker`, 15 | IOT_CORE_TOPIC, 16 | IOT_CERT_SECRET_ID, 17 | [LAT, LNG], 18 | STEP_DISTANCE 19 | ); 20 | 21 | export const handler = async (): Promise => { 22 | while (true) { 23 | await sim.makeStep(); 24 | await new Promise((resolve) => setTimeout(resolve, STEP_FREQUENCY * 1000)); // Wait `STEP_FREQUENCY` seconds 25 | } 26 | }; 27 | 28 | handler(); 29 | -------------------------------------------------------------------------------- /pet_simulator/src/utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { mqtt, iot } from "aws-iot-device-sdk-v2"; 5 | import { getSecret } from "@aws-lambda-powertools/parameters/secrets"; 6 | import { IoTClient, DescribeEndpointCommand } from "@aws-sdk/client-iot"; 7 | import { randomPosition } from "@turf/random"; 8 | import bbox from "@turf/bbox"; 9 | import buffer from "@turf/buffer"; 10 | import { point, Position } from "@turf/helpers"; 11 | import promiseRetry from "promise-retry"; 12 | 13 | const retryOptions = { 14 | retries: 10, 15 | minTimeout: 5_000, 16 | maxTimeout: 10_000, 17 | factor: 1.25, 18 | }; 19 | 20 | class Simulator { 21 | private ioTtopic: string; 22 | private clientId: string; 23 | private isConnected: boolean = false; 24 | private iotCoreClient: IoTClient; 25 | private secretId: string; 26 | private currentPosition: Position; 27 | private stepDistance: number; 28 | private cert?: string; 29 | private key?: string; 30 | private endpoint?: string; 31 | private ioTConnection?: mqtt.MqttClientConnection; 32 | 33 | constructor( 34 | clientId: string, 35 | topic: string, 36 | secretId: string, 37 | seed: Position, 38 | stepDistance: number 39 | ) { 40 | this.ioTtopic = topic; 41 | this.clientId = clientId; 42 | this.secretId = secretId; 43 | this.currentPosition = seed; 44 | this.stepDistance = stepDistance; 45 | this.iotCoreClient = new IoTClient({}); 46 | } 47 | 48 | private async getEndpoint(): Promise { 49 | return promiseRetry(async (retry: (err?: Error) => never, _: number) => { 50 | try { 51 | const endpoint = await this.iotCoreClient.send( 52 | new DescribeEndpointCommand({ 53 | endpointType: "iot:Data-ATS", 54 | }) 55 | ); 56 | 57 | if (!endpoint.endpointAddress) 58 | throw new Error("Unable to get IoT Core Endpoint"); 59 | 60 | console.info(`Got IoT Core Endpoint: ${endpoint.endpointAddress}`); 61 | return endpoint.endpointAddress; 62 | } catch (err) { 63 | retry(err as Error); 64 | } 65 | }, retryOptions); 66 | } 67 | 68 | private async getCertAndKey(): Promise<{ 69 | cert: string; 70 | key: string; 71 | }> { 72 | if (!this.cert || !this.key) { 73 | const secret = await getSecret<{ cert: string; keyPair: string }>( 74 | this.secretId, 75 | { 76 | transform: "json", 77 | maxAge: 10000, 78 | } 79 | ); 80 | if (!secret) { 81 | throw new Error("Could not find secret"); 82 | } 83 | const { cert, keyPair } = secret; 84 | 85 | this.cert = cert; 86 | this.key = keyPair; 87 | 88 | if (!this.cert || !this.key) { 89 | throw new Error("Could not find cert or key"); 90 | } 91 | 92 | console.info("Got cert and key from Secrets Manager"); 93 | } 94 | 95 | return { 96 | cert: this.cert, 97 | key: this.key, 98 | }; 99 | } 100 | 101 | private buildConnection = async ( 102 | clientId: string 103 | ): Promise => { 104 | if (!this.endpoint) { 105 | this.endpoint = await this.getEndpoint(); 106 | } 107 | const { cert, key } = await this.getCertAndKey(); 108 | let configBuilder = iot.AwsIotMqttConnectionConfigBuilder.new_mtls_builder( 109 | cert, 110 | key 111 | ); 112 | configBuilder.with_clean_session(false); 113 | configBuilder.with_client_id(clientId); 114 | configBuilder.with_endpoint(this.endpoint); 115 | const config = configBuilder.build(); 116 | const client = new mqtt.MqttClient(); 117 | 118 | return client.new_connection(config); 119 | }; 120 | 121 | private connect = async () => { 122 | try { 123 | this.ioTConnection = await this.buildConnection(this.clientId); 124 | } catch (err) { 125 | console.error(err); 126 | console.error("Failed to build connection object"); 127 | throw err; 128 | } 129 | 130 | try { 131 | console.info("Connecting to IoT Core"); 132 | await promiseRetry(async (retry: (err?: Error) => never, _: number) => { 133 | try { 134 | await this.ioTConnection?.connect(); 135 | } catch (err) { 136 | console.error(err); 137 | retry(new Error("Connection failed, retrying.")); 138 | } 139 | }, retryOptions); 140 | console.info("Successfully connected to IoT Core"); 141 | this.isConnected = true; 142 | } catch (err) { 143 | console.error("Error connecting to IoT Core", { err }); 144 | throw err; 145 | } 146 | }; 147 | 148 | private publishUpdate = async (location: Position) => { 149 | if (!this.isConnected) { 150 | await this.connect(); 151 | } 152 | 153 | const payload = { 154 | deviceId: this.clientId, 155 | timestamp: new Date().getTime(), 156 | latitude: location[0], 157 | longitude: location[1], 158 | }; 159 | 160 | // Log update before publishing 161 | console.debug(JSON.stringify(payload, null, 2)); 162 | 163 | await this.ioTConnection?.publish( 164 | this.ioTtopic, 165 | JSON.stringify({ 166 | ...payload, 167 | }), 168 | mqtt.QoS.AtMostOnce 169 | ); 170 | }; 171 | 172 | /** 173 | * Generates a random point within a given radius from another point. 174 | * 175 | * It takes the initial position and the map bounds as input. It then creates a buffer 176 | * around the point which represents the area that the device might end up in. 177 | * 178 | * It then generates a random point within the area and publishes it to the IoT Core endpoint/topic. 179 | */ 180 | public makeStep = async () => { 181 | const currentPosition = point(this.currentPosition); 182 | // Create a buffer around the current position (i.e. a polygon 10feet around the point) 183 | const bufferAroundPoint = buffer(currentPosition, this.stepDistance, { 184 | units: "feet", 185 | }); 186 | // Create a bounding box around the buffer 187 | const bboxAroundPoint = bbox(bufferAroundPoint); 188 | // Generate a random point within the intersection bounding box 189 | const nextPosition = randomPosition(bboxAroundPoint); 190 | // Publish 191 | await this.publishUpdate(nextPosition); 192 | }; 193 | } 194 | 195 | export default Simulator; 196 | -------------------------------------------------------------------------------- /pet_simulator/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": [ 7 | "es2022" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | ] 27 | } -------------------------------------------------------------------------------- /scripts/convert-template.mjs: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs"; 5 | import { join, resolve } from "node:path"; 6 | import { execa } from "execa"; 7 | 8 | const getJSONFile = (path) => { 9 | try { 10 | const templateContent = readFileSync(path, { 11 | encoding: "utf-8", 12 | }); 13 | return JSON.parse(templateContent); 14 | } catch (err) { 15 | console.error(err); 16 | console.error(`Unable to read JSON file ${path}`); 17 | } 18 | }; 19 | 20 | const writeJSONFile = (content, path) => { 21 | try { 22 | writeFileSync(path, JSON.stringify(content, null, 2)); 23 | } catch (err) { 24 | console.error(err); 25 | console.error(`Unable to write JSON file ${path}`); 26 | } 27 | }; 28 | 29 | (async () => { 30 | const basePath = "../infra/cdk.out"; 31 | const stackName = "PetTracker"; 32 | const cfnTemplateFileName = `${stackName}.template.json`; 33 | const assetsFileName = `${stackName}.assets.json`; 34 | // Get original Cfn template 35 | const template = getJSONFile(join(basePath, cfnTemplateFileName)); 36 | // Remove Rules section 37 | delete template.Rules; 38 | // Empty Parameters section 39 | delete template.Parameters.BootstrapVersion; 40 | // Put a new Parameter for the S3 bucket where assets will be placed 41 | template.Parameters.AssetBucket = { 42 | Type: "String", 43 | Description: 44 | "Name of the Amazon S3 Bucket where the assets related to this stack will be found. The stack references this bucket.", 45 | }; 46 | // Put a new Parameter for the S3 bucket prefix where assets are 47 | template.Parameters.AssetPrefix = { 48 | Type: "String", 49 | Description: 50 | "Prefix of the Amazon S3 Bucket where the assets related to this stack are. This prefix is prepended to asset keys.", 51 | }; 52 | // Remove metadata from all resources 53 | Object.keys(template.Resources).forEach((resourceKey) => { 54 | if (!template.Resources[resourceKey].hasOwnProperty("Metadata")) return; 55 | delete template.Resources[resourceKey].Metadata; 56 | }); 57 | if (template.Resources.hasOwnProperty("Metadata")) 58 | delete template.Resources.Metadata; 59 | if (template.Resources.hasOwnProperty("CDKMetadata")) 60 | delete template.Resources.CDKMetadata; 61 | // Remove CDKMetadataAvailable from Conditions 62 | if ( 63 | template.hasOwnProperty("Conditions") && 64 | template.Conditions.hasOwnProperty("CDKMetadataAvailable") 65 | ) 66 | delete template.Conditions.CDKMetadataAvailable; 67 | // Remove main 68 | // Replace S3Bucket key in resources with Type===AWS::Lambda::Function && Code.S3Bucket 69 | Object.keys(template.Resources).forEach((resourceKey) => { 70 | if (template.Resources[resourceKey].Type !== "AWS::Lambda::Function") 71 | return; 72 | if (template.Resources[resourceKey].Properties.Code.ZipFile) return; 73 | template.Resources[resourceKey].Properties.Code.S3Bucket = { 74 | Ref: "AssetBucket", 75 | }; 76 | template.Resources[resourceKey].Properties.Code.S3Key = { 77 | "Fn::Sub": [ 78 | "${Prefix}" + template.Resources[resourceKey].Properties.Code.S3Key, 79 | { 80 | Prefix: { 81 | Ref: "AssetPrefix", 82 | }, 83 | }, 84 | ], 85 | }; 86 | }); 87 | 88 | // Empty or create the custom out directory 89 | const outDir = resolve(join(basePath, "deploy")); 90 | if (existsSync(outDir)) { 91 | await execa("rm", ["-rf", outDir]); 92 | } 93 | 94 | mkdirSync(outDir); 95 | 96 | // Get assets list 97 | const assets = getJSONFile(join(basePath, assetsFileName)); 98 | 99 | // Create a zip archive for each asset and place it in the out dir 100 | for await (const file of Object.values(assets.files)) { 101 | if (file.source.packaging !== "zip") continue; 102 | console.log(file); 103 | await execa( 104 | "zip", 105 | [ 106 | "-rj", 107 | file.destinations["current_account-current_region"].objectKey, 108 | "./", 109 | ], 110 | { 111 | cwd: join(basePath, file.source.path), 112 | } 113 | ); 114 | await execa("mv", [ 115 | join( 116 | basePath, 117 | file.source.path, 118 | file.destinations["current_account-current_region"].objectKey 119 | ), 120 | outDir, 121 | ]); 122 | } 123 | 124 | // Save modified Cfn template in the out dir 125 | writeJSONFile(template, join(outDir, `${cfnTemplateFileName}`)); 126 | })(); 127 | -------------------------------------------------------------------------------- /scripts/create-aws-exports.mjs: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { writeFile } from "node:fs/promises"; 5 | import { 6 | CloudFormationClient, 7 | ListStacksCommand, 8 | DescribeStacksCommand, 9 | } from "@aws-sdk/client-cloudformation"; 10 | 11 | const cfnClient = new CloudFormationClient({}); 12 | 13 | const getStackName = async () => { 14 | try { 15 | const res = await cfnClient.send( 16 | new ListStacksCommand({ 17 | StackStatusFilter: [ 18 | "CREATE_COMPLETE", 19 | "UPDATE_COMPLETE", 20 | "ROLLBACK_COMPLETE", 21 | ], 22 | }) 23 | ); 24 | const stack = res.StackSummaries.find((stack) => 25 | stack.StackName.toUpperCase().includes("pettracker".toUpperCase()) 26 | ); 27 | if (!stack) { 28 | throw new Error("Unable to find stack among loaded ones"); 29 | } 30 | return stack; 31 | } catch (err) { 32 | console.error(err); 33 | console.error("Unable to load CloudFormation stacks."); 34 | throw err; 35 | } 36 | }; 37 | 38 | /** 39 | * 40 | * @param {string} stackName 41 | */ 42 | const getStackOutputs = async (stackName) => { 43 | try { 44 | const res = await cfnClient.send( 45 | new DescribeStacksCommand({ 46 | StackName: stackName, 47 | }) 48 | ); 49 | if (res.Stacks.length === 0) { 50 | throw new Error("Stack not found"); 51 | } 52 | const keys = []; 53 | const outputs = {}; 54 | res.Stacks?.[0].Outputs.forEach(({ OutputKey, OutputValue }) => { 55 | outputs[OutputKey] = OutputValue; 56 | keys.push(OutputKey); 57 | }); 58 | return { 59 | keys, 60 | vals: outputs, 61 | }; 62 | } catch (err) { 63 | console.error(err); 64 | console.error("Unable to load CloudFormation Stack outputs."); 65 | throw err; 66 | } 67 | }; 68 | 69 | const saveTemplate = async (template, path) => { 70 | try { 71 | await writeFile( 72 | path, 73 | `const awsmobile = ${JSON.stringify(template, null, 2)} 74 | export default awsmobile; 75 | ` 76 | ); 77 | } catch (err) { 78 | console.error(err); 79 | console.error("Unable to write file"); 80 | throw err; 81 | } 82 | }; 83 | 84 | /** 85 | * 86 | * @param {string} namePart 87 | */ 88 | const getValueFromNamePart = (namePart, values) => 89 | values.find((el) => el.includes(namePart)); 90 | 91 | (async () => { 92 | const stack = await getStackName(); 93 | const { keys, vals } = await getStackOutputs(stack.StackName); 94 | const region = vals[getValueFromNamePart(`AWSRegion`, keys)]; 95 | const template = { 96 | aws_project_region: region, 97 | aws_cognito_identity_pool_id: 98 | vals[getValueFromNamePart(`IdentityPoolId`, keys)], 99 | geo: { 100 | amazon_location_service: { 101 | region, 102 | maps: { 103 | items: { 104 | PetTrackerMap: { 105 | style: "VectorHereExplore", 106 | }, 107 | }, 108 | default: "PetTrackerMap", 109 | }, 110 | geofenceCollections: { 111 | items: ["PetTrackerGeofenceCollection"], 112 | default: "PetTrackerGeofenceCollection", 113 | }, 114 | routeCalculator: "PetTrackerRouteCalculator", 115 | }, 116 | }, 117 | aws_appsync_graphqlEndpoint: vals[getValueFromNamePart(`ApiUrl`, keys)], 118 | aws_appsync_region: region, 119 | aws_appsync_authenticationType: "API_KEY", 120 | aws_appsync_apiKey: vals[getValueFromNamePart(`ApiKey`, keys)], 121 | }; 122 | 123 | saveTemplate(template, "../frontend/src/aws-exports.js"); 124 | })(); 125 | -------------------------------------------------------------------------------- /scripts/newFile.mjs: -------------------------------------------------------------------------------- 1 | import { mkdirSync, existsSync } from "node:fs"; 2 | import { join } from "node:path"; 3 | import { execa } from "execa"; 4 | import { getJSONFile, writeJSONFile } from "./convert-template.mjs"; 5 | 6 | (async () => { 7 | const currentDir = process.cwd(); 8 | const basePath = "../infra/cdk.out"; 9 | const stackName = "PetTracker"; 10 | const cfnTemplateFileName = `${stackName}.template.json`; 11 | const assetsFileName = `${stackName}.assets.json`; 12 | // Get original Cfn template 13 | const template = getJSONFile(join(basePath, cfnTemplateFileName)); 14 | // Remove Rules section 15 | delete template.Rules; 16 | // Empty Parameters section 17 | delete template.Parameters.BootstrapVersion; 18 | // Put a new Parameter for the S3 bucket where assets will be placed 19 | template.Parameters.AssetBucket = { 20 | Type: "String", 21 | Description: 22 | "Name of the Amazon S3 Bucket where the assets related to this stack will be found. The stack references this bucket.", 23 | }; 24 | // Put a new Parameter for the S3 bucket prefix where assets are 25 | template.Parameters.AssetPrefix = { 26 | Type: "String", 27 | Description: 28 | "Prefix of the Amazon S3 Bucket where the assets related to this stack are. This prefix is prepended to asset keys.", 29 | }; 30 | // Remove metadata from all resources 31 | Object.keys(template.Resources).forEach((resourceKey) => { 32 | if (!template.Resources[resourceKey].hasOwnProperty("Metadata")) return; 33 | delete template.Resources[resourceKey].Metadata; 34 | }); 35 | if (template.Resources.hasOwnProperty("Metadata")) 36 | delete template.Resources.Metadata; 37 | if (template.Resources.hasOwnProperty("CDKMetadata")) 38 | delete template.Resources.CDKMetadata; 39 | // Remove CDKMetadataAvailable from Conditions 40 | if ( 41 | template.hasOwnProperty("Conditions") && 42 | template.Conditions.hasOwnProperty("CDKMetadataAvailable") 43 | ) 44 | delete template.Conditions.CDKMetadataAvailable; 45 | // Remove main 46 | // Replace S3Bucket key in resources with Type===AWS::Lambda::Function && Code.S3Bucket 47 | Object.keys(template.Resources).forEach((resourceKey) => { 48 | if (template.Resources[resourceKey].Type !== "AWS::Lambda::Function") 49 | return; 50 | if (template.Resources[resourceKey].Properties.Code.ZipFile) return; 51 | template.Resources[resourceKey].Properties.Code.S3Bucket = { 52 | Ref: "AssetBucket", 53 | }; 54 | template.Resources[resourceKey].Properties.Code.S3Key = { 55 | "Fn::Sub": [ 56 | "${Prefix}" + template.Resources[resourceKey].Properties.Code.S3Key, 57 | { 58 | Prefix: { 59 | Ref: "AssetPrefix", 60 | }, 61 | }, 62 | ], 63 | }; 64 | }); 65 | 66 | // Empty or create the custom out directory 67 | const outDir = resolve(join(basePath, "deploy")); 68 | console.log(outDir); 69 | return false; 70 | if (existsSync(outDir)) { 71 | execa`rm -rf ${outDir}/*`; 72 | } else { 73 | mkdirSync(outDir); 74 | } 75 | 76 | // Get assets list 77 | const assets = getJSONFile(join(basePath, assetsFileName)); 78 | // Create a zip archive for each asset and place it in the out dir 79 | Object.values(assets.files).forEach((file) => { 80 | if (file.source.packaging !== "zip") return; 81 | execa`cd ${join(basePath, file.source.path)}`; 82 | execa`zip -rj ${file.destinations["current_account-current_region"].objectKey} ./`; 83 | execa`mv ${file.destinations["current_account-current_region"].objectKey} ${outDir}`; 84 | execa`cd ${currentDir}`; 85 | }); 86 | 87 | // Save modified Cfn template in the out dir 88 | writeJSONFile(template, join(outDir, `${cfnTemplateFileName}`)); 89 | })(); 90 | -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scripts", 3 | "version": "2.0.0", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "createConfig": "node create-aws-exports.mjs", 8 | "convertCDKtoCfn": "node convert-template.mjs" 9 | }, 10 | "author": { 11 | "name": "Amazon Web Services", 12 | "url": "https://aws.amazon.com" 13 | }, 14 | "license": "MIT-0", 15 | "dependencies": { 16 | "@aws-sdk/client-cloudformation": "^3.821.0", 17 | "execa": "^9.5.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/resize.sh: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | #!/bin/bash 5 | 6 | # Specify the desired volume size in GiB as a command line argument. If not specified, default to 20 GiB. 7 | SIZE=${1:-20} 8 | 9 | # Get the ID of the environment host Amazon EC2 instance. 10 | INSTANCEID=$(curl http://169.254.169.254/latest/meta-data/instance-id) 11 | REGION=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') 12 | 13 | # Get the ID of the Amazon EBS volume associated with the instance. 14 | VOLUMEID=$(aws ec2 describe-instances \ 15 | --instance-id $INSTANCEID \ 16 | --query "Reservations[0].Instances[0].BlockDeviceMappings[0].Ebs.VolumeId" \ 17 | --output text \ 18 | --region $REGION) 19 | 20 | # Resize the EBS volume. 21 | aws ec2 modify-volume --volume-id $VOLUMEID --size $SIZE 22 | 23 | # Wait for the resize to finish. 24 | while [ \ 25 | "$(aws ec2 describe-volumes-modifications \ 26 | --volume-id $VOLUMEID \ 27 | --filters Name=modification-state,Values="optimizing","completed" \ 28 | --query "length(VolumesModifications)"\ 29 | --output text)" != "1" ]; do 30 | sleep 1 31 | done 32 | 33 | #Check if we're on an NVMe filesystem 34 | if [[ -e "/dev/xvda" && $(readlink -f /dev/xvda) = "/dev/xvda" ]] 35 | then 36 | # Rewrite the partition table so that the partition takes up all the space that it can. 37 | sudo growpart /dev/xvda 1 38 | 39 | # Expand the size of the file system. 40 | # Check if we're on AL2 41 | STR=$(cat /etc/os-release) 42 | SUB="VERSION_ID=\"2\"" 43 | if [[ "$STR" == *"$SUB"* ]] 44 | then 45 | sudo xfs_growfs -d / 46 | else 47 | sudo resize2fs /dev/xvda1 48 | fi 49 | 50 | else 51 | # Rewrite the partition table so that the partition takes up all the space that it can. 52 | sudo growpart /dev/nvme0n1 1 53 | 54 | # Expand the size of the file system. 55 | # Check if we're on AL2 56 | STR=$(cat /etc/os-release) 57 | SUB="VERSION_ID=\"2\"" 58 | if [[ "$STR" == *"$SUB"* ]] 59 | then 60 | sudo xfs_growfs -d / 61 | else 62 | sudo resize2fs /dev/nvme0n1p1 63 | fi 64 | fi --------------------------------------------------------------------------------