├── .fly.secrets.yml ├── .fly.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── assets │ ├── css │ │ ├── main.css │ │ └── main.css.map │ ├── images │ │ ├── icons.png │ │ ├── icons@2x.png │ │ ├── widgets.png │ │ └── widgets@2x.png │ └── js │ │ ├── main.js │ │ └── search.js ├── globals.html ├── index.html ├── interfaces │ ├── backends.awss3options.html │ ├── backends.githubpagesoptions.html │ ├── backends.glitchoptions.html │ ├── backends.herokuoptions.html │ ├── backends.subdomainoptions.html │ ├── config.cdnconfig.html │ ├── config.itemconfig.html │ ├── config.ruleinfo.html │ ├── http.fetchfactory.html │ ├── http.fetchfunction.html │ ├── http.proxyfactory.html │ ├── http.proxyfunction.html │ ├── http.proxyoptions.html │ ├── http.redirectoptions.html │ ├── middleware.httpcacheoptions.html │ ├── middleware.injecthtmloptions.html │ └── middleware.responseheadersoptions.html └── modules │ ├── backends.html │ ├── config.html │ ├── http.html │ └── middleware.html ├── examples └── getting-started │ ├── .gitignore │ ├── index.ts │ ├── package.json │ ├── tsconfig.json │ └── webpack.fly.config.js ├── index.ts ├── package.json ├── scripts └── ci │ ├── azure-pipelines.yml │ └── secrets.sh ├── src ├── aws.ts ├── backends.ts ├── backends │ ├── aerobatic.ts │ ├── aws_s3.ts │ ├── echo.ts │ ├── firebase.ts │ ├── ghost_pro.ts │ ├── github_pages.ts │ ├── gitlab_pages.ts │ ├── glitch.ts │ ├── heroku.ts │ ├── netlify.ts │ ├── origin.ts │ ├── squarespace.ts │ ├── subdomain_service.ts │ ├── surge.ts │ └── zeit-now.ts ├── balancer.ts ├── config │ ├── README.md │ ├── backends.ts │ ├── index.ts │ ├── middleware.ts │ └── rules.ts ├── data.ts ├── errors.ts ├── fetch.ts ├── index.ts ├── middleware.ts ├── middleware │ ├── auto-webp.ts │ ├── builder.ts │ ├── http-cache.ts │ ├── https-upgrader.ts │ ├── inject-html.ts │ └── response-headers.ts ├── pipeline.ts ├── proxy.ts ├── shims │ └── crypto.ts ├── text-replacements.ts └── util.ts ├── test ├── backends │ ├── aws_s3.spec.ts │ ├── github_pages.spec.ts │ ├── gitlab_pages.ts │ ├── origin.spec.ts │ └── subdomain_services.spec.ts ├── balancer.spec.ts ├── config │ ├── builder.spec.ts │ └── rules.spec.ts ├── data.spec.ts ├── fixtures │ └── image.png ├── middleware │ ├── auto-webp.spec.ts │ ├── http-cache.spec.ts │ ├── https-upgrader.spec.ts │ └── response-headers.spec.ts ├── pipeline.spec.ts └── proxy.spec.ts ├── tsconfig.json ├── webpack.fly.config.js └── yarn.lock /.fly.secrets.yml: -------------------------------------------------------------------------------- 1 | aws_s3_access_key_id: "" 2 | aws_s3_secret_access_key: "" -------------------------------------------------------------------------------- /.fly.yml: -------------------------------------------------------------------------------- 1 | config: &config 2 | flyCDN: 3 | backends: 4 | fly: 5 | type: origin 6 | origin: https://fly.io/ 7 | headers: 8 | host: fly.io 9 | rules: 10 | - actionType: rewrite 11 | backendKey: fly 12 | middleware: 13 | - type: https-upgrader 14 | - type: auto-webp 15 | 16 | default: &default 17 | app: cdn 18 | config: 19 | <<: *config 20 | 21 | development: 22 | <<: *default 23 | 24 | test: 25 | <<: *default 26 | config: 27 | <<: *config 28 | aws_s3_access_key_id: 29 | fromSecret: aws_s3_access_key_id 30 | aws_s3_secret_access_key: 31 | fromSecret: aws_s3_secret_access_key 32 | 33 | production: 34 | <<: *default 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib 3 | node_modules 4 | .nyc_output 5 | bin/fly 6 | .fly 7 | package-lock.json 8 | examples/**/node_modules 9 | examples/**/.fly 10 | .fly.secrets.yml -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "javascript.format.enable": true 4 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | # [0.9.0-2](https://github.com/superfly/edge/compare/v0.9.0-1...v0.9.0-2) (2019-05-13) 7 | 8 | 9 | 10 | 11 | # [0.9.0-1](https://github.com/superfly/edge/compare/v0.9.0-0...v0.9.0-1) (2019-05-13) 12 | 13 | 14 | ### Bug Fixes 15 | 16 | * bad response on data API DELETE ([3a91157](https://github.com/superfly/edge/commit/3a91157)) 17 | 18 | 19 | 20 | 21 | # [0.9.0-0](https://github.com/superfly/edge/compare/v0.8.0...v0.9.0-0) (2019-05-09) 22 | 23 | 24 | ### Features 25 | 26 | * data store REST API + write through cache ([5a8ccf8](https://github.com/superfly/edge/commit/5a8ccf8)) 27 | 28 | 29 | 30 | 31 | # [0.8.0](https://github.com/superfly/edge/compare/v0.7.2...v0.8.0) (2019-04-10) 32 | 33 | 34 | ### Features 35 | 36 | * allow tls options on proxy builder ([c35213f](https://github.com/superfly/edge/commit/c35213f)) 37 | 38 | 39 | 40 | 41 | ## [0.7.2](https://github.com/superfly/edge/compare/v0.7.1...v0.7.2) (2019-04-04) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * faster installs, fly now a dev dependency ([d9e7e6b](https://github.com/superfly/edge/commit/d9e7e6b)) 47 | 48 | 49 | 50 | 51 | ## [0.7.1](https://github.com/superfly/edge/compare/v0.7.0...v0.7.1) (2019-03-14) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * proxy supports fetch client certificates ([c8b56a4](https://github.com/superfly/edge/commit/c8b56a4)) 57 | 58 | 59 | 60 | 61 | # [0.7.0](https://github.com/superfly/edge/compare/v0.6.0-0...v0.7.0) (2019-03-14) 62 | 63 | 64 | ### Features 65 | 66 | * firebase backend ([#42](https://github.com/superfly/edge/issues/42)) ([a964be5](https://github.com/superfly/edge/commit/a964be5)) 67 | * gitlab pages backend ([#43](https://github.com/superfly/edge/issues/43)) ([93ee12d](https://github.com/superfly/edge/commit/93ee12d)) 68 | * load balancing ([f3c77e6](https://github.com/superfly/edge/commit/f3c77e6)) 69 | 70 | 71 | 72 | 73 | # [0.6.0](https://github.com/superfly/edge/compare/v0.6.0-0...v0.6.0) (2019-03-12) 74 | 75 | 76 | 77 | 78 | # [0.6.0-0](https://github.com/superfly/cdn/compare/v0.5.0-0...v0.6.0-0) (2019-02-20) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * auto-webp works with config based setup now ([a06b1cd](https://github.com/superfly/cdn/commit/a06b1cd)) 84 | * conflicting fly versions in dev/dependencies ([d0aa9c2](https://github.com/superfly/cdn/commit/d0aa9c2)) 85 | * getting started sending wrong host header (fixes: [#34](https://github.com/superfly/cdn/issues/34)) ([073bf67](https://github.com/superfly/cdn/commit/073bf67)) 86 | * include aws dependencies ([66ef0f5](https://github.com/superfly/cdn/commit/66ef0f5)) 87 | * origin backend doesn't accept forwardHostHeader ([6b97455](https://github.com/superfly/cdn/commit/6b97455)) 88 | * origin backend respects forwardHostHeader option ([57cd1d9](https://github.com/superfly/cdn/commit/57cd1d9)) 89 | * origin backend users retries option properly ([5d2242d](https://github.com/superfly/cdn/commit/5d2242d)) 90 | * proxy retries needs back off ([3c305f5](https://github.com/superfly/cdn/commit/3c305f5)) 91 | * relative `proxy` import on s3 backend ([#45](https://github.com/superfly/cdn/issues/45)) ([5e01d86](https://github.com/superfly/cdn/commit/5e01d86)) 92 | * squareSpace -> squarespace ([a60918b](https://github.com/superfly/cdn/commit/a60918b)) 93 | 94 | 95 | ### Features 96 | 97 | * aerobatic backend ([0fc08ab](https://github.com/superfly/cdn/commit/0fc08ab)) 98 | * better error handling on proxy (fixes [#28](https://github.com/superfly/cdn/issues/28)) ([c8c2e99](https://github.com/superfly/cdn/commit/c8c2e99)) 99 | * surge.sh backend ([a924769](https://github.com/superfly/cdn/commit/a924769)) 100 | * Zeit Now backend ([35b30f8](https://github.com/superfly/cdn/commit/35b30f8)) 101 | 102 | 103 | 104 | 105 | # [0.5.0](https://github.com/superfly/cdn/compare/v0.4.0...v0.5.0) (2019-01-17) 106 | 107 | 108 | ### Features 109 | 110 | * Automatically serve webp images when possible ([#17](https://github.com/superfly/cdn/issues/17)) ([c5cafc2](https://github.com/superfly/cdn/commit/c5cafc2)) 111 | * AWS S3 backend ([#22](https://github.com/superfly/cdn/issues/22)) ([1b32d02](https://github.com/superfly/cdn/commit/1b32d02)), closes [#12](https://github.com/superfly/cdn/issues/12) 112 | * Squarespace backend type ([213c4e7](https://github.com/superfly/cdn/commit/213c4e7)), closes [#16](https://github.com/superfly/cdn/issues/16) 113 | 114 | 115 | 116 | 117 | # [0.4.0](https://github.com/superfly/cdn/compare/v0.3.3...v0.4.0) (2019-01-09) 118 | 119 | 120 | ### Features 121 | 122 | * Glitch backend type ([b9bdb0b](https://github.com/superfly/cdn/commit/b9bdb0b)) 123 | * http-cache middleware ([#15](https://github.com/superfly/cdn/issues/15)) ([5d6d47a](https://github.com/superfly/cdn/commit/5d6d47a)) 124 | * Netlify backend type ([f1891a3](https://github.com/superfly/cdn/commit/f1891a3)) 125 | 126 | ### Bug Fixes 127 | 128 | * generated typedefs break tsc compilation ([0e21699](https://github.com/superfly/cdn/commit/0e21699)) 129 | 130 | 131 | 132 | # [0.4.0-1](https://github.com/superfly/cdn/compare/v0.4.0-0...v0.4.0-1) (2019-01-09) 133 | 134 | 135 | 136 | # [0.4.0-0](https://github.com/superfly/cdn/compare/v0.3.3...v0.4.0-0) (2019-01-08) 137 | 138 | 139 | 140 | ## [0.3.3](https://github.com/superfly/cdn/compare/v0.3.2...v0.3.3) (2018-12-18) 141 | 142 | 143 | 144 | 145 | ## [0.3.2](https://github.com/superfly/cdn/compare/v0.3.1...v0.3.2) (2018-12-13) 146 | 147 | 148 | ### Bug Fixes 149 | 150 | * need fly 0.44.2 ([aa5fd85](https://github.com/superfly/cdn/commit/aa5fd85)) 151 | 152 | 153 | 154 | 155 | ## [0.3.1](https://github.com/superfly/cdn/compare/v0.3.0...v0.3.1) (2018-12-13) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | * include proper files in publish ([f6f30e7](https://github.com/superfly/cdn/commit/f6f30e7)) 161 | 162 | 163 | 164 | 165 | # 0.3.0 (2018-12-13) 166 | 167 | 168 | ### Features 169 | 170 | * ghost pro backend type ([dc1fca5](https://github.com/superfly/cdn/commit/dc1fca5)) 171 | * GitHub Pages backend type ([19e1a28](https://github.com/superfly/cdn/commit/19e1a28)) 172 | * Heroku backend support ([a1d7edb](https://github.com/superfly/cdn/commit/a1d7edb)) 173 | * package as an npm ([b53cec1](https://github.com/superfly/cdn/commit/b53cec1)) 174 | * rewrite location headers on proxied responses ([604a531](https://github.com/superfly/cdn/commit/604a531)) 175 | 176 | 177 | 178 | 179 | # 0.2.0 (2018-10-20) 180 | 181 | 182 | ### Features 183 | 184 | * package as an npm ([b53cec1](https://github.com/superfly/fl-site/commit/b53cec1)) 185 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Fly Edge 2 | 3 | Thanks for stopping by! We'd like to make it as easy as possible for you to get started contributing, so read on to learn how. 4 | 5 | ## Where to start? 6 | 7 | If you want to suggest a new feature, found a bug, or have a question, [search the issue tracker](https://github.com/superfly/edge/issues) to see if 8 | someone else in the community has already created a ticket. If not, go ahead and create one 9 | [here](https://github.com/superfly/edge/issues/new)! 10 | 11 | ## Workflow 12 | 13 | The core team uses the [Pull Request workflow](https://guides.github.com/introduction/flow/) for all of our development. Each pull request is automatically tested and verified by our continuous integration system which is reported live on the pull request. Once all checks pass, someone on the core team will review the code and either sign off or provide feedback. After the change is signed off, the pull request will be merged into `master`. Note that `master` is not immediately published, so there might be a delay before the updated packages are live on npm. 14 | 15 | We try to work through pull requests as quickly as we can, but some large or far reaching changes may take longer. 16 | 17 | ### 1. Fork & create a branch 18 | 19 | When you're ready to start contributing, [fork](https://help.github.com/articles/fork-a-repo/) the CDN and clone to your machine: 20 | 21 | ```sh 22 | git clone https://github.com/superfly/edge.git flycdn 23 | cd flycdn 24 | 25 | ``` 26 | 27 | Next, create a branch from `master` with a descriptive name. 28 | 29 | ```sh 30 | git checkout -b 31 | ``` 32 | 33 | A good branch name, for example, where issue #156 is the ticket you're working on, would be `156-update-docs`. 34 | 35 | ### 2. Install dependencies 36 | 37 | The Fly CDN uses [Yarn](https://yarnpkg.com/en/) to manage dependencies and run development scripts. If you don't already have yarn installed, follow [these directions](https://yarnpkg.com/en/docs/install). 38 | 39 | ```sh 40 | yarn install 41 | ``` 42 | 43 | ### 3. Get the test suite running 44 | 45 | Run the test suite: 46 | 47 | ```sh 48 | yarn test 49 | ``` 50 | 51 | If the test suite passed, your environment is ready to go. 52 | 53 | ### 4. Make your changes 54 | 55 | You're now ready to make your changes! 56 | 57 | ### 5. Make a Pull Request 58 | 59 | Once you're happy with your changes and the test suite is passing locally, it's time to prepare your code for a pull request. 60 | 61 | It's good practice to update your code with the latest changes on the Fly CDN's `master` branch before publishing. To do this you'll need to update your local copy of master and merge any changes into your branch: 62 | 63 | ```sh 64 | git remote add upstream https://github.com/superfly/edge.git 65 | git fetch upstream master 66 | git rebase upstream/master 67 | ``` 68 | 69 | Then, push code to your fork: 70 | 71 | ```sh 72 | git push --set-upstream origin 73 | ``` 74 | 75 | Finally, go to GitHub and [make a Pull Request](https://github.com/superfly/edge/compare) :D 76 | 77 | [search the issue tracker]: https://github.com/superfly/edge/issues?q=something 78 | [new issue]: https://github.com/superfly/edge/issues/new 79 | [fork the CDN]: https://help.github.com/articles/fork-a-repo 80 | [make a pull request]: https://help.github.com/articles/creating-a-pull-request 81 | [git rebasing]: http://git-scm.com/book/en/Git-Branching-Rebasing 82 | [interactive rebase]: https://help.github.com/articles/interactive-rebase 83 | 84 | --- 85 | 86 | ## Getting Started with your new Fly CDN 87 | 88 | ### Want a UI? 89 | 90 | Sign up for an account on fly.io and use the `create app` button. 91 | 92 | ### Clone and run tests. 93 | 94 | * `git clone https://github.com/superfly/edge.git flycdn` 95 | * `cd flycdn` 96 | * `yarn install` 97 | * `yarn test` 98 | 99 | ### Local development server 100 | 101 | Run `yarn start` to launch a local development server on port 3000, then have a look in your browser: http://localhost:3000 102 | 103 | ### Deploy to production 104 | 105 | You can deploy this app to the Fly hosting service using the CLI. Sign up at fly.io, then run: 106 | 107 | * `yarn fly login` 108 | * `yarn fly app create ` 109 | * `yarn fly deploy` 110 | 111 | ### Questions? Feedback? 112 | 113 | We're active in [Gitter](https://gitter.im/superfly/fly) (you do not need to sign in to view recent chat) and on [Twitter](https://twitter.com/flydotio) or you can drop us a line at support@fly.io. Thanks! 114 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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 | ![Forking Edge](https://fly.io/public/images/edge-banner.png?@2x) 2 | 3 | # Fly Edge 4 | 5 | [![npm version](https://img.shields.io/npm/v/@fly/edge.svg)](https://www.npmjs.com/package/@fly/edge) 6 | [![isc license](https://img.shields.io/npm/l/@fly/edge.svg)](https://github.com/superfly/edge/blob/master/LICENSE) 7 | [![Build Status](https://dev.azure.com/flydotio/fly/_apis/build/status/fly)](https://dev.azure.com/flydotio/fly/_build/latest?definitionId=1) 8 | ![Gitter](https://img.shields.io/gitter/room/superfly/fly.svg?colorB=red) 9 | 10 | 11 | The Fly Edge project is a set of APIs for routing HTTP traffic, cache content, and add "middleware" (like auth) to any application. It's written in TypeScript and runs on the Fly Edge [runtime](https://github.com/superfly/fly). It's built for developers — that means runs locally, has a tests, and integrate into a CI/release pipeline. 12 | 13 | The code targets the Service Worker API and uses the Fly runtime API where necessary. You can deploy it to fly.io hosting or run it on any platform with an Edge Service Worker implementation (with reduced features). 14 | 15 | ## Getting Started 16 | 17 | ### Pre-requisites 18 | 19 | * yarn (`npm install -g yarn`) 20 | * Node 10.x 21 | * node-gyp: https://github.com/nodejs/node-gyp#installation 22 | 23 | ### Try the starter app 24 | 25 | ```bash 26 | git clone https://gist.github.com/ebc48856b74fde392a6d62a032b59a97.git forking-edge 27 | cd forking-edge 28 | yarn install 29 | yarn start # visit http://localhost:3000 30 | ``` 31 | 32 | Once you have that running, try swapping in a different origin. Edit `index.ts` and and replace `backends.origin("https://getting-started.edgeapp.net")` with `backends.githubPages("superfly/landing")`. 33 | 34 | ### Deploy to production 35 | 36 | You can deploy edge apps to the Fly hosting service using the CLI. Sign up at fly.io, then run: 37 | 38 | ```bash 39 | yarn fly login 40 | yarn fly app create 41 | yarn fly deploy 42 | ``` 43 | 44 | You can also run on CloudFlare or StackPath, though not all features will work. 45 | 46 | ## Features 47 | 48 | ### Straightforward TypeScript/ JavaScript API 49 | 50 | You can do a lot with a single `index.ts` file. This example redirects all requests to `https` and caches content when possible: 51 | 52 | ```typescript 53 | import { backends, middleware, pipeline } from "@fly/edge"; 54 | 55 | // user middleware for https redirect and caching 56 | const mw = pipeline( 57 | middleware.httpsUpgrader, 58 | middleware.httpCache 59 | ) 60 | 61 | // point it at the origin 62 | const app = mw( 63 | backends.origin("https://getting-started.edgeapp.net") 64 | ); 65 | 66 | // respond to http requests 67 | fly.http.respondWith(app); 68 | ``` 69 | 70 | ### Backends 71 | 72 | [Backends](https://github.com/superfly/edge/tree/master/src/backends) are origin services you can route requests to. The project includes a backend type [any HTTP service](https://github.com/superfly/edge/blob/master/src/backends/origin.ts), and more specialized types for proxying to third party services. 73 | 74 | * [GitHub Pages](https://github.com/superfly/edge/blob/master/src/backends/github_pages.ts) 75 | * [Heroku](https://github.com/superfly/edge/blob/master/src/backends/heroku.ts) 76 | * [Ghost Pro](https://github.com/superfly/edge/blob/master/src/backends/ghost_pro.ts) 77 | * [Glitch](https://github.com/superfly/edge/blob/master/src/backends/glitch.ts) 78 | * [Netlify](https://github.com/superfly/edge/blob/master/src/backends/netlify.ts) 79 | 80 | Want to help out? Write a new backend type and open a [pull request](https://github.com/superfly/edge/compare?template=backend_type.md)! 81 | 82 | ### Middleware 83 | 84 | [Middleware](https://github.com/superfly/edge/tree/master/src/middleware) applies logic to requests before they're sent to the backend, and responses before they're sent to users. 85 | 86 | * [HTTP -> HTTPS upgrader](https://github.com/superfly/edge/blob/master/src/middleware/https-upgrader.ts) 87 | * [Add response headers](https://github.com/superfly/edge/blob/master/src/middleware/response-headers.ts) 88 | * [HTTP caching](https://github.com/superfly/edge/blob/master/src/middleware/http-cache.ts) 89 | 90 | ## Development 91 | 92 | See [CONTRIBUTING](https://github.com/superfly/edge/blob/master/CONTRIBUTING.md). 93 | 94 | ## Configuration vs code 95 | 96 | The Fly Edge can be run standalone with a yaml based configuration schema. If you prefer to run with a config file, check out the config [README](https://github.com/superfly/edge/blob/master/src/config/README.md). 97 | 98 | ## Who's using it? 99 | 100 | * cars.com: HTTP routing 101 | * glitch.com: custom domain routing 102 | * fontawesome.com: CDN for paid customers 103 | * distractify.com: routing, caching, redirect management 104 | * greenmatters.com: routing, caching, redirect management 105 | * artstorefronts.com: custom domain routing 106 | * kajabi.com: custom domain routing 107 | * posthaven.com: custom domain routing 108 | 109 | [![](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/flydotio) 110 | -------------------------------------------------------------------------------- /docs/assets/images/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/edge/5cb72c65463ce0c5b4aa7edaf7b790e13faa3793/docs/assets/images/icons.png -------------------------------------------------------------------------------- /docs/assets/images/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/edge/5cb72c65463ce0c5b4aa7edaf7b790e13faa3793/docs/assets/images/icons@2x.png -------------------------------------------------------------------------------- /docs/assets/images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/edge/5cb72c65463ce0c5b4aa7edaf7b790e13faa3793/docs/assets/images/widgets.png -------------------------------------------------------------------------------- /docs/assets/images/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/edge/5cb72c65463ce0c5b4aa7edaf7b790e13faa3793/docs/assets/images/widgets@2x.png -------------------------------------------------------------------------------- /docs/globals.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @fly/cdn 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | 27 |
28 |
29 | Options 30 |
31 |
32 | All 33 |
    34 |
  • Public
  • 35 |
  • Public/Protected
  • 36 |
  • All
  • 37 |
