@netlify/zip-it-and-ship-it
Version:
211 lines (210 loc) • 8.89 kB
JavaScript
import { dirname } from 'path';
// @ts-expect-error(serhalp) -- Remove once https://github.com/schnittstabil/merge-options/pull/28 is merged, or replace
// this dependency.
import mergeOptions from 'merge-options';
import { z } from 'zod';
import { functionConfig } from '../../../config.js';
import { INVOCATION_MODE } from '../../../function.js';
import { rateLimit } from '../../../rate_limit.js';
import { ensureArray } from '../../../utils/ensure_array.js';
import { FunctionBundlingUserError } from '../../../utils/error.js';
import { getRoutes } from '../../../utils/routes.js';
import { RUNTIME } from '../../runtime.js';
import { createBindingsMethod } from '../parser/bindings.js';
import { traverseNodes } from '../parser/exports.js';
import { getImports } from '../parser/imports.js';
import { safelyParseSource, safelyReadSource } from '../parser/index.js';
import { eventHandlers } from '@netlify/serverless-functions-api';
import { parse as parseSchedule } from './properties/schedule.js';
export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions';
const httpMethod = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE', 'HEAD']);
const httpMethods = z.preprocess((input) => (typeof input === 'string' ? input.toUpperCase() : input), httpMethod);
const path = z.string().startsWith('/', { message: "Must start with a '/'" });
export const inSourceConfig = functionConfig
.pick({
externalNodeModules: true,
generator: true,
includedFiles: true,
ignoredNodeModules: true,
name: true,
nodeBundler: true,
nodeVersion: true,
schedule: true,
timeout: true,
})
.extend({
method: z
.union([httpMethods, z.array(httpMethods)], {
errorMap: () => ({ message: 'Must be a string or array of strings' }),
})
.transform(ensureArray)
.optional(),
path: z
.union([path, z.array(path)], { errorMap: () => ({ message: 'Must be a string or array of strings' }) })
.transform(ensureArray)
.optional(),
excludedPath: z
.union([path, z.array(path)], { errorMap: () => ({ message: 'Must be a string or array of strings' }) })
.transform(ensureArray)
.optional(),
preferStatic: z.boolean().optional().catch(undefined),
rateLimit: rateLimit.optional().catch(undefined),
});
/**
* Extracts event subscription slugs from the default export expression,
* if it's an object whose property names match known event handlers.
*/
const getEventSubscriptions = (expression, getAllBindings) => {
let objectExpression;
if (expression?.type === 'ObjectExpression') {
objectExpression = expression;
}
else if (expression?.type === 'Identifier') {
const binding = getAllBindings().get(expression.name);
if (binding?.type === 'ObjectExpression') {
objectExpression = binding;
}
}
if (!objectExpression) {
return [];
}
const events = [];
for (const property of objectExpression.properties) {
let name;
if ((property.type === 'ObjectMethod' || property.type === 'ObjectProperty') &&
property.key.type === 'Identifier') {
name = property.key.name;
}
if (name && name in eventHandlers) {
events.push(eventHandlers[name]);
}
}
return events;
};
const validateScheduleFunction = (functionFound, scheduleFound, functionName) => {
if (!functionFound) {
throw new FunctionBundlingUserError("The `schedule` helper was imported but we couldn't find any usages. If you meant to schedule a function, please check that `schedule` is invoked and `handler` correctly exported.", { functionName, runtime: RUNTIME.JAVASCRIPT });
}
if (!scheduleFound) {
throw new FunctionBundlingUserError('Unable to find cron expression for scheduled function. The cron expression (first argument) for the `schedule` helper needs to be accessible inside the file and cannot be imported.', { functionName, runtime: RUNTIME.JAVASCRIPT });
}
};
/**
* Loads a file at a given path, parses it into an AST, and returns a series of
* data points, such as in-source configuration properties and other metadata.
*/
export const parseFile = async (sourcePath, { functionName }) => {
const source = await safelyReadSource(sourcePath);
if (source === null) {
return {
config: {},
};
}
return parseSource(source, { functionName });
};
/**
* Takes a JS/TS source as a string, parses it into an AST, and returns a
* series of data points, such as in-source configuration properties and
* other metadata.
*/
export const parseSource = (source, { functionName }) => {
const ast = safelyParseSource(source);
if (ast === null) {
return {
config: {},
};
}
const imports = ast.body.flatMap((node) => getImports(node, IN_SOURCE_CONFIG_MODULE));
const scheduledFunctionExpected = imports.some(({ imported }) => imported === 'schedule');
let scheduledFunctionFound = false;
let scheduleFound = false;
const getAllBindings = createBindingsMethod(ast.body);
const { configExport, handlerExports, hasDefaultExport, defaultExportExpression, inputModuleFormat } = traverseNodes(ast.body, getAllBindings);
const isV2API = handlerExports.length === 0 && hasDefaultExport;
if (isV2API) {
const result = {
config: {},
inputModuleFormat,
runtimeAPIVersion: 2,
};
const eventSubscriptions = getEventSubscriptions(defaultExportExpression, getAllBindings);
if (eventSubscriptions.length > 0) {
result.eventSubscriptions = eventSubscriptions;
}
const { data, error, success } = inSourceConfig.safeParse(configExport);
if (success) {
result.config = data;
result.excludedRoutes = getRoutes(functionName, data.excludedPath);
result.routes = getRoutes(functionName, data.path).map((route) => ({
...route,
methods: data.method ?? [],
prefer_static: data.preferStatic || undefined,
}));
}
else {
// TODO: Handle multiple errors.
const [issue] = error.issues;
throw new FunctionBundlingUserError(`Function ${functionName} has a configuration error on '${issue.path.join('.')}': ${issue.message}`, {
functionName,
runtime: RUNTIME.JAVASCRIPT,
});
}
return result;
}
const result = {
config: {},
inputModuleFormat,
runtimeAPIVersion: 1,
};
handlerExports.forEach((node) => {
// We're only interested in exports with call expressions, since that's
// the pattern we use for the wrapper functions.
if (node.type !== 'call-expression') {
return;
}
const { args, local: exportName } = node;
const matchingImport = imports.find(({ local: importName }) => importName === exportName);
if (matchingImport === undefined) {
return;
}
switch (matchingImport.imported) {
case 'schedule': {
const parsed = parseSchedule({ args }, getAllBindings);
scheduledFunctionFound = true;
if (parsed.schedule) {
scheduleFound = true;
}
if (parsed.schedule !== undefined) {
result.config.schedule = parsed.schedule;
}
return;
}
case 'stream': {
result.invocationMode = INVOCATION_MODE.Stream;
return;
}
default:
// no-op
}
return;
});
if (scheduledFunctionExpected) {
validateScheduleFunction(scheduledFunctionFound, scheduleFound, functionName);
}
return result;
};
export const augmentFunctionConfig = (mainFile, tomlConfig, inSourceConfig = {}) => {
const mergedConfig = mergeOptions.call({ concatArrays: true }, tomlConfig, inSourceConfig);
// We can't simply merge included files from the TOML and from in-source
// configuration because their globs are relative to different base paths.
// In the future, we could shift things around so we resolve each glob
// relative to the right base, but for now we say that included files in
// the source override any files defined in the TOML. It doesn't make a lot
// of sense to be defining include files for a framework-generated function
// in the TOML anyway.
if (inSourceConfig?.includedFiles && inSourceConfig.includedFiles.length !== 0) {
mergedConfig.includedFiles = inSourceConfig.includedFiles;
mergedConfig.includedFilesBasePath = dirname(mainFile);
}
return mergedConfig;
};