├── .eslintrc ├── .github ├── .kodiak.toml ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── labeler.yml │ └── release-please.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── netlify │ └── functions │ │ ├── gatsby.ts │ │ └── ipx.ts └── public │ ├── img │ └── test.jpg │ └── index.html ├── manifest.yml ├── netlify.toml ├── package.json ├── renovate.json ├── src ├── http.ts ├── index.ts └── utils.ts ├── test ├── index.test.ts └── utils.test.ts ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs/eslint-config-typescript" 4 | ], 5 | "ignorePatterns": "dist", 6 | "rules": { 7 | "import/named": "off", 8 | "vue/valid-attribute-name": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge.automerge_dependencies] 4 | versions = ["minor", "patch"] 5 | usernames = ["renovate"] 6 | 7 | [approve] 8 | auto_approve_usernames = ["renovate"] -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @netlify/ecosystem-pod-frameworks 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | node: [18] 19 | 20 | steps: 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node }} 24 | 25 | - name: checkout 26 | uses: actions/checkout@master 27 | 28 | - name: cache node_modules 29 | uses: actions/cache@v3 30 | with: 31 | path: node_modules 32 | key: ${{ matrix.os }}-node-v${{ matrix.node }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 33 | 34 | - name: Install dependencies 35 | if: steps.cache.outputs.cache-hit != 'true' 36 | run: yarn 37 | 38 | - name: Lint 39 | run: yarn lint 40 | 41 | - name: Tests 42 | run: yarn test 43 | 44 | - name: Build 45 | run: yarn build 46 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '32 9 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Label PR 2 | on: 3 | pull_request: 4 | types: [opened, edited] 5 | 6 | jobs: 7 | label-pr: 8 | if: github.event_name == 'pull_request' 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | pr: 13 | [ 14 | { prefix: 'fix', type: 'bug' }, 15 | { prefix: 'chore', type: 'chore' }, 16 | { prefix: 'docs', type: 'chore' }, 17 | { prefix: 'test', type: 'chore' }, 18 | { prefix: 'ci', type: 'chore' }, 19 | { prefix: 'feat', type: 'feature' }, 20 | { prefix: 'security', type: 'security' }, 21 | ] 22 | steps: 23 | - uses: netlify/pr-labeler-action@v1.1.0 24 | if: startsWith(github.event.pull_request.title, matrix.pr.prefix) 25 | with: 26 | token: '${{ secrets.GITHUB_TOKEN }}' 27 | label: 'type: ${{ matrix.pr.type }}' 28 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: navikt/github-app-token-generator@a3831f44404199df32d8f39f7c0ad9bb8fa18b1c 11 | id: get-token 12 | with: 13 | private-key: ${{ secrets.TOKENS_PRIVATE_KEY }} 14 | app-id: ${{ secrets.TOKENS_APP_ID }} 15 | - uses: GoogleCloudPlatform/release-please-action@v3 16 | id: release 17 | with: 18 | token: ${{ steps.get-token.outputs.token }} 19 | release-type: node 20 | package-name: '@netlify/ipx' 21 | - uses: actions/checkout@v4 22 | if: ${{ steps.release.outputs.release_created }} 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: '*' 26 | cache: 'npm' 27 | check-latest: true 28 | registry-url: 'https://registry.npmjs.org' 29 | if: ${{ steps.release.outputs.release_created }} 30 | - name: Install dependencies 31 | run: yarn 32 | if: ${{ steps.release.outputs.release_created }} 33 | - run: npm publish 34 | if: ${{ steps.release.outputs.release_created }} 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | dist 4 | .netlify 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.4.6](https://github.com/netlify/netlify-ipx/compare/v1.4.5...v1.4.6) (2023-11-14) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * **deps:** pin unstorage to 1.9.0 ([#225](https://github.com/netlify/netlify-ipx/issues/225)) ([a81e620](https://github.com/netlify/netlify-ipx/commit/a81e620f6bdd506d818f1a26758163ec3b25724d)) 11 | * **deps:** update dependency @netlify/functions to ^2.2.0 ([#207](https://github.com/netlify/netlify-ipx/issues/207)) ([7ea37de](https://github.com/netlify/netlify-ipx/commit/7ea37de51fe63190b886e47b8a75c187cfbee0af)) 12 | * **deps:** update dependency @netlify/functions to ^2.2.1 ([#209](https://github.com/netlify/netlify-ipx/issues/209)) ([f2d4630](https://github.com/netlify/netlify-ipx/commit/f2d46300355a2594329013aa4ece1616839e22d8)) 13 | * **deps:** update dependency @netlify/functions to ^2.3.0 ([#211](https://github.com/netlify/netlify-ipx/issues/211)) ([b981dc8](https://github.com/netlify/netlify-ipx/commit/b981dc8093acbcb1e5bf51363e8d22b3d4ba818c)) 14 | * **deps:** update dependency @netlify/functions to ^2.4.0 ([#222](https://github.com/netlify/netlify-ipx/issues/222)) ([e71a227](https://github.com/netlify/netlify-ipx/commit/e71a2278092c391618b1bf27c557b0975296ea31)) 15 | 16 | ## [1.4.5](https://github.com/netlify/netlify-ipx/compare/v1.4.4...v1.4.5) (2023-10-02) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * **deps:** update dependency @netlify/functions to ^2.0.2 ([#189](https://github.com/netlify/netlify-ipx/issues/189)) ([1839eb1](https://github.com/netlify/netlify-ipx/commit/1839eb16d009bd7e2e1ca3274e01ed24f3809b4d)) 22 | * **deps:** update dependency @netlify/functions to ^2.1.0 ([#204](https://github.com/netlify/netlify-ipx/issues/204)) ([90e707c](https://github.com/netlify/netlify-ipx/commit/90e707c02c36ada4a89effd46921a25bd63ed0c6)) 23 | * **deps:** update dependency ipx to v1.3.0 ([#198](https://github.com/netlify/netlify-ipx/issues/198)) ([2191a92](https://github.com/netlify/netlify-ipx/commit/2191a922a0fc99f7dfa734643af2ca74220aeb13)) 24 | * **deps:** update dependency node-fetch to v2.7.0 ([#194](https://github.com/netlify/netlify-ipx/issues/194)) ([a9e82dc](https://github.com/netlify/netlify-ipx/commit/a9e82dc772a95443124a723b68216e6ecb910b8a)) 25 | * **deps:** update dependency ufo to v1.3.0 ([#195](https://github.com/netlify/netlify-ipx/issues/195)) ([13afa43](https://github.com/netlify/netlify-ipx/commit/13afa43a1a65e2c0a936f79eca9169f398f43048)) 26 | * **deps:** update dependency ufo to v1.3.1 ([#205](https://github.com/netlify/netlify-ipx/issues/205)) ([3dec84e](https://github.com/netlify/netlify-ipx/commit/3dec84e35deebb925b2a84f682cd1030920238de)) 27 | * update to `ipx@1.3.1` ([#206](https://github.com/netlify/netlify-ipx/issues/206)) ([fb85196](https://github.com/netlify/netlify-ipx/commit/fb851968efbae466bdc7c73528f973b6d5579615)) 28 | 29 | ## [1.4.4](https://github.com/netlify/netlify-ipx/compare/v1.4.3...v1.4.4) (2023-08-22) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * **deps:** update dependency @netlify/functions to v2 ([#188](https://github.com/netlify/netlify-ipx/issues/188)) ([fbdab27](https://github.com/netlify/netlify-ipx/commit/fbdab275881e518d5390964518019d21d33644b0)) 35 | * **deps:** update dependency node-fetch to v2.6.13 ([#187](https://github.com/netlify/netlify-ipx/issues/187)) ([9f6ad31](https://github.com/netlify/netlify-ipx/commit/9f6ad31b5864132dd522fb8600ba91dab1432a0f)) 36 | * **deps:** update dependency unstorage to v1.9.0 ([#184](https://github.com/netlify/netlify-ipx/issues/184)) ([65e6b25](https://github.com/netlify/netlify-ipx/commit/65e6b251bb630ca16c3f48bca47e81c84b4f036c)) 37 | 38 | ## [1.4.3](https://github.com/netlify/netlify-ipx/compare/v1.4.2...v1.4.3) (2023-08-10) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * casing now matches what comes in a Netlify function via event.headers ([#181](https://github.com/netlify/netlify-ipx/issues/181)) ([978f4c8](https://github.com/netlify/netlify-ipx/commit/978f4c868e06d8f4df1a1651ebecf1cffeeb4bba)) 44 | * **deps:** update dependency mkdirp to v3 ([#162](https://github.com/netlify/netlify-ipx/issues/162)) ([d946c42](https://github.com/netlify/netlify-ipx/commit/d946c427f2f33aad52cfe956217831400ffc66db)) 45 | 46 | ## [1.4.2](https://github.com/netlify/netlify-ipx/compare/v1.4.1...v1.4.2) (2023-08-03) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * **deps:** update dependency node-fetch to v2.6.12 ([#169](https://github.com/netlify/netlify-ipx/issues/169)) ([6f08164](https://github.com/netlify/netlify-ipx/commit/6f08164358177e6c7555da572bf1d19a1899299a)) 52 | * **deps:** update dependency ufo to v1.2.0 ([#177](https://github.com/netlify/netlify-ipx/issues/177)) ([bee9ce2](https://github.com/netlify/netlify-ipx/commit/bee9ce28612be72da3c70ee997a6b0987cb23b51)) 53 | * **deps:** update dependency unstorage to v1.7.0 ([#166](https://github.com/netlify/netlify-ipx/issues/166)) ([5133543](https://github.com/netlify/netlify-ipx/commit/51335433beb2a18e2cd4c3e6fdad564ec52c7318)) 54 | * **deps:** update dependency unstorage to v1.8.0 ([#175](https://github.com/netlify/netlify-ipx/issues/175)) ([f4e9933](https://github.com/netlify/netlify-ipx/commit/f4e993381be4bae781f9e423abf3ebf5d5b22188)) 55 | * now WAF bypass token header is forwarded ([#178](https://github.com/netlify/netlify-ipx/issues/178)) ([95645b8](https://github.com/netlify/netlify-ipx/commit/95645b826b28d28059ad4303ccb14f5e084e55ce)) 56 | 57 | ## [1.4.1](https://github.com/netlify/netlify-ipx/compare/v1.4.0...v1.4.1) (2023-06-12) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * **deps:** update dependency @netlify/functions to ^1.5.0 ([#150](https://github.com/netlify/netlify-ipx/issues/150)) ([75960db](https://github.com/netlify/netlify-ipx/commit/75960db7d4beef420d8701b42aa0760fd085b77a)) 63 | * **deps:** update dependency @netlify/functions to ^1.6.0 ([#151](https://github.com/netlify/netlify-ipx/issues/151)) ([00c4ed8](https://github.com/netlify/netlify-ipx/commit/00c4ed85fe4000a3fc854f5e4f81c6e89cac9bae)) 64 | * **deps:** update dependency fs-extra to v11.1.1 ([#138](https://github.com/netlify/netlify-ipx/issues/138)) ([f26f91d](https://github.com/netlify/netlify-ipx/commit/f26f91df6c64a1345e75a9e0006b432cca56d3fa)) 65 | * **deps:** update dependency ipx to v1 ([#146](https://github.com/netlify/netlify-ipx/issues/146)) ([bc2979f](https://github.com/netlify/netlify-ipx/commit/bc2979f647e88284d23a4e5d38a49c7ec65c4d6b)) 66 | * **deps:** update dependency ipx to v1.1.0 ([#153](https://github.com/netlify/netlify-ipx/issues/153)) ([9e01e1d](https://github.com/netlify/netlify-ipx/commit/9e01e1d7f6bf01edd51cfa37f26d594e8559b70a)) 67 | * **deps:** update dependency ipx to v1.2.0 ([#163](https://github.com/netlify/netlify-ipx/issues/163)) ([f0447a1](https://github.com/netlify/netlify-ipx/commit/f0447a17def8734b8f817c253cb2e8fecd7b61da)) 68 | * **deps:** update dependency node-fetch to v2.6.11 ([#152](https://github.com/netlify/netlify-ipx/issues/152)) ([2ba8693](https://github.com/netlify/netlify-ipx/commit/2ba86933eae06dd4324df6aafd4fe847b7875258)) 69 | * **deps:** update dependency ufo to v1.1.2 ([#148](https://github.com/netlify/netlify-ipx/issues/148)) ([3b557bc](https://github.com/netlify/netlify-ipx/commit/3b557bc7dcdfd9f19a8811edbfd7e4b42fbd5aa5)) 70 | * **deps:** update dependency unstorage to v1.5.0 ([#141](https://github.com/netlify/netlify-ipx/issues/141)) ([462bf62](https://github.com/netlify/netlify-ipx/commit/462bf6281035ef5007eb29c3f48ef3480bc5bbc7)) 71 | * **deps:** update dependency unstorage to v1.6.1 ([#159](https://github.com/netlify/netlify-ipx/issues/159)) ([ead2da5](https://github.com/netlify/netlify-ipx/commit/ead2da58d7fa5f79f7bc8f9ec618e614d66538f0)) 72 | 73 | ## [1.4.0](https://github.com/netlify/netlify-ipx/compare/v1.3.3...v1.4.0) (2023-04-06) 74 | 75 | 76 | ### Features 77 | 78 | * add pruning mechanism for source images cache ([#136](https://github.com/netlify/netlify-ipx/issues/136)) ([d7b7424](https://github.com/netlify/netlify-ipx/commit/d7b74247640c057f0432d6ea39f4137abb506578)) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * **deps:** update dependency node-fetch to v2.6.8 ([#112](https://github.com/netlify/netlify-ipx/issues/112)) ([0ec330d](https://github.com/netlify/netlify-ipx/commit/0ec330daeb5c802e825277c879ffb106ee9b9a8a)) 84 | * **deps:** update dependency node-fetch to v2.6.9 ([#115](https://github.com/netlify/netlify-ipx/issues/115)) ([3af6712](https://github.com/netlify/netlify-ipx/commit/3af671223fa9303548528a7cb2f5a8806d1b0f7f)) 85 | * **deps:** update dependency ufo to v1.1.1 ([#127](https://github.com/netlify/netlify-ipx/issues/127)) ([118bd63](https://github.com/netlify/netlify-ipx/commit/118bd630502f21415ff658addc0e3b7262a874ab)) 86 | * **deps:** update dependency unstorage to v1.1.4 ([#119](https://github.com/netlify/netlify-ipx/issues/119)) ([f288273](https://github.com/netlify/netlify-ipx/commit/f288273a2bbd6899d008fe5c18d55d228be858f4)) 87 | * **deps:** update dependency unstorage to v1.1.5 ([#121](https://github.com/netlify/netlify-ipx/issues/121)) ([b535f28](https://github.com/netlify/netlify-ipx/commit/b535f28d79416fc5cef7c03537b4137a77d39765)) 88 | * **deps:** update dependency unstorage to v1.4.1 ([#133](https://github.com/netlify/netlify-ipx/issues/133)) ([83922e1](https://github.com/netlify/netlify-ipx/commit/83922e1ca5ddaab6f3d256416b23d334fb1d176a)) 89 | 90 | ## [1.3.3](https://github.com/netlify/netlify-ipx/compare/v1.3.2...v1.3.3) (2023-01-05) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * **deps:** update dependency @netlify/functions to ^1.4.0 ([#107](https://github.com/netlify/netlify-ipx/issues/107)) ([1a2009b](https://github.com/netlify/netlify-ipx/commit/1a2009bbd33be34970580f4959242b58aebf2c01)) 96 | * **deps:** update dependency fs-extra to v11 ([#102](https://github.com/netlify/netlify-ipx/issues/102)) ([36326cf](https://github.com/netlify/netlify-ipx/commit/36326cf4f8c5f4c1cb4a47701f6adb60af12978d)) 97 | * **deps:** update dependency unstorage to v1 ([#105](https://github.com/netlify/netlify-ipx/issues/105)) ([752c47c](https://github.com/netlify/netlify-ipx/commit/752c47cb2b48752ff9eb61b7034f3863e1eac0cb)) 98 | * remove quotes from etag ([#103](https://github.com/netlify/netlify-ipx/issues/103)) ([e4101c0](https://github.com/netlify/netlify-ipx/commit/e4101c01f5582543f7fe3b68f336f112bca7fe04)) 99 | 100 | ## [1.3.2](https://github.com/netlify/netlify-ipx/compare/v1.3.1...v1.3.2) (2022-12-05) 101 | 102 | 103 | ### Bug Fixes 104 | 105 | * add correct ava + typescript setup ([#96](https://github.com/netlify/netlify-ipx/issues/96)) ([910d8ad](https://github.com/netlify/netlify-ipx/commit/910d8ad8448d6feb9d493b3d827528285921b514)) 106 | * add correct ava + typescript setup to enable tests to execute correctly ([910d8ad](https://github.com/netlify/netlify-ipx/commit/910d8ad8448d6feb9d493b3d827528285921b514)) 107 | * **deps:** update dependency ufo to v1 ([#95](https://github.com/netlify/netlify-ipx/issues/95)) ([8fcb745](https://github.com/netlify/netlify-ipx/commit/8fcb7457a34b45b67fecde2ab73da8f8e843f090)) 108 | 109 | ## [1.3.1](https://github.com/netlify/netlify-ipx/compare/v1.3.0...v1.3.1) (2022-10-17) 110 | 111 | 112 | ### Bug Fixes 113 | 114 | * **deps:** update dependency ufo to v0.8.6 ([#86](https://github.com/netlify/netlify-ipx/issues/86)) ([a0c7526](https://github.com/netlify/netlify-ipx/commit/a0c7526c7d6cc84fb2d45ad619b8b91de6c3377b)) 115 | * **deps:** update dependency unstorage to ^0.6.0 ([#87](https://github.com/netlify/netlify-ipx/issues/87)) ([3d3b2e7](https://github.com/netlify/netlify-ipx/commit/3d3b2e730ba2471c3bae1e2ac71c70e86ac5f011)) 116 | 117 | ## [1.3.0](https://github.com/netlify/netlify-ipx/compare/v1.2.5...v1.3.0) (2022-10-05) 118 | 119 | 120 | ### Features 121 | 122 | * add prefix filtering and loop detection for local images ([#83](https://github.com/netlify/netlify-ipx/issues/83)) ([f44db69](https://github.com/netlify/netlify-ipx/commit/f44db693f8bc3e4566c69204d74bb89ce5895c66)) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * **deps:** update dependency @netlify/functions to ^1.3.0 ([#82](https://github.com/netlify/netlify-ipx/issues/82)) ([081a8d3](https://github.com/netlify/netlify-ipx/commit/081a8d3ff8cfb9f8c2a8bd43b704ecb87b5b4284)) 128 | 129 | ## [1.2.5](https://github.com/netlify/netlify-ipx/compare/v1.2.4...v1.2.5) (2022-09-09) 130 | 131 | 132 | ### Bug Fixes 133 | 134 | * **deps:** upgrade ipx ([#71](https://github.com/netlify/netlify-ipx/issues/71)) ([da4fc2e](https://github.com/netlify/netlify-ipx/commit/da4fc2ed0bba8f4dd62a12371e973527fee86c78)) 135 | 136 | ## [1.2.4](https://github.com/netlify/netlify-ipx/compare/v1.2.3...v1.2.4) (2022-09-05) 137 | 138 | 139 | ### Bug Fixes 140 | 141 | * **deps:** update dependency ipx to v0.9.11 ([#68](https://github.com/netlify/netlify-ipx/issues/68)) ([b7551c2](https://github.com/netlify/netlify-ipx/commit/b7551c2abcd1b1c1040cd53824509095eba0b433)) 142 | 143 | ## [1.2.3](https://github.com/netlify/netlify-ipx/compare/v1.2.2...v1.2.3) (2022-08-24) 144 | 145 | 146 | ### Bug Fixes 147 | 148 | * x-forwarded-proto is sanitized now ([#61](https://github.com/netlify/netlify-ipx/issues/61)) ([dfa7505](https://github.com/netlify/netlify-ipx/commit/dfa7505a8d47a76fd527570dc40737a61500759b)) 149 | 150 | ## [1.2.2](https://github.com/netlify/netlify-ipx/compare/v1.2.1...v1.2.2) (2022-08-16) 151 | 152 | 153 | ### Bug Fixes 154 | 155 | * **deps:** update dependency @netlify/functions to ^1.2.0 ([#57](https://github.com/netlify/netlify-ipx/issues/57)) ([a2223db](https://github.com/netlify/netlify-ipx/commit/a2223dbbce7edff61b507ea917e3d120ff60accf)) 156 | 157 | ## [1.2.1](https://github.com/netlify/netlify-ipx/compare/v1.2.0...v1.2.1) (2022-08-15) 158 | 159 | 160 | ### Bug Fixes 161 | 162 | * **deps:** update dependency @netlify/functions to ^1.1.0 ([#54](https://github.com/netlify/netlify-ipx/issues/54)) ([1e7cbfa](https://github.com/netlify/netlify-ipx/commit/1e7cbfa67e340f6ae954da319181ec9189367cee)) 163 | 164 | ## [1.2.0](https://github.com/netlify/netlify-ipx/compare/v1.1.4...v1.2.0) (2022-08-10) 165 | 166 | 167 | ### Features 168 | 169 | * support custom response headers ([#51](https://github.com/netlify/netlify-ipx/issues/51)) ([857eb75](https://github.com/netlify/netlify-ipx/commit/857eb7549a75531d64a55d7d3981e470cbd64002)) 170 | 171 | 172 | ### Bug Fixes 173 | 174 | * remove tarball that was accidentally merged in ([#53](https://github.com/netlify/netlify-ipx/issues/53)) ([0281ea3](https://github.com/netlify/netlify-ipx/commit/0281ea3138150d5bca37d8c02fd4dd3252e2ea39)) 175 | 176 | ## [1.1.4](https://github.com/netlify/netlify-ipx/compare/v1.1.3...v1.1.4) (2022-08-08) 177 | 178 | 179 | ### Bug Fixes 180 | 181 | * **deps:** update dependency ipx to v0.9.10 ([#46](https://github.com/netlify/netlify-ipx/issues/46)) ([8059ab9](https://github.com/netlify/netlify-ipx/commit/8059ab97b8ab9ac6be33652406d519e59967fa04)) 182 | * **deps:** update dependency ufo to v0.8.5 ([#47](https://github.com/netlify/netlify-ipx/issues/47)) ([9ba5bbd](https://github.com/netlify/netlify-ipx/commit/9ba5bbd669e7ad3a0b29613088c7c0b8729013de)) 183 | 184 | ## [1.1.3](https://github.com/netlify/netlify-ipx/compare/v1.1.2...v1.1.3) (2022-06-30) 185 | 186 | 187 | ### Bug Fixes 188 | 189 | * remote patterns logic ([#42](https://github.com/netlify/netlify-ipx/issues/42)) ([4e775ae](https://github.com/netlify/netlify-ipx/commit/4e775ae3f6a505075bc5293cb880b8fb1b6b7f71)) 190 | 191 | ### [1.1.2](https://www.github.com/netlify/netlify-ipx/compare/v1.1.1...v1.1.2) (2022-06-23) 192 | 193 | 194 | ### Bug Fixes 195 | 196 | * **deps:** update dependency ipx to v0.9.9 ([#34](https://www.github.com/netlify/netlify-ipx/issues/34)) ([561bf3d](https://www.github.com/netlify/netlify-ipx/commit/561bf3d18361d42026bec17e68685180364e6e58)) 197 | * **deps:** update dependency unstorage to v0.5.1 ([#35](https://www.github.com/netlify/netlify-ipx/issues/35)) ([290570f](https://www.github.com/netlify/netlify-ipx/commit/290570f19c20c5d215561753ce7a78481f14b09f)) 198 | * revert upgrade of unstorage ([#38](https://www.github.com/netlify/netlify-ipx/issues/38)) ([bb4479b](https://www.github.com/netlify/netlify-ipx/commit/bb4479b5c7f19f03f32c07a5d6c16a6af4d5f3ab)) 199 | 200 | ### [1.1.1](https://www.github.com/netlify/netlify-ipx/compare/v1.1.0...v1.1.1) (2022-06-22) 201 | 202 | 203 | ### Bug Fixes 204 | 205 | * **deps:** update dependency fs-extra to v10.1.0 ([#27](https://www.github.com/netlify/netlify-ipx/issues/27)) ([6230efb](https://www.github.com/netlify/netlify-ipx/commit/6230efb3c0fa8c9fb0a8713b13ab27e47fa898d1)) 206 | * **deps:** update dependency ipx to v0.9.6 ([#18](https://www.github.com/netlify/netlify-ipx/issues/18)) ([0e20145](https://www.github.com/netlify/netlify-ipx/commit/0e20145ab0fd14d4a5468755792a03db08f0bddd)) 207 | * **deps:** update dependency murmurhash to v2.0.1 ([#19](https://www.github.com/netlify/netlify-ipx/issues/19)) ([ddbe8bf](https://www.github.com/netlify/netlify-ipx/commit/ddbe8bf3ba9085dfaa1703f1470b0e173da8ed19)) 208 | * **deps:** update dependency ufo to ^0.8.0 ([#21](https://www.github.com/netlify/netlify-ipx/issues/21)) ([cbdbb69](https://www.github.com/netlify/netlify-ipx/commit/cbdbb6958838200b2ab64913c24ebb55ca476354)) 209 | * **deps:** update dependency unstorage to ^0.5.0 ([#24](https://www.github.com/netlify/netlify-ipx/issues/24)) ([d32cc1f](https://www.github.com/netlify/netlify-ipx/commit/d32cc1f71ef29ec74e8e437f58ded1debb7e2759)) 210 | * remove content-type check ([#16](https://www.github.com/netlify/netlify-ipx/issues/16)) ([604a3d2](https://www.github.com/netlify/netlify-ipx/commit/604a3d240ce6c680c45bc609cb1fc5629b679790)) 211 | 212 | ## [1.1.0](https://github.com/netlify/netlify-ipx/compare/v1.0.1...v1.1.0) (2022-06-09) 213 | 214 | 215 | ### Features 216 | 217 | * add support for NextJS remotePatterns ([#11](https://github.com/netlify/netlify-ipx/issues/11)) ([06ffaf9](https://github.com/netlify/netlify-ipx/commit/06ffaf94481c603578d9150108bd492c296f35df)) 218 | 219 | ### [1.0.1](https://github.com/netlify/netlify-ipx/compare/v1.0.0...v1.0.1) (2022-03-10) 220 | 221 | 222 | ### Bug Fixes 223 | 224 | * handle empty content-type header ([#9](https://github.com/netlify/netlify-ipx/issues/9)) ([2650b33](https://github.com/netlify/netlify-ipx/commit/2650b334dfb4c971f41b2bd77f653554528eca35)) 225 | 226 | ## [1.0.0](https://github.com/netlify/netlify-ipx/compare/v0.0.10...v1.0.0) (2022-03-01) 227 | 228 | 229 | ### Features 230 | 231 | * add support for quality and position args ([#7](https://github.com/netlify/netlify-ipx/issues/7)) ([1f65eb7](https://github.com/netlify/netlify-ipx/commit/1f65eb77180f520380c298a0c3f3e408a3f64c1e)) 232 | 233 | 234 | ### Bug Fixes 235 | 236 | * support bypassing domain check ([#8](https://github.com/netlify/netlify-ipx/issues/8)) ([94caa15](https://github.com/netlify/netlify-ipx/commit/94caa15762c565dd3702aac1fd34a7781cdf13dc)) 237 | 238 | ### [0.0.10](https://github.com/netlify/netlify-ipx/compare/v0.0.9...v0.0.10) (2022-02-22) 239 | 240 | 241 | ### Features 242 | 243 | * add support for base64 props ([#6](https://github.com/netlify/netlify-ipx/issues/6)) ([eca4c08](https://github.com/netlify/netlify-ipx/commit/eca4c08a00ea653a7cc81fe7c92ea1ced315bc82)) 244 | 245 | ### [0.0.9](https://github.com/netlify/netlify-ipx/compare/v0.0.7...v0.0.9) (2022-01-31) 246 | 247 | 248 | ### Bug Fixes 249 | 250 | * improve conditional responses ([d9c6850](https://github.com/netlify/netlify-ipx/commit/d9c68501aa8366a6eb7a608599cf38a0f9885c45)) 251 | 252 | ### [0.0.7](https://github.com/netlify/netlify-ipx/compare/v0.0.6...v0.0.7) (2021-10-05) 253 | 254 | 255 | ### Bug Fixes 256 | 257 | * correct auth logic ([7097932](https://github.com/netlify/netlify-ipx/commit/709793219dba6bff8a3bcfccb65420abd3c7982f)) 258 | * demo ([a73dee0](https://github.com/netlify/netlify-ipx/commit/a73dee0d35bd3cc2efeacbbf6ec66664c2a73933)) 259 | * set content-type for errors ([bb374aa](https://github.com/netlify/netlify-ipx/commit/bb374aac5b296303ecde76498b37306e332ec54e)) 260 | 261 | ### [0.0.6](https://github.com/netlify/netlify-ipx/compare/v0.0.5...v0.0.6) (2021-10-05) 262 | 263 | 264 | ### Features 265 | 266 | * pass auth headers to local requests ([7fa6709](https://github.com/netlify/netlify-ipx/commit/7fa6709c8b81fc553de9f0fafef6df60c2b35107)) 267 | 268 | ### [0.0.5](https://github.com/netlify/netlify-ipx/compare/v0.0.4...v0.0.5) (2021-09-25) 269 | 270 | ### [0.0.4](https://github.com/netlify/netlify-ipx/compare/v0.0.3...v0.0.4) (2021-09-24) 271 | 272 | 273 | ### Bug Fixes 274 | 275 | * decode uri ([008de0f](https://github.com/netlify/netlify-ipx/commit/008de0f9584c11f97934c1be61bf072e3d386d72)) 276 | 277 | ### [0.0.3](https://github.com/netlify/netlify-ipx/compare/v0.0.2...v0.0.3) (2021-09-24) 278 | 279 | 280 | ### Bug Fixes 281 | 282 | * downgrade node fetch and allow manual base path ([19bdad8](https://github.com/netlify/netlify-ipx/commit/19bdad8e8088811111c9b415c67272e69799e384)) 283 | 284 | ### 0.0.2 (2021-09-24) 285 | 286 | 287 | ### Features 288 | 289 | * convert from plugin to library ([d77f606](https://github.com/netlify/netlify-ipx/commit/d77f6063fd7618148a913490c312c19140b4383a)) 290 | 291 | ### [0.0.1](https://github.com/nuxt-contrib/netlify-ipx/compare/v0.0.0...v0.0.1) (2021-07-01) 292 | 293 | ## 0.0.0 (2021-07-01) 294 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Netlify Optimized Images 2 | 3 | > On Demand image optimization for Netlify using [ipx](https://github.com/unjs/ipx). 4 | 5 | 😺 Online demo: https://netlify-ipx.netlify.app 6 | 7 | ## Usage 8 | 9 | Add `@netlify/ipx` as `devDependency`: 10 | 11 | ```sh 12 | # npm 13 | npm i -D @netlify/ipx 14 | 15 | # yarn 16 | yarn add --dev @netlify/ipx 17 | ``` 18 | 19 | Create `netlify/functions/ipx.ts`: 20 | 21 | ```ts 22 | import { createIPXHandler } from "@netlify/ipx"; 23 | 24 | export const handler = createIPXHandler({ 25 | domains: ["images.unsplash.com"], 26 | }); 27 | ``` 28 | 29 | Now you can use IPX to optimize both local and remote assets ✨ 30 | 31 | Resize `/test.jpg` (in `dist`): 32 | 33 | ```html 34 | 35 | ``` 36 | 37 | Resize and change format for a remote url: 38 | 39 | ```html 40 | 43 | ``` 44 | 45 | ## Remote Patterns 46 | 47 | Instead of setting an allowlist on `domains`, you may wish to use the option `remotePatterns`. This method allows wildcards in `hostname` and `pathname` segments. 48 | 49 | `remotePatterns` is an array that contains RemotePattern objects: 50 | 51 | ```ts 52 | remotePatterns: [ 53 | { 54 | protocol: 'https' // or 'http' - not required 55 | hostname: 'example.com' // required 56 | port: '3000' // not required 57 | pathname: '/blog/**' // not required 58 | } 59 | ] 60 | ``` 61 | 62 | To use remote patterns, create `netlify/functions/ipx.ts`: 63 | 64 | ```ts 65 | import { createIPXHandler } from "@netlify/ipx"; 66 | 67 | export const handler = createIPXHandler({ 68 | remotePatterns: [ 69 | { 70 | protocol: "https", 71 | hostname: "images.unsplash.com", 72 | }, 73 | ], 74 | }); 75 | ``` 76 | 77 | `hostname` and `pathname` may contain wildcards: 78 | 79 | ```ts 80 | remotePatterns: [ 81 | { 82 | hostname: '*.example.com' // * = match a single path segment or subdomain 83 | pathname: '/blog/**' // ** = match any number of path segments or subdomains 84 | } 85 | ] 86 | ``` 87 | 88 | ## Local development 89 | 90 | - Clone repository 91 | - Install dependencies with `yarn install` 92 | - Build the project with `yarn build` 93 | - Run netlify development server with `yarn dev`. 94 | - Open http://localhost:8888 95 | 96 | ## License 97 | 98 | MIT 99 | -------------------------------------------------------------------------------- /example/netlify/functions/gatsby.ts: -------------------------------------------------------------------------------- 1 | import { createIPXHandler } from '@netlify/ipx' 2 | 3 | export const handler = createIPXHandler({ 4 | domains: ['images.unsplash.com'], 5 | propsEncoding: 'base64', 6 | basePath: '/_gatsby/image/' 7 | }) 8 | -------------------------------------------------------------------------------- /example/netlify/functions/ipx.ts: -------------------------------------------------------------------------------- 1 | import { createIPXHandler } from '@netlify/ipx' 2 | 3 | export const handler = createIPXHandler({ 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: '*.unsplash.com' 8 | } 9 | ], 10 | domains: [ 11 | 'www.netlify.com' 12 | ], 13 | localPrefix: '/img/', 14 | basePath: '/.netlify/builders/ipx/', 15 | responseHeaders: { 16 | 'Strict-Transport-Security': 'max-age=31536000', 17 | 'X-Test': 'foobar' 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /example/public/img/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netlify/netlify-ipx/63c7c8d7529ab271de9b12b9f969b6702f7bb6d9/example/public/img/test.jpg -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Netlify Optimized Images 6 | 7 | 8 | 9 | 10 |

