mirror of
https://github.com/serverless/serverless.git
synced 2025-12-08 19:46:03 +00:00
270 lines
9.8 KiB
JavaScript
270 lines
9.8 KiB
JavaScript
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { spawn } = require('child_process');
|
|
const { flatten } = require('lodash');
|
|
|
|
/**
|
|
* 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 || {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<string>} 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', '.cjs', '.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<any>} 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,
|
|
});
|
|
|
|
/**
|
|
* In some scenarios, like using tsx and typescript, you have to provide a PID using
|
|
* an intermediary logline instead of depending on the spawn process id
|
|
*/
|
|
let resultPID;
|
|
|
|
// 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: this.environment,
|
|
cwd: this.serviceAbsolutePath,
|
|
}
|
|
);
|
|
|
|
// Write standard output
|
|
child.stdout.on('data', (chunk) => {
|
|
const logLine = chunk.toString();
|
|
if (logLine.startsWith('RESULT_FILE: ')) {
|
|
resultPID = logLine.replace('/tmp/sls_', '').replace('.json', '').trim();
|
|
} else {
|
|
process.stdout.write(chunk.toString());
|
|
}
|
|
});
|
|
|
|
// Write error output
|
|
child.stderr.on('data', (chunk) => {
|
|
process.stderr.write(chunk.toString());
|
|
});
|
|
|
|
// Handles child process errors, not user function errors
|
|
child.on('error', (error) => {
|
|
console.error(`Child process error: ${error.message}`);
|
|
});
|
|
|
|
child.on('close', async (code) => {
|
|
try {
|
|
const result = await getInvocationResult(resultPID ?? 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<any>} 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<boolean>} 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/wrapper.js'),
|
|
versions: ['nodejs14.x', 'nodejs16.x', 'nodejs18.x', 'nodejs20.x'],
|
|
extensions: ['.js', '.mjs', '.cjs'],
|
|
},
|
|
{
|
|
command: 'npx',
|
|
arguments: ['tsx'],
|
|
path: path.join(__dirname, 'runtime-wrappers/wrapper.ts'),
|
|
versions: ['nodejs14.x', 'nodejs16.x', 'nodejs18.x', 'nodejs20.x'],
|
|
extensions: ['.ts', '.mts', '.cts'],
|
|
},
|
|
];
|
|
|
|
module.exports = LocalLambda;
|