diff --git a/src/lib/functions/netlify-function.mjs b/src/lib/functions/netlify-function.mjs index db77ddd8eb9..ad4bf94fb32 100644 --- a/src/lib/functions/netlify-function.mjs +++ b/src/lib/functions/netlify-function.mjs @@ -123,6 +123,12 @@ export default class NetlifyFunction { } } + async getBuildData() { + await this.buildQueue + + return this.buildData + } + // Compares a new set of source files against a previous one, returning an // object with two Sets, one with added and the other with deleted files. getSrcFilesDiff(newSrcFiles) { diff --git a/src/lib/functions/registry.mjs b/src/lib/functions/registry.mjs index d15c8a9420a..efc7cbe4f52 100644 --- a/src/lib/functions/registry.mjs +++ b/src/lib/functions/registry.mjs @@ -14,6 +14,7 @@ import { getPathInProject } from '../settings.mjs' import NetlifyFunction from './netlify-function.mjs' import runtimes from './runtimes/index.mjs' +const DEFAULT_URL_EXPRESSION = /^\/.netlify\/(functions|builders)\/([^/]+).*/ const ZIP_EXTENSION = '.zip' export class FunctionsRegistry { @@ -123,6 +124,32 @@ export class FunctionsRegistry { } async getFunctionForURLPath(urlPath, method) { + const defaultURLMatch = urlPath.match(DEFAULT_URL_EXPRESSION) + + if (defaultURLMatch) { + const func = this.get(defaultURLMatch[2]) + + if (!func) { + return + } + + const { routes = [] } = await func.getBuildData() + + if (routes.length !== 0) { + const paths = routes.map((route) => chalk.underline(route.pattern)).join(', ') + + warn( + `Function ${chalk.yellow(func.name)} cannot be invoked on ${chalk.underline( + urlPath, + )}, because the function has the following URL paths defined: ${paths}`, + ) + + return + } + + return { func, route: null } + } + for (const func of this.functions.values()) { const route = await func.matchURLPath(urlPath, method) diff --git a/src/utils/proxy.mjs b/src/utils/proxy.mjs index 71ce81b2816..25130f450fb 100644 --- a/src/utils/proxy.mjs +++ b/src/utils/proxy.mjs @@ -597,18 +597,17 @@ 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 }) - } - - // Does the request match a function on a custom URL path? - const functionMatch = functionsRegistry ? await functionsRegistry.getFunctionForURLPath(req.url, req.method) : null + const functionMatch = await functionsRegistry.getFunctionForURLPath(req.url, req.method) 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.func.name, [NFFunctionRoute]: functionMatch.route.pattern } + /** @type {Record} */ + const headers = { [NFFunctionName]: functionMatch.func.name } + + if (functionMatch.route) { + headers[NFFunctionRoute] = functionMatch.route.pattern + } return proxy.web(req, res, { headers, target: functionsServer }) } diff --git a/tests/integration/commands/dev/v2-api.test.ts b/tests/integration/commands/dev/v2-api.test.ts index 2247ae38379..8854a8d5e6d 100644 --- a/tests/integration/commands/dev/v2-api.test.ts +++ b/tests/integration/commands/dev/v2-api.test.ts @@ -126,6 +126,14 @@ describe.runIf(gte(version, '18.13.0'))('v2 api', () => { expect(await response.text()).toBe(`With expression path: {"sku":"netlify"}`) }) + test('returns 404 when using the default function URL to access a function with custom routes', async ({ + devServer, + }) => { + const url = `http://localhost:${devServer.port}/.netlify/functions/custom-path-literal` + const response = await fetch(url) + expect(response.status).toBe(404) + }) + 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`