├── .editorconfig ├── .github └── workflows │ ├── autofix.yml │ └── ci.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── ipx.mjs ├── build.config.ts ├── eslint.config.mjs ├── package.json ├── playground.ts ├── pnpm-lock.yaml ├── renovate.json ├── src ├── cli.ts ├── handlers │ ├── handlers.ts │ ├── index.ts │ └── utils.ts ├── index.ts ├── ipx.ts ├── server.ts ├── storage │ ├── http.ts │ ├── node-fs.ts │ └── unstorage.ts ├── types.ts └── utils.ts ├── test ├── assets │ ├── bliss.jpg │ ├── giphy.gif │ ├── nested │ │ └── bliss.jpg │ ├── nuxt.svg │ ├── test.txt │ └── xss.svg ├── assets2 │ ├── bliss.jpg │ └── unjs.jpg ├── fs-dirs.test.ts ├── index.test.ts └── unstorage.test.ts ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["main"] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | autofix: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: npm i -fg corepack && corepack enable 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: "pnpm" 21 | - run: pnpm install 22 | - name: Fix lint issues 23 | run: pnpm run lint:fix 24 | - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef 25 | with: 26 | commit-message: "chore: apply automated updates" 27 | -------------------------------------------------------------------------------- /.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 | permissions: 12 | id-token: write 13 | 14 | jobs: 15 | ci: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 19 | with: 20 | fetch-depth: 0 21 | - run: npm i -fg corepack && corepack enable 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: "pnpm" 26 | - run: pnpm install 27 | - run: pnpm lint 28 | - run: pnpm build 29 | - run: pnpm vitest run --coverage 30 | - uses: codecov/codecov-action@v5 31 | - name: nightly release 32 | if: | 33 | github.ref_name == 'main' && 34 | !contains(github.event.head_commit.message, '[skip-release]') && 35 | !startsWith(github.event.head_commit.message, 'docs') 36 | run: | 37 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> ~/.npmrc && 38 | pnpm changelogen --canary nightly --publish 39 | env: 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | NPM_CONFIG_PROVENANCE: true 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .idea 4 | *.log* 5 | .DS_Store 6 | dist 7 | coverage 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /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 | ## v3.0.3 6 | 7 | [compare changes](https://github.com/unjs/ipx/compare/v3.0.2...v3.0.3) 8 | 9 | ## v3.0.2 10 | 11 | [compare changes](https://github.com/unjs/ipx/compare/v3.0.1...v3.0.2) 12 | 13 | ### 🩹 Fixes 14 | 15 | - Correctly handle `format_auto,animated` ([#235](https://github.com/unjs/ipx/pull/235)) 16 | 17 | ### 📖 Documentation 18 | 19 | - Added jsdocs to exported functions and types ([#222](https://github.com/unjs/ipx/pull/222)) 20 | 21 | ### 🏡 Chore 22 | 23 | - Lint ([0edc15c](https://github.com/unjs/ipx/commit/0edc15c)) 24 | - Update eslint config ([3a5b8c2](https://github.com/unjs/ipx/commit/3a5b8c2)) 25 | - Update deps ([593deec](https://github.com/unjs/ipx/commit/593deec)) 26 | - Update deps ([af12427](https://github.com/unjs/ipx/commit/af12427)) 27 | - Update ci ([e307855](https://github.com/unjs/ipx/commit/e307855)) 28 | - Update release script ([9414e40](https://github.com/unjs/ipx/commit/9414e40)) 29 | 30 | ### ❤️ Contributors 31 | 32 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 33 | - Max ([@onmax](https://github.com/onmax)) 34 | - James Wragg 35 | 36 | ## v3.0.1 37 | 38 | [compare changes](https://github.com/unjs/ipx/compare/v3.0.1-0...v3.0.1) 39 | 40 | ## v3.0.1-0 41 | 42 | [compare changes](https://github.com/unjs/ipx/compare/v3.0.0...v3.0.1-0) 43 | 44 | ### 🩹 Fixes 45 | 46 | - **http:** Properly respect `ignoreCacheControl` option ([96a8489](https://github.com/unjs/ipx/commit/96a8489)) 47 | 48 | ### 🏡 Chore 49 | 50 | - Update lockfile ([6b132ab](https://github.com/unjs/ipx/commit/6b132ab)) 51 | 52 | ### ❤️ Contributors 53 | 54 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 55 | 56 | ## v3.0.0 57 | 58 | [compare changes](https://github.com/unjs/ipx/compare/v2.1.1-0...v3.0.0) 59 | 60 | ### 🏡 Chore 61 | 62 | - Bump to v3.0.0 rc ([624b9db](https://github.com/unjs/ipx/commit/624b9db)) 63 | 64 | ### ❤️ Contributors 65 | 66 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 67 | 68 | ## v2.1.0 69 | 70 | [compare changes](https://github.com/unjs/ipx/compare/v2.0.2...v2.1.0) 71 | 72 | ### 🚀 Enhancements 73 | 74 | - **node-fs:** Add support for multiple dirs ([#203](https://github.com/unjs/ipx/pull/203)) 75 | 76 | ### 🩹 Fixes 77 | 78 | - Missing maxAge default ([#197](https://github.com/unjs/ipx/pull/197)) 79 | - **server:** Set `cache-control` header only after processing image ([#200](https://github.com/unjs/ipx/pull/200)) 80 | - Improve data parsing for unstorage ([#204](https://github.com/unjs/ipx/pull/204)) 81 | 82 | ### 💅 Refactors 83 | 84 | - Jpeg progressive setting via sharpOptions ([#198](https://github.com/unjs/ipx/pull/198)) 85 | 86 | ### 📦 Build 87 | 88 | - `ipx-nightly` release channel ([#191](https://github.com/unjs/ipx/pull/191)) 89 | 90 | ### 🏡 Chore 91 | 92 | - Update dependencies ([39c7199](https://github.com/unjs/ipx/commit/39c7199)) 93 | 94 | ### 🤖 CI 95 | 96 | - Fix nightly release job conditional ([#195](https://github.com/unjs/ipx/pull/195)) 97 | 98 | ### ❤️ Contributors 99 | 100 | - Arkadiusz Sygulski 101 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 102 | - Ucw 103 | - James Wragg 104 | - Bobbie Goede 105 | 106 | ## v2.0.2 107 | 108 | [compare changes](https://github.com/unjs/ipx/compare/v2.0.1...v2.0.2) 109 | 110 | ## v2.0.1 111 | 112 | [compare changes](https://github.com/unjs/ipx/compare/v2.0.0...v2.0.1) 113 | 114 | ### 🩹 Fixes 115 | 116 | - **svgo:** Handle javascript uris in `removexss` plugin ([#186](https://github.com/unjs/ipx/pull/186)) 117 | 118 | ### 🏡 Chore 119 | 120 | - Update dependencies ([37c467b](https://github.com/unjs/ipx/commit/37c467b)) 121 | 122 | ### ❤️ Contributors 123 | 124 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 125 | - Seth Falco ([@SethFalco](http://github.com/SethFalco)) 126 | 127 | ## v2.0.0 128 | 129 | [compare changes](https://github.com/unjs/ipx/compare/v2.0.0-1...v2.0.0) 130 | 131 | ### 🚀 Enhancements 132 | 133 | - **http:** Allow ignoring `cache-control` header via `ignoreCacheControl` ([4690342](https://github.com/unjs/ipx/commit/4690342)) 134 | - Optimize + sanitize svg sources with svgo ([#180](https://github.com/unjs/ipx/pull/180)) 135 | 136 | ### 🩹 Fixes 137 | 138 | - Respect global `maxAge` option as fallback ([2abe014](https://github.com/unjs/ipx/commit/2abe014)) 139 | - **server:** Improve 304 handling ([06820b5](https://github.com/unjs/ipx/commit/06820b5)) 140 | - **server:** Append `vary` header instead of overriding it ([fb3cf1d](https://github.com/unjs/ipx/commit/fb3cf1d)) 141 | - **server:** Set headers only if not already set ([ce0cf0e](https://github.com/unjs/ipx/commit/ce0cf0e)) 142 | 143 | ### 💅 Refactors 144 | 145 | - Upgrade to image-meta 0.2.x ([1017deb](https://github.com/unjs/ipx/commit/1017deb)) 146 | 147 | ### 🏡 Chore 148 | 149 | - Downgrade codecov-action ([2716500](https://github.com/unjs/ipx/commit/2716500)) 150 | - Update to full examples ([7aefb83](https://github.com/unjs/ipx/commit/7aefb83)) 151 | - Update dependencies ([2c33ece](https://github.com/unjs/ipx/commit/2c33ece)) 152 | - Apply automated fixes ([6e884b3](https://github.com/unjs/ipx/commit/6e884b3)) 153 | - Fix type issues ([3a2d92d](https://github.com/unjs/ipx/commit/3a2d92d)) 154 | - Update readme ([e234110](https://github.com/unjs/ipx/commit/e234110)) 155 | - Update readme ([acab74a](https://github.com/unjs/ipx/commit/acab74a)) 156 | 157 | ### ❤️ Contributors 158 | 159 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 160 | 161 | ## v2.0.0-1 162 | 163 | [compare changes](https://github.com/unjs/ipx/compare/v2.0.0-0...v2.0.0-1) 164 | 165 | ### 🚀 Enhancements 166 | 167 | - Use `ofetch` to improve http error handling ([ac30512](https://github.com/unjs/ipx/commit/ac30512)) 168 | 169 | ### 💅 Refactors 170 | 171 | - Improve error handling ([9826cda](https://github.com/unjs/ipx/commit/9826cda)) 172 | 173 | ### 📖 Documentation 174 | 175 | - Typo ([dcd8d72](https://github.com/unjs/ipx/commit/dcd8d72)) 176 | 177 | ### 🏡 Chore 178 | 179 | - Update prerelease script ([26cb3e9](https://github.com/unjs/ipx/commit/26cb3e9)) 180 | - Update docs ([b88925e](https://github.com/unjs/ipx/commit/b88925e)) 181 | - Update lockfile ([ad2e7f9](https://github.com/unjs/ipx/commit/ad2e7f9)) 182 | - Add alias to playground ([062305b](https://github.com/unjs/ipx/commit/062305b)) 183 | 184 | ### ❤️ Contributors 185 | 186 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 187 | - Sébastien Chopin ([@Atinux](http://github.com/Atinux)) 188 | 189 | ## v2.0.0-0 190 | 191 | [compare changes](https://github.com/unjs/ipx/compare/v1.3.0...v2.0.0-0) 192 | 193 | ### 🚀 Enhancements 194 | 195 | - **cli:** ⚠️ Rewrite with citty ([#167](https://github.com/unjs/ipx/pull/167)) 196 | - Reimplement server with h3 ([#168](https://github.com/unjs/ipx/pull/168)) 197 | 198 | ### 💅 Refactors 199 | 200 | - ⚠️ Rewrite storage system ([#164](https://github.com/unjs/ipx/pull/164)) 201 | 202 | ### 📖 Documentation 203 | 204 | - Add note about branch ([4883457](https://github.com/unjs/ipx/commit/4883457)) 205 | 206 | ### 🏡 Chore 207 | 208 | - Update lockfile ([58df066](https://github.com/unjs/ipx/commit/58df066)) 209 | - Add prerelease script ([0c5a2d0](https://github.com/unjs/ipx/commit/0c5a2d0)) 210 | - Update prerelease script ([8299814](https://github.com/unjs/ipx/commit/8299814)) 211 | 212 | #### ⚠️ Breaking Changes 213 | 214 | - **cli:** ⚠️ Rewrite with citty ([#167](https://github.com/unjs/ipx/pull/167)) 215 | - ⚠️ Rewrite storage system ([#164](https://github.com/unjs/ipx/pull/164)) 216 | 217 | ### ❤️ Contributors 218 | 219 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 220 | 221 | ## v1.3.0 222 | 223 | [compare changes](https://github.com/unjs/ipx/compare/v1.2.0...v1.3.0) 224 | 225 | ### 🚀 Enhancements 226 | 227 | - **handlers:** Support `kernel` modifier ([#143](https://github.com/unjs/ipx/pull/143)) 228 | 229 | ### 🩹 Fixes 230 | 231 | - Provide options to sharp method ([#138](https://github.com/unjs/ipx/pull/138)) 232 | - Add `heic` to the supported formats ([#130](https://github.com/unjs/ipx/pull/130)) 233 | 234 | ### 📖 Documentation 235 | 236 | - Mention background modifier ([#156](https://github.com/unjs/ipx/pull/156)) 237 | - Update `background` modifier example ([2c6ad22](https://github.com/unjs/ipx/commit/2c6ad22)) 238 | - Add `h3` middleware example ([#144](https://github.com/unjs/ipx/pull/144)) 239 | 240 | ### 📦 Build 241 | 242 | - Update exports field for type support ([f43772b](https://github.com/unjs/ipx/commit/f43772b)) 243 | 244 | ### 🏡 Chore 245 | 246 | - Fix internal typo ([#148](https://github.com/unjs/ipx/pull/148)) 247 | - Update dependencies ([5b34193](https://github.com/unjs/ipx/commit/5b34193)) 248 | - Replace nodemon with listhen ([a008152](https://github.com/unjs/ipx/commit/a008152)) 249 | 250 | ### 🎨 Styles 251 | 252 | - Format with prettier v3 ([4c5e8bb](https://github.com/unjs/ipx/commit/4c5e8bb)) 253 | 254 | ### 🤖 CI 255 | 256 | - Add autofix ([3bffaa4](https://github.com/unjs/ipx/commit/3bffaa4)) 257 | 258 | ### ❤️ Contributors 259 | 260 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 261 | - Dominik Opyd 262 | - Aura Román ([@kyranet](http://github.com/kyranet)) 263 | - Paweł Szafrański 264 | - Lexpeartha 265 | - Daniel Roe 266 | 267 | ## v1.2.0 268 | 269 | [compare changes](https://github.com/unjs/ipx/compare/v1.1.0...v1.2.0) 270 | 271 | 272 | ### 🚀 Enhancements 273 | 274 | - Support experimental `auto` format ([#85](https://github.com/unjs/ipx/pull/85)) 275 | - **middleware:** Add fallthrough option to handle error with next callback ([#116](https://github.com/unjs/ipx/pull/116)) 276 | - Support working `extract` modifier` ([#114](https://github.com/unjs/ipx/pull/114)) 277 | 278 | ### 🩹 Fixes 279 | 280 | - **middleware:** Sanitize double backslashes and quotes ([#115](https://github.com/unjs/ipx/pull/115)) 281 | - **middleware:** Handle multple argument modifiers ([e4ef303](https://github.com/unjs/ipx/commit/e4ef303)) 282 | 283 | ### 💅 Refactors 284 | 285 | - Enable strict typechecks ([#133](https://github.com/unjs/ipx/pull/133)) 286 | 287 | ### 🏡 Chore 288 | 289 | - Update dependencies ([1499333](https://github.com/unjs/ipx/commit/1499333)) 290 | - Simplify readme ([5391abb](https://github.com/unjs/ipx/commit/5391abb)) 291 | - Update badges ([3832a81](https://github.com/unjs/ipx/commit/3832a81)) 292 | - Update vitest ([e934194](https://github.com/unjs/ipx/commit/e934194)) 293 | 294 | ### ❤️ Contributors 295 | 296 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 297 | - Haruaki OTAKE 298 | 299 | ## v1.1.0 300 | 301 | [compare changes](https://github.com/unjs/ipx/compare/v1.0.1...v1.1.0) 302 | 303 | 304 | ### 🚀 Enhancements 305 | 306 | - Support `sigma` parameter for `blur` operation ([#124](https://github.com/unjs/ipx/pull/124)) 307 | 308 | ### 🏡 Chore 309 | 310 | - Update deps ([f38536d](https://github.com/unjs/ipx/commit/f38536d)) 311 | 312 | ### ❤️ Contributors 313 | 314 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 315 | - Carlos Peña ([@carlo697](http://github.com/carlo697)) 316 | 317 | ## v1.0.1 318 | 319 | [compare changes](https://github.com/unjs/ipx/compare/v1.0.0...v1.0.1) 320 | 321 | ## v1.0.0 322 | 323 | [compare changes](https://github.com/unjs/ipx/compare/v1.0.0-2...v1.0.0) 324 | 325 | 326 | ### 🏡 Chore 327 | 328 | - Update dependencies ([be8facd](https://github.com/unjs/ipx/commit/be8facd)) 329 | - Fix lint issue ([f4c0532](https://github.com/unjs/ipx/commit/f4c0532)) 330 | - Update release script ([eab8d46](https://github.com/unjs/ipx/commit/eab8d46)) 331 | 332 | ### 🎨 Styles 333 | 334 | - Format with prettier ([9713626](https://github.com/unjs/ipx/commit/9713626)) 335 | 336 | ### ❤️ Contributors 337 | 338 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 339 | 340 | ## [1.0.0-2](https://github.com/unjs/ipx/compare/v1.0.0-1...v1.0.0-2) (2022-11-24) 341 | 342 | ## [1.0.0-1](https://github.com/unjs/ipx/compare/v1.0.0-0...v1.0.0-1) (2022-11-23) 343 | 344 | 345 | ### Bug Fixes 346 | 347 | * update defu import ([c55f878](https://github.com/unjs/ipx/commit/c55f878671d340b04f8e3e5ae8ea2849809280d9)) 348 | 349 | ## [1.0.0-0](https://github.com/unjs/ipx/compare/v0.9.11...v1.0.0-0) (2022-11-23) 350 | 351 | 352 | ### Bug Fixes 353 | 354 | * use utc format for `Last-Modified` header ([#89](https://github.com/unjs/ipx/issues/89)) ([1cb0b6d](https://github.com/unjs/ipx/commit/1cb0b6dbefc394b1da6125916db4bb46b6e4a967)) 355 | 356 | ### [0.9.11](https://github.com/unjs/ipx/compare/v0.9.10...v0.9.11) (2022-09-03) 357 | 358 | 359 | ### Features 360 | 361 | * **middleware:** add `Content-Security-Policy` header ([#83](https://github.com/unjs/ipx/issues/83)) ([d1edbf1](https://github.com/unjs/ipx/commit/d1edbf120759697e04259b3708784d05b38f7190)) 362 | 363 | 364 | ### Bug Fixes 365 | 366 | * use `hasProtocol` rather than checking if url starts with `http` ([#80](https://github.com/unjs/ipx/issues/80)) ([696ba5a](https://github.com/unjs/ipx/commit/696ba5a2473b4d1e95222678d59ddfaa9406b6b1)) 367 | 368 | ### [0.9.10](https://github.com/unjs/ipx/compare/v0.9.9...v0.9.10) (2022-07-07) 369 | 370 | 371 | ### Bug Fixes 372 | 373 | * return promise from middleware ([2fb644d](https://github.com/unjs/ipx/commit/2fb644da6c5e2ea52a08db7aed11fd373ac612a1)) 374 | 375 | ### [0.9.9](https://github.com/unjs/ipx/compare/v0.9.8...v0.9.9) (2022-06-22) 376 | 377 | 378 | ### Bug Fixes 379 | 380 | * **http:** handle domains without protocol and port ([a5b4614](https://github.com/unjs/ipx/commit/a5b46149a3c67f9a7418fdb9ec6474f2e1429f0b)) 381 | 382 | ### [0.9.8](https://github.com/unjs/ipx/compare/v0.9.7...v0.9.8) (2022-06-22) 383 | 384 | 385 | ### Bug Fixes 386 | 387 | * **http:** use hostname to compare against domains ([3aabc41](https://github.com/unjs/ipx/commit/3aabc4134e7a9e6f52588815fc51d610fc03324d)) 388 | 389 | ### [0.9.7](https://github.com/unjs/ipx/compare/v0.9.6...v0.9.7) (2022-06-22) 390 | 391 | 392 | ### Bug Fixes 393 | 394 | * **fs:** fix windows path validation ([c631a2b](https://github.com/unjs/ipx/commit/c631a2b11109c306a7460e29a11d852b27301206)) 395 | 396 | ### [0.9.6](https://github.com/unjs/ipx/compare/v0.9.5...v0.9.6) (2022-06-20) 397 | 398 | ### [0.9.5](https://github.com/unjs/ipx/compare/v0.9.4...v0.9.5) (2022-06-20) 399 | 400 | 401 | ### Features 402 | 403 | * `fetchOptions` ([#74](https://github.com/unjs/ipx/issues/74)) ([4d0f235](https://github.com/unjs/ipx/commit/4d0f2352b442c47bfe4ff954f927a94c572bb342)) 404 | * enable animated by default for gif (closes [#53](https://github.com/unjs/ipx/issues/53)) ([155afac](https://github.com/unjs/ipx/commit/155afacd70e3bb130a14df61d7c5f1f3062d0b3f)) 405 | * global `maxAge` option ([#71](https://github.com/unjs/ipx/issues/71)) ([a2481dc](https://github.com/unjs/ipx/commit/a2481dc6ca154b89a89aa537965198069a650f37)) 406 | * **middleware:** allow extended modifier seperators ([a47d2aa](https://github.com/unjs/ipx/commit/a47d2aa86e15b7f5bc43220a3d8d2c06147d7c11)), closes [#57](https://github.com/unjs/ipx/issues/57) 407 | 408 | 409 | ### Bug Fixes 410 | 411 | * improve path validation (resolves [#56](https://github.com/unjs/ipx/issues/56)) ([ec5c15d](https://github.com/unjs/ipx/commit/ec5c15d2ecfa3a5c9c550b918f84cf2f87085f90)) 412 | * **middleware:** sanetize request and response strings (resolves [#42](https://github.com/unjs/ipx/issues/42)) ([1792d3a](https://github.com/unjs/ipx/commit/1792d3aa2f4e572e0ca09cdac7272f60402cf3ea)) 413 | * use `response.arrayBuffer` instead of deprecated `res.buffer` ([b13a77e](https://github.com/unjs/ipx/commit/b13a77e0884e5d4dbb2f7ea7de43ea05e9581698)), closes [#69](https://github.com/unjs/ipx/issues/69) 414 | 415 | ### [0.9.4](https://github.com/unjs/ipx/compare/v0.9.3...v0.9.4) (2022-02-17) 416 | 417 | 418 | ### Bug Fixes 419 | 420 | * revert back cjs entry ([a9f42b9](https://github.com/unjs/ipx/commit/a9f42b926a77ead3daf84f0ae7946c2e490edd45)) 421 | 422 | ### [0.9.3](https://github.com/unjs/ipx/compare/v0.9.2...v0.9.3) (2022-02-15) 423 | 424 | 425 | ### Features 426 | 427 | * Added 'position' option for resize ([#55](https://github.com/unjs/ipx/issues/55)) ([f89dea7](https://github.com/unjs/ipx/commit/f89dea781abc9ad916b431f15cf1c6fa31b0d1ad)) 428 | * update dependencies ([99dfd0e](https://github.com/unjs/ipx/commit/99dfd0e771da2753614585e103305a354f2e7857)), closes [/sharp.pixelplumbing.com/changelog#v0300---1st-february-2022](https://github.com/unjs//sharp.pixelplumbing.com/changelog/issues/v0300---1st-february-2022) 429 | 430 | 431 | ### Bug Fixes 432 | 433 | * allow resize operator with only width ([4662f4e](https://github.com/unjs/ipx/commit/4662f4eaf720a30499179fa66608c853d333e269)) 434 | 435 | ### [0.9.2](https://github.com/unjs/ipx/compare/v0.9.1...v0.9.2) (2022-01-31) 436 | 437 | 438 | ### Bug Fixes 439 | 440 | * use whatwg-url for parsing hostname ([a5ee0b5](https://github.com/unjs/ipx/commit/a5ee0b59ded16c9b48661bfc70f17c0b2fdd87ea)) 441 | 442 | ### [0.9.1](https://github.com/unjs/ipx/compare/v0.9.0...v0.9.1) (2021-11-05) 443 | 444 | 445 | ### Bug Fixes 446 | 447 | * restore `ipx` command (resolves [#51](https://github.com/unjs/ipx/issues/51)) ([9a26c4b](https://github.com/unjs/ipx/commit/9a26c4b18c3f226612fd871f64d9cbc705cda621)) 448 | 449 | ## [0.9.0](https://github.com/unjs/ipx/compare/v0.8.0...v0.9.0) (2021-10-27) 450 | 451 | 452 | ### ⚠ BREAKING CHANGES 453 | 454 | * Sharp is being lazy loaded 455 | * Several dependencies changes for better ESM compatibility 456 | 457 | ### Features 458 | 459 | * lazy load sharp ([9cf14dc](https://github.com/unjs/ipx/commit/9cf14dca95d81e0fa71cc5fe9122d4280e528195)) 460 | 461 | 462 | ### Bug Fixes 463 | 464 | * update image-meta import ([a278cf1](https://github.com/unjs/ipx/commit/a278cf1b0a14409d2000f3ad48758cae02f96f1c)) 465 | * use ohmyfetch for cjs support ([39e78b9](https://github.com/unjs/ipx/commit/39e78b9060a11457bd3b26ec8906982091f3eb1d)) 466 | 467 | ## [0.8.0](https://github.com/unjs/ipx/compare/v0.7.2...v0.8.0) (2021-09-05) 468 | 469 | 470 | ### ⚠ BREAKING CHANGES 471 | 472 | * update sharp to 0.29.0 ([b5a06fb](https://github.com/unjs/ipx/commit/b5a06fbdd2d5e0caf12f8c8a3d389ebed2744425)), [changelog](https://github.com/lovell/sharp/blob/master/docs/changelog.md#v0290---17th-august-2021) 473 | 474 | ### [0.7.2](https://github.com/unjs/ipx/compare/v0.7.1...v0.7.2) (2021-07-26) 475 | 476 | 477 | ### Features 478 | 479 | * default to not upscaling images ([#41](https://github.com/unjs/ipx/issues/41)) ([162f730](https://github.com/unjs/ipx/commit/162f7308650416905b33ab2c031c5fc7b82ef13b)) 480 | 481 | ### [0.7.1](https://github.com/unjs/ipx/compare/v0.7.0...v0.7.1) (2021-07-02) 482 | 483 | 484 | ### Bug Fixes 485 | 486 | * handle background number ([2f82a56](https://github.com/unjs/ipx/commit/2f82a56893004e6797b23ef40c2940155fde63f4)) 487 | * resize with width and hight ([3ca70a0](https://github.com/unjs/ipx/commit/3ca70a017d86a2d907a935eaf6ffab901424bffb)) 488 | * support background with rotate ([b6c8f8c](https://github.com/unjs/ipx/commit/b6c8f8cb1310d18134d0ade2e4c023d3d7a1936c)) 489 | 490 | ## [0.7.0](https://github.com/unjs/ipx/compare/v0.6.7...v0.7.0) (2021-07-01) 491 | 492 | 493 | ### ⚠ BREAKING CHANGES 494 | 495 | * **pkg:** add exports field 496 | * move modifiers to path from query 497 | 498 | ### Features 499 | 500 | * `reqOptions` and `bypassDomain` ([fc8c7b5](https://github.com/unjs/ipx/commit/fc8c7b5b655d61e23f6f63af82669ed23e48eec5)) 501 | * **pkg:** add exports field ([394384f](https://github.com/unjs/ipx/commit/394384f19364845e228aedeee598d8960d263c7e)) 502 | * move modifiers to path from query ([b7570d9](https://github.com/unjs/ipx/commit/b7570d942bf282da38acdc79b34c6e33177611c0)) 503 | 504 | 505 | ### Bug Fixes 506 | 507 | * don't prepend trailing slash to external id ([01e151a](https://github.com/unjs/ipx/commit/01e151a90c0601802bf197cf28542d24fae1c3b4)) 508 | 509 | ### [0.6.7](https://github.com/unjs/ipx/compare/v0.6.6...v0.6.7) (2021-07-01) 510 | 511 | ### Bug Fixes 512 | 513 | - **middleware:** set res.body ([d7dc146](https://github.com/unjs/ipx/commit/d7dc1466224310e583d6c595a3c1e67b00f4a13f)) 514 | 515 | ### [0.6.6](https://github.com/unjs/ipx/compare/v0.6.5...v0.6.6) (2021-07-01) 516 | 517 | ### [0.6.5](https://github.com/unjs/ipx/compare/v0.6.4...v0.6.5) (2021-07-01) 518 | 519 | ### Features 520 | 521 | - expose handleRequest ([7c8c857](https://github.com/unjs/ipx/commit/7c8c857fc4a84d57ee8c2a5919f0b397c2e1b220)) 522 | 523 | ### Bug Fixes 524 | 525 | - **filesystem:** handle when input is not a file ([9e1f7bf](https://github.com/unjs/ipx/commit/9e1f7bf73463b0958362bfa1443f1db24058410a)) 526 | 527 | ### [0.6.4](https://github.com/unjs/ipx/compare/v0.6.3...v0.6.4) (2021-07-01) 528 | 529 | ### Bug Fixes 530 | 531 | - enforce leadingSlash for alias resolution ([3092e00](https://github.com/unjs/ipx/commit/3092e00870a29cb797c1c3b6cb921497523800fa)) 532 | 533 | ### [0.6.3](https://github.com/unjs/ipx/compare/v0.6.2...v0.6.3) (2021-06-30) 534 | 535 | ### Bug Fixes 536 | 537 | - content-type of svg files ([#38](https://github.com/unjs/ipx/issues/38)) ([a7a1b3b](https://github.com/unjs/ipx/commit/a7a1b3b8fb3c1b996ec823d80d029a11a19b9311)) 538 | 539 | ### [0.6.2](https://github.com/unjs/ipx/compare/v0.6.1...v0.6.2) (2021-06-29) 540 | 541 | ### Features 542 | 543 | - experimental animated support (ref [#35](https://github.com/unjs/ipx/issues/35)) ([d93fdfa](https://github.com/unjs/ipx/commit/d93fdfa1d591e70b89084a7f50d37343a7d68df8)) 544 | - support id alias ([#32](https://github.com/unjs/ipx/issues/32)) ([d4356cf](https://github.com/unjs/ipx/commit/d4356cfc28f23000e3e25f597d49eb164da580b3)) 545 | - **http:** use hostname for domain validation ([da5ca74](https://github.com/unjs/ipx/commit/da5ca74b0a57f5e47b1927f282fdda7228e54f58)), closes [nuxt/image#343](https://github.com/nuxt/image/issues/343) 546 | 547 | ### Bug Fixes 548 | 549 | - apply context modifiers first (resolves [#33](https://github.com/unjs/ipx/issues/33)) ([cf9effd](https://github.com/unjs/ipx/commit/cf9effd1f8b390c51507f2b18d2a69de921017fd)) 550 | - default modifiers to empty object ([00d5c1d](https://github.com/unjs/ipx/commit/00d5c1d262a300469d24dc5a92c4a9940f2f0483)) 551 | 552 | ### [0.6.1](https://github.com/unjs/ipx/compare/v0.6.0...v0.6.1) (2021-05-26) 553 | 554 | ## [0.6.0](https://github.com/unjs/ipx/compare/v0.5.8...v0.6.0) (2021-02-15) 555 | 556 | ### ⚠ BREAKING CHANGES 557 | 558 | - improved handlers and format support 559 | 560 | ### Features 561 | 562 | - improved handlers and format support ([f4f6e58](https://github.com/unjs/ipx/commit/f4f6e586119e5c9c7c81354277b42e2d3406bb96)) 563 | 564 | ### [0.5.8](https://github.com/unjs/ipx/compare/v0.5.7...v0.5.8) (2021-02-08) 565 | 566 | ### Bug Fixes 567 | 568 | - **ipx:** handle when modifiers not provided ([4efebd8](https://github.com/unjs/ipx/commit/4efebd88963cfd054004810207874553e89e5d61)) 569 | 570 | ### [0.5.7](https://github.com/unjs/ipx/compare/v0.5.6...v0.5.7) (2021-02-08) 571 | 572 | ### Bug Fixes 573 | 574 | - override meta.type and meta.mimeType if format modifier used ([820f1e2](https://github.com/unjs/ipx/commit/820f1e253dcbd0fe1122a742bb75dcfc364b868b)) 575 | 576 | ### [0.5.6](https://github.com/unjs/ipx/compare/v0.5.5...v0.5.6) (2021-02-04) 577 | 578 | ### Bug Fixes 579 | 580 | - remove extra space ([6df3504](https://github.com/unjs/ipx/commit/6df350413d2cab1b4d4a4c9f8b8a92bd906cc8f5)) 581 | 582 | ### [0.5.5](https://github.com/unjs/ipx/compare/v0.5.4...v0.5.5) (2021-02-04) 583 | 584 | ### Bug Fixes 585 | 586 | - add public and s-maxage ([bfd9727](https://github.com/unjs/ipx/commit/bfd9727ac867d0af390f56dd939347f5183d1763)) 587 | 588 | ### [0.5.4](https://github.com/unjs/ipx/compare/v0.5.3...v0.5.4) (2021-02-04) 589 | 590 | ### Bug Fixes 591 | 592 | - **http:** user headers.get ([9cf5aeb](https://github.com/unjs/ipx/commit/9cf5aebaff8f8fe86014993ac4c91590bc5a6134)) 593 | 594 | ### [0.5.3](https://github.com/unjs/ipx/compare/v0.5.2...v0.5.3) (2021-02-04) 595 | 596 | ### Bug Fixes 597 | 598 | - fix max-age cache header name ([833272b](https://github.com/unjs/ipx/commit/833272b6a4c63c388e941c8f037118c204a8dac4)) 599 | - **types:** optional ipxOptions ([692ab1f](https://github.com/unjs/ipx/commit/692ab1f6c64fa86d77581bebdcabf0ba707b9469)) 600 | 601 | ### [0.5.2](https://github.com/unjs/ipx/compare/v0.5.1...v0.5.2) (2021-02-03) 602 | 603 | ### Features 604 | 605 | - support meta, content-type and svg handling ([37592e7](https://github.com/unjs/ipx/commit/37592e711d166df41c29f1b1117adb186d42ce5d)) 606 | 607 | ### Bug Fixes 608 | 609 | - only giveup svg if no format modifier set ([f5ce8b7](https://github.com/unjs/ipx/commit/f5ce8b7aecd18629b7a116dc6aecd5096d4573aa)) 610 | 611 | ### [0.5.1](https://github.com/unjs/ipx/compare/v0.5.0...v0.5.1) (2021-02-03) 612 | 613 | ### Bug Fixes 614 | 615 | - **pkg:** export index.ts ([6125bbb](https://github.com/unjs/ipx/commit/6125bbb79ad430294f5d371d9a08f8ecca5c8372)) 616 | 617 | ## [0.5.0](https://github.com/unjs/ipx/compare/v0.4.8...v0.5.0) (2021-02-03) 618 | 619 | ### ⚠ BREAKING CHANGES 620 | 621 | - rewrite ipx 622 | 623 | ### Features 624 | 625 | - rewrite ipx ([a60fb0d](https://github.com/unjs/ipx/commit/a60fb0d44b96c9f135af3295730c3da13fbc3e6c)) 626 | 627 | ### [0.4.8](https://github.com/unjs/ipx/compare/v0.4.7...v0.4.8) (2020-12-23) 628 | 629 | ### Bug Fixes 630 | 631 | - update allowList import ([a26cae0](https://github.com/unjs/ipx/commit/a26cae00faa4fea7c190e3fb4efdf5fa1d137095)) 632 | 633 | ### [0.4.7](https://github.com/unjs/ipx/compare/v0.4.6...v0.4.7) (2020-12-23) 634 | 635 | ### Bug Fixes 636 | 637 | - **pkg:** update exports ([584cfe4](https://github.com/unjs/ipx/commit/584cfe4c341da6e10a7da28a20afe6b4d9aeff0a)) 638 | 639 | ### [0.4.6](https://github.com/unjs/ipx/compare/v0.4.5...v0.4.6) (2020-11-30) 640 | 641 | ### [0.4.5](https://github.com/unjs/ipx/compare/v0.4.4...v0.4.5) (2020-11-30) 642 | 643 | ### Bug Fixes 644 | 645 | - prevent unknow format error ([#18](https://github.com/unjs/ipx/issues/18)) ([3f338be](https://github.com/unjs/ipx/commit/3f338be630c76fd2d91901462cc3d5b495719882)) 646 | 647 | ### [0.4.4](https://github.com/unjs/ipx/compare/v0.4.3...v0.4.4) (2020-11-27) 648 | 649 | ### Features 650 | 651 | - add background operation ([#16](https://github.com/unjs/ipx/issues/16)) ([b1a0178](https://github.com/unjs/ipx/commit/b1a0178c2522bba1361a8973bf338fe0ae1cab86)) 652 | 653 | ### [0.4.3](https://github.com/unjs/ipx/compare/v0.4.2...v0.4.3) (2020-11-25) 654 | 655 | ### Features 656 | 657 | - allow gif images ([#15](https://github.com/unjs/ipx/issues/15)) ([51dcfc1](https://github.com/unjs/ipx/commit/51dcfc1dc0a076eca2c33ce5fcaf37b970964bca)) 658 | 659 | ### [0.4.2](https://github.com/unjs/ipx/compare/v0.4.1...v0.4.2) (2020-11-18) 660 | 661 | ### Bug Fixes 662 | 663 | - support `HttpAgent` with `remote` input ([#14](https://github.com/unjs/ipx/issues/14)) ([699b671](https://github.com/unjs/ipx/commit/699b6717d1b6f817edb784d50cd5f2ce8da5d21a)) 664 | 665 | ### [0.4.1](https://github.com/unjs/ipx/compare/v0.4.0...v0.4.1) (2020-11-12) 666 | 667 | ### Features 668 | 669 | - allow overiding `sharp.options` ([#13](https://github.com/unjs/ipx/issues/13)) ([ae7244d](https://github.com/unjs/ipx/commit/ae7244d83712d352e4fd08fa2106122aac6f2689)) 670 | 671 | ## [0.4.0](https://github.com/unjs/ipx/compare/v0.4.0-rc.1...v0.4.0) (2020-11-05) 672 | 673 | ### Features 674 | 675 | - support svg files ([#9](https://github.com/unjs/ipx/issues/9)) ([a020904](https://github.com/unjs/ipx/commit/a02090436e0116de641fa3d415dfeae1bee79379)) 676 | 677 | ### Bug Fixes 678 | 679 | - remove meta ([a490fb6](https://github.com/unjs/ipx/commit/a490fb6bb13a5f215a1ffb39b6acbf6d5de85aca)) 680 | - support adapter on client ([4ffd7e8](https://github.com/unjs/ipx/commit/4ffd7e84553b4b13dbb15bee801d27d014b9dc08)) 681 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Pooya Parsa 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 | # 🖼️ IPX 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | 6 | > [!NOTE] 7 | > This is the active development branch. Check out [v2](https://github.com/unjs/ipx/tree/v2) and [v3](https://github.com/unjs/ipx/tree/v3) for older docs. 8 | 9 | High performance, secure and easy-to-use image optimizer powered by [sharp](https://github.com/lovell/sharp) and [svgo](https://github.com/svg/svgo). 10 | 11 | Used by [Nuxt Image](https://image.nuxt.com/) and [Netlify](https://www.npmjs.com/package/@netlify/ipx) and open to everyone! 12 | 13 | ## Using CLI 14 | 15 | You can use `ipx` command to start server. 16 | 17 | Using `npx`: 18 | 19 | ```bash 20 | npx ipx serve --dir ./ 21 | ``` 22 | 23 | Usin `bun` 24 | 25 | ```bash 26 | bun x npx ipx serve --dir ./ 27 | ``` 28 | 29 | The default serve directory is the current working directory. 30 | 31 | ## Programatic API 32 | 33 | You can use IPX as a middleware or directly use IPX interface. 34 | 35 | ```ts 36 | import { createIPX, ipxFSStorage, ipxHttpStorage } from "ipx"; 37 | 38 | const ipx = createIPX({ 39 | storage: ipxFSStorage({ dir: "./public" }), 40 | httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }), 41 | }); 42 | ``` 43 | 44 | **Example**: Using with [unjs/h3](https://github.com/unjs/h3): 45 | 46 | ```js 47 | import { listen } from "listhen"; 48 | import { createApp, toNodeListener } from "h3"; 49 | import { 50 | createIPX, 51 | ipxFSStorage, 52 | ipxHttpStorage, 53 | createIPXH3Handler, 54 | } from "ipx"; 55 | 56 | const ipx = createIPX({ 57 | storage: ipxFSStorage({ dir: "./public" }), 58 | httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }), 59 | }); 60 | 61 | const app = createApp().use("/", createIPXH3Handler(ipx)); 62 | 63 | listen(toNodeListener(app)); 64 | ``` 65 | 66 | **Example:** Using [express](https://expressjs.com): 67 | 68 | ```js 69 | import { listen } from "listhen"; 70 | import express from "express"; 71 | import { 72 | createIPX, 73 | ipxFSStorage, 74 | ipxHttpStorage, 75 | createIPXNodeServer, 76 | } from "ipx"; 77 | 78 | const ipx = createIPX({ 79 | storage: ipxFSStorage({ dir: "./public" }), 80 | httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }), 81 | }); 82 | 83 | const app = express().use("/", createIPXNodeServer(ipx)); 84 | 85 | listen(app); 86 | ``` 87 | 88 | ## URL Examples 89 | 90 | Get original image: 91 | 92 | `/_/static/buffalo.png` 93 | 94 | Change format to `webp` and keep other things same as source: 95 | 96 | `/f_webp/static/buffalo.png` 97 | 98 | Automatically convert to a preferred format (avif/webp/jpeg). Uses the browsers `accept` header to negotiate: 99 | 100 | `/f_auto/static/buffalo.png` 101 | 102 | Keep original format (`png`) and set width to `200`: 103 | 104 | `/w_200/static/buffalo.png` 105 | 106 | Resize to `200x200px` using `embed` method and change format to `webp`: 107 | 108 | `/embed,f_webp,s_200x200/static/buffalo.png` 109 | 110 | ## Config 111 | 112 | You can universally customize IPX configuration using `IPX_*` environment variables. 113 | 114 | - `IPX_ALIAS` 115 | 116 | - Default: `{}` 117 | 118 | ### Filesystem Source Options 119 | 120 | (enabled by default with CLI only) 121 | 122 | #### `IPX_FS_DIR` 123 | 124 | - Default: `.` (current working directory) 125 | 126 | #### `IPX_FS_MAX_AGE` 127 | 128 | - Default: `300` 129 | 130 | ### HTTP(s) Source Options 131 | 132 | (enabled by default with CLI only) 133 | 134 | #### `IPX_HTTP_DOMAINS` 135 | 136 | - Default: `[]` 137 | 138 | #### `IPX_HTTP_MAX_AGE` 139 | 140 | - Default: `300` 141 | 142 | #### `IPX_HTTP_FETCH_OPTIONS` 143 | 144 | - Default: `{}` 145 | 146 | #### `IPX_HTTP_ALLOW_ALL_DOMAINS` 147 | 148 | - Default: `false` 149 | 150 | ## Modifiers 151 | 152 | | Property | Docs | Example | Comments | 153 | | -------------- | :-------------------------------------------------------------- | :--------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- | 154 | | width / w | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `/width_200/buffalo.png` | 155 | | height / h | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `/height_200/buffalo.png` | 156 | | resize / s | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `/s_200x200/buffalo.png` | 157 | | kernel | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `/s_200x200,kernel_nearest/buffalo.png` | Supported kernel: `nearest`, `cubic`, `mitchell`, `lanczos2` and `lanczos3` (default). | 158 | | fit | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `/s_200x200,fit_outside/buffalo.png` | Sets `fit` option for `resize`. | 159 | | position / pos | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `/s_200x200,pos_top/buffalo.png` | Sets `position` option for `resize`. | 160 | | trim | [Docs](https://sharp.pixelplumbing.com/api-resize#trim) | `/trim_100/buffalo.png` | 161 | | extend | [Docs](https://sharp.pixelplumbing.com/api-resize#extend) | `/extend_{top}_{right}_{bottom}_{left}/buffalo.png` | Extend / pad / extrude one or more edges of the image with either the provided background colour or pixels derived from the image. | 162 | | background / b | \_ | `/r_45,b_00ff00/buffalo.png` | 163 | | extract | [Docs](https://sharp.pixelplumbing.com/api-resize#extract) | `/extract_{left}_{top}_{width}_{height}/buffalo.png` | Extract/crop a region of the image. | 164 | | format / f | [Docs](https://sharp.pixelplumbing.com/api-output#toformat) | `/format_webp/buffalo.png` | Supported format: `jpg`, `jpeg`, `png`, `webp`, `avif`, `gif`, `heif`, `tiff` and `auto` (experimental only with middleware) | 165 | | quality / q | \_ | `/quality_50/buffalo.png` | Accepted values: 0 to 100 | 166 | | rotate | [Docs](https://sharp.pixelplumbing.com/api-operation#rotate) | `/rotate_45/buffalo.png` | 167 | | enlarge | \_ | `/enlarge,s_2000x2000/buffalo.png` | Allow the image to be upscaled. By default the returned image will never be larger than the source in any dimension, while preserving the requested aspect ratio. | 168 | | flip | [Docs](https://sharp.pixelplumbing.com/api-operation#flip) | `/flip/buffalo.png` | 169 | | flop | [Docs](https://sharp.pixelplumbing.com/api-operation#flop) | `/flop/buffalo.png` | 170 | | sharpen | [Docs](https://sharp.pixelplumbing.com/api-operation#sharpen) | `/sharpen_30/buffalo.png` | 171 | | median | [Docs](https://sharp.pixelplumbing.com/api-operation#median) | `/median_10/buffalo.png` | 172 | | blur | [Docs](https://sharp.pixelplumbing.com/api-operation#blur) | `/blur_5/buffalo.png` | 173 | | gamma | [Docs](https://sharp.pixelplumbing.com/api-operation#gamma) | `/gamma_3/buffalo.png` | 174 | | negate | [Docs](https://sharp.pixelplumbing.com/api-operation#negate) | `/negate/buffalo.png` | 175 | | normalize | [Docs](https://sharp.pixelplumbing.com/api-operation#normalize) | `/normalize/buffalo.png` | 176 | | threshold | [Docs](https://sharp.pixelplumbing.com/api-operation#threshold) | `/threshold_10/buffalo.png` | 177 | | tint | [Docs](https://sharp.pixelplumbing.com/api-colour#tint) | `/tint_1098123/buffalo.png` | 178 | | grayscale | [Docs](https://sharp.pixelplumbing.com/api-colour#grayscale) | `/grayscale/buffalo.png` | 179 | | animated | - | `/animated/buffalo.gif` | Experimental | 180 | 181 | ## License 182 | 183 | [MIT](./LICENSE) 184 | 185 | 186 | 187 | [npm-version-src]: https://img.shields.io/npm/v/ipx?style=flat&colorA=18181B&colorB=F0DB4F 188 | [npm-version-href]: https://npmjs.com/package/ipx 189 | [npm-downloads-src]: https://img.shields.io/npm/dm/ipx?style=flat&colorA=18181B&colorB=F0DB4F 190 | [npm-downloads-href]: https://npmjs.com/package/ipx 191 | [github-actions-src]: https://img.shields.io/github/workflow/status/unjs/ipx/ci/main?style=flat&colorA=18181B&colorB=F0DB4F 192 | [github-actions-href]: https://github.com/unjs/ipx/actions?query=workflow%3Aci 193 | [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/ipx/main?style=flat&colorA=18181B&colorB=F0DB4F 194 | [codecov-href]: https://codecov.io/gh/unjs/ipx 195 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/ipx?style=flat&colorA=18181B&colorB=F0DB4F 196 | [bundle-href]: https://bundlephobia.com/result?p=ipx 197 | [license-src]: https://img.shields.io/github/license/unjs/ipx.svg?style=flat&colorA=18181B&colorB=F0DB4F 198 | [license-href]: https://github.com/unjs/ipx/blob/main/LICENSE 199 | -------------------------------------------------------------------------------- /bin/ipx.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "../dist/cli.mjs"; 3 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from "unbuild"; 2 | 3 | export default defineBuildConfig({ 4 | declaration: true, 5 | rollup: { 6 | emitCJS: true, 7 | }, 8 | entries: ["src/index", "src/cli"], 9 | }); 10 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unjs from "eslint-config-unjs"; 2 | 3 | // https://github.com/unjs/eslint-config 4 | export default unjs({ 5 | ignores: [ 6 | "alias" 7 | ], 8 | rules: { 9 | "unicorn/prefer-top-level-await": 0, 10 | "unicorn/no-process-exit": 0, 11 | "unicorn/prevent-abbreviations": 0, 12 | "unicorn/no-null": 0, 13 | "no-undef": 0 14 | }, 15 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ipx", 3 | "version": "3.0.3", 4 | "repository": "unjs/ipx", 5 | "description": "High performance, secure and easy-to-use image optimizer.", 6 | "license": "MIT", 7 | "exports": { 8 | ".": { 9 | "import": { 10 | "types": "./dist/index.d.mts", 11 | "default": "./dist/index.mjs" 12 | }, 13 | "require": { 14 | "types": "./dist/index.d.cts", 15 | "default": "./dist/index.cjs" 16 | } 17 | } 18 | }, 19 | "main": "./dist/index.cjs", 20 | "module": "./dist/index.mjs", 21 | "types": "./dist/index.d.ts", 22 | "bin": "./bin/ipx.mjs", 23 | "files": [ 24 | "dist", 25 | "bin" 26 | ], 27 | "scripts": { 28 | "build": "unbuild", 29 | "dev": "listhen -w playground", 30 | "ipx": "jiti ./src/cli.ts", 31 | "lint": "eslint . && prettier -c src test", 32 | "lint:fix": "eslint . --fix && prettier -w src test", 33 | "prepack": "pnpm build", 34 | "release": "pnpm test && changelogen --release --push && npm publish", 35 | "start": "node bin/ipx.js", 36 | "test": "pnpm lint && vitest run --coverage" 37 | }, 38 | "dependencies": { 39 | "@fastify/accept-negotiator": "^2.0.1", 40 | "citty": "^0.1.6", 41 | "consola": "^3.4.2", 42 | "defu": "^6.1.4", 43 | "destr": "^2.0.3", 44 | "etag": "^1.8.1", 45 | "h3": "^1.15.1", 46 | "image-meta": "^0.2.1", 47 | "listhen": "^1.9.0", 48 | "ofetch": "^1.4.1", 49 | "pathe": "^2.0.3", 50 | "sharp": "^0.33.5", 51 | "svgo": "^3.3.2", 52 | "ufo": "^1.5.4", 53 | "unstorage": "^1.15.0", 54 | "xss": "^1.0.15" 55 | }, 56 | "devDependencies": { 57 | "@types/etag": "^1.8.3", 58 | "@types/is-valid-path": "^0.1.2", 59 | "@vitest/coverage-v8": "^3.0.9", 60 | "changelogen": "^0.6.1", 61 | "eslint": "^9.22.0", 62 | "eslint-config-unjs": "^0.4.2", 63 | "jiti": "^2.4.2", 64 | "prettier": "^3.5.3", 65 | "serve-handler": "^6.1.6", 66 | "typescript": "^5.8.2", 67 | "unbuild": "^3.5.0", 68 | "vitest": "^3.0.9" 69 | }, 70 | "packageManager": "pnpm@10.6.5" 71 | } 72 | -------------------------------------------------------------------------------- /playground.ts: -------------------------------------------------------------------------------- 1 | import { createIPX, createIPXH3App, ipxFSStorage, ipxHttpStorage } from "./src"; 2 | 3 | const ipx = createIPX({ 4 | storage: ipxFSStorage(), 5 | alias: { 6 | "/picsum": "https://picsum.photos", 7 | }, 8 | httpStorage: ipxHttpStorage({ 9 | domains: ["picsum.photos", "images.unsplash.com"], 10 | }), 11 | }); 12 | 13 | export default createIPXH3App(ipx); 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { listen } from "listhen"; 2 | import { defineCommand, runMain } from "citty"; 3 | import { 4 | getArgs as listhenArgs, 5 | parseArgs as parseListhenArgs, 6 | } from "listhen/cli"; 7 | import { name, version, description } from "../package.json"; 8 | import { createIPX } from "./ipx"; 9 | import { createIPXNodeServer } from "./server"; 10 | import { ipxFSStorage } from "./storage/node-fs"; 11 | import { ipxHttpStorage } from "./storage/http"; 12 | 13 | const serve = defineCommand({ 14 | meta: { 15 | description: "Start IPX Server", 16 | }, 17 | args: { 18 | dir: { 19 | type: "string", 20 | required: false, 21 | description: 22 | "Directory to serve (default: current directory) ENV: IPX_FS_DIR", 23 | }, 24 | domains: { 25 | type: "string", 26 | required: false, 27 | description: "Allowed domains (comma separated) ENV: IPX_HTTP_DOMAINS", 28 | }, 29 | ...listhenArgs(), 30 | }, 31 | async run({ args }) { 32 | const ipx = createIPX({ 33 | storage: ipxFSStorage({ 34 | dir: args.dir, 35 | }), 36 | httpStorage: ipxHttpStorage({ 37 | domains: args.domains, 38 | }), 39 | }); 40 | await listen(createIPXNodeServer(ipx), { 41 | name: "IPX", 42 | ...parseListhenArgs(args), 43 | }); 44 | }, 45 | }); 46 | 47 | const main = defineCommand({ 48 | meta: { 49 | name, 50 | version, 51 | description, 52 | }, 53 | subCommands: { 54 | serve, 55 | }, 56 | }); 57 | 58 | runMain(main); 59 | -------------------------------------------------------------------------------- /src/handlers/handlers.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "../types"; 2 | import { 3 | clampDimensionsPreservingAspectRatio, 4 | VArg as VArgument, 5 | } from "./utils"; 6 | 7 | // --------- Context Modifers --------- 8 | 9 | export const quality: Handler = { 10 | args: [VArgument], 11 | order: -1, 12 | apply: (context, _pipe, quality) => { 13 | context.quality = quality; 14 | }, 15 | }; 16 | 17 | // https://sharp.pixelplumbing.com/api-resize#resize 18 | export const fit: Handler = { 19 | args: [VArgument], 20 | order: -1, 21 | apply: (context, _pipe, fit) => { 22 | context.fit = fit; 23 | }, 24 | }; 25 | 26 | // https://sharp.pixelplumbing.com/api-resize#resize 27 | export const position: Handler = { 28 | args: [VArgument], 29 | order: -1, 30 | apply: (context, _pipe, position) => { 31 | context.position = position; 32 | }, 33 | }; 34 | 35 | const HEX_RE = /^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; 36 | const SHORTHEX_RE = /^([\da-f])([\da-f])([\da-f])$/i; 37 | export const background: Handler = { 38 | args: [VArgument], 39 | order: -1, 40 | apply: (context, _pipe, background) => { 41 | background = String(background); 42 | if ( 43 | !background.startsWith("#") && 44 | (HEX_RE.test(background) || SHORTHEX_RE.test(background)) 45 | ) { 46 | background = "#" + background; 47 | } 48 | context.background = background; 49 | }, 50 | }; 51 | 52 | // --------- Resize --------- 53 | 54 | export const enlarge: Handler = { 55 | args: [], 56 | apply: (context) => { 57 | context.enlarge = true; 58 | }, 59 | }; 60 | 61 | export const kernel: Handler = { 62 | args: [VArgument], 63 | apply: (context, _pipe, kernel) => { 64 | context.kernel = kernel; 65 | }, 66 | }; 67 | 68 | export const width: Handler = { 69 | args: [VArgument], 70 | apply: (context, pipe, width) => { 71 | return pipe.resize(width, undefined, { 72 | withoutEnlargement: !context.enlarge, 73 | }); 74 | }, 75 | }; 76 | 77 | export const height: Handler = { 78 | args: [VArgument], 79 | apply: (context, pipe, height) => { 80 | return pipe.resize(undefined, height, { 81 | withoutEnlargement: !context.enlarge, 82 | }); 83 | }, 84 | }; 85 | 86 | export const resize: Handler = { 87 | args: [VArgument, VArgument, VArgument], 88 | apply: (context, pipe, size) => { 89 | let [width, height] = String(size).split("x").map(Number); 90 | if (!width) { 91 | return; 92 | } 93 | if (!height) { 94 | height = width; 95 | } 96 | // sharp's `withoutEnlargement` doesn't respect the requested aspect ratio, so we need to do it ourselves 97 | if (!context.enlarge) { 98 | const clamped = clampDimensionsPreservingAspectRatio(context.meta, { 99 | width, 100 | height, 101 | }); 102 | width = clamped.width; 103 | height = clamped.height; 104 | } 105 | return pipe.resize(width, height, { 106 | fit: context.fit, 107 | position: context.position, 108 | background: context.background, 109 | kernel: context.kernel, 110 | }); 111 | }, 112 | }; 113 | 114 | // https://sharp.pixelplumbing.com/api-resize#trim 115 | export const trim: Handler = { 116 | args: [VArgument], 117 | apply: (_context, pipe, threshold) => { 118 | return pipe.trim(threshold); 119 | }, 120 | }; 121 | 122 | // https://sharp.pixelplumbing.com/api-resize#extend 123 | export const extend: Handler = { 124 | args: [VArgument, VArgument, VArgument, VArgument], 125 | apply: (context, pipe, top, right, bottom, left) => { 126 | return pipe.extend({ 127 | top, 128 | left, 129 | bottom, 130 | right, 131 | background: context.background, 132 | }); 133 | }, 134 | }; 135 | 136 | // https://sharp.pixelplumbing.com/api-resize#extract 137 | export const extract: Handler = { 138 | args: [VArgument, VArgument, VArgument, VArgument], 139 | apply: (_context, pipe, left, top, width, height) => { 140 | return pipe.extract({ 141 | left, 142 | top, 143 | width, 144 | height, 145 | }); 146 | }, 147 | }; 148 | 149 | // --------- Operations --------- 150 | 151 | // https://sharp.pixelplumbing.com/api-operation#rotate 152 | export const rotate: Handler = { 153 | args: [VArgument], 154 | apply: (context, pipe, angel) => { 155 | return pipe.rotate(angel, { 156 | background: context.background, 157 | }); 158 | }, 159 | }; 160 | 161 | // https://sharp.pixelplumbing.com/api-operation#flip 162 | export const flip: Handler = { 163 | args: [], 164 | apply: (_context, pipe) => { 165 | return pipe.flip(); 166 | }, 167 | }; 168 | 169 | // https://sharp.pixelplumbing.com/api-operation#flop 170 | export const flop: Handler = { 171 | args: [], 172 | apply: (_context, pipe) => { 173 | return pipe.flop(); 174 | }, 175 | }; 176 | 177 | // https://sharp.pixelplumbing.com/api-operation#sharpen 178 | export const sharpen: Handler = { 179 | args: [VArgument, VArgument, VArgument], 180 | apply: (_context, pipe, sigma, flat, jagged) => { 181 | return pipe.sharpen(sigma, flat, jagged); 182 | }, 183 | }; 184 | 185 | // https://sharp.pixelplumbing.com/api-operation#median 186 | export const median: Handler = { 187 | args: [VArgument, VArgument, VArgument], 188 | apply: (_context, pipe, size) => { 189 | return pipe.median(size); 190 | }, 191 | }; 192 | 193 | // https://sharp.pixelplumbing.com/api-operation#blur 194 | export const blur: Handler = { 195 | args: [VArgument, VArgument, VArgument], 196 | apply: (_context, pipe, sigma) => { 197 | return pipe.blur(sigma); 198 | }, 199 | }; 200 | 201 | // https://sharp.pixelplumbing.com/api-operation#flatten 202 | // TODO: Support background 203 | export const flatten: Handler = { 204 | args: [VArgument, VArgument, VArgument], 205 | apply: (context, pipe) => { 206 | return pipe.flatten({ 207 | background: context.background, 208 | }); 209 | }, 210 | }; 211 | 212 | // https://sharp.pixelplumbing.com/api-operation#gamma 213 | export const gamma: Handler = { 214 | args: [VArgument, VArgument, VArgument], 215 | apply: (_context, pipe, gamma, gammaOut) => { 216 | return pipe.gamma(gamma, gammaOut); 217 | }, 218 | }; 219 | 220 | // https://sharp.pixelplumbing.com/api-operation#negate 221 | export const negate: Handler = { 222 | args: [VArgument, VArgument, VArgument], 223 | apply: (_context, pipe) => { 224 | return pipe.negate(); 225 | }, 226 | }; 227 | 228 | // https://sharp.pixelplumbing.com/api-operation#normalize 229 | export const normalize: Handler = { 230 | args: [VArgument, VArgument, VArgument], 231 | apply: (_context, pipe) => { 232 | return pipe.normalize(); 233 | }, 234 | }; 235 | 236 | // https://sharp.pixelplumbing.com/api-operation#threshold 237 | export const threshold: Handler = { 238 | args: [VArgument], 239 | apply: (_context, pipe, threshold) => { 240 | return pipe.threshold(threshold); 241 | }, 242 | }; 243 | 244 | // https://sharp.pixelplumbing.com/api-operation#modulate 245 | export const modulate: Handler = { 246 | args: [VArgument], 247 | apply: (_context, pipe, brightness, saturation, hue) => { 248 | return pipe.modulate({ 249 | brightness, 250 | saturation, 251 | hue, 252 | }); 253 | }, 254 | }; 255 | 256 | // --------- Colour Manipulation --------- 257 | 258 | // https://sharp.pixelplumbing.com/api-colour#tint 259 | export const tint: Handler = { 260 | args: [VArgument], 261 | apply: (_context, pipe, rgb) => { 262 | return pipe.tint(rgb); 263 | }, 264 | }; 265 | 266 | // https://sharp.pixelplumbing.com/api-colour#grayscale 267 | export const grayscale: Handler = { 268 | args: [VArgument], 269 | apply: (_context, pipe) => { 270 | return pipe.grayscale(); 271 | }, 272 | }; 273 | 274 | // --------- Aliases --------- 275 | 276 | export const crop = extract; 277 | export const q = quality; 278 | export const b = background; 279 | export const w = width; 280 | export const h = height; 281 | export const s = resize; 282 | export const pos = position; 283 | -------------------------------------------------------------------------------- /src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./handlers"; 2 | export * from "./utils"; 3 | -------------------------------------------------------------------------------- /src/handlers/utils.ts: -------------------------------------------------------------------------------- 1 | import destr from "destr"; 2 | import type { Sharp } from "sharp"; 3 | import type { ImageMeta } from "image-meta"; 4 | import type { Handler, HandlerContext } from "../types"; 5 | import * as Handlers from "./handlers"; 6 | 7 | export function VArg(argument: string) { 8 | return destr(argument); 9 | } 10 | 11 | export function parseArgs( 12 | arguments_: string, 13 | mappers: ((...args: any[]) => any)[], 14 | ) { 15 | const vargs = arguments_.split("_"); 16 | return mappers.map((v, index) => v(vargs[index])); 17 | } 18 | 19 | export type HandlerName = keyof typeof Handlers; 20 | 21 | export function getHandler(key: HandlerName): Handler { 22 | return Handlers[key]; 23 | } 24 | 25 | export function applyHandler( 26 | context: HandlerContext, 27 | pipe: Sharp, 28 | handler: Handler, 29 | argumentsString: string, 30 | ) { 31 | const arguments_ = handler.args 32 | ? parseArgs(argumentsString, handler.args) 33 | : []; 34 | return handler.apply(context, pipe, ...arguments_); 35 | } 36 | 37 | export function clampDimensionsPreservingAspectRatio( 38 | sourceDimensions: ImageMeta, 39 | desiredDimensions: { width: number; height: number }, 40 | ) { 41 | const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height; 42 | let { width, height } = desiredDimensions; 43 | if (sourceDimensions.width && width > sourceDimensions.width) { 44 | width = sourceDimensions.width; 45 | height = Math.round(sourceDimensions.width / desiredAspectRatio); 46 | } 47 | if (sourceDimensions.height && height > sourceDimensions.height) { 48 | height = sourceDimensions.height; 49 | width = Math.round(sourceDimensions.height * desiredAspectRatio); 50 | } 51 | 52 | return { width, height }; 53 | } 54 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ipx"; 2 | export * from "./server"; 3 | export * from "./types"; 4 | export * from "./storage/http"; 5 | export * from "./storage/node-fs"; 6 | export * from "./storage/unstorage"; 7 | -------------------------------------------------------------------------------- /src/ipx.ts: -------------------------------------------------------------------------------- 1 | import { defu } from "defu"; 2 | import { hasProtocol, joinURL, withLeadingSlash } from "ufo"; 3 | import type { SharpOptions } from "sharp"; 4 | import { createError } from "h3"; 5 | import { imageMeta as getImageMeta, type ImageMeta } from "image-meta"; 6 | import type { Config as SVGOConfig } from "svgo"; 7 | import type { IPXStorage } from "./types"; 8 | import { HandlerName, applyHandler, getHandler } from "./handlers"; 9 | import { cachedPromise, getEnv } from "./utils"; 10 | 11 | type IPXSourceMeta = { 12 | /** 13 | * The modification time of the source. Used for cache validation. 14 | * @optional 15 | */ 16 | mtime?: Date; 17 | 18 | /** 19 | * The maximum age (in seconds) that the source should be considered fresh. 20 | * @optional 21 | */ 22 | maxAge?: number; 23 | }; 24 | 25 | /** 26 | * A function type that defines an IPX image processing instance. 27 | * 28 | * This function takes an image identifier and optional modifiers and request options, then provides methods to retrieve 29 | * image metadata and process the image according to the specified modifiers. 30 | * 31 | * @param {string} id - The identifier for the image. This can be a URL or a path, depending on the storage implementation. 32 | * @param {partial>} [modifiers] - Modifiers to be applied to the image, 33 | * such as resizing, cropping or format conversion. This record contains predefined keys such as 'f' or 'format' to specify the output to 34 | * specify the output image format, and 'a' or 'animated' to specify whether the image should be processed as an animation. See 35 | * {@link HandlerName}. 36 | * @param {any} [requestOptions] - Additional options that may be needed for request handling, specific to the storage backend. 37 | * Returns an object with methods: 38 | * - `getSourceMeta`: A method that returns a promise resolving to the source image metadata (`IPXSourceMeta`). 39 | * - `process`: A method that returns a promise resolving to an object containing the processed image data, metadata, 40 | * and format. The image data can be in the form of a `buffer` or a string, depending on the format and processing. 41 | */ 42 | export type IPX = ( 43 | id: string, 44 | modifiers?: Partial< 45 | Record 46 | >, 47 | requestOptions?: any, 48 | ) => { 49 | getSourceMeta: () => Promise; 50 | process: () => Promise<{ 51 | data: Buffer | string; 52 | meta?: ImageMeta; 53 | format?: string; 54 | }>; 55 | }; 56 | 57 | export type IPXOptions = { 58 | /** 59 | * Default cache duration in seconds. If not specified, a default of 1 minute is used. 60 | * @optional 61 | */ 62 | maxAge?: number; 63 | 64 | /** 65 | * A mapping of URL aliases to their corresponding URLs, used to simplify resource identifiers. 66 | * @optional 67 | */ 68 | alias?: Record; 69 | 70 | /** 71 | * Configuration options for the Sharp image processing library. 72 | * @optional 73 | */ 74 | sharpOptions?: SharpOptions; 75 | 76 | /** 77 | * Primary storage backend for handling image assets. 78 | */ 79 | storage: IPXStorage; 80 | 81 | /** 82 | * An optional secondary storage backend used when images are fetched via HTTP. 83 | * @optional 84 | */ 85 | httpStorage?: IPXStorage; 86 | 87 | /** 88 | * Configuration for the SVGO library used when processing SVG images. 89 | * @optional 90 | */ 91 | svgo?: false | SVGOConfig; 92 | }; 93 | 94 | // https://sharp.pixelplumbing.com/#formats 95 | // (gif and svg are not supported as output) 96 | const SUPPORTED_FORMATS = new Set([ 97 | "jpeg", 98 | "png", 99 | "webp", 100 | "avif", 101 | "tiff", 102 | "heif", 103 | "gif", 104 | "heic", 105 | ]); 106 | 107 | /** 108 | * Creates an IPX image processing instance with the specified options. 109 | * @param {IPXOptions} userOptions - Configuration options for the IPX instance. See {@link IPXOptions}. 110 | * @returns {IPX} An IPX processing function configured with the given options. See {@link IPX}. 111 | * @throws {Error} If critical options such as storage are missing or incorrectly configured. 112 | */ 113 | export function createIPX(userOptions: IPXOptions): IPX { 114 | const options: IPXOptions = defu(userOptions, { 115 | alias: getEnv>("IPX_ALIAS") || {}, 116 | maxAge: getEnv("IPX_MAX_AGE") ?? 60 /* 1 minute */, 117 | sharpOptions: { 118 | jpegProgressive: true, 119 | }, 120 | } satisfies Omit); 121 | 122 | // Normalize alias to start with leading slash 123 | options.alias = Object.fromEntries( 124 | Object.entries(options.alias || {}).map((e) => [ 125 | withLeadingSlash(e[0]), 126 | e[1], 127 | ]), 128 | ); 129 | 130 | // Sharp loader 131 | const getSharp = cachedPromise(async () => { 132 | return (await import("sharp").then( 133 | (r) => r.default || r, 134 | )) as typeof import("sharp"); 135 | }); 136 | 137 | const getSVGO = cachedPromise(async () => { 138 | const { optimize } = await import("svgo"); 139 | return { optimize }; 140 | }); 141 | 142 | return function ipx(id, modifiers = {}, opts = {}) { 143 | // Validate id 144 | if (!id) { 145 | throw createError({ 146 | statusCode: 400, 147 | statusText: `IPX_MISSING_ID`, 148 | message: `Resource id is missing`, 149 | }); 150 | } 151 | 152 | // Enforce leading slash for non absolute urls 153 | id = hasProtocol(id) ? id : withLeadingSlash(id); 154 | 155 | // Resolve alias 156 | for (const base in options.alias) { 157 | if (id.startsWith(base)) { 158 | id = joinURL(options.alias[base], id.slice(base.length)); 159 | } 160 | } 161 | 162 | // Resolve Storage 163 | const storage = hasProtocol(id) 164 | ? options.httpStorage || options.storage 165 | : options.storage || options.httpStorage; 166 | if (!storage) { 167 | throw createError({ 168 | statusCode: 500, 169 | statusText: `IPX_NO_STORAGE`, 170 | message: "No storage configured!", 171 | }); 172 | } 173 | 174 | // Resolve Resource 175 | const getSourceMeta = cachedPromise(async () => { 176 | const sourceMeta = await storage.getMeta(id, opts); 177 | if (!sourceMeta) { 178 | throw createError({ 179 | statusCode: 404, 180 | statusText: `IPX_RESOURCE_NOT_FOUND`, 181 | message: `Resource not found: ${id}`, 182 | }); 183 | } 184 | const _maxAge = sourceMeta.maxAge ?? options.maxAge; 185 | return { 186 | maxAge: 187 | typeof _maxAge === "string" ? Number.parseInt(_maxAge) : _maxAge, 188 | mtime: sourceMeta.mtime ? new Date(sourceMeta.mtime) : undefined, 189 | } satisfies IPXSourceMeta; 190 | }); 191 | const getSourceData = cachedPromise(async () => { 192 | const sourceData = await storage.getData(id, opts); 193 | if (!sourceData) { 194 | throw createError({ 195 | statusCode: 404, 196 | statusText: `IPX_RESOURCE_NOT_FOUND`, 197 | message: `Resource not found: ${id}`, 198 | }); 199 | } 200 | return Buffer.from(sourceData); 201 | }); 202 | 203 | const process = cachedPromise(async () => { 204 | // const _sourceMeta = await getSourceMeta(); 205 | const sourceData = await getSourceData(); 206 | 207 | // Detect source image meta 208 | let imageMeta: ImageMeta; 209 | try { 210 | imageMeta = getImageMeta(sourceData) as ImageMeta; 211 | } catch { 212 | throw createError({ 213 | statusCode: 400, 214 | statusText: `IPX_INVALID_IMAGE`, 215 | message: `Cannot parse image metadata: ${id}`, 216 | }); 217 | } 218 | 219 | // Determine format 220 | let mFormat = modifiers.f || modifiers.format; 221 | if (mFormat === "jpg") { 222 | mFormat = "jpeg"; 223 | } 224 | const format = 225 | mFormat && SUPPORTED_FORMATS.has(mFormat) 226 | ? mFormat 227 | : SUPPORTED_FORMATS.has(imageMeta.type || "") // eslint-disable-line unicorn/no-nested-ternary 228 | ? imageMeta.type 229 | : "jpeg"; 230 | 231 | // Use original SVG if format is not specified 232 | if (imageMeta.type === "svg" && !mFormat) { 233 | if (options.svgo === false) { 234 | return { 235 | data: sourceData, 236 | format: "svg+xml", 237 | meta: imageMeta, 238 | }; 239 | } else { 240 | // https://github.com/svg/svgo 241 | const { optimize } = await getSVGO(); 242 | const svg = optimize(sourceData.toString("utf8"), { 243 | ...options.svgo, 244 | plugins: ["removeScriptElement", ...(options.svgo?.plugins || [])], 245 | }).data; 246 | return { 247 | data: svg, 248 | format: "svg+xml", 249 | meta: imageMeta, 250 | }; 251 | } 252 | } 253 | 254 | // Experimental animated support 255 | // https://github.com/lovell/sharp/issues/2275 256 | const animated = 257 | modifiers.animated !== undefined || 258 | modifiers.a !== undefined || 259 | format === "gif"; 260 | 261 | const Sharp = await getSharp(); 262 | let sharp = Sharp(sourceData, { animated, ...options.sharpOptions }); 263 | Object.assign( 264 | (sharp as unknown as { options: SharpOptions }).options, 265 | options.sharpOptions, 266 | ); 267 | 268 | // Resolve modifiers to handlers and sort 269 | const handlers = Object.entries(modifiers) 270 | .map(([name, arguments_]) => ({ 271 | handler: getHandler(name as HandlerName), 272 | name, 273 | args: arguments_, 274 | })) 275 | .filter((h) => h.handler) 276 | .sort((a, b) => { 277 | const aKey = (a.handler.order || a.name || "").toString(); 278 | const bKey = (b.handler.order || b.name || "").toString(); 279 | return aKey.localeCompare(bKey); 280 | }); 281 | 282 | // Apply handlers 283 | const handlerContext: any = { meta: imageMeta }; 284 | for (const h of handlers) { 285 | sharp = applyHandler(handlerContext, sharp, h.handler, h.args) || sharp; 286 | } 287 | 288 | // Apply format 289 | if (SUPPORTED_FORMATS.has(format || "")) { 290 | sharp = sharp.toFormat(format as any, { 291 | quality: handlerContext.quality, 292 | }); 293 | } 294 | 295 | // Convert to buffer 296 | const processedImage = await sharp.toBuffer(); 297 | 298 | return { 299 | data: processedImage, 300 | format, 301 | meta: imageMeta, 302 | }; 303 | }); 304 | 305 | return { 306 | getSourceMeta, 307 | process, 308 | }; 309 | }; 310 | } 311 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { negotiate } from "@fastify/accept-negotiator"; 2 | import { decode } from "ufo"; 3 | import getEtag from "etag"; 4 | import { 5 | defineEventHandler, 6 | getRequestHeader, 7 | setResponseHeader, 8 | setResponseStatus, 9 | createApp, 10 | toNodeListener, 11 | toPlainHandler, 12 | toWebHandler, 13 | createError, 14 | H3Event, 15 | H3Error, 16 | send, 17 | appendResponseHeader, 18 | getResponseHeader, 19 | } from "h3"; 20 | import { IPX } from "./ipx"; 21 | 22 | const MODIFIER_SEP = /[&,]/g; 23 | const MODIFIER_VAL_SEP = /[:=_]/; 24 | 25 | /** 26 | * Creates an H3 handler to handle images using IPX. 27 | * @param {IPX} ipx - An IPX instance to handle image requests. 28 | * @returns {H3Event} An H3 event handler that processes image requests, applies modifiers, handles caching, 29 | * and returns the processed image data. See {@link H3Event}. 30 | * @throws {H3Error} If there are problems with the request parameters or processing the image. See {@link H3Error}. 31 | */ 32 | export function createIPXH3Handler(ipx: IPX) { 33 | const _handler = async (event: H3Event) => { 34 | // Parse URL 35 | const [modifiersString = "", ...idSegments] = event.path 36 | .slice(1 /* leading slash */) 37 | .split("/"); 38 | 39 | const id = safeString(decode(idSegments.join("/"))); 40 | 41 | // Validate 42 | if (!modifiersString) { 43 | throw createError({ 44 | statusCode: 400, 45 | statusText: `IPX_MISSING_MODIFIERS`, 46 | message: `Modifiers are missing: ${id}`, 47 | }); 48 | } 49 | if (!id || id === "/") { 50 | throw createError({ 51 | statusCode: 400, 52 | statusText: `IPX_MISSING_ID`, 53 | message: `Resource id is missing: ${event.path}`, 54 | }); 55 | } 56 | 57 | // Contruct modifiers 58 | const modifiers: Record = Object.create(null); 59 | 60 | // Read modifiers from first segment 61 | if (modifiersString !== "_") { 62 | for (const p of modifiersString.split(MODIFIER_SEP)) { 63 | const [key, ...values] = p.split(MODIFIER_VAL_SEP); 64 | modifiers[safeString(key)] = values 65 | .map((v) => safeString(decode(v))) 66 | .join("_"); 67 | } 68 | } 69 | 70 | // Auto format 71 | const mFormat = modifiers.f || modifiers.format; 72 | if (mFormat === "auto") { 73 | const acceptHeader = getRequestHeader(event, "accept") || ""; 74 | const animated = modifiers.animated ?? modifiers.a; 75 | const autoFormat = autoDetectFormat( 76 | acceptHeader, 77 | // #234 "animated" param adds {animated: ''} to the modifiers 78 | // TODO: fix modifiers to normalized to boolean 79 | !!animated || animated === "", 80 | ); 81 | delete modifiers.f; 82 | delete modifiers.format; 83 | if (autoFormat) { 84 | modifiers.format = autoFormat; 85 | appendResponseHeader(event, "vary", "Accept"); 86 | } 87 | } 88 | 89 | // Create request 90 | const img = ipx(id, modifiers); 91 | 92 | // Get image meta from source 93 | const sourceMeta = await img.getSourceMeta(); 94 | 95 | // Send CSP headers to prevent XSS 96 | sendResponseHeaderIfNotSet( 97 | event, 98 | "content-security-policy", 99 | "default-src 'none'", 100 | ); 101 | 102 | // Handle modified time if available 103 | if (sourceMeta.mtime) { 104 | // Send Last-Modified header 105 | sendResponseHeaderIfNotSet( 106 | event, 107 | "last-modified", 108 | sourceMeta.mtime.toUTCString(), 109 | ); 110 | 111 | // Check for last-modified request header 112 | const _ifModifiedSince = getRequestHeader(event, "if-modified-since"); 113 | if (_ifModifiedSince && new Date(_ifModifiedSince) >= sourceMeta.mtime) { 114 | setResponseStatus(event, 304); 115 | return send(event); 116 | } 117 | } 118 | 119 | // Process image 120 | const { data, format } = await img.process(); 121 | 122 | // Send Cache-Control header 123 | if (typeof sourceMeta.maxAge === "number") { 124 | sendResponseHeaderIfNotSet( 125 | event, 126 | "cache-control", 127 | `max-age=${+sourceMeta.maxAge}, public, s-maxage=${+sourceMeta.maxAge}`, 128 | ); 129 | } 130 | 131 | // Generate and send ETag header 132 | const etag = getEtag(data); 133 | sendResponseHeaderIfNotSet(event, "etag", etag); 134 | 135 | // Check for if-none-match request header 136 | if (etag && getRequestHeader(event, "if-none-match") === etag) { 137 | setResponseStatus(event, 304); 138 | return send(event); 139 | } 140 | 141 | // Content-Type header 142 | if (format) { 143 | sendResponseHeaderIfNotSet(event, "content-type", `image/${format}`); 144 | } 145 | 146 | return data; 147 | }; 148 | 149 | return defineEventHandler(async (event) => { 150 | try { 151 | return await _handler(event); 152 | } catch (_error: unknown) { 153 | const error = createError(_error as H3Error); 154 | setResponseStatus(event, error.statusCode, error.statusMessage); 155 | return { 156 | error: { 157 | message: `[${error.statusCode}] [${ 158 | error.statusMessage || "IPX_ERROR" 159 | }] ${error.message}`, 160 | }, 161 | }; 162 | } 163 | }); 164 | } 165 | 166 | /** 167 | * Creates an H3 application configured to handle image processing using a supplied IPX instance. 168 | * @param {IPX} ipx - An IPX instance to handle image handling requests. 169 | * @returns {any} An H3 application configured to use the IPX image handler. 170 | */ 171 | export function createIPXH3App(ipx: IPX) { 172 | const app = createApp({ debug: true }); 173 | app.use(createIPXH3Handler(ipx)); 174 | return app; 175 | } 176 | 177 | /** 178 | * Creates a web server that can handle IPX image processing requests using an H3 application. 179 | * @param {IPX} ipx - An IPX instance configured for the server. See {@link IPX}. 180 | * @returns {any} A web handler suitable for use with web server environments that support the H3 library. 181 | */ 182 | export function createIPXWebServer(ipx: IPX) { 183 | return toWebHandler(createIPXH3App(ipx)); 184 | } 185 | 186 | /** 187 | * Creates a web server that can handle IPX image processing requests using an H3 application. 188 | * @param {IPX} ipx - An IPX instance configured for the server. See {@link IPX}. 189 | * @returns {any} A web handler suitable for use with web server environments that support the H3 library. 190 | */ 191 | export function createIPXNodeServer(ipx: IPX) { 192 | return toNodeListener(createIPXH3App(ipx)); 193 | } 194 | 195 | /** 196 | * Creates a simple server that can handle IPX image processing requests using an H3 application. 197 | * @param {IPX} ipx - An IPX instance configured for the server. 198 | * @returns {any} A handler suitable for plain HTTP server environments that support the H3 library. 199 | */ 200 | export function createIPXPlainServer(ipx: IPX) { 201 | return toPlainHandler(createIPXH3App(ipx)); 202 | } 203 | 204 | // --- Utils --- 205 | 206 | function sendResponseHeaderIfNotSet(event: H3Event, name: string, value: any) { 207 | if (!getResponseHeader(event, name)) { 208 | setResponseHeader(event, name, value); 209 | } 210 | } 211 | 212 | function autoDetectFormat(acceptHeader: string, animated: boolean) { 213 | if (animated) { 214 | const acceptMime = negotiate(acceptHeader, ["image/webp", "image/gif"]); 215 | return acceptMime?.split("/")[1] || "gif"; 216 | } 217 | const acceptMime = negotiate(acceptHeader, [ 218 | "image/avif", 219 | "image/webp", 220 | "image/jpeg", 221 | "image/png", 222 | "image/tiff", 223 | "image/heif", 224 | "image/gif", 225 | ]); 226 | return acceptMime?.split("/")[1] || "jpeg"; 227 | } 228 | 229 | function safeString(input: string) { 230 | return JSON.stringify(input) 231 | .replace(/^"|"$/g, "") 232 | .replace(/\\+/g, "\\") 233 | .replace(/\\"/g, '"'); 234 | } 235 | -------------------------------------------------------------------------------- /src/storage/http.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | import { createError } from "h3"; 3 | import { getEnv } from "../utils"; 4 | import type { IPXStorage } from "../types"; 5 | 6 | export type HTTPStorageOptions = { 7 | /** 8 | * Custom options for fetch operations, such as headers or method overrides. 9 | * @optional 10 | */ 11 | fetchOptions?: RequestInit; 12 | 13 | /** 14 | * Default maximum age (in seconds) for cache control. If not specified, defaults to the environment setting or 300 seconds. 15 | * @optional 16 | */ 17 | maxAge?: number; 18 | 19 | /** 20 | * Whitelist of domains from which resource fetching is allowed. Can be a single string or an array of strings. 21 | * @optional 22 | */ 23 | domains?: string | string[]; 24 | 25 | /** 26 | * If set to true, allows retrieval from any domain. Overrides the domain whitelist. 27 | * @optional 28 | */ 29 | allowAllDomains?: boolean; 30 | 31 | /** 32 | * If set to true, ignore the cache control header in responses and use the default or specified maxAge. 33 | * @optional 34 | */ 35 | ignoreCacheControl?: boolean; 36 | }; 37 | 38 | const HTTP_RE = /^https?:\/\//; 39 | 40 | /** 41 | * Creates an HTTP storage handler for IPX that fetches image data from external URLs. 42 | * This handler allows configuration to specify allowed domains, caching behaviour and custom fetch options. 43 | * 44 | * @param {HTTPStorageOptions} [_options={}] - Configuration options for HTTP storage, with defaults possibly taken from environment variables. See {@link HTTPStorageOptions}. 45 | * @returns {IPXStorage} An IPXStorage interface implementation for retrieving images over HTTP. See {@link IPXStorage}. 46 | * @throws {H3Error} If validation of the requested URL fails due to a missing hostname or denied host access. See {@link H3Error}. 47 | */ 48 | export function ipxHttpStorage(_options: HTTPStorageOptions = {}): IPXStorage { 49 | const allowAllDomains = 50 | _options.allowAllDomains ?? getEnv("IPX_HTTP_ALLOW_ALL_DOMAINS") ?? false; 51 | let _domains = 52 | _options.domains || getEnv("IPX_HTTP_DOMAINS") || []; 53 | const defaultMaxAge = 54 | _options.maxAge || getEnv("IPX_HTTP_MAX_AGE") || 300; 55 | const fetchOptions = 56 | _options.fetchOptions || getEnv("IPX_HTTP_FETCH_OPTIONS") || {}; 57 | 58 | if (typeof _domains === "string") { 59 | _domains = _domains.split(",").map((s) => s.trim()); 60 | } 61 | 62 | const domains = new Set( 63 | _domains 64 | .map((d) => { 65 | if (!HTTP_RE.test(d)) { 66 | d = "http://" + d; 67 | } 68 | return new URL(d).hostname; 69 | }) 70 | .filter(Boolean), 71 | ); 72 | 73 | function validateId(id: string) { 74 | const url = new URL(decodeURIComponent(id)); 75 | if (!url.hostname) { 76 | throw createError({ 77 | statusCode: 403, 78 | statusText: `IPX_MISSING_HOSTNAME`, 79 | message: `Hostname is missing: ${id}`, 80 | }); 81 | } 82 | if (!allowAllDomains && !domains.has(url.hostname)) { 83 | throw createError({ 84 | statusCode: 403, 85 | statusText: `IPX_FORBIDDEN_HOST`, 86 | message: `Forbidden host: ${url.hostname}`, 87 | }); 88 | } 89 | return url.toString(); 90 | } 91 | 92 | function parseResponse(response: Response) { 93 | let maxAge = defaultMaxAge; 94 | if (_options.ignoreCacheControl !== true) { 95 | const _cacheControl = response.headers.get("cache-control"); 96 | if (_cacheControl) { 97 | const m = _cacheControl.match(/max-age=(\d+)/); 98 | if (m && m[1]) { 99 | maxAge = Number.parseInt(m[1]); 100 | } 101 | } 102 | } 103 | 104 | let mtime; 105 | const _lastModified = response.headers.get("last-modified"); 106 | if (_lastModified) { 107 | mtime = new Date(_lastModified); 108 | } 109 | 110 | return { maxAge, mtime }; 111 | } 112 | 113 | return { 114 | name: "ipx:http", 115 | async getMeta(id) { 116 | const url = validateId(id); 117 | try { 118 | const response = await ofetch.raw(url, { 119 | ...fetchOptions, 120 | method: "HEAD", 121 | }); 122 | const { maxAge, mtime } = parseResponse(response); 123 | return { mtime, maxAge }; 124 | } catch { 125 | return {}; 126 | } 127 | }, 128 | async getData(id) { 129 | const url = validateId(id); 130 | const response = await ofetch(url, { 131 | ...fetchOptions, 132 | method: "GET", 133 | responseType: "arrayBuffer", 134 | }); 135 | return response; 136 | }, 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/storage/node-fs.ts: -------------------------------------------------------------------------------- 1 | import { resolve, parse, join } from "pathe"; 2 | import { createError } from "h3"; 3 | import { cachedPromise, getEnv } from "../utils"; 4 | import type { IPXStorage } from "../types"; 5 | 6 | export type NodeFSSOptions = { 7 | /** 8 | * The directory or list of directories from which to serve files. If not specified, the current directory is used by default. 9 | * @optional 10 | */ 11 | dir?: string | string[]; 12 | 13 | /** 14 | * The directory or list of directories from which to serve files. If not specified, the current directory is used by default. 15 | * @optional 16 | */ 17 | maxAge?: number; 18 | }; 19 | 20 | /** 21 | * Creates a file system storage handler for IPX that allows images to be served from local directories specified in the options. 22 | * This handler resolves directories and handles file access, ensuring that files are served safely. 23 | * 24 | * @param {NodeFSSOptions} [_options={}] - File system storage configuration options, with optional directory paths and caching configuration. See {@link NodeFSSOptions}. 25 | * @returns {IPXStorage} An implementation of the IPXStorage interface for accessing images stored on the local file system. See {@link IPXStorage}. 26 | * @throws {H3Error} If there is a problem accessing the file system module or resolving/reading files. See {@link H3Error}. 27 | */ 28 | export function ipxFSStorage(_options: NodeFSSOptions = {}): IPXStorage { 29 | const dirs = resolveDirs(_options.dir); 30 | const maxAge = _options.maxAge || getEnv("IPX_FS_MAX_AGE"); 31 | 32 | const _getFS = cachedPromise(() => 33 | import("node:fs/promises").catch(() => { 34 | throw createError({ 35 | statusCode: 500, 36 | statusText: `IPX_FILESYSTEM_ERROR`, 37 | message: `Failed to resolve filesystem module`, 38 | }); 39 | }), 40 | ); 41 | 42 | const resolveFile = async (id: string) => { 43 | const fs = await _getFS(); 44 | for (const dir of dirs) { 45 | const filePath = join(dir, id); 46 | if (!isValidPath(filePath) || !filePath.startsWith(dir)) { 47 | throw createError({ 48 | statusCode: 403, 49 | statusText: `IPX_FORBIDDEN_PATH`, 50 | message: `Forbidden path: ${id}`, 51 | }); 52 | } 53 | try { 54 | const stats = await fs.stat(filePath); 55 | if (!stats.isFile()) { 56 | // Keep looking in other dirs we are looking for a file! 57 | continue; 58 | } 59 | return { 60 | stats, 61 | read: () => fs.readFile(filePath), 62 | }; 63 | } catch (error: any) { 64 | if (error.code === "ENOENT") { 65 | // Keep looking in other dirs 66 | continue; 67 | } 68 | throw createError({ 69 | statusCode: 403, 70 | statusText: `IPX_FORBIDDEN_FILE`, 71 | message: `Cannot access file: ${id}`, 72 | }); 73 | } 74 | } 75 | throw createError({ 76 | statusCode: 404, 77 | statusText: `IPX_FILE_NOT_FOUND`, 78 | message: `File not found: ${id}`, 79 | }); 80 | }; 81 | 82 | return { 83 | name: "ipx:node-fs", 84 | async getMeta(id) { 85 | const { stats } = await resolveFile(id); 86 | return { 87 | mtime: stats.mtime, 88 | maxAge, 89 | }; 90 | }, 91 | async getData(id) { 92 | const { read } = await resolveFile(id); 93 | return read(); 94 | }, 95 | }; 96 | } 97 | 98 | const isWindows = process.platform === "win32"; 99 | 100 | function isValidPath(fp: string) { 101 | // Invalid windows path chars 102 | // https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file?redirectedfrom=MSDN#Naming_Conventions 103 | if (isWindows) { 104 | // Remove C:/ as next we are validating : 105 | fp = fp.slice(parse(fp).root.length); 106 | } 107 | if (/["*:<>?|]/.test(fp)) { 108 | return false; 109 | } 110 | return true; 111 | } 112 | 113 | function resolveDirs(dirs?: string | string[]) { 114 | if (!dirs || !Array.isArray(dirs)) { 115 | const dir = resolve(dirs || getEnv("IPX_FS_DIR") || "."); 116 | return [dir]; 117 | } 118 | return dirs.map((dirs) => resolve(dirs)); 119 | } 120 | -------------------------------------------------------------------------------- /src/storage/unstorage.ts: -------------------------------------------------------------------------------- 1 | import type { Storage, Driver } from "unstorage"; 2 | import { createError } from "h3"; 3 | import type { IPXStorage, IPXStorageMeta } from "../types"; 4 | 5 | export type UnstorageIPXStorageOptions = { 6 | /** 7 | * Optional prefix to be placed in front of each storage key, which can help to name or categorise stored items. 8 | * @optional 9 | */ 10 | prefix?: string; 11 | }; 12 | 13 | /** 14 | * Adapts an Unstorage driver or storage system to comply with the IPXStorage interface required by IPX. 15 | * This allows various Unstorage-compatible storage systems to be used to manage image data with IPX. 16 | * 17 | * @param {Storage | Driver} storage - The Unstorage driver or storage instance to adapt. See {@link Storage} and {@link Driver}. 18 | * @param {UnstorageIPXStorageOptions | string} [_options={}] - Configuration options for the adapter, which can be a simple string prefix or an options object. See {@link UnstorageIPXStorageOptions}. 19 | * @returns {IPXStorage}. An IPXStorage compliant object that implements the necessary methods to interact with the provided unstorage driver or storage system. See {@link IPXStorage}. 20 | * @throws {H3Error} If there is a problem retrieving or converting the storage data, detailed error information is thrown. See {@link H3Error}. 21 | */ 22 | export function unstorageToIPXStorage( 23 | storage: Storage | Driver, 24 | _options: UnstorageIPXStorageOptions | string = {}, 25 | ): IPXStorage { 26 | const options = 27 | typeof _options === "string" ? { prefix: _options } : _options; 28 | 29 | const resolveKey = (id: string) => 30 | options.prefix ? `${options.prefix}:${id}` : id; 31 | 32 | return { 33 | name: "ipx:" + ((storage as any).name || "unstorage"), 34 | async getMeta(id, opts = {}) { 35 | if (!storage.getMeta) { 36 | return; 37 | } 38 | const storageKey = resolveKey(id); 39 | const meta = await storage.getMeta(storageKey, opts); 40 | return meta as IPXStorageMeta; 41 | }, 42 | async getData(id, opts = {}) { 43 | if (!storage.getItemRaw) { 44 | return; 45 | } 46 | const storageKey = resolveKey(id); 47 | 48 | // Known possible data types: ArrayBuffer, Buffer, String, Blob 49 | let data = await storage.getItemRaw(storageKey, opts); 50 | 51 | if (!data) { 52 | // File not found, do not attempt to parse 53 | return; 54 | } 55 | 56 | if (data instanceof Blob) { 57 | data = await data.arrayBuffer(); 58 | } 59 | 60 | try { 61 | // IPX requires a Buffer, attempt parse and normalize error 62 | return Buffer.from(data as ArrayBuffer); 63 | } catch (error: any) { 64 | throw createError({ 65 | statusCode: 500, 66 | statusText: `IPX_STORAGE_ERROR`, 67 | message: `Failed to parse storage data to Buffer:\n${error.message}`, 68 | cause: error, 69 | }); 70 | } 71 | }, 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ImageMeta } from "image-meta"; 2 | import type { Sharp, Color, KernelEnum } from "sharp"; 3 | 4 | // ---- Handlers ---- 5 | 6 | export interface HandlerContext { 7 | /** 8 | * Optional quality setting for the output image, affects compression in certain formats. 9 | * @optional 10 | */ 11 | quality?: number; 12 | 13 | /** 14 | * Specifies the method to fit the image to the dimensions provided, e.g., 'contain', 'cover'. 15 | * @optional 16 | */ 17 | fit?: "contain" | "cover" | "fill" | "inside" | "outside"; 18 | 19 | /** 20 | * The position used for cropping or positioning, specified as a number or string. 21 | * @optional 22 | */ 23 | position?: number | string; 24 | 25 | /** 26 | * Background colour to be used if necessary, provided as a colour object. See {@link Color}. 27 | * @optional 28 | */ 29 | background?: Color; 30 | 31 | /** 32 | * Specifies whether to enlarge the image if it is smaller than the desired size. 33 | * @optional 34 | */ 35 | enlarge?: boolean; 36 | 37 | /** 38 | * The type of kernel to use for image operations such as resizing. See {@link KernelEnum}. 39 | * @optional 40 | */ 41 | kernel?: keyof KernelEnum; 42 | 43 | /** 44 | * Metadata about the image being processed. 45 | */ 46 | meta: ImageMeta; 47 | } 48 | 49 | export interface Handler { 50 | /** 51 | * An array of functions that convert the given string arguments into usable forms. 52 | */ 53 | args: ((argument: string) => any)[]; 54 | 55 | /** 56 | * Defines the order in which this handler should be applied relative to other handlers. 57 | * @optional 58 | */ 59 | order?: number; 60 | 61 | /** 62 | * Function to apply the effects of this handler to the image pipeline. 63 | * @param {HandlerContext} context - The current image processing context. See {@link HandlerContext}. 64 | * @param {Sharp} pipe - The Sharp instance to use for image processing. See {@link Sharp}. 65 | * @param {...any} arguments_ - Transformed arguments to use in the handler. 66 | */ 67 | apply: (context: HandlerContext, pipe: Sharp, ...arguments_: any[]) => any; 68 | } 69 | 70 | // ---- Storage ---- 71 | 72 | export type IPXStorageMeta = { 73 | /** 74 | * The modification time of the stored item. 75 | * @optional 76 | */ 77 | mtime?: Date | number | string; 78 | 79 | /** 80 | * The maximum age (in seconds) at which the stored item should be considered fresh. 81 | * @optional 82 | */ 83 | maxAge?: number | string; 84 | }; 85 | 86 | /** 87 | * Options specific to image saving operations. 88 | */ 89 | export type IPXStorageOptions = Record; 90 | 91 | type MaybePromise = T | Promise; 92 | 93 | export interface IPXStorage { 94 | /** 95 | * A descriptive name for the storage type. 96 | */ 97 | name: string; 98 | 99 | /** 100 | * Retrieves metadata for an image identified by 'id'. 101 | * @param {string} id - The identifier for the image. 102 | * @param {IPXStorageOptions} [opts] - Optional metadata retrieval options. See {@link IPXStorageOptions}. 103 | * @returns {MaybePromise} A promise or direct return of the metadata, or undefined if not found. See {@link IPXStorageMeta}. 104 | */ 105 | getMeta: ( 106 | id: string, 107 | opts?: IPXStorageOptions, 108 | ) => MaybePromise; 109 | 110 | /** 111 | * Get the actual data for an image identified by 'id'. 112 | * @param {string} id - The identifier for the image. 113 | * @param {IPXStorageOptions} [opts] - Optional options for the data retrieval. See {@link IPXStorageOptions}. 114 | * @returns {MaybePromise} A promise or direct return of the image data as an ArrayBuffer, or undefined if not found. See {@link ArrayBuffer}. 115 | */ 116 | getData: ( 117 | id: string, 118 | opts?: IPXStorageOptions, 119 | ) => MaybePromise; 120 | } 121 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import destr from "destr"; 2 | 3 | export function getEnv(name: string): T | undefined { 4 | return name in process.env ? destr(process.env[name]) : undefined; 5 | } 6 | 7 | export function cachedPromise any>( 8 | function_: T, 9 | ) { 10 | let p: ReturnType; 11 | return (...arguments_: Parameters) => { 12 | if (p) { 13 | return p; 14 | } 15 | p = Promise.resolve(function_(...arguments_)) as ReturnType; 16 | return p; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /test/assets/bliss.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/ipx/0f322f8a2b3348135155b8c91ffa50ca99eadd9c/test/assets/bliss.jpg -------------------------------------------------------------------------------- /test/assets/giphy.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/ipx/0f322f8a2b3348135155b8c91ffa50ca99eadd9c/test/assets/giphy.gif -------------------------------------------------------------------------------- /test/assets/nested/bliss.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/ipx/0f322f8a2b3348135155b8c91ffa50ca99eadd9c/test/assets/nested/bliss.jpg -------------------------------------------------------------------------------- /test/assets/nuxt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | NuxtJS 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/assets/test.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/ipx/0f322f8a2b3348135155b8c91ffa50ca99eadd9c/test/assets/test.txt -------------------------------------------------------------------------------- /test/assets/xss.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | NuxtJS 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/assets2/bliss.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/ipx/0f322f8a2b3348135155b8c91ffa50ca99eadd9c/test/assets2/bliss.jpg -------------------------------------------------------------------------------- /test/assets2/unjs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/ipx/0f322f8a2b3348135155b8c91ffa50ca99eadd9c/test/assets2/unjs.jpg -------------------------------------------------------------------------------- /test/fs-dirs.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { describe, it, expect, beforeAll } from "vitest"; 3 | import { IPX, createIPX, ipxFSStorage } from "../src"; 4 | 5 | describe("ipx: fs with multiple dirs", () => { 6 | let ipx: IPX; 7 | 8 | beforeAll(() => { 9 | ipx = createIPX({ 10 | storage: ipxFSStorage({ 11 | dir: ["assets", "assets2"].map((d) => 12 | fileURLToPath(new URL(d, import.meta.url)), 13 | ), 14 | }), 15 | }); 16 | }); 17 | 18 | it("local file: 1st layer", async () => { 19 | const source = await ipx("giphy.gif"); 20 | const { data, format } = await source.process(); 21 | expect(data).toBeInstanceOf(Buffer); 22 | expect(format).toBe("gif"); 23 | }); 24 | 25 | it("local file: 2nd layer", async () => { 26 | const source = await ipx("unjs.jpg"); 27 | const { data, format } = await source.process(); 28 | expect(data).toBeInstanceOf(Buffer); 29 | expect(format).toBe("jpeg"); 30 | }); 31 | 32 | it("local file: priority", async () => { 33 | const source = await ipx("bliss.jpg"); 34 | const { data, format, meta } = await source.process(); 35 | expect(data).toBeInstanceOf(Buffer); 36 | expect(format).toBe("jpeg"); 37 | expect(meta?.height).toBe(2160); 38 | }); 39 | 40 | it("error: not found", async () => { 41 | const source = await ipx("unknown.png"); 42 | await expect(() => source.process()).rejects.toThrowError( 43 | "File not found: /unknown.png", 44 | ); 45 | }); 46 | 47 | it("error: forbidden path", async () => { 48 | const source = await ipx("*.png"); 49 | await expect(() => source.process()).rejects.toThrowError( 50 | "Forbidden path: /*.png", 51 | ); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { listen } from "listhen"; 2 | import { resolve } from "pathe"; 3 | import { describe, it, expect, beforeAll } from "vitest"; 4 | import serveHandler from "serve-handler"; 5 | import { IPX, createIPX, ipxFSStorage, ipxHttpStorage } from "../src"; 6 | 7 | describe("ipx", () => { 8 | let ipx: IPX; 9 | beforeAll(() => { 10 | ipx = createIPX({ 11 | storage: ipxFSStorage({ dir: resolve(__dirname, "assets") }), 12 | httpStorage: ipxHttpStorage({ domains: ["localhost:3000"] }), 13 | }); 14 | }); 15 | 16 | it("remote file", async () => { 17 | const listener = await listen( 18 | (request, res) => { 19 | serveHandler(request, res, { public: resolve(__dirname, "assets") }); 20 | }, 21 | { port: 0 }, 22 | ); 23 | const source = await ipx(`${listener.url}/bliss.jpg`); 24 | const { data, format } = await source.process(); 25 | expect(data).toBeInstanceOf(Buffer); 26 | expect(format).toBe("jpeg"); 27 | await listener.close(); 28 | }); 29 | 30 | it("local file", async () => { 31 | const source = await ipx("bliss.jpg"); 32 | const { data, format } = await source.process(); 33 | expect(data).toBeInstanceOf(Buffer); 34 | expect(format).toBe("jpeg"); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/unstorage.test.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { resolve } from "pathe"; 3 | import { describe, it, expect, beforeAll } from "vitest"; 4 | import { createStorage } from "unstorage"; 5 | import fsLiteDriver from "unstorage/drivers/fs-lite"; 6 | import githubDriver from "unstorage/drivers/github"; 7 | import httpDriver from "unstorage/drivers/http"; 8 | import { IPX, createIPX, unstorageToIPXStorage } from "../src"; 9 | 10 | const sampleImage = await readFile( 11 | new URL("assets/bliss.jpg", import.meta.url), 12 | ); 13 | 14 | const tests = [ 15 | { 16 | name: "node-fs", 17 | skip: false, 18 | setup() { 19 | const driver = fsLiteDriver({ base: resolve(__dirname, "assets") }); 20 | const storage = createStorage({ driver }); 21 | return createIPX({ storage: unstorageToIPXStorage(storage) }); 22 | }, 23 | }, 24 | { 25 | name: "memory", 26 | skip: false, 27 | async setup() { 28 | const storage = createStorage(); 29 | await storage.setItemRaw("bliss.jpg", sampleImage); 30 | await storage.setItemRaw("nested/bliss.jpg", sampleImage); 31 | return createIPX({ storage: unstorageToIPXStorage(storage) }); 32 | }, 33 | }, 34 | { 35 | name: "memory (prefixed)", 36 | skip: false, 37 | async setup() { 38 | const storage = createStorage(); 39 | await storage.setItemRaw("images/bliss.jpg", sampleImage); 40 | await storage.setItemRaw("images/nested/bliss.jpg", sampleImage); 41 | return createIPX({ 42 | storage: unstorageToIPXStorage(storage, { prefix: "images" }), 43 | }); 44 | }, 45 | }, 46 | { 47 | name: "github", 48 | skip: !process.env.TEST_UNSTORAGE_GITHUB, 49 | setup: () => { 50 | const driver = githubDriver({ repo: "unjs/ipx", dir: "test/assets" }); 51 | const storage = createStorage({ driver }); 52 | return createIPX({ storage: unstorageToIPXStorage(storage) }); 53 | }, 54 | }, 55 | { 56 | name: "http", 57 | skip: !process.env.TEST_UNSTORAGE_HTTP, 58 | setup: () => { 59 | const driver = httpDriver({ 60 | base: "https://raw.githubusercontent.com/unjs/ipx/main/test/assets", 61 | }); 62 | const storage = createStorage({ driver }); 63 | return createIPX({ storage: unstorageToIPXStorage(storage) }); 64 | }, 65 | }, 66 | ] as const; 67 | 68 | for (const test of tests) { 69 | describe.skipIf(test.skip)(`unstorage:ipx:${test.name}`, () => { 70 | let ipx: IPX; 71 | 72 | beforeAll(async () => { 73 | ipx = await test.setup(); 74 | }); 75 | 76 | it("file found", async () => { 77 | const source = await ipx("bliss.jpg"); 78 | const { data, format } = await source.process(); 79 | expect(data).toBeInstanceOf(Buffer); 80 | expect(format).toBe("jpeg"); 81 | }); 82 | 83 | it("file found nested", async () => { 84 | const source = await ipx("nested/bliss.jpg"); 85 | const { data, format } = await source.process(); 86 | expect(data).toBeInstanceOf(Buffer); 87 | expect(format).toBe("jpeg"); 88 | }); 89 | 90 | it("file not found", async () => { 91 | const source = await ipx("unknown.jpg"); 92 | await expect(() => source.process()).rejects.toThrowError( 93 | "Resource not found: /unknown.jpg", 94 | ); 95 | }); 96 | 97 | it("invalid path", async () => { 98 | const source = await ipx("*.jpg"); 99 | await expect(() => source.process()).rejects.toThrowError( 100 | "Resource not found: /*.jpg", 101 | ); 102 | }); 103 | }); 104 | } 105 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "strict": true 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ["text", "clover", "json"], 7 | }, 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------