I've created an Advanced CDK course, you can check it out here!

BlogResumeTimelineGitHubTwitterLinkedInBlueSky

Converting a CDK construct to using Projen

Cover Image for Converting a CDK construct to using Projen

I've built a few CDK constructs over the last year and found the setup could be a bit laborious. I even created a new project to add as a template for JSII-based constructs. However, this template suffers from all the pains of typical templates, you can't update them after the fact.

Enter projen. The tl;dr: it manages your package.json, .gitignore, and many other project files that you normally have to maintain yourself. I won't cover the basics, just go read the README for Projen, it's thick and juicy.

I'm going to cover here what it took to convert an existing JSII-based CDK construct, nightynight.

The Existing Code

First, a quick review of the existing code:

starting-state

The code follows a very typical AWS CDK construct/app format. There is a bin folder which contains a CDK App. The lib folder contains a sample Stack that allows for some integration testing. The construct itself is in the nightynight.ts file and is pretty simple. It creates a scheduled event and a Lambda Function that stops the given instance.

The Lambda Function uses a NodejsFunction which will come into play later.

Getting Started

I made sure I had a clean git repo and had the tool installed. Since I ran Projen a lot, I used the recommended alias: alias pj="npx projen". After this I used pj to bootstrap a new file: pj new awscdk-construct. Right away I get some interesting results:

āžœ  nightynight git:(feat/blogsandbox) pj new awscdk-construct
npx: installed 64 in 2.835s
šŸ¤– Created .projenrc.js for AwsCdkConstructLibrary
šŸ¤– Synthesizing project...
šŸ¤– aws-cdk: removed
šŸ¤– ts-node: removed
šŸ¤– source-map-support: removed
šŸ¤– @aws-cdk/aws-ec2: removed
šŸ¤– @aws-cdk/aws-events: removed
šŸ¤– @aws-cdk/aws-events-targets: removed
šŸ¤– @aws-cdk/aws-iam: removed
šŸ¤– @aws-cdk/aws-lambda: removed
šŸ¤– @aws-cdk/aws-lambda-nodejs: removed
šŸ¤– @aws-cdk/core: removed
šŸ¤– cdk-iam-floyd: removed
šŸ¤– @aws-cdk/aws-ec2: removed
šŸ¤– @aws-cdk/aws-events: removed
šŸ¤– @aws-cdk/aws-events-targets: removed
šŸ¤– @aws-cdk/aws-iam: removed
šŸ¤– @aws-cdk/aws-lambda: removed
šŸ¤– @aws-cdk/aws-lambda-nodejs: removed
šŸ¤– @aws-cdk/core: removed
šŸ¤– cdk-iam-floyd: removed
Command failed: yarn install --check-files
/bin/sh: yarn: command not found

I didn't have yarn installed so that was a simple fix. The removals of all the modules is interesting, and was the first thing I'd address. I kept an eye on my Gitkraken window and a diff of the whole repo. This guided me on what changes I needed to make. I was aiming at having only a few minor modifications to the existing files and additions. Here's how it looked at the start:

diff

This is the .projenrc.js file it generated:

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

const project = new AwsCdkConstructLibrary({
  authorAddress: "matthew.bonig@gmail.com",
  authorName: "Matthew Bonig",
  cdkVersion: "1.60.0",
  name: "nightynight",
  repository: "git@github.com:mbonig/nightynight.git"
});

project.synth();

Right away I can see fields like name, description and others that simply need setting in the initial props. After a lot of pj commands and reviewing diffs I came to this:


let dependencies = {
  "cdk-iam-floyd": "0.54.1"
};

const project = new AwsCdkConstructLibrary({
  name: "@matthewbonig/nightynight",
  description: "A CDK construct that will automatically stop a running EC2 instance at a given time.",
  authorAddress: "matthew.bonig@gmail.com",
  authorName: "Matthew Bonig",
  cdkVersion: "1.60.0",
  repository: "https://github.com/mbonig/nightynight",
  bin: {
    "nightynight": "bin/nightynight.js"
  },
  dependencies: dependencies,
  peerDependencies: dependencies,
  devDependencies: {
    "yarn": "1.22.10",
    "parcel": "v2.0.0-beta.1"
  },
  cdkDependencies: [
    "@aws-cdk/aws-ec2",
    "@aws-cdk/aws-events",
    "@aws-cdk/aws-events-targets",
    "@aws-cdk/aws-iam",
    "@aws-cdk/aws-lambda",
    "@aws-cdk/aws-lambda-nodejs",
    "@aws-cdk/core"
  ],
  keywords: [
    "cdk",
    "ec2"
  ],
  python: {
    module: "mbonig.nightynight",
    distName: "mbonig.nightynight"
  },
  dependabot: false,
  buildWorkflow: false,
  releaseWorkflow: false
});

