import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { spawn } from 'child_process'; import pkg from 'lodash'; import { fileURLToPath } from 'url'; import utils from '@serverlessinc/sf-core/src/utils.js'; const { log, style } = utils; const logger = log.get('sls:dev:local-lambda'); const { flatten } = pkg; let __dirname = path.dirname(fileURLToPath(import.meta.url)); if (__dirname.endsWith('dist')) { __dirname = path.join(__dirname, '../lib/plugins/aws/dev/local-lambda'); } /** * This is the main Local Lambda class that will be used to invoke local lambda functions. * Each instance of this class represents a single lambda function instance that can be invoked. * * @class * @param {Object} [config={}] - The configuration object for the Local Lambda. * @param {string} config.handler - The AWS Lambda handler. * @param {string} config.runtime - The AWS Lambda runtime. * @param {Object} [config.environment={}] - An object representing environment variables to set for the local lambda instance in addition to the current process environment. * @param {string} config.serviceAbsolutePath - The absolute path to the service directory. * * @property {string} handler - The AWS Lambda handler. * @property {string} runtime - The AWS Lambda runtime. * @property {Object} environment - The local lambda environment variables. * @property {string} serviceAbsolutePath - The absolute path to the service directory. * * @example * const config = { * handler: 'index.handler', * runtime: 'nodejs20.x', * environment: { FOO: 'BAR' }, * serviceAbsolutePath: '~/path/to/service' * }; * * const localLambda = new LocalLambda(config); */ class LocalLambda { constructor(config = {}) { this.serviceAbsolutePath = config.serviceAbsolutePath; const supportedRuntimes = flatten( runtimeWrappers.map((runtimeWrapper) => runtimeWrapper.versions) ); if (!supportedRuntimes.includes(config.runtime)) { throw new Error(`Unsupported runtime: "${config.runtime}"`); } this.handler = config.handler; this.runtime = config.runtime; this.environment = { ...process.env, ...(config.environment || {}), }; this.invocationColorFn = config.invocationColorFn; } /** * Asynchronously retrieves the absolute path of the handler file, considering possible file extensions. * * This method constructs the absolute path of the handler file based on the handler name provided in the class instance, * the service's absolute path, and the runtime environment. It checks for possible file extensions that match the runtime * environment's expected file types and returns the first matching handler file's absolute path. * * @async * @returns {Promise} A promise that resolves to the absolute path of the handler file. * If no files are found, an error will be thrown. * * @example * const handlerFileAbsolutePath = await localLambda.getHandlerFileAbsolutePath(); * console.log(handlerFileAbsolutePath); // Outputs: "/path/to/service/handler.js" */ async getHandlerFileAbsolutePath() { // Extract the handler file name without the extension. const handlerFileName = this.handler.split('.')[0]; // Construct the absolute path to the handler file without the extension. const handlerFileAbsolutePathWithoutExtension = path.resolve( this.serviceAbsolutePath, handlerFileName ); // Get a list of possible extensions based on the specified runtime // ex. ['.js', '.mjs', '.js', '.ts'] const possibleExtensions = flatten( runtimeWrappers .filter((runtimeWrapper) => runtimeWrapper.versions.includes(this.runtime)) .map((possibleRuntime) => possibleRuntime.extensions) ); // Get a list of possible handler file paths with the different extensions // and check to see which one exists const possibleHandlerFiles = await Promise.all( possibleExtensions.map(async (ext) => { return { path: `${handlerFileAbsolutePathWithoutExtension}${ext}`, exists: await fileExists(`${handlerFileAbsolutePathWithoutExtension}${ext}`), }; }) ); // Find the handler file that actually exists const handlerFileAbsolutePath = possibleHandlerFiles.find((file) => file.exists)?.path; // If none of the possible handler files exist, throw an error if (!handlerFileAbsolutePath) { throw new Error(`Handler "${this.handler}" not found in service directory`); } return handlerFileAbsolutePath; } /** * Asynchronously invokes the specified handler function with the provided event object, executing it in a child process * based on the runtime environment configuration. * * The function captures and filters the child process's stdout and stderr streams, extracting the execution result * encapsulated between '__RESULT_START__' and '__RESULT_END__' delimiters. * * @async * @param {Object} [event={}] - The event object to be passed to the handler function. Defaults to an empty object if not provided. * @param {Object} [context={}] - The context object to be passed to the handler function. Defaults to an empty object if not provided. * @returns {Promise} A promise that resolves with the result of the handler function's execution. * * @example * const event = { key: 'value' }; // Example event object * const context = { key: 'value' }; // Example context object * const result = await instance.invoke(event, context); * console.log(result); // Outputs the handler's execution result */ async invoke(event = {}, context = {}) { // get the absolute path of the handler file set in the class instance const handlerFileAbsolutePath = await this.getHandlerFileAbsolutePath(); // extract the handler name from the handler string const handlerName = this.handler.split('.')[1]; // find the runtime wrapper that supports the handler file extension const runtimeWrapper = runtimeWrappers.find((runtimeWrapper) => runtimeWrapper.extensions.includes(path.extname(handlerFileAbsolutePath)) ); return new Promise((resolve, reject) => { /** * Construct the arguments to be passed to the child process: * - The handler file's absolute path to be imported by the wrapper * - The handler function name to be called by the wrapper * - The event that will be passed as an argument to the handler function * - The context that will be passed as a second argument to the handler function */ const argsString = JSON.stringify({ handlerFileAbsolutePath, handlerName, event, context, }); const childEnv = { ...this.environment }; // Spawn a child process to execute the runtime wrapper and set the specified environment variables const child = spawn( runtimeWrapper.command, [...runtimeWrapper.arguments, runtimeWrapper.path, argsString], { env: childEnv, cwd: this.serviceAbsolutePath, } ); // Write standard output child.stdout.on('data', (data) => { logger.notice(`${this.invocationColorFn('─')} ${data.toString().trim()}`); }); // Write error output child.stderr.on('data', (data) => { logger.notice(`${this.invocationColorFn('─')} ${style.error(data.toString().trim())}`); }); // Handles child process errors, not user function errors child.on('error', (error) => { logger.notice( `${this.invocationColorFn('─')} ${style.error(`Child process error: ${error.message}`)}` ); }); child.on('close', async (code) => { try { const result = await getInvocationResult(child.pid); resolve(result); } catch (error) { reject(error); } }); }); } } /** * Gets the invocation result from the temporary file created by the runtime wrapper, * then deletes the file for cleanup. The result is parsed from JSON and returned. * * @param {*} childProcessId - The ID of the child process that executed the runtime wrapper to construct the unique file name. * * @returns {Promise} A promise that resolves with the parsed result from the temporary file. */ const getInvocationResult = async (childProcessId) => { const filePath = path.join(os.tmpdir(), `sls_${childProcessId}.json`); const result = await fs.readFile(filePath, 'utf8'); // delete the temporary file after reading its contents await fs.unlink(filePath); return JSON.parse(result); }; /** * Asynchronously checks if a file exists at the specified file path. * * @async * @function fileExists * @param {string} filePath - The path to the file whose existence is to be checked. * @returns {Promise} A promise that resolves to `true` if the file exists, or `false` if it does not. * @example * async function checkFile() { * const exists = await fileExists('./example.txt'); * console.log(exists ? 'File exists' : 'File does not exist'); * } */ const fileExists = async (filePath) => { try { await fs.access(filePath, fs.constants.F_OK); return true; } catch (error) { return false; } }; /** * This is a list of runtime wrappers that we currently support. * It is easily extensible to support more runtimes. * Each wrapper has a: * - Command to be executed (e.g. 'node', 'ts-node') * - Path to the wrapper file that the command will execute * - List of runtime versions that the wrapper supports * - List of file extensions that the wrapper supports */ const runtimeWrappers = [ { command: 'node', arguments: [], path: path.join(__dirname, 'runtime-wrappers/node.cjs'), versions: ['nodejs14.x', 'nodejs16.x', 'nodejs18.x', 'nodejs20.x'], extensions: ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'], }, ]; export default LocalLambda;