Skip to content

Instantly share code, notes, and snippets.

@rehanvdm
Last active March 15, 2023 04:10
Show Gist options
  • Save rehanvdm/42d6ffa1d0dbd0283352b80182b28199 to your computer and use it in GitHub Desktop.
Save rehanvdm/42d6ffa1d0dbd0283352b80182b28199 to your computer and use it in GitHub Desktop.
EsbuildFunction
/*
# The `EsbuildFunction` CDK component
Located at `stacks/constructs/EsbuildFunction.ts`. This component is using the
["unsafe"](https://joecreager.com/5-reasons-to-avoid-deasync-for-node-js/) `desync` [npm library](https://www.npmjs.com/package/deasync).
The TL;DR is that it is using an option in the V8 that is not really supported. That said this library has been around
from early Node versions and seems to have continued support.
We accept the risk for now, this is okay as we really want to the ability to run async code within the Bundling
function the CDK exposes for assets. The benefit of this is approach is that we no longer have to maintain a separate
build step before calling the CDK commands.
The alternative to this would be to place a build.ts file next to each lambda handler so by creating `src/backend/api-front/build.ts`
that does the building for each lambda function. Then from within the CDK bundler callback, call this build script
synchronously. Also consider that IF this breaks, it breaks at compile time, and there are workarounds like the one
mentioned above. We are never using `deasync` at runtime which would have been a big red flag for me. With all of this
in mind and with a backup strategy, the risk is minimized.
There are also some alternatives but none without drawbacks as well:
- https://www.npmjs.com/package/synchronous-promise
- https://github.com/sindresorhus/make-synchronous
- https://github.com/giuseppeg/styled-jsx-plugin-postcss/commit/52a30d9c12bdc39379e75f473860f7e92ce94c1b
*/
import * as lambda from "aws-cdk-lib/aws-lambda";
import path from "path";
import * as cdk from "aws-cdk-lib";
import {Construct} from "constructs";
import * as esbuild from "esbuild";
import {BundlingOptions} from "aws-cdk-lib";
import fs from "fs";
import {Plugin} from "esbuild";
import deasync from "deasync";
import {visualizer, TemplateType} from "esbuild-visualizer";
import opn from "open";
function getFiles(source: string)
{
return fs.readdirSync(source, { withFileTypes: true })
.filter(dirent => dirent.isFile())
.map(dirent => dirent.name);
}
function esBuildPluginShrinkSourceMap(): Plugin
{
//https://github.com/evanw/esbuild/issues/1685#issuecomment-944916409
return {
name: 'excludeVendorFromSourceMap',
setup(build) {
build.onLoad({ filter: /node_modules/ }, args => {
if (args.path.endsWith('.js') && !args.path.endsWith('.json'))
return {
contents: fs.readFileSync(args.path, 'utf8')
+ '\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIiJdLCJtYXBwaW5ncyI6IkEifQ==',
loader: 'default',
}
else
return
})
},
}
}
export type EsbuildFunctionBundlerOptions = {
/**
* Defaults to cdk.out/esbuild-visualizer
*/
outputDir?: string,
/**
* Defaults to "treemap"
*/
template?: TemplateType,
/**
* Open the HTML file after bundling
*/
open?: boolean
}
export type EsbuildFunctionProps = Omit<lambda.FunctionProps,"code"> & {
/**
* Path to TS file ending in .ts
*/
entry: string,
/**
* The name of the exported handler function in the entry file
*/
handler: string,
esbuildOptions: Omit<esbuild.BuildOptions, "entryPoints" | "outfile">,
bundleAnalyzer?: EsbuildFunctionBundlerOptions
};
export class EsbuildFunction extends lambda.Function
{
constructor(scope: Construct, id: string, props: EsbuildFunctionProps)
{
const srcDir = path.dirname(props.entry);
const fileNameNoExtension = path.basename(props.entry, path.extname(props.entry));
const newLambdaProps: lambda.FunctionProps = {
...props,
handler: `${fileNameNoExtension}.${props.handler}`,
code: lambda.Code.fromAsset(srcDir, {
assetHashType: cdk.AssetHashType.OUTPUT,
bundling: {
image: cdk.DockerImage.fromRegistry('local'),/* Does not exist will always use local below */
local: {
tryBundle(outputDir: string, options: BundlingOptions): boolean
{
let pathTs = props.entry;
let pathJs = path.join(outputDir, fileNameNoExtension +".js");
let bundleAnalysisPath;
const timingLabel = " Bundled in";
console.time(timingLabel);
let done = false;
let result: any;
esbuild.build({
platform: 'node',
target: ["es2020"],
minify: true,
bundle: true,
keepNames: true,
sourcemap: 'linked',
entryPoints: [pathTs],
outfile: pathJs,
external: ["aws-sdk"],
logLevel: "warning",
metafile: true,
...props.esbuildOptions,
plugins: [
...(props.esbuildOptions?.plugins || []),
esBuildPluginShrinkSourceMap(),
],
}).then(async resp =>
{
result = resp;
const bundlerDefaults: EsbuildFunctionBundlerOptions = {
outputDir: "cdk.out/esbuild-visualizer",
template: "treemap"
};
/* Analyze Bundle */
// fs.writeFileSync('meta.json', JSON.stringify(result.metafile));
// let text = await esbuild.analyzeMetafile(result.metafile, {verbose: true, color: true});
// console.log(text);
const htmlContent = await visualizer(result.metafile, {
title: props.functionName,
template: props.bundleAnalyzer?.template || bundlerDefaults.template!,
});
const outputDir = props.bundleAnalyzer?.outputDir || bundlerDefaults.outputDir!;
const outputFile = path.join(outputDir, props.functionName! + ".html")
if(!fs.existsSync(outputDir))
fs.mkdirSync(outputDir);
fs.writeFileSync(outputFile, htmlContent);
bundleAnalysisPath = path.resolve(outputFile)
if(props.bundleAnalyzer?.open)
await opn(outputFile);
}).catch(err => {
result = err;
}).finally(() => done = true);
deasync.loopWhile(() => !done);
// console.log(result);
if(result instanceof Error)
throw result;
const fileNames = getFiles(outputDir);
for(let file of fileNames)
{
const stats = fs.statSync(path.join(outputDir, file));
const fileSizeInMegabytes = (stats.size / (1024*1024)).toFixed(2);
console.log(" - "+file, fileSizeInMegabytes+"mb");
}
console.timeEnd(timingLabel);
console.log(" Bundle analysis: " + bundleAnalysisPath);
return true;
}
}
}
}),
};
super(scope, id, newLambdaProps);
}
}
=== USAGE ===
const apiFrontLambda = new EsbuildFunction(this, name("lambda-api-front"), {
functionName: name("api-front"),
entry: path.join(__dirname, './src/backend/api-front/index.ts'),
handler: 'handler',
//pass anything here, even plugins, or omit
esbuildOptions: {
},
//optional
bundleAnalyzer: {
open: true
},
runtime: lambda.Runtime.NODEJS_16_X,
...defaultNodeJsFuncOpt,
memorySize: 1024,
environment: {
...defaultEnv,
},
reservedConcurrentExecutions: 100,
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment