Skip to content

Commit

Permalink
feat(lambda-nodejs): typescript emitDecoratorMetadata support (aws#16543
Browse files Browse the repository at this point in the history
)

closes [aws#13767](aws#13767)

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
hassanazharkhan authored and TikiTDO committed Feb 21, 2022
1 parent 0386abe commit 05d0c17
Show file tree
Hide file tree
Showing 10 changed files with 460 additions and 24 deletions.
16 changes: 16 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,22 @@ an array of commands to run. Commands are chained with `&&`.
The commands will run in the environment in which bundling occurs: inside the
container for Docker bundling or on the host OS for local bundling.

## Pre Compilation with TSC

In some cases, `esbuild` may not yet support some newer features of the typescript language, such as,
[`emitDecoratorMetadata`](https://www.typescriptlang.org/tsconfig#emitDecoratorMetadata).
In such cases, it is possible to run pre-compilation using `tsc` by setting the `preCompilation` flag.

```ts
new lambda.NodejsFunction(this, 'my-handler', {
bundling: {
preCompilation: true,
},
});
```

Note: A [`tsconfig.json` file](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) is required

## Customizing Docker bundling

Use `bundling.environment` to define environments variables when `esbuild` runs:
Expand Down
3 changes: 3 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ RUN npm install --global yarn@1.22.5
# Install pnpm
RUN npm install --global pnpm

# Install typescript
RUN npm install --global typescript

# Install esbuild
# (unsafe-perm because esbuild has a postinstall script)
ARG ESBUILD_VERSION=0
Expand Down
64 changes: 55 additions & 9 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as os from 'os';
import * as path from 'path';
import { Architecture, AssetCode, Code, Runtime } from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';
import { EsbuildInstallation } from './esbuild-installation';
import { PackageInstallation } from './package-installation';
import { PackageManager } from './package-manager';
import { BundlingOptions, SourceMapMode } from './types';
import { exec, extractDependencies, findUp } from './util';
Expand Down Expand Up @@ -37,6 +37,12 @@ export interface BundlingProps extends BundlingOptions {
* Path to project root
*/
readonly projectRoot: string;

/**
* Run compilation using `tsc` before bundling
*/
readonly preCompilation?: boolean

}

/**
Expand All @@ -57,7 +63,17 @@ export class Bundling implements cdk.BundlingOptions {
this.esbuildInstallation = undefined;
}

private static esbuildInstallation?: EsbuildInstallation;
public static clearTscInstallationCache(): void {
this.tscInstallation = undefined;
}

public static clearTscCompilationCache(): void {
this.tscCompiled = false;
}

private static esbuildInstallation?: PackageInstallation;
private static tscInstallation?: PackageInstallation;
private static tscCompiled = false

// Core bundling options
public readonly image: cdk.DockerImage;
Expand All @@ -76,7 +92,8 @@ export class Bundling implements cdk.BundlingOptions {
constructor(private readonly props: BundlingProps) {
this.packageManager = PackageManager.fromLockFile(props.depsLockFilePath);

Bundling.esbuildInstallation = Bundling.esbuildInstallation ?? EsbuildInstallation.detect();
Bundling.esbuildInstallation = Bundling.esbuildInstallation ?? PackageInstallation.detect('esbuild');
Bundling.tscInstallation = Bundling.tscInstallation ?? PackageInstallation.detect('tsc');

this.projectRoot = props.projectRoot;
this.relativeEntryPath = path.relative(this.projectRoot, path.resolve(props.entry));
Expand All @@ -90,15 +107,19 @@ export class Bundling implements cdk.BundlingOptions {
this.relativeTsconfigPath = path.relative(this.projectRoot, path.resolve(props.tsconfig));
}

if (props.preCompilation && !/\.tsx?$/.test(props.entry)) {
throw new Error('preCompilation can only be used with typescript files');
}

this.externals = [
...props.externalModules ?? ['aws-sdk'], // Mark aws-sdk as external by default (available in the runtime)
...props.nodeModules ?? [], // Mark the modules that we are going to install as externals also
];

// Docker bundling
const shouldBuildImage = props.forceDockerBundling || !Bundling.esbuildInstallation;
this.image = shouldBuildImage
? props.dockerImage ?? cdk.DockerImage.fromBuild(path.join(__dirname, '../lib'), {
this.image = shouldBuildImage ? props.dockerImage ?? cdk.DockerImage.fromBuild(path.join(__dirname, '../lib'),
{
buildArgs: {
...props.buildArgs ?? {},
IMAGE: props.runtime.bundlingImage.image,
Expand All @@ -112,6 +133,7 @@ export class Bundling implements cdk.BundlingOptions {
inputDir: cdk.AssetStaging.BUNDLING_INPUT_DIR,
outputDir: cdk.AssetStaging.BUNDLING_OUTPUT_DIR,
esbuildRunner: 'esbuild', // esbuild is installed globally in the docker image
tscRunner: 'tsc', // tsc is installed globally in the docker image
osPlatform: 'linux', // linux docker image
});
this.command = ['bash', '-c', bundlingCommand];
Expand All @@ -128,21 +150,42 @@ export class Bundling implements cdk.BundlingOptions {

private createBundlingCommand(options: BundlingCommandOptions): string {
const pathJoin = osPathJoin(options.osPlatform);
let tscCommand: string = '';
let relativeEntryPath = this.relativeEntryPath;

if (this.props.preCompilation) {

let tsconfig = this.relativeTsconfigPath;
if (!tsconfig) {
const findConfig = findUp('tsconfig.json', path.dirname(this.props.entry));
if (!findConfig) {
throw new Error('Cannot find a tsconfig.json, please specify the prop: tsconfig');
}
tsconfig = path.relative(this.projectRoot, findConfig);
}

relativeEntryPath = relativeEntryPath.replace(/\.ts(x?)$/, '.js$1');
if (!Bundling.tscCompiled) {
// Intentionally Setting rootDir and outDir, so that the compiled js file always end up next ts file.
tscCommand = `${options.tscRunner} --project ${pathJoin(options.inputDir, tsconfig)} --rootDir ./ --outDir ./`;
Bundling.tscCompiled = true;
}
}

const loaders = Object.entries(this.props.loader ?? {});
const defines = Object.entries(this.props.define ?? {});

if (this.props.sourceMap === false && this.props.sourceMapMode) {
throw new Error('sourceMapMode cannot be used when sourceMap is false');
}
// eslint-disable-next-line no-console

const sourceMapEnabled = this.props.sourceMapMode ?? this.props.sourceMap;
const sourceMapMode = this.props.sourceMapMode ?? SourceMapMode.DEFAULT;
const sourceMapValue = sourceMapMode === SourceMapMode.DEFAULT ? '' : `=${this.props.sourceMapMode}`;

const esbuildCommand: string[] = [
options.esbuildRunner,
'--bundle', `"${pathJoin(options.inputDir, this.relativeEntryPath)}"`,
'--bundle', `"${pathJoin(options.inputDir, relativeEntryPath)}"`,
`--target=${this.props.target ?? toTarget(this.props.runtime)}`,
'--platform=node',
`--outfile="${pathJoin(options.outputDir, 'index.js')}"`,
Expand Down Expand Up @@ -185,6 +228,7 @@ export class Bundling implements cdk.BundlingOptions {

return chain([
...this.props.commandHooks?.beforeBundling(options.inputDir, options.outputDir) ?? [],
tscCommand,
esbuildCommand.join(' '),
...(this.props.nodeModules && this.props.commandHooks?.beforeInstall(options.inputDir, options.outputDir)) ?? [],
depsCommand,
Expand All @@ -194,10 +238,11 @@ export class Bundling implements cdk.BundlingOptions {

private getLocalBundlingProvider(): cdk.ILocalBundling {
const osPlatform = os.platform();
const createLocalCommand = (outputDir: string, esbuild: EsbuildInstallation) => this.createBundlingCommand({
const createLocalCommand = (outputDir: string, esbuild: PackageInstallation, tsc?: PackageInstallation) => this.createBundlingCommand({
inputDir: this.projectRoot,
outputDir,
esbuildRunner: esbuild.isLocal ? this.packageManager.runBinCommand('esbuild') : 'esbuild',
tscRunner: tsc && (tsc.isLocal ? this.packageManager.runBinCommand('tsc') : 'tsc'),
osPlatform,
});
const environment = this.props.environment ?? {};
Expand All @@ -214,7 +259,7 @@ export class Bundling implements cdk.BundlingOptions {
throw new Error(`Expected esbuild version ${ESBUILD_MAJOR_VERSION}.x but got ${Bundling.esbuildInstallation.version}`);
}

const localCommand = createLocalCommand(outputDir, Bundling.esbuildInstallation);
const localCommand = createLocalCommand(outputDir, Bundling.esbuildInstallation, Bundling.tscInstallation);

exec(
osPlatform === 'win32' ? 'cmd' : 'bash',
Expand Down Expand Up @@ -243,6 +288,7 @@ interface BundlingCommandOptions {
readonly inputDir: string;
readonly outputDir: string;
readonly esbuildRunner: string;
readonly tscRunner?: string;
readonly osPlatform: NodeJS.Platform;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { spawnSync } from 'child_process';
import { tryGetModuleVersion } from './util';

/**
* An esbuild installation
* Package installation
*/
export abstract class EsbuildInstallation {
public static detect(): EsbuildInstallation | undefined {
export abstract class PackageInstallation {
public static detect(module: string): PackageInstallation | undefined {
try {
// Check local version first
const version = tryGetModuleVersion('esbuild');
const version = tryGetModuleVersion(module);
if (version) {
return {
isLocal: true,
Expand All @@ -17,11 +17,11 @@ export abstract class EsbuildInstallation {
}

// Fallback to a global version
const esbuild = spawnSync('esbuild', ['--version']);
if (esbuild.status === 0 && !esbuild.error) {
const proc = spawnSync(module, ['--version']);
if (proc.status === 0 && !proc.error) {
return {
isLocal: false,
version: esbuild.stdout.toString().trim(),
version: proc.stdout.toString().trim(),
};
}
return undefined;
Expand Down
10 changes: 10 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,16 @@ export interface BundlingOptions {
*/
readonly forceDockerBundling?: boolean;

/**
* Run compilation using tsc before running file through bundling step.
* This usually is not required unless you are using new experimental features that
* are only supported by typescript's `tsc` compiler.
* One example of such feature is `emitDecoratorMetadata`.
*
* @default false
*/
readonly preCompilation?: boolean

/**
* A custom bundling Docker image.
*
Expand Down
109 changes: 105 additions & 4 deletions packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@ import { Architecture, Code, Runtime } from '@aws-cdk/aws-lambda';
import { AssetHashType, DockerImage } from '@aws-cdk/core';
import { version as delayVersion } from 'delay/package.json';
import { Bundling } from '../lib/bundling';
import { EsbuildInstallation } from '../lib/esbuild-installation';
import { PackageInstallation } from '../lib/package-installation';
import { LogLevel, SourceMapMode } from '../lib/types';
import * as util from '../lib/util';

let detectEsbuildMock: jest.SpyInstance<EsbuildInstallation | undefined>;

let detectPackageInstallationMock: jest.SpyInstance<PackageInstallation | undefined>;
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
jest.restoreAllMocks();
Bundling.clearEsbuildInstallationCache();
Bundling.clearTscInstallationCache();

jest.spyOn(Code, 'fromAsset');

detectEsbuildMock = jest.spyOn(EsbuildInstallation, 'detect').mockReturnValue({
detectPackageInstallationMock = jest.spyOn(PackageInstallation, 'detect').mockReturnValue({
isLocal: true,
version: '0.8.8',
});
Expand Down Expand Up @@ -429,7 +431,7 @@ test('Local bundling', () => {


test('Incorrect esbuild version', () => {
detectEsbuildMock.mockReturnValueOnce({
detectPackageInstallationMock.mockReturnValueOnce({
isLocal: true,
version: '3.4.5',
});
Expand Down Expand Up @@ -554,3 +556,102 @@ test('esbuild bundling with projectRoot and externals and dependencies', () => {
}),
});
});

test('esbuild bundling with pre compilations', () => {
Bundling.bundle({
entry,
projectRoot,
depsLockFilePath,
runtime: Runtime.NODEJS_14_X,
forceDockerBundling: true,
tsconfig,
preCompilation: true,
architecture: Architecture.X86_64,
});

// Correctly bundles with esbuild
expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(depsLockFilePath), {
assetHashType: AssetHashType.OUTPUT,
bundling: expect.objectContaining({
command: [
'bash', '-c',
[
'tsc --project /asset-input/lib/custom-tsconfig.ts --rootDir ./ --outDir ./ &&',
'esbuild --bundle \"/asset-input/lib/handler.js\" --target=node14 --platform=node --outfile=\"/asset-output/index.js\"',
'--external:aws-sdk --tsconfig=/asset-input/lib/custom-tsconfig.ts',
].join(' '),
],
}),
});

Bundling.bundle({
entry,
projectRoot,
depsLockFilePath,
runtime: Runtime.NODEJS_14_X,
forceDockerBundling: true,
tsconfig,
preCompilation: true,
architecture: Architecture.X86_64,
});

// Correctly bundles with esbuild
expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(depsLockFilePath), {
assetHashType: AssetHashType.OUTPUT,
bundling: expect.objectContaining({
command: [
'bash', '-c',
[
'esbuild --bundle \"/asset-input/lib/handler.js\" --target=node14 --platform=node --outfile=\"/asset-output/index.js\"',
'--external:aws-sdk --tsconfig=/asset-input/lib/custom-tsconfig.ts',
].join(' '),
],
}),
});

});

test('esbuild bundling with pre compilations with undefined tsconfig ( Should find in root directory )', () => {
Bundling.clearTscCompilationCache();
const packageLock = path.join(__dirname, '..', 'package-lock.json');

Bundling.bundle({
entry: __filename.replace('.js', '.ts'),
projectRoot: path.dirname(packageLock),
depsLockFilePath: packageLock,
runtime: Runtime.NODEJS_14_X,
forceDockerBundling: true,
preCompilation: true,
architecture: Architecture.X86_64,
});

// Correctly bundles with esbuild
expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(packageLock), {
assetHashType: AssetHashType.OUTPUT,
bundling: expect.objectContaining({
command: [
'bash', '-c',
[
'tsc --project /asset-input/tsconfig.json --rootDir ./ --outDir ./ &&',
'esbuild --bundle \"/asset-input/test/bundling.test.js\" --target=node14 --platform=node --outfile=\"/asset-output/index.js\"',
'--external:aws-sdk',
].join(' '),
],
}),
});
});

test('esbuild bundling with pre compilations and undefined tsconfig ( Should throw) ', () => {
expect(() => {
Bundling.bundle({
entry,
projectRoot,
depsLockFilePath,
runtime: Runtime.NODEJS_14_X,
forceDockerBundling: true,
preCompilation: true,
architecture: Architecture.X86_64,
});
}).toThrow('Cannot find a tsconfig.json, please specify the prop: tsconfig');

});
Loading

0 comments on commit 05d0c17

Please sign in to comment.