@stacksjs/ts-cloud 0.1.1

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 (117) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +321 -0
  3. package/bin/cli.ts +133 -0
  4. package/bin/commands/analytics.ts +328 -0
  5. package/bin/commands/api.ts +379 -0
  6. package/bin/commands/assets.ts +221 -0
  7. package/bin/commands/audit.ts +501 -0
  8. package/bin/commands/backup.ts +682 -0
  9. package/bin/commands/cache.ts +294 -0
  10. package/bin/commands/cdn.ts +281 -0
  11. package/bin/commands/config.ts +202 -0
  12. package/bin/commands/container.ts +105 -0
  13. package/bin/commands/cost.ts +208 -0
  14. package/bin/commands/database.ts +401 -0
  15. package/bin/commands/deploy.ts +674 -0
  16. package/bin/commands/domain.ts +397 -0
  17. package/bin/commands/email.ts +423 -0
  18. package/bin/commands/environment.ts +285 -0
  19. package/bin/commands/events.ts +424 -0
  20. package/bin/commands/firewall.ts +145 -0
  21. package/bin/commands/function.ts +116 -0
  22. package/bin/commands/generate.ts +280 -0
  23. package/bin/commands/git.ts +139 -0
  24. package/bin/commands/iam.ts +464 -0
  25. package/bin/commands/index.ts +48 -0
  26. package/bin/commands/init.ts +120 -0
  27. package/bin/commands/logs.ts +148 -0
  28. package/bin/commands/network.ts +579 -0
  29. package/bin/commands/notify.ts +489 -0
  30. package/bin/commands/queue.ts +407 -0
  31. package/bin/commands/scheduler.ts +370 -0
  32. package/bin/commands/secrets.ts +54 -0
  33. package/bin/commands/server.ts +629 -0
  34. package/bin/commands/shared.ts +97 -0
  35. package/bin/commands/ssl.ts +138 -0
  36. package/bin/commands/stack.ts +325 -0
  37. package/bin/commands/status.ts +385 -0
  38. package/bin/commands/storage.ts +450 -0
  39. package/bin/commands/team.ts +96 -0
  40. package/bin/commands/tunnel.ts +489 -0
  41. package/bin/commands/utils.ts +202 -0
  42. package/build.ts +15 -0
  43. package/cloud +2 -0
  44. package/package.json +99 -0
  45. package/src/aws/acm.ts +768 -0
  46. package/src/aws/application-autoscaling.ts +845 -0
  47. package/src/aws/bedrock.ts +4074 -0
  48. package/src/aws/client.ts +878 -0
  49. package/src/aws/cloudformation.ts +896 -0
  50. package/src/aws/cloudfront.ts +1531 -0
  51. package/src/aws/cloudwatch-logs.ts +154 -0
  52. package/src/aws/comprehend.ts +839 -0
  53. package/src/aws/connect.ts +1056 -0
  54. package/src/aws/deploy-imap.ts +384 -0
  55. package/src/aws/dynamodb.ts +340 -0
  56. package/src/aws/ec2.ts +1385 -0
  57. package/src/aws/ecr.ts +621 -0
  58. package/src/aws/ecs.ts +615 -0
  59. package/src/aws/elasticache.ts +301 -0
  60. package/src/aws/elbv2.ts +942 -0
  61. package/src/aws/email.ts +928 -0
  62. package/src/aws/eventbridge.ts +248 -0
  63. package/src/aws/iam.ts +1689 -0
  64. package/src/aws/imap-server.ts +2100 -0
  65. package/src/aws/index.ts +213 -0
  66. package/src/aws/kendra.ts +1097 -0
  67. package/src/aws/lambda.ts +786 -0
  68. package/src/aws/opensearch.ts +158 -0
  69. package/src/aws/personalize.ts +977 -0
  70. package/src/aws/polly.ts +559 -0
  71. package/src/aws/rds.ts +888 -0
  72. package/src/aws/rekognition.ts +846 -0
  73. package/src/aws/route53-domains.ts +359 -0
  74. package/src/aws/route53.ts +1046 -0
  75. package/src/aws/s3.ts +2318 -0
  76. package/src/aws/scheduler.ts +571 -0
  77. package/src/aws/secrets-manager.ts +769 -0
  78. package/src/aws/ses.ts +1081 -0
  79. package/src/aws/setup-phone.ts +104 -0
  80. package/src/aws/setup-sms.ts +580 -0
  81. package/src/aws/sms.ts +1735 -0
  82. package/src/aws/smtp-server.ts +531 -0
  83. package/src/aws/sns.ts +758 -0
  84. package/src/aws/sqs.ts +382 -0
  85. package/src/aws/ssm.ts +807 -0
  86. package/src/aws/sts.ts +92 -0
  87. package/src/aws/support.ts +391 -0
  88. package/src/aws/test-imap.ts +86 -0
  89. package/src/aws/textract.ts +780 -0
  90. package/src/aws/transcribe.ts +108 -0
  91. package/src/aws/translate.ts +641 -0
  92. package/src/aws/voice.ts +1379 -0
  93. package/src/config.ts +35 -0
  94. package/src/deploy/index.ts +7 -0
  95. package/src/deploy/static-site-external-dns.ts +906 -0
  96. package/src/deploy/static-site.ts +1125 -0
  97. package/src/dns/godaddy.ts +412 -0
  98. package/src/dns/index.ts +183 -0
  99. package/src/dns/porkbun.ts +362 -0
  100. package/src/dns/route53-adapter.ts +414 -0
  101. package/src/dns/types.ts +114 -0
  102. package/src/dns/validator.ts +369 -0
  103. package/src/generators/index.ts +5 -0
  104. package/src/generators/infrastructure.ts +1660 -0
  105. package/src/index.ts +163 -0
  106. package/src/push/apns.ts +452 -0
  107. package/src/push/fcm.ts +506 -0
  108. package/src/push/index.ts +58 -0
  109. package/src/ssl/acme-client.ts +478 -0
  110. package/src/ssl/index.ts +7 -0
  111. package/src/ssl/letsencrypt.ts +747 -0
  112. package/src/types.ts +2 -0
  113. package/src/utils/cli.ts +398 -0
  114. package/src/validation/index.ts +5 -0
  115. package/src/validation/template.ts +405 -0
  116. package/test/index.test.ts +128 -0
  117. package/tsconfig.json +18 -0
