DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

CDK para frontend stacks reutilizables de SPA con CloudFront y S3 en TypeScript

Cada vez que empiezo un proyecto frontend que va a vivir en AWS, me encuentro escribiendo las mismas 300 líneas de CDK para el bucket de S3, la distribución de CloudFront, el certificado, las políticas de caché y el DNS. Después de la quinta vez, decidí convertirlo en un custom construct reutilizable. Este artículo es el walkthrough completo de cómo construí ese construct, cómo lo empaqueté como librería privada, y cómo lo uso en 12 proyectos distintos con una línea de código.

El problema concreto

Cada SPA (Angular, React, Vue, Svelte) en AWS necesita lo mismo:

flowchart TB
    DNS[Route 53] --> CF[CloudFront Distribution]
    CF -->|default behavior| S3[S3 Bucket<br/>SPA assets]
    CF -->|redirect www| Redirect[Redirect Function]
    CF -->|404 fallback| SPA[Return /index.html]
    ACM[ACM Certificate<br/>us-east-1] --> CF
    WAF[AWS WAF] --> CF

    style CF fill:#146eb4,color:#fff
    style S3 fill:#569A31,color:#fff
Enter fullscreen mode Exit fullscreen mode

La infraestructura ideal incluye:

  1. Bucket S3 privado con Origin Access Control.
  2. Distribución CloudFront con HTTP/2 y HTTP/3.
  3. Certificado ACM en us-east-1 (requisito de CloudFront).
  4. Headers de seguridad via CloudFront Functions.
  5. Fallback a /index.html para routing client-side.
  6. Invalidación automática en el pipeline.

Hacer esto a mano cada vez es trabajo mecánico propenso a errores.

El construct reutilizable

Este es el código completo del construct que uso:

// lib/constructs/spa-hosting.ts
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';

export interface SPAHostingProps {
  /**
   * Nombre del dominio (ej: app.empresa.com)
   */
  readonly domainName: string;

  /**
   * Zona de Route 53 existente donde crear el registro
   */
  readonly hostedZone: route53.IHostedZone;

  /**
   * Path local donde están los archivos build del SPA
   */
  readonly sourcePath: string;

  /**
   * Ambiente (afecta nombres de recursos)
   */
  readonly environment: 'dev' | 'staging' | 'production';

  /**
   * Habilitar WAF (recomendado solo producción)
   * @default false
   */
  readonly enableWaf?: boolean;

  /**
   * Incluir subdominio www
   * @default true
   */
  readonly includeWww?: boolean;

  /**
   * Headers custom de seguridad
   */
  readonly additionalSecurityHeaders?: { [key: string]: string };

  /**
   * Price class de CloudFront
   * @default PRICE_CLASS_100 para no-production, PRICE_CLASS_ALL para production
   */
  readonly priceClass?: cloudfront.PriceClass;

  /**
   * Geo restricciones opcionales
   */
  readonly geoRestrictions?: {
    type: 'allowlist' | 'denylist';
    countries: string[];
  };
}

export class SPAHosting extends Construct {
  public readonly distribution: cloudfront.Distribution;
  public readonly bucket: s3.Bucket;
  public readonly deployment: s3deploy.BucketDeployment;

