lambda-live-debugger
Version:
Debug Lambda functions locally like it is running in the cloud
550 lines (547 loc) • 24.7 kB
JavaScript
import * as esbuild from 'esbuild';
import * as fs from 'fs/promises';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { BundlingType } from '../types/resourcesDiscovery.mjs';
import { outputFolder } from '../constants.mjs';
import { findPackageJson } from '../utils/findPackageJson.mjs';
import { CloudFormation } from '../cloudFormation.mjs';
import { Logger } from '../logger.mjs';
import { Worker } from 'node:worker_threads';
import { getModuleDirname, getProjectDirname } from '../getDirname.mjs';
import { findNpmPath } from '../utils/findNpmPath.mjs';
import { loadSharedConfigFiles } from '@smithy/shared-ini-file-loader';
/**
* Support for AWS CDK framework
*/
export class CdkFramework {
/**
* Framework name
*/
get name() {
return 'cdk';
}
/**
* Can this class handle the current project
* @returns
*/
async canHandle() {
// check if there is cdk.json
const cdkJsonPath = path.resolve('cdk.json');
try {
await fs.access(cdkJsonPath, fs.constants.F_OK);
return true;
}
catch {
Logger.verbose(`[CDK] This is not a CDK project. ${cdkJsonPath} not found`);
return false;
}
}
/**
* Get Lambda functions
* @param config Configuration
* @returns Lambda functions
*/
async getLambdas(config) {
const awsConfiguration = {
region: config.region,
profile: config.profile,
role: config.role,
};
const cdkConfigPath = 'cdk.json';
// read cdk.json and extract the entry file
const lambdasInCdk = await this.getLambdasDataFromCdkByCompilingAndRunning(cdkConfigPath, config);
Logger.verbose(`[CDK] Found Lambda functions:`, JSON.stringify(lambdasInCdk, null, 2));
const cdkTokenRegex = /^\${Token\[TOKEN\.\d+\]}$/;
const stackNamesDuplicated = lambdasInCdk.map((lambda) => {
if (cdkTokenRegex.test(lambda.stackName)) {
return lambda.rootStackName;
}
else {
return lambda.stackName;
}
});
const stackNames = [...new Set(stackNamesDuplicated)];
Logger.verbose(`[CDK] Found the following stacks in CDK: ${stackNames.join(', ')}`);
const lambdasDeployed = (await Promise.all(stackNames.map(async (stackName) => {
const lambdasInStack = await CloudFormation.getLambdasInStack(stackName, awsConfiguration);
const stackAndNestedStackNames = [
...new Set(lambdasInStack.map((l) => l.stackName)),
];
const lambdasMetadata = (await Promise.all(stackAndNestedStackNames.map((stackOrNestedStackName) => this.getLambdaCdkPathFromTemplateMetadata(stackOrNestedStackName, awsConfiguration)))).flat();
Logger.verbose(`[CDK] Found Lambda functions in the stack ${stackName}:`, JSON.stringify(lambdasInStack, null, 2));
Logger.verbose(`[CDK] Found Lambda functions in the stack ${stackName} in the template metadata:`, JSON.stringify(lambdasMetadata, null, 2));
const lambdasPhysicalResourceIds = lambdasInStack.map((lambda) => {
return {
lambdaName: lambda.lambdaName,
cdkPath: lambdasMetadata.find((lm) => lm.logicalId === lambda.logicalId &&
lm.stackName === lambda.stackName)?.cdkPath,
};
});
return lambdasPhysicalResourceIds;
}))).flat();
const lambdasDiscovered = [];
// compare lambdas in CDK and Stack and get the code path of the Lambdas
for (const lambdaInCdk of lambdasInCdk) {
const functionName = lambdasDeployed.find((lambda) => lambda.cdkPath === lambdaInCdk.cdkPath)?.lambdaName;
if (functionName) {
const external = [
...(lambdaInCdk.bundling?.externalModules ?? []),
...(lambdaInCdk.bundling?.nodeModules ?? []),
];
lambdasDiscovered.push({
functionName: functionName,
codePath: lambdaInCdk.codePath,
handler: lambdaInCdk.handler,
packageJsonPath: lambdaInCdk.packageJsonPath,
bundlingType: BundlingType.ESBUILD,
esBuildOptions: {
minify: lambdaInCdk.bundling?.minify,
format: lambdaInCdk.bundling?.format,
sourcesContent: lambdaInCdk.bundling?.sourcesContent,
target: lambdaInCdk.bundling?.target,
loader: lambdaInCdk.bundling?.loader,
logLevel: lambdaInCdk.bundling?.logLevel,
keepNames: lambdaInCdk.bundling?.keepNames,
tsconfig: lambdaInCdk.bundling?.tsconfig,
metafile: lambdaInCdk.bundling?.metafile,
banner: lambdaInCdk.bundling?.banner
? { js: lambdaInCdk.bundling?.banner }
: undefined,
footer: lambdaInCdk.bundling?.footer
? { js: lambdaInCdk.bundling?.footer }
: undefined,
charset: lambdaInCdk.bundling?.charset,
define: lambdaInCdk.bundling?.define,
external: external.length > 0 ? external : undefined,
},
metadata: {
framework: 'cdk',
stackName: lambdaInCdk.stackName,
cdkPath: lambdaInCdk.cdkPath,
},
});
}
}
return lambdasDiscovered;
}
/**
* Getz Lambda functions from the CloudFormation template metadata
* @param stackName
* @param awsConfiguration
* @returns
*/
async getLambdaCdkPathFromTemplateMetadata(stackName, awsConfiguration) {
const cfTemplate = await CloudFormation.getCloudFormationStackTemplate(stackName, awsConfiguration);
// get all Lambda functions in the template
if (cfTemplate) {
const lambdas = Object.entries(cfTemplate.Resources)
.filter(([, resource]) => resource.Type === 'AWS::Lambda::Function')
.map(([key, resource]) => {
return {
logicalId: key,
cdkPath: resource.Metadata['aws:cdk:path'],
stackName: stackName,
};
});
return lambdas;
}
return [];
}
/**
* Get Lambdas data from CDK by compiling and running the CDK code
* @param cdkConfigPath
* @param config
* @returns
*/
async getLambdasDataFromCdkByCompilingAndRunning(cdkConfigPath, config) {
const entryFile = await this.getCdkEntryFile(cdkConfigPath);
let isESM = false;
const packageJsonPath = await findPackageJson(entryFile);
if (packageJsonPath) {
try {
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, { encoding: 'utf-8' }));
if (packageJson.type === 'module') {
isESM = true;
Logger.verbose(`[CDK] Using ESM format`);
}
}
catch (err) {
Logger.error(`Error reading CDK package.json (${packageJsonPath}): ${err.message}`, err);
}
}
const rootDir = process.cwd();
// Plugin that:
// - Fixes __dirname issues
// - Injects code to get the file path of the Lambda function and CDK hierarchy
const injectCodePlugin = {
name: 'injectCode',
setup(build) {
build.onLoad({ filter: /.*/ }, async (args) => {
// fix __dirname issues
const isWindows = /^win/.test(process.platform);
const esc = (p) => (isWindows ? p.replace(/\\/g, '/') : p);
const variables = `
const __fileloc = {
filename: "${esc(args.path)}",
dirname: "${esc(path.dirname(args.path))}",
relativefilename: "${esc(path.relative(rootDir, args.path))}",
relativedirname: "${esc(path.relative(rootDir, path.dirname(args.path)))}",
import: { meta: { url: "file://${esc(args.path)}" } }
};
`;
let fileContent = new TextDecoder().decode(await fs.readFile(args.path));
// remove shebang
if (fileContent.startsWith('#!')) {
const firstNewLine = fileContent.indexOf('\n');
fileContent = fileContent.slice(firstNewLine + 1);
}
let contents;
if (args.path.endsWith('.ts') || args.path.endsWith('.js')) {
// add the variables at the top of the file, that contains the file location
contents = `${variables}\n${fileContent}`;
}
else {
contents = fileContent;
}
// for .mjs files, use js loader
const fileExtension = args.path.split('.').pop();
const loader = fileExtension === 'mjs' || fileExtension === 'cjs'
? 'js'
: fileExtension;
// Inject code to get the file path of the Lambda function and CDK hierarchy
if (args.path.includes(path.join('aws-cdk-lib', 'aws-lambda', 'lib', 'function.'))) {
const codeToFind = 'try{jsiiDeprecationWarnings().aws_cdk_lib_aws_lambda_FunctionProps(props)}';
if (!contents.includes(codeToFind)) {
throw new Error(`Can not find code to inject in ${args.path}`);
}
// Inject code to get the file path of the Lambda function and CDK hierarchy
// path to match it with the Lambda function. Store data in the global variable.
contents = contents.replace(codeToFind, `;
global.lambdas = global.lambdas ?? [];
let rootStack = this.stack;
while (rootStack.nestedStackParent) {
rootStack = rootStack.nestedStackParent;
}
const lambdaInfo = {
//cdkPath: this.node.defaultChild?.node.path ?? this.node.path,
stackName: this.stack.stackName,
rootStackName: rootStack.stackName,
codePath: props.entry,
code: props.code,
node: this.node,
handler: props.handler,
bundling: props.bundling
};
// console.log("CDK INFRA: ", {
// stackName: lambdaInfo.stackName,
// rootStackName: lambdaInfo.rootStackName,
// codePath: lambdaInfo.codePath,
// code: lambdaInfo.code,
// handler: lambdaInfo.handler,
// bundling: lambdaInfo.bundling
// });
global.lambdas.push(lambdaInfo);` + codeToFind);
}
else if (args.path.includes(path.join('aws-cdk-lib', 'aws-s3-deployment', 'lib', 'bucket-deployment.'))) {
let codeToFind = 'super(scope,id),this.requestDestinationArn=!1;';
if (!contents.includes(codeToFind)) {
// newer CDK version
codeToFind = 'super(scope,id);';
}
if (!contents.includes(codeToFind)) {
throw new Error(`Can not find code to inject in ${args.path}`);
}
// Inject code to prevent deploying the assets
contents = contents.replace(codeToFind, codeToFind + `return;`);
}
else if (args.path.includes(path.join('aws-cdk-lib', 'aws-lambda-nodejs', 'lib', 'bundling.'))) {
// prevent initializing Docker if esbuild is no installed
// Docker is used for bundling if esbuild is not installed, but it is not needed at this point
const origCode = 'const shouldBuildImage=props.forceDockerBundling||!Bundling.esbuildInstallation;';
const replaceCode = 'const shouldBuildImage=false;';
if (contents.includes(origCode)) {
contents = contents.replace(origCode, replaceCode);
}
else {
throw new Error(`Can not find code to inject in ${args.path} to prevent initializing Docker`);
}
}
return {
contents,
loader,
};
});
},
};
const compileOutput = path.join(getProjectDirname(), outputFolder, `compiledCdk.${isESM ? 'mjs' : 'cjs'}`);
try {
// Build CDK code
await esbuild.build({
entryPoints: [entryFile],
bundle: true,
platform: 'node',
keepNames: true,
outfile: compileOutput,
sourcemap: false,
plugins: [injectCodePlugin],
...(isESM
? {
format: 'esm',
target: 'esnext',
mainFields: ['module', 'main'],
banner: {
js: [
`import { createRequire as topLevelCreateRequire } from 'module';`,
`global.require = global.require ?? topLevelCreateRequire(import.meta.url);`,
`import { fileURLToPath as topLevelFileUrlToPath, URL as topLevelURL } from "url"`,
`global.__dirname = global.__dirname ?? topLevelFileUrlToPath(new topLevelURL(".", import.meta.url))`,
].join('\n'),
},
}
: {
format: 'cjs',
target: 'node18',
}),
define: {
// replace __dirname,... with the a variable that contains the file location
__filename: '__fileloc.filename',
__dirname: '__fileloc.dirname',
__relativefilename: '__fileloc.relativefilename',
__relativedirname: '__fileloc.relativedirname',
'import.meta.url': '__fileloc.import.meta.url',
},
});
}
catch (error) {
throw new Error(`Error building CDK code: ${error.message}`, {
cause: error,
});
}
const context = await this.getCdkContext(cdkConfigPath, config);
const CDK_CONTEXT_JSON = {
...context,
// prevent compiling assets
'aws:cdk:bundling-stacks': [],
};
process.env.CDK_CONTEXT_JSON = JSON.stringify(CDK_CONTEXT_JSON);
process.env.CDK_DEFAULT_REGION =
config.region ?? (await this.getRegion(config.profile));
Logger.verbose(`[CDK] Context:`, JSON.stringify(CDK_CONTEXT_JSON, null, 2));
const awsCdkLibPath = await findNpmPath(path.join(getProjectDirname(), config.subfolder ?? '/'), 'aws-cdk-lib');
Logger.verbose(`[CDK] aws-cdk-lib path: ${awsCdkLibPath}`);
const lambdas = await this.runCdkCodeAndReturnLambdas({
config,
awsCdkLibPath,
compileCodeFile: compileOutput,
});
const list = await Promise.all(lambdas.map(async (lambda) => {
// handler slit into file and file name
const handlerSplit = lambda.handler.split('.');
const handler = handlerSplit.pop();
const filename = handlerSplit[0];
let codePath = lambda.codePath;
if (!codePath) {
const codePathJs = lambda.code?.path
? path.join(lambda.code.path, `${filename}.js`)
: undefined;
const codePathCjs = lambda.code?.path
? path.join(lambda.code.path, `${filename}.cjs`)
: undefined;
const codePathMjs = lambda.code?.path
? path.join(lambda.code.path, `${filename}.mjs`)
: undefined;
// get the first file that exists
codePath = [codePathJs, codePathCjs, codePathMjs]
.filter((c) => c)
.find((file) => fs
.access(file)
.then(() => true)
.catch(() => false));
}
let packageJsonPath;
if (codePath) {
packageJsonPath = await findPackageJson(codePath);
Logger.verbose(`[CDK] package.json path: ${packageJsonPath}`);
}
return {
cdkPath: lambda.cdkPath,
stackName: lambda.stackName,
rootStackName: lambda.rootStackName,
packageJsonPath,
codePath: codePath,
handler,
bundling: lambda.bundling,
};
}));
const filteredList = list.filter((lambda) => lambda.codePath);
const noCodeList = list.filter((lambda) => !lambda.codePath);
if (noCodeList.length > 0) {
Logger.warn(`[CDK] For the following Lambda functions the code file could not be determined and they will be ignored. Inline code is not supported.\n - ${noCodeList
.map((l) => `${l.cdkPath}`)
.join('\n - ')}`);
}
return filteredList.map((lambda) => ({
...lambda,
codePath: lambda.codePath,
}));
}
/**
* Run CDK code in a node thread worker and return the Lambda functions
* @param param0
* @returns
*/
async runCdkCodeAndReturnLambdas({ config, awsCdkLibPath, compileCodeFile, }) {
const lambdas = await new Promise((resolve, reject) => {
const workerPath = pathToFileURL(path.resolve(path.join(getModuleDirname(), 'frameworks/cdkFrameworkWorker.mjs'))).href;
const worker = new Worker(new URL(workerPath), {
workerData: {
verbose: config.verbose,
awsCdkLibPath,
projectDirname: getProjectDirname(),
moduleDirname: getModuleDirname(),
subfolder: config.subfolder,
},
});
worker.on('message', async (message) => {
resolve(message);
await worker.terminate();
});
worker.on('error', (error) => {
reject(new Error(`Error running CDK code in worker: ${error.message}`, {
cause: error,
}));
});
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`CDK worker stopped with exit code ${code}`));
}
});
// worker.stdout.on('data', (data: Buffer) => {
// Logger.log(`[CDK]`, data.toString());
// });
// worker.stderr.on('data', (data: Buffer) => {
// Logger.error(`[CDK]`, data.toString());
// });
worker.postMessage({
compileOutput: compileCodeFile,
});
});
Logger.verbose(`[CDK] Found the following Lambda functions in the CDK code:`, JSON.stringify(lambdas, null, 2));
return lambdas;
}
/**
* Get CDK context
* @param cdkConfigPath
* @param config
* @returns
*/
async getCdkContext(cdkConfigPath, config) {
// get CDK context from the command line
// get all "-c" and "--context" arguments from the command line
const contextFromLldConfig = config.context?.reduce((acc, arg) => {
const [key, value] = arg.split('=');
if (key && value) {
acc[key.trim()] = value.trim();
}
return acc;
}, {});
// get all context from 'cdk.context.json' if it exists
let contextFromJson = {};
try {
const cdkContextJson = await fs.readFile('cdk.context.json', 'utf8');
contextFromJson = JSON.parse(cdkContextJson);
}
catch (err) {
if (err.code !== 'ENOENT') {
throw new Error(`Error reading cdk.context.json: ${err.message}`);
}
}
// get context from cdk.json
let cdkJson = {};
try {
cdkJson = JSON.parse(await fs.readFile(cdkConfigPath, 'utf8'));
}
catch (err) {
if (err.code !== 'ENOENT') {
throw new Error(`Error reading cdk.json: ${err.message}`);
}
}
return { ...contextFromJson, ...cdkJson.context, ...contextFromLldConfig };
}
/**
* Get CDK entry file
* @param cdkConfigPath
* @returns
*/
async getCdkEntryFile(cdkConfigPath) {
const cdkJson = await fs.readFile(cdkConfigPath, 'utf8');
const cdkConfig = JSON.parse(cdkJson);
const entry = cdkConfig.app;
// just file that ends with .ts
let entryFile = entry
?.split(' ')
.find((file) => file.endsWith('.ts'))
?.trim();
if (!entryFile) {
throw new Error(`Entry file not found in ${cdkConfigPath}`);
}
entryFile = path.resolve(entryFile);
Logger.verbose(`[CDK] Entry file: ${entryFile}`);
return entryFile;
}
/**
* Attempts to get the region from a number of sources and falls back to us-east-1 if no region can be found,
* as is done in the AWS CLI.
*
* The order of priority is the following:
*
* 1. Environment variables specifying region, with both an AWS prefix and AMAZON prefix
* to maintain backwards compatibility, and without `DEFAULT` in the name because
* Lambda and CodeBuild set the $AWS_REGION variable.
* 2. Regions listed in the Shared Ini Files - First checking for the profile provided
* and then checking for the default profile.
* 3. xxx
* 4. us-east-1
*
* Code from aws-cdk-cli/packages/@aws-cdk/tmp-toolkit-helpers/src/api/aws-auth
/awscli-compatible.ts
*/
async getRegion(maybeProfile) {
const profile = maybeProfile ||
process.env.AWS_PROFILE ||
process.env.AWS_DEFAULT_PROFILE ||
'default';
const region = process.env.AWS_REGION ||
process.env.AMAZON_REGION ||
process.env.AWS_DEFAULT_REGION ||
process.env.AMAZON_DEFAULT_REGION ||
(await this.getRegionFromIni(profile));
return region;
}
/**
* Looks up the region of the provided profile. If no region is present,
* it will attempt to lookup the default region.
* @param profile The profile to use to lookup the region
* @returns The region for the profile or default profile, if present. Otherwise returns undefined.
*
* Code from aws-cdk-cli/packages/@aws-cdk/tmp-toolkit-helpers/src/api/aws-auth
*/
async getRegionFromIni(profile) {
const sharedFiles = await loadSharedConfigFiles({ ignoreCache: true });
return (this.getRegionFromIniFile(profile, sharedFiles.credentialsFile) ??
this.getRegionFromIniFile(profile, sharedFiles.configFile) ??
this.getRegionFromIniFile('default', sharedFiles.credentialsFile) ??
this.getRegionFromIniFile('default', sharedFiles.configFile));
}
/**
* Get region from ini file
* @param profile
* @param data
* @returns
*/
getRegionFromIniFile(profile, data) {
return data?.[profile]?.region;
}
}
export const cdkFramework = new CdkFramework();