@@ -0,0 +1,1125 @@
1
+ /**
2
+ * Static Site Deployment Module
3
+ * Deploys static sites to AWS using CloudFormation (S3 + CloudFront + Route53/External DNS + ACM)
4
+ */
5
+
6
+ import { CloudFormationClient } from '../aws/cloudformation'
7
+ import { S3Client } from '../aws/s3'
8
+ import { CloudFrontClient } from '../aws/cloudfront'
9
+ import { Route53Client } from '../aws/route53'
10
+ import { ACMClient, ACMDnsValidator } from '../aws/acm'
11
+ import type { DnsProviderConfig } from '../dns/types'
12
+ import { deployStaticSiteWithExternalDns, deployStaticSiteWithExternalDnsFull } from './static-site-external-dns'
13
+
14
+ export interface StaticSiteConfig {
15
+ /** Site name used for resource naming */
16
+ siteName: string
17
+ /** AWS region for S3 bucket */
18
+ region?: string
19
+ /** Custom domain (e.g., docs.example.com) */
20
+ domain?: string
21
+ /** Subdomain part (e.g., 'docs') - used with baseDomain */
22
+ subdomain?: string
23
+ /** Base domain (e.g., 'example.com') - must have Route53 hosted zone */
24
+ baseDomain?: string
25
+ /** S3 bucket name (auto-generated if not provided) */
26
+ bucket?: string
27
+ /** Route53 hosted zone ID (auto-detected if not provided) */
28
+ hostedZoneId?: string
29
+ /** ACM certificate ARN (auto-created if not provided) */
30
+ certificateArn?: string
31
+ /** CloudFormation stack name */
32
+ stackName?: string
33
+ /** Default root object */
34
+ defaultRootObject?: string
35
+ /** Error document */
36
+ errorDocument?: string
37
+ /** Cache control for assets */
38
+ cacheControl?: string
39
+ /** Tags to apply to resources */
40
+ tags?: Record<string, string>
41
+ /**
42
+ * External DNS provider configuration (optional)
43
+ * When provided, DNS records will be managed via the specified provider (Porkbun, GoDaddy, etc.)
44
+ * instead of Route53. Useful when your domain is registered outside AWS.
45
+ */
46
+ dnsProvider?: DnsProviderConfig
47
+ }
48
+
49
+ export interface DeployResult {
50
+ success: boolean
51
+ stackId?: string
52
+ stackName: string
53
+ bucket: string
54
+ distributionId?: string
55
+ distributionDomain?: string
56
+ domain?: string
57
+ certificateArn?: string
58
+ message: string
59
+ }
60
+
61
+ export interface UploadOptions {
62
+ /** Local directory containing built files */
63
+ sourceDir: string
64
+ /** S3 bucket name */
65
+ bucket: string
66
+ /** AWS region */
67
+ region: string
68
+ /** Cache control header */
69
+ cacheControl?: string
70
+ /** Callback for progress updates */
71
+ onProgress?: (uploaded: number, total: number, file: string) => void
72
+ }
73
+
74
+ /**
75
+ * Generate CloudFormation template for static site infrastructure
76
+ */
77
+ export function generateStaticSiteTemplate(config: {
78
+ bucketName: string
79
+ domain?: string
80
+ certificateArn?: string
81
+ hostedZoneId?: string
82
+ defaultRootObject?: string
83
+ errorDocument?: string
84
+ }): object {
85
+ const {
86
+ bucketName,
87
+ domain,
88
+ certificateArn,
89
+ hostedZoneId,
90
+ defaultRootObject = 'index.html',
91
+ errorDocument = '404.html',
92
+ } = config
93
+
94
+ const resources: Record<string, any> = {}
95
+ const outputs: Record<string, any> = {}
96
+
97
+ // S3 Bucket
98
+ resources.S3Bucket = {
99
+ Type: 'AWS::S3::Bucket',
100
+ Properties: {
101
+ BucketName: bucketName,
102
+ PublicAccessBlockConfiguration: {
103
+ BlockPublicAcls: true,
104
+ BlockPublicPolicy: false,
105
+ IgnorePublicAcls: true,
106
+ RestrictPublicBuckets: false,
107
+ },
108
+ WebsiteConfiguration: {
109
+ IndexDocument: defaultRootObject,
110
+ ErrorDocument: errorDocument,
111
+ },
112
+ },
113
+ }
114
+
115
+ outputs.BucketName = {
116
+ Description: 'S3 Bucket Name',
117
+ Value: { Ref: 'S3Bucket' },
118
+ }
119
+
120
+ outputs.BucketArn = {
121
+ Description: 'S3 Bucket ARN',
122
+ Value: { 'Fn::GetAtt': ['S3Bucket', 'Arn'] },
123
+ }
124
+
125
+ // Origin Access Control
126
+ resources.CloudFrontOAC = {
127
+ Type: 'AWS::CloudFront::OriginAccessControl',
128
+ Properties: {
129
+ OriginAccessControlConfig: {
130
+ Name: `OAC-${bucketName}`,
131
+ Description: `OAC for ${bucketName}`,
132
+ OriginAccessControlOriginType: 's3',
133
+ SigningBehavior: 'always',
134
+ SigningProtocol: 'sigv4',
135
+ },
136
+ },
137
+ }
138
+
139
+ // CloudFront Function for URL rewriting (append .html to URLs without extensions)
140
+ resources.UrlRewriteFunction = {
141
+ Type: 'AWS::CloudFront::Function',
142
+ Properties: {
143
+ Name: { 'Fn::Sub': '${AWS::StackName}-url-rewrite' },
144
+ AutoPublish: true,
145
+ FunctionConfig: {
146
+ Comment: 'Append .html extension to URLs without extensions',
147
+ Runtime: 'cloudfront-js-2.0',
148
+ },
149
+ FunctionCode: `function handler(event) {
150
+ var request = event.request;
151
+ var uri = request.uri;
152
+
153
+ // If URI ends with /, serve index.html
154
+ if (uri.endsWith('/')) {
155
+ request.uri = uri + 'index.html';
156
+ }
157
+ // If URI doesn't have an extension, append .html
158
+ else if (!uri.includes('.')) {
159
+ request.uri = uri + '.html';
160
+ }
161
+
162
+ return request;
163
+ }`,
164
+ },
165
+ }
166
+
167
+ // CloudFront Distribution
168
+ const distributionConfig: any = {
169
+ Enabled: true,
170
+ DefaultRootObject: defaultRootObject,
171
+ HttpVersion: 'http2and3',
172
+ IPV6Enabled: true,
173
+ PriceClass: 'PriceClass_100',
174
+ Origins: [
175
+ {
176
+ Id: `S3-${bucketName}`,
177
+ DomainName: { 'Fn::GetAtt': ['S3Bucket', 'RegionalDomainName'] },
178
+ S3OriginConfig: {
179
+ OriginAccessIdentity: '',
180
+ },
181
+ OriginAccessControlId: { 'Fn::GetAtt': ['CloudFrontOAC', 'Id'] },
182
+ },
183
+ ],
184
+ DefaultCacheBehavior: {
185
+ TargetOriginId: `S3-${bucketName}`,
186
+ ViewerProtocolPolicy: 'redirect-to-https',
187
+ AllowedMethods: ['GET', 'HEAD'],
188
+ CachedMethods: ['GET', 'HEAD'],
189
+ Compress: true,
190
+ CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', // Managed-CachingOptimized
191
+ FunctionAssociations: [
192
+ {
193
+ EventType: 'viewer-request',
194
+ FunctionARN: { 'Fn::GetAtt': ['UrlRewriteFunction', 'FunctionARN'] },
195
+ },
196
+ ],
197
+ },
198
+ CustomErrorResponses: [
199
+ {
200
+ ErrorCode: 403,
201
+ ResponseCode: 200,
202
+ ResponsePagePath: `/${defaultRootObject}`,
203
+ ErrorCachingMinTTL: 300,
204
+ },
205
+ {
206
+ ErrorCode: 404,
207
+ ResponseCode: 404,
208
+ ResponsePagePath: `/${errorDocument}`,
209
+ ErrorCachingMinTTL: 300,
210
+ },
211
+ ],
212
+ }
213
+
214
+ // Add custom domain configuration if provided
215
+ if (domain && certificateArn) {
216
+ distributionConfig.Aliases = [domain]
217
+ distributionConfig.ViewerCertificate = {
218
+ AcmCertificateArn: certificateArn,
219
+ SslSupportMethod: 'sni-only',
220
+ MinimumProtocolVersion: 'TLSv1.2_2021',
221
+ }
222
+ }
223
+ else {
224
+ distributionConfig.ViewerCertificate = {
225
+ CloudFrontDefaultCertificate: true,
226
+ }
227
+ }
228
+
229
+ resources.CloudFrontDistribution = {
230
+ Type: 'AWS::CloudFront::Distribution',
231
+ DependsOn: ['S3Bucket', 'CloudFrontOAC', 'UrlRewriteFunction'],
232
+ Properties: {
233
+ DistributionConfig: distributionConfig,
234
+ },
235
+ }
236
+
237
+ outputs.DistributionId = {
238
+ Description: 'CloudFront Distribution ID',
239
+ Value: { Ref: 'CloudFrontDistribution' },
240
+ }
241
+
242
+ outputs.DistributionDomain = {
243
+ Description: 'CloudFront Distribution Domain',
244
+ Value: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
245
+ }
246
+
247
+ // S3 Bucket Policy for CloudFront OAC
248
+ resources.S3BucketPolicy = {
249
+ Type: 'AWS::S3::BucketPolicy',
250
+ DependsOn: ['S3Bucket', 'CloudFrontDistribution'],
251
+ Properties: {
252
+ Bucket: { Ref: 'S3Bucket' },
253
+ PolicyDocument: {
254
+ Version: '2012-10-17',
255
+ Statement: [
256
+ {
257
+ Sid: 'AllowCloudFrontServicePrincipal',
258
+ Effect: 'Allow',
259
+ Principal: {
260
+ Service: 'cloudfront.amazonaws.com',
261
+ },
262
+ Action: 's3:GetObject',
263
+ Resource: { 'Fn::Sub': 'arn:aws:s3:::${S3Bucket}/*' },
264
+ Condition: {
265
+ StringEquals: {
266
+ 'AWS:SourceArn': {
267
+ 'Fn::Sub': 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}',
268
+ },
269
+ },
270
+ },
271
+ },
272
+ ],
273
+ },
274
+ },
275
+ }
276
+
277
+ // Route53 DNS Record if domain and hosted zone provided
278
+ if (domain && hostedZoneId) {
279
+ resources.DNSRecord = {
280
+ Type: 'AWS::Route53::RecordSet',
281
+ DependsOn: 'CloudFrontDistribution',
282
+ Properties: {
283
+ HostedZoneId: hostedZoneId,
284
+ Name: domain,
285
+ Type: 'A',
286
+ AliasTarget: {
287
+ DNSName: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
288
+ HostedZoneId: 'Z2FDTNDATAQYW2', // CloudFront hosted zone ID (global)
289
+ EvaluateTargetHealth: false,
290
+ },
291
+ },
292
+ }
293
+
294
+ // Also create AAAA record for IPv6
295
+ resources.DNSRecordIPv6 = {
296
+ Type: 'AWS::Route53::RecordSet',
297
+ DependsOn: 'CloudFrontDistribution',
298
+ Properties: {
299
+ HostedZoneId: hostedZoneId,
300
+ Name: domain,
301
+ Type: 'AAAA',
302
+ AliasTarget: {
303
+ DNSName: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
304
+ HostedZoneId: 'Z2FDTNDATAQYW2',
305
+ EvaluateTargetHealth: false,
306
+ },
307
+ },
308
+ }
309
+
310
+ outputs.SiteUrl = {
311
+ Description: 'Site URL',
312
+ Value: { 'Fn::Sub': 'https://${DNSRecord}' },
313
+ }
314
+ }
315
+
316
+ return {
317
+ AWSTemplateFormatVersion: '2010-09-09',
318
+ Description: `Static site infrastructure for ${domain || bucketName}`,
319
+ Resources: resources,
320
+ Outputs: outputs,
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Deploy a static site to AWS
326
+ * Automatically routes to external DNS deployment when a non-Route53 dnsProvider is configured
327
+ */
328
+ export async function deployStaticSite(config: StaticSiteConfig): Promise<DeployResult> {
329
+ // If using external DNS provider (not Route53), delegate to the external DNS deployment
330
+ if (config.dnsProvider && config.dnsProvider.provider !== 'route53') {
331
+ const domain = config.domain || (config.subdomain && config.baseDomain ? `${config.subdomain}.${config.baseDomain}` : undefined)
332
+ if (!domain) {
333
+ return {
334
+ success: false,
335
+ stackName: config.stackName || `${config.siteName}-static-site`,
336
+ bucket: config.bucket || `${config.siteName}-${Date.now()}`,
337
+ message: 'Domain is required when using external DNS provider',
338
+ }
339
+ }
340
+
341
+ return deployStaticSiteWithExternalDns({
342
+ siteName: config.siteName,
343
+ region: config.region,
344
+ domain,
345
+ bucket: config.bucket,
346
+ certificateArn: config.certificateArn,
347
+ stackName: config.stackName,
348
+ defaultRootObject: config.defaultRootObject,
349
+ errorDocument: config.errorDocument,
350
+ cacheControl: config.cacheControl,
351
+ tags: config.tags,
352
+ dnsProvider: config.dnsProvider,
353
+ })
354
+ }
355
+
356
+ const region = config.region || 'us-east-1'
357
+ const cfRegion = 'us-east-1' // CloudFormation for global resources must be in us-east-1
358
+
359
+ // Determine full domain
360
+ let domain: string | undefined
361
+ if (config.domain) {
362
+ domain = config.domain
363
+ }
364
+ else if (config.subdomain && config.baseDomain) {
365
+ domain = `${config.subdomain}.${config.baseDomain}`
366
+ }
367
+
368
+ // Generate bucket name if not provided
369
+ const bucket = config.bucket || (domain ? domain.replace(/\./g, '-') : `${config.siteName}-${Date.now()}`)
370
+
371
+ // Generate stack name
372
+ const stackName = config.stackName || `${config.siteName}-static-site`
373
+
374
+ // Initialize clients
375
+ const cf = new CloudFormationClient(cfRegion)
376
+ const route53 = new Route53Client()
377
+ const acm = new ACMClient('us-east-1') // ACM certs for CloudFront must be in us-east-1
378
+ const acmValidator = new ACMDnsValidator('us-east-1')
379
+
380
+ let hostedZoneId = config.hostedZoneId
381
+ let certificateArn = config.certificateArn
382
+
383
+ // Auto-detect hosted zone if domain is specified
384
+ if (domain && !hostedZoneId) {
385
+ const zone = await route53.findHostedZoneForDomain(domain)
386
+ if (zone) {
387
+ hostedZoneId = zone.Id.replace('/hostedzone/', '')
388
+ }
389
+ else {
390
+ return {
391
+ success: false,
392
+ stackName,
393
+ bucket,
394
+ message: `No Route53 hosted zone found for ${config.baseDomain || domain}. Please create one first.`,
395
+ }
396
+ }
397
+ }
398
+
399
+ // Auto-create SSL certificate if domain is specified
400
+ if (domain && !certificateArn && hostedZoneId) {
401
+ // Check for existing certificate
402
+ const existingCert = await acm.findCertificateByDomain(domain)
403
+ if (existingCert && existingCert.Status === 'ISSUED') {
404
+ certificateArn = existingCert.CertificateArn
405
+ }
406
+ else {
407
+ // Request and validate new certificate
408
+ const certResult = await acmValidator.requestAndValidate({
409
+ domainName: domain,
410
+ hostedZoneId,
411
+ waitForValidation: true,
412
+ maxWaitMinutes: 10,
413
+ })
414
+ certificateArn = certResult.certificateArn
415
+ }
416
+ }
417
+
418
+ // Check if stack already exists
419
+ let stackExists = false
420
+ let existingBucketName: string | undefined
421
+ try {
422
+ const existingStacks = await cf.describeStacks({ stackName })
423
+ if (existingStacks.Stacks.length > 0) {
424
+ const stack = existingStacks.Stacks[0]
425
+ const stackStatus = stack.StackStatus
426
+
427
+ // If stack is being deleted, wait for it to complete
428
+ if (stackStatus === 'DELETE_IN_PROGRESS') {
429
+ console.log('Previous stack is still being deleted, waiting...')
430
+ await cf.waitForStack(stackName, 'stack-delete-complete')
431
+ stackExists = false
432
+ }
433
+ else if (stackStatus === 'DELETE_COMPLETE') {
434
+ stackExists = false
435
+ }
436
+ else {
437
+ stackExists = true
438
+ // Get existing bucket name from stack outputs to ensure consistency during updates
439
+ const outputs = stack.Outputs || []
440
+ existingBucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
441
+ }
442
+ }
443
+ }
444
+ catch (err: any) {
445
+ // Stack doesn't exist - this is expected for new deployments
446
+ if (err.message?.includes('does not exist') || err.code === 'ValidationError') {
447
+ stackExists = false
448
+ }
449
+ else {
450
+ throw err
451
+ }
452
+ }
453
+
454
+ // If stack doesn't exist, check for orphaned resources and clean them up
455
+ // Use a unique bucket name suffix if cleanup fails
456
+ // If stack exists, use the existing bucket name to avoid CloudFormation trying to recreate resources
457
+ let finalBucket = existingBucketName || bucket
458
+ if (!stackExists) {
459
+ const s3 = new S3Client(region)
460
+ const cloudfront = new CloudFrontClient()
461
+
462
+ // Check if S3 bucket exists (orphaned from previous non-CloudFormation deployment)
463
+ let bucketCleanedUp = false
464
+ try {
465
+ const headResult = await s3.headBucket(bucket)
466
+ if (headResult.exists) {
467
+ // Bucket exists without a stack - try to clean it up with timeout
468
+ console.log(`Found orphaned S3 bucket ${bucket}, cleaning up...`)
469
+ try {
470
+ // Timeout for bucket cleanup (30 seconds)
471
+ const cleanupPromise = s3.emptyBucket(bucket).then(() => s3.deleteBucket(bucket))
472
+ const timeoutPromise = new Promise<never>((_, reject) =>
473
+ setTimeout(() => reject(new Error('Bucket cleanup timeout')), 30000),
474
+ )
475
+ await Promise.race([cleanupPromise, timeoutPromise])
476
+ console.log(`Deleted orphaned S3 bucket ${bucket}`)
477
+ bucketCleanedUp = true
478
+ }
479
+ catch (cleanupErr: any) {
480
+ console.log(`Note: Could not clean up S3 bucket: ${cleanupErr.message}`)
481
+ // If we can't clean up the bucket, use a unique suffix
482
+ const suffix = Date.now().toString(36)
483
+ finalBucket = `${bucket}-${suffix}`
484
+ console.log(`Using alternative bucket name: ${finalBucket}`)
485
+ }
486
+ }
487
+ }
488
+ catch {
489
+ // Bucket doesn't exist, good
490
+ }
491
+
492
+ // Check for existing CloudFront distribution that WE created for this domain
493
+ // Only reuse distributions that have our domain as an alias - NEVER use other projects' resources
494
+ if (domain) {
495
+ try {
496
+ console.log(`Checking for existing CloudFront distribution for ${domain}...`)
497
+ const distributions = await cloudfront.listDistributions()
498
+
499
+ for (const dist of distributions) {
500
+ // Handle various alias structures: Items can be an array, or Items.CNAME can be a string or array
501
+ let aliases: string[] = []
502
+ if (dist.Aliases?.Items) {
503
+ if (Array.isArray(dist.Aliases.Items)) {
504
+ aliases = dist.Aliases.Items
505
+ }
506
+ else if (typeof dist.Aliases.Items === 'object') {
507
+ // Items.CNAME can be a string or array
508
+ const cname = (dist.Aliases.Items as any).CNAME
509
+ if (typeof cname === 'string') {
510
+ aliases = [cname]
511
+ }
512
+ else if (Array.isArray(cname)) {
513
+ aliases = cname
514
+ }
515
+ }
516
+ }
517
+
518
+ // Only use distribution if it has OUR domain as an alias
519
+ if (aliases.includes(domain)) {
520
+ console.log(`Found existing CloudFront distribution ${dist.Id} for ${domain}`)
521
+
522
+ // Get the origin bucket from the distribution
523
+ const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
524
+ const originsData = distConfig.DistributionConfig?.Origins?.Items
525
+ let originBucket: string | undefined
526
+
527
+ if (originsData) {
528
+ let originList: any[] = []
529
+ if (Array.isArray(originsData)) {
530
+ originList = originsData
531
+ }
532
+ else if (originsData.Origin) {
533
+ originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
534
+ }
535
+ else {
536
+ originList = [originsData]
537
+ }
538
+
539
+ for (const origin of originList) {
540
+ const domainName = origin.DomainName || ''
541
+ // Extract bucket name from S3 domain
542
+ const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
543
+ if (s3Match) {
544
+ originBucket = s3Match[1]
545
+ break
546
+ }
547
+ }
548
+ }
549
+
550
+ if (originBucket) {
551
+ // Verify this bucket name matches our expected naming convention
552
+ const expectedBucketPrefix = domain.replace(/\./g, '-')
553
+ if (!originBucket.startsWith(expectedBucketPrefix) && !originBucket.includes(config.siteName)) {
554
+ console.log(`Warning: Found distribution with mismatched bucket ${originBucket}, skipping...`)
555
+ continue
556
+ }
557
+
558
+ console.log(`Using existing S3 bucket: ${originBucket}`)
559
+
560
+ // Ensure Route53 records exist for this distribution
561
+ if (hostedZoneId && dist.DomainName) {
562
+ try {
563
+ console.log(`Ensuring Route53 records exist for ${domain}...`)
564
+ await route53.createAliasRecord({
565
+ HostedZoneId: hostedZoneId,
566
+ Name: domain,
567
+ Type: 'A',
568
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
569
+ TargetDNSName: dist.DomainName,
570
+ EvaluateTargetHealth: false,
571
+ })
572
+ await route53.createAliasRecord({
573
+ HostedZoneId: hostedZoneId,
574
+ Name: domain,
575
+ Type: 'AAAA',
576
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
577
+ TargetDNSName: dist.DomainName,
578
+ EvaluateTargetHealth: false,
579
+ })
580
+ console.log(`Route53 records ensured for ${domain}`)
581
+ }
582
+ catch (dnsErr: any) {
583
+ console.log(`Note: Could not update Route53 records: ${dnsErr.message}`)
584
+ }
585
+ }
586
+
587
+ return {
588
+ success: true,
589
+ stackName: `existing-${dist.Id}`,
590
+ bucket: originBucket,
591
+ distributionId: dist.Id,
592
+ distributionDomain: dist.DomainName,
593
+ domain,
594
+ certificateArn,
595
+ message: 'Using existing CloudFront distribution',
596
+ }
597
+ }
598
+ }
599
+ }
600
+ }
601
+ catch {
602
+ // No distributions or error listing them
603
+ }
604
+
605
+ // Check for orphaned Route53 records
606
+ if (hostedZoneId) {
607
+ try {
608
+ const recordsResult = await route53.listResourceRecordSets({ HostedZoneId: hostedZoneId })
609
+ const records = recordsResult.ResourceRecordSets || []
610
+ for (const record of records) {
611
+ if (record.Name === `${domain}.` && (record.Type === 'A' || record.Type === 'AAAA')) {
612
+ // This is an alias record, check if it points to a CloudFront distribution
613
+ if (record.AliasTarget) {
614
+ console.log(`Found orphaned Route53 ${record.Type} record for ${domain}, cleaning up...`)
615
+ try {
616
+ await route53.deleteRecord({
617
+ HostedZoneId: hostedZoneId,
618
+ RecordSet: record,
619
+ })
620
+ console.log(`Deleted orphaned Route53 ${record.Type} record for ${domain}`)
621
+ }
622
+ catch (recordErr: any) {
623
+ console.log(`Note: Could not delete Route53 record: ${recordErr.message}`)
624
+ }
625
+ }
626
+ }
627
+ }
628
+ }
629
+ catch {
630
+ // Error listing/deleting records
631
+ }
632
+ }
633
+ }
634
+ }
635
+
636
+ // Generate CloudFormation template with final bucket name
637
+ const template = generateStaticSiteTemplate({
638
+ bucketName: finalBucket,
639
+ domain,
640
+ certificateArn,
641
+ hostedZoneId,
642
+ defaultRootObject: config.defaultRootObject,
643
+ errorDocument: config.errorDocument,
644
+ })
645
+
646
+ // Build tags
647
+ const tags = Object.entries(config.tags || {}).map(([Key, Value]) => ({ Key, Value }))
648
+ tags.push({ Key: 'ManagedBy', Value: 'ts-cloud' })
649
+ tags.push({ Key: 'Application', Value: config.siteName })
650
+
651
+ // Create or update stack
652
+ let stackId: string
653
+ let isUpdate = false
654
+
655
+ if (stackExists) {
656
+ isUpdate = true
657
+ console.log(`Updating CloudFormation stack: ${stackName}`)
658
+ console.log(`Using existing bucket: ${finalBucket}`)
659
+ console.log(`Domain: ${domain || 'not specified'}`)
660
+ console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
661
+ try {
662
+ const result = await cf.updateStack({
663
+ stackName,
664
+ templateBody: JSON.stringify(template),
665
+ capabilities: ['CAPABILITY_IAM'],
666
+ tags,
667
+ })
668
+ stackId = result.StackId
669
+ console.log(`Update initiated, stack ID: ${stackId}`)
670
+ }
671
+ catch (err: any) {
672
+ // No updates needed is not an error
673
+ if (err.message?.includes('No updates are to be performed')) {
674
+ const stacks = await cf.describeStacks({ stackName })
675
+ stackId = stacks.Stacks[0].StackId
676
+ // No actual update needed, return success with existing stack info
677
+ const outputs = stacks.Stacks[0]?.Outputs || []
678
+ const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
679
+
680
+ return {
681
+ success: true,
682
+ stackId,
683
+ stackName,
684
+ bucket: getOutput('BucketName') || finalBucket,
685
+ distributionId: getOutput('DistributionId'),
686
+ distributionDomain: getOutput('DistributionDomain'),
687
+ domain,
688
+ certificateArn,
689
+ message: 'Static site infrastructure is already up to date',
690
+ }
691
+ }
692
+ else {
693
+ throw err
694
+ }
695
+ }
696
+ }
697
+ else {
698
+ console.log(`Creating CloudFormation stack: ${stackName}`)
699
+ console.log(`Bucket name: ${finalBucket}`)
700
+ console.log(`Domain: ${domain || 'not specified'}`)
701
+ console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
702
+ console.log('Stack does not exist, creating...')
703
+ const result = await cf.createStack({
704
+ stackName,
705
+ templateBody: JSON.stringify(template),
706
+ capabilities: ['CAPABILITY_IAM'],
707
+ tags,
708
+ onFailure: 'DELETE',
709
+ })
710
+ stackId = result.StackId
711
+ console.log(`Create initiated, stack ID: ${stackId}`)
712
+ }
713
+
714
+ // Wait for stack to complete using the appropriate wait type
715
+ console.log(`Waiting for stack to reach ${isUpdate ? 'stack-update-complete' : 'stack-create-complete'}...`)
716
+ try {
717
+ await cf.waitForStack(stackName, isUpdate ? 'stack-update-complete' : 'stack-create-complete')
718
+ console.log('Stack operation completed successfully!')
719
+ }
720
+ catch (err: any) {
721
+ // CloudFormation failed - try direct API creation instead
722
+ // This handles cases where CloudFormation has stricter validation than direct API calls
723
+ if (err.message?.includes('must be verified') || err.message?.includes('Access denied for operation') || err.message?.includes('failed')) {
724
+ console.log('CloudFormation deployment failed, trying direct API creation...')
725
+
726
+ const cloudfront = new CloudFrontClient()
727
+
728
+ // First check if we already have a distribution for this domain
729
+ if (domain) {
730
+ try {
731
+ const distributions = await cloudfront.listDistributions()
732
+
733
+ for (const dist of distributions) {
734
+ let aliases: string[] = []
735
+ if (dist.Aliases?.Items) {
736
+ if (Array.isArray(dist.Aliases.Items)) {
737
+ aliases = dist.Aliases.Items
738
+ }
739
+ else if (typeof dist.Aliases.Items === 'object') {
740
+ const cname = (dist.Aliases.Items as any).CNAME
741
+ if (typeof cname === 'string') {
742
+ aliases = [cname]
743
+ }
744
+ else if (Array.isArray(cname)) {
745
+ aliases = cname
746
+ }
747
+ }
748
+ }
749
+
750
+ if (aliases.includes(domain)) {
751
+ console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`)
752
+
753
+ // Get the origin bucket from the distribution
754
+ const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
755
+ const originsData = distConfig.DistributionConfig?.Origins?.Items
756
+ let originBucket: string | undefined
757
+
758
+ if (originsData) {
759
+ let originList: any[] = []
760
+ if (Array.isArray(originsData)) {
761
+ originList = originsData
762
+ }
763
+ else if (originsData.Origin) {
764
+ originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
765
+ }
766
+ else {
767
+ originList = [originsData]
768
+ }
769
+
770
+ for (const origin of originList) {
771
+ const domainName = origin.DomainName || ''
772
+ const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
773
+ if (s3Match) {
774
+ originBucket = s3Match[1]
775
+ break
776
+ }
777
+ }
778
+ }
779
+
780
+ if (originBucket) {
781
+ console.log(`Using existing S3 bucket: ${originBucket}`)
782
+ return {
783
+ success: true,
784
+ stackName: `existing-${dist.Id}`,
785
+ bucket: originBucket,
786
+ distributionId: dist.Id,
787
+ distributionDomain: dist.DomainName,
788
+ domain,
789
+ certificateArn,
790
+ message: 'Using existing CloudFront distribution (account verification pending for new distributions)',
791
+ }
792
+ }
793
+ }
794
+ }
795
+ }
796
+ catch {
797
+ // Couldn't find existing infrastructure
798
+ }
799
+ }
800
+
801
+ // No existing infrastructure found - try to create directly via API calls
802
+ // This often bypasses CloudFormation's stricter validation
803
+ console.log('No existing infrastructure found, creating via direct API calls...')
804
+
805
+ try {
806
+ const s3Direct = new S3Client(region)
807
+
808
+ // Step 1: Create or reuse S3 bucket
809
+ const bucketExists = await s3Direct.headBucket(finalBucket)
810
+ if (bucketExists.exists) {
811
+ console.log(`Using existing S3 bucket: ${finalBucket}`)
812
+ }
813
+ else {
814
+ console.log(`Creating S3 bucket: ${finalBucket}...`)
815
+ await s3Direct.createBucket(finalBucket)
816
+ }
817
+
818
+ // Configure bucket for static website hosting
819
+ await s3Direct.putBucketWebsite(finalBucket, {
820
+ IndexDocument: config.defaultRootObject || 'index.html',
821
+ ErrorDocument: config.errorDocument || '404.html',
822
+ })
823
+
824
+ // Block public access (we'll use CloudFront OAC)
825
+ await s3Direct.putPublicAccessBlock(finalBucket, {
826
+ BlockPublicAcls: true,
827
+ IgnorePublicAcls: true,
828
+ BlockPublicPolicy: false,
829
+ RestrictPublicBuckets: false,
830
+ })
831
+ console.log(`S3 bucket ${finalBucket} configured`)
832
+
833
+ // Step 2: Create Origin Access Control
834
+ const oacName = `OAC-${finalBucket}`
835
+ console.log(`Creating Origin Access Control: ${oacName}...`)
836
+ const oac = await cloudfront.findOrCreateOriginAccessControl(oacName)
837
+ console.log(`Origin Access Control ${oac.Id} ready`)
838
+
839
+ // Step 3: Create CloudFront distribution
840
+ console.log(`Creating CloudFront distribution...`)
841
+ const distResult = await cloudfront.createDistributionForS3({
842
+ bucketName: finalBucket,
843
+ bucketRegion: region,
844
+ originAccessControlId: oac.Id,
845
+ aliases: domain ? [domain] : [],
846
+ certificateArn: certificateArn,
847
+ defaultRootObject: config.defaultRootObject || 'index.html',
848
+ comment: `Distribution for ${domain || finalBucket}`,
849
+ })
850
+ console.log(`CloudFront distribution ${distResult.Id} created`)
851
+
852
+ // Step 4: Update S3 bucket policy for CloudFront access
853
+ console.log(`Updating S3 bucket policy...`)
854
+ const bucketPolicy = CloudFrontClient.getS3BucketPolicyForCloudFront(finalBucket, distResult.ARN)
855
+ await s3Direct.putBucketPolicy(finalBucket, bucketPolicy)
856
+ console.log(`S3 bucket policy updated`)
857
+
858
+ // Step 5: Create Route53 records
859
+ if (domain && hostedZoneId) {
860
+ console.log(`Creating Route53 records for ${domain}...`)
861
+ try {
862
+ await route53.createAliasRecord({
863
+ HostedZoneId: hostedZoneId,
864
+ Name: domain,
865
+ Type: 'A',
866
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
867
+ TargetDNSName: distResult.DomainName,
868
+ EvaluateTargetHealth: false,
869
+ })
870
+ await route53.createAliasRecord({
871
+ HostedZoneId: hostedZoneId,
872
+ Name: domain,
873
+ Type: 'AAAA',
874
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
875
+ TargetDNSName: distResult.DomainName,
876
+ EvaluateTargetHealth: false,
877
+ })
878
+ console.log(`Route53 records created for ${domain}`)
879
+ }
880
+ catch (dnsErr: any) {
881
+ console.log(`Note: Could not create Route53 records: ${dnsErr.message}`)
882
+ }
883
+ }
884
+
885
+ return {
886
+ success: true,
887
+ stackName: `direct-${distResult.Id}`,
888
+ bucket: finalBucket,
889
+ distributionId: distResult.Id,
890
+ distributionDomain: distResult.DomainName,
891
+ domain,
892
+ certificateArn,
893
+ message: 'Static site infrastructure created via direct API calls',
894
+ }
895
+ }
896
+ catch (directErr: any) {
897
+ console.log(`Direct API creation failed: ${directErr.message}`)
898
+ return {
899
+ success: false,
900
+ stackId,
901
+ stackName,
902
+ bucket: finalBucket,
903
+ message: `Deployment failed: ${directErr.message}`,
904
+ }
905
+ }
906
+ }
907
+
908
+ return {
909
+ success: false,
910
+ stackId,
911
+ stackName,
912
+ bucket: finalBucket,
913
+ message: `Stack deployment failed: ${err.message}`,
914
+ }
915
+ }
916
+
917
+ // Get stack outputs
918
+ const stacks = await cf.describeStacks({ stackName })
919
+ const outputs = stacks.Stacks[0]?.Outputs || []
920
+ const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
921
+
922
+ return {
923
+ success: true,
924
+ stackId,
925
+ stackName,
926
+ bucket: getOutput('BucketName') || finalBucket,
927
+ distributionId: getOutput('DistributionId'),
928
+ distributionDomain: getOutput('DistributionDomain'),
929
+ domain,
930
+ certificateArn,
931
+ message: 'Static site infrastructure deployed successfully',
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Upload files to S3 bucket
937
+ */
938
+ export async function uploadStaticFiles(options: UploadOptions): Promise<{ uploaded: number; errors: string[] }> {
939
+ const { sourceDir, bucket, region, cacheControl = 'max-age=31536000, public', onProgress } = options
940
+ const s3 = new S3Client(region)
941
+
942
+ const { readdir, stat } = await import('node:fs/promises')
943
+ const { join, relative } = await import('node:path')
944
+
945
+ // Recursively list files
946
+ async function listFiles(dir: string): Promise<string[]> {
947
+ const files: string[] = []
948
+ const entries = await readdir(dir, { withFileTypes: true })
949
+
950
+ for (const entry of entries) {
951
+ const fullPath = join(dir, entry.name)
952
+ if (entry.isDirectory()) {
953
+ files.push(...await listFiles(fullPath))
954
+ }
955
+ else {
956
+ files.push(fullPath)
957
+ }
958
+ }
959
+
960
+ return files
961
+ }
962
+
963
+ // Get content type
964
+ function getContentType(filePath: string): string {
965
+ const ext = filePath.split('.').pop()?.toLowerCase()
966
+ const types: Record<string, string> = {
967
+ 'html': 'text/html; charset=utf-8',
968
+ 'css': 'text/css; charset=utf-8',
969
+ 'js': 'application/javascript; charset=utf-8',
970
+ 'json': 'application/json; charset=utf-8',
971
+ 'png': 'image/png',
972
+ 'jpg': 'image/jpeg',
973
+ 'jpeg': 'image/jpeg',
974
+ 'gif': 'image/gif',
975
+ 'svg': 'image/svg+xml',
976
+ 'ico': 'image/x-icon',
977
+ 'webp': 'image/webp',
978
+ 'woff': 'font/woff',
979
+ 'woff2': 'font/woff2',
980
+ 'ttf': 'font/ttf',
981
+ 'xml': 'application/xml',
982
+ 'txt': 'text/plain; charset=utf-8',
983
+ }
984
+ return types[ext || ''] || 'application/octet-stream'
985
+ }
986
+
987
+ const files = await listFiles(sourceDir)
988
+ const errors: string[] = []
989
+ let uploaded = 0
990
+
991
+ for (const file of files) {
992
+ const key = relative(sourceDir, file)
993
+ const contentType = getContentType(file)
994
+ const fileCacheControl = file.endsWith('.html') ? 'max-age=3600, public' : cacheControl
995
+
996
+ try {
997
+ const content = await Bun.file(file).arrayBuffer()
998
+
999
+ await s3.putObject({
1000
+ bucket,
1001
+ key,
1002
+ body: Buffer.from(content),
1003
+ contentType,
1004
+ cacheControl: fileCacheControl,
1005
+ })
1006
+
1007
+ uploaded++
1008
+ onProgress?.(uploaded, files.length, key)
1009
+ }
1010
+ catch (err: any) {
1011
+ errors.push(`Failed to upload ${key}: ${err.message}`)
1012
+ }
1013
+ }
1014
+
1015
+ return { uploaded, errors }
1016
+ }
1017
+
1018
+ /**
1019
+ * Invalidate CloudFront cache
1020
+ */
1021
+ export async function invalidateCache(distributionId: string): Promise<{ invalidationId: string }> {
1022
+ const cloudfront = new CloudFrontClient()
1023
+ const result = await cloudfront.invalidateAll(distributionId)
1024
+ return { invalidationId: result.Id }
1025
+ }
1026
+
1027
+ /**
1028
+ * Delete static site infrastructure
1029
+ */
1030
+ export async function deleteStaticSite(stackName: string, region: string = 'us-east-1'): Promise<{ success: boolean; message: string }> {
1031
+ const cf = new CloudFormationClient(region)
1032
+
1033
+ // First, empty the S3 bucket (CloudFormation can't delete non-empty buckets)
1034
+ try {
1035
+ const stacks = await cf.describeStacks({ stackName })
1036
+ const outputs = stacks.Stacks[0]?.Outputs || []
1037
+ const bucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
1038
+
1039
+ if (bucketName) {
1040
+ const s3 = new S3Client(region)
1041
+ await s3.emptyBucket(bucketName)
1042
+ }
1043
+ }
1044
+ catch {
1045
+ // Bucket might not exist or already be empty
1046
+ }
1047
+
1048
+ // Delete the stack
1049
+ await cf.deleteStack(stackName)
1050
+
1051
+ // Wait for deletion
1052
+ const result = await cf.waitForStackComplete(stackName, 60, 10000)
1053
+
1054
+ return {
1055
+ success: result.success || result.status === 'DELETE_COMPLETE',
1056
+ message: result.success ? 'Static site deleted successfully' : `Deletion failed: ${result.status}`,
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Full deployment: infrastructure + files + cache invalidation
1062
+ */
1063
+ export async function deployStaticSiteFull(config: StaticSiteConfig & {
1064
+ sourceDir: string
1065
+ cleanBucket?: boolean
1066
+ onProgress?: (stage: string, detail?: string) => void
1067
+ }): Promise<DeployResult & { filesUploaded?: number }> {
1068
+ const { sourceDir, cleanBucket = true, onProgress, ...siteConfig } = config
1069
+
1070
+ // Step 1: Deploy infrastructure
1071
+ onProgress?.('infrastructure', 'Deploying CloudFormation stack...')
1072
+ const infraResult = await deployStaticSite(siteConfig)
1073
+
1074
+ if (!infraResult.success) {
1075
+ return infraResult
1076
+ }
1077
+
1078
+ // Step 2: Clean bucket before upload (ensures no stale files)
1079
+ if (cleanBucket) {
1080
+ onProgress?.('clean', 'Cleaning old files from S3...')
1081
+ try {
1082
+ const s3 = new S3Client(siteConfig.region || 'us-east-1')
1083
+ await s3.emptyBucket(infraResult.bucket)
1084
+ }
1085
+ catch (err: any) {
1086
+ // Log but don't fail - bucket might be empty
1087
+ console.log(`Note: Could not clean bucket: ${err.message}`)
1088
+ }
1089
+ }
1090
+
1091
+ // Step 3: Upload files
1092
+ onProgress?.('upload', 'Uploading files to S3...')
1093
+ const uploadResult = await uploadStaticFiles({
1094
+ sourceDir,
1095
+ bucket: infraResult.bucket,
1096
+ region: siteConfig.region || 'us-east-1',
1097
+ cacheControl: siteConfig.cacheControl,
1098
+ onProgress: (uploaded, total, file) => {
1099
+ onProgress?.('upload', `${uploaded}/${total}: ${file}`)
1100
+ },
1101
+ })
1102
+
1103
+ if (uploadResult.errors.length > 0) {
1104
+ return {
1105
+ ...infraResult,
1106
+ success: false,
1107
+ message: `Upload errors: ${uploadResult.errors.join(', ')}`,
1108
+ filesUploaded: uploadResult.uploaded,
1109
+ }
1110
+ }
1111
+
1112
+ // Step 3: Invalidate cache
1113
+ if (infraResult.distributionId) {
1114
+ onProgress?.('invalidate', 'Invalidating CloudFront cache...')
1115
+ await invalidateCache(infraResult.distributionId)
1116
+ }
1117
+
1118
+ onProgress?.('complete', 'Deployment complete!')
1119
+
1120
+ return {
1121
+ ...infraResult,
1122
+ filesUploaded: uploadResult.uploaded,
1123
+ message: `Deployed ${uploadResult.uploaded} files successfully`,
1124
+ }
1125
+ }