diff --git a/src/utils/proxy.mjs b/src/utils/proxy.mjs index eb6b0e3bbd0..54968fe48dc 100644 --- a/src/utils/proxy.mjs +++ b/src/utils/proxy.mjs @@ -181,7 +181,7 @@ const alternativePathsFor = function (url) { return paths } -const serveRedirect = async function ({ env, match, options, proxy, req, res, siteInfo }) { +const serveRedirect = async function ({ env, functionsRegistry, match, options, proxy, req, res, siteInfo }) { if (!match) return proxy.web(req, res, options) options = options || req.proxyOptions || {} @@ -214,6 +214,7 @@ const serveRedirect = async function ({ env, match, options, proxy, req, res, si if (isFunction(options.functionsPort, req.url)) { return proxy.web(req, res, { target: options.functionsServer }) } + const urlForAddons = getAddonUrl(options.addonsUrls, req) if (urlForAddons) { return handleAddonUrl({ req, res, addonUrl: urlForAddons }) @@ -327,22 +328,28 @@ const serveRedirect = async function ({ env, match, options, proxy, req, res, si return proxy.web(req, res, { target: options.functionsServer }) } + const functionWithCustomRoute = functionsRegistry && (await functionsRegistry.getFunctionForURLPath(destURL)) const destStaticFile = await getStatic(dest.pathname, options.publicFolder) let statusValue - if (match.force || (!staticFile && ((!options.framework && destStaticFile) || isInternal(destURL)))) { + if ( + match.force || + (!staticFile && ((!options.framework && destStaticFile) || isInternal(destURL) || functionWithCustomRoute)) + ) { req.url = destStaticFile ? destStaticFile + dest.search : destURL const { status } = match statusValue = status console.log(`${NETLIFYDEVLOG} Rewrote URL to`, req.url) } - if (isFunction(options.functionsPort, req.url)) { + if (isFunction(options.functionsPort, req.url) || functionWithCustomRoute) { + const functionHeaders = functionWithCustomRoute ? { [NFFunctionName]: functionWithCustomRoute.name } : {} const url = reqToURL(req, originalURL) req.headers['x-netlify-original-pathname'] = url.pathname req.headers['x-netlify-original-search'] = url.search - return proxy.web(req, res, { target: options.functionsServer }) + return proxy.web(req, res, { headers: functionHeaders, target: options.functionsServer }) } + const addonUrl = getAddonUrl(options.addonsUrls, req) if (addonUrl) { return handleAddonUrl({ req, res, addonUrl }) @@ -434,12 +441,22 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port, } if (proxyRes.statusCode === 404 || proxyRes.statusCode === 403) { + // If a request for `/path` has failed, we'll a few variations like + // `/path/index.html` to mimic the CDN behavior. if (req.alternativePaths && req.alternativePaths.length !== 0) { req.url = req.alternativePaths.shift() return proxy.web(req, res, req.proxyOptions) } + + // The request has failed but we might still have a matching redirect + // rule (without `force`) that should kick in. This is how we mimic the + // file shadowing behavior from the CDN. if (req.proxyOptions && req.proxyOptions.match) { return serveRedirect({ + // We don't want to match functions at this point because any redirects + // to functions will have already been processed, so we don't supply a + // functions registry to `serveRedirect`. + functionsRegistry: null, req, res, proxy: handlers, @@ -453,7 +470,19 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port, if (req.proxyOptions.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) { req.url = proxyRes.headers.location - return serveRedirect({ req, res, proxy: handlers, match: null, options: req.proxyOptions, siteInfo, env }) + return serveRedirect({ + // We don't want to match functions at this point because any redirects + // to functions will have already been processed, so we don't supply a + // functions registry to `serveRedirect`. + functionsRegistry: null, + req, + res, + proxy: handlers, + match: null, + options: req.proxyOptions, + siteInfo, + env, + }) } const responseData = [] @@ -565,20 +594,20 @@ const onRequest = async ( return proxy.web(req, res, { target: edgeFunctionsProxyURL }) } + // Does the request match a function on the fixed URL path? if (isFunction(settings.functionsPort, req.url)) { return proxy.web(req, res, { target: functionsServer }) } - if (functionsRegistry) { - const functionMatch = await functionsRegistry.getFunctionForURLPath(req.url) + // Does the request match a function on a custom URL path? + const functionMatch = functionsRegistry ? await functionsRegistry.getFunctionForURLPath(req.url) : null - // Setting an internal header with the function name so that we don't have - // to match the URL again in the functions server. + if (functionMatch) { + // Setting an internal header with the function name so that we don't + // have to match the URL again in the functions server. const headers = { [NFFunctionName]: functionMatch.name } - if (functionMatch) { - return proxy.web(req, res, { headers, target: functionsServer }) - } + return proxy.web(req, res, { headers, target: functionsServer }) } const addonUrl = getAddonUrl(addonsUrls, req) @@ -604,7 +633,7 @@ const onRequest = async ( // We don't want to generate an ETag for 3xx redirects. req[shouldGenerateETag] = ({ statusCode }) => statusCode < 300 || statusCode >= 400 - return serveRedirect({ req, res, proxy, match, options, siteInfo, env }) + return serveRedirect({ req, res, proxy, match, options, siteInfo, env, functionsRegistry }) } // The request will be served by the framework server, which means we want to diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/netlify.toml b/tests/integration/__fixtures__/dev-server-with-v2-functions/netlify.toml index 5ae964ac10f..e0c108ee3b4 100644 --- a/tests/integration/__fixtures__/dev-server-with-v2-functions/netlify.toml +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/netlify.toml @@ -1,2 +1,27 @@ +[build] +publish = "public" + [functions] directory = "functions" + +[[redirects]] +force = true +from = "/v2-to-legacy-with-force" +status = 200 +to = "/.netlify/functions/custom-path-literal" + +[[redirects]] +from = "/v2-to-legacy-without-force" +status = 200 +to = "/.netlify/functions/custom-path-literal" + +[[redirects]] +force = true +from = "/v2-to-custom-with-force" +status = 200 +to = "/products" + +[[redirects]] +from = "/v2-to-custom-without-force" +status = 200 +to = "/products" diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/public/products.html b/tests/integration/__fixtures__/dev-server-with-v2-functions/public/products.html new file mode 100644 index 00000000000..17079e343d5 --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/public/products.html @@ -0,0 +1 @@ +/products from origin diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/public/v2-to-custom-without-force.html b/tests/integration/__fixtures__/dev-server-with-v2-functions/public/v2-to-custom-without-force.html new file mode 100644 index 00000000000..2a18639f48a --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/public/v2-to-custom-without-force.html @@ -0,0 +1 @@ +/v2-to-custom-without-force from origin \ No newline at end of file diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/public/v2-to-legacy-without-force.html b/tests/integration/__fixtures__/dev-server-with-v2-functions/public/v2-to-legacy-without-force.html new file mode 100644 index 00000000000..3242a10f669 --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/public/v2-to-legacy-without-force.html @@ -0,0 +1 @@ +/v2-to-legacy-without-force from origin \ No newline at end of file diff --git a/tests/integration/commands/dev/v2-api.test.ts b/tests/integration/commands/dev/v2-api.test.ts index 56dfabd972f..c40a01d57cf 100644 --- a/tests/integration/commands/dev/v2-api.test.ts +++ b/tests/integration/commands/dev/v2-api.test.ts @@ -118,5 +118,35 @@ describe.runIf(gte(version, '18.13.0'))('v2 api', () => { expect(response.status).toBe(200) expect(await response.text()).toBe(`With expression path: ${url}`) }) + + describe('handles rewrites to a function', () => { + test('rewrite to legacy URL format with `force: true`', async ({ devServer }) => { + const url = `http://localhost:${devServer.port}/v2-to-legacy-with-force` + const response = await fetch(url) + expect(response.status).toBe(200) + expect(await response.text()).toBe(`With literal path: ${url}`) + }) + + test('rewrite to legacy URL format with `force: false`', async ({ devServer }) => { + const url = `http://localhost:${devServer.port}/v2-to-legacy-without-force` + const response = await fetch(url) + expect(response.status).toBe(200) + expect(await response.text()).toBe('/v2-to-legacy-without-force from origin') + }) + + test('rewrite to custom URL format with `force: true`', async ({ devServer }) => { + const url = `http://localhost:${devServer.port}/v2-to-custom-with-force` + const response = await fetch(url) + expect(response.status).toBe(200) + expect(await response.text()).toBe(`With literal path: ${url}`) + }) + + test('rewrite to custom URL format with `force: false`', async ({ devServer }) => { + const url = `http://localhost:${devServer.port}/v2-to-custom-without-force` + const response = await fetch(url) + expect(response.status).toBe(200) + expect(await response.text()).toBe('/v2-to-custom-without-force from origin') + }) + }) }) })