  constructor(scope: Construct, id: string, props: SPAHostingProps) {
    super(scope, id);

    const { domainName, hostedZone, environment } = props;
    const includeWww = props.includeWww ?? true;

    // Certificado ACM en us-east-1 (requisito de CloudFront)
    const certificate = new acm.DnsValidatedCertificate(this, 'Certificate', {
      domainName,
      subjectAlternativeNames: includeWww ? [`www.${domainName}`] : undefined,
      hostedZone,
      region: 'us-east-1',
    });

    // Bucket S3 privado
    this.bucket = new s3.Bucket(this, 'SpaBucket', {
      bucketName: `${domainName.replace(/\./g, '-')}-${environment}`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      versioned: environment === 'production',
      removalPolicy: environment === 'production'
        ? cdk.RemovalPolicy.RETAIN
        : cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: environment !== 'production',
      lifecycleRules: [
        {
          id: 'abort-incomplete-uploads',
          abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
        },
      ],
    });

    // CloudFront Function para headers de seguridad
    const securityHeadersFunction = new cloudfront.Function(this, 'SecurityHeaders', {
      code: cloudfront.FunctionCode.fromInline(this.buildSecurityHeadersCode(props)),
      runtime: cloudfront.FunctionRuntime.JS_2_0,
    });

    // Origin Access Control
    const oac = new cloudfront.S3OriginAccessControl(this, 'OAC', {
      signing: cloudfront.Signing.SIGV4_ALWAYS,
    });

    // WAF si está habilitado
    let webAclArn: string | undefined;
    if (props.enableWaf) {
      webAclArn = this.createWebAcl(environment).attrArn;
    }

    // Response Headers Policy para CSP, HSTS, etc.
    const responseHeadersPolicy = this.createResponseHeadersPolicy(props);

    // Distribución CloudFront
    this.distribution = new cloudfront.Distribution(this, 'Distribution', {
      domainNames: includeWww ? [domainName, `www.${domainName}`] : [domainName],
      certificate,
      defaultRootObject: 'index.html',
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
      minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
      priceClass: props.priceClass ?? this.defaultPriceClass(environment),
      webAclId: webAclArn,
      geoRestriction: props.geoRestrictions
        ? this.buildGeoRestriction(props.geoRestrictions)
        : undefined,
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket, {
          originAccessControl: oac,
        }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        compress: true,
        functionAssociations: [
          {
            function: securityHeadersFunction,
            eventType: cloudfront.FunctionEventType.VIEWER_RESPONSE,
          },
        ],
        responseHeadersPolicy,
      },
      errorResponses: [
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
      ],
      logBucket: environment === 'production' ? this.createLogBucket() : undefined,
      enableLogging: environment === 'production',
    });

    // DNS records
    new route53.ARecord(this, 'AliasRecord', {
      zone: hostedZone,
      recordName: domainName,
      target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(this.distribution)),
    });

    if (includeWww) {
      new route53.ARecord(this, 'WwwAliasRecord', {
        zone: hostedZone,
        recordName: `www.${domainName}`,
        target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(this.distribution)),
      });
    }

    // Deployment automático del SPA
    this.deployment = new s3deploy.BucketDeployment(this, 'Deployment', {
      sources: [s3deploy.Source.asset(props.sourcePath)],
      destinationBucket: this.bucket,
      distribution: this.distribution,
      distributionPaths: ['/*'],
      cacheControl: [
        s3deploy.CacheControl.setPublic(),
        s3deploy.CacheControl.maxAge(cdk.Duration.days(365)),
        s3deploy.CacheControl.immutable(),
      ],
      prune: true,
      memoryLimit: 512,
      exclude: ['index.html', 'service-worker.js'],
    });

    // Deploy HTML con cache distinto
    new s3deploy.BucketDeployment(this, 'HtmlDeployment', {
      sources: [s3deploy.Source.asset(props.sourcePath, { exclude: ['**/*', '!index.html', '!service-worker.js'] })],
      destinationBucket: this.bucket,
      distribution: this.distribution,
      distributionPaths: ['/index.html', '/service-worker.js'],
      cacheControl: [
        s3deploy.CacheControl.noCache(),
        s3deploy.CacheControl.mustRevalidate(),
      ],
      prune: false,
    });
  }

  private buildSecurityHeadersCode(props: SPAHostingProps): string {
    const customHeaders = props.additionalSecurityHeaders
      ? Object.entries(props.additionalSecurityHeaders)
          .map(([key, value]) => `    headers['${key.toLowerCase()}'] = { value: '${value}' };`)
          .join('\n')
      : '';

    return `
function handler(event) {
  var response = event.response;
  var headers = response.headers;

  headers['strict-transport-security'] = { value: 'max-age=63072000; includeSubDomains; preload' };
  headers['x-content-type-options'] = { value: 'nosniff' };
  headers['x-frame-options'] = { value: 'DENY' };
  headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin' };
  headers['permissions-policy'] = { value: 'geolocation=(), microphone=(), camera=()' };
${customHeaders}

  return response;
}
`;
  }

  private createResponseHeadersPolicy(props: SPAHostingProps): cloudfront.ResponseHeadersPolicy {
    return new cloudfront.ResponseHeadersPolicy(this, 'ResponseHeaders', {
      corsBehavior: {
        accessControlAllowCredentials: false,
        accessControlAllowHeaders: ['*'],
        accessControlAllowMethods: ['GET', 'HEAD', 'OPTIONS'],
        accessControlAllowOrigins: [`https://${props.domainName}`],
        accessControlMaxAge: cdk.Duration.hours(1),
        originOverride: true,
      },
      securityHeadersBehavior: {
        contentSecurityPolicy: {
          contentSecurityPolicy: [
            `default-src 'self'`,
            `script-src 'self' 'unsafe-inline'`,
            `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
            `font-src 'self' https://fonts.gstatic.com`,
            `img-src 'self' data: https:`,
            `connect-src 'self' https://api.${props.domainName}`,
          ].join('; '),
          override: true,
        },
      },
    });
  }

  private createWebAcl(environment: string): wafv2.CfnWebACL {
    return new wafv2.CfnWebACL(this, 'WebAcl', {
      scope: 'CLOUDFRONT',
      defaultAction: { allow: {} },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: `${environment}-spa-waf`,
        sampledRequestsEnabled: true,
      },
      rules: [
        {
          name: 'AWSManagedRulesCommonRuleSet',
          priority: 1,
          overrideAction: { none: {} },
          statement: {
            managedRuleGroupStatement: {
              vendorName: 'AWS',
              name: 'AWSManagedRulesCommonRuleSet',
            },
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            metricName: 'common-rule-set',
            sampledRequestsEnabled: true,
          },
        },
        {
          name: 'RateLimitRule',
          priority: 2,
          action: { block: {} },
          statement: {
            rateBasedStatement: {
              limit: 2000,
              aggregateKeyType: 'IP',
            },
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            metricName: 'rate-limit',
            sampledRequestsEnabled: true,
          },
        },
      ],
    });
  }

  private createLogBucket(): s3.Bucket {
    return new s3.Bucket(this, 'LogBucket', {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      lifecycleRules: [
        {
          id: 'expire-logs',
          expiration: cdk.Duration.days(90),
        },
      ],
      objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
    });
  }

  private defaultPriceClass(environment: string): cloudfront.PriceClass {
    return environment === 'production'
      ? cloudfront.PriceClass.PRICE_CLASS_ALL
      : cloudfront.PriceClass.PRICE_CLASS_100;
  }

  private buildGeoRestriction(config: NonNullable<SPAHostingProps['geoRestrictions']>): cloudfront.GeoRestriction {
    return config.type === 'allowlist'
      ? cloudfront.GeoRestriction.allowlist(...config.countries)
      : cloudfront.GeoRestriction.denylist(...config.countries);
  }
}
Enter fullscreen mode Exit fullscreen mode

Usándolo en un proyecto

Una vez publicado el construct, usarlo en un proyecto nuevo toma literalmente 10 líneas:

// stacks/frontend-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as route53 from 'aws-cdk-lib/aws-route53';
import { SPAHosting } from '@miempresa/cdk-constructs';
import { Construct } from 'constructs';

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

    const hostedZone = route53.HostedZone.fromLookup(this, 'Zone', {
      domainName: 'miempresa.com',
    });

    new SPAHosting(this, 'App', {
      domainName: 'app.miempresa.com',
      hostedZone,
      sourcePath: '../angular-app/dist/app',
      environment: 'production',
      enableWaf: true,
      additionalSecurityHeaders: {
        'X-App-Version': '1.2.3',
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Eso es todo. El construct se encarga del resto.

Empaquetando como librería privada

Uso CodeArtifact para publicar internamente. El setup:

# En la carpeta del construct library
npm init
npm install aws-cdk-lib constructs
npm install -D typescript @types/node jest
Enter fullscreen mode Exit fullscreen mode

El package.json:

{
  "name": "@miempresa/cdk-constructs",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "aws-cdk-lib": "^2.140.0",
    "constructs": "^10.0.0"
  },
  "devDependencies": {
    "aws-cdk-lib": "^2.140.0",
    "constructs": "^10.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

El tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "declaration": true,
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Enter fullscreen mode Exit fullscreen mode

Tests del construct

Los tests de CDK con assertions validan que el CloudFormation generado sea correcto:

// test/spa-hosting.test.ts
import { App, Stack } from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import * as route53 from 'aws-cdk-lib/aws-route53';
import { SPAHosting } from '../src/constructs/spa-hosting';

describe('SPAHosting', () => {
  let stack: Stack;
  let template: Template;

  beforeEach(() => {
    const app = new App();
    stack = new Stack(app, 'TestStack', {
      env: { account: '123456789012', region: 'us-east-1' },
    });

    const hostedZone = route53.HostedZone.fromHostedZoneAttributes(stack, 'Zone', {
      hostedZoneId: 'Z1234567890',
      zoneName: 'example.com',
    });

    new SPAHosting(stack, 'TestSpa', {
      domainName: 'app.example.com',
      hostedZone,
      sourcePath: './test/fixtures',
      environment: 'production',
      enableWaf: true,
    });

    template = Template.fromStack(stack);
  });

  it('creates encrypted S3 bucket', () => {
    template.hasResourceProperties('AWS::S3::Bucket', {
      BucketEncryption: {
        ServerSideEncryptionConfiguration: [
          {
            ServerSideEncryptionByDefault: {
              SSEAlgorithm: 'AES256',
            },
          },
        ],
      },
    });
  });

  it('blocks public access', () => {
    template.hasResourceProperties('AWS::S3::Bucket', {
      PublicAccessBlockConfiguration: {
        BlockPublicAcls: true,
        BlockPublicPolicy: true,
        IgnorePublicAcls: true,
        RestrictPublicBuckets: true,
      },
    });
  });

  it('creates CloudFront distribution with HTTP/2 and HTTP/3', () => {
    template.hasResourceProperties('AWS::CloudFront::Distribution', {
      DistributionConfig: {
        HttpVersion: 'http2and3',
      },
    });
  });

  it('creates WAF when enableWaf is true', () => {
    template.resourceCountIs('AWS::WAFv2::WebACL', 1);
  });

  it('creates 404 fallback to index.html', () => {
    template.hasResourceProperties('AWS::CloudFront::Distribution', {
      DistributionConfig: {
        CustomErrorResponses: Match.arrayWith([
          Match.objectLike({
            ErrorCode: 404,
            ResponseCode: 200,
            ResponsePagePath: '/index.html',
          }),
        ]),
      },
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Publicación y consumo

Publicar el paquete:

# Login a CodeArtifact
aws codeartifact login --tool npm --domain miempresa --repository internal

# Build y publish
npm run build
npm publish
Enter fullscreen mode Exit fullscreen mode

En el proyecto consumidor:

aws codeartifact login --tool npm --domain miempresa --repository internal
npm install @miempresa/cdk-constructs
Enter fullscreen mode Exit fullscreen mode

Arquitectura del flujo de deploy

flowchart LR
    Dev[Desarrollador] -->|git push| Git[GitHub]
    Git -->|trigger| Actions[GitHub Actions]
    Actions --> Build[npm run build]
    Build --> Sync[cdk deploy]
    Sync --> S3Deploy[BucketDeployment]
    S3Deploy --> S3[S3 Bucket]
    Sync --> Invalidate[CloudFront<br/>Invalidation]
    Invalidate --> CF[CloudFront]
    CF -->|Sirve| Users[Usuarios]

    style Actions fill:#2088FF,color:#fff
    style CF fill:#146eb4,color:#fff
Enter fullscreen mode Exit fullscreen mode

Extendiendo el construct

Cuando un proyecto necesita algo que el construct base no soporta, puedo extenderlo:

export class SPAWithApi extends SPAHosting {
  public readonly apiOrigin: origins.HttpOrigin;

  constructor(scope: Construct, id: string, props: SPAWithApiProps) {
    super(scope, id, props);

    this.apiOrigin = new origins.HttpOrigin(props.apiEndpoint);

    this.distribution.addBehavior('/api/*', this.apiOrigin, {
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
      allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
      cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
      originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Lo que aprendí construyendo esto

1. Los constructs son contratos.

Una vez publicado, cambiar la API del construct rompe a todos los consumidores. Yo versioneo con semver estricto. Breaking changes van a major, agregados a minor, bug fixes a patch.

2. Los default values importan más de lo que crees.

El priceClass default basado en ambiente, el versioning automático en producción, el autoDeleteObjects en dev. Cada default ahorra una decisión al consumidor.

3. Props deben ser narrow, no wide.

En vez de exponer toda la configuración de CloudFront, expongo props de alto nivel como enableWaf: boolean. Si alguien necesita algo muy custom, puede extender el construct.

4. Los tests de assertions son tu red de seguridad.

Cuando refactorizo el construct, los tests me dicen si el CloudFormation generado cambió. Eso previene drift silencioso.

5. La documentación inline con TSDoc es oro.

Cuando alguien usa el construct en su IDE, los comentarios TSDoc aparecen en el autocompletado. Es la mejor documentación porque está donde se lee.

Cierre

Un construct bien hecho te ahorra horas por proyecto nuevo. El secreto está en elegir el nivel de abstracción correcto: ni tan bajo que solo wrappee, ni tan alto que sea rígido. Este construct de SPA Hosting ha pasado por unas 15 iteraciones en 18 meses, y cada iteración surgió de un problema real en producción.

En el próximo artículo vamos a profundizar en Server Components de React corriendo sobre Lambda. Es un tema técnico denso pero cada vez más común.

Top comments (0)