From d13cf433437841fb8f2862ff07e0a96f7d970268 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 28 May 2025 13:48:51 +0200 Subject: [PATCH 01/33] feat: initial image cdn handling --- .github/workflows/release-please.yaml | 4 + README.md | 1 + package-lock.json | 1613 +++++++++++++++++++++++-- package.json | 1 + packages/dev/package.json | 1 + packages/dev/src/main.ts | 28 +- packages/images/.gitignore | 1 + packages/images/CHANGELOG.md | 1 + packages/images/package.json | 43 + packages/images/src/main.test.ts | 3 + packages/images/src/main.ts | 108 ++ packages/images/tsconfig.json | 16 + packages/images/tsup.config.ts | 17 + release-please-config.json | 5 +- 14 files changed, 1760 insertions(+), 82 deletions(-) create mode 100644 packages/images/.gitignore create mode 100644 packages/images/CHANGELOG.md create mode 100644 packages/images/package.json create mode 100644 packages/images/src/main.test.ts create mode 100644 packages/images/src/main.ts create mode 100644 packages/images/tsconfig.json create mode 100644 packages/images/tsup.config.ts diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 2d6bdac8..1d56111b 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -62,6 +62,10 @@ jobs: if: ${{ steps.release.outputs['packages/functions--release_created'] }} env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - run: npm publish packages/images/ --provenance --access=public + if: ${{ steps.release.outputs['packages/images--release_created'] }} + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - run: npm publish packages/redirects/ --provenance --access=public if: ${{ steps.release.outputs['packages/redirects--release_created'] }} env: diff --git a/README.md b/README.md index 61b51a7f..30701359 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ npm run build --workspaces=true | ๐Ÿ› ๏ธ [@netlify/dev](packages/dev) | Emulation of the Netlify environment for local development | [![npm version](https://img.shields.io/npm/v/@netlify/dev.svg)](https://www.npmjs.com/package/@netlify/dev) | | ๐Ÿ”ง [@netlify/dev-utils](packages/dev-utils) | TypeScript utilities for the local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/dev-utils.svg)](https://www.npmjs.com/package/@netlify/dev-utils) | | โšก [@netlify/functions](packages/functions) | TypeScript utilities for interacting with Netlify Functions | [![npm version](https://img.shields.io/npm/v/@netlify/functions.svg)](https://www.npmjs.com/package/@netlify/functions) | +| ๐Ÿ–ผ๏ธ [@netlify/images](packages/images) | TypeScript utilities for interacting with Netlify Image CDN | [![npm version](https://img.shields.io/npm/v/@netlify/functions.svg)](https://www.npmjs.com/package/@netlify/images) | | ๐Ÿ” [@netlify/otel](packages/otel) | TypeScript utilities to interact with Netlify's OpenTelemetry | [![npm version](https://img.shields.io/npm/v/@netlify/otel.svg)](https://www.npmjs.com/package/@netlify/otel) | | ๐Ÿ”„ [@netlify/redirects](packages/redirects) | TypeScript implementation of Netlify's rewrites and redirects engine | [![npm version](https://img.shields.io/npm/v/@netlify/redirects.svg)](https://www.npmjs.com/package/@netlify/redirects) | | ๐Ÿ›๏ธ [@netlify/runtime](packages/runtime) | Netlify compute runtime | [![npm version](https://img.shields.io/npm/v/@netlify/runtime.svg)](https://www.npmjs.com/package/@netlify/runtime) | diff --git a/package-lock.json b/package-lock.json index 65cdb6e1..fe4ca90d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "packages/blobs", "packages/cache", "packages/functions", + "packages/images", "packages/redirects", "packages/runtime", "packages/static", @@ -132,6 +133,16 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", @@ -660,6 +671,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -731,6 +758,367 @@ "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1187,6 +1575,10 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/@netlify/images": { + "resolved": "packages/images", + "link": true + }, "node_modules/@netlify/open-api": { "version": "2.37.0", "resolved": "https://registry.npmjs.org/@netlify/open-api/-/open-api-2.37.0.tgz", @@ -1567,102 +1959,435 @@ "protobufjs": "^7.3.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.0.tgz", + "integrity": "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.0.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.200.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.200.0.tgz", + "integrity": "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.200.0", + "@opentelemetry/core": "2.0.0", + "@opentelemetry/resources": "2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.0.tgz", + "integrity": "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.0.0", + "@opentelemetry/resources": "2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.0.tgz", + "integrity": "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.0.0", + "@opentelemetry/resources": "2.0.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.0.0.tgz", + "integrity": "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.0.0", + "@opentelemetry/core": "2.0.0", + "@opentelemetry/sdk-trace-base": "2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.33.0.tgz", + "integrity": "sha512-TIpZvE8fiEILFfTlfPnltpBaD3d9/+uQHVCyC3vfdh6WfCXKhNFzoP5RyDDIndfvZC5GrA4pyEDNyjPloJud+w==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/resources": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.0.tgz", - "integrity": "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.200.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.200.0.tgz", - "integrity": "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA==", - "license": "Apache-2.0", + "node_modules/@parcel/watcher-wasm": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-wasm/-/watcher-wasm-2.5.1.tgz", + "integrity": "sha512-RJxlQQLkaMMIuWRozy+z2vEqbaQlCuaCgVZIUCzQLYggY22LZbP5Y1+ia+FD724Ids9e+XIyOLXLrLgQSHIthw==", + "bundleDependencies": [ + "napi-wasm" + ], + "license": "MIT", "dependencies": { - "@opentelemetry/api-logs": "0.200.0", - "@opentelemetry/core": "2.0.0", - "@opentelemetry/resources": "2.0.0" + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "napi-wasm": "^1.1.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.0.tgz", - "integrity": "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.0", - "@opentelemetry/resources": "2.0.0" - }, + "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.0.tgz", - "integrity": "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.0", - "@opentelemetry/resources": "2.0.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.0.0.tgz", - "integrity": "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.0.0", - "@opentelemetry/core": "2.0.0", - "@opentelemetry/sdk-trace-base": "2.0.0" - }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.33.0.tgz", - "integrity": "sha512-TIpZvE8fiEILFfTlfPnltpBaD3d9/+uQHVCyC3vfdh6WfCXKhNFzoP5RyDDIndfvZC5GrA4pyEDNyjPloJud+w==", + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, "engines": { - "node": ">=14" + "node": ">=0.10" } }, "node_modules/@pkgjs/parseargs": { @@ -2040,6 +2765,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@tsd/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.8.3.tgz", @@ -2627,6 +3361,19 @@ "dev": true, "license": "MIT" }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -2852,6 +3599,12 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3116,6 +3869,32 @@ "node": ">=18" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/clipboardy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", + "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", + "license": "MIT", + "dependencies": { + "execa": "^8.0.1", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3282,7 +4061,6 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, "license": "MIT" }, "node_modules/consola": { @@ -3301,6 +4079,12 @@ "node": ">=18" } }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, "node_modules/copy-file": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-file/-/copy-file-11.0.0.tgz", @@ -3389,6 +4173,95 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3484,6 +4357,18 @@ "node": ">=0.10.0" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -3740,6 +4625,61 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -4259,6 +5199,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -4696,6 +5645,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port-please": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.2.tgz", + "integrity": "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==", + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -4842,6 +5797,23 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/h3": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.3.tgz", + "integrity": "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.4", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.0", + "radix3": "^1.1.2", + "ufo": "^1.6.1", + "uncrypto": "^0.1.3" + } + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -4896,7 +5868,17 @@ "lru-cache": "^6.0.0" }, "engines": { - "node": ">=10" + "node": ">=10" + } + }, + "node_modules/http-shutdown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/http-shutdown/-/http-shutdown-1.2.2.tgz", + "integrity": "sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, "node_modules/https-proxy-agent": { @@ -4967,6 +5949,12 @@ "node": ">= 4" } }, + "node_modules/image-meta": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/image-meta/-/image-meta-0.2.1.tgz", + "integrity": "sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5041,6 +6029,151 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ipx": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ipx/-/ipx-3.0.3.tgz", + "integrity": "sha512-c8ZWrM9Rzf8C/W1WoBb9KJ73C76+s3xyBL4iS5WdlPVIObE14tKKW79JIWbMkzhPZw71ZL/mLRMSvQOOhwbj0Q==", + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.1", + "citty": "^0.1.6", + "consola": "^3.4.2", + "defu": "^6.1.4", + "destr": "^2.0.3", + "etag": "^1.8.1", + "h3": "^1.15.1", + "image-meta": "^0.2.1", + "listhen": "^1.9.0", + "ofetch": "^1.4.1", + "pathe": "^2.0.3", + "sharp": "^0.33.5", + "svgo": "^3.3.2", + "ufo": "^1.5.4", + "unstorage": "^1.15.0", + "xss": "^1.0.15" + }, + "bin": { + "ipx": "bin/ipx.mjs" + } + }, + "node_modules/ipx/node_modules/@netlify/blobs": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-8.2.0.tgz", + "integrity": "sha512-9djLZHBKsoKk8XCgwWSEPK9QnT8qqxEQGuYh48gFIcNLvpBKkLnHbDZuyUxmNemCfDz7h0HnMXgSPnnUVgARhg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, + "node_modules/ipx/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/ipx/node_modules/unstorage": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.16.0.tgz", + "integrity": "sha512-WQ37/H5A7LcRPWfYOrDa1Ys02xAbpPJq6q5GkO88FBXVSQzHd7+BjEwfRqyaSWCv9MbsJy058GWjjPjcJ16GGA==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^4.0.3", + "destr": "^2.0.5", + "h3": "^1.15.2", + "lru-cache": "^10.4.3", + "node-fetch-native": "^1.6.6", + "ofetch": "^1.4.1", + "ufo": "^1.6.1" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6.0.3 || ^7.0.0", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/kv": "^1.0.1", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/irregular-plurals": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", @@ -5086,6 +6219,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5116,6 +6264,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5177,6 +6343,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is64bit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", + "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", + "license": "MIT", + "dependencies": { + "system-architecture": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5226,10 +6422,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5472,6 +6665,42 @@ "dev": true, "license": "MIT" }, + "node_modules/listhen": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz", + "integrity": "sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==", + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.4.1", + "@parcel/watcher-wasm": "^2.4.1", + "citty": "^0.1.6", + "clipboardy": "^4.0.0", + "consola": "^3.2.3", + "crossws": ">=0.2.0 <0.4.0", + "defu": "^6.1.4", + "get-port-please": "^3.1.2", + "h3": "^1.12.0", + "http-shutdown": "^1.2.2", + "jiti": "^2.1.2", + "mlly": "^1.7.1", + "node-forge": "^1.3.1", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "ufo": "^1.5.4", + "untun": "^0.1.3", + "uqr": "^0.1.2" + }, + "bin": { + "listen": "bin/listhen.mjs", + "listhen": "bin/listhen.mjs" + } + }, + "node_modules/listhen/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "license": "MIT" + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -5661,6 +6890,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -5867,7 +7102,6 @@ "version": "1.7.4", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.14.0", @@ -5940,6 +7174,12 @@ "resolved": "https://registry.npmjs.org/netlify-redirector/-/netlify-redirector-0.5.0.tgz", "integrity": "sha512-4zdzIP+6muqPCuE8avnrgDJ6KW/2+UpHTRcTbMXCIRxiRmyrX+IZ4WSJGZdHPWF3WmQpXpy603XxecZ9iygN7w==" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -5978,6 +7218,12 @@ } } }, + "node_modules/node-fetch-native": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz", + "integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==", + "license": "MIT" + }, "node_modules/node-fetch/node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -6000,6 +7246,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -6011,6 +7266,12 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-mock-http": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.0.tgz", + "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==", + "license": "MIT" + }, "node_modules/node-source-walk": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-7.0.1.tgz", @@ -6192,6 +7453,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6214,6 +7487,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ofetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", + "integrity": "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.3", + "node-fetch-native": "^1.6.4", + "ufo": "^1.5.4" + } + }, "node_modules/omit.js": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/omit.js/-/omit.js-2.0.2.tgz", @@ -6470,7 +7754,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pathval": { @@ -6533,7 +7816,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.1.8", @@ -6844,6 +8126,12 @@ "integrity": "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==", "license": "MIT" }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7338,6 +8626,58 @@ "node": ">=10" } }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7576,7 +8916,6 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "dev": true, "license": "MIT" }, "node_modules/streamsearch": { @@ -7855,6 +9194,52 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svgo": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/system-architecture": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", + "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", @@ -8265,7 +9650,12 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, "node_modules/undici-types": { @@ -8307,6 +9697,32 @@ "node": ">=0.10.0" } }, + "node_modules/untun": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/untun/-/untun-0.1.3.tgz", + "integrity": "sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.5", + "consola": "^3.2.3", + "pathe": "^1.1.1" + }, + "bin": { + "untun": "bin/untun.mjs" + } + }, + "node_modules/untun/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "license": "MIT" + }, + "node_modules/uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -8774,6 +10190,28 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -9067,6 +10505,7 @@ "@netlify/config": "^23.0.7", "@netlify/dev-utils": "2.2.0", "@netlify/functions": "3.1.10", + "@netlify/images": "0.0.0", "@netlify/redirects": "1.1.4", "@netlify/runtime": "2.2.2", "@netlify/static": "1.1.4" @@ -9277,6 +10716,22 @@ "node": ">=0.10" } }, + "packages/images": { + "name": "@netlify/images", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "ipx": "^3.0.3" + }, + "devDependencies": { + "@netlify/dev-utils": "^2.2.0", + "tsup": "^8.5.0", + "vitest": "^3.1.4" + }, + "engines": { + "node": "^18.14.0 || >=20" + } + }, "packages/otel": { "name": "@netlify/otel", "version": "1.1.0", diff --git a/package.json b/package.json index e7453cfc..36eba358 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "packages/blobs", "packages/cache", "packages/functions", + "packages/images", "packages/redirects", "packages/runtime", "packages/static", diff --git a/packages/dev/package.json b/packages/dev/package.json index 8f987ae1..9d803cd7 100644 --- a/packages/dev/package.json +++ b/packages/dev/package.json @@ -56,6 +56,7 @@ "@netlify/config": "^23.0.7", "@netlify/dev-utils": "2.2.0", "@netlify/functions": "3.1.10", + "@netlify/images": "0.0.0", "@netlify/redirects": "1.1.4", "@netlify/runtime": "2.2.2", "@netlify/static": "1.1.4" diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index e6b9dad2..18572361 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -5,6 +5,7 @@ import process from 'node:process' import { resolveConfig } from '@netlify/config' import { ensureNetlifyIgnore, getAPIToken, LocalState, type Logger } from '@netlify/dev-utils' import { FunctionsHandler } from '@netlify/functions/dev' +import { ImageHandler } from '@netlify/images' import { RedirectsHandler } from '@netlify/redirects' import { StaticHandler } from '@netlify/static' @@ -40,6 +41,15 @@ export interface Features { enabled: boolean } + /** + * Configuration options for Netlify Image CDN. + * + * {@link} https://docs.netlify.com/image-cdn/overview/ + */ + images?: { + enabled: boolean + } + /** * Configuration options for Netlify redirects and rewrites. * @@ -78,6 +88,7 @@ export class NetlifyDev { blobs: boolean environmentVariables: boolean functions: boolean + images: boolean redirects: boolean static: boolean } @@ -99,6 +110,7 @@ export class NetlifyDev { blobs: options.blobs?.enabled !== false, environmentVariables: options.environmentVariables?.enabled !== false, functions: options.functions?.enabled !== false, + images: options.images?.enabled !== false, redirects: options.redirects?.enabled !== false, static: options.staticFiles?.enabled !== false, } @@ -123,6 +135,12 @@ export class NetlifyDev { }) : null + const images = this.#features.images + ? new ImageHandler({ + imagesConfig: this.#config?.config.images, + }) + : null + // Redirects const redirects = this.#features.redirects ? new RedirectsHandler({ @@ -163,7 +181,13 @@ export class NetlifyDev { return functionMatch.handle(request) } - // 2. Check if the request matches a redirect rule. + // 2. Check if the request matches Image CDN. + const imageMatch = await images?.match(request) + if (imageMatch) { + return imageMatch.handle() + } + + // 3. Check if the request matches a redirect rule. const redirectMatch = await redirects?.match(request) if (redirectMatch) { // If the redirect rule matches a function, we'll serve it. The exception @@ -184,7 +208,7 @@ export class NetlifyDev { } } - // 3. Check if the request matches a static file. + // 4. Check if the request matches a static file. const staticMatch = await staticFiles?.match(request) if (staticMatch) { return staticMatch.handle() diff --git a/packages/images/.gitignore b/packages/images/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/packages/images/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/images/CHANGELOG.md b/packages/images/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/packages/images/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/packages/images/package.json b/packages/images/package.json new file mode 100644 index 00000000..cb554f3a --- /dev/null +++ b/packages/images/package.json @@ -0,0 +1,43 @@ +{ + "name": "@netlify/images", + "version": "0.0.0", + "description": "TypeScript implementtion of Netlify's Image CDN", + "type": "module", + "engines": { + "node": "^18.14.0 || >=20" + }, + "main": "./dist/main.js", + "exports": "./dist/main.js", + "types": "./dist/main.d.ts", + "files": [ + "dist/**/*", + "server.d.ts" + ], + "scripts": { + "build": "tsup-node", + "prepack": "npm run build", + "test": "vitest run", + "test:dev": "vitest", + "test:ci": "npm run build && vitest run", + "dev": "tsup-node --watch", + "publint": "npx -y publint --strict" + }, + "keywords": [], + "license": "MIT", + "repository": "netlify/primitives", + "bugs": { + "url": "https://github.com/netlify/primitives/issues" + }, + "author": "Netlify Inc.", + "directories": { + "test": "test" + }, + "devDependencies": { + "@netlify/dev-utils": "^2.2.0", + "tsup": "^8.5.0", + "vitest": "^3.1.4" + }, + "dependencies": { + "ipx": "^3.0.3" + } +} diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts new file mode 100644 index 00000000..b58f0b6c --- /dev/null +++ b/packages/images/src/main.test.ts @@ -0,0 +1,3 @@ +import { test } from 'vitest' + +test.todo('Add tests') diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts new file mode 100644 index 00000000..fba16cef --- /dev/null +++ b/packages/images/src/main.ts @@ -0,0 +1,108 @@ +import { createIPX, createIPXWebServer, ipxFSStorage, ipxHttpStorage } from 'ipx' + +interface ImagesConfig { + remote_images: string[] +} + +interface ImageHandlerOptions { + imagesConfig?: ImagesConfig +} + +export interface ImageMatch { + handle: () => Promise +} + +const IMAGE_CDN_ENDPOINTS = ['/.netlify/images', '/.netlify/images/'] + +interface IpxParams { + w?: string | null + h?: string | null + s?: string | null + quality?: string | null + format?: string | null + fit?: string | null + position?: string | null +} + +export class ImageHandler { + private allowedRemoteUrlPatterns: RegExp[] + + constructor(options: ImageHandlerOptions) { + // TODO: handle invalid patterns + this.allowedRemoteUrlPatterns = (options.imagesConfig?.remote_images ?? []).map( + (stringPattern) => new RegExp(stringPattern), + ) + } + + private generateIPXRequestURL(imageURL: URL, netlifyImageCdnParams: URLSearchParams): URL { + const params: IpxParams = {} + + const width = netlifyImageCdnParams.get('w') || netlifyImageCdnParams.get('width') || null + const height = netlifyImageCdnParams.get('h') || netlifyImageCdnParams.get('height') || null + + if (width && height) { + params.s = `${width}x${height}` + } else { + params.w = width + params.h = height + } + + params.quality = netlifyImageCdnParams.get('q') || netlifyImageCdnParams.get('quality') || null + params.format = netlifyImageCdnParams.get('fm') || null + + const fit = netlifyImageCdnParams.get('fit') || null + params.fit = fit === 'contain' ? 'inside' : fit + + params.position = netlifyImageCdnParams.get('position') || null + + const ipxModifiers = Object.entries(params) + .filter(([, value]) => value !== null) + .map(([key, value]) => `${key}_${value}`) + .join(',') + + return new URL(`/${ipxModifiers || `_`}/${encodeURIComponent(imageURL.href)}`, imageURL.origin) + } + + async match(request: Request): Promise { + const url = new URL(request.url) + + if (IMAGE_CDN_ENDPOINTS.includes(url.pathname)) { + return { + handle: async () => { + if (request.method !== 'GET') { + return new Response('Method Not Allowed', { status: 405 }) + } + + const sourceImageUrlParam = url.searchParams.get('url') + if (!sourceImageUrlParam) { + return new Response('Bad Request: Missing "url" query parameter', { status: 400 }) + } + + const sourceImageUrl = new URL(sourceImageUrlParam, url.origin) + + // if it's not local image, check if it it's allowed + if ( + sourceImageUrl.origin !== url.origin && + !this.allowedRemoteUrlPatterns.some((allowedRemoteUrlPattern) => + allowedRemoteUrlPattern.test(sourceImageUrl.href), + ) + ) { + return new Response('Forbidden: Remote image URL not allowed', { status: 403 }) + } + + const ipx = createIPX({ + storage: ipxFSStorage(), + httpStorage: ipxHttpStorage({ + allowAllDomains: true, + }), + }) + + const ipxHandler = createIPXWebServer(ipx) + + const ipxRequest = new Request(this.generateIPXRequestURL(sourceImageUrl, url.searchParams), request) + return ipxHandler(ipxRequest) + }, + } + } + } +} diff --git a/packages/images/tsconfig.json b/packages/images/tsconfig.json new file mode 100644 index 00000000..7b592eaa --- /dev/null +++ b/packages/images/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "rootDir": "./src", + "moduleResolution": "node", + "allowJs": true, + "declaration": true, + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/images/tsup.config.ts b/packages/images/tsup.config.ts new file mode 100644 index 00000000..9a088574 --- /dev/null +++ b/packages/images/tsup.config.ts @@ -0,0 +1,17 @@ +import { argv } from 'node:process' + +import { defineConfig } from 'tsup' + +export default defineConfig([ + { + clean: true, + format: ['esm'], + entry: ['src/main.ts'], + tsconfig: 'tsconfig.json', + splitting: false, + bundle: true, + dts: true, + outDir: './dist', + watch: argv.includes('--watch'), + }, +]) diff --git a/release-please-config.json b/release-please-config.json index 0fed87f7..84558781 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -2,7 +2,9 @@ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "bootstrap-sha": "1e628be35d2ae079b63ec8e98ca0c6831237b677", "last-release-sha": "1e628be35d2ae079b63ec8e98ca0c6831237b677", - "plugins": ["node-workspace"], + "plugins": [ + "node-workspace" + ], "release-type": "node", "separate-pull-requests": false, "packages": { @@ -11,6 +13,7 @@ "packages/dev": {}, "packages/dev-utils": {}, "packages/functions": {}, + "packages/images": {}, "packages/otel": {}, "packages/redirects": {}, "packages/runtime": {}, From 7dda011f7260978849809e5f85d70ef5561d261c Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 30 May 2025 12:40:04 +0200 Subject: [PATCH 02/33] chore: fix lint --- eslint.config.js | 6 ++++ eslint_temporary_suppressions.js | 6 ++++ package-lock.json | 1 + packages/dev/src/main.ts | 2 +- packages/images/src/main.ts | 53 ++++++++++++++++---------------- 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 84a917eb..880058e8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,5 @@ // @ts-check +import { promises as fs } from 'node:fs' import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { includeIgnoreFile } from '@eslint/compat' @@ -13,9 +14,14 @@ import temporarySuppressions from './eslint_temporary_suppressions.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +const packagesPath = path.join(__dirname, 'packages') +const packages = await fs.readdir(packagesPath) +const packageIgnores = packages.map((name) => includeIgnoreFile(path.resolve(packagesPath, name, '.gitignore'))) + export default tseslint.config( // Global rules and configuration includeIgnoreFile(path.resolve(__dirname, '.gitignore')), + ...packageIgnores, { linterOptions: { reportUnusedDisableDirectives: true, diff --git a/eslint_temporary_suppressions.js b/eslint_temporary_suppressions.js index 46c6d5df..bcd9b775 100644 --- a/eslint_temporary_suppressions.js +++ b/eslint_temporary_suppressions.js @@ -734,6 +734,12 @@ export default [ 'n/no-unsupported-features/node-builtins': 'off', }, }, + { + files: ['packages/images/src/main.ts'], + rules: { + 'n/no-unsupported-features/node-builtins': 'off', + }, + }, { files: ['packages/otel/src/bootstrap/main.ts'], rules: { diff --git a/package-lock.json b/package-lock.json index 09a398eb..04fba070 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11071,6 +11071,7 @@ } }, "packages/images": { + "name": "@netlify/images", "version": "0.0.0", "license": "MIT", "dependencies": { diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index eebd3dcf..3f233c91 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -206,7 +206,7 @@ export class NetlifyDev { } // 2. Check if the request matches Image CDN. - const imageMatch = await images?.match(request) + const imageMatch = images?.match(request) if (imageMatch) { return imageMatch.handle() } diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts index fba16cef..66688ca2 100644 --- a/packages/images/src/main.ts +++ b/packages/images/src/main.ts @@ -14,16 +14,6 @@ export interface ImageMatch { const IMAGE_CDN_ENDPOINTS = ['/.netlify/images', '/.netlify/images/'] -interface IpxParams { - w?: string | null - h?: string | null - s?: string | null - quality?: string | null - format?: string | null - fit?: string | null - position?: string | null -} - export class ImageHandler { private allowedRemoteUrlPatterns: RegExp[] @@ -35,35 +25,44 @@ export class ImageHandler { } private generateIPXRequestURL(imageURL: URL, netlifyImageCdnParams: URLSearchParams): URL { - const params: IpxParams = {} + const ipxParams: string[] = [] - const width = netlifyImageCdnParams.get('w') || netlifyImageCdnParams.get('width') || null - const height = netlifyImageCdnParams.get('h') || netlifyImageCdnParams.get('height') || null + const width = netlifyImageCdnParams.get('w') ?? netlifyImageCdnParams.get('width') + const height = netlifyImageCdnParams.get('h') ?? netlifyImageCdnParams.get('height') if (width && height) { - params.s = `${width}x${height}` - } else { - params.w = width - params.h = height + ipxParams.push(`resize_${width}x${height}`) + } else if (width) { + ipxParams.push(`width_${width}`) + } else if (height) { + ipxParams.push(`height_${height}`) } - params.quality = netlifyImageCdnParams.get('q') || netlifyImageCdnParams.get('quality') || null - params.format = netlifyImageCdnParams.get('fm') || null + const quality = netlifyImageCdnParams.get('q') ?? netlifyImageCdnParams.get('quality') + if (quality) { + ipxParams.push(`quality_${quality}`) + } + const format = netlifyImageCdnParams.get('fm') + if (format) { + ipxParams.push(`format_${format}`) + } - const fit = netlifyImageCdnParams.get('fit') || null - params.fit = fit === 'contain' ? 'inside' : fit + const fit = netlifyImageCdnParams.get('fit') + if (fit) { + ipxParams.push(`fit_${fit === 'contain' ? 'inside' : fit}`) + } - params.position = netlifyImageCdnParams.get('position') || null + const position = netlifyImageCdnParams.get('position') + if (position) { + ipxParams.push(`position_${position}`) + } - const ipxModifiers = Object.entries(params) - .filter(([, value]) => value !== null) - .map(([key, value]) => `${key}_${value}`) - .join(',') + const ipxModifiers = ipxParams.join(',') return new URL(`/${ipxModifiers || `_`}/${encodeURIComponent(imageURL.href)}`, imageURL.origin) } - async match(request: Request): Promise { + match(request: Request): ImageMatch | undefined { const url = new URL(request.url) if (IMAGE_CDN_ENDPOINTS.includes(url.pathname)) { From 279af0a9d8813ce135e646dffa9dc47656f1f9b4 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 30 May 2025 12:54:31 +0200 Subject: [PATCH 03/33] chore: format --- release-please-config.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index a9963a15..c4bcd6c8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -2,9 +2,7 @@ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "bootstrap-sha": "1e628be35d2ae079b63ec8e98ca0c6831237b677", "last-release-sha": "1e628be35d2ae079b63ec8e98ca0c6831237b677", - "plugins": [ - "node-workspace" - ], + "plugins": ["node-workspace"], "release-type": "node", "separate-pull-requests": false, "packages": { From 984eed316090e10bbcada00e3062eeb24fd98ab4 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 30 May 2025 12:55:03 +0200 Subject: [PATCH 04/33] chore: TODO note about serverAddress --- packages/images/src/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts index 66688ca2..0d076f95 100644 --- a/packages/images/src/main.ts +++ b/packages/images/src/main.ts @@ -77,6 +77,7 @@ export class ImageHandler { return new Response('Bad Request: Missing "url" query parameter', { status: 400 }) } + // TODO: use serverAddress instead of url.origin once https://github.com/netlify/primitives/pull/233 is merged const sourceImageUrl = new URL(sourceImageUrlParam, url.origin) // if it's not local image, check if it it's allowed From 10de36f761c729a887ee7bc848e80e30ee43a621 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 30 May 2025 13:15:53 +0200 Subject: [PATCH 05/33] feat: warn about invalid remote patterns --- packages/dev/src/main.ts | 1 + packages/images/src/main.test.ts | 27 +++++++++++++++++++++++++-- packages/images/src/main.ts | 25 +++++++++++++++++-------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index 3f233c91..4ca472e9 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -161,6 +161,7 @@ export class NetlifyDev { const images = this.#features.images ? new ImageHandler({ imagesConfig: this.#config?.config.images, + logger: this.#logger, }) : null diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index b58f0b6c..eb483f92 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,3 +1,26 @@ -import { test } from 'vitest' +import type { Logger } from '@netlify/dev-utils' +import { describe, expect, test, vi } from 'vitest' +import { ImageHandler } from './main.js' -test.todo('Add tests') +describe('`ImageHandler`', () => { + describe('constructor', () => { + test('warns about malformed remote image patterns', () => { + const logger = { + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + } satisfies Logger + + new ImageHandler({ + logger, + imagesConfig: { + remote_images: ['https://example.com/images/*', 'invalid-regex['], + }, + }) + + expect(logger.warn).toHaveBeenCalledWith( + 'Malformed remote image pattern: "invalid-regex[": Invalid regular expression: /invalid-regex[/: Unterminated character class. Skipping it.', + ) + }) + }) +}) diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts index 0d076f95..ae5fb826 100644 --- a/packages/images/src/main.ts +++ b/packages/images/src/main.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@netlify/dev-utils' import { createIPX, createIPXWebServer, ipxFSStorage, ipxHttpStorage } from 'ipx' interface ImagesConfig { @@ -6,6 +7,7 @@ interface ImagesConfig { interface ImageHandlerOptions { imagesConfig?: ImagesConfig + logger: Logger } export interface ImageMatch { @@ -15,13 +17,20 @@ export interface ImageMatch { const IMAGE_CDN_ENDPOINTS = ['/.netlify/images', '/.netlify/images/'] export class ImageHandler { - private allowedRemoteUrlPatterns: RegExp[] - - constructor(options: ImageHandlerOptions) { - // TODO: handle invalid patterns - this.allowedRemoteUrlPatterns = (options.imagesConfig?.remote_images ?? []).map( - (stringPattern) => new RegExp(stringPattern), - ) + #allowedRemoteUrlPatterns: RegExp[] + #logger: Logger + + constructor({ logger, imagesConfig }: ImageHandlerOptions) { + this.#logger = logger + this.#allowedRemoteUrlPatterns = (imagesConfig?.remote_images ?? []).reduce((acc, stringPattern) => { + try { + acc.push(new RegExp(stringPattern)) + } catch (maybeError) { + const error = maybeError instanceof Error ? maybeError : new Error(String(maybeError)) + this.#logger.warn(`Malformed remote image pattern: "${stringPattern}": ${error.message}. Skipping it.`) + } + return acc + }, []) } private generateIPXRequestURL(imageURL: URL, netlifyImageCdnParams: URLSearchParams): URL { @@ -83,7 +92,7 @@ export class ImageHandler { // if it's not local image, check if it it's allowed if ( sourceImageUrl.origin !== url.origin && - !this.allowedRemoteUrlPatterns.some((allowedRemoteUrlPattern) => + !this.#allowedRemoteUrlPatterns.some((allowedRemoteUrlPattern) => allowedRemoteUrlPattern.test(sourceImageUrl.href), ) ) { From 3541d01374ba962729dbd5ab96f5837501dec471 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 30 May 2025 13:49:21 +0200 Subject: [PATCH 06/33] test: add tests for remote images --- package-lock.json | 14 +++++++ packages/images/package.json | 1 + packages/images/src/main.test.ts | 68 +++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04fba070..6c6ec320 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6252,6 +6252,19 @@ "resolved": "https://registry.npmjs.org/image-meta/-/image-meta-0.2.1.tgz", "integrity": "sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==" }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -11079,6 +11092,7 @@ }, "devDependencies": { "@netlify/dev-utils": "^2.2.0", + "image-size": "^2.0.2", "tsup": "^8.5.0", "vitest": "^3.1.4" }, diff --git a/packages/images/package.json b/packages/images/package.json index cb554f3a..a00bc61f 100644 --- a/packages/images/package.json +++ b/packages/images/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@netlify/dev-utils": "^2.2.0", + "image-size": "^2.0.2", "tsup": "^8.5.0", "vitest": "^3.1.4" }, diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index eb483f92..8c1cc883 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,20 +1,25 @@ import type { Logger } from '@netlify/dev-utils' +import { imageSize } from 'image-size' import { describe, expect, test, vi } from 'vitest' import { ImageHandler } from './main.js' +function getMockLogger(): Logger { + return { + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + } +} + describe('`ImageHandler`', () => { describe('constructor', () => { test('warns about malformed remote image patterns', () => { - const logger = { - error: vi.fn(), - warn: vi.fn(), - log: vi.fn(), - } satisfies Logger + const logger = getMockLogger() new ImageHandler({ logger, imagesConfig: { - remote_images: ['https://example.com/images/*', 'invalid-regex['], + remote_images: ['https://example.com/images/.*', 'invalid-regex['], }, }) @@ -23,4 +28,55 @@ describe('`ImageHandler`', () => { ) }) }) + + describe('match', () => { + describe('remote images', () => { + test('allow remote images matching configured patterns', async () => { + const imageHandler = new ImageHandler({ + logger: getMockLogger(), + imagesConfig: { + remote_images: ['https://images.unsplash.com/.*'], + }, + }) + + const url = new URL('https://netlify.com/.netlify/images') + url.searchParams.set('url', 'https://images.unsplash.com/photo-1517849845537-4d257902454a') + url.searchParams.set('w', '100') + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + + const { width } = imageSize(await response.bytes()) + + expect(width).toBe(100) + }, 30_000) + + test('does not allow remote images not matching configured patterns', async () => { + const imageHandler = new ImageHandler({ + logger: getMockLogger(), + imagesConfig: { + remote_images: [], + }, + }) + + const url = new URL('https://netlify.com/.netlify/images') + url.searchParams.set('url', 'https://images.unsplash.com/photo-1517849845537-4d257902454a') + url.searchParams.set('w', '100') + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.status).toBe(403) + expect(await response.text()).toBe('Forbidden: Remote image URL not allowed') + }) + }) + }) }) From 868d6e332874dd62058e1f8df21d44e170fc71b9 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 2 Jun 2025 09:48:58 +0200 Subject: [PATCH 07/33] test: add tests for images endpoint and request method --- packages/images/src/main.test.ts | 115 ++++++++++++++++++++++++++++++- packages/images/src/main.ts | 1 + 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index 8c1cc883..ec1ba9ec 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,7 +1,8 @@ import type { Logger } from '@netlify/dev-utils' import { imageSize } from 'image-size' -import { describe, expect, test, vi } from 'vitest' +import { beforeEach, describe, expect, test, vi } from 'vitest' import { ImageHandler } from './main.js' +import { createIPXWebServer } from 'ipx' function getMockLogger(): Logger { return { @@ -11,6 +12,21 @@ function getMockLogger(): Logger { } } +const mockedIpxResponseBody = 'Response from IPX' + +vi.mock('ipx', async () => { + return { + ...(await vi.importActual('ipx')), + createIPXWebServer: vi.fn(() => () => Promise.resolve(new Response(mockedIpxResponseBody))), //.mockImplementation(), + } +}) + +const mockCreateIPXWebServer = vi.mocked(createIPXWebServer) + +beforeEach(() => { + mockCreateIPXWebServer.mockReset() +}) + describe('`ImageHandler`', () => { describe('constructor', () => { test('warns about malformed remote image patterns', () => { @@ -30,8 +46,101 @@ describe('`ImageHandler`', () => { }) describe('match', () => { + describe('image endpoints', () => { + test('matches on `/.netlify/images', async () => { + const imageHandler = new ImageHandler({ + logger: getMockLogger(), + }) + + const url = new URL('/.netlify/images', 'https://netlify.com') + url.searchParams.set('url', 'image.png') + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + expect(await response.text()).toBe(mockedIpxResponseBody) + }) + + test('matches on `/.netlify/images/', async () => { + const imageHandler = new ImageHandler({ + logger: getMockLogger(), + }) + + const url = new URL('/.netlify/images/', 'https://netlify.com') + url.searchParams.set('url', 'image.png') + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + expect(await response.text()).toBe(mockedIpxResponseBody) + }) + + test('does not match on `/.netlify/foo', async () => { + const imageHandler = new ImageHandler({ + logger: getMockLogger(), + }) + + const url = new URL('/.netlify/foo', 'https://netlify.com') + url.searchParams.set('url', 'image.png') + + const match = imageHandler.match(new Request(url)) + + expect(match).not.toBeDefined() + }) + }) + + describe('request methods', () => { + test('allows GET requests', async () => { + const imageHandler = new ImageHandler({ + logger: getMockLogger(), + }) + + const url = new URL('/.netlify/images', 'https://netlify.com') + url.searchParams.set('url', 'image.png') + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + expect(await response.text()).toBe(mockedIpxResponseBody) + }) + + test('does not allow POST requests', async () => { + const imageHandler = new ImageHandler({ + logger: getMockLogger(), + }) + + const url = new URL('/.netlify/images', 'https://netlify.com') + url.searchParams.set('url', 'image.png') + + const match = imageHandler.match(new Request(url, { method: 'POST' })) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(false) + expect(await response.text()).toBe('Method Not Allowed') + }) + }) + describe('remote images', () => { test('allow remote images matching configured patterns', async () => { + mockCreateIPXWebServer.mockImplementation( + (await vi.importActual('ipx')).createIPXWebServer, + ) + const imageHandler = new ImageHandler({ logger: getMockLogger(), imagesConfig: { @@ -39,7 +148,7 @@ describe('`ImageHandler`', () => { }, }) - const url = new URL('https://netlify.com/.netlify/images') + const url = new URL('/.netlify/images', 'https://netlify.com') url.searchParams.set('url', 'https://images.unsplash.com/photo-1517849845537-4d257902454a') url.searchParams.set('w', '100') @@ -64,7 +173,7 @@ describe('`ImageHandler`', () => { }, }) - const url = new URL('https://netlify.com/.netlify/images') + const url = new URL('/.netlify/images', 'https://netlify.com') url.searchParams.set('url', 'https://images.unsplash.com/photo-1517849845537-4d257902454a') url.searchParams.set('w', '100') diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts index ae5fb826..8582903b 100644 --- a/packages/images/src/main.ts +++ b/packages/images/src/main.ts @@ -102,6 +102,7 @@ export class ImageHandler { const ipx = createIPX({ storage: ipxFSStorage(), httpStorage: ipxHttpStorage({ + // checking if url is allowed is done above, so we disable IPX checking allowAllDomains: true, }), }) From a2b1065b3f5152b36890ab95319ffd45f5188404 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 09:35:02 +0200 Subject: [PATCH 08/33] post-merge fixes --- package-lock.json | 162 +---------------------------------- packages/dev/src/main.ts | 5 +- packages/images/package.json | 2 +- 3 files changed, 5 insertions(+), 164 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0fa99aa..ec3b0054 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7775,24 +7775,6 @@ "dev": true, "license": "MIT" }, - "node_modules/netlify": { - "version": "13.3.5", - "resolved": "https://registry.npmjs.org/netlify/-/netlify-13.3.5.tgz", - "integrity": "sha512-Nc3loyVASW59W+8fLDZT1lncpG7llffyZ2o0UQLx/Fr20i7P8oP+lE7+TEcFvXj9IUWU6LjB9P3BH+iFGyp+mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@netlify/open-api": "^2.37.0", - "lodash-es": "^4.17.21", - "micro-api-client": "^3.3.0", - "node-fetch": "^3.0.0", - "p-wait-for": "^5.0.0", - "qs": "^6.9.6" - }, - "engines": { - "node": "^14.16.0 || >=16.0.0" - } - }, "node_modules/netlify-redirector": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/netlify-redirector/-/netlify-redirector-0.5.0.tgz", @@ -10825,20 +10807,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/write-file-atomic": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", - "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/xss": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", @@ -11491,7 +11459,7 @@ "ipx": "^3.0.3" }, "devDependencies": { - "@netlify/dev-utils": "^2.2.0", + "@netlify/dev-utils": "^3.1.1", "image-size": "^2.0.2", "tsup": "^8.5.0", "vitest": "^3.1.4" @@ -11500,134 +11468,6 @@ "node": "^18.14.0 || >=20" } }, - "packages/images/node_modules/@netlify/dev-utils": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-2.2.0.tgz", - "integrity": "sha512-5XUvZuffe3KetyhbWwd4n2ktd7wraocCYw10tlM+/u/95iAz29GjNiuNxbCD1T6Bn1MyGc4QLVNKOWhzJkVFAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/server": "^0.9.60", - "chokidar": "^4.0.1", - "decache": "^4.6.2", - "dot-prop": "9.0.0", - "env-paths": "^3.0.0", - "find-up": "7.0.0", - "lodash.debounce": "^4.0.8", - "netlify": "^13.3.5", - "parse-gitignore": "^2.0.0", - "uuid": "^11.1.0", - "write-file-atomic": "^6.0.0" - }, - "engines": { - "node": "^14.16.0 || >=16.0.0" - } - }, - "packages/images/node_modules/@whatwg-node/server": { - "version": "0.9.71", - "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.9.71.tgz", - "integrity": "sha512-ueFCcIPaMgtuYDS9u0qlUoEvj6GiSsKrwnOLPp9SshqjtcRaR1IEHRjoReq3sXNydsF5i0ZnmuYgXq9dV53t0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/fetch": "^0.10.5", - "@whatwg-node/promise-helpers": "^1.2.2", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "packages/images/node_modules/find-up": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", - "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^7.2.0", - "path-exists": "^5.0.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/images/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/images/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/images/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/images/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "packages/images/node_modules/yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/otel": { "name": "@netlify/otel", "version": "3.0.2", diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index c19e0486..7ba9ab87 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -131,7 +131,7 @@ interface HandleOptions { headersCollector?: HeadersCollector } -export type ResponseType = 'edge-function' | 'function' | 'redirect' | 'static' +export type ResponseType = 'edge-function' | 'function' | 'image' | 'redirect' | 'static' export class NetlifyDev { #apiHost?: string @@ -272,7 +272,8 @@ export class NetlifyDev { // 4. Check if the request matches an image. const imageMatch = this.#imageHandler?.match(matchRequest) if (imageMatch) { - return imageMatch.handle() + const response = await imageMatch.handle() + return { response, type: 'image' } } // 5. Check if the request matches a static file. diff --git a/packages/images/package.json b/packages/images/package.json index a00bc61f..c698003d 100644 --- a/packages/images/package.json +++ b/packages/images/package.json @@ -33,7 +33,7 @@ "test": "test" }, "devDependencies": { - "@netlify/dev-utils": "^2.2.0", + "@netlify/dev-utils": "^3.1.1", "image-size": "^2.0.2", "tsup": "^8.5.0", "vitest": "^3.1.4" From 99db75e520500c0dfd600433924473d028351996 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 09:35:18 +0200 Subject: [PATCH 09/33] adjust feature support matrix --- packages/dev/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dev/README.md b/packages/dev/README.md index cbb7fb49..a1c8e79a 100644 --- a/packages/dev/README.md +++ b/packages/dev/README.md @@ -8,7 +8,7 @@ users, it is primarily designed as a foundational library for higher-level tools [Netlify Vite Plugin](https://docs.netlify.com/integrations/vite/overview/). It provides a local request pipeline that mimics the Netlify platformโ€™s request handling, including support for -Functions, Blobs, Static files, and Redirects. +Functions, Blobs, Static files, Redirects, and Image CDN. ## ๐Ÿšง Feature Support @@ -21,7 +21,7 @@ Functions, Blobs, Static files, and Redirects. | Redirects and Rewrites | โœ… Yes | | Headers | โœ… Yes | | Environment Variables | โœ… Yes | -| Image CDN | โŒ No | +| Image CDN | โœ… Yes | > Note: Missing features will be added incrementally. This module is **not** intended to be a full replacement for the > Netlify CLI. From 2e7f8fb58dd2b16acf6857400d76056a1c2374d5 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 09:51:12 +0200 Subject: [PATCH 10/33] test: adjust assertions for added feature --- packages/vite-plugin/src/main.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 7227853d..08c40c3a 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -221,7 +221,7 @@ defined on your team and site and much more. Run npx netlify init to get started expect(mockLogger.info).toHaveBeenNthCalledWith(1, 'Environment loaded', expect.objectContaining({})) expect(mockLogger.info).toHaveBeenNthCalledWith( 2, - 'Middleware loaded. Emulating features: blobs, environmentVariables, functions, headers, redirects, static.', + 'Middleware loaded. Emulating features: blobs, environmentVariables, functions, headers, images, redirects, static.', expect.objectContaining({}), ) expect(mockLogger.info).toHaveBeenNthCalledWith( From d6476cd860b47924fb124c5fa986c33a2a80e69d Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 09:56:10 +0200 Subject: [PATCH 11/33] chore: fix lint --- eslint_temporary_suppressions.js | 6 ++++++ packages/dev/src/main.ts | 10 +--------- packages/images/src/main.test.ts | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/eslint_temporary_suppressions.js b/eslint_temporary_suppressions.js index 1224e45b..8f7ee3b3 100644 --- a/eslint_temporary_suppressions.js +++ b/eslint_temporary_suppressions.js @@ -705,6 +705,12 @@ export default [ 'n/no-unsupported-features/node-builtins': 'off', }, }, + { + files: ['packages/images/src/main.test.ts'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, { files: ['packages/otel/src/bootstrap/main.ts'], rules: { diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index 7ba9ab87..53b8e565 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -4,15 +4,7 @@ import path from 'node:path' import process from 'node:process' import { resolveConfig } from '@netlify/config' -import { - ensureNetlifyIgnore, - getAPIToken, - mockLocation, - LocalState, - type Logger, - HTTPServer, - netlifyCommand, -} from '@netlify/dev-utils' +import { ensureNetlifyIgnore, getAPIToken, mockLocation, LocalState, type Logger, HTTPServer } from '@netlify/dev-utils' import { EdgeFunctionsHandler } from '@netlify/edge-functions/dev' import { FunctionsHandler } from '@netlify/functions/dev' import { HeadersHandler, type HeadersCollector } from '@netlify/headers' diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index ec1ba9ec..149b50fd 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -83,7 +83,7 @@ describe('`ImageHandler`', () => { expect(await response.text()).toBe(mockedIpxResponseBody) }) - test('does not match on `/.netlify/foo', async () => { + test('does not match on `/.netlify/foo', () => { const imageHandler = new ImageHandler({ logger: getMockLogger(), }) From a49fdc570f3967151628ac446808fe8d544c6ddf Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 10:07:25 +0200 Subject: [PATCH 12/33] test: adjust to not use .bytes(), match mocked response, not response text --- packages/images/src/main.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index 149b50fd..17e484a3 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -12,12 +12,12 @@ function getMockLogger(): Logger { } } -const mockedIpxResponseBody = 'Response from IPX' +const mockedIpxResponse = new Response('Mocked response from IPX') vi.mock('ipx', async () => { return { ...(await vi.importActual('ipx')), - createIPXWebServer: vi.fn(() => () => Promise.resolve(new Response(mockedIpxResponseBody))), //.mockImplementation(), + createIPXWebServer: vi.fn(() => () => Promise.resolve(mockedIpxResponse.clone())), } }) @@ -62,7 +62,7 @@ describe('`ImageHandler`', () => { const response = await match!.handle() expect(response.ok).toBe(true) - expect(await response.text()).toBe(mockedIpxResponseBody) + expect(response).toMatchObject(mockedIpxResponse) }) test('matches on `/.netlify/images/', async () => { @@ -80,7 +80,7 @@ describe('`ImageHandler`', () => { const response = await match!.handle() expect(response.ok).toBe(true) - expect(await response.text()).toBe(mockedIpxResponseBody) + expect(response).toMatchObject(mockedIpxResponse) }) test('does not match on `/.netlify/foo', () => { @@ -113,7 +113,7 @@ describe('`ImageHandler`', () => { const response = await match!.handle() expect(response.ok).toBe(true) - expect(await response.text()).toBe(mockedIpxResponseBody) + expect(response).toMatchObject(mockedIpxResponse) }) test('does not allow POST requests', async () => { @@ -160,7 +160,7 @@ describe('`ImageHandler`', () => { expect(response.ok).toBe(true) - const { width } = imageSize(await response.bytes()) + const { width } = imageSize(new Uint8Array(await response.arrayBuffer())) expect(width).toBe(100) }, 30_000) From 281e4bbb6339815b3523467e92741fd97fab4d35 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 11:23:37 +0200 Subject: [PATCH 13/33] fix: use originServerAddress --- packages/dev/src/main.ts | 1 + packages/images/src/main.test.ts | 3 +++ packages/images/src/main.ts | 21 +++++++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index 53b8e565..ae66a2aa 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -475,6 +475,7 @@ export class NetlifyDev { this.#imageHandler = new ImageHandler({ imagesConfig: this.#config?.config.images, logger: this.#logger, + originServerAddress: serverAddress, }) } diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index 17e484a3..c094ca74 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -50,6 +50,7 @@ describe('`ImageHandler`', () => { test('matches on `/.netlify/images', async () => { const imageHandler = new ImageHandler({ logger: getMockLogger(), + originServerAddress: 'http://localhost:5173', }) const url = new URL('/.netlify/images', 'https://netlify.com') @@ -68,6 +69,7 @@ describe('`ImageHandler`', () => { test('matches on `/.netlify/images/', async () => { const imageHandler = new ImageHandler({ logger: getMockLogger(), + originServerAddress: 'http://localhost:5173', }) const url = new URL('/.netlify/images/', 'https://netlify.com') @@ -101,6 +103,7 @@ describe('`ImageHandler`', () => { test('allows GET requests', async () => { const imageHandler = new ImageHandler({ logger: getMockLogger(), + originServerAddress: 'http://localhost:5173', }) const url = new URL('/.netlify/images', 'https://netlify.com') diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts index 8582903b..aa2ea828 100644 --- a/packages/images/src/main.ts +++ b/packages/images/src/main.ts @@ -8,6 +8,7 @@ interface ImagesConfig { interface ImageHandlerOptions { imagesConfig?: ImagesConfig logger: Logger + originServerAddress?: string } export interface ImageMatch { @@ -19,8 +20,9 @@ const IMAGE_CDN_ENDPOINTS = ['/.netlify/images', '/.netlify/images/'] export class ImageHandler { #allowedRemoteUrlPatterns: RegExp[] #logger: Logger + #originServerURL?: URL - constructor({ logger, imagesConfig }: ImageHandlerOptions) { + constructor({ logger, imagesConfig, originServerAddress }: ImageHandlerOptions) { this.#logger = logger this.#allowedRemoteUrlPatterns = (imagesConfig?.remote_images ?? []).reduce((acc, stringPattern) => { try { @@ -31,6 +33,7 @@ export class ImageHandler { } return acc }, []) + this.#originServerURL = originServerAddress ? new URL(originServerAddress) : undefined } private generateIPXRequestURL(imageURL: URL, netlifyImageCdnParams: URLSearchParams): URL { @@ -86,12 +89,22 @@ export class ImageHandler { return new Response('Bad Request: Missing "url" query parameter', { status: 400 }) } - // TODO: use serverAddress instead of url.origin once https://github.com/netlify/primitives/pull/233 is merged - const sourceImageUrl = new URL(sourceImageUrlParam, url.origin) + let sourceImageUrl: URL + try { + sourceImageUrl = new URL(sourceImageUrlParam, this.#originServerURL) + } catch (error) { + throw new Error( + `Failed to construct source image URL from "${sourceImageUrlParam}".` + + (!this.#originServerURL && !sourceImageUrlParam.startsWith('http') + ? '\nLooks like source image is local and `originServerAddress` was not provided.' + : ''), + { cause: error }, + ) + } // if it's not local image, check if it it's allowed if ( - sourceImageUrl.origin !== url.origin && + sourceImageUrl.origin !== this.#originServerURL?.origin && !this.#allowedRemoteUrlPatterns.some((allowedRemoteUrlPattern) => allowedRemoteUrlPattern.test(sourceImageUrl.href), ) From 3d8fcc20c35fd601d38fed446815a66e4be543fd Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 11:59:22 +0200 Subject: [PATCH 14/33] chore: remove not existign server.d.ts from package.json#files --- packages/images/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/images/package.json b/packages/images/package.json index c698003d..a29eb40c 100644 --- a/packages/images/package.json +++ b/packages/images/package.json @@ -10,8 +10,7 @@ "exports": "./dist/main.js", "types": "./dist/main.d.ts", "files": [ - "dist/**/*", - "server.d.ts" + "dist/**/*" ], "scripts": { "build": "tsup-node", From 5f667c9d264fc704c000f558b13909265f168804 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 12:13:27 +0200 Subject: [PATCH 15/33] test: use mock logger from dev-utils --- packages/images/src/main.test.ts | 34 +++++++++++++------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index c094ca74..d02b0d88 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,17 +1,9 @@ -import type { Logger } from '@netlify/dev-utils' +import { createMockLogger } from '@netlify/dev-utils' import { imageSize } from 'image-size' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { ImageHandler } from './main.js' import { createIPXWebServer } from 'ipx' -function getMockLogger(): Logger { - return { - error: vi.fn(), - warn: vi.fn(), - log: vi.fn(), - } -} - const mockedIpxResponse = new Response('Mocked response from IPX') vi.mock('ipx', async () => { @@ -30,7 +22,7 @@ beforeEach(() => { describe('`ImageHandler`', () => { describe('constructor', () => { test('warns about malformed remote image patterns', () => { - const logger = getMockLogger() + const logger = { ...createMockLogger(), warn: vi.fn() } new ImageHandler({ logger, @@ -49,7 +41,7 @@ describe('`ImageHandler`', () => { describe('image endpoints', () => { test('matches on `/.netlify/images', async () => { const imageHandler = new ImageHandler({ - logger: getMockLogger(), + logger: createMockLogger(), originServerAddress: 'http://localhost:5173', }) @@ -68,7 +60,7 @@ describe('`ImageHandler`', () => { test('matches on `/.netlify/images/', async () => { const imageHandler = new ImageHandler({ - logger: getMockLogger(), + logger: createMockLogger(), originServerAddress: 'http://localhost:5173', }) @@ -87,7 +79,7 @@ describe('`ImageHandler`', () => { test('does not match on `/.netlify/foo', () => { const imageHandler = new ImageHandler({ - logger: getMockLogger(), + logger: createMockLogger(), }) const url = new URL('/.netlify/foo', 'https://netlify.com') @@ -102,7 +94,7 @@ describe('`ImageHandler`', () => { describe('request methods', () => { test('allows GET requests', async () => { const imageHandler = new ImageHandler({ - logger: getMockLogger(), + logger: createMockLogger(), originServerAddress: 'http://localhost:5173', }) @@ -121,7 +113,7 @@ describe('`ImageHandler`', () => { test('does not allow POST requests', async () => { const imageHandler = new ImageHandler({ - logger: getMockLogger(), + logger: createMockLogger(), }) const url = new URL('/.netlify/images', 'https://netlify.com') @@ -145,15 +137,17 @@ describe('`ImageHandler`', () => { ) const imageHandler = new ImageHandler({ - logger: getMockLogger(), + logger: createMockLogger(), imagesConfig: { remote_images: ['https://images.unsplash.com/.*'], }, }) + const requestedWidth = 100 + const url = new URL('/.netlify/images', 'https://netlify.com') url.searchParams.set('url', 'https://images.unsplash.com/photo-1517849845537-4d257902454a') - url.searchParams.set('w', '100') + url.searchParams.set('w', requestedWidth.toString()) const match = imageHandler.match(new Request(url)) @@ -165,12 +159,12 @@ describe('`ImageHandler`', () => { const { width } = imageSize(new Uint8Array(await response.arrayBuffer())) - expect(width).toBe(100) + expect(width).toBe(requestedWidth) }, 30_000) test('does not allow remote images not matching configured patterns', async () => { const imageHandler = new ImageHandler({ - logger: getMockLogger(), + logger: createMockLogger(), imagesConfig: { remote_images: [], }, From 65aa6b31d7075edce1311d16a0b5575a9021f86b Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 12:40:01 +0200 Subject: [PATCH 16/33] test: add first local image test --- eslint_temporary_suppressions.js | 1 + package-lock.json | 18 +++++++ packages/images/package.json | 1 + packages/images/src/main.test.ts | 81 +++++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/eslint_temporary_suppressions.js b/eslint_temporary_suppressions.js index 8f7ee3b3..0c70ba09 100644 --- a/eslint_temporary_suppressions.js +++ b/eslint_temporary_suppressions.js @@ -709,6 +709,7 @@ export default [ files: ['packages/images/src/main.test.ts'], rules: { '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', }, }, { diff --git a/package-lock.json b/package-lock.json index ec3b0054..c9a0e630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6927,6 +6927,23 @@ "node": ">=10" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js-image-generator": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/js-image-generator/-/js-image-generator-1.0.4.tgz", + "integrity": "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "jpeg-js": "^0.4.2" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11461,6 +11478,7 @@ "devDependencies": { "@netlify/dev-utils": "^3.1.1", "image-size": "^2.0.2", + "js-image-generator": "^1.0.4", "tsup": "^8.5.0", "vitest": "^3.1.4" }, diff --git a/packages/images/package.json b/packages/images/package.json index a29eb40c..c288772f 100644 --- a/packages/images/package.json +++ b/packages/images/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@netlify/dev-utils": "^3.1.1", "image-size": "^2.0.2", + "js-image-generator": "^1.0.4", "tsup": "^8.5.0", "vitest": "^3.1.4" }, diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index d02b0d88..2c139913 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,8 +1,12 @@ +import http from 'node:http' + import { createMockLogger } from '@netlify/dev-utils' import { imageSize } from 'image-size' import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import { ImageHandler } from './main.js' import { createIPXWebServer } from 'ipx' +import { generateImage } from 'js-image-generator' + +import { ImageHandler } from './main.js' const mockedIpxResponse = new Response('Mocked response from IPX') @@ -130,6 +134,81 @@ describe('`ImageHandler`', () => { }) }) + describe('local images', () => { + let originServer: http.Server + let originServerAddress: string + const LOCAL_IMAGE_PATH = '/local/image.jpg' + const LOCAL_IMAGE_WIDTH = 800 + const LOCAL_IMAGE_HEIGHT = 600 + + beforeAll(async () => { + ;[originServer, originServerAddress] = await new Promise<[http.Server, string]>((resolve, reject) => { + const originServer = http.createServer(function originHandler(req, res) { + if (req.url !== LOCAL_IMAGE_PATH) { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + return + } + + generateImage(LOCAL_IMAGE_WIDTH, LOCAL_IMAGE_HEIGHT, 50, (err, imageData) => { + if (err) { + console.error('Error generating image:', err) + res.writeHead(500, { 'Content-Type': 'text/plain' }) + res.end('Internal Server Error') + return + } + + res.writeHead(200, { 'Content-Type': 'image/jpeg' }) + + res.end(imageData.data) + }) + }) + originServer.listen(() => { + const address = originServer.address() + + if (!address || typeof address === 'string') { + reject(new Error('Server cannot be started on a pipe or Unix socket')) + return + } + + resolve([originServer, `http://localhost:${address.port.toString()}`]) + }) + }) + }) + + afterAll(() => { + originServer.close() + }) + + beforeEach(async () => { + mockCreateIPXWebServer.mockImplementation( + (await vi.importActual('ipx')).createIPXWebServer, + ) + }) + + test('preserves original width if width param is not used', async () => { + const imageHandler = new ImageHandler({ + logger: createMockLogger(), + originServerAddress, + }) + + const url = new URL('/.netlify/images', originServerAddress) + url.searchParams.set('url', LOCAL_IMAGE_PATH) + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + + const { width } = imageSize(new Uint8Array(await response.arrayBuffer())) + + expect(width).toBe(LOCAL_IMAGE_WIDTH) + }) + }) + describe('remote images', () => { test('allow remote images matching configured patterns', async () => { mockCreateIPXWebServer.mockImplementation( From bff46f9534b518a374f2ab13281b6cb8ab3ed9d2 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 12:52:27 +0200 Subject: [PATCH 17/33] test: add tests for resizing --- packages/images/src/main.test.ts | 83 +++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index 2c139913..e514f70b 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -139,7 +139,7 @@ describe('`ImageHandler`', () => { let originServerAddress: string const LOCAL_IMAGE_PATH = '/local/image.jpg' const LOCAL_IMAGE_WIDTH = 800 - const LOCAL_IMAGE_HEIGHT = 600 + const LOCAL_IMAGE_HEIGHT = 400 beforeAll(async () => { ;[originServer, originServerAddress] = await new Promise<[http.Server, string]>((resolve, reject) => { @@ -207,6 +207,87 @@ describe('`ImageHandler`', () => { expect(width).toBe(LOCAL_IMAGE_WIDTH) }) + + test('resizes image to specified width preserving aspect ratio', async () => { + const imageHandler = new ImageHandler({ + logger: createMockLogger(), + originServerAddress, + }) + + const requestedWidth = 200 + + const url = new URL('/.netlify/images', originServerAddress) + url.searchParams.set('url', LOCAL_IMAGE_PATH) + url.searchParams.set('w', requestedWidth.toString()) + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + + const { width, height } = imageSize(new Uint8Array(await response.arrayBuffer())) + + expect(width).toBe(requestedWidth) + expect(width / height).toBe(LOCAL_IMAGE_WIDTH / LOCAL_IMAGE_HEIGHT) + }) + + test('resizes image to specified height preserving aspect ratio', async () => { + const imageHandler = new ImageHandler({ + logger: createMockLogger(), + originServerAddress, + }) + + const requestedHeight = 200 + + const url = new URL('/.netlify/images', originServerAddress) + url.searchParams.set('url', LOCAL_IMAGE_PATH) + url.searchParams.set('h', requestedHeight.toString()) + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + + const { width, height } = imageSize(new Uint8Array(await response.arrayBuffer())) + + expect(height).toBe(requestedHeight) + expect(width / height).toBe(LOCAL_IMAGE_WIDTH / LOCAL_IMAGE_HEIGHT) + }) + + test('resizes image to specified width and height ignoring original aspect ratio', async () => { + const imageHandler = new ImageHandler({ + logger: createMockLogger(), + originServerAddress, + }) + + const requestedWidth = 200 + const requestedHeight = 200 + + const url = new URL('/.netlify/images', originServerAddress) + url.searchParams.set('url', LOCAL_IMAGE_PATH) + url.searchParams.set('w', requestedWidth.toString()) + url.searchParams.set('h', requestedHeight.toString()) + + const match = imageHandler.match(new Request(url)) + + expect(match).toBeDefined() + + const response = await match!.handle() + + expect(response.ok).toBe(true) + + const { width, height } = imageSize(new Uint8Array(await response.arrayBuffer())) + + expect(width).toBe(requestedWidth) + expect(height).toBe(requestedHeight) + expect(width / height).not.toBe(LOCAL_IMAGE_WIDTH / LOCAL_IMAGE_HEIGHT) + }) }) describe('remote images', () => { From be1420c851e15c3d64d42afc7c04e1e8594e61b3 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 13:30:32 +0200 Subject: [PATCH 18/33] test: test image cdn handling in vite --- package-lock.json | 4 +- packages/dev-utils/package.json | 1 + packages/dev-utils/src/main.ts | 1 + packages/dev-utils/src/test/fixture.ts | 4 +- packages/dev-utils/src/test/generate-image.ts | 16 ++++ packages/images/package.json | 1 - packages/images/src/main.test.ts | 22 +++-- packages/vite-plugin/src/main.test.ts | 83 ++++++++++++++++++- 8 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 packages/dev-utils/src/test/generate-image.ts diff --git a/package-lock.json b/package-lock.json index c9a0e630..5bdfa040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6931,14 +6931,12 @@ "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/js-image-generator": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/js-image-generator/-/js-image-generator-1.0.4.tgz", "integrity": "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==", - "dev": true, "license": "ISC", "dependencies": { "jpeg-js": "^0.4.2" @@ -11116,6 +11114,7 @@ "dot-prop": "9.0.0", "env-paths": "^3.0.0", "find-up": "7.0.0", + "js-image-generator": "^1.0.4", "lodash.debounce": "^4.0.8", "parse-gitignore": "^2.0.0", "uuid": "^11.1.0", @@ -11478,7 +11477,6 @@ "devDependencies": { "@netlify/dev-utils": "^3.1.1", "image-size": "^2.0.2", - "js-image-generator": "^1.0.4", "tsup": "^8.5.0", "vitest": "^3.1.4" }, diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 3d28d17a..2b3d1e13 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -58,6 +58,7 @@ "dot-prop": "9.0.0", "env-paths": "^3.0.0", "find-up": "7.0.0", + "js-image-generator": "^1.0.4", "lodash.debounce": "^4.0.8", "parse-gitignore": "^2.0.0", "uuid": "^11.1.0", diff --git a/packages/dev-utils/src/main.ts b/packages/dev-utils/src/main.ts index 62735563..98c2a1df 100644 --- a/packages/dev-utils/src/main.ts +++ b/packages/dev-utils/src/main.ts @@ -16,4 +16,5 @@ export { watchDebounced } from './lib/watch-debounced.js' export { EventInspector } from './test/event_inspector.js' export { MockFetch } from './test/fetch.js' export { Fixture } from './test/fixture.js' +export { generateImage } from './test/generate-image.js' export { createMockLogger } from './test/logger.js' diff --git a/packages/dev-utils/src/test/fixture.ts b/packages/dev-utils/src/test/fixture.ts index 72c66cfd..25b310ef 100644 --- a/packages/dev-utils/src/test/fixture.ts +++ b/packages/dev-utils/src/test/fixture.ts @@ -9,7 +9,7 @@ import tmp from 'tmp-promise' const run = promisify(exec) export class Fixture { directory?: tmp.DirectoryResult - files: Record + files: Record npmDependencies: Record constructor() { @@ -87,7 +87,7 @@ export class Fixture { await fs.rm(this.directory!.path, { force: true, recursive: true }) } - withFile(path: string, contents: string) { + withFile(path: string, contents: string | Buffer) { this.files[path] = contents return this diff --git a/packages/dev-utils/src/test/generate-image.ts b/packages/dev-utils/src/test/generate-image.ts new file mode 100644 index 00000000..37f91b16 --- /dev/null +++ b/packages/dev-utils/src/test/generate-image.ts @@ -0,0 +1,16 @@ +import { generateImage as generateImageCallback } from 'js-image-generator' + +export async function generateImage(width: number, height: number): Promise { + return new Promise((resolve, reject) => { + generateImageCallback(width, height, 80, (error, image) => { + if (error) { + reject(error) + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const imageBuffer = image.data as Buffer + + resolve(imageBuffer) + } + }) + }) +} diff --git a/packages/images/package.json b/packages/images/package.json index c288772f..a29eb40c 100644 --- a/packages/images/package.json +++ b/packages/images/package.json @@ -34,7 +34,6 @@ "devDependencies": { "@netlify/dev-utils": "^3.1.1", "image-size": "^2.0.2", - "js-image-generator": "^1.0.4", "tsup": "^8.5.0", "vitest": "^3.1.4" }, diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index e514f70b..467596a0 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,10 +1,9 @@ import http from 'node:http' -import { createMockLogger } from '@netlify/dev-utils' +import { createMockLogger, generateImage } from '@netlify/dev-utils' import { imageSize } from 'image-size' import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { createIPXWebServer } from 'ipx' -import { generateImage } from 'js-image-generator' import { ImageHandler } from './main.js' @@ -150,18 +149,17 @@ describe('`ImageHandler`', () => { return } - generateImage(LOCAL_IMAGE_WIDTH, LOCAL_IMAGE_HEIGHT, 50, (err, imageData) => { - if (err) { - console.error('Error generating image:', err) + generateImage(LOCAL_IMAGE_WIDTH, LOCAL_IMAGE_HEIGHT) + .then((imageData) => { + res.writeHead(200, { 'Content-Type': 'image/jpeg' }) + + res.end(imageData) + }) + .catch((error: unknown) => { + console.error('Error generating image', error) res.writeHead(500, { 'Content-Type': 'text/plain' }) res.end('Internal Server Error') - return - } - - res.writeHead(200, { 'Content-Type': 'image/jpeg' }) - - res.end(imageData.data) - }) + }) }) originServer.listen(() => { const address = originServer.address() diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 248303df..30e1b36a 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -3,8 +3,8 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Fixture } from '@netlify/dev-utils' -import { type Browser, type ConsoleMessage, type Page, chromium } from 'playwright' +import { Fixture, generateImage } from '@netlify/dev-utils' +import { type Browser, type ConsoleMessage, type Locator, type Page, chromium } from 'playwright' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { createServer } from 'vite' @@ -429,6 +429,85 @@ defined on your team and site and much more. Run npx netlify init to get started await server.close() await fixture.destroy() }) + + test('Handles Image CDN requests', async () => { + const fixture = new Fixture() + .withFile( + 'netlify.toml', + `[images] + remote_images = [ + "https://images.unsplash.com/photo-1517849845537.*" + ]`, + ) + .withFile( + 'vite.config.js', + `import { defineConfig } from 'vite'; + import netlify from '@netlify/vite-plugin'; + + export default defineConfig({ + plugins: [ + netlify({ + middleware: true, + }) + ] + });`, + ) + .withFile( + 'index.html', + ` + + Hello World + +

Hello from the browser

+ + + + + `, + ) + .withFile('local/image.jpg', await generateImage(800, 400)) + + const directory = await fixture.create() + await fixture + .withPackages({ + vite: '6.0.0', + '@netlify/vite-plugin': pathToFileURL(path.resolve(directory, PLUGIN_PATH)).toString(), + }) + .create() + + const mockLogger = createMockViteLogger() + const { server, url } = await startTestServer({ + root: directory, + logLevel: 'info', + customLogger: mockLogger, + }) + + await page.goto(url) + + const getImageSize = (locator: Locator) => { + return locator.evaluate((img: HTMLImageElement) => { + if (img.naturalWidth === 0 || img.naturalHeight === 0) { + throw new Error(`Image was not loaded`) + } + + return { + width: img.naturalWidth, + height: img.naturalHeight, + } + }) + } + + expect(await getImageSize(page.locator('#local-image'))).toEqual({ width: 100, height: 50 }) + expect(await getImageSize(page.locator('#allowed-remote-image'))).toEqual({ width: 100, height: 133 }) + + await expect( + async () => await getImageSize(page.locator('#not-allowed-remote-image')), + 'Not allowed remote image should not load', + ).rejects.toThrowError(`Image was not loaded`) + + await server.close() + await fixture.destroy() + }) }) describe('With @vitejs/plugin-react', () => { From 6986b42f0765cf4c33461906887cd19eb2c82e2e Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 13:35:55 +0200 Subject: [PATCH 19/33] chore: add jsdoc for generateImage --- packages/dev-utils/src/test/generate-image.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/dev-utils/src/test/generate-image.ts b/packages/dev-utils/src/test/generate-image.ts index 37f91b16..365abd7c 100644 --- a/packages/dev-utils/src/test/generate-image.ts +++ b/packages/dev-utils/src/test/generate-image.ts @@ -1,5 +1,8 @@ import { generateImage as generateImageCallback } from 'js-image-generator' +/** + * Returns Buffer of a generated random noise jpeg image with the specified width and height. + */ export async function generateImage(width: number, height: number): Promise { return new Promise((resolve, reject) => { generateImageCallback(width, height, 80, (error, image) => { From 2c53d928db272e12607253b90251583d27425670 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 15:33:29 +0200 Subject: [PATCH 20/33] chore: no need to supress n/no-unsupported-features/node-builtins --- eslint_temporary_suppressions.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/eslint_temporary_suppressions.js b/eslint_temporary_suppressions.js index 0c70ba09..9dbebc5a 100644 --- a/eslint_temporary_suppressions.js +++ b/eslint_temporary_suppressions.js @@ -699,12 +699,6 @@ export default [ '@typescript-eslint/no-unsafe-assignment': 'off', }, }, - { - files: ['packages/images/src/main.ts'], - rules: { - 'n/no-unsupported-features/node-builtins': 'off', - }, - }, { files: ['packages/images/src/main.test.ts'], rules: { From 5c473d8f81097ef3511d6f2fdbbeb6f404eedcbb Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 17:27:32 +0200 Subject: [PATCH 21/33] docs: fix shield url for netlify/images --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 462a961d..d4b9e34c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ npm run build --workspaces=true | ๐Ÿ”ง [@netlify/dev-utils](packages/dev-utils) | TypeScript utilities for the local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/dev-utils.svg)](https://www.npmjs.com/package/@netlify/dev-utils) | | โšก [@netlify/functions](packages/functions) | TypeScript utilities for interacting with Netlify Functions | [![npm version](https://img.shields.io/npm/v/@netlify/functions.svg)](https://www.npmjs.com/package/@netlify/functions) | | ๐Ÿ“‹ [@netlify/headers](packages/headers) | TypeScript implementation of Netlify's headers engine | [![npm version](https://img.shields.io/npm/v/@netlify/headers.svg)](https://www.npmjs.com/package/@netlify/headers) | -| ๐Ÿ–ผ๏ธ [@netlify/images](packages/images) | TypeScript utilities for interacting with Netlify Image CDN | [![npm version](https://img.shields.io/npm/v/@netlify/functions.svg)](https://www.npmjs.com/package/@netlify/images) | +| ๐Ÿ–ผ๏ธ [@netlify/images](packages/images) | TypeScript utilities for interacting with Netlify Image CDN | [![npm version](https://img.shields.io/npm/v/@netlify/images.svg)](https://www.npmjs.com/package/@netlify/images) | | ๐Ÿ” [@netlify/otel](packages/otel) | TypeScript utilities to interact with Netlify's OpenTelemetry | [![npm version](https://img.shields.io/npm/v/@netlify/otel.svg)](https://www.npmjs.com/package/@netlify/otel) | | ๐Ÿ”„ [@netlify/redirects](packages/redirects) | TypeScript implementation of Netlify's rewrites and redirects engine | [![npm version](https://img.shields.io/npm/v/@netlify/redirects.svg)](https://www.npmjs.com/package/@netlify/redirects) | | ๐Ÿ›๏ธ [@netlify/runtime](packages/runtime) | Netlify compute runtime | [![npm version](https://img.shields.io/npm/v/@netlify/runtime.svg)](https://www.npmjs.com/package/@netlify/runtime) | From 723270e688e762bed6db85d8de4fc93b222dc7b6 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 17:32:12 +0200 Subject: [PATCH 22/33] fix: drop node 18 --- packages/images/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/images/package.json b/packages/images/package.json index a29eb40c..f2426e58 100644 --- a/packages/images/package.json +++ b/packages/images/package.json @@ -1,10 +1,10 @@ { "name": "@netlify/images", "version": "0.0.0", - "description": "TypeScript implementtion of Netlify's Image CDN", + "description": "TypeScript implementation of Netlify's Image CDN", "type": "module", "engines": { - "node": "^18.14.0 || >=20" + "node": ">=20.6.1" }, "main": "./dist/main.js", "exports": "./dist/main.js", From d5755321858879ddf4617259f46fd34c1186f98d Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 17:32:52 +0200 Subject: [PATCH 23/33] chore: add images to .release-please-manifest.json --- .release-please-manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9727bff7..55152259 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -6,6 +6,7 @@ "packages/edge-functions": "2.14.2", "packages/functions": "4.1.2", "packages/headers": "2.0.1", + "packages/images": "0.0.0", "packages/otel": "3.0.2", "packages/redirects": "3.0.1", "packages/runtime": "4.0.2", From 7621a4d52b17a74085466ae16b70a5c3a8a04bc0 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 18:49:20 +0200 Subject: [PATCH 24/33] fix: spin up server when images are enabled and there is no origin server provided --- package-lock.json | 5 +- packages/dev-utils/package.json | 1 + packages/dev-utils/src/main.ts | 2 +- .../src/test/{generate-image.ts => image.ts} | 5 ++ packages/dev/src/main.test.ts | 52 ++++++++++++++++++- packages/dev/src/main.ts | 5 +- packages/images/package.json | 1 - packages/images/src/main.test.ts | 13 +++-- packages/vite-plugin/src/main.test.ts | 2 +- 9 files changed, 70 insertions(+), 16 deletions(-) rename packages/dev-utils/src/test/{generate-image.ts => image.ts} (78%) diff --git a/package-lock.json b/package-lock.json index 650ebd9a..121c7fbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6405,7 +6405,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "dev": true, "license": "MIT", "bin": { "image-size": "bin/image-size.js" @@ -11105,6 +11104,7 @@ "dot-prop": "9.0.0", "env-paths": "^3.0.0", "find-up": "7.0.0", + "image-size": "^2.0.2", "js-image-generator": "^1.0.4", "lodash.debounce": "^4.0.8", "parse-gitignore": "^2.0.0", @@ -11467,12 +11467,11 @@ }, "devDependencies": { "@netlify/dev-utils": "^3.1.1", - "image-size": "^2.0.2", "tsup": "^8.5.0", "vitest": "^3.1.4" }, "engines": { - "node": "^18.14.0 || >=20" + "node": ">=20.6.1" } }, "packages/otel": { diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 37f1c3ae..de8fd6c6 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -58,6 +58,7 @@ "env-paths": "^3.0.0", "find-up": "7.0.0", "js-image-generator": "^1.0.4", + "image-size": "^2.0.2", "lodash.debounce": "^4.0.8", "parse-gitignore": "^2.0.0", "uuid": "^11.1.0", diff --git a/packages/dev-utils/src/main.ts b/packages/dev-utils/src/main.ts index 98c2a1df..3b4e8e8b 100644 --- a/packages/dev-utils/src/main.ts +++ b/packages/dev-utils/src/main.ts @@ -16,5 +16,5 @@ export { watchDebounced } from './lib/watch-debounced.js' export { EventInspector } from './test/event_inspector.js' export { MockFetch } from './test/fetch.js' export { Fixture } from './test/fixture.js' -export { generateImage } from './test/generate-image.js' +export { generateImage, getImageResponseSize } from './test/image.js' export { createMockLogger } from './test/logger.js' diff --git a/packages/dev-utils/src/test/generate-image.ts b/packages/dev-utils/src/test/image.ts similarity index 78% rename from packages/dev-utils/src/test/generate-image.ts rename to packages/dev-utils/src/test/image.ts index 365abd7c..23a8111d 100644 --- a/packages/dev-utils/src/test/generate-image.ts +++ b/packages/dev-utils/src/test/image.ts @@ -1,3 +1,4 @@ +import { imageSize } from 'image-size' import { generateImage as generateImageCallback } from 'js-image-generator' /** @@ -17,3 +18,7 @@ export async function generateImage(width: number, height: number): Promise { await dev.stop() await fixture.destroy() }) + + test('Image CDN requests are supported', async () => { + const fixture = new Fixture() + .withFile( + 'netlify.toml', + `[images] + remote_images = [ + "https://images.unsplash.com/photo-1517849845537.*" + ]`, + ) + .withFile('local/image.jpg', await generateImage(800, 400)) + + const directory = await fixture.create() + + const dev = new NetlifyDev({ + projectRoot: directory, + edgeFunctions: { + // disable edge functions to avoid relying on edge functions handling spinning up internal server + // for local images + enabled: false, + }, + }) + + await dev.start() + + const localImageRequest = new Request( + `https://site.netlify/.netlify/images?url=${encodeURIComponent('local/image.jpg')}&w=100`, + ) + const localImageResponse = await dev.handle(localImageRequest) + expect(localImageResponse?.ok).toBe(true) + expect(localImageResponse?.headers.get('content-type')).toMatch(/^image\//) + expect(await getImageResponseSize(localImageResponse!)).toMatchObject({ width: 100, height: 50 }) + + const allowedRemoteImageRequest = new Request( + `https://site.netlify/.netlify/images?url=${encodeURIComponent('https://images.unsplash.com/photo-1517849845537-4d257902454a')}&w=100`, + ) + const allowedRemoteImageResponse = await dev.handle(allowedRemoteImageRequest) + expect(allowedRemoteImageResponse?.ok).toBe(true) + expect(allowedRemoteImageResponse?.headers.get('content-type')).toMatch(/^image\//) + expect(await getImageResponseSize(allowedRemoteImageResponse!)).toMatchObject({ width: 100, height: 133 }) + + const notAllowedRemoteImageRequest = new Request( + `https://site.netlify/.netlify/images?url=${encodeURIComponent('https://images.unsplash.com/photo-1625316708582-7c38734be31d')}&w=100`, + ) + const notAllowedRemoteImageResponse = await dev.handle(notAllowedRemoteImageRequest) + expect(notAllowedRemoteImageResponse?.status).toBe(403) + + await dev.stop() + await fixture.destroy() + }) }) describe('With linked site', () => { diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index 6fba13d7..5798cc3d 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -393,10 +393,11 @@ export class NetlifyDev { let serverAddress: string | undefined // If a custom server has been provided, use it. If not, we must stand up - // a new one, since it's required for communication with edge functions. + // a new one, since it's required for communication with edge functions + // and local images support for Image CDN. if (typeof this.#server === 'string') { serverAddress = this.#server - } else if (this.#features.edgeFunctions) { + } else if (this.#features.edgeFunctions || this.#features.images) { const passthroughServer = new HTTPServer(async (req) => { const res = await this.handle(req) diff --git a/packages/images/package.json b/packages/images/package.json index f2426e58..a9097e2e 100644 --- a/packages/images/package.json +++ b/packages/images/package.json @@ -33,7 +33,6 @@ }, "devDependencies": { "@netlify/dev-utils": "^3.1.1", - "image-size": "^2.0.2", "tsup": "^8.5.0", "vitest": "^3.1.4" }, diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index 467596a0..1f67e642 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,7 +1,6 @@ import http from 'node:http' -import { createMockLogger, generateImage } from '@netlify/dev-utils' -import { imageSize } from 'image-size' +import { createMockLogger, generateImage, getImageResponseSize } from '@netlify/dev-utils' import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { createIPXWebServer } from 'ipx' @@ -201,7 +200,7 @@ describe('`ImageHandler`', () => { expect(response.ok).toBe(true) - const { width } = imageSize(new Uint8Array(await response.arrayBuffer())) + const { width } = await getImageResponseSize(response) expect(width).toBe(LOCAL_IMAGE_WIDTH) }) @@ -226,7 +225,7 @@ describe('`ImageHandler`', () => { expect(response.ok).toBe(true) - const { width, height } = imageSize(new Uint8Array(await response.arrayBuffer())) + const { width, height } = await getImageResponseSize(response) expect(width).toBe(requestedWidth) expect(width / height).toBe(LOCAL_IMAGE_WIDTH / LOCAL_IMAGE_HEIGHT) @@ -252,7 +251,7 @@ describe('`ImageHandler`', () => { expect(response.ok).toBe(true) - const { width, height } = imageSize(new Uint8Array(await response.arrayBuffer())) + const { width, height } = await getImageResponseSize(response) expect(height).toBe(requestedHeight) expect(width / height).toBe(LOCAL_IMAGE_WIDTH / LOCAL_IMAGE_HEIGHT) @@ -280,7 +279,7 @@ describe('`ImageHandler`', () => { expect(response.ok).toBe(true) - const { width, height } = imageSize(new Uint8Array(await response.arrayBuffer())) + const { width, height } = await getImageResponseSize(response) expect(width).toBe(requestedWidth) expect(height).toBe(requestedHeight) @@ -315,7 +314,7 @@ describe('`ImageHandler`', () => { expect(response.ok).toBe(true) - const { width } = imageSize(new Uint8Array(await response.arrayBuffer())) + const { width } = await getImageResponseSize(response) expect(width).toBe(requestedWidth) }, 30_000) diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 30e1b36a..69fc74a1 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -459,7 +459,7 @@ defined on your team and site and much more. Run npx netlify init to get started Hello World

Hello from the browser

- + From a38db958fcecac54847ef574bcf25c5781adcafb Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 4 Jun 2025 19:22:06 +0200 Subject: [PATCH 25/33] chore: fix lint --- eslint_temporary_suppressions.js | 1 + packages/dev-utils/src/test/image.ts | 3 +++ packages/dev/src/main.test.ts | 9 +++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/eslint_temporary_suppressions.js b/eslint_temporary_suppressions.js index 9dbebc5a..b64f464b 100644 --- a/eslint_temporary_suppressions.js +++ b/eslint_temporary_suppressions.js @@ -703,6 +703,7 @@ export default [ files: ['packages/images/src/main.test.ts'], rules: { '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', }, }, diff --git a/packages/dev-utils/src/test/image.ts b/packages/dev-utils/src/test/image.ts index 23a8111d..770c6212 100644 --- a/packages/dev-utils/src/test/image.ts +++ b/packages/dev-utils/src/test/image.ts @@ -20,5 +20,8 @@ export async function generateImage(width: number, height: number): Promise { const localImageResponse = await dev.handle(localImageRequest) expect(localImageResponse?.ok).toBe(true) expect(localImageResponse?.headers.get('content-type')).toMatch(/^image\//) - expect(await getImageResponseSize(localImageResponse!)).toMatchObject({ width: 100, height: 50 }) + expect(await getImageResponseSize(localImageResponse ?? new Response('No @netlify/dev response'))).toMatchObject({ + width: 100, + height: 50, + }) const allowedRemoteImageRequest = new Request( `https://site.netlify/.netlify/images?url=${encodeURIComponent('https://images.unsplash.com/photo-1517849845537-4d257902454a')}&w=100`, @@ -486,7 +489,9 @@ describe('Handling requests', () => { const allowedRemoteImageResponse = await dev.handle(allowedRemoteImageRequest) expect(allowedRemoteImageResponse?.ok).toBe(true) expect(allowedRemoteImageResponse?.headers.get('content-type')).toMatch(/^image\//) - expect(await getImageResponseSize(allowedRemoteImageResponse!)).toMatchObject({ width: 100, height: 133 }) + expect( + await getImageResponseSize(allowedRemoteImageResponse ?? new Response('No @netlify/dev response')), + ).toMatchObject({ width: 100, height: 133 }) const notAllowedRemoteImageRequest = new Request( `https://site.netlify/.netlify/images?url=${encodeURIComponent('https://images.unsplash.com/photo-1625316708582-7c38734be31d')}&w=100`, From 74dc91aab13bcff191ac1cac2940fb9d4f217717 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 09:23:39 +0200 Subject: [PATCH 26/33] Update packages/images/src/main.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Eduardo Bouรงas --- packages/images/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts index aa2ea828..18f17ff6 100644 --- a/packages/images/src/main.ts +++ b/packages/images/src/main.ts @@ -102,7 +102,7 @@ export class ImageHandler { ) } - // if it's not local image, check if it it's allowed + // if it's not local image, check if it's allowed if ( sourceImageUrl.origin !== this.#originServerURL?.origin && !this.#allowedRemoteUrlPatterns.some((allowedRemoteUrlPattern) => From 3021856854a3b7ad6797bafeee9cf9ff30f352a4 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 09:58:41 +0200 Subject: [PATCH 27/33] chore: http.createServer -> HTTPServer --- packages/images/src/main.test.ts | 62 ++++++++++++++------------------ 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index 1f67e642..bd40006f 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,6 +1,4 @@ -import http from 'node:http' - -import { createMockLogger, generateImage, getImageResponseSize } from '@netlify/dev-utils' +import { createMockLogger, generateImage, getImageResponseSize, HTTPServer } from '@netlify/dev-utils' import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { createIPXWebServer } from 'ipx' @@ -133,48 +131,40 @@ describe('`ImageHandler`', () => { }) describe('local images', () => { - let originServer: http.Server let originServerAddress: string + let originServer: HTTPServer + const LOCAL_IMAGE_PATH = '/local/image.jpg' const LOCAL_IMAGE_WIDTH = 800 const LOCAL_IMAGE_HEIGHT = 400 beforeAll(async () => { - ;[originServer, originServerAddress] = await new Promise<[http.Server, string]>((resolve, reject) => { - const originServer = http.createServer(function originHandler(req, res) { - if (req.url !== LOCAL_IMAGE_PATH) { - res.writeHead(404, { 'Content-Type': 'text/plain' }) - res.end('Not Found') - return - } - - generateImage(LOCAL_IMAGE_WIDTH, LOCAL_IMAGE_HEIGHT) - .then((imageData) => { - res.writeHead(200, { 'Content-Type': 'image/jpeg' }) - - res.end(imageData) - }) - .catch((error: unknown) => { - console.error('Error generating image', error) - res.writeHead(500, { 'Content-Type': 'text/plain' }) - res.end('Internal Server Error') - }) - }) - originServer.listen(() => { - const address = originServer.address() - - if (!address || typeof address === 'string') { - reject(new Error('Server cannot be started on a pipe or Unix socket')) - return - } - - resolve([originServer, `http://localhost:${address.port.toString()}`]) - }) + originServer = new HTTPServer(async (request: Request) => { + const url = new URL(request.url) + if (url.pathname !== LOCAL_IMAGE_PATH) { + return new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain' } }) + } + + try { + const imageBuffer = await generateImage(LOCAL_IMAGE_WIDTH, LOCAL_IMAGE_HEIGHT) + + return new Response(imageBuffer, { + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Length': imageBuffer.length.toString(), + }, + }) + } catch (error: unknown) { + console.error('Error generating image', error) + return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } }) + } }) + + originServerAddress = await originServer.start() }) - afterAll(() => { - originServer.close() + afterAll(async () => { + await originServer.stop() }) beforeEach(async () => { From 92becbb3bea20d0524d0a7bf742c44a8242285b2 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 10:07:00 +0200 Subject: [PATCH 28/33] fix: early bail to limit nesting --- packages/images/src/main.ts | 100 ++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/packages/images/src/main.ts b/packages/images/src/main.ts index 18f17ff6..b10aacf1 100644 --- a/packages/images/src/main.ts +++ b/packages/images/src/main.ts @@ -77,55 +77,57 @@ export class ImageHandler { match(request: Request): ImageMatch | undefined { const url = new URL(request.url) - if (IMAGE_CDN_ENDPOINTS.includes(url.pathname)) { - return { - handle: async () => { - if (request.method !== 'GET') { - return new Response('Method Not Allowed', { status: 405 }) - } - - const sourceImageUrlParam = url.searchParams.get('url') - if (!sourceImageUrlParam) { - return new Response('Bad Request: Missing "url" query parameter', { status: 400 }) - } - - let sourceImageUrl: URL - try { - sourceImageUrl = new URL(sourceImageUrlParam, this.#originServerURL) - } catch (error) { - throw new Error( - `Failed to construct source image URL from "${sourceImageUrlParam}".` + - (!this.#originServerURL && !sourceImageUrlParam.startsWith('http') - ? '\nLooks like source image is local and `originServerAddress` was not provided.' - : ''), - { cause: error }, - ) - } - - // if it's not local image, check if it's allowed - if ( - sourceImageUrl.origin !== this.#originServerURL?.origin && - !this.#allowedRemoteUrlPatterns.some((allowedRemoteUrlPattern) => - allowedRemoteUrlPattern.test(sourceImageUrl.href), - ) - ) { - return new Response('Forbidden: Remote image URL not allowed', { status: 403 }) - } - - const ipx = createIPX({ - storage: ipxFSStorage(), - httpStorage: ipxHttpStorage({ - // checking if url is allowed is done above, so we disable IPX checking - allowAllDomains: true, - }), - }) - - const ipxHandler = createIPXWebServer(ipx) - - const ipxRequest = new Request(this.generateIPXRequestURL(sourceImageUrl, url.searchParams), request) - return ipxHandler(ipxRequest) - }, - } + if (!IMAGE_CDN_ENDPOINTS.includes(url.pathname)) { + return + } + + return { + handle: async () => { + if (request.method !== 'GET') { + return new Response('Method Not Allowed', { status: 405 }) + } + + const sourceImageUrlParam = url.searchParams.get('url') + if (!sourceImageUrlParam) { + return new Response('Bad Request: Missing "url" query parameter', { status: 400 }) + } + + let sourceImageUrl: URL + try { + sourceImageUrl = new URL(sourceImageUrlParam, this.#originServerURL) + } catch (error) { + throw new Error( + `Failed to construct source image URL from "${sourceImageUrlParam}".` + + (!this.#originServerURL && !sourceImageUrlParam.startsWith('http') + ? '\nLooks like source image is local and `originServerAddress` was not provided.' + : ''), + { cause: error }, + ) + } + + // if it's not local image, check if it's allowed + if ( + sourceImageUrl.origin !== this.#originServerURL?.origin && + !this.#allowedRemoteUrlPatterns.some((allowedRemoteUrlPattern) => + allowedRemoteUrlPattern.test(sourceImageUrl.href), + ) + ) { + return new Response('Forbidden: Remote image URL not allowed', { status: 403 }) + } + + const ipx = createIPX({ + storage: ipxFSStorage(), + httpStorage: ipxHttpStorage({ + // checking if url is allowed is done above, so we disable IPX checking + allowAllDomains: true, + }), + }) + + const ipxHandler = createIPXWebServer(ipx) + + const ipxRequest = new Request(this.generateIPXRequestURL(sourceImageUrl, url.searchParams), request) + return ipxHandler(ipxRequest) + }, } } } From 24d3ec7b2e6ccebfbd68360cb56c5a599caee900 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 10:27:58 +0200 Subject: [PATCH 29/33] refactor: create helper function for HTTPServer handler that serves random images --- packages/dev-utils/src/main.ts | 2 +- packages/dev-utils/src/test/image.ts | 27 +++++++++++++++++++++++++ packages/images/src/main.test.ts | 30 +++++++++------------------- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/dev-utils/src/main.ts b/packages/dev-utils/src/main.ts index 3b4e8e8b..9cdc8220 100644 --- a/packages/dev-utils/src/main.ts +++ b/packages/dev-utils/src/main.ts @@ -16,5 +16,5 @@ export { watchDebounced } from './lib/watch-debounced.js' export { EventInspector } from './test/event_inspector.js' export { MockFetch } from './test/fetch.js' export { Fixture } from './test/fixture.js' -export { generateImage, getImageResponseSize } from './test/image.js' +export { createImageServerHandler, generateImage, getImageResponseSize } from './test/image.js' export { createMockLogger } from './test/logger.js' diff --git a/packages/dev-utils/src/test/image.ts b/packages/dev-utils/src/test/image.ts index 770c6212..6063bfa6 100644 --- a/packages/dev-utils/src/test/image.ts +++ b/packages/dev-utils/src/test/image.ts @@ -19,6 +19,33 @@ export async function generateImage(width: number, height: number): Promise { width: number; height: number } | null) { + return async (request: Request): Promise => { + const url = new URL(request.url) + + const imageConfig = imageConfigFromURL(url) + + if (!imageConfig) { + return new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain' } }) + } + + try { + const imageBuffer = await generateImage(imageConfig.width, imageConfig.height) + return new Response(imageBuffer, { + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Length': imageBuffer.length.toString(), + }, + }) + } catch (error) { + return new Response('Error generating image', { status: 500 }) + } + } +} + export async function getImageResponseSize(response: Response) { if (!response.headers.get('content-type')?.startsWith('image/')) { throw new Error('Response is not an image') diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index bd40006f..4ba03c74 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -1,4 +1,4 @@ -import { createMockLogger, generateImage, getImageResponseSize, HTTPServer } from '@netlify/dev-utils' +import { createImageServerHandler, createMockLogger, getImageResponseSize, HTTPServer } from '@netlify/dev-utils' import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { createIPXWebServer } from 'ipx' @@ -139,26 +139,14 @@ describe('`ImageHandler`', () => { const LOCAL_IMAGE_HEIGHT = 400 beforeAll(async () => { - originServer = new HTTPServer(async (request: Request) => { - const url = new URL(request.url) - if (url.pathname !== LOCAL_IMAGE_PATH) { - return new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain' } }) - } - - try { - const imageBuffer = await generateImage(LOCAL_IMAGE_WIDTH, LOCAL_IMAGE_HEIGHT) - - return new Response(imageBuffer, { - headers: { - 'Content-Type': 'image/jpeg', - 'Content-Length': imageBuffer.length.toString(), - }, - }) - } catch (error: unknown) { - console.error('Error generating image', error) - return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } }) - } - }) + originServer = new HTTPServer( + createImageServerHandler((url: URL) => { + if (url.pathname === LOCAL_IMAGE_PATH) { + return { width: LOCAL_IMAGE_WIDTH, height: LOCAL_IMAGE_HEIGHT } + } + return null + }), + ) originServerAddress = await originServer.start() }) From f025ef11985b9d495bddc82644fb7e1a9d485a6e Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 10:42:35 +0200 Subject: [PATCH 30/33] test: use own http server for testing remote images instead of unsplash --- packages/dev/src/main.test.ts | 24 ++++++++++++++++------ packages/images/src/main.test.ts | 29 +++++++++++++++++++++++---- packages/vite-plugin/src/main.test.ts | 23 +++++++++++++++------ 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/dev/src/main.test.ts b/packages/dev/src/main.test.ts index 25f8719a..47d22b93 100644 --- a/packages/dev/src/main.test.ts +++ b/packages/dev/src/main.test.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' -import { Fixture, generateImage, getImageResponseSize } from '@netlify/dev-utils' +import { createImageServerHandler, Fixture, generateImage, getImageResponseSize, HTTPServer } from '@netlify/dev-utils' import { describe, expect, test } from 'vitest' import { isFile } from './lib/fs.js' @@ -449,15 +449,26 @@ describe('Handling requests', () => { }) test('Image CDN requests are supported', async () => { + const IMAGE_WIDTH = 800 + const IMAGE_HEIGHT = 400 + + const remoteServer = new HTTPServer( + createImageServerHandler(() => { + return { width: IMAGE_WIDTH, height: IMAGE_HEIGHT } + }), + ) + + const remoteServerAddress = await remoteServer.start() + const fixture = new Fixture() .withFile( 'netlify.toml', `[images] remote_images = [ - "https://images.unsplash.com/photo-1517849845537.*" + "^${remoteServerAddress}/allowed/.*" ]`, ) - .withFile('local/image.jpg', await generateImage(800, 400)) + .withFile('local/image.jpg', await generateImage(IMAGE_WIDTH, IMAGE_HEIGHT)) const directory = await fixture.create() @@ -484,21 +495,22 @@ describe('Handling requests', () => { }) const allowedRemoteImageRequest = new Request( - `https://site.netlify/.netlify/images?url=${encodeURIComponent('https://images.unsplash.com/photo-1517849845537-4d257902454a')}&w=100`, + `https://site.netlify/.netlify/images?url=${encodeURIComponent(`${remoteServerAddress}/allowed/image`)}&w=100`, ) const allowedRemoteImageResponse = await dev.handle(allowedRemoteImageRequest) expect(allowedRemoteImageResponse?.ok).toBe(true) expect(allowedRemoteImageResponse?.headers.get('content-type')).toMatch(/^image\//) expect( await getImageResponseSize(allowedRemoteImageResponse ?? new Response('No @netlify/dev response')), - ).toMatchObject({ width: 100, height: 133 }) + ).toMatchObject({ width: 100, height: 50 }) const notAllowedRemoteImageRequest = new Request( - `https://site.netlify/.netlify/images?url=${encodeURIComponent('https://images.unsplash.com/photo-1625316708582-7c38734be31d')}&w=100`, + `https://site.netlify/.netlify/images?url=${encodeURIComponent(`${remoteServerAddress}/not-allowed/image`)}&w=100`, ) const notAllowedRemoteImageResponse = await dev.handle(notAllowedRemoteImageRequest) expect(notAllowedRemoteImageResponse?.status).toBe(403) + await remoteServer.stop() await dev.stop() await fixture.destroy() }) diff --git a/packages/images/src/main.test.ts b/packages/images/src/main.test.ts index 4ba03c74..9e8270e4 100644 --- a/packages/images/src/main.test.ts +++ b/packages/images/src/main.test.ts @@ -266,6 +266,26 @@ describe('`ImageHandler`', () => { }) describe('remote images', () => { + let remoteServerAddress: string + let remoteServer: HTTPServer + + const IMAGE_WIDTH = 800 + const IMAGE_HEIGHT = 400 + + beforeAll(async () => { + remoteServer = new HTTPServer( + createImageServerHandler(() => { + return { width: IMAGE_WIDTH, height: IMAGE_HEIGHT } + }), + ) + + remoteServerAddress = await remoteServer.start() + }) + + afterAll(async () => { + await remoteServer.stop() + }) + test('allow remote images matching configured patterns', async () => { mockCreateIPXWebServer.mockImplementation( (await vi.importActual('ipx')).createIPXWebServer, @@ -274,14 +294,14 @@ describe('`ImageHandler`', () => { const imageHandler = new ImageHandler({ logger: createMockLogger(), imagesConfig: { - remote_images: ['https://images.unsplash.com/.*'], + remote_images: [`^${remoteServerAddress}/.*`], }, }) const requestedWidth = 100 const url = new URL('/.netlify/images', 'https://netlify.com') - url.searchParams.set('url', 'https://images.unsplash.com/photo-1517849845537-4d257902454a') + url.searchParams.set('url', remoteServerAddress) url.searchParams.set('w', requestedWidth.toString()) const match = imageHandler.match(new Request(url)) @@ -292,9 +312,10 @@ describe('`ImageHandler`', () => { expect(response.ok).toBe(true) - const { width } = await getImageResponseSize(response) + const { width, height } = await getImageResponseSize(response) expect(width).toBe(requestedWidth) + expect(width / height).toBe(IMAGE_WIDTH / IMAGE_HEIGHT) }, 30_000) test('does not allow remote images not matching configured patterns', async () => { @@ -306,7 +327,7 @@ describe('`ImageHandler`', () => { }) const url = new URL('/.netlify/images', 'https://netlify.com') - url.searchParams.set('url', 'https://images.unsplash.com/photo-1517849845537-4d257902454a') + url.searchParams.set('url', remoteServerAddress) url.searchParams.set('w', '100') const match = imageHandler.match(new Request(url)) diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 69fc74a1..52afe1f4 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -3,7 +3,7 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Fixture, generateImage } from '@netlify/dev-utils' +import { createImageServerHandler, Fixture, generateImage, HTTPServer } from '@netlify/dev-utils' import { type Browser, type ConsoleMessage, type Locator, type Page, chromium } from 'playwright' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { createServer } from 'vite' @@ -431,12 +431,23 @@ defined on your team and site and much more. Run npx netlify init to get started }) test('Handles Image CDN requests', async () => { + const IMAGE_WIDTH = 800 + const IMAGE_HEIGHT = 400 + + const remoteServer = new HTTPServer( + createImageServerHandler(() => { + return { width: IMAGE_WIDTH, height: IMAGE_HEIGHT } + }), + ) + + const remoteServerAddress = await remoteServer.start() + const fixture = new Fixture() .withFile( 'netlify.toml', `[images] remote_images = [ - "https://images.unsplash.com/photo-1517849845537.*" + "^${remoteServerAddress}/allowed/.*" ]`, ) .withFile( @@ -460,12 +471,12 @@ defined on your team and site and much more. Run npx netlify init to get started

Hello from the browser

- - + + `, ) - .withFile('local/image.jpg', await generateImage(800, 400)) + .withFile('local/image.jpg', await generateImage(IMAGE_WIDTH, IMAGE_HEIGHT)) const directory = await fixture.create() await fixture @@ -498,7 +509,7 @@ defined on your team and site and much more. Run npx netlify init to get started } expect(await getImageSize(page.locator('#local-image'))).toEqual({ width: 100, height: 50 }) - expect(await getImageSize(page.locator('#allowed-remote-image'))).toEqual({ width: 100, height: 133 }) + expect(await getImageSize(page.locator('#allowed-remote-image'))).toEqual({ width: 100, height: 50 }) await expect( async () => await getImageSize(page.locator('#not-allowed-remote-image')), From 955c10984ca5f389b27b74b959634734ff1fcf27 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 11:07:37 +0200 Subject: [PATCH 31/33] fix: move image matching to before function matching --- packages/dev/src/main.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index 5798cc3d..46e6ba23 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -216,7 +216,14 @@ export class NetlifyDev { } } - // 2. Check if the request matches a function. + // 2. Check if the request matches an image. + const imageMatch = this.#imageHandler?.match(readRequest) + if (imageMatch) { + const response = await imageMatch.handle() + return { response, type: 'image' } + } + + // 3. Check if the request matches a function. const functionMatch = await this.#functionsHandler?.match(readRequest, destPath) if (functionMatch) { // If the function prefers static files, check if there is a static match @@ -237,7 +244,7 @@ export class NetlifyDev { return { response: await functionMatch.handle(getWriteRequest()), type: 'function' } } - // 3. Check if the request matches a redirect rule. + // 4. Check if the request matches a redirect rule. const redirectMatch = await this.#redirectsHandler?.match(readRequest) if (redirectMatch) { // If the redirect rule matches a function, we'll serve it. The exception @@ -274,13 +281,6 @@ export class NetlifyDev { } } - // 4. Check if the request matches an image. - const imageMatch = this.#imageHandler?.match(readRequest) - if (imageMatch) { - const response = await imageMatch.handle() - return { response, type: 'image' } - } - // 5. Check if the request matches a static file. const staticMatch = await this.#staticHandler?.match(readRequest) if (staticMatch) { From 0af3615e5f606bef46a98f5bcfcaf765fe789495 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 11:08:51 +0200 Subject: [PATCH 32/33] fix: handle image cdn rewrites --- packages/dev/src/main.test.ts | 25 ++++++++++++++++++++++--- packages/dev/src/main.ts | 10 +++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/dev/src/main.test.ts b/packages/dev/src/main.test.ts index 47d22b93..e35a726a 100644 --- a/packages/dev/src/main.test.ts +++ b/packages/dev/src/main.test.ts @@ -464,9 +464,18 @@ describe('Handling requests', () => { .withFile( 'netlify.toml', `[images] - remote_images = [ - "^${remoteServerAddress}/allowed/.*" - ]`, + remote_images = [ + "^${remoteServerAddress}/allowed/.*" + ] + + [[redirects]] + from = "/image-cdn-rewrite" + to = "/.netlify/images?url=:url&w=:width" + status = 200 + + [redirects.query] + url = ":url" + w = ":width"`, ) .withFile('local/image.jpg', await generateImage(IMAGE_WIDTH, IMAGE_HEIGHT)) @@ -510,6 +519,16 @@ describe('Handling requests', () => { const notAllowedRemoteImageResponse = await dev.handle(notAllowedRemoteImageRequest) expect(notAllowedRemoteImageResponse?.status).toBe(403) + const rewriteImageRequest = new Request( + `https://site.netlify/image-cdn-rewrite?url=${encodeURIComponent('local/image.jpg')}&w=100`, + ) + const rewriteImageResponse = await dev.handle(rewriteImageRequest) + expect(rewriteImageResponse?.ok).toBe(true) + expect(rewriteImageResponse?.headers.get('content-type')).toMatch(/^image\//) + expect( + await getImageResponseSize(rewriteImageResponse ?? new Response('No @netlify/dev response')), + ).toMatchObject({ width: 100, height: 50 }) + await remoteServer.stop() await dev.stop() await fixture.destroy() diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index 46e6ba23..b6d33ab7 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -247,10 +247,18 @@ export class NetlifyDev { // 4. Check if the request matches a redirect rule. const redirectMatch = await this.#redirectsHandler?.match(readRequest) if (redirectMatch) { + const redirectRequest = new Request(redirectMatch.target) + // If the redirect rule matches Image CDN, we'll serve it. + const imageMatch = this.#imageHandler?.match(redirectRequest) + if (imageMatch) { + const response = await imageMatch.handle() + return { response, type: 'image' } + } + // If the redirect rule matches a function, we'll serve it. The exception // is if the function prefers static files, which in this case means that // we'll follow the redirect rule. - const functionMatch = await this.#functionsHandler?.match(new Request(redirectMatch.target), destPath) + const functionMatch = await this.#functionsHandler?.match(redirectRequest, destPath) if (functionMatch && !functionMatch.preferStatic) { return { response: await functionMatch.handle(getWriteRequest()), From 58f0e0e1b205f52ade5dfcbb3504517978b94848 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 5 Jun 2025 11:10:18 +0200 Subject: [PATCH 33/33] fix: log error when image generation for tests fails --- packages/dev-utils/src/test/image.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dev-utils/src/test/image.ts b/packages/dev-utils/src/test/image.ts index 6063bfa6..e5913d20 100644 --- a/packages/dev-utils/src/test/image.ts +++ b/packages/dev-utils/src/test/image.ts @@ -41,6 +41,7 @@ export function createImageServerHandler(imageConfigFromURL: (url: URL) => { wid }, }) } catch (error) { + console.log('Error generating image', error) return new Response('Error generating image', { status: 500 }) } }