├── .github ├── funding.yml └── workflows │ └── build.yml ├── .eslintignore ├── .eslintrc ├── .editorconfig ├── src ├── global-res-headers.js ├── fetch-cache.js ├── handle-options.js ├── resolve-request.js ├── normalize-url.js ├── get-request-cache-key.js ├── fetch-request.js ├── index.js └── normalize-url.test.js ├── wrangler.toml ├── .prettierrc ├── webpack.config.js ├── .gitignore ├── license ├── package.json └── readme.md /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [transitive-bullshit] 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .snapshots/ 2 | dist/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "browser": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /src/global-res-headers.js: -------------------------------------------------------------------------------- 1 | export const globalResHeaders = { 2 | 'access-control-allow-origin': '*' 3 | } 4 | 5 | export const globalResHeadersKeys = Object.keys(globalResHeaders) 6 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cf-image-proxy" 2 | type = "javascript" 3 | webpack_config = "webpack.config.js" 4 | account_id = "TODO" 5 | workers_dev = true 6 | 7 | [env.production] 8 | zone_id = "TODO" 9 | route = "TODO" 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "bracketSpacing": true, 8 | "jsxBracketSameLine": false, 9 | "arrowParens": "always", 10 | "trailingComma": "none" 11 | } 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { EnvironmentPlugin } = require('webpack') 4 | const pick = require('lodash.pick') 5 | 6 | module.exports = { 7 | target: 'webworker', 8 | entry: './src/index.js', 9 | devtool: 'cheap-source-map', 10 | plugins: [new EnvironmentPlugin(pick(process.env, ['NODE_ENV']))] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [12.x, 14.x] 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Use Node.js ${{ matrix.node-version }} 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: ${{ matrix.node-version }} 15 | - run: npm install 16 | - run: npm run build --if-present 17 | - run: npm run test --if-present 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.build 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | dist/ -------------------------------------------------------------------------------- /src/fetch-cache.js: -------------------------------------------------------------------------------- 1 | const cache = caches.default 2 | 3 | export async function fetchCache(opts) { 4 | const { event, cacheKey, fetch: fetchResponse } = opts 5 | 6 | let response 7 | 8 | if (cacheKey) { 9 | console.log('cacheKey', cacheKey.url) 10 | response = await cache.match(cacheKey) 11 | } 12 | 13 | if (!response) { 14 | response = await fetchResponse() 15 | response = new Response(response.body, response) 16 | 17 | if (cacheKey) { 18 | if (response.headers.has('Cache-Control')) { 19 | // cache will respect response headers 20 | event.waitUntil( 21 | cache.put(cacheKey, response.clone()).catch((err) => { 22 | console.warn('cache put error', cacheKey, err) 23 | }) 24 | ) 25 | } 26 | 27 | response.headers.set('cf-cache-status', 'MISS') 28 | } else { 29 | response.headers.set('cf-cache-status', 'BYPASS') 30 | } 31 | } 32 | 33 | return response 34 | } 35 | -------------------------------------------------------------------------------- /src/handle-options.js: -------------------------------------------------------------------------------- 1 | const allowedMethods = 'GET, HEAD, POST, PUT, DELETE, TRACE, PATCH, OPTIONS' 2 | 3 | export function handleOptions(request) { 4 | // Make sure the necessary headers are present for this to be a valid pre-flight request 5 | if ( 6 | request.headers.get('Origin') !== null && 7 | request.headers.get('Access-Control-Request-Method') !== null && 8 | request.headers.get('Access-Control-Request-Headers') !== null 9 | ) { 10 | // Handle CORS pre-flight request. 11 | return new Response(null, { 12 | headers: { 13 | 'Access-Control-Allow-Origin': '*', 14 | 'Access-Control-Allow-Methods': allowedMethods, 15 | 'Access-Control-Allow-Headers': request.headers.get( 16 | 'Access-Control-Request-Headers' 17 | ) 18 | } 19 | }) 20 | } else { 21 | // Handle standard OPTIONS request. 22 | // If you want to allow other HTTP Methods, you can do that here. 23 | return new Response(null, { 24 | headers: { 25 | Allow: allowedMethods 26 | } 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Travis Fischer 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 | -------------------------------------------------------------------------------- /src/resolve-request.js: -------------------------------------------------------------------------------- 1 | const notionImagePrefix = 'https://www.notion.so/image/' 2 | 3 | /** 4 | * @param {*} event 5 | * @param {*} request 6 | */ 7 | export async function resolveRequest(event, request) { 8 | const requestUrl = new URL(request.url) 9 | let originUri = decodeURIComponent(requestUrl.pathname.slice(1)) 10 | 11 | // special-case optimization for notion images coming from unsplash 12 | if (originUri.startsWith(notionImagePrefix)) { 13 | const imageUri = decodeURIComponent( 14 | originUri.slice(notionImagePrefix.length) 15 | ) 16 | const imageUrl = new URL(imageUri) 17 | 18 | // adjust unsplash defaults to have a max width and intelligent format conversion 19 | if (imageUrl.hostname === 'images.unsplash.com') { 20 | const { searchParams } = imageUrl 21 | 22 | if (!searchParams.has('w') && !searchParams.has('fit')) { 23 | imageUrl.searchParams.set('w', 1920) 24 | imageUrl.searchParams.set('fit', 'max') 25 | } 26 | 27 | if (!searchParams.has('auto')) { 28 | imageUrl.searchParams.set('auto', 'format') 29 | } 30 | 31 | originUri = `${notionImagePrefix}${encodeURIComponent( 32 | imageUrl.toString() 33 | )}` 34 | } 35 | } 36 | 37 | const originReq = new Request(originUri, request) 38 | 39 | return { originReq } 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-image-proxy", 3 | "private": true, 4 | "description": "Proxy CDN for caching static assets from third-party domains via Cloudflare Workers.", 5 | "author": "Travis Fischer ", 6 | "repository": "transitive-bullshit/cf-image-proxy", 7 | "homepage": "https://transitivebullsh.it", 8 | "license": "MIT", 9 | "main": "./dist/main.js", 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "run-p build:watch worker:watch", 15 | "prestart": "test -e dist/main.js || run-s \"build --mode development\"", 16 | "dev": "run-s start", 17 | "build": "NODE_ENV=production webpack --mode production", 18 | "build:development": "NODE_ENV=production webpack --mode development", 19 | "build:watch": "NODE_ENV=development webpack --mode development --watch", 20 | "worker": "cloudworker --port 6100 --debug --enable-cache dist/main.js", 21 | "worker:watch": "run-s \"worker --watch\"", 22 | "prepreview": "run-s build:development", 23 | "preview": "wrangler preview", 24 | "prepublish": "run-s build:development", 25 | "publish": "wrangler publish", 26 | "deploy": "run-s \"publish --env production\"", 27 | "pretest": "run-s build", 28 | "test": "ava -v" 29 | }, 30 | "devDependencies": { 31 | "@cloudflare/wrangler": "^1.6.0", 32 | "@dollarshaveclub/cloudworker": "^0.1.2", 33 | "ava": "^2.4.0", 34 | "esm": "^3.2.25", 35 | "lodash.pick": "^4.4.0", 36 | "npm-run-all": "^4.1.5", 37 | "webpack": "^4.41.5", 38 | "webpack-cli": "^3.3.10" 39 | }, 40 | "ava": { 41 | "failFast": true, 42 | "snapshotDir": "./.snapshots", 43 | "require": [ 44 | "esm" 45 | ] 46 | }, 47 | "dependencies": { 48 | "lodash.get": "^4.4.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/normalize-url.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Stripped down version of [normalize-url](https://github.com/sindresorhus/normalize-url) 3 | * by sindresorhus 4 | * 5 | * - always converts http => https 6 | * - removed unused options 7 | * - removed dataURL support 8 | */ 9 | export const normalizeUrl = (urlString) => { 10 | const urlObj = new URL(urlString) 11 | 12 | if (urlObj.protocol === 'http:') { 13 | urlObj.protocol = 'https:' 14 | } 15 | 16 | /* 17 | // Remove auth 18 | // TODO: Cloudflare Workers seems to have a subtle bug where if you set URL.username and 19 | // URL.password at all, it will include the @ authentication prefix in the resulting URL. 20 | // This does not repro in normal web or Node.js contexts. 21 | if (options.stripAuthentication) { 22 | urlObj.username = '' 23 | urlObj.password = '' 24 | } 25 | */ 26 | 27 | // Remove duplicate slashes if not preceded by a protocol 28 | if (urlObj.pathname) { 29 | urlObj.pathname = urlObj.pathname.replace(/(? s.trim())) 20 | if (directives.has('no-store') || directives.has('no-cache')) { 21 | return null 22 | } 23 | } 24 | 25 | const url = request.url 26 | const normalizedUrl = normalizeUrl(url) 27 | 28 | if (url !== normalizedUrl) { 29 | return normalizeRequestHeaders( 30 | new Request(normalizedUrl, { method: request.method }) 31 | ) 32 | } 33 | 34 | return normalizeRequestHeaders(new Request(request)) 35 | } catch (err) { 36 | console.error('error computing cache key', request.method, request.url, err) 37 | return null 38 | } 39 | } 40 | 41 | const requestHeaderWhitelist = new Set([ 42 | 'cache-control', 43 | 'accept', 44 | 'accept-encoding', 45 | 'accept-language', 46 | 'user-agent' 47 | ]) 48 | 49 | function normalizeRequestHeaders(request) { 50 | const headers = Object.fromEntries(request.headers.entries()) 51 | const keys = Object.keys(headers) 52 | 53 | for (const key of keys) { 54 | if (!requestHeaderWhitelist.has(key)) { 55 | request.headers.delete(key) 56 | } 57 | } 58 | 59 | return request 60 | } 61 | -------------------------------------------------------------------------------- /src/fetch-request.js: -------------------------------------------------------------------------------- 1 | import * as globalHeaders from './global-res-headers' 2 | 3 | const headerWhitelist = new Set([ 4 | 'connection', 5 | 'content-disposition', 6 | 'content-type', 7 | 'content-length', 8 | 'cf-polished', 9 | 'date', 10 | 'status', 11 | 'transfer-encoding' 12 | ]) 13 | 14 | export async function fetchRequest(event, { originReq }) { 15 | // const originRes = await fetch(originReq) 16 | 17 | // console.log( 18 | // 'req', 19 | // originReq.url, 20 | // Object.fromEntries(originReq.headers.entries()) 21 | // ) 22 | 23 | const originRes = await fetch(originReq, { 24 | cf: { 25 | polish: 'lossy', 26 | cacheEverything: true 27 | } 28 | }) 29 | 30 | // Construct a new response so we can mutate its headers 31 | const res = new Response(originRes.body, originRes) 32 | // console.log('res0', res.status, Object.fromEntries(res.headers.entries())) 33 | 34 | // Stripe additional headers from the response that may impact cacheability 35 | // like content security policy stuff 36 | normalizeResponseHeaders(res) 37 | 38 | // Override cache-control 39 | res.headers.set( 40 | 'cache-control', 41 | 'public, immutable, s-maxage=31536000, max-age=31536000, stale-while-revalidate=60' 42 | ) 43 | 44 | // Set CORS headers 45 | for (const header of globalHeaders.globalResHeadersKeys) { 46 | res.headers.set(header, globalHeaders.globalResHeaders[header]) 47 | } 48 | 49 | // console.log('res1', res.status, Object.fromEntries(res.headers.entries())) 50 | return res 51 | } 52 | 53 | function normalizeResponseHeaders(res) { 54 | const headers = Object.fromEntries(res.headers.entries()) 55 | const keys = Object.keys(headers) 56 | 57 | for (const key of keys) { 58 | if (!headerWhitelist.has(key)) { 59 | res.headers.delete(key) 60 | } 61 | } 62 | 63 | return res 64 | } 65 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { fetchCache } from './fetch-cache' 2 | import { getRequestCacheKey } from './get-request-cache-key' 3 | import { handleOptions } from './handle-options' 4 | import { fetchRequest } from './fetch-request' 5 | import { resolveRequest } from './resolve-request' 6 | import { globalResHeaders } from './global-res-headers' 7 | 8 | addEventListener('fetch', (event) => { 9 | event.respondWith(handleFetchEvent(event)) 10 | }) 11 | 12 | /** 13 | * @param {*} event 14 | */ 15 | async function handleFetchEvent(event) { 16 | const gatewayStartTime = Date.now() 17 | let gatewayTimespan 18 | let res 19 | 20 | function recordTimespans() { 21 | const now = Date.now() 22 | gatewayTimespan = now - gatewayStartTime 23 | } 24 | 25 | try { 26 | const { request } = event 27 | const { method } = request 28 | 29 | if (method === 'OPTIONS') { 30 | return handleOptions(request) 31 | } 32 | 33 | const { originReq } = await resolveRequest(event, request) 34 | 35 | try { 36 | const cacheKey = await getRequestCacheKey(originReq) 37 | const originRes = await fetchCache({ 38 | event, 39 | cacheKey, 40 | fetch: () => fetchRequest(event, { originReq }) 41 | }) 42 | 43 | res = new Response(originRes.body, originRes) 44 | recordTimespans() 45 | 46 | res.headers.set('x-proxy-response-time', `${gatewayTimespan}ms`) 47 | 48 | return res 49 | } catch (err) { 50 | console.error(err) 51 | recordTimespans() 52 | 53 | res = new Response( 54 | JSON.stringify({ 55 | error: err.message, 56 | type: err.type, 57 | code: err.code 58 | }), 59 | { status: 500, headers: globalResHeaders } 60 | ) 61 | 62 | return res 63 | } 64 | } catch (err) { 65 | console.error(err) 66 | 67 | if (err.response) { 68 | // TODO: make sure this response also has CORS globalResHeaders 69 | return err.response 70 | } else { 71 | return new Response( 72 | JSON.stringify({ 73 | error: err.message, 74 | type: err.type, 75 | code: err.code 76 | }), 77 | { 78 | status: 500, 79 | headers: globalResHeaders 80 | } 81 | ) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # CF Image proxy 2 | 3 | > Image proxy and CDN for [Cloudflare Workers](https://workers.cloudflare.com). 4 | 5 | [![Build Status](https://github.com/transitive-bullshit/cf-image-proxy/actions/workflows/build.yml/badge.svg)](https://github.com/transitive-bullshit/cf-image-proxy/actions/workflows/build.yml) [![Prettier Code Formatting](https://img.shields.io/badge/code_style-prettier-brightgreen.svg)](https://prettier.io) 6 | 7 | ## Features 8 | 9 | - Free 💪 10 | - Super simple to setup and self-host 11 | - Perfect lighthouse scores 12 | - Handles CORS for you 13 | - Normalizes origin URLs 14 | - Respects `pragma: no-cache` and related headers 15 | - Used in hundreds of prod sites 16 | 17 | ## Setup 18 | 19 | 1. Create a new blank [Cloudflare Worker](https://workers.cloudflare.com). 20 | 2. Fork / clone this repo 21 | 3. Update the missing values in [wrangler.toml](./wrangler.toml) 22 | 4. `npm install` 23 | 5. `npm run dev` to test locally 24 | 6. `npm run deploy` to deploy to cloudflare workers 💪 25 | 26 | ### wrangler.toml 27 | 28 | ```yaml 29 | name = "cf-image-proxy" 30 | type = "javascript" 31 | webpack_config = "webpack.config.js" 32 | account_id = "TODO" 33 | workers_dev = true 34 | 35 | [env.production] 36 | zone_id = "TODO" 37 | route = "TODO" 38 | ``` 39 | 40 | You can find your `account_id` and `zone_id` in your Cloudflare Workers settings. 41 | 42 | Your `route` should look like `"exampledomain.com/*"`. 43 | 44 | ### Cloudflare Polish 45 | 46 | You can optionally enable Polish in your Cloudflare zone settings if you want to enable on-the-fly image optimization as part of your CDN. In many cases, this will serve images to supported clients in an optimized `webp` format. 47 | 48 | This may increase costs, so it's not recommended for everyone. The CF worker should support both configurations without issue. 49 | 50 | ### CDN 51 | 52 | By default, all assets will be served with a `cache-control` header set to `public, immutable, s-maxage=31536000, max-age=31536000, stale-while-revalidate=60`, which effectively makes them cached at all levels indefinitely (or more practically until Cloudflare or your browser purges the asset from its cache). 53 | 54 | If you want to change this `cache-control` header or add additional headers, see [src/fetch-request.js](./src/fetch-request.js). 55 | 56 | ## Usage 57 | 58 | ### Next.js Notion Starter Kit 59 | 60 | If you're using this image proxy as part of [nextjs-notion-starter-kit](https://github.com/transitive-bullshit/nextjs-notion-starter-kit), all you need to do is set `imageCDNHost` in your `site.config.js` and your image proxy will be used automatically. 61 | 62 | If you're not using this Next.js Notion boilerplate, then read on. 63 | 64 | ### General Usage 65 | 66 | In the application where you want to consume your proxied images, you'll need to replace your third-party image URLs. 67 | 68 | You can replace them with your proxy domain plus a path that contains the URI-encoded version of your original domain. In TypeScript, this looks like the following: 69 | 70 | ```ts 71 | const imageCDNHost = 'https://exampledomain.com' 72 | 73 | export const mapImageUrl = (imageUrl: string) => { 74 | if (imageUrl.startsWith('data:')) { 75 | return imageUrl 76 | } 77 | 78 | if (imageCDNHost) { 79 | // Our proxy uses Cloudflare's global CDN to cache these image assets 80 | return `${imageCDNHost}/${encodeURIComponent(imageUrl)}` 81 | } else { 82 | return imageUrl 83 | } 84 | } 85 | ``` 86 | 87 | ## Technical Notes 88 | 89 | A few notes about the implementation: 90 | 91 | - It is hosted via Cloudflare (CF) edge [workers](https://workers.cloudflare.com). 92 | - It is transpiled by webpack before uploading to CF. 93 | - CF runs our worker via V8 directly in an environment mimicking [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API). 94 | - This means that our worker does not have access to Node.js primitives such as `fs`, `dns` and `http`. 95 | - It does have access to a custom [web fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). 96 | 97 | ## TODO 98 | 99 | - [x] Initial release extracted from Notion2Site 100 | - [ ] Support restricting the origin domain in order to prevent abuse 101 | - [ ] Add a snazzy demo 102 | 103 | ## License 104 | 105 | MIT © [Travis Fischer](https://transitivebullsh.it) 106 | 107 | Support my OSS work by following me on twitter twitter 108 | -------------------------------------------------------------------------------- /src/normalize-url.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { normalizeUrl } from './normalize-url' 3 | 4 | test('main', (t) => { 5 | t.is(normalizeUrl('http://sindresorhus.com'), 'https://sindresorhus.com') 6 | t.is(normalizeUrl('http://sindresorhus.com '), 'https://sindresorhus.com') 7 | t.is(normalizeUrl('http://sindresorhus.com.'), 'https://sindresorhus.com') 8 | t.is(normalizeUrl('http://SindreSorhus.com'), 'https://sindresorhus.com') 9 | t.is(normalizeUrl('http://sindresorhus.com'), 'https://sindresorhus.com') 10 | t.is(normalizeUrl('HTTP://sindresorhus.com'), 'https://sindresorhus.com') 11 | 12 | // TODO: why isn't this parsed correctly by Node.js URL? 13 | // t.is(normalizeUrl('//sindresorhus.com'), 'https://sindresorhus.com') 14 | 15 | t.is(normalizeUrl('http://sindresorhus.com'), 'https://sindresorhus.com') 16 | t.is(normalizeUrl('http://sindresorhus.com:80'), 'https://sindresorhus.com') 17 | t.is(normalizeUrl('https://sindresorhus.com:443'), 'https://sindresorhus.com') 18 | t.is(normalizeUrl('ftp://sindresorhus.com:21'), 'ftp://sindresorhus.com') 19 | t.is( 20 | normalizeUrl('https://sindresorhus.com/foo/'), 21 | 'https://sindresorhus.com/foo' 22 | ) 23 | t.is( 24 | normalizeUrl('http://sindresorhus.com/?foo=bar baz'), 25 | 'https://sindresorhus.com/?foo=bar+baz' 26 | ) 27 | t.is( 28 | normalizeUrl('https://foo.com/https://bar.com'), 29 | 'https://foo.com/https://bar.com' 30 | ) 31 | t.is( 32 | normalizeUrl('https://foo.com/https://bar.com/foo//bar'), 33 | 'https://foo.com/https://bar.com/foo/bar' 34 | ) 35 | t.is( 36 | normalizeUrl('https://foo.com/http://bar.com'), 37 | 'https://foo.com/http://bar.com' 38 | ) 39 | t.is( 40 | normalizeUrl('https://foo.com/http://bar.com/foo//bar'), 41 | 'https://foo.com/http://bar.com/foo/bar' 42 | ) 43 | t.is( 44 | normalizeUrl('https://sindresorhus.com/%7Efoo/'), 45 | 'https://sindresorhus.com/~foo', 46 | 'decode URI octets' 47 | ) 48 | t.is(normalizeUrl('https://sindresorhus.com/?'), 'https://sindresorhus.com') 49 | t.is(normalizeUrl('https://êxample.com'), 'https://xn--xample-hva.com') 50 | t.is( 51 | normalizeUrl('https://sindresorhus.com/?b=bar&a=foo'), 52 | 'https://sindresorhus.com/?a=foo&b=bar' 53 | ) 54 | t.is( 55 | normalizeUrl('https://sindresorhus.com/?foo=bar*|<>:"'), 56 | 'https://sindresorhus.com/?foo=bar*%7C%3C%3E%3A%22' 57 | ) 58 | t.is( 59 | normalizeUrl('https://sindresorhus.com:5000'), 60 | 'https://sindresorhus.com:5000' 61 | ) 62 | t.is( 63 | normalizeUrl('https://sindresorhus.com/foo#bar'), 64 | 'https://sindresorhus.com/foo#bar' 65 | ) 66 | t.is( 67 | normalizeUrl('https://sindresorhus.com/foo/bar/../baz'), 68 | 'https://sindresorhus.com/foo/baz' 69 | ) 70 | t.is( 71 | normalizeUrl('https://sindresorhus.com/foo/bar/./baz'), 72 | 'https://sindresorhus.com/foo/bar/baz' 73 | ) 74 | t.is( 75 | normalizeUrl( 76 | 'https://i.vimeocdn.com/filter/overlay?src0=https://i.vimeocdn.com/video/598160082_1280x720.jpg&src1=https://f.vimeocdn.com/images_v6/share/play_icon_overlay.png' 77 | ), 78 | 'https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F598160082_1280x720.jpg&src1=https%3A%2F%2Ff.vimeocdn.com%2Fimages_v6%2Fshare%2Fplay_icon_overlay.png' 79 | ) 80 | }) 81 | 82 | test('removeTrailingSlash and removeDirectoryIndex options)', (t) => { 83 | t.is( 84 | normalizeUrl('https://sindresorhus.com/path/'), 85 | 'https://sindresorhus.com/path' 86 | ) 87 | t.is( 88 | normalizeUrl('https://sindresorhus.com/#/path/'), 89 | 'https://sindresorhus.com/#/path/' 90 | ) 91 | t.is( 92 | normalizeUrl('https://sindresorhus.com/foo/#/bar/'), 93 | 'https://sindresorhus.com/foo#/bar/' 94 | ) 95 | }) 96 | 97 | test('sortQueryParameters', (t) => { 98 | t.is( 99 | normalizeUrl('https://sindresorhus.com/?a=Z&b=Y&c=X&d=W'), 100 | 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' 101 | ) 102 | t.is( 103 | normalizeUrl('https://sindresorhus.com/?b=Y&c=X&a=Z&d=W'), 104 | 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' 105 | ) 106 | t.is( 107 | normalizeUrl('https://sindresorhus.com/?a=Z&d=W&b=Y&c=X'), 108 | 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' 109 | ) 110 | t.is(normalizeUrl('https://sindresorhus.com/'), 'https://sindresorhus.com') 111 | }) 112 | 113 | test('invalid urls', (t) => { 114 | t.throws(() => { 115 | normalizeUrl('http://') 116 | }, 'Invalid URL: http://') 117 | 118 | t.throws(() => { 119 | normalizeUrl('/') 120 | }, 'Invalid URL: /') 121 | 122 | t.throws(() => { 123 | normalizeUrl('/relative/path/') 124 | }, 'Invalid URL: /relative/path/') 125 | }) 126 | 127 | test('remove duplicate pathname slashes', (t) => { 128 | t.is( 129 | normalizeUrl('https://sindresorhus.com////foo/bar'), 130 | 'https://sindresorhus.com/foo/bar' 131 | ) 132 | t.is( 133 | normalizeUrl('https://sindresorhus.com////foo////bar'), 134 | 'https://sindresorhus.com/foo/bar' 135 | ) 136 | t.is( 137 | normalizeUrl('ftp://sindresorhus.com//foo'), 138 | 'ftp://sindresorhus.com/foo' 139 | ) 140 | t.is( 141 | normalizeUrl('https://sindresorhus.com:5000///foo'), 142 | 'https://sindresorhus.com:5000/foo' 143 | ) 144 | t.is( 145 | normalizeUrl('https://sindresorhus.com///foo'), 146 | 'https://sindresorhus.com/foo' 147 | ) 148 | t.is( 149 | normalizeUrl('https://sindresorhus.com:5000//foo'), 150 | 'https://sindresorhus.com:5000/foo' 151 | ) 152 | t.is( 153 | normalizeUrl('https://sindresorhus.com//foo'), 154 | 'https://sindresorhus.com/foo' 155 | ) 156 | }) 157 | --------------------------------------------------------------------------------