Publishing CDK construct to Constructs Hub
Two weeks ago I've published Lambda Bun Runtime and Lambda Keep Active CDK constructs which you can download and install from npm. I've also written about those in their respective articles.
Since there is strong community build around Constructs Hub I wanted my constructs to be available there as well. Unfortunatelly for me you need to use projen in order to be picked up by Construct Hub. So I have converted my repositories to projen repositories. This article is about my thorny path.
What is projen and why you want to use itโ
For me projen is simply tool for generating projects. There are several pre-sets which you can use (one of them is AWS CDK Construct Library) but you can also use this tool to generate your own pre-set for your projects.
In general by modifying .projenrc.ts file you are giving the recipe on how the whole project structure should look like. You can define things like individual fields within package.json, what dependencies should be installed with your project and how you want to format and test your project.
Once .projenrc.ts is specified you simply run npx projen and all the configuration files are re-generated by your .projenrc.ts recipe.
Here the biggest mindset shift is that you shoudn't edit any configuration files by hand because all of them are being re-generated every time you run npx projen.
Basically only thing you need to edit is .projenrc.ts and your source files which are usually located under src/ folder.
At least that's the main idea.
Starting new projectโ
When you are starting new project you just need to run following command:
npx projen new [project type]
Even if you are trying to migrate existing project to projen I recommend to start new project like this in separate folder and only once you are happy with your result I recommend to delete everything from your original repository and copy everything from this new folder back to it.
Since I wanted to create AWS CDK Construct Library so it could be published at Construct Hub, the [project type] I used was awscdk-construct.
And also, I love bun so I tried something like this:
bunx projen new awscdk-construct
Output of above command
$ bunx projen new awscdk-construct
๐พ Installing dependencies...
๐พ install | yarn install --check-files
Volta error: Node is not available.
To run any Node command, first set a default version using `volta install node`
Error details written to /Users/[redacted]/.volta/log/volta-error-2025-12-05_14_07_26.194.log
/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/task-runtime.js:158
throw new Error(`Task "${this.fullname}" failed when executing "${command}" (cwd: ${(0, path_1.resolve)(cwd ?? this.workdir)})`);
^
Error: Task "install" failed when executing "yarn install --check-files" (cwd: /Users/[redacted]/test)
at new RunTask (/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/task-runtime.js:158:27)
at TaskRuntime.runTask (/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/task-runtime.js:56:9)
at NodePackage.installDependencies (/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/javascript/node-package.js:850:17)
at NodePackage.postSynthesize (/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/javascript/node-package.js:355:18)
at AwsCdkConstructLibrary.synth (/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/project.js:356:22)
at evalmachine.<anonymous>:18:20
at Script.runInContext (node:vm:149:12)
at Object.runInContext (node:vm:301:6)
at createProject (/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/projects.js:97:8)
at Projects.createProject (/private/tmp/bunx-501-projen@latest/node_modules/projen/lib/projects.js:30:9)
Of course it failed. I am using volta for managing multiple Node.js versions and the projen tried to install dependencies by yarn which I don't even have installed. So I googled a bit and I found out that you can pass arguments to projen so I asked politely for projen to use bun as a package manager like this:
bunx projen new awscdk-construct \
--package-manager bun
That worked! But wait I might want to pass other parameters as well. You can see the full list when you run npx projen --help.
Since I was going to migrate existing project over to projen I ended up with following starting command:
bunx projen new awscdk-construct \
--name @beesolve/lambda-bun-runtime \
--description "AWS Lambda bun runtime layer and construct" \
--author "BeeSolve s.r.o." \
--author-organization true \
--author-address support@beesolve.com \
--repositoryUrl https://github.com/beesolve/lambda-bun-runtime \
--package-manager bun \
--cdk-version 2.231.0 \
--constructs-version 10.4.3 \
--clobber false \
--jest false \
--keywords "bun" \
--keywords "aws" \
--keywords "lambda" \
--keywords "runtime" \
--license "MIT" \
--major-version 1 \
--npm-access "public" \
--prettier true \
--vscode false \
--release-to-npm true
Most of those are pretty obvious, like name, description, author*, repositoryUrl and license.
I've already written about package-manager - if you don't like bun or you want to use other package manager you should change this one.
You can set versions of some libraries within these parameters by using cdk-version and constructs-version.
Another cool one is prettier - by enabling this your code will be automatically formatted by prettier. You can even change .prettierignore and .pretierrc.json further inside .projenrc.ts file.
Special shout out to keywords - you can add multiple keywords by using --keywords multiple times in your command.
There are few arguments which I just provided because I don't want some functionality to be included with my project like clobber and jest - I am not planning to write tests for these construct so I don't need additional dependencies. Also I don't use vscode so I don't need support for it.
Very special parameters are npm-access and release-to-npm and major-version. All of these are related to the fact that we want to publish the package to npm and that it has already been published to npm previously. If you start with new project and you don't want to deploy major version yet, you should omit major-version but whenever you will need to publish major version you will need to add it to your .projenrc.ts file.
The above command will produce following .projenrc.ts file and also bunch of other config files and folders:
import { awscdk, javascript } from "projen";
const project = new awscdk.AwsCdkConstructLibrary({
author: "BeeSolve s.r.o.",
authorAddress: "support@beesolve.com",
authorOrganization: true,
cdkVersion: "2.231.0",
clobber: false,
constructsVersion: "10.4.3",
defaultReleaseBranch: "main",
description: "AWS Lambda bun runtime layer and construct",
jest: false,
jsiiVersion: "~5.9.0",
keywords: ["bun", "aws", "lambda", "runtime"],
license: "MIT",
majorVersion: 1,
name: "@beesolve/lambda-bun-runtime",
npmAccess: javascript.NpmAccess.PUBLIC,
packageManager: javascript.NodePackageManager.BUN,
prettier: true,
projenrcTs: true,
releaseToNpm: true,
repositoryUrl: "https://github.com/beesolve/lambda-bun-runtime",
vscode: false,
// deps: [], /* Runtime dependencies of this module. */
// devDeps: [], /* Build dependencies for this module. */
// packageName: undefined, /* The "name" in package.json. */
});
project.synth();
This is a good starting point let's see how to actually migrate the code and what issues I hit along the way in the next section.
Migrating the actual codeโ
For this part we assume that we are going to migrate codebase in this state 1.
When you are migrating the existing library which has already been published to npm you will need to create tag with the version from your latest package.json and push it to the git.
This is crucial because you cannot change package.json by hand from now on everything needs to be generated by projen. Creating the tag is in this case the only way how projen can pick up the previous version.
Before we start copying the actual code let's review our .projenrc.ts file. It seems that we should resolve our dependencies first.
I've already done this for Lambda Keep Active project so I know that whenewer I will be using aws-cdk-lib and cosntructs I need to add them as peerDeps and devDeps. I tried to add them as deps but that didn't work and for some reason you also shouldn't add aws-cdk dependency as it seems that that's explicit when you configure cdkVersion.
Here is why you need to use peerDeps
Error I was facing:
[2025-12-03T14:55:03.678] [ERROR] jsii/compiler - Type model errors prevented the JSII assembly from being created
warning JSII6: A "peerDependency" on "aws-cdk-lib" at "^2.231.0" means you should take a "devDependency" on "aws-cdk-lib" at "2.231.0" (found "undefined")
warning JSII6: A "peerDependency" on "constructs" at "^10.4.3" means you should take a "devDependency" on "constructs" at "10.4.3" (found "undefined")
Help from LLM:
Key changes:
- Moved aws-cdk-lib and constructs to peerDeps
- Added exact versions of aws-cdk-lib and constructs to devDeps
- Removed aws-cdk from deps and bundledDeps
The main issue was that you had peer dependencies that weren't properly matched in your devDeps. The warnings suggest that you need to have devDependencies that exactly match the version of your peer dependencies.
A few additional notes:
- The exact version matching for devDeps helps prevent potential version conflicts
- Peer dependencies indicate that these packages should be provided by the consumer of your library
- The devDeps ensure that during development and testing, you have the correct versions available
Try this configuration, and it should resolve the JSII compiler warnings you were experiencing. Let me know if you have any questions or if you encounter any further issues!
When I run my projen build for the first time I found out that the yaml package was doing some weird shit as well and after quick investigation it seems that there are two versions of this package so to prevent aforementioned weird shit I am adding yaml to devDeps as well.
Why yaml needs to be in devDeps
yaml@1 required by aws-cdk-lib and yaml@2 required by projen itself:
$ bun why yaml
yaml@1.10.2
โโ aws-cdk-lib@2.231.0 (requires 1.10.2)
โโ dev @beesolve/lambda-bun-runtime (requires 2.231.0)
โโ peer @beesolve/lambda-bun-runtime (requires ^2.231.0)
yaml@2.8.2
โโ commit-and-tag-version@12.6.1 (requires ^2.6.0)
โ โโ dev @beesolve/lambda-bun-runtime (requires ^12)
โโ projen@0.98.28 (requires ^2.2.2)
โโ dev @beesolve/lambda-bun-runtime (requires ^0.98.28)
After adding my dependencies .projenrc.ts looks like this:
import { awscdk, javascript } from "projen";
const project = new awscdk.AwsCdkConstructLibrary({
author: "BeeSolve s.r.o.",
authorAddress: "support@beesolve.com",
authorOrganization: true,
cdkVersion: "2.231.0",
clobber: false,
constructsVersion: "10.4.3",
defaultReleaseBranch: "main",
description: "AWS Lambda bun runtime layer and construct",
devDeps: ["aws-cdk-lib@2.231.0", "constructs@10.4.3", "yaml@^2.8.1"],
jest: false,
jsiiVersion: "~5.9.0",
keywords: ["bun", "aws", "lambda", "runtime"],
license: "MIT",
majorVersion: 1,
name: "@beesolve/lambda-bun-runtime",
npmAccess: javascript.NpmAccess.PUBLIC,
packageManager: javascript.NodePackageManager.BUN,
peerDeps: ["aws-cdk-lib@^2.231.0", "constructs@^10.4.3"],
prettier: true,
projenrcTs: true,
releaseToNpm: true,
repositoryUrl: "https://github.com/beesolve/lambda-bun-runtime",
vscode: false,
});
project.synth();
Ok, so .projenrc.ts has been modified now we need to run projen to regenerate config files and install new dependencies.
You should run projen after each change in .projenrc.ts. With bun you should run it like this:
bunx run projen
# or if you wish to ignore npx and run projen with bunx
# in my experience this does not add any performance gain though
bunx --bun run projen
Now is time to migrate actual code.
All of your code should be located in src/ folder. Since our original package is super simple and it contains only one file cdk.ts I just moved it to src/index.ts.
Other things like build.ts won't be needed as projen will build our project so it is ready for npm publish. We still need to generate actual Bun Lambda layer zip file though.
This might be a bit tricky. Previously the script for generating the zip file was written in Bun shell but now since I don't want to introduce any special dependencies I simply converted 2 the script to bash script.
I put this shell script alongside with modified runtime.ts to command/ folder. You can see the resulting bash script on GitHub. I've also added task for projen and added command/ to .npmignore - again everything needs to be done within .projenrc.ts:
import { awscdk, javascript, release } from "projen";
const project = new awscdk.AwsCdkConstructLibrary({
// ...
releaseTrigger: release.ReleaseTrigger.manual(),
// ...
});
project.addTask("build-layer", {
description: "Build bun layer.",
exec: "./command/buildLayer.sh",
});
project.addPackageIgnore("command");
project.synth();
As you can see in above code I've also changed releasing to manual releases. As I wanted to do releases manually I tried to do it like this. Nevertheless the AwsCdkConstructLibrary has set up by default to publish everything through GitHub Actions and I didn't have time to go through that yet so maybe in another article.
Do not forget to run bun run projen in order to regenerate everything.
Now it seems we have everything in place. We should be able to run our custom task bun run projen build-layer in order to generate lambda layer zip file and then we should be able to release our project.
Since our buildLayer.sh script is going to output the zip file to lib/ folder I think we first need to create it by running projen release.
But before we can do that we need to commit our changes to git. Here you should use conventional commits so projen pick up new versions.
TL;DR
Use fix: your comment for patch versions and feat: your comment for minor versions.
After everything is in git we can finally run the release command:
bun run projen release
This command goes through everything, generates bundles, formats the code etc. Once it runs successfully (there were some problems which I've described in next two sections) we can run our custom task and then rerun the release once again.
The JSII bundle has been generated for us at ./dist/js. The last thing we need to do is actually publish the bundle to npm. In my case I used following npm command:
npm publish dist/js/lambda-bun-runtime@1.1.0.jsii.tgz --access public
And that's all folks! After few minutes my construct has been picked up by the Construct Hub.
Next two sections describe some problems I have encountered when trying to release the constructs for the first time.
Problems with releasing new versionโ
As I stated at the beginning I was moving the existing project to projen. That meant that I wanted to start versioning projen not from 0.0.0 but from 1.1.0 in this case.
In order to tell projen about this version you need to set up majorVersion in your .projenrc.ts file to 1 and also you need to publish tag with latest version in your git repository.
Don't forget to fetch the tags before you run bun run projen release because projen won't pick up your latest version.
Also I mentioned that I started by generating new projen project in different folder. Once I have everything set up and my bun run build has worked, just before running the bun run projen release I have removed all files apart from .git/ from my original project and move all the files apart from .git/ to it.
This way I could do new commit which contained whole diff. Of course I wanted to create new version eg. 1.1.0 so I used commit message feat: convert to projen.
After this I fetched all the tags by running git fetch --tags and run bun run projen release.
The projen release built JSII package, bumped the package version and pushed everything to git.
The last step for me was to publish JSII package to npm as described in previous section.
Problems with JSIIโ
The whole point of Construct Hub asking developers to use their projen pre-set is so the constructs are JSII compatible. This means that potentially you can write your construct in one language once and then generate the constructs in multiple languages.
This comes with its own set of constraints.
For me that meant that I needed to rewrite my TypeScript types to interfaces. Also I needed to export all interfaces even those which I use internally.
For Lambda Keep Active project I needed to change the method signature so JSII can pick it up.
This whole thing is straightforward and JSII will point everything out for you. There are some cryptic messages - for me those were decoded by LLM and they meant that I needed to replace types with interfaces. You might come to other problems.
Example of JSII errors I have encountered.
Error 1:
error JSII9997: Unknown error: undefined is not an object (evaluating 'intersectionType.intersection.types') -- TypeError: undefined is not an object (evaluating 'intersectionType.intersection.types')
at validateIntersectionType (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/assembler.js:1758:61)
at _optionalValue (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/assembler.js:1617:18)
at _toParameter (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/assembler.js:1574:21)
at _visitClass (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/assembler.js:1044:31)
at _visitNode (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/assembler.js:704:29)
at emit (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/assembler.js:138:26)
at consumeProgram (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/compiler.js:177:40)
at <anonymous> (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/main.js:147:79)
at <anonymous> (/Users/[redacted]/projen-cdk-library/node_modules/jsii/lib/main.js:115:16)
at <anonymous> (/Users/[redacted]/projen-cdk-library/node_modules/yargs/build/index.cjs:1:8993)
๐พ Task "build ยป compile" failed when executing "jsii --silence-warnings=reserved-word" (cwd: /Users/[redacted]/projen-cdk-library)
error: script "projen" exited with code 1
Conclusion:
You shouldn't use TypeScript types, everything should be simple interface.
Error 2:
[2025-12-03T14:57:22.042] [ERROR] jsii/compiler - Type model errors prevented the JSII assembly from being created
src/cdk.ts:54:12 - error JSII3001: Type "__function" cannot be used as the property type because it is private or @internal
54 readonly keepActive = (lambda: IFunction) => {
~~~~~~~~~~
src/cdk.ts:54:25
54 readonly keepActive = (lambda: IFunction) => {
~~~~~~~~~~~~~~~~~~~~~~~~
55 Tags.of(lambda).add("keepActive", "true");
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
56 };
~~~
The referenced type is declared here
๐พ Task "build ยป compile" failed when executing "jsii --silence-warnings=reserved-word" (cwd: /Users/ivan/data/work/github.com/beesolve/projen-cdk-library)
error: script "projen" exited with code 1
Conclusion
In your classes you should use functions like this:
class A exports Construct {
constructor(/* ... */) {}
public yourMethod() {
// your implementation
}
}
instead of this:
class A exports Construct {
constructor(/* ... */) {}
readonly yourMethod = () => {
// your implementation
}
}
Conclusionโ
The whole process of converting project to use projen was painful for me. There are 3 things why:
- it was something new which I had 0 experience with
- I was converting existing project - starting new project should be easier
- I love simple things and I don't like overcomplicated things - I understand why
projenexists and why someone would use it but for me when I look at version of my constructs withoutprojenand withprojenI think the first version is much cleaner.
Nevertheless when I will be publishing CDK constructs to Construct Hub in the future I will start with projen ๐
