├── .dwollaci.yml ├── .editorconfig ├── .eslint.js ├── .eslintrc ├── .gitignore ├── .lintstagedrc.json ├── .nvmrc ├── .prettierignore ├── LICENSE ├── PITCHME.md ├── PITCHME.yaml ├── README.md ├── assets ├── PITCHME.css ├── logo.png ├── new.png └── old.png ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts ├── genEvent.ts ├── server.ts ├── tsconfig.json └── updateAll.ts ├── serverless.js ├── src ├── config.ts ├── handler.ts ├── http.ts ├── index.d.ts ├── logger.ts ├── mapper.ts ├── postHook.ts ├── publishResults.ts ├── sendHooks.ts └── util.ts ├── test ├── config.test.ts ├── handler.test.ts ├── mapper.test.ts ├── postHook.test.ts ├── publishResults.test.ts ├── sendHooks.test.ts └── util.test.ts ├── tsconfig.json └── webpack.config.js /.dwollaci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | build: 3 | nodeLabel: nvm 4 | steps: 5 | - | 6 | . ${NVM_DIR}/nvm.sh --no-use 7 | nvm install 8 | npm install 9 | ENVIRONMENT=devint npm test 10 | filesToStash: 11 | - "**" 12 | prepublish: 13 | nodeLabel: nvm 14 | steps: 15 | - | 16 | . ${NVM_DIR}/nvm.sh --no-use 17 | nvm install 18 | npm i --no-save 19 | npm run build 20 | filesToStash: 21 | - ".webpack/" 22 | deployDevInt: 23 | nodeLabel: nvm-deployer 24 | steps: 25 | - | 26 | . ${NVM_DIR}/nvm.sh --no-use 27 | nvm install 28 | npm install -g npm 29 | npm install -g serverless@"<4.0.0" 30 | ENVIRONMENT=devint SKRIPTS_DEPLOYMENT_BUCKET=dwolla-encrypted npm run deploy 31 | deployUat: 32 | nodeLabel: nvm-deployer 33 | steps: 34 | - | 35 | . ${NVM_DIR}/nvm.sh --no-use 36 | nvm install 37 | npm install -g npm 38 | npm install -g serverless@"<4.0.0" 39 | ENVIRONMENT=uat SKRIPTS_DEPLOYMENT_BUCKET=dwolla-encrypted npm run deploy 40 | deployProd: 41 | nodeLabel: nvm-deployer 42 | steps: 43 | - | 44 | . ${NVM_DIR}/nvm.sh --no-use 45 | nvm install 46 | npm install -g npm 47 | npm install -g serverless@"<4.0.0" 48 | ENVIRONMENT=prod SKRIPTS_DEPLOYMENT_BUCKET=dwolla-encrypted npm run deploy 49 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = 0 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslint.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "plugin:@typescript-eslint/recommended", 4 | "plugin:prettier/recommended", 5 | ], 6 | overrides: [ 7 | { 8 | files: ["*.js", "*.jsx"], 9 | rules: { 10 | "@typescript-eslint/no-var-requires": "off", 11 | "@typescript-eslint/explicit-function-return-type": "off", 12 | "@typescript-eslint/explicit-member-accessibility": "off", 13 | }, 14 | }, 15 | ], 16 | parser: "@typescript-eslint/parser", 17 | plugins: ["@typescript-eslint"], 18 | rules: { 19 | "@typescript-eslint/no-use-before-define": ["off"], 20 | curly: ["error", "multi-line"], 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.eslint.js", 3 | "ignorePatterns": ["**/node_modules/**"], 4 | "rules": { 5 | "@typescript-eslint/ban-types": ["off"], 6 | "@typescript-eslint/no-this-alias": ["off"], 7 | "@typescript-eslint/ban-ts-comment": ["off"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | event.json 3 | src/*.js 4 | scripts/*.js 5 | scripts/*.js.map 6 | 7 | .idea/ 8 | .serverless/ 9 | .vscode/ 10 | .webpack/ 11 | node_modules/ 12 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx}": ["npm run lint", "npm run format"], 3 | "*.{json,yml,yaml,md,html,css,less,scss,graphql}": ["npm run format"] 4 | } 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/iron 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | scripts/*.js -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dwolla, Inc. 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 | -------------------------------------------------------------------------------- /PITCHME.md: -------------------------------------------------------------------------------- 1 | # Serverless Webhooks 2 | 3 | [Rocky Warren](https://rockywarren.com/) [@therockstorm](https://github.com/therockstorm) 4 | Principal Software Engineer [@Dwolla](https://twitter.com/dwolla) 5 | 6 | --- 7 | 8 | @snap[north] 9 | Outline 10 | @snapend 11 | @title[Outline] 12 | 13 | @snap[west list-content span-100] 14 | @ul[](false) 15 | 16 | - Original architecture and its limitations 17 | - New architecture with code walkthrough 18 | - Rollout strategy 19 | - Lessons learned 20 | - Results 21 | @ulend 22 | @snapend 23 | 24 | --- 25 | 26 | @snap[north] 27 | Background 28 | @snapend 29 | @title[Background] 30 | 31 | @snap[west list-content span-100] 32 | @ul 33 | 34 | - Dwolla provides a payment platform API 35 | - Bank transfers, user management, instant bank account verification 36 | - Certain actions trigger a webhook (also called web callback or push API) 37 | - HTTP POST to partner API providing real-time event details 38 | - Eliminates polling for updates 39 | @ulend 40 | @snapend 41 | 42 | --- 43 | 44 | @snap[north] 45 | Original Architecture 46 | @snapend 47 | @title[Original Architecture] 48 | 49 | @snap[south span-85] 50 | ![https://mermaidjs.github.io/mermaid-live-editor/#/edit/eyJjb2RlIjoiZ3JhcGggTFJcbiAgICBCW1NydmMgQV0gLS4tPiBEXG4gICAgQ1tTcnZjIEJdIC0uLT4gRFxuICAgIERbU3Vic10gLS0-IEUoZmE6ZmEtZGF0YWJhc2UpXG4gICAgQVtEd29sbGEgZmE6ZmEtc2VydmVyXSAtLT4gRFxuICAgIFxuICAgIEZbSGFuZGxlcnNdLS4tPkRcbiAgICBELS4tPiBGXG4gICAgRi0tPkdbQSBmYTpmYS1zZXJ2ZXJdXG4gICAgRi0tPkhbQiBmYTpmYS1zZXJ2ZXJdXG4gICAgRi0uLT58MTV8RlxuICAgIEYtLi0-fDQ1fEZcbiAgICBGLS4tPnwuLi58RlxuXG4gICAgY2xhc3NEZWYgZGIgZmlsbDojQUREOEU2XG4gICAgY2xhc3MgRSBkYjsiLCJtZXJtYWlkIjp7InRoZW1lIjoiZGVmYXVsdCJ9fQ](assets/old.png) 51 | @snapend 52 | 53 | Note: 54 | 55 | - Partners create webhook subscriptions indicating URL for us to call 56 | - `Subscriptions` receives events from services and publishes to single, shared queue 57 | - `Handler`s read off queue, call partner API, and publish result 58 | - `Subscriptions` receives and stores result 59 | 60 | --- 61 | 62 | @snap[north] 63 | Limitations 64 | @snapend 65 | @title[Limitations] 66 | 67 | @snap[west list-content span-100] 68 | @ul 69 | 70 | - At peak load, delayed ~60 mins, defeating their purpose 71 | - Partner processes (notifications, etc.) are then delayed 72 | - One slow-to-respond or high-volume partner affects everyone 73 | - Scaling handlers causes parallel API calls for everyone 74 | - Non-trivial per-partner configuration 75 | @ulend 76 | @snapend 77 | 78 | --- 79 | 80 | @snap[north] 81 | New Architecture 82 | @snapend 83 | @title[New Architecture] 84 | 85 | @snap[south span-75] 86 | ![https://mermaidjs.github.io/mermaid-live-editor/#/edit/eyJjb2RlIjoiZ3JhcGggTFJcbiAgICBCW1NydmMgQV0gLS4tPiBEXG4gICAgQ1tTcnZjIEJdIC0uLT4gRFxuICAgIERbU3Vic10gLS0-IEUoZmE6ZmEtZGF0YWJhc2UpXG4gICAgQVtEd29sbGEgZmE6ZmEtc2VydmVyXSAtLT4gRFxuICAgIFxuICAgIEZbSGFuZGxlcnNdLS4tPkRcbiAgICBGLS4tPnxSZXRyeXxGXG4gICAgRC0uLT4gRlxuICAgIEYtLT5HW0EgZmE6ZmEtc2VydmVyXVxuXG4gICAgSFtIYW5kbGVyc10tLi0-RFxuICAgIEgtLi0-fFJldHJ5fEhcbiAgICBELS4tPiBIXG4gICAgSC0tPklbQiBmYTpmYS1zZXJ2ZXJdXG5cbiAgICBjbGFzc0RlZiBkYiBmaWxsOiNBREQ4RTZcbiAgICBjbGFzcyBFIGRiOyIsIm1lcm1haWQiOnsidGhlbWUiOiJkZWZhdWx0In19](assets/new.png) 87 | @snapend 88 | 89 | Note: 90 | 91 | - One queue per partner, dynamically provisioned on subscription creation 92 | - Individually configurable depending on scalability of partner APIs 93 | - Slow or high-volume partners only impact themselves 94 | 95 | --- 96 | 97 | @snap[north] 98 | Why SQS and Lambda? 99 | @snapend 100 | @title[Why SQS and Lambda?] 101 | 102 | @snap[west list-content span-100] 103 | @ul 104 | 105 | - Creating queue/handler with AWS SDKs simpler than custom code 106 | - Spiky workload perfect for pay-per-use pricing, auto-scaling 107 | - No server management maximizes time spent adding value, decreased attack surface 108 | - ~1 minute deployments reduce development cycle 109 | @ulend 110 | @snapend 111 | 112 | --- 113 | 114 | ## Code Walkthrough 115 | 116 | Note: 117 | 118 | - `webhook-provisioner`: Create, delete, disable 119 | - `webhook-handler`: postHook, publishResult, requeue, error, update-all 120 | - `cloudwatch-alarm-to-slack` 121 | 122 | --- 123 | 124 | @snap[north] 125 | Rollout 126 | @snapend 127 | @title[Rollout] 128 | 129 | @snap[west list-content span-100] 130 | @ul 131 | 132 | - Whitelist test partners in Sandbox via Feature Flags 133 | - Enable globally in Sandbox 134 | - Whitelist beta partners in Prod 135 | - Monitor, gather feedback 136 | - Migrate in batches based on webhook volume 137 | @ulend 138 | @snapend 139 | 140 | --- 141 | 142 | @snap[north] 143 | Lessons Learned 144 | @snapend 145 | @title[Lessons Learned] 146 | 147 | @snap[west list-content span-100] 148 | @ul 149 | 150 | - Audit dependencies to keep bundle size and memory usage low (e.g. HTTP libs) 151 | - CloudWatch can get expensive, defaults retention to forever 152 | - Follow Best Practices for avoiding throttling, dead-letter queues, idempotency, batch size 153 | - Lambda errors elusive, CloudWatch Insights helps 154 | - Include high cardinality values in log messages, take charge of monitors/alerts 155 | @ulend 156 | @snapend 157 | 158 | Note: 159 | 160 | - TypeScript: Painter's Tape for JavaScript 161 | - 404 from customer, logs contained id, url, status with no issues 162 | 163 | --- 164 | 165 | @snap[north] 166 | Lessons Learned 167 | @snapend 168 | @title[Lessons Learned] 169 | 170 | @snap[west list-content span-100] 171 | @ul 172 | 173 | - One Lambda serving multiple queues limits configuration options 174 | - TypeScript, Serverless Framework, `aws-cdk` are great 175 | - Think twice before dynamically provisioning resources, concurrency, prepare to retry 176 | - Understand AWS Account Limits (IAM, Lambda, SQS, CloudFormation Stacks, etc.) 177 | - Utilize tagging to manage lots of resources 178 | @ulend 179 | @snapend 180 | 181 | --- 182 | 183 | @snap[north] 184 | Results 185 | @snapend 186 | @title[Results] 187 | 188 | @snap[west list-content span-100] 189 | @ul 190 | 191 | - Infinitely scalable, from 60 min delay at peak load to under one 192 | - Configurable to individual partner's needs 193 | - Low costs and maintenance, free when not in use 194 | @ulend 195 | @snapend 196 | 197 | --- 198 | 199 | @snap[north] 200 | Free Code! 201 | @snapend 202 | @title[Free Code!] 203 | 204 | @snap[west list-content span-100] 205 | @ul[](false) 206 | 207 | - [webhook-provisioner](https://github.com/Dwolla/webhook-provisioner): Provision AWS resources 208 | - [webhook-handler](https://github.com/Dwolla/webhook-handler): POST webhooks to APIs 209 | - [webhook-receiver](https://github.com/Dwolla/webhook-receiver): Receive and verify webhooks 210 | - [cloudwatch-alarm-to-slack](https://github.com/Dwolla/cloudwatch-alarm-to-slack): Forward CloudWatch Alarms to Slack 211 | - [sqs-mv](https://github.com/Dwolla/sqs-mv): Move SQS messages from one queue to another 212 | - [generator-serverless](https://github.com/therockstorm/generator-serverless): Serverless Yeoman generator 213 | @ulend 214 | @snapend 215 | 216 | --- 217 | 218 | ## Questions? 219 | -------------------------------------------------------------------------------- /PITCHME.yaml: -------------------------------------------------------------------------------- 1 | theme: night 2 | theme-override: assets/PITCHME.css 3 | logo: assets/logo.png 4 | logo-postion: top-left 5 | layout: center 6 | footnote: "Dwolla | Efficient & Secure ACH Payments" 7 | published: true 8 | transition: none 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webhook-handler 2 | 3 | An AWS Lambda function that POSTs Dwolla webhook `Events` to partner APIs and sends the result to SQS. If the API doesn't return as expected, the `Event` is requeued and retried via our [backoff schedule](https://docs.dwolla.com/#webhook-subscriptions). For details, see the [GitPitch Deck](https://gitpitch.com/dwolla/webhook-handler). 4 | 5 | ## Setup 6 | 7 | - Clone the repository and run `npm install` 8 | - Ensure your [AWS credentials are available](https://serverless.com/framework/docs/providers/aws/guide/credentials/) 9 | - Deploy with `ENVIRONMENT=your-env DEPLOYMENT_BUCKET=your-bucket npm run deploy` 10 | - Export `PARTNER_QUEUE_URL`, `RESULT_QUEUE_URL`, and `ERROR_QUEUE_URL` with the queue URLs created in AWS. 11 | 12 | ## Developing 13 | 14 | - Run tests, `npm test` 15 | - Invoke locally by editing `genEvent.ts` to your liking, running `npm run start`, and browsing to the localhost port logged. 16 | -------------------------------------------------------------------------------- /assets/PITCHME.css: -------------------------------------------------------------------------------- 1 | .list-content ul { 2 | font-size: 0.85em; 3 | } 4 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dwolla/webhook-handler/08bc473bed1b1b7a4b4979a056b5f009ee766950/assets/logo.png -------------------------------------------------------------------------------- /assets/new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dwolla/webhook-handler/08bc473bed1b1b7a4b4979a056b5f009ee766950/assets/new.png -------------------------------------------------------------------------------- /assets/old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dwolla/webhook-handler/08bc473bed1b1b7a4b4979a056b5f009ee766950/assets/old.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhook-handler", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "export ENVIRONMENT=local && npm run build:config && sls webpack", 6 | "build:config": "tsc --project ./tsconfig.json", 7 | "deploy": "npm run build:config && sls deploy", 8 | "format": "prettier --ignore-path .prettierignore --write \"./**/*.+(js|jsx|ts|tsx|json|yml|yaml|md|html|css|less|scss|graphql)\"", 9 | "jest": "PARTNER_QUEUE_URL=partner.com RESULT_QUEUE_URL=result.com ERROR_QUEUE_URL=error.com VERSION=v1 jest --silent --verbose", 10 | "lint": "eslint --fix --config .eslintrc --ext '.ts,.tsx,.js,.jsx' '.'", 11 | "start": "cd scripts && tsc && node genEvent.js && node --inspect server.js", 12 | "test": "export ENVIRONMENT=devint && npm run build:config && npm run lint && npm run jest && sls package", 13 | "update:all": "cd scripts && tsc && node updateAll.js", 14 | "watch": "npm run jest -- --watch" 15 | }, 16 | "dependencies": { 17 | "p-limit": "^3.1.0", 18 | "source-map-support": "^0.5.13", 19 | "ts-loader": "^9.5.1" 20 | }, 21 | "devDependencies": { 22 | "@types/aws-lambda": "^8.10.71", 23 | "@types/jest": "^29.2.0", 24 | "@typescript-eslint/eslint-plugin": "^5.59.0", 25 | "@typescript-eslint/parser": "^5.59.0", 26 | "aws-sdk": "^2.512.0", 27 | "eslint": "^8.42.0", 28 | "eslint-config-prettier": "^8.8.0", 29 | "eslint-plugin-prettier": "^5.0.0", 30 | "husky": "^3.0.4", 31 | "jest": "^29.7.0", 32 | "lint-staged": "^13.2.2", 33 | "serverless": "^3.36.0", 34 | "serverless-iam-roles-per-function": "^3.0.0-d84bffd", 35 | "serverless-webpack": "^5.14.1", 36 | "ts-jest": "^29.2.0", 37 | "typescript": "^5.5.3", 38 | "webpack-cli": "^5.1.4" 39 | }, 40 | "husky": { 41 | "hooks": { 42 | "pre-commit": "npx lint-staged --config .lintstagedrc.json" 43 | } 44 | }, 45 | "prettier": { 46 | "semi": false 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/genEvent.ts: -------------------------------------------------------------------------------- 1 | import { SQSEvent, SQSRecord } from "aws-lambda" 2 | import { writeFileSync } from "fs" 3 | 4 | const HOOK = { 5 | _links: { 6 | account: { 7 | href: "https://api.dwolla.com/accounts/11111111-8fe5-40b5-9be7-111111111111", 8 | }, 9 | resource: { 10 | href: "https://api.dwolla.com/transfers/11111111-2559-e711-8102-111111111111", 11 | }, 12 | self: { 13 | href: "https://api.dwolla.com/events/11111111-e6c5-41b7-99fe-111111111111", 14 | }, 15 | }, 16 | created: new Date().toISOString(), 17 | id: "11111111-e6c5-41b7-99fe-111111111111", 18 | resourceId: "11111111-2559-e711-8102-111111111111", 19 | timestamp: new Date().toISOString(), 20 | topic: "transfer_completed", 21 | } 22 | 23 | const CREATED_EVENT = (id: string) => ({ 24 | body: JSON.stringify(HOOK), 25 | id, 26 | signatureSha256: "fake-sig", 27 | timestamp: new Date().toISOString(), 28 | topic: "transfer_completed", 29 | url: "https://hookb.in/wNyjXg9VXpU0w0V3BMnG", 30 | }) 31 | 32 | const RECORD = (id: string): SQSRecord => ({ 33 | attributes: { 34 | ApproximateFirstReceiveTimestamp: "1523232000001", 35 | ApproximateReceiveCount: "1", 36 | SenderId: "123456789012", 37 | SentTimestamp: "1523232000000", 38 | }, 39 | awsRegion: "us-west-2", 40 | body: JSON.stringify(CREATED_EVENT(id)), 41 | eventSource: "aws:sqs", 42 | eventSourceARN: "arn:aws:sqs:us-west-2:123456789012:MyQueue", 43 | md5OfBody: "35du3f", 44 | messageAttributes: { 45 | // retryCnt: { stringValue: '1', dataType: 'Number' }, 46 | // requeueUntil: { stringValue: '1545409898', dataType: 'Number' } 47 | }, 48 | messageId: "11111111-b21e-4ac1-bd88-111111111111", 49 | receiptHandle: "MessageReceiptHandle", 50 | }) 51 | 52 | const EVENT = (): SQSEvent => { 53 | const rs: SQSRecord[] = [] 54 | for (let i = 0; i < 2; i++) rs.push(RECORD(`id-${i}`)) 55 | return { Records: rs } 56 | } 57 | 58 | writeFileSync("./event.json", JSON.stringify(EVENT())) 59 | -------------------------------------------------------------------------------- /scripts/server.ts: -------------------------------------------------------------------------------- 1 | import { error, log } from "../src/logger" 2 | import { SQSEvent } from "aws-lambda" 3 | import { readFileSync } from "fs" 4 | import { createServer, IncomingMessage, ServerResponse } from "http" 5 | import { handle } from "../src/handler" 6 | 7 | const PORT = 8010 8 | const FUNCS = [{ path: "/func", fn: (evt: SQSEvent) => handle(evt) }] 9 | 10 | const writeRes = (body: object, res: ServerResponse): void => { 11 | res.writeHead(200, { "Content-Type": "application/json" }) 12 | res.write(JSON.stringify(body)) 13 | res.end() 14 | } 15 | 16 | const parsed = JSON.parse(readFileSync("./event.json", "utf8")) 17 | 18 | const requestHandler = async ( 19 | req: IncomingMessage, 20 | res: ServerResponse, 21 | ): Promise => { 22 | const url = req.url || "/" 23 | if (req.method === "POST") { 24 | let body = "" 25 | req.on("data", (data) => (body += data)) 26 | req.on("end", async () => await handleReq(JSON.parse(body), url, res)) 27 | return 28 | } 29 | 30 | return handleReq(parsed, url, res) 31 | } 32 | 33 | const handleReq = async ( 34 | evt: SQSEvent, 35 | url: string, 36 | res: ServerResponse, 37 | ): Promise => { 38 | try { 39 | if (url === "/") { 40 | return writeRes( 41 | { 42 | body: `Visit ${FUNCS.map((f) => f.path).join( 43 | ", ", 44 | )} to invoke the corresponding Lambda function. POST an event or use the default specified in server.ts with a GET.`, 45 | event: evt, 46 | statusCode: 200, 47 | }, 48 | res, 49 | ) 50 | } 51 | const func = FUNCS.find((f) => f.path === url) 52 | return writeRes( 53 | func ? await func.fn(evt) : { statusCode: 400, body: "Path not found" }, 54 | res, 55 | ) 56 | } catch (e: any) { 57 | error("handle err", e) 58 | return writeRes({ statusCode: 500, body: e.message }, res) 59 | } 60 | } 61 | 62 | createServer(requestHandler).listen(PORT, () => 63 | log(`Listening at http://localhost:${PORT}...`), 64 | ) 65 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "target": "ES2018", 5 | "module": "commonjs", 6 | "lib": ["es2016", "es2017.object", "es2017.string"], 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "resolveJsonModule": true, 20 | "strictPropertyInitialization": false 21 | }, 22 | "exclude": ["node_modules/**", "../node_modules/**"] 23 | } 24 | -------------------------------------------------------------------------------- /scripts/updateAll.ts: -------------------------------------------------------------------------------- 1 | import { error, log } from "../src/logger" 2 | import Lambda, { InvocationResponse } from "aws-sdk/clients/lambda" 3 | import { envVarRequired } from "../src/util" 4 | 5 | const env = envVarRequired("ENVIRONMENT") 6 | const region = process.env.AWS_REGION || "us-west-2" 7 | const lam = new Lambda({ region }) 8 | 9 | const updateAll = async () => { 10 | try { 11 | const res = await invoke("updateCode") 12 | log(decode(res.LogResult)) 13 | } catch (err: any) { 14 | exitWithErr(err) 15 | } 16 | } 17 | 18 | const invoke = async (fn: string) => { 19 | const isError = (r: InvocationResponse) => 20 | !r.StatusCode || 21 | r.StatusCode !== 200 || 22 | r.FunctionError || 23 | !r.Payload || 24 | JSON.parse(r.Payload as string).statusCode !== 200 25 | 26 | const exit = (r: InvocationResponse) => { 27 | const l = decode(r.LogResult) 28 | delete r.LogResult 29 | exitWithErr(`${JSON.stringify(r, null, 2)}\n\n${l}`) 30 | } 31 | 32 | const res = await lam 33 | .invoke({ 34 | FunctionName: `webhook-provisioner-${env}-${fn}`, 35 | LogType: "Tail", 36 | }) 37 | .promise() 38 | if (isError(res)) exit(res) 39 | 40 | return res 41 | } 42 | 43 | const decode = (s?: string) => (s ? Buffer.from(s, "base64").toString() : "") 44 | 45 | const exitWithErr = (err: string | Error): never => { 46 | error(err) 47 | return process.exit(1) // Fail CI job 48 | } 49 | 50 | updateAll() 51 | -------------------------------------------------------------------------------- /serverless.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cfnRole: process.env.SKRIPTS_CFN_ROLE || null, 3 | custom: { 4 | tags: { 5 | Creator: "serverless", 6 | Environment: "${self:provider.stage}", 7 | Project: "webhooks", 8 | Team: "growth", 9 | DeployJobUrl: "${env:BUILD_URL, 'n/a'}", 10 | "org.label-schema.vcs-url": "${env:GIT_URL, 'n/a'}", 11 | "org.label-schema.vcs-ref": "${env:GIT_COMMIT, 'n/a'}", 12 | }, 13 | }, 14 | frameworkVersion: "3", 15 | provider: { 16 | deploymentBucket: process.env.SKRIPTS_DEPLOYMENT_BUCKET 17 | ? { 18 | name: process.env.SKRIPTS_DEPLOYMENT_BUCKET, 19 | serverSideEncryption: "AES256", 20 | } 21 | : null, 22 | environment: { AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1 }, 23 | logRetentionInDays: 365, 24 | memorySize: 128, 25 | name: "aws", 26 | region: "us-west-2", 27 | runtime: "nodejs20.x", 28 | stackTags: "${self:custom.tags}", 29 | stage: "${opt:stage, env:ENVIRONMENT}", 30 | tags: "${self:custom.tags}", 31 | timeout: 10, 32 | }, 33 | package: { individually: true }, 34 | plugins: ["serverless-iam-roles-per-function", "serverless-webpack"], 35 | service: "${file(./package.json):name}", 36 | vpc: 37 | process.env.SKRIPTS_VPC_SECURITY_GROUPS && process.env.SKRIPTS_VPC_SUBNETS 38 | ? { 39 | securityGroupIds: process.env.SKRIPTS_VPC_SECURITY_GROUPS.split(","), 40 | subnetIds: process.env.SKRIPTS_VPC_SUBNETS.split(","), 41 | } 42 | : null, 43 | functions: { func: { handler: "src/handler.handle" } }, 44 | } 45 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const con = parseInt(process.env.CONCURRENCY || "1", 10) 2 | const pqu = process.env.PARTNER_QUEUE_URL || "" 3 | const rqu = process.env.RESULT_QUEUE_URL || "" 4 | const equ = process.env.ERROR_QUEUE_URL || "" 5 | const ver = process.env.VERSION || "" 6 | const rtm = parseInt(process.env.RETRIES_MAX || "8", 10) 7 | 8 | export const concurrency = (): number => con 9 | export const partnerQueueUrl = (): string => pqu 10 | export const resultQueueUrl = (): string => rqu 11 | export const errorQueueUrl = (): string => equ 12 | export const version = (): string => ver 13 | export const retriesMax = (): number => rtm 14 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { error, log } from "./logger" 2 | import { SQSEvent } from "aws-lambda" 3 | import { SendMessageBatchResult as BatchRes } from "aws-sdk/clients/sqs" 4 | import "source-map-support/register" 5 | import { name } from "../package.json" 6 | import { version } from "./config" 7 | import { toReqs } from "./mapper" 8 | import { BATCH_ERROR, sendErrorBatch } from "./publishResults" 9 | import { sendHooks } from "./sendHooks" 10 | 11 | const v = version() 12 | 13 | export const handle = async (evt: SQSEvent): Promise => { 14 | log(`v=${v} ${JSON.stringify(evt)}`) 15 | const rs = toReqs(evt.Records) 16 | try { 17 | return await sendHooks(rs) 18 | } catch (err: any) { 19 | error(name, err) 20 | if (err.message !== BATCH_ERROR) return sendErrorBatch(rs) 21 | else return Promise.reject(err) // Batch won't be deleted from queue and will be retried 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/http.ts: -------------------------------------------------------------------------------- 1 | import http, { RequestOptions } from "http" 2 | import https from "https" 3 | import { IHttp } from "." 4 | 5 | export const post = (body: string, opts: RequestOptions): Promise => 6 | new Promise((resolve, reject) => { 7 | let timer: NodeJS.Timeout 8 | const res = (val: any) => { 9 | clearTimeout(timer) 10 | resolve(val) 11 | } 12 | const rej = (val: any) => { 13 | clearTimeout(timer) 14 | reject(val) 15 | } 16 | const fn = opts.protocol === "https:" ? https : http 17 | const req = fn.request(opts, (r) => { 18 | r.resume() 19 | res({ statusCode: r.statusCode }) 20 | }) 21 | if (opts.timeout) { 22 | timer = setTimeout(() => { 23 | req.abort() 24 | rej(new Error(`Exceeded ${opts.timeout}ms timeout`)) 25 | }, opts.timeout) 26 | } 27 | req.on("error", (err) => (req.aborted ? null : rej(err))) 28 | req.end(body) 29 | }) 30 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export type Partitions = Readonly<[Res[], Res[]]> 2 | 3 | export type Event = Readonly<{ 4 | id: string 5 | url: string 6 | topic: string 7 | body: string 8 | signatureSha256?: string 9 | timestamp: string 10 | }> 11 | 12 | export type Req = Readonly<{ 13 | event: Event 14 | retryCnt: number 15 | requeueUntil: number 16 | requeue: boolean 17 | }> 18 | 19 | export type Res = Readonly<{ 20 | req: Req 21 | httpReq?: IHttpReq 22 | httpRes?: IHttpRes 23 | err?: string 24 | }> 25 | 26 | export type Header = Readonly<{ 27 | name: string 28 | value: string 29 | }> 30 | 31 | type Http = Readonly<{ 32 | headers: Header[] 33 | body: string 34 | timestamp: string 35 | }> 36 | 37 | export interface IHttpReq extends Http { 38 | url: string 39 | } 40 | 41 | export interface IHttpRes extends Http { 42 | statusCode: number 43 | } 44 | 45 | export interface IHttp { 46 | statusCode?: number 47 | } 48 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | const log = (...args: any[]): void => console.log(args) 2 | const error = (...args: any[]): void => console.log("[error]", args) 3 | const warn = (...args: any[]): void => console.log("[warn]", args) 4 | 5 | export { log, error, warn } 6 | -------------------------------------------------------------------------------- /src/mapper.ts: -------------------------------------------------------------------------------- 1 | import { log } from "./logger" 2 | import { SQSMessageAttribute, SQSRecord } from "aws-lambda" 3 | import { 4 | MessageBodyAttributeMap, 5 | SendMessageBatchRequestEntryList, 6 | } from "aws-sdk/clients/sqs" 7 | import { Event, Header, IHttpReq, IHttpRes, Partitions, Req, Res } from "." 8 | import { partnerQueueUrl, retriesMax } from "./config" 9 | import { epochMs, epochMsTo, now } from "./util" 10 | 11 | const PARTNER_QUEUE = partnerQueueUrl() 12 | const [MINS, HRS] = [60 * 1000, 3600 * 1000] 13 | const [MAX_RETRIES, MAX_BACKOFF] = [retriesMax(), 72 * HRS] 14 | 15 | export const retries: { [k: string]: number } = { 16 | 1: 15 * MINS, 17 | 2: HRS, 18 | 3: 3 * HRS, 19 | 4: 6 * HRS, 20 | 5: 12 * HRS, 21 | 6: 24 * HRS, 22 | 7: 48 * HRS, 23 | 8: MAX_BACKOFF, 24 | } 25 | 26 | export const toReqs = (rs: SQSRecord[]): Req[] => { 27 | const toReq = (r: SQSRecord, event: Event): Req => { 28 | const toInt = (s: SQSMessageAttribute, max: number): number => { 29 | // @ts-ignore 30 | const i = s ? Math.min(parseInt(s.stringValue, 10), max) : 0 31 | return isNaN(i) ? 0 : i 32 | } 33 | const requeueUntil = toInt( 34 | r.messageAttributes.requeueUntil, 35 | epochMsTo(event.timestamp) + MAX_BACKOFF, 36 | ) 37 | 38 | return { 39 | event, 40 | requeue: epochMs() < requeueUntil, 41 | requeueUntil, 42 | retryCnt: toInt(r.messageAttributes.retryCnt, MAX_RETRIES), 43 | } 44 | } 45 | 46 | return rs.reduce((acc, r) => { 47 | const e = JSON.parse(r.body) 48 | if (!acc.filter((a) => a.event.id === e.id).length) acc.push(toReq(r, e)) 49 | return acc 50 | }, [] as Req[]) 51 | } 52 | 53 | const toHttpReq = ( 54 | body: string, 55 | headers: { [k: string]: string }, 56 | reqTs: number, 57 | url: string, 58 | ): IHttpReq => ({ 59 | body: body || "", 60 | headers: toHeaders(headers), 61 | timestamp: toIso(reqTs), 62 | url: url || "", 63 | }) 64 | 65 | export const toHttpRes = (resTs: number, code: number): IHttpRes => ({ 66 | body: "", 67 | headers: [], 68 | statusCode: code || 0, 69 | timestamp: toIso(resTs), 70 | }) 71 | 72 | export const partition = (rs: Res[]): Partitions => { 73 | const retry = (r: Res) => 74 | r.req.retryCnt < MAX_RETRIES && 75 | (r.err || (r.httpRes && r.httpRes.statusCode >= 400)) 76 | 77 | return rs.reduce( 78 | ([a, b], r): Partitions => 79 | r.req.requeue ? [a, [...b, r]] : [[...a, r], retry(r) ? [...b, r] : b], 80 | [[], []] as Partitions, 81 | ) 82 | } 83 | 84 | export const toResult = (rs: Res[]): SendMessageBatchRequestEntryList => 85 | rs && rs.length 86 | ? rs.map((r) => ({ 87 | Id: r.req.event.id, 88 | MessageBody: JSON.stringify({ 89 | cause: r.err, 90 | id: r.req.event.id, 91 | request: r.httpReq, 92 | response: r.httpRes, 93 | retryCnt: r.req.retryCnt, 94 | }), 95 | })) 96 | : [] 97 | 98 | export const toRequeue = (rs: Res[]): SendMessageBatchRequestEntryList => 99 | rs && rs.length 100 | ? rs.map((r) => ({ 101 | DelaySeconds: 900, // 15 mins 102 | Id: r.req.event.id, 103 | MessageAttributes: calcAttrs(r.req), 104 | MessageBody: JSON.stringify(r.req.event), 105 | })) 106 | : [] 107 | 108 | export const toError = (reqs: Req[]) => 109 | reqs && reqs.length 110 | ? reqs.map((r) => ({ 111 | Id: r.event.id, 112 | MessageAttributes: attrs(r.retryCnt, r.requeueUntil), 113 | MessageBody: JSON.stringify(r.event), 114 | })) 115 | : [] 116 | 117 | const toIso = (n?: number) => (n ? new Date(n).toISOString() : now()) 118 | 119 | const toHeaders = (hs: any): Header[] => 120 | hs ? Object.keys(hs).map((h: any) => ({ name: h, value: hs[h] })) : [] 121 | 122 | const calcAttrs = (r: Req) => { 123 | const rc = r.retryCnt + (r.requeue ? 0 : 1) 124 | const am = attrs( 125 | rc, 126 | r.requeue 127 | ? r.requeueUntil 128 | : epochMsTo(r.event.timestamp) + retries[rc] || 0, 129 | ) 130 | log( 131 | `id=${r.event.id}`, 132 | Object.keys(am) 133 | .map((k) => `${k}=${am[k].StringValue}`) 134 | .join(" "), 135 | ) 136 | return am 137 | } 138 | 139 | const attrs = (rc: number, ru: number): MessageBodyAttributeMap => ({ 140 | partnerQueueUrl: { StringValue: PARTNER_QUEUE, DataType: "String" }, 141 | requeueUntil: { StringValue: ru.toString(), DataType: "Number" }, 142 | retryCnt: { StringValue: rc.toString(), DataType: "Number" }, 143 | }) 144 | 145 | export { toHttpReq } 146 | -------------------------------------------------------------------------------- /src/postHook.ts: -------------------------------------------------------------------------------- 1 | import { log, warn } from "./logger" 2 | import { URL } from "url" 3 | import { Req, Res } from "." 4 | import { post } from "./http" 5 | import { toHttpReq, toHttpRes } from "./mapper" 6 | import { epochMs } from "./util" 7 | 8 | const postHook = async (req: Req): Promise => { 9 | const eUrl = req.event.url 10 | const eBody = req.event.body 11 | const msg = `id=${req.event.id} url=${eUrl}` 12 | const headers = { 13 | "Content-Length": eBody.length.toString(), 14 | "Content-Type": "application/json", 15 | "User-Agent": "dwolla-webhooks/1.1", 16 | "X-Dwolla-Topic": req.event.topic, 17 | "X-Request-Signature-SHA-256": req.event.signatureSha256 || "", 18 | } 19 | const start = epochMs() 20 | 21 | try { 22 | const url = new URL(eUrl) 23 | log(msg) 24 | 25 | const status = ( 26 | await post(eBody, { 27 | headers, 28 | hostname: url.hostname, 29 | method: "POST", 30 | path: url.search ? `${url.pathname}${url.search}` : url.pathname, 31 | port: url.port, 32 | protocol: url.protocol, 33 | timeout: 10000, 34 | }) 35 | ).statusCode 36 | 37 | log(`${msg} status=${status} successful=${status && status < 300}`) 38 | return { 39 | httpReq: toHttpReq(eBody, headers, start, eUrl), 40 | httpRes: status ? toHttpRes(epochMs(), status) : undefined, 41 | req, 42 | } 43 | } catch (err: any) { 44 | warn(`${msg} code=${err.code} message=${err.message}`, err) 45 | return { 46 | err: err.message, 47 | httpReq: toHttpReq(eBody, headers, start, eUrl), 48 | req, 49 | } 50 | } 51 | } 52 | 53 | export { postHook } 54 | -------------------------------------------------------------------------------- /src/publishResults.ts: -------------------------------------------------------------------------------- 1 | import { error, log, warn } from "./logger" 2 | import SQS, { 3 | SendMessageBatchRequestEntryList as EntryList, 4 | SendMessageBatchResult as BatchRes, 5 | } from "aws-sdk/clients/sqs" 6 | import { Req, Res } from "." 7 | import { errorQueueUrl, partnerQueueUrl, resultQueueUrl } from "./config" 8 | import { partition, toError, toRequeue, toResult } from "./mapper" 9 | 10 | export const BATCH_ERROR = "Failed to send error batch" 11 | 12 | type Queue = Readonly<{ name: string; url: string }> 13 | const [partnerQueue, resultQueue, errorQueue, sqs] = [ 14 | { name: "partner", url: partnerQueueUrl() }, 15 | { name: "result", url: resultQueueUrl() }, 16 | { name: "error", url: errorQueueUrl() }, 17 | new SQS({ 18 | httpOptions: { 19 | // @ts-ignore 20 | sslEnabled: true, 21 | timeout: 5000, // Default of 120000 is > function timeout 22 | }, 23 | }), 24 | ] 25 | 26 | export const publishResults = async ( 27 | rs: Res[], 28 | attempt = 1, 29 | ): Promise => { 30 | const [result, requeue] = partition(rs) 31 | return ([] as BatchRes[]).concat( 32 | ...(await Promise.all([ 33 | sendBatch(resultQueue, toResult(result), rs, attempt), 34 | sendBatch(partnerQueue, toRequeue(requeue), rs, attempt), 35 | ])), 36 | ) 37 | } 38 | 39 | export const sendErrorBatch = async (reqs: Req[]): Promise => 40 | sendBatch(errorQueue, toError(reqs), [], 1, true) 41 | 42 | const sendBatch = async ( 43 | q: Queue, 44 | es: EntryList, 45 | rs: Res[], 46 | attempt: number, 47 | throwOnErr = false, 48 | ): Promise => { 49 | let res: BatchRes = { Successful: [], Failed: [] } 50 | if (!es.length) return Promise.resolve([res]) 51 | 52 | log(`Sending ${es.length} to ${q.url}`) 53 | try { 54 | res = await sqs.sendMessageBatch({ QueueUrl: q.url, Entries: es }).promise() 55 | } catch (e) { 56 | error("Throwing", e) 57 | throw e 58 | } 59 | 60 | if (res.Successful.length) { 61 | log(`Sent ${q.name}`, res.Successful.map((s) => s.Id).join(",")) 62 | } 63 | if (res.Failed.length) { 64 | if (throwOnErr) throw new Error(BATCH_ERROR) 65 | 66 | warn( 67 | `Failed ${q.name}, attempt=${attempt}`, 68 | res.Failed.map((s) => JSON.stringify(s)).join("\n"), 69 | ) 70 | const reqs = rs.filter((r) => 71 | res.Failed.map((f) => f.Id).includes(r.req.event.id), 72 | ) 73 | return await (attempt < 3 74 | ? publishResults(reqs, attempt + 1) 75 | : sendErrorBatch(reqs.map((r) => r.req))) 76 | } 77 | 78 | return [res] 79 | } 80 | -------------------------------------------------------------------------------- /src/sendHooks.ts: -------------------------------------------------------------------------------- 1 | import { log } from "./logger" 2 | import { SendMessageBatchResult as BatchResult } from "aws-sdk/clients/sqs" 3 | import pLimit from "p-limit" 4 | import { Req, Res } from "." 5 | import { concurrency } from "./config" 6 | import { postHook } from "./postHook" 7 | import { publishResults } from "./publishResults" 8 | 9 | const limit = pLimit(concurrency()) 10 | 11 | const sendHooks = async (reqs: Req[]): Promise => 12 | publishResults(await Promise.all(reqs.map((r) => limit(post, r)))) 13 | 14 | const post = (r: Req) => { 15 | if (r.requeue) { 16 | log(`Re-queuing message for id=${r.event?.id || ""}`) 17 | return Promise.resolve({ req: r }) 18 | } else { 19 | return postHook(r) 20 | } 21 | } 22 | 23 | export { sendHooks } 24 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | const now = () => new Date().toISOString() 2 | 3 | const epochMs = (): number => new Date().getTime() 4 | 5 | const epochMsTo = (date: string): number => Date.parse(date) 6 | 7 | const envVarRequired = (name: string): string => { 8 | const envVar = process.env[name] 9 | if (envVar) { 10 | return envVar 11 | } 12 | throw new Error(`${name} required`) 13 | } 14 | 15 | export { now, epochMs, epochMsTo, envVarRequired } 16 | -------------------------------------------------------------------------------- /test/config.test.ts: -------------------------------------------------------------------------------- 1 | const c = () => require("../src/config") 2 | 3 | test("concurrency", () => expect(c().concurrency()).toBe(1)) 4 | test("partnerQueueUrl", () => expect(c().partnerQueueUrl()).toBe("partner.com")) 5 | test("resultQueueUrl", () => expect(c().resultQueueUrl()).toBe("result.com")) 6 | test("errorQueueUrl", () => expect(c().errorQueueUrl()).toBe("error.com")) 7 | test("version", () => expect(c().version()).toBe("v1")) 8 | test("retriesMax", () => expect(c().retriesMax()).toBe(8)) 9 | -------------------------------------------------------------------------------- /test/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { SQSEvent } from "aws-lambda" 2 | import { sendErrorBatch, BATCH_ERROR } from "../src/publishResults" 3 | import { sendHooks } from "../src/sendHooks" 4 | import { handle } from "../src/handler" 5 | 6 | jest.mock("../src/sendHooks") 7 | jest.mock("../src/publishResults") 8 | const sendHooksMock = jest.mocked(sendHooks) 9 | const sendErrorBatchMock = jest.mocked(sendErrorBatch) 10 | 11 | describe("handler", () => { 12 | beforeEach(() => jest.resetAllMocks()) 13 | 14 | it("calls sendHooks", async () => { 15 | await handle({ 16 | Records: [{ body: "{}", messageAttributes: {} }], 17 | } as SQSEvent) 18 | 19 | expect(sendHooksMock).toHaveBeenCalled() 20 | }) 21 | 22 | it("calls sendErrorBatch on error", async () => { 23 | const err = new Error() 24 | sendHooksMock.mockRejectedValue(err) 25 | 26 | await handle({ 27 | Records: [{ body: "{}", messageAttributes: {} }], 28 | } as SQSEvent) 29 | 30 | expect(sendErrorBatchMock).toHaveBeenCalled() 31 | }) 32 | 33 | it("throws if BATCH_ERROR", async () => { 34 | const err = new Error(BATCH_ERROR) 35 | sendHooksMock.mockRejectedValue(err) 36 | 37 | await expect( 38 | handle({ 39 | Records: [{ body: "{}", messageAttributes: {} }], 40 | } as SQSEvent), 41 | ).rejects.toBe(err) 42 | 43 | expect(sendErrorBatchMock).toHaveBeenCalledTimes(0) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/mapper.test.ts: -------------------------------------------------------------------------------- 1 | import { SQSRecord } from "aws-lambda" 2 | import { SendMessageBatchRequestEntryList } from "aws-sdk/clients/sqs" 3 | import { Event, IHttpReq, IHttpRes, Req, Res } from "../src" 4 | import { epochMs, epochMsTo, now } from "../src/util" 5 | import { 6 | partition, 7 | retries, 8 | toHttpReq, 9 | toHttpRes, 10 | toReqs, 11 | toRequeue, 12 | toResult, 13 | } from "../src/mapper" 14 | 15 | jest.mock("../src/util") 16 | const epochMsMock = jest.mocked(epochMs) 17 | const epochMsToMock = jest.mocked(epochMsTo) 18 | const nowMock = jest.mocked(now) 19 | 20 | const NOW = "2018-12-26T16:44:38.633Z" 21 | 22 | describe("toReqs", () => { 23 | const BODY = { id: "my-id", timestamp: NOW } as Event 24 | 25 | it("maps", () => { 26 | const exp: Req = { 27 | event: BODY, 28 | requeue: false, 29 | requeueUntil: 0, 30 | retryCnt: 0, 31 | } 32 | epochMsMock.mockReturnValue(0) 33 | 34 | expect( 35 | toReqs([ 36 | { 37 | body: JSON.stringify(BODY), 38 | messageAttributes: {}, 39 | } as SQSRecord, 40 | ]), 41 | ).toEqual([exp]) 42 | }) 43 | 44 | it("removes duplicates", () => { 45 | const exp: Req = { 46 | event: BODY, 47 | requeue: false, 48 | requeueUntil: 0, 49 | retryCnt: 0, 50 | } 51 | epochMsMock.mockReturnValue(0) 52 | 53 | expect( 54 | toReqs([ 55 | { 56 | body: JSON.stringify(BODY), 57 | messageAttributes: {}, 58 | }, 59 | { 60 | body: JSON.stringify(BODY), 61 | messageAttributes: {}, 62 | }, 63 | ] as SQSRecord[]), 64 | ).toEqual([exp]) 65 | }) 66 | 67 | it("maps with attributes", () => { 68 | const exp: Req = { 69 | event: BODY, 70 | requeue: true, 71 | requeueUntil: 2, 72 | retryCnt: 1, 73 | } 74 | epochMsMock.mockReturnValue(1) 75 | epochMsToMock.mockReturnValue(0) 76 | 77 | expect( 78 | toReqs([ 79 | // @ts-ignore 80 | { 81 | body: JSON.stringify(BODY), 82 | messageAttributes: { 83 | requeueUntil: { stringValue: exp.requeueUntil.toString() }, 84 | retryCnt: { stringValue: exp.retryCnt.toString() }, 85 | }, 86 | } as SQSRecord, 87 | ]), 88 | ).toEqual([exp]) 89 | }) 90 | 91 | it("does not allow attributes over max values", () => { 92 | const exp: Req = { 93 | event: BODY, 94 | requeue: true, 95 | requeueUntil: 259200000, 96 | retryCnt: 8, 97 | } 98 | epochMsMock.mockReturnValue(1) 99 | epochMsToMock.mockReturnValue(0) 100 | 101 | expect( 102 | toReqs([ 103 | // @ts-ignore 104 | { 105 | body: JSON.stringify(BODY), 106 | messageAttributes: { 107 | requeueUntil: { stringValue: (exp.requeueUntil + 1).toString() }, 108 | retryCnt: { stringValue: (exp.retryCnt + 1).toString() }, 109 | }, 110 | } as SQSRecord, 111 | ]), 112 | ).toEqual([exp]) 113 | }) 114 | }) 115 | 116 | describe("toHttpReq", () => { 117 | it("maps empty", () => { 118 | const exp: IHttpReq = { 119 | body: "", 120 | headers: [], 121 | timestamp: NOW, 122 | url: "", 123 | } 124 | nowMock.mockReturnValue(exp.timestamp) 125 | 126 | expect( 127 | toHttpReq( 128 | undefined as unknown as string, 129 | undefined as unknown as { [p: string]: string }, 130 | undefined as unknown as number, 131 | undefined as unknown as string, 132 | ), 133 | ).toEqual(exp) 134 | }) 135 | 136 | it("maps", () => { 137 | const d = new Date() 138 | const exp: IHttpReq = { 139 | body: "hi", 140 | headers: [ 141 | { name: "a", value: "b" }, 142 | { name: "c", value: "d" }, 143 | ], 144 | timestamp: d.toISOString(), 145 | url: "https://www.example.com", 146 | } 147 | 148 | expect( 149 | toHttpReq(exp.body, { a: "b", c: "d" }, d.getTime(), exp.url), 150 | ).toEqual(exp) 151 | }) 152 | }) 153 | 154 | describe("toHttpRes", () => { 155 | it("maps empty", () => { 156 | const exp: IHttpRes = { 157 | body: "", 158 | headers: [], 159 | statusCode: 0, 160 | timestamp: NOW, 161 | } 162 | 163 | nowMock.mockReturnValue(exp.timestamp) 164 | 165 | expect( 166 | toHttpRes(undefined as unknown as number, undefined as unknown as number), 167 | ).toEqual(exp) 168 | }) 169 | 170 | it("maps", () => { 171 | const d = new Date() 172 | const exp: IHttpRes = { 173 | body: "", 174 | headers: [], 175 | statusCode: 200, 176 | timestamp: d.toISOString(), 177 | } 178 | 179 | expect(toHttpRes(d.getTime(), exp.statusCode)).toEqual(exp) 180 | }) 181 | }) 182 | 183 | test("partition", () => { 184 | const requeue = { req: { requeue: true, retryCnt: 7 } } as Res 185 | const error = { req: { retryCnt: 7 }, err: "err" } as Res 186 | const failure = { req: { retryCnt: 7 }, httpRes: { statusCode: 400 } } as Res 187 | const maxAttempts = { req: { retryCnt: 8 }, err: "err" } as Res 188 | const success1 = { req: { retryCnt: 7 } } as Res 189 | const success2 = { req: { retryCnt: 7 }, httpRes: { statusCode: 399 } } as Res 190 | 191 | expect( 192 | partition([requeue, error, failure, maxAttempts, success1, success2]), 193 | ).toEqual([ 194 | [error, failure, maxAttempts, success1, success2], 195 | [requeue, error, failure], 196 | ]) 197 | }) 198 | 199 | describe("toResult", () => { 200 | it("maps empty", () => { 201 | expect(toResult(undefined as unknown as Res[])).toEqual([]) 202 | expect(toResult([] as Res[])).toEqual([]) 203 | }) 204 | 205 | it("maps", () => { 206 | const event1 = { 207 | cause: "err", 208 | id: "id1", 209 | request: { url: "url" }, 210 | response: { statusCode: 200 }, 211 | retryCnt: 1, 212 | } 213 | const event2 = { id: "id2" } 214 | const exp: SendMessageBatchRequestEntryList = [ 215 | { Id: event1.id, MessageBody: JSON.stringify(event1) }, 216 | { Id: event2.id, MessageBody: JSON.stringify(event2) }, 217 | ] 218 | 219 | expect( 220 | toResult([ 221 | { 222 | err: event1.cause, 223 | httpReq: event1.request, 224 | httpRes: event1.response, 225 | req: { event: { id: event1.id }, retryCnt: event1.retryCnt }, 226 | }, 227 | { req: { event: { id: event2.id } } }, 228 | ] as Res[]), 229 | ).toEqual(exp) 230 | }) 231 | }) 232 | 233 | describe("toRequeue", () => { 234 | it("maps empty", () => { 235 | expect(toRequeue(undefined as unknown as Res[])).toEqual([]) 236 | expect(toRequeue([] as Res[])).toEqual([]) 237 | }) 238 | 239 | it("maps", () => { 240 | const event1 = { id: "id1", timestamp: NOW } 241 | const event2 = { id: "id2", timestamp: NOW } 242 | const exp: SendMessageBatchRequestEntryList = [ 243 | { 244 | DelaySeconds: 900, 245 | Id: event1.id, 246 | MessageAttributes: { 247 | partnerQueueUrl: { 248 | DataType: "String", 249 | StringValue: "partner.com", 250 | }, 251 | requeueUntil: { 252 | DataType: "Number", 253 | StringValue: "900001", 254 | }, 255 | retryCnt: { 256 | DataType: "Number", 257 | StringValue: "1", 258 | }, 259 | }, 260 | MessageBody: JSON.stringify(event1), 261 | }, 262 | { 263 | DelaySeconds: 900, 264 | Id: event2.id, 265 | MessageAttributes: { 266 | partnerQueueUrl: { 267 | DataType: "String", 268 | StringValue: "partner.com", 269 | }, 270 | requeueUntil: { 271 | DataType: "Number", 272 | StringValue: "2", 273 | }, 274 | retryCnt: { 275 | DataType: "Number", 276 | StringValue: "0", 277 | }, 278 | }, 279 | MessageBody: JSON.stringify(event2), 280 | }, 281 | ] 282 | epochMsToMock.mockReturnValue(1) 283 | 284 | expect( 285 | toRequeue([ 286 | { 287 | req: { 288 | event: { id: event1.id, timestamp: event1.timestamp }, 289 | requeue: false, 290 | requeueUntil: 1, 291 | retryCnt: 0, 292 | }, 293 | }, 294 | { 295 | req: { 296 | event: { id: event2.id, timestamp: event2.timestamp }, 297 | requeue: true, 298 | requeueUntil: 2, 299 | retryCnt: 0, 300 | }, 301 | }, 302 | ] as Res[]), 303 | ).toEqual(exp) 304 | 305 | expect(epochMsToMock).toHaveBeenCalledWith(NOW) 306 | }) 307 | }) 308 | 309 | const [MINS, HRS] = [60 * 1000, 3600 * 1000] 310 | test("retries", () => { 311 | expect(retries[0]).toBe(undefined) 312 | expect(retries[1]).toBe(15 * MINS) 313 | expect(retries[2]).toBe(HRS) 314 | expect(retries[3]).toBe(3 * HRS) 315 | expect(retries[4]).toBe(6 * HRS) 316 | expect(retries[5]).toBe(12 * HRS) 317 | expect(retries[6]).toBe(24 * HRS) 318 | expect(retries[7]).toBe(48 * HRS) 319 | expect(retries[8]).toBe(72 * HRS) 320 | expect(retries[9]).toBe(undefined) 321 | }) 322 | -------------------------------------------------------------------------------- /test/postHook.test.ts: -------------------------------------------------------------------------------- 1 | import { URL } from "url" 2 | import { Req } from "../src" 3 | import { post } from "../src/http" 4 | import { toHttpReq, toHttpRes } from "../src/mapper" 5 | import { epochMs } from "../src/util" 6 | import { postHook } from "../src/postHook" 7 | 8 | jest.mock("http") 9 | jest.mock("https") 10 | jest.mock("../src/http") 11 | jest.mock("../src/mapper") 12 | jest.mock("../src/util") 13 | const toHttpReqMock = jest.mocked(toHttpReq) 14 | const toHttpResMock = jest.mocked(toHttpRes) 15 | const postMock = jest.mocked(post) 16 | const epochMsMock = jest.mocked(epochMs) 17 | 18 | const START = new Date().getTime() 19 | const END = START + 1000 20 | const REQ = { 21 | event: { 22 | body: "bod", 23 | id: "id", 24 | signatureSha256: "sig", 25 | topic: "top", 26 | url: "https://www.example.com", 27 | }, 28 | } as Req 29 | 30 | const headers = (req: Req, sig?: string) => ({ 31 | "Content-Length": req.event.body.length.toString(), 32 | "Content-Type": "application/json", 33 | "User-Agent": "dwolla-webhooks/1.1", 34 | "X-Dwolla-Topic": req.event.topic, 35 | "X-Request-Signature-SHA-256": sig, 36 | }) 37 | 38 | describe("postHook", () => { 39 | afterEach(() => { 40 | toHttpReqMock.mockReset() 41 | toHttpResMock.mockReset() 42 | }) 43 | 44 | it("posts req and returns res", async () => { 45 | const res = { 46 | data: "", 47 | headers: {}, 48 | statusCode: 200, 49 | } 50 | const exp = { 51 | httpReq: { body: "", headers: [], timestamp: "", url: "url" }, 52 | httpRes: { body: "", headers: [], statusCode: 200, timestamp: "" }, 53 | req: REQ, 54 | } 55 | toHttpReqMock.mockReturnValue(exp.httpReq) 56 | toHttpResMock.mockReturnValue(exp.httpRes) 57 | postMock.mockResolvedValue(res) 58 | epochMsMock.mockReturnValueOnce(START) 59 | epochMsMock.mockReturnValueOnce(END) 60 | 61 | const act = await postHook(exp.req) 62 | 63 | expectPostReq(REQ, REQ.event.signatureSha256) 64 | expect(toHttpReqMock).toHaveBeenCalledWith( 65 | REQ.event.body, 66 | headers(REQ, REQ.event.signatureSha256), 67 | START, 68 | REQ.event.url, 69 | ) 70 | expect(toHttpResMock).toHaveBeenCalledWith(END, res.statusCode) 71 | expect(act).toEqual(exp) 72 | }) 73 | 74 | it("handles error", async () => { 75 | const err = { message: "msg" } 76 | const exp = { 77 | err: err.message, 78 | httpReq: { body: "", headers: [], timestamp: "", url: "url" }, 79 | req: REQ, 80 | } 81 | toHttpReqMock.mockReturnValue(exp.httpReq) 82 | postMock.mockRejectedValue(err) 83 | epochMsMock.mockReturnValueOnce(START) 84 | 85 | await expect(postHook(exp.req)).resolves.toEqual(exp) 86 | 87 | expectPostReq(REQ, REQ.event.signatureSha256) 88 | expect(toHttpReqMock).toHaveBeenCalledWith( 89 | REQ.event.body, 90 | headers(REQ, REQ.event.signatureSha256), 91 | START, 92 | REQ.event.url, 93 | ) 94 | }) 95 | 96 | it("sets signature to empty if not provided", async () => { 97 | postMock.mockResolvedValue({}) 98 | const req = { 99 | event: { id: "i", url: REQ.event.url, topic: "t", body: "b" }, 100 | } as Req 101 | 102 | await postHook(req) 103 | 104 | expectPostReq(req, "") 105 | }) 106 | 107 | const expectPostReq = (req: Req, sig?: string) => { 108 | const url = new URL(req.event.url) 109 | expect(postMock).toHaveBeenCalledWith(req.event.body, { 110 | headers: headers(req, sig), 111 | hostname: url.hostname, 112 | method: "POST", 113 | path: url.pathname, 114 | port: url.port, 115 | protocol: url.protocol, 116 | timeout: 10000, 117 | }) 118 | } 119 | }) 120 | -------------------------------------------------------------------------------- /test/publishResults.test.ts: -------------------------------------------------------------------------------- 1 | import SQS, { 2 | MessageBodyAttributeMap, 3 | SendMessageBatchRequestEntryList, 4 | } from "aws-sdk/clients/sqs" 5 | import { Res } from "../src" 6 | import { partnerQueueUrl, resultQueueUrl, errorQueueUrl } from "../src/config" 7 | import { toResult, toRequeue, toError, partition } from "../src/mapper" 8 | 9 | jest.mock("aws-sdk/clients/sqs") 10 | jest.mock("../src/config") 11 | jest.mock("../src/mapper") 12 | jest.mock("../src/util") 13 | const sqs = jest.mocked(SQS) 14 | const toResultMock = jest.mocked(toResult) 15 | const toRequeueMock = jest.mocked(toRequeue) 16 | const toErrorMock = jest.mocked(toError) 17 | const partitionMock = jest.mocked(partition) 18 | const partnerQueueUrlMock = jest.mocked(partnerQueueUrl) 19 | const resultQueueUrlMock = jest.mocked(resultQueueUrl) 20 | const errorQueueUrlMock = jest.mocked(errorQueueUrl) 21 | 22 | const [PARTNER_URL, RESULT_URL, ERROR_URL] = ["url", "resultUrl", "errorUrl"] 23 | sqs.mockImplementation(() => { 24 | return { sendMessageBatch: sendMessageBatchMock } as unknown as SQS 25 | }) 26 | 27 | const sendMessageBatchMock = jest.fn() 28 | 29 | partnerQueueUrlMock.mockReturnValue(PARTNER_URL) 30 | resultQueueUrlMock.mockReturnValue(RESULT_URL) 31 | errorQueueUrlMock.mockReturnValue(ERROR_URL) 32 | 33 | import { publishResults } from "../src/publishResults" 34 | 35 | describe("publishResults", () => { 36 | beforeEach(() => { 37 | sqs.mockReset() 38 | sendMessageBatchMock.mockReset() 39 | jest.clearAllMocks() 40 | }) 41 | afterEach(() => sendMessageBatchMock.mockReset()) 42 | 43 | const generateEvent = (id: string): Readonly => { 44 | return { 45 | req: { 46 | event: { 47 | id: `Event Id ${id}`, 48 | url: "someExternalUrl", 49 | topic: "Topic", 50 | body: "{}", 51 | signatureSha256: "string", 52 | timestamp: Date.now().toString(), 53 | }, 54 | requeue: false, 55 | requeueUntil: 0, 56 | retryCnt: 0, 57 | }, 58 | } 59 | } 60 | 61 | const generateSendMessageBatchRequestEntryList = ( 62 | req: Res[], 63 | ): SendMessageBatchRequestEntryList => { 64 | return req.map((r) => { 65 | return { 66 | DelaySeconds: 900, 67 | Id: r.req.event.id, 68 | MessageAttributes: undefined, 69 | MessageBody: JSON.stringify(r.req.event), 70 | } 71 | }) 72 | } 73 | 74 | it("sends message batch", async () => { 75 | const rs = [{}] as Res[] 76 | const result: Res[] = [generateEvent("1")] 77 | const requeue = [generateEvent("2")] 78 | const resultEs: SendMessageBatchRequestEntryList = 79 | generateSendMessageBatchRequestEntryList(result) 80 | const requeueEs: SendMessageBatchRequestEntryList = 81 | generateSendMessageBatchRequestEntryList(requeue) 82 | const exp = { Successful: [{}], Failed: [] } 83 | sendMessageBatchMock.mockReturnValue({ promise: () => exp }) 84 | partitionMock.mockReturnValue([result, requeue]) 85 | toResultMock.mockReturnValue(resultEs) 86 | toRequeueMock.mockReturnValue(requeueEs) 87 | 88 | expect(await publishResults(rs)).toEqual([exp, exp]) 89 | 90 | expect(partitionMock).toHaveBeenCalledWith(rs) 91 | expect(toResultMock).toHaveBeenCalledWith(result) 92 | expect(toRequeueMock).toHaveBeenCalledWith(requeue) 93 | expect(sendMessageBatchMock).toHaveBeenCalledTimes(2) 94 | expect(sendMessageBatchMock).toHaveBeenCalledWith({ 95 | Entries: requeueEs, 96 | QueueUrl: PARTNER_URL, 97 | }) 98 | expect(sendMessageBatchMock).toHaveBeenCalledWith({ 99 | Entries: resultEs, 100 | QueueUrl: RESULT_URL, 101 | }) 102 | }) 103 | 104 | it("try 3 times and then throw", async () => { 105 | const id = "10" 106 | const rs = [{ req: { event: { id } } }] as Res[] 107 | const result = [generateEvent("1")] 108 | const resultEs = generateSendMessageBatchRequestEntryList(result) 109 | const errorEs = [ 110 | { 111 | Id: id, 112 | MessageAttributes: {} as MessageBodyAttributeMap, 113 | MessageBody: "message body", 114 | }, 115 | ] 116 | sendMessageBatchMock.mockReturnValue({ 117 | promise: () => ({ Failed: [{ Id: id }], Successful: [] }), 118 | }) 119 | partitionMock.mockReturnValue([result, []]) 120 | toResultMock.mockReturnValue(resultEs) 121 | toRequeueMock.mockReturnValue([]) 122 | toErrorMock.mockReturnValue(errorEs) 123 | 124 | await expect(publishResults(rs)).rejects.toEqual( 125 | new Error("Failed to send error batch"), 126 | ) 127 | 128 | const args = { Entries: resultEs, QueueUrl: RESULT_URL } 129 | 130 | expect(partition).toHaveBeenCalledWith(rs) 131 | expect(toResult).toHaveBeenCalledWith(result) 132 | expect(toError).toHaveBeenCalledWith(rs.map((e) => e.req)) 133 | expect(sendMessageBatchMock).toHaveBeenCalledTimes(4) 134 | expect(sendMessageBatchMock).toHaveBeenCalledWith(args) 135 | expect(sendMessageBatchMock).lastCalledWith({ 136 | Entries: errorEs, 137 | QueueUrl: ERROR_URL, 138 | }) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /test/sendHooks.test.ts: -------------------------------------------------------------------------------- 1 | import { SendMessageBatchResult } from "aws-sdk/clients/sqs" 2 | import { Req, Res, Event } from "../src" 3 | import { postHook } from "../src/postHook" 4 | import { publishResults } from "../src/publishResults" 5 | import { sendHooks } from "../src/sendHooks" 6 | 7 | jest.mock("../src/postHook") 8 | jest.mock("../src/publishResults") 9 | const postHookMock = jest.mocked(postHook) 10 | const publishResultsMock = jest.mocked(publishResults) 11 | 12 | const event: Event = { 13 | id: "id", 14 | url: "url", 15 | topic: "topic", 16 | body: "body", 17 | signatureSha256: "signature", 18 | timestamp: "timestamp", 19 | } 20 | 21 | describe("sendHooks", () => { 22 | afterEach(() => { 23 | jest.resetAllMocks() 24 | //postHookMock.mockReset() 25 | //publishResultsMock.mockReset() 26 | }) 27 | 28 | it("posts hook and publishes results", async () => { 29 | const req = [{ event } as Req, { event, retryCnt: 1 } as Req] 30 | const exp = [{ Successful: [], Failed: [] }] as SendMessageBatchResult[] 31 | const res = [{} as Res, { err: "err" } as Res] 32 | postHookMock.mockResolvedValueOnce(res[0]) 33 | postHookMock.mockResolvedValueOnce(res[1]) 34 | publishResultsMock.mockResolvedValue(exp) 35 | 36 | expect(await sendHooks(req)).toBe(exp) 37 | 38 | expect(postHookMock).toHaveBeenCalledTimes(2) 39 | expect(postHookMock).toHaveBeenCalledWith(req[0]) 40 | expect(postHookMock).toHaveBeenCalledWith(req[1]) 41 | expect(publishResultsMock).toHaveBeenCalledWith(res) 42 | }) 43 | 44 | it("does not post hook if requeue", async () => { 45 | const exp = [{ Successful: [], Failed: [] }] as SendMessageBatchResult[] 46 | postHookMock.mockResolvedValue({} as Res) 47 | publishResultsMock.mockResolvedValue(exp) 48 | 49 | expect(await sendHooks([{ event, requeue: true } as Req])).toBe(exp) 50 | 51 | expect(postHookMock).not.toHaveBeenCalled() 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/util.test.ts: -------------------------------------------------------------------------------- 1 | import { epochMs, epochMsTo, now } from "../src/util" 2 | 3 | const toMins = (n: number) => Math.floor(n / 10000) 4 | 5 | const toMinsStr = (s: string) => s.substring(0, 16) 6 | 7 | test("now", () => 8 | expect(toMinsStr(now())).toBe(toMinsStr(new Date().toISOString()))) 9 | 10 | test("epochMs", () => 11 | expect(toMins(epochMs())).toBe(toMins(new Date().getTime()))) 12 | 13 | test("epochMsTo", () => 14 | expect(epochMsTo("2018-01-01T12:34:56.789Z")).toBe(1514810096789)) 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./scripts/tsconfig.json", 3 | "include": ["./src", "./scripts"] 4 | } 5 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const slsw = require("serverless-webpack") 2 | module.exports = { 3 | devtool: "source-map", 4 | entry: slsw.lib.entries, 5 | mode: slsw.lib.webpack.isLocal ? "development" : "production", 6 | module: { rules: [{ test: /\.tsx?$/, loader: "ts-loader" }] }, 7 | performance: { hints: false }, 8 | resolve: { extensions: [".js", ".jsx", ".json", ".ts", ".tsx"] }, 9 | target: "node", 10 | } 11 | --------------------------------------------------------------------------------