@stacksjs/ts-cloud 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/aws/s3.d.ts +1 -1
  2. package/dist/bin/cli.js +223 -222
  3. package/dist/index.js +132 -132
  4. package/package.json +18 -16
  5. package/src/aws/acm.ts +768 -0
  6. package/src/aws/application-autoscaling.ts +845 -0
  7. package/src/aws/bedrock.ts +4074 -0
  8. package/src/aws/client.ts +891 -0
  9. package/src/aws/cloudformation.ts +896 -0
  10. package/src/aws/cloudfront.ts +1531 -0
  11. package/src/aws/cloudwatch-logs.ts +154 -0
  12. package/src/aws/comprehend.ts +839 -0
  13. package/src/aws/connect.ts +1056 -0
  14. package/src/aws/deploy-imap.ts +384 -0
  15. package/src/aws/dynamodb.ts +340 -0
  16. package/src/aws/ec2.ts +1385 -0
  17. package/src/aws/ecr.ts +621 -0
  18. package/src/aws/ecs.ts +615 -0
  19. package/src/aws/elasticache.ts +301 -0
  20. package/src/aws/elbv2.ts +942 -0
  21. package/src/aws/email.ts +928 -0
  22. package/src/aws/eventbridge.ts +248 -0
  23. package/src/aws/iam.ts +1689 -0
  24. package/src/aws/imap-server.ts +2100 -0
  25. package/src/aws/index.ts +213 -0
  26. package/src/aws/kendra.ts +1097 -0
  27. package/src/aws/lambda.ts +786 -0
  28. package/src/aws/opensearch.ts +158 -0
  29. package/src/aws/personalize.ts +977 -0
  30. package/src/aws/polly.ts +559 -0
  31. package/src/aws/rds.ts +888 -0
  32. package/src/aws/rekognition.ts +846 -0
  33. package/src/aws/route53-domains.ts +359 -0
  34. package/src/aws/route53.ts +1046 -0
  35. package/src/aws/s3.ts +2334 -0
  36. package/src/aws/scheduler.ts +571 -0
  37. package/src/aws/secrets-manager.ts +769 -0
  38. package/src/aws/ses.ts +1081 -0
  39. package/src/aws/setup-phone.ts +104 -0
  40. package/src/aws/setup-sms.ts +580 -0
  41. package/src/aws/sms.ts +1735 -0
  42. package/src/aws/smtp-server.ts +531 -0
  43. package/src/aws/sns.ts +758 -0
  44. package/src/aws/sqs.ts +382 -0
  45. package/src/aws/ssm.ts +807 -0
  46. package/src/aws/sts.ts +92 -0
  47. package/src/aws/support.ts +391 -0
  48. package/src/aws/test-imap.ts +86 -0
  49. package/src/aws/textract.ts +780 -0
  50. package/src/aws/transcribe.ts +108 -0
  51. package/src/aws/translate.ts +641 -0
  52. package/src/aws/voice.ts +1379 -0
  53. package/src/config.ts +35 -0
  54. package/src/deploy/index.ts +7 -0
  55. package/src/deploy/static-site-external-dns.ts +945 -0
  56. package/src/deploy/static-site.ts +1175 -0
  57. package/src/dns/cloudflare.ts +548 -0
  58. package/src/dns/godaddy.ts +412 -0
  59. package/src/dns/index.ts +205 -0
  60. package/src/dns/porkbun.ts +362 -0
  61. package/src/dns/route53-adapter.ts +414 -0
  62. package/src/dns/types.ts +119 -0
  63. package/src/dns/validator.ts +369 -0
  64. package/src/generators/index.ts +5 -0
  65. package/src/generators/infrastructure.ts +1660 -0
  66. package/src/index.ts +163 -0
  67. package/src/push/apns.ts +452 -0
  68. package/src/push/fcm.ts +506 -0
  69. package/src/push/index.ts +58 -0
  70. package/src/security/pre-deploy-scanner.ts +655 -0
  71. package/src/ssl/acme-client.ts +478 -0
  72. package/src/ssl/index.ts +7 -0
  73. package/src/ssl/letsencrypt.ts +747 -0
  74. package/src/types.ts +2 -0
  75. package/src/utils/cli.ts +398 -0
  76. package/src/validation/index.ts +5 -0
  77. package/src/validation/template.ts +405 -0
