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

src/commands/dev/dev.mjs

Lines changed: 2 additions & 1 deletion
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) {

src/commands/serve/serve.mjs

Lines changed: 3 additions & 1 deletion
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,

src/lib/functions/netlify-function.mjs

Lines changed: 22 additions & 0 deletions
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.

src/lib/functions/registry.mjs

Lines changed: 10 additions & 0 deletions
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

src/lib/functions/runtimes/js/builders/zisi.mjs

Lines changed: 2 additions & 1 deletion
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
/**

src/lib/functions/server.mjs

Lines changed: 19 additions & 5 deletions
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
/**

src/utils/headers.mjs

Lines changed: 1 addition & 0 deletions
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'

src/utils/proxy-server.mjs

Lines changed: 3 additions & 0 deletions
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,

src/utils/proxy.mjs

Lines changed: 52 additions & 8 deletions
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,
Lines changed: 5 additions & 0 deletions
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+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy