diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 719e82df1d..4d4d56c649 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -28,15 +28,22 @@ jobs: repo: context.repo.repo, issue_number: context.payload.pull_request.number, }); - return labels.some(label => label.name === 'autorelease: pending' || label.name === 'test all versions'); + const shouldTestAllVersions = labels.some(label => label.name === 'autorelease: pending' || label.name === 'test all versions'); + if (shouldTestAllVersions) { + return 'all' + } + + return labels.some(label => label.name === 'test latest and canary') ? 'latest-and-canary' : 'latest' - name: Set Next.js versions to test id: set-matrix # If this is the nightly build or a release PR then run the full matrix of versions run: | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then echo "matrix=${{ github.event.inputs.versions }}" >> $GITHUB_OUTPUT - elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "true" ]; then + elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "all" ]; then echo "matrix=[\"latest\", \"canary\", \"14.2.15\", \"13.5.1\"]" >> $GITHUB_OUTPUT + elif [ "${{ steps.check-labels.outputs.result }}" = "latest-and-canary" ]; then + echo "matrix=[\"latest\", \"canary\"]" >> $GITHUB_OUTPUT else echo "matrix=[\"latest\"]" >> $GITHUB_OUTPUT fi diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index dd9d6d164e..7a955daf4f 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -124,6 +124,24 @@ jobs: echo "version=$NODE_VERSION" >> $GITHUB_OUTPUT echo "Node version for 'next@${{ matrix.version_spec.selector }}' is '$NODE_VERSION'" + - name: Decide default bundler + id: decide-default-bundler + shell: bash + run: | + # we run tests with default bundler for given Next.js version + # some tests have webpack/turbopack specific assertions or skips + # so we need to set IS_WEBPACK_TEST, IS_TURBOPACK_TEST env vars accordingly + # to ensure tests assert behavior of the default bundler + DEFAULT_BUNDLER="webpack" + if [ "${{ matrix.version_spec.selector }}" = "canary" ]; then + # this is not ideal, because we set turbopack default just when explicitly using canary tag as target + # but next@canary are still on 15 major, so we can't yet use major version of resolved next version + # as condition + DEFAULT_BUNDLER="turbopack" + fi + echo "default_bundler=$DEFAULT_BUNDLER" >> $GITHUB_OUTPUT + echo "Default bundler for 'next@${{ matrix.version_spec.selector }}' is '$DEFAULT_BUNDLER'" + - name: setup node uses: actions/setup-node@v5 with: @@ -218,6 +236,9 @@ jobs: # one job may wait for deploys in other jobs (only one deploy may be in progress for # a given alias at a time), resulting in cascading timeouts. DEPLOY_ALIAS: vercel-next-e2e-${{ matrix.version_spec.selector }}-${{ matrix.group }} + NEXT_RESOLVED_VERSION: ${{ matrix.version_spec.version }} + IS_WEBPACK_TEST: ${{ steps.decide-default-bundler.outputs.default_bundler == 'webpack' && '1' || '' }} + IS_TURBOPACK_TEST: ${{ steps.decide-default-bundler.outputs.default_bundler == 'turbopack' && '1' || '' }} run: node run-tests.js -g ${{ matrix.group }}/${{ needs.setup.outputs.total }} -c ${TEST_CONCURRENCY} --type e2e working-directory: ${{ env.next-path }} @@ -226,7 +247,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: test-result-${{matrix.version_spec.selector}}-${{ matrix.group }} - path: ${{ env.next-path }}/test/test-junit-report/*.xml + path: ${{ env.next-path }}/test/${{steps.decide-default-bundler.outputs.default_bundler == 'turbopack' && 'turbopack-test-junit-report' || 'test-junit-report'}}/*.xml publish-test-results: name: 'E2E Test Summary (${{matrix.version_spec.selector}})' needs: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5c4f16c2f4..7fb4b5aae6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.13.5" + ".": "5.14.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2639f268..e39e6faf57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [5.14.0](https://github.com/opennextjs/opennextjs-netlify/compare/v5.13.5...v5.14.0) (2025-10-14) + + +### Features + +* skew protection ([#3147](https://github.com/opennextjs/opennextjs-netlify/issues/3147)) ([95be9b9](https://github.com/opennextjs/opennextjs-netlify/commit/95be9b99f7b889e39195c074b39a726981506ed0)) +* support revalidateTag with SWR behavior ([#3173](https://github.com/opennextjs/opennextjs-netlify/issues/3173)) ([d24f15e](https://github.com/opennextjs/opennextjs-netlify/commit/d24f15ee595be1d01f44504983b70b01f96bb1b1)) + ## [5.13.5](https://github.com/opennextjs/opennextjs-netlify/compare/v5.13.4...v5.13.5) (2025-10-06) diff --git a/e2e-report/package-lock.json b/e2e-report/package-lock.json index 9527054d33..1a9bc4f4c5 100644 --- a/e2e-report/package-lock.json +++ b/e2e-report/package-lock.json @@ -8,7 +8,7 @@ "name": "e2e-test-site", "version": "0.2.0", "dependencies": { - "@netlify/plugin-nextjs": "^5.13.4", + "@netlify/plugin-nextjs": "^5.13.5", "next": "^15.5.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -117,19 +117,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -164,9 +167,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", - "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", "dev": true, "license": "MIT", "engines": { @@ -187,13 +190,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.16.0", "levn": "^0.4.1" }, "engines": { @@ -789,9 +792,9 @@ } }, "node_modules/@netlify/plugin-nextjs": { - "version": "5.13.4", - "resolved": "https://registry.npmjs.org/@netlify/plugin-nextjs/-/plugin-nextjs-5.13.4.tgz", - "integrity": "sha512-F53b8FU49b3BXOvziiOLcgTxG6nLrQiieux4KvoBJo8YCCUu1IJRNncFw8/LfSG2IMpwWMe8fKBBKqSoi21EZA==", + "version": "5.13.5", + "resolved": "https://registry.npmjs.org/@netlify/plugin-nextjs/-/plugin-nextjs-5.13.5.tgz", + "integrity": "sha512-zuP4sqckaeJ8rtiNYwoOpQnEb/8E4Ewf+FJDxts2s6gfgmXGuk4W5ViKZtITWWjuzxqbxG+p4t20PXltIyeJBA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2999,9 +3002,9 @@ } }, "node_modules/eslint": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", - "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", "peer": true, @@ -3009,11 +3012,11 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.35.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.37.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4485,10 +4488,11 @@ } }, "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "license": "MIT", "peer": true, "bin": { "jiti": "bin/jiti.js" @@ -19245,9 +19249,9 @@ } }, "node_modules/sass": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", - "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "devOptional": true, "license": "MIT", "peer": true, @@ -19861,9 +19865,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19875,7 +19879,7 @@ "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.6", + "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", @@ -19884,7 +19888,7 @@ "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", diff --git a/e2e-report/package.json b/e2e-report/package.json index d81d7c0b06..18514ca8fd 100644 --- a/e2e-report/package.json +++ b/e2e-report/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "@netlify/plugin-nextjs": "^5.13.4", + "@netlify/plugin-nextjs": "^5.13.5", "next": "^15.5.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/package-lock.json b/package-lock.json index 845a1cae16..0aaed5a515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@netlify/plugin-nextjs", - "version": "5.13.5", + "version": "5.14.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@netlify/plugin-nextjs", - "version": "5.13.5", + "version": "5.14.0", "license": "MIT", "devDependencies": { "@fastly/http-compute-js": "1.1.5", @@ -17,15 +17,17 @@ "@netlify/edge-functions": "^2.17.1", "@netlify/edge-functions-bootstrap": "^2.14.0", "@netlify/eslint-config-node": "^7.0.1", - "@netlify/functions": "^4.2.7", + "@netlify/functions": "^4.3.0", "@netlify/serverless-functions-api": "^2.5.0", "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", @@ -534,34 +536,6 @@ "integrity": "sha512-htzFO1Zc57S8kgdRK9mLcPVTW1BY2ijfH7Dk2CeZmspTWKdKqSo1iwmqrq2WtRjFlo8aRZYgLX0wFrDXF/9DLA==", "dev": true }, - "node_modules/@bundled-es-modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", - "dev": true, - "dependencies": { - "cookie": "^0.7.2" - } - }, - "node_modules/@bundled-es-modules/statuses": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", - "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", - "dev": true, - "dependencies": { - "statuses": "^2.0.1" - } - }, - "node_modules/@bundled-es-modules/tough-cookie": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", - "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", - "dev": true, - "dependencies": { - "@types/tough-cookie": "^4.0.5", - "tough-cookie": "^4.1.4" - } - }, "node_modules/@bytecodealliance/jco": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@bytecodealliance/jco/-/jco-1.3.0.tgz", @@ -3598,9 +3572,41 @@ } }, "node_modules/@jsonjoy.com/base64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.1.tgz", - "integrity": "sha512-LnFjVChaGY8cZVMwAIMjvA1XwQjZ/zIXHyh28IyJkyNkzof4Dkm1+KN9UIm3lHhREH4vs7XwZ0NpkZKnwOtEfg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.0.tgz", + "integrity": "sha512-6RX+W5a+ZUY/c/7J5s5jK9UinLfJo5oWKh84fb4X0yK2q4WXEWUWZWuEMjvCb1YNUQhEAhUfr5scEGOH7jC4YQ==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", "dev": true, "engines": { "node": ">=10.0" @@ -3614,15 +3620,38 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.0.3.tgz", - "integrity": "sha512-Q0SPAdmK6s5Fe3e1kcNvwNyk6e2+CxM8XZdGbf4abZG7nUO05KSie3/iX29loTBuY+75uVP6RixDSPVpotfzmQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.16.0.tgz", + "integrity": "sha512-L4/W6WRI7pXYJbPGqzYH1zJfckE/0ZP8ttNg/EPLwC+P23wSZYRmz2DNydAu2a8uc20bPlxsvWcYvDYoBJ5BYQ==", "dev": true, "dependencies": { - "@jsonjoy.com/base64": "^1.1.1", - "@jsonjoy.com/util": "^1.1.2", + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", - "thingies": "^1.20.0" + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" }, "engines": { "node": ">=10.0" @@ -3636,10 +3665,14 @@ } }, "node_modules/@jsonjoy.com/util": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.3.0.tgz", - "integrity": "sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", "dev": true, + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, "engines": { "node": ">=10.0" }, @@ -3693,9 +3726,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.37.6", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", - "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "version": "0.39.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.7.tgz", + "integrity": "sha512-sURvQbbKsq5f8INV54YJgJEdk8oxBanqkTiXXd33rKmofFCwZLhLRszPduMZ9TA9b8/1CHc/IJmOlBHJk2Q5AQ==", "dev": true, "dependencies": { "@open-draft/deferred-promise": "^2.2.0", @@ -4127,14 +4160,14 @@ "dev": true }, "node_modules/@netlify/functions": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-4.2.7.tgz", - "integrity": "sha512-TN2sijuyrEejhLfataxAKSFjFi8ZC0IMqrubg3Rz3ROBBwk54vdLwxibHxnKexou75MXsrpCotsEzm/V0xZwBA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-4.3.0.tgz", + "integrity": "sha512-m00J4hO/AL+1mAD4jCia1kg2jIoO3S9+DXCge58n5tTqPlWt42Vgig5zm0ICJoAyjMKw2bGzfVw9a/s/x6d1+Q==", "dev": true, "dependencies": { - "@netlify/blobs": "10.0.11", - "@netlify/dev-utils": "4.2.0", - "@netlify/types": "2.0.3", + "@netlify/blobs": "10.1.0", + "@netlify/dev-utils": "4.3.0", + "@netlify/types": "2.1.0", "@netlify/zip-it-and-ship-it": "^14.1.3", "cron-parser": "^4.9.0", "decache": "^4.6.2", @@ -4164,22 +4197,22 @@ } }, "node_modules/@netlify/functions/node_modules/@netlify/blobs": { - "version": "10.0.11", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.0.11.tgz", - "integrity": "sha512-/pa7eD2gxkhJ6aUIJULrRu3tvAaimy+sA6vHUuGRMvncjOuRpeatXLHxuzdn8DyK1CZCjN3E33oXsdEpoqG7SA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.1.0.tgz", + "integrity": "sha512-dFpqDc6/x5LEu9L7kblCQu00CFEchH8J42jmQoXPuhKoE7avajzeLTbVKA8Olk3S/c2m9ejegrgbhL8NRA2Jyw==", "dev": true, "dependencies": { - "@netlify/dev-utils": "4.2.0", - "@netlify/runtime-utils": "2.1.0" + "@netlify/dev-utils": "4.3.0", + "@netlify/runtime-utils": "2.2.0" }, "engines": { "node": "^14.16.0 || >=16.0.0" } }, "node_modules/@netlify/functions/node_modules/@netlify/dev-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.2.0.tgz", - "integrity": "sha512-P/uLJ5IKB4DhUOd6Q4Mpk7N0YKrnijUhAL3C05dEftNi3U3xJB98YekYfsL3G6GkS3L35pKGMx+vKJRwUHpP1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.0.tgz", + "integrity": "sha512-vZAL8pMuj3yPQlmHSgyaA/UQFxc6pZgU0LucFJ1+IPWGJtIzBXHRvuR4acpoP72HtyQPUHJ42s7U9GaaSGVNHg==", "dev": true, "dependencies": { "@whatwg-node/server": "^0.10.0", @@ -4202,10 +4235,19 @@ "node": "^18.14.0 || >=20" } }, + "node_modules/@netlify/functions/node_modules/@netlify/runtime-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.2.0.tgz", + "integrity": "sha512-K3kWIxIMucibzQsATU2xw2JI+OpS9PZfPW/a+81gmeLC8tLv5YAxTVT0NFY/3imk1kcOJb9g7658jPLqDJaiAw==", + "dev": true, + "engines": { + "node": "^18.14.0 || >=20" + } + }, "node_modules/@netlify/functions/node_modules/@netlify/types": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.0.3.tgz", - "integrity": "sha512-OcV8ivKTdsyANqVSQzbusOA7FVtE9s6zwxNCGR/aNnQaVxMUgm93UzKgfR7cZ1nnQNZHAbjd0dKJKaAUqrzbMw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.1.0.tgz", + "integrity": "sha512-ktUb5d58pt1lQGXO5E9S0F1ljM0g+CoQuGTVII0IxBc0apmPq5RI0o3OWLY7U3ZERRiYTg5UfjiMihBEzuZsuw==", "dev": true, "engines": { "node": "^18.14.0 || >=20" @@ -5317,12 +5359,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", - "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", "dev": true, "dependencies": { - "playwright": "1.55.1" + "playwright": "1.56.0" }, "bin": { "playwright": "cli.js" @@ -5812,11 +5854,15 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/estree": { "version": "1.0.8", @@ -5898,12 +5944,6 @@ "integrity": "sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==", "dev": true }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true - }, "node_modules/@types/triple-beam": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.4.tgz", @@ -6605,6 +6645,16 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -7983,15 +8033,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/copy-file": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-file/-/copy-file-11.0.0.tgz", @@ -11167,6 +11208,22 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -12863,19 +12920,18 @@ } }, "node_modules/memfs": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", - "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.49.0.tgz", + "integrity": "sha512-L9uC9vGuc4xFybbdOpRLoOAOq1YEBBsocCs5NVW32DfU+CZWWIn3OVF+lB8Gp4ttBVSMazwrTrjv8ussX/e3VQ==", "dev": true, "dependencies": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", "tslib": "^2.0.0" }, - "engines": { - "node": ">= 4.0.0" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/streamich" @@ -13407,29 +13463,29 @@ "dev": true }, "node_modules/msw": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.8.2.tgz", - "integrity": "sha512-ugu8RBgUj6//RD0utqDDPdS+QIs36BKYkDAM6u59hcMVtFM4PM0vW4l3G1R+1uCWP2EWFUG8reT/gPXVEtx7/w==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.5.tgz", + "integrity": "sha512-atFI4GjKSJComxcigz273honh8h4j5zzpk5kwG4tGm0TPcYne6bqmVrufeRll6auBeouIkXqZYXxVbWSWxM3RA==", "dev": true, "hasInstallScript": true, "dependencies": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", - "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.37.0", + "@mswjs/interceptors": "^0.39.1", "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", "type-fest": "^4.26.1", + "until-async": "^3.0.2", "yargs": "^17.7.2" }, "bin": { @@ -13450,6 +13506,15 @@ } } }, + "node_modules/msw/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -28822,12 +28887,12 @@ } }, "node_modules/playwright": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", - "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", "dev": true, "dependencies": { - "playwright-core": "1.55.1" + "playwright-core": "1.56.0" }, "bin": { "playwright": "cli.js" @@ -28840,9 +28905,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", - "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -29150,12 +29215,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -29175,12 +29234,6 @@ "node": ">=6" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -29642,12 +29695,6 @@ "integrity": "sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==", "dev": true }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -29754,6 +29801,12 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -29939,9 +29992,9 @@ "peer": true }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -30330,9 +30383,9 @@ "dev": true }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "engines": { "node": ">= 0.8" @@ -30815,13 +30868,17 @@ "dev": true }, "node_modules/thingies": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", - "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", "dev": true, "engines": { "node": ">=10.18" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, "peerDependencies": { "tslib": "^2" } @@ -30896,6 +30953,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", + "integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==", + "dev": true, + "dependencies": { + "tldts-core": "^7.0.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz", + "integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==", + "dev": true + }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -30943,27 +31018,15 @@ "dev": true }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^7.0.5" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { @@ -30973,9 +31036,9 @@ "dev": true }, "node_modules/tree-dump": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.1.tgz", - "integrity": "sha512-WCkcRBVPSlHHq1dc/px9iOfqklvzCbdRwvlNfxGZsrHqf6aZttfPrd7DJTt6oR10dwUfpFFQeVTkPbBIZxX/YA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, "engines": { "node": ">=10.0" @@ -31203,9 +31266,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -31303,9 +31366,9 @@ } }, "node_modules/unionfs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/unionfs/-/unionfs-4.5.4.tgz", - "integrity": "sha512-qI3RvJwwdFcWUdZz1dWgAyLSfGlY2fS2pstvwkZBUTnkxjcnIvzriBLtqJTKz9FtArAvJeiVCqHlxhOw8Syfyw==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/unionfs/-/unionfs-4.6.0.tgz", + "integrity": "sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==", "dev": true, "dependencies": { "fs-monkey": "^1.0.0" @@ -31372,6 +31435,15 @@ "node": ">=0.10.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -31411,16 +31483,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/urlpattern-polyfill": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", @@ -33073,34 +33135,6 @@ "integrity": "sha512-htzFO1Zc57S8kgdRK9mLcPVTW1BY2ijfH7Dk2CeZmspTWKdKqSo1iwmqrq2WtRjFlo8aRZYgLX0wFrDXF/9DLA==", "dev": true }, - "@bundled-es-modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", - "dev": true, - "requires": { - "cookie": "^0.7.2" - } - }, - "@bundled-es-modules/statuses": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", - "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", - "dev": true, - "requires": { - "statuses": "^2.0.1" - } - }, - "@bundled-es-modules/tough-cookie": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", - "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", - "dev": true, - "requires": { - "@types/tough-cookie": "^4.0.5", - "tough-cookie": "^4.1.4" - } - }, "@bytecodealliance/jco": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@bytecodealliance/jco/-/jco-1.3.0.tgz", @@ -34978,30 +35012,60 @@ "peer": true }, "@jsonjoy.com/base64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.1.tgz", - "integrity": "sha512-LnFjVChaGY8cZVMwAIMjvA1XwQjZ/zIXHyh28IyJkyNkzof4Dkm1+KN9UIm3lHhREH4vs7XwZ0NpkZKnwOtEfg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "requires": {} + }, + "@jsonjoy.com/buffers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.0.tgz", + "integrity": "sha512-6RX+W5a+ZUY/c/7J5s5jK9UinLfJo5oWKh84fb4X0yK2q4WXEWUWZWuEMjvCb1YNUQhEAhUfr5scEGOH7jC4YQ==", + "dev": true, + "requires": {} + }, + "@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", "dev": true, "requires": {} }, "@jsonjoy.com/json-pack": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.0.3.tgz", - "integrity": "sha512-Q0SPAdmK6s5Fe3e1kcNvwNyk6e2+CxM8XZdGbf4abZG7nUO05KSie3/iX29loTBuY+75uVP6RixDSPVpotfzmQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.16.0.tgz", + "integrity": "sha512-L4/W6WRI7pXYJbPGqzYH1zJfckE/0ZP8ttNg/EPLwC+P23wSZYRmz2DNydAu2a8uc20bPlxsvWcYvDYoBJ5BYQ==", "dev": true, "requires": { - "@jsonjoy.com/base64": "^1.1.1", - "@jsonjoy.com/util": "^1.1.2", + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", - "thingies": "^1.20.0" + "thingies": "^2.5.0" + } + }, + "@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "requires": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" } }, "@jsonjoy.com/util": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.3.0.tgz", - "integrity": "sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", "dev": true, - "requires": {} + "requires": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + } }, "@mapbox/node-pre-gyp": { "version": "2.0.0", @@ -35030,9 +35094,9 @@ } }, "@mswjs/interceptors": { - "version": "0.37.6", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", - "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "version": "0.39.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.7.tgz", + "integrity": "sha512-sURvQbbKsq5f8INV54YJgJEdk8oxBanqkTiXXd33rKmofFCwZLhLRszPduMZ9TA9b8/1CHc/IJmOlBHJk2Q5AQ==", "dev": true, "requires": { "@open-draft/deferred-promise": "^2.2.0", @@ -35383,14 +35447,14 @@ } }, "@netlify/functions": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-4.2.7.tgz", - "integrity": "sha512-TN2sijuyrEejhLfataxAKSFjFi8ZC0IMqrubg3Rz3ROBBwk54vdLwxibHxnKexou75MXsrpCotsEzm/V0xZwBA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-4.3.0.tgz", + "integrity": "sha512-m00J4hO/AL+1mAD4jCia1kg2jIoO3S9+DXCge58n5tTqPlWt42Vgig5zm0ICJoAyjMKw2bGzfVw9a/s/x6d1+Q==", "dev": true, "requires": { - "@netlify/blobs": "10.0.11", - "@netlify/dev-utils": "4.2.0", - "@netlify/types": "2.0.3", + "@netlify/blobs": "10.1.0", + "@netlify/dev-utils": "4.3.0", + "@netlify/types": "2.1.0", "@netlify/zip-it-and-ship-it": "^14.1.3", "cron-parser": "^4.9.0", "decache": "^4.6.2", @@ -35403,19 +35467,19 @@ }, "dependencies": { "@netlify/blobs": { - "version": "10.0.11", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.0.11.tgz", - "integrity": "sha512-/pa7eD2gxkhJ6aUIJULrRu3tvAaimy+sA6vHUuGRMvncjOuRpeatXLHxuzdn8DyK1CZCjN3E33oXsdEpoqG7SA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.1.0.tgz", + "integrity": "sha512-dFpqDc6/x5LEu9L7kblCQu00CFEchH8J42jmQoXPuhKoE7avajzeLTbVKA8Olk3S/c2m9ejegrgbhL8NRA2Jyw==", "dev": true, "requires": { - "@netlify/dev-utils": "4.2.0", - "@netlify/runtime-utils": "2.1.0" + "@netlify/dev-utils": "4.3.0", + "@netlify/runtime-utils": "2.2.0" } }, "@netlify/dev-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.2.0.tgz", - "integrity": "sha512-P/uLJ5IKB4DhUOd6Q4Mpk7N0YKrnijUhAL3C05dEftNi3U3xJB98YekYfsL3G6GkS3L35pKGMx+vKJRwUHpP1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.0.tgz", + "integrity": "sha512-vZAL8pMuj3yPQlmHSgyaA/UQFxc6pZgU0LucFJ1+IPWGJtIzBXHRvuR4acpoP72HtyQPUHJ42s7U9GaaSGVNHg==", "dev": true, "requires": { "@whatwg-node/server": "^0.10.0", @@ -35435,10 +35499,16 @@ "write-file-atomic": "^5.0.1" } }, + "@netlify/runtime-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.2.0.tgz", + "integrity": "sha512-K3kWIxIMucibzQsATU2xw2JI+OpS9PZfPW/a+81gmeLC8tLv5YAxTVT0NFY/3imk1kcOJb9g7658jPLqDJaiAw==", + "dev": true + }, "@netlify/types": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.0.3.tgz", - "integrity": "sha512-OcV8ivKTdsyANqVSQzbusOA7FVtE9s6zwxNCGR/aNnQaVxMUgm93UzKgfR7cZ1nnQNZHAbjd0dKJKaAUqrzbMw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.1.0.tgz", + "integrity": "sha512-ktUb5d58pt1lQGXO5E9S0F1ljM0g+CoQuGTVII0IxBc0apmPq5RI0o3OWLY7U3ZERRiYTg5UfjiMihBEzuZsuw==", "dev": true }, "is-stream": { @@ -36235,12 +36305,12 @@ "optional": true }, "@playwright/test": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", - "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", "dev": true, "requires": { - "playwright": "1.55.1" + "playwright": "1.56.0" } }, "@protobufjs/aspromise": { @@ -36562,11 +36632,14 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, - "@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true + "@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "requires": { + "@types/node": "*" + } }, "@types/estree": { "version": "1.0.8", @@ -36648,12 +36721,6 @@ "integrity": "sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==", "dev": true }, - "@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true - }, "@types/triple-beam": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.4.tgz", @@ -37144,6 +37211,12 @@ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true }, + "adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true + }, "agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -38146,12 +38219,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true - }, "copy-file": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-file/-/copy-file-11.0.0.tgz", @@ -40438,6 +40505,13 @@ "is-glob": "^4.0.3" } }, + "glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "requires": {} + }, "global-dirs": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", @@ -41647,14 +41721,16 @@ "dev": true }, "memfs": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", - "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.49.0.tgz", + "integrity": "sha512-L9uC9vGuc4xFybbdOpRLoOAOq1YEBBsocCs5NVW32DfU+CZWWIn3OVF+lB8Gp4ttBVSMazwrTrjv8ussX/e3VQ==", "dev": true, "requires": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, @@ -42046,29 +42122,37 @@ "dev": true }, "msw": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.8.2.tgz", - "integrity": "sha512-ugu8RBgUj6//RD0utqDDPdS+QIs36BKYkDAM6u59hcMVtFM4PM0vW4l3G1R+1uCWP2EWFUG8reT/gPXVEtx7/w==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.5.tgz", + "integrity": "sha512-atFI4GjKSJComxcigz273honh8h4j5zzpk5kwG4tGm0TPcYne6bqmVrufeRll6auBeouIkXqZYXxVbWSWxM3RA==", "dev": true, "requires": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", - "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.37.0", + "@mswjs/interceptors": "^0.39.1", "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", "type-fest": "^4.26.1", + "until-async": "^3.0.2", "yargs": "^17.7.2" + }, + "dependencies": { + "cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true + } } }, "mute-stream": { @@ -52687,19 +52771,19 @@ "dev": true }, "playwright": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", - "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.55.1" + "playwright-core": "1.56.0" } }, "playwright-core": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", - "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", "dev": true }, "pluralize": { @@ -52872,12 +52956,6 @@ "integrity": "sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==", "dev": true }, - "psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true - }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -52894,12 +52972,6 @@ "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -53244,12 +53316,6 @@ "integrity": "sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==", "dev": true }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, "resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -53325,6 +53391,12 @@ "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "dev": true }, + "rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -53451,9 +53523,9 @@ "peer": true }, "semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true }, "set-error-message": { @@ -53760,9 +53832,9 @@ "dev": true }, "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true }, "std-env": { @@ -54114,9 +54186,9 @@ "dev": true }, "thingies": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", - "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", "dev": true, "requires": {} }, @@ -54175,6 +54247,21 @@ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true }, + "tldts": { + "version": "7.0.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", + "integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==", + "dev": true, + "requires": { + "tldts-core": "^7.0.16" + } + }, + "tldts-core": { + "version": "7.0.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz", + "integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==", + "dev": true + }, "tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -54215,23 +54302,12 @@ "dev": true }, "tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, "requires": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "dependencies": { - "universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true - } + "tldts": "^7.0.5" } }, "tr46": { @@ -54241,9 +54317,9 @@ "dev": true }, "tree-dump": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.1.tgz", - "integrity": "sha512-WCkcRBVPSlHHq1dc/px9iOfqklvzCbdRwvlNfxGZsrHqf6aZttfPrd7DJTt6oR10dwUfpFFQeVTkPbBIZxX/YA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, "requires": {} }, @@ -54396,9 +54472,9 @@ } }, "typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true }, "unbox-primitive": { @@ -54464,9 +54540,9 @@ "dev": true }, "unionfs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/unionfs/-/unionfs-4.5.4.tgz", - "integrity": "sha512-qI3RvJwwdFcWUdZz1dWgAyLSfGlY2fS2pstvwkZBUTnkxjcnIvzriBLtqJTKz9FtArAvJeiVCqHlxhOw8Syfyw==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/unionfs/-/unionfs-4.6.0.tgz", + "integrity": "sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==", "dev": true, "requires": { "fs-monkey": "^1.0.0" @@ -54518,6 +54594,12 @@ } } }, + "until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true + }, "update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -54537,16 +54619,6 @@ "punycode": "^2.1.0" } }, - "url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "requires": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "urlpattern-polyfill": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", diff --git a/package.json b/package.json index e6bcca3ca9..9402203de6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netlify/plugin-nextjs", - "version": "5.13.5", + "version": "5.14.0", "description": "Run Next.js seamlessly on Netlify", "main": "./dist/index.js", "type": "module", @@ -60,15 +60,17 @@ "@netlify/edge-functions-bootstrap": "^2.14.0", "@netlify/edge-functions": "^2.17.1", "@netlify/eslint-config-node": "^7.0.1", - "@netlify/functions": "^4.2.7", + "@netlify/functions": "^4.3.0", "@netlify/serverless-functions-api": "^2.5.0", "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 63b09901ef..1fe088777b 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -207,6 +207,11 @@ export class PluginContext { return join(this.edgeFunctionsDir, EDGE_HANDLER_NAME) } + /** Absolute path to the skew protection config */ + get skewProtectionConfigPath(): string { + return this.resolveFromPackagePath('.netlify/v1/skew-protection.json') + } + constructor(options: NetlifyPluginOptions) { this.constants = options.constants this.featureFlags = options.featureFlags diff --git a/src/build/skew-protection.test.ts b/src/build/skew-protection.test.ts new file mode 100644 index 0000000000..0fbc2d206a --- /dev/null +++ b/src/build/skew-protection.test.ts @@ -0,0 +1,334 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { Span } from '@opentelemetry/api' +import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest' + +import type { PluginContext } from './plugin-context.js' +import { + EnabledOrDisabledReason, + setSkewProtection, + shouldEnableSkewProtection, + skewProtectionConfig, +} from './skew-protection.js' + +// Mock fs promises +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), +})) + +// Mock path +vi.mock('node:path', () => ({ + dirname: vi.fn(), +})) + +describe('shouldEnableSkewProtection', () => { + let mockCtx: PluginContext + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + // Save original env + originalEnv = { ...process.env } + + // Reset env vars + delete process.env.NETLIFY_NEXT_SKEW_PROTECTION + // Set valid DEPLOY_ID by default + process.env.DEPLOY_ID = 'test-deploy-id' + + mockCtx = { + featureFlags: {}, + constants: { + IS_LOCAL: false, + }, + } as PluginContext + + vi.clearAllMocks() + }) + + afterEach(() => { + // Restore original env + process.env = originalEnv + }) + + describe('default behavior', () => { + it('should return disabled by default', () => { + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, + }) + }) + }) + + describe('environment variable handling', () => { + describe('opt-in', () => { + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "true"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) + }) + + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "1"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) + }) + }) + + describe('opt-out', () => { + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "false"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) + }) + + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "0"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '0' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) + }) + }) + }) + + describe('feature flag opt-in', () => { + it('should enable when feature flag is set', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_FF, + }) + }) + + it('should not enable when feature flag is false', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': false } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, + }) + }) + }) + + describe('DEPLOY_ID validation', () => { + it('should disable when DEPLOY_ID is missing and not explicitly opted in', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + delete process.env.DEPLOY_ID + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, + }) + }) + + it('should disable when DEPLOY_ID is "0" and not explicitly opted in', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + process.env.DEPLOY_ID = '0' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, + }) + }) + + it('should show specific reason when env var is set but DEPLOY_ID is invalid in local context', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + process.env.DEPLOY_ID = '0' + mockCtx.constants.IS_LOCAL = true + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR, + }) + }) + }) + + describe('precedence', () => { + it('should prioritize env var opt-out over feature flag', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) + }) + + it('should prioritize env var opt-in over feature flag', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + mockCtx.featureFlags = { 'next-runtime-skew-protection': false } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) + }) + }) +}) + +describe('setSkewProtection', () => { + let mockCtx: PluginContext + let mockSpan: Span + let originalEnv: NodeJS.ProcessEnv + let consoleSpy: { + log: MockInstance + warn: MockInstance + } + + beforeEach(() => { + // Save original env + originalEnv = { ...process.env } + + // Reset env vars + delete process.env.NETLIFY_NEXT_SKEW_PROTECTION + delete process.env.NEXT_DEPLOYMENT_ID + // Set valid DEPLOY_ID by default + process.env.DEPLOY_ID = 'test-deploy-id' + + mockCtx = { + featureFlags: {}, + constants: { + IS_LOCAL: false, + }, + skewProtectionConfigPath: '/test/path/skew-protection.json', + } as PluginContext + + mockSpan = { + setAttribute: vi.fn(), + } as unknown as Span + + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => { + /* no op */ + }), + warn: vi.spyOn(console, 'warn').mockImplementation(() => { + /* no op */ + }), + } + + vi.clearAllMocks() + }) + + afterEach(() => { + // Restore original env + process.env = originalEnv + consoleSpy.log.mockRestore() + consoleSpy.warn.mockRestore() + }) + + it('should set span attribute and return early when disabled', async () => { + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_OUT_DEFAULT, + ) + expect(mkdir).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + expect(consoleSpy.log).not.toHaveBeenCalled() + expect(consoleSpy.warn).not.toHaveBeenCalled() + }) + + it('should show warning when env var is set but no valid DEPLOY_ID', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + process.env.DEPLOY_ID = '0' + mockCtx.constants.IS_LOCAL = true + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR, + ) + expect(consoleSpy.warn).toHaveBeenCalledWith( + 'NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to true, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.', + ) + expect(mkdir).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + }) + + it('should set up skew protection when enabled via env var', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + + vi.mocked(dirname).mockReturnValue('/test/path') + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_IN_ENV_VAR, + ) + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=true environment variable.', + ) + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id') + expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) + expect(writeFile).toHaveBeenCalledWith( + '/test/path/skew-protection.json', + JSON.stringify(skewProtectionConfig), + ) + }) + + it('should set up skew protection when enabled via feature flag', async () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + vi.mocked(dirname).mockReturnValue('/test/path') + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_IN_FF, + ) + expect(consoleSpy.log).toHaveBeenCalledWith('Setting up Next.js Skew Protection.') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id') + expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) + expect(writeFile).toHaveBeenCalledWith('/test/path/skew-protection.json', expect.any(String)) + }) + + it('should handle different env var values correctly', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' + + await setSkewProtection(mockCtx, mockSpan) + + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=1 environment variable.', + ) + }) +}) diff --git a/src/build/skew-protection.ts b/src/build/skew-protection.ts new file mode 100644 index 0000000000..150ad15f39 --- /dev/null +++ b/src/build/skew-protection.ts @@ -0,0 +1,118 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { Span } from '@opentelemetry/api' + +import type { PluginContext } from './plugin-context.js' + +// eslint-disable-next-line no-shadow +export const enum EnabledOrDisabledReason { + OPT_OUT_DEFAULT = 'off-default', + OPT_OUT_NO_VALID_DEPLOY_ID = 'off-no-valid-deploy-id', + OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR = 'off-no-valid-deploy-id-env-var', + OPT_IN_FF = 'on-ff', + OPT_IN_ENV_VAR = 'on-env-var', + OPT_OUT_ENV_VAR = 'off-env-var', +} + +const optInOptions = new Set([ + EnabledOrDisabledReason.OPT_IN_FF, + EnabledOrDisabledReason.OPT_IN_ENV_VAR, +]) + +export const skewProtectionConfig = { + patterns: ['.*'], + sources: [ + { + type: 'cookie', + name: '__vdpl', + }, + { + type: 'header', + name: 'X-Deployment-Id', + }, + { + type: 'query', + name: 'dpl', + }, + ], +} + +export function shouldEnableSkewProtection(ctx: PluginContext) { + let enabledOrDisabledReason: EnabledOrDisabledReason = EnabledOrDisabledReason.OPT_OUT_DEFAULT + + if ( + process.env.NETLIFY_NEXT_SKEW_PROTECTION === 'true' || + process.env.NETLIFY_NEXT_SKEW_PROTECTION === '1' + ) { + enabledOrDisabledReason = EnabledOrDisabledReason.OPT_IN_ENV_VAR + } else if ( + process.env.NETLIFY_NEXT_SKEW_PROTECTION === 'false' || + process.env.NETLIFY_NEXT_SKEW_PROTECTION === '0' + ) { + return { + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + } + } else if (ctx.featureFlags?.['next-runtime-skew-protection']) { + enabledOrDisabledReason = EnabledOrDisabledReason.OPT_IN_FF + } else { + return { + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, + } + } + + if ( + (!process.env.DEPLOY_ID || process.env.DEPLOY_ID === '0') && + optInOptions.has(enabledOrDisabledReason) + ) { + // We can't proceed without a valid DEPLOY_ID, because Next.js does inline deploy ID at build time + // This should only be the case for CLI deploys + return { + enabled: false, + enabledOrDisabledReason: + enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR && ctx.constants.IS_LOCAL + ? // this case is singled out to provide visible feedback to users that env var has no effect + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR + : // this is silent disablement to avoid spam logs for users opted in via feature flag + // that don't explicitly opt in via env var + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, + } + } + + return { + enabled: optInOptions.has(enabledOrDisabledReason), + enabledOrDisabledReason, + } +} + +export const setSkewProtection = async (ctx: PluginContext, span: Span) => { + const { enabled, enabledOrDisabledReason } = shouldEnableSkewProtection(ctx) + + span.setAttribute('skewProtection', enabledOrDisabledReason) + + if (!enabled) { + if (enabledOrDisabledReason === EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR) { + console.warn( + `NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to ${process.env.NETLIFY_NEXT_SKEW_PROTECTION}, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.`, + ) + } + return + } + + if (enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR) { + console.log( + `Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=${process.env.NETLIFY_NEXT_SKEW_PROTECTION} environment variable.`, + ) + } else { + console.log('Setting up Next.js Skew Protection.') + } + + process.env.NEXT_DEPLOYMENT_ID = process.env.DEPLOY_ID + + await mkdir(dirname(ctx.skewProtectionConfigPath), { + recursive: true, + }) + await writeFile(ctx.skewProtectionConfigPath, JSON.stringify(skewProtectionConfig)) +} diff --git a/src/index.ts b/src/index.ts index 27d9c1ff7b..296da96949 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/ed import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js' import { setImageConfig } from './build/image-cdn.js' import { PluginContext } from './build/plugin-context.js' +import { setSkewProtection } from './build/skew-protection.js' import { verifyAdvancedAPIRoutes, verifyNetlifyFormsWorkaround, @@ -49,7 +50,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { return } - await tracer.withActiveSpan('onPreBuild', async () => { + await tracer.withActiveSpan('onPreBuild', async (span) => { // Enable Next.js standalone mode at build time process.env.NEXT_PRIVATE_STANDALONE = 'true' const ctx = new PluginContext(options) @@ -62,6 +63,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { } else { await restoreBuildCache(ctx) } + await setSkewProtection(ctx, span) }) } diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 1baa0c3957..7b2fd8d321 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -25,7 +25,13 @@ import { } from '../storage/storage.cjs' import { getLogger, getRequestContext } from './request-context.cjs' -import { isAnyTagStale, markTagsAsStaleAndPurgeEdgeCache, purgeEdgeCache } from './tags-handler.cjs' +import { + isAnyTagStaleOrExpired, + markTagsAsStaleAndPurgeEdgeCache, + purgeEdgeCache, + type RevalidateTagDurations, + type TagStaleOrExpiredStatus, +} from './tags-handler.cjs' import { getTracer, recordWarning } from './tracer.cjs' let memoizedPrerenderManifest: PrerenderManifest @@ -290,19 +296,26 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { return null } - const staleByTags = await this.checkCacheEntryStaleByTags( + const { stale: staleByTags, expired: expiredByTags } = await this.checkCacheEntryStaleByTags( blob, context.tags, context.softTags, ) - if (staleByTags) { - span.addEvent('Stale', { staleByTags, key, ttl }) + if (expiredByTags) { + span.addEvent('Expired', { expiredByTags, key, ttl }) return null } this.captureResponseCacheLastModified(blob, key, span) + if (staleByTags) { + span.addEvent('Stale', { staleByTags, key, ttl }) + // note that we modify this after we capture last modified to ensure that Age is correct + // but we still let Next.js know that entry is stale + blob.lastModified = -1 // indicate that the entry is stale + } + // Next sets a kind/kindHint and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions const isDataRequest = Boolean(context.fetchUrl) if (!isDataRequest) { @@ -477,8 +490,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { }) } - async revalidateTag(tagOrTags: string | string[]) { - return markTagsAsStaleAndPurgeEdgeCache(tagOrTags) + async revalidateTag(tagOrTags: string | string[], durations?: RevalidateTagDurations) { + return markTagsAsStaleAndPurgeEdgeCache(tagOrTags, durations) } resetRequestCache() { @@ -493,7 +506,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { cacheEntry: NetlifyCacheHandlerValue, tags: string[] = [], softTags: string[] = [], - ) { + ): TagStaleOrExpiredStatus | Promise { let cacheTags: string[] = [] if (cacheEntry.value?.kind === 'FETCH') { @@ -508,7 +521,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { cacheTags = (cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER] as string)?.split(/,|%2c/gi) || [] } else { - return false + return { + stale: false, + expired: false, + } } // 1. Check if revalidateTags array passed from Next.js contains any of cacheEntry tags @@ -516,14 +532,17 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { // TODO: test for this case for (const tag of this.revalidatedTags) { if (cacheTags.includes(tag)) { - return true + return { + stale: true, + expired: true, + } } } } // 2. If any in-memory tags don't indicate that any of tags was invalidated // we will check blob store. - return isAnyTagStale(cacheTags, cacheEntry.lastModified) + return isAnyTagStaleOrExpired(cacheTags, cacheEntry.lastModified) } } diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index 47b86d8562..3f896b6722 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -11,47 +11,55 @@ import { getLogger, getRequestContext } from './request-context.cjs' const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}` -/** - * Get timestamp of the last revalidation for a tag - */ -async function getTagRevalidatedAt( +async function getTagManifest( tag: string, cacheStore: MemoizedKeyValueStoreBackedByRegionalBlobStore, -): Promise { +): Promise { const tagManifest = await cacheStore.get(tag, 'tagManifest.get') if (!tagManifest) { return null } - return tagManifest.revalidatedAt + return tagManifest } /** * Get the most recent revalidation timestamp for a list of tags */ -export async function getMostRecentTagRevalidationTimestamp(tags: string[]) { +export async function getMostRecentTagExpirationTimestamp(tags: string[]) { if (tags.length === 0) { return 0 } const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' }) - const timestampsOrNulls = await Promise.all( - tags.map((tag) => getTagRevalidatedAt(tag, cacheStore)), - ) + const manifestsOrNulls = await Promise.all(tags.map((tag) => getTagManifest(tag, cacheStore))) - const timestamps = timestampsOrNulls.filter((timestamp) => timestamp !== null) - if (timestamps.length === 0) { + const expirationTimestamps = manifestsOrNulls + .filter((manifest) => manifest !== null) + .map((manifest) => manifest.expireAt) + if (expirationTimestamps.length === 0) { return 0 } - return Math.max(...timestamps) + return Math.max(...expirationTimestamps) } +export type TagStaleOrExpiredStatus = + // FRESH + | { stale: false; expired: false } + // STALE + | { stale: true; expired: false; expireAt: number } + // EXPIRED (should be treated similarly to MISS) + | { stale: true; expired: true } + /** - * Check if any of the tags were invalidated since the given timestamp + * Check if any of the tags expired since the given timestamp */ -export function isAnyTagStale(tags: string[], timestamp: number): Promise { +export function isAnyTagStaleOrExpired( + tags: string[], + timestamp: number, +): Promise { if (tags.length === 0 || !timestamp) { - return Promise.resolve(false) + return Promise.resolve({ stale: false, expired: false }) } const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' }) @@ -60,37 +68,74 @@ export function isAnyTagStale(tags: string[], timestamp: number): Promise((resolve, reject) => { - const tagManifestPromises: Promise[] = [] + // "Worst case" scenario is none of tag was expired in which case we need to wait + // for all blob store checks to finish before we can be certain that no tag is expired. + return new Promise((resolve, reject) => { + const tagManifestPromises: Promise[] = [] for (const tag of tags) { - const lastRevalidationTimestampPromise = getTagRevalidatedAt(tag, cacheStore) + const tagManifestPromise = getTagManifest(tag, cacheStore) tagManifestPromises.push( - lastRevalidationTimestampPromise.then((lastRevalidationTimestamp) => { - if (!lastRevalidationTimestamp) { + tagManifestPromise.then((tagManifest) => { + if (!tagManifest) { // tag was never revalidated - return false + return { stale: false, expired: false } } - const isStale = lastRevalidationTimestamp >= timestamp - if (isStale) { - // resolve outer promise immediately if any of the tags is stale - resolve(true) - return true + const stale = tagManifest.staleAt >= timestamp + const expired = tagManifest.expireAt >= timestamp && tagManifest.expireAt <= Date.now() + + if (expired && stale) { + const expiredResult: TagStaleOrExpiredStatus = { + stale, + expired, + } + // resolve outer promise immediately if any of the tags is expired + resolve(expiredResult) + return expiredResult } - return false + + if (stale) { + const staleResult: TagStaleOrExpiredStatus = { + stale, + expired, + expireAt: tagManifest.expireAt, + } + return staleResult + } + return { stale: false, expired: false } }), ) } - // make sure we resolve promise after all blobs are checked (if we didn't resolve as stale yet) + // make sure we resolve promise after all blobs are checked (if we didn't resolve as expired yet) Promise.all(tagManifestPromises) - .then((tagManifestAreStale) => { - resolve(tagManifestAreStale.some((tagIsStale) => tagIsStale)) + .then((tagManifestsAreStaleOrExpired) => { + let result: TagStaleOrExpiredStatus = { stale: false, expired: false } + + for (const tagResult of tagManifestsAreStaleOrExpired) { + if (tagResult.expired) { + // if any of the tags is expired, the whole thing is expired + result = tagResult + break + } + + if (tagResult.stale) { + result = { + stale: true, + expired: false, + expireAt: + // make sure to use expireAt that is lowest of all tags + result.stale && !result.expired && typeof result.expireAt === 'number' + ? Math.min(result.expireAt, tagResult.expireAt) + : tagResult.expireAt, + } + } + } + + resolve(result) }) .catch(reject) }) @@ -122,15 +167,30 @@ export function purgeEdgeCache(tagOrTags: string | string[]): Promise { }) } -async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { - getLogger().withFields({ tags }).debug('doRevalidateTagAndPurgeEdgeCache') +// shape of this type comes from Next.js https://github.com/vercel/next.js/blob/fffa2831b61fa74852736eeaad2f17fbdd553bce/packages/next/src/server/lib/incremental-cache/index.ts#L78 +// and we use it internally +export type RevalidateTagDurations = { + /** + * Number of seconds after which tagged cache entries should no longer serve stale content. + */ + expire?: number +} + +async function doRevalidateTagAndPurgeEdgeCache( + tags: string[], + durations?: RevalidateTagDurations, +): Promise { + getLogger().withFields({ tags, durations }).debug('doRevalidateTagAndPurgeEdgeCache') if (tags.length === 0) { return } + const now = Date.now() + const tagManifest: TagManifest = { - revalidatedAt: Date.now(), + staleAt: now, + expireAt: now + (durations?.expire ? durations.expire * 1000 : 0), } const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' }) @@ -148,10 +208,13 @@ async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { await purgeEdgeCache(tags) } -export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) { +export function markTagsAsStaleAndPurgeEdgeCache( + tagOrTags: string | string[], + durations?: RevalidateTagDurations, +) { const tags = getCacheTagsFromTagOrTags(tagOrTags) - const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags) + const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags, durations) const requestContext = getRequestContext() if (requestContext) { diff --git a/src/run/handlers/use-cache-handler.ts b/src/run/handlers/use-cache-handler.ts index b2f6f5f2b9..510a2087b5 100644 --- a/src/run/handlers/use-cache-handler.ts +++ b/src/run/handlers/use-cache-handler.ts @@ -10,8 +10,8 @@ import type { import { getLogger } from './request-context.cjs' import { - getMostRecentTagRevalidationTimestamp, - isAnyTagStale, + getMostRecentTagExpirationTimestamp, + isAnyTagStaleOrExpired, markTagsAsStaleAndPurgeEdgeCache, } from './tags-handler.cjs' import { getTracer } from './tracer.cjs' @@ -127,7 +127,9 @@ export const NetlifyDefaultUseCacheHandler = { return undefined } - if (await isAnyTagStale(entry.tags, entry.timestamp)) { + const { stale } = await isAnyTagStaleOrExpired(entry.tags, entry.timestamp) + + if (stale) { getLogger() .withFields({ cacheKey, ttl, status: 'STALE BY TAG' }) .debug(`[NetlifyDefaultUseCacheHandler] get result`) @@ -229,7 +231,7 @@ export const NetlifyDefaultUseCacheHandler = { tags, }) - const expiration = await getMostRecentTagRevalidationTimestamp(tags) + const expiration = await getMostRecentTagExpirationTimestamp(tags) getLogger() .withFields({ tags, expiration }) diff --git a/src/shared/blob-types.cts b/src/shared/blob-types.cts index c7b113a262..75a53194b0 100644 --- a/src/shared/blob-types.cts +++ b/src/shared/blob-types.cts @@ -1,6 +1,15 @@ import { type NetlifyCacheHandlerValue } from './cache-types.cjs' -export type TagManifest = { revalidatedAt: number } +export type TagManifest = { + /** + * Timestamp when tag was revalidated. Used to determine if a tag is stale. + */ + staleAt: number + /** + * Timestamp when tagged cache entry should no longer serve stale content. + */ + expireAt: number +} export type HtmlBlob = { html: string @@ -13,9 +22,11 @@ export const isTagManifest = (value: BlobType): value is TagManifest => { return ( typeof value === 'object' && value !== null && - 'revalidatedAt' in value && - typeof value.revalidatedAt === 'number' && - Object.keys(value).length === 1 + 'staleAt' in value && + typeof value.staleAt === 'number' && + 'expiredAt' in value && + typeof value.expiredAt === 'number' && + Object.keys(value).length === 2 ) } diff --git a/src/shared/blob-types.test.ts b/src/shared/blob-types.test.ts index 16c0a5c5f9..2bff65f43b 100644 --- a/src/shared/blob-types.test.ts +++ b/src/shared/blob-types.test.ts @@ -4,7 +4,7 @@ import { BlobType, HtmlBlob, isHtmlBlob, isTagManifest, TagManifest } from './bl describe('isTagManifest', () => { it(`returns true for TagManifest instance`, () => { - const value: TagManifest = { revalidatedAt: 0 } + const value: TagManifest = { staleAt: 0, expiredAt: 0 } expect(isTagManifest(value)).toBe(true) }) @@ -21,7 +21,7 @@ describe('isHtmlBlob', () => { }) it(`returns false for non-HtmlBlob instance`, () => { - const value: BlobType = { revalidatedAt: 0 } + const value: BlobType = { staleAt: 0, expiredAt: 0 } expect(isHtmlBlob(value)).toBe(false) }) }) diff --git a/tests/e2e/on-demand-app.test.ts b/tests/e2e/on-demand-app.test.ts index b4e6ea63aa..9b6ee6dac9 100644 --- a/tests/e2e/on-demand-app.test.ts +++ b/tests/e2e/on-demand-app.test.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test' import { test } from '../utils/playwright-helpers.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' -test.describe('app router on-demand revalidation', () => { +test.describe('app router on-demand revalidation (pre Next 16 APIs)', () => { for (const { label, prerendered, pagePath, revalidateApiPath, expectedH1Content } of [ { label: 'revalidatePath (prerendered page with static path)', @@ -193,3 +193,299 @@ test.describe('app router on-demand revalidation', () => { }) } }) + +if (nextVersionSatisfies('>=16.0.0-alpha.0')) { + test.describe('app router on-demand revalidation (Next 16 APIs)', () => { + test.describe.configure({ mode: 'parallel' }) + + for (const { label, prerendered, pagePathSuffix, tagSuffix, expectedH1Content } of [ + { + label: 'prerendered page with static path', + prerendered: true, + pagePathSuffix: '/product-static', + tagSuffix: 'product-static', + expectedH1Content: 'Product product-static', + }, + { + label: 'prerendered page with dynamic path', + prerendered: true, + pagePathSuffix: '/product/prerendered', + tagSuffix: 'prerendered', + expectedH1Content: 'Product prerendered', + }, + { + label: 'not prerendered page with dynamic path', + prerendered: false, + pagePathSuffix: '/product/not-prerendered', + tagSuffix: 'not-prerendered', + expectedH1Content: 'Product not-prerendered', + }, + ]) { + test.describe(label, () => { + for (const { label, revalidateApiProfileSuffix, tagPrefix } of [ + { + label: 'revalidateTag with string profile', + revalidateApiProfileSuffix: `profile=testCacheLife`, + tagPrefix: `revalidate-tag-string-profile`, + }, + { + label: 'revalidateTag with explicit inline expire', + revalidateApiProfileSuffix: `expire=5`, + tagPrefix: `revalidate-tag-explicit-inline-expire`, + }, + ]) { + test(label, async ({ page, pollUntilHeadersMatch, next16TagRevalidation }) => { + const pagePath = `/${tagPrefix}${pagePathSuffix}` + const revalidateApiPath = `/api/revalidate-tag?tag=${tagPrefix}-${tagSuffix}&${revalidateApiProfileSuffix}` + + // in case there is retry or some other test did hit that path before + // we want to make sure that cdn cache is not warmed up + const purgeCdnCache = await page.goto( + new URL(`/api/purge-cdn?path=${pagePath}`, next16TagRevalidation.url).href, + ) + expect(purgeCdnCache?.status()).toBe(200) + + // wait a bit until cdn cache purge propagates + await page.waitForTimeout(500) + + const response1 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // either first time hitting this route or we invalidated + // just CDN node in earlier step + // we will invoke function and see Next cache hit status + // in the response if it was prerendered at build time + // or regenerated in previous attempt to run this test + 'cache-status': [ + /"Netlify Edge"; fwd=(miss|stale)/m, + prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m, + ], + }, + headersNotMatchedMessage: + 'First request to tested page should be a miss or stale on the Edge and hit in Next.js', + }, + ) + const headers1 = response1?.headers() || {} + expect(response1?.status()).toBe(200) + expect(headers1['x-nextjs-cache']).toBeUndefined() + expect(headers1['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + const date1 = await page.getByTestId('date-now').textContent() + + const h1 = await page.locator('h1').textContent() + expect(h1).toBe(expectedH1Content) + + const response2 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // we are hitting the same page again and we most likely will see + // CDN hit (in this case Next reported cache status is omitted + // as it didn't actually take place in handling this request) + // or we will see CDN miss because different CDN node handled request + 'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m, + }, + headersNotMatchedMessage: + 'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)', + }, + ) + const headers2 = response2?.headers() || {} + expect(response2?.status()).toBe(200) + expect(headers2['x-nextjs-cache']).toBeUndefined() + if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) { + // if we missed CDN cache, we will see Next cache hit status + // as we reuse cached response + expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m) + } + expect(headers2['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page is cached + const date2 = await page.getByTestId('date-now').textContent() + expect(date2).toBe(date1) + + const revalidate = await page.goto( + new URL(revalidateApiPath, next16TagRevalidation.url).href, + ) + expect(revalidate?.status()).toBe(200) + + // wait a bit until cdn tags and invalidated and cdn is purged + await page.waitForTimeout(500) + + // now after the revalidation with delayed expiration, it should serve stale if we are still before expiration time was not reached + const response3 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // revalidatePath just marks the page(s) as stale and does NOT + // automatically refreshes the cache. This request should result + // in serving stale content and trigger background revalidation. + 'cache-status': [ + /"Next.js"; hit; fwd=stale/m, + /"Netlify Edge"; fwd=(miss|stale)/m, + ], + }, + headersNotMatchedMessage: + 'Third request to tested page should be a miss or stale on the Edge and stale in Next.js after on-demand revalidation with delayed expiration', + }, + ) + const headers3 = response3?.headers() || {} + expect(response3?.status()).toBe(200) + expect(headers3?.['x-nextjs-cache']).toBeUndefined() + expect(headers3['debug-netlify-cdn-cache-control'], 'Stale is not cacheable').toBe( + 'public, max-age=0, must-revalidate, durable', + ) + + // the page is stale but still served, because we hit it before expiration + const date3 = await page.getByTestId('date-now').textContent() + expect(date3).toBe(date2) + + // previous request should trigger background revalidation. There is 5s sleep in data fetching in tested page + // so let's wait for that + + await page.waitForTimeout(6000) + + const response4 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // we are hitting the same page again and we most likely will see + // CDN hit (in this case Next reported cache status is omitted + // as it didn't actually take place in handling this request) + // or we will see CDN miss because different CDN node handled request + 'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m, + }, + headersNotMatchedMessage: + 'Fourth request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)', + }, + ) + const headers4 = response4?.headers() || {} + expect(response4?.status()).toBe(200) + expect(headers4?.['x-nextjs-cache']).toBeUndefined() + if (!headers4['cache-status'].includes('"Netlify Edge"; hit')) { + // if we missed CDN cache, we will see Next cache hit status + // as we reuse cached response + expect(headers4['cache-status']).toMatch(/"Next.js"; hit/m) + } + expect(headers4['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page is cached + const date4 = await page.getByTestId('date-now').textContent() + expect(date4).not.toBe(date3) + + // lets revalidate again, but now we will wait for expiration time to pass to test that we are not serving stale anymore + const revalidate2 = await page.goto( + new URL(revalidateApiPath, next16TagRevalidation.url).href, + ) + expect(revalidate2?.status()).toBe(200) + + // revalidation should allow stale to be served for 5 seconds, let's wait to test case after expiration + await page.waitForTimeout(6000) + + // now after the revalidation it should have a different date + const response5 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // revalidatePath just marks the page(s) as invalid and does NOT + // automatically refreshes the cache. This request will cause + // Next.js cache miss and new response will be generated and cached + // Depending if we hit same CDN node as previous request, we might + // get either fwd=miss or fwd=stale + 'cache-status': [/"Next.js"; fwd=miss/m, /"Netlify Edge"; fwd=(miss|stale)/m], + }, + headersNotMatchedMessage: + 'Third request to tested page should be a miss or stale on the Edge and miss in Next.js after on-demand revalidation', + }, + ) + const headers5 = response5?.headers() || {} + expect(response5?.status()).toBe(200) + expect(headers5?.['x-nextjs-cache']).toBeUndefined() + expect(headers5['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page has now an updated date + const date5 = await page.getByTestId('date-now').textContent() + expect(date5).not.toBe(date4) + }) + } + + test('updateTag in server action', async ({ + page, + pollUntilHeadersMatch, + next16TagRevalidation, + }) => { + const pagePath = `/update-tag/${pagePathSuffix}` + // in case there is retry or some other test did hit that path before + // we want to make sure that cdn cache is not warmed up + const purgeCdnCache = await page.goto( + new URL(`/api/purge-cdn?path=${pagePath}`, next16TagRevalidation.url).href, + ) + expect(purgeCdnCache?.status()).toBe(200) + + // wait a bit until cdn cache purge propagates + await page.waitForTimeout(500) + + const response1 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // either first time hitting this route or we invalidated + // just CDN node in earlier step + // we will invoke function and see Next cache hit status + // in the response if it was prerendered at build time + // or regenerated in previous attempt to run this test + 'cache-status': [ + /"Netlify Edge"; fwd=(miss|stale)/m, + prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m, + ], + }, + headersNotMatchedMessage: + 'First request to tested page should be a miss or stale on the Edge and hit in Next.js', + }, + ) + const headers1 = response1?.headers() || {} + expect(response1?.status()).toBe(200) + expect(headers1['x-nextjs-cache']).toBeUndefined() + expect(headers1['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + const date1 = await page.getByTestId('date-now').textContent() + + const h1 = await page.locator('h1').textContent() + expect(h1).toBe(expectedH1Content) + + const response2 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // we are hitting the same page again and we most likely will see + // CDN hit (in this case Next reported cache status is omitted + // as it didn't actually take place in handling this request) + // or we will see CDN miss because different CDN node handled request + 'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m, + }, + headersNotMatchedMessage: + 'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)', + }, + ) + const headers2 = response2?.headers() || {} + expect(response2?.status()).toBe(200) + expect(headers2['x-nextjs-cache']).toBeUndefined() + if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) { + // if we missed CDN cache, we will see Next cache hit status + // as we reuse cached response + expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m) + } + expect(headers2['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page is cached + const date2 = await page.getByTestId('date-now').textContent() + expect(date2).toBe(date1) + + await page.getByTestId('update-tag-button').click() + + await expect(page.getByTestId('date-now')).not.toHaveText(date2!, { timeout: 15_000 }) + }) + }) + } + }) +} diff --git a/tests/e2e/simple-app.test.ts b/tests/e2e/simple-app.test.ts index c9b4aea017..50a6773bcd 100644 --- a/tests/e2e/simple-app.test.ts +++ b/tests/e2e/simple-app.test.ts @@ -234,7 +234,7 @@ test('requesting a non existing page route that needs to be fetched from the blo // would not ... and then https://github.com/vercel/next.js/pull/69802 changed it back again // (14.2.10 and canary.147) const shouldHavePrivateDirective = nextVersionSatisfies( - '<14.2.4 || >=14.2.10 <15.0.0-canary.24 || ^15.0.0-canary.147', + '<14.2.4 || >=14.2.10 <15.0.0-canary.24 || >=15.0.0-canary.147', ) expect(headers['debug-netlify-cdn-cache-control']).toBe( diff --git a/tests/e2e/skew-protection.test.ts b/tests/e2e/skew-protection.test.ts new file mode 100644 index 0000000000..dfec460313 --- /dev/null +++ b/tests/e2e/skew-protection.test.ts @@ -0,0 +1,574 @@ +import { expect } from '@playwright/test' +import { execaCommand } from 'execa' +import { + createE2EFixture, + createSite, + deleteSite, + getBuildFixtureVariantCommand, + publishDeploy, +} from '../utils/create-e2e-fixture.js' +import { test as baseTest } from '../utils/playwright-helpers.js' +import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' + +type ExtendedFixtures = { + skewProtection: { + siteId: string + url: string + deployA: Awaited> + deployB: Awaited> + } +} + +const test = baseTest.extend< + { prepareSkewProtectionScenario: (callback: () => T) => Promise }, + ExtendedFixtures +>({ + prepareSkewProtectionScenario: async ({ skewProtection }, use) => { + const fixture = async (callback: () => T) => { + // first we will publish deployA + // then we call arbitrary callback to allow tests to load page using deployA + // and after that we will publish deployB so page loaded in browser is not using + // currently published deploy anymore, but still get results from initially published deploy + + const pollURL = `${skewProtection.url}/variant.txt` + + await publishDeploy(skewProtection.siteId, skewProtection.deployA.deployID) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('A')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + const result = await callback() + + await publishDeploy(skewProtection.siteId, skewProtection.deployB.deployID) + + // https://netlify.slack.com/archives/C098NQ4DEF6/p1758207235732189 + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('B')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + return result + } + + await use(fixture) + }, + skewProtection: [ + async ({}, use) => { + const { siteId, url } = await createSite({ + name: `next-skew-tests-${Date.now()}`, + }) + + let onBuildStart: () => void = () => {} + const waitForBuildStart = new Promise((resolve) => { + onBuildStart = () => { + resolve() + } + }) + + const deployAPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + onBuildStart, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + }) + + // we don't have to wait for deployA to finish completely before starting deployB, but we do have to wait a little bit + // to at least when build starts building, as otherwise whole deploy might be skipped and only second deploy happens + await waitForBuildStart + + const deployBPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + onPreDeploy: async (fixtureRoot) => { + await execaCommand( + `${getBuildFixtureVariantCommand('variant-b')} --apply-file-changes-only`, + { + cwd: fixtureRoot, + }, + ) + }, + }) + + const [deployA, deployB] = await Promise.all([deployAPromise, deployBPromise]) + + const fixture = { + url, + siteId, + deployA, + deployB, + + cleanup: async () => { + if (process.env.E2E_PERSIST) { + console.log( + `💾 Fixture and deployed site have been persisted. To clean up automatically, run tests without the 'E2E_PERSIST' environment variable.`, + ) + + return + } + + await deployA.cleanup() + await deployB.cleanup() + await deleteSite(siteId) + }, + } + + // for local iteration - this will print out snippet to allow to reuse previously deployed setup + // paste this at the top of `skewProtection` fixture function and this will avoid having to wait for redeploys + // keep in mind that if fixture itself require changes, you will have to redeploy + // uncomment console.log if you want to use same site/fixture and just iterate on test themselves + // and run a test with E2E_PERSIST=1 to keep site around for future runs + if (process.env.E2E_PERSIST) { + console.log( + 'You can reuse persisted site by pasting below snippet at the top of `skewProtection` fixture logic', + ) + console.log(`await use(${JSON.stringify(fixture, null, 2)})\n\nreturn`) + } + await use(fixture) + + await fixture.cleanup() + }, + { + scope: 'worker', + }, + ], +}) + +test.describe('Skew Protection', () => { + test.describe('App Router', () => { + test('should scope next/link navigation to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + + // this tests that both RSC and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-linked-page"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-server-component-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-page-client-component-current-variant')).toHaveText( + '"A"', + ) + }) + + test('should scope server actions to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('server-action-button').click() + + const element = await page.waitForSelector('[data-testid="server-action-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be either "B" (currently published deploy) + // or error about not finding server action - example of such error: + // "Error: Server Action "00a130b1673301d79679b22abb06a62c3125376d79" was not found on the server. + // Read more: https://nextjs.org/docs/messages/failed-to-find-server-action" + expect(content).toBe(`"A"`) + }) + + test('should scope route handler to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('scoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-route-handler-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope route handler to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('unscoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-route-handler-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Pages Router', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('when linked page is fully static', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-fully-static"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-static-current-variant')).toHaveText('"A"') + }) + + test('when linked page is getStaticProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getStaticProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getStaticProps-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-getStaticProps-props-variant')).toHaveText('"A"') + }) + + test('when linked page is getServerSideProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getServerSideProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getServerSideProps-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-getServerSideProps-props-variant')).toHaveText('"A"') + }) + }) + + test('should scope api route to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('scoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-api-route-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope api route to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('unscoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-api-route-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Middleware', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('NextResponse.next()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-next"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('next') + }) + + test('NextResponse.redirect()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('NextResponse.rewrite()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test('should scope middleware endpoint to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('scoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="scoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope middleware endpoint to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('unscoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="unscoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Next.js config rewrite and redirects', () => { + test('should scope next/link navigation to initial deploy when link target is Next.js config redirect', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('should scope next/link navigation to initial deploy when link target is Next.js config rewrite', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test.describe('Dynamic import', () => { + test('should scope dynamic import to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/dynamic-import`) + }) + + page.getByTestId('dynamic-import-button').click() + + const element = await page.waitForSelector('[data-testid="dynamic-import-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + }) +}) diff --git a/tests/fixtures/next-16-tag-revalidation/app/api/purge-cdn/route.ts b/tests/fixtures/next-16-tag-revalidation/app/api/purge-cdn/route.ts new file mode 100644 index 0000000000..1f2b9d521f --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/api/purge-cdn/route.ts @@ -0,0 +1,40 @@ +import { purgeCache } from '@netlify/functions' +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + const url = new URL(request.url) + const pathToPurge = url.searchParams.get('path') + + if (!pathToPurge) { + return NextResponse.json( + { + status: 'error', + error: 'missing "path" query parameter', + }, + { status: 400 }, + ) + } + try { + await purgeCache({ tags: [`_N_T_${encodeURI(pathToPurge)}`] }) + return NextResponse.json( + { + status: 'ok', + }, + { + status: 200, + }, + ) + } catch (error) { + return NextResponse.json( + { + status: 'error', + error: error.toString(), + }, + { + status: 500, + }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/next-16-tag-revalidation/app/api/revalidate-tag/route.ts b/tests/fixtures/next-16-tag-revalidation/app/api/revalidate-tag/route.ts new file mode 100644 index 0000000000..9984ed5362 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/api/revalidate-tag/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' +import { revalidateTag } from 'next/cache' + +export async function GET(request: NextRequest) { + const url = new URL(request.url) + const tagToRevalidate = url.searchParams.get('tag') ?? 'collection' + + let profile: Parameters[1] | undefined | null + if (url.searchParams.has('profile')) { + profile = url.searchParams.get('profile') + } else if (url.searchParams.has('expire')) { + profile = { + expire: parseInt(url.searchParams.get('expire')), + } + } + + if (profile) { + console.log(`Revalidating tag: ${tagToRevalidate}, profile: ${JSON.stringify(profile)}`) + + revalidateTag(tagToRevalidate, profile) + return NextResponse.json({ revalidated: true, now: new Date().toISOString() }) + } else { + return NextResponse.json({ error: 'Missing profile or expire query param' }, { status: 400 }) + } +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/next-16-tag-revalidation/app/layout.js b/tests/fixtures/next-16-tag-revalidation/app/layout.js new file mode 100644 index 0000000000..c7729550fe --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Revalidate fetch', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product-static/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product-static/page.js new file mode 100644 index 0000000000..4d944fbe0a --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product-static/page.js @@ -0,0 +1,35 @@ +import { unstable_cache } from 'next/cache' + +const slug = 'product-static' +const tag = `revalidate-tag-explicit-inline-expire-${slug}` + +const Product = async () => { + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product/[slug]/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product/[slug]/page.js new file mode 100644 index 0000000000..525073afdb --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product/[slug]/page.js @@ -0,0 +1,44 @@ +import { unstable_cache } from 'next/cache' + +const Product = async ({ params }) => { + const { slug } = await params + + const tag = `revalidate-tag-explicit-inline-expire-${slug}` + + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export async function generateStaticParams() { + return [ + { + slug: 'prerendered', + }, + ] +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product-static/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product-static/page.js new file mode 100644 index 0000000000..e172b8e561 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product-static/page.js @@ -0,0 +1,35 @@ +import { unstable_cache } from 'next/cache' + +const slug = 'product-static' +const tag = `revalidate-tag-string-profile-${slug}` + +const Product = async () => { + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product/[slug]/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product/[slug]/page.js new file mode 100644 index 0000000000..8a1b3f06f1 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product/[slug]/page.js @@ -0,0 +1,44 @@ +import { unstable_cache } from 'next/cache' + +const Product = async ({ params }) => { + const { slug } = await params + + const tag = `revalidate-tag-string-profile-${slug}` + + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export async function generateStaticParams() { + return [ + { + slug: 'prerendered', + }, + ] +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/update-tag/product-static/page.js b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product-static/page.js new file mode 100644 index 0000000000..2c9ac266ca --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product-static/page.js @@ -0,0 +1,46 @@ +import { unstable_cache, updateTag } from 'next/cache' + +const slug = 'product-static' +const tag = `update-tag-${slug}` + +const Product = async () => { + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ +
+
+ ) +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/update-tag/product/[slug]/page.js b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product/[slug]/page.js new file mode 100644 index 0000000000..8067165dc4 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product/[slug]/page.js @@ -0,0 +1,55 @@ +import { unstable_cache, updateTag } from 'next/cache' + +const Product = async ({ params }) => { + const { slug } = await params + + const tag = `update-tag-${slug}` + + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ +
+
+ ) +} + +export async function generateStaticParams() { + return [ + { + slug: 'prerendered', + }, + ] +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/next-env.d.ts b/tests/fixtures/next-16-tag-revalidation/next-env.d.ts new file mode 100644 index 0000000000..c4e7c0ebef --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import './.next/types/routes.d.ts' + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/fixtures/next-16-tag-revalidation/next.config.js b/tests/fixtures/next-16-tag-revalidation/next.config.js new file mode 100644 index 0000000000..14adefcbd9 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/next.config.js @@ -0,0 +1,16 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + outputFileTracingRoot: __dirname, + experimental: { + cacheLife: { + testCacheLife: { + stale: 0, + revalidate: 365 * 60 * 60 * 24, // 1 year + expire: 5, // 5 seconds to test expiration + }, + }, + }, +} + +module.exports = nextConfig diff --git a/tests/fixtures/next-16-tag-revalidation/package.json b/tests/fixtures/next-16-tag-revalidation/package.json new file mode 100644 index 0000000000..68821f2441 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/package.json @@ -0,0 +1,26 @@ +{ + "name": "next-16-tag-revalidation", + "description": "Testing updated tags/caching APIs for next@16", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "@netlify/functions": "^2.7.0", + "@types/node": "^24.7.2", + "@types/react": "^19.2.2", + "next": "^16.0.0-beta.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "semver": "^7.7.2", + "typescript": "^5.9.3" + }, + "test": { + "dependencies": { + "next": ">=16.0.0-alpha.0" + } + } +} diff --git a/tests/fixtures/next-16-tag-revalidation/tsconfig.json b/tests/fixtures/next-16-tag-revalidation/tsconfig.json new file mode 100644 index 0000000000..41369d3b36 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.mts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/tests/fixtures/ppr/next.config.js b/tests/fixtures/ppr/next.config.js index 5f233036fc..665328ab73 100644 --- a/tests/fixtures/ppr/next.config.js +++ b/tests/fixtures/ppr/next.config.js @@ -1,12 +1,27 @@ +const { satisfies } = require('semver') + +// https://github.com/vercel/next.js/pull/84280 +const pprConfigHardDeprecated = satisfies( + require('next/package.json').version, + '>=15.6.0-canary.54', + { + includePrerelease: true, + }, +) + /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', eslint: { ignoreDuringBuilds: true, }, - experimental: { - ppr: true, - }, + experimental: pprConfigHardDeprecated + ? { + cacheComponents: true, + } + : { + ppr: true, + }, outputFileTracingRoot: __dirname, } diff --git a/tests/fixtures/ppr/package.json b/tests/fixtures/ppr/package.json index 7cc6aca0b6..276dd9325b 100644 --- a/tests/fixtures/ppr/package.json +++ b/tests/fixtures/ppr/package.json @@ -10,7 +10,8 @@ "dependencies": { "next": "canary", "react": "18.2.0", - "react-dom": "18.2.0" + "react-dom": "18.2.0", + "semver": "^7.7.2" }, "test": { "dependencies": { diff --git a/tests/fixtures/server-components/app/api/on-demand-revalidate/tag/route.ts b/tests/fixtures/server-components/app/api/on-demand-revalidate/tag/route.ts index ae91b90b4e..a2d9a0aba9 100644 --- a/tests/fixtures/server-components/app/api/on-demand-revalidate/tag/route.ts +++ b/tests/fixtures/server-components/app/api/on-demand-revalidate/tag/route.ts @@ -1,5 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' -import { revalidateTag } from 'next/cache' +import { revalidateTag as typedRevalidateTag } from 'next/cache' + +// https://github.com/vercel/next.js/pull/83822 deprecated revalidateTag with single argument, but it still is working +// types however do not allow single param usage, so typing as any to workaround type error +const revalidateTag = typedRevalidateTag as any export async function GET(request: NextRequest) { const url = new URL(request.url) diff --git a/tests/fixtures/skew-protection/app/app-router/actions.js b/tests/fixtures/skew-protection/app/app-router/actions.js new file mode 100644 index 0000000000..b7f7deb422 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/actions.js @@ -0,0 +1,5 @@ +'use server' + +export async function testAction() { + return process.env.SKEW_VARIANT +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/client-component.js b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js new file mode 100644 index 0000000000..2006d4f1ab --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js @@ -0,0 +1,12 @@ +'use client' + +export function ClientComponent() { + return ( +

+ Client Component - variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/page.js b/tests/fixtures/skew-protection/app/app-router/linked/page.js new file mode 100644 index 0000000000..8d8d10c8d5 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/page.js @@ -0,0 +1,16 @@ +import { ClientComponent } from './client-component' + +export default function Page() { + return ( + <> +

Skew Protection Testing - App Router - next/link navigation test

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ + + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/page.js b/tests/fixtures/skew-protection/app/app-router/page.js new file mode 100644 index 0000000000..b091fb2be2 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/page.js @@ -0,0 +1,132 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +import { testAction } from './actions' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [actionResult, setActionResult] = useState(null) + const [scopedRouteHandlerResult, setScopedRouteHandlerResult] = useState(null) + const [unscopedRouteHandlerResult, setUnscopedRouteHandlerResult] = useState(null) + + return ( + <> +

Skew Protection Testing - App Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+

Server Action

+
+ + {actionResult && ( +

+ Action result: {actionResult} ( + {actionResult === process.env.SKEW_VARIANT ? 'match' : 'mismatch'}) +

+ )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of route handler as one that served initial html to the browser + } +

Fetching route-handler (scoped)

+
+ + {scopedRouteHandlerResult && ( +

+ Scoped route handler result: + {scopedRouteHandlerResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of route handler + } +

Fetching route-handler (unscoped)

+
+ + {unscopedRouteHandlerResult && ( +

+ Unscoped route handler result: + {unscopedRouteHandlerResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/route-handler/route.js b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js new file mode 100644 index 0000000000..2a526c18a9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js @@ -0,0 +1,3 @@ +export const GET = async (req) => { + return new Response(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js new file mode 100644 index 0000000000..967763eda9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js @@ -0,0 +1 @@ +export const variant = process.env.SKEW_VARIANT diff --git a/tests/fixtures/skew-protection/app/dynamic-import/page.js b/tests/fixtures/skew-protection/app/dynamic-import/page.js new file mode 100644 index 0000000000..c1f9b68044 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/page.js @@ -0,0 +1,40 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [dynamicallyImportedValue, setDynamicallyImportedValue] = useState(null) + + return ( + <> +

Skew Protection Testing - Dynamic import

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

Dynamic import

+
+ + {dynamicallyImportedValue && ( +

+ Dynamic import result: + {dynamicallyImportedValue} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/layout.js b/tests/fixtures/skew-protection/app/layout.js new file mode 100644 index 0000000000..6565e7bafd --- /dev/null +++ b/tests/fixtures/skew-protection/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Simple Next App', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/[slug]/page.js b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js new file mode 100644 index 0000000000..9c5f4971ec --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js @@ -0,0 +1,16 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

Skew Protection Testing - Middleware - link target page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/page.js b/tests/fixtures/skew-protection/app/middleware/page.js new file mode 100644 index 0000000000..f8f57a53d7 --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/page.js @@ -0,0 +1,125 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [scopedMiddlewareEndpointResult, setScopedMiddlewareEndpointResult] = useState(null) + const [unscopedMiddlewareEndpointResult, setUnscopedMiddlewareEndpointResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Middleware

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of middleware endpoint as one that served initial html to the browser + } +

Fetching middleware endpoint (scoped)

+
+ + {scopedMiddlewareEndpointResult && ( +

+ Scoped middleware endpoint result: + + {scopedMiddlewareEndpointResult} + +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of middleware endpoint + } +

Fetching middleware endpoint (unscoped)

+
+ + {unscopedMiddlewareEndpointResult && ( +

+ Unscoped middleware endpoint result: + + {unscopedMiddlewareEndpointResult} + +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/[slug]/page.js b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js new file mode 100644 index 0000000000..2bf7c3ac85 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js @@ -0,0 +1,18 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

+ Skew Protection Testing - next.config.js - link target page +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/page.js b/tests/fixtures/skew-protection/app/next-config/page.js new file mode 100644 index 0000000000..883e8a1155 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/page.js @@ -0,0 +1,56 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + + return ( + <> +

+ Skew Protection Testing - next.config.js +

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/page.js b/tests/fixtures/skew-protection/app/page.js new file mode 100644 index 0000000000..831070adbd --- /dev/null +++ b/tests/fixtures/skew-protection/app/page.js @@ -0,0 +1,30 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing

+ + + ) +} diff --git a/tests/fixtures/skew-protection/middleware.js b/tests/fixtures/skew-protection/middleware.js new file mode 100644 index 0000000000..0b354a6d55 --- /dev/null +++ b/tests/fixtures/skew-protection/middleware.js @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export function middleware(request) { + const parsedVariant = JSON.parse(process.env.SKEW_VARIANT) + + if (request.nextUrl.pathname === '/middleware/next') { + return NextResponse.next() + } + + if (request.nextUrl.pathname === '/middleware/redirect') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/redirect-${parsedVariant.toLowerCase()}` + return NextResponse.redirect(url) + } + + if (request.nextUrl.pathname === '/middleware/rewrite') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/rewrite-${parsedVariant.toLowerCase()}` + return NextResponse.rewrite(url) + } + + if (request.nextUrl.pathname === '/middleware/json') { + return NextResponse.json(parsedVariant) + } +} + +export const config = { + matcher: '/middleware/:path*', +} diff --git a/tests/fixtures/skew-protection/next.config.mjs b/tests/fixtures/skew-protection/next.config.mjs new file mode 100644 index 0000000000..6ada619f2e --- /dev/null +++ b/tests/fixtures/skew-protection/next.config.mjs @@ -0,0 +1,57 @@ +import { remoteImage, variant } from './variant-config.mjs' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + // for next@<14.0.0 + serverActions: true, + // for next@<14.1.4 + useDeploymentId: true, + // Optionally, use with Server Actions + useDeploymentIdServerActions: true, + }, + outputFileTracingRoot: import.meta.dirname, + + // for next@<15.1.0 + webpack(config, { webpack }) { + config.plugins.push( + new webpack.DefinePlugin({ + // double JSON.stringify is intentional here - this is to keep results same as when using `compile.define` + 'process.env.SKEW_VARIANT': JSON.stringify(JSON.stringify(variant)), + }), + ) + return config + }, + turbopack: {}, + compiler: { + // this is same as above webpack config, but this will apply to turbopack builds as well + // so just future proofing it here + define: { + 'process.env.SKEW_VARIANT': JSON.stringify(variant), + }, + }, + + redirects() { + return [ + { + source: '/next-config/redirect', + destination: `/next-config/redirect-${variant.toLowerCase()}`, + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/next-config/rewrite', + destination: `/next-config/rewrite-${variant.toLowerCase()}`, + }, + ] + }, +} + +export default nextConfig diff --git a/tests/fixtures/skew-protection/package.json b/tests/fixtures/skew-protection/package.json new file mode 100644 index 0000000000..299855e023 --- /dev/null +++ b/tests/fixtures/skew-protection/package.json @@ -0,0 +1,15 @@ +{ + "name": "skew-protection", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "npm run build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/skew-protection/pages/api/api-route.js b/tests/fixtures/skew-protection/pages/api/api-route.js new file mode 100644 index 0000000000..7dc0f0c926 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/api/api-route.js @@ -0,0 +1,3 @@ +export default function handler(_req, res) { + res.send(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/index.js b/tests/fixtures/skew-protection/pages/pages-router/index.js new file mode 100644 index 0000000000..364da0cccb --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/index.js @@ -0,0 +1,119 @@ +import { useState } from 'react' +import Link from 'next/link' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [unscopedApiRouteResult, setUnscopedApiRouteResult] = useState(null) + const [scopedApiRouteResult, setScopedApiRouteResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Pages Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of api route as one that served initial html to the browser + } +

Fetching API route (scoped)

+
+ + {scopedApiRouteResult && ( +

+ Scoped API route result: + {scopedApiRouteResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of api route + } +

Fetching API route (unscoped)

+
+ + {unscopedApiRouteResult && ( +

+ Unscoped API route result: + {unscopedApiRouteResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js new file mode 100644 index 0000000000..26cc4cd94d --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js @@ -0,0 +1,27 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getServerSideProps +

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+

+ Variant from props:{' '} + {variant} +

+ + ) +} + +export async function getServerSideProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js new file mode 100644 index 0000000000..1e9e6dd46e --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js @@ -0,0 +1,24 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getStaticProps +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Variant from props: {variant} +

+ + ) +} + +export async function getStaticProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-static.js b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js new file mode 100644 index 0000000000..7eaaf5eff2 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js @@ -0,0 +1,11 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing - Pages Router - fully static page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/public/local-image-b.png b/tests/fixtures/skew-protection/public/local-image-b.png new file mode 100644 index 0000000000..e0e353318f Binary files /dev/null and b/tests/fixtures/skew-protection/public/local-image-b.png differ diff --git a/tests/fixtures/skew-protection/public/local-image.png b/tests/fixtures/skew-protection/public/local-image.png new file mode 100644 index 0000000000..a282ce91bb Binary files /dev/null and b/tests/fixtures/skew-protection/public/local-image.png differ diff --git a/tests/fixtures/skew-protection/public/variant-b.txt b/tests/fixtures/skew-protection/public/variant-b.txt new file mode 100644 index 0000000000..223b7836fb --- /dev/null +++ b/tests/fixtures/skew-protection/public/variant-b.txt @@ -0,0 +1 @@ +B diff --git a/tests/fixtures/skew-protection/public/variant.txt b/tests/fixtures/skew-protection/public/variant.txt new file mode 100644 index 0000000000..f70f10e4db --- /dev/null +++ b/tests/fixtures/skew-protection/public/variant.txt @@ -0,0 +1 @@ +A diff --git a/tests/fixtures/skew-protection/test-variants.json b/tests/fixtures/skew-protection/test-variants.json new file mode 100644 index 0000000000..448f5d8cd1 --- /dev/null +++ b/tests/fixtures/skew-protection/test-variants.json @@ -0,0 +1,9 @@ +{ + "variant-b": { + "files": { + "variant-config.mjs": "variant-config-b.mjs", + "public/local-image.png": "public/local-image-b.png", + "public/variant.txt": "public/variant-b.txt" + } + } +} diff --git a/tests/fixtures/skew-protection/variant-config-b.mjs b/tests/fixtures/skew-protection/variant-config-b.mjs new file mode 100644 index 0000000000..ad5de288c7 --- /dev/null +++ b/tests/fixtures/skew-protection/variant-config-b.mjs @@ -0,0 +1,2 @@ +export const variant = 'B' +export const remoteImage = 'pixabay' diff --git a/tests/fixtures/skew-protection/variant-config.mjs b/tests/fixtures/skew-protection/variant-config.mjs new file mode 100644 index 0000000000..2eec99991f --- /dev/null +++ b/tests/fixtures/skew-protection/variant-config.mjs @@ -0,0 +1,2 @@ +export const variant = 'A' +export const remoteImage = 'unsplash' diff --git a/tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts b/tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts index 4900705b4b..519d5c0267 100644 --- a/tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts +++ b/tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts @@ -1,6 +1,10 @@ -import { revalidateTag } from 'next/cache' +import { revalidateTag as typedRevalidateTag } from 'next/cache' import { NextRequest } from 'next/server' +// https://github.com/vercel/next.js/pull/83822 deprecated revalidateTag with single argument, but it still is working +// types however do not allow single param usage, so typing as any to workaround type error +const revalidateTag = typedRevalidateTag as any + export async function GET(request: NextRequest, { params }) { const { slug } = await params diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 7d865559fc..3339dbce34 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -36,9 +36,11 @@ import { } from '../utils/helpers.js' import { hasDefaultTurbopackBuilds, + isExperimentalPPRHardDeprecated, nextVersionSatisfies, shouldHaveAppRouterGlobalErrorInPrerenderManifest, shouldHaveAppRouterNotFoundInPrerenderManifest, + shouldHaveSlashIndexTagForIndexPage, } from '../utils/next-version-helpers.mjs' const mockedCp = cp as Mock<(typeof import('node:fs/promises'))['cp']> @@ -205,7 +207,11 @@ test('index should be normalized within the cacheHandler and await runPlugin(ctx) const index = await invokeFunction(ctx, { url: '/' }) expect(index.statusCode).toBe(200) - expect(index.headers?.['netlify-cache-tag']).toBe('_N_T_/layout,_N_T_/page,_N_T_/') + expect(index.headers?.['netlify-cache-tag']).toBe( + shouldHaveSlashIndexTagForIndexPage() + ? '_N_T_/layout,_N_T_/page,_N_T_/,_N_T_/index' + : '_N_T_/layout,_N_T_/page,_N_T_/', + ) }) // with 15.0.0-canary.187 and later Next.js no longer produce `stale-while-revalidate` directive @@ -398,7 +404,7 @@ test.skipIf(process.env.NEXT_VERSION !== 'canary')( '/1', '/2', '/404', - '/[dynamic]', + isExperimentalPPRHardDeprecated() ? undefined : '/[dynamic]', shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/index', diff --git a/tests/netlify-deploy.ts b/tests/netlify-deploy.ts index 61ad84e836..b190e76148 100644 --- a/tests/netlify-deploy.ts +++ b/tests/netlify-deploy.ts @@ -4,6 +4,7 @@ import fs from 'fs-extra' import { Span } from 'next/src/trace' import { tmpdir } from 'node:os' import path from 'path' +import { satisfies as satisfiesVersionRange } from 'semver' import { NextInstance } from './base' async function packNextRuntimeImpl() { @@ -58,6 +59,34 @@ export class NextDeployInstance extends NextInstance { const setupStartTime = Date.now() + const { runtimePackageName, runtimePackageTarballPath } = await packNextRuntime() + + this.dependencies = { + ...(this.dependencies || {}), + // add the runtime package as a dependency + [runtimePackageName]: `file:${runtimePackageTarballPath}`, + } + + if ( + typeof this.files === 'string' && + this.files.includes('back-forward-cache') && + process.env.NEXT_RESOLVED_VERSION && + satisfiesVersionRange(process.env.NEXT_RESOLVED_VERSION, '<15.6.0') + ) { + require('console').log('Pinning @types/react(-dom) for back-forward-cache test fixture') + // back-forward-cache test fixture is failing types checking because: + // - @types/react(-dom) types are not pinned + // - fixture uses react `unstable_Activity` export which since fixture was introduced is no longer unstable + // and types were updated for that and no longer provide types for that export (instead provide for `Activity`) + // this adds the pinning of types to version of types that still had `unstable_Activity` type + this.dependencies['@types/react'] = '19.1.1' + this.dependencies['@types/react-dom'] = '19.1.2' + } + + if (!this.buildCommand && this.buildArgs && this.buildArgs.length > 0) { + this.buildCommand = `next build ${this.buildArgs.join(' ')}` + } + // create the test site await super.createTestDir({ parentSpan, skipInstall: true }) @@ -70,10 +99,13 @@ export class NextDeployInstance extends NextInstance { await fs.rename(nodeModules, nodeModulesBak) } - const { runtimePackageName, runtimePackageTarballPath } = await packNextRuntime() - // install dependencies - await execa('npm', ['i', runtimePackageTarballPath, '--legacy-peer-deps'], { + // --force is used to match behavior of `pnpm install --strict-peer-dependencies=false` used by Vercel + // there is a test fixture that have `@babel/preset-flow` as a dependency which has a peer dependency of `@babel/core@^7.0.0-0`, + // but `@babel/core` is not specified as dependency, so we need to automatically attempt to install peer dependencies + // but also not fail in case of peer dependency versions not matching for other fixtures, so `--legacy-peer-deps` is not good option here + // https://github.com/vercel/next.js/blob/7453d200579512a6574f9c53edd716e5cc01615c/test/e2e/babel/index.test.js#L7-L9 + await execa('npm', ['install', '--force'], { cwd: this.testDir, stdio: 'inherit', }) diff --git a/tests/prepare.mjs b/tests/prepare.mjs index 164f702b17..5d4e9304ee 100644 --- a/tests/prepare.mjs +++ b/tests/prepare.mjs @@ -31,7 +31,9 @@ const e2eOnlyFixtures = new Set([ // see https://github.com/opennextjs/opennextjs-netlify/actions/runs/13268839161/job/37043172448?pr=2749#step:12:78 'middleware-og', 'middleware-single-matcher', + 'next-16-tag-revalidation', 'nx-integrated', + 'skew-protection', 'turborepo', 'turborepo-npm', 'unstable-cache', diff --git a/tests/utils/build-variants.mjs b/tests/utils/build-variants.mjs index 0fc6f33c06..513e6a11cd 100644 --- a/tests/utils/build-variants.mjs +++ b/tests/utils/build-variants.mjs @@ -52,7 +52,10 @@ const variants = { } // build variants declared by args or build everything if not args provided -const variantsToBuild = argv.length > 2 ? argv.slice(2) : Object.keys(variants) +const variantsToBuild = + argv.length > 2 ? argv.slice(2).filter((arg) => !arg.startsWith('--')) : Object.keys(variants) + +const flags = argv.slice(2).filter((arg) => arg.startsWith('--')) /** @type {string[]} */ const notExistingVariants = [] @@ -118,12 +121,6 @@ for (const variantToBuild of variantsToBuild) { } } - const buildCommand = variant.buildCommand ?? 'next build' - const distDir = variant.distDir ?? '.next' - console.warn( - `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, - ) - for (const [target, source] of Object.entries(variant.files ?? {})) { const targetBackup = `${target}.bak` // create backup @@ -139,6 +136,17 @@ for (const variantToBuild of variantsToBuild) { }) } + if (flags.includes('--apply-file-changes-only')) { + console.warn(`[build-variants] Applied file changes for ${variantToBuild} variant`) + continue + } + + const buildCommand = variant.buildCommand ?? 'next build' + const distDir = variant.distDir ?? '.next' + console.warn( + `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, + ) + const result = await execaCommand(buildCommand, { env: { ...process.env, diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 639a996aeb..a03be0fe05 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -1,3 +1,4 @@ +import AdmZip from 'adm-zip' import { execaCommand } from 'execa' import fg from 'fast-glob' import { exec } from 'node:child_process' @@ -45,6 +46,25 @@ interface E2EConfig { * Site ID to deploy to. Defaults to the `NETLIFY_SITE_ID` environment variable or a default site. */ siteId?: string + /** + * If set to true, instead of using CLI to deploy, we will zip the source files and trigger build from zip. + */ + useBuildbot?: boolean + /** + * Runs before deploying the site if defined. + */ + onPreDeploy?: (isolatedFixtureRoot: string) => Promise + /** + * Buildbot mode specific callback that will be called once the build starts. + * Useful for scenario of triggering multiple consecutive builds, to be able to schedule builds + * before previous one finish completely. If multiple builds are scheduled at the same time, some + * of them might be skipped and this callback allows to avoid this scenario. + */ + onBuildStart?: () => Promise | void + /** + * Environment variables that will be added to `netlify.toml` if set. + */ + env?: Record } /** @@ -83,6 +103,9 @@ export const createE2EFixture = async (fixture: string, config: E2EConfig = {}) await setNextVersionInFixture(isolatedFixtureRoot, NEXT_VERSION) await installRuntime(packageName, isolatedFixtureRoot, config) await verifyFixture(isolatedFixtureRoot, config) + await config.onPreDeploy?.(isolatedFixtureRoot) + + const deploySite = config.useBuildbot ? deploySiteWithBuildbot : deploySiteWithCLI const result = await deploySite(isolatedFixtureRoot, config) @@ -160,6 +183,13 @@ async function buildAndPackRuntime( `[build] command = "${buildCommand}" publish = "${publishDirectory ?? join(siteRelDir, '.next')}" +${ + config.env + ? `[build.environment]\n${Object.entries(config.env) + .map(([key, value]) => `${key} = "${value}"`) + .join('\n')}` + : '' +} [[plugins]] package = "${name}" @@ -263,7 +293,7 @@ async function verifyFixture(isolatedFixtureRoot: string, { expectedCliVersion } } } -async function deploySite( +export async function deploySiteWithCLI( isolatedFixtureRoot: string, { packagePath, cwd = '', siteId = SITE_ID }: E2EConfig, ): Promise { @@ -296,6 +326,91 @@ async function deploySite( } } +export async function deploySiteWithBuildbot( + isolatedFixtureRoot: string, + { packagePath, siteId = SITE_ID, publishDirectory = '.next', onBuildStart }: E2EConfig, +): Promise { + if (packagePath) { + // It's likely possible to support this, just skipping implementing it until there's a need + // throwing just to be explicit that this was not done to avoid potential confusion if things + // don't work + throw new Error('packagePath is not currently supported when deploying with buildbot') + } + + if (!process.env.NETLIFY_AUTH_TOKEN) { + // we use CLI (ntl api) for most of operations, but build zip upload seems impossible with CLI + // and we do need to use API directly and we do need token for that + throw new Error('NETLIFY_AUTH_TOKEN is required for buildbot deploy, but it was not set') + } + + console.log(`🚀 Packing source files and triggering deploy`) + + const newZip = new AdmZip() + newZip.addLocalFolder(isolatedFixtureRoot, '', (entry) => { + if ( + // don't include node_modules / .git / publish dir in zip + entry.startsWith('node_modules') || + entry.startsWith('.git') || + entry.startsWith(publishDirectory) + ) { + return false + } + return true + }) + + const result = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, { + method: 'POST', + headers: { + 'Content-Type': 'application/zip', + Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`, + }, + // @ts-expect-error sigh, it works + body: newZip.toBuffer(), + }) + const { deploy_id } = await result.json() + + let didRunOnBuildStartCallback = false + const runOnBuildStartCallbackOnce = onBuildStart + ? () => { + if (!didRunOnBuildStartCallback) { + didRunOnBuildStartCallback = true + return onBuildStart() + } + } + : () => {} + + // poll for status + while (true) { + const { stdout } = await execaCommand( + `npx netlify api getDeploy --data=${JSON.stringify({ deploy_id })}`, + ) + const { state } = JSON.parse(stdout) + + if (state === 'error' || state === 'rejected') { + await runOnBuildStartCallbackOnce() + throw new Error( + `The deploy failed https://app.netlify.com/projects/${siteId}/deploys/${deploy_id}`, + ) + } + if (state === 'ready') { + await runOnBuildStartCallbackOnce() + break + } + + if (state === 'building') { + await runOnBuildStartCallbackOnce() + } + + await new Promise((resolve) => setTimeout(resolve, 5000)) + } + + return { + deployID: deploy_id, + url: `https://${deploy_id}--${siteId}.netlify.app`, // this is not nice, but it does work + logs: '', + } +} + export async function deleteDeploy(deployID?: string): Promise { if (!deployID) { return @@ -315,10 +430,38 @@ async function cleanup(dest: string, deployId?: string): Promise { await Promise.allSettled([deleteDeploy(deployId), rm(dest, { recursive: true, force: true })]) } -function getBuildFixtureVariantCommand(variantName: string) { +export function getBuildFixtureVariantCommand(variantName: string) { return `node ${fileURLToPath(new URL(`./build-variants.mjs`, import.meta.url))} ${variantName}` } +export async function createSite(siteConfig?: { name: string }) { + const cmd = `npx netlify api createSiteInTeam --data=${JSON.stringify({ + account_slug: 'netlify-integration-testing', + body: siteConfig ?? {}, + })}` + + const { stdout } = await execaCommand(cmd) + const { site_id, ssl_url, admin_url } = JSON.parse(stdout) + + console.log(`🚀 Created site ${ssl_url} / ${admin_url}`) + + return { + siteId: site_id as string, + url: ssl_url as string, + adminUrl: admin_url as string, + } +} + +export async function deleteSite(siteId: string) { + const cmd = `npx netlify api deleteSite --data=${JSON.stringify({ site_id: siteId })}` + await execaCommand(cmd) +} + +export async function publishDeploy(siteId: string, deployID: string) { + const cmd = `npx netlify api restoreSiteDeploy --data=${JSON.stringify({ site_id: siteId, deploy_id: deployID })}` + await execaCommand(cmd) +} + export const fixtureFactories = { simple: () => createE2EFixture('simple'), helloWorldTurbopack: () => @@ -393,6 +536,7 @@ export const fixtureFactories = { buildCommand: 'turbo build --filter page-router', }), serverComponents: () => createE2EFixture('server-components'), + next16TagRevalidation: () => createE2EFixture('next-16-tag-revalidation'), nxIntegrated: () => createE2EFixture('nx-integrated', { packagePath: 'apps/next-app', diff --git a/tests/utils/next-version-helpers.mjs b/tests/utils/next-version-helpers.mjs index 5761f20ca6..564afbf319 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -53,6 +53,16 @@ export function hasDefaultTurbopackBuilds() { return nextVersionSatisfies('>=15.6.0-canary.40') } +export function shouldHaveSlashIndexTagForIndexPage() { + // https://github.com/vercel/next.js/pull/84586 + return nextVersionSatisfies('>=v15.6.0-canary.50') +} + +export function isExperimentalPPRHardDeprecated() { + // https://github.com/vercel/next.js/pull/84280 + return nextVersionSatisfies('>=15.6.0-canary.54') +} + /** * Check if current next version requires React 19 * @param {string} version Next version