Repository: netlify/cli
Pull request #5804 (2023-06-14 14:50:24)

feat: add support for new functions API
🎉 Thanks for submitting a pull request! 🎉

#### Summary

Fixes https://github.com/netlify/pod-dev-foundations/issues/533

This implements a worker pool for function requests. Every lambda function is executed in a separate worker and the worker afterward restarted. This is done because we cannot otherwise invalidate the `import()` cache.

Typescript wasn't tested for v2 functions, because zisi does not yet support TS with NFT parser.


Missing

- [x] console.log in functions only sometimes works
- [x] debugging does not work in v2 functions with workers. funny enough it works in v1 functions with workers.

I also discovered this bug in tinypool: https://github.com/tinylibs/tinypool/pull/60




github-actions[bot]: ## 📊 Benchmark results

*Comparing with 9b6ba350f08cfab5c3f5c41bd56b94bdee7802f7*

- **Dependency count**: 1,221 (no change)
- **Package size**: 233 MB ⬆️ **0.01% increase** vs. 9b6ba350f08cfab5c3f5c41bd56b94bdee7802f7
<!--delta:post-package-size@{}-->
Skn0tt: > Can you confirm that [debugging functions](https://cli.netlify.com/vscode/) still works, even when they run in a worker?

Debugging currently doesn't work, and it looks like that's because we don't emit a sourcemap for the .mjs file. I'll see what we can do.
Skn0tt: Okay, so the underlying problem for debugging is that we're using the `none` bundler for the V2 API, which doesn't have support for sourcemaps: 

https://github.com/netlify/cli/blob/71cb338c5347b86d9f97bea49ed45b128b9171e6/src/lib/functions/runtimes/js/builders/zisi.mjs#L124

I tried switching it to `esbuild` or `nft`, but that didn't help. I'd say we ship this without debugging support for now, and address this in a follow-up. Not sure how hard it is to add sourcemap support to the `none` bundler 🤔 
eduardoboucas: @Skn0tt Just to confirm: debugging will continue to work the same for existing functions, just not for functions using the V2 syntax, because existing functions will continue to use the existing invocation mechanism and only the V2 ones will use worker threads. Is that right?
Skn0tt: In my test, debugging continued to work for non-V2 functions, since ZISI emits a sourcemap for them. It won't work for V2 syntax, because we're not emitting a sourcemap. I was able to place a breakpoint in the compiled `.netlify/functions/serve` code and debugging worked just fine, so I don't think the Worker Threads are a problem.
Skn0tt: I'm unsure what's up with the timouting tests. Will take another look next week.
Skn0tt: The timeouting tests were fixed in https://github.com/netlify/cli/pull/5804/commits/1df39f719d599d3386db21e2d004f9e2663f3fb6.
Skn0tt: https://github.com/netlify/cli/pull/5804/commits/efacbd6509f7d9d800fd7fb8a2bf26f63d1da1c0 adds support for debugging by using `esbuild` as the bundler. This emits sourcemaps, so debugging works again. It also decreases performance, so I'd like to come up with a simpler solution if possible.

The reason we need sourcemaps in the first place is that ZISI copies over the source files into the `.netlify/function-serve` directory, and then imports them locally:

https://github.com/netlify/zip-it-and-ship-it/blob/6901bb20c94963aaf23266889ede0cf03627526b/src/runtimes/node/utils/entry_file.ts#L34
Generates this `.neltify/functions-serve/ping/__netlify_entry_point.mjs`:

```js
import * as func from "./src/ping.mjs";
import { getLambdaHandler } from "./___netlify-bootstrap.mjs";
export const handler = getLambdaHandler(func);
```

What if we add a "local dev mode" where the generated file is this instead:

```js
import * as func from ".../../netlify/functions/ping.mjs";
import { getLambdaHandler } from "./___netlify-bootstrap.mjs";
export const handler = getLambdaHandler(func);
```

That way, we wouldn't need any source map generation step.

Patch:
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 681f131c..ef05e36a 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -25,6 +25,7 @@ module.exports = {
     'no-process-exit': 0,
     'fp/no-loops': 'off',
     'import/max-dependencies': 0,
+    'import/no-dynamic-require': 0,
     'import/extensions': [2, 'ignorePackages'],
     'n/no-process-exit': 0,
     'n/no-sync': 0,
diff --git a/package-lock.json b/package-lock.json
index fffa3802..d82cab2a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
         "@netlify/edge-bundler": "8.16.2",
         "@netlify/framework-info": "9.8.10",
         "@netlify/local-functions-proxy": "1.1.1",
+        "@netlify/serverless-functions-api": "1.5.1",
         "@netlify/zip-it-and-ship-it": "9.10.0",
         "@octokit/rest": "19.0.13",
         "@skn0tt/lambda-local": "2.0.3",
@@ -128,6 +129,7 @@
         "@netlify/functions": "1.6.0",
         "@types/fs-extra": "11.0.1",
         "@types/prettyjson": "0.0.30",
+        "@types/semver": "7.5.0",
         "@vitest/coverage-c8": "0.31.4",
         "ava": "4.3.3",
         "c8": "7.14.0",
@@ -4915,6 +4917,12 @@
       "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz",
       "integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g=="
     },
+    "node_modules/@types/semver": {
+      "version": "7.5.0",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
+      "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
+      "dev": true
+    },
     "node_modules/@types/serve-static": {
       "version": "1.13.10",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
@@ -25800,6 +25808,12 @@
       "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz",
       "integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g=="
     },
+    "@types/semver": {
+      "version": "7.5.0",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
+      "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
+      "dev": true
+    },
     "@types/serve-static": {
       "version": "1.13.10",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
diff --git a/package.json b/package.json
index 754ad3e2..4323846b 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,7 @@
     "@netlify/edge-bundler": "8.16.2",
     "@netlify/framework-info": "9.8.10",
     "@netlify/local-functions-proxy": "1.1.1",
+    "@netlify/serverless-functions-api": "1.5.1",
     "@netlify/zip-it-and-ship-it": "9.10.0",
     "@octokit/rest": "19.0.13",
     "@skn0tt/lambda-local": "2.0.3",
@@ -190,6 +191,7 @@
     "@netlify/functions": "1.6.0",
     "@types/fs-extra": "11.0.1",
     "@types/prettyjson": "0.0.30",
+    "@types/semver": "7.5.0",
     "@vitest/coverage-c8": "0.31.4",
     "ava": "4.3.3",
     "c8": "7.14.0",
diff --git a/src/commands/dev/dev.mjs b/src/commands/dev/dev.mjs
index b9168ca0..ecb86fc7 100644
--- a/src/commands/dev/dev.mjs
+++ b/src/commands/dev/dev.mjs
@@ -172,6 +172,10 @@ const dev = async (options, command) => {
     siteUrl,
     capabilities,
     timeouts,
+    geolocationMode: options.geo,
+    geoCountry: options.country,
+    offline: options.offline,
+    state,
   })
 
   // Try to add `.netlify` to `.gitignore`.
@@ -198,8 +202,6 @@ const dev = async (options, command) => {
     configPath: configPathOverride,
     debug: options.debug,
     env,
-    geolocationMode: options.geo,
-    geoCountry: options.country,
     getUpdatedConfig,
     inspectSettings,
     offline: options.offline,
@@ -207,6 +209,8 @@ const dev = async (options, command) => {
     site,
     siteInfo,
     state,
+    geolocationMode: options.geo,
+    geoCountry: options.country,
   })
 
   if (devConfig.autoLaunch !== false) {
diff --git a/src/commands/functions/functions-create.mjs b/src/commands/functions/functions-create.mjs
index 115173df..e43eeda7 100644
--- a/src/commands/functions/functions-create.mjs
+++ b/src/commands/functions/functions-create.mjs
@@ -100,7 +100,6 @@ const formatRegistryArrayForInquirer = async function (lang, funcType) {
       .filter((folderName) => !folderName.endsWith('.md'))
       .map(async (folderName) => {
         const templatePath = path.join(templatesDir, lang, folderName, '.netlify-function-template.mjs')
-        // eslint-disable-next-line import/no-dynamic-require
         const template = await import(pathToFileURL(templatePath))
 
         return template.default
@@ -362,7 +361,6 @@ const downloadFromURL = async function (command, options, argumentName, function
   if (await fileExistsAsync(fnTemplateFile)) {
     const {
       default: { addons = [], onComplete },
-      // eslint-disable-next-line import/no-dynamic-require
     } = await import(pathToFileURL(fnTemplateFile).href)
 
     await installAddons(command, addons, path.resolve(fnFolder))
@@ -387,7 +385,6 @@ const getNpmInstallPackages = (existingPackages = {}, neededPackages = {}) =>
 // we don't do this check, we may be upgrading the version of a module used in
 // another part of the project, which we don't want to do.
 const installDeps = async ({ functionPackageJson, functionPath, functionsDir }) => {
-  // eslint-disable-next-line import/no-dynamic-require
   const { dependencies: functionDependencies, devDependencies: functionDevDependencies } = require(functionPackageJson)
   const sitePackageJson = await findUp('package.json', { cwd: functionsDir })
   const npmInstallFlags = ['--no-audit', '--no-fund']
@@ -401,7 +398,6 @@ const installDeps = async ({ functionPackageJson, functionPath, functionsDir })
     return
   }
 
-  // eslint-disable-next-line import/no-dynamic-require
   const { dependencies: siteDependencies, devDependencies: siteDevDependencies } = require(sitePackageJson)
   const dependencies = getNpmInstallPackages(siteDependencies, functionDependencies)
   const devDependencies = getNpmInstallPackages(siteDevDependencies, functionDevDependencies)
diff --git a/src/commands/functions/functions-invoke.mjs b/src/commands/functions/functions-invoke.mjs
index 92696415..6554a454 100644
--- a/src/commands/functions/functions-invoke.mjs
+++ b/src/commands/functions/functions-invoke.mjs
@@ -68,7 +68,7 @@ const processPayloadFromFlag = function (payloadString) {
     if (pathexists) {
       try {
         // there is code execution potential here
-        // eslint-disable-next-line import/no-dynamic-require
+
         payload = require(payloadpath)
         return payload
       } catch (error_) {
diff --git a/src/commands/functions/functions-serve.mjs b/src/commands/functions/functions-serve.mjs
index c14ceb3a..6cde4a93 100644
--- a/src/commands/functions/functions-serve.mjs
+++ b/src/commands/functions/functions-serve.mjs
@@ -13,7 +13,7 @@ const DEFAULT_PORT = 9999
  * @param {import('../base-command.mjs').default} command
  */
 const functionsServe = async (options, command) => {
-  const { api, config, site, siteInfo } = command.netlify
+  const { api, config, site, siteInfo, state } = command.netlify
 
   const functionsDir = getFunctionsDir({ options, config }, join('netlify', 'functions'))
   let { env } = command.netlify.cachedConfig
@@ -48,6 +48,10 @@ const functionsServe = async (options, command) => {
     timeouts,
     functionsPrefix: '/.netlify/functions/',
     buildersPrefix: '/.netlify/builders/',
+    geolocationMode: options.geo,
+    geoCountry: options.country,
+    offline: options.offline,
+    state,
   })
 }
 
diff --git a/src/commands/recipes/common.mjs b/src/commands/recipes/common.mjs
index abe88fbb..4da0ff84 100644
--- a/src/commands/recipes/common.mjs
+++ b/src/commands/recipes/common.mjs
@@ -8,7 +8,7 @@ export const getRecipe = async (name) => {
   const recipePath = resolve(directoryPath, '../../recipes', name, 'index.mjs')
 
   // windows needs a URL for absolute paths
-  // eslint-disable-next-line import/no-dynamic-require
+
   const recipe = await import(pathToFileURL(recipePath).href)
 
   return recipe
@@ -22,7 +22,7 @@ export const listRecipes = async () => {
       const recipePath = join(recipesPath, name, 'index.mjs')
 
       // windows needs a URL for absolute paths
-      // eslint-disable-next-line import/no-dynamic-require
+
       const recipe = await import(pathToFileURL(recipePath).href)
 
       return {
diff --git a/src/commands/serve/serve.mjs b/src/commands/serve/serve.mjs
index d1d1b0b0..3d5017f5 100644
--- a/src/commands/serve/serve.mjs
+++ b/src/commands/serve/serve.mjs
@@ -101,6 +101,10 @@ const serve = async (options, command) => {
     siteUrl,
     capabilities,
     timeouts,
+    geolocationMode: options.geo,
+    geoCountry: options.country,
+    offline: options.offline,
+    state,
   })
 
   // Try to add `.netlify` to `.gitignore`.
diff --git a/src/lib/functions/runtimes/js/builders/zisi.mjs b/src/lib/functions/runtimes/js/builders/zisi.mjs
index 8803b461..a3634141 100644
--- a/src/lib/functions/runtimes/js/builders/zisi.mjs
+++ b/src/lib/functions/runtimes/js/builders/zisi.mjs
@@ -22,11 +22,25 @@ const addFunctionsConfigDefaults = (config) => ({
   },
 })
 
-const buildFunction = async ({ cache, config, directory, func, hasTypeModule, projectRoot, targetDirectory }) => {
+/**
+ * @param {object} params
+ * @param {import("@netlify/zip-it-and-ship-it/dist/feature_flags.js").FeatureFlags} params.featureFlags
+ */
+const buildFunction = async ({
+  cache,
+  config,
+  directory,
+  featureFlags,
+  func,
+  hasTypeModule,
+  projectRoot,
+  targetDirectory,
+}) => {
   const zipOptions = {
     archiveFormat: 'none',
     basePath: projectRoot,
     config,
+    featureFlags: { ...featureFlags, zisi_functions_api_v2: true },
   }
   const functionDirectory = path.dirname(func.mainFile)
 
@@ -42,9 +56,11 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr
   // this case, we use `mainFile` as the function path of `zipFunction`.
   const entryPath = functionDirectory === directory ? func.mainFile : functionDirectory
   const {
+    entryFilename,
     includedFiles,
     inputs,
     path: functionPath,
+    runtimeAPIVersion,
     schedule,
   } = await memoizedBuild({
     cache,
@@ -52,7 +68,7 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr
     command: () => zipFunction(entryPath, targetDirectory, zipOptions),
   })
   const srcFiles = inputs.filter((inputPath) => !inputPath.includes(`${path.sep}node_modules${path.sep}`))
-  const buildPath = path.join(functionPath, `${func.name}.js`)
+  const buildPath = path.join(functionPath, entryFilename)
 
   // some projects include a package.json with "type=module", forcing Node to interpret every descending file
   // as ESM. ZISI outputs CJS, so we emit an overriding directive into the output directory.
@@ -67,7 +83,7 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr
 
   clearFunctionsCache(targetDirectory)
 
-  return { buildPath, includedFiles, srcFiles, schedule }
+  return { buildPath, includedFiles, runtimeAPIVersion, srcFiles, schedule }
 }
 
 /**
@@ -76,14 +92,14 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr
  * @param {string} params.mainFile
  * @param {string} params.projectRoot
  */
-export const parseForSchedule = async ({ config, mainFile, projectRoot }) => {
+export const parseFunctionForMetadata = async ({ config, mainFile, projectRoot }) => {
   const { listFunction } = await import('@netlify/zip-it-and-ship-it')
-  const listedFunction = await listFunction(mainFile, {
+
+  return await listFunction(mainFile, {
     config: netlifyConfigToZisiConfig({ config, projectRoot }),
+    featureFlags: { zisi_functions_api_v2: true },
     parseISC: true,
   })
-
-  return listedFunction && listedFunction.schedule
 }
 
 // Clears the cache for any files inside the directory from which functions are
@@ -109,26 +125,44 @@ const getTargetDirectory = async ({ errorExit }) => {
 const netlifyConfigToZisiConfig = ({ config, projectRoot }) =>
   addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig: config.functions, projectRoot }))
 
-export default async function handler({ config, directory, errorExit, func, projectRoot }) {
+export default async function handler({ config, directory, errorExit, func, metadata, projectRoot }) {
   const functionsConfig = netlifyConfigToZisiConfig({ config, projectRoot })
 
   const packageJson = await readPackageUp(func.mainFile)
   const hasTypeModule = packageJson && packageJson.packageJson.type === 'module'
 
-  // We must use esbuild for certain file extensions.
-  const mustTranspile = ['.mjs', '.ts'].includes(path.extname(func.mainFile))
-  const mustUseEsbuild = hasTypeModule || mustTranspile
-
-  if (mustUseEsbuild && !functionsConfig['*'].nodeBundler) {
-    functionsConfig['*'].nodeBundler = 'esbuild'
-  }
-
-  // TODO: Resolve functions config globs so that we can check for the bundler
-  // on a per-function basis.
-  const isUsingEsbuild = ['esbuild_zisi', 'esbuild'].includes(functionsConfig['*'].nodeBundler)
-
-  if (!isUsingEsbuild) {
-    return false
+  /** @type {import("@netlify/zip-it-and-ship-it/dist/feature_flags.js").FeatureFlags} */
+  const featureFlags = {}
+
+  if (metadata.runtimeAPIVersion === 2) {
+    // For TypeScript we use NFT, otherwise we leave the file untouched with the `none` bundler
+    const isTypescript = ['.ts', '.mts', '.cts'].includes(path.extname(func.mainFile))
+
+    if (isTypescript) {
+      functionsConfig['*'].nodeBundler = 'nft'
+    } else {
+      // using esbuild is less performant than `none`, but it emits sourcemaps and thus
+      // enables debugging functions
+      functionsConfig['*'].nodeBundler = 'esbuild'
+      featureFlags.zisi_pure_esm = true
+      featureFlags.zisi_pure_esm_mjs = true
+    }
+  } else {
+    // We must use esbuild for certain file extensions.
+    const mustTranspile = ['.mjs', '.ts', '.mts', '.cts'].includes(path.extname(func.mainFile))
+    const mustUseEsbuild = hasTypeModule || mustTranspile
+
+    if (mustUseEsbuild && !functionsConfig['*'].nodeBundler) {
+      functionsConfig['*'].nodeBundler = 'esbuild'
+    }
+
+    // TODO: Resolve functions config globs so that we can check for the bundler
+    // on a per-function basis.
+    const isUsingEsbuild = ['esbuild_zisi', 'esbuild'].includes(functionsConfig['*'].nodeBundler)
+
+    if (!isUsingEsbuild) {
+      return false
+    }
   }
 
   // Enable source map support.
@@ -138,7 +172,16 @@ export default async function handler({ config, directory, errorExit, func, proj
 
   return {
     build: ({ cache = {} }) =>
-      buildFunction({ cache, config: functionsConfig, directory, func, projectRoot, targetDirectory, hasTypeModule }),
+      buildFunction({
+        cache,
+        config: functionsConfig,
+        directory,
+        func,
+        projectRoot,
+        targetDirectory,
+        hasTypeModule,
+        featureFlags,
+      }),
     builderName: 'zip-it-and-ship-it',
     target: targetDirectory,
   }
diff --git a/src/lib/functions/runtimes/js/constants.mjs b/src/lib/functions/runtimes/js/constants.mjs
new file mode 100644
index 00000000..d801133d
--- /dev/null
+++ b/src/lib/functions/runtimes/js/constants.mjs
@@ -0,0 +1,1 @@
+export const SECONDS_TO_MILLISECONDS = 1000
diff --git a/src/lib/functions/runtimes/js/index.mjs b/src/lib/functions/runtimes/js/index.mjs
index f8e6f6b1..2ab96831 100644
--- a/src/lib/functions/runtimes/js/index.mjs
+++ b/src/lib/functions/runtimes/js/index.mjs
@@ -1,15 +1,17 @@
+import { createConnection } from 'net'
 import { dirname } from 'path'
+import { pathToFileURL } from 'url'
+import { Worker } from 'worker_threads'
 
 import lambdaLocal from '@skn0tt/lambda-local'
 import winston from 'winston'
 
 import detectNetlifyLambdaBuilder from './builders/netlify-lambda.mjs'
-import detectZisiBuilder, { parseForSchedule } from './builders/zisi.mjs'
+import detectZisiBuilder, { parseFunctionForMetadata } from './builders/zisi.mjs'
+import { SECONDS_TO_MILLISECONDS } from './constants.mjs'
 
 export const name = 'js'
 
-const SECONDS_TO_MILLISECONDS = 1000
-
 let netlifyLambdaDetectorCache
 
 const logger = winston.createLogger({
@@ -37,7 +39,8 @@ export const getBuildFunction = async ({ config, directory, errorExit, func, pro
     return netlifyLambdaBuilder.build
   }
 
-  const zisiBuilder = await detectZisiBuilder({ config, directory, errorExit, func, projectRoot })
+  const metadata = await parseFunctionForMetadata({ mainFile: func.mainFile, config, projectRoot })
+  const zisiBuilder = await detectZisiBuilder({ config, directory, errorExit, func, metadata, projectRoot })
 
   if (zisiBuilder) {
     return zisiBuilder.build
@@ -48,15 +51,55 @@ export const getBuildFunction = async ({ config, directory, errorExit, func, pro
   // main file otherwise.
   const functionDirectory = dirname(func.mainFile)
   const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory]
-  const schedule = await parseForSchedule({ mainFile: func.mainFile, config, projectRoot })
 
-  return () => ({ schedule, srcFiles })
+  return () => ({ schedule: metadata.schedule, srcFiles })
 }
 
+const workerURL = new URL('worker.mjs', import.meta.url)
+
 export const invokeFunction = async ({ context, event, func, timeout }) => {
+  if (func.buildData.runtimeAPIVersion !== 2) {
+    return await invokeFunctionDirectly({ context, event, func, timeout })
+  }
+
+  const workerData = {
+    clientContext: JSON.stringify(context),
+    event,
+    // If a function builder has defined a `buildPath` property, we use it.
+    // Otherwise, we'll invoke the function's main file.
+    // Because we use import() we have to use file:// URLs for Windows.
+    entryFilePath: pathToFileURL(func.buildData?.buildPath ?? func.mainFile).href,
+    timeoutMs: timeout * SECONDS_TO_MILLISECONDS,
+  }
+
+  const worker = new Worker(workerURL, { workerData })
+  return await new Promise((resolve, reject) => {
+    worker.on('message', (result) => {
+      if (result?.streamPort) {
+        const client = createConnection(
+          {
+            port: result.streamPort,
+            host: 'localhost',
+          },
+          () => {
+            result.body = client
+            resolve(result)
+          },
+        )
+        client.on('error', reject)
+      } else {
+        resolve(result)
+      }
+    })
+
+    worker.on('error', reject)
+  })
+}
+
+export const invokeFunctionDirectly = async ({ context, event, func, timeout }) => {
   // If a function builder has defined a `buildPath` property, we use it.
   // Otherwise, we'll invoke the function's main file.
-  const lambdaPath = (func.buildData && func.buildData.buildPath) || func.mainFile
+  const lambdaPath = func.buildData?.buildPath ?? func.mainFile
   const result = await lambdaLocal.execute({
     clientContext: JSON.stringify(context),
     event,
diff --git a/src/lib/functions/runtimes/js/worker.mjs b/src/lib/functions/runtimes/js/worker.mjs
new file mode 100644
index 00000000..ecb01d69
--- /dev/null
+++ b/src/lib/functions/runtimes/js/worker.mjs
@@ -0,0 +1,49 @@
+import { createServer } from 'net'
+import { isMainThread, workerData, parentPort } from 'worker_threads'
+
+import lambdaLocal from '@skn0tt/lambda-local'
+import { isStream } from 'is-stream'
+import sourceMapSupport from 'source-map-support'
+
+if (isMainThread) {
+  throw new Error(`Do not import "${import.meta.url}" in the main thread.`)
+}
+
+sourceMapSupport.install()
+
+lambdaLocal.getLogger().level = 'warn'
+
+const { clientContext, entryFilePath, event, timeoutMs } = workerData
+
+const lambdaFunc = await import(entryFilePath)
+
+const result = await lambdaLocal.execute({
+  clientContext,
+  event,
+  lambdaFunc,
+  region: 'dev',
+  timeoutMs,
+  verboseLevel: 3,
+})
+
+// When the result body is a StreamResponse
+// we open up a http server that proxies back to the main thread.
+if (result && isStream(result.body)) {
+  const { body } = result
+  delete result.body
+  await new Promise((resolve, reject) => {
+    const server = createServer((socket) => {
+      body.pipe(socket).on('end', () => server.close())
+    })
+    server.on('error', (error) => {
+      reject(error)
+    })
+    server.listen({ port: 0, host: 'localhost' }, () => {
+      const { port } = server.address()
+      result.streamPort = port
+      resolve()
+    })
+  })
+}
+
+parentPort.postMessage(result)
diff --git a/src/lib/functions/server.mjs b/src/lib/functions/server.mjs
index 38bd72b1..351df9e5 100644
--- a/src/lib/functions/server.mjs
+++ b/src/lib/functions/server.mjs
@@ -3,6 +3,8 @@ 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 { headers as efHeaders } from '../edge-functions/headers.mjs'
+import { getGeoLocation } from '../geo-location.mjs'
 
 import { handleBackgroundFunction, handleBackgroundFunctionResult } from './background.mjs'
 import { createFormSubmissionHandler } from './form-submissions-handler.mjs'
@@ -102,10 +104,15 @@ export const createHandler = function (options) {
       (prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }),
       {},
     )
-    const headers = Object.entries({ ...request.headers, 'client-ip': [remoteAddress] }).reduce(
-      (prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }),
-      {},
-    )
+
+    const geoLocation = await getGeoLocation({ ...options, mode: options.geo })
+
+    const headers = Object.entries({
+      ...request.headers,
+      'client-ip': [remoteAddress],
+      'x-nf-client-connection-ip': [remoteAddress],
+      [efHeaders.Geo]: JSON.stringify(geoLocation),
+    }).reduce((prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }), {})
     const rawQuery = new URLSearchParams(requestQuery).toString()
     const protocol = options.config?.dev?.https ? 'https' : 'http'
     const url = new URL(requestPath, `${protocol}://${request.get('host') || 'localhost'}`)
diff --git a/src/lib/functions/synchronous.mjs b/src/lib/functions/synchronous.mjs
index e2d9740a..e377a714 100644
--- a/src/lib/functions/synchronous.mjs
+++ b/src/lib/functions/synchronous.mjs
@@ -73,7 +73,7 @@ const formatLambdaLocalError = (err, acceptsHtml) =>
         errorMessage: err.errorMessage,
         trace: err.stackTrace,
       })
-    : `${err.errorType}: ${err.errorMessage}\n ${err.stackTrace.join('\n ')}`
+    : `${err.errorType}: ${err.errorMessage}\n ${err.stackTrace?.join('\n ')}`
 
 const processRenderedResponse = async (err, request) => {
   const acceptsHtml = request.headers && request.headers.accept && request.headers.accept.includes('text/html')
@@ -102,7 +102,7 @@ const validateLambdaResponse = (lambdaResponse) => {
   }
   if (!Number(lambdaResponse.statusCode)) {
     return {
-      error: `Your function response must have a numerical statusCode. You gave: $ ${lambdaResponse.statusCode}`,
+      error: `Your function response must have a numerical statusCode. You gave: ${lambdaResponse.statusCode}`,
     }
   }
   if (lambdaResponse.body && typeof lambdaResponse.body !== 'string' && !isStream(lambdaResponse.body)) {
diff --git a/src/utils/execa.mjs b/src/utils/execa.mjs
index e23d75c7..2cc82e9d 100644
--- a/src/utils/execa.mjs
+++ b/src/utils/execa.mjs
@@ -10,7 +10,6 @@ import { env } from 'process'
 let execa
 
 if (env.NETLIFY_CLI_EXECA_PATH) {
-  // eslint-disable-next-line import/no-dynamic-require
   const execaMock = await import(env.NETLIFY_CLI_EXECA_PATH)
   execa = execaMock.default
 } else {


Test patch:
diff --git a/tests/integration/130.eleventy.test.cjs b/tests/integration/130.eleventy.test.cjs
index f71cc9fa..ae48b377 100644
--- a/tests/integration/130.eleventy.test.cjs
+++ b/tests/integration/130.eleventy.test.cjs
@@ -67,6 +67,9 @@ test('functions rewrite echo without body', async (t) => {
     host: `${host}:${port}`,
     'user-agent': 'got (https://github.com/sindresorhus/got)',
     'x-forwarded-for': originalIP,
+    'x-nf-client-connection-ip': clientIP,
+    'x-nf-geo':
+      '{"city":"San Francisco","country":{"code":"US","name":"United States"},"subdivision":{"code":"CA","name":"California"},"longitude":0,"latitude":0,"timezone":"UTC"}',
   })
   t.is(requestID.length, 26)
   t.is(response.httpMethod, 'GET')
@@ -98,6 +101,9 @@ test('functions rewrite echo with body', async (t) => {
     'content-length': '10',
     'user-agent': 'got (https://github.com/sindresorhus/got)',
     'x-forwarded-for': originalIP,
+    'x-nf-client-connection-ip': clientIP,
+    'x-nf-geo':
+      '{"city":"San Francisco","country":{"code":"US","name":"United States"},"subdivision":{"code":"CA","name":"California"},"longitude":0,"latitude":0,"timezone":"UTC"}',
   })
   t.is(requestID.length, 26)
   t.is(response.httpMethod, 'POST')
@@ -119,6 +125,9 @@ test('functions echo with multiple query params', async (t) => {
     host: `${host}:${port}`,
     'user-agent': 'got (https://github.com/sindresorhus/got)',
     'x-forwarded-for': originalIP,
+    'x-nf-client-connection-ip': clientIP,
+    'x-nf-geo':
+      '{"city":"San Francisco","country":{"code":"US","name":"United States"},"subdivision":{"code":"CA","name":"California"},"longitude":0,"latitude":0,"timezone":"UTC"}',
   })
   t.is(requestID.length, 26)
   t.is(response.httpMethod, 'GET')
diff --git a/tests/integration/330.serving-functions.test.cjs b/tests/integration/330.serving-functions.test.cjs
index 0d010e96..b5bf5338 100644
--- a/tests/integration/330.serving-functions.test.cjs
+++ b/tests/integration/330.serving-functions.test.cjs
@@ -726,7 +726,7 @@ test('Serves functions that dynamically load files included in the `functions.in
         handler: async (event) => {
           const { name } = event.queryStringParameters
 
-          // eslint-disable-next-line import/no-dynamic-require, n/global-require
+          // eslint-disable-next-line n/global-require
           const { data } = require(`../files/${name}.json`)
 
           return {
diff --git a/tests/integration/400.command.dev.test.cjs b/tests/integration/400.command.dev.test.cjs
index f97e5eaf..0ca76c76 100644
--- a/tests/integration/400.command.dev.test.cjs
+++ b/tests/integration/400.command.dev.test.cjs
@@ -5,6 +5,7 @@ const process = require('process')
 const avaTest = require('ava')
 const { isCI } = require('ci-info')
 const FormData = require('form-data')
+const { gte } = require('semver')
 
 const { withDevServer } = require('./utils/dev-server.cjs')
 const got = require('./utils/got.cjs')
@@ -126,7 +127,12 @@ test('should replicate Lambda behaviour for synchronous return values', async (t
     await withDevServer({ cwd: builder.directory }, async (server) => {
       const response = await got(`${server.url}/.netlify/functions/env`, {
         throwHttpErrors: false,
+        retry: {
+          limit: 0,
+        },
       })
+
+      t.is(response.statusCode, 500)
       t.true(response.body.startsWith('no lambda response.'))
     })
   })
@@ -414,44 +420,45 @@ test('should handle multipart form data when redirecting', async (t) => {
   })
 })
 
-test('should support functions with streaming responses', async (t) => {
-  await withSiteBuilder('site-with-streaming-function', async (builder) => {
-    builder
-      .withPackageJson({ packageJson: { dependencies: { '@netlify/functions': 'latest' } } })
-      .withCommand({ command: ['npm', 'install'] })
-      .withContentFile({
-        content: `
+if (gte(process.version, '18.0.0')) {
+  test('should support functions with streaming responses', async (t) => {
+    await withSiteBuilder('site-with-streaming-function', async (builder) => {
+      builder
+        .withPackageJson({ packageJson: { dependencies: { '@netlify/functions': 'latest' } } })
+        .withCommand({ command: ['npm', 'install'] })
+        .withContentFile({
+          content: `
           const { stream } = require("@netlify/functions");
 
           class TimerSource {
             #input;
             #interval;
-          
+
             constructor(input) {
               this.#input = input;
             }
-          
+
             start(controller) {
               this.#interval = setInterval(() => {
                 const string = this.#input.shift();
-          
+
                 if (string === undefined) {
                   controller.close();
-          
+
                   clearInterval(this.#interval);
-          
+
                   return;
                 }
-          
+
                 controller.enqueue(string);
               }, 50);
             }
-          
+
             cancel() {
               clearInterval(this.#interval);
             }
           }
-          
+
           exports.handler = stream(async (event) => ({
             body: new ReadableStream(new TimerSource(["one", "two", "three"])),
             headers: {
@@ -460,29 +467,30 @@ test('should support functions with streaming responses', async (t) => {
             statusCode: 200,
           }));
       `,
-        path: 'netlify/functions/streamer.js',
-      })
+          path: 'netlify/functions/streamer.js',
+        })
 
-    await builder.buildAsync()
+      await builder.buildAsync()
 
-    await withDevServer({ cwd: builder.directory }, async (server) => {
-      const chunks = []
-      const response = got.stream(`${server.url}/.netlify/functions/streamer`)
+      await withDevServer({ cwd: builder.directory }, async (server) => {
+        const chunks = []
+        const response = got.stream(`${server.url}/.netlify/functions/streamer`)
 
-      let lastTimestamp = 0
+        let lastTimestamp = 0
 
-      response.on('data', (chunk) => {
-        const now = Date.now()
+        response.on('data', (chunk) => {
+          const now = Date.now()
 
-        t.true(now > lastTimestamp)
+          t.true(now > lastTimestamp)
 
-        lastTimestamp = now
-        chunks.push(chunk.toString())
-      })
+          lastTimestamp = now
+          chunks.push(chunk.toString())
+        })
 
-      await pause(500)
+        await pause(500)
 
-      t.deepEqual(chunks, ['one', 'two', 'three'])
+        t.deepEqual(chunks, ['one', 'two', 'three'])
+      })
     })
   })
-})
+}
diff --git a/tests/integration/630.serving-functions-go.test.cjs b/tests/integration/630.serving-functions-go.test.cjs
index 4c187d1d..d3daa654 100644
--- a/tests/integration/630.serving-functions-go.test.cjs
+++ b/tests/integration/630.serving-functions-go.test.cjs
@@ -8,7 +8,7 @@ const { withSiteBuilder } = require('./utils/site-builder.cjs')
 
 const WAIT_WRITE = 1000
 
-test.only('Updates a Go function when a file is modified', async (t) => {
+test('Updates a Go function when a file is modified', async (t) => {
   const originalBody = 'Hello, world!'
   const updatedBody = 'Hello, Netlify!'
   const [execaMock, removeExecaMock] = await createExecaMock(`
diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/brotli.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/brotli.mjs
new file mode 100644
index 00000000..cfc18c62
--- /dev/null
+++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/brotli.mjs
@@ -0,0 +1,13 @@
+import { brotliCompressSync } from "node:zlib"
+
+export default async () => {
+  const text = "What's 🍞🏄‍♀️? A breadboad!".repeat(100)
+  const buffer = new TextEncoder().encode(text)
+  const brotli = brotliCompressSync(buffer)
+  return new Response(brotli, {
+    status: 200,
+    headers: {
+      "Content-Encoding": "br",
+    }
+  })
+}
diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/context.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/context.mjs
new file mode 100644
index 00000000..3071b210
--- /dev/null
+++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/context.mjs
@@ -0,0 +1,10 @@
+export default async (_request, context) =>
+  new Response(
+    JSON.stringify({
+      ...context,
+      cookies: { foo: context.cookies.get('foo') },
+    }),
+    {
+      status: 200,
+    },
+  )
diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/log.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/log.mjs
new file mode 100644
index 00000000..e6a9ee03
--- /dev/null
+++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/log.mjs
@@ -0,0 +1,4 @@
+export default async () => {
+  console.log("🪵🪵🪵")
+  return new Response("")
+}
\ No newline at end of file
diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/ping.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/ping.mjs
new file mode 100644
index 00000000..0aa32a4a
--- /dev/null
+++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/ping.mjs
@@ -0,0 +1,4 @@
+export default async () =>
+  new Response('pong', {
+    status: 200,
+  })
diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/stream.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/stream.mjs
new file mode 100644
index 00000000..d297d84f
--- /dev/null
+++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/stream.mjs
@@ -0,0 +1,15 @@
+export default async () =>
+  new Response(
+    new ReadableStream({
+      start(controller) {
+        controller.enqueue('first chunk')
+        setTimeout(() => {
+          controller.enqueue('second chunk')
+          controller.close()
+        }, 50)
+      },
+    }),
+    {
+      status: 200,
+    },
+  )
diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/netlify.toml b/tests/integration/__fixtures__/dev-server-with-v2-functions/netlify.toml
new file mode 100644
index 00000000..5ae964ac
--- /dev/null
+++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/netlify.toml
@@ -0,0 +1,2 @@
+[functions]
+directory = "functions"
diff --git a/tests/integration/commands/dev/v2-api.test.ts b/tests/integration/commands/dev/v2-api.test.ts
new file mode 100644
index 00000000..b175c1ad
--- /dev/null
+++ b/tests/integration/commands/dev/v2-api.test.ts
@@ -0,0 +1,74 @@
+import { version } from 'process'
+
+import { gte } from 'semver'
+import { describe, expect, test } from 'vitest'
+
+import { FixtureTestContext, setupFixtureTests } from '../../utils/fixture.js'
+import got from '../../utils/got.cjs'
+
+describe.runIf(gte(version, '18.13.0'))('v2 api', () => {
+  setupFixtureTests('dev-server-with-v2-functions', { devServer: true }, () => {
+    test<FixtureTestContext>('should successfully be able to run v2 functions', async ({ devServer }) => {
+      const response = await got(`http://localhost:${devServer.port}/.netlify/functions/ping`, {
+        throwHttpErrors: false,
+        retry: { limit: 0 },
+      })
+
+      expect(response.statusCode).toBe(200)
+      expect(response.body).toBe('pong')
+    })
+
+    test<FixtureTestContext>('supports streamed responses', async ({ devServer }) => {
+      const response = await fetch(`http://localhost:${devServer.port}/.netlify/functions/stream`)
+
+      expect(response.status).toBe(200)
+
+      const reader = response.body!.getReader()
+
+      const firstChunk = await reader.read()
+      expect(new TextDecoder().decode(firstChunk.value)).toBe('first chunk')
+      expect(firstChunk.done).toBeFalsy()
+
+      const secondChunk = await reader.read()
+      expect(new TextDecoder().decode(secondChunk.value)).toBe('second chunk')
+      expect(secondChunk.done).toBeFalsy()
+
+      const thirdChunk = await reader.read()
+      expect(thirdChunk.done).toBeTruthy()
+    })
+
+    test<FixtureTestContext>('receives context', async ({ devServer }) => {
+      const response = await fetch(`http://localhost:${devServer.port}/.netlify/functions/context`, {
+        headers: {
+          Cookie: 'foo=bar;',
+        },
+      })
+
+      expect(response.status).toBe(200)
+
+      const context = await response.json()
+      expect(context.requestId).toEqual(response.headers.get('x-nf-request-id'))
+      expect(context.site.url).toEqual(`http://localhost:${devServer.port}`)
+      expect(context.server.region).toEqual('dev')
+      expect(context.ip).toEqual('::1')
+      expect(context.geo.city).toEqual('San Francisco')
+
+      expect(context.cookies).toEqual({ foo: 'bar' })
+      // this is also not set for edge-functions, TODO!
+      // expect(context.account.id).toEqual('???')
+    })
+
+    test<FixtureTestContext>('logging works', async ({ devServer }) => {
+      const response = await fetch(`http://localhost:${devServer.port}/.netlify/functions/log`)
+      expect(response.status).toBe(200)
+      expect(devServer.outputBuffer.map((buffer) => buffer.toString())).toContain('🪵🪵🪵\n')
+    })
+
+    test<FixtureTestContext>('brotli encoding works', async ({ devServer }) => {
+      const response = await fetch(`http://localhost:${devServer.port}/.netlify/functions/brotli`)
+      
+      expect(response.status).toBe(200)
+      expect(await response.text()).toBe("What's 🍞🏄‍♀️? A breadboad!".repeat(100))
+    })
+  })
+})
diff --git a/tests/unit/lib/functions/server.test.mjs b/tests/unit/lib/functions/server.test.mjs
index 873ef977..ddea16a1 100644
--- a/tests/unit/lib/functions/server.test.mjs
+++ b/tests/unit/lib/functions/server.test.mjs
@@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'
 
 import { FunctionsRegistry } from '../../../../src/lib/functions/registry.mjs'
 import { createHandler } from '../../../../src/lib/functions/server.mjs'
+import StateConfig from '../../../../src/utils/state-config.mjs'
 
 vi.mock('../../../../src/utils/command-helpers.mjs', async () => ({
   ...(await vi.importActual('../../../../src/utils/command-helpers.mjs')),
@@ -33,7 +34,7 @@ describe('createHandler', () => {
     })
     await functionsRegistry.scan([functionsDirectory])
     const app = express()
-    app.all('*', createHandler({ functionsRegistry }))
+    app.all('*', createHandler({ functionsRegistry, geo: 'mock', state: new StateConfig(projectRoot) }))
 
     return await new Promise((resolve) => {
       server = app.listen(resolve)