Skip to content

@jaypie/constructs

Prerequisites: npm install @jaypie/constructs aws-cdk-lib constructs

@jaypie/constructs provides AWS CDK constructs preconfigured with Jaypie conventions, Datadog integration, and environment variable patterns.

Terminal window
npm install @jaypie/constructs aws-cdk-lib constructs
Construct Purpose
JaypieLambda Lambda function with Datadog
JaypieQueuedLambda SQS-triggered Lambda
JaypieBucketQueuedLambda S3-triggered Lambda via SQS
JaypieExpressLambda Express app on Lambda
JaypieApiGateway REST API with custom domain
JaypieWebDeploymentBucket Static site with CloudFront
JaypieDynamoDb DynamoDB with GSI patterns
JaypieNextJs Next.js deployment via cdk-nextjs-standalone
JaypieEnvSecret Secret reference for injection
Class Purpose
JaypieStack Base stack with tags and naming
JaypieAppStack Application resources
JaypieInfrastructureStack Shared infrastructure

Standard Lambda with Datadog integration.

import { JaypieLambda } from "@jaypie/constructs";
new JaypieLambda(this, "Api", {
code: "../api/dist",
handler: "handler.handler",
secrets: [mongoSecret],
timeout: cdk.Duration.seconds(30),
memorySize: 256,
environment: {
CUSTOM_VAR: "value",
},
});
Option Type Default Description
code string - Path to code directory
handler string - Handler function (file.export)
secrets JaypieEnvSecret[] - Secrets to inject
timeout Duration 30s Function timeout
memorySize number 128 Memory in MB
environment object - Additional env vars
vpc IVpc - VPC for function

Lambda triggered by SQS queue.

import { JaypieQueuedLambda } from "@jaypie/constructs";
new JaypieQueuedLambda(this, "Worker", {
code: "../worker/dist",
handler: "index.handler",
queueName: "tasks",
batchSize: 10,
maxBatchingWindow: cdk.Duration.seconds(5),
});
// Sets CDK_ENV_QUEUE_URL automatically
Option Type Default Description
queueName string - SQS queue name
batchSize number 10 Max messages per batch
maxBatchingWindow Duration 0 Batch window duration

Lambda triggered by S3 events via SQS.

import { JaypieBucketQueuedLambda } from "@jaypie/constructs";
new JaypieBucketQueuedLambda(this, "FileProcessor", {
code: "../processor/dist",
handler: "index.handler",
bucketName: "uploads",
events: ["s3:ObjectCreated:*"],
prefix: "incoming/",
});
// Sets CDK_ENV_BUCKET automatically

Express.js application on Lambda.

import { JaypieExpressLambda } from "@jaypie/constructs";
new JaypieExpressLambda(this, "Api", {
code: "../api/dist",
handler: "handler.handler",
secrets: [mongoSecret, apiKeySecret],
});

REST API Gateway with custom domain.

import { JaypieApiGateway } from "@jaypie/constructs";
new JaypieApiGateway(this, "Gateway", {
handler: lambdaFunction,
host: "api.example.com",
zone: "example.com",
});
Option Type Description
handler IFunction Lambda function
host string Custom domain hostname
zone string Route53 hosted zone

CloudFront distribution over a Lambda Function URL or any origin, with ACM certificate, Route53 records, security headers, and access logging by default. WAFv2 is opt-in via waf: true.

import { JaypieDistribution, JaypieExpressLambda } from "@jaypie/constructs";
const api = new JaypieExpressLambda(this, "Api", {
code: "../api/dist",
handler: "handler.handler",
});
new JaypieDistribution(this, "Distribution", {
handler: api,
host: "api.example.com",
zone: "example.com",
});

host accepts an array. The first entry is primary: it supplies PROJECT_BASE_URL and the certificate’s domainName, and the rest become subject alternative names. Every entry gets an A and AAAA record, which serves a zero-downtime hostname cutover.

