@stacksjs/ts-cloud 0.1.8 → 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.
- package/dist/bin/cli.js +1 -1
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
|
@@ -0,0 +1,1175 @@
|
|
|
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 for existing CloudFront distribution FIRST before cleaning up any "orphaned" buckets
|
|
463
|
+
// This prevents deleting buckets that are actively used by a CloudFront distribution
|
|
464
|
+
let hasExistingDistribution = false
|
|
465
|
+
|
|
466
|
+
// Check for existing CloudFront distribution that WE created for this domain
|
|
467
|
+
// Only reuse distributions that have our domain as an alias - NEVER use other projects' resources
|
|
468
|
+
if (domain) {
|
|
469
|
+
try {
|
|
470
|
+
console.log(`Checking for existing CloudFront distribution for ${domain}...`)
|
|
471
|
+
const distributions = await cloudfront.listDistributions()
|
|
472
|
+
|
|
473
|
+
for (const dist of distributions) {
|
|
474
|
+
// Handle various alias structures: Items can be an array, or Items.CNAME can be a string or array
|
|
475
|
+
let aliases: string[] = []
|
|
476
|
+
if (dist.Aliases?.Items) {
|
|
477
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
478
|
+
aliases = dist.Aliases.Items
|
|
479
|
+
}
|
|
480
|
+
else if (typeof dist.Aliases.Items === 'object') {
|
|
481
|
+
// Items.CNAME can be a string or array
|
|
482
|
+
const cname = (dist.Aliases.Items as any).CNAME
|
|
483
|
+
if (typeof cname === 'string') {
|
|
484
|
+
aliases = [cname]
|
|
485
|
+
}
|
|
486
|
+
else if (Array.isArray(cname)) {
|
|
487
|
+
aliases = cname
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Only use distribution if it has OUR domain as an alias
|
|
493
|
+
if (aliases.includes(domain)) {
|
|
494
|
+
hasExistingDistribution = true
|
|
495
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} for ${domain}`)
|
|
496
|
+
|
|
497
|
+
// Get the origin bucket from the distribution
|
|
498
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
|
|
499
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items
|
|
500
|
+
let originBucket: string | undefined
|
|
501
|
+
|
|
502
|
+
if (originsData) {
|
|
503
|
+
let originList: any[] = []
|
|
504
|
+
if (Array.isArray(originsData)) {
|
|
505
|
+
originList = originsData
|
|
506
|
+
}
|
|
507
|
+
else if (originsData.Origin) {
|
|
508
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
originList = [originsData]
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const origin of originList) {
|
|
515
|
+
const domainName = origin.DomainName || ''
|
|
516
|
+
// Extract bucket name from S3 domain
|
|
517
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
|
|
518
|
+
if (s3Match) {
|
|
519
|
+
originBucket = s3Match[1]
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (originBucket) {
|
|
526
|
+
// Verify this bucket name matches our expected naming convention
|
|
527
|
+
const expectedBucketPrefix = domain.replace(/\./g, '-')
|
|
528
|
+
if (!originBucket.startsWith(expectedBucketPrefix) && !originBucket.includes(config.siteName)) {
|
|
529
|
+
console.log(`Warning: Found distribution with mismatched bucket ${originBucket}, skipping...`)
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
console.log(`Using existing S3 bucket: ${originBucket}`)
|
|
534
|
+
|
|
535
|
+
// Ensure Route53 records exist for this distribution
|
|
536
|
+
if (hostedZoneId && dist.DomainName) {
|
|
537
|
+
try {
|
|
538
|
+
console.log(`Ensuring Route53 records exist for ${domain}...`)
|
|
539
|
+
await route53.createAliasRecord({
|
|
540
|
+
HostedZoneId: hostedZoneId,
|
|
541
|
+
Name: domain,
|
|
542
|
+
Type: 'A',
|
|
543
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
544
|
+
TargetDNSName: dist.DomainName,
|
|
545
|
+
EvaluateTargetHealth: false,
|
|
546
|
+
})
|
|
547
|
+
await route53.createAliasRecord({
|
|
548
|
+
HostedZoneId: hostedZoneId,
|
|
549
|
+
Name: domain,
|
|
550
|
+
Type: 'AAAA',
|
|
551
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
552
|
+
TargetDNSName: dist.DomainName,
|
|
553
|
+
EvaluateTargetHealth: false,
|
|
554
|
+
})
|
|
555
|
+
console.log(`Route53 records ensured for ${domain}`)
|
|
556
|
+
}
|
|
557
|
+
catch (dnsErr: any) {
|
|
558
|
+
console.log(`Note: Could not update Route53 records: ${dnsErr.message}`)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
success: true,
|
|
564
|
+
stackName: `existing-${dist.Id}`,
|
|
565
|
+
bucket: originBucket,
|
|
566
|
+
distributionId: dist.Id,
|
|
567
|
+
distributionDomain: dist.DomainName,
|
|
568
|
+
domain,
|
|
569
|
+
certificateArn,
|
|
570
|
+
message: 'Using existing CloudFront distribution',
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// No distributions or error listing them
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Check for orphaned Route53 records
|
|
581
|
+
if (hostedZoneId) {
|
|
582
|
+
try {
|
|
583
|
+
const recordsResult = await route53.listResourceRecordSets({ HostedZoneId: hostedZoneId })
|
|
584
|
+
const records = recordsResult.ResourceRecordSets || []
|
|
585
|
+
for (const record of records) {
|
|
586
|
+
if (record.Name === `${domain}.` && (record.Type === 'A' || record.Type === 'AAAA')) {
|
|
587
|
+
// This is an alias record, check if it points to a CloudFront distribution
|
|
588
|
+
if (record.AliasTarget) {
|
|
589
|
+
console.log(`Found orphaned Route53 ${record.Type} record for ${domain}, cleaning up...`)
|
|
590
|
+
try {
|
|
591
|
+
await route53.deleteRecord({
|
|
592
|
+
HostedZoneId: hostedZoneId,
|
|
593
|
+
RecordSet: record,
|
|
594
|
+
})
|
|
595
|
+
console.log(`Deleted orphaned Route53 ${record.Type} record for ${domain}`)
|
|
596
|
+
}
|
|
597
|
+
catch (recordErr: any) {
|
|
598
|
+
console.log(`Note: Could not delete Route53 record: ${recordErr.message}`)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Error listing/deleting records
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Only clean up orphaned S3 buckets when no CloudFront distribution references them
|
|
611
|
+
if (!hasExistingDistribution) {
|
|
612
|
+
try {
|
|
613
|
+
const headResult = await s3.headBucket(bucket)
|
|
614
|
+
if (headResult.exists) {
|
|
615
|
+
console.log(`Found orphaned S3 bucket ${bucket}, cleaning up...`)
|
|
616
|
+
try {
|
|
617
|
+
const cleanupPromise = s3.emptyBucket(bucket).then(() => s3.deleteBucket(bucket))
|
|
618
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
619
|
+
setTimeout(() => reject(new Error('Bucket cleanup timeout')), 30000),
|
|
620
|
+
)
|
|
621
|
+
await Promise.race([cleanupPromise, timeoutPromise])
|
|
622
|
+
console.log(`Deleted orphaned S3 bucket ${bucket}`)
|
|
623
|
+
}
|
|
624
|
+
catch (cleanupErr: any) {
|
|
625
|
+
console.log(`Note: Could not clean up S3 bucket: ${cleanupErr.message}`)
|
|
626
|
+
const suffix = Date.now().toString(36)
|
|
627
|
+
finalBucket = `${bucket}-${suffix}`
|
|
628
|
+
console.log(`Using alternative bucket name: ${finalBucket}`)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// Bucket doesn't exist, good
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Generate CloudFormation template with final bucket name
|
|
639
|
+
const template = generateStaticSiteTemplate({
|
|
640
|
+
bucketName: finalBucket,
|
|
641
|
+
domain,
|
|
642
|
+
certificateArn,
|
|
643
|
+
hostedZoneId,
|
|
644
|
+
defaultRootObject: config.defaultRootObject,
|
|
645
|
+
errorDocument: config.errorDocument,
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// Build tags
|
|
649
|
+
const tags = Object.entries(config.tags || {}).map(([Key, Value]) => ({ Key, Value }))
|
|
650
|
+
tags.push({ Key: 'ManagedBy', Value: 'ts-cloud' })
|
|
651
|
+
tags.push({ Key: 'Application', Value: config.siteName })
|
|
652
|
+
|
|
653
|
+
// Create or update stack
|
|
654
|
+
let stackId: string
|
|
655
|
+
let isUpdate = false
|
|
656
|
+
|
|
657
|
+
if (stackExists) {
|
|
658
|
+
isUpdate = true
|
|
659
|
+
console.log(`Updating CloudFormation stack: ${stackName}`)
|
|
660
|
+
console.log(`Using existing bucket: ${finalBucket}`)
|
|
661
|
+
console.log(`Domain: ${domain || 'not specified'}`)
|
|
662
|
+
console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
|
|
663
|
+
try {
|
|
664
|
+
const result = await cf.updateStack({
|
|
665
|
+
stackName,
|
|
666
|
+
templateBody: JSON.stringify(template),
|
|
667
|
+
capabilities: ['CAPABILITY_IAM'],
|
|
668
|
+
tags,
|
|
669
|
+
})
|
|
670
|
+
stackId = result.StackId
|
|
671
|
+
console.log(`Update initiated, stack ID: ${stackId}`)
|
|
672
|
+
}
|
|
673
|
+
catch (err: any) {
|
|
674
|
+
// No updates needed is not an error
|
|
675
|
+
if (err.message?.includes('No updates are to be performed')) {
|
|
676
|
+
const stacks = await cf.describeStacks({ stackName })
|
|
677
|
+
stackId = stacks.Stacks[0].StackId
|
|
678
|
+
// No actual update needed, return success with existing stack info
|
|
679
|
+
const outputs = stacks.Stacks[0]?.Outputs || []
|
|
680
|
+
const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
success: true,
|
|
684
|
+
stackId,
|
|
685
|
+
stackName,
|
|
686
|
+
bucket: getOutput('BucketName') || finalBucket,
|
|
687
|
+
distributionId: getOutput('DistributionId'),
|
|
688
|
+
distributionDomain: getOutput('DistributionDomain'),
|
|
689
|
+
domain,
|
|
690
|
+
certificateArn,
|
|
691
|
+
message: 'Static site infrastructure is already up to date',
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
throw err
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
console.log(`Creating CloudFormation stack: ${stackName}`)
|
|
701
|
+
console.log(`Bucket name: ${finalBucket}`)
|
|
702
|
+
console.log(`Domain: ${domain || 'not specified'}`)
|
|
703
|
+
console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
|
|
704
|
+
console.log('Stack does not exist, creating...')
|
|
705
|
+
const result = await cf.createStack({
|
|
706
|
+
stackName,
|
|
707
|
+
templateBody: JSON.stringify(template),
|
|
708
|
+
capabilities: ['CAPABILITY_IAM'],
|
|
709
|
+
tags,
|
|
710
|
+
onFailure: 'DELETE',
|
|
711
|
+
})
|
|
712
|
+
stackId = result.StackId
|
|
713
|
+
console.log(`Create initiated, stack ID: ${stackId}`)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Wait for stack to complete using the appropriate wait type
|
|
717
|
+
console.log(`Waiting for stack to reach ${isUpdate ? 'stack-update-complete' : 'stack-create-complete'}...`)
|
|
718
|
+
try {
|
|
719
|
+
await cf.waitForStack(stackName, isUpdate ? 'stack-update-complete' : 'stack-create-complete')
|
|
720
|
+
console.log('Stack operation completed successfully!')
|
|
721
|
+
}
|
|
722
|
+
catch (err: any) {
|
|
723
|
+
// CloudFormation failed - try direct API creation instead
|
|
724
|
+
// This handles cases where CloudFormation has stricter validation than direct API calls
|
|
725
|
+
if (err.message?.includes('must be verified') || err.message?.includes('Access denied for operation') || err.message?.includes('failed')) {
|
|
726
|
+
console.log('CloudFormation deployment failed, trying direct API creation...')
|
|
727
|
+
|
|
728
|
+
const cloudfront = new CloudFrontClient()
|
|
729
|
+
|
|
730
|
+
// First check if we already have a distribution for this domain
|
|
731
|
+
if (domain) {
|
|
732
|
+
try {
|
|
733
|
+
const distributions = await cloudfront.listDistributions()
|
|
734
|
+
|
|
735
|
+
for (const dist of distributions) {
|
|
736
|
+
let aliases: string[] = []
|
|
737
|
+
if (dist.Aliases?.Items) {
|
|
738
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
739
|
+
aliases = dist.Aliases.Items
|
|
740
|
+
}
|
|
741
|
+
else if (typeof dist.Aliases.Items === 'object') {
|
|
742
|
+
const cname = (dist.Aliases.Items as any).CNAME
|
|
743
|
+
if (typeof cname === 'string') {
|
|
744
|
+
aliases = [cname]
|
|
745
|
+
}
|
|
746
|
+
else if (Array.isArray(cname)) {
|
|
747
|
+
aliases = cname
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (aliases.includes(domain)) {
|
|
753
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`)
|
|
754
|
+
|
|
755
|
+
// Get the origin bucket from the distribution
|
|
756
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
|
|
757
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items
|
|
758
|
+
let originBucket: string | undefined
|
|
759
|
+
|
|
760
|
+
if (originsData) {
|
|
761
|
+
let originList: any[] = []
|
|
762
|
+
if (Array.isArray(originsData)) {
|
|
763
|
+
originList = originsData
|
|
764
|
+
}
|
|
765
|
+
else if (originsData.Origin) {
|
|
766
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
originList = [originsData]
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
for (const origin of originList) {
|
|
773
|
+
const domainName = origin.DomainName || ''
|
|
774
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
|
|
775
|
+
if (s3Match) {
|
|
776
|
+
originBucket = s3Match[1]
|
|
777
|
+
break
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (originBucket) {
|
|
783
|
+
console.log(`Using existing S3 bucket: ${originBucket}`)
|
|
784
|
+
return {
|
|
785
|
+
success: true,
|
|
786
|
+
stackName: `existing-${dist.Id}`,
|
|
787
|
+
bucket: originBucket,
|
|
788
|
+
distributionId: dist.Id,
|
|
789
|
+
distributionDomain: dist.DomainName,
|
|
790
|
+
domain,
|
|
791
|
+
certificateArn,
|
|
792
|
+
message: 'Using existing CloudFront distribution (account verification pending for new distributions)',
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
// Couldn't find existing infrastructure
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// No existing infrastructure found - try to create directly via API calls
|
|
804
|
+
// This often bypasses CloudFormation's stricter validation
|
|
805
|
+
console.log('No existing infrastructure found, creating via direct API calls...')
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
const s3Direct = new S3Client(region)
|
|
809
|
+
|
|
810
|
+
// Step 1: Create or reuse S3 bucket
|
|
811
|
+
const bucketExists = await s3Direct.headBucket(finalBucket)
|
|
812
|
+
if (bucketExists.exists) {
|
|
813
|
+
console.log(`Using existing S3 bucket: ${finalBucket}`)
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
console.log(`Creating S3 bucket: ${finalBucket}...`)
|
|
817
|
+
await s3Direct.createBucket(finalBucket)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Configure bucket for static website hosting
|
|
821
|
+
await s3Direct.putBucketWebsite(finalBucket, {
|
|
822
|
+
IndexDocument: config.defaultRootObject || 'index.html',
|
|
823
|
+
ErrorDocument: config.errorDocument || '404.html',
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
// Block public access (we'll use CloudFront OAC)
|
|
827
|
+
await s3Direct.putPublicAccessBlock(finalBucket, {
|
|
828
|
+
BlockPublicAcls: true,
|
|
829
|
+
IgnorePublicAcls: true,
|
|
830
|
+
BlockPublicPolicy: false,
|
|
831
|
+
RestrictPublicBuckets: false,
|
|
832
|
+
})
|
|
833
|
+
console.log(`S3 bucket ${finalBucket} configured`)
|
|
834
|
+
|
|
835
|
+
// Step 2: Create Origin Access Control
|
|
836
|
+
const oacName = `OAC-${finalBucket}`
|
|
837
|
+
console.log(`Creating Origin Access Control: ${oacName}...`)
|
|
838
|
+
const oac = await cloudfront.findOrCreateOriginAccessControl(oacName)
|
|
839
|
+
console.log(`Origin Access Control ${oac.Id} ready`)
|
|
840
|
+
|
|
841
|
+
// Step 3: Create CloudFront distribution
|
|
842
|
+
console.log(`Creating CloudFront distribution...`)
|
|
843
|
+
const distResult = await cloudfront.createDistributionForS3({
|
|
844
|
+
bucketName: finalBucket,
|
|
845
|
+
bucketRegion: region,
|
|
846
|
+
originAccessControlId: oac.Id,
|
|
847
|
+
aliases: domain ? [domain] : [],
|
|
848
|
+
certificateArn: certificateArn,
|
|
849
|
+
defaultRootObject: config.defaultRootObject || 'index.html',
|
|
850
|
+
comment: `Distribution for ${domain || finalBucket}`,
|
|
851
|
+
})
|
|
852
|
+
console.log(`CloudFront distribution ${distResult.Id} created`)
|
|
853
|
+
|
|
854
|
+
// Step 4: Update S3 bucket policy for CloudFront access
|
|
855
|
+
console.log(`Updating S3 bucket policy...`)
|
|
856
|
+
const bucketPolicy = CloudFrontClient.getS3BucketPolicyForCloudFront(finalBucket, distResult.ARN)
|
|
857
|
+
await s3Direct.putBucketPolicy(finalBucket, bucketPolicy)
|
|
858
|
+
console.log(`S3 bucket policy updated`)
|
|
859
|
+
|
|
860
|
+
// Step 5: Create Route53 records
|
|
861
|
+
if (domain && hostedZoneId) {
|
|
862
|
+
console.log(`Creating Route53 records for ${domain}...`)
|
|
863
|
+
try {
|
|
864
|
+
await route53.createAliasRecord({
|
|
865
|
+
HostedZoneId: hostedZoneId,
|
|
866
|
+
Name: domain,
|
|
867
|
+
Type: 'A',
|
|
868
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
869
|
+
TargetDNSName: distResult.DomainName,
|
|
870
|
+
EvaluateTargetHealth: false,
|
|
871
|
+
})
|
|
872
|
+
await route53.createAliasRecord({
|
|
873
|
+
HostedZoneId: hostedZoneId,
|
|
874
|
+
Name: domain,
|
|
875
|
+
Type: 'AAAA',
|
|
876
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
877
|
+
TargetDNSName: distResult.DomainName,
|
|
878
|
+
EvaluateTargetHealth: false,
|
|
879
|
+
})
|
|
880
|
+
console.log(`Route53 records created for ${domain}`)
|
|
881
|
+
}
|
|
882
|
+
catch (dnsErr: any) {
|
|
883
|
+
console.log(`Note: Could not create Route53 records: ${dnsErr.message}`)
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
success: true,
|
|
889
|
+
stackName: `direct-${distResult.Id}`,
|
|
890
|
+
bucket: finalBucket,
|
|
891
|
+
distributionId: distResult.Id,
|
|
892
|
+
distributionDomain: distResult.DomainName,
|
|
893
|
+
domain,
|
|
894
|
+
certificateArn,
|
|
895
|
+
message: 'Static site infrastructure created via direct API calls',
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
catch (directErr: any) {
|
|
899
|
+
console.log(`Direct API creation failed: ${directErr.message}`)
|
|
900
|
+
return {
|
|
901
|
+
success: false,
|
|
902
|
+
stackId,
|
|
903
|
+
stackName,
|
|
904
|
+
bucket: finalBucket,
|
|
905
|
+
message: `Deployment failed: ${directErr.message}`,
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
success: false,
|
|
912
|
+
stackId,
|
|
913
|
+
stackName,
|
|
914
|
+
bucket: finalBucket,
|
|
915
|
+
message: `Stack deployment failed: ${err.message}`,
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Get stack outputs
|
|
920
|
+
const stacks = await cf.describeStacks({ stackName })
|
|
921
|
+
const outputs = stacks.Stacks[0]?.Outputs || []
|
|
922
|
+
const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
success: true,
|
|
926
|
+
stackId,
|
|
927
|
+
stackName,
|
|
928
|
+
bucket: getOutput('BucketName') || finalBucket,
|
|
929
|
+
distributionId: getOutput('DistributionId'),
|
|
930
|
+
distributionDomain: getOutput('DistributionDomain'),
|
|
931
|
+
domain,
|
|
932
|
+
certificateArn,
|
|
933
|
+
message: 'Static site infrastructure deployed successfully',
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Upload files to S3 bucket (only uploads changed files)
|
|
939
|
+
*/
|
|
940
|
+
export async function uploadStaticFiles(options: UploadOptions): Promise<{ uploaded: number; skipped: number; errors: string[] }> {
|
|
941
|
+
const { sourceDir, bucket, region, cacheControl = 'max-age=31536000, public', onProgress } = options
|
|
942
|
+
const s3 = new S3Client(region)
|
|
943
|
+
|
|
944
|
+
const { readdir } = await import('node:fs/promises')
|
|
945
|
+
const { join, relative } = await import('node:path')
|
|
946
|
+
const { createHash } = await import('node:crypto')
|
|
947
|
+
|
|
948
|
+
// Recursively list files
|
|
949
|
+
async function listFiles(dir: string): Promise<string[]> {
|
|
950
|
+
const files: string[] = []
|
|
951
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
952
|
+
|
|
953
|
+
for (const entry of entries) {
|
|
954
|
+
const fullPath = join(dir, entry.name)
|
|
955
|
+
if (entry.isDirectory()) {
|
|
956
|
+
files.push(...await listFiles(fullPath))
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
files.push(fullPath)
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return files
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Get content type
|
|
967
|
+
function getContentType(filePath: string): string {
|
|
968
|
+
const ext = filePath.split('.').pop()?.toLowerCase()
|
|
969
|
+
const types: Record<string, string> = {
|
|
970
|
+
'html': 'text/html; charset=utf-8',
|
|
971
|
+
'css': 'text/css; charset=utf-8',
|
|
972
|
+
'js': 'application/javascript; charset=utf-8',
|
|
973
|
+
'json': 'application/json; charset=utf-8',
|
|
974
|
+
'png': 'image/png',
|
|
975
|
+
'jpg': 'image/jpeg',
|
|
976
|
+
'jpeg': 'image/jpeg',
|
|
977
|
+
'gif': 'image/gif',
|
|
978
|
+
'svg': 'image/svg+xml',
|
|
979
|
+
'ico': 'image/x-icon',
|
|
980
|
+
'webp': 'image/webp',
|
|
981
|
+
'woff': 'font/woff',
|
|
982
|
+
'woff2': 'font/woff2',
|
|
983
|
+
'ttf': 'font/ttf',
|
|
984
|
+
'xml': 'application/xml',
|
|
985
|
+
'txt': 'text/plain; charset=utf-8',
|
|
986
|
+
}
|
|
987
|
+
return types[ext || ''] || 'application/octet-stream'
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Compute MD5 hash of content (matches S3 ETag format)
|
|
991
|
+
function computeMD5(content: Buffer): string {
|
|
992
|
+
return createHash('md5').update(content).digest('hex')
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Fetch all existing S3 objects to build ETag map
|
|
996
|
+
async function getExistingETags(): Promise<Map<string, string>> {
|
|
997
|
+
const etagMap = new Map<string, string>()
|
|
998
|
+
let continuationToken: string | undefined
|
|
999
|
+
|
|
1000
|
+
do {
|
|
1001
|
+
const result = await s3.listObjects({
|
|
1002
|
+
bucket,
|
|
1003
|
+
maxKeys: 1000,
|
|
1004
|
+
continuationToken,
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
for (const obj of result.objects) {
|
|
1008
|
+
// S3 ETags are quoted, remove quotes for comparison
|
|
1009
|
+
const etag = obj.ETag?.replace(/"/g, '') || ''
|
|
1010
|
+
etagMap.set(obj.Key, etag)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
continuationToken = result.nextContinuationToken
|
|
1014
|
+
} while (continuationToken)
|
|
1015
|
+
|
|
1016
|
+
return etagMap
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const files = await listFiles(sourceDir)
|
|
1020
|
+
const errors: string[] = []
|
|
1021
|
+
let uploaded = 0
|
|
1022
|
+
let skipped = 0
|
|
1023
|
+
|
|
1024
|
+
// Get existing ETags from S3
|
|
1025
|
+
const existingETags = await getExistingETags()
|
|
1026
|
+
|
|
1027
|
+
for (const file of files) {
|
|
1028
|
+
const key = relative(sourceDir, file)
|
|
1029
|
+
const contentType = getContentType(file)
|
|
1030
|
+
const fileCacheControl = file.endsWith('.html') ? 'max-age=3600, public' : cacheControl
|
|
1031
|
+
|
|
1032
|
+
try {
|
|
1033
|
+
const content = Buffer.from(await Bun.file(file).arrayBuffer())
|
|
1034
|
+
const localMD5 = computeMD5(content)
|
|
1035
|
+
const existingETag = existingETags.get(key)
|
|
1036
|
+
|
|
1037
|
+
// Skip upload if file hasn't changed
|
|
1038
|
+
if (existingETag && existingETag === localMD5) {
|
|
1039
|
+
skipped++
|
|
1040
|
+
onProgress?.(uploaded + skipped, files.length, key)
|
|
1041
|
+
continue
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
await s3.putObject({
|
|
1045
|
+
bucket,
|
|
1046
|
+
key,
|
|
1047
|
+
body: content,
|
|
1048
|
+
contentType,
|
|
1049
|
+
cacheControl: fileCacheControl,
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
uploaded++
|
|
1053
|
+
onProgress?.(uploaded + skipped, files.length, key)
|
|
1054
|
+
}
|
|
1055
|
+
catch (err: any) {
|
|
1056
|
+
errors.push(`Failed to upload ${key}: ${err.message}`)
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return { uploaded, skipped, errors }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Invalidate CloudFront cache
|
|
1065
|
+
*/
|
|
1066
|
+
export async function invalidateCache(distributionId: string): Promise<{ invalidationId: string }> {
|
|
1067
|
+
const cloudfront = new CloudFrontClient()
|
|
1068
|
+
const result = await cloudfront.invalidateAll(distributionId)
|
|
1069
|
+
return { invalidationId: result.Id }
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Delete static site infrastructure
|
|
1074
|
+
*/
|
|
1075
|
+
export async function deleteStaticSite(stackName: string, region: string = 'us-east-1'): Promise<{ success: boolean; message: string }> {
|
|
1076
|
+
const cf = new CloudFormationClient(region)
|
|
1077
|
+
|
|
1078
|
+
// First, empty the S3 bucket (CloudFormation can't delete non-empty buckets)
|
|
1079
|
+
try {
|
|
1080
|
+
const stacks = await cf.describeStacks({ stackName })
|
|
1081
|
+
const outputs = stacks.Stacks[0]?.Outputs || []
|
|
1082
|
+
const bucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
|
|
1083
|
+
|
|
1084
|
+
if (bucketName) {
|
|
1085
|
+
const s3 = new S3Client(region)
|
|
1086
|
+
await s3.emptyBucket(bucketName)
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
// Bucket might not exist or already be empty
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Delete the stack
|
|
1094
|
+
await cf.deleteStack(stackName)
|
|
1095
|
+
|
|
1096
|
+
// Wait for deletion
|
|
1097
|
+
const result = await cf.waitForStackComplete(stackName, 60, 10000)
|
|
1098
|
+
|
|
1099
|
+
return {
|
|
1100
|
+
success: result.success || result.status === 'DELETE_COMPLETE',
|
|
1101
|
+
message: result.success ? 'Static site deleted successfully' : `Deletion failed: ${result.status}`,
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Full deployment: infrastructure + files + cache invalidation
|
|
1107
|
+
*/
|
|
1108
|
+
export async function deployStaticSiteFull(config: StaticSiteConfig & {
|
|
1109
|
+
sourceDir: string
|
|
1110
|
+
cleanBucket?: boolean
|
|
1111
|
+
onProgress?: (stage: string, detail?: string) => void
|
|
1112
|
+
}): Promise<DeployResult & { filesUploaded?: number; filesSkipped?: number }> {
|
|
1113
|
+
const { sourceDir, cleanBucket = false, onProgress, ...siteConfig } = config
|
|
1114
|
+
|
|
1115
|
+
// Step 1: Deploy infrastructure
|
|
1116
|
+
onProgress?.('infrastructure', 'Deploying CloudFormation stack...')
|
|
1117
|
+
const infraResult = await deployStaticSite(siteConfig)
|
|
1118
|
+
|
|
1119
|
+
if (!infraResult.success) {
|
|
1120
|
+
return infraResult
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Step 2: Clean bucket before upload (ensures no stale files)
|
|
1124
|
+
if (cleanBucket) {
|
|
1125
|
+
onProgress?.('clean', 'Cleaning old files from S3...')
|
|
1126
|
+
try {
|
|
1127
|
+
const s3 = new S3Client(siteConfig.region || 'us-east-1')
|
|
1128
|
+
await s3.emptyBucket(infraResult.bucket)
|
|
1129
|
+
}
|
|
1130
|
+
catch (err: any) {
|
|
1131
|
+
// Log but don't fail - bucket might be empty
|
|
1132
|
+
console.log(`Note: Could not clean bucket: ${err.message}`)
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Step 3: Upload files
|
|
1137
|
+
onProgress?.('upload', 'Uploading files to S3...')
|
|
1138
|
+
const uploadResult = await uploadStaticFiles({
|
|
1139
|
+
sourceDir,
|
|
1140
|
+
bucket: infraResult.bucket,
|
|
1141
|
+
region: siteConfig.region || 'us-east-1',
|
|
1142
|
+
cacheControl: siteConfig.cacheControl,
|
|
1143
|
+
onProgress: (uploaded, total, file) => {
|
|
1144
|
+
onProgress?.('upload', `${uploaded}/${total}: ${file}`)
|
|
1145
|
+
},
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
if (uploadResult.errors.length > 0) {
|
|
1149
|
+
return {
|
|
1150
|
+
...infraResult,
|
|
1151
|
+
success: false,
|
|
1152
|
+
message: `Upload errors: ${uploadResult.errors.join(', ')}`,
|
|
1153
|
+
filesUploaded: uploadResult.uploaded,
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Step 4: Invalidate cache (only if files were uploaded)
|
|
1158
|
+
if (infraResult.distributionId && uploadResult.uploaded > 0) {
|
|
1159
|
+
onProgress?.('invalidate', 'Invalidating CloudFront cache...')
|
|
1160
|
+
await invalidateCache(infraResult.distributionId)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
onProgress?.('complete', 'Deployment complete!')
|
|
1164
|
+
|
|
1165
|
+
const message = uploadResult.skipped > 0
|
|
1166
|
+
? `Deployed ${uploadResult.uploaded} files (${uploadResult.skipped} unchanged)`
|
|
1167
|
+
: `Deployed ${uploadResult.uploaded} files successfully`
|
|
1168
|
+
|
|
1169
|
+
return {
|
|
1170
|
+
...infraResult,
|
|
1171
|
+
filesUploaded: uploadResult.uploaded,
|
|
1172
|
+
filesSkipped: uploadResult.skipped,
|
|
1173
|
+
message,
|
|
1174
|
+
}
|
|
1175
|
+
}
|