Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support custom function routes #5954

Merged
merged 5 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading