From e015262129942b9e48d007d192c68e59994c07e0 Mon Sep 17 00:00:00 2001 From: lemusthelroy Date: Wed, 15 Oct 2025 16:57:06 +0100 Subject: [PATCH 1/6] feat: patch fetch and carry lambda headers to the patch --- packages/functions/dist-dev/builder.ts | 225 ++++ packages/functions/dist-dev/config.ts | 42 + packages/functions/dist-dev/index.ts | 117 ++ packages/functions/dist-dev/lambda.ts | 65 ++ packages/functions/dist-dev/main.js | 1113 +++++++++++++++++++ packages/functions/dist-dev/worker.js | 106 ++ packages/otel/src/bootstrap/main.ts | 39 +- packages/otel/src/constants.ts | 1 + packages/otel/src/instrumentations/fetch.ts | 24 +- packages/otel/src/main.ts | 8 +- 10 files changed, 1733 insertions(+), 7 deletions(-) create mode 100644 packages/functions/dist-dev/builder.ts create mode 100644 packages/functions/dist-dev/config.ts create mode 100644 packages/functions/dist-dev/index.ts create mode 100644 packages/functions/dist-dev/lambda.ts create mode 100644 packages/functions/dist-dev/main.js create mode 100644 packages/functions/dist-dev/worker.js diff --git a/packages/functions/dist-dev/builder.ts b/packages/functions/dist-dev/builder.ts new file mode 100644 index 00000000..a1b9fb7b --- /dev/null +++ b/packages/functions/dist-dev/builder.ts @@ -0,0 +1,225 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' + +import { memoize } from '@netlify/dev-utils' +import { zipFunction, listFunction, ArchiveFormat, Config as FunctionsConfig } from '@netlify/zip-it-and-ship-it' +import { FeatureFlags } from '@netlify/zip-it-and-ship-it/dist/feature_flags.js' +import decache from 'decache' +import { readPackageUp } from 'read-package-up' +import sourceMapSupport from 'source-map-support' + +import { normalizeFunctionsConfig, TOMLFunctionConfig } from './config.js' +import { NetlifyFunction } from '../../function.js' + +import { BuildCache, BuildResult, FunctionBuilder } from '../../builder.js' + +const require = createRequire(import.meta.url) + +const serveFunctionsFolder = path.join('.netlify', 'functions-serve') + +const addFunctionsConfigDefaults = (config: FunctionsConfig): FunctionsConfig => ({ + ...config, + '*': { + nodeSourcemap: true, + ...config['*'], + }, +}) + +interface BuildFunctionOptions { + cache: BuildCache + config: FunctionsConfig + directory: string + featureFlags: Record + func: NetlifyFunction + hasTypeModule: boolean + projectRoot: string + targetDirectory: string +} + +const buildFunction = async ({ + cache, + config, + directory, + featureFlags, + func, + hasTypeModule, + projectRoot, + targetDirectory, +}: BuildFunctionOptions) => { + const zipOptions = { + archiveFormat: 'none' as ArchiveFormat, + basePath: projectRoot, + config, + featureFlags: { ...featureFlags, zisi_functions_api_v2: true } as FeatureFlags, + } + const functionDirectory = path.dirname(func.mainFile) + + // If we have a function at `functions/my-func/index.js` and we pass + // that path to `zipFunction`, it will lack the context of the whole + // functions directory and will infer the name of the function to be + // `index`, not `my-func`. Instead, we need to pass the directory of + // the function. The exception is when the function is a file at the + // root of the functions directory (e.g. `functions/my-func.js`). In + // this case, we use `mainFile` as the function path of `zipFunction`. + const entryPath = functionDirectory === directory ? func.mainFile : functionDirectory + const buildResult = await memoize({ + cache, + cacheKey: `zisi-${entryPath}`, + command: () => zipFunction(entryPath, targetDirectory, zipOptions), + }) + + if (!buildResult) { + return + } + + const { + entryFilename, + excludedRoutes, + includedFiles, + inputs, + mainFile, + outputModuleFormat, + path: functionPath, + routes, + runtimeAPIVersion, + schedule, + } = buildResult + const srcFiles = (inputs ?? []).filter((inputPath) => !inputPath.includes(`${path.sep}node_modules${path.sep}`)) + 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. + if (hasTypeModule) { + await writeFile( + path.join(functionPath, `package.json`), + JSON.stringify({ + type: 'commonjs', + }), + ) + } + + clearFunctionsCache(targetDirectory) + + return { + buildPath, + excludedRoutes, + includedFiles, + outputModuleFormat, + mainFile, + routes, + runtimeAPIVersion, + srcFiles, + schedule, + targetDirectory, + } +} + +interface ParseFunctionForMetadataOptions { + config: any + mainFile: string + projectRoot: string +} + +export const parseFunctionForMetadata = async ({ config, mainFile, projectRoot }: ParseFunctionForMetadataOptions) => + await listFunction(mainFile, { + config: netlifyConfigToZisiConfig(config.functions, projectRoot), + featureFlags: { zisi_functions_api_v2: true } as FeatureFlags, + parseISC: true, + }) + +// Clears the cache for any files inside the directory from which functions are +// served. +const clearFunctionsCache = (functionsPath: string) => { + Object.keys(require.cache) + .filter((key) => key.startsWith(functionsPath)) + .forEach(decache) +} + +const netlifyConfigToZisiConfig = (functionsConfig: Record, projectRoot: string) => + addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig, projectRoot })) + +interface HandlerOptions { + config: any + directory: string + func: NetlifyFunction + metadata: any + projectRoot: string + targetDirectory: string +} + +export const getNoopBuilder = async ({ directory, func, metadata }: HandlerOptions): Promise => { + const functionDirectory = path.dirname(func.mainFile) + const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory] + const build = async () => + ({ + buildPath: '', + excludedRoutes: [], + includedFiles: [], + mainFile: func.mainFile, + outputModuleFormat: 'cjs', + routes: [], + runtimeAPIVersion: func.runtimeAPIVersion, + schedule: metadata.schedule, + srcFiles, + }) as BuildResult + + return { + build, + builderName: '', + } +} + +export const getZISIBuilder = async ({ + config, + directory, + func, + metadata, + projectRoot, + targetDirectory, +}: HandlerOptions): Promise => { + const functionsConfig = netlifyConfigToZisiConfig(config.functions, projectRoot) + const packageJson = await readPackageUp({ cwd: path.dirname(func.mainFile) }) + const hasTypeModule = Boolean(packageJson && packageJson.packageJson.type === 'module') + const featureFlags: FeatureFlags = {} + + if (metadata.runtimeAPIVersion === 2) { + 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 { nodeBundler } = functionsConfig['*'] + const isUsingEsbuild = nodeBundler === 'esbuild_zisi' || nodeBundler === 'esbuild' + + if (!isUsingEsbuild) { + return null + } + } + + // Enable source map support. + sourceMapSupport.install() + + return { + build: ({ cache = {} }: { cache?: BuildCache }) => + buildFunction({ + cache, + config: functionsConfig, + directory, + func, + projectRoot, + targetDirectory, + hasTypeModule, + featureFlags, + }), + builderName: 'zip-it-and-ship-it', + } +} diff --git a/packages/functions/dist-dev/config.ts b/packages/functions/dist-dev/config.ts new file mode 100644 index 00000000..6818e10f --- /dev/null +++ b/packages/functions/dist-dev/config.ts @@ -0,0 +1,42 @@ +import type { Config as FunctionsConfig, NodeBundlerName } from '@netlify/zip-it-and-ship-it' + +// TODO: Import from `@netlify/config`. +export interface TOMLFunctionConfig { + external_node_modules: string[] + ignored_node_modules: string[] + included_files: string[] + node_bundler: string + schedule: string +} + +interface NormalizeFunctionsConfigOptions { + functionsConfig: Record + projectRoot: string + siteEnv?: Record +} + +// The function configuration keys returned by @netlify/config are not an exact +// match to the properties that @netlify/zip-it-and-ship-it expects. We do that +// translation here. +export const normalizeFunctionsConfig = ({ + functionsConfig = {}, + projectRoot, + siteEnv = {}, +}: NormalizeFunctionsConfigOptions) => + Object.entries(functionsConfig).reduce( + (result, [pattern, config]) => ({ + ...result, + [pattern]: { + externalNodeModules: config.external_node_modules, + includedFiles: config.included_files, + includedFilesBasePath: projectRoot, + ignoredNodeModules: config.ignored_node_modules, + nodeBundler: (config.node_bundler === 'esbuild' ? 'esbuild_zisi' : config.node_bundler) as NodeBundlerName, + nodeVersion: siteEnv.AWS_LAMBDA_JS_RUNTIME, + processDynamicNodeImports: true, + schedule: config.schedule, + zipGo: true, + }, + }), + {} as FunctionsConfig, + ) diff --git a/packages/functions/dist-dev/index.ts b/packages/functions/dist-dev/index.ts new file mode 100644 index 00000000..2b7feb1c --- /dev/null +++ b/packages/functions/dist-dev/index.ts @@ -0,0 +1,117 @@ +import { createConnection } from 'node:net' +import { pathToFileURL } from 'node:url' +import { Worker } from 'node:worker_threads' + +import lambdaLocal from 'lambda-local' + +import type { NetlifyFunction } from '../../function.js' +import type { Runtime } from '../index.js' + +import { getNoopBuilder, getZISIBuilder, parseFunctionForMetadata } from './builder.js' + +// TODO: Find better place for this. +const BLOBS_CONTEXT_VARIABLE = 'NETLIFY_BLOBS_CONTEXT' + +lambdaLocal.getLogger().level = 'alert' + +import { HandlerEvent, HandlerResponse } from '../../../src/main.js' +import { lambdaEventFromWebRequest, webResponseFromLambdaResponse } from './lambda.js' + +export const nodeJSRuntime: Runtime = { + getBuildFunction: async ({ config, directory, func, projectRoot, targetDirectory }) => { + const metadata = await parseFunctionForMetadata({ mainFile: func.mainFile, config, projectRoot }) + const zisiBuilder = await getZISIBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }) + + if (zisiBuilder) { + return zisiBuilder.build + } + + const noopBuilder = await getNoopBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }) + + return noopBuilder.build + }, + + invokeFunction: async ({ context, environment, func, request, route, timeout }) => { + const event = await lambdaEventFromWebRequest(request, route) + const buildData = await func.getBuildData() + + if (buildData?.runtimeAPIVersion !== 2) { + const lambdaResponse = await invokeFunctionDirectly({ context, event, func, timeout }) + + return webResponseFromLambdaResponse(lambdaResponse) + } + + const workerData = { + clientContext: JSON.stringify(context), + environment, + 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(buildData?.buildPath ?? func.mainFile).href, + timeoutMs: timeout * 1_000, + } + + const worker = new Worker(workerURL, { workerData }) + const lambdaResponse = 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) + }) + + return webResponseFromLambdaResponse(lambdaResponse) + }, +} + +const workerURL = new URL('worker.js', import.meta.url) + +export const invokeFunctionDirectly = async ({ + context, + event, + func, + timeout, +}: { + context: any + event: HandlerEvent + func: NetlifyFunction + timeout: number +}) => { + // If a function builder has defined a `buildPath` property, we use it. + // Otherwise, we'll invoke the function's main file. + const buildData = await func.getBuildData() + const lambdaPath = buildData?.buildPath ?? func.mainFile + const result = (await lambdaLocal.execute({ + clientContext: JSON.stringify(context), + environment: { + // We've set the Blobs context on the parent process, which means it will + // be available to the Lambda. This would be inconsistent with production + // where only V2 functions get the context injected. To fix it, unset the + // context variable before invoking the function. + // This has the side-effect of also removing the variable from `process.env`. + [BLOBS_CONTEXT_VARIABLE]: undefined, + }, + event, + lambdaPath, + timeoutMs: timeout * 1_000, + verboseLevel: 3, + esm: lambdaPath.endsWith('.mjs'), + })) as HandlerResponse + + return result +} diff --git a/packages/functions/dist-dev/lambda.ts b/packages/functions/dist-dev/lambda.ts new file mode 100644 index 00000000..717f9a39 --- /dev/null +++ b/packages/functions/dist-dev/lambda.ts @@ -0,0 +1,65 @@ +import { shouldBase64Encode } from '@netlify/dev-utils' +import { HandlerEvent } from '../../../src/function/handler_event.js' +import { HandlerResponse } from '../../../src/function/handler_response.js' + +export const headersObjectFromWebHeaders = (webHeaders: Headers) => { + const headers: Record = {} + const multiValueHeaders: Record = {} + + webHeaders.forEach((value, key) => { + headers[key] = value + multiValueHeaders[key] = value.split(',').map((value) => value.trim()) + }) + + return { + headers, + multiValueHeaders, + } +} + +export const webHeadersFromHeadersObject = (headersObject: HandlerResponse['headers']) => { + const headers = new Headers() + + Object.entries(headersObject ?? {}).forEach(([name, value]) => { + if (value !== undefined) { + headers.set(name.toLowerCase(), value.toString()) + } + }) + + return headers +} + +export const lambdaEventFromWebRequest = async (request: Request, route?: string): Promise => { + const url = new URL(request.url) + const queryStringParameters: Record = {} + const multiValueQueryStringParameters: Record = {} + + url.searchParams.forEach((value, key) => { + queryStringParameters[key] = queryStringParameters[key] ? `${queryStringParameters[key]},${value}` : value + multiValueQueryStringParameters[key] = [...(multiValueQueryStringParameters[key] ?? []), value] + }) + + const { headers, multiValueHeaders } = headersObjectFromWebHeaders(request.headers) + const body = (await request.text()) || null + + return { + rawUrl: url.toString(), + rawQuery: url.search, + path: url.pathname, + httpMethod: request.method, + headers, + multiValueHeaders, + queryStringParameters, + multiValueQueryStringParameters, + body, + isBase64Encoded: shouldBase64Encode(request.headers.get('content-type') ?? ''), + route, + } +} + +export const webResponseFromLambdaResponse = async (lambdaResponse: HandlerResponse): Promise => { + return new Response(lambdaResponse.body, { + headers: webHeadersFromHeadersObject(lambdaResponse.headers), + status: lambdaResponse.statusCode, + }) +} diff --git a/packages/functions/dist-dev/main.js b/packages/functions/dist-dev/main.js new file mode 100644 index 00000000..02cedbf0 --- /dev/null +++ b/packages/functions/dist-dev/main.js @@ -0,0 +1,1113 @@ +// dev/main.ts +import { Buffer as Buffer2 } from "buffer"; + +// dev/registry.ts +import { stat } from "fs/promises"; +import { createRequire as createRequire2 } from "module"; +import { basename as basename2, extname as extname2, isAbsolute, join, resolve } from "path"; +import { env } from "process"; +import { watchDebounced } from "@netlify/dev-utils"; +import { listFunctions } from "@netlify/zip-it-and-ship-it"; +import extractZip from "extract-zip"; + +// dev/function.ts +import { basename, extname } from "path"; +import { version as nodeVersion } from "process"; +import { headers as netlifyHeaders, renderFunctionErrorPage } from "@netlify/dev-utils"; +import CronParser from "cron-parser"; +import semver from "semver"; +var BACKGROUND_FUNCTION_SUFFIX = "-background"; +var TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".cts", ".mts", ".ts"]); +var V2_MIN_NODE_VERSION = "18.14.0"; +var difference = (setA, setB) => new Set([...setA].filter((item) => !setB.has(item))); +var getNextRun = function(schedule) { + const cron = CronParser.parseExpression(schedule, { + tz: "Etc/UTC" + }); + return cron.next().toDate(); +}; +var getBlobsEventProperty = (context) => ({ + primary_region: context.primaryRegion, + url: context.edgeURL, + url_uncached: context.edgeURL, + token: context.token +}); +var NetlifyFunction = class { + name; + mainFile; + displayName; + schedule; + runtime; + blobsContext; + config; + directory; + projectRoot; + settings; + timeoutBackground; + timeoutSynchronous; + // Determines whether this is a background function based on the function + // name. + isBackground; + buildQueue; + buildData; + buildError = null; + // List of the function's source files. This starts out as an empty set + // and will get populated on every build. + srcFiles = /* @__PURE__ */ new Set(); + excludedRoutes; + routes; + constructor({ + blobsContext, + config, + directory, + displayName, + excludedRoutes, + mainFile, + name, + projectRoot, + routes, + runtime, + settings, + timeoutBackground, + timeoutSynchronous + }) { + this.blobsContext = blobsContext; + this.config = config; + this.directory = directory; + this.excludedRoutes = excludedRoutes; + this.mainFile = mainFile; + this.name = name; + this.displayName = displayName ?? name; + this.projectRoot = projectRoot; + this.routes = routes; + this.runtime = runtime; + this.timeoutBackground = timeoutBackground; + this.timeoutSynchronous = timeoutSynchronous; + this.settings = settings; + this.isBackground = name.endsWith(BACKGROUND_FUNCTION_SUFFIX); + const functionConfig = config.functions?.[name]; + this.schedule = functionConfig?.schedule; + this.srcFiles = /* @__PURE__ */ new Set(); + } + get filename() { + if (!this.buildData?.mainFile) { + return null; + } + return basename(this.buildData.mainFile); + } + getRecommendedExtension() { + if (this.buildData?.runtimeAPIVersion !== 2) { + return; + } + const extension = this.buildData?.mainFile ? extname(this.buildData.mainFile) : void 0; + const moduleFormat = this.buildData?.outputModuleFormat; + if (moduleFormat === "esm") { + return; + } + if (extension === ".ts") { + return ".mts"; + } + if (extension === ".js") { + return ".mjs"; + } + } + hasValidName() { + return /^[A-Za-z0-9_-]+$/.test(this.name); + } + async isScheduled() { + await this.buildQueue; + return Boolean(this.schedule); + } + isSupported() { + return !(this.buildData?.runtimeAPIVersion === 2 && semver.lt(nodeVersion, V2_MIN_NODE_VERSION)); + } + isTypeScript() { + if (this.filename === null) { + return false; + } + return TYPESCRIPT_EXTENSIONS.has(extname(this.filename)); + } + async getNextRun() { + if (!await this.isScheduled()) { + return null; + } + return getNextRun(this.schedule); + } + // The `build` method transforms source files into invocable functions. Its + // return value is an object with: + // + // - `srcFilesDiff`: Files that were added and removed since the last time + // the function was built. + async build({ buildDirectory, cache }) { + this.buildQueue = this.runtime.getBuildFunction({ + config: this.config, + directory: this.directory, + func: this, + projectRoot: this.projectRoot, + targetDirectory: buildDirectory + }).then((buildFunction2) => buildFunction2({ cache })); + try { + const buildData = await this.buildQueue; + if (buildData === void 0) { + throw new Error(`Could not build function ${this.name}`); + } + const { includedFiles = [], routes, schedule, srcFiles } = buildData; + const srcFilesSet = new Set(srcFiles); + const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet); + this.buildData = buildData; + this.buildError = null; + this.routes = routes; + this.srcFiles = srcFilesSet; + this.schedule = schedule || this.schedule; + if (!this.isSupported()) { + throw new Error( + `Function requires Node.js version ${V2_MIN_NODE_VERSION} or above, but ${nodeVersion.slice( + 1 + )} is installed. Refer to https://ntl.fyi/functions-runtime for information on how to update.` + ); + } + return { includedFiles, srcFilesDiff }; + } catch (error) { + if (error instanceof Error) { + this.buildError = error; + } + return { error }; + } + } + formatError(rawError, acceptsHTML) { + const error = this.normalizeError(rawError); + if (acceptsHTML) { + return JSON.stringify({ + ...error, + stackTrace: void 0, + trace: error.stackTrace + }); + } + return `${error.errorType}: ${error.errorMessage} + ${error.stackTrace.join("\n")}`; + } + async getBuildData() { + await this.buildQueue; + return this.buildData; + } + // Compares a new set of source files against a previous one, returning an + // object with two Sets, one with added and the other with deleted files. + getSrcFilesDiff(newSrcFiles) { + const added = difference(newSrcFiles, this.srcFiles); + const deleted = difference(this.srcFiles, newSrcFiles); + return { + added, + deleted + }; + } + async handleError(rawError, acceptsHTML) { + const errorString = typeof rawError === "string" ? rawError : this.formatError(rawError, acceptsHTML); + const status = 500; + if (acceptsHTML) { + const body = await renderFunctionErrorPage(errorString, "function"); + return new Response(body, { + headers: { + "Content-Type": "text/html" + }, + status + }); + } + return new Response(errorString, { status }); + } + // Invokes the function and returns its response object. + async invoke({ buildCache = {}, buildDirectory, clientContext = {}, request, route }) { + if (buildDirectory) { + await this.build({ buildDirectory, cache: buildCache }); + } else { + await this.buildQueue; + } + if (this.buildError) { + throw this.buildError; + } + const timeout = this.isBackground ? this.timeoutBackground : this.timeoutSynchronous; + const environment = {}; + if (this.blobsContext) { + const payload = JSON.stringify(getBlobsEventProperty(this.blobsContext)); + request.headers.set(netlifyHeaders.BlobsInfo, Buffer.from(payload).toString("base64")); + } + try { + return await this.runtime.invokeFunction({ + context: clientContext, + environment, + func: this, + request, + route, + timeout + }); + } catch (error) { + const acceptsHTML = request.headers.get("accept")?.includes("text/html"); + return await this.handleError(error, Boolean(acceptsHTML)); + } + } + /** + * Matches all routes agains the incoming request. If a match is found, then the matched route is returned. + * @returns matched route + */ + async matchURLPath(rawPath, method) { + let path2 = rawPath !== "/" && rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath; + path2 = path2.toLowerCase(); + const { excludedRoutes = [], routes = [] } = this; + const matchingRoute = routes.find((route) => { + if (route.methods && route.methods.length !== 0 && !route.methods.includes(method)) { + return false; + } + if ("literal" in route && route.literal !== void 0) { + return path2 === route.literal; + } + if ("expression" in route && route.expression !== void 0) { + const regex = new RegExp(route.expression); + return regex.test(path2); + } + return false; + }); + if (!matchingRoute) { + return; + } + const isExcluded = excludedRoutes.some((excludedRoute) => { + if ("literal" in excludedRoute && excludedRoute.literal !== void 0) { + return path2 === excludedRoute.literal; + } + if ("expression" in excludedRoute && excludedRoute.expression !== void 0) { + const regex = new RegExp(excludedRoute.expression); + return regex.test(path2); + } + return false; + }); + if (isExcluded) { + return; + } + return matchingRoute; + } + normalizeError(error) { + if (error instanceof Error) { + const normalizedError = { + errorMessage: error.message, + errorType: error.name, + stackTrace: error.stack ? error.stack.split("\n") : [] + }; + if ("code" in error && error.code === "ERR_REQUIRE_ESM") { + return { + ...normalizedError, + errorMessage: "a CommonJS file cannot import ES modules. Consider switching your function to ES modules. For more information, refer to https://ntl.fyi/functions-runtime." + }; + } + return normalizedError; + } + const stackTrace = error.stackTrace.map((line) => ` at ${line}`); + return { + errorType: error.errorType, + errorMessage: error.errorMessage, + stackTrace + }; + } + get runtimeAPIVersion() { + return this.buildData?.runtimeAPIVersion ?? 1; + } + setRoutes(routes) { + if (this.buildData) { + this.buildData.routes = routes; + } + } + get url() { + const port = this.settings.port || this.settings.functionsPort; + const protocol = this.settings.https ? "https" : "http"; + const url = new URL(`/.netlify/functions/${this.name}`, `${protocol}://localhost:${port}`); + return url.href; + } +}; + +// dev/runtimes/nodejs/index.ts +import { createConnection } from "net"; +import { pathToFileURL } from "url"; +import { Worker } from "worker_threads"; +import lambdaLocal from "lambda-local"; + +// dev/runtimes/nodejs/builder.ts +import { writeFile } from "fs/promises"; +import { createRequire } from "module"; +import path from "path"; +import { memoize } from "@netlify/dev-utils"; +import { zipFunction, listFunction } from "@netlify/zip-it-and-ship-it"; +import decache from "decache"; +import { readPackageUp } from "read-package-up"; +import sourceMapSupport from "source-map-support"; + +// dev/runtimes/nodejs/config.ts +var normalizeFunctionsConfig = ({ + functionsConfig = {}, + projectRoot, + siteEnv = {} +}) => Object.entries(functionsConfig).reduce( + (result, [pattern, config]) => ({ + ...result, + [pattern]: { + externalNodeModules: config.external_node_modules, + includedFiles: config.included_files, + includedFilesBasePath: projectRoot, + ignoredNodeModules: config.ignored_node_modules, + nodeBundler: config.node_bundler === "esbuild" ? "esbuild_zisi" : config.node_bundler, + nodeVersion: siteEnv.AWS_LAMBDA_JS_RUNTIME, + processDynamicNodeImports: true, + schedule: config.schedule, + zipGo: true + } + }), + {} +); + +// dev/runtimes/nodejs/builder.ts +var require2 = createRequire(import.meta.url); +var serveFunctionsFolder = path.join(".netlify", "functions-serve"); +var addFunctionsConfigDefaults = (config) => ({ + ...config, + "*": { + nodeSourcemap: true, + ...config["*"] + } +}); +var 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); + const entryPath = functionDirectory === directory ? func.mainFile : functionDirectory; + const buildResult = await memoize({ + cache, + cacheKey: `zisi-${entryPath}`, + command: () => zipFunction(entryPath, targetDirectory, zipOptions) + }); + if (!buildResult) { + return; + } + const { + entryFilename, + excludedRoutes, + includedFiles, + inputs, + mainFile, + outputModuleFormat, + path: functionPath, + routes, + runtimeAPIVersion, + schedule + } = buildResult; + const srcFiles = (inputs ?? []).filter((inputPath) => !inputPath.includes(`${path.sep}node_modules${path.sep}`)); + const buildPath = path.join(functionPath, entryFilename); + if (hasTypeModule) { + await writeFile( + path.join(functionPath, `package.json`), + JSON.stringify({ + type: "commonjs" + }) + ); + } + clearFunctionsCache(targetDirectory); + return { + buildPath, + excludedRoutes, + includedFiles, + outputModuleFormat, + mainFile, + routes, + runtimeAPIVersion, + srcFiles, + schedule, + targetDirectory + }; +}; +var parseFunctionForMetadata = async ({ config, mainFile, projectRoot }) => await listFunction(mainFile, { + config: netlifyConfigToZisiConfig(config.functions, projectRoot), + featureFlags: { zisi_functions_api_v2: true }, + parseISC: true +}); +var clearFunctionsCache = (functionsPath) => { + Object.keys(require2.cache).filter((key) => key.startsWith(functionsPath)).forEach(decache); +}; +var netlifyConfigToZisiConfig = (functionsConfig, projectRoot) => addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig, projectRoot })); +var getNoopBuilder = async ({ directory, func, metadata }) => { + const functionDirectory = path.dirname(func.mainFile); + const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory]; + const build = async () => ({ + buildPath: "", + excludedRoutes: [], + includedFiles: [], + mainFile: func.mainFile, + outputModuleFormat: "cjs", + routes: [], + runtimeAPIVersion: func.runtimeAPIVersion, + schedule: metadata.schedule, + srcFiles + }); + return { + build, + builderName: "" + }; +}; +var getZISIBuilder = async ({ + config, + directory, + func, + metadata, + projectRoot, + targetDirectory +}) => { + const functionsConfig = netlifyConfigToZisiConfig(config.functions, projectRoot); + const packageJson = await readPackageUp({ cwd: path.dirname(func.mainFile) }); + const hasTypeModule = Boolean(packageJson && packageJson.packageJson.type === "module"); + const featureFlags = {}; + if (metadata.runtimeAPIVersion === 2) { + featureFlags.zisi_pure_esm = true; + featureFlags.zisi_pure_esm_mjs = true; + } else { + const mustTranspile = [".mjs", ".ts", ".mts", ".cts"].includes(path.extname(func.mainFile)); + const mustUseEsbuild = hasTypeModule || mustTranspile; + if (mustUseEsbuild && !functionsConfig["*"].nodeBundler) { + functionsConfig["*"].nodeBundler = "esbuild"; + } + const { nodeBundler } = functionsConfig["*"]; + const isUsingEsbuild = nodeBundler === "esbuild_zisi" || nodeBundler === "esbuild"; + if (!isUsingEsbuild) { + return null; + } + } + sourceMapSupport.install(); + return { + build: ({ cache = {} }) => buildFunction({ + cache, + config: functionsConfig, + directory, + func, + projectRoot, + targetDirectory, + hasTypeModule, + featureFlags + }), + builderName: "zip-it-and-ship-it" + }; +}; + +// dev/runtimes/nodejs/lambda.ts +import { shouldBase64Encode } from "@netlify/dev-utils"; +var headersObjectFromWebHeaders = (webHeaders) => { + const headers = {}; + const multiValueHeaders = {}; + webHeaders.forEach((value, key) => { + headers[key] = value; + multiValueHeaders[key] = value.split(",").map((value2) => value2.trim()); + }); + return { + headers, + multiValueHeaders + }; +}; +var webHeadersFromHeadersObject = (headersObject) => { + const headers = new Headers(); + Object.entries(headersObject ?? {}).forEach(([name, value]) => { + if (value !== void 0) { + headers.set(name.toLowerCase(), value.toString()); + } + }); + return headers; +}; +var lambdaEventFromWebRequest = async (request, route) => { + const url = new URL(request.url); + const queryStringParameters = {}; + const multiValueQueryStringParameters = {}; + url.searchParams.forEach((value, key) => { + queryStringParameters[key] = queryStringParameters[key] ? `${queryStringParameters[key]},${value}` : value; + multiValueQueryStringParameters[key] = [...multiValueQueryStringParameters[key] ?? [], value]; + }); + const { headers, multiValueHeaders } = headersObjectFromWebHeaders(request.headers); + const body = await request.text() || null; + return { + rawUrl: url.toString(), + rawQuery: url.search, + path: url.pathname, + httpMethod: request.method, + headers, + multiValueHeaders, + queryStringParameters, + multiValueQueryStringParameters, + body, + isBase64Encoded: shouldBase64Encode(request.headers.get("content-type") ?? ""), + route + }; +}; +var webResponseFromLambdaResponse = async (lambdaResponse) => { + return new Response(lambdaResponse.body, { + headers: webHeadersFromHeadersObject(lambdaResponse.headers), + status: lambdaResponse.statusCode + }); +}; + +// dev/runtimes/nodejs/index.ts +var BLOBS_CONTEXT_VARIABLE = "NETLIFY_BLOBS_CONTEXT"; +lambdaLocal.getLogger().level = "alert"; +var nodeJSRuntime = { + getBuildFunction: async ({ config, directory, func, projectRoot, targetDirectory }) => { + const metadata = await parseFunctionForMetadata({ mainFile: func.mainFile, config, projectRoot }); + const zisiBuilder = await getZISIBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }); + if (zisiBuilder) { + return zisiBuilder.build; + } + const noopBuilder = await getNoopBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }); + return noopBuilder.build; + }, + invokeFunction: async ({ context, environment, func, request, route, timeout }) => { + const event = await lambdaEventFromWebRequest(request, route); + const buildData = await func.getBuildData(); + if (buildData?.runtimeAPIVersion !== 2) { + const lambdaResponse2 = await invokeFunctionDirectly({ context, event, func, timeout }); + return webResponseFromLambdaResponse(lambdaResponse2); + } + const workerData = { + clientContext: JSON.stringify(context), + environment, + 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(buildData?.buildPath ?? func.mainFile).href, + timeoutMs: timeout * 1e3 + }; + const worker = new Worker(workerURL, { workerData }); + const lambdaResponse = await new Promise((resolve2, reject) => { + worker.on("message", (result) => { + if (result?.streamPort) { + const client = createConnection( + { + port: result.streamPort, + host: "localhost" + }, + () => { + result.body = client; + resolve2(result); + } + ); + client.on("error", reject); + } else { + resolve2(result); + } + }); + worker.on("error", reject); + }); + return webResponseFromLambdaResponse(lambdaResponse); + } +}; +var workerURL = new URL("worker.js", import.meta.url); +var invokeFunctionDirectly = async ({ + context, + event, + func, + timeout +}) => { + const buildData = await func.getBuildData(); + const lambdaPath = buildData?.buildPath ?? func.mainFile; + const result = await lambdaLocal.execute({ + clientContext: JSON.stringify(context), + environment: { + // We've set the Blobs context on the parent process, which means it will + // be available to the Lambda. This would be inconsistent with production + // where only V2 functions get the context injected. To fix it, unset the + // context variable before invoking the function. + // This has the side-effect of also removing the variable from `process.env`. + [BLOBS_CONTEXT_VARIABLE]: void 0 + }, + event, + lambdaPath, + timeoutMs: timeout * 1e3, + verboseLevel: 3, + esm: lambdaPath.endsWith(".mjs") + }); + return result; +}; + +// dev/runtimes/index.ts +var runtimes = { + js: nodeJSRuntime +}; + +// dev/registry.ts +var DEFAULT_FUNCTION_URL_EXPRESSION = /^\/.netlify\/(functions|builders)\/([^/]+).*/; +var TYPES_PACKAGE = "@netlify/functions"; +var FunctionsRegistry = class { + /** + * Context object for Netlify Blobs + */ + blobsContext; + /** + * The functions held by the registry + */ + functions = /* @__PURE__ */ new Map(); + /** + * File watchers for function files. Maps function names to objects built + * by the `watchDebounced` utility. + */ + functionWatchers = /* @__PURE__ */ new Map(); + /** + * Keeps track of whether we've checked whether `TYPES_PACKAGE` is + * installed. + */ + hasCheckedTypesPackage = false; + buildCache; + config; + debug; + destPath; + directoryWatchers; + handleEvent; + frameworksAPIFunctionsPath; + internalFunctionsPath; + manifest; + projectRoot; + timeouts; + settings; + watch; + constructor({ + blobsContext, + config, + debug = false, + destPath, + eventHandler, + frameworksAPIFunctionsPath, + internalFunctionsPath, + manifest, + projectRoot, + settings, + timeouts, + watch + }) { + this.blobsContext = blobsContext; + this.config = config; + this.debug = debug; + this.destPath = destPath; + this.frameworksAPIFunctionsPath = frameworksAPIFunctionsPath; + this.handleEvent = eventHandler ?? (() => { + }); + this.internalFunctionsPath = internalFunctionsPath; + this.projectRoot = projectRoot; + this.timeouts = timeouts; + this.settings = settings; + this.watch = watch === true; + this.buildCache = {}; + this.directoryWatchers = /* @__PURE__ */ new Map(); + this.manifest = manifest; + } + async checkTypesPackage() { + if (this.hasCheckedTypesPackage) { + return; + } + this.hasCheckedTypesPackage = true; + const require3 = createRequire2(this.projectRoot); + try { + require3.resolve(TYPES_PACKAGE, { paths: [this.projectRoot] }); + } catch (error) { + if (error?.code === "MODULE_NOT_FOUND") { + this.handleEvent({ name: "FunctionMissingTypesPackageEvent" }); + } + } + } + /** + * Builds a function and sets up the appropriate file watchers so that any + * changes will trigger another build. + */ + async buildFunctionAndWatchFiles(func, firstLoad = false) { + if (!firstLoad) { + this.handleEvent({ function: func, name: "FunctionReloadingEvent" }); + } + const { + error: buildError, + includedFiles, + srcFilesDiff + } = await func.build({ buildDirectory: this.destPath, cache: this.buildCache }); + if (buildError) { + this.handleEvent({ function: func, name: "FunctionBuildErrorEvent" }); + } else { + this.handleEvent({ firstLoad, function: func, name: "FunctionLoadedEvent" }); + } + if (func.isTypeScript()) { + this.checkTypesPackage(); + } + if (!srcFilesDiff) { + return; + } + if (!this.watch) { + return; + } + const watcher = this.functionWatchers.get(func.name); + if (watcher) { + srcFilesDiff.deleted.forEach((path2) => { + watcher.unwatch(path2); + }); + srcFilesDiff.added.forEach((path2) => { + watcher.add(path2); + }); + return; + } + if (srcFilesDiff.added.size !== 0) { + const filesToWatch = [...srcFilesDiff.added, ...includedFiles]; + const newWatcher = await watchDebounced(filesToWatch, { + onChange: () => { + this.buildFunctionAndWatchFiles(func, false); + } + }); + this.functionWatchers.set(func.name, newWatcher); + } + } + set eventHandler(handler) { + this.handleEvent = handler; + } + /** + * Returns a function by name. + */ + get(name) { + return this.functions.get(name); + } + /** + * Looks for the first function that matches a given URL path. If a match is + * found, returns an object with the function and the route. If the URL path + * matches the default functions URL (i.e. can only be for a function) but no + * function with the given name exists, returns an object with the function + * and the route set to `null`. Otherwise, `undefined` is returned, + */ + async getFunctionForURLPath(urlPath, method) { + const url = new URL(`http://localhost${urlPath}`); + const defaultURLMatch = DEFAULT_FUNCTION_URL_EXPRESSION.exec(url.pathname); + if (defaultURLMatch) { + const func = this.get(defaultURLMatch[2]); + if (!func) { + return { func: null, route: null }; + } + const { routes = [] } = func; + if (routes.length !== 0) { + this.handleEvent({ + function: func, + name: "FunctionNotInvokableOnPathEvent", + urlPath + }); + return; + } + return { func, route: null }; + } + for (const func of this.functions.values()) { + const route = await func.matchURLPath(url.pathname, method); + if (route) { + return { func, route }; + } + } + } + isInternalFunction(func) { + if (this.internalFunctionsPath && func.mainFile.includes(this.internalFunctionsPath)) { + return true; + } + if (this.frameworksAPIFunctionsPath && func.mainFile.includes(this.frameworksAPIFunctionsPath)) { + return true; + } + return false; + } + /** + * Adds a function to the registry + */ + async registerFunction(name, func, isReload = false) { + this.handleEvent({ function: func, name: "FunctionRegisteredEvent" }); + if (extname2(func.mainFile) === ".zip") { + const unzippedDirectory = await this.unzipFunction(func); + const manifestEntry = (this.manifest?.functions || []).find((manifestFunc) => manifestFunc.name === func.name); + if (!manifestEntry) { + return; + } + if (this.debug) { + this.handleEvent({ function: func, name: "FunctionExtractedEvent" }); + } + func.setRoutes(manifestEntry?.routes); + try { + const v2EntryPointPath = join(unzippedDirectory, "___netlify-entry-point.mjs"); + await stat(v2EntryPointPath); + func.mainFile = v2EntryPointPath; + } catch { + func.mainFile = join(unzippedDirectory, basename2(manifestEntry.mainFile)); + } + } else if (this.watch) { + this.buildFunctionAndWatchFiles(func, !isReload); + } + this.functions.set(name, func); + } + /** + * A proxy to zip-it-and-ship-it's `listFunctions` method. It exists just so + * that we can mock it in tests. + */ + async listFunctions(...args) { + return await listFunctions(...args); + } + /** + * Takes a list of directories and scans for functions. It keeps tracks of + * any functions in those directories that we've previously seen, and takes + * care of registering and unregistering functions as they come and go. + */ + async scan(relativeDirs) { + const directories = relativeDirs.filter((dir) => Boolean(dir)).map((dir) => isAbsolute(dir) ? dir : join(this.projectRoot, dir)); + if (directories.length === 0) { + return; + } + const functions = await this.listFunctions(directories, { + featureFlags: { + buildRustSource: env.NETLIFY_EXPERIMENTAL_BUILD_RUST_SOURCE === "true" + }, + configFileDirectories: [this.internalFunctionsPath].filter(Boolean), + config: this.config.functions, + parseISC: true + }); + const ignoredFunctions = new Set( + functions.filter( + (func) => this.isInternalFunction(func) && this.functions.has(func.name) && !this.isInternalFunction(this.functions.get(func.name)) + ).map((func) => func.name) + ); + const deletedFunctions = [...this.functions.values()].filter((oldFunc) => { + const isFound = functions.some( + (newFunc) => ignoredFunctions.has(newFunc.name) || newFunc.name === oldFunc.name && newFunc.mainFile === oldFunc.mainFile + ); + return !isFound; + }); + await Promise.all(deletedFunctions.map((func) => this.unregisterFunction(func))); + const deletedFunctionNames = new Set(deletedFunctions.map((func) => func.name)); + const addedFunctions = await Promise.all( + // zip-it-and-ship-it returns an array sorted based on which extension should have precedence, + // where the last ones precede the previous ones. This is why + // we reverse the array so we get the right functions precedence in the CLI. + functions.reverse().map(async ({ displayName, excludedRoutes, mainFile, name, routes, runtime: runtimeName }) => { + if (ignoredFunctions.has(name)) { + return; + } + const runtime = runtimes[runtimeName]; + if (runtime === void 0) { + return; + } + if (this.functions.has(name)) { + return; + } + const directory = directories.find((directory2) => mainFile.startsWith(directory2)); + if (directory === void 0) { + return; + } + const func = new NetlifyFunction({ + blobsContext: this.blobsContext, + config: this.config, + directory, + displayName, + excludedRoutes, + mainFile, + name, + projectRoot: this.projectRoot, + routes, + runtime, + settings: this.settings, + timeoutBackground: this.timeouts.backgroundFunctions, + timeoutSynchronous: this.timeouts.syncFunctions + }); + const isReload = deletedFunctionNames.has(name); + await this.registerFunction(name, func, isReload); + return func; + }) + ); + const addedFunctionNames = new Set(addedFunctions.filter(Boolean).map((func) => func?.name)); + deletedFunctions.forEach(async (func) => { + if (addedFunctionNames.has(func.name)) { + return; + } + this.handleEvent({ function: func, name: "FunctionRemovedEvent" }); + }); + if (this.watch) { + await Promise.all(directories.map((path2) => this.setupDirectoryWatcher(path2))); + } + } + /** + * Creates a watcher that looks at files being added or removed from a + * functions directory. It doesn't care about files being changed, because + * those will be handled by each functions' watcher. + */ + async setupDirectoryWatcher(directory) { + if (this.directoryWatchers.has(directory)) { + return; + } + const watcher = await watchDebounced(directory, { + depth: 1, + onAdd: () => { + this.scan([directory]); + }, + onUnlink: () => { + this.scan([directory]); + } + }); + this.directoryWatchers.set(directory, watcher); + } + /** + * Removes a function from the registry and closes its file watchers. + */ + async unregisterFunction(func) { + const { name } = func; + this.functions.delete(name); + const watcher = this.functionWatchers.get(name); + if (watcher) { + await watcher.close(); + } + this.functionWatchers.delete(name); + } + /** + * Takes a zipped function and extracts its contents to an internal directory. + */ + async unzipFunction(func) { + const targetDirectory = resolve(this.projectRoot, this.destPath, ".unzipped", func.name); + await extractZip(func.mainFile, { dir: targetDirectory }); + return targetDirectory; + } +}; + +// dev/server/client-context.ts +import { jwtDecode } from "jwt-decode"; +var buildClientContext = (headers) => { + if (!headers.authorization) return; + const parts = headers.authorization.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") return; + const identity = { + url: "https://netlify-dev-locally-emulated-identity.netlify.app/.netlify/identity", + // { + // "source": "netlify dev", + // "testData": "NETLIFY_DEV_LOCALLY_EMULATED_IDENTITY" + // } + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb3VyY2UiOiJuZXRsaWZ5IGRldiIsInRlc3REYXRhIjoiTkVUTElGWV9ERVZfTE9DQUxMWV9FTVVMQVRFRF9JREVOVElUWSJ9.2eSDqUOZAOBsx39FHFePjYj12k0LrxldvGnlvDu3GMI" + }; + try { + const user = jwtDecode(parts[1]); + const netlifyContext = JSON.stringify({ + identity, + user + }); + return { + identity, + user, + custom: { + netlify: Buffer.from(netlifyContext).toString("base64") + } + }; + } catch { + } +}; + +// dev/main.ts +var CLOCKWORK_USERAGENT = "Netlify Clockwork"; +var UNLINKED_SITE_MOCK_ID = "unlinked"; +var FunctionsHandler = class { + accountID; + buildCache; + geolocation; + globalBuildDirectory; + registry; + scan; + siteID; + constructor({ accountId, geolocation, siteId, userFunctionsPath, ...registryOptions }) { + const registry = new FunctionsRegistry(registryOptions); + this.accountID = accountId; + this.buildCache = {}; + this.geolocation = geolocation; + this.globalBuildDirectory = registryOptions.destPath; + this.registry = registry; + this.scan = registry.scan([userFunctionsPath]); + this.siteID = siteId; + } + async invoke(request, route, func, buildDirectory) { + let remoteAddress = request.headers.get("x-forwarded-for") || ""; + remoteAddress = remoteAddress.split(remoteAddress.includes(".") ? ":" : ",").pop()?.trim() ?? ""; + request.headers.set("x-nf-client-connection-ip", remoteAddress); + if (this.accountID) { + request.headers.set("x-nf-account-id", this.accountID); + } + request.headers.set("x-nf-site-id", this.siteID ?? UNLINKED_SITE_MOCK_ID); + request.headers.set("x-nf-geo", Buffer2.from(JSON.stringify(this.geolocation)).toString("base64")); + const { headers: headersObject } = headersObjectFromWebHeaders(request.headers); + const clientContext = buildClientContext(headersObject) || {}; + if (func.isBackground) { + await func.invoke({ + buildCache: this.buildCache, + buildDirectory: buildDirectory ?? this.globalBuildDirectory, + request, + route + }); + return new Response(null, { status: 202 }); + } + if (await func.isScheduled()) { + const newRequest = new Request(request, { + ...request, + method: "POST" + }); + newRequest.headers.set("user-agent", CLOCKWORK_USERAGENT); + newRequest.headers.set("x-nf-event", "schedule"); + return await func.invoke({ + buildCache: this.buildCache, + buildDirectory: buildDirectory ?? this.globalBuildDirectory, + clientContext, + request: newRequest, + route + }); + } + return await func.invoke({ + buildCache: this.buildCache, + buildDirectory: buildDirectory ?? this.globalBuildDirectory, + clientContext, + request, + route + }); + } + async match(request, buildDirectory) { + await this.scan; + const url = new URL(request.url); + const match = await this.registry.getFunctionForURLPath(url.pathname, request.method); + if (!match) { + return; + } + const functionName = match?.func?.name; + if (!functionName) { + return; + } + const matchingRoute = match.route?.pattern; + const func = this.registry.get(functionName); + if (func === void 0) { + return { + handle: async () => new Response("Function not found...", { + status: 404 + }), + preferStatic: false + }; + } + if (!func.hasValidName()) { + return { + handle: async () => new Response("Function name should consist only of alphanumeric characters, hyphen & underscores.", { + status: 400 + }), + preferStatic: false + }; + } + return { + handle: (request2) => this.invoke(request2, matchingRoute, func, buildDirectory), + preferStatic: match.route?.prefer_static ?? false + }; + } +}; +export { + FunctionsHandler +}; diff --git a/packages/functions/dist-dev/worker.js b/packages/functions/dist-dev/worker.js new file mode 100644 index 00000000..b8e0b0f5 --- /dev/null +++ b/packages/functions/dist-dev/worker.js @@ -0,0 +1,106 @@ +// @ts-check +// This is a JavaScript file because we need to locate it at runtime using the +// `Worker` API and using a `.ts` complicates things. To make it type-safe, +// we use JSDoc annotations. +import { createServer } from 'node:net' +import process from 'node:process' +import { isMainThread, workerData, parentPort } from 'node:worker_threads' + +import { isStream } from 'is-stream' +import lambdaLocal from 'lambda-local' +import sourceMapSupport from 'source-map-support' + +// https://github.com/nodejs/undici/blob/a36e299d544863c5ade17d4090181be894366024/lib/web/fetch/constants.js#L6 +const nullBodyStatus = new Set([101, 204, 205, 304]) + +/** + * @typedef HandlerResponse + * @type {import('../../../src/function/handler_response.js').HandlerResponse} + */ + +/** + * @typedef WorkerResult + * @type {HandlerResponse & { streamPort?: number }} + */ + +if (isMainThread) { + throw new Error(`Do not import "${import.meta.url}" in the main thread.`) +} + +sourceMapSupport.install() + +lambdaLocal.getLogger().level = 'alert' + +const { clientContext, entryFilePath, environment = {}, event, timeoutMs } = workerData + +// Injecting into the environment any properties passed in by the parent. +for (const key in environment) { + process.env[key] = environment[key] +} +const lambdaFunc = await import(entryFilePath) +const invocationResult = /** @type {HandlerResponse} */ ( + await lambdaLocal.execute({ + clientContext, + event, + lambdaFunc, + region: 'dev', + timeoutMs, + verboseLevel: 3, + }) +) + +/** + * When the result body is a stream and result status code allow to have a body, + * open up a http server that proxies back to the main thread and resolve with server port. + * Otherwise, resolve with undefined. + * + * @param {HandlerResponse} invocationResult + * @returns {Promise} + */ +async function getStreamPortForStreamingResponse(invocationResult) { + // if we don't have result or result's body is not a stream, we do not need a stream port + if (!invocationResult || !isStream(invocationResult.body)) { + return undefined + } + + const { body } = invocationResult + + delete invocationResult.body + + // For streaming responses, lambda-local always returns a result with body stream. + // We need to discard it if result's status code does not allow response to have a body. + const shouldNotHaveABody = nullBodyStatus.has(invocationResult.statusCode) + if (shouldNotHaveABody) { + return undefined + } + + // create a server that will proxy the body stream back to the main thread + return 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 address = server.address() + + /** @type {number | undefined} */ + let streamPort + if (address && typeof address !== 'string') { + streamPort = address.port + } + + resolve(streamPort) + }) + }) +} + +const streamPort = await getStreamPortForStreamingResponse(invocationResult) + +if (parentPort) { + /** @type {WorkerResult} */ + const message = { ...invocationResult, streamPort } + + parentPort.postMessage(message) +} diff --git a/packages/otel/src/bootstrap/main.ts b/packages/otel/src/bootstrap/main.ts index 7b2eb337..875e5d6c 100644 --- a/packages/otel/src/bootstrap/main.ts +++ b/packages/otel/src/bootstrap/main.ts @@ -1,6 +1,7 @@ -import { type SpanProcessor } from '@opentelemetry/sdk-trace-node' +import { BatchSpanProcessor, ConsoleSpanExporter, type SpanProcessor } from '@opentelemetry/sdk-trace-node' import type { Instrumentation } from '@opentelemetry/instrumentation' -import { GET_TRACER, SHUTDOWN_TRACERS } from '../constants.js' +import { GET_TRACE_CONTEXT_FORWARDER, GET_TRACER, SHUTDOWN_TRACERS } from '../constants.js' +import { Context, context, W3CTraceContextPropagator } from '../opentelemetry.ts' export interface TracerProviderOptions { serviceName: string @@ -11,6 +12,7 @@ export interface TracerProviderOptions { siteName: string instrumentations?: (Instrumentation | Promise)[] spanProcessors?: (SpanProcessor | Promise)[] + propagationHeaders?: Headers } export const createTracerProvider = async (options: TracerProviderOptions) => { @@ -43,9 +45,30 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { }) nodeTracerProvider.register({ - propagator: new W3CTraceContextPropagator(), + propagator: new W3CTraceContextPropagator() + }) + let traceContextForwarder: (propagator: W3CTraceContextPropagator, requestHeaders: Headers) => void + + if (options.propagationHeaders) { + traceContextForwarder = (propagator: W3CTraceContextPropagator, requestHeaders: Headers): Context => { + const getter = { + keys: (carrier: Headers) => [...carrier.keys()], + get: (carrier: Headers, key: string) => carrier.get(key) || undefined, + } + const extractedContext = propagator.extract(context.active(), options.propagationHeaders, getter) + + propagator?.inject(context.active(), requestHeaders, { + set: (carrier, key, value) => { + carrier.set(key, value) + }, + }) + + return extractedContext + } + } + const instrumentations = await Promise.all(options.instrumentations ?? []) registerInstrumentations({ @@ -53,6 +76,7 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { tracerProvider: nodeTracerProvider, }) + const { trace } = await import('@opentelemetry/api') const { SugaredTracer } = await import('@opentelemetry/api/experimental') const { default: pkg } = await import('../../package.json', { with: { type: 'json' } }) @@ -70,6 +94,15 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { }, }) + Object.defineProperty(globalThis, GET_TRACE_CONTEXT_FORWARDER, { + enumerable: false, + configurable: true, + writable: false, + value: function() { + return traceContextForwarder + }, + }) + Object.defineProperty(globalThis, SHUTDOWN_TRACERS, { enumerable: false, configurable: true, diff --git a/packages/otel/src/constants.ts b/packages/otel/src/constants.ts index 3262a667..aaabf161 100644 --- a/packages/otel/src/constants.ts +++ b/packages/otel/src/constants.ts @@ -1,3 +1,4 @@ export const GET_TRACER = '__netlify__getTracer' export const SHUTDOWN_TRACERS = '__netlify__shutdownTracers' +export const GET_TRACE_CONTEXT_FORWARDER = '__netlify__getTraceContextForwarder' export const TRACE_PREFIX = '__nfOTLPTrace' diff --git a/packages/otel/src/instrumentations/fetch.ts b/packages/otel/src/instrumentations/fetch.ts index df3f51a7..c764434b 100644 --- a/packages/otel/src/instrumentations/fetch.ts +++ b/packages/otel/src/instrumentations/fetch.ts @@ -1,7 +1,8 @@ import * as api from '@opentelemetry/api' import { SugaredTracer } from '@opentelemetry/api/experimental' -import { _globalThis } from '@opentelemetry/core' +import { _globalThis, W3CTraceContextPropagator } from '@opentelemetry/core' import { InstrumentationConfig, type Instrumentation } from '@opentelemetry/instrumentation' +import { getTraceContextForwarder } from '../main.ts' export interface FetchInstrumentationConfig extends InstrumentationConfig { getRequestAttributes?(headers: Request): api.Attributes @@ -26,9 +27,9 @@ export class FetchInstrumentation implements Instrumentation { return this.config } - setConfig(): void {} + setConfig(): void { } - setMeterProvider(): void {} + setMeterProvider(): void { } setTracerProvider(provider: api.TracerProvider): void { this.provider = provider } @@ -120,6 +121,23 @@ export class FetchInstrumentation implements Instrumentation { return await originalFetch(resource, options) } + const traceContextForwarder = getTraceContextForwarder() + if (options?.headers && traceContextForwarder) { + const headers = new Headers(options.headers) + const extractedContext = traceContextForwarder(new W3CTraceContextPropagator(), headers) + + // Replace headers in options with the mutated version + const nextOptions: RequestInit = { ...options, headers } + + return tracer.startActiveSpan('fetch', {}, extractedContext, async (span) => { + const request = new Request(resource, nextOptions) + this.annotateFromRequest(span, request) + const response = await originalFetch(request, nextOptions) + this.annotateFromResponse(span, response) + return response + }) + } + return tracer.withActiveSpan('fetch', async (span) => { const request = new Request(resource, options) this.annotateFromRequest(span, request) diff --git a/packages/otel/src/main.ts b/packages/otel/src/main.ts index 67befeca..23d20086 100644 --- a/packages/otel/src/main.ts +++ b/packages/otel/src/main.ts @@ -1,16 +1,22 @@ import { type SugaredSpanOptions, type SugaredTracer } from '@opentelemetry/api/experimental' -import { GET_TRACER, SHUTDOWN_TRACERS } from './constants.js' +import { GET_TRACER, SHUTDOWN_TRACERS, GET_TRACE_CONTEXT_FORWARDER } from './constants.js' import type { Context, Span } from '@opentelemetry/api' +import { W3CTraceContextPropagator } from '@opentelemetry/core' type GlobalThisExtended = typeof globalThis & { [GET_TRACER]?: (name?: string, version?: string) => SugaredTracer | undefined [SHUTDOWN_TRACERS]?: () => void + [GET_TRACE_CONTEXT_FORWARDER]?: () => ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) | undefined } export const getTracer = (name?: string, version?: string): SugaredTracer | undefined => { return (globalThis as GlobalThisExtended)[GET_TRACER]?.(name, version) } +export const getTraceContextForwarder = (): ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) | undefined => { + return (globalThis as GlobalThisExtended)[GET_TRACE_CONTEXT_FORWARDER]?.() +} + export const shutdownTracers = async (): Promise => { return (globalThis as GlobalThisExtended)[SHUTDOWN_TRACERS]?.() } From 9b5f4cc98188ca5c39b98f7e5785523d81a3f1ce Mon Sep 17 00:00:00 2001 From: lemusthelroy Date: Wed, 15 Oct 2025 16:59:01 +0100 Subject: [PATCH 2/6] fix: remove build art --- packages/functions/dist-dev/builder.ts | 225 ----- packages/functions/dist-dev/config.ts | 42 - packages/functions/dist-dev/index.ts | 117 --- packages/functions/dist-dev/lambda.ts | 65 -- packages/functions/dist-dev/main.js | 1113 ------------------------ packages/functions/dist-dev/worker.js | 106 --- 6 files changed, 1668 deletions(-) delete mode 100644 packages/functions/dist-dev/builder.ts delete mode 100644 packages/functions/dist-dev/config.ts delete mode 100644 packages/functions/dist-dev/index.ts delete mode 100644 packages/functions/dist-dev/lambda.ts delete mode 100644 packages/functions/dist-dev/main.js delete mode 100644 packages/functions/dist-dev/worker.js diff --git a/packages/functions/dist-dev/builder.ts b/packages/functions/dist-dev/builder.ts deleted file mode 100644 index a1b9fb7b..00000000 --- a/packages/functions/dist-dev/builder.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' -import { createRequire } from 'node:module' -import path from 'node:path' - -import { memoize } from '@netlify/dev-utils' -import { zipFunction, listFunction, ArchiveFormat, Config as FunctionsConfig } from '@netlify/zip-it-and-ship-it' -import { FeatureFlags } from '@netlify/zip-it-and-ship-it/dist/feature_flags.js' -import decache from 'decache' -import { readPackageUp } from 'read-package-up' -import sourceMapSupport from 'source-map-support' - -import { normalizeFunctionsConfig, TOMLFunctionConfig } from './config.js' -import { NetlifyFunction } from '../../function.js' - -import { BuildCache, BuildResult, FunctionBuilder } from '../../builder.js' - -const require = createRequire(import.meta.url) - -const serveFunctionsFolder = path.join('.netlify', 'functions-serve') - -const addFunctionsConfigDefaults = (config: FunctionsConfig): FunctionsConfig => ({ - ...config, - '*': { - nodeSourcemap: true, - ...config['*'], - }, -}) - -interface BuildFunctionOptions { - cache: BuildCache - config: FunctionsConfig - directory: string - featureFlags: Record - func: NetlifyFunction - hasTypeModule: boolean - projectRoot: string - targetDirectory: string -} - -const buildFunction = async ({ - cache, - config, - directory, - featureFlags, - func, - hasTypeModule, - projectRoot, - targetDirectory, -}: BuildFunctionOptions) => { - const zipOptions = { - archiveFormat: 'none' as ArchiveFormat, - basePath: projectRoot, - config, - featureFlags: { ...featureFlags, zisi_functions_api_v2: true } as FeatureFlags, - } - const functionDirectory = path.dirname(func.mainFile) - - // If we have a function at `functions/my-func/index.js` and we pass - // that path to `zipFunction`, it will lack the context of the whole - // functions directory and will infer the name of the function to be - // `index`, not `my-func`. Instead, we need to pass the directory of - // the function. The exception is when the function is a file at the - // root of the functions directory (e.g. `functions/my-func.js`). In - // this case, we use `mainFile` as the function path of `zipFunction`. - const entryPath = functionDirectory === directory ? func.mainFile : functionDirectory - const buildResult = await memoize({ - cache, - cacheKey: `zisi-${entryPath}`, - command: () => zipFunction(entryPath, targetDirectory, zipOptions), - }) - - if (!buildResult) { - return - } - - const { - entryFilename, - excludedRoutes, - includedFiles, - inputs, - mainFile, - outputModuleFormat, - path: functionPath, - routes, - runtimeAPIVersion, - schedule, - } = buildResult - const srcFiles = (inputs ?? []).filter((inputPath) => !inputPath.includes(`${path.sep}node_modules${path.sep}`)) - 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. - if (hasTypeModule) { - await writeFile( - path.join(functionPath, `package.json`), - JSON.stringify({ - type: 'commonjs', - }), - ) - } - - clearFunctionsCache(targetDirectory) - - return { - buildPath, - excludedRoutes, - includedFiles, - outputModuleFormat, - mainFile, - routes, - runtimeAPIVersion, - srcFiles, - schedule, - targetDirectory, - } -} - -interface ParseFunctionForMetadataOptions { - config: any - mainFile: string - projectRoot: string -} - -export const parseFunctionForMetadata = async ({ config, mainFile, projectRoot }: ParseFunctionForMetadataOptions) => - await listFunction(mainFile, { - config: netlifyConfigToZisiConfig(config.functions, projectRoot), - featureFlags: { zisi_functions_api_v2: true } as FeatureFlags, - parseISC: true, - }) - -// Clears the cache for any files inside the directory from which functions are -// served. -const clearFunctionsCache = (functionsPath: string) => { - Object.keys(require.cache) - .filter((key) => key.startsWith(functionsPath)) - .forEach(decache) -} - -const netlifyConfigToZisiConfig = (functionsConfig: Record, projectRoot: string) => - addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig, projectRoot })) - -interface HandlerOptions { - config: any - directory: string - func: NetlifyFunction - metadata: any - projectRoot: string - targetDirectory: string -} - -export const getNoopBuilder = async ({ directory, func, metadata }: HandlerOptions): Promise => { - const functionDirectory = path.dirname(func.mainFile) - const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory] - const build = async () => - ({ - buildPath: '', - excludedRoutes: [], - includedFiles: [], - mainFile: func.mainFile, - outputModuleFormat: 'cjs', - routes: [], - runtimeAPIVersion: func.runtimeAPIVersion, - schedule: metadata.schedule, - srcFiles, - }) as BuildResult - - return { - build, - builderName: '', - } -} - -export const getZISIBuilder = async ({ - config, - directory, - func, - metadata, - projectRoot, - targetDirectory, -}: HandlerOptions): Promise => { - const functionsConfig = netlifyConfigToZisiConfig(config.functions, projectRoot) - const packageJson = await readPackageUp({ cwd: path.dirname(func.mainFile) }) - const hasTypeModule = Boolean(packageJson && packageJson.packageJson.type === 'module') - const featureFlags: FeatureFlags = {} - - if (metadata.runtimeAPIVersion === 2) { - 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 { nodeBundler } = functionsConfig['*'] - const isUsingEsbuild = nodeBundler === 'esbuild_zisi' || nodeBundler === 'esbuild' - - if (!isUsingEsbuild) { - return null - } - } - - // Enable source map support. - sourceMapSupport.install() - - return { - build: ({ cache = {} }: { cache?: BuildCache }) => - buildFunction({ - cache, - config: functionsConfig, - directory, - func, - projectRoot, - targetDirectory, - hasTypeModule, - featureFlags, - }), - builderName: 'zip-it-and-ship-it', - } -} diff --git a/packages/functions/dist-dev/config.ts b/packages/functions/dist-dev/config.ts deleted file mode 100644 index 6818e10f..00000000 --- a/packages/functions/dist-dev/config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Config as FunctionsConfig, NodeBundlerName } from '@netlify/zip-it-and-ship-it' - -// TODO: Import from `@netlify/config`. -export interface TOMLFunctionConfig { - external_node_modules: string[] - ignored_node_modules: string[] - included_files: string[] - node_bundler: string - schedule: string -} - -interface NormalizeFunctionsConfigOptions { - functionsConfig: Record - projectRoot: string - siteEnv?: Record -} - -// The function configuration keys returned by @netlify/config are not an exact -// match to the properties that @netlify/zip-it-and-ship-it expects. We do that -// translation here. -export const normalizeFunctionsConfig = ({ - functionsConfig = {}, - projectRoot, - siteEnv = {}, -}: NormalizeFunctionsConfigOptions) => - Object.entries(functionsConfig).reduce( - (result, [pattern, config]) => ({ - ...result, - [pattern]: { - externalNodeModules: config.external_node_modules, - includedFiles: config.included_files, - includedFilesBasePath: projectRoot, - ignoredNodeModules: config.ignored_node_modules, - nodeBundler: (config.node_bundler === 'esbuild' ? 'esbuild_zisi' : config.node_bundler) as NodeBundlerName, - nodeVersion: siteEnv.AWS_LAMBDA_JS_RUNTIME, - processDynamicNodeImports: true, - schedule: config.schedule, - zipGo: true, - }, - }), - {} as FunctionsConfig, - ) diff --git a/packages/functions/dist-dev/index.ts b/packages/functions/dist-dev/index.ts deleted file mode 100644 index 2b7feb1c..00000000 --- a/packages/functions/dist-dev/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { createConnection } from 'node:net' -import { pathToFileURL } from 'node:url' -import { Worker } from 'node:worker_threads' - -import lambdaLocal from 'lambda-local' - -import type { NetlifyFunction } from '../../function.js' -import type { Runtime } from '../index.js' - -import { getNoopBuilder, getZISIBuilder, parseFunctionForMetadata } from './builder.js' - -// TODO: Find better place for this. -const BLOBS_CONTEXT_VARIABLE = 'NETLIFY_BLOBS_CONTEXT' - -lambdaLocal.getLogger().level = 'alert' - -import { HandlerEvent, HandlerResponse } from '../../../src/main.js' -import { lambdaEventFromWebRequest, webResponseFromLambdaResponse } from './lambda.js' - -export const nodeJSRuntime: Runtime = { - getBuildFunction: async ({ config, directory, func, projectRoot, targetDirectory }) => { - const metadata = await parseFunctionForMetadata({ mainFile: func.mainFile, config, projectRoot }) - const zisiBuilder = await getZISIBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }) - - if (zisiBuilder) { - return zisiBuilder.build - } - - const noopBuilder = await getNoopBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }) - - return noopBuilder.build - }, - - invokeFunction: async ({ context, environment, func, request, route, timeout }) => { - const event = await lambdaEventFromWebRequest(request, route) - const buildData = await func.getBuildData() - - if (buildData?.runtimeAPIVersion !== 2) { - const lambdaResponse = await invokeFunctionDirectly({ context, event, func, timeout }) - - return webResponseFromLambdaResponse(lambdaResponse) - } - - const workerData = { - clientContext: JSON.stringify(context), - environment, - 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(buildData?.buildPath ?? func.mainFile).href, - timeoutMs: timeout * 1_000, - } - - const worker = new Worker(workerURL, { workerData }) - const lambdaResponse = 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) - }) - - return webResponseFromLambdaResponse(lambdaResponse) - }, -} - -const workerURL = new URL('worker.js', import.meta.url) - -export const invokeFunctionDirectly = async ({ - context, - event, - func, - timeout, -}: { - context: any - event: HandlerEvent - func: NetlifyFunction - timeout: number -}) => { - // If a function builder has defined a `buildPath` property, we use it. - // Otherwise, we'll invoke the function's main file. - const buildData = await func.getBuildData() - const lambdaPath = buildData?.buildPath ?? func.mainFile - const result = (await lambdaLocal.execute({ - clientContext: JSON.stringify(context), - environment: { - // We've set the Blobs context on the parent process, which means it will - // be available to the Lambda. This would be inconsistent with production - // where only V2 functions get the context injected. To fix it, unset the - // context variable before invoking the function. - // This has the side-effect of also removing the variable from `process.env`. - [BLOBS_CONTEXT_VARIABLE]: undefined, - }, - event, - lambdaPath, - timeoutMs: timeout * 1_000, - verboseLevel: 3, - esm: lambdaPath.endsWith('.mjs'), - })) as HandlerResponse - - return result -} diff --git a/packages/functions/dist-dev/lambda.ts b/packages/functions/dist-dev/lambda.ts deleted file mode 100644 index 717f9a39..00000000 --- a/packages/functions/dist-dev/lambda.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { shouldBase64Encode } from '@netlify/dev-utils' -import { HandlerEvent } from '../../../src/function/handler_event.js' -import { HandlerResponse } from '../../../src/function/handler_response.js' - -export const headersObjectFromWebHeaders = (webHeaders: Headers) => { - const headers: Record = {} - const multiValueHeaders: Record = {} - - webHeaders.forEach((value, key) => { - headers[key] = value - multiValueHeaders[key] = value.split(',').map((value) => value.trim()) - }) - - return { - headers, - multiValueHeaders, - } -} - -export const webHeadersFromHeadersObject = (headersObject: HandlerResponse['headers']) => { - const headers = new Headers() - - Object.entries(headersObject ?? {}).forEach(([name, value]) => { - if (value !== undefined) { - headers.set(name.toLowerCase(), value.toString()) - } - }) - - return headers -} - -export const lambdaEventFromWebRequest = async (request: Request, route?: string): Promise => { - const url = new URL(request.url) - const queryStringParameters: Record = {} - const multiValueQueryStringParameters: Record = {} - - url.searchParams.forEach((value, key) => { - queryStringParameters[key] = queryStringParameters[key] ? `${queryStringParameters[key]},${value}` : value - multiValueQueryStringParameters[key] = [...(multiValueQueryStringParameters[key] ?? []), value] - }) - - const { headers, multiValueHeaders } = headersObjectFromWebHeaders(request.headers) - const body = (await request.text()) || null - - return { - rawUrl: url.toString(), - rawQuery: url.search, - path: url.pathname, - httpMethod: request.method, - headers, - multiValueHeaders, - queryStringParameters, - multiValueQueryStringParameters, - body, - isBase64Encoded: shouldBase64Encode(request.headers.get('content-type') ?? ''), - route, - } -} - -export const webResponseFromLambdaResponse = async (lambdaResponse: HandlerResponse): Promise => { - return new Response(lambdaResponse.body, { - headers: webHeadersFromHeadersObject(lambdaResponse.headers), - status: lambdaResponse.statusCode, - }) -} diff --git a/packages/functions/dist-dev/main.js b/packages/functions/dist-dev/main.js deleted file mode 100644 index 02cedbf0..00000000 --- a/packages/functions/dist-dev/main.js +++ /dev/null @@ -1,1113 +0,0 @@ -// dev/main.ts -import { Buffer as Buffer2 } from "buffer"; - -// dev/registry.ts -import { stat } from "fs/promises"; -import { createRequire as createRequire2 } from "module"; -import { basename as basename2, extname as extname2, isAbsolute, join, resolve } from "path"; -import { env } from "process"; -import { watchDebounced } from "@netlify/dev-utils"; -import { listFunctions } from "@netlify/zip-it-and-ship-it"; -import extractZip from "extract-zip"; - -// dev/function.ts -import { basename, extname } from "path"; -import { version as nodeVersion } from "process"; -import { headers as netlifyHeaders, renderFunctionErrorPage } from "@netlify/dev-utils"; -import CronParser from "cron-parser"; -import semver from "semver"; -var BACKGROUND_FUNCTION_SUFFIX = "-background"; -var TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".cts", ".mts", ".ts"]); -var V2_MIN_NODE_VERSION = "18.14.0"; -var difference = (setA, setB) => new Set([...setA].filter((item) => !setB.has(item))); -var getNextRun = function(schedule) { - const cron = CronParser.parseExpression(schedule, { - tz: "Etc/UTC" - }); - return cron.next().toDate(); -}; -var getBlobsEventProperty = (context) => ({ - primary_region: context.primaryRegion, - url: context.edgeURL, - url_uncached: context.edgeURL, - token: context.token -}); -var NetlifyFunction = class { - name; - mainFile; - displayName; - schedule; - runtime; - blobsContext; - config; - directory; - projectRoot; - settings; - timeoutBackground; - timeoutSynchronous; - // Determines whether this is a background function based on the function - // name. - isBackground; - buildQueue; - buildData; - buildError = null; - // List of the function's source files. This starts out as an empty set - // and will get populated on every build. - srcFiles = /* @__PURE__ */ new Set(); - excludedRoutes; - routes; - constructor({ - blobsContext, - config, - directory, - displayName, - excludedRoutes, - mainFile, - name, - projectRoot, - routes, - runtime, - settings, - timeoutBackground, - timeoutSynchronous - }) { - this.blobsContext = blobsContext; - this.config = config; - this.directory = directory; - this.excludedRoutes = excludedRoutes; - this.mainFile = mainFile; - this.name = name; - this.displayName = displayName ?? name; - this.projectRoot = projectRoot; - this.routes = routes; - this.runtime = runtime; - this.timeoutBackground = timeoutBackground; - this.timeoutSynchronous = timeoutSynchronous; - this.settings = settings; - this.isBackground = name.endsWith(BACKGROUND_FUNCTION_SUFFIX); - const functionConfig = config.functions?.[name]; - this.schedule = functionConfig?.schedule; - this.srcFiles = /* @__PURE__ */ new Set(); - } - get filename() { - if (!this.buildData?.mainFile) { - return null; - } - return basename(this.buildData.mainFile); - } - getRecommendedExtension() { - if (this.buildData?.runtimeAPIVersion !== 2) { - return; - } - const extension = this.buildData?.mainFile ? extname(this.buildData.mainFile) : void 0; - const moduleFormat = this.buildData?.outputModuleFormat; - if (moduleFormat === "esm") { - return; - } - if (extension === ".ts") { - return ".mts"; - } - if (extension === ".js") { - return ".mjs"; - } - } - hasValidName() { - return /^[A-Za-z0-9_-]+$/.test(this.name); - } - async isScheduled() { - await this.buildQueue; - return Boolean(this.schedule); - } - isSupported() { - return !(this.buildData?.runtimeAPIVersion === 2 && semver.lt(nodeVersion, V2_MIN_NODE_VERSION)); - } - isTypeScript() { - if (this.filename === null) { - return false; - } - return TYPESCRIPT_EXTENSIONS.has(extname(this.filename)); - } - async getNextRun() { - if (!await this.isScheduled()) { - return null; - } - return getNextRun(this.schedule); - } - // The `build` method transforms source files into invocable functions. Its - // return value is an object with: - // - // - `srcFilesDiff`: Files that were added and removed since the last time - // the function was built. - async build({ buildDirectory, cache }) { - this.buildQueue = this.runtime.getBuildFunction({ - config: this.config, - directory: this.directory, - func: this, - projectRoot: this.projectRoot, - targetDirectory: buildDirectory - }).then((buildFunction2) => buildFunction2({ cache })); - try { - const buildData = await this.buildQueue; - if (buildData === void 0) { - throw new Error(`Could not build function ${this.name}`); - } - const { includedFiles = [], routes, schedule, srcFiles } = buildData; - const srcFilesSet = new Set(srcFiles); - const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet); - this.buildData = buildData; - this.buildError = null; - this.routes = routes; - this.srcFiles = srcFilesSet; - this.schedule = schedule || this.schedule; - if (!this.isSupported()) { - throw new Error( - `Function requires Node.js version ${V2_MIN_NODE_VERSION} or above, but ${nodeVersion.slice( - 1 - )} is installed. Refer to https://ntl.fyi/functions-runtime for information on how to update.` - ); - } - return { includedFiles, srcFilesDiff }; - } catch (error) { - if (error instanceof Error) { - this.buildError = error; - } - return { error }; - } - } - formatError(rawError, acceptsHTML) { - const error = this.normalizeError(rawError); - if (acceptsHTML) { - return JSON.stringify({ - ...error, - stackTrace: void 0, - trace: error.stackTrace - }); - } - return `${error.errorType}: ${error.errorMessage} - ${error.stackTrace.join("\n")}`; - } - async getBuildData() { - await this.buildQueue; - return this.buildData; - } - // Compares a new set of source files against a previous one, returning an - // object with two Sets, one with added and the other with deleted files. - getSrcFilesDiff(newSrcFiles) { - const added = difference(newSrcFiles, this.srcFiles); - const deleted = difference(this.srcFiles, newSrcFiles); - return { - added, - deleted - }; - } - async handleError(rawError, acceptsHTML) { - const errorString = typeof rawError === "string" ? rawError : this.formatError(rawError, acceptsHTML); - const status = 500; - if (acceptsHTML) { - const body = await renderFunctionErrorPage(errorString, "function"); - return new Response(body, { - headers: { - "Content-Type": "text/html" - }, - status - }); - } - return new Response(errorString, { status }); - } - // Invokes the function and returns its response object. - async invoke({ buildCache = {}, buildDirectory, clientContext = {}, request, route }) { - if (buildDirectory) { - await this.build({ buildDirectory, cache: buildCache }); - } else { - await this.buildQueue; - } - if (this.buildError) { - throw this.buildError; - } - const timeout = this.isBackground ? this.timeoutBackground : this.timeoutSynchronous; - const environment = {}; - if (this.blobsContext) { - const payload = JSON.stringify(getBlobsEventProperty(this.blobsContext)); - request.headers.set(netlifyHeaders.BlobsInfo, Buffer.from(payload).toString("base64")); - } - try { - return await this.runtime.invokeFunction({ - context: clientContext, - environment, - func: this, - request, - route, - timeout - }); - } catch (error) { - const acceptsHTML = request.headers.get("accept")?.includes("text/html"); - return await this.handleError(error, Boolean(acceptsHTML)); - } - } - /** - * Matches all routes agains the incoming request. If a match is found, then the matched route is returned. - * @returns matched route - */ - async matchURLPath(rawPath, method) { - let path2 = rawPath !== "/" && rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath; - path2 = path2.toLowerCase(); - const { excludedRoutes = [], routes = [] } = this; - const matchingRoute = routes.find((route) => { - if (route.methods && route.methods.length !== 0 && !route.methods.includes(method)) { - return false; - } - if ("literal" in route && route.literal !== void 0) { - return path2 === route.literal; - } - if ("expression" in route && route.expression !== void 0) { - const regex = new RegExp(route.expression); - return regex.test(path2); - } - return false; - }); - if (!matchingRoute) { - return; - } - const isExcluded = excludedRoutes.some((excludedRoute) => { - if ("literal" in excludedRoute && excludedRoute.literal !== void 0) { - return path2 === excludedRoute.literal; - } - if ("expression" in excludedRoute && excludedRoute.expression !== void 0) { - const regex = new RegExp(excludedRoute.expression); - return regex.test(path2); - } - return false; - }); - if (isExcluded) { - return; - } - return matchingRoute; - } - normalizeError(error) { - if (error instanceof Error) { - const normalizedError = { - errorMessage: error.message, - errorType: error.name, - stackTrace: error.stack ? error.stack.split("\n") : [] - }; - if ("code" in error && error.code === "ERR_REQUIRE_ESM") { - return { - ...normalizedError, - errorMessage: "a CommonJS file cannot import ES modules. Consider switching your function to ES modules. For more information, refer to https://ntl.fyi/functions-runtime." - }; - } - return normalizedError; - } - const stackTrace = error.stackTrace.map((line) => ` at ${line}`); - return { - errorType: error.errorType, - errorMessage: error.errorMessage, - stackTrace - }; - } - get runtimeAPIVersion() { - return this.buildData?.runtimeAPIVersion ?? 1; - } - setRoutes(routes) { - if (this.buildData) { - this.buildData.routes = routes; - } - } - get url() { - const port = this.settings.port || this.settings.functionsPort; - const protocol = this.settings.https ? "https" : "http"; - const url = new URL(`/.netlify/functions/${this.name}`, `${protocol}://localhost:${port}`); - return url.href; - } -}; - -// dev/runtimes/nodejs/index.ts -import { createConnection } from "net"; -import { pathToFileURL } from "url"; -import { Worker } from "worker_threads"; -import lambdaLocal from "lambda-local"; - -// dev/runtimes/nodejs/builder.ts -import { writeFile } from "fs/promises"; -import { createRequire } from "module"; -import path from "path"; -import { memoize } from "@netlify/dev-utils"; -import { zipFunction, listFunction } from "@netlify/zip-it-and-ship-it"; -import decache from "decache"; -import { readPackageUp } from "read-package-up"; -import sourceMapSupport from "source-map-support"; - -// dev/runtimes/nodejs/config.ts -var normalizeFunctionsConfig = ({ - functionsConfig = {}, - projectRoot, - siteEnv = {} -}) => Object.entries(functionsConfig).reduce( - (result, [pattern, config]) => ({ - ...result, - [pattern]: { - externalNodeModules: config.external_node_modules, - includedFiles: config.included_files, - includedFilesBasePath: projectRoot, - ignoredNodeModules: config.ignored_node_modules, - nodeBundler: config.node_bundler === "esbuild" ? "esbuild_zisi" : config.node_bundler, - nodeVersion: siteEnv.AWS_LAMBDA_JS_RUNTIME, - processDynamicNodeImports: true, - schedule: config.schedule, - zipGo: true - } - }), - {} -); - -// dev/runtimes/nodejs/builder.ts -var require2 = createRequire(import.meta.url); -var serveFunctionsFolder = path.join(".netlify", "functions-serve"); -var addFunctionsConfigDefaults = (config) => ({ - ...config, - "*": { - nodeSourcemap: true, - ...config["*"] - } -}); -var 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); - const entryPath = functionDirectory === directory ? func.mainFile : functionDirectory; - const buildResult = await memoize({ - cache, - cacheKey: `zisi-${entryPath}`, - command: () => zipFunction(entryPath, targetDirectory, zipOptions) - }); - if (!buildResult) { - return; - } - const { - entryFilename, - excludedRoutes, - includedFiles, - inputs, - mainFile, - outputModuleFormat, - path: functionPath, - routes, - runtimeAPIVersion, - schedule - } = buildResult; - const srcFiles = (inputs ?? []).filter((inputPath) => !inputPath.includes(`${path.sep}node_modules${path.sep}`)); - const buildPath = path.join(functionPath, entryFilename); - if (hasTypeModule) { - await writeFile( - path.join(functionPath, `package.json`), - JSON.stringify({ - type: "commonjs" - }) - ); - } - clearFunctionsCache(targetDirectory); - return { - buildPath, - excludedRoutes, - includedFiles, - outputModuleFormat, - mainFile, - routes, - runtimeAPIVersion, - srcFiles, - schedule, - targetDirectory - }; -}; -var parseFunctionForMetadata = async ({ config, mainFile, projectRoot }) => await listFunction(mainFile, { - config: netlifyConfigToZisiConfig(config.functions, projectRoot), - featureFlags: { zisi_functions_api_v2: true }, - parseISC: true -}); -var clearFunctionsCache = (functionsPath) => { - Object.keys(require2.cache).filter((key) => key.startsWith(functionsPath)).forEach(decache); -}; -var netlifyConfigToZisiConfig = (functionsConfig, projectRoot) => addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig, projectRoot })); -var getNoopBuilder = async ({ directory, func, metadata }) => { - const functionDirectory = path.dirname(func.mainFile); - const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory]; - const build = async () => ({ - buildPath: "", - excludedRoutes: [], - includedFiles: [], - mainFile: func.mainFile, - outputModuleFormat: "cjs", - routes: [], - runtimeAPIVersion: func.runtimeAPIVersion, - schedule: metadata.schedule, - srcFiles - }); - return { - build, - builderName: "" - }; -}; -var getZISIBuilder = async ({ - config, - directory, - func, - metadata, - projectRoot, - targetDirectory -}) => { - const functionsConfig = netlifyConfigToZisiConfig(config.functions, projectRoot); - const packageJson = await readPackageUp({ cwd: path.dirname(func.mainFile) }); - const hasTypeModule = Boolean(packageJson && packageJson.packageJson.type === "module"); - const featureFlags = {}; - if (metadata.runtimeAPIVersion === 2) { - featureFlags.zisi_pure_esm = true; - featureFlags.zisi_pure_esm_mjs = true; - } else { - const mustTranspile = [".mjs", ".ts", ".mts", ".cts"].includes(path.extname(func.mainFile)); - const mustUseEsbuild = hasTypeModule || mustTranspile; - if (mustUseEsbuild && !functionsConfig["*"].nodeBundler) { - functionsConfig["*"].nodeBundler = "esbuild"; - } - const { nodeBundler } = functionsConfig["*"]; - const isUsingEsbuild = nodeBundler === "esbuild_zisi" || nodeBundler === "esbuild"; - if (!isUsingEsbuild) { - return null; - } - } - sourceMapSupport.install(); - return { - build: ({ cache = {} }) => buildFunction({ - cache, - config: functionsConfig, - directory, - func, - projectRoot, - targetDirectory, - hasTypeModule, - featureFlags - }), - builderName: "zip-it-and-ship-it" - }; -}; - -// dev/runtimes/nodejs/lambda.ts -import { shouldBase64Encode } from "@netlify/dev-utils"; -var headersObjectFromWebHeaders = (webHeaders) => { - const headers = {}; - const multiValueHeaders = {}; - webHeaders.forEach((value, key) => { - headers[key] = value; - multiValueHeaders[key] = value.split(",").map((value2) => value2.trim()); - }); - return { - headers, - multiValueHeaders - }; -}; -var webHeadersFromHeadersObject = (headersObject) => { - const headers = new Headers(); - Object.entries(headersObject ?? {}).forEach(([name, value]) => { - if (value !== void 0) { - headers.set(name.toLowerCase(), value.toString()); - } - }); - return headers; -}; -var lambdaEventFromWebRequest = async (request, route) => { - const url = new URL(request.url); - const queryStringParameters = {}; - const multiValueQueryStringParameters = {}; - url.searchParams.forEach((value, key) => { - queryStringParameters[key] = queryStringParameters[key] ? `${queryStringParameters[key]},${value}` : value; - multiValueQueryStringParameters[key] = [...multiValueQueryStringParameters[key] ?? [], value]; - }); - const { headers, multiValueHeaders } = headersObjectFromWebHeaders(request.headers); - const body = await request.text() || null; - return { - rawUrl: url.toString(), - rawQuery: url.search, - path: url.pathname, - httpMethod: request.method, - headers, - multiValueHeaders, - queryStringParameters, - multiValueQueryStringParameters, - body, - isBase64Encoded: shouldBase64Encode(request.headers.get("content-type") ?? ""), - route - }; -}; -var webResponseFromLambdaResponse = async (lambdaResponse) => { - return new Response(lambdaResponse.body, { - headers: webHeadersFromHeadersObject(lambdaResponse.headers), - status: lambdaResponse.statusCode - }); -}; - -// dev/runtimes/nodejs/index.ts -var BLOBS_CONTEXT_VARIABLE = "NETLIFY_BLOBS_CONTEXT"; -lambdaLocal.getLogger().level = "alert"; -var nodeJSRuntime = { - getBuildFunction: async ({ config, directory, func, projectRoot, targetDirectory }) => { - const metadata = await parseFunctionForMetadata({ mainFile: func.mainFile, config, projectRoot }); - const zisiBuilder = await getZISIBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }); - if (zisiBuilder) { - return zisiBuilder.build; - } - const noopBuilder = await getNoopBuilder({ config, directory, func, metadata, projectRoot, targetDirectory }); - return noopBuilder.build; - }, - invokeFunction: async ({ context, environment, func, request, route, timeout }) => { - const event = await lambdaEventFromWebRequest(request, route); - const buildData = await func.getBuildData(); - if (buildData?.runtimeAPIVersion !== 2) { - const lambdaResponse2 = await invokeFunctionDirectly({ context, event, func, timeout }); - return webResponseFromLambdaResponse(lambdaResponse2); - } - const workerData = { - clientContext: JSON.stringify(context), - environment, - 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(buildData?.buildPath ?? func.mainFile).href, - timeoutMs: timeout * 1e3 - }; - const worker = new Worker(workerURL, { workerData }); - const lambdaResponse = await new Promise((resolve2, reject) => { - worker.on("message", (result) => { - if (result?.streamPort) { - const client = createConnection( - { - port: result.streamPort, - host: "localhost" - }, - () => { - result.body = client; - resolve2(result); - } - ); - client.on("error", reject); - } else { - resolve2(result); - } - }); - worker.on("error", reject); - }); - return webResponseFromLambdaResponse(lambdaResponse); - } -}; -var workerURL = new URL("worker.js", import.meta.url); -var invokeFunctionDirectly = async ({ - context, - event, - func, - timeout -}) => { - const buildData = await func.getBuildData(); - const lambdaPath = buildData?.buildPath ?? func.mainFile; - const result = await lambdaLocal.execute({ - clientContext: JSON.stringify(context), - environment: { - // We've set the Blobs context on the parent process, which means it will - // be available to the Lambda. This would be inconsistent with production - // where only V2 functions get the context injected. To fix it, unset the - // context variable before invoking the function. - // This has the side-effect of also removing the variable from `process.env`. - [BLOBS_CONTEXT_VARIABLE]: void 0 - }, - event, - lambdaPath, - timeoutMs: timeout * 1e3, - verboseLevel: 3, - esm: lambdaPath.endsWith(".mjs") - }); - return result; -}; - -// dev/runtimes/index.ts -var runtimes = { - js: nodeJSRuntime -}; - -// dev/registry.ts -var DEFAULT_FUNCTION_URL_EXPRESSION = /^\/.netlify\/(functions|builders)\/([^/]+).*/; -var TYPES_PACKAGE = "@netlify/functions"; -var FunctionsRegistry = class { - /** - * Context object for Netlify Blobs - */ - blobsContext; - /** - * The functions held by the registry - */ - functions = /* @__PURE__ */ new Map(); - /** - * File watchers for function files. Maps function names to objects built - * by the `watchDebounced` utility. - */ - functionWatchers = /* @__PURE__ */ new Map(); - /** - * Keeps track of whether we've checked whether `TYPES_PACKAGE` is - * installed. - */ - hasCheckedTypesPackage = false; - buildCache; - config; - debug; - destPath; - directoryWatchers; - handleEvent; - frameworksAPIFunctionsPath; - internalFunctionsPath; - manifest; - projectRoot; - timeouts; - settings; - watch; - constructor({ - blobsContext, - config, - debug = false, - destPath, - eventHandler, - frameworksAPIFunctionsPath, - internalFunctionsPath, - manifest, - projectRoot, - settings, - timeouts, - watch - }) { - this.blobsContext = blobsContext; - this.config = config; - this.debug = debug; - this.destPath = destPath; - this.frameworksAPIFunctionsPath = frameworksAPIFunctionsPath; - this.handleEvent = eventHandler ?? (() => { - }); - this.internalFunctionsPath = internalFunctionsPath; - this.projectRoot = projectRoot; - this.timeouts = timeouts; - this.settings = settings; - this.watch = watch === true; - this.buildCache = {}; - this.directoryWatchers = /* @__PURE__ */ new Map(); - this.manifest = manifest; - } - async checkTypesPackage() { - if (this.hasCheckedTypesPackage) { - return; - } - this.hasCheckedTypesPackage = true; - const require3 = createRequire2(this.projectRoot); - try { - require3.resolve(TYPES_PACKAGE, { paths: [this.projectRoot] }); - } catch (error) { - if (error?.code === "MODULE_NOT_FOUND") { - this.handleEvent({ name: "FunctionMissingTypesPackageEvent" }); - } - } - } - /** - * Builds a function and sets up the appropriate file watchers so that any - * changes will trigger another build. - */ - async buildFunctionAndWatchFiles(func, firstLoad = false) { - if (!firstLoad) { - this.handleEvent({ function: func, name: "FunctionReloadingEvent" }); - } - const { - error: buildError, - includedFiles, - srcFilesDiff - } = await func.build({ buildDirectory: this.destPath, cache: this.buildCache }); - if (buildError) { - this.handleEvent({ function: func, name: "FunctionBuildErrorEvent" }); - } else { - this.handleEvent({ firstLoad, function: func, name: "FunctionLoadedEvent" }); - } - if (func.isTypeScript()) { - this.checkTypesPackage(); - } - if (!srcFilesDiff) { - return; - } - if (!this.watch) { - return; - } - const watcher = this.functionWatchers.get(func.name); - if (watcher) { - srcFilesDiff.deleted.forEach((path2) => { - watcher.unwatch(path2); - }); - srcFilesDiff.added.forEach((path2) => { - watcher.add(path2); - }); - return; - } - if (srcFilesDiff.added.size !== 0) { - const filesToWatch = [...srcFilesDiff.added, ...includedFiles]; - const newWatcher = await watchDebounced(filesToWatch, { - onChange: () => { - this.buildFunctionAndWatchFiles(func, false); - } - }); - this.functionWatchers.set(func.name, newWatcher); - } - } - set eventHandler(handler) { - this.handleEvent = handler; - } - /** - * Returns a function by name. - */ - get(name) { - return this.functions.get(name); - } - /** - * Looks for the first function that matches a given URL path. If a match is - * found, returns an object with the function and the route. If the URL path - * matches the default functions URL (i.e. can only be for a function) but no - * function with the given name exists, returns an object with the function - * and the route set to `null`. Otherwise, `undefined` is returned, - */ - async getFunctionForURLPath(urlPath, method) { - const url = new URL(`http://localhost${urlPath}`); - const defaultURLMatch = DEFAULT_FUNCTION_URL_EXPRESSION.exec(url.pathname); - if (defaultURLMatch) { - const func = this.get(defaultURLMatch[2]); - if (!func) { - return { func: null, route: null }; - } - const { routes = [] } = func; - if (routes.length !== 0) { - this.handleEvent({ - function: func, - name: "FunctionNotInvokableOnPathEvent", - urlPath - }); - return; - } - return { func, route: null }; - } - for (const func of this.functions.values()) { - const route = await func.matchURLPath(url.pathname, method); - if (route) { - return { func, route }; - } - } - } - isInternalFunction(func) { - if (this.internalFunctionsPath && func.mainFile.includes(this.internalFunctionsPath)) { - return true; - } - if (this.frameworksAPIFunctionsPath && func.mainFile.includes(this.frameworksAPIFunctionsPath)) { - return true; - } - return false; - } - /** - * Adds a function to the registry - */ - async registerFunction(name, func, isReload = false) { - this.handleEvent({ function: func, name: "FunctionRegisteredEvent" }); - if (extname2(func.mainFile) === ".zip") { - const unzippedDirectory = await this.unzipFunction(func); - const manifestEntry = (this.manifest?.functions || []).find((manifestFunc) => manifestFunc.name === func.name); - if (!manifestEntry) { - return; - } - if (this.debug) { - this.handleEvent({ function: func, name: "FunctionExtractedEvent" }); - } - func.setRoutes(manifestEntry?.routes); - try { - const v2EntryPointPath = join(unzippedDirectory, "___netlify-entry-point.mjs"); - await stat(v2EntryPointPath); - func.mainFile = v2EntryPointPath; - } catch { - func.mainFile = join(unzippedDirectory, basename2(manifestEntry.mainFile)); - } - } else if (this.watch) { - this.buildFunctionAndWatchFiles(func, !isReload); - } - this.functions.set(name, func); - } - /** - * A proxy to zip-it-and-ship-it's `listFunctions` method. It exists just so - * that we can mock it in tests. - */ - async listFunctions(...args) { - return await listFunctions(...args); - } - /** - * Takes a list of directories and scans for functions. It keeps tracks of - * any functions in those directories that we've previously seen, and takes - * care of registering and unregistering functions as they come and go. - */ - async scan(relativeDirs) { - const directories = relativeDirs.filter((dir) => Boolean(dir)).map((dir) => isAbsolute(dir) ? dir : join(this.projectRoot, dir)); - if (directories.length === 0) { - return; - } - const functions = await this.listFunctions(directories, { - featureFlags: { - buildRustSource: env.NETLIFY_EXPERIMENTAL_BUILD_RUST_SOURCE === "true" - }, - configFileDirectories: [this.internalFunctionsPath].filter(Boolean), - config: this.config.functions, - parseISC: true - }); - const ignoredFunctions = new Set( - functions.filter( - (func) => this.isInternalFunction(func) && this.functions.has(func.name) && !this.isInternalFunction(this.functions.get(func.name)) - ).map((func) => func.name) - ); - const deletedFunctions = [...this.functions.values()].filter((oldFunc) => { - const isFound = functions.some( - (newFunc) => ignoredFunctions.has(newFunc.name) || newFunc.name === oldFunc.name && newFunc.mainFile === oldFunc.mainFile - ); - return !isFound; - }); - await Promise.all(deletedFunctions.map((func) => this.unregisterFunction(func))); - const deletedFunctionNames = new Set(deletedFunctions.map((func) => func.name)); - const addedFunctions = await Promise.all( - // zip-it-and-ship-it returns an array sorted based on which extension should have precedence, - // where the last ones precede the previous ones. This is why - // we reverse the array so we get the right functions precedence in the CLI. - functions.reverse().map(async ({ displayName, excludedRoutes, mainFile, name, routes, runtime: runtimeName }) => { - if (ignoredFunctions.has(name)) { - return; - } - const runtime = runtimes[runtimeName]; - if (runtime === void 0) { - return; - } - if (this.functions.has(name)) { - return; - } - const directory = directories.find((directory2) => mainFile.startsWith(directory2)); - if (directory === void 0) { - return; - } - const func = new NetlifyFunction({ - blobsContext: this.blobsContext, - config: this.config, - directory, - displayName, - excludedRoutes, - mainFile, - name, - projectRoot: this.projectRoot, - routes, - runtime, - settings: this.settings, - timeoutBackground: this.timeouts.backgroundFunctions, - timeoutSynchronous: this.timeouts.syncFunctions - }); - const isReload = deletedFunctionNames.has(name); - await this.registerFunction(name, func, isReload); - return func; - }) - ); - const addedFunctionNames = new Set(addedFunctions.filter(Boolean).map((func) => func?.name)); - deletedFunctions.forEach(async (func) => { - if (addedFunctionNames.has(func.name)) { - return; - } - this.handleEvent({ function: func, name: "FunctionRemovedEvent" }); - }); - if (this.watch) { - await Promise.all(directories.map((path2) => this.setupDirectoryWatcher(path2))); - } - } - /** - * Creates a watcher that looks at files being added or removed from a - * functions directory. It doesn't care about files being changed, because - * those will be handled by each functions' watcher. - */ - async setupDirectoryWatcher(directory) { - if (this.directoryWatchers.has(directory)) { - return; - } - const watcher = await watchDebounced(directory, { - depth: 1, - onAdd: () => { - this.scan([directory]); - }, - onUnlink: () => { - this.scan([directory]); - } - }); - this.directoryWatchers.set(directory, watcher); - } - /** - * Removes a function from the registry and closes its file watchers. - */ - async unregisterFunction(func) { - const { name } = func; - this.functions.delete(name); - const watcher = this.functionWatchers.get(name); - if (watcher) { - await watcher.close(); - } - this.functionWatchers.delete(name); - } - /** - * Takes a zipped function and extracts its contents to an internal directory. - */ - async unzipFunction(func) { - const targetDirectory = resolve(this.projectRoot, this.destPath, ".unzipped", func.name); - await extractZip(func.mainFile, { dir: targetDirectory }); - return targetDirectory; - } -}; - -// dev/server/client-context.ts -import { jwtDecode } from "jwt-decode"; -var buildClientContext = (headers) => { - if (!headers.authorization) return; - const parts = headers.authorization.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") return; - const identity = { - url: "https://netlify-dev-locally-emulated-identity.netlify.app/.netlify/identity", - // { - // "source": "netlify dev", - // "testData": "NETLIFY_DEV_LOCALLY_EMULATED_IDENTITY" - // } - token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb3VyY2UiOiJuZXRsaWZ5IGRldiIsInRlc3REYXRhIjoiTkVUTElGWV9ERVZfTE9DQUxMWV9FTVVMQVRFRF9JREVOVElUWSJ9.2eSDqUOZAOBsx39FHFePjYj12k0LrxldvGnlvDu3GMI" - }; - try { - const user = jwtDecode(parts[1]); - const netlifyContext = JSON.stringify({ - identity, - user - }); - return { - identity, - user, - custom: { - netlify: Buffer.from(netlifyContext).toString("base64") - } - }; - } catch { - } -}; - -// dev/main.ts -var CLOCKWORK_USERAGENT = "Netlify Clockwork"; -var UNLINKED_SITE_MOCK_ID = "unlinked"; -var FunctionsHandler = class { - accountID; - buildCache; - geolocation; - globalBuildDirectory; - registry; - scan; - siteID; - constructor({ accountId, geolocation, siteId, userFunctionsPath, ...registryOptions }) { - const registry = new FunctionsRegistry(registryOptions); - this.accountID = accountId; - this.buildCache = {}; - this.geolocation = geolocation; - this.globalBuildDirectory = registryOptions.destPath; - this.registry = registry; - this.scan = registry.scan([userFunctionsPath]); - this.siteID = siteId; - } - async invoke(request, route, func, buildDirectory) { - let remoteAddress = request.headers.get("x-forwarded-for") || ""; - remoteAddress = remoteAddress.split(remoteAddress.includes(".") ? ":" : ",").pop()?.trim() ?? ""; - request.headers.set("x-nf-client-connection-ip", remoteAddress); - if (this.accountID) { - request.headers.set("x-nf-account-id", this.accountID); - } - request.headers.set("x-nf-site-id", this.siteID ?? UNLINKED_SITE_MOCK_ID); - request.headers.set("x-nf-geo", Buffer2.from(JSON.stringify(this.geolocation)).toString("base64")); - const { headers: headersObject } = headersObjectFromWebHeaders(request.headers); - const clientContext = buildClientContext(headersObject) || {}; - if (func.isBackground) { - await func.invoke({ - buildCache: this.buildCache, - buildDirectory: buildDirectory ?? this.globalBuildDirectory, - request, - route - }); - return new Response(null, { status: 202 }); - } - if (await func.isScheduled()) { - const newRequest = new Request(request, { - ...request, - method: "POST" - }); - newRequest.headers.set("user-agent", CLOCKWORK_USERAGENT); - newRequest.headers.set("x-nf-event", "schedule"); - return await func.invoke({ - buildCache: this.buildCache, - buildDirectory: buildDirectory ?? this.globalBuildDirectory, - clientContext, - request: newRequest, - route - }); - } - return await func.invoke({ - buildCache: this.buildCache, - buildDirectory: buildDirectory ?? this.globalBuildDirectory, - clientContext, - request, - route - }); - } - async match(request, buildDirectory) { - await this.scan; - const url = new URL(request.url); - const match = await this.registry.getFunctionForURLPath(url.pathname, request.method); - if (!match) { - return; - } - const functionName = match?.func?.name; - if (!functionName) { - return; - } - const matchingRoute = match.route?.pattern; - const func = this.registry.get(functionName); - if (func === void 0) { - return { - handle: async () => new Response("Function not found...", { - status: 404 - }), - preferStatic: false - }; - } - if (!func.hasValidName()) { - return { - handle: async () => new Response("Function name should consist only of alphanumeric characters, hyphen & underscores.", { - status: 400 - }), - preferStatic: false - }; - } - return { - handle: (request2) => this.invoke(request2, matchingRoute, func, buildDirectory), - preferStatic: match.route?.prefer_static ?? false - }; - } -}; -export { - FunctionsHandler -}; diff --git a/packages/functions/dist-dev/worker.js b/packages/functions/dist-dev/worker.js deleted file mode 100644 index b8e0b0f5..00000000 --- a/packages/functions/dist-dev/worker.js +++ /dev/null @@ -1,106 +0,0 @@ -// @ts-check -// This is a JavaScript file because we need to locate it at runtime using the -// `Worker` API and using a `.ts` complicates things. To make it type-safe, -// we use JSDoc annotations. -import { createServer } from 'node:net' -import process from 'node:process' -import { isMainThread, workerData, parentPort } from 'node:worker_threads' - -import { isStream } from 'is-stream' -import lambdaLocal from 'lambda-local' -import sourceMapSupport from 'source-map-support' - -// https://github.com/nodejs/undici/blob/a36e299d544863c5ade17d4090181be894366024/lib/web/fetch/constants.js#L6 -const nullBodyStatus = new Set([101, 204, 205, 304]) - -/** - * @typedef HandlerResponse - * @type {import('../../../src/function/handler_response.js').HandlerResponse} - */ - -/** - * @typedef WorkerResult - * @type {HandlerResponse & { streamPort?: number }} - */ - -if (isMainThread) { - throw new Error(`Do not import "${import.meta.url}" in the main thread.`) -} - -sourceMapSupport.install() - -lambdaLocal.getLogger().level = 'alert' - -const { clientContext, entryFilePath, environment = {}, event, timeoutMs } = workerData - -// Injecting into the environment any properties passed in by the parent. -for (const key in environment) { - process.env[key] = environment[key] -} -const lambdaFunc = await import(entryFilePath) -const invocationResult = /** @type {HandlerResponse} */ ( - await lambdaLocal.execute({ - clientContext, - event, - lambdaFunc, - region: 'dev', - timeoutMs, - verboseLevel: 3, - }) -) - -/** - * When the result body is a stream and result status code allow to have a body, - * open up a http server that proxies back to the main thread and resolve with server port. - * Otherwise, resolve with undefined. - * - * @param {HandlerResponse} invocationResult - * @returns {Promise} - */ -async function getStreamPortForStreamingResponse(invocationResult) { - // if we don't have result or result's body is not a stream, we do not need a stream port - if (!invocationResult || !isStream(invocationResult.body)) { - return undefined - } - - const { body } = invocationResult - - delete invocationResult.body - - // For streaming responses, lambda-local always returns a result with body stream. - // We need to discard it if result's status code does not allow response to have a body. - const shouldNotHaveABody = nullBodyStatus.has(invocationResult.statusCode) - if (shouldNotHaveABody) { - return undefined - } - - // create a server that will proxy the body stream back to the main thread - return 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 address = server.address() - - /** @type {number | undefined} */ - let streamPort - if (address && typeof address !== 'string') { - streamPort = address.port - } - - resolve(streamPort) - }) - }) -} - -const streamPort = await getStreamPortForStreamingResponse(invocationResult) - -if (parentPort) { - /** @type {WorkerResult} */ - const message = { ...invocationResult, streamPort } - - parentPort.postMessage(message) -} From 9c9eb9ceca641097b6e6922f6424df6d9b801ed4 Mon Sep 17 00:00:00 2001 From: lemusthelroy Date: Thu, 16 Oct 2025 10:10:28 +0100 Subject: [PATCH 3/6] fix: remove log --- .../otel/src/instrumentations/fetch.test.ts | 32 ++++++++++++++++++- packages/otel/src/instrumentations/fetch.ts | 4 +-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/otel/src/instrumentations/fetch.test.ts b/packages/otel/src/instrumentations/fetch.test.ts index e492d3b6..f4b4165e 100644 --- a/packages/otel/src/instrumentations/fetch.test.ts +++ b/packages/otel/src/instrumentations/fetch.test.ts @@ -85,10 +85,15 @@ describe('header exclusion', () => { }) }) +let requestHeaders = new Headers() + describe('patched fetch', () => { const server = setupServer( // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - http.get('http://localhost:3000/ok', () => HttpResponse.json({ message: 'ok' })), + http.get('http://localhost:3000/ok', ({ request }) => { + requestHeaders = request.headers + return HttpResponse.json({ message: 'ok' }) + }), // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return http.post('http://localhost:3000/ok', () => HttpResponse.json({ message: 'ok' })), ) @@ -98,6 +103,7 @@ describe('patched fetch', () => { }) beforeEach(async () => { + requestHeaders = new Headers() await createTracerProvider({ headers: new Headers({ 'x-nf-enable-tracing': 'true' }), serviceName: 'test-service', @@ -162,4 +168,28 @@ describe('patched fetch', () => { }) await expect(fetch(req).then((r) => r.json())).resolves.toEqual({ message: 'ok' }) }) + + it('uses propagation headers to forward trace context', async () => { + const traceParent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + await createTracerProvider({ + propagationHeaders: new Headers({ 'traceparent': traceParent }), + serviceName: 'test-service', + serviceVersion: '1.0.0', + deploymentEnvironment: 'test', + siteUrl: 'https://example.com', + siteId: '12345', + siteName: 'example', + instrumentations: [new FetchInstrumentation()], + }) + + await expect(fetch('http://localhost:3000/ok', { headers: new Headers({ 'some-header': "value" }) }).then((r) => r.json())).resolves.toEqual({ message: 'ok' }) + + const forwardedTraceParent = requestHeaders.get("traceparent") + expect(forwardedTraceParent).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/) + expect(forwardedTraceParent).not.toBe(traceParent) + + // ensure we do not strip existing headers + expect(requestHeaders.get("some-header")).toBe("value") + + }) }) diff --git a/packages/otel/src/instrumentations/fetch.ts b/packages/otel/src/instrumentations/fetch.ts index c764434b..268ccfd1 100644 --- a/packages/otel/src/instrumentations/fetch.ts +++ b/packages/otel/src/instrumentations/fetch.ts @@ -122,8 +122,8 @@ export class FetchInstrumentation implements Instrumentation { } const traceContextForwarder = getTraceContextForwarder() - if (options?.headers && traceContextForwarder) { - const headers = new Headers(options.headers) + if (traceContextForwarder) { + const headers = options?.headers ? new Headers(options.headers) : new Headers() const extractedContext = traceContextForwarder(new W3CTraceContextPropagator(), headers) // Replace headers in options with the mutated version From fd7989f5552367cde78af0dba02b668501c1ab1f Mon Sep 17 00:00:00 2001 From: lemusthelroy Date: Thu, 16 Oct 2025 10:18:12 +0100 Subject: [PATCH 4/6] fix: fix lint --- packages/otel/src/bootstrap/main.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/otel/src/bootstrap/main.ts b/packages/otel/src/bootstrap/main.ts index 875e5d6c..6e1cb277 100644 --- a/packages/otel/src/bootstrap/main.ts +++ b/packages/otel/src/bootstrap/main.ts @@ -1,4 +1,4 @@ -import { BatchSpanProcessor, ConsoleSpanExporter, type SpanProcessor } from '@opentelemetry/sdk-trace-node' +import { type SpanProcessor } from '@opentelemetry/sdk-trace-node' import type { Instrumentation } from '@opentelemetry/instrumentation' import { GET_TRACE_CONTEXT_FORWARDER, GET_TRACER, SHUTDOWN_TRACERS } from '../constants.js' import { Context, context, W3CTraceContextPropagator } from '../opentelemetry.ts' @@ -55,12 +55,13 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { traceContextForwarder = (propagator: W3CTraceContextPropagator, requestHeaders: Headers): Context => { const getter = { keys: (carrier: Headers) => [...carrier.keys()], - get: (carrier: Headers, key: string) => carrier.get(key) || undefined, + get: (carrier: Headers, key: string) => carrier.get(key) ?? undefined, } const extractedContext = propagator.extract(context.active(), options.propagationHeaders, getter) - propagator?.inject(context.active(), requestHeaders, { + propagator.inject(context.active(), requestHeaders, { set: (carrier, key, value) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call carrier.set(key, value) }, }) From 10afba1786a3f96a18d633cbf7f7ead4088eb7e9 Mon Sep 17 00:00:00 2001 From: lemusthelroy Date: Thu, 16 Oct 2025 10:22:39 +0100 Subject: [PATCH 5/6] fix: format otel --- packages/otel/src/bootstrap/main.ts | 6 ++---- packages/otel/src/instrumentations/fetch.test.ts | 13 +++++++------ packages/otel/src/instrumentations/fetch.ts | 4 ++-- packages/otel/src/main.ts | 8 ++++++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/otel/src/bootstrap/main.ts b/packages/otel/src/bootstrap/main.ts index 6e1cb277..9076afc9 100644 --- a/packages/otel/src/bootstrap/main.ts +++ b/packages/otel/src/bootstrap/main.ts @@ -45,8 +45,7 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { }) nodeTracerProvider.register({ - propagator: new W3CTraceContextPropagator() - + propagator: new W3CTraceContextPropagator(), }) let traceContextForwarder: (propagator: W3CTraceContextPropagator, requestHeaders: Headers) => void @@ -77,7 +76,6 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { tracerProvider: nodeTracerProvider, }) - const { trace } = await import('@opentelemetry/api') const { SugaredTracer } = await import('@opentelemetry/api/experimental') const { default: pkg } = await import('../../package.json', { with: { type: 'json' } }) @@ -99,7 +97,7 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { enumerable: false, configurable: true, writable: false, - value: function() { + value: function () { return traceContextForwarder }, }) diff --git a/packages/otel/src/instrumentations/fetch.test.ts b/packages/otel/src/instrumentations/fetch.test.ts index f4b4165e..61d5be02 100644 --- a/packages/otel/src/instrumentations/fetch.test.ts +++ b/packages/otel/src/instrumentations/fetch.test.ts @@ -170,9 +170,9 @@ describe('patched fetch', () => { }) it('uses propagation headers to forward trace context', async () => { - const traceParent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + const traceParent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' await createTracerProvider({ - propagationHeaders: new Headers({ 'traceparent': traceParent }), + propagationHeaders: new Headers({ traceparent: traceParent }), serviceName: 'test-service', serviceVersion: '1.0.0', deploymentEnvironment: 'test', @@ -182,14 +182,15 @@ describe('patched fetch', () => { instrumentations: [new FetchInstrumentation()], }) - await expect(fetch('http://localhost:3000/ok', { headers: new Headers({ 'some-header': "value" }) }).then((r) => r.json())).resolves.toEqual({ message: 'ok' }) + await expect( + fetch('http://localhost:3000/ok', { headers: new Headers({ 'some-header': 'value' }) }).then((r) => r.json()), + ).resolves.toEqual({ message: 'ok' }) - const forwardedTraceParent = requestHeaders.get("traceparent") + const forwardedTraceParent = requestHeaders.get('traceparent') expect(forwardedTraceParent).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/) expect(forwardedTraceParent).not.toBe(traceParent) // ensure we do not strip existing headers - expect(requestHeaders.get("some-header")).toBe("value") - + expect(requestHeaders.get('some-header')).toBe('value') }) }) diff --git a/packages/otel/src/instrumentations/fetch.ts b/packages/otel/src/instrumentations/fetch.ts index 268ccfd1..db246517 100644 --- a/packages/otel/src/instrumentations/fetch.ts +++ b/packages/otel/src/instrumentations/fetch.ts @@ -27,9 +27,9 @@ export class FetchInstrumentation implements Instrumentation { return this.config } - setConfig(): void { } + setConfig(): void {} - setMeterProvider(): void { } + setMeterProvider(): void {} setTracerProvider(provider: api.TracerProvider): void { this.provider = provider } diff --git a/packages/otel/src/main.ts b/packages/otel/src/main.ts index 23d20086..24d638e8 100644 --- a/packages/otel/src/main.ts +++ b/packages/otel/src/main.ts @@ -6,14 +6,18 @@ import { W3CTraceContextPropagator } from '@opentelemetry/core' type GlobalThisExtended = typeof globalThis & { [GET_TRACER]?: (name?: string, version?: string) => SugaredTracer | undefined [SHUTDOWN_TRACERS]?: () => void - [GET_TRACE_CONTEXT_FORWARDER]?: () => ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) | undefined + [GET_TRACE_CONTEXT_FORWARDER]?: () => + | ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) + | undefined } export const getTracer = (name?: string, version?: string): SugaredTracer | undefined => { return (globalThis as GlobalThisExtended)[GET_TRACER]?.(name, version) } -export const getTraceContextForwarder = (): ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) | undefined => { +export const getTraceContextForwarder = (): + | ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) + | undefined => { return (globalThis as GlobalThisExtended)[GET_TRACE_CONTEXT_FORWARDER]?.() } From c305c9fc25fa121878389cb875851665b489032a Mon Sep 17 00:00:00 2001 From: lemusthelroy Date: Thu, 16 Oct 2025 10:28:59 +0100 Subject: [PATCH 6/6] fix: update type --- packages/otel/src/bootstrap/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/otel/src/bootstrap/main.ts b/packages/otel/src/bootstrap/main.ts index 9076afc9..6dd48353 100644 --- a/packages/otel/src/bootstrap/main.ts +++ b/packages/otel/src/bootstrap/main.ts @@ -48,7 +48,7 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { propagator: new W3CTraceContextPropagator(), }) - let traceContextForwarder: (propagator: W3CTraceContextPropagator, requestHeaders: Headers) => void + let traceContextForwarder: (propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context if (options.propagationHeaders) { traceContextForwarder = (propagator: W3CTraceContextPropagator, requestHeaders: Headers): Context => {