@@ -0,0 +1,1531 @@
1
+ /**
2
+ * AWS CloudFront Operations
3
+ * Direct API calls without AWS CLI dependency
4
+ */
5
+
6
+ import { AWSClient } from './client'
7
+
8
+ export interface InvalidationOptions {
9
+ distributionId: string
10
+ paths: string[]
11
+ callerReference?: string
12
+ }
13
+
14
+ export interface Distribution {
15
+ Id: string
16
+ ARN: string
17
+ Status: string
18
+ DomainName: string
19
+ Aliases?: { Quantity?: number; Items?: string[] }
20
+ Enabled: boolean
21
+ }
22
+
23
+ /**
24
+ * CloudFront client using direct API calls
25
+ */
26
+ export class CloudFrontClient {
27
+ private client: AWSClient
28
+
29
+ constructor(profile?: string) {
30
+ this.client = new AWSClient()
31
+ }
32
+
33
+ /**
34
+ * Create cache invalidation
35
+ */
36
+ async createInvalidation(options: InvalidationOptions): Promise<{
37
+ Id: string
38
+ Status: string
39
+ CreateTime: string
40
+ }> {
41
+ const callerReference = options.callerReference || Date.now().toString()
42
+
43
+ const invalidationBatchXml = `<?xml version="1.0" encoding="UTF-8"?>
44
+ <InvalidationBatch>
45
+ <Paths>
46
+ <Quantity>${options.paths.length}</Quantity>
47
+ <Items>
48
+ ${options.paths.map(path => `<Path>${path}</Path>`).join('\n ')}
49
+ </Items>
50
+ </Paths>
51
+ <CallerReference>${callerReference}</CallerReference>
52
+ </InvalidationBatch>`
53
+
54
+ const result = await this.client.request({
55
+ service: 'cloudfront',
56
+ region: 'us-east-1', // CloudFront is global
57
+ method: 'POST',
58
+ path: `/2020-05-31/distribution/${options.distributionId}/invalidation`,
59
+ body: invalidationBatchXml,
60
+ headers: {
61
+ 'Content-Type': 'application/xml',
62
+ },
63
+ })
64
+
65
+ return {
66
+ Id: result.Id || result.Invalidation?.Id,
67
+ Status: result.Status || result.Invalidation?.Status || 'InProgress',
68
+ CreateTime: result.CreateTime || result.Invalidation?.CreateTime || new Date().toISOString(),
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Get invalidation status
74
+ */
75
+ async getInvalidation(distributionId: string, invalidationId: string): Promise<{
76
+ Id: string
77
+ Status: string
78
+ CreateTime: string
79
+ }> {
80
+ const result = await this.client.request({
81
+ service: 'cloudfront',
82
+ region: 'us-east-1',
83
+ method: 'GET',
84
+ path: `/2020-05-31/distribution/${distributionId}/invalidation/${invalidationId}`,
85
+ })
86
+
87
+ return {
88
+ Id: result.Id || result.Invalidation?.Id,
89
+ Status: result.Status || result.Invalidation?.Status,
90
+ CreateTime: result.CreateTime || result.Invalidation?.CreateTime,
91
+ }
92
+ }
93
+
94
+ /**
95
+ * List invalidations
96
+ */
97
+ async listInvalidations(distributionId: string): Promise<Array<{
98
+ Id: string
99
+ Status: string
100
+ CreateTime: string
101
+ }>> {
102
+ const result = await this.client.request({
103
+ service: 'cloudfront',
104
+ region: 'us-east-1',
105
+ method: 'GET',
106
+ path: `/2020-05-31/distribution/${distributionId}/invalidation`,
107
+ })
108
+
109
+ // Parse invalidation list
110
+ const invalidations: Array<{ Id: string, Status: string, CreateTime: string }> = []
111
+
112
+ // Simple parser - would need proper XML parsing in production
113
+ if (result.InvalidationSummary) {
114
+ const summaries = Array.isArray(result.InvalidationSummary)
115
+ ? result.InvalidationSummary
116
+ : [result.InvalidationSummary]
117
+
118
+ invalidations.push(...summaries.map((item: any) => ({
119
+ Id: item.Id,
120
+ Status: item.Status,
121
+ CreateTime: item.CreateTime,
122
+ })))
123
+ }
124
+
125
+ return invalidations
126
+ }
127
+
128
+ /**
129
+ * Wait for invalidation to complete
130
+ */
131
+ async waitForInvalidation(distributionId: string, invalidationId: string): Promise<void> {
132
+ const maxAttempts = 60 // 5 minutes
133
+ let attempts = 0
134
+
135
+ while (attempts < maxAttempts) {
136
+ const invalidation = await this.getInvalidation(distributionId, invalidationId)
137
+
138
+ if (invalidation.Status === 'Completed') {
139
+ return
140
+ }
141
+
142
+ // Wait 5 seconds before next attempt
143
+ await new Promise(resolve => setTimeout(resolve, 5000))
144
+ attempts++
145
+ }
146
+
147
+ throw new Error(`Timeout waiting for invalidation ${invalidationId} to complete`)
148
+ }
149
+
150
+ /**
151
+ * List distributions
152
+ */
153
+ async listDistributions(): Promise<Distribution[]> {
154
+ const result = await this.client.request({
155
+ service: 'cloudfront',
156
+ region: 'us-east-1',
157
+ method: 'GET',
158
+ path: '/2020-05-31/distribution',
159
+ })
160
+
161
+ const distributions: Distribution[] = []
162
+
163
+ // The response structure is: DistributionList.Items.DistributionSummary
164
+ const distList = result.DistributionList || result
165
+ const items = distList.Items
166
+ const summaryData = items?.DistributionSummary
167
+
168
+ if (summaryData) {
169
+ const summaries = Array.isArray(summaryData)
170
+ ? summaryData
171
+ : [summaryData]
172
+
173
+ distributions.push(...summaries.map((item: any) => ({
174
+ Id: item.Id,
175
+ ARN: item.ARN,
176
+ Status: item.Status,
177
+ DomainName: item.DomainName,
178
+ Aliases: item.Aliases || undefined,
179
+ Enabled: item.Enabled === 'true' || item.Enabled === true,
180
+ })))
181
+ }
182
+
183
+ return distributions
184
+ }
185
+
186
+ /**
187
+ * Get distribution by ID
188
+ */
189
+ async getDistribution(distributionId: string): Promise<Distribution> {
190
+ const result = await this.client.request({
191
+ service: 'cloudfront',
192
+ region: 'us-east-1',
193
+ method: 'GET',
194
+ path: `/2020-05-31/distribution/${distributionId}`,
195
+ })
196
+
197
+ const dist = result.Distribution || result
198
+
199
+ return {
200
+ Id: dist.Id,
201
+ ARN: dist.ARN,
202
+ Status: dist.Status,
203
+ DomainName: dist.DomainName,
204
+ Aliases: dist.DistributionConfig?.Aliases?.Items || dist.Aliases?.Items || [],
205
+ Enabled: dist.DistributionConfig?.Enabled === 'true' || dist.DistributionConfig?.Enabled === true,
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Get distribution configuration (full config including origins and cache behaviors)
211
+ */
212
+ async getDistributionConfig(distributionId: string): Promise<{
213
+ ETag: string
214
+ DistributionConfig: {
215
+ Origins: {
216
+ Quantity: number
217
+ Items: any
218
+ }
219
+ DefaultCacheBehavior: {
220
+ TargetOriginId: string
221
+ ViewerProtocolPolicy: string
222
+ AllowedMethods?: { Quantity: number, Items: string[] }
223
+ CachedMethods?: { Quantity: number, Items: string[] }
224
+ ForwardedValues?: any
225
+ TrustedSigners?: any
226
+ MinTTL?: number
227
+ DefaultTTL?: number
228
+ MaxTTL?: number
229
+ }
230
+ CacheBehaviors?: {
231
+ Quantity: number
232
+ Items: Array<{
233
+ PathPattern: string
234
+ TargetOriginId: string
235
+ ViewerProtocolPolicy: string
236
+ AllowedMethods?: { Quantity: number, Items: string[] }
237
+ CachedMethods?: { Quantity: number, Items: string[] }
238
+ ForwardedValues?: any
239
+ MinTTL?: number
240
+ DefaultTTL?: number
241
+ MaxTTL?: number
242
+ }>
243
+ }
244
+ Aliases?: { Quantity: number, Items: string[] }
245
+ Comment?: string
246
+ Enabled: boolean
247
+ }
248
+ }> {
249
+ const result = await this.client.request({
250
+ service: 'cloudfront',
251
+ region: 'us-east-1',
252
+ method: 'GET',
253
+ path: `/2020-05-31/distribution/${distributionId}/config`,
254
+ })
255
+
256
+ return {
257
+ ETag: result.ETag || '',
258
+ DistributionConfig: result.DistributionConfig || result,
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Invalidate all files
264
+ */
265
+ async invalidateAll(distributionId: string): Promise<{
266
+ Id: string
267
+ Status: string
268
+ CreateTime: string
269
+ }> {
270
+ return this.createInvalidation({
271
+ distributionId,
272
+ paths: ['/*'],
273
+ })
274
+ }
275
+
276
+ /**
277
+ * Invalidate specific paths
278
+ */
279
+ async invalidatePaths(distributionId: string, paths: string[]): Promise<{
280
+ Id: string
281
+ Status: string
282
+ CreateTime: string
283
+ }> {
284
+ // Ensure paths start with /
285
+ const formattedPaths = paths.map(path => path.startsWith('/') ? path : `/${path}`)
286
+
287
+ return this.createInvalidation({
288
+ distributionId,
289
+ paths: formattedPaths,
290
+ })
291
+ }
292
+
293
+ /**
294
+ * Invalidate by pattern
295
+ */
296
+ async invalidatePattern(distributionId: string, pattern: string): Promise<{
297
+ Id: string
298
+ Status: string
299
+ CreateTime: string
300
+ }> {
301
+ // CloudFront supports wildcards like /images/* or /css/*
302
+ const path = pattern.startsWith('/') ? pattern : `/${pattern}`
303
+
304
+ return this.createInvalidation({
305
+ distributionId,
306
+ paths: [path],
307
+ })
308
+ }
309
+
310
+ /**
311
+ * Invalidate after deployment
312
+ * Useful for CI/CD pipelines
313
+ */
314
+ async invalidateAfterDeployment(options: {
315
+ distributionId: string
316
+ changedPaths?: string[]
317
+ invalidateAll?: boolean
318
+ wait?: boolean
319
+ }): Promise<{
320
+ invalidationId: string
321
+ status: string
322
+ }> {
323
+ const { distributionId, changedPaths, invalidateAll = false, wait = false } = options
324
+
325
+ let result
326
+
327
+ if (invalidateAll || !changedPaths || changedPaths.length === 0) {
328
+ result = await this.invalidateAll(distributionId)
329
+ }
330
+ else {
331
+ result = await this.invalidatePaths(distributionId, changedPaths)
332
+ }
333
+
334
+ if (wait) {
335
+ await this.waitForInvalidation(distributionId, result.Id)
336
+ }
337
+
338
+ return {
339
+ invalidationId: result.Id,
340
+ status: result.Status,
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Find distribution by domain name or alias
346
+ */
347
+ async findDistributionByDomain(domain: string): Promise<Distribution | null> {
348
+ const distributions = await this.listDistributions()
349
+
350
+ // Check both CloudFront domain and aliases
351
+ const found = distributions.find((dist) => {
352
+ if (dist.DomainName === domain) {
353
+ return true
354
+ }
355
+ if (dist.Aliases?.Items && dist.Aliases.Items.includes(domain)) {
356
+ return true
357
+ }
358
+ return false
359
+ })
360
+
361
+ return found || null
362
+ }
363
+
364
+ /**
365
+ * Batch invalidate multiple distributions
366
+ * Useful for multi-region or blue/green deployments
367
+ */
368
+ async batchInvalidate(distributionIds: string[], paths: string[] = ['/*']): Promise<Array<{
369
+ distributionId: string
370
+ invalidationId: string
371
+ status: string
372
+ }>> {
373
+ const results = await Promise.all(
374
+ distributionIds.map(async (distributionId) => {
375
+ const result = await this.createInvalidation({
376
+ distributionId,
377
+ paths,
378
+ })
379
+ return {
380
+ distributionId,
381
+ invalidationId: result.Id,
382
+ status: result.Status,
383
+ }
384
+ }),
385
+ )
386
+
387
+ return results
388
+ }
389
+
390
+ /**
391
+ * Update custom error responses for a distribution
392
+ * Use this to configure how CloudFront handles 4xx/5xx errors from origins
393
+ */
394
+ async updateCustomErrorResponses(options: {
395
+ distributionId: string
396
+ customErrorResponses: Array<{
397
+ errorCode: number
398
+ responsePagePath?: string
399
+ responseCode?: number
400
+ errorCachingMinTTL?: number
401
+ }>
402
+ }): Promise<{
403
+ Distribution: Distribution
404
+ ETag: string
405
+ }> {
406
+ const { distributionId, customErrorResponses } = options
407
+
408
+ // First, get the current config with ETag
409
+ const getResult = await this.client.request({
410
+ service: 'cloudfront',
411
+ region: 'us-east-1',
412
+ method: 'GET',
413
+ path: `/2020-05-31/distribution/${distributionId}/config`,
414
+ returnHeaders: true,
415
+ })
416
+
417
+ const etag = getResult.headers?.etag || getResult.headers?.ETag || ''
418
+ const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig
419
+
420
+ if (!currentConfig) {
421
+ throw new Error('Failed to get current distribution config')
422
+ }
423
+
424
+ // Update custom error responses
425
+ if (customErrorResponses.length === 0) {
426
+ currentConfig.CustomErrorResponses = {
427
+ Quantity: 0,
428
+ }
429
+ }
430
+ else {
431
+ currentConfig.CustomErrorResponses = {
432
+ Quantity: customErrorResponses.length,
433
+ Items: {
434
+ CustomErrorResponse: customErrorResponses.map(err => ({
435
+ ErrorCode: err.errorCode,
436
+ ...(err.responsePagePath && { ResponsePagePath: err.responsePagePath }),
437
+ ...(err.responseCode && { ResponseCode: err.responseCode }),
438
+ ...(err.errorCachingMinTTL !== undefined && { ErrorCachingMinTTL: err.errorCachingMinTTL }),
439
+ })),
440
+ },
441
+ }
442
+ }
443
+
444
+ // Build the XML for the update request
445
+ const configXml = this.buildDistributionConfigXml(currentConfig)
446
+
447
+ // Update the distribution
448
+ const result = await this.client.request({
449
+ service: 'cloudfront',
450
+ region: 'us-east-1',
451
+ method: 'PUT',
452
+ path: `/2020-05-31/distribution/${distributionId}/config`,
453
+ body: configXml,
454
+ headers: {
455
+ 'Content-Type': 'application/xml',
456
+ 'If-Match': etag,
457
+ },
458
+ })
459
+
460
+ const dist = result.Distribution || result
461
+
462
+ return {
463
+ Distribution: {
464
+ Id: dist.Id,
465
+ ARN: dist.ARN,
466
+ Status: dist.Status,
467
+ DomainName: dist.DomainName,
468
+ Aliases: dist.DistributionConfig?.Aliases?.Items || [],
469
+ Enabled: dist.DistributionConfig?.Enabled === 'true' || dist.DistributionConfig?.Enabled === true,
470
+ },
471
+ ETag: result.ETag || '',
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Remove all custom error responses from a distribution
477
+ * This will make CloudFront return actual 4xx/5xx errors instead of custom pages
478
+ */
479
+ async removeCustomErrorResponses(distributionId: string): Promise<{
480
+ Distribution: Distribution
481
+ ETag: string
482
+ }> {
483
+ return this.updateCustomErrorResponses({
484
+ distributionId,
485
+ customErrorResponses: [],
486
+ })
487
+ }
488
+
489
+ /**
490
+ * Update distribution configuration
491
+ * This method updates the CloudFront distribution with new settings like aliases and certificates
492
+ */
493
+ async updateDistribution(options: {
494
+ distributionId: string
495
+ aliases?: string[]
496
+ certificateArn?: string
497
+ comment?: string
498
+ }): Promise<{
499
+ Distribution: Distribution
500
+ ETag: string
501
+ }> {
502
+ const { distributionId, aliases, certificateArn, comment } = options
503
+
504
+ // First, get the current config with ETag
505
+ const getResult = await this.client.request({
506
+ service: 'cloudfront',
507
+ region: 'us-east-1',
508
+ method: 'GET',
509
+ path: `/2020-05-31/distribution/${distributionId}/config`,
510
+ returnHeaders: true,
511
+ })
512
+
513
+ const etag = getResult.headers?.etag || getResult.headers?.ETag || ''
514
+ const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig
515
+
516
+ if (!currentConfig) {
517
+ throw new Error('Failed to get current distribution config')
518
+ }
519
+
520
+ // Update the config with new values
521
+ if (aliases && aliases.length > 0) {
522
+ currentConfig.Aliases = {
523
+ Quantity: aliases.length,
524
+ Items: { Item: aliases },
525
+ }
526
+ }
527
+
528
+ if (certificateArn) {
529
+ currentConfig.ViewerCertificate = {
530
+ ACMCertificateArn: certificateArn,
531
+ SSLSupportMethod: 'sni-only',
532
+ MinimumProtocolVersion: 'TLSv1.2_2021',
533
+ CertificateSource: 'acm',
534
+ }
535
+ }
536
+
537
+ if (comment) {
538
+ currentConfig.Comment = comment
539
+ }
540
+
541
+ // Build the XML for the update request
542
+ const configXml = this.buildDistributionConfigXml(currentConfig)
543
+
544
+ // Update the distribution
545
+ const result = await this.client.request({
546
+ service: 'cloudfront',
547
+ region: 'us-east-1',
548
+ method: 'PUT',
549
+ path: `/2020-05-31/distribution/${distributionId}/config`,
550
+ body: configXml,
551
+ headers: {
552
+ 'Content-Type': 'application/xml',
553
+ 'If-Match': etag,
554
+ },
555
+ })
556
+
557
+ const dist = result.Distribution || result
558
+
559
+ return {
560
+ Distribution: {
561
+ Id: dist.Id,
562
+ ARN: dist.ARN,
563
+ Status: dist.Status,
564
+ DomainName: dist.DomainName,
565
+ Aliases: aliases ? { Quantity: aliases.length, Items: aliases } : { Quantity: 0, Items: [] },
566
+ Enabled: dist.DistributionConfig?.Enabled === 'true' || dist.DistributionConfig?.Enabled === true,
567
+ },
568
+ ETag: result.ETag || '',
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Helper to build XML from distribution config object
574
+ * CloudFront requires specific XML structures - this method handles the complex nesting
575
+ */
576
+ private buildDistributionConfigXml(config: any): string {
577
+ const escapeXml = (str: string): string => {
578
+ return str.replace(/&/g, '&amp;')
579
+ .replace(/</g, '&lt;')
580
+ .replace(/>/g, '&gt;')
581
+ .replace(/"/g, '&quot;')
582
+ .replace(/'/g, '&apos;')
583
+ }
584
+
585
+ // Map of parent element names to their child element names for array items
586
+ const arrayChildNames: Record<string, string> = {
587
+ Items: '', // Will be determined by context
588
+ Methods: 'Method',
589
+ Headers: 'Name',
590
+ Cookies: 'Name',
591
+ QueryStringCacheKeys: 'Name',
592
+ TrustedKeyGroups: 'KeyGroup',
593
+ TrustedSigners: 'AwsAccountNumber',
594
+ LambdaFunctionAssociations: 'LambdaFunctionAssociation',
595
+ FunctionAssociations: 'FunctionAssociation',
596
+ CacheBehaviors: 'CacheBehavior',
597
+ CustomErrorResponses: 'CustomErrorResponse',
598
+ GeoRestriction: 'Location',
599
+ }
600
+
601
+ // Elements inside Items that have specific child names
602
+ const itemsChildNames: Record<string, string> = {
603
+ Origins: 'Origin',
604
+ Aliases: 'CNAME',
605
+ AllowedMethods: 'Method',
606
+ CachedMethods: 'Method',
607
+ CustomErrorResponses: 'CustomErrorResponse',
608
+ CacheBehaviors: 'CacheBehavior',
609
+ }
610
+
611
+ const buildXmlElement = (name: string, value: any, indent: string = '', parentContext: string = ''): string => {
612
+ if (value === null || value === undefined) {
613
+ return ''
614
+ }
615
+
616
+ // Skip XML metadata attributes
617
+ if (name.startsWith('@_') || name === '?xml') {
618
+ return ''
619
+ }
620
+
621
+ if (typeof value === 'boolean') {
622
+ return `${indent}<${name}>${value}</${name}>\n`
623
+ }
624
+
625
+ if (typeof value === 'number' || typeof value === 'string') {
626
+ return `${indent}<${name}>${escapeXml(String(value))}</${name}>\n`
627
+ }
628
+
629
+ if (Array.isArray(value)) {
630
+ // For arrays, we need to output each item with the appropriate element name
631
+ const childName = arrayChildNames[name] || name.replace(/s$/, '')
632
+ return value.map(item => buildXmlElement(childName, item, indent, name)).join('')
633
+ }
634
+
635
+ if (typeof value === 'object') {
636
+ // Handle Items specially - they contain the actual array items
637
+ if (name === 'Items') {
638
+ // Figure out what type of items these are based on parent context
639
+ const childElementName = itemsChildNames[parentContext] || ''
640
+
641
+ // Check if Items has named children (like CNAME, Origin, etc.)
642
+ const keys = Object.keys(value).filter(k => !k.startsWith('@_'))
643
+
644
+ if (keys.length === 1 && !Array.isArray(value[keys[0]])) {
645
+ // Single named child that's not an array - could be a single item
646
+ const childKey = keys[0]
647
+ const childValue = value[childKey]
648
+ if (typeof childValue === 'string') {
649
+ // Single item like {CNAME: "domain.com"}
650
+ return `${indent}<Items>\n${indent} <${childKey}>${escapeXml(childValue)}</${childKey}>\n${indent}</Items>\n`
651
+ }
652
+ else if (typeof childValue === 'object' && !Array.isArray(childValue)) {
653
+ // Single complex item like {Origin: {...}}
654
+ return `${indent}<Items>\n${buildXmlElement(childKey, childValue, indent + ' ', name)}${indent}</Items>\n`
655
+ }
656
+ }
657
+
658
+ if (keys.length === 1 && Array.isArray(value[keys[0]])) {
659
+ // Named array child like {CNAME: ["a.com", "b.com"]} or {Origin: [{...}, {...}]}
660
+ const childKey = keys[0]
661
+ const childArray = value[childKey]
662
+ let children = ''
663
+ for (const item of childArray) {
664
+ if (typeof item === 'string') {
665
+ children += `${indent} <${childKey}>${escapeXml(item)}</${childKey}>\n`
666
+ }
667
+ else {
668
+ children += buildXmlElement(childKey, item, indent + ' ', name)
669
+ }
670
+ }
671
+ return `${indent}<Items>\n${children}${indent}</Items>\n`
672
+ }
673
+
674
+ // Check if Items is an array directly passed in
675
+ if (Array.isArray(value)) {
676
+ let children = ''
677
+ const childName = childElementName || 'Item'
678
+ for (const item of value) {
679
+ if (typeof item === 'string') {
680
+ children += `${indent} <${childName}>${escapeXml(item)}</${childName}>\n`
681
+ }
682
+ else {
683
+ children += buildXmlElement(childName, item, indent + ' ', name)
684
+ }
685
+ }
686
+ return `${indent}<Items>\n${children}${indent}</Items>\n`
687
+ }
688
+
689
+ // Fall through to regular object handling if none of the special cases match
690
+ }
691
+
692
+ let children = ''
693
+ for (const [key, val] of Object.entries(value)) {
694
+ if (!key.startsWith('@_')) {
695
+ children += buildXmlElement(key, val, indent + ' ', name)
696
+ }
697
+ }
698
+
699
+ if (children === '') {
700
+ return `${indent}<${name}/>\n`
701
+ }
702
+
703
+ return `${indent}<${name}>\n${children}${indent}</${name}>\n`
704
+ }
705
+
706
+ return ''
707
+ }
708
+
709
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">\n${Object.entries(config).filter(([k]) => !k.startsWith('@_')).map(([key, val]) => buildXmlElement(key, val, ' ', 'DistributionConfig')).join('')}</DistributionConfig>`
710
+ }
711
+
712
+ /**
713
+ * Add aliases to a distribution
714
+ */
715
+ async addAliases(distributionId: string, aliases: string[], certificateArn: string): Promise<{
716
+ Distribution: Distribution
717
+ ETag: string
718
+ }> {
719
+ return this.updateDistribution({
720
+ distributionId,
721
+ aliases,
722
+ certificateArn,
723
+ })
724
+ }
725
+
726
+ /**
727
+ * Create a CloudFront Function
728
+ * CloudFront Functions are lightweight JavaScript functions for viewer request/response manipulation
729
+ */
730
+ async createFunction(options: {
731
+ name: string
732
+ code: string
733
+ comment?: string
734
+ runtime?: 'cloudfront-js-1.0' | 'cloudfront-js-2.0'
735
+ }): Promise<{
736
+ FunctionARN: string
737
+ Name: string
738
+ Stage: string
739
+ ETag: string
740
+ }> {
741
+ const { name, code, comment = '', runtime = 'cloudfront-js-2.0' } = options
742
+
743
+ const functionXml = `<?xml version="1.0" encoding="UTF-8"?>
744
+ <CreateFunctionRequest xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
745
+ <Name>${name}</Name>
746
+ <FunctionConfig>
747
+ <Comment>${comment}</Comment>
748
+ <Runtime>${runtime}</Runtime>
749
+ </FunctionConfig>
750
+ <FunctionCode>${Buffer.from(code).toString('base64')}</FunctionCode>
751
+ </CreateFunctionRequest>`
752
+
753
+ const result = await this.client.request({
754
+ service: 'cloudfront',
755
+ region: 'us-east-1',
756
+ method: 'POST',
757
+ path: '/2020-05-31/function',
758
+ body: functionXml,
759
+ headers: {
760
+ 'Content-Type': 'application/xml',
761
+ },
762
+ })
763
+
764
+ const func = result.FunctionSummary || result
765
+
766
+ return {
767
+ FunctionARN: func.FunctionMetadata?.FunctionARN || func.FunctionARN,
768
+ Name: func.Name || name,
769
+ Stage: func.FunctionMetadata?.Stage || 'DEVELOPMENT',
770
+ ETag: result.ETag || '',
771
+ }
772
+ }
773
+
774
+ /**
775
+ * List CloudFront Functions
776
+ */
777
+ async listFunctions(): Promise<Array<{
778
+ Name: string
779
+ FunctionARN: string
780
+ Stage: string
781
+ CreatedTime: string
782
+ LastModifiedTime: string
783
+ }>> {
784
+ const result = await this.client.request({
785
+ service: 'cloudfront',
786
+ region: 'us-east-1',
787
+ method: 'GET',
788
+ path: '/2020-05-31/function',
789
+ })
790
+
791
+ const functions: Array<{
792
+ Name: string
793
+ FunctionARN: string
794
+ Stage: string
795
+ CreatedTime: string
796
+ LastModifiedTime: string
797
+ }> = []
798
+
799
+ const items = result.FunctionList?.Items?.FunctionSummary
800
+ if (items) {
801
+ const list = Array.isArray(items) ? items : [items]
802
+ for (const item of list) {
803
+ functions.push({
804
+ Name: item.Name,
805
+ FunctionARN: item.FunctionMetadata?.FunctionARN,
806
+ Stage: item.FunctionMetadata?.Stage,
807
+ CreatedTime: item.FunctionMetadata?.CreatedTime,
808
+ LastModifiedTime: item.FunctionMetadata?.LastModifiedTime,
809
+ })
810
+ }
811
+ }
812
+
813
+ return functions
814
+ }
815
+
816
+ /**
817
+ * Get a CloudFront Function
818
+ */
819
+ async getFunction(name: string, stage: 'DEVELOPMENT' | 'LIVE' = 'LIVE'): Promise<{
820
+ FunctionARN: string
821
+ Name: string
822
+ Stage: string
823
+ ETag: string
824
+ FunctionCode?: string
825
+ } | null> {
826
+ try {
827
+ const result = await this.client.request({
828
+ service: 'cloudfront',
829
+ region: 'us-east-1',
830
+ method: 'GET',
831
+ path: `/2020-05-31/function/${name}`,
832
+ queryParams: { Stage: stage },
833
+ returnHeaders: true,
834
+ })
835
+
836
+ const func = result.body?.FunctionSummary || result.FunctionSummary || result.body || result
837
+
838
+ return {
839
+ FunctionARN: func.FunctionMetadata?.FunctionARN,
840
+ Name: func.Name || name,
841
+ Stage: func.FunctionMetadata?.Stage || stage,
842
+ ETag: result.headers?.etag || result.ETag || '',
843
+ FunctionCode: func.FunctionCode,
844
+ }
845
+ }
846
+ catch (err: any) {
847
+ if (err.message?.includes('404') || err.message?.includes('NoSuchFunctionExists')) {
848
+ return null
849
+ }
850
+ throw err
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Publish a CloudFront Function (move from DEVELOPMENT to LIVE stage)
856
+ * Can be called with just the name (will auto-fetch ETag) or with options object
857
+ */
858
+ async publishFunction(nameOrOptions: string | { Name: string, IfMatch: string }, etag?: string): Promise<{
859
+ FunctionARN: string
860
+ Stage: string
861
+ FunctionSummary?: {
862
+ Name: string
863
+ FunctionMetadata: {
864
+ FunctionARN: string
865
+ Stage: string
866
+ }
867
+ }
868
+ }> {
869
+ let name: string
870
+ let functionETag: string | undefined
871
+
872
+ if (typeof nameOrOptions === 'object') {
873
+ name = nameOrOptions.Name
874
+ functionETag = nameOrOptions.IfMatch
875
+ }
876
+ else {
877
+ name = nameOrOptions
878
+ functionETag = etag
879
+ }
880
+
881
+ // Get the current ETag if not provided
882
+ if (!functionETag) {
883
+ const func = await this.getFunction(name, 'DEVELOPMENT')
884
+ if (!func) {
885
+ throw new Error(`Function ${name} not found`)
886
+ }
887
+ functionETag = func.ETag
888
+ }
889
+
890
+ const result = await this.client.request({
891
+ service: 'cloudfront',
892
+ region: 'us-east-1',
893
+ method: 'POST',
894
+ path: `/2020-05-31/function/${name}/publish`,
895
+ headers: {
896
+ 'If-Match': functionETag,
897
+ },
898
+ })
899
+
900
+ const func = result.FunctionSummary || result
901
+
902
+ return {
903
+ FunctionARN: func.FunctionMetadata?.FunctionARN,
904
+ Stage: func.FunctionMetadata?.Stage || 'LIVE',
905
+ FunctionSummary: func,
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Describe a CloudFront Function (get metadata including ETag)
911
+ */
912
+ async describeFunction(options: { Name: string, Stage?: 'DEVELOPMENT' | 'LIVE' }): Promise<{
913
+ ETag: string
914
+ FunctionSummary: {
915
+ Name: string
916
+ Status: string
917
+ FunctionConfig: {
918
+ Comment: string
919
+ Runtime: string
920
+ }
921
+ FunctionMetadata: {
922
+ FunctionARN: string
923
+ Stage: string
924
+ CreatedTime: string
925
+ LastModifiedTime: string
926
+ }
927
+ }
928
+ }> {
929
+ const { Name, Stage = 'DEVELOPMENT' } = options
930
+
931
+ const result = await this.client.request({
932
+ service: 'cloudfront',
933
+ region: 'us-east-1',
934
+ method: 'GET',
935
+ path: `/2020-05-31/function/${Name}/describe`,
936
+ queryParams: { Stage },
937
+ returnHeaders: true,
938
+ })
939
+
940
+ return {
941
+ ETag: result.headers?.etag || result.ETag || '',
942
+ FunctionSummary: result.body?.FunctionSummary || result.FunctionSummary || result.body || result,
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Update a CloudFront Function
948
+ */
949
+ async updateFunction(options: {
950
+ Name: string
951
+ FunctionCode: string
952
+ FunctionConfig: {
953
+ Comment: string
954
+ Runtime: 'cloudfront-js-1.0' | 'cloudfront-js-2.0'
955
+ }
956
+ IfMatch: string
957
+ }): Promise<{
958
+ ETag: string
959
+ FunctionSummary: {
960
+ Name: string
961
+ FunctionMetadata: {
962
+ FunctionARN: string
963
+ Stage: string
964
+ }
965
+ }
966
+ }> {
967
+ const { Name, FunctionCode, FunctionConfig, IfMatch } = options
968
+
969
+ const functionXml = `<?xml version="1.0" encoding="UTF-8"?>
970
+ <UpdateFunctionRequest xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
971
+ <FunctionConfig>
972
+ <Comment>${FunctionConfig.Comment}</Comment>
973
+ <Runtime>${FunctionConfig.Runtime}</Runtime>
974
+ </FunctionConfig>
975
+ <FunctionCode>${Buffer.from(FunctionCode).toString('base64')}</FunctionCode>
976
+ </UpdateFunctionRequest>`
977
+
978
+ const result = await this.client.request({
979
+ service: 'cloudfront',
980
+ region: 'us-east-1',
981
+ method: 'PUT',
982
+ path: `/2020-05-31/function/${Name}`,
983
+ body: functionXml,
984
+ headers: {
985
+ 'Content-Type': 'application/xml',
986
+ 'If-Match': IfMatch,
987
+ },
988
+ returnHeaders: true,
989
+ })
990
+
991
+ return {
992
+ ETag: result.headers?.etag || result.ETag || '',
993
+ FunctionSummary: result.body?.FunctionSummary || result.FunctionSummary || result.body || result,
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Delete a CloudFront Function
999
+ */
1000
+ async deleteFunction(name: string, etag?: string): Promise<void> {
1001
+ // Get the current ETag if not provided
1002
+ let functionETag = etag
1003
+ if (!functionETag) {
1004
+ const func = await this.getFunction(name, 'DEVELOPMENT')
1005
+ if (!func) {
1006
+ // Function doesn't exist, nothing to delete
1007
+ return
1008
+ }
1009
+ functionETag = func.ETag
1010
+ }
1011
+
1012
+ await this.client.request({
1013
+ service: 'cloudfront',
1014
+ region: 'us-east-1',
1015
+ method: 'DELETE',
1016
+ path: `/2020-05-31/function/${name}`,
1017
+ headers: {
1018
+ 'If-Match': functionETag,
1019
+ },
1020
+ })
1021
+ }
1022
+
1023
+ /**
1024
+ * Create a standard index.html rewrite function for S3 static sites
1025
+ * This function rewrites directory requests to index.html
1026
+ */
1027
+ async createIndexRewriteFunction(name: string): Promise<{
1028
+ FunctionARN: string
1029
+ Name: string
1030
+ Stage: string
1031
+ ETag: string
1032
+ }> {
1033
+ const code = `function handler(event) {
1034
+ var request = event.request;
1035
+ var uri = request.uri;
1036
+
1037
+ // Check if the request is for a directory (ends with /)
1038
+ if (uri.endsWith('/')) {
1039
+ request.uri += 'index.html';
1040
+ }
1041
+ // Check if the request doesn't have a file extension
1042
+ else if (!uri.includes('.')) {
1043
+ // Add trailing slash to redirect to directory
1044
+ request.uri += '/index.html';
1045
+ }
1046
+
1047
+ return request;
1048
+ }`
1049
+
1050
+ return this.createFunction({
1051
+ name,
1052
+ code,
1053
+ comment: 'Rewrite directory requests to index.html for S3 static sites',
1054
+ runtime: 'cloudfront-js-2.0',
1055
+ })
1056
+ }
1057
+
1058
+ /**
1059
+ * Get origin access control configurations
1060
+ */
1061
+ async listOriginAccessControls(): Promise<Array<{
1062
+ Id: string
1063
+ Name: string
1064
+ Description?: string
1065
+ SigningProtocol: string
1066
+ SigningBehavior: string
1067
+ OriginAccessControlOriginType: string
1068
+ }>> {
1069
+ const result = await this.client.request({
1070
+ service: 'cloudfront',
1071
+ region: 'us-east-1',
1072
+ method: 'GET',
1073
+ path: '/2020-05-31/origin-access-control',
1074
+ })
1075
+
1076
+ const items: any[] = []
1077
+
1078
+ if (result.OriginAccessControlList?.Items?.OriginAccessControlSummary) {
1079
+ const summaries = Array.isArray(result.OriginAccessControlList.Items.OriginAccessControlSummary)
1080
+ ? result.OriginAccessControlList.Items.OriginAccessControlSummary
1081
+ : [result.OriginAccessControlList.Items.OriginAccessControlSummary]
1082
+
1083
+ items.push(...summaries.map((item: any) => ({
1084
+ Id: item.Id,
1085
+ Name: item.Name,
1086
+ Description: item.Description,
1087
+ SigningProtocol: item.SigningProtocol,
1088
+ SigningBehavior: item.SigningBehavior,
1089
+ OriginAccessControlOriginType: item.OriginAccessControlOriginType,
1090
+ })))
1091
+ }
1092
+
1093
+ return items
1094
+ }
1095
+
1096
+ /**
1097
+ * Create an Origin Access Control for S3
1098
+ */
1099
+ async createOriginAccessControl(options: {
1100
+ name: string
1101
+ description?: string
1102
+ signingProtocol?: 'sigv4'
1103
+ signingBehavior?: 'always' | 'never' | 'no-override'
1104
+ originType?: 's3'
1105
+ }): Promise<{
1106
+ Id: string
1107
+ Name: string
1108
+ Description: string
1109
+ SigningProtocol: string
1110
+ SigningBehavior: string
1111
+ OriginAccessControlOriginType: string
1112
+ ETag: string
1113
+ }> {
1114
+ const {
1115
+ name,
1116
+ description = `OAC for ${name}`,
1117
+ signingProtocol = 'sigv4',
1118
+ signingBehavior = 'always',
1119
+ originType = 's3',
1120
+ } = options
1121
+
1122
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1123
+ <OriginAccessControlConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
1124
+ <Name>${name}</Name>
1125
+ <Description>${description}</Description>
1126
+ <SigningProtocol>${signingProtocol}</SigningProtocol>
1127
+ <SigningBehavior>${signingBehavior}</SigningBehavior>
1128
+ <OriginAccessControlOriginType>${originType}</OriginAccessControlOriginType>
1129
+ </OriginAccessControlConfig>`
1130
+
1131
+ const result = await this.client.request({
1132
+ service: 'cloudfront',
1133
+ region: 'us-east-1',
1134
+ method: 'POST',
1135
+ path: '/2020-05-31/origin-access-control',
1136
+ body,
1137
+ headers: {
1138
+ 'Content-Type': 'application/xml',
1139
+ },
1140
+ returnHeaders: true,
1141
+ })
1142
+
1143
+ const oac = result.body?.OriginAccessControl || result.OriginAccessControl || result.body || result
1144
+
1145
+ return {
1146
+ Id: oac.Id,
1147
+ Name: oac.OriginAccessControlConfig?.Name || name,
1148
+ Description: oac.OriginAccessControlConfig?.Description || description,
1149
+ SigningProtocol: oac.OriginAccessControlConfig?.SigningProtocol || signingProtocol,
1150
+ SigningBehavior: oac.OriginAccessControlConfig?.SigningBehavior || signingBehavior,
1151
+ OriginAccessControlOriginType: oac.OriginAccessControlConfig?.OriginAccessControlOriginType || originType,
1152
+ ETag: result.headers?.etag || result.ETag || '',
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Find or create an Origin Access Control
1158
+ */
1159
+ async findOrCreateOriginAccessControl(name: string): Promise<{
1160
+ Id: string
1161
+ Name: string
1162
+ isNew: boolean
1163
+ }> {
1164
+ const oacs = await this.listOriginAccessControls()
1165
+ const existing = oacs.find(oac => oac.Name === name)
1166
+
1167
+ if (existing) {
1168
+ return { Id: existing.Id, Name: existing.Name, isNew: false }
1169
+ }
1170
+
1171
+ const created = await this.createOriginAccessControl({ name })
1172
+ return { Id: created.Id, Name: created.Name, isNew: true }
1173
+ }
1174
+
1175
+ /**
1176
+ * Create a CloudFront distribution for a static S3 website
1177
+ */
1178
+ async createDistributionForS3(options: {
1179
+ bucketName: string
1180
+ bucketRegion: string
1181
+ originAccessControlId: string
1182
+ aliases?: string[]
1183
+ certificateArn?: string
1184
+ defaultRootObject?: string
1185
+ comment?: string
1186
+ priceClass?: 'PriceClass_100' | 'PriceClass_200' | 'PriceClass_All'
1187
+ enabled?: boolean
1188
+ }): Promise<{
1189
+ Id: string
1190
+ ARN: string
1191
+ DomainName: string
1192
+ Status: string
1193
+ ETag: string
1194
+ }> {
1195
+ const {
1196
+ bucketName,
1197
+ bucketRegion,
1198
+ originAccessControlId,
1199
+ aliases = [],
1200
+ certificateArn,
1201
+ defaultRootObject = 'index.html',
1202
+ comment = `Distribution for ${bucketName}`,
1203
+ priceClass = 'PriceClass_100',
1204
+ enabled = true,
1205
+ } = options
1206
+
1207
+ const originId = `S3-${bucketName}`
1208
+ const s3DomainName = `${bucketName}.s3.${bucketRegion}.amazonaws.com`
1209
+ const callerReference = `${Date.now()}-${Math.random().toString(36).slice(2)}`
1210
+
1211
+ // Build aliases XML
1212
+ let aliasesXml = '<Aliases><Quantity>0</Quantity></Aliases>'
1213
+ if (aliases.length > 0) {
1214
+ aliasesXml = `<Aliases>
1215
+ <Quantity>${aliases.length}</Quantity>
1216
+ <Items>
1217
+ ${aliases.map(a => `<CNAME>${a}</CNAME>`).join('\n ')}
1218
+ </Items>
1219
+ </Aliases>`
1220
+ }
1221
+
1222
+ // Build viewer certificate XML
1223
+ let viewerCertificateXml = `<ViewerCertificate>
1224
+ <CloudFrontDefaultCertificate>true</CloudFrontDefaultCertificate>
1225
+ </ViewerCertificate>`
1226
+
1227
+ if (certificateArn && aliases.length > 0) {
1228
+ viewerCertificateXml = `<ViewerCertificate>
1229
+ <ACMCertificateArn>${certificateArn}</ACMCertificateArn>
1230
+ <SSLSupportMethod>sni-only</SSLSupportMethod>
1231
+ <MinimumProtocolVersion>TLSv1.2_2021</MinimumProtocolVersion>
1232
+ <CertificateSource>acm</CertificateSource>
1233
+ </ViewerCertificate>`
1234
+ }
1235
+
1236
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1237
+ <DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
1238
+ <CallerReference>${callerReference}</CallerReference>
1239
+ <Comment>${comment}</Comment>
1240
+ <DefaultRootObject>${defaultRootObject}</DefaultRootObject>
1241
+ <Origins>
1242
+ <Quantity>1</Quantity>
1243
+ <Items>
1244
+ <Origin>
1245
+ <Id>${originId}</Id>
1246
+ <DomainName>${s3DomainName}</DomainName>
1247
+ <OriginPath></OriginPath>
1248
+ <S3OriginConfig>
1249
+ <OriginAccessIdentity></OriginAccessIdentity>
1250
+ </S3OriginConfig>
1251
+ <OriginAccessControlId>${originAccessControlId}</OriginAccessControlId>
1252
+ </Origin>
1253
+ </Items>
1254
+ </Origins>
1255
+ <DefaultCacheBehavior>
1256
+ <TargetOriginId>${originId}</TargetOriginId>
1257
+ <ViewerProtocolPolicy>redirect-to-https</ViewerProtocolPolicy>
1258
+ <AllowedMethods>
1259
+ <Quantity>2</Quantity>
1260
+ <Items>
1261
+ <Method>GET</Method>
1262
+ <Method>HEAD</Method>
1263
+ </Items>
1264
+ <CachedMethods>
1265
+ <Quantity>2</Quantity>
1266
+ <Items>
1267
+ <Method>GET</Method>
1268
+ <Method>HEAD</Method>
1269
+ </Items>
1270
+ </CachedMethods>
1271
+ </AllowedMethods>
1272
+ <Compress>true</Compress>
1273
+ <CachePolicyId>658327ea-f89d-4fab-a63d-7e88639e58f6</CachePolicyId>
1274
+ </DefaultCacheBehavior>
1275
+ ${aliasesXml}
1276
+ ${viewerCertificateXml}
1277
+ <PriceClass>${priceClass}</PriceClass>
1278
+ <Enabled>${enabled}</Enabled>
1279
+ <HttpVersion>http2and3</HttpVersion>
1280
+ <IsIPV6Enabled>true</IsIPV6Enabled>
1281
+ <CustomErrorResponses>
1282
+ <Quantity>1</Quantity>
1283
+ <Items>
1284
+ <CustomErrorResponse>
1285
+ <ErrorCode>403</ErrorCode>
1286
+ <ResponsePagePath>/index.html</ResponsePagePath>
1287
+ <ResponseCode>200</ResponseCode>
1288
+ <ErrorCachingMinTTL>300</ErrorCachingMinTTL>
1289
+ </CustomErrorResponse>
1290
+ </Items>
1291
+ </CustomErrorResponses>
1292
+ </DistributionConfig>`
1293
+
1294
+ const result = await this.client.request({
1295
+ service: 'cloudfront',
1296
+ region: 'us-east-1',
1297
+ method: 'POST',
1298
+ path: '/2020-05-31/distribution',
1299
+ body,
1300
+ headers: {
1301
+ 'Content-Type': 'application/xml',
1302
+ },
1303
+ returnHeaders: true,
1304
+ })
1305
+
1306
+ const dist = result.body?.Distribution || result.Distribution || result.body || result
1307
+
1308
+ return {
1309
+ Id: dist.Id,
1310
+ ARN: dist.ARN,
1311
+ DomainName: dist.DomainName,
1312
+ Status: dist.Status,
1313
+ ETag: result.headers?.etag || result.ETag || '',
1314
+ }
1315
+ }
1316
+
1317
+ /**
1318
+ * Get S3 bucket policy for CloudFront OAC access
1319
+ */
1320
+ static getS3BucketPolicyForCloudFront(bucketName: string, distributionArn: string): object {
1321
+ return {
1322
+ Version: '2012-10-17',
1323
+ Statement: [
1324
+ {
1325
+ Sid: 'AllowCloudFrontServicePrincipal',
1326
+ Effect: 'Allow',
1327
+ Principal: {
1328
+ Service: 'cloudfront.amazonaws.com',
1329
+ },
1330
+ Action: 's3:GetObject',
1331
+ Resource: `arn:aws:s3:::${bucketName}/*`,
1332
+ Condition: {
1333
+ StringEquals: {
1334
+ 'AWS:SourceArn': distributionArn,
1335
+ },
1336
+ },
1337
+ },
1338
+ ],
1339
+ }
1340
+ }
1341
+
1342
+ /**
1343
+ * Wait for distribution to be deployed
1344
+ */
1345
+ async waitForDistributionDeployed(distributionId: string, maxAttempts = 60): Promise<boolean> {
1346
+ for (let i = 0; i < maxAttempts; i++) {
1347
+ const dist = await this.getDistribution(distributionId)
1348
+
1349
+ if (dist.Status === 'Deployed') {
1350
+ return true
1351
+ }
1352
+
1353
+ // Wait 30 seconds between checks
1354
+ await new Promise(resolve => setTimeout(resolve, 30000))
1355
+ }
1356
+
1357
+ return false
1358
+ }
1359
+
1360
+ /**
1361
+ * Disable a CloudFront distribution
1362
+ * Must be disabled before it can be deleted
1363
+ */
1364
+ async disableDistribution(distributionId: string): Promise<{ ETag: string }> {
1365
+ // Get current config with ETag
1366
+ const getResult = await this.client.request({
1367
+ service: 'cloudfront',
1368
+ region: 'us-east-1',
1369
+ method: 'GET',
1370
+ path: `/2020-05-31/distribution/${distributionId}/config`,
1371
+ returnHeaders: true,
1372
+ })
1373
+
1374
+ const etag = getResult.headers?.etag || getResult.headers?.ETag || ''
1375
+ const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig
1376
+
1377
+ if (!currentConfig) {
1378
+ throw new Error('Failed to get current distribution config')
1379
+ }
1380
+
1381
+ // Set enabled to false
1382
+ currentConfig.Enabled = false
1383
+
1384
+ // Build the XML for the update request
1385
+ const configXml = this.buildDistributionConfigXml(currentConfig)
1386
+
1387
+ // Update the distribution
1388
+ const result = await this.client.request({
1389
+ service: 'cloudfront',
1390
+ region: 'us-east-1',
1391
+ method: 'PUT',
1392
+ path: `/2020-05-31/distribution/${distributionId}/config`,
1393
+ body: configXml,
1394
+ headers: {
1395
+ 'Content-Type': 'application/xml',
1396
+ 'If-Match': etag,
1397
+ },
1398
+ returnHeaders: true,
1399
+ })
1400
+
1401
+ return { ETag: result.headers?.etag || result.headers?.ETag || result.ETag || '' }
1402
+ }
1403
+
1404
+ /**
1405
+ * Delete a CloudFront distribution
1406
+ * Distribution must be disabled first
1407
+ */
1408
+ async deleteDistribution(distributionId: string, etag?: string): Promise<void> {
1409
+ // If no ETag provided, get it first
1410
+ let etagToUse = etag || ''
1411
+ if (!etagToUse) {
1412
+ const getResult = await this.client.request({
1413
+ service: 'cloudfront',
1414
+ region: 'us-east-1',
1415
+ method: 'GET',
1416
+ path: `/2020-05-31/distribution/${distributionId}`,
1417
+ returnHeaders: true,
1418
+ })
1419
+ etagToUse = getResult.headers?.etag || getResult.headers?.ETag || ''
1420
+ }
1421
+
1422
+ await this.client.request({
1423
+ service: 'cloudfront',
1424
+ region: 'us-east-1',
1425
+ method: 'DELETE',
1426
+ path: `/2020-05-31/distribution/${distributionId}`,
1427
+ headers: {
1428
+ 'If-Match': etagToUse,
1429
+ },
1430
+ })
1431
+ }
1432
+
1433
+ /**
1434
+ * Wait for distribution to be disabled (ready for deletion)
1435
+ */
1436
+ async waitForDistributionDisabled(distributionId: string, maxAttempts = 60): Promise<boolean> {
1437
+ for (let i = 0; i < maxAttempts; i++) {
1438
+ const dist = await this.getDistribution(distributionId)
1439
+
1440
+ if (dist.Status === 'Deployed' && !dist.Enabled) {
1441
+ return true
1442
+ }
1443
+
1444
+ // Wait 30 seconds between checks
1445
+ await new Promise(resolve => setTimeout(resolve, 30000))
1446
+ }
1447
+
1448
+ return false
1449
+ }
1450
+
1451
+ /**
1452
+ * Remove a specific alias (CNAME) from a CloudFront distribution
1453
+ * This allows the alias to be used by another distribution
1454
+ */
1455
+ async removeAlias(distributionId: string, alias: string): Promise<{ ETag: string }> {
1456
+ // Get current config with ETag
1457
+ const getResult = await this.client.request({
1458
+ service: 'cloudfront',
1459
+ region: 'us-east-1',
1460
+ method: 'GET',
1461
+ path: `/2020-05-31/distribution/${distributionId}/config`,
1462
+ returnHeaders: true,
1463
+ })
1464
+
1465
+ const etag = getResult.headers?.etag || getResult.headers?.ETag || ''
1466
+ const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig
1467
+
1468
+ if (!currentConfig) {
1469
+ throw new Error('Failed to get current distribution config')
1470
+ }
1471
+
1472
+ // Remove the alias from the Aliases list
1473
+ // Handle various structures: Items can be an array, or Items.CNAME can be a string or array
1474
+ let items: string[] = []
1475
+ if (currentConfig.Aliases?.Items) {
1476
+ if (Array.isArray(currentConfig.Aliases.Items)) {
1477
+ items = currentConfig.Aliases.Items
1478
+ }
1479
+ else if (typeof currentConfig.Aliases.Items === 'object') {
1480
+ const cname = currentConfig.Aliases.Items.CNAME
1481
+ if (typeof cname === 'string') {
1482
+ items = [cname]
1483
+ }
1484
+ else if (Array.isArray(cname)) {
1485
+ items = cname
1486
+ }
1487
+ }
1488
+ }
1489
+
1490
+ if (items.length === 0) {
1491
+ throw new Error(`Distribution has no aliases to remove`)
1492
+ }
1493
+
1494
+ const newItems = items.filter((a: string) => a !== alias)
1495
+
1496
+ if (newItems.length === items.length) {
1497
+ throw new Error(`Alias ${alias} not found in distribution`)
1498
+ }
1499
+
1500
+ currentConfig.Aliases.Quantity = newItems.length
1501
+ // CloudFront expects Items to be an array, not Items.CNAME
1502
+ currentConfig.Aliases.Items = newItems.length > 0 ? newItems : undefined
1503
+
1504
+ // If removing the last alias, we need to also remove the ViewerCertificate ACM config
1505
+ if (newItems.length === 0) {
1506
+ currentConfig.ViewerCertificate = {
1507
+ CloudFrontDefaultCertificate: true,
1508
+ MinimumProtocolVersion: 'TLSv1.2_2021',
1509
+ }
1510
+ }
1511
+
1512
+ // Build the XML for the update request
1513
+ const configXml = this.buildDistributionConfigXml(currentConfig)
1514
+
1515
+ // Update the distribution
1516
+ const result = await this.client.request({
1517
+ service: 'cloudfront',
1518
+ region: 'us-east-1',
1519
+ method: 'PUT',
1520
+ path: `/2020-05-31/distribution/${distributionId}/config`,
1521
+ body: configXml,
1522
+ headers: {
1523
+ 'Content-Type': 'application/xml',
1524
+ 'If-Match': etag,
1525
+ },
1526
+ returnHeaders: true,
1527
+ })
1528
+
1529
+ return { ETag: result.headers?.etag || result.headers?.ETag || result.ETag || '' }
1530
+ }
1531
+ }