I wasn't able to get it to line up exactly with my previous package.json, but it was awfully close. Notice a few specific things:

  • The repository was set by Projen to the git's remote, which was the ssh address of the repository. However, since I publish this construct to pypi I had to set that back to the https url.
  • I install yarn and parcel as devDependencies, so that the existing Github Action work without editing. Yarn is used in the scripts commands and parcel is used by the NodejsFunction. By installing parcel the Github Action doesn't require the use of Docker.
  • I've turned off the three Github Actions with the dependabot, buildWorkflow and releaseWorkflow fields. I'd like to turn those back on but the existing Github Action I have is working well, so they're slightly redundant right now.

However, this wasn't enough. There are some additional changes to the project:

project.addFields({
  main: "lib/nightynight.js",
  types: "lib/nightynight.d.ts",
  awscdkio: {
    twitter: "mattbonig"
  },
  public: true
});

There are some fields that I need to set directly. Projen provides that release valve through the addFields method, since this project type doesn't expose everything available in an NPM project (and it shouldn't). Since my package has a scope (@matthewbonig) I require the public field for npmjs to accept it. I've also setup the awscdkio field for the Construct Catalog.

Next up, I add some specific excludes and includes to git and npm's ignore files:

const tempDirectories = ["cdk.context.json", ".cdk.staging/", ".idea/", ".parcel-cache/", "cdk.out/"];
project.gitignore.exclude(...tempDirectories);
project.npmignore.exclude(...tempDirectories);

The options are all just temp directories or files we don't ever want to commit or package up.

Testing

So, feeling pretty comfortable I started testing. I had run pj a lot, but I hadn't actually tried testing my existing code, so a simple yarn test and I was off:

āžœ  nightynight git:(feat/blogsandbox) āœ— yarn test
yarn run v1.22.10
$ rm -fr lib/ && jest --passWithNoTests && yarn eslint
 FAIL  test/nightynight.test.ts
  ā— Test suite failed to run

    test/nightynight.test.ts:3:29 - error TS2307: Cannot find module '../lib/nightynight' or its corresponding type declarations.

    3 import { NightyNight } from '../lib/nightynight';
                                  ~~~~~~~~~~~~~~~~~~~~

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        2.879 s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

What? Where'd my construct go?!

The first step of the test script was to remove the lib folder, which is where my source code exists. It expects all source code in src, so I restored the files using Gitkraken and then moved them over to the src directory (updating the reference in the test accordingly). I also cleaned up the index.ts file that Projen creates.

At this point I started dealing with a lot of weird errors around testing my construct. Turns out I needed to delete the jest.config.js file that was still around.

After some trial and error with publishing I discovered I needed to modify the compile script:


project.addScript(
  "compile", "jsii --silence-warnings=reserved-word --no-fix-peer-dependencies && jsii-docgen && cp src/nightynight.handler.ts lib/nightynight.handler.ts"
);

All I did was copy the existing compile script from the package.json file and add the cp of the handler at the end. This file can't be compiled and packaged the same as the rest of the construct and needs to be shipped along with the construct so that the consumer of this package can compile it. This is specifically because I'm using the NodejsFunction and wouldn't be needed in many cases. Without this step I was having a problem where a synth with the published construct resulted in an error about the missing entry file.

I did some tests, committed my code and then watched the Github Action to see it get built and published. There were a few things I found during this trial and error but it generally went pretty smoothly.

Wrap Up

So there it is. About an hour's worth of work and I was all set. I have the WakeyWakey construct to convert next. However, now that there is some similarity here I don't want to just copy/paste this code to that repository. I'll refactor this work into a reusable class that inherits from AwsCdkConstructLibrary! Once again we see static text files being replaced by reusable classes and a synth process. If this feels familiar, it should, considering who created Projen.

Future

Now in the future I expect only a few minor changes regarding Projen. I will want to keep deps on the CDK and iam-cdk-floyd up to date. I probably can't rely on dependabot to do this so I'll have to work something up myself.