new JaypieDistribution(this, "Distribution", {
handler: api,
host: ["api0.example.com", "api.example.com"],
zone: "example.com",
deleteExistingRecord: ["api.example.com"],
});

deleteExistingRecord force-deletes a conflicting Route53 record before the alias is created. It accepts true (every host), a hostname, or an array of hostnames.

Option Type Description
handler IOrigin | IFunctionUrl | IFunction Origin; an IFunction gets a Function URL
host string | HostConfig | array of either Domain name or names; first entry is primary
zone string | IHostedZone Route53 hosted zone
deleteExistingRecord boolean | string | string[] Force-delete conflicting records
streaming boolean Lambda response streaming
waf boolean | JaypieWafConfig WAFv2 WebACL (default disabled)
securityHeaders boolean | overrides Security response headers (default enabled)
logRetention Duration | number Retention for the log buckets this construct creates (default 365 days)
originAccessControl boolean Make CloudFront the only caller of the Function URL (default disabled)

With an IFunction handler, the Function URL is AuthType: NONE by default, so the Lambda is invokable around CloudFront and the WAF. originAccessControl: true creates an AWS_IAM Function URL, uses FunctionUrlOrigin.withOriginAccessControl, and adds the lambda:InvokeFunction permission Lambda requires alongside lambda:InvokeFunctionUrl:

new JaypieDistribution(this, "Distribution", {
handler,
originAccessControl: true,
});

Clients must then send the body SHA-256 in x-amz-content-sha256 on POST and PUT. For a body carrying low-entropy secrets, salt it with a nonce so the hash is not reversible; that header is redacted from WAF logs by default.

The CloudFront access log bucket and WAF log bucket this construct creates block all public access, enforce SSL, are versioned, use SSE-S3, expire objects after logRetention, and carry RemovalPolicy.RETAIN with no auto-delete. They survive stack deletion. waf.logRetention overrides the retention for the WAF bucket alone.

WAF logs always redact authorization, cookie, x-amz-content-sha256, and x-api-key. waf.redactedHeaders merges with that list rather than replacing it, and waf.redactedFields passes query string, URI path, and JSON body matchers through.

Static site on S3 fronted by CloudFront, ACM, and Route53. Ships with default security headers and CloudFront access logging, and opt-in WAFv2 — same override mechanisms as JaypieDistribution (securityHeaders, responseHeadersPolicy, waf, logBucket, destination). When CDK_ENV_REPO is set, also provisions a scoped GitHub OIDC deploy role with cloudfront:CreateInvalidation on the distribution.

import { JaypieWebDeploymentBucket } from "@jaypie/constructs";
new JaypieWebDeploymentBucket(this, "Web", {
host: "app.example.com",
zone: "example.com",
});
// host accepts a HostConfig object resolved via envHostname()
new JaypieWebDeploymentBucket(this, "Web", {
host: { subdomain: "app", domain: "example.com" },
zone: "example.com",
});

The bucket name defaults to constructEnvName(component), and component defaults to "web" — independent of the construct id and of host. Two instances in one account and region collide on <env>-<key>-web-<nonce>, so give the second one a component (or an explicit name):

new JaypieWebDeploymentBucket(this, "Web", { host: webHost, zone });
new JaypieWebDeploymentBucket(this, "App", { component: "app", host: appHost, zone });

Changing component or name on a deployed stack renames the bucket, which replaces it.

