netlify-cli
Version:
Netlify command line tool
241 lines • 9.56 kB
JavaScript
import { Buffer } from 'buffer';
import { basename, extname } from 'path';
import { version as nodeVersion } from 'process';
import CronParser from 'cron-parser';
import semver from 'semver';
import { logAndThrowError } from '../../utils/command-helpers.js';
import { BACKGROUND } from '../../utils/functions/get-functions.js';
import { getBlobsEventProperty } from '../blobs/blobs.js';
const TYPESCRIPT_EXTENSIONS = new Set(['.cts', '.mts', '.ts']);
const V2_MIN_NODE_VERSION = '18.14.0';
// Returns a new set with all elements of `setA` that don't exist in `setB`.
const difference = (setA, setB) => new Set([...setA].filter((item) => !setB.has(item)));
const getNextRun = function (schedule) {
const cron = CronParser.parseExpression(schedule, {
tz: 'Etc/UTC',
});
return cron.next().toDate();
};
export default class NetlifyFunction {
blobsContext;
config;
directory;
projectRoot;
timeoutBackground;
timeoutSynchronous;
settings;
displayName;
mainFile;
name;
runtime;
schedule;
// 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 = new Set();
constructor({ blobsContext, config, directory, displayName, mainFile, name, projectRoot, runtime, settings, timeoutBackground, timeoutSynchronous, }) {
this.blobsContext = blobsContext;
this.config = config;
this.directory = directory;
this.mainFile = mainFile;
this.name = name;
this.displayName = displayName ?? name;
this.projectRoot = projectRoot;
this.runtime = runtime;
this.timeoutBackground = timeoutBackground;
this.timeoutSynchronous = timeoutSynchronous;
this.settings = settings;
this.isBackground = name.endsWith(BACKGROUND);
const functionConfig = config.functions?.[name];
// @ts-expect-error -- XXX(serhalp): fixed in stack PR (bumps to https://github.com/netlify/build/pull/6165)
this.schedule = functionConfig && functionConfig.schedule;
this.srcFiles = 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) : undefined;
const moduleFormat = this.buildData.outputModuleFormat;
if (moduleFormat === 'esm') {
return;
}
if (extension === '.ts') {
return '.mts';
}
if (extension === '.js') {
return '.mjs';
}
}
hasValidName() {
// same as https://github.com/netlify/bitballoon/blob/fbd7881e6c8e8c48e7a0145da4ee26090c794108/app/models/deploy.rb#L482
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;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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({ cache }) {
const buildFunction = await this.runtime.getBuildFunction({
config: this.config,
directory: this.directory,
errorExit: logAndThrowError,
func: this,
projectRoot: this.projectRoot,
});
this.buildQueue = buildFunction({ cache });
try {
const { includedFiles = [], schedule, srcFiles, ...buildData } = await this.buildQueue;
const srcFilesSet = new Set(srcFiles);
const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet);
this.buildData = buildData;
this.buildError = null;
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 };
}
}
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,
};
}
// Invokes the function and returns its response object.
async invoke(event = {}, context = {}) {
await this.buildQueue;
if (this.buildError) {
// TODO(serhalp): I don't think this error handling works as expected. Investigate.
return { result: null, error: { errorType: '', stackTrace: [], errorMessage: this.buildError.message } };
}
const timeout = this.isBackground ? this.timeoutBackground : this.timeoutSynchronous;
if (timeout == null) {
throw new Error('Function timeout (`timeoutBackground` or `timeoutSynchronous`) not set');
}
const environment = {};
if (this.blobsContext) {
const payload = JSON.stringify(getBlobsEventProperty(this.blobsContext));
event.blobs = Buffer.from(payload).toString('base64');
}
try {
const result = await this.runtime.invokeFunction({
context,
environment,
event,
func: this,
timeout,
});
return { result, error: null };
}
catch (error) {
return { result: null, error: error };
}
}
/**
* 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, hasStaticFile) {
await this.buildQueue;
let path = rawPath !== '/' && rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
path = path.toLowerCase();
const { excludedRoutes = [], routes = [] } = this.buildData ?? {};
const matchingRoute = routes.find((route) => {
if (route.methods && route.methods.length !== 0 && !route.methods.includes(method)) {
return false;
}
if ('literal' in route && route.literal !== undefined) {
return path === route.literal;
}
if ('expression' in route && route.expression !== undefined) {
const regex = new RegExp(route.expression);
return regex.test(path);
}
return false;
});
if (!matchingRoute) {
return;
}
const isExcluded = excludedRoutes.some((excludedRoute) => {
if ('literal' in excludedRoute && excludedRoute.literal !== undefined) {
return path === excludedRoute.literal;
}
if ('expression' in excludedRoute && excludedRoute.expression !== undefined) {
const regex = new RegExp(excludedRoute.expression);
return regex.test(path);
}
return false;
});
if (isExcluded) {
return;
}
if (matchingRoute.prefer_static && (await hasStaticFile())) {
return;
}
return matchingRoute;
}
get runtimeAPIVersion() {
return this.buildData?.runtimeAPIVersion ?? 1;
}
get url() {
// This line fixes the issue here https://github.com/netlify/cli/issues/4116
// Not sure why `settings.port` was used here nor does a valid reference exist.
// However, it remains here to serve whatever purpose for which it was added.
// @ts-expect-error(serhalp) -- Remove use of `port` here? Otherwise, pass it in from `functions:serve`.
const port = this.settings.port || this.settings.functionsPort;
// @ts-expect-error(serhalp) -- Same as above for `https`
const protocol = this.settings.https ? 'https' : 'http';
const url = new URL(`/.netlify/functions/${this.name}`, `${protocol}://localhost:${port}`);
return url.href;
}
}
//# sourceMappingURL=netlify-function.js.map