From 166c235bb6ab24c1edb2ed749efa814cf4f5a639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Fri, 2 Oct 2026 15:06:11 +0200 Subject: [PATCH 1/4] feat: match redirects with @netlify/redirect-matcher Replace netlify-redirector, the 2018 Emscripten build, with @netlify/redirect-matcher, its WebAssembly successor, which @netlify/dev already uses through @netlify/redirects. The role check that re-read exceptions.JWT is removed: netlify-redirector only reported that field together with force404, which is handled first, so the block could not run. Parse errors from the matcher are now logged. @netlify/dev is bumped to 5.1.6 so only one matcher ships, along with @netlify/dev-utils and @netlify/blobs to the versions it pins, which keeps their types compatible with @netlify/server-dev. --- package-lock.json | 161 +++++++++++++------------- package.json | 8 +- src/utils/proxy.ts | 70 ++--------- src/utils/redirects.ts | 5 +- src/utils/rules-proxy.ts | 46 +++----- src/utils/types.ts | 4 +- tests/integration/rules-proxy.test.ts | 15 +-- tests/unit/utils/rules-proxy.test.ts | 114 +++++++++++++++++- types/netlify-redirector/index.d.ts | 39 ------- 9 files changed, 240 insertions(+), 222 deletions(-) delete mode 100644 types/netlify-redirector/index.d.ts diff --git a/package-lock.json b/package-lock.json index 524f933318f..3f805a4f205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,18 +13,19 @@ "@fastify/static": "^10.0.0", "@netlify/ai": "^1.0.1", "@netlify/api": "^15.1.2", - "@netlify/blobs": "^11.1.0", + "@netlify/blobs": "^11.1.3", "@netlify/build": "^37.3.3", "@netlify/build-info": "^11.3.0", "@netlify/config": "^25.2.6", - "@netlify/dev": "^5.1.2", - "@netlify/dev-utils": "^6.0.1", + "@netlify/dev": "^5.1.6", + "@netlify/dev-utils": "^6.0.3", "@netlify/edge-bundler": "^16.1.1", "@netlify/edge-functions": "^4.0.0", "@netlify/edge-functions-bootstrap": "^3.2.0", "@netlify/headers-parser": "^10.1.1", "@netlify/images": "^2.0.1", "@netlify/local-functions-proxy": "^2.0.3", + "@netlify/redirect-matcher": "^0.4.2", "@netlify/redirect-parser": "^16.1.1", "@netlify/server-dev": "^0.1.1", "@netlify/zip-it-and-ship-it": "^16.2.3", @@ -82,7 +83,6 @@ "modern-tar": "^0.8.0", "multiparty": "^4.2.3", "nanospinner": "^1.2.2", - "netlify-redirector": "^0.5.0", "node-fetch": "^3.3.2", "normalize-package-data": "^7.0.1", "open": "^11.0.0", @@ -2805,13 +2805,13 @@ "license": "Apache 2" }, "node_modules/@netlify/blobs": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-11.1.0.tgz", - "integrity": "sha512-CGb+UsFLywJA22bxcdsTmla25w/AK72sVxj+KUkjees2zw717KUBmm3miTFkDA05vZi1MbnCBIxiU0cEwbWg8w==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-11.1.3.tgz", + "integrity": "sha512-epr7eGb13LugY+0WUDl91dYmypOfbrJ6MYf07M3Zoey5WBYRD2rqDrOhDskfAlEaJ2MoWGaPWrVxltIxfI5LoQ==", "license": "MIT", "dependencies": { - "@netlify/dev-utils": "6.0.1", - "@netlify/otel": "^7.0.2", + "@netlify/dev-utils": "6.0.3", + "@netlify/otel": "^7.0.4", "@netlify/runtime-utils": "3.0.0" }, "engines": { @@ -3233,9 +3233,9 @@ } }, "node_modules/@netlify/cache": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@netlify/cache/-/cache-4.0.0.tgz", - "integrity": "sha512-tRWkLmO6pyqZetFkhYgtX6LH1umqaJAZN5mRNOJubca/+Mqm0VTUyl51FeS0UbVfetLwiwgpzbGmABpc5gGHyg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@netlify/cache/-/cache-4.0.2.tgz", + "integrity": "sha512-7GybzVhXvnQboaninZLRv5HG15VrvzlQFSSsnfNNLsQqxzG8cl7YJR61aNR3tFhxeTaLX2llmGBPEjUGHUEidg==", "license": "MIT", "dependencies": { "@netlify/runtime-utils": "3.0.0" @@ -3483,23 +3483,23 @@ } }, "node_modules/@netlify/dev": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-5.1.2.tgz", - "integrity": "sha512-xrwDJWvc/Q1RYlpPLJy/5evea6V1YAh0PK8XK6x92eTW4rqJ5NwDwZa8PF8n7eu1OJkKkDLCfu3U9qUcx2gy3w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-5.1.6.tgz", + "integrity": "sha512-VaM5ws1DMiy3+EzMiCnZieaGyhqfLrVZT4QrrRDIkW3cm2SoJCv0oyuhGRzPov1j99/xHZpeKAGGn53eQtYYNg==", "license": "MIT", "dependencies": { "@netlify/ai": "^1.0.1", - "@netlify/blobs": "11.1.0", + "@netlify/blobs": "11.1.3", "@netlify/config": "^25.2.3", "@netlify/database-dev": "1.0.1", - "@netlify/dev-utils": "6.0.1", - "@netlify/edge-functions-dev": "2.0.1", - "@netlify/functions-dev": "2.0.7", - "@netlify/headers": "3.0.1", - "@netlify/images": "2.0.1", - "@netlify/redirects": "4.0.2", - "@netlify/runtime": "5.0.4", - "@netlify/server-dev": "0.1.1", + "@netlify/dev-utils": "6.0.3", + "@netlify/edge-functions-dev": "2.0.3", + "@netlify/functions-dev": "2.0.10", + "@netlify/headers": "3.0.3", + "@netlify/images": "2.0.3", + "@netlify/redirects": "4.1.0", + "@netlify/runtime": "5.0.7", + "@netlify/server-dev": "0.1.3", "@netlify/static": "4.0.0", "ulid": "^3.0.0" }, @@ -3508,9 +3508,9 @@ } }, "node_modules/@netlify/dev-utils": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-6.0.1.tgz", - "integrity": "sha512-q5g70dO3q/M/XTBQz7uIECJxVlJoS8UBlzpjf22DV6q61UzXa8EONTPXPmvisrFcUi1I3AUKplPcHGQYZZj+Vw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-6.0.3.tgz", + "integrity": "sha512-p+M9a8mI0H0rRohY7Z4TkblvJMwsl5nfNKzmmhz1XBgSLBoDcgwf1TroHvM0TyK5sRiD8LViXe1wuJbzYlilUQ==", "license": "MIT", "dependencies": { "@whatwg-node/server": "^0.11.0", @@ -4183,12 +4183,12 @@ } }, "node_modules/@netlify/edge-functions": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-4.0.0.tgz", - "integrity": "sha512-0jerPQQ5mLczl7jwCRfTJsLcaiXbvPOC+Whtxxk7IHdz24OvZGjUtVHAxhppjT8wcX48I7C6oAff04QdQDMTfw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-4.0.2.tgz", + "integrity": "sha512-DQflkwaC3greGAAZ9v2wE5PU0qkXMeC7xhRnF/8DEGCjWaVLt/uf48nqDkyCxLG84GYMSQSmlMs+O4wPf+FqPw==", "license": "MIT", "dependencies": { - "@netlify/types": "3.0.0" + "@netlify/types": "3.2.0" }, "engines": { "node": ">=22.12.0" @@ -4201,14 +4201,14 @@ "license": "MIT" }, "node_modules/@netlify/edge-functions-dev": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@netlify/edge-functions-dev/-/edge-functions-dev-2.0.1.tgz", - "integrity": "sha512-IIIBoBhbqoOeOQLEfYLmW55IHOAxXc9athsUH7ZHKUUs3agi7ehVaZYZwhkQxB+l/K9/vRYHUnAZD715auIDpA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions-dev/-/edge-functions-dev-2.0.3.tgz", + "integrity": "sha512-BVjkmdOFmqNwsL2Nt8kkfBBbw0Zz9nRT5OedMqvj6nO/x95gq1wuyOXEwfNWV5i2WlNtJrBlOXD4Il1HLLqJ5Q==", "license": "MIT", "dependencies": { - "@netlify/dev-utils": "6.0.1", + "@netlify/dev-utils": "6.0.3", "@netlify/edge-bundler": "^16.0.3", - "@netlify/edge-functions": "4.0.0", + "@netlify/edge-functions": "4.0.2", "@netlify/edge-functions-bootstrap": "2.16.0", "@netlify/runtime-utils": "3.0.0", "get-port": "^7.1.0" @@ -4236,26 +4236,26 @@ } }, "node_modules/@netlify/functions": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-6.0.0.tgz", - "integrity": "sha512-Pee+FoFnAtdejTkl62a7mZxj6Wx4eqVNuYyDc5T4XPJN9GaAWaUf4cZQFlf2m4Fqq0HoghjtcPKo+BZviVPPKA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-6.0.2.tgz", + "integrity": "sha512-JqbPgAOt2GAwzCEitdEWAHOn6AkZ0Rna401wdAyuDIBiDv9M03b3Cu5mGH7/eHOmIXfZIcN1pG35flHjfu6asQ==", "license": "MIT", "dependencies": { - "@netlify/types": "3.0.0" + "@netlify/types": "3.2.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@netlify/functions-dev": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@netlify/functions-dev/-/functions-dev-2.0.7.tgz", - "integrity": "sha512-WPeI32j+NtwxnORtmR+tO3RezW36L8yp2AV76sZw53mnTwl8P4ZkdOXjknaVaC8N8L8Vqof+gmUhw/o9cwYsOg==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@netlify/functions-dev/-/functions-dev-2.0.10.tgz", + "integrity": "sha512-ZWNvjGHjpHejMiJtPUS+qOtSHbMpdMX0A/gdiub2UlTSvkRc4wD0R3WgQoDW03dhk8Bm2vYX2AoJly9AqIeK4w==", "license": "MIT", "dependencies": { - "@netlify/blobs": "11.1.0", - "@netlify/dev-utils": "6.0.1", - "@netlify/functions": "6.0.0", + "@netlify/blobs": "11.1.3", + "@netlify/dev-utils": "6.0.3", + "@netlify/functions": "6.0.2", "@netlify/zip-it-and-ship-it": "^16.0.0", "cron-parser": "^5.0.0", "decache": "^4.6.2", @@ -4308,9 +4308,9 @@ } }, "node_modules/@netlify/headers": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@netlify/headers/-/headers-3.0.1.tgz", - "integrity": "sha512-LcEu16gy28dkEp2+pa5EafauTfmFl6hkwiPqmoMBY+GiLLWQC00X8L4O1ZUrPqbKynsraSqEeKEfocC2FktrEQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@netlify/headers/-/headers-3.0.3.tgz", + "integrity": "sha512-WsCnWNJhmjHMN+bCI3PkFFMO9abggh/5E+SyLD2Z+75ye3+MnIkljMx4j35eYpiOXooZD+lPud2eHkgHixC8Ew==", "license": "MIT", "dependencies": { "@netlify/headers-parser": "^10.1.1" @@ -4333,9 +4333,9 @@ } }, "node_modules/@netlify/images": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@netlify/images/-/images-2.0.1.tgz", - "integrity": "sha512-47DzR/PqIJqbUIef6P7EmhbNQbUOD4IuGNhTG7h3+xcynhQdBLBkCaBwY2DXOzUloA1MxYhKGYV2z4R3pO36hg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@netlify/images/-/images-2.0.3.tgz", + "integrity": "sha512-DdaJr59U1hrtdSxMrKn1VDzeYoQH/fqexlHa3LgOIqXfgTrl0Wi7A9SZjAOK+jZTX9HVxchaVo6rm17IrZ2MKg==", "license": "MIT", "dependencies": { "ipx": "^3.1.1" @@ -4577,9 +4577,9 @@ } }, "node_modules/@netlify/otel": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-7.0.2.tgz", - "integrity": "sha512-MHEsRJTZtOCrLa2fR+Y3GN51rvgxUHazuRVxuWuG7R1qzwynXAZB20Ufwp7BxKSTKcp7LNVEnKAZ/k2ytVmYvQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-7.0.4.tgz", + "integrity": "sha512-HrGxXgauSZEzSd2mKu5YlqCvHpaNjQzf/yvwBdVXstZLRWWvbWIZBanrbXG0poyq70PBuiL+zVp3lU48aWnRNg==", "license": "MIT", "dependencies": { "@opentelemetry/api": "1.9.1", @@ -4725,6 +4725,15 @@ "node": "^14.14.0 || >=16.0.0" } }, + "node_modules/@netlify/redirect-matcher": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@netlify/redirect-matcher/-/redirect-matcher-0.4.2.tgz", + "integrity": "sha512-rPiVS6niO2xTMj1NhqVF5goFzJiNZul5NiZ5PVqU3wVB4psJCplLFlePg4GDIL+guiKN0UVwl0P1LWtcrywmoA==", + "license": "UNLICENSED", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@netlify/redirect-parser": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/@netlify/redirect-parser/-/redirect-parser-16.1.1.tgz", @@ -4739,16 +4748,16 @@ } }, "node_modules/@netlify/redirects": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@netlify/redirects/-/redirects-4.0.2.tgz", - "integrity": "sha512-4Qpz6FpwrLITrScg34zjK9Iv0BiyyPVAGO7HvUZg0txihHPgKBRtLMByEwfP6mhuzTExoxMXYklqgHg2xtbPxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@netlify/redirects/-/redirects-4.1.0.tgz", + "integrity": "sha512-koVRBcUfh9W2lefQl6unnvvLHSygez0GqbmxSZ0uCt462mBDFXrGeqJvqfMqSrGh+1d6p78TZDpdpeoflwWJ8A==", "license": "MIT", "dependencies": { - "@netlify/dev-utils": "6.0.1", + "@netlify/dev-utils": "6.0.3", + "@netlify/redirect-matcher": "^0.4.2", "@netlify/redirect-parser": "^16.1.1", "cookie": "^2.0.0", - "jsonwebtoken": "9.0.3", - "netlify-redirector": "^0.5.0" + "jsonwebtoken": "9.0.3" }, "engines": { "node": ">=22.12.0" @@ -4861,15 +4870,15 @@ } }, "node_modules/@netlify/runtime": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@netlify/runtime/-/runtime-5.0.4.tgz", - "integrity": "sha512-CaST8JVVUUTAC1Ay8tLdo7huhwFUbp9KfDFZQmUSOeUEvbQW6cg1Z6CeDrgTE7bTtBz/fxMFo5C+u+6wIbXk7g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@netlify/runtime/-/runtime-5.0.7.tgz", + "integrity": "sha512-asVHnZLc+Y8OiW8Sb6O/AToV1byr9NLq+ZaOYFF9YFj4t2WAek1C0wBIYlUY9r8A1badnCh+v69yar37MMpNIQ==", "license": "MIT", "dependencies": { - "@netlify/blobs": "^11.1.0", - "@netlify/cache": "4.0.0", + "@netlify/blobs": "^11.1.3", + "@netlify/cache": "4.0.2", "@netlify/runtime-utils": "3.0.0", - "@netlify/types": "3.0.0" + "@netlify/types": "3.2.0" }, "engines": { "node": ">=22.12.0" @@ -4885,12 +4894,12 @@ } }, "node_modules/@netlify/server-dev": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@netlify/server-dev/-/server-dev-0.1.1.tgz", - "integrity": "sha512-yA+uMfscw2Unz8JvC0dRP9z5P0lnCOy//9YICFF4nzDijYXe80bsnvrblB8wOPYBq0/KmVhl4s0gCP/3roTp4w==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@netlify/server-dev/-/server-dev-0.1.3.tgz", + "integrity": "sha512-1JAC09NXF923O0hJNrYLjeKVGyY45TgC+niTpPYOr0QcDA+q81cixRBH8b4WUB7CslxpAZQbxBdObyDUoM4IFw==", "license": "MIT", "dependencies": { - "@netlify/dev-utils": "6.0.1", + "@netlify/dev-utils": "6.0.3", "@netlify/serverless-functions-api": "^2.21.2", "get-port": "^7.1.0" }, @@ -4990,9 +4999,9 @@ } }, "node_modules/@netlify/types": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-3.0.0.tgz", - "integrity": "sha512-zRCidsQgI4CZWrZB/kmwdPf0QbSNljakfNSLizFvrsSA3UcUrTWSBNMVKmZnob92/8ESeUN0ewXLOlffJU+ATQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-3.2.0.tgz", + "integrity": "sha512-PVvY1EEN9i2Er0LQoiyNG7i/BnwnttLuZqIkBnhL2Jrcj3P5N/NcGkv1i20MJ2ngNGSPQcT7tagDmF3V6BsdWg==", "license": "MIT", "engines": { "node": ">=22.12.0" @@ -16632,10 +16641,6 @@ "dev": true, "license": "MIT" }, - "node_modules/netlify-redirector": { - "version": "0.5.0", - "license": "MIT" - }, "node_modules/nock": { "version": "14.0.10", "dev": true, diff --git a/package.json b/package.json index ac155ea2e24..629472a7355 100644 --- a/package.json +++ b/package.json @@ -55,18 +55,19 @@ "@fastify/static": "^10.0.0", "@netlify/ai": "^1.0.1", "@netlify/api": "^15.1.2", - "@netlify/blobs": "^11.1.0", + "@netlify/blobs": "^11.1.3", "@netlify/build": "^37.3.3", "@netlify/build-info": "^11.3.0", "@netlify/config": "^25.2.6", - "@netlify/dev": "^5.1.2", - "@netlify/dev-utils": "^6.0.1", + "@netlify/dev": "^5.1.6", + "@netlify/dev-utils": "^6.0.3", "@netlify/edge-bundler": "^16.1.1", "@netlify/edge-functions": "^4.0.0", "@netlify/edge-functions-bootstrap": "^3.2.0", "@netlify/headers-parser": "^10.1.1", "@netlify/images": "^2.0.1", "@netlify/local-functions-proxy": "^2.0.3", + "@netlify/redirect-matcher": "^0.4.2", "@netlify/redirect-parser": "^16.1.1", "@netlify/server-dev": "^0.1.1", "@netlify/zip-it-and-ship-it": "^16.2.3", @@ -124,7 +125,6 @@ "modern-tar": "^0.8.0", "multiparty": "^4.2.3", "nanospinner": "^1.2.2", - "netlify-redirector": "^0.5.0", "node-fetch": "^3.3.2", "normalize-package-data": "^7.0.1", "open": "^11.0.0", diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 1c50acff2eb..107edd83b86 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -19,17 +19,14 @@ import { ServerHandler } from '@netlify/server-dev' import { runBeforeProcessExit } from './shell.js' import type { AIGatewayContext } from '@netlify/ai/bootstrap' +import type { MatchResult } from '@netlify/redirect-matcher' import contentType from 'content-type' -import { parseCookie } from 'cookie' -import { getProperty } from 'dot-prop' import generateETag from 'etag' import getAvailablePort from 'get-port' import httpProxy from 'http-proxy' import { createProxyMiddleware } from 'http-proxy-middleware' -import { jwtDecode } from 'jwt-decode' import { locatePath } from 'locate-path' import { throttle } from './object-utilities.js' -import type { Match } from 'netlify-redirector' import pFilter from 'p-filter' import type { BaseCommand } from '../commands/index.js' @@ -172,8 +169,8 @@ const isEndpointExists = async function (endpoint: string, origin: string) { } } -const isExternal = function (match: Match): boolean { - return 'to' in match && /^https?:\/\//.exec(match.to) != null +const isExternal = function (match: MatchResult): match is Extract { + return match.type === 'match' && /^https?:\/\//.exec(match.to) != null } const stripOrigin = function ({ hash, pathname, search }: URL): string { @@ -208,7 +205,7 @@ const handleAddonUrl = function ({ addonUrl, req, res }) { proxyToExternalUrl({ req, res, dest, destURL }) } -const isRedirect = function (match: Match | { status?: number | undefined }): boolean { +const isRedirect = function (match: MatchResult | { status?: number | undefined }): boolean { return 'status' in match && match.status != null && match.status >= 300 && match.status <= 400 } @@ -270,14 +267,14 @@ const serveRedirect = async function ({ res, siteInfo, }: { - match: Match | null + match: MatchResult | null } & Record) { if (!match) return proxy.web(req, res, options) options = options || req.proxyOptions || {} options.match = null - if (match.force404) { + if (match.type === 'forcedNotFound') { res.writeHead(404) res.end(await render404(options.publicFolder)) return @@ -289,8 +286,9 @@ const serveRedirect = async function ({ }) } - if (match.signingSecret) { - const signingSecretVar = env[match.signingSecret] + const signingSecretName = match.signer?.jwtSecret + if (signingSecretName) { + const signingSecretVar = env[signingSecretName] if (signingSecretVar) { req.headers['x-nf-sign'] = signRedirect({ @@ -302,7 +300,7 @@ const serveRedirect = async function ({ } else { log( NETLIFYDEVWARN, - `Could not sign redirect because environment variable ${chalk.yellow(match.signingSecret)} is not set`, + `Could not sign redirect because environment variable ${chalk.yellow(signingSecretName)} is not set`, ) } } @@ -318,50 +316,6 @@ const serveRedirect = async function ({ } const originalURL = req.url - if (match.exceptions && match.exceptions.JWT) { - // Some values of JWT can start with :, so, make sure to normalize them - const expectedRoles = new Set( - match.exceptions.JWT.split(',').map((value) => (value.startsWith(':') ? value.slice(1) : value)), - ) - - const cookieValues = parseCookie(req.headers.cookie || '') - const token = cookieValues.nf_jwt - - // Serve not found by default - req.url = '/.netlify/non-existent-path' - - if (token) { - let jwtValue = {} - try { - jwtValue = jwtDecode(token) || {} - } catch (error) { - // @ts-expect-error TS(2571) FIXME: Object is of type 'unknown'. - console.warn(NETLIFYDEVWARN, 'Error while decoding JWT provided in request', error.message) - res.writeHead(400) - res.end('Invalid JWT provided. Please see logs for more info.') - return - } - - // @ts-expect-error TS(2339) FIXME: Property 'exp' does not exist on type '{}'. - if ((jwtValue.exp || 0) < Math.round(Date.now() / MILLISEC_TO_SEC)) { - console.warn(NETLIFYDEVWARN, 'Expired JWT provided in request', req.url) - } else { - const presentedRoles = getProperty(jwtValue, options.jwtRolePath) || [] - if (!Array.isArray(presentedRoles)) { - console.warn(NETLIFYDEVWARN, `Invalid roles value provided in JWT ${options.jwtRolePath}`, presentedRoles) - res.writeHead(400) - res.end('Invalid JWT provided. Please see logs for more info.') - return - } - - // Restore the URL if everything is correct - if (presentedRoles.some((pr) => expectedRoles.has(pr))) { - req.url = originalURL - } - } - } - } - const reqUrl = reqToURL(req, req.url) const isHiddenProxy = match.proxyHeaders && @@ -488,8 +442,6 @@ const reqToURL = function (req, pathname) { ) } -const MILLISEC_TO_SEC = 1e3 - const initializeProxy = async function ({ config, configPath, @@ -1138,7 +1090,7 @@ export const startProxy = async function ({ } const match = await rewriter(req) - if (match && !match.force404 && isExternal(match)) { + if (match && isExternal(match)) { const reqUrl = reqToURL(req, req.url) const dest = new URL(match.to, `${reqUrl.protocol}//${reqUrl.host}`) const destURL = stripOrigin(dest) diff --git a/src/utils/redirects.ts b/src/utils/redirects.ts index ffa925e1a8d..dc6cac4a1e2 100644 --- a/src/utils/redirects.ts +++ b/src/utils/redirects.ts @@ -32,10 +32,11 @@ const getErrorMessage = function ({ message }) { return message } -// `netlify-redirector` does not handle the same shape as the backend: -// - `from` is called `origin` +// `@netlify/redirect-matcher` reads a different rule shape from the backend's: // - `query` is called `params` // - `conditions.role|country|language` are capitalized +// - `signed` becomes `sign.jwt_secret` +// The matcher reads the source path from `path`; `origin` is ignored. const normalizeRedirect = function ({ // @ts-expect-error TS(7031) FIXME: Binding element 'country' implicitly has an 'any' ... Remove this comment to see the full error message conditions: { country, language, role, ...conditions }, diff --git a/src/utils/rules-proxy.ts b/src/utils/rules-proxy.ts index b99ab5f03c5..44d86178ea6 100644 --- a/src/utils/rules-proxy.ts +++ b/src/utils/rules-proxy.ts @@ -1,14 +1,13 @@ import path from 'path' +import { createMatcher, type Matcher, type MatchResult } from '@netlify/redirect-matcher' import chokidar, { type FSWatcher } from 'chokidar' import { parseCookie } from 'cookie' -import redirector from 'netlify-redirector' -import type { Match, RedirectMatcher } from 'netlify-redirector' import pFilter from 'p-filter' import { fileExistsAsync } from '../lib/fs.js' -import { NETLIFYDEVLOG, type NormalizedCachedConfigConfig } from './command-helpers.js' +import { NETLIFYDEVERR, NETLIFYDEVLOG, log, type NormalizedCachedConfigConfig } from './command-helpers.js' import { parseRedirects } from './redirects.js' import type { Request, Rewriter } from './types.js' @@ -55,7 +54,7 @@ export const createRewriter = async function ({ jwtSecret: string projectDir: string }): Promise { - let matcher: RedirectMatcher | null = null + let matcher: Matcher | null = null const redirectsFiles = [ ...new Set([path.resolve(distDir ?? '', '_redirects'), path.resolve(projectDir, '_redirects')]), ] @@ -69,26 +68,26 @@ export const createRewriter = async function ({ existingRedirectsFiles.map((redirectFile) => path.relative(projectDir, redirectFile)), ) redirects = await parseRedirects({ config, redirectsFiles, configPath }) + matcher?.close() matcher = null }) - const getMatcher = async (): Promise => { + const getMatcher = async (): Promise> => { if (matcher) return matcher - if (redirects.length !== 0) { - return (matcher = await redirector.parseJSON(JSON.stringify(redirects), { - jwtSecret, - jwtRoleClaim, - })) + // Without rules, skip compiling the matcher's WebAssembly module. + if (redirects.length === 0) { + return { match: () => null } } - return { - match() { - return null - }, + + matcher = await createMatcher(redirects, { jwtSecret, jwtRoleClaim }) + if (matcher.parseErrors.length !== 0) { + log(NETLIFYDEVERR, `Redirects matcher errors:\n${matcher.parseErrors.map(({ message }) => message).join('\n\n')}`) } + return matcher } - return async function rewriter(req: Request): Promise { + return async function rewriter(req: Request): Promise { const matcherFunc = await getMatcher() const reqUrl = new URL( req.url ?? '', @@ -103,24 +102,13 @@ export const createRewriter = async function ({ ...req.headers, } - // Definition: https://github.com/netlify/libredirect/blob/e81bbeeff9f7c260a5fb74cad296ccc67a92325b/node/src/redirects.cpp#L28-L60 - const matchReq = { + return matcherFunc.match({ scheme: reqUrl.protocol.replace(/:.*$/, ''), host: reqUrl.hostname, path: decodeURIComponent(reqUrl.pathname), query: reqUrl.search.slice(1), headers, - cookieValues, - getHeader: (name: string) => { - const val = headers[name.toLowerCase()] - if (Array.isArray(val)) { - return val[0] - } - return val || '' - }, - getCookie: (key: string) => cookieValues[key] || '', - } - const match = matcherFunc.match(matchReq) - return match + cookies: cookieValues, + }) } } diff --git a/src/utils/types.ts b/src/utils/types.ts index 556cf788d0f..40109d8d8d3 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -2,7 +2,7 @@ import type { Buffer } from 'buffer' import type { IncomingMessage } from 'http' import type { PollingStrategy } from '@netlify/build-info' -import type { Match } from 'netlify-redirector' +import type { MatchResult } from '@netlify/redirect-matcher' export type { GlobalConfigStore } from '@netlify/dev-utils' export type { LocalState } from '@netlify/dev-utils' @@ -67,7 +67,7 @@ export interface Request extends IncomingMessage { hostname?: string } -export type Rewriter = (req: Request) => Promise +export type Rewriter = (req: Request) => Promise // FIXME(serhalp): Much of this appears to be wrong? Most of these should be optional, or at // the very least `siteInfo` should be optional on `CachedConfig` when no site is linked... diff --git a/tests/integration/rules-proxy.test.ts b/tests/integration/rules-proxy.test.ts index e70e76df639..afc685b8bff 100644 --- a/tests/integration/rules-proxy.test.ts +++ b/tests/integration/rules-proxy.test.ts @@ -53,12 +53,13 @@ describe('rules-proxy', () => { const res = await fetch(`http://localhost:${(server?.address() as net.AddressInfo).port}/something`) const body = await res.json() - expect(body).toHaveProperty('from', '/something') - expect(body).toHaveProperty('to', '/ping') - expect(body).toHaveProperty('force', false) - expect(body).toHaveProperty('host', '') - expect(body).toHaveProperty('negative', false) - expect(body).toHaveProperty('scheme', '') - expect(body).toHaveProperty('status', 200) + expect(body).toEqual({ + type: 'match', + status: 200, + to: '/ping', + force: false, + proxyHeaders: {}, + netlifyVary: '', + }) }) }) diff --git a/tests/unit/utils/rules-proxy.test.ts b/tests/unit/utils/rules-proxy.test.ts index 269593b3074..2d6992d3328 100644 --- a/tests/unit/utils/rules-proxy.test.ts +++ b/tests/unit/utils/rules-proxy.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, test } from 'vitest' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' -import { getLanguage } from '../../../src/utils/rules-proxy.js' +import jwt from 'jsonwebtoken' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import type { NormalizedCachedConfigConfig } from '../../../src/utils/command-helpers.js' +import { createRewriter, getLanguage, getWatchers } from '../../../src/utils/rules-proxy.js' +import type { Request } from '../../../src/utils/types.js' describe('getLanguage', () => { test('detects language', () => { @@ -9,3 +16,106 @@ describe('getLanguage', () => { expect(language).toBe('ur') }) }) + +describe('createRewriter', () => { + const jwtSecret = 'test-secret' + const jwtRoleClaim = 'app_metadata.authorization.roles' + const directories: string[] = [] + + const rewriterFor = async (redirectsFile: string, configRedirects: unknown[] = []) => { + const projectDir = await mkdtemp(join(tmpdir(), 'rules-proxy-')) + directories.push(projectDir) + await writeFile(join(projectDir, '_redirects'), redirectsFile) + + const rewriter = await createRewriter({ + config: { redirects: configRedirects } as unknown as NormalizedCachedConfigConfig, + jwtRoleClaim, + jwtSecret, + projectDir, + }) + return { projectDir, rewriter } + } + + const request = (url: string, headers: Record = {}) => + ({ url, headers: { host: 'localhost:8888', ...headers } }) as unknown as Request + + const roleToken = (roles: string[]) => + jwt.sign({ app_metadata: { authorization: { roles } } }, jwtSecret, { expiresIn: '1h' }) + + afterEach(async () => { + await Promise.all( + getWatchers() + .splice(0) + .map((watcher) => watcher.close()), + ) + await Promise.all(directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))) + }) + + test('matches a redirect rule', async () => { + const { rewriter } = await rewriterFor('/old /new 301\n') + + expect(await rewriter(request('/old'))).toMatchObject({ type: 'match', status: 301, to: '/new', force: false }) + expect(await rewriter(request('/other'))).toBeNull() + }) + + test('forces a 404 for a role rule without a JWT', async () => { + const { rewriter } = await rewriterFor('/admin/* /admin/:splat 200! Role=admin\n') + + expect(await rewriter(request('/admin/dashboard'))).toMatchObject({ type: 'forcedNotFound' }) + }) + + test('forces a 404 for a role rule with a JWT for another role', async () => { + const { rewriter } = await rewriterFor('/admin/* /admin/:splat 200! Role=admin\n') + + const result = await rewriter(request('/admin/dashboard', { cookie: `nf_jwt=${roleToken(['editor'])}` })) + expect(result).toMatchObject({ type: 'forcedNotFound' }) + }) + + test('matches a role rule with a JWT for the role', async () => { + const { rewriter } = await rewriterFor('/admin/* /admin/:splat 200! Role=admin\n') + + const result = await rewriter(request('/admin/dashboard', { cookie: `nf_jwt=${roleToken(['admin'])}` })) + expect(result).toMatchObject({ type: 'match', status: 200, to: '/admin/dashboard', force: true }) + }) + + test('matches a Country condition from the nf_country cookie', async () => { + const { rewriter } = await rewriterFor('/ /es/ 302 Country=es\n') + + expect(await rewriter(request('/', { cookie: 'nf_country=es' }))).toMatchObject({ type: 'match', to: '/es/' }) + expect(await rewriter(request('/', { cookie: 'nf_country=de' }))).toBeNull() + }) + + test('matches a Language condition from the Accept-Language header', async () => { + const { rewriter } = await rewriterFor('/ /fr/ 302 Language=fr\n') + + const french = await rewriter(request('/', { 'accept-language': 'fr-CA,fr;q=0.9,en;q=0.8' })) + expect(french).toMatchObject({ type: 'match', to: '/fr/' }) + expect(await rewriter(request('/', { 'accept-language': 'en-US' }))).toBeNull() + }) + + test('reports the signing secret name of a signed rule', async () => { + const { rewriter } = await rewriterFor('', [ + { from: '/api/*', to: 'https://api.example.com/:splat', status: 200, signed: 'SIGNING_VAR' }, + ]) + + expect(await rewriter(request('/api/users'))).toMatchObject({ + type: 'match', + to: 'https://api.example.com/users', + signer: { jwtSecret: 'SIGNING_VAR' }, + }) + }) + + test('reloads rules when the _redirects file changes', async () => { + const { projectDir, rewriter } = await rewriterFor('/old /new 301\n') + expect(await rewriter(request('/old'))).toMatchObject({ to: '/new' }) + + await writeFile(join(projectDir, '_redirects'), '/old /newer 301\n') + + await vi.waitFor( + async () => { + expect(await rewriter(request('/old'))).toMatchObject({ to: '/newer' }) + }, + { timeout: 5000, interval: 100 }, + ) + }) +}) diff --git a/types/netlify-redirector/index.d.ts b/types/netlify-redirector/index.d.ts deleted file mode 100644 index f09c231e6d0..00000000000 --- a/types/netlify-redirector/index.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -declare module 'netlify-redirector' { - export interface Options { - jwtSecret?: string - jwtRoleClaim?: string - } - export interface Request { - scheme: string - host: string - path: string - query: string - getHeader: (name: string) => string - getCookie: (name: string) => string - } - export type Match = ( - | { - from: string - to: string - host: string - scheme: string - status: number - force: boolean - negative: boolean - proxyHeaders?: Record - signingSecret?: string - } - | { - force404: true - } - ) & { - force404?: boolean - conditions: Record - exceptions: Record - } - export interface RedirectMatcher { - match(req: Request): Match | null - } - export function parsePlain(rules: string, options: Options): Promise - export function parseJSON(rules: string, options: Options): Promise -} From ee85384d33ade7d57639b3d29a75829446d6c7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Fri, 2 Oct 2026 15:09:06 +0200 Subject: [PATCH 2/4] chore: move dot-prop to devDependencies Its only runtime use was the removed role re-check in proxy.ts; it is still used by integration tests. --- package-lock.json | 4 +++- package.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f805a4f205..cf69afa9cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,6 @@ "cron-parser": "^5.0.0", "debug": "^4.4.3", "decache": "^4.6.2", - "dot-prop": "^10.1.0", "dotenv": "^17.3.1", "env-paths": "^4.0.0", "envinfo": "^7.21.0", @@ -155,6 +154,7 @@ "c8": "^10.1.3", "cheerio": "^1.1.2", "dedent": "^1.7.2", + "dot-prop": "^10.1.0", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.23.1", @@ -11510,6 +11510,7 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.2.0.tgz", "integrity": "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==", + "dev": true, "license": "MIT", "dependencies": { "type-fest": "^5.0.0" @@ -11525,6 +11526,7 @@ "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" diff --git a/package.json b/package.json index 629472a7355..405d08d8492 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "cron-parser": "^5.0.0", "debug": "^4.4.3", "decache": "^4.6.2", - "dot-prop": "^10.1.0", "dotenv": "^17.3.1", "env-paths": "^4.0.0", "envinfo": "^7.21.0", @@ -193,6 +192,7 @@ "c8": "^10.1.3", "cheerio": "^1.1.2", "dedent": "^1.7.2", + "dot-prop": "^10.1.0", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.23.1", From b441e6588bc617f1fbb3a19ab236832efed5eb4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Fri, 2 Oct 2026 15:21:48 +0200 Subject: [PATCH 3/4] fix: share one matcher build across concurrent redirect requests Concurrent first requests each built their own matcher, and a rules reload during a build could leave a matcher of the old rules cached. Cache the build promise instead, and stop closing the previous matcher on reload, since a request may still hold it; it is freed once garbage-collected. --- src/utils/rules-proxy.ts | 21 +++-- tests/unit/utils/rules-proxy-matcher.test.ts | 90 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 tests/unit/utils/rules-proxy-matcher.test.ts diff --git a/src/utils/rules-proxy.ts b/src/utils/rules-proxy.ts index 44d86178ea6..854deaa4153 100644 --- a/src/utils/rules-proxy.ts +++ b/src/utils/rules-proxy.ts @@ -54,7 +54,7 @@ export const createRewriter = async function ({ jwtSecret: string projectDir: string }): Promise { - let matcher: Matcher | null = null + let matcher: Promise> | null = null const redirectsFiles = [ ...new Set([path.resolve(distDir ?? '', '_redirects'), path.resolve(projectDir, '_redirects')]), ] @@ -68,25 +68,28 @@ export const createRewriter = async function ({ existingRedirectsFiles.map((redirectFile) => path.relative(projectDir, redirectFile)), ) redirects = await parseRedirects({ config, redirectsFiles, configPath }) - matcher?.close() + // Not closed: a request may still hold the previous matcher. The package + // frees it once it is garbage-collected. matcher = null }) - const getMatcher = async (): Promise> => { - if (matcher) return matcher - + const buildMatcher = async (): Promise> => { // Without rules, skip compiling the matcher's WebAssembly module. if (redirects.length === 0) { return { match: () => null } } - matcher = await createMatcher(redirects, { jwtSecret, jwtRoleClaim }) - if (matcher.parseErrors.length !== 0) { - log(NETLIFYDEVERR, `Redirects matcher errors:\n${matcher.parseErrors.map(({ message }) => message).join('\n\n')}`) + const built = await createMatcher(redirects, { jwtSecret, jwtRoleClaim }) + if (built.parseErrors.length !== 0) { + log(NETLIFYDEVERR, `Redirects matcher errors:\n${built.parseErrors.map(({ message }) => message).join('\n\n')}`) } - return matcher + return built } + // The promise is cached, not the matcher, so concurrent requests share one + // build and a reload mid-build cannot cache a matcher of the old rules. + const getMatcher = (): Promise> => (matcher ??= buildMatcher()) + return async function rewriter(req: Request): Promise { const matcherFunc = await getMatcher() const reqUrl = new URL( diff --git a/tests/unit/utils/rules-proxy-matcher.test.ts b/tests/unit/utils/rules-proxy-matcher.test.ts new file mode 100644 index 00000000000..0b2c8e9bfc3 --- /dev/null +++ b/tests/unit/utils/rules-proxy-matcher.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, describe, expect, test, vi } from 'vitest' + +import type { NormalizedCachedConfigConfig } from '../../../src/utils/command-helpers.js' +import { createRewriter, getWatchers } from '../../../src/utils/rules-proxy.js' +import type { Request } from '../../../src/utils/types.js' + +const stubMatcher = { match: () => null, parseErrors: [], rulesCount: 1, close: () => {} } + +const { createMatcher } = vi.hoisted(() => ({ + createMatcher: vi.fn(async (_rules: { to?: string }[]) => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return stubMatcher + }), +})) + +vi.mock('@netlify/redirect-matcher', () => ({ createMatcher })) + +describe('createRewriter matcher lifecycle', () => { + const directories: string[] = [] + const request = { url: '/old', headers: { host: 'localhost:8888' } } as unknown as Request + + const rewriterFor = async (redirectsFile: string) => { + const projectDir = await mkdtemp(join(tmpdir(), 'rules-proxy-matcher-')) + directories.push(projectDir) + await writeFile(join(projectDir, '_redirects'), redirectsFile) + const rewriter = await createRewriter({ + config: { redirects: [] } as unknown as NormalizedCachedConfigConfig, + jwtRoleClaim: '', + jwtSecret: '', + projectDir, + }) + return { projectDir, rewriter } + } + + afterEach(async () => { + vi.restoreAllMocks() + createMatcher.mockClear() + await Promise.all( + getWatchers() + .splice(0) + .map((watcher) => watcher.close()), + ) + await Promise.all(directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))) + }) + + test('concurrent first requests share one matcher', async () => { + const { rewriter } = await rewriterFor('/old /new 301\n') + + await Promise.all([rewriter(request), rewriter(request), rewriter(request)]) + + expect(createMatcher).toHaveBeenCalledTimes(1) + }) + + test('a reload during the first build does not leave the old rules cached', async () => { + let finishFirstBuild = () => {} + createMatcher.mockImplementationOnce(async () => { + await new Promise((resolve) => { + finishFirstBuild = resolve + }) + return stubMatcher + }) + const reloaded = vi.fn() + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + if (String(args[0]).includes('Reloading redirect rules')) reloaded() + }) + const { projectDir, rewriter } = await rewriterFor('/old /new 301\n') + + const firstRequest = rewriter(request) + await writeFile(join(projectDir, '_redirects'), '/old /newer 301\n') + await vi.waitFor( + () => { + expect(reloaded).toHaveBeenCalled() + }, + { timeout: 5000, interval: 50 }, + ) + // Let the reload finish re-parsing the rules before the old build completes. + await new Promise((resolve) => setTimeout(resolve, 100)) + finishFirstBuild() + await firstRequest + + await rewriter(request) + + expect(createMatcher).toHaveBeenCalledTimes(2) + expect(createMatcher.mock.calls[1][0]).toMatchObject([{ to: '/newer' }]) + }) +}) From 2fd5725bd93d8fd24657884169566e7bcc3a0156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Fri, 2 Oct 2026 15:27:01 +0200 Subject: [PATCH 4/4] fix: retry a failed redirect matcher build A rejected build stayed cached, so every later request failed until the rules were reloaded. Clear it on failure, but only while it is still the cached build, so a newer build started by a reload is kept. --- src/utils/rules-proxy.ts | 13 ++++++++++++- tests/unit/utils/rules-proxy-matcher.test.ts | 10 ++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/utils/rules-proxy.ts b/src/utils/rules-proxy.ts index 854deaa4153..e8264aa73af 100644 --- a/src/utils/rules-proxy.ts +++ b/src/utils/rules-proxy.ts @@ -88,7 +88,18 @@ export const createRewriter = async function ({ // The promise is cached, not the matcher, so concurrent requests share one // build and a reload mid-build cannot cache a matcher of the old rules. - const getMatcher = (): Promise> => (matcher ??= buildMatcher()) + const getMatcher = (): Promise> => { + if (!matcher) { + const build = buildMatcher() + matcher = build + // A failed build is retried by the next request, unless a reload has + // already replaced it with a newer one. + build.catch(() => { + if (matcher === build) matcher = null + }) + } + return matcher + } return async function rewriter(req: Request): Promise { const matcherFunc = await getMatcher() diff --git a/tests/unit/utils/rules-proxy-matcher.test.ts b/tests/unit/utils/rules-proxy-matcher.test.ts index 0b2c8e9bfc3..4525a1dc260 100644 --- a/tests/unit/utils/rules-proxy-matcher.test.ts +++ b/tests/unit/utils/rules-proxy-matcher.test.ts @@ -55,6 +55,16 @@ describe('createRewriter matcher lifecycle', () => { expect(createMatcher).toHaveBeenCalledTimes(1) }) + test('a failed build is retried by the next request', async () => { + createMatcher.mockRejectedValueOnce(new Error('failed to load matcher')) + const { rewriter } = await rewriterFor('/old /new 301\n') + + await expect(rewriter(request)).rejects.toThrow('failed to load matcher') + await expect(rewriter(request)).resolves.toBeNull() + + expect(createMatcher).toHaveBeenCalledTimes(2) + }) + test('a reload during the first build does not leave the old rules cached', async () => { let finishFirstBuild = () => {} createMatcher.mockImplementationOnce(async () => {