The default behavior serves the S3 static website origin, with CACHING_OPTIMIZED in production and CACHING_DISABLED elsewhere. No /* behavior is created, so paths registered afterward are evaluated ahead of the default and a static site can share a distribution with a Lambda surface:

const web = new JaypieWebDeploymentBucket(this, "Web", { host, zone });
web.distribution.addBehavior("/app/*", new origins.FunctionUrlOrigin(api.functionUrl));

Pass defaultBehavior to override the default behavior. It merges over the construct’s values rather than replacing them, so an override names only what it changes and the S3 website origin stays wired up:

new JaypieWebDeploymentBucket(this, "Web", {
host, zone,
defaultBehavior: {
functionAssociations: [
{ eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, function: myFunction },
],
},
});

The bucket is a public S3 website endpoint by default, which CloudFront cannot front with origin access control. originAccessControl: true serves it privately through the S3 REST endpoint instead:

new JaypieWebDeploymentBucket(this, "Web", {
host,
originAccessControl: true,
spa: true,
zone,
});

The bucket gets BLOCK_ALL and enforceSSL with no ACL, no public read, and no website configuration, and the distribution sets defaultRootObject: "index.html". Pair it with spa: true: the REST endpoint has no error document and OAC grants only s3:GetObject, so a miss returns 403 rather than the app shell.

A single-page app has one index.html, so a deep link like /jobs has no S3 key behind it. The website error document renders the shell correctly but the response carries a 404 status — invisible to a human, wrong for crawlers, uptime checks, and any client branching on res.ok.

spa: true attaches a viewer-request CloudFront Function that rewrites any URI whose last segment has no file extension to /index.html, so S3 serves the real index key and returns 200:

new JaypieWebDeploymentBucket(this, "App", {
component: "app",
host: appHost,
spa: true,
zone,
});

The function is scoped to the default behavior, so paths registered with addBehavior keep their genuine 404s. This is why a distribution-wide errorResponses mapping is the wrong tool when a Lambda surface shares the distribution: it would rewrite those 404s into 200s serving the app shell.

Combining spa: true with a caller-supplied viewer-request association throws ConfigurationError at synth, since CloudFront permits one function per event type. Other event types compose, with the rewrite appended last.

The distribution is unconditional. With no host or no zone, the construct skips only the certificate and the Route53 alias record; the distribution, response headers policy, access log bucket, SPA function, WebACL, DistributionId output, and the deploy role’s invalidation grant are all still created. The site serves on the CloudFront default domain:

const web = new JaypieWebDeploymentBucket(this, "Web", { spa: true });
web.distributionDomainName; // d111111abcdef8.cloudfront.net

distribution and distributionDomainName are always defined, so a sandbox deployed before its hosted zone exists is reachable, and a downstream build can resolve the URL from the stack. Adding host and zone later adds the certificate and alias record without replacing the distribution.

Call exportOutputs() to emit stack-level CfnOutputs with hash-free logical IDs (DestinationBucketName, DestinationBucketDeployRoleArn, DistributionId, CertificateArn):

const web = new JaypieWebDeploymentBucket(this, "Web", { host, zone });
web.exportOutputs();
// Multi-instance stacks: use prefix to avoid collisions
appWeb.exportOutputs({ prefix: "App" }); // AppDestinationBucketName, ...
docsWeb.exportOutputs({ prefix: "Docs" }); // DocsDestinationBucketName, ...

Outputs whose underlying resource doesn’t exist (e.g., no deploy role, no certificate) are skipped.

The deploy role trusts two GitHub OIDC sub patterns. GitHub issues newer repositories an id-embedded subject (repo:acme@162184378/widget@1339091097:environment:sandbox) while older repositories still present the plain repo:<org>/<repo>:* form. A repo-level subject template does not remove the ids, so a trust policy matching only the plain form fails with Not authorized to perform sts:AssumeRoleWithWebIdentity. The derived default emits both, wildcarding the ids it does not know.

// Default from CDK_ENV_REPO=acme/widget:
// repo:acme/widget:* and repo:acme@*/widget@*:*
new JaypieWebDeploymentBucket(this, "Web", { host, zone });
// Pin the organization id (prop, CDK_ENV_REPO_ORGANIZATION_ID, or
// PROJECT_REPO_ORGANIZATION_ID)
new JaypieWebDeploymentBucket(this, "Web", {
host, zone,
organizationId: "162184378",
});
// Full override: a string, or an array trusting any one pattern.
// Also creates the deploy role when CDK_ENV_REPO is unset.
new JaypieWebDeploymentBucket(this, "Web", {
host, zone,
repoRestriction: ["repo:acme/widget:*", "repo:acme@162184378/widget@*:*"],
});

