npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

serverless-spa-construct

v0.0.4

Published

A high-level AWS CDK construct for building serverless SPAs with DynamoDB, Cognito, API Gateway, S3, CloudFront, WAF, and more.

Readme


Why This Construct?

Building a production-ready SPA on AWS requires orchestrating 10+ services across multiple regions. CloudFront demands that WAF WebACLs, ACM certificates, and Lambda@Edge functions all reside in us-east-1, while your application stack lives in another region. Managing cross-region dependencies, secret rotation, and multi-layer authentication by hand is tedious, error-prone, and results in hundreds of lines of CDK code that every team ends up rewriting.

This construct library encapsulates all of that complexity behind a clean factory method API. One call creates a fully wired infrastructure:

| What you get | Details | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Factory method pattern | minimal, withWaf, withCustomDomain, withCustomDomainAndWaf — pick exactly the feature set you need | | Cross-region dependency management | SSM Parameter Store handles WAF ARN, certificate ARN, secret ARN, Lambda@Edge version ARN, and custom header name automatically | | Auto-wiring between constructs | ApiConstruct receives DynamoDB table and Cognito User Pool. FrontendConstruct receives REST API, WAF WebACL ARN, certificate, and Lambda@Edge version. All IAM grants are created for you | | Two-level API design | High-level factory methods for common patterns, plus nine low-level constructs for full control | | advanced option | Override any sub-construct property (GSIs, WAF custom rules, rotation interval, cache TTL, removal policy, tags) without dropping down to the low-level API |

Architecture-Level Benefits

  • Multi-layer origin protection — Lambda@Edge injects a secret custom header (x-origin-verify) into every origin request. A Lambda Authorizer validates this header against the Secrets Manager value. Only requests through CloudFront reach your API.
  • Dual authentication — JWT token (via aws-jwt-verify) and custom header validation in a single Lambda Authorizer invocation.
  • Automatic secret rotation — Secrets Manager secret with a rotation Lambda (UUID, configurable schedule, default 7 days). Cross-region replication to your application region avoids latency.
  • In-memory caching — Both Lambda@Edge and Lambda Authorizer cache secret values (configurable TTL, default 300s) to minimize Secrets Manager API calls.
  • WAF with sensible defaults — Rate limiting (2000 req/5min), AWS Managed Rules Common Rule Set, and SQLi Rule Set enabled by default.
  • SPA routing via CloudFront Functions — Extension-less paths rewrite to /index.html without intercepting API error responses.
  • S3 Origin Access Control (OAC) — No legacy OAI. S3 bucket blocks all public access.
  • Cognito SPA-friendly defaults — Email sign-in, self sign-up, SRP auth flow, no client secret.
  • DynamoDB single-table design — PK/SK string attributes, on-demand billing, optional GSIs and PITR.
  • ACM certificate with DNS validation — Certificate in us-east-1 validated against Route53 hosted zone with SAN support.

Architecture

                                    ┌──────────────────┐
                                    │  Cognito          │
                                    │  User Pool        │
                                    │  (JWT issuer)     │
                                    └────────┬─────────┘
                                             │ JWT verification
