Skip to content

Commit 82b94b5

Browse files
feat: support custom function routes (#5954)
* feat: support custom function paths * chore: add comments * feat: support redirects
1 parent 30f77e0 commit 82b94b5

File tree

16 files changed

+196
-16
lines changed

16 files changed

+196
-16
lines changed

Diff for: src/commands/dev/dev.mjs

+2-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const dev = async (options, command) => {
161161
},
162162
})
163163

164-
await startFunctionsServer({
164+
const functionsRegistry = await startFunctionsServer({
165165
api,
166166
command,
167167
config,
@@ -217,6 +217,7 @@ const dev = async (options, command) => {
217217
geolocationMode: options.geo,
218218
geoCountry: options.country,
219219
accountId,
220+
functionsRegistry,
220221
})
221222

222223
if (devConfig.autoLaunch !== false) {

Diff for: src/commands/serve/serve.mjs

+3-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const serve = async (options, command) => {
9393
options,
9494
})
9595

96-
await startFunctionsServer({
96+
const functionsRegistry = await startFunctionsServer({
9797
api,
9898
command,
9999
config,
@@ -132,7 +132,9 @@ const serve = async (options, command) => {
132132
addonsUrls,
133133
config,
134134
configPath: configPathOverride,
135+
debug: options.debug,
135136
env,
137+
functionsRegistry,
136138
geolocationMode: options.geo,
137139
geoCountry: options.country,
138140
getUpdatedConfig,

Diff for: src/lib/functions/netlify-function.mjs

+22
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,28 @@ export default class NetlifyFunction {
158158
}
159159
}
160160

161+
async matchURLPath(rawPath) {
162+
await this.buildQueue
163+
164+
const path = (rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath).toLowerCase()
165+
const { routes = [] } = this.buildData
166+
const isMatch = routes.some(({ expression, literal }) => {
167+
if (literal !== undefined) {
168+
return path === literal
169+
}
170+
171+
if (expression !== undefined) {
172+
const regex = new RegExp(expression)
173+
174+
return regex.test(path)
175+
}
176+
177+
return false
178+
})
179+
180+
return isMatch
181+
}
182+
161183
get url() {
162184
// This line fixes the issue here https://github.com/netlify/cli/issues/4116
163185
// Not sure why `settings.port` was used here nor does a valid reference exist.

Diff for: src/lib/functions/registry.mjs

+10
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ export class FunctionsRegistry {
122122
return this.functions.get(name)
123123
}
124124

125+
async getFunctionForURLPath(urlPath) {
126+
for (const func of this.functions.values()) {
127+
const isMatch = await func.matchURLPath(urlPath)
128+
129+
if (isMatch) {
130+
return func
131+
}
132+
}
133+
}
134+
125135
async registerFunction(name, funcBeforeHook) {
126136
const { runtime } = funcBeforeHook
127137

Diff for: src/lib/functions/runtimes/js/builders/zisi.mjs

+2-1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const buildFunction = async ({
5858
includedFiles,
5959
inputs,
6060
path: functionPath,
61+
routes,
6162
runtimeAPIVersion,
6263
schedule,
6364
} = await memoizedBuild({
@@ -81,7 +82,7 @@ const buildFunction = async ({
8182

8283
clearFunctionsCache(targetDirectory)
8384

84-
return { buildPath, includedFiles, runtimeAPIVersion, srcFiles, schedule }
85+
return { buildPath, includedFiles, routes, runtimeAPIVersion, srcFiles, schedule }
8586
}
8687

8788
/**

Diff for: src/lib/functions/server.mjs

+19-5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import jwtDecode from 'jwt-decode'
77

88
import { NETLIFYDEVERR, NETLIFYDEVLOG, error as errorExit, log } from '../../utils/command-helpers.mjs'
99
import { CLOCKWORK_USERAGENT, getFunctionsDistPath, getInternalFunctionsDir } from '../../utils/functions/index.mjs'
10+
import { NFFunctionName } from '../../utils/headers.mjs'
1011
import { headers as efHeaders } from '../edge-functions/headers.mjs'
1112
import { getGeoLocation } from '../geo-location.mjs'
1213

@@ -55,9 +56,20 @@ export const createHandler = function (options) {
5556
const { functionsRegistry } = options
5657

5758
return async function handler(request, response) {
58-
// handle proxies without path re-writes (http-servr)
59-
const cleanPath = request.path.replace(/^\/.netlify\/(functions|builders)/, '')
60-
const functionName = cleanPath.split('/').find(Boolean)
59+
// If this header is set, it means we've already matched a function and we
60+
// can just grab its name directly. We delete the header from the request
61+
// because we don't want to expose it to user code.
62+
let functionName = request.header(NFFunctionName)
63+
delete request.headers[NFFunctionName]
64+
65+
// If we didn't match a function with a custom route, let's try to match
66+
// using the fixed URL format.
67+
if (!functionName) {
68+
const cleanPath = request.path.replace(/^\/.netlify\/(functions|builders)/, '')
69+
70+
functionName = cleanPath.split('/').find(Boolean)
71+
}
72+
6173
const func = functionsRegistry.get(functionName)
6274

6375
if (func === undefined) {
@@ -231,7 +243,7 @@ const getFunctionsServer = (options) => {
231243
* @param {*} options.site
232244
* @param {string} options.siteUrl
233245
* @param {*} options.timeouts
234-
* @returns
246+
* @returns {Promise<import('./registry.mjs').FunctionsRegistry | undefined>}
235247
*/
236248
export const startFunctionsServer = async (options) => {
237249
const { capabilities, command, config, debug, loadDistFunctions, settings, site, siteUrl, timeouts } = options
@@ -272,9 +284,11 @@ export const startFunctionsServer = async (options) => {
272284

273285
await functionsRegistry.scan(functionsDirectories)
274286

275-
const server = await getFunctionsServer(Object.assign(options, { functionsRegistry }))
287+
const server = getFunctionsServer(Object.assign(options, { functionsRegistry }))
276288

277289
await startWebServer({ server, settings, debug })
290+
291+
return functionsRegistry
278292
}
279293

280294
/**

Diff for: src/utils/headers.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ const getErrorMessage = function ({ message }) {
4646
return message
4747
}
4848

49+
export const NFFunctionName = 'x-nf-function-name'
4950
export const NFRequestID = 'x-nf-request-id'

Diff for: src/utils/proxy-server.mjs

+3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const generateInspectSettings = (edgeInspect, edgeInspectBrk) => {
5252
* @param {*} params.siteInfo
5353
* @param {string} params.projectDir
5454
* @param {import('./state-config.mjs').default} params.state
55+
* @param {import('../lib/functions/registry.mjs').FunctionsRegistry=} params.functionsRegistry
5556
* @returns
5657
*/
5758
export const startProxyServer = async ({
@@ -61,6 +62,7 @@ export const startProxyServer = async ({
6162
configPath,
6263
debug,
6364
env,
65+
functionsRegistry,
6466
geoCountry,
6567
geolocationMode,
6668
getUpdatedConfig,
@@ -78,6 +80,7 @@ export const startProxyServer = async ({
7880
configPath: configPath || site.configPath,
7981
debug,
8082
env,
83+
functionsRegistry,
8184
geolocationMode,
8285
geoCountry,
8386
getUpdatedConfig,

Diff for: src/utils/proxy.mjs

+52-8
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import renderErrorTemplate from '../lib/render-error-template.mjs'
3131

3232
import { NETLIFYDEVLOG, NETLIFYDEVWARN, log, chalk } from './command-helpers.mjs'
3333
import createStreamPromise from './create-stream-promise.mjs'
34-
import { headersForPath, parseHeaders, NFRequestID } from './headers.mjs'
34+
import { headersForPath, parseHeaders, NFFunctionName, NFRequestID } from './headers.mjs'
3535
import { generateRequestID } from './request-id.mjs'
3636
import { createRewriter, onChanges } from './rules-proxy.mjs'
3737
import { signRedirect } from './sign-redirect.mjs'
@@ -181,7 +181,7 @@ const alternativePathsFor = function (url) {
181181
return paths
182182
}
183183

184-
const serveRedirect = async function ({ env, match, options, proxy, req, res, siteInfo }) {
184+
const serveRedirect = async function ({ env, functionsRegistry, match, options, proxy, req, res, siteInfo }) {
185185
if (!match) return proxy.web(req, res, options)
186186

187187
options = options || req.proxyOptions || {}
@@ -214,6 +214,7 @@ const serveRedirect = async function ({ env, match, options, proxy, req, res, si
214214
if (isFunction(options.functionsPort, req.url)) {
215215
return proxy.web(req, res, { target: options.functionsServer })
216216
}
217+
217218
const urlForAddons = getAddonUrl(options.addonsUrls, req)
218219
if (urlForAddons) {
219220
return handleAddonUrl({ req, res, addonUrl: urlForAddons })
@@ -327,22 +328,28 @@ const serveRedirect = async function ({ env, match, options, proxy, req, res, si
327328
return proxy.web(req, res, { target: options.functionsServer })
328329
}
329330

331+
const functionWithCustomRoute = functionsRegistry && (await functionsRegistry.getFunctionForURLPath(destURL))
330332
const destStaticFile = await getStatic(dest.pathname, options.publicFolder)
331333
let statusValue
332-
if (match.force || (!staticFile && ((!options.framework && destStaticFile) || isInternal(destURL)))) {
334+
if (
335+
match.force ||
336+
(!staticFile && ((!options.framework && destStaticFile) || isInternal(destURL) || functionWithCustomRoute))
337+
) {
333338
req.url = destStaticFile ? destStaticFile + dest.search : destURL
334339
const { status } = match
335340
statusValue = status
336341
console.log(`${NETLIFYDEVLOG} Rewrote URL to`, req.url)
337342
}
338343

339-
if (isFunction(options.functionsPort, req.url)) {
344+
if (isFunction(options.functionsPort, req.url) || functionWithCustomRoute) {
345+
const functionHeaders = functionWithCustomRoute ? { [NFFunctionName]: functionWithCustomRoute.name } : {}
340346
const url = reqToURL(req, originalURL)
341347
req.headers['x-netlify-original-pathname'] = url.pathname
342348
req.headers['x-netlify-original-search'] = url.search
343349

344-
return proxy.web(req, res, { target: options.functionsServer })
350+
return proxy.web(req, res, { headers: functionHeaders, target: options.functionsServer })
345351
}
352+
346353
const addonUrl = getAddonUrl(options.addonsUrls, req)
347354
if (addonUrl) {
348355
return handleAddonUrl({ req, res, addonUrl })
@@ -434,12 +441,22 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port,
434441
}
435442

436443
if (proxyRes.statusCode === 404 || proxyRes.statusCode === 403) {
444+
// If a request for `/path` has failed, we'll a few variations like
445+
// `/path/index.html` to mimic the CDN behavior.
437446
if (req.alternativePaths && req.alternativePaths.length !== 0) {
438447
req.url = req.alternativePaths.shift()
439448
return proxy.web(req, res, req.proxyOptions)
440449
}
450+
451+
// The request has failed but we might still have a matching redirect
452+
// rule (without `force`) that should kick in. This is how we mimic the
453+
// file shadowing behavior from the CDN.
441454
if (req.proxyOptions && req.proxyOptions.match) {
442455
return serveRedirect({
456+
// We don't want to match functions at this point because any redirects
457+
// to functions will have already been processed, so we don't supply a
458+
// functions registry to `serveRedirect`.
459+
functionsRegistry: null,
443460
req,
444461
res,
445462
proxy: handlers,
@@ -453,7 +470,19 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port,
453470

454471
if (req.proxyOptions.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
455472
req.url = proxyRes.headers.location
456-
return serveRedirect({ req, res, proxy: handlers, match: null, options: req.proxyOptions, siteInfo, env })
473+
return serveRedirect({
474+
// We don't want to match functions at this point because any redirects
475+
// to functions will have already been processed, so we don't supply a
476+
// functions registry to `serveRedirect`.
477+
functionsRegistry: null,
478+
req,
479+
res,
480+
proxy: handlers,
481+
match: null,
482+
options: req.proxyOptions,
483+
siteInfo,
484+
env,
485+
})
457486
}
458487

459488
const responseData = []
@@ -551,7 +580,7 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port,
551580
}
552581

553582
const onRequest = async (
554-
{ addonsUrls, edgeFunctionsProxy, env, functionsServer, proxy, rewriter, settings, siteInfo },
583+
{ addonsUrls, edgeFunctionsProxy, env, functionsRegistry, functionsServer, proxy, rewriter, settings, siteInfo },
555584
req,
556585
res,
557586
) => {
@@ -565,9 +594,22 @@ const onRequest = async (
565594
return proxy.web(req, res, { target: edgeFunctionsProxyURL })
566595
}
567596

597+
// Does the request match a function on the fixed URL path?
568598
if (isFunction(settings.functionsPort, req.url)) {
569599
return proxy.web(req, res, { target: functionsServer })
570600
}
601+
602+
// Does the request match a function on a custom URL path?
603+
const functionMatch = functionsRegistry ? await functionsRegistry.getFunctionForURLPath(req.url) : null
604+
605+
if (functionMatch) {
606+
// Setting an internal header with the function name so that we don't
607+
// have to match the URL again in the functions server.
608+
const headers = { [NFFunctionName]: functionMatch.name }
609+
610+
return proxy.web(req, res, { headers, target: functionsServer })
611+
}
612+
571613
const addonUrl = getAddonUrl(addonsUrls, req)
572614
if (addonUrl) {
573615
return handleAddonUrl({ req, res, addonUrl })
@@ -591,7 +633,7 @@ const onRequest = async (
591633
// We don't want to generate an ETag for 3xx redirects.
592634
req[shouldGenerateETag] = ({ statusCode }) => statusCode < 300 || statusCode >= 400
593635

594-
return serveRedirect({ req, res, proxy, match, options, siteInfo, env })
636+
return serveRedirect({ req, res, proxy, match, options, siteInfo, env, functionsRegistry })
595637
}
596638

597639
// The request will be served by the framework server, which means we want to
@@ -628,6 +670,7 @@ export const startProxy = async function ({
628670
configPath,
629671
debug,
630672
env,
673+
functionsRegistry,
631674
geoCountry,
632675
geolocationMode,
633676
getUpdatedConfig,
@@ -681,6 +724,7 @@ export const startProxy = async function ({
681724
rewriter,
682725
settings,
683726
addonsUrls,
727+
functionsRegistry,
684728
functionsServer,
685729
edgeFunctionsProxy,
686730
siteInfo,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default async (req) => new Response(`With expression path: ${req.url}`)
2+
3+
export const config = {
4+
path: '/products/:sku',
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default async (req) => new Response(`With literal path: ${req.url}`)
2+
3+
export const config = {
4+
path: '/products',
5+
}
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,27 @@
1+
[build]
2+
publish = "public"
3+
14
[functions]
25
directory = "functions"
6+
7+
[[redirects]]
8+
force = true
9+
from = "/v2-to-legacy-with-force"
10+
status = 200
11+
to = "/.netlify/functions/custom-path-literal"
12+
13+
[[redirects]]
14+
from = "/v2-to-legacy-without-force"
15+
status = 200
16+
to = "/.netlify/functions/custom-path-literal"
17+
18+
[[redirects]]
19+
force = true
20+
from = "/v2-to-custom-with-force"
21+
status = 200
22+
to = "/products"
23+
24+
[[redirects]]
25+
from = "/v2-to-custom-without-force"
26+
status = 200
27+
to = "/products"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/products from origin
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/v2-to-custom-without-force from origin
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/v2-to-legacy-without-force from origin

0 commit comments

Comments
 (0)