├── .all-contributorsrc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── pull_requests.yml │ └── release_tags.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── src ├── helpers.ts ├── index.ts ├── sls-config-parser.ts ├── sns-adapter.ts ├── sns-server.ts ├── tsconfig.json └── types.d.ts ├── test ├── mock │ ├── handler.ts │ ├── mock.state.ts │ └── multi.dot.handler.ts ├── spec │ └── sns.ts └── tsconfig.json ├── tslint.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "serverless-offline-sns", 3 | "projectOwner": "mj1618", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": false, 9 | "contributors": [ 10 | { 11 | "login": "mj1618", 12 | "name": "Matthew James", 13 | "avatar_url": "https://avatars0.githubusercontent.com/u/6138817?v=4", 14 | "profile": "https://github.com/mj1618", 15 | "contributions": [ 16 | "question", 17 | "code", 18 | "design", 19 | "doc", 20 | "example" 21 | ] 22 | }, 23 | { 24 | "login": "darbio", 25 | "name": "darbio", 26 | "avatar_url": "https://avatars0.githubusercontent.com/u/517620?v=4", 27 | "profile": "https://github.com/darbio", 28 | "contributions": [ 29 | "bug", 30 | "code" 31 | ] 32 | }, 33 | { 34 | "login": "TiVoMaker", 35 | "name": "TiVoMaker", 36 | "avatar_url": "https://avatars2.githubusercontent.com/u/5116271?v=4", 37 | "profile": "https://github.com/TiVoMaker", 38 | "contributions": [ 39 | "bug", 40 | "code", 41 | "design", 42 | "doc" 43 | ] 44 | }, 45 | { 46 | "login": "jadehwangsonos", 47 | "name": "Jade Hwang", 48 | "avatar_url": "https://avatars3.githubusercontent.com/u/32281536?v=4", 49 | "profile": "https://github.com/jadehwangsonos", 50 | "contributions": [ 51 | "bug" 52 | ] 53 | }, 54 | { 55 | "login": "bennettrogers", 56 | "name": "Bennett Rogers", 57 | "avatar_url": "https://avatars1.githubusercontent.com/u/933251?v=4", 58 | "profile": "https://github.com/bennettrogers", 59 | "contributions": [ 60 | "bug", 61 | "code" 62 | ] 63 | }, 64 | { 65 | "login": "jbreckel", 66 | "name": "Julius Breckel", 67 | "avatar_url": "https://avatars2.githubusercontent.com/u/9253219?v=4", 68 | "profile": "https://github.com/jbreckel", 69 | "contributions": [ 70 | "code", 71 | "example", 72 | "test" 73 | ] 74 | }, 75 | { 76 | "login": "RainaWLK", 77 | "name": "RainaWLK", 78 | "avatar_url": "https://avatars1.githubusercontent.com/u/29059474?v=4", 79 | "profile": "https://github.com/RainaWLK", 80 | "contributions": [ 81 | "bug", 82 | "code" 83 | ] 84 | }, 85 | { 86 | "login": "jamiel", 87 | "name": "Jamie Learmonth", 88 | "avatar_url": "https://avatars2.githubusercontent.com/u/33498?v=4", 89 | "profile": "http://www.boxlightmedia.com", 90 | "contributions": [ 91 | "bug" 92 | ] 93 | }, 94 | { 95 | "login": "gevorggalstyan", 96 | "name": "Gevorg A. Galstyan", 97 | "avatar_url": "https://avatars2.githubusercontent.com/u/2598355?v=4", 98 | "profile": "https://github.com/gevorggalstyan", 99 | "contributions": [ 100 | "bug", 101 | "code" 102 | ] 103 | }, 104 | { 105 | "login": "idmontie", 106 | "name": "Ivan Montiel", 107 | "avatar_url": "https://avatars3.githubusercontent.com/u/412382?v=4", 108 | "profile": "https://idmontie.github.io", 109 | "contributions": [ 110 | "bug", 111 | "code", 112 | "test" 113 | ] 114 | }, 115 | { 116 | "login": "mledom", 117 | "name": "Matt Ledom", 118 | "avatar_url": "https://avatars0.githubusercontent.com/u/205515?v=4", 119 | "profile": "https://github.com/mledom", 120 | "contributions": [ 121 | "code", 122 | "design" 123 | ] 124 | }, 125 | { 126 | "login": "kmfk", 127 | "name": "Keith Kirk", 128 | "avatar_url": "https://avatars3.githubusercontent.com/u/2430033?v=4", 129 | "profile": "http://kmfk.io", 130 | "contributions": [ 131 | "code", 132 | "design" 133 | ] 134 | }, 135 | { 136 | "login": "kobim", 137 | "name": "Kobi Meirson", 138 | "avatar_url": "https://avatars1.githubusercontent.com/u/679761?v=4", 139 | "profile": "https://github.com/kobim", 140 | "contributions": [ 141 | "code" 142 | ] 143 | }, 144 | { 145 | "login": "lagnat", 146 | "name": "Steve Green", 147 | "avatar_url": "https://avatars2.githubusercontent.com/u/2048655?v=4", 148 | "profile": "https://github.com/lagnat", 149 | "contributions": [ 150 | "code" 151 | ] 152 | }, 153 | { 154 | "login": "DanielSchaffer", 155 | "name": "Daniel", 156 | "avatar_url": "https://avatars1.githubusercontent.com/u/334487?v=4", 157 | "profile": "http://dandoes.net", 158 | "contributions": [ 159 | "bug", 160 | "code", 161 | "design" 162 | ] 163 | }, 164 | { 165 | "login": "byF", 166 | "name": "Zdenek Farana", 167 | "avatar_url": "https://avatars2.githubusercontent.com/u/592682?v=4", 168 | "profile": "https://zdenekfarana.com/", 169 | "contributions": [ 170 | "code" 171 | ] 172 | }, 173 | { 174 | "login": "woss", 175 | "name": "Daniel Maricic", 176 | "avatar_url": "https://avatars3.githubusercontent.com/u/80440?v=4", 177 | "profile": "https://woss.io", 178 | "contributions": [ 179 | "code" 180 | ] 181 | }, 182 | { 183 | "login": "BrandonE", 184 | "name": "Brandon Evans", 185 | "avatar_url": "https://avatars1.githubusercontent.com/u/542245?v=4", 186 | "profile": "http://www.brandonmevans.com", 187 | "contributions": [ 188 | "code" 189 | ] 190 | }, 191 | { 192 | "login": "astuyve", 193 | "name": "AJ Stuyvenberg", 194 | "avatar_url": "https://avatars0.githubusercontent.com/u/1598537?v=4", 195 | "profile": "https://aaronstuyvenberg.com", 196 | "contributions": [ 197 | "question", 198 | "code", 199 | "test" 200 | ] 201 | }, 202 | { 203 | "login": "jkruse14", 204 | "name": "justin.kruse", 205 | "avatar_url": "https://avatars1.githubusercontent.com/u/16331726?v=4", 206 | "profile": "https://github.com/jkruse14", 207 | "contributions": [ 208 | "test", 209 | "code" 210 | ] 211 | }, 212 | { 213 | "login": "Clement134", 214 | "name": "Clement134", 215 | "avatar_url": "https://avatars2.githubusercontent.com/u/6473775?v=4", 216 | "profile": "https://github.com/Clement134", 217 | "contributions": [ 218 | "bug", 219 | "code" 220 | ] 221 | }, 222 | { 223 | "login": "pjcav", 224 | "name": "PJ Cavanaugh", 225 | "avatar_url": "https://avatars3.githubusercontent.com/u/33069039?v=4", 226 | "profile": "https://github.com/pjcav", 227 | "contributions": [ 228 | "bug", 229 | "code" 230 | ] 231 | }, 232 | { 233 | "login": "victorsferreira", 234 | "name": "Victor Ferreira", 235 | "avatar_url": "https://avatars3.githubusercontent.com/u/25830138?v=4", 236 | "profile": "https://github.com/victorsferreira", 237 | "contributions": [ 238 | "bug", 239 | "code" 240 | ] 241 | }, 242 | { 243 | "login": "shierro", 244 | "name": "Theo", 245 | "avatar_url": "https://avatars2.githubusercontent.com/u/12129589?v=4", 246 | "profile": "https://github.com/shierro", 247 | "contributions": [ 248 | "doc" 249 | ] 250 | }, 251 | { 252 | "login": "mteleskycmp", 253 | "name": "Matt Telesky", 254 | "avatar_url": "https://avatars0.githubusercontent.com/u/47985584?v=4", 255 | "profile": "https://github.com/mteleskycmp", 256 | "contributions": [ 257 | "bug", 258 | "code" 259 | ] 260 | }, 261 | { 262 | "login": "perkyguy", 263 | "name": "Garrett Scott", 264 | "avatar_url": "https://avatars3.githubusercontent.com/u/4624648?v=4", 265 | "profile": "https://github.com/perkyguy", 266 | "contributions": [ 267 | "bug", 268 | "code" 269 | ] 270 | }, 271 | { 272 | "login": "Pat-rice", 273 | "name": "Patrice Gargiolo", 274 | "avatar_url": "https://avatars3.githubusercontent.com/u/428113?v=4", 275 | "profile": "https://github.com/Pat-rice", 276 | "contributions": [ 277 | "doc" 278 | ] 279 | }, 280 | { 281 | "login": "anaerobic", 282 | "name": "Michael W. Martin", 283 | "avatar_url": "https://avatars3.githubusercontent.com/u/5074290?v=4", 284 | "profile": "https://games.crossfit.com/athlete/110515", 285 | "contributions": [ 286 | "bug", 287 | "code" 288 | ] 289 | }, 290 | { 291 | "login": "mr-black-8", 292 | "name": "mr-black-8", 293 | "avatar_url": "https://avatars0.githubusercontent.com/u/18377620?v=4", 294 | "profile": "https://github.com/mr-black-8", 295 | "contributions": [ 296 | "bug", 297 | "code" 298 | ] 299 | }, 300 | { 301 | "login": "brocksamson", 302 | "name": "Matthew Miller", 303 | "avatar_url": "https://avatars1.githubusercontent.com/u/314629?v=4", 304 | "profile": "https://github.com/brocksamson", 305 | "contributions": [ 306 | "bug", 307 | "code" 308 | ] 309 | }, 310 | { 311 | "login": "jason-adnuntius", 312 | "name": "Jason Pell", 313 | "avatar_url": "https://avatars0.githubusercontent.com/u/52263930?v=4", 314 | "profile": "https://github.com/jason-adnuntius", 315 | "contributions": [ 316 | "code" 317 | ] 318 | }, 319 | { 320 | "login": "ziktar", 321 | "name": "ziktar", 322 | "avatar_url": "https://avatars2.githubusercontent.com/u/1040751?v=4", 323 | "profile": "https://github.com/ziktar", 324 | "contributions": [ 325 | "bug", 326 | "code" 327 | ] 328 | }, 329 | { 330 | "login": "stevencsf", 331 | "name": "stevencsf", 332 | "avatar_url": "https://avatars1.githubusercontent.com/u/7518762?v=4", 333 | "profile": "https://github.com/stevencsf", 334 | "contributions": [ 335 | "bug" 336 | ] 337 | }, 338 | { 339 | "login": "Alexandre-io", 340 | "name": "Alexandre", 341 | "avatar_url": "https://avatars.githubusercontent.com/u/8135542?v=4", 342 | "profile": "https://github.com/Alexandre-io", 343 | "contributions": [ 344 | "code" 345 | ] 346 | }, 347 | { 348 | "login": "k-k", 349 | "name": "Keith Kirk", 350 | "avatar_url": "https://avatars.githubusercontent.com/u/2430033?v=4", 351 | "profile": "http://kmfk.io/", 352 | "contributions": [ 353 | "code" 354 | ] 355 | }, 356 | { 357 | "login": "crash7", 358 | "name": "Christian Musa", 359 | "avatar_url": "https://avatars.githubusercontent.com/u/1450075?v=4", 360 | "profile": "https://github.com/crash7", 361 | "contributions": [ 362 | "code" 363 | ] 364 | }, 365 | { 366 | "login": "Glavin001", 367 | "name": "Glavin Wiechert", 368 | "avatar_url": "https://avatars.githubusercontent.com/u/1885333?v=4", 369 | "profile": "https://codepass.ca/", 370 | "contributions": [ 371 | "code" 372 | ] 373 | }, 374 | { 375 | "login": "jagregory", 376 | "name": "James Gregory", 377 | "avatar_url": "https://avatars.githubusercontent.com/u/10828?v=4", 378 | "profile": "https://www.jagregory.com/", 379 | "contributions": [ 380 | "code" 381 | ] 382 | }, 383 | { 384 | "login": "richlloydmiles", 385 | "name": "Richard", 386 | "avatar_url": "https://avatars.githubusercontent.com/u/8024768?v=4", 387 | "profile": "https://www.atheneum.ai/", 388 | "contributions": [ 389 | "code" 390 | ] 391 | }, 392 | { 393 | "login": "alex-vance", 394 | "name": "alex-vance", 395 | "avatar_url": "https://avatars.githubusercontent.com/u/50587352?v=4", 396 | "profile": "https://github.com/alex-vance", 397 | "contributions": [ 398 | "code" 399 | ] 400 | }, 401 | { 402 | "login": "christiangoltz", 403 | "name": "christiangoltz", 404 | "avatar_url": "https://avatars.githubusercontent.com/u/2478085?v=4", 405 | "profile": "https://github.com/christiangoltz", 406 | "contributions": [ 407 | "code" 408 | ] 409 | }, 410 | { 411 | "login": "artem7902", 412 | "name": "Artem Yefimenko", 413 | "avatar_url": "https://avatars.githubusercontent.com/u/26010756?v=4", 414 | "profile": "https://github.com/artem7902", 415 | "contributions": [ 416 | "code" 417 | ] 418 | }, 419 | { 420 | "login": "jhackshaw", 421 | "name": "Jeff Hackshaw", 422 | "avatar_url": "https://avatars.githubusercontent.com/u/36460150?v=4", 423 | "profile": "https://github.com/jhackshaw", 424 | "contributions": [ 425 | "code" 426 | ] 427 | }, 428 | { 429 | "login": "dsarlo", 430 | "name": "Daniel Sarlo", 431 | "avatar_url": "https://avatars.githubusercontent.com/u/16106087?v=4", 432 | "profile": "https://github.com/dsarlo", 433 | "contributions": [ 434 | "code" 435 | ] 436 | } 437 | ], 438 | "repoType": "github", 439 | "contributorsPerLine": 7, 440 | "repoHost": "https://github.com", 441 | "commitConvention": "none", 442 | "skipCi": true 443 | } 444 | -------------------------------------------------------------------------------- /.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 ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '23 12 * * 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' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/pull_requests.yml: -------------------------------------------------------------------------------- 1 | name: pull_requests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | name: Test and Build 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Use Node.js 18.x 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 18.x 15 | - name: install, lint, test, build 16 | run: | 17 | DEBUG=true yarn install --frozen-lockfile 18 | yarn run lint 19 | yarn test 20 | yarn build -------------------------------------------------------------------------------- /.github/workflows/release_tags.yml: -------------------------------------------------------------------------------- 1 | name: release_tags 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | name: Test and Build 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js 18.x 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18.x 18 | - name: install, lint, test, build 19 | run: | 20 | DEBUG=true yarn install --frozen-lockfile 21 | yarn run lint 22 | yarn test 23 | yarn build 24 | 25 | publish: 26 | name: Publish 27 | runs-on: ubuntu-latest 28 | needs: [test] 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Use Node.js 18.x 32 | uses: actions/setup-node@v3 33 | with: 34 | node-version: 18.x 35 | - name: install 36 | run: yarn install --frozen-lockfile 37 | 38 | - name: git-tag-name 39 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 40 | 41 | - name: git-tag-to-package-version 42 | run: npm version ${{ env.RELEASE_VERSION }} --no-git-tag-version 43 | 44 | - uses: JS-DevTools/npm-publish@v3 45 | with: 46 | token: ${{ secrets.NPM_TOKEN }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | dist/ 3 | .vscode/ 4 | coverage/ 5 | .nyc_output/ 6 | .idea 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tslint.json 3 | .travis.yml 4 | .vscode/ 5 | coverage/ 6 | .nyc_output/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Matthew James 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Looking for a maintainer for this project, email me if you are interested. 2 | 3 | # serverless-offline-sns 4 | A serverless plugin to listen to offline SNS and call lambda fns with events. 5 | 6 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 7 | ![build status](https://github.com/mj1618/serverless-offline-sns/actions/workflows/build.yml/badge.svg) 8 | [![npm version](https://badge.fury.io/js/serverless-offline-sns.svg)](https://badge.fury.io/js/serverless-offline-sns) 9 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing) 10 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 11 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 12 | [![All Contributors](https://img.shields.io/badge/all_contributors-33-orange.svg?style=flat-square)](#contributors) 13 | 14 | ## Docs 15 | - [Prerequisites](#prerequisites) 16 | - [Installation](#installation) 17 | - [Configure](#configure) 18 | - [Usage](#usage) 19 | - [Contributors](#contributors) 20 | 21 | For an example of a working application please see [serverless-offline-sns-example](https://github.com/mj1618/serverless-offline-sns-example) 22 | 23 | ## Prerequisites 24 | 25 | This plugin provides an SNS server configured automatically without you specifying an endpoint. 26 | 27 | If you'd rather use your own endpoint, e.g. from your AWS account or a [localstack](https://github.com/localstack/localstack) SNS server endpoint, you can put it in the custom config. See below for details. 28 | 29 | ## Installation 30 | 31 | Install the plugin 32 | ```bash 33 | npm install serverless-offline-sns --save 34 | ``` 35 | 36 | Let serverless know about the plugin 37 | ```YAML 38 | plugins: 39 | - serverless-offline-sns 40 | ``` 41 | 42 | Note that ordering matters when used with serverless-offline and serverless-webpack. serverless-webpack must be specified at the start of the list of plugins. 43 | 44 | Configure the plugin with your offline SNS endpoint, host to listen on, and a free port the plugin can use. 45 | 46 | ```YAML 47 | custom: 48 | serverless-offline-sns: 49 | port: 4002 # a free port for the sns server to run on 50 | debug: false 51 | # host: 0.0.0.0 # Optional, defaults to 127.0.0.1 if not provided to serverless-offline 52 | # sns-endpoint: http://127.0.0.1:4567 # Optional. Only if you want to use a custom SNS provider endpoint 53 | # sns-subscribe-endpoint: http://127.0.0.1:3000 # Optional. Only if you want to use a custom subscribe endpoint from SNS to send messages back to 54 | # accountId: 123456789012 # Optional 55 | # location: .build # Optional if the location of your handler.js is not in ./ (useful for typescript) 56 | ``` 57 | 58 | For example, if you would like to connect to AWS and have callbacks coming via ngrok, use: 59 | 60 | ```YAML 61 | serverless-offline-sns: 62 | sns-endpoint: sns.${self:provider.region}.amazonaws.com 63 | sns-subscribe-endpoint: 64 | remotePort: 80 65 | localPort: 66 | accountId: ${self:provider.accountId} 67 | ``` 68 | 69 | In normal operation, the plugin will use the same *--host* option as provided to serverless-offline. The *host* parameter as shown above overrides this setting. 70 | 71 | If you are using the [serverless-offline](https://github.com/dherault/serverless-offline) plugin serverless-offline-sns will start automatically. If you are not using this plugin you can run the following command instead: 72 | ```bash 73 | serverless offline-sns start 74 | ``` 75 | 76 | ## Configure 77 | 78 | Configure your function handlers with events as described in the [Serverless SNS Documentation](https://serverless.com/framework/docs/providers/aws/events/sns/) 79 | 80 | Here's an example `serverless.yml` config which calls a function on an SNS notifcation. Note that the offline-sns plugin will automatically pick up this config, subscribe to the topic and call the handler on an SNS notification. 81 | 82 | ```YAML 83 | functions: 84 | pong: 85 | handler: handler.pong 86 | events: 87 | - sns: test-topic 88 | ``` 89 | 90 | Or you can use the exact ARN of the topic, in 2 ways: 91 | ```YAML 92 | functions: 93 | pong: 94 | handler: handler.pong 95 | events: 96 | - sns: 97 | arn: "arn:aws:sns:us-east-1:123456789012:test-topic" # 1st way 98 | - sns: "arn:aws:sns:us-east-1:123456789012:test-topic-two" # 2nd way 99 | ``` 100 | 101 | Here's a demo of some code that will trigger this handler: 102 | 103 | ```javascript 104 | var AWS = require("aws-sdk"); // must be npm installed to use 105 | var sns = new AWS.SNS({ 106 | endpoint: "http://127.0.0.1:4002", 107 | region: "us-east-1", 108 | }); 109 | sns.publish({ 110 | Message: "{content: \"hello!\"}", 111 | MessageStructure: "json", 112 | TopicArn: "arn:aws:sns:us-east-1:123456789012:test-topic", 113 | }, () => { 114 | console.log("ping"); 115 | }); 116 | ``` 117 | 118 | Note the region that offline-sns will listen on is what is configured in your serverless.yml provider. 119 | 120 | ## Localstack docker configuration 121 | In order to listen to localstack SNS event, if localstack is started with docker, you need the following: 122 | ```YAML 123 | custom: 124 | serverless-offline-sns: 125 | host: 0.0.0.0 # Enable plugin to listen on every local address 126 | sns-subscribe-endpoint: 192.168.1.225 #Host ip address 127 | sns-endpoint: http://localhost:4575 # Default localstack sns endpoint 128 | ``` 129 | What happens is that the container running localstack will execute a POST request to the plugin, but to reach outside the container, it needs to use the host ip address. 130 | 131 | ## Hosted AWS SNS configuration 132 | 133 | In order to listen to a hosted SNS on AWS, you need the following: 134 | ```YAML 135 | custom: 136 | serverless-offline-sns: 137 | localPort: ${env:LOCAL_PORT} 138 | remotePort: ${env:SNS_SUBSCRIBE_REMOTE_PORT} 139 | host: 0.0.0.0 140 | sns-subscribe-endpoint: ${env:SNS_SUBSCRIBE_ENDPOINT} 141 | sns-endpoint: ${env:SNS_ENDPOINT} 142 | ``` 143 | 144 | If you want to unsubscribe when you stop your server, then call `sls offline-sns cleanup` when the script exits. 145 | 146 | ## Multiple serverless services configuration 147 | 148 | If you have multiple serverless services, please specify a root directory: 149 | ```YAML 150 | custom: 151 | serverless-offline-sns: 152 | servicesDirectory: "/path/to/directory" 153 | ``` 154 | 155 | The root directory must contain directories with serverless.yaml files inside. 156 | 157 | ## Usage 158 | 159 | If you use [serverless-offline](https://github.com/dherault/serverless-offline) this plugin will start automatically. 160 | 161 | However if you don't use serverless-offline you can start this plugin manually with - 162 | ```bash 163 | serverless offline-sns start 164 | ``` 165 | 166 | ### Subscribing 167 | 168 | `serverless-offline-sns` supports `http`, `https`, and `sqs` subscriptions. `email`, `email-json`, 169 | `sms`, `application`, and `lambda` protocols are not supported at this time. 170 | 171 | When using `sqs` the `Endpoint` for the subscription must be the full `QueueUrl` returned from 172 | the SQS service when creating the queue or listing with `ListQueues`: 173 | 174 | ```javascript 175 | // async 176 | const queue = await sqs.createQueue({ QueueName: 'my-queue' }).promise(); 177 | const subscription = await sns.subscribe({ 178 | TopicArn: myTopicArn, 179 | Protocol: 'sqs', 180 | Endpoint: queue.QueueUrl, 181 | }).promise(); 182 | ``` 183 | 184 | ## Contributors 185 | 186 | Happy to accept contributions, [feature requests](https://github.com/mj1618/serverless-offline-sns/issues) and [issues](https://github.com/mj1618/serverless-offline-sns/issues). 187 | 188 | Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 |

Matthew James

💬 💻 🎨 📖 💡

darbio

🐛 💻

TiVoMaker

🐛 💻 🎨 📖

Jade Hwang

🐛

Bennett Rogers

🐛 💻

Julius Breckel

💻 💡 ⚠️

RainaWLK

🐛 💻

Jamie Learmonth

🐛

Gevorg A. Galstyan

🐛 💻

Ivan Montiel

🐛 💻 ⚠️

Matt Ledom

💻 🎨

Keith Kirk

💻 🎨

Kobi Meirson

💻

Steve Green

💻

Daniel

🐛 💻 🎨

Zdenek Farana

💻

Daniel Maricic

💻

Brandon Evans

💻

AJ Stuyvenberg

💬 💻 ⚠️

justin.kruse

⚠️ 💻

Clement134

🐛 💻

PJ Cavanaugh

🐛 💻

Victor Ferreira

🐛 💻

Theo

📖

Matt Telesky

🐛 💻

Garrett Scott

🐛 💻

Patrice Gargiolo

📖

Michael W. Martin

🐛 💻

mr-black-8

🐛 💻

Matthew Miller

🐛 💻

Jason Pell

💻

ziktar

🐛 💻

stevencsf

🐛

Alexandre

💻

Keith Kirk

💻

Christian Musa

💻

Glavin Wiechert

💻

James Gregory

💻

Richard

💻

alex-vance

💻

christiangoltz

💻

Artem Yefimenko

💻

Jeff Hackshaw

💻

Daniel Sarlo