JaypieGitHubDeployRole takes the same organizationId and repoRestriction props, scoped to the whole organization rather than one repository. The githubOidcSubjects() helper builds the pair directly.

DynamoDB table with Jaypie single-table design patterns.

import { JaypieDynamoDb } from "@jaypie/constructs";
import { fabricIndex } from "@jaypie/fabric";
// Basic table (no GSIs by default)
new JaypieDynamoDb(this, "myApp");
// With indexes using fabricIndex()
new JaypieDynamoDb(this, "myApp", {
indexes: [
fabricIndex(), // indexModel: pk=["model"], sk=["scope","updatedAt"]
fabricIndex("alias"), // indexModelAlias: pk=["model","alias"], sparse
fabricIndex("xid"), // indexModelXid: pk=["model","xid"], sparse
],
});

Creates table with:

  • Primary key: id (PK) — no sort key
  • No GSIs by default — use indexes prop with fabricIndex() to add them

Next.js deployment using cdk-nextjs-standalone.

import { JaypieNextJs } from "@jaypie/constructs";
// With custom domain
new JaypieNextJs(this, "App", {
domainName: "app.example.com",
hostedZone: "example.com",
nextjsPath: "../nextjs",
});
// CloudFront URL only (no custom domain)
new JaypieNextJs(this, "App", {
domainProps: false,
nextjsPath: "../nextjs",
});
// With response streaming
new JaypieNextJs(this, "App", {
nextjsPath: "../nextjs",
streaming: true,
});
Option Type Description
nextjsPath string Path to Next.js application
domainName string Custom domain name
hostedZone string Route53 hosted zone
domainProps false Set to false for CloudFront-only deployment
streaming boolean Enable Lambda response streaming
secrets JaypieEnvSecret[] Secrets to inject
tables ITable[] DynamoDB tables to grant access

When using streaming: true, you must also create open-next.config.ts in your Next.js app:

nextjs/open-next.config.ts
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
const config = {
default: {
override: {
wrapper: "aws-lambda-streaming",
},
},
} satisfies OpenNextConfig;
export default config;

Without this, the Lambda returns a JSON envelope instead of streamed HTML because Lambda’s RESPONSE_STREAM invoke mode requires the OpenNext streaming wrapper.

Secret reference for Lambda injection. Reads process.env[envKey], an explicit value, or generateSecretString. Supports removalPolicy as boolean (true = RETAIN, false = DESTROY) or CDK RemovalPolicy.

import { JaypieSecret, JaypieLambda } from "@jaypie/constructs";
import { isProductionEnv } from "@jaypie/kit";
const mongoSecret = new JaypieSecret(this, "MongoSecret", {
envKey: "MONGODB_URI",
removalPolicy: isProductionEnv(),
});
new JaypieLambda(this, "Api", {
secrets: [mongoSecret],
});
// Lambda has SECRET_MONGODB_URI env var

A SCREAMING_SNAKE_CASE construct id is shorthand for the envKey, so new JaypieSecret(this, "MONGODB_URI") is equivalent to the call above.

Synth throws ConfigurationError whenever a declared secret source produces no secret string, so a blank credential never defers to runtime. A source is declared by envKey or by passing a value key, and an empty string counts as empty.

new JaypieSecret(this, "ApiKey", { value: process.env.MISSING }); // throws
new JaypieSecret(this, "ApiKey", { envKey: "MISSING" }); // throws
new JaypieSecret(this, "Placeholder"); // generated value

external: true creates an empty secret, so the value never enters the template, the CDK assets bucket, cdk.out, or cdk diff. CI sets the value after deploy with aws secretsmanager put-secret-value, using the ARN output described by the envKey. envKey still names the runtime variable.