┌────────┐    ┌──────────────┐    ┌──────────┴─────────────┐
│  User  │───▶│  CloudFront  │───▶│ API Gateway (REST)     │
└────────┘    │              │    │ - Resource policy       │
              │ /api/* ──────┼───▶│ - Cognito Authorizer   │
              │              │    └──────────┬──────────────┘
              │ /* ──────────┼──┐            │
              └──────────────┘  │            ▼
                                │  ┌─────────┴─────────┐
                                │  │ Lambda             │
                                │  │ (Node.js 20.x)    │
                                │  └─────────┬─────────┘
                                │            │
                                ▼            ▼
                      ┌──────────────┐  ┌───────────┐
                      │ S3 Bucket    │  │ DynamoDB  │
                      └──────────────┘  └───────────┘

When WAF and custom domain are enabled, the security stack (us-east-1) adds:

┌───────────────────┐    ┌───────────────────┐    ┌─────────────────────┐
│ WAF WebACL        │    │ Secrets Manager   │    │ ACM Certificate     │
│ (CLOUDFRONT)      │    │ (auto-rotation)   │    │ (DNS validation)    │
└───────────────────┘    └────────┬──────────┘    └─────────────────────┘
                                  │
                         ┌────────┴──────────┐
                         │ Lambda@Edge       │
                         │ (origin request)  │
                         └───────────────────┘
                                  │
                         ┌────────┴──────────┐
                         │ SSM Parameters    │
                         │ (cross-region)    │
                         └───────────────────┘

Cross-Region Architecture

us-east-1 (Security Stack)              Your Region (Main Stack)
┌───────────────────────────┐            ┌───────────────────────────┐
│ WAF WebACL                │            │ DynamoDB                  │
│ ACM Certificate           │   SSM      │ Cognito User Pool         │
│ Secrets Manager (primary) │ ─────────▶ │ API Gateway + Lambda      │
│ Lambda@Edge               │  params    │ S3 + CloudFront           │
│ SSM Parameters            │            │ Secrets Manager (replica) │
└───────────────────────────┘            └───────────────────────────┘

Installation

npm install serverless-spa-construct

Or with yarn:

yarn add serverless-spa-construct

Peer dependencies (must be installed separately):

npm install aws-cdk-lib constructs

Quick Start

The simplest setup uses ServerlessSpaConstruct.minimal() with a CloudFront default domain:

import { Stack, StackProps } from 'aws-cdk-lib';
import { AttributeType } from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
import { ServerlessSpaConstruct } from 'serverless-spa-construct';

export class MyAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const app = ServerlessSpaConstruct.minimal(this, 'App', {
      lambdaEntry: './lambda/handler.ts',
      partitionKey: { name: 'PK', type: AttributeType.STRING },
    });

    // Access outputs
    // app.distributionDomainName - CloudFront URL
    // app.apiUrl                - API Gateway endpoint
    // app.userPoolId            - Cognito User Pool ID
    // app.userPoolClientId      - Cognito User Pool Client ID
    // app.tableName             - DynamoDB table name
  }
}

For a complete working example, see the serverless-spa-construct-test repository.

Usage Patterns

Pattern 1: Minimal (Development / Testing)

No custom domain, no WAF. Uses CloudFront default domain.

ServerlessSpaConstruct.minimal(this, 'App', {
  lambdaEntry: './lambda/handler.ts',
  partitionKey: { name: 'PK', type: AttributeType.STRING },
  sortKey: { name: 'SK', type: AttributeType.STRING },
});

Pattern 2: Custom Domain

Requires a ServerlessSpaSecurityConstruct with certificate deployed in us-east-1 first.

ServerlessSpaConstruct.withCustomDomain(this, 'App', {
  lambdaEntry: './lambda/handler.ts',
  partitionKey: { name: 'PK', type: AttributeType.STRING },
  domainName: 'www.example.com',
  hostedZoneId: 'Z1234567890ABC',
  zoneName: 'example.com',
  ssmPrefix: '/myapp/security/',
  alternativeDomainNames: ['example.com'],
});

Pattern 3: WAF Protection

Requires a ServerlessSpaSecurityConstruct with WAF deployed in us-east-1 first.

ServerlessSpaConstruct.withWaf(this, 'App', {
  lambdaEntry: './lambda/handler.ts',
  partitionKey: { name: 'PK', type: AttributeType.STRING },
  ssmPrefix: '/myapp/security/',
});

Pattern 4: Custom Domain + WAF (Full Production)

Requires a ServerlessSpaSecurityConstruct with WAF and certificate deployed in us-east-1 first.

ServerlessSpaConstruct.withCustomDomainAndWaf(this, 'App', {
  lambdaEntry: './lambda/handler.ts',
  partitionKey: { name: 'PK', type: AttributeType.STRING },
  sortKey: { name: 'SK', type: AttributeType.STRING },
  domainName: 'www.example.com',
  hostedZoneId: 'Z1234567890ABC',
  zoneName: 'example.com',
  ssmPrefix: '/myapp/security/',
  alternativeDomainNames: ['example.com'],
  securityRegion: 'us-east-1',
});

Full Example: Two-Stack Production Deployment

A complete production setup with a security stack in us-east-1 and a main application stack in your preferred region. A working reference implementation is available at serverless-spa-construct-test.

CDK App Entry Point

// bin/app.ts
import * as cdk from 'aws-cdk-lib/core';
import { SecurityStack } from '../lib/security-stack';
import { MainStack } from '../lib/main-stack';

const app = new cdk.App();

const securityStack = new SecurityStack(app, 'SecurityStack', {
  env: { region: 'us-east-1' },
  crossRegionReferences: true,
});

const mainStack = new MainStack(app, 'MainStack', {
  env: { region: 'ap-northeast-1' },
  crossRegionReferences: true,
});

mainStack.addDependency(securityStack);

Security Stack (us-east-1)

// lib/security-stack.ts
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import { ServerlessSpaSecurityConstruct } from 'serverless-spa-construct';

export class SecurityStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    ServerlessSpaSecurityConstruct.withWafAndCertificate(this, 'Security', {
      ssmPrefix: '/myapp/security/',
      rateLimit: 2000,
      domainName: 'www.example.com',
      hostedZoneId: 'Z1234567890ABC',
      zoneName: 'example.com',
      alternativeDomainNames: ['example.com'],
    });
  }
}

Main Application Stack

// lib/main-stack.ts
import { AttributeType } from 'aws-cdk-lib/aws-dynamodb';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import * as path from 'path';
import { ServerlessSpaConstruct } from 'serverless-spa-construct';

export class MainStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    ServerlessSpaConstruct.withCustomDomainAndWaf(this, 'App', {
      lambdaEntry: path.join(__dirname, '../lambda/handler.ts'),
      partitionKey: { name: 'PK', type: AttributeType.STRING },
      sortKey: { name: 'SK', type: AttributeType.STRING },
      domainName: 'www.example.com',
      hostedZoneId: 'Z1234567890ABC',
      zoneName: 'example.com',
      ssmPrefix: '/myapp/security/',
      alternativeDomainNames: ['example.com'],
      securityRegion: 'us-east-1',
      advanced: {
        tags: {
          Project: 'MyApp',
          Environment: 'Production',
        },
      },
    });
  }
}

Deployment

# Deploy both stacks (CDK resolves dependencies automatically)
npx cdk deploy --all

# Or deploy individually
npx cdk deploy SecurityStack
npx cdk deploy MainStack

Security Construct Factory Methods

ServerlessSpaSecurityConstruct must be deployed in us-east-1. It provides security resources shared with the main stack via SSM Parameter Store.

| Factory Method | WAF | Custom Header + Lambda@Edge | ACM Certificate | | ------------------------- | :-: | :-------------------------: | :-------------: | | minimal() | — | ✅ | — | | withWaf() | ✅ | ✅ | — | | withCertificate() | — | ✅ | ✅ | | withWafAndCertificate() | ✅ | ✅ | ✅ |

// Minimal: custom header only
ServerlessSpaSecurityConstruct.minimal(this, 'Security', {
  ssmPrefix: '/myapp/security/',
});

// WAF protection
ServerlessSpaSecurityConstruct.withWaf(this, 'Security', {
  ssmPrefix: '/myapp/security/',
  rateLimit: 3000,
});

// Certificate only
ServerlessSpaSecurityConstruct.withCertificate(this, 'Security', {
  ssmPrefix: '/myapp/security/',
  domainName: 'www.example.com',
  hostedZoneId: 'Z1234567890ABC',
  zoneName: 'example.com',
});

// Full security suite
ServerlessSpaSecurityConstruct.withWafAndCertificate(this, 'Security', {
  ssmPrefix: '/myapp/security/',
  rateLimit: 2000,
  domainName: 'www.example.com',
  hostedZoneId: 'Z1234567890ABC',
  zoneName: 'example.com',
  alternativeDomainNames: ['example.com'],
});

Advanced Options

Both high-level constructs accept an advanced option for fine-grained control over individual sub-constructs.

ServerlessSpaConstruct.minimal(this, 'App', {
  lambdaEntry: './lambda/handler.ts',
  partitionKey: { name: 'PK', type: AttributeType.STRING },
  advanced: {
    database: {
      billingMode: BillingMode.PAY_PER_REQUEST,
      pointInTimeRecoveryEnabled: true,
      globalSecondaryIndexes: [
        {
          indexName: 'GSI1',
          partitionKey: { name: 'GSI1PK', type: AttributeType.STRING },
          sortKey: { name: 'GSI1SK', type: AttributeType.STRING },
        },
      ],
    },
    api: {
      customHeaderName: 'x-custom-verify',
      authorizerCacheTtlSeconds: 600,
    },
    frontend: {
      edgeFunctionVersion: myEdgeFunctionVersion,
    },
    removalPolicy: RemovalPolicy.RETAIN,
    tags: {
      Team: 'Backend',
    },
  },
});
ServerlessSpaSecurityConstruct.withWaf(this, 'Security', {
  ssmPrefix: '/myapp/security/',
  rateLimit: 5000,
  advanced: {
    waf: {
      enableCommonRuleSet: true,
      enableSqliRuleSet: true,
      customRules: [
        {
          name: 'BlockBadBots',
          priority: 10,
          statement: {
            byteMatchStatement: {
              searchString: 'BadBot',
              fieldToMatch: { singleHeader: { name: 'user-agent' } },
              textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
              positionalConstraint: 'CONTAINS',
            },
          },
          action: { block: {} },
        },
      ],
    },
    secret: {
      customHeaderName: 'x-custom-verify',
      rotationDays: 14,
    },
    replicaRegions: ['ap-northeast-1', 'eu-west-1'],
    edgeCacheTtlSeconds: 600,
    removalPolicy: RemovalPolicy.DESTROY,
  },
});

Low-Level Constructs

For cases where the high-level constructs do not fit your needs, you can use the low-level constructs individually.

| Construct | Description | | ---------------------- | --------------------------------------------------------------------------- | | DatabaseConstruct | DynamoDB table with single-table design defaults (PK/SK, on-demand billing) | | AuthConstruct | Cognito User Pool with SPA-friendly defaults (email sign-in, SRP auth) | | ApiConstruct | API Gateway (REST) + Lambda with Cognito/Lambda Authorizer support | | FrontendConstruct | S3 + CloudFront with SPA routing, OAC, and optional custom domain | | WafConstruct | WAF WebACL (CLOUDFRONT scope) with rate limiting and managed rules | | CertificateConstruct | ACM certificate with Route53 DNS validation | | SecretConstruct | Secrets Manager secret with auto-rotation for custom header values | | LambdaEdgeConstruct | Lambda@Edge function for injecting custom headers into origin requests | | SsmConstruct | SSM Parameter Store for cross-region configuration sharing |

import { DatabaseConstruct, AuthConstruct, ApiConstruct, FrontendConstruct } from 'serverless-spa-construct';

const database = new DatabaseConstruct(this, 'Database', {
  partitionKey: { name: 'PK', type: AttributeType.STRING },
});

const auth = new AuthConstruct(this, 'Auth');

const api = new ApiConstruct(this, 'Api', {
  entry: './lambda/handler.ts',
  table: database.table,
  userPool: auth.userPool,
});

const frontend = new FrontendConstruct(this, 'Frontend', {
  api: api.api,
  customHeaderName: api.customHeaderName,
});

How SSM-Based Dependency Management Works

The two stacks communicate through SSM Parameter Store, not through CloudFormation exports or hard-coded ARNs.

  1. ServerlessSpaSecurityConstruct creates resources in us-east-1 and writes their identifiers to SSM parameters under a shared prefix (e.g., /myapp/security/):

    • {prefix}waf-acl-arn — WAF WebACL ARN
    • {prefix}custom-header-name — Custom header name (e.g., x-origin-verify)
    • {prefix}secret-arn — Secrets Manager secret ARN
    • {prefix}edge-function-version-arn — Lambda@Edge function version ARN
    • {prefix}certificate-arn — ACM certificate ARN
  2. ServerlessSpaConstruct in the main stack creates individual AwsCustomResource instances that call ssm:GetParameter against us-east-1 at deploy time. Each reader has a least-privilege IAM policy scoped to arn:aws:ssm:{region}:{account}:parameter{prefix}*.

  3. The retrieved values configure CloudFront (WAF association, certificate, Lambda@Edge), API Gateway (Lambda Authorizer with secret ARN), and other resources.

This approach avoids circular dependencies between stacks, allows independent updates, supports multiple environments via SSM prefix namespacing, and automatically picks up rotated secret values on redeployment.

Security Model

This library implements defense in depth with multiple independent security layers:

User Request
  │
  ▼
[1] WAF WebACL ─── rate limiting + managed rules
  │
  ▼
[2] CloudFront ─── HTTPS only, OAC for S3
  │
  ▼
[3] Lambda@Edge ── injects secret custom header at origin request
  │
  ▼
[4] Lambda Authorizer ── validates custom header + JWT in one call
  │
  ▼
[5] API Gateway ── proxies to Lambda
  │
  ▼
[6] Lambda Handler ── DynamoDB access with least-privilege grants

| Layer | What it does | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | WAF | Blocks malicious traffic before CloudFront. Rate limiting prevents volumetric attacks. AWS Managed Rules block known attack patterns. | | CloudFront | Enforces HTTPS via ViewerProtocolPolicy.HTTPS_ONLY. S3 accessed exclusively through OAC with BlockPublicAccess.BLOCK_ALL. | | Lambda@Edge | Runs on every /api/* origin request. Retrieves secret from Secrets Manager and injects as custom header. In-memory caching (default 300s TTL). Rejects with 403 if secret unavailable. | | Lambda Authorizer | Validates (a) custom header matches secret value and (b) JWT token is valid against Cognito User Pool. Both checks must pass. | | API Gateway | REST API with proxy integration. CORS configured with Cors.ALL_ORIGINS and Cors.ALL_METHODS by default. | | Lambda Handler | Receives only authorized requests. Has read/write access to DynamoDB via grantReadWriteData. |

The SecretConstruct creates a rotation Lambda that follows the standard Secrets Manager four-step protocol:

  1. createSecret — Generates a new UUID and stores it as AWSPENDING.
  2. setSecret — No-op (no external service to update).
  3. testSecret — No-op (no external service to test).
  4. finishSecret — Promotes AWSPENDING to AWSCURRENT and updates the SSM parameter for cross-region consistency.

The rotation interval is configurable (default: 7 days). After rotation, the new secret value propagates to replicas automatically via Secrets Manager replication. The Lambda@Edge and Lambda Authorizer caches expire within the configured TTL (default: 300 seconds), after which they fetch the new value.

Prerequisites

  • Node.js 18.x or later
  • AWS CDK v2.189.1 or later
  • AWS CLI configured with appropriate credentials
  • Route53 hosted zone (for custom domain features)

API Reference

Full API documentation is auto-generated. See the API.md file for detailed type definitions and property descriptions.

Also available on Construct Hub.

Contributing

Contributions are welcome. Please read the contributing guide and code of conduct before submitting a pull request.

License

Apache-2.0