netlify-cli 16.1.0 → 16.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "netlify-cli",
3
3
  "description": "Netlify command line tool",
4
- "version": "16.1.0",
4
+ "version": "16.2.0",
5
5
  "author": "Netlify Inc.",
6
6
  "type": "module",
7
7
  "engines": {
@@ -44,13 +44,13 @@
44
44
  "dependencies": {
45
45
  "@bugsnag/js": "7.20.2",
46
46
  "@fastify/static": "6.10.2",
47
- "@netlify/build": "29.20.4",
48
- "@netlify/build-info": "7.7.3",
47
+ "@netlify/build": "29.20.8",
48
+ "@netlify/build-info": "7.7.4",
49
49
  "@netlify/config": "20.8.0",
50
- "@netlify/edge-bundler": "8.17.1",
50
+ "@netlify/edge-bundler": "8.19.0",
51
51
  "@netlify/local-functions-proxy": "1.1.1",
52
- "@netlify/serverless-functions-api": "1.5.2",
53
- "@netlify/zip-it-and-ship-it": "9.15.1",
52
+ "@netlify/serverless-functions-api": "1.7.3",
53
+ "@netlify/zip-it-and-ship-it": "9.17.0",
54
54
  "@octokit/rest": "19.0.13",
55
55
  "ansi-escapes": "6.2.0",
56
56
  "ansi-styles": "6.2.1",
@@ -106,7 +106,7 @@
106
106
  "isexe": "2.0.0",
107
107
  "jsonwebtoken": "9.0.1",
108
108
  "jwt-decode": "3.1.2",
109
- "lambda-local": "2.1.1",
109
+ "lambda-local": "2.1.2",
110
110
  "listr": "0.14.3",
111
111
  "locate-path": "7.2.0",
112
112
  "lodash": "4.17.21",
@@ -116,7 +116,7 @@
116
116
  "multiparty": "4.2.3",
117
117
  "netlify": "13.1.10",
118
118
  "netlify-headers-parser": "7.1.2",
119
- "netlify-redirect-parser": "14.1.3",
119
+ "netlify-redirect-parser": "14.2.0",
120
120
  "netlify-redirector": "0.4.0",
121
121
  "node-fetch": "2.6.12",
122
122
  "node-version-alias": "3.4.1",
@@ -161,7 +161,7 @@ const dev = async (options, command) => {
161
161
  },
162
162
  })
163
163
 
