UNPKG

netlify-cli

Version:

Netlify command line tool

450 lines 20.8 kB
import { mkdir, stat } from 'fs/promises'; import { createRequire } from 'module'; import { basename, extname, isAbsolute, join, resolve } from 'path'; import { env } from 'process'; import { listFunctions } from '@netlify/zip-it-and-ship-it'; import extractZip from 'extract-zip'; import { chalk, log, getTerminalLink, NETLIFYDEVERR, NETLIFYDEVLOG, NETLIFYDEVWARN, warn, watchDebounced, } from '../../utils/command-helpers.js'; import { INTERNAL_FUNCTIONS_FOLDER, SERVE_FUNCTIONS_FOLDER } from '../../utils/functions/functions.js'; import { BACKGROUND_FUNCTIONS_WARNING } from '../log.js'; import { getPathInProject } from '../settings.js'; import NetlifyFunction from './netlify-function.js'; import runtimes from './runtimes/index.js'; export const DEFAULT_FUNCTION_URL_EXPRESSION = /^\/.netlify\/(functions|builders)\/([^/]+).*/; const TYPES_PACKAGE = '@netlify/functions'; const ZIP_EXTENSION = '.zip'; const isErrnoException = (value) => value instanceof Error && Object.hasOwn(value, 'code'); const isInternalFunction = (func, frameworksAPIFunctionsPath) => func.mainFile.includes(getPathInProject([INTERNAL_FUNCTIONS_FOLDER])) || func.mainFile.includes(frameworksAPIFunctionsPath); export class FunctionsRegistry { /** * The functions held by the registry */ functions = new Map(); /** * File watchers for function files. Maps function names to objects built * by the `watchDebounced` utility. */ functionWatchers = new Map(); directoryWatchers; /** * Keeps track of whether we've checked whether `TYPES_PACKAGE` is * installed. */ hasCheckedTypesPackage = false; /** * Context object for Netlify Blobs */ blobsContext; buildCommandCache; capabilities; config; debug; frameworksAPIPaths; isConnected; logLambdaCompat; manifest; projectRoot; // TODO(serhalp): This is confusing. Refactor to accept entire settings or rename or something? settings; timeouts; constructor({ blobsContext, capabilities, config, debug = false, frameworksAPIPaths, isConnected = false, logLambdaCompat, manifest, projectRoot, settings, timeouts, }) { this.capabilities = capabilities; this.config = config; this.debug = debug; this.frameworksAPIPaths = frameworksAPIPaths; this.isConnected = isConnected; this.projectRoot = projectRoot; this.timeouts = timeouts; this.settings = settings; this.blobsContext = blobsContext; /** * An object to be shared among all functions in the registry. It can be * used to cache the results of the build function — e.g. it's used in * the `memoizedBuild` method in the JavaScript runtime. */ this.buildCommandCache = {}; /** * File watchers for parent directories where functions live — i.e. the * ones supplied to `scan()`. This is a Map because in the future we * might have several function directories. */ this.directoryWatchers = new Map(); /** * Whether to log V1 functions as using the "Lambda compatibility mode" * */ this.logLambdaCompat = Boolean(logLambdaCompat); /** * Contents of a `manifest.json` file that can be looked up when dealing * with built functions. * */ this.manifest = manifest; } checkTypesPackage() { if (this.hasCheckedTypesPackage) { return; } this.hasCheckedTypesPackage = true; const require = createRequire(this.projectRoot); try { require.resolve(TYPES_PACKAGE, { paths: [this.projectRoot] }); } catch (error) { if (isErrnoException(error) && error.code === 'MODULE_NOT_FOUND') { this.logEvent('missing-types-package', {}); } } } /** * Runs before `scan` and calls any `onDirectoryScan` hooks defined by the * runtime before the directory is read. This gives runtime the opportunity * to run additional logic when a directory is scanned. */ static async prepareDirectoryScan(directory) { await mkdir(directory, { recursive: true }); // We give runtimes the opportunity to react to a directory scan and run // additional logic before the directory is read. So if they implement a // `onDirectoryScan` hook, we run it. await Promise.all(Object.values(runtimes).map((runtime) => { if (!('onDirectoryScan' in runtime)) { return null; } return runtime.onDirectoryScan({ directory }); })); } /** * 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.logEvent('reloading', { func }); } const { error: buildError, includedFiles, srcFilesDiff } = await func.build({ cache: this.buildCommandCache }); if (buildError) { this.logEvent('buildError', { func }); } else { const event = firstLoad ? 'loaded' : 'reloaded'; const recommendedExtension = func.getRecommendedExtension(); if (recommendedExtension) { const { filename } = func; const newFilename = filename ? `${basename(filename, extname(filename))}${recommendedExtension}` : null; const action = newFilename ? `rename the function file to ${chalk.underline(newFilename)}. Refer to https://ntl.fyi/functions-runtime for more information` : `refer to https://ntl.fyi/functions-runtime`; const warning = `The function is using the legacy CommonJS format. To start using ES modules, ${action}.`; this.logEvent(event, { func, warnings: [warning] }); } else { this.logEvent(event, { func }); } } if (func.isTypeScript()) { this.checkTypesPackage(); } // If the build hasn't resulted in any files being added or removed, there // is nothing else we need to do. if (!srcFilesDiff) { return; } const watcher = this.functionWatchers.get(func.name); // If there is already a watcher for this function, we need to unwatch any // files that have been removed and watch any files that have been added. if (watcher) { srcFilesDiff.deleted.forEach((path) => { watcher.unwatch(path); }); srcFilesDiff.added.forEach((path) => { watcher.add(path); }); return; } // If there is no watcher for this function but the build produced files, // we create a new watcher and watch them. 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); } } /** * 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, hasStaticFile) { // We're constructing a URL object just so that we can extract the path from // the incoming URL. It doesn't really matter that we don't have the actual // local URL with the correct port. 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 = [] } = (await func.getBuildData()) ?? {}; if (routes.length !== 0) { const paths = routes.map((route) => chalk.underline(route.pattern)).join(', '); warn(`Function ${chalk.yellow(func.name)} cannot be invoked on ${chalk.underline(url.pathname)}, because the function has the following URL paths defined: ${paths}`); return; } return { func, route: null }; } for (const func of this.functions.values()) { const route = await func.matchURLPath(url.pathname, method, hasStaticFile); if (route) { return { func, route }; } } } /** * Logs an event associated with functions. */ logEvent(event, { func, warnings = [] }) { let warningsText = ''; if (warnings.length !== 0) { warningsText = ` with warnings:\n${warnings.map((warning) => ` - ${warning}`).join('\n')}`; } if (event === 'buildError') { log(`${NETLIFYDEVERR} ${chalk.red('Failed to load')} function ${chalk.yellow(func?.displayName)}: ${func?.buildError?.message ?? ''}`); } if (event === 'extracted') { log(`${NETLIFYDEVLOG} ${chalk.green('Extracted')} function ${chalk.yellow(func?.displayName)} from ${func?.mainFile ?? ''}.`); return; } if (event === 'loaded') { const icon = warningsText ? NETLIFYDEVWARN : NETLIFYDEVLOG; const color = warningsText ? chalk.yellow : chalk.green; const mode = func?.runtimeAPIVersion === 1 && this.logLambdaCompat ? ` in ${getTerminalLink('Lambda compatibility mode', 'https://ntl.fyi/lambda-compat')}` : ''; log(`${icon} ${color('Loaded')} function ${chalk.yellow(func?.displayName)}${mode}${warningsText}`); return; } if (event === 'missing-types-package') { log(`${NETLIFYDEVWARN} For a better experience with TypeScript functions, consider installing the ${chalk.underline(TYPES_PACKAGE)} package. Refer to https://ntl.fyi/function-types for more information.`); } if (event === 'reloaded') { const icon = warningsText ? NETLIFYDEVWARN : NETLIFYDEVLOG; const color = warningsText ? chalk.yellow : chalk.green; log(`${icon} ${color('Reloaded')} function ${chalk.yellow(func?.displayName)}${warningsText}`); return; } if (event === 'reloading') { log(`${NETLIFYDEVLOG} ${chalk.magenta('Reloading')} function ${chalk.yellow(func?.displayName)}...`); return; } if (event === 'removed') { log(`${NETLIFYDEVLOG} ${chalk.magenta('Removed')} function ${chalk.yellow(func?.displayName)}`); } } /** * Adds a function to the registry */ async registerFunction(name, funcBeforeHook, isReload = false) { const { runtime } = funcBeforeHook; // The `onRegister` hook allows runtimes to modify the function before it's // registered, or to prevent it from being registered altogether if the // hook returns `null`. const func = typeof runtime.onRegister === 'function' ? runtime.onRegister(funcBeforeHook) : funcBeforeHook; if (func === null) { return; } if (func.isBackground && this.isConnected && !this.capabilities.backgroundFunctions) { warn(BACKGROUND_FUNCTIONS_WARNING); } if (!func.hasValidName()) { warn(`Function name '${func.name}' is invalid. It should consist only of alphanumeric characters, hyphen & underscores.`); } // If the function file is a ZIP, we extract it and rewire its main file to // the new location. if (extname(func.mainFile) === ZIP_EXTENSION) { const unzippedDirectory = await this.unzipFunction(func); // If there's a manifest file, look up the function in order to extract the build data. const manifestEntry = (this.manifest?.functions ?? []).find((manifestFunc) => manifestFunc.name === func.name); // We found a zipped function that does not have a corresponding entry in // the manifest. This shouldn't happen, but we ignore the function in // this case. if (!manifestEntry) { return; } if (this.debug) { this.logEvent('extracted', { func }); } func.buildData = { ...manifestEntry.buildData, routes: manifestEntry.routes, }; // When we look at an unzipped function, we don't know whether it uses // the legacy entry file format (i.e. `[function name].mjs`) or the new // one (i.e. `___netlify-entry-point.mjs`). Let's look for the new one // and use it if it exists, otherwise use the old one. try { const v2EntryPointPath = join(unzippedDirectory, '___netlify-entry-point.mjs'); await stat(v2EntryPointPath); func.mainFile = v2EntryPointPath; } catch { func.mainFile = join(unzippedDirectory, basename(manifestEntry.mainFile)); } } else { 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))); // check after filtering to filter out [undefined] for example if (directories.length === 0) { return; } await Promise.all(directories.map((path) => FunctionsRegistry.prepareDirectoryScan(path))); const functions = await this.listFunctions(directories, { 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 const ignoredFunctions = new Set(functions .filter((func) => isInternalFunction(func, this.frameworksAPIPaths.functions.path) && this.functions.has(func.name) && !isInternalFunction(this.functions.get(func.name), this.frameworksAPIPaths.functions.path)) .map((func) => func.name)); // Before registering any functions, we look for any functions that were on // the previous list but are missing from the new one. We unregister them. 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, mainFile, name, runtime: runtimeName }) => { if (ignoredFunctions.has(name)) { return; } const runtime = runtimes[runtimeName]; // If there is no matching runtime, it means this function is not yet // supported in Netlify Dev. if (runtime === undefined) { return; } // If this function has already been registered, we skip it. if (this.functions.has(name)) { return; } const func = new NetlifyFunction({ blobsContext: this.blobsContext, config: this.config, directory: directories.find((directory) => mainFile.startsWith(directory)), mainFile, name, displayName, projectRoot: this.projectRoot, // @ts-expect-error(serhalp) -- I think TS needs to know that a given instance of `runtime` in this loop at // this point will have a refined type of only one of the runtime types in the union, and this type is // consistent between the `NetlifyFunction` and the `runtime`. But... how do? runtime, timeoutBackground: this.timeouts.backgroundFunctions, timeoutSynchronous: this.timeouts.syncFunctions, settings: this.settings, }); // If a function we're registering was also unregistered in this run, // then it was a rename. Let's flag it as such so that the messaging // is adjusted accordingly. 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((func) => { // If a function we've unregistered was also registered in this run, then // it was a rename that we've already logged. Nothing to do in this case. if (addedFunctionNames.has(func.name)) { return; } this.logEvent('removed', { func }); }); await Promise.all(directories.map((path) => this.setupDirectoryWatcher(path))); } /** * 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, getPathInProject([SERVE_FUNCTIONS_FOLDER, '.unzipped', func.name])); await extractZip(func.mainFile, { dir: targetDirectory }); return targetDirectory; } } //# sourceMappingURL=registry.js.map