Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lambda-nodejs): typescript emitDecoratorMetadata support #16543

Merged
merged 24 commits into from
Oct 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
44ad230
feat(lambda-nodejs) experimental decorators support
hassanazharkhan Sep 19, 2021
a4e0db8
PR feedback
hassanazharkhan Oct 3, 2021
3fed217
fix tests
hassanazharkhan Oct 3, 2021
4780f61
PR feedback
hassanazharkhan Oct 5, 2021
09f0841
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 7, 2021
46f291f
PR Feedback
hassanazharkhan Oct 8, 2021
b480888
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 8, 2021
4817f62
Update README.md
hassanazharkhan Oct 8, 2021
64af4f1
PR Feedback
hassanazharkhan Oct 11, 2021
c3ed61b
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 11, 2021
34001bf
fix test cases!
hassanazharkhan Oct 12, 2021
0385715
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 12, 2021
243fe09
fix preCompilation!
hassanazharkhan Oct 13, 2021
9ca84b2
fix comment
hassanazharkhan Oct 13, 2021
f6484dc
Cleanup!
hassanazharkhan Oct 13, 2021
ff00a83
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 13, 2021
dc51d9c
Update util.test.ts
hassanazharkhan Oct 13, 2021
a5eaef2
Integ test
hassanazharkhan Oct 15, 2021
94fdb53
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 15, 2021
b1cdca6
PR feedback!
hassanazharkhan Oct 16, 2021
f978628
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 25, 2021
3d2535b
PR feedback!
hassanazharkhan Oct 25, 2021
0a469c0
Merge branch 'master' into feat/pre-compilation
hassanazharkhan Oct 25, 2021
a40dc46
Merge branch 'master' into feat/pre-compilation
mergify[bot] Oct 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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