38 |
39 | 40 | 41 |
42 |
43 | Menu 44 |
45 |
46 |
47 |
48 |
49 |
50 | 55 |

@fly/cdn

56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |

Index

64 |
65 |
66 |
67 |

External modules

68 | 74 |
75 |
76 |
77 |
78 |
79 | 104 |
105 |
106 |
107 |
108 |

Legend

109 |
110 |
    111 |
  • Module
  • 112 |
  • Object literal
  • 113 |
  • Variable
  • 114 |
  • Function
  • 115 |
  • Function with type parameter
  • 116 |
  • Index signature
  • 117 |
  • Type alias
  • 118 |
119 |
    120 |
  • Enumeration
  • 121 |
  • Enumeration member
  • 122 |
  • Property
  • 123 |
  • Method
  • 124 |
125 |
    126 |
  • Interface
  • 127 |
  • Interface with type parameter
  • 128 |
  • Constructor
  • 129 |
  • Property
  • 130 |
  • Method
  • 131 |
  • Index signature
  • 132 |
133 |
    134 |
  • Class
  • 135 |
  • Class with type parameter
  • 136 |
  • Constructor
  • 137 |
  • Property
  • 138 |
  • Method
  • 139 |
  • Accessor
  • 140 |
  • Index signature
  • 141 |
142 |
    143 |
  • Inherited constructor
  • 144 |
  • Inherited property
  • 145 |
  • Inherited method
  • 146 |
  • Inherited accessor
  • 147 |
148 |
    149 |
  • Protected property
  • 150 |
  • Protected method
  • 151 |
  • Protected accessor
  • 152 |
153 |
    154 |
  • Private property
  • 155 |
  • Private method
  • 156 |
  • Private accessor
  • 157 |
158 |
    159 |
  • Static property
  • 160 |
  • Static method
  • 161 |
162 |
163 |
164 |
165 |
166 |

Generated using TypeDoc

167 |
168 |
169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /docs/interfaces/backends.glitchoptions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GlitchOptions | @fly/cdn 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | 27 |
28 |
29 | Options 30 |
31 |
32 | All 33 |
    34 |
  • Public
  • 35 |
  • Public/Protected
  • 36 |
  • All
  • 37 |
38 |
39 | 40 | 41 |
42 |
43 | Menu 44 |
45 |
46 |
47 |
48 |
49 |
50 | 61 |

Interface GlitchOptions

62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |

Glitch configugration.

72 |
73 |
74 |
75 |
76 |

Hierarchy

77 |
    78 |
  • 79 | GlitchOptions 80 |
  • 81 |
82 |
83 |
84 |

Index

85 |
86 |
87 |
88 |

Properties

89 | 92 |
93 |
94 |
95 |
96 |
97 |

Properties

98 |
99 | 100 |

appName

101 |
appName: string
102 | 107 |
108 |
109 |

Glitch application name: .glitch.me

110 |
111 |
112 |
113 |
114 |
115 | 185 |
186 |
187 |
188 |
189 |

Legend

190 |
191 |
    192 |
  • Module
  • 193 |
  • Object literal
  • 194 |
  • Variable
  • 195 |
  • Function
  • 196 |
  • Function with type parameter
  • 197 |
  • Index signature
  • 198 |
  • Type alias
  • 199 |
200 |
    201 |
  • Enumeration
  • 202 |
  • Enumeration member
  • 203 |
  • Property
  • 204 |
  • Method
  • 205 |
206 |
    207 |
  • Interface
  • 208 |
  • Interface with type parameter
  • 209 |
  • Constructor
  • 210 |
  • Property
  • 211 |
  • Method
  • 212 |
  • Index signature
  • 213 |
214 |
    215 |
  • Class
  • 216 |
  • Class with type parameter
  • 217 |
  • Constructor
  • 218 |
  • Property
  • 219 |
  • Method
  • 220 |
  • Accessor
  • 221 |
  • Index signature
  • 222 |
223 |
    224 |
  • Inherited constructor
  • 225 |
  • Inherited property
  • 226 |
  • Inherited method
  • 227 |
  • Inherited accessor
  • 228 |
229 |
    230 |
  • Protected property
  • 231 |
  • Protected method
  • 232 |
  • Protected accessor
  • 233 |
234 |
    235 |
  • Private property
  • 236 |
  • Private method
  • 237 |
  • Private accessor
  • 238 |
239 |
    240 |
  • Static property
  • 241 |
  • Static method
  • 242 |
243 |
244 |
245 |
246 |
247 |

Generated using TypeDoc

248 |
249 |
250 | 251 | 252 | 253 | -------------------------------------------------------------------------------- /docs/interfaces/http.fetchfactory.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FetchFactory | @fly/cdn 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | 27 |
28 |
29 | Options 30 |
31 |
32 | All 33 |
    34 |
  • Public
  • 35 |
  • Public/Protected
  • 36 |
  • All
  • 37 |
38 |
39 | 40 | 41 |
42 |
43 | Menu 44 |
45 |
46 |
47 |
48 |
49 |
50 | 61 |

Interface FetchFactory

62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |

Hierarchy

70 |
    71 |
  • 72 | FetchFactory 73 |
  • 74 |
75 |
76 |
77 |

Callable

78 | 81 |
    82 |
  • 83 | 88 |

    Parameters

    89 |
      90 |
    • 91 |
      fetch: FetchFunction
      92 |
    • 93 |
    • 94 |
      Optional options: any
      95 |
    • 96 |
    97 |

    Returns FetchFunction

    98 |
  • 99 |
100 |
101 |
102 | 170 |
171 |
172 |
173 |
174 |

Legend

175 |
176 |
    177 |
  • Module
  • 178 |
  • Object literal
  • 179 |
  • Variable
  • 180 |
  • Function
  • 181 |
  • Function with type parameter
  • 182 |
  • Index signature
  • 183 |
  • Type alias
  • 184 |
185 |
    186 |
  • Enumeration
  • 187 |
  • Enumeration member
  • 188 |
  • Property
  • 189 |
  • Method
  • 190 |
191 |
    192 |
  • Interface
  • 193 |
  • Interface with type parameter
  • 194 |
  • Constructor
  • 195 |
  • Property
  • 196 |
  • Method
  • 197 |
  • Index signature
  • 198 |
199 |
    200 |
  • Class
  • 201 |
  • Class with type parameter
  • 202 |
  • Constructor
  • 203 |
  • Property
  • 204 |
  • Method
  • 205 |
  • Accessor
  • 206 |
  • Index signature
  • 207 |
208 |
    209 |
  • Inherited constructor
  • 210 |
  • Inherited property
  • 211 |
  • Inherited method
  • 212 |
  • Inherited accessor
  • 213 |
214 |
    215 |
  • Protected property
  • 216 |
  • Protected method
  • 217 |
  • Protected accessor
  • 218 |
219 |
    220 |
  • Private property
  • 221 |
  • Private method
  • 222 |
  • Private accessor
  • 223 |
224 |
    225 |
  • Static property
  • 226 |
  • Static method
  • 227 |
228 |
229 |
230 |
231 |
232 |

Generated using TypeDoc

233 |
234 |
235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /docs/interfaces/middleware.responseheadersoptions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ResponseHeadersOptions | @fly/cdn 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | 27 |
28 |
29 | Options 30 |
31 |
32 | All 33 |
    34 |
  • Public
  • 35 |
  • Public/Protected
  • 36 |
  • All
  • 37 |
38 |
39 | 40 | 41 |
42 |
43 | Menu 44 |
45 |
46 |
47 |
48 |
49 |
50 | 61 |

Interface ResponseHeadersOptions

62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |

Header name/value pairs to set on a response. The boolean false removes the header entirely.

72 |
73 |
74 |
75 |
76 |

Hierarchy

77 |
    78 |
  • 79 | ResponseHeadersOptions 80 |
  • 81 |
82 |
83 |
84 |

Indexable

85 |
[name: string]: string | false
86 |
87 |
88 |

Header name/value pairs to set on a response. The boolean false removes the header entirely.

89 |
90 |
91 |
92 |
93 | 158 |
159 |
160 |
161 |
162 |

Legend

163 |
164 |
    165 |
  • Module
  • 166 |
  • Object literal
  • 167 |
  • Variable
  • 168 |
  • Function
  • 169 |
  • Function with type parameter
  • 170 |
  • Index signature
  • 171 |
  • Type alias
  • 172 |
173 |
    174 |
  • Enumeration
  • 175 |
  • Enumeration member
  • 176 |
  • Property
  • 177 |
  • Method
  • 178 |
179 |
    180 |
  • Interface
  • 181 |
  • Interface with type parameter
  • 182 |
  • Constructor
  • 183 |
  • Property
  • 184 |
  • Method
  • 185 |
  • Index signature
  • 186 |
187 |
    188 |
  • Class
  • 189 |
  • Class with type parameter
  • 190 |
  • Constructor
  • 191 |
  • Property
  • 192 |
  • Method
  • 193 |
  • Accessor
  • 194 |
  • Index signature
  • 195 |
196 |
    197 |
  • Inherited constructor
  • 198 |
  • Inherited property
  • 199 |
  • Inherited method
  • 200 |
  • Inherited accessor
  • 201 |
202 |
    203 |
  • Protected property
  • 204 |
  • Protected method
  • 205 |
  • Protected accessor
  • 206 |
207 |
    208 |
  • Private property
  • 209 |
  • Private method
  • 210 |
  • Private accessor
  • 211 |
212 |
    213 |
  • Static property
  • 214 |
  • Static method
  • 215 |
216 |
217 |
218 |
219 |
220 |

Generated using TypeDoc

