├── .eslintrc ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── hookdeck.config.d.ts ├── img ├── build_time.jpg ├── build_time.svg ├── hookdeck-connection.png ├── hookdeck-events.png ├── hookdeck-requests.png ├── hookdeck-vercel-middleware.png ├── run_time.jpg ├── run_time.svg └── vercel-logs.png ├── index.ts ├── package-lock.json ├── package.json ├── scripts ├── addPrebuildScript.js ├── hookdeck.config.js ├── hookdeck.config.sample.js ├── middleware.ts └── prebuild.js ├── tsconfig.json └── tsup.config.ts /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "plugin:import/errors", 9 | "plugin:import/warnings", 10 | "plugin:import/typescript" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "project": "./tsconfig.json", 15 | "tsconfigRootDir": "./", 16 | "sourceType": "module", 17 | "extraFileExtensions": [".mjs"] 18 | }, 19 | "plugins": ["@typescript-eslint", "import"], 20 | "ignorePatterns": ["dist", "node_modules", "scripts/middleware.ts"], 21 | "rules": { 22 | "@typescript-eslint/adjacent-overload-signatures": "error", 23 | "@typescript-eslint/no-empty-function": "error", 24 | "@typescript-eslint/no-empty-interface": "warn", 25 | "@typescript-eslint/no-floating-promises": "error", 26 | "@typescript-eslint/no-namespace": "error", 27 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 28 | "@typescript-eslint/prefer-for-of": "warn", 29 | "@typescript-eslint/triple-slash-reference": "error", 30 | "@typescript-eslint/unified-signatures": "warn", 31 | "constructor-super": "error", 32 | "eqeqeq": ["warn", "always"], 33 | "import/no-deprecated": "warn", 34 | "import/no-extraneous-dependencies": "warn", 35 | "import/no-unassigned-import": "warn", 36 | "no-cond-assign": "error", 37 | "no-duplicate-case": "error", 38 | "no-duplicate-imports": "error", 39 | "no-empty": [ 40 | "error", 41 | { 42 | "allowEmptyCatch": true 43 | } 44 | ], 45 | "no-invalid-this": "off", 46 | "no-new-wrappers": "error", 47 | "no-param-reassign": "error", 48 | "no-redeclare": "error", 49 | "no-sequences": "error", 50 | "no-shadow": [ 51 | "error", 52 | { 53 | "hoist": "all" 54 | } 55 | ], 56 | "no-throw-literal": "error", 57 | "no-unsafe-finally": "error", 58 | "no-unused-labels": "error", 59 | "no-var": "warn", 60 | "no-void": "error", 61 | "prefer-const": "warn" 62 | }, 63 | "settings": { 64 | "import/resolver": { 65 | "typescript": {} 66 | }, 67 | "jest": { 68 | "version": 26 69 | }, 70 | "jsdoc": { 71 | "tagNamePreference": { 72 | "returns": "return" 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .hookdeck/ 2 | hookdeck.config.js 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Hookdeck Technologies Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hookdeck Vercel Middleware 2 | 3 | The Hookdeck Vercel Middleware adds the ability to authenticate, delay, filter, queue, throttle, and retry asynchronous HTTP requests (e.g., webhooks) made to a Vercel application. The use cases for this include managing webhooks from API providers such as Stripe, Shopify, and Twilio, or when building asynchronous APIs. 4 | 5 | ![Hookdeck Vercel Middleware](img/hookdeck-vercel-middleware.png) 6 | 7 | ## Get Started 8 | 9 | Before you begin: 10 | 11 | - Create a [Vercel](https://vercel.com?ref=github-hookdeck-vercel) account and a project. 12 | - [Signup for a Hookdeck account](https://dashboard.hookdeck.com/signup?ref=github-hookdeck-vercel) and create your Hookdeck project. 13 | - Get the Hookdeck API key and Signing Secret from your [project secrets](https://dashboard.hookdeck.com/settings/project/secrets?ref=github-hookdeck-vercel). 14 | - Add `HOOKDECK_API_KEY` and `HOOKDECK_SIGNING_SECRET` (optional but recommended) as [environment variables](https://vercel.com/docs/projects/environment-variables?ref=github-hookdeck-vercel) 15 | for your Vercel project. 16 | 17 | Install the Hookdeck Vercel package: 18 | 19 | ```bash 20 | npm i @hookdeck/vercel 21 | ``` 22 | 23 | > Once installed, package a `hookdeck.config.js` file is created at the root of your project. Also, the command `hookdeck-vercel deploy` is appended to the `prebuild` script of your `package.json`. 24 | 25 | Ensure the `match` key in `hookdeck.config.js` matches the route you want the middleware to intercept: 26 | 27 | ```js 28 | const { RetryStrategy, DestinationRateLimitPeriod } = require('@hookdeck/sdk/api'); 29 | 30 | /** @type {import("@hookdeck/vercel").HookdeckConfig} */ 31 | const hookdeckConfig = { 32 | match: { 33 | '/api/webhooks': { 34 | retry: { 35 | strategy: RetryStrategy.Linear, 36 | count: 5, 37 | interval: 1 * 60 * 1000, // in milliseconds 38 | }, 39 | rate: { 40 | limit: 10, 41 | period: DestinationRateLimitPeriod.Second, 42 | }, 43 | }, 44 | }, 45 | }; 46 | 47 | module.exports = hookdeckConfig; 48 | ``` 49 | 50 | This example configures a retry strategy with a linear back-off and a delivery rate limit of 10 requests per second. 51 | 52 | > ℹ️ The Hookdeck Vercel Middleware uses `VERCEL_BRANCH_URL` by default. If you are not deploying your application via source control, set `hookdeckConfig.vercel_url` to a different URL such as `process.env.VERCEL_URL`. 53 | 54 | Update `middleware.ts` (or `middleware.js`) to add the Hookdeck Vercel Middleware and ensure `config.matcher` has the same (or broader) value as you have in `hookdeck.config.js` (e.g., `/api/webhooks`): 55 | 56 | ```typescript 57 | import { withHookdeck } from '@hookdeck/vercel'; 58 | import hookdeckConfig from './hookdeck.config'; 59 | 60 | import { NextResponse } from 'next/server'; 61 | 62 | export const config = { 63 | matcher: '/api/webhooks', 64 | }; 65 | 66 | function middleware(request: Request) { 67 | // ... existing or additional your middleware logic 68 | 69 | NextResponse.next(); 70 | } 71 | 72 | // wrap the middleware with Hookdeck wrapper 73 | export default withHookdeck(hookdeckConfig, middleware); 74 | ``` 75 | 76 | If you don't already have a route, create one. For example, `app/api/webhooks/route.ts` for the `/api/webhooks` endpoint in a Next.js app using the app router: 77 | 78 | ```typescript 79 | export async function POST() { 80 | const data = { received: true }; 81 | 82 | return Response.json(data); 83 | } 84 | ``` 85 | 86 | Deploy the application to Vercel. 87 | 88 | Once the deployment has succeeded, make a request to your middleware endpoint: 89 | 90 | ```bash 91 | curl --location 'http://your.vercel.app/api/webhooks' \ 92 | --header 'Content-Type: application/json' \ 93 | --data '{ 94 | "test": "value" 95 | }' 96 | ``` 97 | 98 | Check the Vercel logs to see that the middleware is processing the events: 99 | 100 | ![Vercel Logs](img/vercel-logs.png) 101 | 102 | Check the Hookdeck logs to see that the requests are being handled and the events are being processed and delivered: 103 | 104 | ![Hookdeck events](img/hookdeck-events.png) 105 | 106 | ## Configuration 107 | 108 | ## Environment Variables 109 | 110 | Environment variables used by the middleware. Get the values from the [Hookdeck project secrets](https://dashboard.hookdeck.com/settings/project/secrets?ref=github-hookdeck-vercel). 111 | 112 | - `HOOKDECK_API_KEY`: The Hookdeck project API Key used to manage the [connections](https://hookdeck.com/docs/connections?ref=github-hookdeck-vercel) within your project. 113 | - `HOOKDECK_SIGNING_SECRET`: Optional but recommended. Used to check the signature of the inbound HTTP request when received from Hookdeck. See [webhook signature verification](https://hookdeck.com/docs/authentication#hookdeck-webhook-signature-verification?ref=github-hookdeck-vercel). Get the value from the [Hookdeck project secrets](https://dashboard.hookdeck.com/settings/project/secrets?ref=github-hookdeck-vercel). 114 | 115 | You can also set these values programmatically within `hookdeck.config.js`. 116 | 117 | ### `middleware.ts` 118 | 119 | The [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware?ref=github-hookdeck-vercel) must be updated as follows: 120 | 121 | 1. Update the exported `config.matcher` to match the route for the Hookdeck Vercel Middleware 122 | 2. Import and use `withHookdeck` middleware wrapper 123 | 124 | ```typescript 125 | // add Hookdeck imports 126 | import { withHookdeck } from '@hookdeck/vercel'; 127 | import hookdeckConfig from './hookdeck.config'; 128 | 129 | export const config = { 130 | matcher: 'path/to/match', 131 | }; 132 | 133 | // the middleware is not exported anymore 134 | function middleware(request: Request) { 135 | // ... your middleware logic 136 | // return `NextResponse.next()` or `next()` to manage the request with Hookdeck 137 | } 138 | 139 | // wrap the middleware with Hookdeck wrapper 140 | export default withHookdeck(hookdeckConfig, middleware); 141 | ``` 142 | 143 | ### `hookdeck.config.js` 144 | 145 | The minimum configuration is the following, with the `match` containing an object with a matching path (`path/to/match`) as the key. This value should be the same as the value exported via `config.matcher` in `middleware.ts`. 146 | 147 | ```js 148 | /** @type {import("@hookdeck/vercel").HookdeckConfig} */ 149 | const hookdeckConfig = { 150 | match: { 151 | 'path/to/match': {}, 152 | }, 153 | }; 154 | 155 | module.exports = hookdeckConfig; 156 | ``` 157 | 158 | > IMPORTANT: Ensure `config.matcher` in your `middleware` includes the routes specified in the `hookdeck.config.js` `match` fields. Only routes that match both expressions will trigger the Hookdeck functionality. 159 | 160 | Full configuration options: 161 | 162 | - `api_key`: The Hookdeck project API Key used to manage the [connections](https://hookdeck.com/docs/connections?ref=github-hookdeck-vercel) within your project. This config value will override the `HOOKDECK_API_KEY` environment variable. Get the value from the [Hookdeck project secrets](https://dashboard.hookdeck.com/settings/project/secrets?ref=github-hookdeck-vercel). 163 | - `signing_secret`: Used to check the signature of the inbound HTTP request when it is received from Hookdeck. This config value will override the `HOOKDECK_SIGNING_SECRET` environment variable. See [webhook signature verification](https://hookdeck.com/docs/authentication#hookdeck-webhook-signature-verification?ref=github-hookdeck-vercel). Get the value from the [Hookdeck project secrets](https://dashboard.hookdeck.com/settings/project/secrets?ref=github-hookdeck-vercel). 164 | - `vercel_url`: The Vercel URL that receives the requests. If not specified, the url stored in env var `VERCEL_BRANCH_URL` will be used. 165 | - `match`: a key-value map of paths for routes and the configuration for each of those routes. 166 | - `[path]` - the matching string or regex that will be compared or tested against the pathname of the URL that triggered the middleware. If there is more than one match, then the request is sent to every matching configuration. 167 | - `retry`: use the values specified in the [Retry documentation](https://hookdeck.com/docs/api#retry?ref=github-hookdeck-vercel) to configure Hookdeck's retry strategy. 168 | - `delay`: the number of milliseconds to hold the event when it arrives at Hookdeck. 169 | - `filters`: specify different filters to exclude some events from forwarding. Use the syntax specified in the [Filter documentation](https://hookdeck.com/docs/api#filter?ref=github-hookdeck-vercel). 170 | - `rate`: set the delivery rate to be used for the outcoming traffic. Check the syntax in the `rate_limit_period` key in the [Destination documentation](https://hookdeck.com/docs/api#destination-object?ref=github-hookdeck-vercel). 171 | - `verification`: the inbound (source) verification mechanism to use. Check all possible values and syntax in the [Source documentation](https://hookdeck.com/docs/api#source-object?ref=github-hookdeck-vercel). 172 | - `custom_response`: the custom response to send back the webhook origin. Check the syntax in the [Source documentation](https://hookdeck.com/docs/api#source-object?ref=github-hookdeck-vercel). 173 | 174 | Here's an example with all the configuration options: 175 | 176 | ```javascript 177 | const { 178 | RetryStrategy, 179 | DestinationRateLimitPeriod, 180 | SourceCustomResponseContentType, 181 | } = require('@hookdeck/sdk/api'); 182 | 183 | /** @type {import("@hookdeck/vercel").HookdeckConfig} */ 184 | const hookdeckConfig = { 185 | // vercel_url: '', // optional. Uses `VERCEL_BRANCH_URL` env var as default. 186 | match: { 187 | '/api/webhooks': { 188 | // all these fields are optional 189 | retry: { 190 | strategy: RetryStrategy.Linear, 191 | count: 5, 192 | interval: 1 * 60 * 1000, // in milliseconds 193 | }, 194 | delay: 1 * 60 * 1000, // in milliseconds 195 | filters: [ 196 | { 197 | headers: { 198 | 'x-my-header': 'my-value', 199 | }, 200 | body: {}, 201 | query: {}, 202 | path: {}, 203 | }, 204 | ], 205 | rate: { 206 | limit: 10, 207 | period: DestinationRateLimitPeriod.Minute, 208 | }, 209 | verification: { 210 | type: 'API_KEY', 211 | configs: { 212 | header_key: 'x-my-api-key', 213 | api_key: 'this-is-my-token', 214 | }, 215 | }, 216 | custom_response: { 217 | content_type: SourceCustomResponseContentType.Json, 218 | body: '{"message": "Vercel handled the webhook using Hookdeck"}', 219 | }, 220 | }, 221 | }, 222 | }; 223 | 224 | module.exports = hookdeckConfig; 225 | ``` 226 | 227 | This includes request delay, retry, and a rate of delivery: 228 | 229 | ```javascript 230 | const { RetryStrategy, DestinationRateLimitPeriod } = require('@hookdeck/sdk/api'); 231 | 232 | /** @type {import("@hookdeck/vercel").HookdeckConfig} */ 233 | const hookdeckConfig = { 234 | vercel_url: 'https://my-vercel-project-eta-five-30.vercel.app', 235 | match: { 236 | '/api/webhook': { 237 | name: 'my-webhook-source-name', 238 | retry: { 239 | strategy: RetryStrategy.Linear, 240 | count: 5, 241 | interval: 15000, // in ms 242 | }, 243 | delay: 30000, // in ms 244 | rate: { 245 | limit: 100, 246 | period: DestinationRateLimitPeriod.Minute, 247 | }, 248 | }, 249 | }, 250 | }; 251 | 252 | module.exports = hookdeckConfig; 253 | ``` 254 | 255 | ## Considerations and Limitations 256 | 257 | ### Removing the Middleware and Going Directly to Hookdeck 258 | 259 | The Hookdeck Vercel middleware adds a hop to every process request, so if milliseconds are a factor in processing requests, you may want to use Hookdeck directly and not use the middleware. 260 | 261 | With the Hookdeck Vercel Middleware: 262 | 263 | 1. Request to Vercel URL 264 | 2. Forward request to Hookdeck 265 | 3. Request back to Vercel URL (which the middleware passes through) 266 | 267 | Without the Hookdeck Vercel Middleware: 268 | 269 | 1. Request to Hookdeck Source URL 270 | 2. Request to Vercel URL 271 | 272 | You can remove the middleware by uninstalling the package and removing any configuration and directly using the [Hookdeck Source](https://hookdeck.com/docs/sources?ref=github-hookdeck-vercel) URL where you previously used the Vercel URL, for example, as your Stripe or Shopify webhook URL. 273 | 274 | ### Parallel Matching 275 | 276 | If you have multiple entries in the config file with the same `match` path, be aware that the middleware will send the request via `fetch` call once per match and will try to do that in parallel. This heavy use is not a common case, but please check [Edge Middleware Limitations](https://vercel.com/docs/functions/edge-middleware/limitations?ref=github-hookdeck-vercel) if you are in this scenario. 277 | 278 | ## How the Hookdeck Vercel Middleware Works 279 | 280 | The `@hookdeck/vercel` package is supported in the [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware?ref=github-hookdeck-vercel) and executes before a request is processed on your site. This way, the request can be forwarded to Hookdeck and then received again by your specified endpoint, but with all the extra features you may need from Hookdeck, such as queuing, filters, and retry strategies. 281 | 282 | This Hookdeck Vercel Middleware package is used in two stages: 283 | 284 | ### Deploy/Build 285 | 286 | During deployment, a `prebuild`` hook checks a configuration file and dynamically creates and configures a [connection](https://hookdeck.com/docs/connections?ref=github-hookdeck-vercel) in Hookdeck: 287 | 288 | ![Build time](img/build_time.jpg) 289 | 290 | For example, the following `hookdeck.config.js`: 291 | 292 | ```typescript 293 | /** @type {import("@hookdeck/vercel").HookdeckConfig} */ 294 | const hookdeckConfig = { 295 | vercel_url: 'https://hookdeck-vercel-example.vercel.app/', 296 | match: { 297 | '/api/webhooks': {}, 298 | }, 299 | }; 300 | 301 | module.exports = hookdeckConfig; 302 | ``` 303 | 304 | Results in something like the following connection being created in Hookdeck: 305 | 306 | ![Hookdeck Connection](img/hookdeck-connection.png) 307 | 308 | ### Runtime 309 | 310 | The package also in runtime by sending to Hookdeck the requests that match your configuration: 311 | 312 | ![Run time](img/run_time.jpg) 313 | 314 | When your Edge Middleware is triggered (because your middleware config matches), the `withHookdeck` wrapper acts as follows: 315 | 316 | - If there is no config file or none of the entries inside `hookdeck.config.js` matches the route, then your `middleware` function is invoked as is. 317 | If there are matches with the entries of `hookdeck.config.js` then the following can happen: 318 | 319 | 1. The received request has not been processed by Hookdeck (yet). In this case, your `middleware` function is invoked to obtain a `response`. If the returned value from your `middleware` is `NextResponse.next()` or `next()`, then the request is bounced back to Hookdeck. 320 | 321 | _NOTE_: If you are not using `next/server` or `@vercel/edge`, return a new `Response` with a header `x-middleware-next` with value `"1"` if you want you want Hookdeck to manage your request. 322 | 323 | 2. The received request comes from Hookdeck and has been processed. Then, the request is sent to the final route or URL you specified. Your `middleware` function code will not be executed this time. 324 | 325 | ## Development 326 | 327 | ### Build 328 | 329 | ```sh 330 | npm run build 331 | ``` 332 | 333 | ### Release 334 | 335 | Bump the version according to semver. 336 | 337 | Commit the changes. 338 | 339 | Push the changes to GitHub. 340 | 341 | Release to NPM 342 | 343 | ```sh 344 | npm run release 345 | ``` 346 | 347 | Release on GitHub. 348 | -------------------------------------------------------------------------------- /hookdeck.config.d.ts: -------------------------------------------------------------------------------- 1 | import { Hookdeck } from '@hookdeck/sdk'; 2 | 3 | interface DeliveryRate { 4 | limit?: number; 5 | period?: Hookdeck.DestinationRateLimitPeriod; 6 | } 7 | 8 | interface HookdeckConfig { 9 | vercel_url?: string; // optional 10 | 11 | api_key?: string; // not recommended, use HOOKDECK_API_KEY instead 12 | signing_secret?: string; // not recommended, use HOOKDECK_SIGNING_SECRET instead 13 | 14 | match: { 15 | [key: string]: { 16 | // all attributes are optional 17 | 18 | // source name 19 | name?: string; 20 | 21 | // Hookdeck basic functionality 22 | retry?: Omit; 23 | delay?: number; 24 | filters?: Array>; 25 | rate?: DeliveryRate; 26 | 27 | // source verification 28 | verification?: Hookdeck.SourceVerification; 29 | 30 | // tweak response 31 | custom_response?: Hookdeck.SourceCustomResponse; 32 | }; 33 | }; 34 | } 35 | 36 | export type { HookdeckConfig }; 37 | -------------------------------------------------------------------------------- /img/build_time.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hookdeck/hookdeck-vercel/9d8bf3dd6c5c74f1d7b894e771d48b6f1e72e754/img/build_time.jpg -------------------------------------------------------------------------------- /img/build_time.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | build_time 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Prebuild 16 | 17 | 18 | 19 | 20 | 21 | Continue 22 | building 23 | 24 | 25 | 26 | 27 | 28 | middleware found 29 | 30 | 31 | 32 | config file found 33 | 34 | 35 | 36 | 37 | 38 | build will fail if 39 | somenthing is 40 | wrong with your 41 | API Key or your 42 | configuration 43 | 44 | 45 | 46 | 47 | 48 | Hookdeck 49 | 50 | 51 | Vercel 52 | 53 | 54 | 55 | 56 | Hookdeck API 57 | 58 | 59 | 60 | creates resources if necesary 61 | and verify API Key 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 3 71 | 72 | 73 | 74 | deployment 75 | 76 | 77 | 78 | 79 | 1 80 | 81 | 82 | 83 | check use and 84 | configuration 85 | 86 | 87 | 88 | 89 | 2 90 | 91 | 92 | 93 | Returns operation status 94 | and connections info 95 | 96 | 97 | 98 | 99 | 4 100 | 101 | 102 | 103 | Deploy time 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /img/hookdeck-connection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hookdeck/hookdeck-vercel/9d8bf3dd6c5c74f1d7b894e771d48b6f1e72e754/img/hookdeck-connection.png -------------------------------------------------------------------------------- /img/hookdeck-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hookdeck/hookdeck-vercel/9d8bf3dd6c5c74f1d7b894e771d48b6f1e72e754/img/hookdeck-events.png -------------------------------------------------------------------------------- /img/hookdeck-requests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hookdeck/hookdeck-vercel/9d8bf3dd6c5c74f1d7b894e771d48b6f1e72e754/img/hookdeck-requests.png -------------------------------------------------------------------------------- /img/hookdeck-vercel-middleware.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hookdeck/hookdeck-vercel/9d8bf3dd6c5c74f1d7b894e771d48b6f1e72e754/img/hookdeck-vercel-middleware.png -------------------------------------------------------------------------------- /img/run_time.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hookdeck/hookdeck-vercel/9d8bf3dd6c5c74f1d7b894e771d48b6f1e72e754/img/run_time.jpg -------------------------------------------------------------------------------- /img/run_time.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | run_time 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Returns to your 12 | Middleware 13 | 14 | 15 | 16 | 4 17 | 18 | 19 | 20 | 21 | Hookdeck 22 | 23 | 24 | Vercel 25 | 26 | 27 | 28 | 29 | Your source 30 | ( 31 | s 32 | ) 33 | 34 | endpoint 35 | ( 36 | s 37 | ) 38 | 39 | 40 | 41 | 42 | 43 | Processed 44 | request 45 | 46 | 47 | 48 | 49 | 50 | Edge 51 | Middleware 52 | 53 | 54 | 55 | 56 | 57 | your funcion or 58 | endpoint 59 | 60 | 61 | 62 | 63 | 64 | 65 | request 66 | 67 | 68 | if match 69 | 70 | 71 | 72 | 73 | 74 | 75 | 1 76 | 77 | 78 | 79 | 80 | 81 | 2 82 | 83 | 84 | 85 | 86 | 87 | 88 | Applies your 89 | configuration 90 | 91 | 92 | 93 | 3 94 | 95 | 96 | 97 | 98 | Continue to final 99 | endpoint 100 | 101 | 102 | 103 | 104 | 5 105 | 106 | 107 | 108 | 109 | Run time 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /img/vercel-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hookdeck/hookdeck-vercel/9d8bf3dd6c5c74f1d7b894e771d48b6f1e72e754/img/vercel-logs.png -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { HookdeckConfig } from './hookdeck.config'; 2 | import { next } from '@vercel/edge'; 3 | 4 | const AUTHENTICATED_ENTRY_POINT = 'https://hkdk.events/publish'; 5 | const HOOKDECK_PROCESSED_HEADER = 'x-hookdeck-signature'; 6 | const HOOKDECK_SIGNATURE_HEADER_1 = 'x-hookdeck-signature'; 7 | const HOOKDECK_SIGNATURE_HEADER_2 = 'x-hookdeck-signature-2'; 8 | 9 | export function withHookdeck(config: HookdeckConfig, f?: Function): (args) => Promise { 10 | return async function (...args) { 11 | const request = args[0]; 12 | 13 | try { 14 | const pathname = getPathnameWithFallback(request); 15 | const cleanPath = pathname.split('&')[0]; 16 | 17 | const connections: Array = []; 18 | for (const e of Object.entries(config.match)) { 19 | const key = e[0]; 20 | const value = e[1] as any; 21 | 22 | const conn = Object.assign(value, { 23 | matcher: key, 24 | source_name: value.name || (await vercelHash(key)), 25 | }); 26 | 27 | connections.push(conn); 28 | } 29 | 30 | const matching = connections.filter( 31 | (conn_config) => (cleanPath.match(conn_config.matcher) ?? []).length > 0, 32 | ); 33 | 34 | if (matching.length === 0) { 35 | console.debug( 36 | `[Hookdeck] No match for path '${cleanPath}'... calling ${f ? 'user middleware' : 'next'}`, 37 | ); 38 | return Promise.resolve(f ? f.apply(this, args) : next()); 39 | } 40 | 41 | const api_key = config.api_key || process.env.HOOKDECK_API_KEY; 42 | if (!api_key) { 43 | console.warn( 44 | "[Hookdeck] Hookdeck API key not found. You must set it as a env variable named HOOKDECK_API_KEY or include it in your hookdeck.config.js file.", 45 | ); 46 | return Promise.resolve(f ? f.apply(this, args) : next()); 47 | } 48 | 49 | // Check if vercel or next env is develoment 50 | const is_development = 51 | process && 52 | (process.env.VERCEL_ENV === 'development' || process.env.NODE_ENV === 'development'); 53 | if (is_development) { 54 | console.warn( 55 | '[Hookdeck] Local development environment detected. Hookdeck middleware is disabled locally. Bypassing the middleware...', 56 | ); 57 | return Promise.resolve(f ? f.apply(this, args) : next()); 58 | } 59 | 60 | const contains_proccesed_header = !!request.headers.get(HOOKDECK_PROCESSED_HEADER); 61 | if (contains_proccesed_header) { 62 | // Optional Hookdeck webhook signature verification 63 | let verified = true; 64 | 65 | const secret = config.signing_secret || process.env.HOOKDECK_SIGNING_SECRET; 66 | if (!!secret) { 67 | verified = await verifyHookdeckSignature(request, secret); 68 | } 69 | 70 | if (!verified) { 71 | const msg = '[Hookdeck] Invalid Hookdeck Signature in request.'; 72 | console.error(msg); 73 | return new Response(msg, { status: 401 }); 74 | } 75 | 76 | // Go to next (Edge function or regular page) in the chain 77 | return next(); 78 | } 79 | 80 | const middlewareResponse = await Promise.resolve(f ? f.apply(this, args) : next()); 81 | // invoke middleware if it returns something different to `next()` 82 | if ( 83 | middlewareResponse && 84 | middlewareResponse.headers.get('x-middleware-next') !== '1' && 85 | middlewareResponse.headers.get('x-from-middleware') !== '1' 86 | ) { 87 | return middlewareResponse; 88 | } 89 | 90 | // Forward to Hookdeck 91 | 92 | if (matching.length === 1) { 93 | // single source 94 | const source_name = matching[0].source_name; 95 | return await forwardToHookdeck(request, api_key, source_name, pathname); 96 | } 97 | 98 | // multiple sources: check if there are multiple matches with the same api_key and source_name 99 | 100 | const used = new Map(); 101 | 102 | for (const result of matching) { 103 | const source_name = result.source_name; 104 | if (!source_name) { 105 | console.error( 106 | `Path match not found for path "${cleanPath}" with Hookdeck Source name "${source_name}". You must include "match" configuration in your hookdeck.config.js file.`, 107 | ); 108 | return middlewareResponse; 109 | } 110 | 111 | const match_key = `${api_key}/${source_name}`; 112 | const array = used[match_key] ?? []; 113 | array.push(result); 114 | used[match_key] = array; 115 | } 116 | 117 | const promises: Promise[] = []; 118 | 119 | for (const array of Object.values(used)) { 120 | const used_connection_ids: string[] = []; 121 | 122 | if ((array as [any]).length > 1) { 123 | // If there is more than one similar match, we need the connection_id 124 | // to pick out the right connection 125 | for (const entry of array) { 126 | if (!!entry.id && !used_connection_ids.includes(entry.id)) { 127 | const source_name = entry.source_name; 128 | promises.push(forwardToHookdeck(request, api_key, source_name, pathname)); 129 | used_connection_ids.push(entry.id); 130 | } 131 | } 132 | 133 | if (promises.length === 0) { 134 | console.warn( 135 | `[Hookdeck] Could not find a path match for "${array[0].source_name}". Skipping.`, 136 | ); 137 | } 138 | } 139 | } 140 | 141 | // If several promises were fullfilled, return the first one as required by the middleware definition 142 | return Promise.all(promises).then((val) => val[0]); 143 | } catch (e) { 144 | // If an error is thrown here, it's better not to continue 145 | // with default middleware function, as it could lead to more errors 146 | console.error('[Hookdeck] Exception in withHookdeck', e); 147 | return new Response(JSON.stringify(e), { status: 500 }); 148 | } 149 | }; 150 | } 151 | 152 | function getPathnameWithFallback(request: any): string { 153 | if (request.nextUrl) { 154 | // NextJS url 155 | return request.nextUrl.pathname; 156 | } 157 | 158 | if (request.url) { 159 | // vanilla object 160 | return new URL(request.url).pathname; 161 | } 162 | 163 | // unknown 164 | return ''; 165 | } 166 | 167 | async function verifyHookdeckSignature(request, secret: string | undefined): Promise { 168 | const signature1 = (request.headers ?? {})[HOOKDECK_SIGNATURE_HEADER_1]; 169 | const signature2 = (request.headers ?? {})[HOOKDECK_SIGNATURE_HEADER_2]; 170 | 171 | if (secret && (signature1 || signature2)) { 172 | // TODO: assumed string body 173 | const body = await new Response(request.body).text(); 174 | const encoder = new TextEncoder(); 175 | const keyData = encoder.encode(secret); 176 | const bodyData = encoder.encode(body); 177 | const cryptoKey = await crypto.subtle.importKey( 178 | 'raw', 179 | keyData, 180 | { name: 'HMAC', hash: { name: 'SHA-256' } }, 181 | false, 182 | ['sign'], 183 | ); 184 | const hmac = await crypto.subtle.sign('HMAC', cryptoKey, bodyData); 185 | const hash = btoa(String.fromCharCode(...new Uint8Array(hmac))); 186 | 187 | return hash === signature1 || hash === signature2; 188 | } 189 | 190 | return true; 191 | } 192 | 193 | async function forwardToHookdeck( 194 | request: Request, 195 | api_key: string, 196 | source_name: string, 197 | pathname: string, 198 | ): Promise { 199 | const request_headers = {}; 200 | // iterate using forEach because this can be either a Headers object or a plain object 201 | request.headers.forEach((value, key) => { 202 | if (!key.startsWith('x-vercel-')) { 203 | request_headers[key] = value; 204 | } 205 | }); 206 | 207 | const headers = { 208 | ...request_headers, 209 | connection: 'close', 210 | authorization: `Bearer ${api_key}`, 211 | 'x-hookdeck-source-name': source_name, 212 | }; 213 | 214 | // TODO: assumed string body 215 | const body = await new Response(request.body).text(); 216 | 217 | const options = { 218 | method: request.method, 219 | headers, 220 | }; 221 | 222 | if (!!body) { 223 | options['body'] = body; 224 | } 225 | 226 | console.debug( 227 | `[Hookdeck] Forwarding to hookdeck (${!!body ? 'with' : 'without'} body)...`, 228 | options, 229 | ); 230 | 231 | return fetch(`${AUTHENTICATED_ENTRY_POINT}${pathname}`, options); 232 | } 233 | 234 | async function vercelHash(key) { 235 | // can't use NPM crypto library in Edge Runtime, must 236 | // use crypto.subtle 237 | const hash = await sha1(key); 238 | return `vercel-${hash.slice(0, 9)}`; 239 | } 240 | 241 | async function sha1(str) { 242 | // credits to: https://gist.github.com/GaspardP/fffdd54f563f67be8944 243 | // Get the string as arraybuffer. 244 | const buffer = new TextEncoder().encode(str); 245 | const hash = await crypto.subtle.digest('SHA-1', buffer); 246 | return hex(hash); 247 | } 248 | 249 | function hex(buffer) { 250 | let digest = ''; 251 | const view = new DataView(buffer); 252 | for (let i = 0; i < view.byteLength; i += 4) { 253 | // We use getUint32 to reduce the number of iterations (notice the `i += 4`) 254 | const value = view.getUint32(i); 255 | // toString(16) will transform the integer into the corresponding hex string 256 | // but will remove any initial "0" 257 | const stringValue = value.toString(16); 258 | // One Uint32 element is 4 bytes or 8 hex chars (it would also work with 4 259 | // chars for Uint16 and 2 chars for Uint8) 260 | const padding = '00000000'; 261 | const paddedValue = (padding + stringValue).slice(-padding.length); 262 | digest += paddedValue; 263 | } 264 | 265 | return digest; 266 | } 267 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hookdeck/vercel", 3 | "version": "0.3.1", 4 | "description": "The Hookdeck Vercel Middleware adds the ability to authenticate, delay, filter, queue, throttle, and retry asynchronous HTTP requests (e.g., webhooks) made to a Vercel application. The use cases for this include managing webhooks from API providers such as Stripe, Shopify, and Twilio, or when building asynchronous APIs.", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "format": "prettier --write '*.{json,js}' 'scripts/**/*.{js,ts}' './**/*.{js,ts}'", 13 | "lint": "eslint --ext .ts,.tsx -c .eslintrc . && prettier --check '*.{json,js}' 'scripts/**/*.{js,ts}' './**/*.{js,ts}'", 14 | "prepack": "npm run build", 15 | "build": "tsup && cp -r scripts dist", 16 | "postinstall": "node ./dist/scripts/addPrebuildScript.js --loglevel=verbose", 17 | "posti": "node ./dist/scripts/addPrebuildScript.js --loglevel=verbose", 18 | "prerelease": "npm run build", 19 | "release": "npm publish --access public" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/hookdeck/hookdeck-vercel.git" 24 | }, 25 | "author": "", 26 | "license": "Apache-2.0", 27 | "bugs": { 28 | "url": "https://github.com/hookdeck/hookdeck-vercel/issues" 29 | }, 30 | "homepage": "https://github.com/hookdeck/hookdeck-vercel#readme", 31 | "dependencies": { 32 | "@hookdeck/sdk": "^0.4.0", 33 | "@vercel/edge": "^1.1.1", 34 | "app-root-path": "^3.1.0" 35 | }, 36 | "devDependencies": { 37 | "@types/node": "^20.11.30", 38 | "eslint": "^8.56.0", 39 | "eslint-config-prettier": "^9.1.0", 40 | "eslint-config-typescript": "^3.0.0", 41 | "eslint-import-resolver-typescript": "^3.6.1", 42 | "eslint-plugin-import": "^2.29.1", 43 | "prettier": "^3.2.5", 44 | "ts-node": "^10.9.2", 45 | "tsup": "^8.0.2", 46 | "typescript": "^5.4.2" 47 | }, 48 | "bin": { 49 | "hookdeck-vercel": "./dist/scripts/prebuild.js" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /scripts/addPrebuildScript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | // npm install is run at the root of the project 7 | // where the module is being installed. 8 | // See: https://github.com/npm/npm/issues/16990 9 | const appRoot = process.env.INIT_CWD; 10 | 11 | const libraryName = '@hookdeck/vercel'; 12 | const HOOKDECK_CONFIG_FILENAME = 'hookdeck.config.js'; 13 | const MIDDLEWARE_FILENAME = 'middleware.ts'; 14 | const prebuildScript = 'hookdeck-vercel deploy'; 15 | const green = 'color:green;'; 16 | 17 | const log = (...args) => { 18 | args.unshift(`[${libraryName}]`); 19 | console.log.apply(console, args); 20 | }; 21 | 22 | log(`Post Install Script Running...`); 23 | 24 | const packagePath = path.resolve(`${appRoot}/package.json`); 25 | if (fs.existsSync(packagePath)) { 26 | const packageJSON = JSON.parse(fs.readFileSync(packagePath, 'utf8')); 27 | // adds or update if needed prebuild script 28 | if (!packageJSON.scripts.prebuild) { 29 | packageJSON.scripts.prebuild = prebuildScript; 30 | fs.writeFileSync(packagePath, JSON.stringify(packageJSON, null, 2)); 31 | log(`Prebuild script added to ${packagePath}`, green); 32 | } else { 33 | if (packageJSON.scripts.prebuild.includes(prebuildScript) === true) { 34 | log(`Prebuild script already exists in ${packagePath}`, green); 35 | } else { 36 | const addedCommand = `${packageJSON.scripts.prebuild} && ${prebuildScript}`; 37 | packageJSON.scripts.prebuild = addedCommand; 38 | fs.writeFileSync(packagePath, JSON.stringify(packageJSON, null, 2)); 39 | log(`Prebuild script updated in ${packagePath}`, green); 40 | } 41 | } 42 | // adds build script if needed 43 | if (!packageJSON.scripts.build) { 44 | packageJSON.scripts.build = ''; 45 | fs.writeFileSync(packagePath, JSON.stringify(packageJSON, null, 2)); 46 | log(`Build script added to ${packagePath}`, green); 47 | } 48 | } else { 49 | log('Could not find package.json in the current directory.'); 50 | process.exit(1); 51 | } 52 | 53 | const hookdeckConfigPath = path.resolve(`${appRoot}/${HOOKDECK_CONFIG_FILENAME}`); 54 | 55 | if (!fs.existsSync(hookdeckConfigPath)) { 56 | const sourcePath = path.join(__dirname, HOOKDECK_CONFIG_FILENAME); 57 | fs.copyFileSync(sourcePath, hookdeckConfigPath); 58 | log(`Default ${HOOKDECK_CONFIG_FILENAME} added in your project root`); 59 | } else { 60 | log(`${HOOKDECK_CONFIG_FILENAME} already exists in your project`); 61 | } 62 | 63 | function existsMiddlewareFileAt(basePath) { 64 | const extensions = ['js', 'mjs', 'ts']; // Add more if needed 65 | for (const ext of extensions) { 66 | const filePath = `${basePath}.${ext}`; 67 | try { 68 | const middlewareSourceCode = fs.readFileSync(filePath, 'utf-8'); 69 | if (middlewareSourceCode) { 70 | return true; 71 | } 72 | } catch (error) { 73 | // File does not exist, continue checking the next extension 74 | } 75 | } 76 | return false; 77 | } 78 | 79 | const existsMiddlewareFile = 80 | existsMiddlewareFileAt(`${appRoot}/middleware`) || 81 | existsMiddlewareFileAt(`${appRoot}/src/middleware`); 82 | 83 | if (!existsMiddlewareFile) { 84 | const target = fs.existsSync(`${appRoot}/src`) ? 'src' : 'root'; 85 | log( 86 | `Middleware file is not detected. Adding an empty ${MIDDLEWARE_FILENAME} file at ${target} directory for convenience`, 87 | ); 88 | const sourcePath = path.join(__dirname, MIDDLEWARE_FILENAME); 89 | 90 | if (target === 'src') { 91 | const targetPath = path.join(appRoot, '/src', MIDDLEWARE_FILENAME); 92 | const includeFileName = HOOKDECK_CONFIG_FILENAME.replace('.js', ''); 93 | let middlewareSource = fs.readFileSync(sourcePath, 'utf8'); 94 | middlewareSource = middlewareSource.replace(`./${includeFileName}`, `../${includeFileName}`); 95 | fs.writeFileSync(targetPath, middlewareSource); 96 | } else { 97 | const targetPath = path.join(appRoot, MIDDLEWARE_FILENAME); 98 | fs.copyFileSync(sourcePath, targetPath); 99 | } 100 | log('Middleware file created'); 101 | } 102 | -------------------------------------------------------------------------------- /scripts/hookdeck.config.js: -------------------------------------------------------------------------------- 1 | // Hookdeck SDK is a dependency of Hookdeck Vercel Middleware. 2 | // const { 3 | // RetryStrategy, 4 | // DestinationRateLimitPeriod, 5 | // SourceCustomResponseContentType, 6 | // } = require('@hookdeck/sdk/api'); 7 | 8 | /** @type {import("@hookdeck/vercel").HookdeckConfig} */ 9 | const hookdeckConfig = { 10 | // vercel_url: '', // optional. Uses `VERCEL_BRANCH_URL` env var as default. 11 | match: { 12 | '/api/webhooks': { 13 | // all these fields are optional 14 | // retry: { 15 | // strategy: RetryStrategy.Linear, 16 | // count: 5, 17 | // interval: 1 * 60 * 1000, // in milliseconds 18 | // }, 19 | // delay: 1 * 60 * 1000, // in milliseconds 20 | // filters: [ 21 | // { 22 | // headers: { 23 | // 'Contenty-Type': 'application/json', 24 | // }, 25 | // }, 26 | // ], 27 | // rate: { 28 | // limit: 10, 29 | // period: DestinationRateLimitPeriod.Minute, 30 | // }, 31 | // verification: { 32 | // type: 'API_KEY', 33 | // configs: { 34 | // header_key: 'x-my-api-key', 35 | // api_key: 'this-is-my-token', 36 | // }, 37 | // }, 38 | // custom_response: { 39 | // content_type: SourceCustomResponseContentType.Json, 40 | // body: '{"message": "Vercel handled the webhook using Hookdeck"}', 41 | // }, 42 | }, 43 | }, 44 | }; 45 | 46 | module.exports = hookdeckConfig; 47 | -------------------------------------------------------------------------------- /scripts/hookdeck.config.sample.js: -------------------------------------------------------------------------------- 1 | const { 2 | RetryStrategy, 3 | DestinationRateLimitPeriod, 4 | SourceCustomResponseContentType, 5 | } = require('@hookdeck/sdk/api'); 6 | 7 | const hookdeckConfig = { 8 | vercel_url: '', 9 | match: { 10 | '/path/to/match': { 11 | // all fields below this line are optional 12 | name: 'my-source', 13 | retry: { 14 | strategy: RetryStrategy.Linear, 15 | count: 0, 16 | interval: 0, 17 | }, 18 | delay: 0, 19 | filters: [ 20 | { 21 | headers: {}, 22 | body: {}, 23 | query: {}, 24 | path: {}, 25 | }, 26 | ], 27 | rate: { 28 | limit: 100, 29 | period: DestinationRateLimitPeriod.Minute, 30 | }, 31 | 32 | verification: {}, 33 | 34 | custom_response: { 35 | contentType: SourceCustomResponseContentType.Json, 36 | body: '', 37 | }, 38 | }, 39 | }, 40 | }; 41 | 42 | module.exports = hookdeckConfig; 43 | -------------------------------------------------------------------------------- /scripts/middleware.ts: -------------------------------------------------------------------------------- 1 | import { next } from '@vercel/edge'; 2 | import { withHookdeck } from '@hookdeck/vercel'; 3 | import hookdeckConfig from './hookdeck.config'; 4 | 5 | export const config = { 6 | matcher: 'your-match-config-here', 7 | }; 8 | 9 | async function middleware(request: Request) { 10 | /** 11 | * Your middleware function here 12 | * See https://vercel.com/docs/functions/edge-middleware 13 | */ 14 | 15 | // Hookdeck will process the request only if `next()` is returned 16 | return next(); 17 | } 18 | 19 | export default withHookdeck(hookdeckConfig, middleware); 20 | -------------------------------------------------------------------------------- /scripts/prebuild.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const appRoot = require('app-root-path'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const process = require('process'); 6 | const crypto = require('crypto'); 7 | 8 | const modulePath = path.join(process.cwd(), 'hookdeck.config'); 9 | let hookdeckConfig; 10 | try { 11 | hookdeckConfig = require(modulePath); 12 | console.log(`Module ${modulePath} successfully loaded`, JSON.stringify(hookdeckConfig)); 13 | } catch (error) { 14 | console.error(`Error loading module ${modulePath}`, error); 15 | process.exit(1); 16 | } 17 | 18 | const LIBRARY_NAME = '@hookdeck/vercel'; 19 | const WRAPPER_NAME = 'withHookdeck'; 20 | const TUTORIAL_URL = 'https://github.com/hookdeck/hookdeck-vercel/blob/main/README.md'; 21 | 22 | const HookdeckEnvironment = require('@hookdeck/sdk').HookdeckEnvironment; 23 | const API_ENDPOINT = HookdeckEnvironment.Default; 24 | 25 | const args = process.argv.slice(2); 26 | 27 | switch (args[0]) { 28 | case 'deploy': 29 | if (!checkPrebuild()) { 30 | process.exit(1); 31 | } 32 | break; 33 | default: 34 | console.log(`invalid command ${args[0]}`); 35 | } 36 | 37 | async function checkPrebuild() { 38 | try { 39 | if (!validateMiddleware()) { 40 | return false; 41 | } 42 | if (!validateConfig(hookdeckConfig)) { 43 | return false; 44 | } 45 | 46 | const { match, api_key, signing_secret, vercel_url } = hookdeckConfig; 47 | 48 | const connections = []; 49 | for (const e of Object.entries(match)) { 50 | const key = e[0]; 51 | const value = e[1]; 52 | 53 | let url = vercel_url || process.env.VERCEL_BRANCH_URL; 54 | if (url && !url.startsWith('http://') && !url.startsWith('https://')) { 55 | url = `https://${url}`; 56 | } 57 | 58 | const conn = Object.assign(value, { 59 | api_key: api_key || process.env.HOOKDECK_API_KEY, 60 | signing_secret: signing_secret || process.env.HOOKDECK_SIGNING_SECRET, 61 | host: url, 62 | matcher: key, 63 | source_name: value.name || (await vercelHash(key)), 64 | destination_name: slugify(key), 65 | }); 66 | 67 | connections.push(conn); 68 | } 69 | 70 | if (connections.length === 0) { 71 | console.warn( 72 | `hookdeck.config.js file seems to be invalid. Please follow the steps in ${TUTORIAL_URL}.`, 73 | ); 74 | return false; 75 | } 76 | 77 | console.log('hookdeck.config.js is valid'); 78 | 79 | const created_connections_pseudo_keys = {}; 80 | for (const conn_config of connections) { 81 | const has_connection_id = !!conn_config.id; 82 | 83 | let connection; 84 | if (has_connection_id) { 85 | connection = await updateConnection(conn_config.api_key, conn_config.id, conn_config); 86 | } else { 87 | // avoid creating identical connections 88 | const pseudo_key = `${conn_config.api_key}*${conn_config.source_name}`; 89 | const cached_connection_id = created_connections_pseudo_keys[pseudo_key] || null; 90 | 91 | if (cached_connection_id) { 92 | connection = await updateConnection( 93 | conn_config.api_key, 94 | cached_connection_id, 95 | conn_config, 96 | ); 97 | } else { 98 | const source = await getSourceByName(conn_config.api_key, conn_config.source_name); 99 | if (source) { 100 | const destination = await getDestinationByName( 101 | conn_config.api_key, 102 | conn_config.destination_name, 103 | ); 104 | if (destination) { 105 | connection = await getConnectionWithSourceAndDestination( 106 | conn_config.api_key, 107 | source, 108 | destination, 109 | ); 110 | if (connection) { 111 | connection = await updateConnection( 112 | conn_config.api_key, 113 | connection.id, 114 | conn_config, 115 | ); 116 | } 117 | } 118 | } 119 | if (!connection) { 120 | connection = await autoCreateConnection(conn_config.api_key, conn_config); 121 | } 122 | created_connections_pseudo_keys[pseudo_key] = connection.id; 123 | } 124 | } 125 | 126 | console.log('Hookdeck connection configured successfully', connection.source.url); 127 | } 128 | 129 | console.log('Hookdeck successfully configured'); 130 | return true; 131 | } catch (error) { 132 | console.error('Error:', error); 133 | return false; 134 | } 135 | } 136 | 137 | function generateId(prefix = '') { 138 | const ID_length = 16; 139 | 140 | const randomAlphaNumeric = (length) => { 141 | let s = ''; 142 | Array.from({ length }).some(() => { 143 | s += Math.random().toString(36).slice(2); 144 | return s.length >= length; 145 | }); 146 | return s.slice(0, length); 147 | }; 148 | 149 | const nanoid = randomAlphaNumeric(ID_length); 150 | return `${prefix}${nanoid}`; 151 | } 152 | 153 | function isValidPropertyValue(propValue) { 154 | return !(propValue === undefined || propValue === null || !isString(propValue)); 155 | } 156 | 157 | function isString(str) { 158 | return typeof str === 'string' || str instanceof String; 159 | } 160 | 161 | function getDestinationUrl(config) { 162 | let dest_url = config.url || config.host || `https://${process.env.VERCEL_BRANCH_URL}`; 163 | dest_url = dest_url.endsWith('/') ? dest_url.slice(0, -1) : dest_url; 164 | dest_url = dest_url.startsWith('http') ? dest_url : `https://${dest_url}`; 165 | return dest_url; 166 | } 167 | 168 | function getConnectionRules(config) { 169 | const rules = []; 170 | 171 | if ((config.retry || null) !== null && config.retry.constructor === Object) { 172 | const target = config.retry; 173 | rules.push(Object.assign(target, { type: 'retry' })); 174 | } 175 | if ((config.delay || null) !== null && isNaN(config.delay) === false) { 176 | rules.push({ type: 'delay', delay: config.delay }); 177 | } 178 | if (typeof (config.alert || null) === 'string' || config.alert instanceof String) { 179 | // 'each_attempt' or 'last_attempt' 180 | rules.push({ type: 'alert', strategy: config.alert }); 181 | } 182 | if (Array.isArray(config.filters)) { 183 | for (const filter of config.filters.map((e) => Object.assign(e, { type: 'filter' }))) { 184 | rules.push(filter); 185 | } 186 | } 187 | 188 | // Transformations disabled for now 189 | // if ((config.transformation || null) !== null && config.transformation.constructor === Object) { 190 | // const target = config.transformation; 191 | // rules.push({ type: 'transform', transformation: target }); 192 | // } 193 | 194 | return rules; 195 | } 196 | 197 | async function autoCreateConnection(api_key, config) { 198 | if (!config.path_forwarding_disabled) { 199 | // if they set a specific url, path forwarding is disabled by default 200 | config.path_forwarding_disabled = !!config.url ? true : false; 201 | } 202 | 203 | const dest_url = getDestinationUrl(config); 204 | 205 | const data = { 206 | source: Object.assign( 207 | { 208 | description: 'Autogenerated from Vercel integration', 209 | name: config.source_name, 210 | }, 211 | config.source_config || {}, 212 | ), 213 | destination: Object.assign( 214 | { 215 | description: 'Autogenerated from Vercel integration', 216 | url: dest_url, 217 | name: config.destination_name, 218 | }, 219 | config.destination_config || {}, 220 | ), 221 | rules: config.rules || [], 222 | description: 'Autogenerated from Vercel integration', 223 | }; 224 | 225 | const rules = getConnectionRules(config); 226 | if (rules.length > 0) { 227 | data['rules'] = rules; 228 | } 229 | 230 | if (!!config.allowed_http_methods) { 231 | data.source.allowed_http_methods = config.allowed_http_methods; 232 | } 233 | 234 | if (!!config.custom_response) { 235 | data.source.custom_response = config.custom_response; 236 | } 237 | 238 | if (!!config.verification) { 239 | data.source.verification = config.verification; 240 | } 241 | 242 | if (config.path_forwarding_disabled !== null) { 243 | data.destination.path_forwarding_disabled = config.path_forwarding_disabled; 244 | } 245 | if (!!config.http_method) { 246 | data.destination.http_method = config.http_method; 247 | } 248 | if (!!config.auth_method) { 249 | data.destination.auth_method = config.auth_method; 250 | } 251 | if (!!config.rate) { 252 | data.destination.delivery_rate = config.rate; 253 | } 254 | 255 | try { 256 | const url = `${API_ENDPOINT}/connections`; 257 | const response = await fetch(url, { 258 | method: 'PUT', 259 | mode: 'cors', 260 | headers: { 261 | 'Content-Type': 'application/json', 262 | Authorization: `Bearer ${api_key}`, 263 | }, 264 | credentials: 'include', 265 | body: JSON.stringify(data), 266 | }); 267 | if (response.status !== 200) { 268 | manageResponseError('Error getting connections', response, JSON.stringify(data)); 269 | } 270 | const json = await response.json(); 271 | console.log('Connection created', json); 272 | return json; 273 | } catch (e) { 274 | manageError(e); 275 | } 276 | } 277 | 278 | function manageError(error) { 279 | console.error(error); 280 | process.exit(1); 281 | } 282 | 283 | function manageResponseError(msg, response, body) { 284 | switch (response.status) { 285 | case 401: 286 | console.error(`${msg}: Invalid or expired api_key`, response.status, response.statusText); 287 | break; 288 | 289 | default: 290 | console.error(msg, response.status, response.statusText, body); 291 | break; 292 | } 293 | process.exit(1); 294 | } 295 | 296 | function readMiddlewareFile(basePath) { 297 | const extensions = ['js', 'mjs', 'ts']; // Supported by now 298 | for (const ext of extensions) { 299 | const filePath = `${basePath}.${ext}`; 300 | try { 301 | const middlewareSourceCode = fs.readFileSync(filePath, 'utf-8'); 302 | if (middlewareSourceCode) { 303 | const purgedCode = middlewareSourceCode.replace(/(\/\*[^*]*\*\/)|(\/\/[^*]*)/g, ''); // removes al comments. May mess with http:// bars but doesn't matter here. 304 | if (purgedCode.length > 0) { 305 | return purgedCode; 306 | } else { 307 | console.warn(`File ${filePath} is empty`); 308 | } 309 | } 310 | } catch (error) { 311 | // File does not exist, continue checking the next extension 312 | } 313 | } 314 | return null; 315 | } 316 | 317 | function validateMiddleware() { 318 | // 1) Check if middleware exists. If not, just shows a warning 319 | const middlewareSourceCode = 320 | readMiddlewareFile(`${appRoot}/middleware`) || readMiddlewareFile(`${appRoot}/src/middleware`); 321 | if (!middlewareSourceCode) { 322 | console.warn( 323 | `Middleware file not found. Consider removing ${LIBRARY_NAME} from your dev dependencies if you are not using it.`, 324 | ); 325 | } 326 | 327 | // 2) Check if library is used in middleware. 328 | const hasLibraryName = middlewareSourceCode.includes(LIBRARY_NAME); 329 | const hasWrapper = middlewareSourceCode.includes(WRAPPER_NAME); 330 | 331 | if (!hasLibraryName || !hasWrapper) { 332 | // If it's not being used, just shows a warning 333 | console.warn( 334 | `Usage of ${LIBRARY_NAME} not found in the middleware file. Consider removing ${LIBRARY_NAME} from your dev dependencies if you are not using it.`, 335 | ); 336 | } else { 337 | console.log(`Usage of ${LIBRARY_NAME} detected`); 338 | } 339 | return true; 340 | } 341 | 342 | function validateConfig(config) { 343 | if (!config) { 344 | console.error( 345 | `Usage of ${LIBRARY_NAME} detected but hookdeck.config.js could not be imported. Please follow the steps in ${TUTORIAL_URL} to export the hookdeckConfig object`, 346 | ); 347 | return false; 348 | } 349 | 350 | const api_key = config.api_key || process.env.HOOKDECK_API_KEY; 351 | if (!api_key) { 352 | console.error( 353 | `Hookdeck's API key not found. You must set it as a env variable named HOOKDECK_API_KEY or include it in your hookdeck.config.js. Check ${TUTORIAL_URL} for more info.`, 354 | ); 355 | return false; 356 | } 357 | if (!isString(api_key) || api_key.trim().length === 0) { 358 | console.error(`Invalid Hookdeck API KEY format. Check ${TUTORIAL_URL} for more info.`); 359 | return false; 360 | } 361 | 362 | if (!(config.signing_secret || process.env.HOOKDECK_SIGNING_SECRET)) { 363 | console.warn( 364 | "Signing secret key is not present neither in `hookdeckConfig.signing_secret` nor `process.env.HOOKDECK_SIGNING_SECRET`. You won't be able to validate webhooks' signatures. " + 365 | `Please follow the steps in ${TUTORIAL_URL}.`, 366 | ); 367 | } 368 | 369 | if ( 370 | (config.vercel_url || '').trim() === '' && 371 | (process.env.VERCEL_BRANCH_URL || '').trim() === '' 372 | ) { 373 | console.error( 374 | '`VERCEL_BRANCH_URL` env var and `vercel_url` config key are empty. ' + 375 | 'It seems that this project is not connected to a Git repository. ' + 376 | "In such case, can must define the env var `VERCEL_BRANCH_URL` or `vercel_url` key in `hookdeck.config` file pointing to your Vercel's public url." + 377 | 'Check this documentation for more information about Vercel url: https://vercel.com/docs/deployments/generated-urls', 378 | ); 379 | return false; 380 | } 381 | 382 | return true; 383 | } 384 | 385 | async function updateConnection(api_key, id, config) { 386 | const data = {}; 387 | const rules = getConnectionRules(config); 388 | if (rules.length > 0) { 389 | data['rules'] = rules; 390 | } 391 | 392 | try { 393 | const url = `${API_ENDPOINT}/connections/${id}`; 394 | const response = await fetch(url, { 395 | method: 'PUT', 396 | mode: 'cors', 397 | headers: { 398 | 'Content-Type': 'application/json', 399 | Authorization: `Bearer ${api_key}`, 400 | }, 401 | credentials: 'include', 402 | body: JSON.stringify(data), 403 | }); 404 | if (response.status !== 200) { 405 | manageResponseError(`Error updating connection with ID ${id}`, JSON.stringify(data)); 406 | } 407 | const json = await response.json(); 408 | console.log('Connection updated', json); 409 | 410 | // Updates configurations if neeeded 411 | if ( 412 | (config.allowed_http_methods || null) !== null || 413 | (config.custom_response || null) !== null || 414 | (config.verification || null) !== null 415 | ) { 416 | await updateSource(api_key, json.source.id, config); 417 | } 418 | 419 | if ( 420 | config.path_forwarding_disabled !== null || 421 | (config.http_method || null) !== null || 422 | (config.auth_method || null) !== null || 423 | (config.rate || null) !== null 424 | ) { 425 | await updateDestination(api_key, json.destination, config); 426 | } 427 | 428 | return json; 429 | } catch (e) { 430 | manageError(e); 431 | } 432 | } 433 | 434 | async function updateSource(api_key, id, config) { 435 | const data = {}; 436 | 437 | data.allowed_http_methods = config.allowed_http_methods || [ 438 | 'GET', 439 | 'POST', 440 | 'PUT', 441 | 'PATCH', 442 | 'DELETE', 443 | ]; 444 | 445 | if ((config.custom_response || null) !== null) { 446 | data.custom_response = config.custom_response; 447 | } 448 | if ((config.verification || null) !== null) { 449 | data.verification = config.verification; 450 | } 451 | 452 | const url = `${API_ENDPOINT}/sources/${id}`; 453 | const response = await fetch(url, { 454 | method: 'PUT', 455 | mode: 'cors', 456 | headers: { 457 | 'Content-Type': 'application/json', 458 | Authorization: `Bearer ${api_key}`, 459 | }, 460 | credentials: 'include', 461 | body: JSON.stringify(data), 462 | }); 463 | if (response.status !== 200) { 464 | manageResponseError( 465 | `Error while updating source with ID ${id}`, 466 | response, 467 | JSON.stringify(data), 468 | ); 469 | } 470 | const json = await response.json(); 471 | console.log('Source updated', json); 472 | } 473 | 474 | async function updateDestination(api_key, destination, config) { 475 | const data = {}; 476 | if (config.path_forwarding_disabled !== null) { 477 | data.path_forwarding_disabled = config.path_forwarding_disabled; 478 | } 479 | if ((config.http_method || null) !== null) { 480 | data.http_method = config.http_method; 481 | } 482 | if ((config.auth_method || null) !== null) { 483 | data.auth_method = config.auth_method; 484 | } 485 | if ((config.rate || null) !== null) { 486 | data.rate_limit = config.rate.limit; 487 | data.rate_limit_period = config.rate.period; 488 | } 489 | // url or cli_path are required to update destination 490 | if ((config.host || null) !== null) { 491 | data.url = config.host; 492 | } else if (destination.url) { 493 | data.url = destination.url; 494 | } else if (destination.cli_path) { 495 | data.cli_path = destination; 496 | } else { 497 | console.warn('No destination url or cli_path found in destination object. Update may fail.'); 498 | } 499 | 500 | const id = destination.id; 501 | const url = `${API_ENDPOINT}/destinations/${id}`; 502 | console.log('Updating destination'); 503 | console.log(url); 504 | console.log(data); 505 | const response = await fetch(url, { 506 | method: 'PUT', 507 | mode: 'cors', 508 | headers: { 509 | 'Content-Type': 'application/json', 510 | Authorization: `Bearer ${api_key}`, 511 | }, 512 | credentials: 'include', 513 | body: JSON.stringify(data), 514 | }); 515 | if (response.status !== 200) { 516 | manageResponseError( 517 | `Error while updating destination with ID ${id}`, 518 | response, 519 | JSON.stringify(data), 520 | ); 521 | } 522 | const json = await response.json(); 523 | console.log('Destination updated', json); 524 | } 525 | 526 | function slugify(text) { 527 | return text 528 | .toString() 529 | .toLowerCase() 530 | .replace(/\//g, '-') // Replace / with - 531 | .replace(/\s+/g, '-') // Replace spaces with - 532 | .replace(/[^\w\-]+/g, '') // Remove all non-word chars 533 | .replace(/\-\-+/g, '-') // Replace multiple - with single - 534 | .replace(/^-+/, '') // Trim - from start of text 535 | .replace(/-+$/, ''); // Trim - from end of text 536 | } 537 | 538 | async function getConnectionWithSourceAndDestination(api_key, source, destination) { 539 | try { 540 | const url = `${API_ENDPOINT}/connections?source_id=${source.id}&destination_id=${destination.id}`; 541 | const response = await fetch(url, { 542 | method: 'GET', 543 | mode: 'cors', 544 | headers: { 545 | 'Content-Type': 'application/json', 546 | Authorization: `Bearer ${api_key}`, 547 | }, 548 | credentials: 'include', 549 | }); 550 | if (response.status !== 200) { 551 | manageResponseError( 552 | `Error getting connection for source ${source.id} and destination ${destination.id}`, 553 | response, 554 | ); 555 | } 556 | const json = await response.json(); 557 | if (json.models.length === 0) { 558 | return null; 559 | } 560 | 561 | console.info( 562 | `Connection for source ${source.id} and destination ${destination.id} found`, 563 | json.models[0], 564 | ); 565 | return json.models[0]; 566 | } catch (e) { 567 | manageError(e); 568 | } 569 | } 570 | 571 | async function getSourceByName(api_key, source_name) { 572 | try { 573 | const url = `${API_ENDPOINT}/sources?name=${source_name}`; 574 | const response = await fetch(url, { 575 | method: 'GET', 576 | mode: 'cors', 577 | headers: { 578 | 'Content-Type': 'application/json', 579 | Authorization: `Bearer ${api_key}`, 580 | }, 581 | credentials: 'include', 582 | }); 583 | if (response.status !== 200) { 584 | manageResponseError(`Error getting source '${source_name}'`, response); 585 | } 586 | const json = await response.json(); 587 | if (json.models.length === 0) { 588 | return null; 589 | } 590 | 591 | console.info(`Source '${source_name}' found`, json.models[0]); 592 | return json.models[0]; 593 | } catch (e) { 594 | manageError(e); 595 | } 596 | } 597 | 598 | async function getDestinationByName(api_key, name) { 599 | try { 600 | const url = `${API_ENDPOINT}/destinations?name=${name}`; 601 | const response = await fetch(url, { 602 | method: 'GET', 603 | mode: 'cors', 604 | headers: { 605 | Authorization: `Bearer ${api_key}`, 606 | }, 607 | credentials: 'include', 608 | }); 609 | if (response.status !== 200) { 610 | manageResponseError(`Error getting destination by name ${name}`, response); 611 | } 612 | const json = await response.json(); 613 | if (json.models.length === 0) { 614 | return null; 615 | } 616 | 617 | console.info(`Destination '${name}' found`, json.models[0]); 618 | return json.models[0]; 619 | } catch (e) { 620 | manageError(e); 621 | } 622 | } 623 | 624 | async function vercelHash(key) { 625 | const hash = await sha1(key); 626 | return `vercel-${hash.slice(0, 9)}`; 627 | } 628 | 629 | async function sha1(str) { 630 | // credits to: https://gist.github.com/GaspardP/fffdd54f563f67be8944 631 | // Get the string as arraybuffer. 632 | const buffer = new TextEncoder().encode(str); 633 | const hash = await crypto.subtle.digest('SHA-1', buffer); 634 | return hex(hash); 635 | } 636 | 637 | function hex(buffer) { 638 | let digest = ''; 639 | const view = new DataView(buffer); 640 | for (let i = 0; i < view.byteLength; i += 4) { 641 | // We use getUint32 to reduce the number of iterations (notice the `i += 4`) 642 | const value = view.getUint32(i); 643 | // toString(16) will transform the integer into the corresponding hex string 644 | // but will remove any initial "0" 645 | const stringValue = value.toString(16); 646 | // One Uint32 element is 4 bytes or 8 hex chars (it would also work with 4 647 | // chars for Uint16 and 2 chars for Uint8) 648 | const padding = '00000000'; 649 | const paddedValue = (padding + stringValue).slice(-padding.length); 650 | digest += paddedValue; 651 | } 652 | 653 | return digest; 654 | } 655 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs" /* Specify what module code is generated. */, 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | "rootDirs": [ 34 | "./hookdeck.config.d.ts" 35 | ] /* Allow multiple folders to be treated as one when resolving modules. */, 36 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 40 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 41 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 42 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 43 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 44 | // "resolveJsonModule": true, /* Enable importing .json files. */ 45 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 46 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 47 | 48 | /* JavaScript Support */ 49 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 50 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 51 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 52 | 53 | /* Emit */ 54 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 55 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 56 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 57 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 60 | "outDir": "dist" /* Specify an output folder for all emitted files. */, 61 | // "removeComments": true, /* Disable emitting comments. */ 62 | // "noEmit": true, /* Disable emitting files from a compilation. */ 63 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 64 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 65 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 66 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 67 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 68 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 69 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 70 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 71 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 72 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 73 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 74 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 75 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 76 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 77 | 78 | /* Interop Constraints */ 79 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 80 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 81 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 82 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 83 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 84 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 85 | 86 | /* Type Checking */ 87 | "strict": true /* Enable all strict type-checking options. */, 88 | "noImplicitAny": false /* Enable error reporting for expressions and declarations with an implied 'any' type. */, 89 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 90 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 91 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 92 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 93 | "noImplicitThis": false /* Enable error reporting when 'this' is given the type 'any'. */, 94 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 95 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 96 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 97 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 98 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 99 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 100 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 101 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 102 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 103 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 104 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 105 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 106 | 107 | /* Completeness */ 108 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 109 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['index.ts'], 5 | format: ['cjs', 'esm'], // Build for commonJS and ESmodules 6 | dts: true, // Generate declaration file (.d.ts) 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | }); 11 | --------------------------------------------------------------------------------