├── .cargo-ok ├── .gitignore ├── .prettierrc ├── .release-it.json ├── CONTRIBUTING.md ├── LICENSE_APACHE ├── LICENSE_MIT ├── README.md ├── package-lock.json ├── package.json ├── src ├── constants.ts ├── forward.ts ├── handler.ts ├── index.ts ├── interface.ts ├── log.ts ├── sentry.ts └── utils │ ├── aws-sns-handler.ts │ ├── body.ts │ ├── cors.ts │ ├── headers.ts │ └── index.ts ├── test ├── _setup.ts ├── handler.ts └── tsconfig.json ├── tsconfig.json ├── types └── index.d.ts ├── webpack.config.js └── wrangler.toml /.cargo-ok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/actionsflow/webhook2github/1a5d75f1370734da8130d8e5664c174c56059afd/.cargo-ok -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | transpiled 4 | worker 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear in the root of a volume 17 | .DocumentRevisions-V100 18 | .fseventsd 19 | .Spotlight-V100 20 | .TemporaryItems 21 | .Trashes 22 | .VolumeIcon.icns 23 | .com.apple.timemachine.donotpresent 24 | 25 | # Directories potentially created on remote AFP share 26 | .AppleDB 27 | .AppleDesktop 28 | Network Trash Folder 29 | Temporary Items 30 | .apdisk 31 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "printWidth": 80 7 | } -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore(release): v${version}", 4 | "requireBranch": "main", 5 | "requireCommits": false, 6 | "tagName": "v${version}", 7 | "push": true 8 | }, 9 | "github": { 10 | "release": true 11 | }, 12 | "npm": { "publish": false } 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | ## Local start 4 | 5 | ```bash 6 | npm start 7 | ``` 8 | 9 | ## Deploy to Development 10 | 11 | ```bash 12 | npm run deploy 13 | ``` 14 | 15 | ## Deploy to Production 16 | 17 | ```bash 18 | npm run deploy:production 19 | ``` 20 | -------------------------------------------------------------------------------- /LICENSE_APACHE: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /LICENSE_MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Cloudflare, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webhook2github 2 | 3 | This API enables forward webhook requests to [github repository_dispatch event webhook](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#repository_dispatch). 4 | 5 | API Endpoint: [`https://webhook.actionsflow.workers.dev/`](https://webhook.actionsflow.workers.dev/) 6 | 7 | ## Usage 8 | 9 | ```bash 10 | https://webhook.actionsflow.workers.dev///?__token= 11 | ``` 12 | 13 | The default response of the webhook will use [the github `create-a-repository-dispatch-event` API response](https://docs.github.com/en/rest/reference/repos#create-a-repository-dispatch-event). You can use search params `__response_code`, `__response_content_type`, `__response_body` to specify a custom response. 14 | 15 | You can also use headers `X-Github-Authorization` instead of search params `__token` for more security. 16 | 17 | The webhook also supports the cross-origin resource sharing request. 18 | 19 | For example: 20 | 21 | ```bash 22 | curl --request POST 'https://webhook.actionsflow.workers.dev/actionsflow/webhook2github/webhook/webhook?__token=' \ 23 | --header 'Content-Type: application/json' \ 24 | --data-raw '{ 25 | "key": "value" 26 | }' 27 | ``` 28 | 29 | Specify response code example: 30 | 31 | ```bash 32 | curl --request POST 'https://webhook.actionsflow.workers.dev/actionsflow/webhook2github/webhook/webhook?__token=&__response_code=200' \ 33 | --header 'Content-Type: application/json' \ 34 | --data-raw '{ 35 | "key": "value" 36 | }' 37 | ``` 38 | 39 | An axios example: 40 | 41 | ```javascript 42 | var axios = require('axios') 43 | var data = JSON.stringify({ key: 'value' }) 44 | 45 | var config = { 46 | method: 'post', 47 | url: 48 | 'https://webhook.actionsflow.workers.dev/actionsflow/webhook2github/webhook/webhook?__token=', 49 | 50 | data: data, 51 | } 52 | 53 | axios(config) 54 | .then(function (response) { 55 | console.log(JSON.stringify(response.data)) 56 | }) 57 | .catch(function (error) { 58 | console.log(error) 59 | }) 60 | ``` 61 | 62 | ## How It Works 63 | 64 | This API will forward the following original webhook request: 65 | 66 | ```bash 67 | https://webhook.actionsflow.workers.dev///?__token= 68 | ``` 69 | 70 | To `https://api.github.com/repos///dispatches`, with body: 71 | 72 | ```json 73 | { 74 | "event_type": "webhook", 75 | "client_payload": { 76 | "path": "", 77 | "method": "", 78 | "headers": "", 79 | "body": "" 80 | } 81 | } 82 | ``` 83 | 84 | So Github actions will be triggered with `repository_dispatch` event. 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhook2github", 3 | "private": true, 4 | "version": "1.0.1", 5 | "description": "Cloudflare worker TypeScript template", 6 | "main": "index.js", 7 | "scripts": { 8 | "build": "webpack", 9 | "dev": "NODE_ENV=development npm run build", 10 | "start": "wrangler dev", 11 | "log": "wrangler tail", 12 | "deploy": "wrangler publish", 13 | "deploy:production": "npm run deploy -- --env production", 14 | "postdeploy:production": "npm run release", 15 | "format": "prettier --write '**/*.{ts,js,css,json,md}'", 16 | "test:clean": "rimraf ./transpiled/src ./transpiled/test", 17 | "test": "npm run test:clean && npm run transpile && mocha --require source-map-support/register --recursive transpiled/test", 18 | "transpile": "tsc --project ./test", 19 | "release": "release-it" 20 | }, 21 | "author": "author", 22 | "license": "MIT OR Apache-2.0", 23 | "devDependencies": { 24 | "@cloudflare/workers-types": "^2.0.0", 25 | "@types/chai": "^4.2.12", 26 | "@types/mocha": "^7.0.2", 27 | "chai": "^4.2.0", 28 | "mocha": "^8.1.3", 29 | "prettier": "^2.1.2", 30 | "release-it": "^14.0.3", 31 | "rimraf": "^3.0.2", 32 | "service-worker-mock": "^2.0.5", 33 | "ts-loader": "^7.0.5", 34 | "typescript": "^3.9.7", 35 | "webpack": "^4.44.2", 36 | "webpack-cli": "^3.3.12" 37 | }, 38 | "dependencies": { 39 | "@types/vary": "^1.1.0", 40 | "chalk": "^4.1.0", 41 | "loglevel": "^1.7.0", 42 | "loglevel-plugin-prefix": "^0.8.4", 43 | "path-to-regexp": "^6.2.0", 44 | "vary": "^1.1.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const defaultResponse = `This API enables forward webhook requests to github repository_dispatch event webhook. . 2 | 3 | Usage: 4 | 5 | https://webhook.actionsflow.workers.dev/ Shows help 6 | https://webhook.actionsflow.workers.dev///?__token= Forward webhook request 7 | 8 | The API will forward the original request to \`https://api.github.com/repos///dispatches\`, with body: 9 | 10 | { 11 | "event_type": "webhook", 12 | "client_payload": { 13 | "path": "", 14 | "method": "", 15 | "headers": "", 16 | "body": "" 17 | } 18 | } 19 | 20 | The API default reponse will use the github api response. You can use search params \`__response_code\`, \`__response_content_type\`, \`__response_body\` to specify. 21 | You can also use headers \`X-Github-Authorization\` instead of search params \`__token\` 22 | 23 | The API also supports the cors request. 24 | 25 | Source code: https://github.com/actionsflow/webhook2github 26 | ` 27 | export const defaultResponseCode = 200 28 | export const defaultResponseContentType = 'application/json' 29 | export const defaultResponseBody = `{ "success": true }` 30 | export const systemSearchParams = [ 31 | '__response_code', 32 | '__response_content_type', 33 | '__response_body', 34 | '__token', 35 | ] 36 | export const EVENT_TYPE = 'webhook' 37 | export const systemHeaders = ['X-Github-Authorization'] 38 | -------------------------------------------------------------------------------- /src/forward.ts: -------------------------------------------------------------------------------- 1 | import { IRequest } from './interface' 2 | import { 3 | getHeadersObj, 4 | getBody, 5 | getEditableHeaders, 6 | awsSnsHandler, 7 | } from './utils' 8 | import { 9 | defaultResponseCode, 10 | defaultResponseBody, 11 | defaultResponseContentType, 12 | systemSearchParams, 13 | EVENT_TYPE, 14 | systemHeaders, 15 | } from './constants' 16 | export default async function forward(request: IRequest): Promise { 17 | const params = request.params 18 | let forwardUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/dispatches` 19 | let payloadPath = params.path 20 | ? '/' + (params.path as string[]).join('/') 21 | : '/' 22 | const searchParams = request.searchParams 23 | const githubToken = searchParams.get('__token') 24 | const gihtubHeaderAuthorizationParam = request.headers.get( 25 | 'X-Github-Authorization', 26 | ) 27 | const authorization = request.headers.get('Authorization') 28 | 29 | let githubAuthorization = '' 30 | if (githubToken) { 31 | githubAuthorization = `token ${githubToken}` 32 | } else if (gihtubHeaderAuthorizationParam) { 33 | if (gihtubHeaderAuthorizationParam.split(' ').length > 1) { 34 | githubAuthorization = gihtubHeaderAuthorizationParam 35 | } else { 36 | githubAuthorization = `token ${gihtubHeaderAuthorizationParam}` 37 | } 38 | } else if (authorization) { 39 | githubAuthorization = authorization 40 | } 41 | const forwardHeaders = new Headers({ 42 | 'Content-Type': 'application/json', 43 | Accept: '*/*', 44 | 'User-Agent': 45 | request.headers.get('User-Agent') || 46 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36', 47 | }) 48 | 49 | if (githubAuthorization) { 50 | forwardHeaders.set('Authorization', githubAuthorization) 51 | } else { 52 | return new Response( 53 | JSON.stringify({ 54 | success: false, 55 | message: 56 | 'You must provide a github token in search parameters __token or header X-Github-Authorization', 57 | documentation_url: 58 | 'https://actionsflow.github.io/docs/reference/4-webhook', 59 | }), 60 | { 61 | status: 401, 62 | headers: new Headers({ 63 | 'Content-Type': defaultResponseContentType, 64 | }), 65 | }, 66 | ) 67 | } 68 | // valid 69 | // intercept 70 | if ( 71 | request.headers.get('x-amz-sns-message-type') === 'SubscriptionConfirmation' 72 | ) { 73 | return await awsSnsHandler(request) 74 | } 75 | 76 | const payloadSearchParams = new URLSearchParams(request.search) 77 | systemSearchParams.forEach((key) => { 78 | // delete system param 79 | payloadSearchParams.delete(key) 80 | }) 81 | const payloadSearch = payloadSearchParams.toString() 82 | let finalPayloadPath = 83 | payloadPath + (payloadSearch ? `?${payloadSearch}` : '') 84 | let finalPayloadHeaders = getEditableHeaders(request.headers) 85 | // delete system headers key 86 | systemHeaders.forEach((headerKey) => { 87 | finalPayloadHeaders.delete(headerKey) 88 | }) 89 | const builtBody: { 90 | event_type: string 91 | client_payload: Record 92 | } = { 93 | event_type: EVENT_TYPE, 94 | client_payload: { 95 | path: finalPayloadPath, 96 | method: request.method, 97 | headers: getHeadersObj(finalPayloadHeaders), 98 | }, 99 | } 100 | if (request.body) { 101 | builtBody.client_payload.body = request.body 102 | } 103 | const response = await fetch(forwardUrl, { 104 | method: 'POST', 105 | headers: forwardHeaders, 106 | body: JSON.stringify(builtBody), 107 | }) 108 | 109 | let responseCode: string | number | null = searchParams.get('__response_code') 110 | if (!responseCode) { 111 | responseCode = 112 | response.status === 204 ? defaultResponseCode : response.status 113 | } 114 | if (!responseCode) { 115 | responseCode = defaultResponseCode 116 | } 117 | const responseContentType = 118 | searchParams.get('__response_content_type') || 119 | response.headers.get('Content-Type') || 120 | defaultResponseContentType 121 | const finalStatus = Number(responseCode) 122 | const originResponseBody = await getBody(response) 123 | const searchParamsResponseBody = searchParams.get('__response_body') 124 | let responseBody: string | null = defaultResponseBody 125 | if (searchParamsResponseBody) { 126 | responseBody = searchParamsResponseBody 127 | } else if (originResponseBody !== null) { 128 | responseBody = originResponseBody 129 | } 130 | 131 | if ([101, 204, 205, 304].includes(finalStatus)) { 132 | responseBody = null 133 | } 134 | 135 | const newResponseHeaders = getEditableHeaders(response.headers) 136 | newResponseHeaders.set('Content-Type', responseContentType) 137 | return new Response(responseBody, { 138 | status: finalStatus, 139 | headers: newResponseHeaders, 140 | }) 141 | } 142 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import forward from './forward' 2 | import { match } from 'path-to-regexp' 3 | import { defaultResponse } from './constants' 4 | import { IRequest, IParams } from './interface' 5 | import { getBody, cors, getHeadersObj } from './utils' 6 | import { log } from './log' 7 | import { log as report } from './sentry' 8 | export async function handleRequest( 9 | request: Request, 10 | event: FetchEvent, 11 | ): Promise { 12 | // log.debug(`new [${request.method}] request: ${request.url}`) 13 | // log.debug(`request headers: ${getHeadersObj(request.headers)}`) 14 | try { 15 | let method = request.method 16 | if (method === 'OPTIONS') { 17 | // handle OPTIONS cors 18 | const firstCorsFn = cors({ isOptions: true }) 19 | const firstCorsResult = await firstCorsFn(request.headers, new Headers()) 20 | if (firstCorsResult.valid) { 21 | return new Response(null, { 22 | status: 204, 23 | headers: firstCorsResult.headers, 24 | }) 25 | } 26 | } 27 | 28 | let response: Response 29 | // url 30 | const url = request.url 31 | const urlObj = new URL(url) 32 | const pathname = urlObj.pathname 33 | // only forwarn /:owner/:repo/:path* 34 | const forwardMatchedRoute = '/:owner/:repo/:path*' 35 | // match specific path 36 | const matchFn = match(forwardMatchedRoute, { decode: decodeURIComponent }) 37 | const matchResult = matchFn(pathname) 38 | if (matchResult) { 39 | // 40 | let body: string | null = null 41 | if (request.body) { 42 | try { 43 | body = await getBody(request) 44 | } catch (error) { 45 | // log.warn('read body error', error) 46 | } 47 | } 48 | if (body) { 49 | // log.debug('request body: ', body) 50 | } 51 | // format request 52 | const newRequest: IRequest = { 53 | method: request.method, 54 | path: matchResult.path, 55 | rawRequest: request, 56 | params: matchResult.params as IParams, 57 | searchParams: urlObj.searchParams, 58 | body: body, 59 | headers: request.headers, 60 | URL: urlObj, 61 | search: urlObj.search, 62 | } 63 | response = await forward(newRequest) 64 | } else { 65 | response = new Response(defaultResponse, { 66 | headers: new Headers({ 67 | 'Content-Type': 'text/plain', 68 | }), 69 | }) 70 | } 71 | 72 | // handle reponse cors 73 | const corsFn = cors({}) 74 | const secondCorsResult = await corsFn(request.headers, response.headers) 75 | const newResponse = new Response(response.body, { 76 | status: response.status, 77 | headers: secondCorsResult.headers, 78 | }) 79 | return newResponse 80 | } catch (e) { 81 | // event.waitUntil(report(e, request)) 82 | return new Response( 83 | JSON.stringify({ 84 | message: e.message || 'An error occurred!', 85 | }), 86 | { 87 | status: e.statusCode || 500, 88 | headers: new Headers({ 89 | 'Content-Type': 'application/json', 90 | }), 91 | }, 92 | ) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from './handler' 2 | 3 | addEventListener('fetch', (event) => { 4 | event.respondWith(handleRequest(event.request, event)) 5 | }) 6 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | export type IParams = Record 2 | export interface IRequest { 3 | path: string 4 | method: string 5 | search: string 6 | searchParams: URLSearchParams 7 | params: IParams 8 | body: string | null 9 | headers: Headers 10 | rawRequest: Request 11 | URL: URL 12 | } 13 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import * as Log from 'loglevel' 3 | import prefix from 'loglevel-plugin-prefix' 4 | const log = Log.getLogger('webhook2github') 5 | interface IColors { 6 | [key: string]: chalk.Chalk 7 | } 8 | export const colors: IColors = { 9 | TRACE: chalk.magenta, 10 | DEBUG: chalk.cyan, 11 | INFO: chalk.green, 12 | WARN: chalk.yellow, 13 | ERROR: chalk.red, 14 | } 15 | prefix.reg(Log) 16 | const logLevel = typeof LOG_LEVEL === 'undefined' ? 'info' : LOG_LEVEL 17 | log.setDefaultLevel((logLevel as Log.LogLevelDesc) || 'info') 18 | if (ENVIRONMENT === 'development') { 19 | log.setLevel('debug') 20 | } 21 | prefix.apply(log, { 22 | format(level, name, timestamp) { 23 | return `${chalk.gray(`[${timestamp}]`)} ${colors[level.toUpperCase()]( 24 | level, 25 | )} ${chalk.green(`${name}:`)}` 26 | }, 27 | }) 28 | export { log, Log, prefix } 29 | -------------------------------------------------------------------------------- /src/sentry.ts: -------------------------------------------------------------------------------- 1 | // Get the key from the "DSN" at: https://sentry.io/settings//projects//keys/ 2 | // The "DSN" will be in the form: https://@sentry.io/ 3 | // eg, https://0000aaaa1111bbbb2222cccc3333dddd@sentry.io/123456 4 | const SENTRY_PROJECT_ID = '5441224' 5 | const SENTRY_KEY_LOCAL = SENTRY_KEY 6 | 7 | // Useful if you have multiple apps within a project – not necessary, only used in TAGS and SERVER_NAME below 8 | const APP = 'webhook' 9 | 10 | // https://docs.sentry.io/error-reporting/configuration/?platform=javascript#environment 11 | const ENV = ENVIRONMENT 12 | 13 | // https://docs.sentry.io/error-reporting/configuration/?platform=javascript#release 14 | // A string describing the version of the release – we just use: git rev-parse --verify HEAD 15 | // You can use this to associate files/source-maps: https://docs.sentry.io/cli/releases/#upload-files 16 | const RELEASE = 'v1' 17 | 18 | // https://docs.sentry.io/enriching-error-data/context/?platform=javascript#tagging-events 19 | const TAGS = { app: APP } 20 | 21 | // https://docs.sentry.io/error-reporting/configuration/?platform=javascript#server-name 22 | const SERVER_NAME = `${APP}-${ENV}` 23 | 24 | // Indicates the name of the SDK client 25 | const CLIENT_NAME = 'bustle-cf-sentry' 26 | const CLIENT_VERSION = '1.0.0' 27 | const RETRIES = 5 28 | 29 | // The log() function takes an Error object and the current request 30 | // 31 | // Eg, from a worker: 32 | // 33 | // addEventListener('fetch', event => { 34 | // event.respondWith(async () => { 35 | // try { 36 | // throw new Error('Oh no!') 37 | // } catch (e) { 38 | // await log(e, event.request) 39 | // } 40 | // return new Response('Logged!') 41 | // }) 42 | // }) 43 | 44 | export async function log(err: Error, request: Request) { 45 | // const body = JSON.stringify(toSentryEvent(err, request)) 46 | 47 | // for (let i = 0; i <= RETRIES; i++) { 48 | // const res = await fetch( 49 | // `https://sentry.io/api/${SENTRY_PROJECT_ID}/store/`, 50 | // { 51 | // method: 'POST', 52 | // headers: { 53 | // 'Content-Type': 'application/json', 54 | // 'X-Sentry-Auth': [ 55 | // 'Sentry sentry_version=7', 56 | // `sentry_client=${CLIENT_NAME}/${CLIENT_VERSION}`, 57 | // `sentry_key=${SENTRY_KEY_LOCAL}`, 58 | // ].join(', '), 59 | // }, 60 | // body, 61 | // }, 62 | // ) 63 | // if (res.status === 200) { 64 | // return 65 | // } 66 | // // We couldn't send to Sentry, try to log the response at least 67 | // console.error({ httpStatus: res.status, ...(await res.json()) }) // eslint-disable-line no-console 68 | // } 69 | } 70 | 71 | function toSentryEvent(err: any, request: Request) { 72 | const errType = err.name || (err.contructor || {}).name 73 | const frames = parse(err) 74 | const extraKeys = Object.keys(err).filter( 75 | (key) => !['name', 'message', 'stack'].includes(key), 76 | ) 77 | return { 78 | event_id: uuidv4(), 79 | message: errType + ': ' + (err.message || ''), 80 | exception: { 81 | values: [ 82 | { 83 | type: errType, 84 | value: err.message, 85 | stacktrace: frames.length ? { frames: frames.reverse() } : undefined, 86 | }, 87 | ], 88 | }, 89 | extra: extraKeys.length 90 | ? { 91 | [errType]: extraKeys.reduce( 92 | (obj, key) => ({ ...obj, [key]: err[key] }), 93 | {}, 94 | ), 95 | } 96 | : undefined, 97 | tags: TAGS, 98 | platform: 'javascript', 99 | environment: ENV, 100 | server_name: SERVER_NAME, 101 | timestamp: Date.now() / 1000, 102 | request: 103 | request && request.url 104 | ? { 105 | method: request.method, 106 | url: request.url, 107 | query_string: '', 108 | headers: request.headers, 109 | data: request.body, 110 | } 111 | : undefined, 112 | release: RELEASE, 113 | } 114 | } 115 | 116 | function parse(err: any) { 117 | return (err.stack || '') 118 | .split('\n') 119 | .slice(1) 120 | .map((line: string) => { 121 | if (line.match(/^\s*[-]{4,}$/)) { 122 | return { filename: line } 123 | } 124 | 125 | // From https://github.com/felixge/node-stack-trace/blob/1ec9ba43eece124526c273c917104b4226898932/lib/stack-trace.js#L42 126 | const lineMatch = line.match( 127 | /at (?:(.+)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/, 128 | ) 129 | if (!lineMatch) { 130 | return 131 | } 132 | 133 | return { 134 | function: lineMatch[1] || undefined, 135 | filename: lineMatch[2] || undefined, 136 | lineno: +lineMatch[3] || undefined, 137 | colno: +lineMatch[4] || undefined, 138 | in_app: lineMatch[5] !== 'native' || undefined, 139 | } 140 | }) 141 | .filter(Boolean) 142 | } 143 | 144 | function uuidv4() { 145 | const bytes = new Uint8Array(16) 146 | crypto.getRandomValues(bytes) 147 | bytes[6] = (bytes[6] & 0x0f) | 0x40 148 | bytes[8] = (bytes[8] & 0x3f) | 0x80 149 | return [...bytes].map((b) => ('0' + b.toString(16)).slice(-2)).join('') // to hex 150 | } 151 | -------------------------------------------------------------------------------- /src/utils/aws-sns-handler.ts: -------------------------------------------------------------------------------- 1 | import { IRequest } from '../interface' 2 | 3 | export const handler = async (request: IRequest): Promise => { 4 | const bodyString = request.body as string 5 | const body = JSON.parse(bodyString) 6 | if (body.Type === 'SubscriptionConfirmation') { 7 | await fetch(body.SubscribeURL) 8 | return new Response( 9 | JSON.stringify({ 10 | success: true, 11 | }), 12 | { 13 | headers: new Headers({ 'Content-Type': 'application/json' }), 14 | }, 15 | ) 16 | } else { 17 | throw new Error(`Can not found Type params, rawBody: ${request.body}`) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/body.ts: -------------------------------------------------------------------------------- 1 | import { log } from '../log' 2 | /** 3 | * readRequestBody reads in the incoming request body 4 | * Use await getBody(..) in an async function to get the string 5 | * @param {Request} request the incoming request to read from 6 | */ 7 | export async function getBody( 8 | request: Request | Response, 9 | ): Promise { 10 | const { headers } = request 11 | const contentType = headers.get('content-type') || '' 12 | try { 13 | if (contentType.includes('application/json')) { 14 | return JSON.stringify(await request.json()) 15 | } else if (contentType.includes('application/text')) { 16 | return await request.text() 17 | } else if (contentType.includes('text/html')) { 18 | return await request.text() 19 | } else if (contentType.includes('form')) { 20 | return await request.text() 21 | 22 | // const formData = await request.formData() 23 | // const body: Record = {} 24 | // for (const entry of formData.entries()) { 25 | // body[entry[0]] = entry[1] 26 | // } 27 | // return JSON.stringify(body) 28 | } else { 29 | return await request.text() 30 | // const myBlob = await request.blob() 31 | // const objectURL = URL.createObjectURL(myBlob) 32 | // return objectURL 33 | } 34 | } catch (error) { 35 | // log.warn('parse body error', error) 36 | return null 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/cors.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | import { getEditableHeaders } from './headers' 3 | interface Options { 4 | credentials?: true 5 | exposeHeaders?: string | ReadonlyArray 6 | allowHeaders?: string | ReadonlyArray 7 | maxAge?: string 8 | allowMethods?: string | ReadonlyArray 9 | origin?: boolean | string | ((headers: Headers) => string) 10 | isOptions?: boolean 11 | } 12 | /** 13 | * CORS middleware 14 | * 15 | * @param {Object} [options] 16 | * - {String|Function(ctx)} origin `Access-Control-Allow-Origin`, default is request Origin header 17 | * - {String|Array} allowMethods `Access-Control-Allow-Methods`, default is 'GET,HEAD,PUT,POST,DELETE,PATCH' 18 | * - {String|Array} exposeHeaders `Access-Control-Expose-Headers` 19 | * - {String|Array} allowHeaders `Access-Control-Allow-Headers` 20 | * - {String|Number} maxAge `Access-Control-Max-Age` in seconds 21 | * - {Boolean} credentials `Access-Control-Allow-Credentials` 22 | * - {Boolean} keepHeadersOnError Add set headers to `err.header` if an error is thrown 23 | * @return {Function} cors middleware 24 | * @api public 25 | */ 26 | export default function (options: Options) { 27 | const defaults = { 28 | allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH', 29 | } 30 | 31 | options = { 32 | ...defaults, 33 | ...options, 34 | } 35 | 36 | if (Array.isArray(options.exposeHeaders)) { 37 | options.exposeHeaders = options.exposeHeaders.join(',') 38 | } 39 | 40 | if (Array.isArray(options.allowMethods)) { 41 | options.allowMethods = options.allowMethods.join(',') 42 | } 43 | 44 | if (Array.isArray(options.allowHeaders)) { 45 | options.allowHeaders = options.allowHeaders.join(',') 46 | } 47 | 48 | if (options.maxAge) { 49 | options.maxAge = String(options.maxAge) 50 | } 51 | 52 | return async function cors( 53 | requestHeaders: Headers, 54 | responseHeaders: Headers, 55 | ): Promise<{ valid: boolean; headers: Headers }> { 56 | const originHeaders = responseHeaders 57 | const headers = getEditableHeaders(originHeaders) 58 | // If the Origin header is not present terminate this set of steps. 59 | // The request is outside the scope of this specification. 60 | const requestOrigin = requestHeaders.get('Origin') 61 | 62 | // Always set Vary header 63 | // https://github.com/rs/cors/issues/10 64 | headers.append('Vary', 'Origin') 65 | 66 | if (!requestOrigin) { 67 | return { valid: false, headers: headers } 68 | } 69 | 70 | let origin 71 | if (typeof options.origin === 'function') { 72 | origin = options.origin(headers) 73 | if ((origin as any) instanceof Promise) origin = await origin 74 | if (!origin) throw new Error('origin method error') 75 | } else { 76 | origin = options.origin || requestOrigin 77 | } 78 | 79 | let credentials = !!options.credentials 80 | 81 | const headersSet: Record = {} 82 | 83 | function set(key: string, value: string) { 84 | headers.set(key, value) 85 | headersSet[key] = value 86 | } 87 | 88 | if (!options.isOptions) { 89 | // Simple Cross-Origin Request, Actual Request, and Redirects 90 | set('Access-Control-Allow-Origin', origin as string) 91 | 92 | if (credentials === true) { 93 | set('Access-Control-Allow-Credentials', 'true') 94 | } 95 | 96 | if (options.exposeHeaders) { 97 | set('Access-Control-Expose-Headers', options.exposeHeaders as string) 98 | } 99 | 100 | return { valid: true, headers: headers } 101 | } else { 102 | // Preflight Request 103 | 104 | // If there is no Access-Control-Request-Method header or if parsing failed, 105 | // do not set any additional headers and terminate this set of steps. 106 | // The request is outside the scope of this specification. 107 | if (!requestHeaders.get('Access-Control-Request-Method')) { 108 | // this not preflight request, ignore it 109 | return { valid: false, headers: headers } 110 | } 111 | 112 | headers.set('Access-Control-Allow-Origin', origin as string) 113 | 114 | if (credentials === true) { 115 | headers.set('Access-Control-Allow-Credentials', 'true') 116 | } 117 | 118 | if (options.maxAge) { 119 | headers.set('Access-Control-Max-Age', options.maxAge) 120 | } 121 | 122 | if (options.allowMethods) { 123 | headers.set( 124 | 'Access-Control-Allow-Methods', 125 | options.allowMethods as string, 126 | ) 127 | } 128 | 129 | let allowHeaders = options.allowHeaders 130 | if (!allowHeaders) { 131 | allowHeaders = requestHeaders.get( 132 | 'access-control-request-headers', 133 | ) as string 134 | } 135 | if (allowHeaders) { 136 | headers.set('Access-Control-Allow-Headers', allowHeaders as string) 137 | } 138 | return { valid: true, headers: headers } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/utils/headers.ts: -------------------------------------------------------------------------------- 1 | export const getEditableHeaders = (originHeaders: Headers): Headers => { 2 | const headers = new Headers() 3 | 4 | for (let pair of originHeaders.entries()) { 5 | headers.set(pair[0], pair[1]) 6 | } 7 | return headers 8 | } 9 | 10 | export const getHeadersObj = ( 11 | originHeaders: Headers, 12 | ): Record => { 13 | const headersObj: Record = {} 14 | for (var pair of originHeaders.entries()) { 15 | const key = pair[0] 16 | const value = pair[1] 17 | headersObj[key] = value 18 | } 19 | return headersObj 20 | } 21 | export const printHeaders = (headers: Headers): void => { 22 | const headersObj: Record = {} 23 | for (let pair of headers.entries()) { 24 | headersObj[pair[0]] = pair[1] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import cors from './cors' 2 | export { getBody } from './body' 3 | export { cors } 4 | export { getEditableHeaders, printHeaders, getHeadersObj } from './headers' 5 | export { handler as awsSnsHandler } from './aws-sns-handler' 6 | -------------------------------------------------------------------------------- /test/_setup.ts: -------------------------------------------------------------------------------- 1 | // set up global namespace for worker environment 2 | import * as makeServiceWorkerEnv from 'service-worker-mock' 3 | declare var global: any 4 | Object.assign(global, makeServiceWorkerEnv()) 5 | -------------------------------------------------------------------------------- /test/handler.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { handleRequest } from '../src/handler' 3 | 4 | describe('handler returns response with request method', () => { 5 | const methods = [ 6 | 'GET', 7 | 'HEAD', 8 | 'POST', 9 | 'PUT', 10 | 'DELETE', 11 | 'CONNECT', 12 | 'OPTIONS', 13 | 'TRACE', 14 | 'PATCH', 15 | ] 16 | methods.forEach((method) => { 17 | it(method, async () => { 18 | const result = await handleRequest(new Request('/', { method })) 19 | const text = await result.text() 20 | expect(text).to.include(method) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "../transpiled", 4 | "target": "esnext", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "experimentalDecorators": true, 8 | "lib": ["esnext", "webworker"] 9 | }, 10 | "include": ["./*.ts", "../node_modules/@cloudflare/workers-types/index.d.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "commonjs", 5 | "target": "esnext", 6 | "lib": ["esnext", "webworker"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "types": ["@cloudflare/workers-types"] 14 | }, 15 | "include": [ 16 | "types", 17 | "./src/*.ts", 18 | "./test/*.ts", 19 | "./src/**/*.ts", 20 | "./test/**/*.ts", 21 | "./node_modules/@cloudflare/workers-types/index.d.ts" 22 | ], 23 | "exclude": ["node_modules/", "dist/"] 24 | } 25 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare var LOG_LEVEL: string 2 | declare var ENVIRONMENT: string 3 | declare var SENTRY_KEY: string 4 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const mode = process.env.NODE_ENV || 'production' 5 | 6 | module.exports = { 7 | output: { 8 | filename: `worker.${mode}.js`, 9 | path: path.join(__dirname, 'dist'), 10 | }, 11 | mode, 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js'], 14 | plugins: [], 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.tsx?$/, 20 | loader: 'ts-loader', 21 | options: { 22 | transpileOnly: true, 23 | }, 24 | }, 25 | ], 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | account_id = "03e7294bdb3750ed5a0d6afef6d770e4" 2 | compatibility_date = "2022-01-24" 3 | name = "webhook-dev" 4 | route = "" 5 | type = "webpack" 6 | vars = {ENVIRONMENT = "development"} 7 | webpack_config = "webpack.config.js" 8 | workers_dev = true 9 | zone_id = "73599f8e5f0cdb85f65ae7b132f574fc" 10 | [env.production] 11 | name = "webhook" 12 | vars = {ENVIRONMENT = "production"} 13 | --------------------------------------------------------------------------------