221 |
222 |
223 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /examples/getting-started/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib 3 | build 4 | node_modules 5 | .nyc_output 6 | bin/fly 7 | .fly 8 | package-lock.json 9 | yarn.lock 10 | -------------------------------------------------------------------------------- /examples/getting-started/index.ts: -------------------------------------------------------------------------------- 1 | import { backends, proxy, middleware, pipeline } from "@fly/cdn"; 2 | 3 | const mw = pipeline( 4 | middleware.httpsUpgrader, 5 | middleware.httpCache 6 | ) 7 | 8 | const app = mw( 9 | proxy("https://getting-started.edgeapp.net") 10 | ); 11 | 12 | fly.http.respondWith(app); -------------------------------------------------------------------------------- /examples/getting-started/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "yarn fly server", 4 | "test": "yarn fly test" 5 | }, 6 | "dependencies": { 7 | "@fly/cdn": "^0.4.0" 8 | }, 9 | "peerDependencies": {}, 10 | "devDependencies": { 11 | "@types/chai": "^4.1.7", 12 | "@types/mocha": "^5.2.5", 13 | "chai": "^4.2.0", 14 | "ts-loader": "^3.5.0", 15 | "typescript": "^3.1.2" 16 | }, 17 | "publishConfig": { 18 | "access": "public" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/getting-started/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./index.ts"], 3 | "compilerOptions": { 4 | "outDir": "./build/", 5 | "baseUrl": ".", 6 | "paths": { 7 | "@fly/cdn": ["./src/"] 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /examples/getting-started/webpack.fly.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: "./index.ts", 3 | resolve: { 4 | extensions: ['.js', '.ts', '.tsx'] 5 | }, 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.tsx?$/, 10 | loader: 'ts-loader' 11 | } 12 | ] 13 | } 14 | } -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { buildAppFromConfig } from "./src"; 2 | 3 | // from config 4 | fly.http.respondWith(buildAppFromConfig()); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fly/edge", 3 | "version": "0.9.0-2", 4 | "description": "Fly's TypeScript Edge", 5 | "main": "./lib/index.js", 6 | "typings": "./lib/index.d.ts", 7 | "files": [ 8 | "lib" 9 | ], 10 | "scripts": { 11 | "start": "yarn fly server", 12 | "test": "yarn fly test", 13 | "build": "yarn tsc", 14 | "prepublishOnly": "yarn tsc", 15 | "doc": "typedoc" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/superfly/edge.git" 20 | }, 21 | "keywords": [ 22 | "routing", 23 | "adn", 24 | "cdn", 25 | "edge", 26 | "flyio", 27 | "onehostname", 28 | "global", 29 | "load-balancer", 30 | "middleware" 31 | ], 32 | "author": "Kurt Mackey", 33 | "license": "Apache-2.0", 34 | "bugs": { 35 | "url": "https://github.com/superfly/edge/issues" 36 | }, 37 | "homepage": "https://github.com/superfly/edge#readme", 38 | "dependencies": { 39 | "aws4": "^1.8.0", 40 | "http-cache-semantics": "^4.0.1", 41 | "lodash": "^4.17.11", 42 | "sjcl": "^1.0.8" 43 | }, 44 | "peerDependencies": {}, 45 | "devDependencies": { 46 | "@fly/fly": "^0.52.0", 47 | "@types/aws4": "^1.5.1", 48 | "@types/chai": "^4.1.7", 49 | "@types/lodash": "^4.14.119", 50 | "@types/mocha": "^5.2.5", 51 | "@types/sjcl": "^1.0.28", 52 | "@types/source-map": "^0.5.2", 53 | "arraybuffer-loader": "^1.0.3", 54 | "chai": "^4.2.0", 55 | "image-webpack-loader": "^4.3.1", 56 | "standard-version": "^4.4.0", 57 | "ts-loader": "^3.5.0", 58 | "typedoc": "^0.13.0", 59 | "typedoc-plugin-external-module-name": "^1.1.3", 60 | "typescript": "^3.1.2", 61 | "webpack": "^4.28.4" 62 | }, 63 | "publishConfig": { 64 | "access": "public" 65 | }, 66 | "directories": { 67 | "doc": "docs", 68 | "test": "test" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /scripts/ci/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Node.js 2 | # Build a general Node.js project with npm. 3 | # Add steps that analyze code, save build artifacts, deploy, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript 5 | 6 | pool: 7 | vmImage: 'Ubuntu 16.04' 8 | 9 | steps: 10 | - task: NodeTool@0 11 | inputs: 12 | versionSpec: '11.x' 13 | displayName: 'Install Node.js' 14 | - script: | 15 | npm install -g yarn 16 | displayName: "install yarn" 17 | - script: | 18 | yarn install 19 | displayName: 'yarn install' 20 | - bash: | 21 | echo "Generating fly secrets." 22 | 23 | cat > .fly.secrets.yml < .fly.secrets.yml < { 21 | const config = normalizeOptions(options); 22 | 23 | const aerobaticHost = `${config.subdomain}.aerobaticapp.com` 24 | const uri = `https://${aerobaticHost}${config.directory}` 25 | const headers = { 26 | "host": aerobaticHost, 27 | "x-forwarded-host": config.hostname || false 28 | } 29 | 30 | const fn = proxy(uri, { headers: headers }) 31 | return Object.assign(fn, { proxyConfig: config}) 32 | } 33 | 34 | aerobatic.normalizeOptions = normalizeOptions; 35 | -------------------------------------------------------------------------------- /src/backends/aws_s3.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | import aws, { Credentials } from '../aws' 5 | import { ProxyFunction } from '../proxy'; 6 | 7 | /** 8 | * AWS S3 bucket options 9 | */ 10 | export interface AwsS3Options { 11 | bucket: string, 12 | region?: string, 13 | credentials?: Credentials 14 | } 15 | 16 | const allowedMethods = ["GET", "HEAD"] 17 | 18 | /** 19 | * Creates a fetch-like proxy function for making requests to AWS S3. 20 | * 21 | * Example: 22 | * 23 | * ```typescript 24 | * import { awsS3 } from "./src/backends"; 25 | * const backend = awsS3({ 26 | * bucket: "flyio-test-website", 27 | * // region: "us-east-1" 28 | * // credentials: { // for private S3 buckets 29 | * // accessKeyId: app.config.aws_access_key_id, 30 | * // secretAccessKey: app.config.aws_secret_access_key, // store this as a secret 31 | * // } 32 | * }); 33 | * ``` 34 | * @param options AWS S3 bucket to proxy to 35 | * @module Backends 36 | */ 37 | export function awsS3(options: AwsS3Options | string): ProxyFunction { 38 | const opts = normalizeOptions(options); 39 | 40 | const fn = async function awsS3Fetch(req: RequestInfo, init?: RequestInit): Promise { 41 | if (typeof req === "string") req = new Request(req, init); 42 | 43 | if (!allowedMethods.includes(req.method)) 44 | return new Response(`HTTP Method not allowed, only ${allowedMethods.join(", ")} are allowed.`, { status: 405 }) 45 | 46 | const url = new URL(req.url); 47 | if (url.pathname.endsWith("/")) 48 | url.pathname += "index.html" 49 | 50 | let res: Response; 51 | 52 | if (typeof opts.credentials !== 'object') 53 | res = await fetch(buildS3Url(opts, url.pathname), { method: req.method, headers: req.headers }) 54 | else 55 | res = await aws.fetch({ 56 | path: `/${opts.bucket}${url.pathname}`, 57 | service: 's3', 58 | region: opts.region, 59 | method: req.method, 60 | }, opts.credentials); 61 | 62 | if (res.status >= 500) { 63 | console.error(`AWS S3 returned a server error, status code: ${res.status}, body:`, await res.text()); 64 | return new Response(req.method === "GET" ? "Something went wrong." : null, { status: 500 }) 65 | } 66 | 67 | if (res.status === 404) 68 | return new Response(req.method === "GET" ? "Not found." : null, { status: 404 }) 69 | 70 | if (res.status >= 400) { 71 | console.error(`AWS S3 returned a client error, status code: ${res.status}, body:`, await res.text()); 72 | return new Response(req.method === "GET" ? "Something went wrong." : null, { status: 500 }) 73 | } 74 | 75 | for (let h in res.headers) 76 | if (h.startsWith("x-amz-")) 77 | res.headers.delete(h) 78 | 79 | return res 80 | } 81 | 82 | return Object.assign(fn, { proxyConfig: opts }) 83 | } 84 | 85 | function normalizeOptions(options: AwsS3Options | string): AwsS3Options { 86 | if (typeof options === 'string') 87 | options = { bucket: options } 88 | return options 89 | } 90 | 91 | function buildS3Url(opts: AwsS3Options, path: string): string { 92 | const region = opts.region || 'us-east-1'; 93 | let host: string; 94 | if (region === 'us-east-1') { 95 | host = "s3.amazonaws.com" 96 | } else { 97 | host = `s3-${opts.region}.amazonaws.com` 98 | } 99 | 100 | return `http://${host}/${opts.bucket}${path}` 101 | } 102 | -------------------------------------------------------------------------------- /src/backends/echo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | /** 5 | * A useful fetch-like function for debugging. Echos request information 6 | * as a JSON response. 7 | * @hidden 8 | * @param req The request to echo 9 | * @param init Request init information 10 | */ 11 | export async function echo(req: RequestInfo, init?: RequestInit){ 12 | if(typeof req === "string"){ 13 | req = new Request(req, init) 14 | init = undefined 15 | } 16 | 17 | const body = { 18 | method: req.method, 19 | url: req.url, 20 | remoteAddr: (req as any).remoteAddr, 21 | headers: (req.headers as any).toJSON() 22 | } 23 | 24 | return new Response(JSON.stringify(body, null, "\t"), { headers: {"content-type": "application/json"}}); 25 | } -------------------------------------------------------------------------------- /src/backends/firebase.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { normalizeOptions, SubdomainOptions } from "./subdomain_service"; 7 | 8 | /** 9 | * Creates a `fetch` like function for proxying requests to hosted Firebase sites. 10 | * 11 | * Example: 12 | * ```typescript 13 | * import { firebase } from "./src/backends"; 14 | * const backend = firebase({ 15 | * subdomain: "multi-site-magic" 16 | * }); 17 | * ``` 18 | * @param options Firebase site information. Accepts subdomain as a string. 19 | */ 20 | export function firebase(options: SubdomainOptions | string): ProxyFunction { 21 | const config = normalizeOptions(options); 22 | 23 | const firebaseHost = `${config.subdomain}.firebaseapp.com` 24 | const uri = `https://${firebaseHost}${config.directory}` 25 | const headers = { 26 | "host": firebaseHost, 27 | "x-forwarded-host": config.hostname || false 28 | } 29 | 30 | const fn = proxy(uri, { headers: headers }) 31 | return Object.assign(fn, { proxyConfig: config}) 32 | } 33 | 34 | firebase.normalizeOptions = normalizeOptions; 35 | -------------------------------------------------------------------------------- /src/backends/ghost_pro.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { SubdomainOptions, normalizeOptions } from "./subdomain_service"; 7 | 8 | /** 9 | * Creates a `fetch` like function for proxying requests to hosted Ghost Pro blogs. 10 | * 11 | * Example: 12 | * ```typescript 13 | * import { ghost } from "./src/backends"; 14 | * const backend = ghost({ 15 | * subdomain: "fly-io", 16 | * directory: "/articles/", 17 | * hostname: "fly.io" 18 | * }); 19 | * ``` 20 | * @param options Ghost Pro blog information. Accepts subdomain as a string. 21 | */ 22 | export function ghostProBlog(options: SubdomainOptions | string): ProxyFunction { 23 | const config = normalizeOptions(options); 24 | 25 | const ghostHost = `${config.subdomain}.ghost.io` 26 | const uri = `https://${ghostHost}${config.directory}` 27 | const headers = { 28 | "host": ghostHost, 29 | "x-forwarded-host": config.hostname || false 30 | } 31 | 32 | const fn = proxy(uri, { headers: headers }) 33 | return Object.assign(fn, { proxyConfig: config}) 34 | } 35 | 36 | ghostProBlog.normalizeOptions = normalizeOptions; 37 | -------------------------------------------------------------------------------- /src/backends/github_pages.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | import { proxy, ProxyFunction } from "../proxy"; 5 | import { isObject, merge } from "../util"; 6 | import * as errors from "../errors"; 7 | 8 | 9 | /** 10 | * GitHub Repository information. 11 | */ 12 | export interface GitHubPagesOptions { 13 | 14 | /** Repository owner */ 15 | owner: string, 16 | 17 | /** Repository name format */ 18 | repository: string, 19 | 20 | /** The custom hostname on repository */ 21 | hostname?: string 22 | } 23 | 24 | /** 25 | * Creates a fetch-like proxy function for making requests to GitHub pages 26 | * hosted sites. 27 | * 28 | * Example: 29 | * 30 | * ```typescript 31 | * import { githubPages } from "./src/backends"; 32 | * const backend = githubPages({ 33 | * owner: "superfly", 34 | * repo: "edge", 35 | * hostname: "docs.fly.io" 36 | * }); 37 | * ``` 38 | * @param config The Github repository to proxy to 39 | * @module Backends 40 | */ 41 | export function githubPages(options: GitHubPagesOptions | string): ProxyFunction { 42 | const config = _normalizeOptions(options); 43 | 44 | let ghFetch = buildGithubPagesProxy(config) 45 | let buildTime = 0 // first failure might need a retry 46 | 47 | const c = config 48 | 49 | const fn = async function githubPagesFetch(req: RequestInfo, init?: RequestInit) { 50 | const original = ghFetch 51 | if(typeof req === "string"){ 52 | req = new Request(req, init) 53 | } 54 | console.debug("ghpages starting fetch:", req.url, buildTime) 55 | let resp = await ghFetch(req, init) 56 | console.debug("ghpages resp status:", resp.status) 57 | if(resp.status === 404 && ghFetch.proxyConfig.hostname){ 58 | // hostname might've been cleared 59 | const url = new URL(req.url) 60 | const diff = Date.now() - buildTime 61 | if( 62 | (url.pathname === "/" && diff > 10000) // retry after 10s for root 63 | || diff > 30000){ // wait 5min for everything else 64 | 65 | 66 | console.debug("ghpages hostname request got 404:", c.hostname) 67 | c.hostname = undefined 68 | ghFetch = buildGithubPagesProxy(c) 69 | } 70 | } 71 | if(resp.status === 301 && !ghFetch.proxyConfig.hostname){ 72 | // 301s happen when you request .github.io/ and need a custom domain 73 | let location = resp.headers.get("location") 74 | if(location){ 75 | const url = new URL(location) 76 | c.hostname = url.hostname 77 | ghFetch = buildGithubPagesProxy(c) 78 | console.debug("ghpages found hostname:", c) 79 | } 80 | } 81 | if(original !== ghFetch){ 82 | // underlying proxy function changed, store it and retry 83 | console.debug("ghpages got a new fetch fn") 84 | self.proxyConfig = ghFetch.proxyConfig 85 | resp = await ghFetch(req, init) 86 | } 87 | return resp 88 | } 89 | 90 | let self = Object.assign(fn, { proxyConfig: ghFetch.proxyConfig}) 91 | return self 92 | } 93 | 94 | githubPages.normalizeOptions = _normalizeOptions; 95 | 96 | function _normalizeOptions(input: unknown): GitHubPagesOptions { 97 | const options: GitHubPagesOptions = { owner: "", repository: "" }; 98 | 99 | if (typeof input === "string" && input.includes("/")) { 100 | [options.owner, options.repository] = input.split("/"); 101 | } else if (isObject(input)) { 102 | merge(options, input, ["owner", "repository", "hostname"]); 103 | } else { 104 | throw errors.invalidInput("options must be a GitHubPagesOptions object or `owner/repo` string"); 105 | } 106 | 107 | if (!options.owner) { 108 | throw errors.invalidProperty("owner", "is required"); 109 | } 110 | if (!options.repository) { 111 | throw errors.invalidProperty("repository", "is required"); 112 | } 113 | 114 | return options; 115 | } 116 | 117 | function buildGithubPagesProxy(options: GitHubPagesOptions): ProxyFunction { 118 | const {owner, repository, hostname} = options 119 | const ghHost = `${owner}.github.io` 120 | const headers = { 121 | host: ghHost, 122 | "x-forwarded-host": false 123 | } 124 | let path = `/${repository}/` 125 | 126 | if(hostname){ 127 | path = '/' // no repo path when hostname exists 128 | headers.host = hostname 129 | } 130 | 131 | console.debug("ghpages creating proxy:", `https://${ghHost}${path}`, { 132 | headers: headers, 133 | stripPath: path 134 | }) 135 | const fn = proxy(`https://${ghHost}${path}`, { 136 | headers: headers, 137 | stripPath: path 138 | }) 139 | 140 | return Object.assign(fn, { proxyConfig: options } ) 141 | } -------------------------------------------------------------------------------- /src/backends/gitlab_pages.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | import { proxy, ProxyFunction } from "../proxy"; 5 | import { isObject, merge } from "../util"; 6 | import * as errors from "../errors"; 7 | 8 | 9 | /** 10 | * GitLab Repository information. 11 | */ 12 | export interface GitLabPagesOptions { 13 | 14 | /** Repository owner */ 15 | owner: string, 16 | 17 | /** Repository name format */ 18 | repository: string, 19 | 20 | /** The custom hostname on repository */ 21 | hostname?: string 22 | } 23 | 24 | /** 25 | * Creates a fetch-like proxy function for making requests to GitLab pages 26 | * hosted sites. 27 | * 28 | * Example: 29 | * 30 | * ```typescript 31 | * import { gitlabPages } from "./src/backends"; 32 | * const backend = gitlabPages({ 33 | * owner: "superfly", 34 | * repo: "cdn", 35 | * hostname: "docs.fly.io" 36 | * }); 37 | * ``` 38 | * @param config The GitLab repository to proxy to 39 | * @module Backends 40 | */ 41 | export function gitlabPages(options: GitLabPagesOptions | string): ProxyFunction { 42 | const config = _normalizeOptions(options); 43 | 44 | let glFetch = buildGitlabPagesProxy(config) 45 | let buildTime = 0 // first failure might need a retry 46 | 47 | const c = config 48 | 49 | const fn = async function gitlabPagesFetch(req: RequestInfo, init?: RequestInit) { 50 | const original = glFetch 51 | if(typeof req === "string"){ 52 | req = new Request(req, init) 53 | } 54 | console.debug("glpages starting fetch:", req.url, buildTime) 55 | let resp = await glFetch(req, init) 56 | console.debug("glpages resp status:", resp.status) 57 | if(resp.status === 404 && glFetch.proxyConfig.hostname){ 58 | // hostname might've been cleared 59 | const url = new URL(req.url) 60 | const diff = Date.now() - buildTime 61 | if( 62 | (url.pathname === "/" && diff > 10000) // retry after 10s for root 63 | || diff > 30000){ // wait 5min for everything else 64 | 65 | 66 | console.debug("glpages hostname request got 404:", c.hostname) 67 | c.hostname = undefined 68 | glFetch = buildGitlabPagesProxy(c) 69 | } 70 | } 71 | if(resp.status === 301 && !glFetch.proxyConfig.hostname){ 72 | // 301s happen when you request .github.io/ and need a custom domain 73 | let location = resp.headers.get("location") 74 | if(location){ 75 | const url = new URL(location) 76 | c.hostname = url.hostname 77 | glFetch = buildGitlabPagesProxy(c) 78 | console.debug("glpages found hostname:", c) 79 | } 80 | } 81 | if(original !== glFetch){ 82 | // underlying proxy function changed, store it and retry 83 | console.debug("glpages got a new fetch fn") 84 | self.proxyConfig = glFetch.proxyConfig 85 | resp = await glFetch(req, init) 86 | } 87 | return resp 88 | } 89 | 90 | let self = Object.assign(fn, { proxyConfig: glFetch.proxyConfig}) 91 | return self 92 | } 93 | 94 | gitlabPages.normalizeOptions = _normalizeOptions; 95 | 96 | function _normalizeOptions(input: unknown): GitLabPagesOptions { 97 | const options: GitLabPagesOptions = { owner: "", repository: "" }; 98 | 99 | if (typeof input === "string" && input.includes("/")) { 100 | [options.owner, options.repository] = input.split("/"); 101 | } else if (isObject(input)) { 102 | merge(options, input, ["owner", "repository", "hostname"]); 103 | } else { 104 | throw errors.invalidInput("options must be a GitLabPagesOptions object or `owner/repo` string"); 105 | } 106 | 107 | if (!options.owner) { 108 | throw errors.invalidProperty("owner", "is required"); 109 | } 110 | if (!options.repository) { 111 | throw errors.invalidProperty("repository", "is required"); 112 | } 113 | 114 | return options; 115 | } 116 | 117 | function buildGitlabPagesProxy(options: GitLabPagesOptions): ProxyFunction { 118 | const {owner, repository, hostname} = options 119 | const glHost = `${owner}.gitlab.io` 120 | const headers = { 121 | host: glHost, 122 | "x-forwarded-host": false 123 | } 124 | let path = `/${repository}/` 125 | 126 | if(hostname){ 127 | path = '/' // no repo path when hostname exists 128 | headers.host = hostname 129 | } 130 | 131 | console.debug("glpages creating proxy:", `https://${glHost}${path}`, { 132 | headers: headers, 133 | stripPath: path 134 | }) 135 | const fn = proxy(`https://${glHost}${path}`, { 136 | headers: headers, 137 | stripPath: path 138 | }) 139 | 140 | return Object.assign(fn, { proxyConfig: options } ) 141 | } -------------------------------------------------------------------------------- /src/backends/glitch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { SubdomainOptions, optionNormalizer } from "./subdomain_service"; 7 | 8 | /** 9 | * Glitch configuration. 10 | */ 11 | export interface GlitchOptions { 12 | /** Glitch application name: .glitch.me */ 13 | appName: string 14 | } 15 | 16 | 17 | const normalizeOptions = optionNormalizer({hostname: false, subdomain: "appName"}) 18 | 19 | /** 20 | * Creates a `fetch` like function for proxying requests to Glitch apps. 21 | * 22 | * Example: 23 | * ```typescript 24 | * import { glitch } from "./src/backends"; 25 | * 26 | * const backend = glitch({ 27 | * appName: "fly-example" 28 | * }) 29 | * ``` 30 | * 31 | * @param options Glitch app information. Accepts `appName` as a string. 32 | */ 33 | export function glitch(options: GlitchOptions | string): ProxyFunction { 34 | const config = normalizeOptions(options); 35 | if(config.hostname){ 36 | delete config.hostname 37 | } 38 | 39 | const glitchHost = `${config.subdomain}.glitch.me` 40 | const uri = `https://${glitchHost}` 41 | const headers = { 42 | "host": glitchHost 43 | } 44 | 45 | const fn = proxy(uri, { headers: headers }) 46 | return Object.assign(fn, { proxyConfig: config}) 47 | } 48 | 49 | glitch.normalizeOptions = normalizeOptions; 50 | -------------------------------------------------------------------------------- /src/backends/heroku.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { SubdomainOptions, optionNormalizer } from "./subdomain_service"; 7 | 8 | /** 9 | * Heroku application configuration. 10 | */ 11 | export interface HerokuOptions { 12 | /** Heroku App name: .herokuapp.com */ 13 | appName: string, 14 | 15 | /** If Heroku is configured with a custom domain name, use it. */ 16 | hostname?: string 17 | } 18 | const normalizeOptions = optionNormalizer({subdomain: "appName"}) 19 | 20 | /** 21 | * Creates a `fetch` like function for proxying requests to a Heroku app. 22 | * Example: 23 | * ```typescript 24 | * import { heroku } from "./src/backends"; 25 | * const backend = heroku({ 26 | * appName: "example" 27 | * }); 28 | * ``` 29 | * @param config Heroku app information. Accepts appName as a string. 30 | */ 31 | export function heroku(options: HerokuOptions | string): ProxyFunction{ 32 | const config = normalizeOptions(options); 33 | 34 | const herokuHost = `${config.subdomain}.herokuapp.com`; 35 | const uri = `https://${herokuHost}`; 36 | const headers = { 37 | "host": herokuHost, 38 | "x-forwarded-host": config.hostname 39 | }; 40 | 41 | const fn = proxy(uri, { headers }); 42 | return Object.assign(fn, { proxyConfig: config }); 43 | } 44 | 45 | heroku.normalizeOptions = normalizeOptions; 46 | -------------------------------------------------------------------------------- /src/backends/netlify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { normalizeOptions, SubdomainOptions } from "./subdomain_service"; 7 | 8 | /** 9 | * Creates a `fetch` like function for proxying requests to hosted Netlify sites. 10 | * 11 | * Example: 12 | * ```typescript 13 | * import { netlify } from "./src/backends"; 14 | * const backend = netlify({ 15 | * subdomain: "example" 16 | * }); 17 | * ``` 18 | * @param options Netlify site information. Accepts subdomain as a string. 19 | */ 20 | export function netlify(options: SubdomainOptions | string): ProxyFunction { 21 | const config = normalizeOptions(options); 22 | 23 | const netlifyHost = `${config.subdomain}.netlify.app` 24 | const uri = `https://${netlifyHost}${config.directory}` 25 | const headers = { 26 | "host": netlifyHost, 27 | "x-forwarded-host": config.hostname || false 28 | } 29 | 30 | const fn = proxy(uri, { headers: headers }) 31 | return Object.assign(fn, { proxyConfig: config}) 32 | } 33 | 34 | netlify.normalizeOptions = normalizeOptions; 35 | -------------------------------------------------------------------------------- /src/backends/origin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | import { ProxyFunction, proxy } from "../proxy"; 5 | import { isObject, merge } from "../util"; 6 | import * as errors from "../errors"; 7 | 8 | /** 9 | * Proxy options for generic http/https backends 10 | * @hidden 11 | * See {@link Backends/backend} 12 | */ 13 | export interface OriginOptions { 14 | origin: string | URL, 15 | forwardHostHeader?: boolean, 16 | retries?: number, 17 | headers?: { [name: string]: string | boolean | undefined }, 18 | } 19 | 20 | /** 21 | * Creates a fetch-like proxy function for making requests to http/https origins 22 | * @hidden 23 | */ 24 | export function origin(options: OriginOptions | string | URL): ProxyFunction { 25 | const config = _normalizeOptions(options); 26 | 27 | const fn = proxy(config.origin, { forwardHostHeader: config.forwardHostHeader, headers: config.headers, retries: config.retries }); 28 | 29 | return Object.assign(fn, { proxyConfig: config }); 30 | } 31 | 32 | origin.normalizeOptions = _normalizeOptions; 33 | 34 | function _normalizeOptions(input: unknown): OriginOptions { 35 | const options: OriginOptions = { 36 | origin: "" 37 | }; 38 | 39 | if (typeof input === "string" || input instanceof URL) { 40 | options.origin = input; 41 | } else if (isObject(input)) { 42 | merge(options, input, ["origin", "headers", "retries", "forwardHostHeader"]); 43 | } else { 44 | throw errors.invalidInput("options must be an OriginOptions object or url string"); 45 | } 46 | 47 | errors.assertPresent(options.origin, "origin"); 48 | errors.assertUrl(options.origin, "origin"); 49 | 50 | if(options.forwardHostHeader === undefined){ 51 | options.forwardHostHeader = false; 52 | } 53 | 54 | return options; 55 | } 56 | -------------------------------------------------------------------------------- /src/backends/squarespace.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { SubdomainOptions, normalizeOptions } from "./subdomain_service"; 7 | 8 | /** 9 | * Creates a `fetch` like function for proxying requests to hosted Squarespace. 10 | * 11 | * Example: 12 | * ```typescript 13 | * import { squarespace } from "./src/backends"; 14 | * const backend = squarespace({ 15 | * subdomain: "archmotorcycle", 16 | * directory: "/", 17 | * hostname: "www.archmotorcycle.com" 18 | * }); 19 | * ``` 20 | * @param options SquareSpace information. Accepts subdomain as a string. 21 | */ 22 | export function squarespace(options: SubdomainOptions | string): ProxyFunction { 23 | const config = normalizeOptions(options); 24 | 25 | const squarespaceHost = `${config.subdomain}.squarespace.com` 26 | const uri = `https://${squarespaceHost}${config.directory}` 27 | const headers = { 28 | "host": squarespaceHost, 29 | "x-forwarded-host": config.hostname || false 30 | } 31 | 32 | const fn = proxy(uri, { headers: headers, rewriteLocationHeaders: true }) 33 | return Object.assign(fn, { proxyConfig: config}) 34 | } 35 | 36 | squarespace.normalizeOptions = normalizeOptions; 37 | -------------------------------------------------------------------------------- /src/backends/subdomain_service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | import { isObject, merge } from "../util"; 5 | import * as errors from "../errors"; 6 | 7 | /** 8 | * Settings for backends that use subdomains 9 | */ 10 | export interface SubdomainOptions { 11 | [k: string]: string | undefined 12 | /** Backend's subdomain: .example.com */ 13 | subdomain: string, 14 | /** Subdirectory site is served from (if any) */ 15 | directory?: string, 16 | /** If the Backend expects a specific x-forwarded-host, we need that to proxy properly */ 17 | hostname?: string 18 | } 19 | 20 | /** @hidden */ 21 | export function optionNormalizer(map?: {[k:string]: string | false | undefined}){ 22 | return function normalize(input: unknown): SubdomainOptions { 23 | if(!map) map = {} 24 | const options: SubdomainOptions = { 25 | subdomain: "", 26 | directory: "/" 27 | }; 28 | 29 | if (typeof input === "string") { 30 | options.subdomain = input.trim(); 31 | } else if (isObject(input)) { 32 | for(const k of ["subdomain", "directory", "hostname"]){ 33 | let alias = map[k] 34 | if(!alias && alias !== false){ 35 | alias = k 36 | } 37 | if(alias !== false && input[alias]){ 38 | const value = input[alias] 39 | options[k] = typeof value === "string" ? value.trim() : value; 40 | } 41 | } 42 | } else { 43 | throw errors.invalidInput("options must be a SubdomainOptions object or string"); 44 | } 45 | 46 | if (!options.subdomain) { 47 | throw errors.invalidProperty(map["subdomain"] || "subdomain", "is required"); 48 | } 49 | 50 | return options; 51 | } 52 | } 53 | 54 | /** @hidden */ 55 | export const normalizeOptions = optionNormalizer(); -------------------------------------------------------------------------------- /src/backends/surge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { SubdomainOptions, normalizeOptions } from "./subdomain_service"; 7 | 8 | /** 9 | * Creates a `fetch` like function for proxying requests to hosted Surge. 10 | * 11 | * Example: 12 | * ```typescript 13 | * import { surge } from "./src/backends"; 14 | * const backend = surge({ 15 | * subdomain: "cloistered-swim", 16 | * directory: "/", 17 | * }); 18 | * ``` 19 | * @param options surge information. Accepts subdomain as a string. 20 | */ 21 | export function surge(options: SubdomainOptions | string): ProxyFunction { 22 | const config = normalizeOptions(options); 23 | 24 | const surgeHost = `${config.subdomain}.surge.sh` 25 | const uri = `https://${surgeHost}${config.directory}` 26 | const headers = { 27 | "host": surgeHost, 28 | "x-forwarded-host": config.hostname || false 29 | } 30 | 31 | const fn = proxy(uri, { headers: headers, rewriteLocationHeaders: true }) 32 | return Object.assign(fn, { proxyConfig: config}) 33 | } 34 | 35 | surge.normalizeOptions = normalizeOptions; 36 | -------------------------------------------------------------------------------- /src/backends/zeit-now.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Backends 3 | */ 4 | 5 | import { proxy, ProxyFunction } from "../proxy"; 6 | import { SubdomainOptions, normalizeOptions } from "./subdomain_service"; 7 | 8 | /** 9 | * Creates a `fetch` like function for proxying requests to hosted Zeit Now. 10 | * 11 | * Example: 12 | * ```typescript 13 | * import { zeitNow } from "./src/backends"; 14 | * const backend = zeitNow({ 15 | * subdomain: "nextjs-news-v2", 16 | * directory: "/", 17 | * hostname: "https://nextjs-news-v2.now.sh/" 18 | * }); 19 | * ``` 20 | * @param options zeitNow information. Accepts subdomain as a string. 21 | */ 22 | export function zeitNow(options: SubdomainOptions | string): ProxyFunction { 23 | const config = normalizeOptions(options); 24 | 25 | const zeitNowHost = `${config.subdomain}.now.sh` 26 | const uri = `https://${zeitNowHost}${config.directory}` 27 | const headers = { 28 | "host": zeitNowHost, 29 | "x-forwarded-host": config.hostname || false 30 | } 31 | 32 | const fn = proxy(uri, { headers: headers, rewriteLocationHeaders: true }) 33 | return Object.assign(fn, { proxyConfig: config}) 34 | } 35 | 36 | zeitNow.normalizeOptions = normalizeOptions; 37 | -------------------------------------------------------------------------------- /src/balancer.ts: -------------------------------------------------------------------------------- 1 | import { FetchFunction } from "./fetch"; 2 | 3 | /** 4 | * A fetch function load balancer. Distributes requests to a set of backends; attempts to 5 | * send requests to most recently healthy backends using a 2 random (pick two healthiest, 6 | * randomize which gets requests). 7 | * 8 | * If all backends are healthy, tries to evenly distribute requests as much as possible. 9 | * 10 | * When backends return server errors (500-599) it retries idempotent requests 11 | * until it gets a good response, or all backends have been tried. 12 | * 13 | * @param backends fetch functions for each backend to balance accross 14 | * @returns a function that behaves just like fetch, with a `.backends` property for 15 | * retrieving backend stats. 16 | */ 17 | export default function balancer(backends: FetchFunction[]) { 18 | let tracked = syncBackends([], backends) 19 | 20 | const fn = async function fetchBalancer(req: RequestInfo, init?: RequestInit | undefined): Promise { 21 | if (typeof req === "string") { 22 | req = new Request(req) 23 | } 24 | const url = new URL(req.url) 25 | let trackLatency = url.pathname === "/" // this would be configurable in real life 26 | const attempted = new Set() 27 | while (attempted.size < tracked.length) { 28 | let backend: Backend | null = null 29 | const [backendA, backendB] = chooseBackends(tracked, attempted) 30 | 31 | if (!backendA) { 32 | return new Response("No backend available", { status: 502 }) 33 | } 34 | if (!backendB) { 35 | backend = backendA 36 | } else { 37 | // randomize between 2 good candidates 38 | backend = (Math.floor(Math.random() * 2) == 0) ? backendA : backendB 39 | } 40 | 41 | const promise = backend.proxy(req, init) 42 | if (backend.scoredRequestCount != backend.requestCount) { 43 | // fixup score 44 | // this should be relatively concurrent with the fetch promise 45 | scoreHealth(backend) 46 | } 47 | backend.requestCount += 1 48 | attempted.add(backend) 49 | 50 | const start = Date.now() 51 | let resp: Response 52 | try { 53 | resp = await promise 54 | } catch (e) { 55 | resp = proxyError 56 | trackLatency = false 57 | } 58 | setFixedArrayValue(backend.statuses, resp.status, 10, backend.requestCount) 59 | 60 | if(trackLatency){ 61 | const ms = Date.now() - start 62 | setFixedArrayValue(backend.latencies, ms, 10, backend.requestCount) 63 | scoreLatency(backend) 64 | } 65 | 66 | // save backend stats every 3s 67 | /*if(!backend.lastSaved || (Date.now() - backend.lastSaved) > 3000){ 68 | 69 | }*/ 70 | 71 | if (resp.status >= 500 && resp.status < 600) { 72 | backend.lastError = Date.now() 73 | // always recompute score on errors 74 | scoreHealth(backend) 75 | 76 | // clear out response to trigger retry 77 | if (canRetry(req, resp)) { 78 | continue 79 | } 80 | } 81 | 82 | return resp 83 | } 84 | 85 | return proxyError 86 | } 87 | 88 | const balancer = Object.assign(fn, { 89 | backends: tracked, 90 | updateBackends: (backends: FetchFunction[]) => balancer.backends = tracked = syncBackends(tracked, backends) 91 | }) 92 | 93 | return balancer; 94 | } 95 | const proxyError = new Response("couldn't connect to origin", { status: 502 }) 96 | 97 | export function syncBackends(current: Backend[], replacements: FetchFunction[]){ 98 | const idx = new Map() 99 | for(const b of current){ 100 | idx.set(b.proxy, b) 101 | } 102 | 103 | const updated: Backend[] = [] 104 | 105 | for(const fn of replacements){ 106 | if (typeof fn !== "function") { 107 | throw Error("Backend must be a fetch like function") 108 | } 109 | const b = idx.get(fn) || { 110 | proxy: fn, 111 | requestCount: 0, 112 | scoredRequestCount: 0, 113 | statuses: Array(10), 114 | latencies: Array(10), 115 | lastError: 0, 116 | healthScore: 1, 117 | latencyScore: 1, 118 | errorCount: 0 119 | } 120 | 121 | updated.push(b) 122 | } 123 | return updated; 124 | } 125 | 126 | /** 127 | * Represents a backend with health and statistics. 128 | */ 129 | export interface Backend { 130 | proxy: (req: RequestInfo, init?: RequestInit | undefined) => Promise, 131 | requestCount: 0, 132 | scoredRequestCount: 0, 133 | statuses: number[], 134 | latencies: number[], 135 | lastError: number, 136 | healthScore: number, 137 | latencyScore: number, 138 | errorCount: 0, 139 | lastSaved?: number 140 | } 141 | // compute a backend health score with time + status codes 142 | function scoreHealth(backend: Backend, errorBasis?: number) { 143 | if (typeof errorBasis !== "number" && !errorBasis) errorBasis = Date.now() 144 | 145 | const timeSinceError = (errorBasis - backend.lastError) 146 | const statuses = backend.statuses 147 | const timeWeight = (backend.lastError === 0 && 0) || 148 | ((timeSinceError < 1000) && 1) || 149 | ((timeSinceError < 3000) && 0.8) || 150 | ((timeSinceError < 5000) && 0.3) || 151 | ((timeSinceError < 10000) && 0.1) || 152 | 0; 153 | if (statuses.length == 0) return 0 154 | let requests = 0 155 | let errors = 0 156 | for (let i = 0; i < statuses.length; i++) { 157 | const status = statuses[i] 158 | if (status && !isNaN(status)) { 159 | requests += 1 160 | if (status >= 500 && status < 600) { 161 | errors += 1 162 | } 163 | } 164 | } 165 | const healthScore = (1 - (timeWeight * (errors / requests))) 166 | backend.healthScore = healthScore 167 | backend.scoredRequestCount = backend.requestCount 168 | return healthScore 169 | } 170 | function scoreLatency(backend: Backend){ 171 | let total = 0 172 | for(const l of backend.latencies){ 173 | total += l 174 | } 175 | const avgLatency = total / backend.latencies.length 176 | 177 | backend.latencyScore = orderOfMagnitude(avgLatency) 178 | return backend.latencyScore 179 | } 180 | function canRetry(req: Request, resp: Response) { 181 | if (resp && resp.status < 500) return false // don't retry normal boring errors or success 182 | if (req.method == "GET" || req.method == "HEAD") return true 183 | return false 184 | } 185 | 186 | function chooseBackends(backends: Backend[], attempted?: Set) { 187 | let b1: Backend | undefined 188 | let b2: Backend | undefined 189 | for (let i = 0; i < backends.length; i++) { 190 | const b = backends[i] 191 | if (attempted && attempted.has(b)) continue; 192 | 193 | if (!b1) { 194 | b1 = b 195 | continue 196 | } 197 | if (!b2) { 198 | b2 = b 199 | continue 200 | } 201 | 202 | const old1 = b1 203 | b1 = bestBackend(b, b1) 204 | 205 | if (old1 != b1) { 206 | // b1 got replaced, make sure it's not better 207 | b2 = bestBackend(old1, b2) 208 | } else { 209 | b2 = bestBackend(b, b2) 210 | } 211 | } 212 | 213 | // if two best backends have different latency, use only the fastest one 214 | if(b1 && b2 && b1.latencyScore < b2.latencyScore) return [b1] 215 | if(b1 && b2 && b2.latencyScore < b1.latencyScore) return [b2] 216 | 217 | return [b1, b2] 218 | } 219 | 220 | function bestBackend(b1: Backend, b2: Backend) { 221 | // simple health check before we compare latency 222 | if(b1.healthScore < 0.85 && b2.healthScore > 0.85){ 223 | return b2 224 | } 225 | if(b2.healthScore < 0.85 && b1.healthScore > 0.85){ 226 | return b1 227 | } 228 | if ( 229 | b1.latencyScore < b2.latencyScore || 230 | (b1.latencyScore == b2.latencyScore && b1.requestCount < b2.requestCount) 231 | ) { 232 | return b1 233 | } 234 | return b2 235 | } 236 | 237 | function setFixedArrayValue(arr: T[], value: T, maxLength: number, totalCount: number){ 238 | if(arr.length < maxLength){ 239 | arr.push(value) 240 | }else{ 241 | arr[(totalCount- 1) % arr.length] = value 242 | } 243 | } 244 | 245 | function orderOfMagnitude(value: number){ 246 | //https://stackoverflow.com/questions/23917074/javascript-flooring-number-to-order-of-magnitude 247 | const order = Math.floor(Math.log(value) / Math.LN10) 248 | return Math.pow(10, order) 249 | } 250 | 251 | /** @private */ 252 | export const _internal = { 253 | chooseBackends, 254 | scoreHealth, 255 | scoreLatency 256 | } -------------------------------------------------------------------------------- /src/config/README.md: -------------------------------------------------------------------------------- 1 | # Fly Edge (Standalone app) 2 | 3 | The Fly Edge can be run standalone with a yaml based configuration schema. 4 | 5 | By default, this app looks for configuration information in `.fly.yml` and uses that to serve requests. This configuration file redirects `http` requests to `https`, and proxies all requests to a single backend (getting-started.edgeapp.net). 6 | 7 | ```yaml 8 | app: edge-app 9 | config: 10 | flyApp: 11 | backends: 12 | getting-started: 13 | type: origin 14 | origin: https://getting-started.edgeapp.net 15 | headers: 16 | host: getting-started.edgeapp.net 17 | rules: 18 | - actionType: rewrite 19 | backendKey: getting-started 20 | middleware: 21 | - type: https-upgrader 22 | ``` 23 | 24 | This config is the equivalent of writing TypeScript that consumes the `@fly/edge` as library: 25 | 26 | ```typescript 27 | 28 | import { middleware } from "@fly/edge"; 29 | import proxy from "@fly/edge/proxy"; 30 | 31 | const origin = proxy("https://getting-started.edgeapp.net") 32 | 33 | fly.http.respondWith( 34 | proxy.httpsUpgrader( 35 | origin 36 | ) 37 | ); 38 | ``` -------------------------------------------------------------------------------- /src/config/backends.ts: -------------------------------------------------------------------------------- 1 | /** @module Config */ 2 | import * as backends from "../backends"; 3 | import { ProxyFunction, ProxyFactory } from "../proxy"; 4 | import { ItemConfig } from "./index"; 5 | 6 | export type BackendMap = Map; 7 | 8 | // type FactoryDefinition = [ProxyFactory, (options: any) => boolean]; 9 | 10 | const factories = new Map([ 11 | ["origin", backends.origin], 12 | ["github_pages", backends.githubPages], 13 | ["heroku", backends.heroku], 14 | ["ghost_pro", backends.ghostProBlog], 15 | ["glitch", backends.glitch], 16 | ["squarespace", backends.squarespace], 17 | ["aws_s3", backends.awsS3], 18 | ["surge", backends.surge], 19 | ["zeit-now", backends.zeitNow], 20 | ["aerobatic", backends.aerobatic], 21 | ["gitlab_pages", backends.gitlabPages], 22 | ["firebase", backends.firebase], 23 | ]); 24 | 25 | function getFactory(type: string): ProxyFactory { 26 | const def = factories.get(type); 27 | if (!def) { 28 | throw new Error(`Unknown backend type '${type}'`); 29 | } 30 | return def; 31 | } 32 | 33 | export function buildBackend(config: ItemConfig): ProxyFunction { 34 | try{ 35 | const factory = getFactory(config.type); 36 | return factory(config); 37 | }catch(err){ 38 | console.error("Exception building backend:", err, config) 39 | const backendError = async (..._: any[]) => new Response(err.toString(), { status: 500 } ) 40 | return Object.assign(backendError, { proxyConfig: config} ) 41 | } 42 | } 43 | 44 | export function validateBackend(config: ItemConfig) { 45 | const factory = getFactory(config.type); 46 | if (factory.normalizeOptions) { 47 | factory.normalizeOptions(config); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | /** @module Config */ 2 | import { validateBackend, buildBackend } from "./backends"; 3 | import { isObject } from "../util"; 4 | import { FetchFunction } from "../fetch"; 5 | import { ProxyFunction } from "../proxy"; 6 | import { RuleInfo, validateRule, buildRules } from "./rules"; 7 | import { buildMiddleware, validateMiddleware } from "./middleware"; 8 | import { stringify } from "querystring"; 9 | 10 | export const AppConfigKey = "flyEdge"; 11 | 12 | export interface CdnConfig { 13 | backends: { [key: string]: ItemConfig }, 14 | middleware: ItemConfig[], 15 | rules: RuleInfo[] 16 | } 17 | 18 | export interface ItemConfig { 19 | type: string; 20 | [prop: string]: unknown; 21 | } 22 | 23 | export type BackendProxies = Map; 24 | 25 | export function isItemConfig(input: unknown): input is ItemConfig { 26 | if (!isObject(input)) { 27 | throw new Error("must be an object"); 28 | } 29 | 30 | if (typeof input.type !== "string" || input.type.length == 0) { 31 | throw new Error("must have a type property specifying a backend"); 32 | } 33 | 34 | return true 35 | } 36 | 37 | export function isCdnConfig(input: unknown): input is CdnConfig { 38 | if (!isObject(input)) { 39 | throw new Error("config must be an object") 40 | } 41 | 42 | const { backendConfigs = { }, ruleConfigs = [], middlewareConfigs = [] } = input; 43 | 44 | if (!isObject(backendConfigs)) { 45 | throw new Error("backends property must be a map of keys -> Backend definition"); 46 | } 47 | 48 | for (const [key, cfg] of Object.entries(backendConfigs)) { 49 | try { 50 | if (isItemConfig(cfg)) { 51 | validateBackend(cfg); 52 | } 53 | } catch (error) { 54 | throw new Error(`backend config for ${key} is invalid: ${error.message || error}`) 55 | } 56 | } 57 | 58 | if (!(ruleConfigs instanceof Array)) { 59 | throw new Error("rules property must be an array of Rule definitions"); 60 | } 61 | 62 | for (const [idx, cfg] of ruleConfigs.entries()) { 63 | try { 64 | validateRule(cfg) 65 | } catch (error) { 66 | throw new Error(`rule at index ${idx} is invalid: ${error.message || error}`) 67 | } 68 | } 69 | 70 | if (!(middlewareConfigs instanceof Array)) { 71 | throw new Error("middleware property must be an array of Middleware definitions"); 72 | } 73 | 74 | for (const [idx, cfg] of middlewareConfigs.entries()) { 75 | try { 76 | if (isItemConfig) { 77 | validateMiddleware(cfg); 78 | } 79 | } catch (error) { 80 | throw new Error(`middleware at index ${idx} is invalid: ${error.message || error}`) 81 | } 82 | } 83 | 84 | return true; 85 | } 86 | 87 | export function buildCdn(config: CdnConfig) { 88 | const backends = new Map(); 89 | 90 | for (const [key, cfg] of Object.entries(config.backends)) { 91 | const b = buildBackend(cfg); 92 | backends.set(key, b); 93 | } 94 | console.log("Built backends:", Object.getOwnPropertyNames(config.backends)); 95 | 96 | let fn = buildRules(backends, config.rules) 97 | 98 | const middleware = config.middleware; 99 | if (middleware && middleware.length > 0) { 100 | for (let i = middleware.length - 1; i >= 0; i--) { 101 | fn = buildMiddleware(fn, middleware[i]); 102 | } 103 | } 104 | 105 | return Object.assign(fn, { 106 | backends: backends 107 | }) 108 | } 109 | 110 | export function buildAppFromConfig(c?: any) { 111 | try { 112 | const config = c || app.config[AppConfigKey] || app.config['flyCDN']; // support flyCDN config key for a while 113 | if (!config) { 114 | throw new Error("flyApp config property not found"); 115 | } 116 | if (!isCdnConfig(config)) { 117 | // This is unreachable because isCdnConfig throws but typescript can't infer that so we throw too 118 | throw new Error("App config not supported"); 119 | } 120 | return buildCdn(config) 121 | } catch (error) { 122 | const fn = (...args: any[]) => { 123 | return Promise.resolve(new Response(`Invalid App Config: ${error.message || error}`)); 124 | } 125 | return Object.assign(fn, { backends: new Map()}) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/config/middleware.ts: -------------------------------------------------------------------------------- 1 | /** @module Config */ 2 | import { FetchFactory, FetchFunction } from "../fetch"; 3 | import * as middleware from "../middleware"; 4 | import { ItemConfig } from "./index"; 5 | 6 | type FactoryDefinition = [FetchFactory, ((options: any) => boolean)?]; 7 | 8 | const factories = new Map([ 9 | ["https-upgrader", [middleware.httpsUpgrader]], 10 | ["response-headers", [middleware.responseHeaders]], 11 | ["inject-html", [middleware.injectHTML]], 12 | ["http-cache", [middleware.httpCache]], 13 | ["auto-webp", [middleware.autoWebp]] 14 | ]); 15 | 16 | function getFactory(type: string): FactoryDefinition { 17 | const def = factories.get(type); 18 | if (!def) { 19 | throw new Error(`Unknown middleware type '${type}'`); 20 | } 21 | return def; 22 | } 23 | 24 | export function buildMiddleware(fetch: FetchFunction, config: ItemConfig): FetchFunction { 25 | const [factory, validator] = getFactory(config.type); 26 | return factory(fetch, config); 27 | } 28 | 29 | export function validateMiddleware(config: ItemConfig) { 30 | const [factory, validator] = getFactory(config.type); 31 | if (validator) { 32 | validator(config); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/config/rules.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Config 3 | * @ignore 4 | * */ 5 | import { applyReplacements } from "../text-replacements"; 6 | import { BackendProxies } from "./index"; 7 | 8 | export interface RuleInfo { 9 | actionType: "redirect" | "rewrite", 10 | backendKey?: string, 11 | matchScheme?: string, 12 | hostname?: string, 13 | pathMatchMode?: "prefix" | "full", 14 | httpHeaderKey?: string, 15 | httpHeaderValue?: RegExp | string, 16 | pathPattern?: RegExp | string, 17 | pathReplacementPattern?: string, 18 | redirectURLPattern?: string, 19 | redirectStatus?: number, 20 | responseReplacements?: [string, string][], 21 | } 22 | 23 | declare var app: any 24 | 25 | export function validateRule(r: any): r is RuleInfo { 26 | if (typeof r !== "object") { 27 | throw new Error("must be an object") 28 | } 29 | if (!r.actionType) { 30 | throw new Error("actionType must be defined") 31 | } 32 | if (r.actionType !== "redirect" && r.actionType !== "rewrite") { 33 | throw new Error("actionType must be either `redirect` or `rewrite`") 34 | } 35 | if (r.actionType === "rewrite" && !r.backendKey) { 36 | throw new Error("must inclue `backendKey` when actionType is set to `rewrite`") 37 | } 38 | return true 39 | } 40 | 41 | export function buildRules(backends: BackendProxies, rules: RuleInfo[]) { 42 | const compiled = rules.map(compileRule) 43 | return async function ruleFetch(req: RequestInfo, init?: RequestInit) { 44 | if (typeof req === "string") { 45 | req = new Request(req, init) 46 | } 47 | const match = compiled.find((r) => r(req)) 48 | if (!match) { 49 | return new Response("no routing rule found", { status: 404 }) 50 | } 51 | const rule = match.rule 52 | // do the redirect 53 | if (rule.actionType === "redirect") { 54 | let original = new URL(req.url) 55 | let url: string | undefined = undefined 56 | if (match.pathPattern && rule.redirectURLPattern) { 57 | url = match.pathPattern.replace(original.pathname, rule.redirectURLPattern) 58 | } else if (rule.redirectURLPattern) { 59 | url = rule.redirectURLPattern 60 | } 61 | if (!url || original.toString() === url) { 62 | return new Response("Can't redirect to a bad URL", { status: 500 }) 63 | } 64 | const status = rule.redirectStatus || 302 65 | const redirectTo = new URL(url, original) 66 | return new Response("Redirect", { status: status, headers: { location: redirectTo.toString() } }) 67 | } 68 | if (rule.actionType !== "rewrite") { 69 | return new Response("Invalid rule action", { status: 500 }) 70 | } 71 | const backend = rule.backendKey && backends ? backends.get(rule.backendKey) : undefined 72 | if (!backend) { 73 | return new Response("No backend for rule", { status: 502 }) 74 | } 75 | // rewrite request if necessary 76 | if (match.pathPattern && rule.pathReplacementPattern) { 77 | let url = new URL(req.url) 78 | url = new URL(match.pathPattern.replace(url.pathname, rule.pathReplacementPattern), url) 79 | req = new Request(url.toString(), req) 80 | } 81 | if (!rule.responseReplacements || rule.responseReplacements.length === 0) { 82 | return await backend(req, init) 83 | } 84 | 85 | req.headers.delete("accept-encoding") 86 | let resp = await backend(req, init) 87 | return applyReplacements(resp, rule.responseReplacements) 88 | } 89 | } 90 | 91 | function compileRule(rule: RuleInfo) { 92 | const pathPattern = ensurePathPatternMatcher(rule.pathPattern) 93 | const httpHeaderValue = ensureRegExp(rule.httpHeaderValue) 94 | const fn = function compiledRule(req: Request) { 95 | const url = new URL(req.url) 96 | if (rule.matchScheme === "http" || rule.matchScheme === "https") { 97 | const scheme = url.protocol.slice(0, -1) 98 | if (scheme !== rule.matchScheme) return false 99 | } 100 | if (rule.hostname && rule.hostname !== "") { 101 | if (url.hostname !== rule.hostname) { 102 | return false 103 | } 104 | } 105 | if (rule.httpHeaderKey && rule.httpHeaderKey !== "" && httpHeaderValue) { 106 | const header = req.headers.get(rule.httpHeaderKey) 107 | if (!header || !header.match(httpHeaderValue)) { 108 | return false 109 | } 110 | } 111 | if (pathPattern && !pathPattern.match(url.pathname)) { 112 | return false 113 | } 114 | return true 115 | } 116 | return Object.assign(fn, { rule: rule, pathPattern: pathPattern }) 117 | } 118 | 119 | function ensureRegExp(pattern?: string | RegExp): RegExp | null { 120 | if (!pattern || pattern == "") return null 121 | if (typeof pattern === "string") return new RegExp(pattern) 122 | if (pattern instanceof RegExp) return pattern 123 | 124 | throw new Error("Pattern must be a string or RegExp: " + typeof pattern) 125 | } 126 | 127 | function ensurePathPatternMatcher(pattern?: string | RegExp): PathPatternMatcher | null { 128 | if (!pattern || pattern == "") return null 129 | if (typeof pattern === "string") return new PathPatternMatcher(pattern) 130 | if (pattern instanceof RegExp) return new PathPatternMatcher(pattern) 131 | 132 | throw new Error("Pattern must be a string or RegExp: " + typeof pattern) 133 | } 134 | 135 | 136 | export class PathPatternMatcher { 137 | private regex: RegExp 138 | private params: string[] = [] 139 | 140 | constructor(pattern: string | RegExp) { 141 | if (pattern instanceof RegExp) { 142 | this.regex = pattern 143 | } else { 144 | const matches = pattern.match(/([:\*]\w+)/g) 145 | if (matches) { 146 | for (const match of matches) { 147 | this.params.push(match.substring(1)) 148 | if (match.startsWith(":")) { 149 | pattern = pattern.replace(match, "([^/.]+)") 150 | } else if (match.startsWith("*")) { 151 | pattern = pattern.replace(match, "(.+)") 152 | } 153 | } 154 | } 155 | this.regex = new RegExp(pattern) 156 | } 157 | } 158 | 159 | match(path: string) { 160 | return this.regex.test(path) 161 | } 162 | 163 | parse(path: string) { 164 | const params: { [name: string]: string } = {} 165 | 166 | const match = this.regex.exec(path) 167 | if (!match) { 168 | return params 169 | } 170 | for (const [paramIndex, paramName] of this.params.entries()) { 171 | params[paramName] = match[paramIndex + 1]; 172 | } 173 | return params 174 | } 175 | 176 | replace(path: string, replacement: string) { 177 | const params = this.parse(path) 178 | for (const [name, value] of Object.entries(params)) { 179 | replacement = replacement.replace(`$${name}`, value) 180 | } 181 | return replacement 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | import db, { Collection } from "@fly/v8env/lib/fly/data"; 2 | import cache from "@fly/v8env/lib/fly/cache"; 3 | import { FetchFunction } from "./fetch"; 4 | 5 | export interface RestOptions{ 6 | authToken: string, 7 | basePath?: string, 8 | cache?: CacheOptions 9 | } 10 | 11 | export interface CacheOptions{ 12 | ttl?: number, 13 | toCacheKey?: (collection: string, key: string) => string 14 | } 15 | 16 | const apiPathPattern = /^\/([a-zA-Z0-9-_]+)(\/(.+))?$/ 17 | /** 18 | * Creates a REST API for updating the Fly k/v data store. 19 | * 20 | * ```typescript 21 | * import { data } from "@fly/edge" 22 | * const api = restAPI({authToken: "aSeCUrToken", basePath: "/__data/"}); 23 | * fly.http.respondWith(req => { 24 | * const url = new URL(req.url); 25 | * if(url.pathname.startsWith("/__data/")){ 26 | * return api(req); 27 | * } 28 | * return new Response('not found', { status: 404}); 29 | * }) 30 | * ``` 31 | * 32 | * @param tokenOrOptions 33 | */ 34 | export function restAPI(tokenOrOptions: string | RestOptions): FetchFunction{ 35 | const options = typeof tokenOrOptions === "string" ? {authToken: tokenOrOptions} : tokenOrOptions; 36 | const { authToken, basePath } = options; 37 | return async function fetchRest(req, init){ 38 | if(typeof req === "string"){ 39 | req = new Request(req, init); 40 | init = undefined; 41 | } 42 | const auth = (req.headers.get("Authorization") || "").split("Bearer ", 2); 43 | if(auth.length < 2 || auth[1] !== authToken){ 44 | return new Response("Access denied", { status: 403}); 45 | } 46 | 47 | const url = new URL(req.url); 48 | let path = url.pathname; 49 | if(basePath && path.startsWith(basePath) && path.length > basePath.length){ 50 | path = path.substr(basePath.length); 51 | } 52 | if(!path.startsWith("/")){ 53 | path = `/${path}`; 54 | } 55 | 56 | const match = path.match(apiPathPattern); 57 | 58 | if(!match){ 59 | return jsonResponse({error: "not found"}, { status: 404}) 60 | } 61 | 62 | const colName: string = match[1]; 63 | let key: string | undefined = match[3]; 64 | if(!key){ 65 | return jsonResponse({error: "not found"}, { status: 404 }) 66 | } 67 | 68 | const collection = cachedCollection(colName, options.cache); 69 | 70 | let data: any; 71 | switch(req.method){ 72 | case "GET": 73 | data = await collection.get(key); 74 | if(data === null){ 75 | return jsonResponse({error: "not found"}, { status: 404 }) 76 | } 77 | return jsonResponse(data, { status: 200}) 78 | case "PUT": 79 | data = await req.json(); 80 | await collection.put(key, data); 81 | return jsonResponse(data, { status: 201}) 82 | case "DELETE": 83 | await collection.del(key); 84 | return jsonResponse({ok: true}, { status: 204}) 85 | } 86 | 87 | return jsonResponse({error: "not found"}, { status: 404}) 88 | } 89 | } 90 | 91 | /** 92 | * Get a collection with a write through cache. Data retrieved from the collection will 93 | * be cached in the current region. Put/Delete will expire a key globally. 94 | * @param name 95 | * @param opts 96 | */ 97 | export function cachedCollection(name: string, opts?: CacheOptions): Collection{ 98 | return new CachedCollection(name); 99 | } 100 | 101 | export class CachedCollection extends Collection{ 102 | constructor(name: string, public readonly options?: CacheOptions){ 103 | super(name); 104 | this.options = options; 105 | } 106 | public async get(key: string){ 107 | const cacheKey = this.toCacheKey(key); 108 | const value = await cache.getString(key); 109 | 110 | if(value){ 111 | try{ 112 | return JSON.parse(value); 113 | }catch(err){ 114 | console.error("CacheCollection: JSON parse failed. ", err.message) 115 | // fall through on parse fail. 116 | } 117 | } 118 | 119 | const result = await super.get(key); 120 | await cache.set(cacheKey, typeof result !== "string" ? JSON.stringify(result) : result); 121 | return super.get(key); 122 | } 123 | 124 | public async del(key: string){ 125 | const result = await super.del(key) 126 | await expire(this.name, key, this.options); 127 | return result; 128 | } 129 | 130 | public async put(key: string, obj: any){ 131 | const result = await super.put(key, obj); 132 | await expire(this.name, key, this.options); 133 | return result; 134 | } 135 | 136 | public toCacheKey(key: string){ 137 | if(this.options && this.options.toCacheKey){ 138 | return this.options.toCacheKey(this.name, key); 139 | } 140 | return toCacheKey(this.name, key); 141 | } 142 | } 143 | 144 | function jsonResponse(data: any, init?: ResponseInit){ 145 | if(!init) init = {}; 146 | init.headers = new Headers(init.headers); 147 | init.headers.set("content-type", "application/json"); 148 | if(typeof data !== "string"){ 149 | data = JSON.stringify(data); 150 | } 151 | return new Response(data, init) 152 | } 153 | 154 | function toCacheKey(colName: string, key: string){ 155 | return `db.${colName}(${key})`; 156 | } 157 | 158 | async function expire(collection: string, key: string, opts?: CacheOptions){ 159 | let cacheKey: string; 160 | if(opts && opts.toCacheKey){ 161 | cacheKey = opts.toCacheKey(collection, key); 162 | }else{ 163 | cacheKey = toCacheKey(collection, key); 164 | } 165 | 166 | return cache.global.del(cacheKey); 167 | } -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** @module HTTP */ 2 | /** @ignore */ 3 | export class ValidationError extends Error { 4 | public readonly field: string; 5 | constructor(field: string, message: string) { 6 | super(`${field} ${message}`); 7 | this.field = field; 8 | } 9 | } 10 | 11 | /** @ignore */ 12 | export class InputError extends Error { } 13 | 14 | /** @ignore */ 15 | export function invalidInput(message: string): Error { 16 | return new InputError(message); 17 | } 18 | 19 | /** @ignore */ 20 | export function invalidProperty(prop: string, message: string): Error { 21 | return new ValidationError(prop, message); 22 | } 23 | 24 | /** @ignore */ 25 | export function assertPresent(value: unknown, propertyName: string) { 26 | if (!value) { 27 | throw new ValidationError(propertyName, "is required"); 28 | } 29 | } 30 | 31 | /** @ignore */ 32 | export function assertUrl(value: string | URL, propertyName: string) { 33 | if (value instanceof URL) { 34 | return; 35 | } 36 | 37 | if (typeof value === "string") { 38 | try { 39 | //console.log("assertUrl", {value}) 40 | const x = new URL(value); 41 | //console.log("after url assertUrl", { value, x }) 42 | return; 43 | } catch (err) { } 44 | } 45 | 46 | throw new ValidationError(propertyName, "must be a valid url"); 47 | } 48 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP helpers, utilities, etc. 3 | * @module HTTP 4 | */ 5 | import { FlyRequest } from "@fly/v8env/lib/fly/fetch"; 6 | 7 | /** 8 | * Converts RequestInfo into a Request object. 9 | * @param req raw request 10 | */ 11 | export function normalizeRequest(req: RequestInfo, init?: RequestInit) { 12 | if (typeof req === "string") { 13 | req = new Request(req, init) 14 | } 15 | if (!(req instanceof Request)) { 16 | throw new Error("req must be either a string or a Request object") 17 | } 18 | return req as FlyRequest 19 | } 20 | 21 | /** 22 | * A `fetch` like function. These functions accept HTTP 23 | * requests, do some magic, and return HTTP responses. 24 | */ 25 | export interface FetchFunction { 26 | /** 27 | * @param req URL or request object 28 | * @param init Options for request 29 | */ 30 | (req: RequestInfo, init?: RequestInit): Promise 31 | } 32 | 33 | /** 34 | * A function that generates a fetch-like function with additional logic 35 | */ 36 | export type FetchGenerator = (fetch: FetchFunction, ...args: any[]) => FetchFunction 37 | export type FetchGeneratorWithOptions = (fetch: FetchFunction, options?: T) => FetchFunction 38 | 39 | /** 40 | * Options for redirects 41 | */ 42 | export interface RedirectOptions { 43 | /** The HTTP status code to send (defaults to 302) */ 44 | status?: number, 45 | 46 | /** Text to send as response body. Defaults to "". */ 47 | text?: string 48 | } 49 | 50 | export interface FetchFactory { 51 | (fetch: FetchFunction, options?: any): FetchFunction; 52 | } 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | /** @module HTTP */ 3 | export * from "./fetch"; 4 | export * from "./proxy"; 5 | export * from "./pipeline"; 6 | 7 | import * as middleware from "./middleware" 8 | export { middleware } 9 | import * as backends from "./backends"; 10 | import { FetchFunction } from "./fetch"; 11 | export { backends }; 12 | export { isCdnConfig, buildCdn, buildAppFromConfig } from "./config"; 13 | import * as data from "./data"; 14 | export { data }; 15 | 16 | declare global { 17 | const fly: { 18 | http: { 19 | respondWith: (fn: (req: Request) => Promise) => void 20 | } 21 | } 22 | const app: { 23 | env: "production" | "development" | "test", 24 | region: string, 25 | config: any 26 | } 27 | 28 | export interface RequestInit{ 29 | timeout?: number 30 | readTimeout?: number 31 | certificate?: { 32 | key?: string | Buffer | Array 33 | cert?: string | Buffer | Array 34 | ca?: string | Buffer | Array 35 | pfx?: string | Buffer | Array 36 | passphrase?: string 37 | } 38 | tls?: { 39 | servername?: string 40 | } 41 | } 42 | 43 | export interface Request{ 44 | remoteAddr?: string 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Middleware 3 | */ 4 | export * from "./middleware/https-upgrader"; 5 | export * from "./middleware/response-headers"; 6 | export * from "./middleware/inject-html"; 7 | export * from "./middleware/http-cache"; 8 | export { autoWebp } from "./middleware/auto-webp"; 9 | -------------------------------------------------------------------------------- /src/middleware/auto-webp.ts: -------------------------------------------------------------------------------- 1 | /** @module Middleware */ 2 | import { Image } from "@fly/v8env/lib/fly/image"; 3 | import responseCache from "@fly/v8env/lib/fly/cache/response"; 4 | import { FetchFunction } from "../fetch"; 5 | 6 | /** 7 | * Automatically encodes images as webp for supported clients. 8 | * This would apply to requests with 'image/webp' in the accept header 9 | * and 'image/jpeg' or 'image/png' in the response type from origin. 10 | * 11 | * Example: 12 | * 13 | * ```typescript 14 | * import { origin } from "./src/backends"; 15 | * import { autoWebp } from "./src/middleware/auto-webp"; 16 | * 17 | * const backend = origin({ 18 | * origin: "https://fly.io/", 19 | * headers: {host: "fly.io"} 20 | * }) 21 | * 22 | * const images = autoWebp(backend); 23 | * 24 | * fly.http.respondWith(images); 25 | * ``` 26 | */ 27 | 28 | export function autoWebp(fetch: FetchFunction): FetchFunction { 29 | return async function imageConversions(req: RequestInfo, init?: RequestInit) { 30 | if (typeof req === "string"){ 31 | req = new Request(req, init); 32 | init = undefined; 33 | } 34 | 35 | // pass through if client doesn't support webp 36 | if(!webpAllowed(req)){ 37 | return fetch(req, init); 38 | } 39 | 40 | // generate a cache key 41 | const key = `webp:${req.url}`; 42 | 43 | // get response from responseCache and serve it (if available) 44 | let resp: Response = await responseCache.get(key) 45 | if(resp){ 46 | resp.headers.set("Fly-Image-Cache", "HIT") 47 | return resp 48 | } 49 | 50 | resp = await fetch(req, init); 51 | 52 | // check if the req is a png or jpeg image 53 | if(!isImage(resp)) { 54 | return resp 55 | } 56 | 57 | if (req.method === "GET"){ 58 | let img = await loadImage(resp) 59 | img = img.webp({ force: true, quality: 60 }); 60 | resp.headers.set("content-type", "image/webp"); 61 | 62 | const body = await img.toBuffer() 63 | resp = new Response(body.data, resp) 64 | resp.headers.set("content-length", body.data.byteLength.toString()) 65 | let etag = resp.headers.get("etag") 66 | if(etag){ 67 | etag = etag.replace(/^("?)([^"]+)("?)$/, "$1$2-webp$3") 68 | resp.headers.set("etag", etag) 69 | } 70 | 71 | // put webp in responseCache for an hour 72 | await responseCache.set(key, resp, { tags: [req.url], ttl: 3600 }) 73 | resp.headers.set("Fly-Image-Cache", "MISS") 74 | } 75 | return resp 76 | } 77 | } 78 | 79 | async function loadImage(resp: Response): Promise { 80 | if (!isImage(resp)) { 81 | throw new Error("Response wasn't an image") 82 | } 83 | const raw = await resp.arrayBuffer() 84 | const img = new Image(raw) 85 | 86 | return img 87 | } 88 | 89 | function isImage(resp: Response): boolean{ 90 | const contentType = resp.headers.get("Content-Type") || "" 91 | if (contentType.includes("image/png") || contentType.includes("image/jpeg")) { 92 | return true 93 | } 94 | return false 95 | } 96 | 97 | export function webpAllowed(req: Request){ 98 | const accept = req.headers.get("accept") || "" 99 | if( 100 | accept.includes("image/webp") 101 | ){ 102 | return true 103 | } 104 | return false 105 | } -------------------------------------------------------------------------------- /src/middleware/builder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Middleware 3 | */ 4 | import { FetchFunction, FetchGeneratorWithOptions, FetchGenerator } from "../fetch"; 5 | 6 | export type ResponseModifierResult = Promise | undefined | void | Response 7 | /** 8 | * A convenience function for building middleware that only operates on a request. 9 | * @param fn 10 | */ 11 | export function requestModifier(fn: (req: Request, options: J) => ResponseModifierResult): FetchGeneratorWithOptions 12 | /** 13 | * A convenience function for building middleware that only operates on a response. 14 | * @param fn 15 | */ 16 | export function requestModifier(fn: (req: Request, ...args: any[]) => ResponseModifierResult): FetchGenerator 17 | { 18 | return function(fetch: FetchFunction, ...args: any[]){ 19 | return async function(req: RequestInfo, init?: RequestInit){ 20 | if(typeof req === "string"){ 21 | req = new Request(req, init) 22 | init = undefined 23 | } 24 | let resp = fn(req, ...args) 25 | if(resp instanceof Promise){ 26 | resp = await resp 27 | } 28 | if(resp){ 29 | return resp 30 | } 31 | return fetch(req, init) 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * A convenience function for building middleware that only operates on a response. 38 | * @param fn 39 | */ 40 | export function responseModifier(fn: (resp: Response, options: J) => Promise | Response): FetchGeneratorWithOptions 41 | /** 42 | * A convenience function for building middleware that only operates on a response. 43 | * @param fn 44 | */ 45 | export function responseModifier(fn: (resp: Response, ...args: any[]) => Promise | Response): FetchGenerator 46 | { 47 | return function(fetch: FetchFunction, ...args: any[]){ 48 | return async function(req: RequestInfo, init?: RequestInit){ 49 | const resp = await fetch(req, init) 50 | if(typeof req === "string"){ 51 | req = new Request(req, init) 52 | init = undefined 53 | } 54 | 55 | return fn(resp, ...args) 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /src/middleware/http-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Middleware 3 | */ 4 | import cache from "@fly/v8env/lib/fly/cache"; 5 | import { FetchFunction } from "../fetch"; 6 | 7 | /** 8 | * HTTP caching options. 9 | */ 10 | export interface HTTPCacheOptions{ 11 | /** Overrides the cache TTL for all cacheable requests */ 12 | overrideMaxAge?: number 13 | } 14 | /** 15 | * Cache HTTP responses with `cache-control` headers. 16 | * 17 | * Basic example: 18 | * ```typescript 19 | * import httpCache from "./src/middleware/http-cache"; 20 | * import backends from "./src/backends"; 21 | * 22 | * const glitch = backends.glitch("fly-example"); 23 | * 24 | * const origin = httpCache(glitch, { overrideMaxAge: 3600 }); 25 | * 26 | * fly.http.respondWith(origin); 27 | * ``` 28 | * 29 | * @param fetch 30 | * @param options 31 | */ 32 | export function httpCache(fetch: FetchFunction, options?: HTTPCacheOptions): FetchFunction{ 33 | return async function httpCacheFetch(req: RequestInfo, init?: RequestInit): Promise{ 34 | if(!options) options = {}; 35 | if(typeof req === "string"){ 36 | req = new Request(req, init); 37 | init = undefined; 38 | } 39 | 40 | // check the cache 41 | let cacheable = true; 42 | for(const h of ["Authorization", "Cookie"]){ 43 | if(req.headers.get(h)){ 44 | console.warn(h + " headers are not supported in http-cache") 45 | cacheable = false; 46 | } 47 | } 48 | let resp = cacheable ? await storage.match(req) : undefined; 49 | 50 | if(resp){ 51 | // got a hit 52 | resp.headers.set("Fly-Cache", "hit"); 53 | return resp; 54 | } 55 | 56 | resp = await fetch(req, init); 57 | 58 | // this should do nothing if the response can't be cached 59 | const cacheHappened = cacheable ? await storage.put(req, resp, options.overrideMaxAge) : false; 60 | 61 | if(cacheHappened){ 62 | resp.headers.set("Fly-Cache", "miss"); 63 | } 64 | return resp; 65 | } 66 | } 67 | 68 | /** 69 | * Configurable HTTP caching middleware. This is extremely useful within a `pipeline`: 70 | * 71 | * ```typescript 72 | * const app = pipeline( 73 | * httpsUpgrader, 74 | * httpCaching.configure({overrideMaxAge: 3600}), 75 | * glitch("fly-example") 76 | * ) 77 | * 78 | */ 79 | httpCache.configure = (options?: HTTPCacheOptions) => { 80 | return (fetch: FetchFunction) => httpCache(fetch, options) 81 | } 82 | 83 | // copied from fly v8env 84 | const CachePolicy = require("http-cache-semantics"); 85 | /** 86 | * export: 87 | * match(req): res | null 88 | * add(req): void 89 | * put(req, res): void 90 | * @private 91 | */ 92 | 93 | const storage = { 94 | async match(req: Request) { 95 | const hashed = hashData(req) 96 | const key = "httpcache:policy:" + hashed // first try with no vary variant 97 | for (let i = 0; i < 5; i++) { 98 | const policyRaw = await cache.getString(key) 99 | console.debug("Got policy:", key, policyRaw) 100 | if (!policyRaw) { 101 | return undefined 102 | } 103 | const policy = CachePolicy.fromObject(JSON.parse(policyRaw)) 104 | 105 | // if it fits i sits 106 | if (policy.satisfiesWithoutRevalidation(req)) { 107 | const headers = policy.responseHeaders() 108 | const bodyKey = "httpcache:body:" + hashed 109 | 110 | const body = await cache.get(bodyKey) 111 | console.debug("Got body", body.constructor.name, body.byteLength) 112 | return new Response(body, { status: policy._status, headers }) 113 | // }else if(policy._headers){ 114 | // TODO: try a new vary based key 115 | // policy._headers has the varies / vary values 116 | // key = hashData(req, policy._headers) 117 | // return undefined 118 | } else { 119 | return undefined 120 | } 121 | } 122 | return undefined // no matches found 123 | }, 124 | async add(req: Request) { 125 | console.debug("cache add") 126 | 127 | const res = await fetch(req) 128 | return await storage.put(req, res) 129 | }, 130 | async put(req: Request, res: Response, ttl?: number): Promise { 131 | const resHeaders: any = {} 132 | const key = hashData(req) 133 | 134 | if(res.headers.get("vary")){ 135 | console.warn("Vary headers are not supported in http-cache") 136 | return false; 137 | } 138 | 139 | for (const [name, value] of (res as any).headers) { 140 | resHeaders[name] = value 141 | } 142 | const cacheableRes = { 143 | status: res.status, 144 | headers: resHeaders 145 | } 146 | const policy = new CachePolicy( 147 | { 148 | url: req.url, 149 | method: req.method, 150 | headers: req.headers || {} 151 | }, 152 | cacheableRes 153 | ) 154 | 155 | if(typeof ttl === "number"){ 156 | policy._rescc['max-age'] = ttl; // hack to make policy handle overridden ttl 157 | console.warn("ttl:", ttl, "storable:", policy.storable()); 158 | } 159 | 160 | ttl = typeof ttl === "number" ? ttl : Math.floor(policy.timeToLive() / 1000); 161 | if (policy.storable() && ttl > 0) { 162 | console.debug("Setting cache policy:", "httpcache:policy:" + key, ttl) 163 | await cache.set("httpcache:policy:" + key, JSON.stringify(policy.toObject()), ttl) 164 | const respBody = await res.arrayBuffer() 165 | await cache.set("httpcache:body:" + key, respBody, ttl) 166 | return true; 167 | } 168 | return false; 169 | } 170 | } 171 | 172 | function hashData(req: Request) { 173 | let toHash = `` 174 | 175 | const u = normalizeURL(req.url) 176 | 177 | toHash += u.toString() 178 | toHash += req.method 179 | 180 | // TODO: cacheable cookies 181 | // TODO: cache version for grand busting 182 | 183 | console.debug("hashData", toHash) 184 | return (crypto as any).subtle.digestSync("sha-1", toHash, "hex") 185 | } 186 | 187 | function normalizeURL(u:string) { 188 | const url = new URL(u) 189 | url.hash = "" 190 | const sp = url.searchParams 191 | sp.sort() 192 | url.search = sp.toString() 193 | 194 | return url 195 | } 196 | -------------------------------------------------------------------------------- /src/middleware/https-upgrader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Middleware 3 | */ 4 | import { RedirectOptions } from "../fetch"; 5 | import { requestModifier } from "./builder"; 6 | 7 | /** 8 | * Redirects http requests to https in production. 9 | * 10 | * In development, this only logs a message 11 | */ 12 | export const httpsUpgrader = requestModifier(httpsRedirect) 13 | 14 | /** 15 | * Checks request protocol, returns Redirect response if request is http. 16 | * 17 | * In development, this function just logs a message to the console. 18 | * 19 | * @param req The request to check 20 | * @param options Options for the resulting redirect 21 | */ 22 | export function httpsRedirect(req: Request, options?: RedirectOptions){ 23 | let { status, text } = options || { status: 302, text: "" } 24 | status = status || 302 25 | text = text || "Redirecting" 26 | if (app.env === "development") console.log("skipping httpsUpgrader in dev") 27 | const url = new URL(req.url) 28 | if (app.env != "development" && url.protocol != "https:") { 29 | url.protocol = "https:" 30 | return new Response(text, { status: status, headers: { location: url.toString() } }) 31 | } 32 | } -------------------------------------------------------------------------------- /src/middleware/inject-html.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Middleware 3 | */ 4 | import { FetchFunction } from "../fetch"; 5 | import { applyReplacements } from "../text-replacements"; 6 | 7 | export interface InjectHTMLOptions { 8 | targetTag: string; 9 | html: string; 10 | } 11 | 12 | /** @ignore */ 13 | export function injectHTML(fetch: FetchFunction, options?: InjectHTMLOptions): FetchFunction { 14 | const { targetTag = "", html = "" } = options || {}; 15 | 16 | if (!targetTag || !html) { 17 | return fetch; 18 | } 19 | 20 | return async function injectHTML(req: RequestInfo, init?: RequestInit) { 21 | const resp = await fetch(req, init) 22 | return applyReplacements(resp, [[targetTag, html]]) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/middleware/response-headers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Middleware 3 | */ 4 | import { responseModifier } from "./builder"; 5 | 6 | /** 7 | * Header name/value pairs to set on a response. The boolean `false` removes the header entirely. 8 | */ 9 | export interface ResponseHeadersOptions { 10 | [name: string]: string | false 11 | } 12 | 13 | /** 14 | * Middleware to set headers on responses 15 | * @param fetch 16 | * @param options 17 | */ 18 | export const responseHeaders = responseModifier(addResponseHeaders) 19 | 20 | /** 21 | * Sets provided headers on a response object 22 | * @private 23 | * @param resp 24 | * @param options 25 | * @hidden 26 | */ 27 | export async function addResponseHeaders(resp: Response, headers: ResponseHeadersOptions){ 28 | if (headers) { 29 | if("headers" in headers && typeof headers.headers === "object"){ 30 | //@ts-ignore 31 | headers = (headers.headers as ResponseHeadersOptions); 32 | } 33 | for (const [k, v] of Object.entries(headers)) { 34 | if (v === false || v === "") { 35 | resp.headers.delete(k) 36 | } else if (v !== true) { // true implies pass through 37 | resp.headers.set(k, v.toString()) 38 | } 39 | } 40 | } 41 | return resp 42 | } 43 | -------------------------------------------------------------------------------- /src/pipeline.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A library for composing `fetch` generators into a single pipeline. 3 | * 4 | * @preferred 5 | * @module HTTP 6 | */ 7 | 8 | import { FetchGenerator, FetchFunction } from "./fetch" 9 | 10 | /** 11 | * PipeplineStage can either be a FetchGenerator function, or a tuple of 12 | * FetchGenerator + args. 13 | */ 14 | export type PipelineStage = FetchGenerator | [FetchGenerator, any[]] 15 | 16 | /** 17 | * Combine multiple fetches into a single function. Allows middleware type functionality 18 | * 19 | * Example: 20 | * 21 | * ```javascript 22 | * import { pipeline } from "@fly/fetch/pipeline" 23 | * 24 | * const addHeader = function(fetch){ 25 | * return function(req, init){ 26 | * if(typeof req === "string") req = new Request(req, init) 27 | * req.headers.set("Superfly-Header", "shazam") 28 | * return fetch(req, init) 29 | * } 30 | * } 31 | * 32 | * const p = pipeline(fetch, addHeader) 33 | * 34 | * fly.http.respondWith(p) 35 | * 36 | * @param stages fetch generator functions that apply additional logic 37 | * @returns a combinedfunction that can be used anywhere that wants `fetch` 38 | */ 39 | export function pipeline(...stages: PipelineStage[]) { 40 | /** 41 | * @param fetch the "origin" fetch function to call at the end of the pipeline 42 | */ 43 | function pipelineFetch(fetch: FetchFunction) { 44 | for (let i = stages.length - 1; i >= 0; i--) { 45 | const s = stages[i] 46 | const fn = typeof s === "function" ? s : s[0] 47 | const opts = s instanceof Array ? s[1] : [] 48 | fetch = fn(fetch, opts) 49 | } 50 | return Object.assign(fetch, { stages }) 51 | } 52 | 53 | return Object.assign(pipelineFetch, { stages }) 54 | } 55 | 56 | export default pipeline 57 | -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Library for proxying requests to origins. Use this to create `fetch` like functions 3 | * for making requests to other services. For example: 4 | * 5 | * ```javascript 6 | * // sends all traffic to an Amazon ELB, 7 | * // `Host` header passes through from visitor request 8 | * const origin = proxy("https://elb1298.amazonaws.com") 9 | * ``` 10 | * 11 | * By default, this function sends the `Host` header inferred from the origin URL. To forward 12 | * host headers sent by visitors, set `forwardHostHeader` to true. 13 | * 14 | * ```javascript 15 | * // sends all traffic to an Amazon ELB, include host header from original request. 16 | * const origin = proxy("https://elb1298.amazonaws.com", { 17 | * forwardHostHeader: true 18 | * }) 19 | * ``` 20 | * 21 | * And then way more rare, no host header at all. Usually you'd strip out `x-forwarded-host`, 22 | * since some origins don't like that: 23 | * ```javascript 24 | * // sends all traffic to an Amazon ELB, never sends a host header 25 | * const origin = proxy("https://elb1298.amazonaws.com", { 26 | * headers: { host: false} 27 | * }) 28 | * ``` 29 | * 30 | * @preferred 31 | * @module HTTP 32 | */ 33 | import { normalizeRequest, FetchFunction } from "./fetch" 34 | function sleep(ms: number){ 35 | return new Promise((resolve, _) => { 36 | setTimeout(resolve, ms) 37 | }); 38 | } 39 | /** 40 | * This generates a `fetch` like function for proxying requests to a given origin. 41 | * When this function makes origin requests, it adds standard proxy headers like 42 | * `X-Forwarded-Host` and `X-Forwarded-For`. It also passes headers from the original 43 | * request to the origin. 44 | * @param origin A URL to an origin, can include a path to rebase requests. 45 | * @param options Options and headers to control origin request. 46 | */ 47 | export function proxy(origin: string | URL, options?: ProxyOptions): ProxyFunction { 48 | if (!options) { 49 | options = {} 50 | } 51 | if(options.errorTo503 !== false){ 52 | options.errorTo503 = true; 53 | } 54 | options.origin = origin.toString(); 55 | const fetchFn = options.fetch || fetch; 56 | async function proxyFetch(req: RequestInfo, init?: RequestInit) { 57 | if(!(req instanceof Request)){ 58 | req = new Request(req, init); 59 | init = undefined; 60 | } 61 | if (!options) { 62 | options = {} 63 | } 64 | const breq = buildProxyRequest(origin, options, req, init) 65 | const retries = options.retries || 0; 66 | const delayMS = 100; 67 | let tryCount = 0; 68 | do { 69 | if(tryCount > 0){ 70 | console.warn("Retrying request:", tryCount, "in", delayMS * tryCount * tryCount, "ms") 71 | await sleep(Math.min(delayMS * tryCount * tryCount, 5000)); 72 | breq.headers.set("Fly-Proxy-Retry", tryCount.toString()); 73 | } 74 | tryCount += 1; 75 | try{ 76 | let bresp = await fetchFn(breq.clone(), { certificate: options.certificate, tls: options.tls }) 77 | if(options.rewriteLocationHeaders !== false){ 78 | bresp = rewriteLocationHeader(req.url, breq.url, bresp) 79 | } 80 | return bresp // breaks loop on successful response 81 | }catch(err){ 82 | if(tryCount < retries){ 83 | continue; 84 | } 85 | if(!options.errorTo503) throw err; // breaks loop 86 | } 87 | } while(tryCount <= retries); 88 | return new Response("origin error", { status: 503 }) 89 | } 90 | 91 | return Object.assign(proxyFetch, { proxyConfig: options}) 92 | } 93 | 94 | /** 95 | * @protected 96 | * @hidden 97 | * @param origin 98 | * @param options 99 | * @param req 100 | * @param init 101 | */ 102 | export function buildProxyRequest(origin: string | URL, options: ProxyOptions, req: RequestInfo, init?: RequestInit) { 103 | if (typeof req === "string") { 104 | req = new Request(req, init) 105 | init = undefined 106 | } 107 | 108 | if (!(req instanceof Request)) { 109 | throw new Error("req must be either a string or a Request object") 110 | } 111 | 112 | const url = new URL(req.url) 113 | let breq: Request | null = null 114 | 115 | breq = req.clone() 116 | 117 | if (typeof origin === "string") { 118 | origin = new URL(origin) 119 | } 120 | 121 | const requestedHostname = req.headers.get("host") || url.hostname 122 | url.hostname = origin.hostname 123 | url.protocol = origin.protocol 124 | url.port = origin.port 125 | 126 | if (options.stripPath && typeof options.stripPath === "string") { 127 | // remove basePath so we can serve `onehosthame.com/dir/` from `origin.com/` 128 | url.pathname = url.pathname.substring(options.stripPath.length) 129 | } 130 | if (origin.pathname && origin.pathname.length > 0) { 131 | url.pathname = [origin.pathname.replace(/\/$/, ""), url.pathname.replace(/^\//, "")].join("/") 132 | } 133 | if (url.pathname.startsWith("//")) { 134 | url.pathname = url.pathname.substring(1) 135 | } 136 | 137 | if (url.toString() !== breq.url) { 138 | breq = new Request(url.toString(), breq) 139 | } 140 | // we extend req with remoteAddr 141 | if(req.remoteAddr){ 142 | breq.headers.set("x-forwarded-for", req.remoteAddr) 143 | } 144 | breq.headers.set("x-forwarded-host", requestedHostname) 145 | breq.headers.set("x-forwarded-proto", url.protocol.replace(":", "")) 146 | 147 | if (!options.forwardHostHeader) { 148 | // set host header to origin.hostnames 149 | breq.headers.set("host", origin.hostname) 150 | } 151 | 152 | if (options.headers) { 153 | for (const h of Object.getOwnPropertyNames(options.headers)) { 154 | const v = options.headers[h] 155 | if (v === false) { 156 | breq.headers.delete(h) 157 | } else if (v && typeof v === "string") { 158 | breq.headers.set(h, v) 159 | } 160 | } 161 | } 162 | return breq; 163 | } 164 | 165 | export function rewriteLocationHeader(url: URL | string, burl: URL | string, resp: Response){ 166 | const locationHeader = resp.headers.get("location") 167 | if(!locationHeader){ 168 | return resp 169 | } 170 | if(typeof url === "string"){ 171 | url = new URL(url) 172 | } 173 | if(typeof burl === "string"){ 174 | burl = new URL(burl) 175 | } 176 | const location = new URL(locationHeader, burl) 177 | 178 | if(location.hostname !== burl.hostname || location.protocol !== burl.protocol){ 179 | return resp 180 | } 181 | 182 | 183 | let pathname = location.pathname 184 | if(url.pathname.endsWith(burl.pathname)){ 185 | // url path: /original/path/ 186 | // burl path: /path/ 187 | // need to prefix base 188 | const prefix = url.pathname.substring(0, url.pathname.length - burl.pathname.length) 189 | pathname = prefix + location.pathname 190 | 191 | } else if(burl.pathname.endsWith(url.pathname)) { 192 | // url path: /original/path/ 193 | // burl path: /path/ 194 | // need to remove prefix 195 | const remove = burl.pathname.substring(0, burl.pathname.length - url.pathname.length) 196 | if(location.pathname.startsWith(remove)){ 197 | pathname = location.pathname.substring(remove.length, location.pathname.length) 198 | } 199 | } 200 | if(pathname !== location.pathname){ 201 | // do the rewrite 202 | location.pathname = pathname 203 | location.protocol = url.protocol 204 | location.hostname = url.hostname 205 | resp.headers.set("location", location.toString()) 206 | } 207 | 208 | return resp 209 | } 210 | 211 | /** 212 | * Options for `proxy`. 213 | */ 214 | export interface ProxyOptions { 215 | /** 216 | * Replace this portion of URL path before making request to origin. 217 | * 218 | * For example, this makes a request to `https://fly.io/path1/to/document.html`: 219 | * ```javascript 220 | * const opts = { stripPath: "/path2/"} 221 | * const origin = proxy("https://fly.io/path1/", opts) 222 | * origin("https://somehostname.com/path2/to/document.html") 223 | * ``` 224 | */ 225 | stripPath?: string 226 | 227 | /** 228 | * Forward `Host` header from original request. Without this options, 229 | * proxy requests infers a host header from the origin URL. 230 | * Defaults to `false`. 231 | */ 232 | forwardHostHeader?: boolean 233 | 234 | /** 235 | * Rewrite location headers (defaults to true) to match incoming request. 236 | * 237 | * Example: 238 | * - Request url: http://test.com/blog/asdf 239 | * - Proxy url: http://origin.com/asdf 240 | * - Location http://origin.com/jklm bcomes http://test.com/blog/jklm 241 | */ 242 | rewriteLocationHeaders?: boolean 243 | /** 244 | * Headers to set on backend request. Each header accepts either a `boolean` or `string`. 245 | * * If set to `false`, strip header entirely before sending. 246 | * * `true` or `undefined` send the header through unmodified from the original request. 247 | * * `string` header values are sent as is 248 | */ 249 | headers?: { 250 | [key: string]: string | boolean | undefined 251 | /** 252 | * Host header to set before sending origin request. Some sites only respond to specific 253 | * host headers. 254 | */ 255 | host?: string | boolean 256 | } 257 | 258 | /** 259 | * When underlying fetch throws an error, return a 503 response. 260 | * 261 | * Defaults to `true`. 262 | */ 263 | errorTo503?: boolean, 264 | 265 | /** 266 | * When underlying connection throughs an error, retry request times. 267 | */ 268 | retries?: number, 269 | 270 | /** @private */ 271 | origin?: string, 272 | 273 | /** @private */ 274 | fetch?: FetchFunction, 275 | 276 | certificate?: { 277 | key?: string | Buffer | Array 278 | cert?: string | Buffer | Array 279 | ca?: string | Buffer | Array 280 | pfx?: string | Buffer | Array 281 | passphrase?: string 282 | }, 283 | 284 | tls?:{ 285 | servername?: string 286 | } 287 | } 288 | 289 | 290 | /** 291 | * A proxy `fetch` like function. These functions include their 292 | * original configuration information. 293 | */ 294 | export interface ProxyFunction extends FetchFunction { 295 | proxyConfig: T 296 | } 297 | 298 | export interface ProxyFactory { 299 | (options: TInput): ProxyFunction; 300 | normalizeOptions?: (input: any) => TOpts; 301 | } 302 | 303 | /* 304 | Requests with rewrites: 305 | - https://example.com/blog/ -> https://example.blogservice.com/ 306 | - strip /blog/ to backend (proxy function does this) 307 | - prepend /blog/ to location headers on response 308 | */ 309 | -------------------------------------------------------------------------------- /src/shims/crypto.ts: -------------------------------------------------------------------------------- 1 | /** @module HTTP */ 2 | import * as sjcl from 'sjcl' 3 | 4 | /** @hidden */ 5 | export function createHmac(_algo: string, key: string | sjcl.BitArray) { 6 | const mac = new sjcl.misc.hmac(typeof key === 'string' ? sjcl.codec.utf8String.toBits(key) : key); 7 | return { 8 | update: (data: string) => { 9 | mac.update(data); 10 | return { 11 | digest: (encoding: string) => { 12 | let result = mac.digest(); 13 | return encoding === 'hex' ? sjcl.codec.hex.fromBits(result) : result; 14 | }, 15 | }; 16 | }, 17 | }; 18 | } 19 | 20 | /** @hidden */ 21 | export function createHash() { 22 | const hash = new sjcl.hash.sha256(); 23 | return { 24 | update: (data: string) => { 25 | hash.update(data); 26 | return { 27 | digest: () => sjcl.codec.hex.fromBits(hash.finalize()), 28 | }; 29 | }, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/text-replacements.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module HTTP 3 | * @ignore 4 | */ 5 | export async function applyReplacements(resp: Response, replacements?: [string, string][]) { 6 | if (!replacements || replacements.length === 0) return resp 7 | const contentType = resp.headers.get("content-type") || "" 8 | if ( 9 | contentType.includes("/html") || 10 | contentType.includes("application/javascript") || 11 | contentType.includes("application/json") || 12 | contentType.includes("text/") 13 | ) { 14 | const start = Date.now() 15 | let body = await resp.text() 16 | for (const r of replacements) { 17 | body = body.replace(r[0], r[1]) 18 | } 19 | resp.headers.delete("content-length") 20 | resp = new Response(body, resp) 21 | } 22 | return resp 23 | } -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /** @module HTTP */ 2 | import { merge as _merge, pick } from "lodash"; 3 | 4 | /** @hidden */ 5 | export function isObject(thing: unknown): thing is Partial { 6 | return thing !== null && typeof thing === "object" 7 | } 8 | 9 | /** @hidden */ 10 | export function merge(target: T, other: {}, keys: (keyof T)[]): T { 11 | return _merge(target, pick(other, keys)); 12 | } 13 | -------------------------------------------------------------------------------- /test/backends/aws_s3.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { backends } from "../../src" 3 | 4 | const { awsS3 } = backends 5 | 6 | describe("backends/awsS3", () => { 7 | describe("public", () => { 8 | it("responds with the file's content", async () => { 9 | const fn = awsS3("flyio-test-website"); 10 | const resp = await fn("/index.html"); 11 | expect(resp.status).to.eq(200); 12 | for (let h in resp.headers) 13 | expect(h.startsWith("x-amz-")).to.eq(false) 14 | expect(resp.headers.get("etag")).to.not.be.empty 15 | }) 16 | it("uses index.html by default", async () => { 17 | const fn = awsS3("flyio-test-website"); 18 | const resp = await fn("/"); 19 | expect(resp.status).to.eq(200); 20 | for (let h in resp.headers) 21 | expect(h.startsWith("x-amz-")).to.eq(false) 22 | }) 23 | it("responds to HEAD requests correctly", async () => { 24 | const fn = awsS3("flyio-test-website"); 25 | const resp = await fn("/", { method: "HEAD" }); 26 | expect(resp.status).to.eq(200); 27 | for (let h in resp.headers) 28 | expect(h.startsWith("x-amz-")).to.eq(false) 29 | expect(await resp.text()).to.be.empty 30 | }) 31 | describe("w/ options", () => { 32 | it("responds with the file's content", async () => { 33 | const fn = awsS3({ bucket: "flyio-test-website" }); 34 | const resp = await fn("/index.html"); 35 | expect(resp.status).to.eq(200); 36 | for (let h in resp.headers) 37 | expect(h.startsWith("x-amz-")).to.eq(false) 38 | }) 39 | }) 40 | describe("error handling", () => { 41 | it("return an error for anything other than GET or HEAD methods", async () => { 42 | for (let method of ["POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "GIBBERISH"]) { 43 | let resp = await awsS3({ bucket: "flyio-test-website" })("/index.html", { method }) 44 | expect(resp.status).to.eq(405) 45 | } 46 | }) 47 | it("GET returns a 404 when file is not found", async () => { 48 | const fn = awsS3({ bucket: "flyio-test-website" }); 49 | const resp = await fn("/index2.html"); 50 | expect(resp.status).to.eq(404); 51 | for (let h in resp.headers) 52 | expect(h.startsWith("x-amz-")).to.eq(false) // just to make sure we don't leak 53 | }) 54 | it("HEAD returns a 404 when file is not found", async () => { 55 | const fn = awsS3({ bucket: "flyio-test-website" }); 56 | const resp = await fn("/index2.html", { method: "HEAD" }); 57 | expect(resp.status).to.eq(404); 58 | for (let h in resp.headers) 59 | expect(h.startsWith("x-amz-")).to.eq(false) // just to make sure we don't leak 60 | expect(resp.body).to.be.null 61 | }) 62 | }) 63 | }) 64 | 65 | describe("private", () => { 66 | describe("error handling", () => { 67 | it("return an error for anything other than GET or HEAD methods", async () => { 68 | for (let method of ["POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "GIBBERISH"]) { 69 | let resp = await awsS3({ 70 | bucket: "flyio-private-website", 71 | credentials: { 72 | accessKeyId: "gibberish", 73 | secretAccessKey: "gibberish" 74 | } 75 | })("/index.html", { method }) 76 | expect(resp.status).to.eq(405) 77 | } 78 | }) 79 | it("returns a 500 when credentials are off", async () => { 80 | const fn = awsS3({ 81 | bucket: "flyio-private-website", 82 | credentials: { 83 | accessKeyId: "gibberish", 84 | secretAccessKey: "gibberish" 85 | } 86 | }); 87 | const resp = await fn("/index.html"); 88 | expect(resp.status).to.eq(500); 89 | for (let h in resp.headers) 90 | expect(h.startsWith("x-amz-")).to.eq(false) // just to make sure we don't leak 91 | }) 92 | 93 | if (!app.config.aws_s3_secret_access_key) { 94 | it('404 might work (did not run, missing proper secrets)'); 95 | return 96 | } 97 | it("GET returns a 404 when a file is not found", async () => { 98 | const fn = awsS3({ 99 | bucket: "flyio-private-website", 100 | credentials: { 101 | accessKeyId: app.config.aws_s3_access_key_id, 102 | secretAccessKey: app.config.aws_s3_secret_access_key 103 | } 104 | }); 105 | const resp = await fn("/index2.html"); 106 | expect(resp.status).to.eq(404); 107 | for (let h in resp.headers) 108 | expect(h.startsWith("x-amz-")).to.eq(false) // just to make sure we don't leak 109 | }) 110 | it("HEAD returns a 404 when a file is not found", async () => { 111 | const fn = awsS3({ 112 | bucket: "flyio-private-website", 113 | credentials: { 114 | accessKeyId: app.config.aws_s3_access_key_id, 115 | secretAccessKey: app.config.aws_s3_secret_access_key 116 | } 117 | }); 118 | const resp = await fn("/index2.html", { method: "HEAD" }); 119 | expect(resp.status).to.eq(404); 120 | for (let h in resp.headers) 121 | expect(h.startsWith("x-amz-")).to.eq(false) // just to make sure we don't leak 122 | expect(resp.body).to.be.null 123 | }) 124 | }) 125 | 126 | if (!app.config.aws_s3_secret_access_key) { 127 | it('might work (did not run, missing proper secrets)'); 128 | return 129 | } 130 | 131 | it('responds with the file', async () => { 132 | const fn = awsS3({ 133 | bucket: "flyio-private-website", 134 | credentials: { 135 | accessKeyId: app.config.aws_s3_access_key_id, 136 | secretAccessKey: app.config.aws_s3_secret_access_key 137 | } 138 | }); 139 | const resp = await fn("/index.html"); 140 | expect(resp.status).to.eq(200); 141 | for (let h in resp.headers) 142 | expect(h.startsWith("x-amz-")).to.eq(false) 143 | expect(resp.headers.get("etag")).to.not.be.empty 144 | }) 145 | 146 | it("responds to HEAD requests correctly", async () => { 147 | const fn = awsS3({ 148 | bucket: "flyio-private-website", 149 | credentials: { 150 | accessKeyId: app.config.aws_s3_access_key_id, 151 | secretAccessKey: app.config.aws_s3_secret_access_key 152 | } 153 | }); 154 | const resp = await fn("/", { method: "HEAD" }); 155 | expect(resp.status).to.eq(200); 156 | for (let h in resp.headers) 157 | expect(h.startsWith("x-amz-")).to.eq(false) 158 | expect(await resp.text()).to.be.empty 159 | }) 160 | }) 161 | 162 | }) -------------------------------------------------------------------------------- /test/backends/github_pages.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { githubPages } from "../../src/backends" 3 | import * as errors from "../../src/errors"; 4 | 5 | 6 | describe("backends/githubPages", function() { 7 | this.timeout(15000) 8 | 9 | describe("options", () => { 10 | const validOptions = [ 11 | [ 12 | "superfly/edge", 13 | { owner: "superfly", repository: "edge" } 14 | ], 15 | [ 16 | { owner: "superfly", repository: "edge" }, 17 | { owner: "superfly", repository: "edge" } 18 | ], 19 | [ 20 | { owner: "superfly", repository: "edge", hostname: "host.name" }, 21 | { owner: "superfly", repository: "edge", hostname: "host.name" } 22 | ], 23 | ]; 24 | 25 | for (const [input, config] of validOptions) { 26 | it(`accepts ${JSON.stringify(input)}`, () => { 27 | expect(githubPages(input as any).proxyConfig).to.eql(config); 28 | }) 29 | } 30 | 31 | const invalidOptions = [ 32 | [undefined, errors.InputError], 33 | ["", errors.InputError], 34 | [{ }, /owner is required/], 35 | [{ owner: "", repository: "edge" }, /owner is required/], 36 | [{ repository: "edge" }, /owner is required/], 37 | [{ owner: "superfly", repository: "" }, /repository is required/], 38 | [{ owner: "superfly" }, /repository is required/], 39 | ] 40 | 41 | for (const [input, err] of invalidOptions) { 42 | it(`rejects ${JSON.stringify(input)}`, () => { 43 | expect(() => { githubPages(input as any) }).throw(err as any); 44 | }) 45 | } 46 | }) 47 | 48 | describe("fetch", () => { 49 | it("works with plain repos", async () => { 50 | const fn = githubPages("superfly/edge"); 51 | const config = fn.proxyConfig; 52 | 53 | const resp = await fn("https://fly.io/", { method: "HEAD" }) 54 | expect(resp.status).to.eq(200) 55 | expect(fn.proxyConfig).to.eq(config, "ghFetch function changed when it shouldn't have") 56 | }) 57 | 58 | it("detects a custom domain and retries", async () => { 59 | const fn = githubPages("superfly/landing") 60 | const config = fn.proxyConfig; 61 | expect(config.hostname).to.be.undefined 62 | 63 | const resp = await fn("https://fly.io/", { method: "HEAD" }) 64 | expect(resp.status).to.eq(200) 65 | expect(fn.proxyConfig.hostname).to.eq("preview.fly.io") 66 | }) 67 | 68 | it("detects custom domain removal and retries", async () => { 69 | const fn = githubPages({ 70 | owner: "superfly", 71 | repository: "edge", 72 | hostname: "docs.fly.io" 73 | }) 74 | const config = fn.proxyConfig 75 | expect(config.hostname).to.eq("docs.fly.io") 76 | 77 | const resp = await fn("https://fly.io/", { method: "HEAD" }) 78 | expect(resp.status).to.eq(200) 79 | expect(fn.proxyConfig.hostname).to.be.undefined 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /test/backends/gitlab_pages.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { gitlabPages } from "../../src/backends" 3 | import * as errors from "../../src/errors"; 4 | 5 | 6 | describe("backends/gitlabPages", function() { 7 | this.timeout(15000) 8 | 9 | describe("options", () => { 10 | const validOptions = [ 11 | [ 12 | "superfly/cdn", 13 | { owner: "superfly", repository: "cdn" } 14 | ], 15 | [ 16 | { owner: "superfly", repository: "cdn" }, 17 | { owner: "superfly", repository: "cdn" } 18 | ], 19 | [ 20 | { owner: "superfly", repository: "cdn", hostname: "host.name" }, 21 | { owner: "superfly", repository: "cdn", hostname: "host.name" } 22 | ], 23 | ]; 24 | 25 | for (const [input, config] of validOptions) { 26 | it(`accepts ${JSON.stringify(input)}`, () => { 27 | expect(gitlabPages(input as any).proxyConfig).to.eql(config); 28 | }) 29 | } 30 | 31 | const invalidOptions = [ 32 | [undefined, errors.InputError], 33 | ["", errors.InputError], 34 | [{ }, /owner is required/], 35 | [{ owner: "", repository: "cdn" }, /owner is required/], 36 | [{ repository: "cdn" }, /owner is required/], 37 | [{ owner: "superfly", repository: "" }, /repository is required/], 38 | [{ owner: "superfly" }, /repository is required/], 39 | ] 40 | 41 | for (const [input, err] of invalidOptions) { 42 | it(`rejects ${JSON.stringify(input)}`, () => { 43 | expect(() => { gitlabPages(input as any) }).throw(err as any); 44 | }) 45 | } 46 | }) 47 | 48 | describe("fetch", () => { 49 | it("works with plain repos", async () => { 50 | const fn = gitlabPages("superfly/cdn"); 51 | const config = fn.proxyConfig; 52 | 53 | const resp = await fn("https://fly.io/", { method: "HEAD" }) 54 | expect(resp.status).to.eq(200) 55 | expect(fn.proxyConfig).to.eq(config, "glFetch function changed when it shouldn't have") 56 | }) 57 | 58 | it("detects a custom domain and retries", async () => { 59 | const fn = gitlabPages("superfly/landing") 60 | const config = fn.proxyConfig; 61 | expect(config.hostname).to.be.undefined 62 | 63 | const resp = await fn("https://fly.io/", { method: "HEAD" }) 64 | expect(resp.status).to.eq(200) 65 | expect(fn.proxyConfig.hostname).to.eq("preview.fly.io") 66 | }) 67 | 68 | it("detects custom domain removal and retries", async () => { 69 | const fn = gitlabPages({ 70 | owner: "superfly", 71 | repository: "cdn", 72 | hostname: "docs.fly.io" 73 | }) 74 | const config = fn.proxyConfig 75 | expect(config.hostname).to.eq("docs.fly.io") 76 | 77 | const resp = await fn("https://fly.io/", { method: "HEAD" }) 78 | expect(resp.status).to.eq(200) 79 | expect(fn.proxyConfig.hostname).to.be.undefined 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /test/backends/origin.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { origin } from "../../src/backends" 3 | import * as errors from "../../src/errors"; 4 | 5 | 6 | describe("backends/origin", function() { 7 | this.timeout(15000) 8 | 9 | describe("options", () => { 10 | const validOptions = [ 11 | [ 12 | "https://fly.io", 13 | { origin: "https://fly.io", forwardHostHeader: false } 14 | ], 15 | [ 16 | new URL("https://fly.io"), 17 | { origin: new URL("https://fly.io"), forwardHostHeader: false } 18 | ], 19 | [ 20 | { origin: "https://fly.io" }, 21 | { origin: "https://fly.io", forwardHostHeader: false }, 22 | ] 23 | ]; 24 | 25 | for (const [input, config] of validOptions) { 26 | it(`accepts ${JSON.stringify(input)}`, () => { 27 | expect(origin(input as any).proxyConfig).to.eql(config); 28 | }) 29 | } 30 | 31 | const invalidOptions = [ 32 | [undefined, errors.InputError], 33 | ["", /origin is required/], 34 | // URL in fly doesn't follow spec right now so these are valid :( 35 | // ["not-a-url", /origin must be a valid url/], 36 | // ["google.com/missing-schema", /origin must be a valid url/], 37 | [{}, /origin is required/], 38 | [{origin: ""}, /origin is required/], 39 | ] 40 | 41 | for (const [input, err] of invalidOptions) { 42 | it(`rejects ${JSON.stringify(input)}`, () => { 43 | expect(() => { origin(input as any) }).throw(err as any); 44 | }) 45 | } 46 | }) 47 | 48 | it('works', async () => { 49 | const fn = origin("https://example.com"); 50 | 51 | const resp = await fn("https://origin/", { method: "HEAD"}) 52 | expect(resp.status).to.eq(200) 53 | }) 54 | }) -------------------------------------------------------------------------------- /test/backends/subdomain_services.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { squarespace, ghostProBlog, glitch, netlify, heroku, surge, zeitNow, aerobatic, firebase } from "../../src/backends" 3 | import * as errors from "../../src/errors"; 4 | import { normalizeOptions } from "../../src/backends/subdomain_service"; 5 | 6 | const defs: any[] = [ 7 | { backend: ghostProBlog, tests: [ 8 | { subdomain: "fly-io", hostname: 'fly.io', directory: "/articles/" }, 9 | { subdomain: "demo" } 10 | ]}, 11 | { backend: glitch, options: ["subdomain"], tests: [ 12 | { appName: "fly-example" } 13 | ]}, 14 | { backend: heroku, tests: [ 15 | { appName: "example" } 16 | ]}, 17 | { backend: netlify, options: ["subdomain", "directory"], tests: [ 18 | { subdomain: "example" } 19 | ]}, 20 | { backend: squarespace, tests: [ 21 | { subdomain: "archmotorcycle" },// whoah 22 | { subdomain: "archmotorcycle", hostname: "www.archmotorcycle.com" } // whoah 23 | ]}, 24 | { backend: surge, options: ["subdomain", "directory"], tests: [ 25 | { subdomain: "cloistered-swim" } 26 | ]}, 27 | { backend: zeitNow, tests: [ 28 | { subdomain: "nextjs-news-v2" } 29 | ]}, 30 | { backend: aerobatic, options: ["subdomain", "directory"], tests: [ 31 | { subdomain: "sample" } 32 | ]}, 33 | { backend: firebase, options: ["subdomain", "directory"], tests: [ 34 | { subdomain: "multi-site-magic" } 35 | ]}, 36 | ] 37 | for(const d of defs){ 38 | const backend = d.backend as Function; 39 | describe(`backends/${backend.name}`, function() { 40 | this.timeout(15000) 41 | 42 | for(const t of d.tests){ 43 | it(`works with settings: ${JSON.stringify(t)}`, async () => { 44 | const fn = backend(t as any); 45 | 46 | const resp = await fn("https://backend/", { 47 | method: "HEAD", 48 | headers: { 49 | "User-Agent": "flyio test suite" 50 | } 51 | }) 52 | expect(resp.status).to.eq(200); 53 | }) 54 | } 55 | }) 56 | } 57 | 58 | describe("backends/subdomainService", () => { 59 | const validOptions = [ 60 | [ 61 | "subdomain", 62 | { subdomain: "subdomain", directory: "/" } 63 | ], 64 | [ 65 | "subdomain ", 66 | { subdomain: "subdomain", directory: "/"} 67 | ], 68 | [ 69 | { subdomain: "subdomain " }, 70 | { subdomain: "subdomain", directory: "/" } 71 | ], 72 | [ 73 | { subdomain: "subdomain" }, 74 | { subdomain: "subdomain", directory: "/" } 75 | ], 76 | [ 77 | { subdomain: "subdomain", hostname: "host.name" }, 78 | { subdomain: "subdomain", directory: "/", hostname: "host.name" } 79 | ], 80 | [ 81 | { subdomain: "subdomain", directory: "/" }, 82 | { subdomain: "subdomain", directory: "/" } 83 | ] 84 | ]; 85 | 86 | for (const [input, config] of validOptions) { 87 | it(`normalizes ${JSON.stringify(input)}`, () => { 88 | expect(normalizeOptions(input)).to.eql(config); 89 | }) 90 | } 91 | 92 | const invalidOptions = [ 93 | [undefined, errors.InputError], 94 | ["", /subdomain is required/], 95 | [{}, /subdomain is required/], 96 | [{ subdomain: "" }, /subdomain is required/], 97 | ]; 98 | 99 | for (const [input, err] of invalidOptions) { 100 | it(`rejects ${JSON.stringify(input)}`, () => { 101 | expect(() => { normalizeOptions(input) }).throw(err as any); 102 | }) 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /test/balancer.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import balancer, { _internal, Backend, syncBackends } from "../src/balancer" 3 | 4 | async function fakeFetch(req: RequestInfo, init?: RequestInit) { 5 | return new Response("hi") 6 | } 7 | async function fakeFetchError(req: RequestInfo, init?: RequestInit) { 8 | return new Response("nooooo", { status: 502 }) 9 | } 10 | 11 | function healthy() { 12 | return { 13 | proxy: fakeFetch.bind({}), // same function, different times 14 | requestCount: 0, 15 | statuses: [200, 200, 200], 16 | lastError: 0, 17 | healthScore: 1, 18 | latencyScore: 1, 19 | errorCount: 0 20 | } 21 | } 22 | function slow(latencyScore?: number){ 23 | const b = healthy() 24 | b.latencyScore = latencyScore || 10 25 | return b 26 | } 27 | function unhealthy(score?: number) { 28 | const b = healthy() 29 | b.statuses.push(500, 500, 500) 30 | b.healthScore = score || 0.5 31 | return b 32 | } 33 | describe("balancing", () => { 34 | describe("backend scoring", () => { 35 | it("should score healthy backends high", () => { 36 | const backend = healthy() 37 | const score = _internal.scoreHealth(backend) 38 | 39 | expect(score).to.eq(1) 40 | }) 41 | 42 | it("should score unhealthy backends low", () => { 43 | const backend = unhealthy() 44 | const score = _internal.scoreHealth(backend, 0) 45 | 46 | expect(score).to.eq(0.5) 47 | }) 48 | 49 | 50 | it("should give less weight to older errors", () => { 51 | const backend = unhealthy() 52 | let score = _internal.scoreHealth(backend, backend.lastError + 999) 53 | expect(score).to.eq(0.5) 54 | 55 | score = _internal.scoreHealth(backend, backend.lastError + 2000) // 2s old error 56 | expect(score).to.eq(0.6) 57 | 58 | score = _internal.scoreHealth(backend, backend.lastError + 4000) // 4s old error 59 | expect(score).to.eq(0.85) 60 | 61 | score = _internal.scoreHealth(backend, backend.lastError + 9000) // 9s old error 62 | expect(score).to.eq(0.95) 63 | }) 64 | 65 | const latencies: { [key: string]: number[] } = { 66 | "1": [1, 2, 8, 10, 5, 8, 9], 67 | "10": [10, 12, 18, 19, 70, 8, 9], 68 | "100": [100, 900, 758, 123, 204, 203] 69 | } 70 | for(const r of Object.getOwnPropertyNames(latencies)){ 71 | const v = parseInt(r) 72 | const arr = latencies[v] 73 | const average = avg(arr) 74 | it(`should bucket ${average} average latencyScore: ${v}`, () => { 75 | const backend = healthy() 76 | backend.latencies = arr 77 | 78 | const score = _internal.scoreLatency(backend) 79 | expect(score).to.eq(v) 80 | }) 81 | } 82 | }) 83 | 84 | describe("backend selection", () => { 85 | it("should choose healthy backends first", () => { 86 | const h = [healthy(), healthy()] 87 | const backends = [unhealthy(), unhealthy(), unhealthy()].concat(h) 88 | const [b1, b2] = _internal.chooseBackends(backends) 89 | 90 | expect(h.find((e) => e === b1)).to.eq(b1, "Backend 1 should be in selected") 91 | expect(h.find((e) => e === b2)).to.eq(b2, "Backend 2 should be in selected") 92 | expect(b1).to.not.eq(b2, "Backend 1 and Backend 2 should be different") 93 | }) 94 | 95 | it("should choose lowest latency backends first", () => { 96 | const h = [healthy(), healthy()] 97 | const s = slow() 98 | const backends = [s, unhealthy(), unhealthy(), unhealthy()].concat(h) 99 | const [b1, b2] = _internal.chooseBackends(backends) 100 | 101 | expect(b1).to.not.eq(s, "Backend 1 should not be the slow one") 102 | expect(b2).to.not.eq(s, "Backend 2 should not be the slow one") 103 | }) 104 | 105 | it("should not choose a high latency backend", () => { 106 | const h = [healthy()] 107 | const s = slow() 108 | const backends = [s].concat(h) 109 | const [b1, b2] = _internal.chooseBackends(backends) 110 | 111 | expect(b1).to.not.eq(s, "Backend 1 should not be the slow one") 112 | expect(b2).to.not.eq(s, "Backend 2 should not be the slow one") 113 | }) 114 | 115 | it("ignores backends that have been tried", () => { 116 | const h = [healthy(), healthy()] 117 | const backends = [unhealthy(), unhealthy(), unhealthy()].concat(h) 118 | const attempted = new Set(h) 119 | 120 | const [b1, b2] = _internal.chooseBackends(backends, attempted) 121 | expect(h.find((e) => e === b1)).to.eq(undefined, "Backend 1 should not be in selected") 122 | expect(h.find((e) => e === b2)).to.eq(undefined, "Backend 2 should not be in selected") 123 | expect(b1).to.not.eq(b2, "Backend 1 and Backend 2 should be different") 124 | }) 125 | }) 126 | 127 | describe("backend stats", () => { 128 | it("should store last 10 statuses", async () => { 129 | const req = new Request("http://localhost/hello/") 130 | const fn = balancer([fakeFetch]) 131 | const backend = fn.backends[0] 132 | const statuses = Array() 133 | for (let i = 0; i < 20; i++) { 134 | let resp = await fn(req) 135 | statuses.push(resp.status) 136 | } 137 | 138 | expect(backend.statuses.length).to.eq(10) 139 | expect(backend.statuses).to.deep.eq(statuses.slice(-10)) 140 | expect(backend.healthScore).to.eq(1) 141 | }) 142 | 143 | it("should retry on failure", async () => { 144 | const fn = balancer([ 145 | fakeFetchError.bind({}), 146 | fakeFetchError.bind({}), 147 | fakeFetch 148 | ]) 149 | // lower health of the last backend so we try errors first 150 | fn.backends[2].healthScore = 0.1 151 | 152 | const resp = await fn("http://localhost/") 153 | const used = fn.backends.filter((b) => b.requestCount > 0) 154 | 155 | expect(resp.status).to.eq(200) 156 | expect(await resp.text()).to.eq("hi") 157 | expect(used.length).to.be.gte(2) // at least two backends hit 158 | }) 159 | }) 160 | 161 | describe("backend changes", () => { 162 | it("should keep existing backends when possible", async () => { 163 | const original = [healthy(), unhealthy(), healthy()] 164 | const updated = [original[1].proxy, healthy().proxy, original[0].proxy, healthy().proxy] 165 | 166 | const backends = syncBackends(original, updated) 167 | 168 | expect(backends.length).to.eq(updated.length) 169 | 170 | for(const i of [1, 0]){ 171 | expect(backends).to.include(original[i]) 172 | } 173 | }) 174 | 175 | it('balancer should use new backends', async () => { 176 | const fn = balancer([fakeFetch]) 177 | const original = fn.backends; 178 | fn.updateBackends([healthy().proxy, healthy().proxy]) 179 | 180 | const updated = fn.backends; 181 | 182 | expect(updated.length).to.eq(2) 183 | expect(updated).to.not.eq(original); 184 | }) 185 | }) 186 | }) 187 | 188 | function avg(array: number[]){ 189 | const sum = array.reduce((a,b) => a + b, 0) 190 | return sum / array.length 191 | } -------------------------------------------------------------------------------- /test/config/builder.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { buildAppFromConfig, backends } from "../../src"; 3 | import { glitch } from "../../src/backends"; 4 | 5 | const expected:any = { 6 | "getting-started": backends.origin({ 7 | origin: "https://getting-started.edgeapp.net", 8 | headers:{ 9 | host: "getting-started.edgeapp.net" 10 | } 11 | }), 12 | "glitch": glitch({appName:"fly-example"}) 13 | } 14 | describe("config/buildApp", () => { 15 | it("should build default config", () => { 16 | const cdn = buildAppFromConfig(defaultConfig); 17 | for(const [k, b] of cdn.backends.entries()){ 18 | const fn = expected[k]; 19 | expect(b.proxyConfig).to.eql(fn.proxyConfig); 20 | } 21 | 22 | }) 23 | }); 24 | 25 | const defaultConfig = { 26 | "backends": { 27 | "getting-started": { 28 | "type": "origin", 29 | "origin": "https://getting-started.edgeapp.net", 30 | "headers": { 31 | "host": "getting-started.edgeapp.net" 32 | } 33 | }, 34 | "glitch": { 35 | "type": "glitch", 36 | "appName": "fly-example" 37 | } 38 | }, 39 | "rules": [ 40 | { 41 | "actionType": "rewrite", 42 | "backendKey": "getting-started" 43 | } 44 | ], 45 | "middleware": [ 46 | { 47 | "type": "https-upgrader" 48 | } 49 | ] 50 | }; -------------------------------------------------------------------------------- /test/config/rules.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { PathPatternMatcher } from "../../src/config/rules"; 3 | 4 | 5 | it("replaces a matched pattern", () => { 6 | const pathPattern = new PathPatternMatcher("/first/:first/second/:second/rest/*rest") 7 | 8 | const path = "/first/1/second/2/rest/a/b/c/1/2/3" 9 | expect(pathPattern.match(path)).to.be.true 10 | 11 | expect(pathPattern.parse(path)).to.eql({ first: "1", second: "2", rest: "a/b/c/1/2/3" }) 12 | 13 | expect(pathPattern.replace(path, "/$first/$second/$rest")).to.equal("/1/2/a/b/c/1/2/3") 14 | }) 15 | 16 | it("ensures a match", () => { 17 | const pathPattern = new PathPatternMatcher("/first/:first/second/:second/rest/*rest") 18 | 19 | const path = "/first/1/second/2" 20 | expect(pathPattern.match(path)).to.be.false 21 | }) -------------------------------------------------------------------------------- /test/data.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as data from "../src/data"; 3 | import { FetchFunction } from '../src/fetch'; 4 | 5 | import db from "@fly/v8env/lib/fly/data"; 6 | import cache from "@fly/v8env/lib/fly/cache"; 7 | 8 | describe("restAPI", () => { 9 | const paths:Record = { 10 | "/": data.restAPI("asdf"), 11 | "/with/a/base/path/": data.restAPI({authToken: "asdf", basePath: "/with/a/base/path/"}) 12 | } 13 | 14 | for(const path of Object.getOwnPropertyNames(paths)){ 15 | const api = paths[path]; 16 | const colName = "testCollection"; 17 | const testKey = `key-${new Date().getTime()}`; 18 | const collection = db.collection(colName); 19 | const cacheKey = `db.${colName}(${testKey})`; 20 | 21 | it(`GET ${path}${colName}/${testKey}`, async ()=>{ 22 | const value = JSON.stringify({time: new Date().getTime()}); 23 | await collection.put(testKey, value); 24 | 25 | const resp = await api(`http://api${path}${colName}/${testKey}`, { 26 | headers: {"Authorization": "Bearer asdf"} 27 | }) 28 | expect(resp.status).to.eq(200); 29 | 30 | const body = await resp.text(); 31 | expect(body).to.eq(value); 32 | 33 | const cacheValue = await cache.getString(cacheKey); 34 | expect(cacheValue).to.eq(body); 35 | }) 36 | 37 | it(`GET (missing) ${path}${colName}/${testKey}`, async ()=>{ 38 | const resp = await api(`http://api${path}${colName}/${testKey}-asdf`, { 39 | headers: {"Authorization": "Bearer asdf"} 40 | }) 41 | expect(resp.status).to.eq(404); 42 | }) 43 | 44 | it(`DELETE ${path}${colName}/${testKey}`, async ()=>{ 45 | const value = JSON.stringify({time: new Date().getTime()}); 46 | await collection.put(testKey, value); 47 | 48 | const resp = await api(`http://api${path}${colName}/${testKey}`, { 49 | method: "DELETE", 50 | headers: {"Authorization": "Bearer asdf"} 51 | }) 52 | expect(resp.status).to.eq(204); 53 | 54 | const v = await collection.get(testKey); 55 | expect(v).to.eq(null); 56 | }) 57 | 58 | it(`PUT ${path}${colName}/${testKey}`, async ()=>{ 59 | const value = JSON.stringify({time: new Date().getTime()}); 60 | 61 | const resp = await api(`http://api${path}${colName}/${testKey}`, { 62 | method: "PUT", 63 | body: value, 64 | headers: {"Authorization": "Bearer asdf"} 65 | }) 66 | 67 | expect(resp.status).to.eq(201); 68 | 69 | const v = JSON.stringify(await collection.get(testKey)); 70 | expect(v).to.eq(value); 71 | }) 72 | 73 | it('expires cache on PUT', async ()=>{ 74 | cache.set(cacheKey, "hocuspocus"); 75 | 76 | const value = JSON.stringify({time: new Date().getTime()}); 77 | 78 | const resp = await api(`http://api${path}${colName}/${testKey}`, { 79 | method: "PUT", 80 | body: value, 81 | headers: {"Authorization": "Bearer asdf"} 82 | }) 83 | 84 | expect(resp.status).to.eq(201); 85 | const cacheValue = await cache.getString(cacheKey); 86 | expect(cacheValue).to.eq(null); 87 | }) 88 | } 89 | }) -------------------------------------------------------------------------------- /test/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/edge/5cb72c65463ce0c5b4aa7edaf7b790e13faa3793/test/fixtures/image.png -------------------------------------------------------------------------------- /test/middleware/auto-webp.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { autoWebp } from "../../src/middleware"; 3 | import { Image } from "@fly/v8env/lib/fly/image"; 4 | 5 | const img = require("../fixtures/image.png"); 6 | 7 | async function mock(req: RequestInfo){ 8 | const url = new URL(typeof req === "string" ? req : req.url); 9 | 10 | if(url.pathname === "/text"){ 11 | return new Response("this is some unaltered text", { headers: {"content-type": "text/plain"}}); 12 | } 13 | if(url.pathname === "/image.png"){ 14 | return new Response(img, { headers: {"content-type": "image/png"}}); 15 | } 16 | return new Response("not found", { status: 404 }) 17 | } 18 | 19 | describe("middleware/autoWebp", function() { 20 | it("successfully lets non-image responses pass through", async ()=>{ 21 | const fetch = autoWebp(mock); 22 | const resp = await fetch("https://testing/text"); 23 | expect(resp.status).to.eq(200); 24 | expect(resp.headers.get("content-type")).to.eq("text/plain"); 25 | }) 26 | 27 | it("serves webp when accept includes image/webp", async ()=>{ 28 | const fetch = autoWebp(mock) 29 | const resp = await fetch("https://testing/image.png", { 30 | headers: { 31 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" // a typical chrome accept header 32 | } 33 | }) 34 | const body = await resp.arrayBuffer(); 35 | expect(resp.status).to.eq(200) 36 | expect(resp.headers.get("content-type")).to.eq("image/webp") 37 | expect(body.byteLength).to.not.eq(img.byteLength, "webp image data should be smaller than source png") 38 | 39 | const transformed = new Image(body); 40 | const meta = transformed.metadata(); 41 | 42 | expect(meta.format).to.eq("webp"); 43 | }) 44 | it("doesn't serve webp when header is missing", async ()=>{ 45 | const fetch = autoWebp(mock) 46 | const resp = await fetch("https://testing/image.png", { 47 | headers: { 48 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" // a typical chrome accept header 49 | } 50 | }) 51 | const body = await resp.arrayBuffer(); 52 | expect(resp.status).to.eq(200) 53 | expect(resp.headers.get("content-type")).to.eq("image/png") 54 | expect(body.byteLength).to.be.eq(img.byteLength, "untouched image data should be the same as the original") 55 | }) 56 | }) -------------------------------------------------------------------------------- /test/middleware/http-cache.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { httpCache } from "../../src/middleware"; 3 | 4 | function withHeaders(headers:any){ 5 | return async function fetchWithHeaders(..._:any[]){ 6 | const resp = new Response(`response at: ${Date.now()}`) 7 | for(const k of Object.getOwnPropertyNames(headers)){ 8 | const v = headers[k]; 9 | resp.headers.set(k, v); 10 | } 11 | return resp; 12 | } 13 | } 14 | 15 | const noCacheHeaders = [ 16 | ["response", {"vary": "any", "cache-control": "public, max-age=3600"}], 17 | ["response", {"cache-control": "no-cache"}], 18 | ["response", {"cache-control": "public, max-age=0"}], 19 | ["request", { "authorization": "blerp" }], 20 | ["request", { "cookie": "blerp" }] 21 | ] 22 | describe("middleware/httpCache", function() { 23 | for(const [type, h] of noCacheHeaders){ 24 | it(`doesn't cache ${type} headers: ` + JSON.stringify(h), async () => { 25 | const responseHeaders = type === "response" ? h : { "cache-control": "public, max-age=3600"}; 26 | const fn = httpCache( 27 | withHeaders(Object.assign({}, responseHeaders)) 28 | ); 29 | 30 | const requestHeaders = type === "request" ? h : {}; 31 | const resp = await fn(`http://anyurl.com/asdf-${Math.random()}`, { headers: requestHeaders}); 32 | 33 | expect(resp.status).to.eq(200); 34 | expect(resp.headers.get("fly-cache")).is.null; 35 | }); 36 | } 37 | 38 | it("properly sets fly-cache to miss when cache happens", async () => { 39 | const fn = httpCache( 40 | withHeaders({ 41 | "cache-control": "public, max-age=3600" 42 | }) 43 | ); 44 | const resp1 = await fn("http://anyurl.com/cached-url"); 45 | const resp2 = await fn("http://anyurl.com/cached-url"); 46 | 47 | expect(resp1.headers.get('fly-cache')).to.eq('miss'); 48 | expect(resp2.headers.get("fly-cache")).to.eq('hit') 49 | 50 | const [body1, body2] = await Promise.all([ 51 | resp1.text(), 52 | resp2.text() 53 | ]); 54 | 55 | expect(body1).to.eq(body2); 56 | }) 57 | 58 | it("accepts max-age overrides", async () => { 59 | const generator = httpCache.configure({ overrideMaxAge: 100}) 60 | const fn = generator(withHeaders({ 61 | "cache-control": "public, max-age=0" 62 | })) 63 | 64 | const resp1 = await fn("http://anyurl.com/cached-url-max-age"); 65 | const resp2 = await fn("http://anyurl.com/cached-url-max-age"); 66 | 67 | expect(resp1.headers.get('fly-cache')).to.eq('miss'); 68 | expect(resp2.headers.get("fly-cache")).to.eq('hit') 69 | }) 70 | // it("redirects with default options", async ()=>{ 71 | // const fn = httpsUpgrader(echo); 72 | // const resp = await fn("http://wat/") 73 | // expect(resp.status).to.eq(302) 74 | // expect(resp.headers.get("location")).to.eq("https://wat/") 75 | // }) 76 | 77 | // it("redirects with options", async () =>{ 78 | // const fn = httpsUpgrader(echo, {status: 307, text: "hurrdurr"}); 79 | // const resp = await fn("http://wat/") 80 | // const body = await resp.text() 81 | // expect(resp.status).to.eq(307) 82 | // expect(body).to.eq("hurrdurr") 83 | // expect(resp.headers.get("location")).to.eq("https://wat/") 84 | // }) 85 | 86 | // it("skips redirect in dev mode", async () => { 87 | // app.env = "development" 88 | // const fn = httpsUpgrader(echo); 89 | // const resp = await fn("http://wat/") 90 | // expect(resp.status).to.eq(200) 91 | // expect(resp.headers.get("location")).to.be.null 92 | // app.env = "test" 93 | // }) 94 | }) -------------------------------------------------------------------------------- /test/middleware/https-upgrader.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { httpsUpgrader } from "../../src/middleware"; 3 | import { echo } from "../../src/backends"; 4 | 5 | 6 | describe("middleware/httpsUpgrader", function() { 7 | it("redirects with default options", async ()=>{ 8 | const fn = httpsUpgrader(echo); 9 | const resp = await fn("http://wat/") 10 | expect(resp.status).to.eq(302) 11 | expect(resp.headers.get("location")).to.eq("https://wat/") 12 | }) 13 | 14 | it("redirects with options", async () =>{ 15 | const fn = httpsUpgrader(echo, {status: 307, text: "hurrdurr"}); 16 | const resp = await fn("http://wat/") 17 | const body = await resp.text() 18 | expect(resp.status).to.eq(307) 19 | expect(body).to.eq("hurrdurr") 20 | expect(resp.headers.get("location")).to.eq("https://wat/") 21 | }) 22 | 23 | it("skips redirect in dev mode", async () => { 24 | app.env = "development" 25 | const fn = httpsUpgrader(echo); 26 | const resp = await fn("http://wat/") 27 | expect(resp.status).to.eq(200) 28 | expect(resp.headers.get("location")).to.be.null 29 | app.env = "test" 30 | }) 31 | }) -------------------------------------------------------------------------------- /test/middleware/response-headers.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { responseHeaders } from "../../src/middleware"; 3 | import { echo } from "../../src/backends"; 4 | 5 | 6 | describe("middleware/responseHeaders", function() { 7 | it("sets a header with a string value", async ()=>{ 8 | const fn = responseHeaders(echo, { "Powered-By": "Caffeine" }); 9 | const resp = await fn("http://wat/") 10 | expect(resp.headers.get("powered-by")).to.eq("Caffeine") 11 | }) 12 | it("removes a header with a `false` value", async ()=>{ 13 | const fn = responseHeaders(echo, { "content-type": false }); 14 | const resp = await fn("http://wat/") 15 | expect(resp.headers.get("content-type")).to.be.null 16 | }) 17 | it("ignores a header setting with a `true` value", async ()=>{ 18 | const fn = responseHeaders(echo, { "content-type": true }); 19 | const resp = await fn("http://wat/") 20 | expect(resp.headers.get("content-type")).to.eq("application/json") 21 | }) 22 | }) -------------------------------------------------------------------------------- /test/pipeline.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | 3 | import { pipeline, FetchFunction } from "../src" 4 | 5 | function outer(fetch: FetchFunction) { 6 | return async function outerFetch(req: RequestInfo, init?: RequestInit) { 7 | (req as any).headers.set("Outer-Fn", "woop!") 8 | return fetch(req, init) 9 | } 10 | } 11 | 12 | function inner(fetch: FetchFunction) { 13 | return async function innerFetch(req: RequestInfo, init?: RequestInit) { 14 | (req as any).headers.set("Inner-Fn", "woowoo!") 15 | return fetch(req, init) 16 | } 17 | } 18 | 19 | async function echo(req: RequestInfo, init?: RequestInit) { 20 | const headers: any = Object.assign( 21 | { 22 | stages: JSON.stringify( 23 | fn.stages.map(s => { 24 | if (typeof s === "function") { 25 | return s.name 26 | } 27 | return typeof s 28 | }) 29 | ) 30 | }, 31 | (req as any).headers.toJSON() 32 | ) 33 | return new Response("hi", { headers: headers }) 34 | } 35 | 36 | const p = pipeline(outer, inner) 37 | const fn = p(echo) 38 | 39 | describe("pipeline", () => { 40 | it("should make stages available", () => { 41 | expect(p.stages).to.exist 42 | expect(p.stages.length).to.eq(2) 43 | expect(p.stages[0]).to.eq(outer) 44 | expect(p.stages[1]).to.eq(inner) 45 | }) 46 | 47 | it("should make stages available after fetch is generated", () => { 48 | expect(fn.stages).to.exist 49 | expect(fn.stages.length).to.eq(2) 50 | expect(fn.stages[0]).to.eq(outer) 51 | expect(fn.stages[1]).to.eq(inner) 52 | }) 53 | 54 | it("should run pipeline functions", async () => { 55 | const resp = await fn(new Request("http://localhost")) 56 | expect(resp.headers.get("Outer-Fn")).to.eq("woop!") 57 | expect(resp.headers.get("Inner-Fn")).to.eq("woowoo!") 58 | expect(resp.headers.get("stages")).to.eq('["outer","inner"]') 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/proxy.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | 3 | import * as proxy from "../src/proxy" 4 | 5 | const origin = "https://fly.io/proxy/" 6 | const req = new Request("https://wat.com/path/to/thing", { headers: { host: "notwat.com" } }) 7 | describe("proxy", () => { 8 | it("includes host header and base path properly", () => { 9 | const breq = proxy.buildProxyRequest(origin, {}, req) 10 | const url = new URL(breq.url) 11 | expect(breq.headers.get("host")).to.eq("fly.io") 12 | expect(breq.headers.get("x-forwarded-host")).to.eq("notwat.com") 13 | expect(url.pathname).to.eq("/proxy/path/to/thing") 14 | }) 15 | 16 | it("includes host header from request when forwardHostHeader", () => { 17 | const breq = proxy.buildProxyRequest(origin, { forwardHostHeader: true }, req) 18 | const url = new URL(breq.url) 19 | expect(breq.headers.get("host")).to.eq("notwat.com") 20 | expect(breq.headers.get("x-forwarded-host")).to.eq("notwat.com") 21 | expect(url.pathname).to.eq("/proxy/path/to/thing") 22 | }) 23 | 24 | it("rewrites paths properly", () => { 25 | const breq = proxy.buildProxyRequest(origin, { stripPath: "/path/to/" }, req) 26 | const url = new URL(breq.url) 27 | expect(url.pathname).to.eq("/proxy/thing") 28 | }) 29 | 30 | describe("location header", () =>{ 31 | it("rewrites location when path stripped", () => { 32 | const url = "http://test.com/blog/asdf" 33 | const burl = "http://origin.com/asdf" 34 | let resp = new Response("hi", { headers: { location: "/wakka"}}) 35 | 36 | resp = proxy.rewriteLocationHeader(url, burl, resp) 37 | 38 | expect(resp.headers.get("location")).to.eq("http://test.com/blog/wakka") 39 | }) 40 | 41 | it("rewrites location when path prefixed", () => { 42 | const url = "http://test.com/asdf" 43 | const burl = "http://origin.com/blog/asdf" 44 | 45 | let resp = new Response("hi", { headers: { location: "/blog/wakka"}}) 46 | 47 | resp = proxy.rewriteLocationHeader(url, burl, resp) 48 | 49 | expect(resp.headers.get("location")).to.eq("http://test.com/wakka") 50 | }) 51 | 52 | it("leaves unrelated header alone", () =>{ 53 | const url = "http://test.com/blog/asdf" 54 | const burl = "http://origin.com/asdf" 55 | 56 | let resp = new Response("hi", { headers: { location: "http://another.com/wakka"}}) 57 | 58 | resp = proxy.rewriteLocationHeader(url, burl, resp) 59 | 60 | expect(resp.headers.get("location")).to.eq("http://another.com/wakka") 61 | }) 62 | }) 63 | 64 | describe("errors", () => { 65 | const badOrigin = async (req: RequestInfo): Promise => { 66 | req = typeof req === "string" ? new Request(req) : req; 67 | const url = new URL(req.url) 68 | const retry = parseInt(req.headers.get("Fly-Proxy-Retry") || "0") 69 | 70 | if(retry < 2 && url.pathname === "/socket-hang-up"){ 71 | throw new Error("socket hang up") 72 | } 73 | return new Response("ok") 74 | } 75 | it("returns a 503 and not an exception by default", async () => { 76 | const fn = proxy.proxy("http://wat", { fetch: badOrigin}) 77 | const resp = await fn("http://wat/socket-hang-up") 78 | expect(resp.status).to.eq(503) 79 | }) 80 | it("throws errors when errorTo503=false", async () => { 81 | const fn = proxy.proxy("http://wat", { fetch: badOrigin, errorTo503: false}) 82 | expect(async ()=> await fn("http://wat/socket-hang-up")).to.throw; 83 | }) 84 | 85 | it("retries failed requests", async () => { 86 | const fn = proxy.proxy("http://wat", { fetch: badOrigin, retries: 10}) 87 | const resp = await fn("http://wat/socket-hang-up") 88 | expect(resp.status).to.eq(200) 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*.ts" 4 | ], 5 | "compilerOptions": { 6 | "target": "esnext", 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "lib": [ 10 | "es2017", 11 | "dom" 12 | ], 13 | "outDir": "./lib", 14 | "declaration": true, 15 | "strict": true, 16 | "noImplicitAny": true, 17 | "esModuleInterop": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "rootDirs": [ 21 | "./src", 22 | "./test", 23 | ], 24 | "baseUrl": ".", 25 | "paths": { 26 | "v8env/src/fly/*": ["./node_modules/@fly/v8env/lib/fly/*"], 27 | "@fly/edge": ["./src/"] 28 | } 29 | }, 30 | "typedocOptions": { 31 | "out": "./docs/", 32 | "includeDeclarations": true, 33 | "excludeExternals": true, 34 | "excludeNotExported": true, 35 | "mode": "modules" 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /webpack.fly.config.js: -------------------------------------------------------------------------------- 1 | let path = require('path') 2 | 3 | module.exports = { 4 | entry: "./index.ts", 5 | resolve: { 6 | extensions: ['.js', '.ts', '.tsx', '.png', '.jpg', '.gif', '.svg'], 7 | alias: { 8 | crypto: path.resolve(__dirname, 'src', 'shims', 'crypto'), 9 | } 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.tsx?$/, 15 | loader: 'ts-loader' 16 | }, 17 | { 18 | test: /\.(ico|svg|png|jpg|gif)$/, 19 | use: ['arraybuffer-loader', 'image-webpack-loader'] 20 | } 21 | ] 22 | } 23 | } --------------------------------------------------------------------------------