diff --git a/src/commands/dev/dev.mjs b/src/commands/dev/dev.mjs index 8c879ea099b..dc06dfca8d3 100644 --- a/src/commands/dev/dev.mjs +++ b/src/commands/dev/dev.mjs @@ -161,7 +161,7 @@ const dev = async (options, command) => { }, }) - await startFunctionsServer({ + const functionsRegistry = await startFunctionsServer({ api, command, config, @@ -217,6 +217,7 @@ const dev = async (options, command) => { geolocationMode: options.geo, geoCountry: options.country, accountId, + functionsRegistry, }) if (devConfig.autoLaunch !== false) { diff --git a/src/commands/serve/serve.mjs b/src/commands/serve/serve.mjs index f8333661197..2cbc6bbbfb4 100644 --- a/src/commands/serve/serve.mjs +++ b/src/commands/serve/serve.mjs @@ -93,7 +93,7 @@ const serve = async (options, command) => { options, }) - await startFunctionsServer({ + const functionsRegistry = await startFunctionsServer({ api, command, config, @@ -132,7 +132,9 @@ const serve = async (options, command) => { addonsUrls, config, configPath: configPathOverride, + debug: options.debug, env, + functionsRegistry, geolocationMode: options.geo, geoCountry: options.country, getUpdatedConfig, diff --git a/src/lib/functions/netlify-function.mjs b/src/lib/functions/netlify-function.mjs index 16db091bf52..baa7d1ce32c 100644 --- a/src/lib/functions/netlify-function.mjs +++ b/src/lib/functions/netlify-function.mjs @@ -158,6 +158,28 @@ export default class NetlifyFunction { } } + async matchURLPath(rawPath) { + await this.buildQueue + + const path = (rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath).toLowerCase() + const { routes = [] } = this.buildData + const isMatch = routes.some(({ expression, literal }) => { + if (literal !== undefined) { + return path === literal + } + + if (expression !== undefined) { + const regex = new RegExp(expression) + + return regex.test(path) + } + + return false + }) + + return isMatch + } + get url() { // This line fixes the issue here https://github.com/netlify/cli/issues/4116 // Not sure why `settings.port` was used here nor does a valid reference exist. diff --git a/src/lib/functions/registry.mjs b/src/lib/functions/registry.mjs index 8a9b84d0b22..1c55deafaf1 100644 --- a/src/lib/functions/registry.mjs +++ b/src/lib/functions/registry.mjs @@ -122,6 +122,16 @@ export class FunctionsRegistry { return this.functions.get(name) } + async getFunctionForURLPath(urlPath) { + for (const func of this.functions.values()) { + const isMatch = await func.matchURLPath(urlPath) + + if (isMatch) { + return func + } + } + } + async registerFunction(name, funcBeforeHook) { const { runtime } = funcBeforeHook diff --git a/src/lib/functions/runtimes/js/builders/zisi.mjs b/src/lib/functions/runtimes/js/builders/zisi.mjs index 1e2c4884581..1023344c21a 100644 --- a/src/lib/functions/runtimes/js/builders/zisi.mjs +++ b/src/lib/functions/runtimes/js/builders/zisi.mjs @@ -58,6 +58,7 @@ const buildFunction = async ({ includedFiles, inputs, path: functionPath, + routes, runtimeAPIVersion, schedule, } = await memoizedBuild({ @@ -81,7 +82,7 @@ const buildFunction = async ({ clearFunctionsCache(targetDirectory) - return { buildPath, includedFiles, runtimeAPIVersion, srcFiles, schedule } + return { buildPath, includedFiles, routes, runtimeAPIVersion, srcFiles, schedule } } /** diff --git a/src/lib/functions/server.mjs b/src/lib/functions/server.mjs index 9fedd0ddf29..8be9717d570 100644 --- a/src/lib/functions/server.mjs +++ b/src/lib/functions/server.mjs @@ -7,6 +7,7 @@ import jwtDecode from 'jwt-decode' import { NETLIFYDEVERR, NETLIFYDEVLOG, error as errorExit, log } from '../../utils/command-helpers.mjs' import { CLOCKWORK_USERAGENT, getFunctionsDistPath, getInternalFunctionsDir } from '../../utils/functions/index.mjs' +import { NFFunctionName } from '../../utils/headers.mjs' import { headers as efHeaders } from '../edge-functions/headers.mjs' import { getGeoLocation } from '../geo-location.mjs' @@ -55,9 +56,20 @@ export const createHandler = function (options) { const { functionsRegistry } = options return async function handler(request, response) { - // handle proxies without path re-writes (http-servr) - const cleanPath = request.path.replace(/^\/.netlify\/(functions|builders)/, '') - const functionName = cleanPath.split('/').find(Boolean) + // If this header is set, it means we've already matched a function and we + // can just grab its name directly. We delete the header from the request + // because we don't want to expose it to user code. + let functionName = request.header(NFFunctionName) + delete request.headers[NFFunctionName] + + // If we didn't match a function with a custom route, let's try to match + // using the fixed URL format. + if (!functionName) { + const cleanPath = request.path.replace(/^\/.netlify\/(functions|builders)/, '') + + functionName = cleanPath.split('/').find(Boolean) + } + const func = functionsRegistry.get(functionName) if (func === undefined) { @@ -231,7 +243,7 @@ const getFunctionsServer = (options) => { * @param {*} options.site * @param {string} options.siteUrl * @param {*} options.timeouts - * @returns + * @returns {Promise} */ export const startFunctionsServer = async (options) => { const { capabilities, command, config, debug, loadDistFunctions, settings, site, siteUrl, timeouts } = options @@ -272,9 +284,11 @@ export const startFunctionsServer = async (options) => { await functionsRegistry.scan(functionsDirectories) - const server = await getFunctionsServer(Object.assign(options, { functionsRegistry })) + const server = getFunctionsServer(Object.assign(options, { functionsRegistry })) await startWebServer({ server, settings, debug }) + + return functionsRegistry } /** diff --git a/src/utils/headers.mjs b/src/utils/headers.mjs index d0eb68bcd75..c570fe705aa 100644 --- a/src/utils/headers.mjs +++ b/src/utils/headers.mjs @@ -46,4 +46,5 @@ const getErrorMessage = function ({ message }) { return message } +export const NFFunctionName = 'x-nf-function-name' export const NFRequestID = 'x-nf-request-id' diff --git a/src/utils/proxy-server.mjs b/src/utils/proxy-server.mjs index 07f0460793c..e2a64ec3d3a 100644 --- a/src/utils/proxy-server.mjs +++ b/src/utils/proxy-server.mjs @@ -52,6 +52,7 @@ export const generateInspectSettings = (edgeInspect, edgeInspectBrk) => { * @param {*} params.siteInfo * @param {string} params.projectDir * @param {import('./state-config.mjs').default} params.state + * @param {import('../lib/functions/registry.mjs').FunctionsRegistry=} params.functionsRegistry * @returns */ export const startProxyServer = async ({ @@ -61,6 +62,7 @@ export const startProxyServer = async ({ configPath, debug, env, + functionsRegistry, geoCountry, geolocationMode, getUpdatedConfig, @@ -78,6 +80,7 @@ export const startProxyServer = async ({ configPath: configPath || site.configPath, debug, env, + functionsRegistry, geolocationMode, geoCountry, getUpdatedConfig, diff --git a/src/utils/proxy.mjs b/src/utils/proxy.mjs index 4c1c5fe6fbc..54968fe48dc 100644 --- a/src/utils/proxy.mjs +++ b/src/utils/proxy.mjs @@ -31,7 +31,7 @@ import renderErrorTemplate from '../lib/render-error-template.mjs' import { NETLIFYDEVLOG, NETLIFYDEVWARN, log, chalk } from './command-helpers.mjs' import createStreamPromise from './create-stream-promise.mjs' -import { headersForPath, parseHeaders, NFRequestID } from './headers.mjs' +import { headersForPath, parseHeaders, NFFunctionName, NFRequestID } from './headers.mjs' import { generateRequestID } from './request-id.mjs' import { createRewriter, onChanges } from './rules-proxy.mjs' import { signRedirect } from './sign-redirect.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 = [] @@ -551,7 +580,7 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port, } const onRequest = async ( - { addonsUrls, edgeFunctionsProxy, env, functionsServer, proxy, rewriter, settings, siteInfo }, + { addonsUrls, edgeFunctionsProxy, env, functionsRegistry, functionsServer, proxy, rewriter, settings, siteInfo }, req, res, ) => { @@ -565,9 +594,22 @@ 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) : null + + 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 } + + return proxy.web(req, res, { headers, target: functionsServer }) + } + const addonUrl = getAddonUrl(addonsUrls, req) if (addonUrl) { return handleAddonUrl({ req, res, addonUrl }) @@ -591,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 @@ -628,6 +670,7 @@ export const startProxy = async function ({ configPath, debug, env, + functionsRegistry, geoCountry, geolocationMode, getUpdatedConfig, @@ -681,6 +724,7 @@ export const startProxy = async function ({ rewriter, settings, addonsUrls, + functionsRegistry, functionsServer, edgeFunctionsProxy, siteInfo, diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/custom-path-expression.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/custom-path-expression.mjs new file mode 100644 index 00000000000..16f01d3c7a7 --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/custom-path-expression.mjs @@ -0,0 +1,5 @@ +export default async (req) => new Response(`With expression path: ${req.url}`) + +export const config = { + path: '/products/:sku', +} diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/custom-path-literal.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/custom-path-literal.mjs new file mode 100644 index 00000000000..52952eafdc0 --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/custom-path-literal.mjs @@ -0,0 +1,5 @@ +export default async (req) => new Response(`With literal path: ${req.url}`) + +export const config = { + path: '/products', +} 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 d2d6b0ad8ca..c40a01d57cf 100644 --- a/tests/integration/commands/dev/v2-api.test.ts +++ b/tests/integration/commands/dev/v2-api.test.ts @@ -104,5 +104,49 @@ describe.runIf(gte(version, '18.13.0'))('v2 api', () => { expect(response.headers.get('content-type')).toBe('text/html') expect(await response.text()).toContain('') }) + + test('supports custom URLs using a literal path', async ({ devServer }) => { + const url = `http://localhost:${devServer.port}/products` + const response = await fetch(url) + expect(response.status).toBe(200) + expect(await response.text()).toBe(`With literal path: ${url}`) + }) + + test('supports custom URLs using an expression path', async ({ devServer }) => { + const url = `http://localhost:${devServer.port}/products/netlify` + const response = await fetch(url) + 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') + }) + }) }) })