💻
253 | 254 | 255 | 256 | 257 | 258 | 259 | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! 260 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-offline-sns", 3 | "version": "version", 4 | "description": "Serverless plugin to run a local SNS server and call lambdas with events notifications.", 5 | "exports": "./dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc -p src", 9 | "watch": "tsc -p src -w", 10 | "test": "nyc ts-mocha -r ts-node/register --loader=ts-node/esm --no-warnings=ExperimentalWarning --experimental-specifier-resolution=node \"test/**/*.ts\" -p test/tsconfig.json", 11 | "lint": "tslint -c tslint.json 'src/**/*'", 12 | "prepare": "yarn run lint && yarn test && yarn build", 13 | "prettier": "npx prettier --write src test", 14 | "upgrade": "npx npm-check-updates -u" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mj1618/serverless-offline-sns.git" 19 | }, 20 | "keywords": [ 21 | "serverless-plugin", 22 | "serverless", 23 | "sns", 24 | "offline", 25 | "localstack" 26 | ], 27 | "publishConfig": { 28 | "registry": "https://registry.npmjs.org/" 29 | }, 30 | "author": "Matthew James ", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/mj1618/serverless-offline-sns/issues" 34 | }, 35 | "homepage": "https://github.com/mj1618/serverless-offline-sns#readme", 36 | "dependencies": { 37 | "@aws-sdk/client-sns": "^3.465.0", 38 | "@aws-sdk/client-sqs": "^3.465.0", 39 | "body-parser": "^1.20.2", 40 | "cors": "^2.8.5", 41 | "express": "^4.18.2", 42 | "lodash": "^4.17.21", 43 | "node-fetch": "^3.3.2", 44 | "serverless": "^3.38.0", 45 | "shelljs": "^0.8.5", 46 | "uuid": "^9.0.1", 47 | "xml": "^1.0.1" 48 | }, 49 | "devDependencies": { 50 | "@types/chai": "^4.3.11", 51 | "@types/cors": "^2.8.17", 52 | "@types/express": "^4.17.21", 53 | "@types/mocha": "^10.0.6", 54 | "@types/node": "^20.10.2", 55 | "@types/node-fetch": "^2.6.9", 56 | "@types/serverless": "^3.12.18", 57 | "@types/shelljs": "^0.8.15", 58 | "@types/uuid": "^9.0.7", 59 | "@types/xml": "^1.0.11", 60 | "all-contributors-cli": "^6.26.1", 61 | "aws-sdk-client-mock": "^3.0.0", 62 | "chai": "^4.3.10", 63 | "mocha": "^10.2.0", 64 | "nyc": "^15.1.0", 65 | "prettier": "3.1.0", 66 | "ts-mocha": "^10.0.0", 67 | "ts-node": "^10.9.1", 68 | "tslint": "^5.20.1", 69 | "tslint-config-prettier": "^1.18.0", 70 | "typescript": "^5.3.2" 71 | }, 72 | "nyc": { 73 | "extension": [ 74 | ".ts", 75 | ".tsx" 76 | ], 77 | "exclude": [ 78 | "**/*.d.ts" 79 | ], 80 | "include": [ 81 | "src/" 82 | ], 83 | "reporter": [ 84 | "html", 85 | "lcov" 86 | ], 87 | "all": true 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from "uuid"; 2 | import { MessageAttributes } from "./types.js"; 3 | 4 | export function createAttr() { 5 | return { 6 | _attr: { 7 | xmlns: "http://sns.amazonaws.com/doc/2010-03-31/", 8 | }, 9 | }; 10 | } 11 | 12 | export function createMetadata() { 13 | return { 14 | ResponseMetadata: [ 15 | { 16 | RequestId: uuid(), 17 | }, 18 | ], 19 | }; 20 | } 21 | 22 | export function arrayify(obj) { 23 | return Object.keys(obj).map((key) => { 24 | const x = {}; 25 | x[key] = obj[key]; 26 | return x; 27 | }); 28 | } 29 | 30 | export function parseMessageAttributes(body) { 31 | if (body.MessageStructure === "json") { 32 | return {}; 33 | } 34 | 35 | const entries = Object.keys(body) 36 | .filter((key) => key.startsWith("MessageAttributes.entry")) 37 | .reduce((prev, key) => { 38 | const index = key 39 | .replace("MessageAttributes.entry.", "") 40 | .match(/.*?(?=\.|$)/i)[0]; 41 | return prev.includes(index) ? prev : [...prev, index]; 42 | }, []); 43 | return entries 44 | .map((index) => `MessageAttributes.entry.${index}`) 45 | .reduce( 46 | (prev, baseKey) => ({ 47 | ...prev, 48 | [`${body[`${baseKey}.Name`]}`]: { 49 | Type: body[`${baseKey}.Value.DataType`], 50 | Value: 51 | body[`${baseKey}.Value.BinaryValue`] || 52 | body[`${baseKey}.Value.StringValue`], 53 | }, 54 | }), 55 | {} 56 | ); 57 | } 58 | 59 | export function parseAttributes(body) { 60 | const indices = Object.keys(body) 61 | .filter((key) => key.startsWith("Attributes.entry")) 62 | .reduce((prev, key) => { 63 | const index = key 64 | .replace("Attributes.entry.", "") 65 | .match(/.*?(?=\.|$)/i)[0]; 66 | return prev.includes(index) ? prev : [...prev, index]; 67 | }, []); 68 | const attrs = {}; 69 | for (const key of indices.map((index) => `Attributes.entry.${index}`)) { 70 | attrs[body[`${key}.key`]] = body[`${key}.value`]; 71 | } 72 | return attrs; 73 | } 74 | 75 | export function createSnsLambdaEvent( 76 | topicArn, 77 | subscriptionArn, 78 | subject, 79 | message, 80 | messageId, 81 | messageAttributes?, 82 | messageGroupId? 83 | ) { 84 | return { 85 | Records: [ 86 | { 87 | EventVersion: "1.0", 88 | EventSubscriptionArn: subscriptionArn, 89 | EventSource: "aws:sns", 90 | Sns: { 91 | SignatureVersion: "1", 92 | Timestamp: new Date().toISOString(), 93 | Signature: "EXAMPLE", 94 | SigningCertUrl: "EXAMPLE", 95 | MessageId: messageId, 96 | Message: message, 97 | MessageAttributes: messageAttributes || {}, 98 | ...(messageGroupId && { MessageGroupId: messageGroupId }), 99 | Type: "Notification", 100 | UnsubscribeUrl: "EXAMPLE", 101 | TopicArn: topicArn, 102 | Subject: subject, 103 | }, 104 | }, 105 | ], 106 | }; 107 | } 108 | 109 | export function createSnsTopicEvent( 110 | topicArn, 111 | subscriptionArn, 112 | subject, 113 | message, 114 | messageId, 115 | messageStructure, 116 | messageAttributes?, 117 | messageGroupId? 118 | ) { 119 | return { 120 | SignatureVersion: "1", 121 | Timestamp: new Date().toISOString(), 122 | Signature: "EXAMPLE", 123 | SigningCertUrl: "EXAMPLE", 124 | MessageId: messageId, 125 | Message: message, 126 | MessageStructure: messageStructure, 127 | MessageAttributes: messageAttributes || {}, 128 | ...(messageGroupId && { MessageGroupId: messageGroupId }), 129 | Type: "Notification", 130 | UnsubscribeUrl: "EXAMPLE", 131 | TopicArn: topicArn, 132 | Subject: subject, 133 | }; 134 | } 135 | 136 | export function createMessageId() { 137 | return uuid(); 138 | } 139 | 140 | const phoneNumberValidator = /^\++?[1-9]\d{1,14}$/; 141 | 142 | export function validatePhoneNumber(phoneNumber) { 143 | if (!phoneNumberValidator.test(phoneNumber)) { 144 | throw new Error(`PhoneNumber ${phoneNumber} is not valid to publish`); 145 | } 146 | return phoneNumber; 147 | } 148 | 149 | // the topics name is that last part of the ARN: 150 | // arn:aws:sns::: 151 | export const topicNameFromArn = (arn) => { 152 | const arnParts = arn.split(":"); 153 | return arnParts[arnParts.length - 1]; 154 | }; 155 | 156 | export const topicArnFromName = (name, region, accountId) => 157 | `arn:aws:sns:${region}:${accountId}:${name}`; 158 | 159 | export const formatMessageAttributes = ( 160 | messageAttributes: MessageAttributes 161 | ) => { 162 | const newMessageAttributes = {}; 163 | for (const [key, value] of Object.entries(messageAttributes)) { 164 | newMessageAttributes[key] = { 165 | DataType: value.Type, 166 | StringValue: value.Value, 167 | }; 168 | } 169 | return newMessageAttributes; 170 | }; 171 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as shell from "shelljs"; 2 | 3 | import { SNSAdapter } from "./sns-adapter.js"; 4 | import express from "express"; 5 | import cors from "cors"; 6 | import bodyParser from "body-parser"; 7 | import { ISNSAdapter } from "./types.js"; 8 | import { SNSServer } from "./sns-server.js"; 9 | import _ from "lodash"; 10 | import { resolve } from "path"; 11 | import { topicNameFromArn } from "./helpers.js"; 12 | import { spawn } from "child_process"; 13 | import lodashfp from 'lodash/fp.js'; 14 | const { get, has } = lodashfp; 15 | 16 | import { loadServerlessConfig } from "./sls-config-parser.js"; 17 | import url from 'url'; 18 | 19 | class ServerlessOfflineSns { 20 | private config: any; 21 | private serverless: any; 22 | public commands: object; 23 | private localPort: number; 24 | private remotePort: number; 25 | public hooks: object; 26 | private snsAdapter: ISNSAdapter; 27 | private app: any; 28 | private snsServer: any; 29 | private server: any; 30 | private options: any; 31 | private location: string; 32 | private region: string; 33 | private accountId: string; 34 | private servicesDirectory: string; 35 | private autoSubscribe: boolean; 36 | 37 | constructor(serverless: any, options: any = {}) { 38 | this.app = express(); 39 | this.app.use(cors()); 40 | this.app.use((req, res, next) => { 41 | // fix for https://github.com/s12v/sns/issues/45 not sending content-type 42 | req.headers["content-type"] = req.headers["content-type"] || "text/plain"; 43 | next(); 44 | }); 45 | this.app.use( 46 | bodyParser.json({ 47 | type: ["application/json", "text/plain"], 48 | limit: "10mb", 49 | }) 50 | ); 51 | this.options = options; 52 | this.serverless = serverless; 53 | 54 | this.commands = { 55 | "offline-sns": { 56 | usage: 57 | "Listens to offline SNS events and passes them to configured Lambda fns", 58 | lifecycleEvents: ["start", "cleanup"], 59 | commands: { 60 | start: { 61 | lifecycleEvents: ["init", "end"], 62 | }, 63 | cleanup: { 64 | lifecycleEvents: ["init"], 65 | }, 66 | }, 67 | }, 68 | }; 69 | 70 | this.hooks = { 71 | "before:offline:start": () => this.start(), 72 | "before:offline:start:init": () => this.start(), 73 | "after:offline:start:end": () => this.stop(), 74 | "offline-sns:start:init": () => { 75 | this.start(); 76 | return this.waitForSigint(); 77 | }, 78 | "offline-sns:cleanup:init": async () => { 79 | this.init(); 80 | this.setupSnsAdapter(); 81 | return this.unsubscribeAll(); 82 | }, 83 | "offline-sns:start:end": () => this.stop(), 84 | }; 85 | } 86 | 87 | public init() { 88 | process.env = _.extend( 89 | {}, 90 | process.env, 91 | this.serverless.service.provider.environment 92 | ); 93 | this.config = 94 | this.serverless.service.custom["serverless-offline-sns"] || {}; 95 | this.localPort = this.config.port || this.config.localPort || 4002; 96 | this.remotePort = this.config.port || this.config.remotePort || 4002; 97 | this.accountId = this.config.accountId || "123456789012"; 98 | const offlineConfig = 99 | this.serverless.service.custom["serverless-offline"] || {}; 100 | this.servicesDirectory = this.config.servicesDirectory || ""; 101 | this.location = process.cwd(); 102 | const locationRelativeToCwd = 103 | this.options.location || this.config.location || offlineConfig.location; 104 | if (locationRelativeToCwd) { 105 | this.location = process.cwd() + "/" + locationRelativeToCwd; 106 | } else if (this.serverless.config.servicePath) { 107 | this.location = this.serverless.config.servicePath; 108 | } 109 | if (this.serverless.service.provider.region) { 110 | this.region = this.serverless.service.provider.region; 111 | } else { 112 | this.region = "us-east-1"; 113 | } 114 | this.autoSubscribe = this.config.autoSubscribe === undefined ? true : this.config.autoSubscribe; 115 | } 116 | 117 | public async start() { 118 | this.init(); 119 | await this.listen(); 120 | await this.serve(); 121 | await this.subscribeAll(); 122 | return this.snsAdapter; 123 | } 124 | 125 | public async waitForSigint() { 126 | return new Promise((res) => { 127 | process.on("SIGINT", () => { 128 | this.log("Halting offline-sns server"); 129 | res(true); 130 | }); 131 | }); 132 | } 133 | 134 | public async serve() { 135 | this.snsServer = new SNSServer( 136 | (msg, ctx) => this.debug(msg, ctx), 137 | this.app, 138 | this.region, 139 | this.accountId 140 | ); 141 | } 142 | private getFunctionName(name) { 143 | let result; 144 | Object.entries(this.serverless.service.functions).forEach( 145 | ([funcName, funcValue]) => { 146 | const events = get(["events"], funcValue); 147 | events && 148 | events.forEach((event) => { 149 | const attribute = get(["sqs", "arn"], event); 150 | if (!has("Fn::GetAtt", attribute)) return; 151 | const [resourceName, value] = attribute["Fn::GetAtt"]; 152 | if (value !== "Arn") return; 153 | if (name !== resourceName) return; 154 | result = funcName; 155 | }); 156 | } 157 | ); 158 | return result; 159 | } 160 | 161 | private getResourceSubscriptions(serverless) { 162 | const resources = serverless.service.resources?.Resources; 163 | const subscriptions = []; 164 | if (!resources) return subscriptions; 165 | new Map(Object.entries(resources)).forEach((value, key) => { 166 | let type = get(["Type"], value); 167 | if (type !== "AWS::SNS::Subscription") return; 168 | 169 | const endPoint = get(["Properties", "Endpoint"], value); 170 | if (!has("Fn::GetAtt", endPoint)) return; 171 | 172 | const [resourceName, attribute] = endPoint["Fn::GetAtt"]; 173 | type = get(["Type"], resources[resourceName]); 174 | if (attribute !== "Arn") return; 175 | if (type !== "AWS::SQS::Queue") return; 176 | 177 | const filterPolicy = get(["Properties", "FilterPolicy"], value); 178 | const protocol = get(["Properties", "Protocol"], value); 179 | const rawMessageDelivery = get( 180 | ["Properties", "RawMessageDelivery"], 181 | value 182 | ); 183 | const topicArn = get(["Properties", "TopicArn", "Ref"], value); 184 | const topicName = get(["Properties", "TopicName"], resources[topicArn]); 185 | const fnName = this.getFunctionName(resourceName); 186 | 187 | if(!topicName){ 188 | this.log(`${fnName} does not have a topic name, skipping`); 189 | return; 190 | } 191 | 192 | if(!fnName){ 193 | this.log(`${topicName} does not have a function, skipping`); 194 | return; 195 | } 196 | subscriptions.push({ 197 | fnName, 198 | options: { 199 | topicName, 200 | protocol, 201 | rawMessageDelivery, 202 | filterPolicy, 203 | }, 204 | }); 205 | }); 206 | return subscriptions; 207 | } 208 | public async subscribeAll() { 209 | this.setupSnsAdapter(); 210 | await this.unsubscribeAll(); 211 | this.debug("subscribing functions"); 212 | const subscribePromises: Array> = []; 213 | if (this.autoSubscribe) { 214 | if (this.servicesDirectory) { 215 | shell.cd(this.servicesDirectory); 216 | for (const directory of shell.ls("-d", "*/")) { 217 | shell.cd(directory); 218 | const service = directory.split("/")[0]; 219 | const serverless = await loadServerlessConfig(shell.pwd().toString(), this.debug); 220 | this.debug("Processing subscriptions for ", service); 221 | this.debug("shell.pwd()", shell.pwd()); 222 | this.debug("serverless functions", JSON.stringify(serverless.service.functions)); 223 | const subscriptions = this.getResourceSubscriptions(serverless); 224 | subscriptions.forEach((subscription) => 225 | subscribePromises.push( 226 | this.subscribeFromResource(subscription, this.location) 227 | ) 228 | ); 229 | Object.keys(serverless.service.functions).map((fnName) => { 230 | const fn = serverless.service.functions[fnName]; 231 | subscribePromises.push( 232 | Promise.all( 233 | fn.events 234 | .filter((event) => event.sns != null) 235 | .map((event) => { 236 | return this.subscribe( 237 | serverless, 238 | fnName, 239 | event.sns, 240 | shell.pwd() 241 | ); 242 | }) 243 | ) 244 | ); 245 | }); 246 | shell.cd("../"); 247 | } 248 | } else { 249 | const subscriptions = this.getResourceSubscriptions(this.serverless); 250 | subscriptions.forEach((subscription) => 251 | subscribePromises.push( 252 | this.subscribeFromResource(subscription, this.location) 253 | ) 254 | ); 255 | Object.keys(this.serverless.service.functions).map((fnName) => { 256 | const fn = this.serverless.service.functions[fnName]; 257 | subscribePromises.push( 258 | Promise.all( 259 | fn.events 260 | .filter((event) => event.sns != null) 261 | .map((event) => { 262 | return this.subscribe( 263 | this.serverless, 264 | fnName, 265 | event.sns, 266 | this.location 267 | ); 268 | }) 269 | ) 270 | ); 271 | }); 272 | } 273 | } 274 | await this.subscribeAllQueues(subscribePromises); 275 | } 276 | 277 | private async subscribeAllQueues(subscribePromises) { 278 | await Promise.all(subscribePromises); 279 | this.debug("subscribing queues"); 280 | await Promise.all( 281 | (this.config.subscriptions || []).map((sub) => { 282 | return this.subscribeQueue(sub.queue, sub.topic); 283 | }) 284 | ); 285 | } 286 | 287 | private async subscribeFromResource(subscription, location) { 288 | this.debug("subscribe: " + subscription.fnName); 289 | this.log( 290 | `Creating topic: "${subscription.options.topicName}" for fn "${subscription.fnName}"` 291 | ); 292 | const data = await this.snsAdapter.createTopic( 293 | subscription.options.topicName 294 | ); 295 | this.debug("topic: " + JSON.stringify(data)); 296 | const fn = this.serverless.service.functions[subscription.fnName]; 297 | const handler = await this.createHandler(subscription.fnName, fn, location); 298 | await this.snsAdapter.subscribe( 299 | fn, 300 | handler, 301 | data.TopicArn, 302 | subscription.options 303 | ); 304 | } 305 | public async unsubscribeAll() { 306 | const subs = await this.snsAdapter.listSubscriptions(); 307 | this.debug("subs!: " + JSON.stringify(subs)); 308 | await Promise.all( 309 | subs.Subscriptions.filter( 310 | (sub) => sub.Endpoint.indexOf(":" + this.remotePort) > -1 311 | ) 312 | .filter((sub) => sub.SubscriptionArn !== "PendingConfirmation") 313 | .map((sub) => this.snsAdapter.unsubscribe(sub.SubscriptionArn)) 314 | ); 315 | } 316 | 317 | public async subscribe(serverless, fnName, snsConfig, lambdasLocation) { 318 | this.debug("subscribe: " + fnName); 319 | const fn = serverless.service.functions[fnName]; 320 | 321 | if (!fn.runtime) { 322 | fn.runtime = serverless.service.provider.runtime; 323 | } 324 | 325 | let topicName = ""; 326 | 327 | // https://serverless.com/framework/docs/providers/aws/events/sns#using-a-pre-existing-topic 328 | if (typeof snsConfig === "string") { 329 | if (snsConfig.indexOf("arn:aws:sns") === 0) { 330 | topicName = topicNameFromArn(snsConfig); 331 | } else { 332 | topicName = snsConfig; 333 | } 334 | } else if (snsConfig.topicName && typeof snsConfig.topicName === "string") { 335 | topicName = snsConfig.topicName; 336 | } else if (snsConfig.arn && typeof snsConfig.arn === "string") { 337 | topicName = topicNameFromArn(snsConfig.arn); 338 | } 339 | 340 | if (!topicName) { 341 | this.log( 342 | `Unable to create topic for "${fnName}". Please ensure the sns configuration is correct.` 343 | ); 344 | return Promise.resolve( 345 | `Unable to create topic for "${fnName}". Please ensure the sns configuration is correct.` 346 | ); 347 | } 348 | 349 | this.log(`Creating topic: "${topicName}" for fn "${fnName}"`); 350 | const data = await this.snsAdapter.createTopic(topicName); 351 | this.debug("topic: " + JSON.stringify(data)); 352 | const handler = await this.createHandler(fnName, fn, lambdasLocation); 353 | await this.snsAdapter.subscribe( 354 | fn, 355 | handler, 356 | data.TopicArn, 357 | snsConfig 358 | ); 359 | } 360 | 361 | public async subscribeQueue(queueUrl, snsConfig) { 362 | this.debug("subscribe: " + queueUrl); 363 | let topicName = ""; 364 | 365 | // https://serverless.com/framework/docs/providers/aws/events/sns#using-a-pre-existing-topic 366 | if (typeof snsConfig === "string") { 367 | if (snsConfig.indexOf("arn:aws:sns") === 0) { 368 | topicName = topicNameFromArn(snsConfig); 369 | } else { 370 | topicName = snsConfig; 371 | } 372 | } else if (snsConfig.topicName && typeof snsConfig.topicName === "string") { 373 | topicName = snsConfig.topicName; 374 | } else if (snsConfig.arn && typeof snsConfig.arn === "string") { 375 | topicName = topicNameFromArn(snsConfig.arn); 376 | } 377 | 378 | if (!topicName) { 379 | this.log( 380 | `Unable to create topic for "${queueUrl}". Please ensure the sns configuration is correct.` 381 | ); 382 | return Promise.resolve( 383 | `Unable to create topic for "${queueUrl}". Please ensure the sns configuration is correct.` 384 | ); 385 | } 386 | 387 | this.log(`Creating topic: "${topicName}" for queue "${queueUrl}"`); 388 | const data = await this.snsAdapter.createTopic(topicName); 389 | this.debug("topic: " + JSON.stringify(data)); 390 | await this.snsAdapter.subscribeQueue(queueUrl, data.TopicArn, snsConfig); 391 | } 392 | 393 | public async createHandler(fnName, fn, location) { 394 | if (!fn.runtime || fn.runtime.startsWith("nodejs")) { 395 | return await this.createJavascriptHandler(fn, location); 396 | } else { 397 | return async () => await this.createProxyHandler(fnName, fn, location); 398 | } 399 | } 400 | 401 | public async createProxyHandler(funName, funOptions, location) { 402 | const options = this.options; 403 | return (event, context) => { 404 | const args = ["invoke", "local", "-f", funName]; 405 | const stage = options.s || options.stage; 406 | 407 | if (stage) { 408 | args.push("-s", stage); 409 | } 410 | 411 | // Use path to binary if provided, otherwise assume globally-installed 412 | const binPath = options.b || options.binPath; 413 | const cmd = binPath || "sls"; 414 | 415 | const process = spawn(cmd, args, { 416 | cwd: location, 417 | shell: true, 418 | stdio: ["pipe", "pipe", "pipe"], 419 | }); 420 | 421 | process.stdin.write(`${JSON.stringify(event)}\n`); 422 | process.stdin.end(); 423 | 424 | const results = []; 425 | let error = false; 426 | 427 | process.stdout.on("data", (data) => { 428 | if (data) { 429 | const str = data.toString(); 430 | if (str) { 431 | // should we check the debug flag & only log if debug is true? 432 | console.log(str); 433 | results.push(data.toString()); 434 | } 435 | } 436 | }); 437 | 438 | process.stderr.on("data", (data) => { 439 | error = true; 440 | console.warn("error", data); 441 | context.fail(data); 442 | }); 443 | 444 | process.on("close", (code) => { 445 | if (!error) { 446 | // try to parse to json 447 | // valid result should be a json array | object 448 | // technically a string is valid json 449 | // but everything comes back as a string 450 | // so we can't reliably detect json primitives with this method 451 | let response = null; 452 | // we go end to start because the one we want should be last 453 | // or next to last 454 | for (let i = results.length - 1; i >= 0; i--) { 455 | // now we need to find the min | max [] or {} within the string 456 | // if both exist then we need the outer one. 457 | // { "something": [] } is valid, 458 | // [{"something": "valid"}] is also valid 459 | // *NOTE* Doesn't currently support 2 separate valid json bundles 460 | // within a single result. 461 | // this can happen if you use a python logger 462 | // and then do log.warn(json.dumps({'stuff': 'here'})) 463 | const item = results[i]; 464 | const firstCurly = item.indexOf("{"); 465 | const firstSquare = item.indexOf("["); 466 | let start = 0; 467 | let end = item.length; 468 | if (firstCurly === -1 && firstSquare === -1) { 469 | // no json found 470 | continue; 471 | } 472 | if (firstSquare === -1 || firstCurly < firstSquare) { 473 | // found an object 474 | start = firstCurly; 475 | end = item.lastIndexOf("}") + 1; 476 | } else if (firstCurly === -1 || firstSquare < firstCurly) { 477 | // found an array 478 | start = firstSquare; 479 | end = item.lastIndexOf("]") + 1; 480 | } 481 | 482 | try { 483 | response = JSON.parse(item.substring(start, end)); 484 | break; 485 | } catch (err) { 486 | // not json, check the next one 487 | continue; 488 | } 489 | } 490 | if (response !== null) { 491 | context.succeed(response); 492 | } else { 493 | context.succeed(results.join("\n")); 494 | } 495 | } 496 | }); 497 | }; 498 | } 499 | 500 | public async createJavascriptHandler(fn, location) { 501 | // Options are passed from the command line in the options parameter 502 | this.debug(process.cwd()); 503 | const handlerFnNameIndex = fn.handler.lastIndexOf('.'); 504 | const handlerPath = fn.handler.substring(0, handlerFnNameIndex); 505 | const handlerFnName = fn.handler.substring(handlerFnNameIndex + 1); 506 | const fullHandlerPath = resolve(location, handlerPath); 507 | const handlers = await import(`${url.pathToFileURL(fullHandlerPath)}.js`); 508 | return handlers[handlerFnName] || handlers.default[handlerFnName]; 509 | } 510 | 511 | public log(msg, prefix = "INFO[serverless-offline-sns]: ") { 512 | this.serverless.cli.log.call(this.serverless.cli, prefix + msg); 513 | } 514 | 515 | public debug(msg, context?: string) { 516 | if (this.config.debug) { 517 | if (context) { 518 | this.log(msg, `DEBUG[serverless-offline-sns][${context}]: `); 519 | } else { 520 | this.log(msg, "DEBUG[serverless-offline-sns]: "); 521 | } 522 | } 523 | } 524 | 525 | public async listen() { 526 | this.debug("starting plugin"); 527 | let host = "127.0.0.1"; 528 | if (this.config.host) { 529 | this.debug(`using specified host ${this.config.host}`); 530 | host = this.config.host; 531 | } else if (this.options.host) { 532 | this.debug(`using offline specified host ${this.options.host}`); 533 | host = this.options.host; 534 | } 535 | return new Promise((res) => { 536 | this.server = this.app.listen(this.localPort, host, () => { 537 | this.debug(`listening on ${host}:${this.localPort}`); 538 | res(true); 539 | }); 540 | this.server.setTimeout(0); 541 | }); 542 | } 543 | 544 | public async stop() { 545 | this.init(); 546 | this.debug("stopping plugin"); 547 | if (this.server) { 548 | this.server.close(); 549 | } 550 | } 551 | 552 | private setupSnsAdapter() { 553 | this.snsAdapter = new SNSAdapter( 554 | this.localPort, 555 | this.remotePort, 556 | this.serverless.service.provider.region, 557 | this.config["sns-endpoint"], 558 | (msg, ctx) => this.debug(msg, ctx), 559 | this.app, 560 | this.serverless.service.service, 561 | this.serverless.service.provider.stage, 562 | this.accountId, 563 | this.config.host, 564 | this.config["sns-subscribe-endpoint"] 565 | ); 566 | } 567 | } 568 | 569 | export default ServerlessOfflineSns; -------------------------------------------------------------------------------- /src/sls-config-parser.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import Serverless from "serverless"; 4 | import findConfigPath from 'serverless/lib/cli/resolve-configuration-path.js'; 5 | 6 | export async function loadServerlessConfig(cwd = process.cwd(), debug) { 7 | console.log("debug loadServerlessConfig", cwd); 8 | const stat = fs.statSync(cwd); 9 | if (!stat.isDirectory()) { 10 | cwd = path.dirname(cwd); 11 | } 12 | 13 | const configurationPath = await findConfigPath({cwd}); 14 | const serverless = new Serverless({configurationPath}); 15 | await serverless.init(); 16 | return serverless; 17 | } 18 | -------------------------------------------------------------------------------- /src/sns-adapter.ts: -------------------------------------------------------------------------------- 1 | import { ListSubscriptionsResponse, ListTopicsResponse, MessageAttributeValue, SNSClient, ListTopicsCommand, ListSubscriptionsCommand, UnsubscribeCommand, CreateTopicCommand, SubscribeCommand, PublishCommand } from "@aws-sdk/client-sns"; 2 | import _ from "lodash"; 3 | import fetch from "node-fetch"; 4 | import { createMessageId, createSnsLambdaEvent } from "./helpers.js"; 5 | import { IDebug, ISNSAdapter } from "./types.js"; 6 | 7 | export class SNSAdapter implements ISNSAdapter { 8 | private sns: SNSClient; 9 | private pluginDebug: IDebug; 10 | private port: number; 11 | private server: any; 12 | private app: any; 13 | private serviceName: string; 14 | private stage: string; 15 | private endpoint: string; 16 | private adapterEndpoint: string; 17 | private baseSubscribeEndpoint: string; 18 | private accountId: string; 19 | 20 | constructor( 21 | localPort, 22 | remotePort, 23 | region, 24 | snsEndpoint, 25 | debug, 26 | app, 27 | serviceName, 28 | stage, 29 | accountId, 30 | host, 31 | subscribeEndpoint 32 | ) { 33 | this.pluginDebug = debug; 34 | this.app = app; 35 | this.serviceName = serviceName; 36 | this.stage = stage; 37 | this.adapterEndpoint = `http://${host || "127.0.0.1"}:${localPort}`; 38 | this.baseSubscribeEndpoint = subscribeEndpoint 39 | ? `http://${subscribeEndpoint}:${remotePort}` 40 | : this.adapterEndpoint; 41 | this.endpoint = snsEndpoint || `http://127.0.0.1:${localPort}`; 42 | this.debug("using endpoint: " + this.endpoint); 43 | this.accountId = accountId; 44 | this.sns = new SNSClient({ 45 | credentials: { 46 | accessKeyId: "AKID", 47 | secretAccessKey: "SECRET", 48 | }, 49 | endpoint: this.endpoint, 50 | region, 51 | }); 52 | } 53 | 54 | public async listTopics(): Promise { 55 | this.debug("listing topics"); 56 | const req = new ListTopicsCommand({}); 57 | this.debug(JSON.stringify(req.input)); 58 | 59 | return await new Promise((res) => { 60 | this.sns.send(req, (err, topics) => { 61 | if (err) { 62 | this.debug(err, err.stack); 63 | } else { 64 | this.debug(JSON.stringify(topics)); 65 | } 66 | res(topics); 67 | }); 68 | }); 69 | } 70 | 71 | public async listSubscriptions(): Promise { 72 | this.debug("listing subs"); 73 | const req = new ListSubscriptionsCommand({}); 74 | this.debug(JSON.stringify(req.input)); 75 | 76 | return await new Promise((res) => { 77 | this.sns.send(req, (err, subs) => { 78 | if (err) { 79 | this.debug(err, err.stack); 80 | } else { 81 | this.debug(JSON.stringify(subs)); 82 | } 83 | res(subs); 84 | }); 85 | }); 86 | } 87 | 88 | public async unsubscribe(arn) { 89 | this.debug("unsubscribing: " + arn); 90 | const unsubscribeReq = new UnsubscribeCommand({ SubscriptionArn: arn }); 91 | await new Promise((res) => { 92 | this.sns.send( 93 | unsubscribeReq, 94 | (err, data) => { 95 | if (err) { 96 | this.debug(err, err.stack); 97 | } else { 98 | this.debug("unsubscribed: " + JSON.stringify(data)); 99 | } 100 | res(true); 101 | } 102 | ); 103 | }); 104 | } 105 | 106 | public async createTopic(topicName) { 107 | const createTopicReq = new CreateTopicCommand({ Name: topicName }); 108 | return new Promise((res) => 109 | this.sns.send(createTopicReq, (err, data) => { 110 | if (err) { 111 | this.debug(err, err.stack); 112 | } else { 113 | this.debug("arn: " + JSON.stringify(data)); 114 | } 115 | res(data); 116 | }) 117 | ); 118 | } 119 | 120 | private sent: (data) => void; 121 | public Deferred = new Promise((res) => (this.sent = res)); 122 | 123 | public async subscribe(fn, getHandler, arn, snsConfig) { 124 | arn = this.convertPseudoParams(arn); 125 | const subscribeEndpoint = this.baseSubscribeEndpoint + "/" + fn.name; 126 | this.debug("subscribe: " + fn.name + " " + arn); 127 | this.debug("subscribeEndpoint: " + subscribeEndpoint); 128 | this.app.post("/" + fn.name, (req, res) => { 129 | this.debug("calling fn: " + fn.name + " 1"); 130 | const oldEnv = _.extend({}, process.env); 131 | process.env = _.extend({}, process.env, fn.environment); 132 | 133 | let event = req.body; 134 | if (req.is("text/plain") && req.get("x-amz-sns-rawdelivery") !== "true") { 135 | const msg = 136 | event.MessageStructure === "json" 137 | ? JSON.parse(event.Message).default 138 | : event.Message; 139 | event = createSnsLambdaEvent( 140 | event.TopicArn, 141 | "EXAMPLE", 142 | event.Subject || "", 143 | msg, 144 | event.MessageId || createMessageId(), 145 | event.MessageAttributes || {}, 146 | event.MessageGroupId 147 | ); 148 | } 149 | 150 | if (req.body.SubscribeURL) { 151 | this.debug("Visiting subscribe url: " + req.body.SubscribeURL); 152 | return fetch(req.body.SubscribeURL, { 153 | method: "GET" 154 | }).then((fetchResponse) => { 155 | this.debug("Subscribed: " + fetchResponse) 156 | res.status(200).send(); 157 | }); 158 | } 159 | 160 | const sendIt = (err, response) => { 161 | process.env = oldEnv; 162 | if (err) { 163 | res.status(500).send(err); 164 | this.sent(err); 165 | } else { 166 | res.send(response); 167 | this.sent(response); 168 | } 169 | }; 170 | const maybePromise = getHandler( 171 | event, 172 | this.createLambdaContext(fn, sendIt), 173 | sendIt 174 | ); 175 | if (maybePromise && maybePromise.then) { 176 | maybePromise 177 | .then((response) => sendIt(null, response)) 178 | .catch((error) => sendIt(error, null)); 179 | } 180 | }); 181 | const params = { 182 | Protocol: snsConfig.protocol || "http", 183 | TopicArn: arn, 184 | Endpoint: subscribeEndpoint, 185 | Attributes: {}, 186 | }; 187 | 188 | if (snsConfig.rawMessageDelivery === "true") { 189 | params.Attributes["RawMessageDelivery"] = "true"; 190 | } 191 | if (snsConfig.filterPolicy) { 192 | params.Attributes["FilterPolicy"] = JSON.stringify( 193 | snsConfig.filterPolicy 194 | ); 195 | } 196 | 197 | const subscribeRequest = new SubscribeCommand(params); 198 | await new Promise((res) => { 199 | this.sns.send(subscribeRequest, (err, data) => { 200 | if (err) { 201 | this.debug(err, err.stack); 202 | } else { 203 | this.debug( 204 | `successfully subscribed fn "${fn.name}" to topic: "${arn}"` 205 | ); 206 | } 207 | res(true); 208 | }); 209 | }); 210 | } 211 | 212 | public async subscribeQueue(queueUrl, arn, snsConfig) { 213 | arn = this.convertPseudoParams(arn); 214 | this.debug("subscribe: " + queueUrl + " " + arn); 215 | const params = { 216 | Protocol: snsConfig.protocol || "sqs", 217 | TopicArn: arn, 218 | Endpoint: queueUrl, 219 | Attributes: {}, 220 | }; 221 | 222 | if (snsConfig.rawMessageDelivery === "true") { 223 | params.Attributes["RawMessageDelivery"] = "true"; 224 | } 225 | if (snsConfig.filterPolicy) { 226 | params.Attributes["FilterPolicy"] = JSON.stringify( 227 | snsConfig.filterPolicy 228 | ); 229 | } 230 | 231 | const subscribeRequest = new SubscribeCommand(params); 232 | await new Promise((res) => { 233 | this.sns.send(subscribeRequest, (err, data) => { 234 | if (err) { 235 | this.debug(err, err.stack); 236 | } else { 237 | this.debug( 238 | `successfully subscribed queue "${queueUrl}" to topic: "${arn}"` 239 | ); 240 | } 241 | res(true); 242 | }); 243 | }); 244 | } 245 | 246 | public convertPseudoParams(topicArn) { 247 | const awsRegex = /#{AWS::([a-zA-Z]+)}/g; 248 | return topicArn.replace(awsRegex, this.accountId); 249 | } 250 | 251 | public async publish( 252 | topicArn: string, 253 | message: string, 254 | type: string = "", 255 | messageAttributes: Record = {}, 256 | subject: string = "", 257 | messageGroupId?: string 258 | ) { 259 | topicArn = this.convertPseudoParams(topicArn); 260 | const publishReq = new PublishCommand({ 261 | Message: message, 262 | Subject: subject, 263 | MessageStructure: type, 264 | TopicArn: topicArn, 265 | MessageAttributes: messageAttributes, 266 | ...(messageGroupId && { MessageGroupId: messageGroupId }), 267 | }); 268 | return await new Promise((resolve, reject) => 269 | this.sns.send( 270 | publishReq, 271 | (err, result) => { 272 | resolve(result); 273 | } 274 | ) 275 | ); 276 | } 277 | 278 | public async publishToTargetArn( 279 | targetArn: string, 280 | message: string, 281 | type: string = "", 282 | messageAttributes: Record = {}, 283 | messageGroupId?: string 284 | ) { 285 | targetArn = this.convertPseudoParams(targetArn); 286 | const publishReq = new PublishCommand({ 287 | Message: message, 288 | MessageStructure: type, 289 | TargetArn: targetArn, 290 | MessageAttributes: messageAttributes, 291 | ...(messageGroupId && { MessageGroupId: messageGroupId }), 292 | }); 293 | return await new Promise((resolve, reject) => 294 | this.sns.send( 295 | publishReq, 296 | (err, result) => { 297 | resolve(result); 298 | } 299 | ) 300 | ); 301 | } 302 | 303 | public async publishToPhoneNumber( 304 | phoneNumber: string, 305 | message: string, 306 | type: string = "", 307 | messageAttributes: Record = {}, 308 | messageGroupId?: string 309 | ) { 310 | const publishReq = new PublishCommand({ 311 | Message: message, 312 | MessageStructure: type, 313 | PhoneNumber: phoneNumber, 314 | MessageAttributes: messageAttributes, 315 | ...(messageGroupId && { MessageGroupId: messageGroupId }), 316 | }); 317 | return await new Promise((resolve, reject) => 318 | this.sns.send( 319 | publishReq, 320 | (err, result) => { 321 | resolve(result); 322 | } 323 | ) 324 | ); 325 | } 326 | 327 | public debug(msg, stack?: any) { 328 | this.pluginDebug(msg, "adapter"); 329 | } 330 | 331 | private createLambdaContext(fun, cb?) { 332 | const functionName = `${this.serviceName}-${this.stage}-${fun.name}`; 333 | const endTime = 334 | new Date().getTime() + (fun.timeout ? fun.timeout * 1000 : 6000); 335 | const done = typeof cb === "function" ? cb : (x, y) => x || y; // eslint-disable-line no-extra-parens 336 | 337 | return { 338 | /* Methods */ 339 | done, 340 | succeed: (res) => done(null, res), 341 | fail: (err) => done(err, null), 342 | getRemainingTimeInMillis: () => endTime - new Date().getTime(), 343 | 344 | /* Properties */ 345 | functionName, 346 | memoryLimitInMB: fun.memorySize || 1536, 347 | functionVersion: `offline_functionVersion_for_${functionName}`, 348 | invokedFunctionArn: `offline_invokedFunctionArn_for_${functionName}`, 349 | awsRequestId: `offline_awsRequestId_${Math.random() 350 | .toString(10) 351 | .slice(2)}`, 352 | logGroupName: `offline_logGroupName_for_${functionName}`, 353 | logStreamName: `offline_logStreamName_for_${functionName}`, 354 | identity: {}, 355 | clientContext: {}, 356 | }; 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /src/sns-server.ts: -------------------------------------------------------------------------------- 1 | import { TopicsList, Subscription } from "aws-sdk/clients/sns.js"; 2 | import fetch from "node-fetch"; 3 | import { URL } from "url"; 4 | import { IDebug, ISNSServer } from "./types.js"; 5 | import bodyParser from "body-parser"; 6 | import _ from "lodash"; 7 | import xml from "xml"; 8 | import { 9 | arrayify, 10 | createAttr, 11 | createMetadata, 12 | createSnsTopicEvent, 13 | parseMessageAttributes, 14 | parseAttributes, 15 | createMessageId, 16 | validatePhoneNumber, 17 | topicArnFromName, 18 | formatMessageAttributes, 19 | } from "./helpers.js"; 20 | import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; 21 | 22 | export class SNSServer implements ISNSServer { 23 | private topics: TopicsList; 24 | private subscriptions: Subscription[]; 25 | private pluginDebug: IDebug; 26 | private port: number; 27 | private server: any; 28 | private app: any; 29 | private region: string; 30 | private accountId: string; 31 | 32 | constructor(debug, app, region, accountId) { 33 | this.pluginDebug = debug; 34 | this.topics = []; 35 | this.subscriptions = []; 36 | this.app = app; 37 | this.region = region; 38 | this.routes(); 39 | this.accountId = accountId; 40 | } 41 | 42 | public routes() { 43 | this.debug("configuring route"); 44 | this.app.use(bodyParser.json({ limit: "10mb" })); // for parsing application/json 45 | this.app.use(bodyParser.urlencoded({ extended: true, limit: "10mb" })); // for parsing application/x-www-form-urlencoded 46 | this.app.use((req, res, next) => { 47 | res.header("Access-Control-Allow-Origin", "*"); 48 | res.header( 49 | "Access-Control-Allow-Headers", 50 | "Origin, X-Requested-With, Content-Type, Accept" 51 | ); 52 | next(); 53 | }); 54 | this.app.all("/", (req, res) => { 55 | this.debug("hello request"); 56 | this.debug(JSON.stringify(req.body)); 57 | this.debug(JSON.stringify(this.subscriptions)); 58 | if (req.body.Action === "ListSubscriptions") { 59 | this.debug( 60 | "sending: " + xml(this.listSubscriptions(), { indent: "\t" }) 61 | ); 62 | res.send(xml(this.listSubscriptions())); 63 | } else if (req.body.Action === "ListTopics") { 64 | this.debug("sending: " + xml(this.listTopics(), { indent: "\t" })); 65 | res.send(xml(this.listTopics())); 66 | } else if (req.body.Action === "CreateTopic") { 67 | res.send(xml(this.createTopic(req.body.Name))); 68 | } else if (req.body.Action === "Subscribe") { 69 | res.send( 70 | xml( 71 | this.subscribe( 72 | req.body.Endpoint, 73 | req.body.Protocol, 74 | req.body.TopicArn, 75 | req.body 76 | ) 77 | ) 78 | ); 79 | } else if (req.body.Action === "Publish") { 80 | const target = this.extractTarget(req.body); 81 | if (req.body.MessageStructure === "json") { 82 | const json = JSON.parse(req.body.Message); 83 | if (typeof json.default !== "string") { 84 | throw new Error("Messages must have default key"); 85 | } 86 | } 87 | 88 | res.send( 89 | xml( 90 | this.publish( 91 | target, 92 | req.body.Subject, 93 | req.body.Message, 94 | req.body.MessageStructure, 95 | parseMessageAttributes(req.body), 96 | req.body.MessageGroupId 97 | ) 98 | ) 99 | ); 100 | } else if (req.body.Action === "Unsubscribe") { 101 | res.send(xml(this.unsubscribe(req.body.SubscriptionArn))); 102 | } else { 103 | res.send( 104 | xml({ 105 | NotImplementedResponse: [createAttr(), createMetadata()], 106 | }) 107 | ); 108 | } 109 | this.debug(JSON.stringify(this.subscriptions)); 110 | }); 111 | } 112 | 113 | public listTopics() { 114 | this.debug("Topics: " + JSON.stringify(this.topics)); 115 | return { 116 | ListTopicsResponse: [ 117 | createAttr(), 118 | createMetadata(), 119 | { 120 | ListTopicsResult: [ 121 | { 122 | Topics: this.topics.map((topic) => { 123 | return { 124 | member: arrayify({ 125 | TopicArn: topic.TopicArn, 126 | }), 127 | }; 128 | }), 129 | }, 130 | ], 131 | }, 132 | ], 133 | }; 134 | } 135 | 136 | public listSubscriptions() { 137 | this.debug( 138 | this.subscriptions.map((sub) => { 139 | return { 140 | member: [sub], 141 | }; 142 | }) 143 | ); 144 | return { 145 | ListSubscriptionsResponse: [ 146 | createAttr(), 147 | createMetadata(), 148 | { 149 | ListSubscriptionsResult: [ 150 | { 151 | Subscriptions: this.subscriptions.map((sub) => { 152 | return { 153 | member: arrayify({ 154 | Endpoint: sub.Endpoint, 155 | TopicArn: sub.TopicArn, 156 | Owner: sub.Owner, 157 | Protocol: sub.Protocol, 158 | SubscriptionArn: sub.SubscriptionArn, 159 | }), 160 | }; 161 | }), 162 | }, 163 | ], 164 | }, 165 | ], 166 | }; 167 | } 168 | 169 | public unsubscribe(arn) { 170 | this.debug(JSON.stringify(this.subscriptions)); 171 | this.debug("unsubscribing: " + arn); 172 | this.subscriptions = this.subscriptions.filter( 173 | (sub) => sub.SubscriptionArn !== arn 174 | ); 175 | return { 176 | UnsubscribeResponse: [createAttr(), createMetadata()], 177 | }; 178 | } 179 | 180 | public createTopic(topicName) { 181 | const topicArn = topicArnFromName(topicName, this.region, this.accountId); 182 | const topic = { 183 | TopicArn: topicArn, 184 | }; 185 | if (!this.topics.find(({ TopicArn }) => TopicArn === topicArn)) { 186 | this.topics.push(topic); 187 | } 188 | return { 189 | CreateTopicResponse: [ 190 | createAttr(), 191 | createMetadata(), 192 | { 193 | CreateTopicResult: [ 194 | { 195 | TopicArn: topicArn, 196 | }, 197 | ], 198 | }, 199 | ], 200 | }; 201 | } 202 | 203 | public subscribe(endpoint, protocol, arn, body) { 204 | const attributes = parseAttributes(body); 205 | const filterPolicies = 206 | attributes["FilterPolicy"] && JSON.parse(attributes["FilterPolicy"]); 207 | arn = this.convertPseudoParams(arn); 208 | const existingSubscription = this.subscriptions.find((subscription) => { 209 | return ( 210 | subscription.Endpoint === endpoint && subscription.TopicArn === arn 211 | ); 212 | }); 213 | let subscriptionArn; 214 | if (!existingSubscription) { 215 | const sub = { 216 | SubscriptionArn: arn + ":" + Math.floor(Math.random() * (1000000 - 1)), 217 | Protocol: protocol, 218 | TopicArn: arn, 219 | Endpoint: endpoint, 220 | Owner: "", 221 | Attributes: attributes, 222 | Policies: filterPolicies, 223 | }; 224 | this.subscriptions.push(sub); 225 | subscriptionArn = sub.SubscriptionArn; 226 | } else { 227 | subscriptionArn = existingSubscription.SubscriptionArn; 228 | } 229 | return { 230 | SubscribeResponse: [ 231 | createAttr(), 232 | createMetadata(), 233 | { 234 | SubscribeResult: [ 235 | { 236 | SubscriptionArn: subscriptionArn, 237 | }, 238 | ], 239 | }, 240 | ], 241 | }; 242 | } 243 | 244 | private evaluatePolicies(policies: any, messageAttrs: any): boolean { 245 | let shouldSend: boolean = false; 246 | for (const [k, v] of Object.entries(policies)) { 247 | if (!messageAttrs[k]) { 248 | shouldSend = false; 249 | break; 250 | } 251 | let attrs; 252 | if (messageAttrs[k].Type.endsWith(".Array")) { 253 | attrs = JSON.parse(messageAttrs[k].Value); 254 | } else { 255 | attrs = [messageAttrs[k].Value]; 256 | } 257 | if (_.intersection(v as unknown[], attrs).length > 0) { 258 | this.debug( 259 | "filterPolicy Passed: " + 260 | v + 261 | " matched message attrs: " + 262 | JSON.stringify(attrs) 263 | ); 264 | shouldSend = true; 265 | } else { 266 | shouldSend = false; 267 | break; 268 | } 269 | } 270 | if (!shouldSend) { 271 | this.debug( 272 | "filterPolicy Failed: " + 273 | JSON.stringify(policies) + 274 | " did not match message attrs: " + 275 | JSON.stringify(messageAttrs) 276 | ); 277 | } 278 | 279 | return shouldSend; 280 | } 281 | 282 | private publishHttp(event, sub, raw) { 283 | return fetch(sub.Endpoint, { 284 | method: "POST", 285 | body: event, 286 | headers: { 287 | "x-amz-sns-rawdelivery": "" + raw, 288 | "Content-Type": "text/plain; charset=UTF-8", 289 | "Content-Length": Buffer.byteLength(event).toString(), 290 | }, 291 | }) 292 | .then((res) => this.debug(res)) 293 | .catch((ex) => this.debug(ex)); 294 | } 295 | 296 | private publishSqs(event, sub, messageAttributes, messageGroupId) { 297 | const subEndpointUrl = new URL(sub.Endpoint); 298 | const sqsEndpoint = `${subEndpointUrl.protocol}//${subEndpointUrl.host}/`; 299 | const sqs = new SQSClient({ endpoint: sqsEndpoint, region: this.region }); 300 | 301 | if (sub["Attributes"]["RawMessageDelivery"] === "true") { 302 | const sendMsgReq = new SendMessageCommand({ 303 | QueueUrl: sub.Endpoint, 304 | MessageBody: event, 305 | MessageAttributes: formatMessageAttributes(messageAttributes), 306 | ...(messageGroupId && { MessageGroupId: messageGroupId }), 307 | }); 308 | return new Promise((resolve, reject) => { 309 | sqs 310 | .send(sendMsgReq).then(() => { 311 | resolve(); 312 | }); 313 | }); 314 | } else { 315 | const records = JSON.parse(event).Records ?? []; 316 | const messagePromises = records.map((record) => { 317 | const sendMsgReq = new SendMessageCommand({ 318 | QueueUrl: sub.Endpoint, 319 | MessageBody: JSON.stringify(record.Sns), 320 | MessageAttributes: formatMessageAttributes(messageAttributes), 321 | ...(messageGroupId && { MessageGroupId: messageGroupId }), 322 | }); 323 | return new Promise((resolve, reject) => { 324 | sqs 325 | .send(sendMsgReq).then(() => { 326 | resolve(); 327 | }); 328 | }); 329 | }); 330 | return new Promise((resolve, reject) => { 331 | Promise.all(messagePromises).then(() => resolve()); 332 | }); 333 | } 334 | } 335 | 336 | public publish( 337 | topicArn, 338 | subject, 339 | message, 340 | messageStructure, 341 | messageAttributes, 342 | messageGroupId 343 | ) { 344 | const messageId = createMessageId(); 345 | Promise.all( 346 | this.subscriptions 347 | .filter((sub) => sub.TopicArn === topicArn) 348 | .map((sub) => { 349 | const isRaw = sub["Attributes"]["RawMessageDelivery"] === "true"; 350 | if ( 351 | sub["Policies"] && 352 | !this.evaluatePolicies(sub["Policies"], messageAttributes) 353 | ) { 354 | this.debug( 355 | "Filter policies failed. Skipping subscription: " + sub.Endpoint 356 | ); 357 | return; 358 | } 359 | this.debug("fetching: " + sub.Endpoint); 360 | let event; 361 | if (isRaw) { 362 | event = message; 363 | } else { 364 | event = JSON.stringify( 365 | createSnsTopicEvent( 366 | topicArn, 367 | sub.SubscriptionArn, 368 | subject, 369 | message, 370 | messageId, 371 | messageStructure, 372 | messageAttributes, 373 | messageGroupId 374 | ) 375 | ); 376 | } 377 | this.debug("event: " + event); 378 | if (!sub.Protocol) { 379 | sub.Protocol = "http"; 380 | } 381 | const protocol = sub.Protocol.toLowerCase(); 382 | if (protocol === "http") { 383 | return this.publishHttp(event, sub, isRaw); 384 | } 385 | if (protocol === "sqs") { 386 | return this.publishSqs( 387 | event, 388 | sub, 389 | messageAttributes, 390 | messageGroupId 391 | ); 392 | } 393 | throw new Error( 394 | `Protocol '${protocol}' is not supported by serverless-offline-sns` 395 | ); 396 | }) 397 | ); 398 | return { 399 | PublishResponse: [ 400 | createAttr(), 401 | { 402 | PublishResult: [ 403 | { 404 | MessageId: messageId, 405 | }, 406 | ], 407 | }, 408 | createMetadata(), 409 | ], 410 | }; 411 | } 412 | 413 | public extractTarget(body) { 414 | if (!body.PhoneNumber) { 415 | const target = body.TopicArn || body.TargetArn; 416 | if (!target) { 417 | throw new Error("TopicArn or TargetArn is missing"); 418 | } 419 | return this.convertPseudoParams(target); 420 | } else { 421 | return validatePhoneNumber(body.PhoneNumber); 422 | } 423 | } 424 | 425 | public convertPseudoParams(topicArn) { 426 | const awsRegex = /#{AWS::([a-zA-Z]+)}/g; 427 | return topicArn.replace(awsRegex, this.accountId); 428 | } 429 | 430 | public debug(msg) { 431 | if (msg instanceof Object) { 432 | try { 433 | msg = JSON.stringify(msg); 434 | } catch (ex) { } 435 | } 436 | this.pluginDebug(msg, "server"); 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "target": "es2022", 5 | "module": "es2022", 6 | "esModuleInterop": true, 7 | "outDir": "../dist", 8 | "allowUnreachableCode": true, 9 | "noImplicitAny": false, 10 | "declaration": false, 11 | "moduleResolution": "node", 12 | }, 13 | "include": ["**/*.ts"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ListSubscriptionsResponse, 3 | CreateTopicResponse, 4 | PublishResponse, 5 | ListTopicsResponse, 6 | } from "aws-sdk/clients/sns.d.js"; 7 | 8 | export type IDebug = (msg: any, stack?: any) => void; 9 | 10 | export type SLSHandler = (event, ctx, cb) => void; 11 | 12 | export interface ISNSAdapter { 13 | listTopics(): Promise; 14 | listSubscriptions(): Promise; 15 | unsubscribe(arn: string): Promise; 16 | createTopic(topicName: string): Promise; 17 | subscribe( 18 | fnName: string, 19 | handler: SLSHandler, 20 | arn: string, 21 | snsConfig: any 22 | ): Promise; 23 | subscribeQueue(queueUrl: string, arn: string, snsConfig: any): Promise; 24 | publish( 25 | topicArn: string, 26 | message: string 27 | ): Promise; 28 | } 29 | 30 | export type ISNSAdapterConstructable = new ( 31 | endpoint: string, 32 | port: number, 33 | region: string, 34 | debug: IDebug 35 | ) => ISNSAdapter; 36 | 37 | export interface ISNSServer { 38 | routes(); 39 | } 40 | 41 | export type MessageAttributes = IMessageAttribute[]; 42 | 43 | export interface IMessageAttribute { 44 | Type: string; 45 | Value: string; 46 | } 47 | 48 | -------------------------------------------------------------------------------- /test/mock/handler.ts: -------------------------------------------------------------------------------- 1 | import { setEvent, setPongs, setResult } from "./mock.state.js"; 2 | 3 | let nPongs = 0; 4 | 5 | export const resetPongs = () => { 6 | nPongs = 0; 7 | setPongs(0); 8 | }; 9 | 10 | export const pongHandler = (evt, ctx, cb) => { 11 | nPongs += 1; 12 | setPongs(nPongs); 13 | setEvent(evt); 14 | cb(null, "{}"); 15 | }; 16 | 17 | export const envHandler = (evt, ctx, cb) => { 18 | setResult(process.env["MY_VAR"]); 19 | cb(null, "{}"); 20 | }; 21 | 22 | export const pseudoHandler = (evt, ctx, cb) => { 23 | setResult(evt.Records[0].Sns.TopicArn); 24 | cb(null, "{}"); 25 | }; 26 | 27 | export const asyncHandler = async (evt, ctx) => { 28 | await new Promise((res) => setTimeout(res, 100)); 29 | setResult(evt.Records[0].Sns.TopicArn); 30 | return "{}"; 31 | }; 32 | 33 | const defaultExportHandler = (evt, ctx, cb) => { 34 | nPongs += 1; 35 | setPongs(nPongs); 36 | setEvent(evt); 37 | cb(null, "{}"); 38 | }; 39 | 40 | export default { defaultExportHandler }; 41 | -------------------------------------------------------------------------------- /test/mock/mock.state.ts: -------------------------------------------------------------------------------- 1 | let nPongs = 0; 2 | let event; 3 | let resolve; 4 | let deferred; 5 | 6 | export const resetResult = () => 7 | (deferred = new Promise((res) => (resolve = (result) => res(result)))); 8 | export const getResult = async () => deferred; 9 | export const setResult = (value) => { 10 | resolve(value); 11 | }; 12 | export const getPongs = () => nPongs; 13 | export const setPongs = (value) => (nPongs = value); 14 | export const getEvent = () => event; 15 | export const setEvent = (evt) => (event = evt); 16 | export const resetEvent = () => (event = undefined); 17 | export const addPong = () => { 18 | nPongs += 1; 19 | }; 20 | -------------------------------------------------------------------------------- /test/mock/multi.dot.handler.ts: -------------------------------------------------------------------------------- 1 | import { setPongs } from "./mock.state.js"; 2 | 3 | let nPongs = 0; 4 | 5 | export const itsGotDots = (evt, ctx, cb) => { 6 | nPongs += 1; 7 | setPongs(nPongs); 8 | cb(null, "{}"); 9 | }; 10 | -------------------------------------------------------------------------------- /test/spec/sns.ts: -------------------------------------------------------------------------------- 1 | import ServerlessOfflineSns from "../../src/index.js"; 2 | import { expect } from "chai"; 3 | import * as handler from "../mock/handler.js"; 4 | import * as state from "../mock/mock.state.js"; 5 | import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; 6 | import { mockClient } from 'aws-sdk-client-mock'; 7 | 8 | let plugin; 9 | 10 | describe("test", () => { 11 | let accountId; 12 | beforeEach(() => { 13 | accountId = Math.floor(Math.random() * (100000000 - 1)); 14 | handler.resetPongs(); 15 | state.resetEvent(); 16 | state.resetResult(); 17 | }); 18 | 19 | afterEach(() => { 20 | plugin.stop(); 21 | }); 22 | 23 | it("should start on offline start", async () => { 24 | plugin = new ServerlessOfflineSns(createServerless(accountId), { 25 | }); 26 | await plugin.hooks["before:offline:start:init"](); 27 | await plugin.hooks["after:offline:start:end"](); 28 | }); 29 | 30 | it("should start on command start", async () => { 31 | plugin = new ServerlessOfflineSns(createServerless(accountId), { 32 | }); 33 | plugin.hooks["offline-sns:start:init"](); 34 | await new Promise((res) => setTimeout(res, 100)); 35 | await plugin.hooks["offline-sns:start:end"](); 36 | }); 37 | 38 | it('should send event to topic ARN', async () => { 39 | plugin = new ServerlessOfflineSns(createServerless(accountId)); 40 | const snsAdapter = await plugin.start(); 41 | await snsAdapter.publish( 42 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 43 | "{}" 44 | ); 45 | await new Promise((res) => setTimeout(res, 100)); 46 | expect(state.getPongs()).to.eq(2); 47 | }); 48 | 49 | it("should send event to target ARN", async () => { 50 | plugin = new ServerlessOfflineSns(createServerless(accountId)); 51 | const snsAdapter = await plugin.start(); 52 | await snsAdapter.publishToTargetArn( 53 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 54 | "{}" 55 | ); 56 | await new Promise((res) => setTimeout(res, 100)); 57 | expect(state.getPongs()).to.eq(2); 58 | }); 59 | 60 | it("should send event with pseudo parameters", async () => { 61 | plugin = new ServerlessOfflineSns(createServerless(accountId)); 62 | const snsAdapter = await plugin.start(); 63 | await snsAdapter.publish( 64 | "arn:aws:sns:us-east-1:#{AWS::AccountId}:test-topic", 65 | "{}" 66 | ); 67 | await new Promise((res) => setTimeout(res, 100)); 68 | expect(state.getPongs()).to.eq(2); 69 | }); 70 | 71 | it("should send event with MessageAttributes and subject", async () => { 72 | plugin = new ServerlessOfflineSns(createServerless(accountId)); 73 | const snsAdapter = await plugin.start(); 74 | await snsAdapter.publish( 75 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 76 | "message with attributes", 77 | "raw", 78 | { 79 | with: { DataType: "String", StringValue: "attributes" }, 80 | }, 81 | "subject" 82 | ); 83 | await new Promise((res) => setTimeout(res, 100)); 84 | const event = state.getEvent(); 85 | expect(event.Records[0].Sns).to.have.property( 86 | "Message", 87 | "message with attributes" 88 | ); 89 | expect(event.Records[0].Sns).to.have.property("Subject", "subject"); 90 | expect(event.Records[0].Sns).to.have.deep.property("MessageAttributes", { 91 | with: { 92 | Type: "String", 93 | Value: "attributes", 94 | }, 95 | }); 96 | }); 97 | 98 | it("should return a valid response to publish", async () => { 99 | plugin = new ServerlessOfflineSns(createServerless(accountId)); 100 | const snsAdapter = await plugin.start(); 101 | const snsResponse = await snsAdapter.publish( 102 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 103 | "'a simple message'" 104 | ); 105 | await new Promise((res) => setTimeout(res, 100)); 106 | expect(snsResponse).to.have.property("$metadata"); 107 | expect(snsResponse.$metadata).to.have.property("requestId"); 108 | }); 109 | 110 | it("should send a message to a E.164 phone number", async () => { 111 | plugin = new ServerlessOfflineSns(createServerless(accountId)); 112 | const snsAdapter = await plugin.start(); 113 | const snsResponse = await snsAdapter.publishToPhoneNumber( 114 | `+10000000000`, 115 | "{}" 116 | ); 117 | await new Promise((res) => setTimeout(res, 100)); 118 | expect(snsResponse).to.have.property("$metadata"); 119 | expect(snsResponse.$metadata).to.have.property("requestId"); 120 | }); 121 | 122 | it("should error", async () => { 123 | plugin = new ServerlessOfflineSns(createServerlessBad(accountId), {}); 124 | const snsAdapter = await plugin.start(); 125 | const err = await plugin.subscribe( 126 | plugin.serverless, 127 | "badPong", 128 | createServerlessBad(accountId).service.functions.badPong, 129 | plugin.location 130 | ); 131 | expect( 132 | err.indexOf("Please ensure the sns configuration is correct") 133 | ).to.be.greaterThan(-1); 134 | await snsAdapter.publish( 135 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 136 | "{}" 137 | ); 138 | await new Promise((res) => setTimeout(res, 100)); 139 | expect(state.getPongs()).to.eq(0); 140 | }); 141 | 142 | it("should use the custom host for subscription urls", async () => { 143 | plugin = new ServerlessOfflineSns( 144 | createServerless(accountId, "pongHandler", "0.0.0.0") 145 | ); 146 | const snsAdapter = await plugin.start(); 147 | const response = await snsAdapter.listSubscriptions(); 148 | 149 | response.Subscriptions.forEach((sub) => { 150 | expect(sub.Endpoint.startsWith("http://0.0.0.0:4002")).to.be.true; 151 | }); 152 | }); 153 | 154 | it("should use the custom subscribe endpoint for subscription urls", async () => { 155 | plugin = new ServerlessOfflineSns( 156 | createServerless(accountId, "pongHandler", "0.0.0.0", "anotherHost") 157 | ); 158 | const snsAdapter = await plugin.start(); 159 | const response = await snsAdapter.listSubscriptions(); 160 | 161 | response.Subscriptions.forEach((sub) => { 162 | expect(sub.Endpoint.startsWith("http://anotherHost:4002")).to.be.true; 163 | }); 164 | }); 165 | 166 | it("should unsubscribe", async () => { 167 | plugin = new ServerlessOfflineSns(createServerless(accountId)); 168 | const snsAdapter = await plugin.start(); 169 | await plugin.unsubscribeAll(); 170 | await snsAdapter.publish( 171 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 172 | "{}" 173 | ); 174 | await new Promise((res) => setTimeout(res, 100)); 175 | expect(state.getPongs()).to.eq(0); 176 | }); 177 | 178 | it("should read env variable", async () => { 179 | plugin = new ServerlessOfflineSns( 180 | createServerless(accountId, "envHandler") 181 | ); 182 | const snsAdapter = await plugin.start(); 183 | await snsAdapter.publish( 184 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 185 | "{}" 186 | ); 187 | expect(await state.getResult()).to.eq("MY_VAL"); 188 | }); 189 | 190 | it("should read env variable for function", async () => { 191 | plugin = new ServerlessOfflineSns( 192 | createServerless(accountId, "envHandler") 193 | ); 194 | const snsAdapter = await plugin.start(); 195 | await snsAdapter.publish( 196 | `arn:aws:sns:us-east-1:${accountId}:test-topic-2`, 197 | "{}" 198 | ); 199 | expect(await state.getResult()).to.eq("TEST"); 200 | }); 201 | 202 | it("should convert pseudo param on load", async () => { 203 | plugin = new ServerlessOfflineSns( 204 | createServerless(accountId, "pseudoHandler") 205 | ); 206 | const snsAdapter = await plugin.start(); 207 | await snsAdapter.publish( 208 | `arn:aws:sns:us-east-1:${accountId}:test-topic-3`, 209 | "{}" 210 | ); 211 | expect(await state.getResult()).to.eq( 212 | `arn:aws:sns:us-east-1:${accountId}:test-topic-3` 213 | ); 214 | }); 215 | 216 | it("should send event to handlers with more than one dot in the filename", async () => { 217 | plugin = new ServerlessOfflineSns(createServerlessMultiDot(accountId)); 218 | const snsAdapter = await plugin.start(); 219 | await snsAdapter.publish( 220 | `arn:aws:sns:us-east-1:${accountId}:multi-dot-topic`, 221 | "{}" 222 | ); 223 | await new Promise((res) => setTimeout(res, 100)); 224 | expect(state.getPongs()).to.eq(1); 225 | }); 226 | 227 | it('should support commonjs/default handlers', async () => { 228 | plugin = new ServerlessOfflineSns(createServerless(accountId, "defaultExportHandler")); 229 | const snsAdapter = await plugin.start(); 230 | await snsAdapter.publish( 231 | `arn:aws:sns:us-east-1:${accountId}:test-topic`, 232 | "{}" 233 | ); 234 | await new Promise((res) => setTimeout(res, 100)); 235 | expect(state.getPongs()).to.eq(2); 236 | }); 237 | 238 | it('should support async handlers with no callback', async () => { 239 | plugin = new ServerlessOfflineSns(createServerless(accountId, "asyncHandler")); 240 | const snsAdapter = await plugin.start(); 241 | await snsAdapter.publish( 242 | `arn:aws:sns:us-east-1:${accountId}:test-topic-async`, 243 | "{}" 244 | ); 245 | await new Promise((res) => setTimeout(res, 100)); 246 | expect(await state.getResult()).to.eq( 247 | `arn:aws:sns:us-east-1:${accountId}:test-topic-async` 248 | ); 249 | expect(await snsAdapter.Deferred).to.eq("{}"); 250 | }); 251 | 252 | it("should not send event when filter policies exist and fail", async () => { 253 | plugin = new ServerlessOfflineSns( 254 | createServerlessWithFilterPolicies(accountId), 255 | {} 256 | ); 257 | const snsAdapter = await plugin.start(); 258 | await snsAdapter.publish( 259 | `arn:aws:sns:us-east-1:${accountId}:test-topic-policies`, 260 | "message with filter params", 261 | "raw", 262 | { 263 | foo: { DataType: "String", StringValue: "no" }, 264 | } 265 | ); 266 | await new Promise((res) => setTimeout(res, 100)); 267 | expect(state.getPongs()).to.eq(0); 268 | }); 269 | 270 | it("should send event when filter policies exist and pass", async () => { 271 | plugin = new ServerlessOfflineSns( 272 | createServerlessWithFilterPolicies(accountId) 273 | ); 274 | const snsAdapter = await plugin.start(); 275 | await snsAdapter.publish( 276 | `arn:aws:sns:us-east-1:${accountId}:test-topic-policies`, 277 | "message with filter params", 278 | "raw", 279 | { 280 | foo: { DataType: "String", StringValue: "bar" }, 281 | } 282 | ); 283 | await new Promise((res) => setTimeout(res, 100)); 284 | const event = state.getEvent(); 285 | expect(event.Records[0].Sns.Message).to.not.be.empty; 286 | }); 287 | 288 | it("should not send event when multiple filter policies exist and the message only contains one", async () => { 289 | plugin = new ServerlessOfflineSns( 290 | createServerlessWithFilterPolicies(accountId), 291 | {} 292 | ); 293 | const snsAdapter = await plugin.start(); 294 | await snsAdapter.publish( 295 | `arn:aws:sns:us-east-1:${accountId}:test-topic-policies-multiple`, 296 | "message with filter params", 297 | "raw", 298 | { 299 | foo: { DataType: "String", StringValue: "bar" }, 300 | } 301 | ); 302 | await new Promise((res) => setTimeout(res, 100)); 303 | expect(state.getPongs()).to.eq(0); 304 | }); 305 | 306 | it("should not send event when multiple filter policies exist and the message only satisfies one", async () => { 307 | plugin = new ServerlessOfflineSns( 308 | createServerlessWithFilterPolicies(accountId), 309 | {} 310 | ); 311 | const snsAdapter = await plugin.start(); 312 | await snsAdapter.publish( 313 | `arn:aws:sns:us-east-1:${accountId}:test-topic-policies-multiple`, 314 | "message with filter params", 315 | "raw", 316 | { 317 | foo: { DataType: "String", StringValue: "bar" }, 318 | second: { DataType: "String", StringValue: "bar" }, 319 | } 320 | ); 321 | await new Promise((res) => setTimeout(res, 100)); 322 | expect(state.getPongs()).to.eq(0); 323 | 324 | }); 325 | 326 | it("should not wrap the event when the sub's raw message delivery is true", async () => { 327 | const serverless = createServerless(accountId); 328 | serverless.service.functions.pong4.events[0].sns["rawMessageDelivery"] = 329 | "true"; 330 | plugin = new ServerlessOfflineSns(serverless); 331 | 332 | const snsAdapter = await plugin.start(); 333 | await snsAdapter.publish( 334 | `arn:aws:sns:us-east-1:${accountId}:test-topic-3`, 335 | '{"message":"hello"}' 336 | ); 337 | await new Promise((res) => setTimeout(res, 100)); 338 | expect(state.getEvent()).to.eql({ message: "hello" }); 339 | }); 340 | 341 | it("should list topics", async () => { 342 | plugin = new ServerlessOfflineSns(createServerless(accountId), {}); 343 | const snsAdapter = await plugin.start(); 344 | const { Topics } = await snsAdapter.listTopics(); 345 | await new Promise((res) => setTimeout(res, 100)); 346 | const topicArns = Topics.map((topic) => topic.TopicArn); 347 | expect(Topics.length).to.eq(5); 348 | expect(topicArns).to.include( 349 | `arn:aws:sns:us-east-1:${accountId}:test-topic` 350 | ); 351 | }); 352 | 353 | it("should subscribe", async () => { 354 | const sqsMock = mockClient(SQSClient); 355 | plugin = new ServerlessOfflineSns( 356 | createServerless(accountId, "envHandler") 357 | ); 358 | const snsAdapter = await plugin.start(); 359 | await plugin.subscribeAll(); 360 | await snsAdapter.publish( 361 | `arn:aws:sns:us-east-1:${accountId}:topic-pinging`, 362 | "{}" 363 | ); 364 | await new Promise((res) => setTimeout(res, 100)); 365 | const sqsSendArgs = sqsMock.send.args; 366 | expect(sqsMock.send.calledOnce).to.be.true; 367 | expect(sqsSendArgs[0][0].input).to.be.deep.equals({ 368 | QueueUrl: "http://127.0.0.1:4002/undefined", 369 | MessageBody: "{}", 370 | MessageAttributes: {}, 371 | }); 372 | sqsMock.restore(); 373 | }); 374 | 375 | it("should handle empty resource definition", async () => { 376 | const serverless = createServerless(accountId); 377 | serverless.service.resources = undefined; 378 | plugin = new ServerlessOfflineSns(serverless); 379 | await plugin.start(); 380 | }); 381 | 382 | it("should handle messageGroupId", async () => { 383 | const sqsMock = mockClient(SQSClient); 384 | plugin = new ServerlessOfflineSns( 385 | createServerless(accountId, "envHandler") 386 | ); 387 | const snsAdapter = await plugin.start(); 388 | await plugin.subscribeAll(); 389 | await snsAdapter.publish( 390 | `arn:aws:sns:us-east-1:${accountId}:topic-pinging`, 391 | "{}", 392 | "", 393 | {}, 394 | "", 395 | "messageGroupId-here" 396 | ); 397 | await new Promise((res) => setTimeout(res, 100)); 398 | const sqsSendArgs = sqsMock.send.args; 399 | expect(sqsMock.send.calledOnce).to.be.true; 400 | expect(sqsSendArgs[0][0].input).to.be.deep.equals({ 401 | QueueUrl: "http://127.0.0.1:4002/undefined", 402 | MessageBody: "{}", 403 | MessageAttributes: {}, 404 | MessageGroupId: "messageGroupId-here", 405 | }); 406 | sqsMock.restore(); 407 | }); 408 | }); 409 | 410 | const createServerless = ( 411 | accountId: number, 412 | handlerName: string = "pongHandler", 413 | host: string | null = null, 414 | subscribeEndpoint: string | null = null 415 | ) => { 416 | return { 417 | config: {}, 418 | service: { 419 | custom: { 420 | "serverless-offline-sns": { 421 | debug: true, 422 | port: 4002, 423 | accountId, 424 | host, 425 | "sns-subscribe-endpoint": subscribeEndpoint, 426 | }, 427 | }, 428 | provider: { 429 | region: "us-east-1", 430 | environment: { 431 | MY_VAR: "MY_VAL", 432 | }, 433 | }, 434 | functions: { 435 | pong: { 436 | name: "queue-one", 437 | handler: "test/mock/handler." + handlerName, 438 | events: [ 439 | { 440 | sns: `arn:aws:sns:us-east-1:${accountId}:test-topic`, 441 | }, 442 | ], 443 | }, 444 | pong2: { 445 | name: "queue-two", 446 | handler: "test/mock/handler." + handlerName, 447 | events: [ 448 | { 449 | sns: { 450 | arn: `arn:aws:sns:us-east-1:${accountId}:test-topic`, 451 | }, 452 | }, 453 | ], 454 | }, 455 | pong3: { 456 | name: "this-is-auto-created-when-using-serverless", 457 | handler: "test/mock/handler." + handlerName, 458 | environment: { 459 | MY_VAR: "TEST", 460 | }, 461 | events: [ 462 | { 463 | sns: { 464 | arn: `arn:aws:sns:us-east-1:${accountId}:test-topic-2`, 465 | }, 466 | }, 467 | ], 468 | }, 469 | pong4: { 470 | handler: "test/mock/handler." + handlerName, 471 | events: [ 472 | { 473 | sns: { 474 | arn: `arn:aws:sns:us-east-1:#{AWS::AccountId}:test-topic-3`, 475 | }, 476 | }, 477 | ], 478 | }, 479 | pong5: { 480 | handler: "test/mock/handler." + handlerName, 481 | events: [ 482 | { 483 | sns: { 484 | arn: `arn:aws:sns:us-east-1:#{AWS::AccountId}:test-topic-async`, 485 | }, 486 | }, 487 | ], 488 | }, 489 | pong6: { 490 | handler: "test/mock/handler." + handlerName, 491 | events: [ 492 | { 493 | sqs: { 494 | arn: { 495 | "Fn::GetAtt": ["pong6", "Arn"], 496 | }, 497 | }, 498 | }, 499 | ], 500 | }, 501 | }, 502 | resources: { 503 | Resources: { 504 | pong6QueueSubscription: { 505 | Type: "AWS::SNS::Subscription", 506 | Properties: { 507 | Protocol: "sqs", 508 | Endpoint: { 509 | "Fn::GetAtt": ["pong6", "Arn"], 510 | }, 511 | RawMessageDelivery: "true", 512 | TopicArn: { 513 | Ref: "pinging", 514 | }, 515 | }, 516 | }, 517 | pong6: { 518 | Type: "AWS::SQS::Queue", 519 | Properties: { 520 | QueueName: "pong6", 521 | }, 522 | }, 523 | pinging: { 524 | Type: "AWS::SNS::Topic", 525 | Properties: { 526 | TopicName: "topic-pinging", 527 | }, 528 | }, 529 | }, 530 | }, 531 | }, 532 | cli: { 533 | log: (data) => { 534 | if (process.env.DEBUG) { 535 | console.log(data); 536 | } 537 | }, 538 | }, 539 | }; 540 | }; 541 | 542 | const createServerlessMultiDot = ( 543 | accountId: number, 544 | handlerName: string = "pongHandler", 545 | host = null 546 | ) => { 547 | return { 548 | config: {}, 549 | service: { 550 | custom: { 551 | "serverless-offline-sns": { 552 | debug: true, 553 | port: 4002, 554 | accountId, 555 | host, 556 | }, 557 | }, 558 | provider: { 559 | region: "us-east-1", 560 | environment: { 561 | MY_VAR: "MY_VAL", 562 | }, 563 | }, 564 | functions: { 565 | multiDot: { 566 | handler: "test/mock/multi.dot.handler.itsGotDots", 567 | events: [ 568 | { 569 | sns: `arn:aws:sns:us-west-2:${accountId}:multi-dot-topic`, 570 | }, 571 | ], 572 | }, 573 | }, 574 | resources: {}, 575 | }, 576 | cli: { 577 | log: (data) => { 578 | if (process.env.DEBUG) { 579 | console.log(data); 580 | } 581 | }, 582 | }, 583 | }; 584 | }; 585 | 586 | const createServerlessBad = (accountId: number) => { 587 | return { 588 | config: {}, 589 | service: { 590 | custom: { 591 | "serverless-offline-sns": { 592 | debug: true, 593 | port: 4002, 594 | accountId, 595 | }, 596 | }, 597 | provider: { 598 | region: "us-east-1", 599 | }, 600 | functions: { 601 | badPong: { 602 | handler: "test/mock/handler.pongHandler", 603 | events: [ 604 | { 605 | sns: { 606 | topicArn: `arn:aws:sns:us-east-1:${accountId}:test-topic`, 607 | }, 608 | }, 609 | ], 610 | }, 611 | }, 612 | resources: {}, 613 | }, 614 | cli: { 615 | log: (data) => { 616 | if (process.env.DEBUG) { 617 | console.log(data); 618 | } 619 | }, 620 | }, 621 | }; 622 | }; 623 | 624 | const createServerlessWithFilterPolicies = ( 625 | accountId: number, 626 | handlerName: string = "pongHandler", 627 | host = null, 628 | subscribeEndpoint = null 629 | ) => { 630 | return { 631 | config: {}, 632 | service: { 633 | custom: { 634 | "serverless-offline-sns": { 635 | debug: true, 636 | port: 4002, 637 | accountId, 638 | host, 639 | "sns-subscribe-endpoint": subscribeEndpoint, 640 | }, 641 | }, 642 | provider: { 643 | region: "us-east-1", 644 | environment: { 645 | MY_VAR: "MY_VAL", 646 | }, 647 | }, 648 | functions: { 649 | pong: { 650 | name: "some-name", 651 | handler: "test/mock/handler." + handlerName, 652 | events: [ 653 | { 654 | sns: { 655 | topicName: "test-topic-policies", 656 | displayName: "test-topic-policies", 657 | filterPolicy: { 658 | foo: ["bar", "blah"], 659 | }, 660 | }, 661 | }, 662 | ], 663 | }, 664 | pong2: { 665 | name: "some-name2", 666 | handler: "test/mock/handler." + handlerName, 667 | events: [ 668 | { 669 | sns: { 670 | topicName: "test-topic-policies-multiple", 671 | displayName: "test-topic-policies-multiple", 672 | filterPolicy: { 673 | foo: ["bar", "blah"], 674 | second: ["policy"], 675 | }, 676 | }, 677 | }, 678 | ], 679 | }, 680 | }, 681 | resources: {}, 682 | }, 683 | cli: { 684 | log: (data) => { 685 | if (process.env.DEBUG) { 686 | console.log(data); 687 | } 688 | }, 689 | }, 690 | }; 691 | }; 692 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2022", 4 | "target": "es2022", 5 | "declaration": false, 6 | "noImplicitAny": false, 7 | "removeComments": true, 8 | "inlineSourceMap": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "node" 11 | }, 12 | "include": [ 13 | "**/*.ts", 14 | "../src/**/*.ts" 15 | ] 16 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "ordered-imports": false, 10 | "max-line-length": [false], 11 | "no-string-literal": false, 12 | "object-literal-sort-keys": false, 13 | "no-empty": false, 14 | "no-unused-expression": false, 15 | "require-jsdoc": false, 16 | "no-trailing-whitespace": false, 17 | "member-ordering": false, 18 | "arrow-parens": false, 19 | "no-console": false, 20 | "no-empty-interface": false, 21 | "no-var-requires": false, 22 | "trailing-comma": false, 23 | "curly": [true, "ignore-same-line"] 24 | }, 25 | "rulesDirectory": [] 26 | } 27 | --------------------------------------------------------------------------------