Skip to content

Commit

Permalink
feat: support custom function routes (#5954)
Browse files Browse the repository at this point in the history
* feat: support custom function paths

* chore: add comments

* feat: support redirects
  • Loading branch information
eduardoboucas committed Aug 21, 2023
1 parent 30f77e0 commit 82b94b5
Show file tree
Hide file tree
Showing 16 changed files with 196 additions and 16 deletions.
3 changes: 2 additions & 1 deletion src/commands/dev/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ const dev = async (options, command) => {
},
})

await startFunctionsServer({
const functionsRegistry = await startFunctionsServer({
api,
command,
config,
Expand Down Expand Up @@ -217,6 +217,7 @@ const dev = async (options, command) => {
geolocationMode: options.geo,
geoCountry: options.country,
accountId,
functionsRegistry,
})

if (devConfig.autoLaunch !== false) {
Expand Down
4 changes: 3 additions & 1 deletion src/commands/serve/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const serve = async (options, command) => {
options,
})

await startFunctionsServer({
const functionsRegistry = await startFunctionsServer({
api,
command,
config,
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions src/lib/functions/netlify-function.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/lib/functions/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion src/lib/functions/runtimes/js/builders/zisi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const buildFunction = async ({
includedFiles,
inputs,
path: functionPath,
routes,
runtimeAPIVersion,
schedule,
} = await memoizedBuild({
Expand All @@ -81,7 +82,7 @@ const buildFunction = async ({

clearFunctionsCache(targetDirectory)

return { buildPath, includedFiles, runtimeAPIVersion, srcFiles, schedule }
return { buildPath, includedFiles, routes, runtimeAPIVersion, srcFiles, schedule }
}

/**
Expand Down
24 changes: 19 additions & 5 deletions src/lib/functions/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -231,7 +243,7 @@ const getFunctionsServer = (options) => {
* @param {*} options.site
* @param {string} options.siteUrl
* @param {*} options.timeouts
* @returns
* @returns {Promise<import('./registry.mjs').FunctionsRegistry | undefined>}
*/
export const startFunctionsServer = async (options) => {
const { capabilities, command, config, debug, loadDistFunctions, settings, site, siteUrl, timeouts } = options
Expand Down Expand Up @@ -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
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/utils/headers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ const getErrorMessage = function ({ message }) {
return message
}

export const NFFunctionName = 'x-nf-function-name'
export const NFRequestID = 'x-nf-request-id'
3 changes: 3 additions & 0 deletions src/utils/proxy-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand All @@ -61,6 +62,7 @@ export const startProxyServer = async ({
configPath,
debug,
env,
functionsRegistry,
geoCountry,
geolocationMode,
getUpdatedConfig,
Expand All @@ -78,6 +80,7 @@ export const startProxyServer = async ({
configPath: configPath || site.configPath,
debug,
env,
functionsRegistry,
geolocationMode,
geoCountry,
getUpdatedConfig,
Expand Down
60 changes: 52 additions & 8 deletions src/utils/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 || {}
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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,
Expand All @@ -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 = []
Expand Down Expand Up @@ -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,
) => {
Expand All @@ -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 })
Expand All @@ -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
Expand Down Expand Up @@ -628,6 +670,7 @@ export const startProxy = async function ({
configPath,
debug,
env,
functionsRegistry,
geoCountry,
geolocationMode,
getUpdatedConfig,
Expand Down Expand Up @@ -681,6 +724,7 @@ export const startProxy = async function ({
rewriter,
settings,
addonsUrls,
functionsRegistry,
functionsServer,
edgeFunctionsProxy,
siteInfo,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default async (req) => new Response(`With expression path: ${req.url}`)

export const config = {
path: '/products/:sku',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default async (req) => new Response(`With literal path: ${req.url}`)

export const config = {
path: '/products',
}
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/products from origin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/v2-to-custom-without-force from origin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/v2-to-legacy-without-force from origin
Loading

2 comments on commit 82b94b5

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,320
  • Package size: 278 MB

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,320
  • Package size: 278 MB

Please sign in to comment.