new JaypieSecret(this, "ANTHROPIC_API_KEY", { external: true });

JaypieSsoSyncApplication accepts googleCredentialsSecret and scimEndpointAccessTokenSecret to deploy SSOSync without credentials in the template.

Extends JaypieSecret with an environment-driven provider/consumer pattern for sharing secrets across stacks. Accepted anywhere a JaypieSecret is. Deprecated; removed in 2.0. The empty secret guard applies, except in consumer environments, where the secret is imported rather than created.

import { JaypieEnvSecret } from "@jaypie/constructs";
// Sandbox stack provides the secret
new JaypieEnvSecret(this, "MONGODB_URI", { provider: true });
// Personal build consumes it
new JaypieEnvSecret(this, "MONGODB_URI");

Base stack for every Jaypie CDK stack. Extending it automatically:

  1. Names the stack from environment variables: cdk-{PROJECT_SPONSOR}-{PROJECT_KEY}-{PROJECT_ENV}-{PROJECT_NONCE}[-{key}]
  2. Resolves account & region from CDK_DEFAULT_ACCOUNT / CDK_DEFAULT_REGION (overridable via env)
  3. Applies standard tags (env, project, sponsor, nonce, commit, buildHex, buildDate, buildTime, version, service, creation, role, stack) and propagates them to every taggable child resource

When CDK_DEFAULT_ACCOUNT is set and PROJECT_NONCE is unset or not lowercase hex of six or more characters, synth warns @jaypie/constructs:projectNonceFormat. Generate the nonce once per environment with openssl rand -hex 4.

import { JaypieStack, JaypieStackProps } from "@jaypie/constructs";
import type { Construct } from "constructs";
export class DataStack extends JaypieStack {
constructor(scope: Construct, id: string, props: JaypieStackProps = {}) {
super(scope, id, { key: "data", ...props });
// ...your resources...
}
}

JaypieStackProps extends CDK’s StackProps with one optional key field (suffix for the generated stack name). Most projects use JaypieAppStack or JaypieInfrastructureStack; extend JaypieStack directly only for additional categories.

For application resources (Lambda, API Gateway). Pre-fills key: "app":

import { JaypieAppStack, JaypieLambda } from "@jaypie/constructs";
import type { Construct } from "constructs";
export class ApiStack extends JaypieAppStack {
constructor(scope: Construct, id: string) {
super(scope, id);
new JaypieLambda(this, "Api", {
code: "../api/dist",
handler: "handler.handler",
});
}
}

For shared infrastructure. Pre-fills key: "infra" and tags the stack with stackSha from CDK_ENV_INFRASTRUCTURE_STACK_SHA when set:

import { JaypieInfrastructureStack, JaypieEnvSecret } from "@jaypie/constructs";
import type { Construct } from "constructs";
export class InfraStack extends JaypieInfrastructureStack {
public readonly mongoSecret: JaypieEnvSecret;
constructor(scope: Construct, id: string) {
super(scope, id);
this.mongoSecret = new JaypieEnvSecret(this, "MongoSecret", {
secretName: "prod/mongodb-uri",
envName: "MONGODB_URI",
});
}
}

Constructs automatically set:

Variable Source
PROJECT_ENV Stack environment
PROJECT_KEY Stack name
CDK_ENV_QUEUE_URL Queue constructs
CDK_ENV_BUCKET Bucket constructs
CDK_ENV_DATADOG_API_KEY_ARN Datadog integration
SECRET_* Secret constructs

All resources are tagged:

Tag Value
project Project name
environment Environment name
stack Stack ID
bin/cdk.ts
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { ApiStack } from "../lib/api-stack.js";
import { InfraStack } from "../lib/infra-stack.js";
const app = new cdk.App();
new InfraStack(app, "MyProject-Infra");
new ApiStack(app, "MyProject-Api");