├── .gitignore ├── examples ├── edge-cache-html │ ├── docs │ │ ├── workers-kv.png │ │ ├── workers-code.png │ │ ├── workers-routes.png │ │ ├── workers-scripts.png │ │ ├── dashboard-profile.png │ │ ├── dashboard-workers.png │ │ ├── dashboard-zone-id.png │ │ ├── workers-kv-bound.png │ │ ├── workers-resources.png │ │ ├── workers-create-route.png │ │ ├── workers-kv-binding.png │ │ └── dashboard-workers-noedit.png │ ├── WordPress Plugin │ │ ├── cloudflare-page-cache.zip │ │ ├── docs │ │ │ ├── wp-plugin-activate.png │ │ │ ├── wp-plugin-add-button.png │ │ │ ├── wp-plugin-installed.png │ │ │ ├── wp-plugin-install-now.png │ │ │ ├── wp-plugin-upload-button.png │ │ │ └── wp-plugin-upload-select.png │ │ ├── cloudflare-page-cache │ │ │ ├── readme.txt │ │ │ └── cloudflare-page-cache.php │ │ └── README.md │ ├── README.md │ └── edge-cache-html.js ├── cryptocurrency-slack-bot │ ├── bot-demo.gif │ ├── LICENSE │ ├── README.md │ └── index.js ├── security │ ├── ingore-post-and-put.js │ ├── prevent-ip.js │ └── deny-robot.js ├── redirect │ ├── custom-headers.js │ └── device-type.js ├── fast-google-fonts │ ├── README.md │ ├── LICENSE │ └── fast-google-fonts.js ├── aggregate-multiple-requests.js ├── third-party-scripts │ ├── README.md │ ├── LICENSE │ └── third-party-scripts.js └── ab-test.js ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-kv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-kv.png -------------------------------------------------------------------------------- /examples/cryptocurrency-slack-bot/bot-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/cryptocurrency-slack-bot/bot-demo.gif -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-code.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-routes.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-scripts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-scripts.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/dashboard-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/dashboard-profile.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/dashboard-workers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/dashboard-workers.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/dashboard-zone-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/dashboard-zone-id.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-kv-bound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-kv-bound.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-resources.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-create-route.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-create-route.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/workers-kv-binding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/workers-kv-binding.png -------------------------------------------------------------------------------- /examples/edge-cache-html/docs/dashboard-workers-noedit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/docs/dashboard-workers-noedit.png -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/cloudflare-page-cache.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/WordPress Plugin/cloudflare-page-cache.zip -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-activate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-activate.png -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-add-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-add-button.png -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-installed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-installed.png -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-install-now.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-install-now.png -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-upload-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-upload-button.png -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-upload-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/worker-examples/main/examples/edge-cache-html/WordPress Plugin/docs/wp-plugin-upload-select.png -------------------------------------------------------------------------------- /examples/security/ingore-post-and-put.js: -------------------------------------------------------------------------------- 1 | // Ignore POST and PUT HTTP requests. 2 | // This snippet allows all other requests to pass through to the origin. 3 | 4 | addEventListener('fetch', event => { 5 | event.respondWith(fetchAndApply(event.request)) 6 | }) 7 | 8 | async function fetchAndApply(request) { 9 | if (request.method === 'POST' || request.method === 'PUT') { 10 | return new Response('Sorry, this page is not available via that method.', 11 | { status: 403, statusText: 'Forbidden' }) 12 | } 13 | 14 | return fetch(request) 15 | } 16 | -------------------------------------------------------------------------------- /examples/security/prevent-ip.js: -------------------------------------------------------------------------------- 1 | // Blacklist IP addresses. 2 | // This snippet of code prevents a specific IP, 3 | // in this case '225.0.0.1', from connecting to the origin. 4 | 5 | addEventListener('fetch', event => { 6 | event.respondWith(fetchAndApply(event.request)) 7 | }) 8 | 9 | async function fetchAndApply(request) { 10 | if (request.headers.get('cf-connecting-ip') === '225.0.0.1') { 11 | return new Response('Sorry, this page is not available.', 12 | { status: 403, statusText: 'Forbidden' }) 13 | } 14 | 15 | return fetch(request) 16 | } 17 | -------------------------------------------------------------------------------- /examples/security/deny-robot.js: -------------------------------------------------------------------------------- 1 | // Protect your origin from unwanted spiders or crawlers. 2 | // In this case, if the user-agent is "annoying-robot", 3 | // the worker returns the response instead of sending the request to the origin. 4 | 5 | addEventListener('fetch', event => { 6 | event.respondWith(fetchAndApply(event.request)) 7 | }) 8 | 9 | async function fetchAndApply(request) { 10 | if (request.headers.get('user-agent').includes('annoying_robot')) { 11 | return new Response('Sorry, this page is not available.', 12 | { status: 403, statusText: 'Forbidden' }) 13 | } 14 | 15 | return fetch(request) 16 | } 17 | -------------------------------------------------------------------------------- /examples/redirect/custom-headers.js: -------------------------------------------------------------------------------- 1 | // Redirect based on custom request headers 2 | 3 | addEventListener('fetch', event => { 4 | event.respondWith(fetchAndApply(event.request)) 5 | }) 6 | 7 | async function fetchAndApply(request) { 8 | let suffix = '' 9 | 10 | // Check for a custom header sent by the client 11 | if (request.headers.get('X-Dev-Mode')){ 12 | suffix = '/dev' 13 | } 14 | 15 | const init = { 16 | method: request.method, 17 | headers: request.headers 18 | } 19 | const modifiedRequest = new Request(request.url + suffix, init) 20 | return fetch(modifiedRequest) 21 | } 22 | -------------------------------------------------------------------------------- /examples/redirect/device-type.js: -------------------------------------------------------------------------------- 1 | // Redirect based on the device type 2 | 3 | addEventListener('fetch', event => { 4 | event.respondWith(fetchAndApply(event.request)) 5 | }) 6 | 7 | async function fetchAndApply(request) { 8 | const init = { 9 | method: request.method, 10 | headers: request.headers 11 | } 12 | 13 | let uaSuffix = '' 14 | 15 | const ua = request.headers.get('user-agent') 16 | if (ua.match('/iphone/i') || ua.match('/ipod/i')) { 17 | uaSuffix = '/mobile' 18 | } else if (ua.match('/ipad/i')) { 19 | uaSuffix = '/tablet' 20 | } 21 | 22 | const modifiedRequest = new Request(request.url+uaSuffix, init) 23 | return fetch(modifiedRequest) 24 | } 25 | -------------------------------------------------------------------------------- /examples/fast-google-fonts/README.md: -------------------------------------------------------------------------------- 1 | # Fast Google Fonts 2 | 3 | Significantly improves performance of Google Fonts: 4 | 5 | * Rewrites the HTML and puts the browser-specific Google Fonts CSS directly in the HTML (eliminating the round trips for fetching the CSS and improving overall page rendering performance). 6 | * Proxies the font files through the same domain as the page, eliminating the round-trips to connect to Google's servers and allowing HTTP/2 priorities to work correctly. 7 | 8 | The code also provides an example of how to do streaming HTML rewriting effectively in a Cloudflare Worker. 9 | 10 | ## Testing 11 | 12 | For testing purposes it also supports turning off the worker through query parameters added to the page URL: 13 | 14 | * ```cf-worker=bypass``` disables all rewriting. i.e. ```https://www.example.com/?cf-worker=bypass``` 15 | 16 | ## License 17 | 18 | BSD 3-Clause licensed. See the LICENSE file for details. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Worker Recipes 2 | 3 | Cloudflare Workers make it possible to write Javascript which runs on Cloudflare’s network around the world. Using Workers you can build 4 | services which run exceptionally close to your users. You can also intercept any request which would ordinarily travel through 5 | Cloudflare to your origin, and modify it in any way you need. Workers can make requests to arbitrary resources on the Internet, 6 | can perform cryptography using the WebCrypto API, and can do almost anything you might be used to configuring your CDN 7 | to accomplish. 8 | 9 | This repository is intended to contain examples of how Workers can be used to accomplish common tasks. **You are welcome to use, modify, 10 | and extend this code!** If you have an additional example you think would be valuable, please submit a pull request. 11 | 12 | Questions about Workers can be addressed on our [community site](https://community.cloudflare.com/tags/workers)! 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, Cloudflare, Inc. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/aggregate-multiple-requests.js: -------------------------------------------------------------------------------- 1 | // Make multiple requests, aggregate the responses and 2 | // send it back as a single response. 3 | 4 | addEventListener('fetch', event => { 5 | event.respondWith(fetchAndLog(event.request)) 6 | }) 7 | 8 | async function fetchAndLog(request) { 9 | const init = { 10 | method: 'GET', 11 | headers: {'Authorization': 'XXXXXX', 'Content-Type': 'text/plain'} 12 | } 13 | const [btcResp, ethResp, ltcResp] = await Promise.all([ 14 | fetch('https://api.coinbase.com/v2/prices/BTC-USD/spot', init), 15 | fetch('https://api.coinbase.com/v2/prices/ETH-USD/spot', init), 16 | fetch('https://api.coinbase.com/v2/prices/LTC-USD/spot', init) 17 | ]) 18 | 19 | const btc = await btcResp.json() 20 | const eth = await ethResp.json() 21 | const ltc = await ltcResp.json() 22 | 23 | let combined = {} 24 | combined['btc'] = btc['data'].amount 25 | combined['ltc'] = ltc['data'].amount 26 | combined['eth'] = eth['data'].amount 27 | 28 | const responseInit = { 29 | status: 200, 30 | headers: {'Content-Type': 'application/json'} 31 | } 32 | return new Response(JSON.stringify(combined), responseInit) 33 | } 34 | -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/cloudflare-page-cache/readme.txt: -------------------------------------------------------------------------------- 1 | === Cloudflare Page Cache === 2 | Contributors: patrickmeenan, jwineman, furkan811, icyapril, manatarms 3 | Tags: cache,performance,speed,cloudflare 4 | Requires at least: 3.3.1 5 | Tested up to: 5.2 6 | Requires PHP: 5.2.4 7 | Stable tag: trunk 8 | License: GPLv2 or later 9 | License URI: http://www.gnu.org/licenses/gpl-2.0.html 10 | 11 | Adds support for caching pages on Cloudflare and automatic purging when content changes. 12 | 13 | == Description == 14 | Integrates with the "[Edge Cache HTML](https://github.com/cloudflare/worker-examples/tree/master/examples/edge-cache-html)" Cloudflare Worker to edge-cache the generated HTML for anonymous users (not logged-in) resulting in huge performance gains, particularly on slower hosting. 15 | 16 | == Installation == 17 | # FROM YOUR WORDPRESS DASHBOARD 18 | 1. Visit “Plugins” → Add New 19 | 1. Search for "Cloudflare Page Cache" 20 | 1. Activate Cloudflare Page Cache from your Plugins page. 21 | 22 | # FROM WORDPRESS.ORG 23 | 1. Download [Cloudflare Page Cache](https://wordpress.org/plugins/cloudflare-page-cache/) 24 | 1. Upload the “cloudflare-page-cache” directory to your “/wp-content/plugins/” directory, using ftp, sftp, scp etc. 25 | 1. Activate Cloudflare Page Cache from your Plugins page. -------------------------------------------------------------------------------- /examples/third-party-scripts/README.md: -------------------------------------------------------------------------------- 1 | # Third Party Scripts 2 | 3 | Significantly improves performance of common static third-party scripts: 4 | 5 | * Rewrites the HTML to replace the third-party script URLs with URLs that are proxied through the origin and appends a content hash to the URL. 6 | * On the proxied requests it returns the proxied request, stripping most headers and extending the cache to 1 year (which is safe to do since the URL will change if the content ever changes) 7 | 8 | This provides a few big performance and reliability benefits: 9 | 10 | * Browsers will not have to establish new connections to the third-party origin (saving 3 round trips). 11 | * On HTTP/2 connections the scripts will be properly prioritized relative to the other page content. 12 | * Browsers will only re-request the content if the content itself actually changes, independent of the original cache settings. 13 | * If there are connectivity problems between the browser and the third-party or availability problems with the third-party those will be bypassed since the content is cached on the Cloudflare edge. 14 | 15 | ## Testing 16 | 17 | For testing purposes it also supports turning off the worker through query parameters added to the page URL: 18 | 19 | * ```cf-worker=bypass``` disables all rewriting. i.e. ```https://www.example.com/?cf-worker=bypass``` 20 | 21 | ## License 22 | 23 | BSD 3-Clause licensed. See the LICENSE file for details. -------------------------------------------------------------------------------- /examples/fast-google-fonts/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation and/or 11 | other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /examples/third-party-scripts/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation and/or 11 | other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /examples/cryptocurrency-slack-bot/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation and/or 11 | other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /examples/ab-test.js: -------------------------------------------------------------------------------- 1 | // Serve different variants of your site to different visitors 2 | 3 | addEventListener('fetch', event => { 4 | event.respondWith(fetchAndApply(event.request)) 5 | }) 6 | 7 | async function fetchAndApply(request) { 8 | const name = 'experiment-0' 9 | let group // 'control' or 'test', set below 10 | let isNew = false // is the group newly-assigned? 11 | 12 | // Determine which group this request is in. 13 | const cookie = request.headers.get('Cookie') 14 | if (cookie && cookie.includes(`${name}=control`)) { 15 | group = 'control' 16 | } else if (cookie && cookie.includes(`${name}=test`)) { 17 | group = 'test' 18 | } else { 19 | // 50/50 Split 20 | group = Math.random() < 0.5 ? 'control' : 'test' 21 | isNew = true 22 | } 23 | 24 | // We'll prefix the request path with the experiment name. This way, 25 | // the origin server merely has to have two copies of the site under 26 | // top-level directories named "control" and "test". 27 | let url = new URL(request.url) 28 | url.pathname = `/${group}${url.pathname}` 29 | 30 | const modifiedRequest = new Request(url, { 31 | method: request.method, 32 | headers: request.headers 33 | }) 34 | 35 | const response = await fetch(modifiedRequest) 36 | 37 | if (isNew) { 38 | // The experiment was newly-assigned, so add a Set-Cookie header 39 | // to the response. 40 | const newHeaders = new Headers(response.headers) 41 | newHeaders.append('Set-Cookie', `${name}=${group}`) 42 | return new Response(response.body, { 43 | status: response.status, 44 | statusText: response.statusText, 45 | headers: newHeaders 46 | }) 47 | } else { 48 | // Return response unmodified. 49 | return response 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/cryptocurrency-slack-bot/README.md: -------------------------------------------------------------------------------- 1 | # Cryptocurrency Slack Bot 2 | 3 | A [Slack bot](https://api.slack.com/slash-commands) for fetching the latest cryptocurrency prices, built entirely using [Cloudflare Workers](https://developers.cloudflare.com/workers/), Cloudflare's serverless platform 4 | 5 | * It uses the [CoinMarketCap API](https://coinmarketcap.com/api/) to fetch crypto-currency prices 6 | * It caches a map of "ticker" codes (e.g. "BTC" or "ETH") to their public identifiers 7 | * ... and it uses Cloudflare's cache to minimize the need to hit the API on every invocation, whilst still serving recent price data. 8 | 9 | ## Demo 10 | 11 | The bot easy to use: ask it for the price of your favorite cryptocurrency! 12 | 13 | ![Bot demo](bot-demo.gif) 14 | 15 | Ticker symbols and names rely on the currencies listed at [CoinMarketCap](https://coinmarketcap.com/all/views/all/). 16 | 17 | ## Setting up the bot 18 | 19 | Setting up the bot as a Worker in your own Cloudflare account is easy. You'll need a Cloudflare account with Workers enabled (via the "Workers" app in the dashboard). 20 | 21 | 1. [Create a Slack app](https://api.slack.com/slack-apps#creating_apps) as a slash command 22 | 2. Create the Worker: copy the contents of `index.js` into a new Worker script 23 | 3. Fetch the 'verification token' from your newly created Slack app, and replace the contents of `SLACK_TOKEN` at the top of your script. 24 | 4. Create a new route that invokes the Worker (must be HTTPS) - e.g. `https://bots.example.com/cryptocurrencybot*` 25 | 5. Update the Webhook URL in your Slack app configuration with this URL (minus the \*) 26 | 6. Add the app [to your workspace](https://get.slack.help/hc/en-us/articles/202035138-Add-an-app-to-your-workspace). 27 | 28 | ## License 29 | 30 | BSD 3-Clause licensed. See the LICENSE file for details. 31 | -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/README.md: -------------------------------------------------------------------------------- 1 | # WordPress Page Cache Plugin 2 | Integrates with the [Edge Cache HTML worker](..) script to cache WordPress pages on the edge: 3 | 4 | * Caches HTML on the Cloudflare edge for visitors that aren't logged-in (logged-in users bypass the cache). 5 | * Automatically invalidates the cache when content is changed (including changes to the themes). 6 | 7 | The plugin requires no configuration once installed and activated. As long as the plugin is active and the Worker is running for the domain the edge-caching support is enabled. 8 | 9 | # Plugin Installation 10 | 11 | The plugin is not (yet) available in the WordPress plugin directory and must be uploaded manually by an administrator. 12 | 13 | Log into the WordPress dashboard for the site, go to the "Plugins" section and click on the "Add New" button: 14 | 15 | ![Add Plugin](docs/wp-plugin-add-button.png) 16 | 17 | From the gallery page click the "Upload Plugin" button to manually upload the plugin: 18 | 19 | ![Upload Plugin](docs/wp-plugin-upload-button.png) 20 | 21 | From the upload page select the "Choose File" button and select the [plugin zip file](https://github.com/cloudflare/worker-examples/raw/master/examples/edge-cache-html/WordPress%20Plugin/cloudflare-page-cache.zip) downloaded from this repository: 22 | 23 | ![Select Plugin Zip File](docs/wp-plugin-upload-select.png) 24 | 25 | Select the "Install Now" button to upload and install the plugin: 26 | 27 | ![Install Plugin](docs/wp-plugin-install-now.png) 28 | 29 | After the plugin has been uploaded and installed it will still need to be activated. Click the "Activate Plugin" button from the install page. 30 | 31 | ![Activate Plugin](docs/wp-plugin-activate.png) 32 | 33 | Once activated the plugin will be listed in the plugins page with a "Deactivate" link below it. At this point the plugin is installed and working. 34 | 35 | ![Activated](docs/wp-plugin-installed.png) 36 | 37 | # Updating Plugin 38 | 39 | To update the plugin after it has been installed and activated you first need to deactivate and delete the installed plugin from the plugins page and go through the installation steps again. -------------------------------------------------------------------------------- /examples/edge-cache-html/WordPress Plugin/cloudflare-page-cache/cloudflare-page-cache.php: -------------------------------------------------------------------------------- 1 | { 2 | event.respondWith(slackWebhookHandler(event.request)) 3 | }) 4 | 5 | // SLACK_TOKEN is used to authenticate requests are from Slack. 6 | // Keep this value secret. 7 | let SLACK_TOKEN = "PUTYOURTOKENHERE" 8 | let BOT_NAME = "Crypto-bot 🤖" 9 | let REPO_URL = 10 | "https://github.com/cloudflare/worker-examples/tree/master/examples/cryptocurrency-slack-bot" 11 | 12 | let jsonHeaders = new Headers([["Content-Type", "application/json"]]) 13 | 14 | // tickerMap is a map of "ticker" symbols and CoinMarketCap API IDs. 15 | let tickerMap = new Map([ 16 | ["BTC", "bitcoin"], 17 | ["ETH", "ethereum"], 18 | ["XRP", "ripple"], 19 | ["BCH", "bitcoin-cash"], 20 | ["LTC", "litecoin"], 21 | ["ADA", "cardano"], 22 | ["NEO", "neo"], 23 | ["XLM", "stellar"], 24 | ["EOS", "eos"], 25 | ["MIOTA", "iota"], 26 | ["XMR", "monero"], 27 | ["DASH", "dash"], 28 | ["XEM", "nem"], 29 | ["USDT", "tether"], 30 | ["TRX", "tron"], 31 | ["VEN", "vechain"], 32 | ["ETC", "ethereum-classic"], 33 | ["LSK", "lisk"], 34 | ["QTUM", "qtum"], 35 | ["OMG", "omisego"], 36 | ["NANO", "nano"], 37 | ["BTG", "bitcoin-gold"], 38 | ["BNB", "binance-coin"], 39 | ["ICX", "icon"], 40 | ["ZEC", "zcash"], 41 | ["DGD", "digixdao"], 42 | ["PPT", "populous"], 43 | ["STEEM", "steem"], 44 | ["WAVES", "waves"], 45 | ["BCN", "bytecoin-bcn"], 46 | ["STRAT", "stratis"], 47 | ["XVG", "verge"], 48 | ["MKR", "maker"], 49 | ["RHOC", "rchain"], 50 | ["SNT", "status"], 51 | ["DOGE", "dogecoin"], 52 | ["SC", "siacoin"], 53 | ["BTS", "bitshares"], 54 | ["AE", "aeternity"], 55 | ["REP", "augur"], 56 | ["DCR", "decred"], 57 | ["BTM", "bytom"], 58 | ["WTC", "waltonchain"], 59 | ["ONT", "ontology"], 60 | ["ZIL", "zilliqa"], 61 | ["AION", "aion"], 62 | ["KMD", "komodo"], 63 | ["ARDR", "ardor"], 64 | ["ARK", "ark"], 65 | ["CNX", "cryptonex"], 66 | ["KCS", "kucoin-shares"], 67 | ["MONA", "monacoin"], 68 | ["ZRX", "0x"], 69 | ["HSR", "hshare"], 70 | ["ETN", "electroneum"], 71 | ["DGB", "digibyte"], 72 | ["GXS", "gxchain"], 73 | ["VERI", "veritaseum"], 74 | ["PIVX", "pivx"], 75 | ["BAT", "basic-attention-token"], 76 | ["FCT", "factom"], 77 | ["SYS", "syscoin"], 78 | ["GAS", "gas"], 79 | ["R", "revain"], 80 | ["DRGN", "dragonchain"], 81 | ["GNT", "golem-network-tokens"], 82 | ["QASH", "qash"], 83 | ["FUN", "funfair"], 84 | ["ETHOS", "ethos"], 85 | ["LRC", "loopring"], 86 | ["NAS", "nebulas-token"], 87 | ["RDD", "reddcoin"], 88 | ["XZC", "zcoin"], 89 | ["IOST", "iostoken"], 90 | ["EMC", "emercoin"], 91 | ["KNC", "kyber-network"], 92 | ["ELF", "aelf"], 93 | ["KIN", "kin"], 94 | ["SALT", "salt"], 95 | ["GBYTE", "byteball"], 96 | ["NCASH", "nucleus-vision"], 97 | ["PART", "particl"], 98 | ["MAID", "maidsafecoin"], 99 | ["DCN", "dentacoin"], 100 | ["NXT", "nxt"], 101 | ["LINK", "chainlink"], 102 | ["SMART", "smartcash"], 103 | ["REQ", "request-network"], 104 | ["POWR", "power-ledger"], 105 | ["BNT", "bancor"], 106 | ["PAY", "tenx"], 107 | ["CND", "cindicator"], 108 | ["NEBL", "neblio"], 109 | ["POLY", "polymath-network"], 110 | ["NXS", "nexus"], 111 | ["DENT", "dent"], 112 | ["ICN", "iconomi"], 113 | ["ENG", "enigma-project"], 114 | ["MNX", "minexcoin"], 115 | ["STORJ", "storj"] 116 | ]) 117 | 118 | /** 119 | * simpleResponse generates a simple JSON response 120 | * with the given status code and message. 121 | * 122 | * @param {Number} statusCode 123 | * @param {String} message 124 | */ 125 | function simpleResponse(statusCode, message) { 126 | let resp = { 127 | message: message, 128 | status: statusCode 129 | } 130 | 131 | return new Response(JSON.stringify(resp), { 132 | headers: jsonHeaders, 133 | status: statusCode 134 | }) 135 | } 136 | 137 | /** 138 | * slackResponse builds a message for Slack with the given text 139 | * and optional attachment text 140 | * 141 | * @param {string} text - the message text to return 142 | * @param {string[]} [attachmentText] - the (optional) attachment text to add. 143 | */ 144 | function slackResponse(text, attachmentText) { 145 | let content = { 146 | response_type: "in_channel", 147 | text: text, 148 | attachments: [] 149 | } 150 | 151 | if (attachmentText.length > 0) { 152 | attachmentText.forEach(val => { 153 | content.attachments.push({ text: val }) 154 | }) 155 | } 156 | 157 | try { 158 | return new Response(JSON.stringify(content), { 159 | headers: jsonHeaders, 160 | status: 200 161 | }) 162 | } catch (e) { 163 | return simpleResponse( 164 | 200, 165 | "Sorry, I had an issue generating a response. Try again in a bit!" 166 | ) 167 | } 168 | } 169 | 170 | /** 171 | * parseMessage parses the selected currency from the Slack message. 172 | * 173 | * @param {FormData} message - the message text 174 | * @return {string} - the currency name. 175 | */ 176 | function parseMessage(message) { 177 | // 1. Parse the message (trim command, trim whitespace, check length) 178 | // 2. Lookup the ticker <-> id (name) mapping 179 | // 3. Return the name value 180 | // 4. Else, just return the provided value from the message. 181 | try { 182 | let text = message.get("text").trim() 183 | let vals = text.split(" ") 184 | // Example: /slashcommand BTC EUR 185 | let currency = vals[0] 186 | let display = vals[1] 187 | 188 | // If we can't find the ticker => ID in our map, we 189 | // use the user-provided value. 190 | if (tickerMap.has(currency)) { 191 | currency = tickerMap.get(currency) 192 | } 193 | 194 | return { 195 | currency: currency, 196 | display: display 197 | } 198 | } catch (e) { 199 | return null 200 | } 201 | } 202 | 203 | /** 204 | * currencyRequest makes a request to the CoinMarketCap API for the 205 | * given currency ticker. 206 | * Endpoint: https://api.coinmarketcap.com/v1/ticker/{ticker}/?convert={display} 207 | * 208 | * @param {string} ticker - the crypto-currency to fetch the price for 209 | * @param {string} [display] - the currency to display (e.g. USD, EUR) 210 | * @returns {Object} - an Object containing the currency, price in USD, and price in the (optional) display currency. 211 | */ 212 | async function currencyRequest(currency, display) { 213 | let endpoint = "https://api.coinmarketcap.com/v1/ticker/" 214 | 215 | if (display === "") { 216 | display = "USD" 217 | } 218 | 219 | try { 220 | let resp = await fetch( 221 | `${endpoint}${currency}/?convert=${display}`, 222 | { cf: { cacheTtl: 60 } } // Cache our responses for 60s. 223 | ) 224 | 225 | let data = await resp.json() 226 | if (resp.status !== 200) { 227 | throw new Error(`bad status code from CoinMarketCap: HTTP ${resp.status}`) 228 | } 229 | 230 | let cachedResponse = false 231 | if (resp.headers.get("cf-cache-status").toLowerCase() === "hit") { 232 | cachedResponse = true 233 | } 234 | 235 | let reply = { 236 | currency: data[0].name, 237 | symbol: data[0].symbol, 238 | USD: data[0].price_usd, 239 | percent_change_1h: `${data[0].percent_change_1h}%`, 240 | percent_change_24h: `${data[0].percent_change_24h}%`, 241 | percent_change_7d: `${data[0].percent_change_7d}%`, 242 | updated: new Date(parseInt(`${data[0].last_updated}000`)).toUTCString(), 243 | cached: cachedResponse 244 | } 245 | 246 | return reply 247 | } catch (e) { 248 | throw new Error(`could not fetch the selected currency: ${e}`) 249 | } 250 | } 251 | 252 | /** 253 | * slackWebhookHandler handles an incoming Slack 254 | * webhook and generates a response. 255 | * @param {Request} request 256 | */ 257 | async function slackWebhookHandler(request) { 258 | // As per: https://api.slack.com/slash-commands 259 | // - Slash commands are outgoing webhooks (POST requests) 260 | // - Slack authenticates via a verification token. 261 | // - The webhook payload is provided as POST form data 262 | 263 | if (request.method != "POST") { 264 | return simpleResponse( 265 | 200, 266 | `Hi, I'm ${BOT_NAME}, a Slack bot for fetching the latest crypto-currenncy prices. Find my source code at ${REPO_URL}` 267 | ) 268 | } 269 | 270 | let formData 271 | try { 272 | formData = await request.formData() 273 | if (formData.get("token").toString() !== SLACK_TOKEN) { 274 | return simpleResponse(403, "invalid Slack verification token") 275 | } 276 | } catch (e) { 277 | return simpleResponse(400, "could not decode POST form data") 278 | } 279 | 280 | try { 281 | let parsed = parseMessage(formData) 282 | if (parsed === null) { 283 | throw new Error("could not parse your message") 284 | } 285 | 286 | let reply = await currencyRequest(parsed.currency, parsed.display) 287 | 288 | return slackResponse( 289 | `Current price (${reply.currency}): 💵 $USD${reply.USD}`, 290 | [ 291 | `1h Δ: ${reply.percent_change_1h} · 24h Δ: ${ 292 | reply.percent_change_24h 293 | } · 7d Δ: ${reply.percent_change_7d}`, 294 | `Updated: ${reply.updated} | ${reply.cached}` 295 | ] 296 | ) 297 | } catch (e) { 298 | return simpleResponse( 299 | 200, 300 | `Sorry, I had an issue retrieving anything for that currency: ${e}` 301 | ) 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /examples/edge-cache-html/edge-cache-html.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT: Either A Key/Value Namespace must be bound to this worker script 2 | // using the variable name EDGE_CACHE. or the API parameters below should be 3 | // configured. KV is recommended if possible since it can purge just the HTML 4 | // instead of the full cache. 5 | 6 | // API settings if KV isn't being used 7 | const CLOUDFLARE_API = { 8 | email: "", // From https://dash.cloudflare.com/profile 9 | key: "", // Global API Key from https://dash.cloudflare.com/profile 10 | zone: "" // "Zone ID" from the API section of the dashboard overview page https://dash.cloudflare.com/ 11 | }; 12 | 13 | // Default cookie prefixes for bypass 14 | const DEFAULT_BYPASS_COOKIES = [ 15 | "wp-", 16 | "wordpress", 17 | "comment_", 18 | "woocommerce_" 19 | ]; 20 | 21 | /** 22 | * Main worker entry point. 23 | */ 24 | addEventListener("fetch", event => { 25 | const request = event.request; 26 | let upstreamCache = request.headers.get('x-HTML-Edge-Cache'); 27 | 28 | // Only process requests if KV store is set up and there is no 29 | // HTML edge cache in front of this worker (only the outermost cache 30 | // should handle HTML caching in case there are varying levels of support). 31 | let configured = false; 32 | if (typeof EDGE_CACHE !== 'undefined') { 33 | configured = true; 34 | } else if (CLOUDFLARE_API.email.length && CLOUDFLARE_API.key.length && CLOUDFLARE_API.zone.length) { 35 | configured = true; 36 | } 37 | 38 | // Bypass processing of image requests (for everything except Firefox which doesn't use image/*) 39 | const accept = request.headers.get('Accept'); 40 | let isImage = false; 41 | if (accept && (accept.indexOf('image/*') !== -1)) { 42 | isImage = true; 43 | } 44 | 45 | if (configured && !isImage && upstreamCache === null) { 46 | event.passThroughOnException(); 47 | event.respondWith(processRequest(request, event)); 48 | } 49 | }); 50 | 51 | /** 52 | * Process every request coming through to add the edge-cache header, 53 | * watch for purge responses and possibly cache HTML GET requests. 54 | * 55 | * @param {Request} originalRequest - Original request 56 | * @param {Event} event - Original event (for additional async waiting) 57 | */ 58 | async function processRequest(originalRequest, event) { 59 | let cfCacheStatus = null; 60 | const accept = originalRequest.headers.get('Accept'); 61 | const isHTML = (accept && accept.indexOf('text/html') >= 0); 62 | let {response, cacheVer, status, bypassCache} = await getCachedResponse(originalRequest); 63 | 64 | if (response === null) { 65 | // Clone the request, add the edge-cache header and send it through. 66 | let request = new Request(originalRequest); 67 | request.headers.set('x-HTML-Edge-Cache', 'supports=cache|purgeall|bypass-cookies'); 68 | response = await fetch(request); 69 | 70 | if (response) { 71 | const options = getResponseOptions(response); 72 | if (options && options.purge) { 73 | await purgeCache(cacheVer, event); 74 | status += ', Purged'; 75 | } 76 | bypassCache = bypassCache || shouldBypassEdgeCache(request, response); 77 | if ((!options || options.cache) && isHTML && 78 | originalRequest.method === 'GET' && response.status === 200 && 79 | !bypassCache) { 80 | status += await cacheResponse(cacheVer, originalRequest, response, event); 81 | } 82 | } 83 | } else { 84 | // If the origin didn't send the control header we will send the cached response but update 85 | // the cached copy asynchronously (stale-while-revalidate). This commonly happens with 86 | // a server-side disk cache that serves the HTML directly from disk. 87 | cfCacheStatus = 'HIT'; 88 | if (originalRequest.method === 'GET' && response.status === 200 && isHTML) { 89 | bypassCache = bypassCache || shouldBypassEdgeCache(originalRequest, response); 90 | if (!bypassCache) { 91 | const options = getResponseOptions(response); 92 | if (!options) { 93 | status += ', Refreshed'; 94 | event.waitUntil(updateCache(originalRequest, cacheVer, event)); 95 | } 96 | } 97 | } 98 | } 99 | 100 | if (response && status !== null && originalRequest.method === 'GET' && response.status === 200 && isHTML) { 101 | response = new Response(response.body, response); 102 | response.headers.set('x-HTML-Edge-Cache-Status', status); 103 | if (cacheVer !== null) { 104 | response.headers.set('x-HTML-Edge-Cache-Version', cacheVer.toString()); 105 | } 106 | if (cfCacheStatus) { 107 | response.headers.set('CF-Cache-Status', cfCacheStatus); 108 | } 109 | } 110 | 111 | return response; 112 | } 113 | 114 | /** 115 | * Determine if the cache should be bypassed for the given request/response pair. 116 | * Specifically, if the request includes a cookie that the response flags for bypass. 117 | * Can be used on cache lookups to determine if the request needs to go to the origin and 118 | * origin responses to determine if they should be written to cache. 119 | * @param {Request} request - Request 120 | * @param {Response} response - Response 121 | * @returns {bool} true if the cache should be bypassed 122 | */ 123 | function shouldBypassEdgeCache(request, response) { 124 | let bypassCache = false; 125 | 126 | if (request && response) { 127 | const options = getResponseOptions(response); 128 | const cookieHeader = request.headers.get('cookie'); 129 | let bypassCookies = DEFAULT_BYPASS_COOKIES; 130 | if (options) { 131 | bypassCookies = options.bypassCookies; 132 | } 133 | if (cookieHeader && cookieHeader.length && bypassCookies.length) { 134 | const cookies = cookieHeader.split(';'); 135 | for (let cookie of cookies) { 136 | // See if the cookie starts with any of the logged-in user prefixes 137 | for (let prefix of bypassCookies) { 138 | if (cookie.trim().startsWith(prefix)) { 139 | bypassCache = true; 140 | break; 141 | } 142 | } 143 | if (bypassCache) { 144 | break; 145 | } 146 | } 147 | } 148 | } 149 | 150 | return bypassCache; 151 | } 152 | 153 | const CACHE_HEADERS = ['Cache-Control', 'Expires', 'Pragma']; 154 | 155 | /** 156 | * Check for cached HTML GET requests. 157 | * 158 | * @param {Request} request - Original request 159 | */ 160 | async function getCachedResponse(request) { 161 | let response = null; 162 | let cacheVer = null; 163 | let bypassCache = false; 164 | let status = 'Miss'; 165 | 166 | // Only check for HTML GET requests (saves on reading from KV unnecessarily) 167 | // and not when there are cache-control headers on the request (refresh) 168 | const accept = request.headers.get('Accept'); 169 | const cacheControl = request.headers.get('Cache-Control'); 170 | let noCache = false; 171 | if (cacheControl && cacheControl.indexOf('no-cache') !== -1) { 172 | noCache = true; 173 | status = 'Bypass for Reload'; 174 | } 175 | if (!noCache && request.method === 'GET' && accept && accept.indexOf('text/html') >= 0) { 176 | // Build the versioned URL for checking the cache 177 | cacheVer = await GetCurrentCacheVersion(cacheVer); 178 | const cacheKeyRequest = GenerateCacheRequest(request, cacheVer); 179 | 180 | // See if there is a request match in the cache 181 | try { 182 | let cache = caches.default; 183 | let cachedResponse = await cache.match(cacheKeyRequest); 184 | if (cachedResponse) { 185 | // Copy Response object so that we can edit headers. 186 | cachedResponse = new Response(cachedResponse.body, cachedResponse); 187 | 188 | // Check to see if the response needs to be bypassed because of a cookie 189 | bypassCache = shouldBypassEdgeCache(request, cachedResponse); 190 | 191 | // Copy the original cache headers back and clean up any control headers 192 | if (bypassCache) { 193 | status = 'Bypass Cookie'; 194 | } else { 195 | status = 'Hit'; 196 | cachedResponse.headers.delete('Cache-Control'); 197 | cachedResponse.headers.delete('x-HTML-Edge-Cache-Status'); 198 | for (header of CACHE_HEADERS) { 199 | let value = cachedResponse.headers.get('x-HTML-Edge-Cache-Header-' + header); 200 | if (value) { 201 | cachedResponse.headers.delete('x-HTML-Edge-Cache-Header-' + header); 202 | cachedResponse.headers.set(header, value); 203 | } 204 | } 205 | response = cachedResponse; 206 | } 207 | } else { 208 | status = 'Miss'; 209 | } 210 | } catch (err) { 211 | // Send the exception back in the response header for debugging 212 | status = "Cache Read Exception: " + err.message; 213 | } 214 | } 215 | 216 | return {response, cacheVer, status, bypassCache}; 217 | } 218 | 219 | /** 220 | * Asynchronously purge the HTML cache. 221 | * @param {Int} cacheVer - Current cache version (if retrieved) 222 | * @param {Event} event - Original event 223 | */ 224 | async function purgeCache(cacheVer, event) { 225 | if (typeof EDGE_CACHE !== 'undefined') { 226 | // Purge the KV cache by bumping the version number 227 | cacheVer = await GetCurrentCacheVersion(cacheVer); 228 | cacheVer++; 229 | event.waitUntil(EDGE_CACHE.put('html_cache_version', cacheVer.toString())); 230 | } else { 231 | // Purge everything using the API 232 | const url = "https://api.cloudflare.com/client/v4/zones/" + CLOUDFLARE_API.zone + "/purge_cache"; 233 | event.waitUntil(fetch(url,{ 234 | method: 'POST', 235 | headers: {'X-Auth-Email': CLOUDFLARE_API.email, 236 | 'X-Auth-Key': CLOUDFLARE_API.key, 237 | 'Content-Type': 'application/json'}, 238 | body: JSON.stringify({purge_everything: true}) 239 | })); 240 | } 241 | } 242 | 243 | /** 244 | * Update the cached copy of the given page 245 | * @param {Request} originalRequest - Original Request 246 | * @param {String} cacheVer - Cache Version 247 | * @param {EVent} event - Original event 248 | */ 249 | async function updateCache(originalRequest, cacheVer, event) { 250 | // Clone the request, add the edge-cache header and send it through. 251 | let request = new Request(originalRequest); 252 | request.headers.set('x-HTML-Edge-Cache', 'supports=cache|purgeall|bypass-cookies'); 253 | response = await fetch(request); 254 | 255 | if (response) { 256 | status = ': Fetched'; 257 | const options = getResponseOptions(response); 258 | if (options && options.purge) { 259 | await purgeCache(cacheVer, event); 260 | } 261 | let bypassCache = shouldBypassEdgeCache(request, response); 262 | if ((!options || options.cache) && !bypassCache) { 263 | await cacheResponse(cacheVer, originalRequest, response, event); 264 | } 265 | } 266 | } 267 | 268 | /** 269 | * Cache the returned content (but only if it was a successful GET request) 270 | * 271 | * @param {Int} cacheVer - Current cache version (if already retrieved) 272 | * @param {Request} request - Original Request 273 | * @param {Response} originalResponse - Response to (maybe) cache 274 | * @param {Event} event - Original event 275 | * @returns {bool} true if the response was cached 276 | */ 277 | async function cacheResponse(cacheVer, request, originalResponse, event) { 278 | let status = ""; 279 | const accept = request.headers.get('Accept'); 280 | if (request.method === 'GET' && originalResponse.status === 200 && accept && accept.indexOf('text/html') >= 0) { 281 | cacheVer = await GetCurrentCacheVersion(cacheVer); 282 | const cacheKeyRequest = GenerateCacheRequest(request, cacheVer); 283 | 284 | try { 285 | // Move the cache headers out of the way so the response can actually be cached. 286 | // First clone the response so there is a parallel body stream and then 287 | // create a new response object based on the clone that we can edit. 288 | let cache = caches.default; 289 | let clonedResponse = originalResponse.clone(); 290 | let response = new Response(clonedResponse.body, clonedResponse); 291 | for (header of CACHE_HEADERS) { 292 | let value = response.headers.get(header); 293 | if (value) { 294 | response.headers.delete(header); 295 | response.headers.set('x-HTML-Edge-Cache-Header-' + header, value); 296 | } 297 | } 298 | response.headers.delete('Set-Cookie'); 299 | response.headers.set('Cache-Control', 'public; max-age=315360000'); 300 | event.waitUntil(cache.put(cacheKeyRequest, response)); 301 | status = ", Cached"; 302 | } catch (err) { 303 | // status = ", Cache Write Exception: " + err.message; 304 | } 305 | } 306 | return status; 307 | } 308 | 309 | /****************************************************************************** 310 | * Utility Functions 311 | *****************************************************************************/ 312 | 313 | /** 314 | * Parse the commands from the x-HTML-Edge-Cache response header. 315 | * @param {Response} response - HTTP response from the origin. 316 | * @returns {*} Parsed commands 317 | */ 318 | function getResponseOptions(response) { 319 | let options = null; 320 | let header = response.headers.get('x-HTML-Edge-Cache'); 321 | if (header) { 322 | options = { 323 | purge: false, 324 | cache: false, 325 | bypassCookies: [] 326 | }; 327 | let commands = header.split(','); 328 | for (let command of commands) { 329 | if (command.trim() === 'purgeall') { 330 | options.purge = true; 331 | } else if (command.trim() === 'cache') { 332 | options.cache = true; 333 | } else if (command.trim().startsWith('bypass-cookies')) { 334 | let separator = command.indexOf('='); 335 | if (separator >= 0) { 336 | let cookies = command.substr(separator + 1).split('|'); 337 | for (let cookie of cookies) { 338 | cookie = cookie.trim(); 339 | if (cookie.length) { 340 | options.bypassCookies.push(cookie); 341 | } 342 | } 343 | } 344 | } 345 | } 346 | } 347 | 348 | return options; 349 | } 350 | 351 | /** 352 | * Retrieve the current cache version from KV 353 | * @param {Int} cacheVer - Current cache version value if set. 354 | * @returns {Int} The current cache version. 355 | */ 356 | async function GetCurrentCacheVersion(cacheVer) { 357 | if (cacheVer === null) { 358 | if (typeof EDGE_CACHE !== 'undefined') { 359 | cacheVer = await EDGE_CACHE.get('html_cache_version'); 360 | if (cacheVer === null) { 361 | // Uninitialized - first time through, initialize KV with a value 362 | // Blocking but should only happen immediately after worker activation. 363 | cacheVer = 0; 364 | await EDGE_CACHE.put('html_cache_version', cacheVer.toString()); 365 | } else { 366 | cacheVer = parseInt(cacheVer); 367 | } 368 | } else { 369 | cacheVer = -1; 370 | } 371 | } 372 | return cacheVer; 373 | } 374 | 375 | /** 376 | * Generate the versioned Request object to use for cache operations. 377 | * @param {Request} request - Base request 378 | * @param {Int} cacheVer - Current Cache version (must be set) 379 | * @returns {Request} Versioned request object 380 | */ 381 | function GenerateCacheRequest(request, cacheVer) { 382 | let cacheUrl = request.url; 383 | if (cacheUrl.indexOf('?') >= 0) { 384 | cacheUrl += '&'; 385 | } else { 386 | cacheUrl += '?'; 387 | } 388 | cacheUrl += 'cf_edge_cache_ver=' + cacheVer; 389 | return new Request(cacheUrl); 390 | } 391 | -------------------------------------------------------------------------------- /examples/third-party-scripts/third-party-scripts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Definitions for content to self-host. 3 | */ 4 | 5 | const SCRIPT_URLS = [ 6 | // Hosted libraries (usually CDN's for open source). 7 | '/ajax.aspnetcdn.com/', 8 | '/ajax.cloudflare.com/', 9 | '/ajax.googleapis.com/ajax/', 10 | '/cdn.jsdelivr.net/', 11 | '/cdnjs.com/', 12 | '/cdnjs.cloudflare.com/', 13 | '/code.jquery.com/', 14 | '/maxcdn.bootstrapcdn.com/', 15 | '/netdna.bootstrapcdn.com/', 16 | '/oss.maxcdn.com/', 17 | '/stackpath.bootstrapcdn.com/', 18 | 19 | // Popular scripts (can be site-specific) 20 | '/a.optmnstr.com/app/js/', 21 | '/cdn.onesignal.com/sdks/', 22 | '/cdn.optimizely.com/', 23 | '/cdn.shopify.com/s/', 24 | '/css3-mediaqueries-js.googlecode.com/svn/', 25 | '/html5shim.googlecode.com/svn/', 26 | '/html5shiv.googlecode.com/svn/', 27 | '/maps.google.com/maps/api/js', 28 | '/maps.googleapis.com/maps/api/js', 29 | '/pagead2.googlesyndication.com/pagead/js/', 30 | '/platform.twitter.com/widgets.js', 31 | '/platform-api.sharethis.com/js/', 32 | '/s7.addthis.com/js/', 33 | '/stats.wp.com/', 34 | '/ws.sharethis.com/button/', 35 | '/www.google.com/recaptcha/api.js', 36 | '/www.google-analytics.com/analytics.js', 37 | '/www.googletagmanager.com/gtag/js', 38 | '/www.googletagmanager.com/gtm.js', 39 | '/www.googletagservices.com/tag/js/gpt.js' 40 | ]; 41 | 42 | // Regex patterns for matching script and link tags 43 | const SCRIPT_PRE = '<\\s*script[^>]+src\\s*=\\s*[\'"]\\s*((https?:)?/'; 44 | const PATTERN_POST = '[^\'" ]+)\\s*["\'][^>]*>'; 45 | 46 | /** 47 | * Main worker entry point. Looks for font requests that are being proxied and 48 | * requests for HTML content. All major browsers explicitly send an accept: text/html 49 | * for navigational requests and the fallback is to just pass the request through 50 | * unmodified (safe). 51 | */ 52 | addEventListener("fetch", event => { 53 | // Fail-safe in case of an unhandled exception 54 | event.passThroughOnException(); 55 | 56 | const url = new URL(event.request.url); 57 | const bypass = new URL(event.request.url).searchParams.get('cf-worker') === 'bypass'; 58 | if (!bypass) { 59 | let accept = event.request.headers.get('accept'); 60 | if (event.request.method === 'GET' && 61 | isProxyRequest(url)) { 62 | event.respondWith(proxyUrl(url, event.request)); 63 | } else if (accept && accept.indexOf("text/html") >= 0) { 64 | event.respondWith(processHtmlRequest(event.request)); 65 | } 66 | } 67 | }); 68 | 69 | // Workers can only decode utf-8 so keep a list of character encodings that can be decoded. 70 | const VALID_CHARSETS = ['utf-8', 'utf8', 'iso-8859-1', 'us-ascii']; 71 | 72 | /** 73 | * See if the requested resource is a proxy request to an overwritten origin 74 | * (something that starts with a prefix in one of our lists). 75 | * 76 | * @param {*} url - Requested URL 77 | * @param {*} request - Original Request 78 | * @returns {*} - true if the URL matches one of the proxy paths 79 | */ 80 | function isProxyRequest(url) { 81 | let found_prefix = false; 82 | const path = url.pathname + url.search; 83 | for (let prefix of SCRIPT_URLS) { 84 | if (path.startsWith(prefix) && path.indexOf('cf_hash=') >= 0) { 85 | found_prefix = true; 86 | break; 87 | } 88 | } 89 | return found_prefix; 90 | } 91 | 92 | /** 93 | * Generate a new request based on the original. Filter the request 94 | * headers to prevent leaking user data (cookies, etc) and filter 95 | * the response headers to prevent the origin setting policy on 96 | * our origin. 97 | * 98 | * @param {URL} url - Unmodified request URL 99 | * @param {*} request - The original request 100 | * @returns {*} - fetch response 101 | */ 102 | async function proxyUrl(url, request) { 103 | let originUrl = 'https:/' + url.pathname + url.search; 104 | let hashOffset = originUrl.indexOf('cf_hash='); 105 | if (hashOffset >= 2) { 106 | originUrl = originUrl.substring(0, hashOffset - 1); 107 | } 108 | 109 | // Filter the request headers 110 | let init = { 111 | method: request.method, 112 | headers: {} 113 | }; 114 | const proxy_headers = ["Accept", 115 | "Accept-Encoding", 116 | "Accept-Language", 117 | "Referer", 118 | "User-Agent"]; 119 | for (let name of proxy_headers) { 120 | let value = request.headers.get(name); 121 | if (value) { 122 | init.headers[name] = value; 123 | } 124 | } 125 | // Add an X-Forwarded-For with the client IP 126 | const clientAddr = request.headers.get('cf-connecting-ip'); 127 | if (clientAddr) { 128 | init.headers['X-Forwarded-For'] = clientAddr; 129 | } 130 | 131 | // Filter the response headers 132 | const response = await fetch(originUrl, init); 133 | if (response) { 134 | const responseHeaders = ["Content-Type", 135 | "Cache-Control", 136 | "Expires", 137 | "Accept-Ranges", 138 | "Date", 139 | "Last-Modified", 140 | "ETag"]; 141 | let responseInit = {status: response.status, 142 | statusText: response.statusText, 143 | headers: {}}; 144 | for (let name of responseHeaders) { 145 | let value = response.headers.get(name); 146 | if (value) { 147 | responseInit.headers[name] = value; 148 | } 149 | } 150 | // Extend the cache time for successful responses (since the url is 151 | // specific to the hashed content). 152 | if (response.status === 200) { 153 | responseInit.headers['Cache-Control'] = 'private; max-age=315360000'; 154 | } 155 | 156 | const newResponse = new Response(response.body, responseInit); 157 | return newResponse; 158 | } 159 | 160 | return response; 161 | } 162 | 163 | /** 164 | * Handle all of the processing for a (likely) HTML request. 165 | * - Pass through the request to the origin and inspect the response. 166 | * - If the response is HTML set up a streaming transform and pass it on to modifyHtmlStream for processing 167 | * 168 | * Extra care needs to be taken to make sure the character encoding from the original 169 | * HTML is extracted and converted to utf-8 and that the downstream response is identified 170 | * as utf-8. 171 | * 172 | * @param {*} request - The original request 173 | */ 174 | async function processHtmlRequest(request) { 175 | // Fetch from origin server. 176 | const response = await fetch(request); 177 | let contentType = response.headers.get("content-type"); 178 | if (contentType && contentType.indexOf("text/html") !== -1) { 179 | // Workers can only decode utf-8. If it is anything else, pass the 180 | // response through unmodified 181 | const charsetRegex = /charset\s*=\s*([^\s;]+)/mgi; 182 | const match = charsetRegex.exec(contentType); 183 | if (match !== null) { 184 | let charset = match[1].toLowerCase(); 185 | if (!VALID_CHARSETS.includes(charset)) { 186 | return response; 187 | } 188 | } 189 | 190 | // Create an identity TransformStream (a.k.a. a pipe). 191 | // The readable side will become our new response body. 192 | const { readable, writable } = new TransformStream(); 193 | 194 | // Create a cloned response with our modified stream and content type header 195 | const newResponse = new Response(readable, response); 196 | 197 | // Start the async processing of the response stream (don't wait for it to finish) 198 | modifyHtmlStream(response.body, writable, request); 199 | 200 | // Return the in-process response so it can be streamed. 201 | return newResponse; 202 | } else { 203 | return response; 204 | } 205 | } 206 | 207 | /** 208 | * Check to see if the HTML chunk includes a meta tag for an unsupported charset 209 | * @param {*} chunk - Chunk of HTML to scan 210 | * @returns {bool} - true if the HTML chunk includes a meta tag for an unsupported charset 211 | */ 212 | function chunkContainsInvalidCharset(chunk) { 213 | let invalid = false; 214 | 215 | // meta charset 216 | const charsetRegex = /<\s*meta[^>]+charset\s*=\s*['"]([^'"]*)['"][^>]*>/mgi; 217 | const charsetMatch = charsetRegex.exec(chunk); 218 | if (charsetMatch) { 219 | const docCharset = charsetMatch[1].toLowerCase(); 220 | if (!VALID_CHARSETS.includes(docCharset)) { 221 | invalid = true; 222 | } 223 | } 224 | // content-type 225 | const contentTypeRegex = /<\s*meta[^>]+http-equiv\s*=\s*['"]\s*content-type[^>]*>/mgi; 226 | const contentTypeMatch = contentTypeRegex.exec(chunk); 227 | if (contentTypeMatch) { 228 | const metaTag = contentTypeMatch[0]; 229 | const metaRegex = /charset\s*=\s*([^\s"]*)/mgi; 230 | const metaMatch = metaRegex.exec(metaTag); 231 | if (metaMatch) { 232 | const charset = metaMatch[1].toLowerCase(); 233 | if (!VALID_CHARSETS.includes(charset)) { 234 | invalid = true; 235 | } 236 | } 237 | } 238 | return invalid; 239 | } 240 | 241 | /** 242 | * Process the streaming HTML response from the origin server. 243 | * - Attempt to buffer the full head to reduce the likelihood of the patterns spanning multiple response chunks 244 | * - Scan the first response chunk for a charset meta tag (and bail if it isn't a supported charset) 245 | * - Pass the gathered head and each subsequent chunk to modifyHtmlChunk() for actual processing of the text. 246 | * 247 | * @param {*} readable - Input stream (from the origin). 248 | * @param {*} writable - Output stream (to the browser). 249 | * @param {*} request - Original request object for downstream use. 250 | */ 251 | async function modifyHtmlStream(readable, writable, request) { 252 | const reader = readable.getReader(); 253 | const writer = writable.getWriter(); 254 | const encoder = new TextEncoder(); 255 | let decoder = new TextDecoder("utf-8", {fatal: true}); 256 | 257 | let firstChunk = true; 258 | let unsupportedCharset = false; 259 | 260 | // build the list of url patterns we are going to look for. 261 | let patterns = []; 262 | for (let scriptUrl of SCRIPT_URLS) { 263 | let regex = new RegExp(SCRIPT_PRE + scriptUrl + PATTERN_POST, 'gi'); 264 | patterns.push(regex); 265 | } 266 | 267 | let partial = ''; 268 | let content = ''; 269 | 270 | for (;;) { 271 | const { done, value } = await reader.read(); 272 | if (done) { 273 | if (partial.length) { 274 | partial = await modifyHtmlChunk(partial, patterns, request); 275 | await writer.write(encoder.encode(partial)); 276 | } 277 | partial = ''; 278 | break; 279 | } 280 | 281 | let chunk = null; 282 | if (unsupportedCharset) { 283 | // Pass the data straight through 284 | await writer.write(value); 285 | continue; 286 | } else { 287 | try { 288 | chunk = decoder.decode(value, {stream:true}); 289 | } catch (e) { 290 | // Decoding failed, switch to passthrough 291 | unsupportedCharset = true; 292 | if (partial.length) { 293 | await writer.write(encoder.encode(partial)); 294 | partial = ''; 295 | } 296 | await writer.write(value); 297 | continue; 298 | } 299 | } 300 | 301 | try { 302 | // Look inside of the first chunk for a HTML charset or content-type meta tag. 303 | if (firstChunk) { 304 | firstChunk = false; 305 | if (chunkContainsInvalidCharset(chunk)) { 306 | // switch to passthrough 307 | unsupportedCharset = true; 308 | if (partial.length) { 309 | await writer.write(encoder.encode(partial)); 310 | partial = ''; 311 | } 312 | await writer.write(value); 313 | continue; 314 | } 315 | } 316 | 317 | // TODO: Optimize this so we aren't continuously adding strings together 318 | content = partial + chunk; 319 | partial = ''; 320 | 321 | // See if there is an unclosed script tag at the end (and if so, carve 322 | // it out to complete when the remainder comes in). 323 | // This isn't perfect (case sensitive and doesn't allow whitespace in the tag) 324 | // but it is good enough for our purpose and much faster than a regex. 325 | const scriptPos = content.lastIndexOf('= 0) { 327 | const scriptClose = content.indexOf('>', scriptPos); 328 | if (scriptClose === -1) { 329 | partial = content.slice(scriptPos); 330 | content = content.slice(0, scriptPos); 331 | } 332 | } 333 | 334 | if (content.length) { 335 | content = await modifyHtmlChunk(content, patterns, request); 336 | } 337 | } catch (e) { 338 | // Ignore the exception 339 | } 340 | if (content.length) { 341 | await writer.write(encoder.encode(content)); 342 | content = ''; 343 | } 344 | } 345 | await writer.close(); 346 | } 347 | 348 | /** 349 | * Find any of the script tags we are looking for and replace them with hashed versions 350 | * that are proxied through the origin. 351 | * 352 | * @param {*} content - Text chunk from the streaming HTML (or accumulated head) 353 | * @param {*} patterns - RegEx patterns to match 354 | * @param {*} request - Original request object for downstream use. 355 | */ 356 | async function modifyHtmlChunk(content, patterns, request) { 357 | // Fully tokenizing and parsing the HTML is expensive. This regex is much faster and should be reasonably safe. 358 | // It looks for the search patterns and extracts the URL as match #1. It shouldn't match 359 | // in-text content because the < > brackets would be escaped in the HTML. There is some potential risk of 360 | // matching it in an inline script (unlikely but possible). 361 | const pageUrl = new URL(request.url); 362 | for (let pattern of patterns) { 363 | let match = pattern.exec(content); 364 | while (match !== null) { 365 | const originalUrl = match[1]; 366 | let fetchUrl = originalUrl; 367 | if (fetchUrl.startsWith('//')) { 368 | fetchUrl = pageUrl.protocol + fetchUrl; 369 | } 370 | const proxyUrl = await hashContent(originalUrl, fetchUrl, request); 371 | if (proxyUrl) { 372 | content = content.split(originalUrl).join(proxyUrl); 373 | pattern.lastIndex -= originalUrl.length - proxyUrl.length; 374 | } 375 | match = pattern.exec(content); 376 | } 377 | } 378 | return content; 379 | } 380 | 381 | /** 382 | * Fetch the original content and return a hash of the result (for detecting changes). 383 | * Use a local cache because some scripts use cache-control: private to prevent 384 | * proxies from caching. 385 | * 386 | * @param {*} originalUrl - Unmodified URL 387 | * @param {*} url - URL for the third-party request 388 | * @param {*} request - Original request for the page HTML so the user-agent can be passed through 389 | * @param {*} event - Worker event object. 390 | */ 391 | async function hashContent(originalUrl, url, request) { 392 | let proxyUrl = null; 393 | let hash = null; 394 | const userAgent = request.headers.get('user-agent'); 395 | const clientAddr = request.headers.get('cf-connecting-ip'); 396 | const hashCacheKey = new Request(url + "cf-hash-key"); 397 | let cache = null; 398 | 399 | let foundInCache = false; 400 | // Try pulling it from the cache API (wrap it in case it's not implemented) 401 | try { 402 | cache = caches.default; 403 | let response = await cache.match(hashCacheKey); 404 | if (response) { 405 | hash = await response.text(); 406 | proxyUrl = constructProxyUrl(originalUrl, hash); 407 | foundInCache = true; 408 | } 409 | } catch(e) { 410 | // Ignore the exception 411 | } 412 | 413 | if (!foundInCache) { 414 | try { 415 | let headers = {'Referer': request.url, 416 | 'User-Agent': userAgent}; 417 | if (clientAddr) { 418 | headers['X-Forwarded-For'] = clientAddr; 419 | } 420 | const response = await fetch(url, {headers: headers}); 421 | let content = await response.arrayBuffer(); 422 | if (content) { 423 | const hashBuffer = await crypto.subtle.digest('SHA-1', content); 424 | hash = hex(hashBuffer); 425 | proxyUrl = constructProxyUrl(originalUrl, hash); 426 | 427 | // Add the hash to the local cache 428 | try { 429 | if (cache) { 430 | let ttl = 60; 431 | const cacheControl = response.headers.get('cache-control'); 432 | const maxAgeRegex = /max-age\s*=\s*(\d+)/i; 433 | const match = maxAgeRegex.exec(cacheControl); 434 | if (match) { 435 | ttl = parseInt(match[1], 10); 436 | } 437 | const hashCacheResponse = new Response(hash, {ttl: ttl}); 438 | cache.put(hashCacheKey, hashCacheResponse); 439 | } 440 | } catch(e) { 441 | // Ignore the exception 442 | } 443 | } 444 | } catch(e) { 445 | // Ignore the exception 446 | } 447 | } 448 | 449 | return proxyUrl; 450 | } 451 | 452 | /** 453 | * Generate the proxy URL given the content hash and base URL 454 | * @param {*} originalUrl - Original URL 455 | * @param {*} hash - Hash of content 456 | * @returns {*} - URL with content hash appended 457 | */ 458 | function constructProxyUrl(originalUrl, hash) { 459 | let proxyUrl = null; 460 | let pathStart = originalUrl.indexOf('//'); 461 | if (pathStart >= 0) { 462 | proxyUrl = originalUrl.substring(pathStart + 1); 463 | if (proxyUrl.indexOf('?') >= 0) { 464 | proxyUrl += '&'; 465 | } else { 466 | proxyUrl += '?'; 467 | } 468 | proxyUrl += 'cf_hash=' + hash; 469 | } 470 | return proxyUrl; 471 | } 472 | 473 | /** 474 | * Convert a buffer into a hex string (for hashes). 475 | * From: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest 476 | * @param {*} buffer - Binary buffer 477 | * @returns {*} - Hex string of the binary buffer 478 | */ 479 | function hex(buffer) { 480 | var hexCodes = []; 481 | var view = new DataView(buffer); 482 | for (var i = 0; i < view.byteLength; i += 4) { 483 | var value = view.getUint32(i); 484 | var stringValue = value.toString(16); 485 | var padding = '00000000'; 486 | var paddedValue = (padding + stringValue).slice(-padding.length); 487 | hexCodes.push(paddedValue); 488 | } 489 | return hexCodes.join(""); 490 | } 491 | -------------------------------------------------------------------------------- /examples/fast-google-fonts/fast-google-fonts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Main worker entry point. Looks for font requests that are being proxied and 3 | * requests for HTML content. All major browsers explicitly send an accept: text/html 4 | * for navigational requests and the fallback is to just pass the request through 5 | * unmodified (safe). 6 | */ 7 | addEventListener("fetch", event => { 8 | // Fail-safe in case of an unhandled exception 9 | event.passThroughOnException(); 10 | if (event.request.method === 'GET') { 11 | const url = new URL(event.request.url); 12 | const accept = event.request.headers.get('Accept'); 13 | if (url.pathname.startsWith('/fonts.gstatic.com/')) { 14 | // Pass the font requests through to the origin font server 15 | // (through the underlying request cache). 16 | event.respondWith(proxyRequest('https:/' + url.pathname + url.search, 17 | event.request)); 18 | } else if (accept && (accept.indexOf('text/html') >= 0 || accept.indexOf('text/css') >= 0)) { 19 | // The only interesting (non-proxied) requests are for HTML and CSS. 20 | // All of the major browsers advertise they are requesting HTML or CSS in the accept header. 21 | // For any browsers that don't (curl, etc), they will just fall-back to non-accelerated. 22 | if (url.pathname.startsWith('/fonts.googleapis.com/')) { 23 | // Proxy the stylesheet for pages using CSP 24 | event.respondWith(proxyStylesheet('https:/' + url.pathname + url.search, 25 | event.request)); 26 | } else { 27 | event.respondWith(processRequest(event.request, event)); 28 | } 29 | } 30 | } 31 | }); 32 | 33 | // Workers can only decode utf-8 so keep a list of character encodings that can be decoded. 34 | const VALID_CHARSETS = ['utf-8', 'utf8', 'iso-8859-1', 'us-ascii']; 35 | 36 | /** 37 | * Generate a new request based on the original. Filter the request 38 | * headers to prevent leaking user data (cookies, etc) and filter 39 | * the response headers to prevent the origin setting policy on 40 | * our origin. 41 | * 42 | * @param {*} url The URL to proxy 43 | * @param {*} request The original request (to copy parameters from) 44 | */ 45 | async function proxyRequest(url, request) { 46 | let init = { 47 | method: request.method, 48 | headers: {} 49 | }; 50 | // Only pass through a subset of headers 51 | const proxyHeaders = ["Accept", 52 | "Accept-Encoding", 53 | "Accept-Language", 54 | "Referer", 55 | "User-Agent"]; 56 | for (let name of proxyHeaders) { 57 | let value = request.headers.get(name); 58 | if (value) { 59 | init.headers[name] = value; 60 | } 61 | } 62 | // Add an X-Forwarded-For with the client IP 63 | const clientAddr = request.headers.get('cf-connecting-ip'); 64 | if (clientAddr) { 65 | init.headers['X-Forwarded-For'] = clientAddr; 66 | } 67 | 68 | const response = await fetch(url, init); 69 | if (response) { 70 | const responseHeaders = ["Content-Type", 71 | "Cache-Control", 72 | "Expires", 73 | "Accept-Ranges", 74 | "Date", 75 | "Last-Modified", 76 | "ETag"]; 77 | // Only include a strict subset of response headers 78 | let responseInit = {status: response.status, 79 | statusText: response.statusText, 80 | headers: {}}; 81 | for (let name of responseHeaders) { 82 | let value = response.headers.get(name); 83 | if (value) { 84 | responseInit.headers[name] = value; 85 | } 86 | } 87 | // Add some security headers to make sure there isn't scriptable content 88 | // being proxied. 89 | responseInit.headers['X-Content-Type-Options'] = "nosniff"; 90 | const newResponse = new Response(response.body, responseInit); 91 | return newResponse; 92 | } 93 | 94 | return response; 95 | } 96 | 97 | /** 98 | * Handle a proxied stylesheet request. 99 | * 100 | * @param {*} url The URL to proxy 101 | * @param {*} request The original request (to copy parameters from) 102 | */ 103 | async function proxyStylesheet(url, request) { 104 | let css = await fetchCSS(url, request) 105 | if (css) { 106 | const responseInit = {headers: { 107 | "Content-Type": "text/css; charset=utf-8", 108 | "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800" 109 | }}; 110 | const newResponse = new Response(css, responseInit); 111 | return newResponse; 112 | } else { 113 | // Do a straight-through proxy as fallback 114 | return proxyRequest(url, request); 115 | } 116 | } 117 | 118 | /** 119 | * Handle all non-proxied requests. Send HTML or CSS on for further processing 120 | * and pass everything else through unmodified. 121 | * @param {*} request - Original request 122 | * @param {*} event - Original worker event 123 | */ 124 | async function processRequest(request, event) { 125 | const response = await fetch(request); 126 | if (response && response.status === 200) { 127 | const contentType = response.headers.get("content-type"); 128 | if (contentType && contentType.indexOf("text/html") !== -1) { 129 | return await processHtmlResponse(response, event.request, event); 130 | } else if (contentType && contentType.indexOf("text/css") !== -1) { 131 | return await processStylesheetResponse(response, event.request, event); 132 | } 133 | } 134 | 135 | return response; 136 | } 137 | 138 | /** 139 | * Handle all of the processing for a (likely) HTML request. 140 | * - Pass through the request to the origin and inspect the response. 141 | * - If the response is HTML set up a streaming transform and pass it on to modifyHtmlStream for processing 142 | * 143 | * Extra care needs to be taken to make sure the character encoding from the original 144 | * HTML is extracted and converted to utf-8 and that the downstream response is identified 145 | * as utf-8. 146 | * 147 | * @param {*} response The original response 148 | * @param {*} request The original request 149 | * @param {*} event worker event object 150 | */ 151 | async function processHtmlResponse(response, request, event) { 152 | // Workers can only decode utf-8. If it is anything else, pass the 153 | // response through unmodified 154 | const contentType = response.headers.get("content-type"); 155 | const charsetRegex = /charset\s*=\s*([^\s;]+)/mgi; 156 | const match = charsetRegex.exec(contentType); 157 | if (match !== null) { 158 | let charset = match[1].toLowerCase(); 159 | if (!VALID_CHARSETS.includes(charset)) { 160 | return response; 161 | } 162 | } 163 | // See if the stylesheet should be embedded or proxied. 164 | // CSP blocks embedded CSS by default so fall back to proxying 165 | // the stylesheet through the origin. 166 | // 167 | // Note: only 'self' and 'unsafe-inline' CSP rules for style-src 168 | // are recognized. If explicit URLs are used instead then the 169 | // HTML will not be modified. 170 | let embedStylesheet = true; 171 | let csp = response.headers.get("Content-Security-Policy"); 172 | if (csp) { 173 | // Get the style policy that will be applied to the document 174 | let ok = false; 175 | let cspRule = null; 176 | const styleRegex = /style-src[^;]*/gmi; 177 | let match = styleRegex.exec(csp); 178 | if (match !== null) { 179 | cspRule = match[0]; 180 | } else { 181 | const defaultRegex = /default-src[^;]*/gmi; 182 | let match = defaultRegex.exec(csp); 183 | if (match !== null) { 184 | cspRule = match[0]; 185 | } 186 | } 187 | if (cspRule !== null) { 188 | if (cspRule.indexOf("'unsafe-inline'") >= 0) { 189 | ok = true; 190 | embedStylesheet = true; 191 | } else if (cspRule.indexOf("'self'") >= 0) { 192 | ok = true; 193 | embedStylesheet = false; 194 | } 195 | } 196 | 197 | // If CSP is enabled but there are no style rules, just bail 198 | // (shouldn't work even normally but no reason to touch it). 199 | if (!ok) { 200 | return response; 201 | } 202 | } 203 | 204 | // Create an identity TransformStream (a.k.a. a pipe). 205 | // The readable side will become our new response body. 206 | const { readable, writable } = new TransformStream(); 207 | 208 | // Create a cloned response with our modified stream 209 | const newResponse = new Response(readable, response); 210 | 211 | // Start the async processing of the response stream 212 | modifyHtmlStream(response.body, writable, request, event, embedStylesheet); 213 | 214 | // Return the in-process response so it can be streamed. 215 | return newResponse; 216 | } 217 | 218 | /** 219 | * Handle the processing of stylesheets (that might have a @import) 220 | * 221 | * @param {*} response - The stylesheet response 222 | * @param {*} request - The original request 223 | * @param {*} event - The original worker event 224 | */ 225 | async function processStylesheetResponse(response, request, event) { 226 | let body = response.body; 227 | try { 228 | body = await response.text(); 229 | const fontCSSRegex = /@import\s*(url\s*)?[\('"\s]+((https?:)?\/\/fonts.googleapis.com\/css[^'"\)]+)[\s'"\)]+\s*;/mgi; 230 | let match = fontCSSRegex.exec(body); 231 | while (match !== null) { 232 | const matchString = match[0]; 233 | const fontCSS = await fetchCSS(match[2], request, event); 234 | if (fontCSS.length) { 235 | body = body.split(matchString).join(fontCSS); 236 | fontCSSRegex.lastIndex -= matchString.length - fontCSS.length; 237 | } 238 | match = fontCSSRegex.exec(body); 239 | } 240 | } catch (e) { 241 | // Ignore the exception, the original body will be passed through. 242 | } 243 | 244 | // Return a cloned response with the (possibly modified) body. 245 | // We can't just return the original response since we already 246 | // consumed the body. 247 | const newResponse = new Response(body, response); 248 | 249 | return newResponse; 250 | } 251 | 252 | /** 253 | * Check to see if the HTML chunk includes a meta tag for an unsupported charset 254 | * @param {*} chunk - Chunk of HTML to scan 255 | * @returns {bool} - true if the HTML chunk includes a meta tag for an unsupported charset 256 | */ 257 | function chunkContainsInvalidCharset(chunk) { 258 | let invalid = false; 259 | 260 | // meta charset 261 | const charsetRegex = /<\s*meta[^>]+charset\s*=\s*['"]([^'"]*)['"][^>]*>/mgi; 262 | const charsetMatch = charsetRegex.exec(chunk); 263 | if (charsetMatch) { 264 | const docCharset = charsetMatch[1].toLowerCase(); 265 | if (!VALID_CHARSETS.includes(docCharset)) { 266 | invalid = true; 267 | } 268 | } 269 | // content-type 270 | const contentTypeRegex = /<\s*meta[^>]+http-equiv\s*=\s*['"]\s*content-type[^>]*>/mgi; 271 | const contentTypeMatch = contentTypeRegex.exec(chunk); 272 | if (contentTypeMatch) { 273 | const metaTag = contentTypeMatch[0]; 274 | const metaRegex = /charset\s*=\s*([^\s"]*)/mgi; 275 | const metaMatch = metaRegex.exec(metaTag); 276 | if (metaMatch) { 277 | const charset = metaMatch[1].toLowerCase(); 278 | if (!VALID_CHARSETS.includes(charset)) { 279 | invalid = true; 280 | } 281 | } 282 | } 283 | return invalid; 284 | } 285 | 286 | /** 287 | * Process the streaming HTML response from the origin server. 288 | * - Attempt to buffer the full head to reduce the likelihood of the font css spanning multiple response chunks 289 | * - Scan the first response chunk for a charset meta tag (and bail if it isn't a supported charset) 290 | * - Pass the gathered head and each subsequent chunk to modifyHtmlChunk() for actual processing of the text. 291 | * 292 | * @param {*} readable - Input stream (from the origin). 293 | * @param {*} writable - Output stream (to the browser). 294 | * @param {*} request - Original request object for downstream use. 295 | * @param {*} event - Worker event object 296 | * @param {bool} embedStylesheet - true if the stylesheet should be embedded in the HTML 297 | */ 298 | async function modifyHtmlStream(readable, writable, request, event, embedStylesheet) { 299 | const reader = readable.getReader(); 300 | const writer = writable.getWriter(); 301 | const encoder = new TextEncoder(); 302 | let decoder = new TextDecoder("utf-8", {fatal: true}); 303 | 304 | let firstChunk = true; 305 | let unsupportedCharset = false; 306 | 307 | let partial = ''; 308 | let content = ''; 309 | 310 | try { 311 | for(;;) { 312 | const { done, value } = await reader.read(); 313 | if (done) { 314 | if (partial.length) { 315 | partial = await modifyHtmlChunk(partial, request, event, embedStylesheet); 316 | await writer.write(encoder.encode(partial)); 317 | partial = ''; 318 | } 319 | break; 320 | } 321 | 322 | let chunk = null; 323 | if (unsupportedCharset) { 324 | // Pass the data straight through 325 | await writer.write(value); 326 | continue; 327 | } else { 328 | try { 329 | chunk = decoder.decode(value, {stream:true}); 330 | } catch (e) { 331 | // Decoding failed, switch to passthrough 332 | unsupportedCharset = true; 333 | if (partial.length) { 334 | await writer.write(encoder.encode(partial)); 335 | partial = ''; 336 | } 337 | await writer.write(value); 338 | continue; 339 | } 340 | } 341 | 342 | try { 343 | // Look inside of the first chunk for a HTML charset or content-type meta tag. 344 | if (firstChunk) { 345 | firstChunk = false; 346 | if (chunkContainsInvalidCharset(chunk)) { 347 | // switch to passthrough 348 | unsupportedCharset = true; 349 | if (partial.length) { 350 | await writer.write(encoder.encode(partial)); 351 | partial = ''; 352 | } 353 | await writer.write(value); 354 | continue; 355 | } 356 | } 357 | 358 | // TODO: Optimize this so we aren't continuously adding strings together 359 | content = partial + chunk; 360 | partial = ''; 361 | 362 | // See if there is an unclosed link tag at the end (and if so, carve it out 363 | // to complete when the remainder comes in). 364 | // This isn't perfect (case sensitive and doesn't allow whitespace in the tag) 365 | // but it is good enough for our purpose and much faster than a regex. 366 | const linkPos = content.lastIndexOf('= 0) { 368 | const linkClose = content.indexOf('/>', linkPos); 369 | if (linkClose === -1) { 370 | partial = content.slice(linkPos); 371 | content = content.slice(0, linkPos); 372 | } 373 | } 374 | 375 | if (content.length) { 376 | content = await modifyHtmlChunk(content, request, event, embedStylesheet); 377 | } 378 | } catch (e) { 379 | // Ignore the exception 380 | } 381 | if (content.length) { 382 | await writer.write(encoder.encode(content)); 383 | content = ''; 384 | } 385 | } 386 | } catch(e) { 387 | // Ignore the exception 388 | } 389 | 390 | try { 391 | await writer.close(); 392 | } catch(e) { 393 | // Ignore the exception 394 | } 395 | } 396 | 397 | /** 398 | * Identify any tags that pull ing Google font css and inline the css file. 399 | * 400 | * @param {*} content - Text chunk from the streaming HTML (or accumulated head) 401 | * @param {*} request - Original request object for downstream use. 402 | * @param {*} event - Worker event object 403 | * @param {bool} embedStylesheet - true if the stylesheet should be embedded in the HTML 404 | */ 405 | async function modifyHtmlChunk(content, request, event, embedStylesheet) { 406 | // Fully tokenizing and parsing the HTML is expensive. This regex is much faster and should be reasonably safe. 407 | // It looks for Stylesheet links for the Google fonts css and extracts the URL as match #1. It shouldn't match 408 | // in-text content because the < > brackets would be escaped in the HTML. There is some potential risk of 409 | // matching it in an inline script (unlikely but possible). 410 | const fontCSSRegex = /]*href\s*=\s*['"]((https?:)?\/\/fonts.googleapis.com\/css[^'"]+)[^>]*>/mgi; 411 | let match = fontCSSRegex.exec(content); 412 | while (match !== null) { 413 | const matchString = match[0]; 414 | if (matchString.indexOf('stylesheet') >= 0) { 415 | if (embedStylesheet) { 416 | const fontCSS = await fetchCSS(match[1], request, event); 417 | if (fontCSS.length) { 418 | // See if there is a media type on the link tag 419 | let mediaStr = ''; 420 | const mediaMatch = matchString.match(/media\s*=\s*['"][^'"]*['"]/mig); 421 | if (mediaMatch) { 422 | mediaStr = ' ' + mediaMatch[0]; 423 | } 424 | // Replace the actual css 425 | let cssString = "\n"; 426 | cssString += fontCSS; 427 | cssString += "\n\n"; 428 | content = content.split(matchString).join(cssString); 429 | fontCSSRegex.lastIndex -= matchString.length - cssString.length; 430 | } 431 | } else { 432 | // Rewrite the URL to proxy it through the origin 433 | let originalUrl = match[1]; 434 | let startPos = originalUrl.indexOf('/fonts.googleapis.com'); 435 | let newUrl = originalUrl.substr(startPos); 436 | let newString = matchString.split(originalUrl).join(newUrl); 437 | content = content.split(matchString).join(newString); 438 | fontCSSRegex.lastIndex -= matchString.length - newString.length; 439 | } 440 | match = fontCSSRegex.exec(content); 441 | } 442 | } 443 | 444 | return content; 445 | } 446 | 447 | // In-memory cache for high-traffic sites 448 | var FONT_CACHE = {}; 449 | 450 | /** 451 | * Fetch the font css from Google using the same browser user-agent string to make sure the 452 | * correct CSS is returned and rewrite the font URLs to proxy them through the worker (on 453 | * the same origin to avoid a new connection). 454 | * 455 | * @param {*} url - URL for the Google font css. 456 | * @param {*} request - Original request for the page HTML so the user-agent can be passed through 457 | * and the origin can be used for rewriting the font paths. 458 | * @param {*} event - Worker event object 459 | */ 460 | async function fetchCSS(url, request) { 461 | let fontCSS = ""; 462 | if (url.startsWith('/')) 463 | url = 'https:' + url; 464 | const userAgent = request.headers.get('user-agent'); 465 | const clientAddr = request.headers.get('cf-connecting-ip'); 466 | const browser = getCacheKey(userAgent); 467 | const cacheKey = browser ? url + '&' + browser : url; 468 | const cacheKeyRequest = new Request(cacheKey); 469 | let cache = null; 470 | 471 | let foundInCache = false; 472 | if (cacheKey in FONT_CACHE) { 473 | // hit in the memory cache 474 | fontCSS = FONT_CACHE[cacheKey]; 475 | foundInCache = true; 476 | } else { 477 | // Try pulling it from the cache API (wrap it in case it's not implemented) 478 | try { 479 | cache = caches.default; 480 | let response = await cache.match(cacheKeyRequest); 481 | if (response) { 482 | fontCSS = await response.text(); 483 | foundInCache = true; 484 | } 485 | } catch(e) { 486 | // Ignore the exception 487 | } 488 | } 489 | 490 | if (!foundInCache) { 491 | let headers = {'Referer': request.url}; 492 | if (browser) { 493 | headers['User-Agent'] = userAgent; 494 | } else { 495 | headers['User-Agent'] = "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)"; 496 | } 497 | if (clientAddr) { 498 | headers['X-Forwarded-For'] = clientAddr; 499 | } 500 | 501 | try { 502 | const response = await fetch(url, {headers: headers}); 503 | if (response && response.status === 200) { 504 | fontCSS = await response.text(); 505 | 506 | // Rewrite all of the font URLs to come through the worker 507 | fontCSS = fontCSS.replace(/(https?:)?\/\/fonts\.gstatic\.com\//mgi, '/fonts.gstatic.com/'); 508 | 509 | // Add the css info to the font caches 510 | FONT_CACHE[cacheKey] = fontCSS; 511 | try { 512 | if (cache) { 513 | const cacheResponse = new Response(fontCSS, {ttl: 86400}); 514 | event.waitUntil(cache.put(cacheKeyRequest, cacheResponse)); 515 | } 516 | } catch(e) { 517 | // Ignore the exception 518 | } 519 | } 520 | } catch(e) { 521 | // Ignore the exception 522 | } 523 | } 524 | 525 | return fontCSS; 526 | } 527 | 528 | /** 529 | * Identify the common browsers (and versions) for using browser-specific css. 530 | * Others will use a common fallback css fetched without a user agent string (ttf). 531 | * 532 | * @param {*} userAgent - Browser user agent string 533 | * @returns {*} A browser-version-specific string like Chrome61 534 | */ 535 | function getCacheKey(userAgent) { 536 | let os = ''; 537 | const osRegex = /^[^(]*\(\s*(\w+)/mgi; 538 | let match = osRegex.exec(userAgent); 539 | if (match) { 540 | os = match[1]; 541 | } 542 | 543 | let mobile = ''; 544 | if (userAgent.match(/Mobile/mgi)) { 545 | mobile = 'Mobile'; 546 | } 547 | 548 | // Detect Edge first since it includes Chrome and Safari 549 | const edgeRegex = /\s+Edge\/(\d+)/mgi; 550 | match = edgeRegex.exec(userAgent); 551 | if (match) { 552 | return 'Edge' + match[1] + os + mobile; 553 | } 554 | 555 | // Detect Chrome next (and browsers using the Chrome UA/engine) 556 | const chromeRegex = /\s+Chrome\/(\d+)/mgi; 557 | match = chromeRegex.exec(userAgent); 558 | if (match) { 559 | return 'Chrome' + match[1] + os + mobile; 560 | } 561 | 562 | // Detect Safari and Webview next 563 | const webkitRegex = /\s+AppleWebKit\/(\d+)/mgi; 564 | match = webkitRegex.exec(userAgent.match); 565 | if (match) { 566 | return 'WebKit' + match[1] + os + mobile; 567 | } 568 | 569 | // Detect Firefox 570 | const firefoxRegex = /\s+Firefox\/(\d+)/mgi; 571 | match = firefoxRegex.exec(userAgent); 572 | if (match) { 573 | return 'Firefox' + match[1] + os + mobile; 574 | } 575 | 576 | return null; 577 | } 578 | --------------------------------------------------------------------------------