From d7c8699a0f0b05c1befdb27023f336756c967dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 10 Jul 2025 17:29:42 +0100 Subject: [PATCH 1/5] feat: load generated functions from Netlify Build --- src/commands/dev/dev.ts | 5 ++-- src/commands/functions/functions-serve.ts | 1 + src/commands/serve/serve.ts | 3 +- src/lib/functions/registry.ts | 33 ++++++++++++++++------ src/lib/functions/server.ts | 4 +++ src/utils/run-build.ts | 34 ++++++++++++++++++----- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/commands/dev/dev.ts b/src/commands/dev/dev.ts index c7f0084b823..02865d76e92 100644 --- a/src/commands/dev/dev.ts +++ b/src/commands/dev/dev.ts @@ -191,7 +191,7 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { log(`${NETLIFYDEVLOG} Setting up local dev server`) - const { configMutations, configPath: configPathOverride } = await runDevTimeline({ + const { configMutations, generatedFunctions } = await runDevTimeline({ command, options, settings, @@ -208,7 +208,7 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { blobsContext, command, config: mutatedConfig, - + generatedFunctions, debug: options.debug, settings, site, @@ -247,7 +247,6 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { blobsContext, command, config: mutatedConfig, - configPath: configPathOverride, debug: options.debug, disableEdgeFunctions: options.internalDisableEdgeFunctions, projectDir: command.workingDir, diff --git a/src/commands/functions/functions-serve.ts b/src/commands/functions/functions-serve.ts index e6c3706d082..62ce298a860 100644 --- a/src/commands/functions/functions-serve.ts +++ b/src/commands/functions/functions-serve.ts @@ -60,6 +60,7 @@ export const functionsServe = async (options: OptionValues, command: BaseCommand siteUrl, capabilities, timeouts, + generatedFunctions: [], geolocationMode: options.geo, geoCountry: options.country, offline: options.offline, diff --git a/src/commands/serve/serve.ts b/src/commands/serve/serve.ts index fbd90914bdb..845505d5731 100644 --- a/src/commands/serve/serve.ts +++ b/src/commands/serve/serve.ts @@ -110,7 +110,7 @@ export const serve = async (options: OptionValues, command: BaseCommand) => { // which is what build plugins use. process.env[BLOBS_CONTEXT_VARIABLE] = encodeBlobsContext(await getBlobsContextWithAPIAccess(blobsOptions)) - const { configPath: configPathOverride } = await runBuildTimeline({ + const { configPath: configPathOverride, generatedFunctions } = await runBuildTimeline({ command, settings, options, @@ -130,6 +130,7 @@ export const serve = async (options: OptionValues, command: BaseCommand) => { command, config: mergedConfig, debug: options.debug, + generatedFunctions, loadDistFunctions: true, settings, site, diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts index 22c0f3f98a4..245d8020b79 100644 --- a/src/lib/functions/registry.ts +++ b/src/lib/functions/registry.ts @@ -3,6 +3,7 @@ import { createRequire } from 'module' import { basename, extname, isAbsolute, join, resolve } from 'path' import { env } from 'process' +import type { GeneratedFunction } from '@netlify/build' import { type ListedFunction, listFunctions, type Manifest } from '@netlify/zip-it-and-ship-it' import extractZip from 'extract-zip' @@ -74,6 +75,7 @@ export class FunctionsRegistry { private config: NormalizedCachedConfigConfig private debug: boolean private frameworksAPIPaths: ReturnType + private generatedFunctions: GeneratedFunction[] private isConnected: boolean private logLambdaCompat: boolean private manifest?: Manifest @@ -88,6 +90,7 @@ export class FunctionsRegistry { config, debug = false, frameworksAPIPaths, + generatedFunctions, isConnected = false, logLambdaCompat, manifest, @@ -103,6 +106,7 @@ export class FunctionsRegistry { config: NormalizedCachedConfigConfig debug?: boolean frameworksAPIPaths: ReturnType + generatedFunctions: GeneratedFunction[] isConnected?: boolean logLambdaCompat: boolean manifest?: Manifest @@ -115,6 +119,7 @@ export class FunctionsRegistry { this.config = config this.debug = debug this.frameworksAPIPaths = frameworksAPIPaths + this.generatedFunctions = generatedFunctions this.isConnected = isConnected this.projectRoot = projectRoot this.timeouts = timeouts @@ -465,14 +470,24 @@ export class FunctionsRegistry { await Promise.all(directories.map((path) => FunctionsRegistry.prepareDirectory(path))) - const functions = await this.listFunctions(directories, { - featureFlags: { - buildRustSource: env.NETLIFY_EXPERIMENTAL_BUILD_RUST_SOURCE === 'true', + const functions = await this.listFunctions( + { + generated: { + functions: this.generatedFunctions.map((func) => func.path), + }, + user: { + directories, + }, }, - configFileDirectories: [getPathInProject([INTERNAL_FUNCTIONS_FOLDER])], - // @ts-expect-error -- TODO(serhalp): Function config types do not match. Investigate and fix. - config: this.config.functions, - }) + { + featureFlags: { + buildRustSource: env.NETLIFY_EXPERIMENTAL_BUILD_RUST_SOURCE === 'true', + }, + configFileDirectories: [getPathInProject([INTERNAL_FUNCTIONS_FOLDER])], + // @ts-expect-error -- TODO(serhalp): Function config types do not match. Investigate and fix. + config: this.config.functions, + }, + ) // user-defined functions take precedence over internal functions, // so we want to ignore any internal functions where there's a user-defined one with the same name @@ -506,7 +521,7 @@ export class FunctionsRegistry { // 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, mainFile, name, runtime: runtimeName }) => { + functions.reverse().map(async ({ displayName, mainFile, name, runtime: runtimeName, srcDir }) => { if (ignoredFunctions.has(name)) { return } @@ -527,7 +542,7 @@ export class FunctionsRegistry { const func = new NetlifyFunction({ blobsContext: this.blobsContext, config: this.config, - directory: directories.find((directory) => mainFile.startsWith(directory)), + directory: srcDir ?? directories.find((directory) => mainFile.startsWith(directory)), mainFile, name, displayName, diff --git a/src/lib/functions/server.ts b/src/lib/functions/server.ts index 23dac7cad6c..ec068032fea 100644 --- a/src/lib/functions/server.ts +++ b/src/lib/functions/server.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'fs' import type { IncomingHttpHeaders } from 'http' import path from 'path' +import type { GeneratedFunction } from '@netlify/build' import express, { type Request, type RequestHandler } from 'express' import expressLogging from 'express-logging' import { jwtDecode } from 'jwt-decode' @@ -305,6 +306,7 @@ export const startFunctionsServer = async ( backgroundFunctions?: boolean } debug: boolean + generatedFunctions: GeneratedFunction[] loadDistFunctions?: boolean // TODO(serhalp): This is confusing. Refactor to accept entire settings or rename or something? settings: Pick @@ -319,6 +321,7 @@ export const startFunctionsServer = async ( command, config, debug, + generatedFunctions, loadDistFunctions, settings, site, @@ -380,6 +383,7 @@ export const startFunctionsServer = async ( config, debug, frameworksAPIPaths: command.netlify.frameworksAPIPaths, + generatedFunctions, isConnected: Boolean(siteUrl), logLambdaCompat: isFeatureFlagEnabled('cli_log_lambda_compat', siteInfo), manifest, diff --git a/src/utils/run-build.ts b/src/utils/run-build.ts index 3350327cf7a..13a5432f444 100644 --- a/src/utils/run-build.ts +++ b/src/utils/run-build.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'fs' import path, { join } from 'path' -import { NetlifyConfig } from '@netlify/build' +import { NetlifyConfig, type GeneratedFunction } from '@netlify/build' import BaseCommand from '../commands/base-command.js' import { $TSFixMe } from '../commands/types.js' @@ -47,7 +47,22 @@ const cleanInternalDirectory = async (basePath?: string) => { await Promise.all(ops) } -export const runNetlifyBuild = async ({ +type RunNetlifyBuildOptions = { + command: BaseCommand + // The flags of the command + options: $TSFixMe + settings: ServerSettings + env: NodeJS.ProcessEnv + timeline: 'dev' | 'build' +} + +export async function runNetlifyBuild( + opts: RunNetlifyBuildOptions & { timeline: 'dev' }, +): Promise<{ configMutations: unknown; generatedFunctions: GeneratedFunction[] }> +export async function runNetlifyBuild( + opts: RunNetlifyBuildOptions & { timeline: 'build' }, +): Promise<{ configPath: string; generatedFunctions: GeneratedFunction[] }> +export async function runNetlifyBuild({ command, env = {}, options, @@ -60,7 +75,7 @@ export const runNetlifyBuild = async ({ settings: ServerSettings env: NodeJS.ProcessEnv timeline: 'build' | 'dev' -}) => { +}) { const { apiOpts, cachedConfig, site } = command.netlify const { default: buildSite, startDev } = await netlifyBuildPromise @@ -130,7 +145,7 @@ export const runNetlifyBuild = async ({ // Run Netlify Build using the main entry point. // @ts-expect-error TS(2345) FIXME: Argument of type '{ outputConfigPath: string; save... Remove this comment to see the full error message - const { netlifyConfig, success } = await buildSite(buildSiteOptions) + const { netlifyConfig, success, generatedFunctions } = await buildSite(buildSiteOptions) if (!success) { return logAndThrowError('Could not start local server due to a build error') @@ -148,7 +163,7 @@ export const runNetlifyBuild = async ({ } await devCommand({ netlifyConfig, settingsOverrides }) - return { configPath: tempConfigPath } + return { configPath: tempConfigPath, generatedFunctions } } const startDevOptions = { @@ -161,7 +176,12 @@ export const runNetlifyBuild = async ({ } // Run Netlify Build using the `startDev` entry point. - const { configMutations, error: startDevError, success } = await startDev(devCommand, startDevOptions) + const { + configMutations, + error: startDevError, + success, + generatedFunctions, + } = await startDev(devCommand, startDevOptions) if (!success && startDevError) { return logAndThrowError( @@ -169,7 +189,7 @@ export const runNetlifyBuild = async ({ ) } - return { configMutations } + return { configMutations, generatedFunctions } } type RunTimelineOptions = Omit[0], 'timeline'> From 35fcc79416cbb44d0c910b515587ffb40a6a6cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 10 Jul 2025 23:18:45 +0100 Subject: [PATCH 2/5] fix: add guard --- src/lib/functions/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts index 245d8020b79..ce1df6bcc76 100644 --- a/src/lib/functions/registry.ts +++ b/src/lib/functions/registry.ts @@ -119,7 +119,7 @@ export class FunctionsRegistry { this.config = config this.debug = debug this.frameworksAPIPaths = frameworksAPIPaths - this.generatedFunctions = generatedFunctions + this.generatedFunctions = generatedFunctions ?? [] this.isConnected = isConnected this.projectRoot = projectRoot this.timeouts = timeouts From b0abce02f7e5fac7cea670d619a6364e76a06b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 11 Jul 2025 09:50:24 +0100 Subject: [PATCH 3/5] refactor: change directory computation --- src/lib/functions/registry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts index ce1df6bcc76..fd2b31e681a 100644 --- a/src/lib/functions/registry.ts +++ b/src/lib/functions/registry.ts @@ -539,10 +539,11 @@ export class FunctionsRegistry { return } + const directory = directories.find((directory) => mainFile.startsWith(directory)) ?? srcDir const func = new NetlifyFunction({ blobsContext: this.blobsContext, config: this.config, - directory: srcDir ?? directories.find((directory) => mainFile.startsWith(directory)), + directory, mainFile, name, displayName, From dd189fc19139f5f858ad0642660a7a648a6f3b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 11 Jul 2025 12:34:26 +0100 Subject: [PATCH 4/5] chore: add comments --- src/lib/functions/registry.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts index fd2b31e681a..c5e581b8f73 100644 --- a/src/lib/functions/registry.ts +++ b/src/lib/functions/registry.ts @@ -476,6 +476,14 @@ export class FunctionsRegistry { functions: this.generatedFunctions.map((func) => func.path), }, user: { + // In reality, `directories` contains both directories with user and + // generated functions. The registry currently lacks knowledge about + // the contents of each directory, so we put them in the same bag and + // rely on the order of the directories to get the priority right. + // But now that zip-it-and-ship-it accepts an object with mixed paths + // that lets us specify exactly which paths contain user functions or + // generated functions, we should refactor this call so it encodes + // that distiction. directories, }, }, @@ -539,7 +547,21 @@ export class FunctionsRegistry { return } + // This contains the top-level functions directory where this specific + // function is found (not the sub-directory where a function may live). + // Both `netlify/functions/foo/index.js` and `netlify/functions/bar.js` + // would have this value as `netlify/functions`, for example. + // This value was undefined for any functions in `generatedFunctions`, + // because those functions don't usually live inside any of the regular + // function directories. For those cases we use the value of `srcDir`. + // I think this is in need of some refactoring though, because I'm not + // sure why we need to keep track of the parent functions directory and + // not just the directory where the function lives. + // I'm keeping this as is for now, where we're just adding `srcDir` as + // a fallback, to minimise the impact of this change, and then we'll + // revisit when possible. const directory = directories.find((directory) => mainFile.startsWith(directory)) ?? srcDir + const func = new NetlifyFunction({ blobsContext: this.blobsContext, config: this.config, From 8029956d494cb5f88edf00ff40e09e03c1883fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 18 Jul 2025 10:15:04 +0100 Subject: [PATCH 5/5] fix: use function `srcPath` --- src/lib/functions/netlify-function.ts | 8 ++++++++ src/lib/functions/registry.ts | 19 ++----------------- .../functions/runtimes/js/builders/zisi.ts | 18 ++---------------- src/lib/functions/runtimes/js/index.ts | 13 +++---------- 4 files changed, 15 insertions(+), 43 deletions(-) diff --git a/src/lib/functions/netlify-function.ts b/src/lib/functions/netlify-function.ts index 7d26e4c40bf..432cbb78e97 100644 --- a/src/lib/functions/netlify-function.ts +++ b/src/lib/functions/netlify-function.ts @@ -56,6 +56,11 @@ export default class NetlifyFunction { public readonly runtime: Runtime public schedule?: string + // The path to the function boundary: if the function is in a sub-directory, + // this will hold the path to the sub-directory; if not, it's the path to the + // main file. + public readonly srcPath: string + // Determines whether this is a background function based on the function // name. public readonly isBackground: boolean @@ -78,6 +83,7 @@ export default class NetlifyFunction { projectRoot, runtime, settings, + srcPath, timeoutBackground, timeoutSynchronous, }: { @@ -91,6 +97,7 @@ export default class NetlifyFunction { runtime: Runtime // TODO(serhalp): This is confusing. Refactor to accept entire settings or rename or something? settings: Pick + srcPath: string timeoutBackground?: number timeoutSynchronous?: number }) { @@ -105,6 +112,7 @@ export default class NetlifyFunction { this.timeoutBackground = timeoutBackground this.timeoutSynchronous = timeoutSynchronous this.settings = settings + this.srcPath = srcPath this.isBackground = name.endsWith(BACKGROUND) diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts index c5e581b8f73..258d5349c9c 100644 --- a/src/lib/functions/registry.ts +++ b/src/lib/functions/registry.ts @@ -529,7 +529,7 @@ export class FunctionsRegistry { // 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, mainFile, name, runtime: runtimeName, srcDir }) => { + functions.reverse().map(async ({ displayName, mainFile, name, runtime: runtimeName, srcPath }) => { if (ignoredFunctions.has(name)) { return } @@ -547,25 +547,9 @@ export class FunctionsRegistry { return } - // This contains the top-level functions directory where this specific - // function is found (not the sub-directory where a function may live). - // Both `netlify/functions/foo/index.js` and `netlify/functions/bar.js` - // would have this value as `netlify/functions`, for example. - // This value was undefined for any functions in `generatedFunctions`, - // because those functions don't usually live inside any of the regular - // function directories. For those cases we use the value of `srcDir`. - // I think this is in need of some refactoring though, because I'm not - // sure why we need to keep track of the parent functions directory and - // not just the directory where the function lives. - // I'm keeping this as is for now, where we're just adding `srcDir` as - // a fallback, to minimise the impact of this change, and then we'll - // revisit when possible. - const directory = directories.find((directory) => mainFile.startsWith(directory)) ?? srcDir - const func = new NetlifyFunction({ blobsContext: this.blobsContext, config: this.config, - directory, mainFile, name, displayName, @@ -577,6 +561,7 @@ export class FunctionsRegistry { timeoutBackground: this.timeouts.backgroundFunctions, timeoutSynchronous: this.timeouts.syncFunctions, settings: this.settings, + srcPath, }) // If a function we're registering was also unregistered in this run, diff --git a/src/lib/functions/runtimes/js/builders/zisi.ts b/src/lib/functions/runtimes/js/builders/zisi.ts index acb0080a275..6fd0c9e15b3 100644 --- a/src/lib/functions/runtimes/js/builders/zisi.ts +++ b/src/lib/functions/runtimes/js/builders/zisi.ts @@ -39,7 +39,6 @@ const addFunctionsConfigDefaults = (config: NormalizedFunctionsConfig) => ({ const buildFunction = async ({ cache, config, - directory, featureFlags, func, hasTypeModule, @@ -48,7 +47,6 @@ const buildFunction = async ({ }: { cache: BuildCommandCache config: NormalizedFunctionsConfig - directory?: string | undefined featureFlags: FeatureFlags // This seems like it should be `ZisiBuildResult` but it's technically referenced from `detectZisiBuilder` so TS // can't know at that point that we'll only end up calling it with a `ZisiBuildResult`... Consider refactoring? @@ -63,16 +61,6 @@ const buildFunction = async ({ config, featureFlags: { ...featureFlags, zisi_functions_api_v2: true }, } - 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 { entryFilename, excludedRoutes, @@ -86,9 +74,9 @@ const buildFunction = async ({ schedule, } = await memoizedBuild({ cache, - cacheKey: `zisi-${entryPath}`, + cacheKey: `zisi-${func.srcPath}`, command: async () => { - const result = await zipFunction(entryPath, targetDirectory, zipOptions) + const result = await zipFunction(func.srcPath, targetDirectory, zipOptions) if (result == null) { throw new Error('Failed to build function') } @@ -179,7 +167,6 @@ const netlifyConfigToZisiConfig = ({ export default async function detectZisiBuilder({ config, - directory, errorExit, func, metadata, @@ -233,7 +220,6 @@ export default async function detectZisiBuilder({ buildFunction({ cache, config: functionsConfig, - directory, func, projectRoot, targetDirectory, diff --git a/src/lib/functions/runtimes/js/index.ts b/src/lib/functions/runtimes/js/index.ts index ce89e84d33f..5b4c811909b 100644 --- a/src/lib/functions/runtimes/js/index.ts +++ b/src/lib/functions/runtimes/js/index.ts @@ -1,5 +1,4 @@ import { createConnection } from 'net' -import { dirname } from 'path' import { pathToFileURL } from 'url' import { Worker } from 'worker_threads' @@ -30,25 +29,19 @@ lambdaLocal.getLogger().level = 'alert' export async function getBuildFunction({ config, - directory, errorExit, func, projectRoot, }: Parameters>[0]) { const metadata = await getFunctionMetadata({ mainFile: func.mainFile, config, projectRoot }) - const zisiBuilder = await detectZisiBuilder({ config, directory, errorExit, func, metadata, projectRoot }) + const zisiBuilder = await detectZisiBuilder({ config, errorExit, func, metadata, projectRoot }) if (zisiBuilder) { return zisiBuilder.build } - // If there's no function builder, we create a simple one on-the-fly which - // returns as `srcFiles` the function directory, if there is one, or its - // main file otherwise. - const functionDirectory = dirname(func.mainFile) - const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory] - - const build: BuildFunction = () => Promise.resolve({ schedule: metadata?.schedule, srcFiles }) + const build: BuildFunction = () => + Promise.resolve({ schedule: metadata?.schedule, srcFiles: [func.srcPath] }) return build }