projen

AWS Cloud Projects

We support two types of projects for cloud development powered by the AWS Cloud Development Kit (AWS CDK): apps and libraries. Apps represent complete cloud applications while libraries vend constructs which can be consumed by other libraries or by apps. Libraries are published to public or internal package managers (npm, PyPI, Maven, NuGet, etc) while apps are deployed into AWS environments.

This section describes features that are available in both cloud libraries and applications. See AWS CDK Construct Library and AWS CDK Applications for specific details about libraries and applications.

AWS Lambda Functions

AWS Lambda is a serverless compute platform which executes short running code within a managed runtime environment.

To define AWS Lambda functions, create a file with a .lambda.ts suffix under the source tree with AWS Lambda handler code.

For example, say we create src/resize-image.lambda.ts with the following content:

export async function handler(event: any) {
  console.log('I am resizing the image now!');
}

Now run:

$ npx projen

You’ll notice that a new file src/resize-image-function.ts has been added to your project. This is a generated source file which exports a construct named ResizeImageFunction. This construct is a subclass of @aws-cdk/aws-lambda.Function, bound to your specific handler. This means that you don’t need to specify neither the code nor the runtime options when you add it to your app:

import { ResizeImageFunction } from './resize-image-function.ts';

const handler = new ResizeImageFunction(this, 'ResizeImageFunction', {
  env: {
    FOO: '1234',
  },

  // all lambda options are supported...
});

Under the hood, we also added a compilation task to your project which creates a .zip bundle for each handler. This bundle is created with esbuild and includes only your handler code and all of its dependencies. This means that you can freely install and use any dependencies in your project and use them in your handlers. You can manually bundle your handler by executing the bundle:HANDLER or bundle:watch:HANDLER tasks.

To customize this behavior for all functions, use lambdaOptions at the project level. For example:

const { awscdk } = require('projen');

new AwsCdkConstructLibrary({
  // ...
  lambdaOptions: {
    // target node.js runtime
    runtime: awscdk.LambdaRuntime.NODEJS_14_X,

    bundlingOptions: {
      // list of node modules to exclude from the bundle
      externals: [ 'aws-sdk' ],
      sourcemap: true,
    }
  }
});

You can also disable auto-discovery by setting lambdaAutoDiscover to false and then create explicitly add a awscdk.LambdaFunction component for each function in your project. This will allow you to perform more customizations as needed.

const { awscdk } = require('projen');

const p = new AwsCdkTypeScriptApp({
  lambdaAutoDiscover: false
});

new awscdk.LambdaFunction(p, {
  entrypoint: 'src/foo.lambda.ts', // .lambda.ts extension is still required
  runtime: aws_lambda.Runtime.NODEJS_12_X,
});

AWS Lambda Extensions

An AWS Lambda Extension is a way to integrate your preferred development, monitoring, observability, and governance tools with AWS Lambda.

Functionally, AWS Lambda Extensions are long-running executable files that reside in the extensions subdirectory of your code asset. AWS Lambda executes all extensions before starting your handler’s main process. These AWS Lambda Extensions interact with your function’s main process and the Lambda Extension API to integrate with tools outside the Lambda environment. Projen helps with bundling and preparing your code as reusable Lambda Layers.

To create an AWS Lambda Extension with Projen:

Offical AWS extension examples are available in the AWS Samples repository.

Example of an extension:

A skeleton for a Lambda extension follows below. Comments with TODO describe locations where you can provide your custom functionality.

#!/usr/bin/env node
// ^ Don't forget this shebang - Lambda executes the bundled version of this
// file directly and doesn't otherwise know it's a node script.

import { basename } from 'path';

// This example uses the `got` HTTP client and assumes that you have included
// `got` in your `devDependencies`. But, you can use any HTTP client you like.
import got from 'got';

/**
 * Your Lambda Extension's main loop
 */
async function main() {
  const extensionInfo = await registerExtension([
    ExtensionEventType.SHUTDOWN,
    ExtensionEventType.INVOKE,
  ]);

  // TODO: Put your initialization code here. You can do things like
  // testing a connection to your external tooling here.

  while (true) {
    const event = await getNextEvent(extensionInfo.extensionId);

    switch (event.eventType) {
      case ExtensionEventType.SHUTDOWN:
        // TODO: Do something when the lambda extension is being
        // shut down. You might do things here like de-registering
        // your extension from your external tooling.
        return 0;

      case ExtensionEventType.INVOKE:
        // TODO: Do something every time your function is invoked,
        // such as re-establishing a connection with your external
        // tooling after the Lambda has thawed from a period of
        // freezing due to inactivity.
        break;

      default:
        console.log(`Unhandled event type ${event.eventType}`);
    }
  }
}