164
- await startFunctionsServer({
164
+ const functionsRegistry = await startFunctionsServer({
165
165
  api,
166
166
  command,
167
167
  config,
@@ -217,6 +217,7 @@ const dev = async (options, command) => {
217
217
  geolocationMode: options.geo,
218
218
  geoCountry: options.country,
219
219
  accountId,
220
+ functionsRegistry,
220
221
  })
221
222
 
222
223
  if (devConfig.autoLaunch !== false) {
@@ -93,7 +93,7 @@ const serve = async (options, command) => {
93
93
  options,
94
94
  })
95
95
 
96
- await startFunctionsServer({
96
+ const functionsRegistry = await startFunctionsServer({
97
97
  api,
98
98
  command,
99
99
  config,
@@ -132,7 +132,9 @@ const serve = async (options, command) => {
132
132
  addonsUrls,
133
133
  config,
134
134
  configPath: configPathOverride,
135
+ debug: options.debug,
135
136
  env,
137
+ functionsRegistry,
136
138
  geolocationMode: options.geo,
137
139
  geoCountry: options.country,
138
140
  getUpdatedConfig,
@@ -1,5 +1,5 @@
1
1
  import { env } from 'process'
2
2
 
3
- const latestBootstrapURL = 'https://64c264287e9cbb0008621df3--edge.netlify.com/bootstrap/index-combined.ts'
3
+ const latestBootstrapURL = 'https://64e7783fce8cfe0008496c72--edge.netlify.com/bootstrap/index-combined.ts'
4
4
 
5
5
  export const getBootstrapURL = () => env.NETLIFY_EDGE_BOOTSTRAP || latestBootstrapURL
@@ -318,23 +318,38 @@ export class EdgeFunctionsRegistry {
318
318
  functions: this.#functions,
319
319
  featureFlags,
320
320
  })
321
- const invocationMetadata = {
322
- function_config: manifest.function_config,
323
- routes: manifest.routes.map((route) => ({ function: route.function, pattern: route.pattern })),
324
- }
325
321
  const routes = [...manifest.routes, ...manifest.post_cache_routes].map((route) => ({
326
322
  ...route,
327
323
  pattern: new RegExp(route.pattern),
328
324
  }))
329
- const functionNames = routes
330
- .filter(({ pattern }) => pattern.test(urlPath))
331
- .filter(({ function: name }) => {
332
- const isExcluded = manifest.function_config[name]?.excluded_patterns?.some((pattern) =>
333
- new RegExp(pattern).test(urlPath),
334
- )
335
- return !isExcluded
336
- })
337
- .map((route) => route.function)
325
+
326
+ /** @type string[] */
327
+ const functionNames = []
328
+
329
+ /** @type number[] */
330
+ const routeIndexes = []
331
+
332
+ routes.forEach((route, index) => {
333
+ if (!route.pattern.test(urlPath)) {
334
+ return
335
+ }
336
+
337
+ const isExcluded = manifest.function_config[route.function]?.excluded_patterns?.some((pattern) =>
338
+ new RegExp(pattern).test(urlPath),
339
+ )
340
+
341
+ if (isExcluded) {
342
+ return
343
+ }
344
+
345
+ functionNames.push(route.function)
346
+ routeIndexes.push(index)
347
+ })
348
+ const invocationMetadata = {
349
+ function_config: manifest.function_config,
350
+ req_routes: routeIndexes,
351
+ routes: manifest.routes.map((route) => ({ function: route.function, path: route.path, pattern: route.pattern })),
352
+ }
338
353
  const orphanedDeclarations = this.#matchURLPathAgainstOrphanedDeclarations(urlPath)
339
354
 
340
355
  return { functionNames, invocationMetadata, orphanedDeclarations }
@@ -158,6 +158,28 @@ export default class NetlifyFunction {
158
158
  }
159
159
  }
160
160
 
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
+
161
183
  get url() {
162
184
  // This line fixes the issue here https://github.com/netlify/cli/issues/4116
163
185
  // Not sure why `settings.port` was used here nor does a valid reference exist.
@@ -122,6 +122,16 @@ export class FunctionsRegistry {
122
122
  return this.functions.get(name)
123
123
  }
124
124
 
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
+
125
135
  async registerFunction(name, funcBeforeHook) {
126
136
  const { runtime } = funcBeforeHook
127
137
 
@@ -58,6 +58,7 @@ const buildFunction = async ({
58
58
  includedFiles,
59
59
  inputs,
60
60
  path: functionPath,
61
+ routes,
61
62
  runtimeAPIVersion,
62
63
  schedule,
63
64
  } = await memoizedBuild({
@@ -81,7 +82,7 @@ const buildFunction = async ({
81
82
 
82
83
  clearFunctionsCache(targetDirectory)
83
84
 
84
- return { buildPath, includedFiles, runtimeAPIVersion, srcFiles, schedule }
85
+ return { buildPath, includedFiles, routes, runtimeAPIVersion, srcFiles, schedule }
85
86
  }
86
87
 
87
88
  /**
@@ -7,6 +7,7 @@ import jwtDecode from 'jwt-decode'
7
7
 
8
8
  import { NETLIFYDEVERR, NETLIFYDEVLOG, error as errorExit, log } from '../../utils/command-helpers.mjs'
9
9
  import { CLOCKWORK_USERAGENT, getFunctionsDistPath, getInternalFunctionsDir } from '../../utils/functions/index.mjs'
10
+ import { NFFunctionName } from '../../utils/headers.mjs'
10
11
  import { headers as efHeaders } from '../edge-functions/headers.mjs'
11
12
  import { getGeoLocation } from '../geo-location.mjs'
12
13
 
@@ -55,9 +56,20 @@ export const createHandler = function (options) {
55
56
  const { functionsRegistry } = options
56
57
 
57
58
  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
+
61
73
  const func = functionsRegistry.get(functionName)
62
74
 
63
75
  if (func === undefined) {
@@ -231,7 +243,7 @@ const getFunctionsServer = (options) => {
231
243
  * @param {*} options.site
232
244
  * @param {string} options.siteUrl
233
245
  * @param {*} options.timeouts
234
- * @returns
246
+ * @returns {Promise<import('./registry.mjs').FunctionsRegistry | undefined>}
235
247
  */
236
248
  export const startFunctionsServer = async (options) => {
237
249
  const { capabilities, command, config, debug, loadDistFunctions, settings, site, siteUrl, timeouts } = options
@@ -272,9 +284,11 @@ export const startFunctionsServer = async (options) => {
272
284
 
273
285
  await functionsRegistry.scan(functionsDirectories)
274
286
 
275
- const server = await getFunctionsServer(Object.assign(options, { functionsRegistry }))
287
+ const server = getFunctionsServer(Object.assign(options, { functionsRegistry }))
276
288
 
277
289
  await startWebServer({ server, settings, debug })
290
+
291
+ return functionsRegistry
278
292
  }
279
293
 
280
294
  /**
@@ -46,4 +46,5 @@ const getErrorMessage = function ({ message }) {
46
46
  return message
47
47
  }
48
48
 
49
+ export const NFFunctionName = 'x-nf-function-name'
49
50
  export const NFRequestID = 'x-nf-request-id'
@@ -52,6 +52,7 @@ export const generateInspectSettings = (edgeInspect, edgeInspectBrk) => {
52
52
  * @param {*} params.siteInfo
53
53
  * @param {string} params.projectDir
54
54
  * @param {import('./state-config.mjs').default} params.state
55
+ * @param {import('../lib/functions/registry.mjs').FunctionsRegistry=} params.functionsRegistry
55
56
  * @returns
56
57
  */
57
58
  export const startProxyServer = async ({
@@ -61,6 +62,7 @@ export const startProxyServer = async ({
61
62
  configPath,
62
63
  debug,
63
64
  env,
65
+ functionsRegistry,
64
66
  geoCountry,
65
67
  geolocationMode,
66
68
  getUpdatedConfig,
@@ -78,6 +80,7 @@ export const startProxyServer = async ({
78
80
  configPath: configPath || site.configPath,
79
81
  debug,
80
82
  env,
83
+ functionsRegistry,
81
84
  geolocationMode,
82
85
  geoCountry,
83
86
  getUpdatedConfig,
@@ -31,7 +31,7 @@ import renderErrorTemplate from '../lib/render-error-template.mjs'
31
31
 
32
32
  import { NETLIFYDEVLOG, NETLIFYDEVWARN, log, chalk } from './command-helpers.mjs'
33
33
  import createStreamPromise from './create-stream-promise.mjs'
34
- import { headersForPath, parseHeaders, NFRequestID } from './headers.mjs'
34
+ import { headersForPath, parseHeaders, NFFunctionName, NFRequestID } from './headers.mjs'
35
35
  import { generateRequestID } from './request-id.mjs'
36
36
  import { createRewriter, onChanges } from './rules-proxy.mjs'
37
37
  import { signRedirect } from './sign-redirect.mjs'
@@ -181,7 +181,7 @@ const alternativePathsFor = function (url) {
181
181
  return paths
182
182
  }
183
183
 
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 }) {
185
185
  if (!match) return proxy.web(req, res, options)
186
186
 
187
187
  options = options || req.proxyOptions || {}
@@ -214,6 +214,7 @@ const serveRedirect = async function ({ env, match, options, proxy, req, res, si
214
214
  if (isFunction(options.functionsPort, req.url)) {
215
215
  return proxy.web(req, res, { target: options.functionsServer })
216
216
  }
217
+
217
218
  const urlForAddons = getAddonUrl(options.addonsUrls, req)
218
219
  if (urlForAddons) {
219
220
  return handleAddonUrl({ req, res, addonUrl: urlForAddons })
@@ -327,22 +328,28 @@ const serveRedirect = async function ({ env, match, options, proxy, req, res, si
327
328
  return proxy.web(req, res, { target: options.functionsServer })
328
329
  }
329
330
 
331
+ const functionWithCustomRoute = functionsRegistry && (await functionsRegistry.getFunctionForURLPath(destURL))
330
332
  const destStaticFile = await getStatic(dest.pathname, options.publicFolder)
331
333
  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
+ ) {
333
338
  req.url = destStaticFile ? destStaticFile + dest.search : destURL
334
339
  const { status } = match
335
340
  statusValue = status
336
341
  console.log(`${NETLIFYDEVLOG} Rewrote URL to`, req.url)
337
342
  }
338
343
 
339
- if (isFunction(options.functionsPort, req.url)) {
344
+ if (isFunction(options.functionsPort, req.url) || functionWithCustomRoute) {
345
+ const functionHeaders = functionWithCustomRoute ? { [NFFunctionName]: functionWithCustomRoute.name } : {}
340
346
  const url = reqToURL(req, originalURL)
341
347
  req.headers['x-netlify-original-pathname'] = url.pathname
342
348
  req.headers['x-netlify-original-search'] = url.search
343
349
 
344
- return proxy.web(req, res, { target: options.functionsServer })
350
+ return proxy.web(req, res, { headers: functionHeaders, target: options.functionsServer })
345
351
  }
352
+
346
353
  const addonUrl = getAddonUrl(options.addonsUrls, req)
347
354
  if (addonUrl) {
348
355
  return handleAddonUrl({ req, res, addonUrl })
@@ -434,12 +441,22 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port,
434
441
  }
435
442
 
436
443
  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.
437
446
  if (req.alternativePaths && req.alternativePaths.length !== 0) {
438
447
  req.url = req.alternativePaths.shift()
439
448
  return proxy.web(req, res, req.proxyOptions)
440
449
  }
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.
441
454
  if (req.proxyOptions && req.proxyOptions.match) {
442
455
  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,
443
460
  req,
444
461
  res,
445
462
  proxy: handlers,
@@ -453,7 +470,19 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port,
453
470
 
454
471
  if (req.proxyOptions.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
455
472
  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
+ })
457
486
  }
458
487
 
459
488
  const responseData = []
@@ -551,7 +580,7 @@ const initializeProxy = async function ({ configPath, distDir, env, host, port,
551
580
  }
552
581
 
553
582
  const onRequest = async (
554
- { addonsUrls, edgeFunctionsProxy, env, functionsServer, proxy, rewriter, settings, siteInfo },
583
+ { addonsUrls, edgeFunctionsProxy, env, functionsRegistry, functionsServer, proxy, rewriter, settings, siteInfo },
555
584
  req,
556
585
  res,
557
586
  ) => {
@@ -565,9 +594,22 @@ const onRequest = async (
565
594
  return proxy.web(req, res, { target: edgeFunctionsProxyURL })
566
595
  }
567
596
 
597
+ // Does the request match a function on the fixed URL path?
568
598
  if (isFunction(settings.functionsPort, req.url)) {
569
599
  return proxy.web(req, res, { target: functionsServer })
570
600
  }
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
+
571
613
  const addonUrl = getAddonUrl(addonsUrls, req)
572
614
  if (addonUrl) {
573
615
  return handleAddonUrl({ req, res, addonUrl })
@@ -591,7 +633,7 @@ const onRequest = async (
591
633
  // We don't want to generate an ETag for 3xx redirects.
592
634
  req[shouldGenerateETag] = ({ statusCode }) => statusCode < 300 || statusCode >= 400
593
635
 
594
- return serveRedirect({ req, res, proxy, match, options, siteInfo, env })
636
+ return serveRedirect({ req, res, proxy, match, options, siteInfo, env, functionsRegistry })
595
637
  }
596
638
 
597
639
  // The request will be served by the framework server, which means we want to
@@ -628,6 +670,7 @@ export const startProxy = async function ({
628
670
  configPath,
629
671
  debug,
630
672
  env,
673
+ functionsRegistry,
631
674
  geoCountry,
632
675
  geolocationMode,
633
676
  getUpdatedConfig,
@@ -681,6 +724,7 @@ export const startProxy = async function ({
681
724
  rewriter,
682
725
  settings,
683
726
  addonsUrls,
727
+ functionsRegistry,
684
728
  functionsServer,
685
729
  edgeFunctionsProxy,
686
730
  siteInfo,