import { CreateDistributionCommand, CreateInvalidationCommand, DeleteDistributionCommand, GetDistributionCommand, GetDistributionConfigCommand, ListDistributionsCommand, ListInvalidationsCommand, UpdateDistributionCommand, type CacheBehavior, type CloudFrontClient, type DefaultCacheBehavior, type Distribution, type DistributionConfig, type DistributionSummary, type Origin, } from '@aws-sdk/client-cloudfront' import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3' import { CDN_ALLOWED_METHODS, CDN_BUCKET_PATTERN, CDN_ORIGIN_DOMAIN_PATTERN, CDN_ORIGIN_PROTOCOLS, CDN_ORIGIN_TYPES, CDN_VIEWER_PROTOCOLS, awsCdnSchema, } from '../cloud-spi/cdnSchema' import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors' import type {CloudResource, CloudServiceAdapter, CreateResourceInput, DistributionInvalidation, ResourceQuery, ServiceSchema} from '../cloud-spi/types' /** AWS managed cache and origin request policies, which CloudFront and the runtime both know by ID. */ const CACHING_OPTIMIZED = '658327ea-f89d-4fab-a63d-7e88639e58f6' const CACHING_DISABLED = '4135ea2d-6df8-44a3-9df3-4b5a84be39ad' const ALL_VIEWER_EXCEPT_HOST_HEADER = 'b689b0a8-53d0-40ab-baf2-68738e2966ac' const READ_METHODS = ['GET', 'HEAD'] const ALL_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE'] /** CloudFront's limit on paths in one invalidation that are not wildcards. */ const MAX_INVALIDATION_PATHS = 3000 const RECENT_INVALIDATIONS = 5 /** * Talks to CloudFront distributions, verified against Floci core (community, `latest`). * * Notes from probing the runtime: * - A distribution is `Deployed` as soon as it is created or updated; AWS takes minutes. * - `DeleteDistribution` refuses an enabled distribution with `DistributionNotDisabled` * (409), as on AWS, so delete disables it first. * - A missing distribution is `NoSuchDistribution` with HTTP 404. * - Viewer requests reach a distribution at `{id}.cloudfront.localhost` on the runtime's * port. Browsers and curl resolve every `.localhost` name to the machine itself (RFC 6761), * so no DNS or hosts-file entry is needed. */ export class AwsCdnAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'cdn' as const constructor( private readonly cloudfront: CloudFrontClient, private readonly s3: S3Client, private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566', ) {} schema(): ServiceSchema { return awsCdnSchema() } async list(query: ResourceQuery = {}): Promise { const summaries: DistributionSummary[] = [] let marker: string | undefined do { const res = await this.cloudfront.send(new ListDistributionsCommand({...(marker ? {Marker: marker} : {})})) summaries.push(...(res.DistributionList?.Items ?? [])) marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined } while (marker) const search = query.search?.trim().toLowerCase() return summaries.map((summary) => this.toResource(summary, summary.Enabled ?? false, summary.Comment, summary.Origins?.Items)) .filter((resource) => !search || [resource.id, resource.name, ...stringList(resource.metadata.originDomains)].some((value) => value.toLowerCase().includes(search))) } async get(id: string): Promise { let distribution: Distribution | undefined try { distribution = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).Distribution } catch (error) { if (errorName(error) === 'NoSuchDistribution') return null throw error } if (!distribution) return null const config = distribution.DistributionConfig const invalidations = await this.recentInvalidations(id) const resource = this.toResource(distribution, config?.Enabled ?? false, config?.Comment, config?.Origins?.Items) resource.metadata = { ...resource.metadata, defaultRootObject: config?.DefaultRootObject || null, aliases: config?.Aliases?.Items ?? [], behaviors: behaviorsOf(config), invalidations, } return resource } async create(input: CreateResourceInput): Promise { const values = input.values const originType = oneOf(values.originType ?? 's3', CDN_ORIGIN_TYPES, 'originType') const viewerProtocol = oneOf(values.viewerProtocol || 'allow-all', CDN_VIEWER_PROTOCOLS, 'viewerProtocol') const methods = oneOf(values.allowedMethods || 'read', CDN_ALLOWED_METHODS, 'allowedMethods') const comment = text(values.comment) if (comment.length > 128) throw new ValidationError('comment must be at most 128 characters') const defaultRootObject = text(values.defaultRootObject).replace(/^\/+/, '') const origin = originType === 's3' ? await this.s3Origin(text(values.bucket)) : customOrigin(values) const allowed = methods === 'all' ? ALL_METHODS : READ_METHODS const behavior: DefaultCacheBehavior = { TargetOriginId: origin.Id, ViewerProtocolPolicy: viewerProtocol, AllowedMethods: {Quantity: allowed.length, Items: allowed as never, CachedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}}, Compress: true, // A website is cached; an API, which sees every method, is not, and gets the viewer's request. CachePolicyId: methods === 'all' ? CACHING_DISABLED : CACHING_OPTIMIZED, ...(methods === 'all' && originType === 'custom' ? {OriginRequestPolicyId: ALL_VIEWER_EXCEPT_HOST_HEADER} : {}), } const res = await this.cloudfront.send(new CreateDistributionCommand({ DistributionConfig: { CallerReference: crypto.randomUUID(), Comment: comment, Enabled: true, DefaultRootObject: defaultRootObject || undefined, Origins: {Quantity: 1, Items: [origin]}, DefaultCacheBehavior: behavior, }, })) if (!res.Distribution) throw new ValidationError('CloudFront did not return the new distribution') const config = res.Distribution.DistributionConfig return this.toResource(res.Distribution, config?.Enabled ?? true, config?.Comment, config?.Origins?.Items) } /** CloudFront deletes only a disabled distribution, so an enabled one is disabled first. */ async delete(id: string): Promise { let etag = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).ETag const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id})) if (current.DistributionConfig?.Enabled) { const disabled = await this.cloudfront.send(new UpdateDistributionCommand({ Id: id, IfMatch: current.ETag, DistributionConfig: {...current.DistributionConfig, Enabled: false}, })) if (disabled.Distribution?.Status !== 'Deployed') { throw new ConflictError('The distribution is now disabled. CloudFront deletes it once the change is deployed: try again when its status is Deployed') } etag = disabled.ETag } try { await this.cloudfront.send(new DeleteDistributionCommand({Id: id, IfMatch: etag})) } catch (error) { if (errorName(error) === 'DistributionNotDisabled') { throw new ConflictError('CloudFront is still disabling the distribution: try again when its status is Deployed') } throw error } } async setDistributionEnabled(id: string, enabled: boolean): Promise { if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false') const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id})) if (!current.DistributionConfig) throw new NotFoundError(`Distribution ${id} was not found`) if (current.DistributionConfig.Enabled !== enabled) { await this.cloudfront.send(new UpdateDistributionCommand({ Id: id, IfMatch: current.ETag, DistributionConfig: {...current.DistributionConfig, Enabled: enabled}, })) } const resource = await this.get(id) if (!resource) throw new NotFoundError(`Distribution ${id} was not found`) return resource } async createDistributionInvalidation(id: string, paths: string[]): Promise { if (!Array.isArray(paths)) throw new ValidationError('paths must be a list') const items = [...new Set(paths.map((path) => String(path).trim()).filter(Boolean))] if (items.length === 0) throw new ValidationError('Add at least one path, such as /* or /index.html') if (items.length > MAX_INVALIDATION_PATHS) throw new ValidationError(`An invalidation holds at most ${MAX_INVALIDATION_PATHS} paths`) const invalid = items.find((path) => !path.startsWith('/')) if (invalid) throw new ValidationError(`${invalid} must start with /`) const res = await this.cloudfront.send(new CreateInvalidationCommand({ DistributionId: id, InvalidationBatch: {CallerReference: crypto.randomUUID(), Paths: {Quantity: items.length, Items: items}}, })) return { id: res.Invalidation?.Id ?? '', status: res.Invalidation?.Status ?? 'InProgress', createdAt: res.Invalidation?.CreateTime?.toISOString() ?? null, paths: res.Invalidation?.InvalidationBatch?.Paths?.Items ?? items, } } private async s3Origin(bucket: string): Promise { if (!new RegExp(CDN_BUCKET_PATTERN).test(bucket)) throw new ValidationError('bucket must be the name of an existing S3 bucket') try { await this.s3.send(new HeadBucketCommand({Bucket: bucket})) } catch (error) { if (['NotFound', 'NoSuchBucket'].includes(errorName(error))) throw new ValidationError(`There is no S3 bucket named ${bucket}`) throw error } return {Id: `s3-${bucket}`, DomainName: `${bucket}.s3.amazonaws.com`, S3OriginConfig: {OriginAccessIdentity: ''}} } private async recentInvalidations(id: string): Promise { const res = await this.cloudfront.send(new ListInvalidationsCommand({DistributionId: id, MaxItems: RECENT_INVALIDATIONS})) return (res.InvalidationList?.Items ?? []).map((invalidation) => ({ id: invalidation.Id ?? '', status: invalidation.Status ?? '', createdAt: invalidation.CreateTime?.toISOString() ?? null, paths: [], })) } private toResource(distribution: Pick, enabled: boolean, comment: string | undefined, origins: Origin[] | undefined): CloudResource { const id = distribution.Id ?? '' return { id, name: distribution.DomainName ?? id, cloud: 'aws', service: 'cdn', type: 'distribution', region: null, createdAt: distribution.LastModifiedTime?.toISOString() ?? null, status: distribution.Status ?? null, metadata: { arn: distribution.ARN ?? null, domainName: distribution.DomainName ?? null, viewerUrl: this.viewerUrl(id), enabled, comment: comment || null, originDomains: (origins ?? []).map((origin) => origin.DomainName ?? ''), origins: (origins ?? []).map((origin) => ({ id: origin.Id ?? '', domainName: origin.DomainName ?? '', kind: origin.S3OriginConfig ? 's3' : 'custom', protocol: origin.CustomOriginConfig?.OriginProtocolPolicy ?? null, })), }, } } /** Where a browser on this machine reaches the distribution through the runtime. */ private viewerUrl(id: string): string { const port = new URL(this.endpoint).port || '4566' return `http://${id.toLowerCase()}.cloudfront.localhost:${port}/` } } function customOrigin(values: Record): Origin { const domain = text(values.originDomain).toLowerCase() if (!new RegExp(CDN_ORIGIN_DOMAIN_PATTERN).test(domain)) throw new ValidationError('originDomain must be a host name such as api.example.com') const protocol = oneOf(values.originProtocol || 'https-only', CDN_ORIGIN_PROTOCOLS, 'originProtocol') const port = text(values.originPort) const portNumber = port ? Number(port) : null if (portNumber !== null && (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535)) throw new ValidationError('originPort must be between 1 and 65535') return { Id: `custom-${domain}`, DomainName: domain, CustomOriginConfig: { HTTPPort: portNumber ?? 80, HTTPSPort: portNumber ?? 443, OriginProtocolPolicy: protocol, OriginSslProtocols: {Quantity: 1, Items: ['TLSv1.2']}, }, } } function behaviorsOf(config: DistributionConfig | undefined) { const describe = (behavior: DefaultCacheBehavior | CacheBehavior, pathPattern: string) => ({ pathPattern, originId: behavior.TargetOriginId ?? '', viewerProtocol: behavior.ViewerProtocolPolicy ?? '', methods: behavior.AllowedMethods?.Items ?? READ_METHODS, }) return [ ...(config?.CacheBehaviors?.Items ?? []).map((behavior) => describe(behavior, behavior.PathPattern ?? '')), ...(config?.DefaultCacheBehavior ? [describe(config.DefaultCacheBehavior, 'Default (*)')] : []), ] } function oneOf(value: unknown, allowed: readonly T[], field: string): T { if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) } function text(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function stringList(value: unknown): string[] { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] } function errorName(error: unknown): string { return (error as {name?: string} | null)?.name ?? '' }