const EXTENSION_API_BASE_URL = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2020-01-01/extension`;

enum ExtensionEventType {
  INVOKE = 'INVOKE',
  SHUTDOWN = 'SHUTDOWN',
}

interface ExtensionEvent {
  readonly eventType: ExtensionEventType;
  // For complete event structures, see:
  // https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html
}

async function registerExtension(events: ExtensionEventType[]) {
  // Do not set a timeout on the GET call, as the extension can be suspended
  // for a period of time until there is an event to return.
  const res = await got.post(`${EXTENSION_API_BASE_URL}/register`, {
    json: { events },
    headers: {
      'Lambda-Extension-Name': basename(__filename),
    },
  });

  const header = res.headers['lambda-extension-identifier'];
  const extensionId = Array.isArray(header) ? header[0] : header;
  const json = JSON.parse(res.body);

  return {
    extensionId,
    functionName: json.functionName as string,
    functionVersion: json.functionVersion as string,
  };
}

function getNextEvent(extensionId: string): Promise<ExtensionEvent> {
  return got(`${EXTENSION_API_BASE_URL}/event/next`, {
    headers: {
      'Lambda-Extension-Identifier': extensionId,
    },
  }).json();
}

main()
  .then(statusCode => {
    process.exit(statusCode);
  })
  .catch(e => {
    console.error(e);
    process.exit(1);
  });

Integration Snapshot Tests

Files in the test/ tree with the .integ.ts suffix are recognized as integration snapshot tests.

Each test is a simple CDK app (e.g. calls app.synth()) which exercises certain construct(s) within the project. A test is considered passing if the app can be successfully deployed.

To create/update the snapshot, developers are expected to execute the task integ:NAME:deploy with AWS credentials for their personal development environment. This task will deploy the test app to their account. Upon successful deployment (i.e. the test passed), the snapshot will be captured and stored under a directory called xxx.integ.snapshot next to the test entrypoint. This directory should be committed to the repository.

During builds (either local or within a workflow), the task integ:NAME:assert will be executed. This task synthesizes the test app and compares the output to the captured snapshot. The build will fail if the output differs.

For each integration test, the following set of tasks are created:

Task Description
integ:NAME:deploy Deploys & destroys the test app and updates the snapshot.
integ:NAME:assert Synthesizes the test app and compares it with the snapshot (this is the task that runs during build)
integ:NAME:snapshot Synthesizes the test app and updates the snapshot (not recommended to use because it bypasses deployment).
integ:NAME:destroy Destroys a previously deployed test app.

Writing test assertions

You can write your test assertions as AWS Lambda handlers and use the AWS CDK triggers module to execute them as part of the deployment.

Here is an example of a test:

import { App,Stack } from '@aws-cdk/core';
import { Trigger } from '@aws-cdk/triggers';
import { ConstructUnderTest } from '../src';
import { AssertSomeStuffFunction } from './assert-some-stuff-function.ts'; // <-- generated

const app = new App();
const stack = new Stack(app, 'Test');

// this is the construct we want to test
const testee = new ConstructUnderTest(stack, 'ConstructUnderTest');

// execute a lambda handler with some assertions after all testee
// resources are created
new Trigger(stack, 'RunAssertions', {
  executeAfter: [testee],
  handler: new AssertSomeStuffFunction(stack, 'AssertSomeStuffFunction', {
    env: {
      URL: testee.url // <-- some reference to the created construct
    }
  }),
});

Watch

Only relevant for app projects

The watch command will use cdk watch in order to trigger deployments (with opportunistic hot-swapping) when source files or asset bundles are updated. cdk.json will automatically be configured to watch both source code changes and bundles, and rebuild bundles as needed.

To start watching, set up your environment with AWS credentials and AWS_REGION pointing to your development AWS account and execute:

npx projen watch

This will:

If you change a source file in your project, this change will be picked up by cdk watch, assets will be re-bundled and a hotswap deployment will be performed. For example, if you only change some AWS Lambda code, the CDK CLI will simply update the AWS Lambda service with the location of your new code bundle instead of going through an AWS CloudFormation deployment.

Roadmap