Netlify Optimized Images

11 | 12 |
13 | 14 |
/.netlify/builders/ipx/f_webp,w_450/img/test.jpg
15 |
16 | 17 |
18 | 19 |
/.netlify/builders/ipx/f_webp,w_450/img/https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba 20 |
21 |
22 |
23 | 25 |
26 | /.netlify/builders/ipx/f_webp,w_450/img/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1514888286974-6c03e2ca1dba%3Fheight%3D500%26width%3D500%26fit%3Dcrop 27 | With query string in source 28 |
29 |
30 |
31 | 32 |
/.netlify/builders/ipx/f_webp,w_146/img/https://www.netlify.com/img/deploy/button.svg 33 |
34 |
35 |
36 | 37 |
/.netlify/builders/ipx/f_webp,w_146/img/https://www.example.com/img/deploy/button.svg 38 | 39 | 40 | will break! 41 |
42 |
43 |
44 | Gatsby 47 |
48 | /_gatsby/image/aHR0cHM6Ly9pbWFnZXMudW5zcGxhc2guY29tL3Bob3RvLTE1MTc4NDk4NDU1MzctNGQyNTc5MDI0NTRhP2l4bGliPXJiLTEuMi4xJml4aWQ9TW53eE1qQTNmREI4TUh4d2FHOTBieTF3WVdkbGZIeDhmR1Z1ZkRCOGZIeDgmYXV0bz1mb3JtYXQmZml0PWNyb3Amdz0yMDAwJnE9ODA=/dz0zMDAmaD00MDAmZm09YXZpZg==.avif 49 |
50 |
51 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | # https://docs.netlify.com/configure-builds/build-plugins/create-plugins 2 | name: netlify-plugin-ipx 3 | inputs: 4 | - name: domains 5 | description: Allowed external domains 6 | required: false 7 | default: [] 8 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn build" 3 | publish = "example/public/" 4 | 5 | [functions] 6 | directory = "example/netlify/functions/" 7 | external_node_modules = ["fsevents"] 8 | 9 | [[redirects]] 10 | from = "/_gatsby/image/*" 11 | to = "/.netlify/builders/gatsby" 12 | status = 200 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@netlify/ipx", 3 | "version": "1.4.6", 4 | "description": "on-demand image optimization for Netlify", 5 | "repository": "netlify/netlify-ipx", 6 | "license": "MIT", 7 | "main": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "build": "tsc", 14 | "prepack": "yarn build", 15 | "lint": "yarn eslint --ext .ts,.js,.mjs src test", 16 | "test": "ava", 17 | "dev": "netlify dev" 18 | }, 19 | "dependencies": { 20 | "@netlify/functions": "^2.8.1", 21 | "etag": "^1.8.1", 22 | "fs-extra": "^11.0.0", 23 | "ipx": "^1.3.1", 24 | "micromatch": "^4.0.5", 25 | "mkdirp": "^3.0.0", 26 | "murmurhash": "^2.0.0", 27 | "node-fetch": "^2.0.0", 28 | "ufo": "^1.0.0", 29 | "unstorage": "1.12.0" 30 | }, 31 | "devDependencies": { 32 | "@netlify/ipx": "link:.", 33 | "@nuxtjs/eslint-config-typescript": "^12.0.0", 34 | "@types/etag": "^1.8.1", 35 | "@types/fs-extra": "^11.0.0", 36 | "@types/node-fetch": "^2.6.1", 37 | "ava": "^5.0.0", 38 | "esbuild-node-loader": "^0.8.0", 39 | "eslint": "8.57.1", 40 | "jiti": "^1.13.0", 41 | "ts-node": "^10.9.1", 42 | "typescript": "^5.0.0" 43 | }, 44 | "ava": { 45 | "extensions": [ 46 | "ts" 47 | ], 48 | "require": [ 49 | "ts-node/register" 50 | ], 51 | "files": [ 52 | "test/**/*.test.ts" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>netlify/renovate-config"], 4 | "includeForks": true 5 | } -------------------------------------------------------------------------------- /src/http.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { 3 | createWriteStream, 4 | ensureDir, 5 | existsSync, 6 | unlink, 7 | stat, 8 | pathExists 9 | } from 'fs-extra' 10 | import fetch, { Headers } from 'node-fetch' 11 | import { createStorage, Storage } from 'unstorage' 12 | import fsDriver from 'unstorage/drivers/fs' 13 | import murmurhash from 'murmurhash' 14 | import etag from 'etag' 15 | import type { HandlerResponse } from '@netlify/functions' 16 | import { Lock } from './utils' 17 | 18 | interface SourceMetadata { 19 | etag?: string; 20 | lastModified?: string; 21 | } 22 | 23 | const NOT_MODIFIED = 304 24 | const GATEWAY_ERROR = 502 25 | 26 | export interface SourceImageResult { 27 | response?: HandlerResponse 28 | cacheKey?: string; 29 | responseEtag?: string; 30 | finalize: () => Promise 31 | } 32 | 33 | export interface SourceImageOptions { 34 | cacheDir: string 35 | url: string 36 | requestHeaders?: Record 37 | modifiers: string 38 | isLocal?: boolean 39 | requestEtag?: string 40 | } 41 | 42 | interface UsageTrackingItem { 43 | runningCount: number; 44 | cacheKey: string; 45 | lastAccess: number; 46 | inputCacheFile: string 47 | size: number 48 | } 49 | 50 | type UsageTracking = Record< 51 | string, 52 | UsageTrackingItem 53 | >; 54 | 55 | const USAGE_TRACKING_KEY = 'usage-tracking' 56 | const trackingLock = new Lock() 57 | 58 | async function getTracking (metadataStore: Storage): Promise { 59 | return ((await metadataStore.getItem(USAGE_TRACKING_KEY)) ?? 60 | {}) as UsageTracking 61 | } 62 | 63 | async function markUsageStart ( 64 | metadataStore: Storage, 65 | cacheKey: string, 66 | inputCacheFile: string 67 | ): Promise { 68 | await trackingLock.acquire() 69 | try { 70 | const tracking = await getTracking(metadataStore) 71 | 72 | let usageTrackingItem = tracking[cacheKey] 73 | if (!usageTrackingItem) { 74 | tracking[cacheKey] = usageTrackingItem = { 75 | runningCount: 0, 76 | lastAccess: 0, 77 | size: 0, 78 | cacheKey, 79 | inputCacheFile 80 | } 81 | } 82 | 83 | usageTrackingItem.runningCount++ 84 | usageTrackingItem.lastAccess = Date.now() 85 | 86 | await metadataStore.setItem(USAGE_TRACKING_KEY, tracking) 87 | } finally { 88 | trackingLock.release() 89 | } 90 | } 91 | 92 | async function markUsageComplete ( 93 | metadataStore: Storage, 94 | cacheKey: string 95 | 96 | ) { 97 | await trackingLock.acquire() 98 | try { 99 | const tracking = await getTracking(metadataStore) 100 | 101 | const usageTrackingItem = tracking[cacheKey] 102 | if (usageTrackingItem) { 103 | usageTrackingItem.runningCount-- 104 | 105 | if (await pathExists(usageTrackingItem.inputCacheFile)) { 106 | const { size } = await stat(usageTrackingItem.inputCacheFile) 107 | usageTrackingItem.size = size 108 | } else { 109 | // If the file doesn't exist, we can't track it 110 | delete tracking[cacheKey] 111 | } 112 | 113 | await metadataStore.setItem(USAGE_TRACKING_KEY, tracking) 114 | } 115 | } finally { 116 | trackingLock.release() 117 | } 118 | } 119 | 120 | export const CACHE_PRUNING_THRESHOLD = 50 * 1024 * 1024 121 | 122 | async function maybePruneCache (metadataStore: Storage) { 123 | await trackingLock.acquire() 124 | try { 125 | const tracking = await getTracking(metadataStore) 126 | 127 | let totalSize = 0 128 | let totalSizeAvailableToPrune = 0 129 | 130 | const prunableItems: Array = [] 131 | 132 | for (const trackingItem of Object.values(tracking)) { 133 | totalSize += trackingItem.size 134 | if (trackingItem.runningCount === 0) { 135 | totalSizeAvailableToPrune += trackingItem.size 136 | prunableItems.push(trackingItem) 137 | } 138 | } 139 | 140 | const prunableItemsSortedByAccessTime = prunableItems.sort( 141 | (a, b) => a.lastAccess - b.lastAccess 142 | ) 143 | 144 | while ( 145 | totalSize >= CACHE_PRUNING_THRESHOLD && 146 | totalSizeAvailableToPrune > 0 && 147 | prunableItemsSortedByAccessTime.length > 0 148 | ) { 149 | const itemToPrune = prunableItemsSortedByAccessTime.shift() 150 | 151 | await metadataStore.removeItem(`source:${itemToPrune.cacheKey}`) 152 | await unlink(itemToPrune.inputCacheFile) 153 | 154 | delete tracking[itemToPrune.cacheKey] 155 | 156 | totalSize -= itemToPrune.size 157 | totalSizeAvailableToPrune -= itemToPrune.size 158 | } 159 | 160 | await metadataStore.setItem(USAGE_TRACKING_KEY, tracking) 161 | } finally { 162 | trackingLock.release() 163 | } 164 | } 165 | 166 | export async function loadSourceImage ({ cacheDir, url, requestEtag, modifiers, isLocal, requestHeaders = {} }: SourceImageOptions): Promise { 167 | const fileCache = join(cacheDir, 'cache') 168 | const metadataCache = join(cacheDir, 'metadata') 169 | 170 | await ensureDir(fileCache) 171 | await ensureDir(metadataCache) 172 | 173 | const metadataStore = createStorage({ 174 | driver: fsDriver({ base: metadataCache }) 175 | }) 176 | const cacheKey = String(murmurhash(url)) 177 | const inputCacheFile = join(fileCache, cacheKey) 178 | 179 | await markUsageStart(metadataStore, cacheKey, inputCacheFile) 180 | await maybePruneCache(metadataStore) 181 | 182 | function finalize () { 183 | return markUsageComplete(metadataStore, cacheKey) 184 | } 185 | 186 | try { 187 | const headers = new Headers(requestHeaders) 188 | let sourceMetadata: SourceMetadata | undefined 189 | if (existsSync(inputCacheFile)) { 190 | sourceMetadata = (await metadataStore.getItem(`source:${cacheKey}`)) as 191 | | SourceMetadata 192 | | undefined 193 | if (sourceMetadata) { 194 | // Ideally use etag 195 | if (sourceMetadata.etag) { 196 | headers.set('If-None-Match', sourceMetadata.etag) 197 | } else if (sourceMetadata.lastModified) { 198 | headers.set('If-Modified-Since', sourceMetadata.lastModified) 199 | } else { 200 | // If we have neither, the cachefile is useless 201 | await unlink(inputCacheFile) 202 | } 203 | } 204 | } 205 | 206 | let response 207 | try { 208 | response = await fetch(url, { 209 | headers 210 | }) 211 | } catch (e) { 212 | return { 213 | response: { 214 | statusCode: GATEWAY_ERROR, 215 | headers: { 216 | 'Content-Type': 'text/plain' 217 | }, 218 | body: `Error loading source image: ${e.message} ${url}` 219 | }, 220 | finalize 221 | } 222 | } 223 | 224 | const sourceEtag = response.headers.get('etag') 225 | const sourceLastModified = response.headers.get('last-modified') 226 | const metadata = { 227 | etag: sourceEtag || sourceMetadata?.etag, 228 | lastModified: sourceLastModified || sourceMetadata?.lastModified 229 | } 230 | await metadataStore.setItem(`source:${cacheKey}`, metadata) 231 | // We try to contruct an etag without downloading or processing the image, but we need 232 | // either an etag or a last-modified date for the source image to do so. 233 | let responseEtag 234 | if (metadata.etag || metadata.lastModified) { 235 | // etag returns a quoted string for some reason 236 | responseEtag = JSON.parse(etag(`${cacheKey}${metadata.etag || metadata.lastModified}${modifiers}`)) 237 | if (requestEtag && (requestEtag === responseEtag)) { 238 | return { 239 | response: { 240 | statusCode: NOT_MODIFIED 241 | }, 242 | finalize 243 | } 244 | } 245 | } 246 | 247 | if (response.status === NOT_MODIFIED) { 248 | return { cacheKey, responseEtag, finalize } 249 | } 250 | if (!response.ok) { 251 | return { 252 | response: { 253 | statusCode: isLocal ? response.status : GATEWAY_ERROR, 254 | body: `Source image server responsed with ${response.status} ${response.statusText}`, 255 | headers: { 256 | 'Content-Type': 'text/plain' 257 | } 258 | }, 259 | finalize 260 | } 261 | } 262 | 263 | const outfile = createWriteStream(inputCacheFile) 264 | await new Promise((resolve, reject) => { 265 | outfile.on('finish', resolve) 266 | outfile.on('error', reject) 267 | response.body.pipe(outfile) 268 | }) 269 | 270 | return { cacheKey, responseEtag, finalize } 271 | } catch (e) { 272 | await finalize() 273 | throw e 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { tmpdir } from 'os' 3 | import { createIPX, handleRequest, IPXOptions } from 'ipx' 4 | import { builder, Handler } from '@netlify/functions' 5 | import { parseURL } from 'ufo' 6 | import etag from 'etag' 7 | import { loadSourceImage as defaultLoadSourceImage } from './http' 8 | import { decodeBase64Params, doPatternsMatchUrl, RemotePattern } from './utils' 9 | 10 | // WAF is Web Application Firewall 11 | const WAF_BYPASS_TOKEN_HEADER = 'x-nf-waf-bypass-token' 12 | 13 | export interface IPXHandlerOptions extends Partial { 14 | /** 15 | * Path to cache directory 16 | * @default os.tmpdir() /ipx-cache 17 | */ 18 | cacheDir?: string 19 | /** 20 | * Base path for IPX requests 21 | * @default /_ipx/ 22 | */ 23 | basePath?: string 24 | propsEncoding?: 'base64' | undefined 25 | /** 26 | * Bypass domain check for remote images 27 | */ 28 | bypassDomainCheck?: boolean 29 | /** 30 | * Restrict local image access to a specific prefix 31 | */ 32 | localPrefix?: string 33 | /** 34 | * Patterns used to verify remote image URLs 35 | */ 36 | remotePatterns?: RemotePattern[] 37 | /** 38 | * Add custom headers to response 39 | */ 40 | responseHeaders?: Record 41 | } 42 | 43 | const SUBREQUEST_HEADER = 'x-ipx-subrequest' 44 | 45 | const plainText = { 46 | 'Content-Type': 'text/plain' 47 | } 48 | 49 | export function createIPXHandler ({ 50 | cacheDir = join(tmpdir(), 'ipx-cache'), 51 | basePath = '/_ipx/', 52 | propsEncoding, 53 | bypassDomainCheck, 54 | remotePatterns, 55 | responseHeaders, 56 | localPrefix, 57 | ...opts 58 | }: IPXHandlerOptions = {}, loadSourceImage = defaultLoadSourceImage) { 59 | const ipx = createIPX({ ...opts, dir: join(cacheDir, 'cache') }) 60 | if (!basePath.endsWith('/')) { 61 | basePath = `${basePath}/` 62 | } 63 | if (localPrefix && !localPrefix.startsWith('/')) { 64 | localPrefix = `/${localPrefix}` 65 | } 66 | const handler: Handler = async (event, _context) => { 67 | if (event.headers[SUBREQUEST_HEADER]) { 68 | // eslint-disable-next-line no-console 69 | console.error('Source image loop detected') 70 | return { 71 | statusCode: 400, 72 | body: 'Source image loop detected', 73 | headers: plainText 74 | } 75 | } 76 | let domains = (opts as IPXOptions).domains || [] 77 | const remoteURLPatterns = remotePatterns || [] 78 | const requestEtag = event.headers['if-none-match'] 79 | const eventPath = event.path.replace(basePath, '') 80 | 81 | // eslint-disable-next-line prefer-const 82 | let [modifiers = '_', ...segments] = eventPath.split('/') 83 | let id = decodeURIComponent(segments.join('/')) 84 | 85 | if (propsEncoding === 'base64') { 86 | const params = decodeBase64Params(eventPath) 87 | if (params.error) { 88 | return { 89 | statusCode: 400, 90 | body: params.error, 91 | headers: plainText 92 | } 93 | } 94 | id = params.id 95 | modifiers = params.modifiers 96 | } 97 | 98 | const requestHeaders: Record = { 99 | [SUBREQUEST_HEADER]: '1' 100 | } 101 | 102 | const isLocal = !id.startsWith('http://') && !id.startsWith('https://') 103 | if (isLocal) { 104 | // This header is available to all lambdas that went through WAF 105 | // We need to add it for local images (origin server) to be able to bypass WAF 106 | if (event.headers[WAF_BYPASS_TOKEN_HEADER]) { 107 | // eslint-disable-next-line no-console 108 | console.log(`WAF bypass token found, setting ${WAF_BYPASS_TOKEN_HEADER} header to load source image`) 109 | requestHeaders[WAF_BYPASS_TOKEN_HEADER] = 110 | event.headers[WAF_BYPASS_TOKEN_HEADER] 111 | } 112 | 113 | const url = new URL(event.rawUrl) 114 | url.pathname = id 115 | if (localPrefix && !url.pathname.startsWith(localPrefix)) { 116 | return { 117 | statusCode: 400, 118 | body: 'Invalid source image path', 119 | headers: plainText 120 | } 121 | } 122 | id = url.toString() 123 | if (event.headers.cookie) { 124 | requestHeaders.cookie = event.headers.cookie 125 | } 126 | if (event.headers.authorization) { 127 | requestHeaders.authorization = event.headers.authorization 128 | } 129 | } else { 130 | // Parse id as URL 131 | const parsedUrl = parseURL(id, 'https://') 132 | 133 | // Check host 134 | if (!parsedUrl.host) { 135 | return { 136 | statusCode: 403, 137 | body: 'Hostname is missing: ' + id, 138 | headers: plainText 139 | } 140 | } 141 | 142 | if (!bypassDomainCheck) { 143 | let domainAllowed = false 144 | 145 | if (domains.length > 0) { 146 | if (typeof domains === 'string') { 147 | domains = (domains as string).split(',').map(s => s.trim()) 148 | } 149 | 150 | const hosts = domains.map(domain => parseURL(domain, 'https://').host) 151 | 152 | if (hosts.includes(parsedUrl.host)) { 153 | domainAllowed = true 154 | } 155 | } 156 | 157 | if (remoteURLPatterns.length > 0) { 158 | const matchingRemotePattern = remoteURLPatterns.find((remotePattern) => { 159 | return doPatternsMatchUrl(remotePattern, parsedUrl) 160 | }) 161 | 162 | if (matchingRemotePattern) { 163 | domainAllowed = true 164 | } 165 | } 166 | 167 | if (!domainAllowed) { 168 | // eslint-disable-next-line no-console 169 | console.log(`URL not on allowlist. Values provided are: 170 | domains: ${JSON.stringify(domains)} 171 | remotePatterns: ${JSON.stringify(remoteURLPatterns)} 172 | `) 173 | return { 174 | statusCode: 403, 175 | body: 'URL not on allowlist: ' + id, 176 | headers: plainText 177 | } 178 | } 179 | } 180 | } 181 | 182 | const { response, cacheKey, responseEtag, finalize } = await loadSourceImage({ 183 | cacheDir, 184 | url: id, 185 | requestEtag, 186 | modifiers, 187 | isLocal, 188 | requestHeaders 189 | }) 190 | 191 | try { 192 | if (response) { 193 | return response 194 | } 195 | 196 | const res = await handleRequest( 197 | { 198 | url: `/${modifiers}/${cacheKey}`, 199 | headers: event.headers 200 | }, 201 | ipx 202 | ) 203 | 204 | const body = 205 | typeof res.body === 'string' ? res.body : res.body.toString('base64') 206 | 207 | res.headers.etag = responseEtag || JSON.parse(etag(body)) 208 | delete res.headers['Last-Modified'] 209 | 210 | if (requestEtag && requestEtag === res.headers.etag) { 211 | return { 212 | statusCode: 304, 213 | message: 'Not Modified' 214 | } 215 | } 216 | 217 | if (responseHeaders) { 218 | for (const [header, value] of Object.entries(responseHeaders)) { 219 | res.headers[header] = value 220 | } 221 | } 222 | 223 | return { 224 | statusCode: res.statusCode, 225 | message: res.statusMessage, 226 | headers: res.headers, 227 | isBase64Encoded: typeof res.body !== 'string', 228 | body 229 | } 230 | } finally { 231 | await finalize() 232 | } 233 | } 234 | 235 | return builder(handler) 236 | } 237 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import { ParsedURL } from 'ufo' 3 | 4 | import { makeRe } from 'micromatch' 5 | /** 6 | * Support for Gatsby-style base64-encoded URLs 7 | */ 8 | export function decodeBase64Params (path:string) { 9 | const [url, transform] = path.split('/') 10 | if (!url || !transform) { 11 | return { 12 | error: 'Bad Request' 13 | } 14 | } 15 | const id = Buffer.from(url, 'base64').toString('utf8') 16 | // Strip the extension 17 | const transforms = Buffer.from(transform.split('.')[0], 'base64').toString( 18 | 'utf8' 19 | ) 20 | if (!id || !transforms) { 21 | return { 22 | error: 'Bad Request' 23 | } 24 | } 25 | const params = new URLSearchParams(transforms) 26 | 27 | // [ipx modifier name, gatsby modifier name] 28 | const props = [ 29 | ['f', 'fm'], 30 | ['crop', 'pos'], 31 | ['q', 'q'] 32 | ] 33 | 34 | const modifiers: Array = [] 35 | const w = params.get('w') 36 | const h = params.get('h') 37 | if (w && h) { 38 | modifiers.push(`s_${w}x${h}`) 39 | } else { 40 | props.push(['w', 'w'], ['h', 'h']) 41 | } 42 | 43 | for (const [modifier, prop] of props) { 44 | let value = params.get(prop) 45 | if (value) { 46 | if (prop === 'pos') { 47 | value = value.replace(',', ' ') 48 | } 49 | modifiers.push(`${modifier}_${value}`) 50 | } 51 | } 52 | 53 | return { id, modifiers: modifiers.join(',') } 54 | } 55 | 56 | export interface RemotePattern { 57 | protocol?: 'http' | 'https'; 58 | hostname: string; 59 | port?: string; 60 | pathname?: string; 61 | } 62 | 63 | export function doPatternsMatchUrl (remotePattern: RemotePattern, parsedUrl: ParsedURL) { 64 | if (remotePattern.protocol) { 65 | // parsedUrl.protocol contains the : after the http/https, remotePattern does not 66 | if (remotePattern.protocol !== parsedUrl.protocol.slice(0, -1)) { 67 | return false 68 | } 69 | } 70 | 71 | // ufo's ParsedURL doesn't separate out ports from hostname, so this formats next's RemotePattern to match that 72 | const hostAndPort = remotePattern.port ? `${remotePattern.hostname}:${remotePattern.port}` : remotePattern.hostname 73 | 74 | if (!makeRe(hostAndPort).test(parsedUrl.host)) { 75 | return false 76 | } 77 | 78 | if (remotePattern.pathname) { 79 | if (!makeRe(remotePattern.pathname).test(parsedUrl.pathname)) { 80 | return false 81 | } 82 | } 83 | 84 | return true 85 | } 86 | 87 | export class Lock { 88 | #locked = false 89 | #ee = new EventEmitter() 90 | 91 | acquire (): Promise { 92 | return new Promise((resolve) => { 93 | // If nobody has the lock, take it and resolve immediately 94 | if (!this.#locked) { 95 | // Safe because JS doesn't interrupt you on synchronous operations, 96 | // so no need for compare-and-swap or anything like that. 97 | this.#locked = true 98 | return resolve() 99 | } 100 | 101 | // Otherwise, wait until somebody releases the lock and try again 102 | const tryAcquire = () => { 103 | if (!this.#locked) { 104 | this.#locked = true 105 | this.#ee.removeListener('release', tryAcquire) 106 | return resolve() 107 | } 108 | } 109 | this.#ee.on('release', tryAcquire) 110 | }) 111 | } 112 | 113 | release (): void { 114 | // Release the lock immediately 115 | this.#locked = false 116 | setImmediate(() => this.#ee.emit('release')) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http' 2 | import { join } from 'path' 3 | import { tmpdir } from 'os' 4 | import test from 'ava' 5 | import { readFile, statSync, emptyDir, readdirSync } from 'fs-extra' 6 | 7 | import { createIPXHandler } from '../src/index' 8 | import { CACHE_PRUNING_THRESHOLD, SourceImageResult } from '../src/http' 9 | 10 | function getHandlerContext () { 11 | return { 12 | functionName: 'ipx', 13 | callbackWaitsForEmptyEventLoop: false, 14 | functionVersion: '1', 15 | invokedFunctionArn: '', 16 | awsRequestId: '', 17 | logGroupName: '', 18 | logStreamName: '', 19 | memoryLimitInMB: '', 20 | getRemainingTimeInMillis: () => 1000, 21 | done: () => { }, 22 | fail: () => { }, 23 | succeed: () => { } 24 | } 25 | } 26 | 27 | test('source image cache pruning', async (t) => { 28 | const filePath = join(__dirname, '..', 'example', 'public', 'img', 'test.jpg') 29 | const port = 8125 30 | 31 | const { size } = statSync(filePath) 32 | 33 | const imageCountToReachThreshold = Math.floor(CACHE_PRUNING_THRESHOLD / size) + 1 34 | 35 | // just few more images than needed to reach threshold 36 | const imageTestCount = imageCountToReachThreshold + 5 37 | 38 | // we assert success on each tranformation + 2 assertions for cache size 39 | t.plan(imageTestCount + 2) 40 | 41 | await new Promise((resolve) => { 42 | createServer(function (_request, response) { 43 | readFile(filePath, function (error, content) { 44 | if (error) { 45 | response.writeHead(500) 46 | response.end( 47 | error.toString() 48 | ) 49 | } else { 50 | response.writeHead(200, { 'Content-Type': 'image/jpeg' }) 51 | response.end(content) 52 | } 53 | }) 54 | }).listen(port, () => { 55 | resolve() 56 | }) 57 | }) 58 | 59 | const cacheDir = join(tmpdir(), 'ipx-cache') 60 | 61 | await emptyDir(cacheDir) 62 | 63 | const handler = createIPXHandler({ 64 | basePath: '/_ipx/', 65 | cacheDir, 66 | bypassDomainCheck: true 67 | }) 68 | 69 | for (let i = 0; i < imageTestCount; i++) { 70 | const path = `/_ipx/w_500/${i}.jpg` 71 | const response = await handler( 72 | { 73 | rawUrl: `http://localhost:${port}${path}`, 74 | path, 75 | headers: {}, 76 | rawQuery: '', 77 | httpMethod: 'GET', 78 | queryStringParameters: {}, 79 | multiValueQueryStringParameters: {}, 80 | multiValueHeaders: {}, 81 | isBase64Encoded: false, 82 | body: null 83 | }, 84 | getHandlerContext() 85 | ) 86 | if (response) { 87 | t.is(response.statusCode, 200) 88 | } 89 | } 90 | 91 | const cacheSize = readdirSync(join(cacheDir, 'cache')).reduce((acc, filename) => { 92 | const { size } = statSync(join(cacheDir, 'cache', filename)) 93 | return acc + size 94 | }, 0) 95 | 96 | t.is(cacheSize, imageCountToReachThreshold * size, 'cache size should be equal to number of images needed to reach threshold * image size') 97 | t.not( 98 | cacheSize, 99 | imageTestCount * size, 100 | 'cache size should not be equal to number of images * image size if we exceed threshold' 101 | ) 102 | }) 103 | 104 | test('should add WAF headers to local images being transformed', async (t) => { 105 | const handler = createIPXHandler({ 106 | basePath: '/_ipx/', 107 | cacheDir: '/tmp/ipx-cache', 108 | bypassDomainCheck: true 109 | }, (sourceImageOptions) => { 110 | t.assert(sourceImageOptions.requestHeaders && sourceImageOptions.requestHeaders['x-nf-waf-bypass-token'] === 'some token') 111 | 112 | return Promise.resolve({ finalize: () => { } } as SourceImageResult) 113 | }) 114 | 115 | await handler( 116 | { 117 | rawUrl: 'http://localhost:3000/some-path', 118 | path: '/_ipx/w_500/no-file.jpg', 119 | headers: { 'x-nf-waf-bypass-token': 'some token' }, 120 | rawQuery: '', 121 | httpMethod: 'GET', 122 | queryStringParameters: {}, 123 | multiValueQueryStringParameters: {}, 124 | multiValueHeaders: {}, 125 | isBase64Encoded: false, 126 | body: null 127 | }, 128 | getHandlerContext() 129 | ) 130 | }) 131 | 132 | test('should not add WAF headers to remote images being transformed', async (t) => { 133 | const handler = createIPXHandler({ 134 | basePath: '/_ipx/', 135 | cacheDir: '/tmp/ipx-cache', 136 | bypassDomainCheck: true 137 | }, (sourceImageOptions) => { 138 | t.assert(sourceImageOptions.requestHeaders && sourceImageOptions.requestHeaders['x-nf-waf-bypass-token'] === undefined) 139 | 140 | return Promise.resolve({ finalize: () => { } } as SourceImageResult) 141 | }) 142 | 143 | await handler( 144 | { 145 | rawUrl: 'http://localhost:3000/some-path', 146 | path: '/_ipx/w_500/https%3A%2F%2Fsome-site.com%2Fno-file.jpg', 147 | headers: { 'x-nf-waf-bypass-token': 'some token' }, 148 | rawQuery: '', 149 | httpMethod: 'GET', 150 | queryStringParameters: {}, 151 | multiValueQueryStringParameters: {}, 152 | multiValueHeaders: {}, 153 | isBase64Encoded: false, 154 | body: null 155 | }, 156 | getHandlerContext() 157 | ) 158 | }) 159 | test('should not add WAF headers to local images if WAF is disabled', async (t) => { 160 | const handler = createIPXHandler({ 161 | basePath: '/_ipx/', 162 | cacheDir: '/tmp/ipx-cache', 163 | bypassDomainCheck: true 164 | }, (sourceImageOptions) => { 165 | t.assert(sourceImageOptions.requestHeaders && sourceImageOptions.requestHeaders['x-nf-waf-bypass-token'] === undefined) 166 | 167 | return Promise.resolve({ finalize: () => { } } as SourceImageResult) 168 | }) 169 | 170 | await handler( 171 | { 172 | rawUrl: 'http://localhost:3000/some-path', 173 | path: '/_ipx/w_500/no-file.jpg', 174 | headers: {}, 175 | rawQuery: '', 176 | httpMethod: 'GET', 177 | queryStringParameters: {}, 178 | multiValueQueryStringParameters: {}, 179 | multiValueHeaders: {}, 180 | isBase64Encoded: false, 181 | body: null 182 | }, 183 | getHandlerContext() 184 | ) 185 | }) 186 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { parseURL } from 'ufo' 3 | import { decodeBase64Params, doPatternsMatchUrl, RemotePattern } from '../src/utils' 4 | 5 | const encodeUrlAndTransforms = (url = '', transforms = '', extension = ''): string => { 6 | const encodedUrl = Buffer.from(url).toString('base64') 7 | const encodedTransforms = Buffer.from(transforms).toString('base64') 8 | return `${encodedUrl}/${encodedTransforms}${extension}` 9 | } 10 | 11 | test('decodeBase64Params: returns expected response if no url is present', (t) => { 12 | const expectedResponse = { 13 | error: 'Bad Request' 14 | } 15 | 16 | const response = decodeBase64Params('') 17 | 18 | t.deepEqual(response, expectedResponse) 19 | }) 20 | 21 | test('decodeBase64Params: returns expected response if no transform exists in path', (t) => { 22 | const expectedResponse = { 23 | error: 'Bad Request' 24 | } 25 | 26 | const encodedUrl = encodeUrlAndTransforms('/images/image.jpg', '') 27 | 28 | const response = decodeBase64Params(encodedUrl) 29 | 30 | t.deepEqual(response, expectedResponse) 31 | }) 32 | 33 | test("decodeBase64Params: returns expected response if transform doesn't contain any transforms", (t) => { 34 | const expectedId = '/images/' 35 | const expectedResponse = { 36 | error: 'Bad Request' 37 | } 38 | 39 | const encodedUrl = encodeUrlAndTransforms(expectedId, '', '.ext') 40 | 41 | const response = decodeBase64Params(encodedUrl) 42 | 43 | t.deepEqual(response, expectedResponse) 44 | }) 45 | 46 | test('decodeBase64Params: returns expected response if transform contains w and h', (t) => { 47 | const expectedId = '/images/image.jpg' 48 | const expectedResponse = { 49 | id: expectedId, 50 | modifiers: 's_100x200' 51 | } 52 | 53 | const encodedUrl = encodeUrlAndTransforms(expectedId, 'w=100&h=200', '.jpg') 54 | 55 | const response = decodeBase64Params(encodedUrl) 56 | 57 | t.deepEqual(response, expectedResponse) 58 | }) 59 | 60 | test('decodeBase64Params: returns expected response if transform contains w but no h', (t) => { 61 | const expectedId = '/images/image.jpg' 62 | const expectedResponse = { 63 | id: expectedId, 64 | modifiers: 'w_100' 65 | } 66 | 67 | const encodedUrl = encodeUrlAndTransforms(expectedId, 'w=100', '.jpg') 68 | 69 | const response = decodeBase64Params(encodedUrl) 70 | 71 | t.deepEqual(response, expectedResponse) 72 | }) 73 | 74 | test('decodeBase64Params: returns expected response if transform contains h but no w', (t) => { 75 | const expectedId = '/images/image.jpg' 76 | const expectedResponse = { 77 | id: expectedId, 78 | modifiers: 'h_200' 79 | } 80 | 81 | const encodedUrl = encodeUrlAndTransforms(expectedId, 'h=200', '.jpg') 82 | 83 | const response = decodeBase64Params(encodedUrl) 84 | 85 | t.deepEqual(response, expectedResponse) 86 | }) 87 | 88 | test('decodeBase64Params: returns expected response if transform contains fm', (t) => { 89 | const expectedId = '/images/image.jpg' 90 | const expectedResponse = { 91 | id: expectedId, 92 | modifiers: 'f_10' 93 | } 94 | 95 | const encodedUrl = encodeUrlAndTransforms(expectedId, 'fm=10', '.jpg') 96 | 97 | const response = decodeBase64Params(encodedUrl) 98 | 99 | t.deepEqual(response, expectedResponse) 100 | }) 101 | 102 | test('decodeBase64Params: returns expected response if transform contains q', (t) => { 103 | const expectedId = '/images/image.jpg' 104 | const expectedResponse = { 105 | id: expectedId, 106 | modifiers: 'q_101' 107 | } 108 | 109 | const encodedUrl = encodeUrlAndTransforms(expectedId, 'q=101', '.jpg') 110 | 111 | const response = decodeBase64Params(encodedUrl) 112 | 113 | t.deepEqual(response, expectedResponse) 114 | }) 115 | 116 | test('decodeBase64Params: returns expected response if transform contains pos', (t) => { 117 | const expectedId = '/images/image.jpg' 118 | const expectedResponse = { 119 | id: expectedId, 120 | modifiers: 'crop_10 20' 121 | } 122 | 123 | const encodedUrl = encodeUrlAndTransforms(expectedId, 'pos=10,20', '.jpg') 124 | 125 | const response = decodeBase64Params(encodedUrl) 126 | 127 | t.deepEqual(response, expectedResponse) 128 | }) 129 | 130 | test('decodeBase64Params: returns expected response if transform contains multiple valid props', (t) => { 131 | const expectedId = '/images/image.jpg' 132 | const expectedResponse = { 133 | id: expectedId, 134 | modifiers: 's_100x200,f_101,crop_10 20,q_1234' 135 | } 136 | 137 | const encodedUrl = encodeUrlAndTransforms(expectedId, 'pos=10,20&w=100&h=200&fm=101&q=1234', '.jpg') 138 | 139 | const response = decodeBase64Params(encodedUrl) 140 | 141 | t.deepEqual(response, expectedResponse) 142 | }) 143 | 144 | test("doPatternsMatchUrl: returns false if protocol exists in remote pattern and it doesn't match", (t) => { 145 | const remotePattern: RemotePattern = { 146 | protocol: 'http', 147 | hostname: 'fail.com' 148 | } 149 | const parsedUrl = parseURL('https://fake.url/images?w=100&h=200&fm=1234', 'https://') 150 | 151 | const matches = doPatternsMatchUrl(remotePattern, parsedUrl) 152 | 153 | t.false(matches) 154 | }) 155 | 156 | test("doPatternsMatchUrl: returns false if port exists in remote pattern and the full host doesn't match", (t) => { 157 | const remotePattern: RemotePattern = { 158 | hostname: 'fail.com', 159 | port: '3000' 160 | } 161 | const parsedUrl = parseURL('https://fake.url/images?w=100&h=200&fm=1234', 'https://') 162 | 163 | const matches = doPatternsMatchUrl(remotePattern, parsedUrl) 164 | 165 | t.false(matches) 166 | }) 167 | 168 | test("doPatternsMatchUrl: returns false if port doesn't exists in remote pattern and the host doesn't match", (t) => { 169 | const remotePattern: RemotePattern = { 170 | hostname: 'fail.com' 171 | } 172 | const parsedUrl = parseURL('https://fake.url/images?w=100&h=200&fm=1234', 'https://') 173 | 174 | const matches = doPatternsMatchUrl(remotePattern, parsedUrl) 175 | 176 | t.false(matches) 177 | }) 178 | 179 | test("doPatternsMatchUrl: returns false if the pathname doesn't match", (t) => { 180 | const remotePattern: RemotePattern = { 181 | protocol: 'https', 182 | hostname: 'fake.url', 183 | pathname: '/failpath' 184 | } 185 | const parsedUrl = parseURL('https://fake.url/images?w=100&h=200&fm=1234', 'https://') 186 | 187 | const matches = doPatternsMatchUrl(remotePattern, parsedUrl) 188 | 189 | t.false(matches) 190 | }) 191 | 192 | test('doPatternsMatchUrl: returns true if remote pattern fully matches', (t) => { 193 | const remotePattern: RemotePattern = { 194 | protocol: 'https', 195 | hostname: 'fake.url', 196 | port: '3000', 197 | pathname: '/images' 198 | } 199 | const parsedUrl = parseURL('https://fake.url:3000/images?w=100&h=200&fm=1234', 'https://') 200 | 201 | const matches = doPatternsMatchUrl(remotePattern, parsedUrl) 202 | 203 | t.true(matches) 204 | }) 205 | 206 | test('doPatternsMatchUrl: returns true if remote pattern fully matches without pathname', (t) => { 207 | const remotePattern: RemotePattern = { 208 | protocol: 'https', 209 | hostname: 'fake.url', 210 | port: '3000' 211 | } 212 | const parsedUrl = parseURL('https://fake.url:3000/images?w=100&h=200&fm=1234', 'https://') 213 | 214 | const matches = doPatternsMatchUrl(remotePattern, parsedUrl) 215 | 216 | t.true(matches) 217 | }) 218 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "moduleResolution": "Node16", 6 | "esModuleInterop": true, 7 | "outDir": "dist", 8 | "skipLibCheck": true, 9 | "declaration": true, 10 | }, 11 | "include": [ 12 | "src/**/*" 13 | ] 14 | } --------------------------------------------------------------------------------